//! The layers of an Illustrator EPS and `.ai`: read from the editing copy of the art the file //! carries (see `native`). The files here are small ones written by hand for the tests, with the //! operators seen in files their users own. use vectorcraft_doc::{Document, LayerColor, Node, NodeKind, Unit}; use crate::import::import; /// `data` as ASCII85, up to the `~>`. fn ascii85(data: &[u8]) -> String { let mut out = String::new(); for chunk in data.chunks(4) { let v = chunk.iter().enumerate().fold(0u32, |v, (i, b)| v | u32::from(*b) << (24 - 8 * i)); let mut d = [0u8; 5]; let mut x = v; for c in d.iter_mut().rev() { *c = (x % 85) as u8 + b'!'; x /= 85; } out.extend(d.iter().take(chunk.len() + 1).map(|c| char::from(*c))); } out.push_str("~>"); out } fn zstd(data: &str) -> Vec { ruzstd::encoding::compress_to_vec(data.as_bytes(), ruzstd::encoding::CompressionLevel::Fastest) } /// The editing copy of a file with `layers` (made by [`layer`]), 100 × 100 points. fn native(layers: &str) -> String { format!( "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%AI5_NumLayers: 2\n{layers}%%Trailer\n" ) } /// The editing copy of a Large Canvas file (#2031): art and boxes written at 1/`scale`, declared /// with `%AI24_LargeCanvasScale` as Illustrator writes it. The file's page is `size × scale` /// points. fn native_large_canvas(size: f64, scale: f64, layers: &str) -> String { format!( "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 {size} {size}\n%%HiResBoundingBox: 0 0 {size} {size}\n%AI3_Cropmarks: 0 0 {size} {size}\n%AI24_LargeCanvasScale: {scale}\n%AI5_NumLayers: 1\n{layers}%%Trailer\n" ) } /// A layer as the format writes it. fn layer(name: &str, visible: bool, body: &str) -> String { format!("%AI5_BeginLayer\n{} 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{body}\nLB\n%AI5_EndLayer--\n", u8::from(visible)) } /// A yellow square of 4 points at (`x`, `y`) in the format's operators. fn square(x: u32, y: u32) -> String { format!("0 0 1 0 k\n{x} {y} m\n{} {y} L\n{} {} L\n{x} {} L\nf", x + 4, x + 4, y + 4, y + 4) } /// An EPS whose page draws `page` and whose private data is `native`. fn eps(page: &str, native: &str) -> Vec { let stream = ascii85(&zstd(native)); let lines: Vec = stream.as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect(); format!( "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{page}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI24_DataStream\n{}\n%AI9_PrivateDataEnd\n", lines.join("\n") ) .into_bytes() } /// The page's drawing of the square at (`x`, `y`). fn page_square(x: u32, y: u32) -> String { format!("0 0 1 0 setcmykcolor {x} {y} moveto {} {y} lineto {} {} lineto {x} {} lineto closepath fill", x + 4, x + 4, y + 4, y + 4) } fn names(d: &Document) -> Vec { d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect() } #[test] fn native_import_keeps_ruler_units_from_editing_data() { let art = layer("Art", true, &square(10, 10)); let native = format!( "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%AI5_RulerUnits: 1\n%AI5_NumLayers: 1\n{art}%%Trailer\n" ); let r = import(&eps(&page_square(10, 10), &native)).unwrap(); assert_eq!(r.document.units, Unit::Millimeters); } fn kinds(l: &Node) -> Vec<&'static str> { l.children() .into_iter() .flatten() .map(|n| match &n.kind { NodeKind::Group { clip: true, children } => { ["clip[0]", "clip[1]", "clip[2]", "clip[3]"].get(children.len()).copied().unwrap_or("clip[many]") } NodeKind::Group { children, .. } => { ["group[0]", "group[1]", "group[2]", "group[3]"].get(children.len()).copied().unwrap_or("group[many]") } NodeKind::Compound { children, .. } => { ["compound[0]", "compound[1]", "compound[2]", "compound[3]"].get(children.len()).copied().unwrap_or("compound[many]") } NodeKind::Path { clipping: true, .. } => "clippath", NodeKind::Path { .. } => "path", NodeKind::Text(_) => "text", _ => "other", }) .collect() } #[test] fn layers_come_in_with_their_names_colours_and_hidden_state() { let art = native(&(layer("Back", true, &square(10, 10)) + &layer("Hidden", false, &square(50, 50)))); let r = import(&eps(&page_square(10, 10), &art)).unwrap(); let d = &r.document; assert_eq!(names(d), ["Back", "Hidden"], "{:?}", r.warnings); assert_eq!([d.layers[0].visible, d.layers[1].visible], [true, false]); // What is on a hidden layer stays: the page doesn't have it. assert_eq!(kinds(&d.layers[1]), ["path"]); // Colour 0 of the layer colours (light blue), by its place in the list. assert!(matches!(&d.layers[0].kind, NodeKind::Layer { color: LayerColor::Preset(0), .. })); assert!(r.warnings.is_empty(), "{:?}", r.warnings); // The page is where the page's art was. let b = d.layers[0].children().unwrap()[0].visual_bounds().unwrap(); assert!((b.x0 - 10.0).abs() < 1e-6 && (b.y0 - 86.0).abs() < 1e-6, "{b:?}"); } #[test] fn sublayers_groups_compounds_clip_groups_and_hidden_objects_come_in() { let body = [ "u".to_string(), square(10, 10), "U".into(), "*u".into(), square(20, 10), square(30, 10), "*U".into(), // A clipping group: its objects, then its clip path (filled, so it paints too). "q".into(), square(40, 10), "50 10 m 60 10 L 60 20 L 50 20 L h W f".into(), "Q".into(), // Hidden until the next `Xw`. "1 Xw".into(), "u".into(), square(70, 10), "U".into(), "0 Xw".into(), ] .join("\n"); let sub = layer("Inner", true, &square(80, 10)); let art = native(&layer("Outer", true, &format!("{body}\n{sub}"))); // The page has what shows: the clip group's square is outside its clip path, which paints. let clip_fill = "0 0 1 0 setcmykcolor 50 10 moveto 60 10 lineto 60 20 lineto 50 20 lineto closepath fill"; let page = [10, 20, 30, 80].map(|x| page_square(x, 10)).join("\n") + "\n" + clip_fill; let r = import(&eps(&page, &art)).unwrap(); let outer = &r.document.layers[0]; assert_eq!(kinds(outer), ["group[1]", "compound[2]", "clip[3]", "group[1]", "other"]); let children = outer.children().unwrap(); assert!(children[0].visible && !children[3].visible, "the hidden object is hidden, not left out"); // The clip path comes first in its group. assert!(matches!(children[2].children().unwrap()[0].kind, NodeKind::Path { clipping: true, .. })); // The sublayer is a layer of the layer. assert!(matches!(&children[4].kind, NodeKind::Layer { .. })); assert_eq!(children[4].name.as_deref(), Some("Inner")); } #[test] fn what_the_layers_cant_say_keeps_the_page_when_it_shows_and_not_when_it_doesnt() { // An operator that isn't read (`Zq`) on a layer that shows: the layers would draw without it. let shown = native(&layer("Art", true, &format!("{}\n0 0 0 1 0 0 Zq", square(10, 10)))); let r = import(&eps(&page_square(10, 10), &shown)).unwrap(); assert_eq!(names(&r.document), ["Layer 1"]); assert!(r.warnings.iter().any(|w| w.contains("layers weren't read") && w.contains("`Zq`")), "{:?}", r.warnings); // On a hidden layer it only costs that layer's look. let hidden = native(&(layer("Art", true, &square(10, 10)) + &layer("Old", false, "0 0 0 1 0 0 Zq"))); let r = import(&eps(&page_square(10, 10), &hidden)).unwrap(); assert_eq!(names(&r.document), ["Art", "Old"]); assert!(r.warnings.iter().any(|w| w.contains("hidden layers have art this can't read")), "{:?}", r.warnings); } #[test] fn text_and_its_strokes_come_from_the_page_into_the_layer_of_its_text_object() { let page = format!( "{}\n/Helvetica findfont 12 scalefont setfont 10 50 moveto (Hi) show\n10 50 moveto (Hi) false charpath 2 setlinewidth stroke", page_square(10, 10) ); let words = layer("Words", true, "/AI11Text :\n0 /FreeUndo ,\n;\n\n0 0 Xd"); let art = native(&(layer("Art", true, &square(10, 10)) + &words)); let r = import(&eps(&page, &art)).unwrap(); assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings); assert_eq!(kinds(&r.document.layers[0]), ["path"]); // The text and the path of its stroke, where the text object is (no group of their own). assert_eq!(kinds(&r.document.layers[1]), ["text", "path"], "{:?}", r.warnings); assert!(r.warnings.is_empty(), "{:?}", r.warnings); } /// A point type object in the legacy format (`To` … `TO`), as an EPS saved for Illustrator 10 /// keeps its type in the editing data: no `/AI11Text` for the page's type to go in. fn legacy_text(x: u32, y: u32, text: &str) -> String { format!("0 To\n1 0 0 1 {x} {y} 0 Tp\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n({text}) Tx\nTO") } #[test] fn type_in_the_legacy_format_is_the_layers_own_and_the_page_has_text() { let page = format!("{}\n0 0 0 1 setcmykcolor /Helvetica findfont 12 scalefont setfont 10 50 moveto (Hi) show", page_square(10, 10)); let art = native(&(layer("Art", true, &square(10, 10)) + &layer("Words", true, &legacy_text(10, 50, "Hi")))); let r = import(&eps(&page, &art)).unwrap(); assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings); let texts = text_of(&r.document.layers[1]); assert_eq!(texts.len(), 1, "{:?}", r.warnings); assert_eq!(texts[0].plain_text(), "Hi"); assert!(!r.warnings.iter().any(|w| w.contains("left out") || w.contains("weren't read")), "{:?}", r.warnings); // Type the editing data keeps as outlines (here a stand-in, a small square where the page has // more type) comes in as they are, and the import says so. let page = format!("{page}\n/Helvetica findfont 4 scalefont setfont 60 20 moveto (o) show"); let outlines = format!("{}\n0 0 0 1 k\n60.5 20 m\n62.5 20 L\n62.5 22 L\n60.5 22 L\nf", legacy_text(10, 50, "Hi")); let art = native(&(layer("Art", true, &square(10, 10)) + &layer("Words", true, &outlines))); let r = import(&eps(&page, &art)).unwrap(); assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings); assert_eq!(kinds(&r.document.layers[1]), ["text", "path"], "{:?}", r.warnings); assert!(r.warnings.iter().any(|w| w.starts_with("1 text objects of the page are outlines on the layers")), "{:?}", r.warnings); } #[test] fn a_damaged_editing_copy_leaves_the_page() { // Not ASCII85, ASCII85 of something that isn't Zstandard, Zstandard of something that isn't art. let plain = |data: &str| { let lines = format!("%{}", data); format!( "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI24_DataStream\n{lines}\n%AI9_PrivateDataEnd\n", page_square(10, 10) ) .into_bytes() }; for bytes in [plain("this is not ascii85 {{{~>"), plain(&ascii85(b"not zstandard at all")), eps(&page_square(10, 10), "nothing of the art here")] { let r = import(&bytes).unwrap(); assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings); } // A file that has the data but not in a form that can be read says so (and a write over it asks). let r = import(&plain("this is not ascii85 {{{~>")).unwrap(); assert!(r.warnings.iter().any(|w| w.contains("damaged") && crate::is_loss(w)), "{:?}", r.warnings); } /// The `.ai`'s editing data: its marker, then the Zstandard stream. fn private(native: &str) -> Vec { [b"%AI24_ZStandard_Data".as_slice(), &zstd(native)].concat() } /// A page of 100 × 100 with a square at (`x`, `y`) in one layer, as a PDF part would give. fn pdf_part(x: f64, y: f64) -> Document { let mut d = Document::new(100.0, 100.0); let id = d.alloc_id(); let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, y, x + 4.0, y + 4.0)); let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 0.0, 1.0, 0.0)); let item = vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint)); let n = Node::path(id, path, vectorcraft_doc::Appearance { items: vec![item], ..Default::default() }); let layer = d.layers[0].id; d.insert(Some(layer), 0, n).unwrap(); d } /// The PDF part `d` as read in points from pages of `k` points a unit (their `UserUnit`): its /// pages and art `k` times as large. fn in_points(mut d: Document, k: f64) -> Document { for layer in &mut d.layers { std::sync::Arc::make_mut(layer).transform(vectorcraft_geom::Affine::scale(k), true); } for a in &mut d.artboards { a.rect = a.rect.scale_from_origin(k); } d } /// #1479: an image larger than the editing data's reader takes would be missing from the layers, /// while the PDF part has it (it reads larger images): the file comes in as its PDF part, with a /// note saying why, rather than without the image. #[test] fn an_ai_file_with_an_image_too_large_for_its_editing_data_comes_in_as_its_pdf_part() { let raster = |w: u32, h: u32| { format!( "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceGray XN\n[ 1 0 0 1 0 100 ] {w} {h} 0 Xh\n\ [ 1 0 0 1 0 100 ] 0 0 {w} {h} {w} {h} 8 1 0 0 1 0 4 4 0 0\n%%BeginData: 1\rXI\n\u{0}%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN" ) }; // 6,000 × 6,000 pixels: past the reader's 2^25. let art = native(&layer("Texture", true, &format!("{}\n{}", square(10, 10), raster(6000, 6000)))); let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0); assert!(!read, "the PDF part is used: {warnings:?}"); assert!(warnings.iter().any(|w| w.contains("an image is too large to read from them")), "{warnings:?}"); assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), 1, "the PDF part's art"); // An image the reader takes keeps the layers. let art = native(&layer("Texture", true, &format!("{}\n{}", square(10, 10), raster(1, 1)))); let (_, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0); assert!(read, "{warnings:?}"); } /// A `.ai` whose layers hold a placed PDF and no text object: its PDF part draws the placed file, /// type and all, where the layers show the file's preview. That type is the placed file's and needs /// no text object, so the file comes in from its layers, the placed file where its matrix takes the /// canvas point of art space's origin or at the corner of its box (see /// `a_placed_file_at_its_boxs_corner_matches_the_page`). Type outside the placed file still needs /// one, and without it the file comes in as its PDF part. #[test] fn type_of_the_page_inside_a_placed_file_needs_no_text_object() { // A file placed on (30, 30)–(70, 70) whose preview is 2 × 2 pixels of half-transparent black, // as in `a_placed_file_at_its_boxs_corner_matches_the_page`; with the template box, the canvas // point of art space's origin puts it off the page, and the box's corner on it. let place = format!( "%AI5_BeginPlace\n%%FileType: 1\n[1 0 0 -1 -7970 8070] 8000 8000 8040 8040 4 4 0\n(/files/pages.pdf)`\n\ %AI26_BeginPlacedObjectPreview\n2 2 8 32 17 {}\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n", "\x7f\0\0\0".repeat(4) ); let at_origin = native(&layer("Pages", true, &place)); let at_corner = at_origin.replace("%AI5_NumLayers", "%AI3_TemplateBox: 100 50 100 50\n%AI5_NumLayers"); // The PDF part: the placed file's page in 50% grey, and type at (`x`, 55). let pdf = |x: f64| { let mut d = Document::new(100.0, 100.0); let grey = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::gray(0.5))); let rect = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 30.0, 70.0, 70.0)); let page = Node::path( d.alloc_id(), rect, vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(grey)], ..Default::default() }, ); let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() }; let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(x, 55.0), "Hi", style); let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(text))); let layer = d.layers[0].id; d.insert(Some(layer), 0, page).unwrap(); d.insert(Some(layer), 1, text).unwrap(); d }; for data in [&at_origin, &at_corner] { let (d, warnings, read) = crate::layered_ai(&private(data), pdf(38.0), vec![], false, false, 1.0); assert!(read, "{warnings:?}"); assert_eq!(names(&d), ["Pages"]); let mut links = vec![]; d.visit_images(|_, im| links.extend(im.link.as_ref().map(|l| l.path.clone()))); assert_eq!(links, ["/files/pages.pdf"], "{warnings:?}"); assert!(warnings.iter().all(|w| !w.contains("no text object")), "the type is the placed file's, not left out: {warnings:?}"); // Type beside the placed file is a text object's, which the layers don't have. let (_, warnings, read) = crate::layered_ai(&private(data), pdf(5.0), vec![], false, false, 1.0); assert!(!read, "{warnings:?}"); assert!(warnings.iter().any(|w| w.contains("its layers have no text objects")), "{warnings:?}"); } // With a text object the text document makes beside it, the placed file's type is still its // own, not type left out. // The template box's centre (100, 50) is the canvas centre: the anchor is at (5, 10) in the art. let doc = text_document(&[("Note\r", 0, (8096.5, 8231.5), (0.0, 0.0), 0.0)]); let both = native_with_text(&layer("Pages", true, &format!("{place}{}", text_object(0))), &doc) .replace("%AI3_TemplateBox: 50 50 50 50", "%AI3_TemplateBox: 100 50 100 50"); let mut page = pdf(38.0); let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() }; let note = Node::new( page.alloc_id(), NodeKind::Text(Box::new(vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 90.0), "Note", style))), ); let layer_id = page.layers[0].id; page.insert(Some(layer_id), 2, note).unwrap(); let (d, warnings, read) = crate::layered_ai(&private(&both), page, vec![], false, false, 1.0); assert!(read, "{warnings:?}"); assert_eq!(text_of(&d.layers[0]).iter().map(|t| t.plain_text()).collect::>(), ["Note"], "{warnings:?}"); assert!(!warnings.iter().any(|w| w.contains("left out")), "{warnings:?}"); } /// A palette of `[None]` and White, and a color group `Inks` holding a spot color, Red ink. const PALETTE: &str = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 1 1 1 Xa\n(White)\nPc\n1 (Inks) 1 Pg\n0.06 0.98 1 0 (Red ink) 0 x\n(Red ink)\nPc\nPB\n%AI5_EndPalette\n"; /// A `.ai` that comes in as its PDF part, whose layers can't be used, still gets the swatches and /// color groups of its editing data's palette in place of the defaults, and keeps the inks its PDF /// part's art paints with that the palette doesn't have. #[test] fn an_ai_file_that_comes_in_as_its_pdf_part_keeps_the_swatches_of_its_palette() { let image = "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceGray XN\n[ 1 0 0 1 0 100 ] 6000 6000 0 Xh\n\ [ 1 0 0 1 0 100 ] 0 0 6000 6000 6000 6000 8 1 0 0 1 0 4 4 0 0\n%%BeginData: 1\rXI\n\u{0}%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN"; let art = native(&format!("{PALETTE}{}", layer("Texture", true, &format!("{}\n{image}", square(10, 10))))); let mut part = pdf_part(10.0, 86.0); let gold = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 0.2, 0.9, 0.1)); part.swatches.push(vectorcraft_color::swatch::Swatch { name: "Gold ink".into(), paint: gold, global: true, spot: true }); let (d, warnings, read) = crate::layered_ai(&private(&art), part, vec![], false, true, 1.0); assert!(!read, "the PDF part is used: {warnings:?}"); let swatches: Vec<&str> = d.swatches.iter().map(|s| s.name.as_str()).collect(); assert_eq!(swatches, ["[None]", "White", "Gold ink"], "{warnings:?}"); let groups: Vec<&str> = d.swatch_groups.iter().map(|g| g.name.as_str()).collect(); assert_eq!(groups, ["Inks"]); assert!(d.swatch("Red ink").is_some_and(|s| s.spot)); } /// A `.ai` pattern library (a palette of pattern swatches and no layers) whose PDF part defines no /// pattern gets the patterns its editing data defines with the palette's pattern swatches. #[test] fn an_ai_file_without_layers_keeps_the_patterns_of_its_palette() { 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 palette = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 1 1 1 Xa\n(White)\nPc\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n(Dots)\nPc\nPB\n%AI5_EndPalette\n"; let art = native(&format!("{dots}{palette}")); let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0); assert!(!read, "{warnings:?}"); let swatches: Vec<&str> = d.swatches.iter().map(|s| s.name.as_str()).collect(); assert_eq!(swatches, ["[None]", "White", "Dots"], "{warnings:?}"); assert!(matches!(&d.swatch("Dots").map(|s| &s.paint), Some(vectorcraft_color::Paint::Pattern { pattern, .. }) if pattern == "Dots")); assert!(d.pattern("Dots").is_some_and(|p| !p.art.is_empty())); // A pattern that shows an image brings the image's data with it. let pic = "%AI3_BeginPattern: (Pic)\n(Pic) 0 0 4 4\n%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceGray XN\n[ 4 0 0 4 0 4 ] 1 1 0 Xh\n\ [ 4 0 0 4 0 4 ] 0 0 1 1 1 1 8 1 0 0 1 0 4 4 0 0\n%%BeginData: 1\rXI\n\u{0}%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\nE\n%AI3_EndPattern\n"; let art = native(&format!("{pic}{}", palette.replace("(Dots)", "(Pic)"))); let (d, warnings, _) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0); let mut keys = vec![]; d.visit_images(|_, im| keys.push(im.key.clone())); assert_eq!(keys.len(), 1, "{warnings:?}"); assert!(keys.iter().all(|k| d.images.contains_key(k)), "{keys:?}"); } /// An EPS whose editing data can't be used, a palette with no layers among others, comes in as its /// page with the swatches and color groups of that palette in place of the defaults. #[test] fn an_eps_that_comes_in_as_its_page_keeps_the_swatches_of_its_palette() { let r = import(&eps(&page_square(10, 10), &native(PALETTE))).unwrap(); assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings); let swatches: Vec<&str> = r.document.swatches.iter().map(|s| s.name.as_str()).collect(); assert_eq!(swatches, ["[None]", "White"], "{:?}", r.warnings); let groups: Vec<&str> = r.document.swatch_groups.iter().map(|g| g.name.as_str()).collect(); assert_eq!(groups, ["Inks"]); } #[test] fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() { // A square on the artboard, and one above it (the artboard is y 0 to 100 here). let art = native(&(layer("Jig", true, &square(10, 10)) + &layer("Parts", true, &square(30, 110)) + &layer("Old", false, &square(60, 60)))); let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec!["a note".into()], false, false, 1.0); assert_eq!(names(&d), ["Jig", "Parts", "Old"], "{warnings:?}"); assert!(read); assert_eq!(warnings, ["a note"], "nothing else to say"); let above = d.layers[1].children().unwrap()[0].visual_bounds().unwrap(); assert!(above.y1 <= 0.0 + 1e-6, "the art above the artboard is kept, outside it: {above:?}"); assert!(!d.layers[2].visible); } /// A `.ai` whose PDF part was read in points from a page of 10 points a unit (its `UserUnit`), and /// whose editing data has the page in those units: its layers come in at the page's size, ten /// times as large as the editing data has them, with stroke weights and dashes ten times as large. /// Editing data with the page in points comes in as it is. #[test] fn editing_data_in_the_units_of_a_user_unit_page_comes_in_at_the_pages_size() { // A dashed line 2 units wide on a hidden layer, which the page doesn't draw. let line = "0 0 1 0 K\n2 w\n[4 2] 1 d\n10 50 m\n50 50 L\nS"; let art = native(&(layer("Jig", true, &square(10, 10)) + &layer("Parts", true, &square(30, 110)) + &layer("Old", false, line))); let page = in_points(pdf_part(10.0, 86.0), 10.0); let first = |d: &Document, l: usize| d.layers[l].children().unwrap()[0].visual_bounds().unwrap(); let near = |r: vectorcraft_geom::Rect, want: [f64; 4]| [r.x0, r.y0, r.x1, r.y1].iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-6); let (d, w, read) = crate::layered_ai(&private(&art), page.clone(), vec![], false, false, 10.0); assert!(read, "{w:?}"); assert_eq!(names(&d), ["Jig", "Parts", "Old"]); assert_eq!(d.artboards[0].rect, vectorcraft_geom::Rect::new(0.0, 0.0, 1000.0, 1000.0)); // The square on the artboard and the one above it, ten times as large and as far from its corner. assert!(near(first(&d, 0), [100.0, 860.0, 140.0, 900.0]), "{:?}", first(&d, 0)); assert!(near(first(&d, 1), [300.0, -140.0, 340.0, -100.0]), "{:?}", first(&d, 1)); let stroke = d.layers[2].children().unwrap()[0].appearance.stroke().cloned().unwrap(); let dash = stroke.dash.unwrap(); assert_eq!((stroke.width, dash.pattern, dash.offset), (20.0, vec![40.0, 20.0], 10.0)); // Read as if its page had no `UserUnit`, the editing data is for another page. let (_, w, read) = crate::layered_ai(&private(&art), page.clone(), vec![], false, false, 1.0); assert!(!read && w.iter().any(|w| w.contains("for a 100 × 100 pt page, the file's 1000 × 1000 pt page at 1:10")), "{w:?}"); // Editing data with the page in points. let points = native(&layer("Jig", true, "0 0 1 0 k\n100 100 m\n140 100 L\n140 140 L\n100 140 L\nf")).replace(" 0 0 100 100", " 0 0 1000 1000"); let (d, w, read) = crate::layered_ai(&private(&points), page, vec![], false, false, 10.0); assert!(read, "{w:?}"); assert_eq!(d.artboards[0].rect, vectorcraft_geom::Rect::new(0.0, 0.0, 1000.0, 1000.0)); assert!(near(first(&d, 0), [100.0, 860.0, 140.0, 900.0]), "{:?}", first(&d, 0)); } /// A PDF part read from a `UserUnit` page cropped to the art: the art's box, not the artboard, is /// the page in the editing data's units, so the layers are read at the page's size, with their /// artboard. #[test] fn a_user_unit_page_cropped_to_the_art_is_read_in_its_units() { // A 50 × 50 artboard, art over 100 × 100 (its bounding box), and a page of that box at 10 pt a // unit: 1000 × 1000 pt. let art = native(&layer("Jig", true, &square(10, 10))).replace("%AI3_Cropmarks: 0 0 100 100", "%AI3_Cropmarks: 0 0 50 50"); let page = in_points(pdf_part(10.0, 86.0), 10.0); let (d, w, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 10.0); assert!(read, "{w:?}"); assert_eq!(d.artboards[0].rect.size(), vectorcraft_geom::Size::new(500.0, 500.0)); // The square, 36 down the artboard, ten times as large and as far. let b = d.layers[0].children().unwrap()[0].visual_bounds().unwrap(); assert!([b.x0 - 100.0, b.y0 - 360.0, b.x1 - 140.0, b.y1 - 400.0].iter().all(|v| v.abs() < 1e-6), "{b:?}"); } /// A PDF part whose pages are each cropped to all of the art holds the art once a page: the type /// of the pages after the first is that type again, and only the first page's goes into the /// layers. #[test] fn type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once() { // A 50 × 50 artboard, art over 100 × 100, a text object with no story in the file, and two // pages of the art's box in a row, 20 apart, each with the square and the type. let art = native(&layer("Art", true, &(square(10, 10) + &text_object(0)))).replace("%AI3_Cropmarks: 0 0 100 100", "%AI3_Cropmarks: 0 0 50 50"); let mut pdf = pdf_part(10.0, 86.0); let mut second = pdf.artboards[0].clone(); (second.id, second.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0)); pdf.artboards.push(second); let copy = pdf_part(130.0, 86.0).layers[0].children().unwrap()[0].as_ref().clone(); let layer_id = pdf.layers[0].id; let id = pdf.alloc_id(); pdf.insert(Some(layer_id), 1, Node { id, ..copy }).unwrap(); for x in [30.0, 150.0] { let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() }; let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(x, 40.0), "Hi", style); let id = pdf.alloc_id(); pdf.insert(Some(layer_id), 2, Node::new(id, NodeKind::Text(Box::new(text)))).unwrap(); } let (d, w, read) = crate::layered_ai(&private(&art), pdf, vec![], false, false, 1.0); assert!(read, "{w:?}"); let texts = text_of(&d.layers[0]); // The first page's type, where the art has it: 50 above the artboard's top. assert_eq!(texts.iter().map(|t| t.plain_text()).collect::>(), ["Hi"], "{w:?}"); let [.., e, f] = texts[0].xf.as_coeffs(); assert!((e - 30.0).abs() < 1e-6 && (f + 10.0).abs() < 1e-6, "{:?}", texts[0].xf); } /// A PDF saved with only the second of its two artboards has a page for that one, while its /// editing data keeps both: the layers are compared with the page on that artboard, and the page's /// type goes there. #[test] fn a_pdf_of_its_second_artboard_is_compared_on_that_artboard() { // Two 100-point artboards side by side, 50 apart (art space, y up), a square on each, and a // text object with no story in the file. let board = |x0: i32| { format!( "%_/Dictionary :\n%_{x0} 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n", x0 + 100 ) }; let boards = format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{}{}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n", board(0), board(150)); let art = native(&(boards + &layer("Art", true, &(square(10, 10) + "\n" + &square(180, 50) + "\n" + &text_object(0))))); // The page: the second artboard, its square 30 from the left and 46 down, and the type. let mut pdf = pdf_part(30.0, 46.0); let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() }; let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(40.0, 20.0), "Hi", style); let (layer_id, id) = (pdf.layers[0].id, pdf.alloc_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, false, 1.0); assert!(read, "{w:?}"); assert_eq!(d.artboards.len(), 2, "both artboards are kept"); let texts = text_of(&d.layers[0]); assert_eq!(texts.iter().map(|t| t.plain_text()).collect::>(), ["Hi"], "{w:?}"); let [.., e, f] = texts[0].xf.as_coeffs(); assert!((e - 190.0).abs() < 1e-6 && (f - 20.0).abs() < 1e-6, "on the second artboard: {:?}", texts[0].xf); } #[test] fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() { let pdf = pdf_part(10.0, 86.0); // No editing data this reads. let (d, w, read) = crate::layered_ai(b"nothing", pdf.clone(), vec![], false, false, 1.0); assert_eq!(d.layers.len(), pdf.layers.len()); assert!(w.is_empty() && !read); // Editing data that isn't for this page: neither its artboard nor its art's box is the page. let other = native(&layer("Jig", true, &square(10, 10))).replace(" 0 0 100 100", " 0 0 50 50"); let (d, w, _) = crate::layered_ai(&private(&other), pdf.clone(), vec![], false, false, 1.0); assert_eq!(names(&d), names(&pdf)); assert!(w.iter().any(|w| w.contains("for a 50 × 50 pt page, the file's 100 × 100 pt page at 1:2")), "{w:?}"); // A page cropped to the art's box, not the artboard, is that art's page: the layers are read, // with their artboard. let cropped = native(&layer("Jig", true, &square(10, 10))).replace("%AI3_Cropmarks: 0 0 100 100", "%AI3_Cropmarks: 0 0 50 50"); let (d, w, read) = crate::layered_ai(&private(&cropped), pdf.clone(), vec![], false, false, 1.0); assert!(read, "{w:?}"); assert_eq!(names(&d), ["Jig"]); assert_eq!(d.artboards[0].rect.size(), vectorcraft_geom::Size::new(50.0, 50.0)); } /// #2034: editing data for a page of another size gives both sizes in points, in an EPS's note and /// in a `.ai`'s. #[test] 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), ["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); let note = "the file's layers weren't read from its editing data (its editing data is for a 600 × 400 pt page, not the file's 595.276 × 841.89 pt page): they come from its PDF part"; 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] 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), ["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, 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)")); let half = file.replace("%%BoundingBox: 0 0 100 100", "%%BoundingBox: 0 0 50 50"); assert!(note(&half).contains("for a 100 × 100 pt page, the file's 50 × 50 pt page at 2:1)")); // A scale that rounds to 1:1 isn't given. let large = art.replace(" 0 0 100 100", " 0 0 2000 2000"); let (_, w, _) = crate::layered_ai(&private(&large), Document::new(2000.8, 2000.8), vec![], false, false, 1.0); assert!(w.iter().any(|w| w.contains("for a 2000 × 2000 pt page, not the file's 2000.8 × 2000.8 pt page)")), "{w:?}"); } /// Editing data that can't be decompressed leaves the `.ai` as its PDF part, after its notes, with a /// note saying why its layers weren't read, which counts as a loss. #[test] fn an_ai_file_whose_editing_data_cant_be_decompressed_says_so() { let pdf = pdf_part(10.0, 86.0); for (private, why) in [ (&b"%AI12_CompressedDataxx"[..], "its zlib compression is damaged"), (b"%AI24_ZStandard_Data nothing", "its Zstandard compression is damaged"), ] { let (d, w, read) = crate::layered_ai(private, pdf.clone(), vec!["a note".into()], false, false, 1.0); assert!(!read); assert_eq!(names(&d), names(&pdf)); assert_eq!(w, ["a note".to_string(), format!("the file's layers weren't read from its editing data ({why}): they come from its PDF part")]); assert!(crate::is_loss(&w[1]), "{w:?}"); } } /// #2031: a Large Canvas editing copy (`%AI24_LargeCanvasScale: 10`) writes its art and boxes at /// 1/10 scale; the import scales them back, so its layers are read instead of being rejected as /// another page's — with the art at the page's size. Art that doesn't match the page at that /// scale is still another page's. #[test] fn a_large_canvas_eps_keeps_its_layers_at_the_pages_size() { // 10 art units for the page's 100 points: a 0.4-unit square at (5, 5) is the page's 4-point // square at (50, 50). let art = native_large_canvas(10.0, 10.0, &layer("Art", true, "0 0 1 0 k\n5 5 m\n5.4 5 L\n5.4 5.4 L\n5 5.4 L\nf")); let r = import(&eps(&page_square(50, 50), &art)).unwrap(); let d = &r.document; assert_eq!(names(d), ["Art"], "{:?}", r.warnings); assert!(r.warnings.is_empty(), "{:?}", r.warnings); let b = d.layers[0].children().unwrap()[0].visual_bounds().unwrap(); assert!((b.x0 - 50.0).abs() < 1e-6 && (b.y0 - 46.0).abs() < 1e-6, "at the page's place: {b:?}"); assert!((b.width() - 4.0).abs() < 1e-6 && (b.height() - 4.0).abs() < 1e-6, "at the page's size: {b:?}"); // 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), ["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))); let (d, w, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0); assert!(read && names(&d) == ["Art"], "{w:?}"); } /// A `.ai` whose layers can't be read comes in as its PDF part, a page per artboard in a row; its /// pages still go where its editing data puts its artboards, with their art, and keep their names (#1068). #[test] fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() { // Two 100-point artboards, the second below the first (art space, y up). let board = |x0: i32, y0: i32| { format!( "%_/Dictionary :\n%_{x0} {} /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} {y0} /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_(at {x0} {y0}) /UnicodeString (Name) ,\n%_; ,\n", y0 + 100, x0 + 100 ) }; let boards = |list: &[(i32, i32)]| { let all: String = list.iter().map(|(x, y)| board(*x, *y)).collect(); format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{all}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n") }; // An operator this doesn't know, so the layers aren't read. let unread = |list: &[(i32, i32)]| native(&(boards(list) + &layer("Art", true, &(square(10, 10) + "\n1 Zz")))); // The PDF part: the pages side by side, 20 points apart, a square on each. let mut pdf = pdf_part(10.0, 10.0); let second = pdf_part(130.0, 10.0).layers[0].children().unwrap()[0].clone(); let mut second = (*second).clone(); second.id = pdf.alloc_id(); let layer_id = pdf.layers[0].id; pdf.insert(Some(layer_id), 1, second).unwrap(); 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 squares = |d: &Document| d.layers[0].children().unwrap().iter().map(|n| n.visual_bounds().unwrap().origin()).collect::>(); let (d, w, read) = crate::layered_ai(&private(&unread(&[(0, 0), (0, -150)])), pdf.clone(), vec![], false, false, 1.0); assert!(!read && w.iter().any(|w| w.contains("layers weren't read")), "{w:?}"); let rects: Vec<_> = d.artboards.iter().map(|a| a.rect).collect(); assert_eq!(rects, [vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), vectorcraft_geom::Rect::new(0.0, 150.0, 100.0, 250.0)]); let board_names: Vec<_> = d.artboards.iter().map(|a| a.name.as_str()).collect(); assert_eq!(board_names, ["at 0 0", "at 0 -150"]); let moved = squares(&d); assert!((moved[0] - squares(&pdf)[0]).hypot() < 1e-6, "the first page's art stays: {moved:?}"); assert!((moved[1] - vectorcraft_geom::Point::new(10.0, 160.0)).hypot() < 1e-6, "the second's goes with its page: {moved:?}"); // Artboards that don't match the pages leave them in a row. for list in [&[(0, 0)][..], &[(0, 0), (0, -150), (200, 0)]] { let (d, _, _) = crate::layered_ai(&private(&unread(list)), pdf.clone(), vec![], false, false, 1.0); assert_eq!(d.artboards[1].rect, pdf.artboards[1].rect, "{list:?}"); assert_eq!(d.artboards[1].name, pdf.artboards[1].name, "{list:?}"); } // Pages read in points from a user space of 10 points a unit, with artboards in those units: // the same layout, ten times as large. let (d, _, read) = crate::layered_ai(&private(&unread(&[(0, 0), (0, -150)])), in_points(pdf.clone(), 10.0), vec![], false, false, 10.0); assert!(!read); let rects: Vec<_> = d.artboards.iter().map(|a| a.rect).collect(); assert_eq!(rects, [vectorcraft_geom::Rect::new(0.0, 0.0, 1000.0, 1000.0), vectorcraft_geom::Rect::new(0.0, 1500.0, 1000.0, 2500.0)]); let moved = squares(&d); 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 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 // stays in its grayscale model. let art = native(&layer("Art", true, "0.25 g\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf")); let page = "0 0 0 0.75 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto 10 14 lineto closepath fill"; let r = import(&eps(page, &art)).unwrap(); assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); let fill = r.document.layers[0].children().unwrap()[0].appearance.fill().and_then(|f| f.paint.color()); assert_eq!(fill, Some(vectorcraft_color::Color::gray(0.75))); assert!(r.warnings.is_empty(), "{:?}", r.warnings); } /// `native` as a CS-era file keeps it: zlib, under `%AI9_DataStream`. fn older_eps(page: &str, native: &str) -> Vec { use std::io::Write; let mut z = flate2::write::ZlibEncoder::new(vec![], flate2::Compression::default()); z.write_all(native.as_bytes()).unwrap(); let stream = ascii85(&z.finish().unwrap()); let lines: Vec = stream.as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect(); format!( "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{page}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI9_DataStream\n{}\n%AI9_PrivateDataEnd\n", lines.join("\n") ) .into_bytes() } #[test] fn files_from_before_zstandard_give_their_layers_too() { let art = native(&(layer("Back", true, &square(10, 10)) + &layer("Hidden", false, &square(50, 50)))); let r = import(&older_eps(&page_square(10, 10), &art)).unwrap(); assert_eq!(names(&r.document), ["Back", "Hidden"], "{:?}", r.warnings); assert_eq!([r.document.layers[0].visible, r.document.layers[1].visible], [true, false]); assert!(r.warnings.is_empty(), "{:?}", r.warnings); } #[test] fn custom_colours_open_paths_and_planar_group_marks_are_read() { // `x` and `X` are the custom colours of a fill and a stroke (four inks, a name, a tint), `Xx` one // with its Lab values; `H` ends an open path, `XP` marks a group the app made from a planar map. let body = "0.1 0.2 0.3 0.4 (Spot) 0 x\n0 0 0 1 (Edge) 0 X\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf\n\ 0.8 0.1 0 0 62.7 -28 -42 (Blue) 0 2 Xx\n(Adobe Planar Group) 1 0 0 XP\n20 20 m\n24 20 L\n24 24 L\nH\nF"; let r = import(&older_eps( "0.1 0.2 0.3 0.4 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto 10 14 lineto closepath fill 0.8 0.1 0 0 setcmykcolor 20 20 moveto 24 20 lineto 24 24 lineto closepath fill", &native(&layer("Art", true, body)), )) .unwrap(); assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); assert_eq!(kinds(&r.document.layers[0]), ["path", "path"]); } /// A text document of `stories` (each as the text, its alignment code, its anchor and the frame's /// translation, and the start of its first line from the anchor), one font and a 14 point default. /// A story: its text, alignment code, anchor, frame translation and first line's start. type Story<'a> = (&'a str, u8, (f64, f64), (f64, f64), f64); fn text_document(stories: &[Story<'_>]) -> String { let story = |(text, align, anchor, _t, start): &Story<'_>| { let n = text.chars().count(); format!( "<< /0 << /0 ({text}) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 {align} >> /6 0 >> >> /1 {n} >> ] >> \ /6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 {n} >> ] >> >> \ /1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ {} {} ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ << /99 /L /6 [ \ << /99 /S /0 << /0 [ {start} 0.0 ] >> /15 << /0 {n} >> >> ] >> ] >> ] >> ] >> ] >> ] >> >>", anchor.0, anchor.1 ) }; let frame = |(_, _, _, t, _): &(&str, u8, (f64, f64), (f64, f64), f64)| format!("<< /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 {} {} ] >> >> >>", t.0, t.1); format!( "/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ {} ] >> >>\n/1 << /1 [ {} ] /2 << /1 12.0 >> >>\n", stories.iter().map(frame).collect::>().join(" "), stories.iter().map(story).collect::>().join(" ") ) } /// The editing copy of a 100 × 100 file with `layers` and the text `document`; its template box is /// centred at (50, 50). fn native_with_text(layers: &str, document: &str) -> String { let lines: Vec = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect(); format!( "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%AI3_TemplateBox: 50 50 50 50\n\ %AI5_NumLayers: 2\n{layers}%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n%%Trailer\n", lines.join("\n") ) } /// A text object of story `story`, as the layers hold it. fn text_object(story: u32) -> String { format!("/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n{story} /StoryIndex ,\n2 /TextAntialiasing ,\n;\n") } fn text_of(l: &Node) -> Vec<&vectorcraft_doc::TextObject> { l.children().into_iter().flatten().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(&**t) } else { None }).collect() } #[test] fn type_on_a_hidden_layer_comes_from_the_text_document() { // Canvas centre 8191.5 is the template box's centre (50, 50): the anchor is 8.5 right of it and // 11.5 above it, so at (58.5, 61.5) in the art, which is 38.5 down the page. let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Mid\r", 2, (8191.5, 8191.5), (0.0, 0.0), -10.0)]); let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &(text_object(0) + &text_object(1)))), &doc); let r = import(&eps(&page_square(10, 10), &art)).unwrap(); assert_eq!(names(&r.document), ["Back", "Spare"], "{:?}", r.warnings); let spare = &r.document.layers[1]; assert!(!spare.visible); let texts = text_of(spare); assert_eq!(texts.len(), 2, "{:?}", r.warnings); let [a, b] = [texts[0], texts[1]]; assert_eq!(a.plain_text(), "Hi there"); let [.., e, f] = a.xf.as_coeffs(); assert!((e - 58.5).abs() < 1e-6 && (f - 38.5).abs() < 1e-6, "{:?}", a.xf); assert_eq!(a.runs[0].style.size, 14.0); assert_eq!(a.para.justify, vectorcraft_doc::Justify::Left); // Centred type is anchored at its middle. assert_eq!(b.para.justify, vectorcraft_doc::Justify::Center); let [.., e, f] = b.xf.as_coeffs(); assert!((e - 50.0).abs() < 1e-6 && (f - 50.0).abs() < 1e-6, "{:?}", b.xf); assert!(!r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings); } /// A run that sets no colour of its own takes its character style's (here `[Normal Character /// Style]`, black), not the text document's base attributes (here white, which hid the type). /// A style takes what it doesn't set from the style it is based on, and a run's own setting wins. #[test] fn type_from_the_text_document_takes_its_character_styles_attributes() { let own_fill = "/53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> "; let run = |style: u8| format!("/5 {style} /6 << /0 0 /1 14.0 >>"); let doc = text_document(&[ ("Normal\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Based\r", 0, (8200.0, 8170.0), (0.0, 0.0), 0.0), ("Own\r", 0, (8200.0, 8160.0), (0.0, 0.0), 0.0), ]) // The first two runs set no colour, the second of the style based on the normal one. .replacen(own_fill, "", 2) .replacen(&run(0), "/5 X", 1) .replacen(&run(0), &run(1), 1) .replacen("/5 X", &run(0), 1) // The document's base attributes: grey level 1, white, with auto leading. .replacen("/2 << /1 12.0 >>", "/2 << /1 12.0 /4 true /53 << /99 /SimplePaint /0 << /0 0 /1 [ 1.0 1.0 ] >> >> >>", 1) // Its character styles: the normal one black with 18-point leading, and one based on it in red. .replacen( "/0 << /1 <<", "/0 << /5 << /0 [ << /0 << /0 ([Normal Character Style]) /6 << /4 false /5 18.0 \ /53 << /99 /SimplePaint /0 << /0 1 /1 [ 1.0 0.0 0.0 0.0 ] >> >> >> >> >> \ << /0 << /0 (Red) /5 0 /6 << /53 << /99 /SimplePaint /0 << /0 1 /1 [ 1.0 1.0 0.0 0.0 ] >> >> >> >> >> ] >> /1 <<", 1, ); let spare = text_object(0) + &text_object(1) + &text_object(2); let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &spare)), &doc); let r = import(&eps(&page_square(10, 10), &art)).unwrap(); let texts = text_of(&r.document.layers[1]); assert_eq!(texts.iter().map(|t| t.plain_text()).collect::>(), ["Normal", "Based", "Own"], "{:?}", r.warnings); let style = |i: usize| (texts[i].runs[0].style.fill.clone(), texts[i].runs[0].style.leading); let solid = vectorcraft_color::Paint::solid; use vectorcraft_color::Color; assert_eq!(style(0), (solid(Color::rgb(0.0, 0.0, 0.0)), Some(18.0)), "the normal style's"); assert_eq!(style(1), (solid(Color::rgb(1.0, 0.0, 0.0)), Some(18.0)), "its style's colour, the leading of the one it's based on"); assert_eq!(style(2), (solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), Some(18.0)), "the run's own colour"); } #[test] fn type_from_the_text_document_keeps_its_case_setting() { // Key 12 of a character style is its case: 1 Small Caps, 2 All Caps. The characters stay as // typed; the setting shows them as capitals. let doc = text_document(&[ ("hon. guest\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Book Now\r", 0, (8200.0, 8170.0), (0.0, 0.0), 0.0), ("plain\r", 0, (8200.0, 8160.0), (0.0, 0.0), 0.0), ]) .replacen("/1 14.0 /53", "/1 14.0 /12 2 /53", 1) .replacen("/1 14.0 /53", "/1 14.0 /12 1 /53", 1); let spare = text_object(0) + &text_object(1) + &text_object(2); let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &spare)), &doc); let r = import(&eps(&page_square(10, 10), &art)).unwrap(); let texts = text_of(&r.document.layers[1]); assert_eq!(texts.len(), 3, "{:?}", r.warnings); let case = |i: usize| texts[i].runs.iter().map(|r| (r.style.all_caps, r.style.small_caps)).collect::>(); assert_eq!(texts[0].plain_text(), "hon. guest"); assert_eq!(case(0), [(true, None)]); assert_eq!(texts[1].plain_text(), "Book Now"); assert_eq!(case(1), [(false, Some(r.document.setup.small_caps_size))]); assert_eq!(case(2), [(false, None)]); } #[test] fn type_with_a_frame_it_cant_read_is_left_out_with_a_note() { // A story whose frame is of a kind this doesn't know isn't guessed at. let mut doc = text_document(&[("Area\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); doc = doc.replace("/2 << /2 [ 1 0 0 1 0 0 ] >>", "/2 << /0 9 /2 [ 1 0 0 1 0 0 ] >>"); let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc); let r = import(&eps(&page_square(10, 10), &art)).unwrap(); assert!(text_of(&r.document.layers[1]).is_empty()); assert!(r.warnings.iter().any(|w| w.contains("1 text objects on hidden layers")), "{:?}", r.warnings); assert!(crate::is_loss(r.warnings.iter().find(|w| w.contains("couldn't be read")).unwrap())); } #[test] fn shown_type_keeps_the_pages_and_type_off_the_page_comes_from_the_text_document() { // Story 0 is where the page draws "Hi there"; story 1 is off the page, so the page has nothing of it. let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Away\r", 0, (8300.0, 8180.0), (0.0, 0.0), 0.0)]); let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &doc); let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show"; let r = import(&eps(page, &art)).unwrap(); assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); let texts = text_of(&r.document.layers[0]); // The page's own object, and the other made from the file. let all: Vec = { let mut v = vec![]; r.document.walk(|n| { if let NodeKind::Text(t) = &n.kind { v.push(t.plain_text()); } }); v }; assert_eq!(all.len(), 2, "{all:?} {:?}", r.warnings); assert!(all.contains(&"Hi there".to_string()) && all.contains(&"Away".to_string()), "{all:?}"); assert_eq!(texts.len(), 2, "the page's type is in the layer as it is, not in a group"); assert!(!r.warnings.iter().any(|w| w.contains("order the page paints")), "{:?}", r.warnings); } #[test] fn a_text_object_written_as_comments_is_read_like_any_other() { let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); let commented: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect(); let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &commented)), &doc); let r = import(&eps(&page_square(10, 10), &art)).unwrap(); let texts = text_of(&r.document.layers[1]); assert_eq!(texts.len(), 1, "{:?}", r.warnings); assert_eq!(texts[0].plain_text(), "Hi"); } #[test] fn an_area_type_object_with_its_own_nested_art_doesnt_swallow_the_art_after_it() { let area = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n3 /StoryIndex ,\n/Art :\n1 Ap\n0 0 m\n9 0 L\n9 9 L\nn\n; /ConfiningPath ,\n2 /TextAntialiasing ,\n;\n"; let art = native(&layer("Art", true, &(square(10, 10) + "\n" + area + &square(30, 30)))); let r = import(&eps(&(page_square(10, 10) + "\n" + &page_square(30, 30)), &art)).unwrap(); let paths = r.document.layers[0].children().map_or(0, Vec::len); assert!(paths >= 2, "{paths} {:?}", r.warnings); } #[test] fn a_line_the_page_draws_in_pieces_goes_to_its_text_object_whole() { // The page sets "Hi there" as "Hi" and " there" (as when kerning changes); the story is one line. let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); let art = native_with_text(&layer("Art", true, &text_object(0)), &doc); let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi) show 70 61.5 moveto (there) show"; let r = import(&eps(page, &art)).unwrap(); // One text, with the line's space between the pieces. let art = &r.document.layers[0]; assert_eq!(kinds(art), ["text"], "{:?}", r.warnings); let texts: Vec = text_of(art).iter().map(|t| t.plain_text()).collect(); assert_eq!(texts, ["Hi there"]); // Its bounds are laid out afresh for the whole text, not those of the first piece. let NodeKind::Text(t) = &art.children().unwrap()[0].kind else { panic!("text") }; assert!(t.cached_bounds.is_none() && t.cached_baselines.is_empty()); } /// `doc` (from [`text_document`], whose stories all name the first frame) with its last story in /// its own frame, the last, of a kind this doesn't read. fn last_frame_unread(doc: &str) -> String { let frames = doc.matches("/2 << /2 [").count(); let mut doc = doc.to_string(); let at = doc.rfind("/1 << /0 [ << /0 0 >> ]").unwrap(); doc.replace_range(at..at + 23, &format!("/1 << /0 [ << /0 {} >> ]", frames - 1)); let at = doc.rfind("/2 << /2 [").unwrap() + "/2 << ".len(); doc.insert_str(at, "/0 9 "); doc } /// The plain text of every text object in `d`, in order. fn all_texts(d: &Document) -> Vec { let mut all = vec![]; d.walk(|n| { if let NodeKind::Text(t) = &n.kind { all.push(t.plain_text()); } }); all } /// Type of the page that no story places goes to the text object whose type the text document /// can't make, not into made type; with no such text object and none of the page's type in a text /// object, it is art the layers don't draw, which the comparison weighs. #[test] fn type_of_the_page_that_no_story_places_goes_to_a_text_object_that_cant_be_made() { let doc = last_frame_unread(&text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Note\r", 0, (8200.0, 8120.0), (0.0, 0.0), 0.0)])); let art = native_with_text(&layer("Art", true, &(text_object(0) + &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"], "{:?}", r.warnings); assert_eq!(all_texts(&r.document), ["Hi there", "Extra"], "{:?}", r.warnings); // Every text object's type made: the page's "Extra" is in none of them, so the file comes in as its page. let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); let r = import(&eps(page, &native_with_text(&layer("Art", true, &text_object(0)), &doc))).unwrap(); assert!(r.warnings.iter().any(|w| w.contains("layers weren't read")), "{:?}", r.warnings); } /// A story whose first frame's type is made and whose second frame's can't be: the page's type of /// the first frame stays the made type's, and isn't the second frame's too. #[test] fn page_type_of_a_made_frame_isnt_given_to_the_storys_other_frame() { let mut doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Spare\r", 0, (8200.0, 8160.0), (0.0, 0.0), 0.0)]); // Story 0 in frames 0 and 1, both point type: point type isn't threaded, so its second frame has none. let at = doc.find("/1 << /0 [ << /0 0 >> ]").unwrap(); doc.replace_range(at..at + 23, "/1 << /0 [ << /0 0 >> << /0 1 >> ]"); let art = native_with_text(&layer("Art", true, &(frame_object(0, 0) + &frame_object(0, 1))), &doc); let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show"; let r = import(&eps(page, &art)).unwrap(); assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); assert_eq!(all_texts(&r.document), ["Hi there"], "{:?}", r.warnings); } /// Page type of no text object that lies where made type draws, with its words in any case, is that /// type as the page draws it, and doesn't go to a text object whose type can't be made. Page type /// with other words there, or beside made type, does. #[test] fn type_of_the_page_where_made_type_draws_with_its_words_is_that_type() { let doc = last_frame_unread(&text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Note\r", 0, (8200.0, 8120.0), (0.0, 0.0), 0.0)])); let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &doc); let with = |drawn: &str, y: f64| { format!("0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show 58.5 {y} moveto ({drawn}) show") }; // "THERE" over the line of "Hi there", a point below it. let r = import(&eps(&with("THERE", 60.5), &art)).unwrap(); assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); assert_eq!(all_texts(&r.document), ["Hi there"], "{:?}", r.warnings); assert!(r.warnings.iter().any(|w| w.starts_with("1 text objects")), "{:?}", r.warnings); // "Extra" there, or 11.5 below that line: the text object that can't be made takes it. for y in [60.5, 50.0] { let r = import(&eps(&with("Extra", y), &art)).unwrap(); assert_eq!(all_texts(&r.document), ["Hi there", "Extra"], "{y}: {:?}", r.warnings); } } /// Page type of no text object that lies where made type is and has its letters, in any case, is that /// type as the page draws it: the made type stays, and the page's is left out. #[test] fn type_of_the_page_with_the_letters_of_made_type_where_it_lies_is_that_type() { // The page draws the line 3 points below where the story starts it, so no line of the story // owns it. let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); let art = native_with_text(&layer("Art", true, &text_object(0)), &doc); for drawn in ["Hi there", "HI THERE", "there"] { let page = format!("0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 58.5 moveto ({drawn}) show"); let r = import(&eps(&page, &art)).unwrap(); let mut all = vec![]; r.document.walk(|n| { if let NodeKind::Text(t) = &n.kind { all.push((t.plain_text(), t.xf.as_coeffs()[5])); } }); assert_eq!(all, [("Hi there".to_string(), 38.5)], "{drawn}: {:?}", r.warnings); assert!(!r.warnings.iter().any(|w| w.contains("doesn't draw") || w.contains("with another's")), "{drawn}: {:?}", r.warnings); } } /// A right-to-left paragraph's page type has its words in the order they show, not the order they /// are typed (a dimension's label " 120.5 mm " is drawn "mm 120.5"): it is still that text /// object's type as the page draws it, not a second copy of it with the nearest text object. #[test] fn type_of_the_page_with_the_words_of_made_type_in_another_order_is_that_type() { let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); let art = native_with_text(&layer("Art", true, &text_object(0)), &doc); let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 58.5 moveto (there Hi) show"; let r = import(&eps(page, &art)).unwrap(); let mut all = vec![]; r.document.walk(|n| { if let NodeKind::Text(t) = &n.kind { all.push(t.plain_text()); } }); assert_eq!(all, ["Hi there"], "{:?}", r.warnings); assert!(!r.warnings.iter().any(|w| w.contains("with another's")), "{:?}", r.warnings); } /// Page type of no text object that lies where the made type of two text objects is, with the /// letters of one of them in each of its pieces, is the type of both as the page draws it. #[test] fn type_of_the_page_with_the_letters_of_two_made_types_where_it_lies_is_their_type() { // The two stories start 16.5 apart on one line, and the page draws both 3 points below it: its // two pieces are one object, which no line of either story owns. let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("there\r", 0, (8216.5, 8180.0), (0.0, 0.0), 0.0)]); let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &doc); let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 58.5 moveto (Hi) show 75 58.5 moveto (there) show"; let r = import(&eps(page, &art)).unwrap(); let mut all = vec![]; r.document.walk(|n| { if let NodeKind::Text(t) = &n.kind { all.push((t.plain_text(), t.xf.as_coeffs()[5])); } }); assert_eq!(all, [("Hi".to_string(), 38.5), ("there".to_string(), 38.5)], "{:?}", r.warnings); assert!(!r.warnings.iter().any(|w| w.contains("doesn't draw") || w.contains("with another's")), "{:?}", r.warnings); } /// The file's text document is trusted where the page has type where it lays type out, whatever /// letters that type has: the type is the text document's, and the difference is noted as type. /// The page's type with other words is left out, with a note that is a loss. #[test] fn page_type_where_made_type_is_trusts_the_text_document_whatever_its_letters() { let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); let art = native_with_text(&layer("Art", true, &text_object(0)), &doc); let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 50 moveto (Extra) show"; let r = import(&eps(page, &art)).unwrap(); assert_eq!(all_texts(&r.document), ["Hi there"], "{:?}", r.warnings); assert!(r.warnings.iter().any(|w| w.starts_with("the type on the layers differs")), "{:?}", r.warnings); assert!(!r.warnings.iter().any(|w| w.contains("doesn't draw")), "{:?}", r.warnings); assert!(r.warnings.iter().any(|w| w.ends_with("so it is left out") && crate::is_loss(w)), "{:?}", r.warnings); // The page's type with the text document's words, in capitals: nothing is left out. let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 50 moveto (HI THERE) show"; let r = import(&eps(page, &art)).unwrap(); assert_eq!(all_texts(&r.document), ["Hi there"], "{:?}", r.warnings); assert!(!r.warnings.iter().any(|w| w.contains("left out")), "{:?}", 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); } /// The opacity and blend mode of a text object (an `Xy` before it, as a path has, or after it, as a /// group has, or on the look of one with an appearance) are its type's, made from the text document /// or taken from the page, and are applied once where the type comes with other art. #[test] fn type_keeps_the_opacity_and_blend_mode_of_its_text_object() { use vectorcraft_color::BlendMode; use vectorcraft_doc::Knockout; let states = |d: &Document| { let mut v = vec![]; d.walk(|n| { if let NodeKind::Text(_) = &n.kind { v.push((n.opacity, n.blend)); } }); v }; let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); let look = "0 Ae\nu\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\nU\n1 0.2 0 1 0 Xy\n0 0 Xd\n6 () XW\n"; let own: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect(); let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show"; for body in [ format!("1 0.2 0 0 0 Xy\n{}", text_object(0)), format!("{}1 0.2 0 0 0 Xy\n", text_object(0)), format!("{look}%_0 1 0 0 0 Xy\n{own}1 (Style) XW\n"), ] { let art = native_with_text(&layer("Art", true, &body), &doc); // Made from the text document. let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); assert_eq!(states(&d), [(0.2, BlendMode::Multiply)], "{body}{notes:?}"); // From an EPS page, which draws it without transparency. let r = import(&eps(page, &art)).unwrap(); assert_eq!(names(&r.document), ["Art"], "{body}{:?}", r.warnings); assert_eq!(states(&r.document), [(0.2, BlendMode::Multiply)], "{body}{:?}", r.warnings); } // A look with transparency that draws more than the type: the look and the type are in a group // that has the transparency once, whatever state the object is read in. let drawing = look.replace("U\n", &format!("{}\nU\n", square(10, 10))); for reset in ["%_0 1 0 0 0 Xy\n", ""] { let art = native_with_text(&layer("Art", true, &format!("{drawing}{reset}{own}1 (Style) XW\n")), &doc); let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); assert_eq!(kinds(&d.layers[0]), ["group[2]"], "{reset}{notes:?}"); let group = &d.layers[0].children().unwrap()[0]; assert_eq!((group.opacity, group.blend, group.knockout), (0.2, BlendMode::Multiply, Knockout::On), "{reset}"); assert_eq!(kinds(group), ["group[1]", "text"], "{reset}"); assert_eq!(group.children().unwrap()[0].knockout, Knockout::Neutral, "{reset}"); assert!(group.children().unwrap().iter().all(|p| (p.opacity, p.blend) == (1.0, BlendMode::Normal)), "{reset}"); } // Such a group isn't the look of a text object after it, so the groups don't nest. fn depth(n: &Node) -> usize { 1 + n.children().map_or(0, |c| c.iter().map(|c| depth(c)).max().unwrap_or(0)) } let art = native_with_text(&layer("Art", true, &(drawing.clone() + &format!("{own}1 (Style) XW\n").repeat(50))), &doc); let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); assert_eq!(depth(&d.layers[0]), 4, "{notes:?}"); // A story in a frame of a kind this doesn't know: the type that shows is the page's. An EPS page has // no transparency, so its type takes its text object's. let doc = doc.replace("/2 << /2 [ 1 0 0 1 0 0 ] >>", "/2 << /0 9 /2 [ 1 0 0 1 0 0 ] >>"); let art = native_with_text(&layer("Art", true, &format!("{}\n1 0.2 0 0 0 Xy\n{}", square(10, 10), text_object(0))), &doc); let r = import(&eps(&(page_square(10, 10) + "\n" + page), &art)).unwrap(); assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); assert_eq!(states(&r.document), [(0.2, BlendMode::Multiply)], "{:?}", r.warnings); // A PDF part's type at its text object's transparency has it once; transparency of its own that // the text object doesn't have (a fill's) stays. let pdf = |state: (f32, BlendMode)| { let mut pdf = pdf_part(10.0, 86.0); let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() }; let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(58.5, 38.5), "Hi there", style); let mut text = Node::new(pdf.alloc_id(), NodeKind::Text(Box::new(text))); (text.opacity, text.blend) = state; let layer_id = pdf.layers[0].id; pdf.insert(Some(layer_id), 1, text).unwrap(); pdf }; let (d, w, read) = crate::layered_ai(&private(&art), pdf((0.2, BlendMode::Multiply)), vec![], false, false, 1.0); assert!(read, "{w:?}"); assert_eq!(states(&d), [(0.2, BlendMode::Multiply)], "{w:?}"); let plain = native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), text_object(0))), &doc); let (d, w, read) = crate::layered_ai(&private(&plain), pdf((0.7, BlendMode::Normal)), vec![], false, false, 1.0); assert!(read, "{w:?}"); assert_eq!(states(&d), [(0.7, BlendMode::Normal)], "{w:?}"); // The same type with a look that draws more than it, which the page draws with the look's // transparency: the group of the two has it once. let art = native_with_text(&layer("Art", true, &format!("{drawing}{own}1 (Style) XW\n")), &doc); let (d, w, read) = crate::layered_ai(&private(&art), pdf((0.2, BlendMode::Multiply)), vec![], false, false, 1.0); assert!(read, "{w:?}"); assert_eq!(kinds(&d.layers[0]), ["group[2]"], "{w:?}"); let group = &d.layers[0].children().unwrap()[0]; assert_eq!((group.opacity, group.blend), (0.2, BlendMode::Multiply)); assert_eq!(states(&d), [(1.0, BlendMode::Normal)], "{w:?}"); // Page type that is a text and the path of its stroke: a group of the two takes the transparency. let page = format!( "{}\n/Helvetica findfont 12 scalefont setfont 10 50 moveto (Hi) show\n10 50 moveto (Hi) false charpath 2 setlinewidth stroke", page_square(10, 10) ); let words = layer("Words", true, "1 0.2 0 0 0 Xy\n/AI11Text :\n0 /FreeUndo ,\n;\n"); let r = import(&eps(&page, &native(&(layer("Art", true, &square(10, 10)) + &words)))).unwrap(); let words = &r.document.layers[1]; assert_eq!(kinds(words), ["group[2]"], "{:?}", r.warnings); let group = &words.children().unwrap()[0]; assert_eq!((group.opacity, group.blend), (0.2, BlendMode::Multiply)); assert_eq!(kinds(group), ["text", "path"]); assert!(group.children().unwrap().iter().all(|p| (p.opacity, p.blend) == (1.0, BlendMode::Normal))); } #[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. let doc = text_document(&[("One\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Two\r", 0, (8300.0, 8180.0), (0.0, 0.0), 0.0)]); let commented = |story| -> String { text_object(story).lines().map(|l| format!("%_{l}\n")).collect() }; let body = text_object(0) + &commented(0) + &commented(1); let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &body)), &doc); let r = import(&eps(&page_square(10, 10), &art)).unwrap(); let texts: Vec = text_of(&r.document.layers[1]).iter().map(|t| t.plain_text()).collect(); assert_eq!(texts, ["One", "Two"], "{:?}", r.warnings); } #[test] fn a_shown_text_object_on_the_page_that_the_page_doesnt_draw_is_left_out() { // Story 1 is at (58.5, 61.5) on the 100 × 100 page, where the page paints no type: the file // keeps it but the app that wrote it doesn't draw it (type turned to outlines leaves one). let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Ghost\r", 0, (8200.0, 8200.0), (0.0, 0.0), 0.0)]); let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &doc); let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show"; let r = import(&eps(page, &art)).unwrap(); let mut all = vec![]; r.document.walk(|n| { if let NodeKind::Text(t) = &n.kind { all.push(t.plain_text()); } }); assert_eq!(all, ["Hi there"], "{:?}", r.warnings); assert!(r.warnings.iter().any(|w| w.contains("doesn't draw")), "{:?}", r.warnings); } /// A text object on a layer that shows and doesn't print, which the page doesn't draw, isn't taken /// out: it comes in from the text document, on its layer. #[test] fn a_shown_text_object_on_a_layer_that_doesnt_print_stays_on_its_layer() { let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); // The layer's options with its fourth flag, printing, off. let tips = layer("Tips", true, &text_object(0)).replace("1 1 1 1 0 0 1 0 79", "1 1 1 0 0 0 1 0 79"); let art = native_with_text(&(layer("Art", true, &square(10, 10)) + &tips), &doc); let (d, w, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0); assert!(read, "{w:?}"); assert_eq!(names(&d), ["Art", "Tips"], "{w:?}"); let tips = &d.layers[1]; assert!(tips.visible && matches!(tips.kind, NodeKind::Layer { printable: false, .. })); let texts: Vec = text_of(tips).iter().map(|t| t.plain_text()).collect(); assert_eq!(texts, ["Hi there"], "{w:?}"); assert!(!w.iter().any(|w| w.contains("doesn't draw")), "{w:?}"); } #[test] fn pieces_of_two_lines_the_page_draws_alternately_go_each_to_its_own_text() { // "Hello" is drawn as "He" and "llo", with "abcd" ("ab", "cd") drawn between them. let doc = text_document(&[("Hello\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("abcd\r", 0, (8200.0, 8160.0), (0.0, 0.0), 0.0)]); let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &doc); let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (He) show 58.5 81.5 moveto (ab) show \ 74.1 81.5 moveto (cd) show 76.4 61.5 moveto (llo) show"; let r = import(&eps(page, &art)).unwrap(); let mut all = vec![]; r.document.walk(|n| { if let NodeKind::Text(t) = &n.kind { all.push(t.plain_text()); } }); all.sort(); assert_eq!(all, ["Hello", "abcd"], "{:?}", r.warnings); } #[test] fn a_run_longer_than_any_text_doesnt_overflow() { let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]).replace("/1 9 >>", "/1 1e30 >>"); let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc); let r = import(&eps(&page_square(10, 10), &art)).unwrap(); assert_eq!(names(&r.document), ["Back", "Spare"], "{:?}", r.warnings); } #[test] fn more_text_objects_than_are_matched_leave_the_page() { // Text objects whose type the text document can't make are matched with the page's type. let many = text_object(0).repeat(5001); let doc = last_frame_unread(&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), ["Back", "Spare", "Layer 1"], "{:?}", r.warnings); assert!(r.warnings.iter().any(|w| w.contains("too many text objects") && crate::is_loss(w)), "{:?}", r.warnings); } #[test] fn a_dictionary_left_open_at_the_end_of_a_layer_is_a_damaged_file() { let open = "/AI11Text :\n0 /FreeUndo ,\n"; let art = native(&(layer("A", true, &(square(10, 10) + open)) + &layer("B", true, &square(20, 20)))); let r = import(&eps(&page_square(10, 10), &art)).unwrap(); assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings); assert!(r.warnings.iter().any(|w| crate::is_loss(w)), "{:?}", r.warnings); } #[test] fn an_ai_file_without_its_pdf_part_reads_its_layers_and_makes_its_type() { // Point type from the text document, shown or not; a story it can't read is left out, with a // note that is a loss. let mut doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Area\r", 0, (8200.0, 8160.0), (0.0, 0.0), 0.0)]); // Story 1 in frame 1, a frame of a kind this doesn't know. let at = doc.rfind("/1 << /0 [ << /0 0 >> ]").unwrap(); doc.replace_range(at..at + 23, "/1 << /0 [ << /0 1 >> ]"); let at = doc.rfind("/2 << /2 [ 1 0 0 1 0 0 ] >>").unwrap(); doc.replace_range(at..at + 27, "/2 << /0 9 /2 [ 1 0 0 1 0 0 ] >>"); let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Words", true, &(text_object(0) + &text_object(1)))), &doc); let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); assert_eq!(names(&d), ["Back", "Words"], "{notes:?}"); let texts: Vec = text_of(&d.layers[1]).iter().map(|t| t.plain_text()).collect(); assert_eq!(texts, ["Hi there"], "{notes:?}"); let left = notes.iter().find(|n| n.contains("left out")).unwrap(); assert!(left.starts_with("1 ") && crate::is_loss(left), "{notes:?}"); assert!(!notes.iter().any(|n| n.contains("order the page paints")), "{notes:?}"); 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, false, 1.0); 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] fn a_global_colour_or_a_pattern_fill_keeps_the_sublayers_of_an_ai_file() { // The PDF part's yellow square, in a global yellow at full tint, on a sublayer; a hidden // sublayer with a pattern fill (the page doesn't have it). let yellow = "0 0 1 0 (Yellow global) 0 0 Xk\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf"; 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 patterned = "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n50 50 m\n70 50 L\n70 70 L\n50 70 L\nf"; let ground = layer("Ground", true, &(layer("Lawn", true, yellow) + &layer("Paving", false, patterned))); let art = native(&format!("{dots}{ground}")); let (d, warnings, _) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0); assert_eq!(names(&d), ["Ground"], "{warnings:?}"); let subs: Vec<_> = d.layers[0].children().unwrap().iter().map(|l| (l.name.clone().unwrap_or_default(), kinds(l))).collect(); assert_eq!(subs, [("Lawn".to_string(), vec!["path"]), ("Paving".to_string(), vec!["path"])]); assert!(warnings.is_empty(), "{warnings:?}"); assert!(d.swatch("Yellow global").is_some_and(|s| s.global && !s.spot)); assert!(d.pattern("Dots").is_some() && d.swatch("Dots").is_some()); // Without a PDF part, the pattern fill shows. let shown = native(&format!("{dots}{}", layer("Ground", true, &layer("Lawn", true, patterned)))); let (d, notes) = crate::ai_alone(&private(&shown)).unwrap(); let lawn = &d.layers[0].children().unwrap()[0]; assert_eq!((lawn.name.as_deref(), kinds(lawn)), (Some("Lawn"), vec!["path"]), "{notes:?}"); let fill = lawn.children().unwrap()[0].appearance.fill().map(|f| f.paint.clone()); assert!(matches!(&fill, Some(vectorcraft_color::Paint::Pattern { pattern, .. }) if pattern == "Dots"), "{fill:?}"); } /// A `.ai` whose PDF part is older than its editing copy draws less than its layers: trusted, the /// layers are used anyway, and the difference is noted. #[test] fn a_trusted_editing_copy_is_used_where_it_draws_differently_from_the_pdf_part() { 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 (d, w, _) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0); assert_ne!(names(&d), ["Back"], "untrusted, the PDF part is used: {w:?}"); let (d, w, _) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0); assert_eq!(names(&d), ["Back"], "{w:?}"); assert_eq!(d.layers[0].children().unwrap().len(), 2); assert!(w.iter().any(|w| w.contains("differs from the page's") && w.contains("all the same")), "{w:?}"); } #[test] fn an_object_the_page_doesnt_draw_makes_the_file_come_in_as_its_page() { // A 20-point square that the page doesn't draw: 4% of the page, and an object all the same. 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), ["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); } /// The page sets "Hi there" in pieces, and the layers lay the line out again as one: the page and /// the layers differ only where the type is. fn page_in_pieces_and_layers(art: &str) -> Vec { let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi) show 70 61.5 moveto (there) show"; eps(page, &native_with_text(&layer("Art", true, &format!("{art}{}", text_object(0))), &doc)) } #[test] fn type_laid_out_again_is_noted_as_type_and_not_as_art_the_layers_dont_describe() { let r = import(&page_in_pieces_and_layers("")).unwrap(); assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); assert!(r.warnings.iter().any(|w| w.starts_with("the type on the layers differs from the file's own page")), "{:?}", r.warnings); assert!(!r.warnings.iter().any(|w| w.contains("brush strokes")), "{:?}", r.warnings); } #[test] fn art_the_page_doesnt_draw_is_noted_with_its_own_share_and_not_the_types() { // A bar of 0.5 × 80 points that the page doesn't draw, too thin to be refused as an object: 0.4% of // the page, away from the type. let bar = "0 0 0 1 k\n20 10 m\n20.5 10 L\n20.5 90 L\n20 90 L\nf\n"; let r = import(&page_in_pieces_and_layers(bar)).unwrap(); assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); let notes: Vec<&String> = r.warnings.iter().filter(|w| w.contains("differs from the file's own page")).collect(); // The type, all laid out again from the text document, has a note of its own. assert_eq!(notes.len(), 2, "{:?}", r.warnings); assert!(notes[0].starts_with("the art on the layers differs from the file's own page in about 0.4% of it (brush strokes"), "{notes:?}"); assert!(notes[1].starts_with("the type on the layers differs from the file's own page"), "{notes:?}"); } /// An EPS whose page is its art's box `[x0 y0 x1 y1]` (not its artboard, 0 0 100 100), drawing /// `page`, with the editing copy of `layers`. fn eps_of_art(art_box: [u32; 4], page: &str, layers: &str) -> Vec { let [x0, y0, x1, y1] = art_box; let native = native(layers).replace( "%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100", &format!("%%BoundingBox: {x0} {y0} {x1} {y1}\n%%HiResBoundingBox: {x0} {y0} {x1} {y1}"), ); String::from_utf8(eps(page, &native)) .unwrap() .replacen("%%BoundingBox: 0 0 100 100", &format!("%%BoundingBox: {x0} {y0} {x1} {y1}"), 1) .into_bytes() } #[test] fn art_the_layers_print_outside_an_eps_page_of_its_art_makes_it_come_in_as_its_page() { // The page is the box of the art it prints, round the square at (10, 10). 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), ["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(); assert_eq!(names(&r.document), ["Art", "Far"], "{:?}", r.warnings); let not_printed = format!("%AI5_BeginLayer\n1 1 1 0 0 0 1 0 79 128 255 0 50 0 Lb\n(Notes) Ln\n{}\nLB\n%AI5_EndLayer--\n", square(80, 80)); let r = import(&file(¬_printed)).unwrap(); assert_eq!(names(&r.document), ["Art", "Notes"], "{:?}", r.warnings); assert!(r.warnings.is_empty(), "{:?}", r.warnings); } /// An EPS whose page is its art's box keeps that page when it comes in through its layers, as it /// does as its page: not the editing data's artboard (#1102). #[test] fn an_eps_page_of_its_art_stays_its_page_through_the_layers() { let file = eps_of_art([10, 10, 14, 14], &page_square(10, 10), &(layer("Art", true, &square(10, 10)) + &layer("Far", false, &square(80, 80)))); let r = import(&file).unwrap(); assert_eq!(names(&r.document), ["Art", "Far"], "{:?}", r.warnings); let boards: Vec<_> = r.document.artboards.iter().map(|a| a.rect).collect(); let art = r.document.layers[0].children().unwrap()[0].visual_bounds().unwrap(); assert_eq!(boards.len(), 1); assert!((boards[0].width() - 4.0).abs() < 1e-6 && (boards[0].height() - 4.0).abs() < 1e-6, "{boards:?}"); assert!(boards[0].inflate(1e-6, 1e-6).contains_rect(art), "{boards:?} round {art:?}"); } #[test] fn an_eps_saved_as_its_artboard_keeps_the_art_outside_it() { // The page is the artboard (0 0 100 100): the art beyond it is the pasteboard's. let art = native(&(layer("Art", true, &square(10, 10)) + &layer("Scraps", true, &square(120, 10)))); let r = import(&eps(&page_square(10, 10), &art)).unwrap(); assert_eq!(names(&r.document), ["Art", "Scraps"], "{:?}", r.warnings); assert!(r.warnings.is_empty(), "{:?}", r.warnings); } #[test] fn type_asked_for_as_outlines_leaves_a_file_whose_type_shows_as_its_pdf_part() { let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); // Type that shows: the PDF part's outlines are its type, which the text objects can't hold. let shown = native_with_text(&layer("Art", true, &(square(10, 10) + "\n" + &text_object(0))), &doc); let (d, w, _) = crate::layered_ai(&private(&shown), pdf_part(10.0, 86.0), vec![], true, false, 1.0); assert_eq!(names(&d), ["Layer 1"], "{w:?}"); assert!(w.iter().any(|w| w.contains("layers weren't read") && w.contains("outlines")), "{w:?}"); // Type only on a hidden layer: the layers come in. let hidden = native_with_text(&(layer("Art", true, &square(10, 10)) + &layer("Notes", false, &text_object(0))), &doc); let (d, w, _) = crate::layered_ai(&private(&hidden), pdf_part(10.0, 86.0), vec![], true, false, 1.0); assert_eq!(names(&d), ["Art", "Notes"], "{w:?}"); } /// A point of the art (y up) on the canvas of a file whose template box is centred at (50, 50). fn canvas(x: f64, y: f64) -> (f64, f64) { (x + 8141.5, 8241.5 - y) } /// The segments (four points each) of the rectangle `x0 y0 x1 y1` of the art, on the canvas. fn canvas_rect(x0: f64, y0: f64, x1: f64, y1: f64) -> String { let corners = [canvas(x0, y1), canvas(x0, y0), canvas(x1, y0), canvas(x1, y1), canvas(x0, y1)]; corners .windows(2) .map(|w| format!("{} {} {} {} {} {} {} {}", w[0].0, w[0].1, w[0].0, w[0].1, w[1].0, w[1].1, w[1].0, w[1].1)) .collect::>() .join(" ") } /// A story of `text` in `frames`, one paragraph (`para`: its keys) and one style (`style`). fn story(text: &str, frames: &[usize], para: &str, style: &str) -> String { let n = text.chars().count(); let frames: String = frames.iter().map(|f| format!("<< /0 {f} >> ")).collect(); format!( "<< /0 << /0 ({text}) /5 << /0 [ << /0 << /0 << /0 () /5 << {para} >> /6 0 >> >> /1 {n} >> ] >> \ /6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << {style} >> >> >> /1 {n} >> ] >> >> /1 << /0 [ {frames}] >> >>" ) } /// A text document of `frames` and `stories` (written by [`story`]). fn document_of(frames: &[String], stories: &[String]) -> String { format!( "/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ {} ] >> >>\n/1 << /1 [ {} ] /2 << /1 12.0 >> >>\n", frames.join(" "), stories.join(" ") ) } /// A text object of frame `frame` of story `story`. fn frame_object(story: u32, frame: u32) -> String { format!("/AI11Text :\n0 /FreeUndo ,\n{frame} /FrameIndex ,\n{story} /StoryIndex ,\n;\n") } #[test] fn area_type_and_type_on_a_path_come_from_the_text_document() { let area = format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 /7 18 /8 18 >> >> >>", canvas_rect(10.0, 20.0, 60.0, 40.0)); // An open path of two segments, the type from halfway along the first to the end. let (a, b, c) = (canvas(10.0, 70.0), canvas(30.0, 70.0), canvas(50.0, 70.0)); let path = format!( "<< /0 << /0 [ 0 0 ] /1 << /0 [ {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} ] >> /2 << /0 2 /6 [ 0.5 2.0 ] >> >> >>", a.0, a.1, a.0, a.1, b.0, b.1, b.0, b.1, b.0, b.1, b.0, b.1, c.0, c.1, c.0, c.1 ); let red = "/1 9.0 /8 50 /6 1.5 /53 << /99 /CAITextPaint /0 << /0 1 /1 [ 1.0 1.0 0.0 0.0 ] >> >>"; let doc = document_of( &[area, path], &[story("Area type\\rSecond\\r", &[0], "/0 2 /1 4 /2 6 /4 3", red), story("On a path\\r", &[1], "/0 0", "/1 8.0")], ); let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &(text_object(0) + &text_object(1)))), &doc); let r = import(&eps(&page_square(10, 10), &art)).unwrap(); let texts = text_of(&r.document.layers[1]); assert_eq!(texts.len(), 2, "{:?}", r.warnings); let (area, path) = (texts[0], texts[1]); assert_eq!(area.plain_text(), "Area type\nSecond"); let vectorcraft_doc::TextKind::Area { frame } = &area.kind else { panic!("area type: {:?}", area.kind) }; // Art y 20..40 is 60..80 down the 100 pt page. let b = frame.bounds().unwrap(); assert!((b.x0 - 10.0).abs() < 1e-6 && (b.y0 - 60.0).abs() < 1e-6 && (b.x1 - 60.0).abs() < 1e-6 && (b.y1 - 80.0).abs() < 1e-6, "{b:?}"); assert_eq!(area.para.justify, vectorcraft_doc::Justify::Center); assert_eq!((area.para.first_line_indent, area.para.left_indent, area.para.space_before), (4.0, 6.0, 3.0)); let style = &area.runs[0].style; assert_eq!((style.size, style.tracking, style.h_scale), (9.0, 50.0, 150.0)); assert_eq!(style.fill.color(), Some(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))); let vectorcraft_doc::TextKind::OnPath { path: p, start, end } = &path.kind else { panic!("type on a path: {:?}", path.kind) }; assert!((start - 0.25).abs() < 1e-3 && end.is_none(), "{start} {end:?}"); assert_eq!(p.bounds().unwrap().width(), 40.0); assert_eq!(path.runs[0].style.size, 8.0); assert!(!r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings); } /// A point type frame at the point (`x`, `y`) of the art. fn point_frame(x: f64, y: f64) -> String { let (cx, cy) = canvas(x, y); format!("<< /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 {cx} {cy} ] >> >> >>") } /// An area type frame of the rectangle `x0 y0 x1 y1` of the art. fn area_frame(x0: f64, y0: f64, x1: f64, y1: f64) -> String { format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(x0, y0, x1, y1)) } /// The text of `n` when it is a text object, or a group of one object whose text that is. fn lone_text(n: &Node) -> Option { match &n.kind { NodeKind::Text(t) => Some(t.plain_text()), NodeKind::Group { .. } => match n.children()?.as_slice() { [only] => lone_text(only), _ => None, }, _ => None, } } /// What each object of layer `l` holds as one text object ([`lone_text`]). fn lone_texts(l: &Node) -> Vec> { l.children().into_iter().flatten().map(|n| lone_text(n)).collect() } /// `text` as a string of the text document: UTF-16 after its byte order mark, each byte in octal. fn utf16(text: &str) -> String { let mut out = String::from("\\376\\377"); for unit in text.encode_utf16() { for b in unit.to_be_bytes() { out.push_str(&format!("\\{b:03o}")); } } out } /// Type the text document makes for every text object that shows stays in each one's own group, /// on its own layer, however the page draws it: in pieces, in another order, with a quote hung /// outside its frame. #[test] fn type_from_the_text_document_stays_in_its_groups() { let frames = [ point_frame(10.0, 90.0), point_frame(10.0, 78.0), point_frame(55.0, 90.0), area_frame(10.0, 40.0, 45.0, 55.0), area_frame(55.0, 40.0, 90.0, 55.0), ]; let style = "/1 8.0"; let doc = document_of( &frames, &[ story("Alpha\\r", &[0], "/0 0", style), story("Bravo\\r", &[1], "/0 0", style), story("Charlie\\r", &[2], "/0 0", style), story("\"Delta\"\\r", &[3], "/0 0", style), story("\"Echo\"\\r", &[4], "/0 0", style), ], ); let nested = |s: u32| format!("u\nu\n{}U\nU\n", frame_object(s, 0)); let single = |s: u32| format!("u\n{}U\n", frame_object(s, 0)); let words = layer("Words", true, &(nested(0) + &nested(1) + &single(3))); let more = layer("More", true, &(single(2) + &nested(4))); let art = native_with_text(&(words + &more), &doc); // The page paints the third story before the first, each in two pieces, and hangs the area // type's opening quotes outside their frames, painted last. let page = "0 0 0 1 setcmykcolor /Helvetica findfont 8 scalefont setfont \ 55 90 moveto (Char) show 72.3 90 moveto (lie) show 10 90 moveto (Al) show 17.1 90 moveto (pha) show \ 10 78 moveto (Bravo) show 10 48.5 moveto (Delta\") show 55 48.5 moveto (Echo\") show \ 7.2 48.5 moveto (\") show 52.2 48.5 moveto (\") show"; let r = import(&eps(page, &art)).unwrap(); assert_eq!(names(&r.document), ["Words", "More"], "{:?}", r.warnings); let some = |v: &[&str]| v.iter().map(|s| Some(s.to_string())).collect::>(); assert_eq!(lone_texts(&r.document.layers[0]), some(&["Alpha", "Bravo", "\"Delta\""]), "{:?}", r.warnings); assert_eq!(lone_texts(&r.document.layers[1]), some(&["Charlie", "\"Echo\""]), "{:?}", r.warnings); assert_eq!(all_texts(&r.document).len(), 5, "{:?}", r.warnings); } /// The page's type isn't matched with the text objects when the text document makes the type of /// every one: a file of more of them than are matched still comes in with its layers. #[test] fn six_thousand_point_stories_open_with_layers() { let stories: Vec<(String, (f64, f64))> = (0..6000).map(|i| (format!("S{i}\r"), (8150.0 + f64::from(i % 60), 8150.0 + f64::from(i / 60) * 0.8))).collect(); let list: Vec> = stories.iter().map(|(t, at)| (t.as_str(), 0, *at, (0.0, 0.0), 0.0)).collect(); let doc = text_document(&list); let objects: String = (0..6000).map(text_object).collect(); let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Words", true, &objects)), &doc); let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); assert_eq!(names(&d), ["Back", "Words"], "{notes:?}"); assert_eq!(text_of(&d.layers[1]).len(), 6000, "{notes:?}"); } /// A story in a frame of a kind this doesn't read takes the page's type for its own text object /// only: the others keep the type made from the text document, in their groups, and the page's /// type beside made type, with its letters, goes to the text object that can't be made. #[test] fn a_story_with_an_unknown_frame_kind_falls_back_for_that_slot_only() { let doc = last_frame_unread(&text_document(&[ ("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Low\r", 0, (8160.0, 8221.5), (0.0, 0.0), 0.0), ("Hi\r", 0, (8200.0, 8191.5), (0.0, 0.0), 0.0), ])); let grouped = |s: u32| format!("u\n{}U\n", text_object(s)); let art = native_with_text(&layer("Art", true, &(grouped(0) + &grouped(1) + &grouped(2))), &doc); let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show 18.5 20 moveto (Low) show \ 58.5 50 moveto (Hi) show"; let r = import(&eps(page, &art)).unwrap(); assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); let some = |v: &[&str]| v.iter().map(|s| Some(s.to_string())).collect::>(); assert_eq!(lone_texts(&r.document.layers[0]), some(&["Hi there", "Low", "Hi"]), "{:?}", r.warnings); assert!(!r.warnings.iter().any(|w| w.contains("left out")), "{:?}", r.warnings); } /// Type laid out from the text document that the page draws differently (in another font, where /// the file's isn't installed) keeps the layers, with a note; art that differs still doesn't. #[test] fn type_drawn_in_another_font_keeps_the_layers() { let lines = [("Hello\r", 8221.5), ("World\r", 8201.5), ("Again\r", 8181.5), ("Today\r", 8161.5)]; let list: Vec> = lines.iter().map(|(t, y)| (*t, 0, (8146.5, *y), (0.0, 0.0), 0.0)).collect(); let doc = text_document(&list).replace("/1 14.0", "/1 20.0"); let objects: String = (0..4).map(text_object).collect(); // The page draws each line in capitals: a font that isn't this one. let page = "0 0 0 1 setcmykcolor /Helvetica findfont 20 scalefont setfont \ 5 85 moveto (HELLO) show 5 65 moveto (WORLD) show 5 45 moveto (AGAIN) show 5 25 moveto (TODAY) show"; let r = import(&eps(page, &native_with_text(&layer("Art", true, &objects), &doc))).unwrap(); assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); assert_eq!(all_texts(&r.document), ["Hello", "World", "Again", "Today"], "{:?}", r.warnings); assert!(r.warnings.iter().any(|w| w.starts_with("the type on the layers differs from the file's own page")), "{:?}", r.warnings); // A square the page doesn't draw, away from the type: the file comes in as its page. let block = "0 0 0 1 k\n80 5 m\n90 5 L\n90 15 L\n80 15 L\nf\n"; let r = import(&eps(page, &native_with_text(&layer("Art", true, &format!("{block}{objects}")), &doc))).unwrap(); assert!(r.warnings.iter().any(|w| w.contains("layers weren't read")), "{:?}", r.warnings); } /// Type laid out from the text document where the page draws nothing is art the page lacks, even /// with more such text objects than are weighed one by one against the page. #[test] fn type_the_page_doesnt_draw_on_bare_page_still_differs() { let mut list: Vec> = vec![("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]; for i in 0..14 { list.push(("Gm\r", 0, (8146.5 + f64::from(i % 4) * 22.0, 8216.5 + f64::from(i / 4) * 18.0), (0.0, 0.0), 0.0)); } let doc = text_document(&list); let objects: String = (0..15).map(text_object).collect(); let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show"; let r = import(&eps(page, &native_with_text(&layer("Art", true, &objects), &doc))).unwrap(); assert!(r.warnings.iter().any(|w| w.contains("layers weren't read")), "{:?}", r.warnings); } /// A hidden text object whose type the text document can't make takes the page's hidden type, as /// it does when some shown text object's type can't be made either, but not the page's hidden type /// of one whose type is made. #[test] fn a_hidden_text_object_that_cant_be_made_takes_the_pages_hidden_type() { let doc = last_frame_unread(&text_document(&[ ("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Known\rMore\r", 0, (8200.0, 8200.0), (0.0, 0.0), 0.0), ("Secret\r", 0, (8200.0, 8160.0), (0.0, 0.0), 0.0), ])); let spare = layer("Spare", false, &(text_object(1) + &text_object(2))); let art = native_with_text(&(layer("Art", true, &format!("{}\n{}", square(10, 10), text_object(0))) + &spare), &doc); let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() }; let text = |d: &mut Document, s: &str, y: f64| { Node::new(d.alloc_id(), NodeKind::Text(Box::new(vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(58.5, y), s, style.clone())))) }; // The last text object's type over "Known", in words or in marks only. for secret in ["Secret", "?!"] { let mut page = pdf_part(10.0, 86.0); let shown = text(&mut page, "Hi there", 38.5); let first = page.layers[0].id; page.insert(Some(first), 1, shown).unwrap(); let spare = page.add_layer(Some("Spare")); // The two lines of "Known More" where its story puts them, and the other over them, painted // first. for (i, (s, y)) in [(secret, 60.0), ("Known", 58.5), ("More", 73.0)].into_iter().enumerate() { let n = text(&mut page, s, y); page.insert(Some(spare), i, n).unwrap(); } if let Some(l) = page.node_mut(spare) { l.visible = false; } let (d, w, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); assert!(read, "{secret}: {w:?}"); assert_eq!(names(&d), ["Art", "Spare"], "{w:?}"); let texts = |l: usize| text_of(&d.layers[l]).iter().map(|t| t.plain_text()).collect::>(); assert_eq!((texts(0), texts(1)), (vec!["Hi there".to_string()], vec!["Known\nMore".to_string(), secret.to_string()]), "{w:?}"); } } /// A small label the page draws where no text object of the layers has type differs too little to /// send the file to its page: the import says it is left out. Type the page draws of a hidden /// text object isn't counted. #[test] fn a_small_label_of_the_page_in_no_text_object_is_noted() { 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); assert!(!r.warnings.iter().any(|w| w.contains("so it is left out")), "{:?}", r.warnings); let label = format!("{page} /Helvetica findfont 3 scalefont setfont 10 90 moveto (no.) show"); let r = import(&eps(&label, &art)).unwrap(); assert_eq!(names(&r.document), ["Art", "Spare"], "{:?}", r.warnings); let note = r.warnings.iter().find(|w| *w == "some of the page's type has no text object in the layers, so it is left out"); assert!(note.is_some_and(|w| crate::is_loss(w)), "{:?}", r.warnings); } /// A line break inside a paragraph (`\x03` in the text document) stays a line break in it. #[test] fn a_soft_return_is_a_line_break_in_its_paragraph() { let doc = text_document(&[("One\\003Two\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Words", true, &text_object(0))), &doc); let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); let texts = text_of(&d.layers[1]); assert_eq!(texts.len(), 1, "{notes:?}"); assert_eq!(texts[0].plain_text(), "One\u{2028}Two"); assert!(texts[0].paras.is_empty(), "one paragraph: {:?}", texts[0].paras); } /// A Thai story is one text object however the page splits its type: by word, and its marks /// above apart from their letters, above the top of its frame. #[test] fn a_thai_story_stays_one_text_object() { let thai = "\u{0E2A}\u{0E38}\u{0E02}\u{0E2A}\u{0E31}\u{0E19}\u{0E15}\u{0E4C} \u{0E27}\u{0E31}\u{0E19}\u{0E40}\u{0E01}\u{0E34}\u{0E14}"; let doc = document_of(&[area_frame(10.0, 30.0, 90.0, 60.0)], &[story(&utf16(&format!("{thai}\r")), &[0], "/0 0", "/1 14.0")]); let art = native_with_text(&layer("Art", true, &format!("{}\nu\n{}U\n", square(10, 10), frame_object(0, 0))), &doc); let mut page = pdf_part(10.0, 86.0); let layer_id = page.layers[0].id; let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() }; // Doc y 40 is the frame's top: the words on its first line, the marks a little above it. let pieces = [ ("\u{0E2A}\u{0E38}\u{0E02}\u{0E2A}\u{0E19}\u{0E15}", 10.0, 53.0), ("\u{0E27}\u{0E19}\u{0E40}\u{0E01}\u{0E14}", 50.0, 53.0), ("\u{0E31}\u{0E4C}", 24.0, 41.0), ("\u{0E31}\u{0E34}", 58.0, 41.0), ]; for (i, (text, x, y)) in pieces.iter().enumerate() { let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(*x, *y), text, style.clone()); let n = Node::new(page.alloc_id(), NodeKind::Text(Box::new(t))); page.insert(Some(layer_id), 1 + i, n).unwrap(); } let (d, w, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); assert!(read, "{w:?}"); assert_eq!(names(&d), ["Art"], "{w:?}"); assert_eq!(all_texts(&d), [thai], "{w:?}"); assert_eq!(lone_texts(&d.layers[0]).last(), Some(&Some(thai.to_string())), "{w:?}"); } /// A story of `text` on frame 0 whose layout sets glyphs `advance` wide round the outside of a /// circle of radius `radius`, the circle's length between their centres `fraction` of an advance. fn round_story(text: &str, advance: f64, radius: f64, fraction: f64) -> String { let n = text.chars().count(); let (mut xs, mut ys, mut matrices, mut ends) = (vec![], vec![], vec![], vec![]); for i in 0..=n { // From the circle's left, clockwise on the canvas (y down), feet towards its centre. let angle = std::f64::consts::PI + i as f64 * advance * fraction / radius; let along = (-angle.sin(), angle.cos()); xs.push(radius * angle.cos() - along.0 * advance * 0.5); ys.push(radius * angle.sin() - along.1 * advance * 0.5); matrices.push(format!("{} {} {} {} 0 0", along.0, along.1, -along.1, along.0)); ends.push(i as f64 * advance); } let nums = |v: &[f64]| v.iter().map(|x| format!("{x}")).collect::>().join(" "); format!( "<< /0 << /0 ({text}) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 0 >> /6 0 >> >> /1 {n} >> ] >> \ /6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /1 12.0 >> >> >> /1 {n} >> ] >> >> /1 << /0 [ << /0 0 >> ] \ /2 [ << /99 /PC /6 [ << /99 /F /6 [ << /99 /L /6 [ << /99 /S /6 [ << /99 /G /21 << /1 [ {} ] /2 [ {} ] /3 [ {} ] /4 [ {} ] >> >> ] >> ] >> ] >> ] >> ] >> >>", nums(&xs), nums(&ys), matrices.join(" "), nums(&ends) ) } /// The file closes type up round the outside of a curve (its layout puts the glyphs' centres /// closer than their advances), though the frame has no spacing of its own: the type comes in /// spaced as the layout is, not at its advances, so it doesn't run on past where the page ends it. #[test] fn type_round_a_circle_is_spaced_as_the_file_lays_it_out() { // A circle of radius 40 round the middle of the page, in four cubics from its right, clockwise // on the page; the type from its left, over the top, round almost all of it. let k = 40.0 * 0.552_284_75; let points = [ (90.0, 50.0), (90.0, 50.0 - k), (50.0 + k, 10.0), (50.0, 10.0), (50.0 - k, 10.0), (10.0, 50.0 - k), (10.0, 50.0), (10.0, 50.0 + k), (50.0 - k, 90.0), (50.0, 90.0), (50.0 + k, 90.0), (90.0, 50.0 + k), (90.0, 50.0), ]; let all: Vec<(f64, f64)> = points.iter().map(|(x, y)| canvas(*x, *y)).collect(); let segment = |i: usize| (0..4).map(|j| format!("{} {}", all[i * 3 + j].0, all[i * 3 + j].1)).collect::>().join(" "); let frame = format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 2 /6 [ 2.0 5.99 ] >> >> >>", (0..4).map(segment).collect::>().join(" ")); // Glyphs 5 apart, their centres 5 × 40/48 apart round the circle: spaced 8 above the path. let doc = document_of(&[frame], &[round_story("abcdefgh", 5.0, 40.0, 40.0 / 48.0)]); let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc); let r = import(&eps(&page_square(10, 10), &art)).unwrap(); let texts = text_of(&r.document.layers[1]); assert_eq!(texts.len(), 1, "{:?}", r.warnings); let t = texts[0]; assert!(matches!(t.kind, vectorcraft_doc::TextKind::OnPath { .. }), "{:?}", t.kind); assert!((t.path_spacing - 8.0).abs() < 0.05, "{}", t.path_spacing); // Laid out, the glyphs' centres are 40/48 of their advances apart round the circle. let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); let centres: Vec<(f64, f64)> = layout .glyphs .iter() .map(|g| (g.origin.x + g.angle.cos() * g.advance * 0.5 - 50.0, g.origin.y + g.angle.sin() * g.advance * 0.5 - 50.0, g.advance)) .map(|(x, y, a)| (y.atan2(x), a)) .collect(); assert_eq!(centres.len(), 8); for w in centres.windows(2) { let turn = (w[1].0 - w[0].0 + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; let ratio = turn.abs() * 40.0 / ((w[0].1 + w[1].1) * 0.5); assert!((ratio - 40.0 / 48.0).abs() < 0.01, "{ratio}"); } } #[test] fn threaded_area_type_flows_through_its_frames() { let frames = [ format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(10.0, 60.0, 40.0, 80.0)), format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(50.0, 60.0, 80.0, 80.0)), ]; let doc = document_of(&frames, &[story("A story in two frames\\r", &[0, 1], "/0 0", "/1 10.0")]); let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &(frame_object(0, 0) + &frame_object(0, 1)))), &doc); let r = import(&eps(&page_square(10, 10), &art)).unwrap(); let texts = text_of(&r.document.layers[1]); assert_eq!(texts.len(), 2, "{:?}", r.warnings); assert_eq!(texts[0].plain_text(), "A story in two frames"); assert_eq!(texts[1].plain_text(), ""); let ids: Vec<_> = r.document.layers[1].children().unwrap().iter().map(|n| n.id).collect(); assert_eq!(r.document.text_threads, [ids]); } #[test] fn shown_type_the_page_draws_in_pieces_comes_whole_from_the_text_document() { // Type on a path the page draws letter by letter: the letters lie on the path, and only the // text document's type object comes in. let (a, b) = (canvas(10.0, 50.0), canvas(90.0, 50.0)); let path = format!( "<< /0 << /0 [ 0 0 ] /1 << /0 [ {} {} {} {} {} {} {} {} ] >> /2 << /0 2 /6 [ 0 0.99 ] >> >> >>", a.0, a.1, a.0, a.1, b.0, b.1, b.0, b.1 ); let doc = document_of(&[path], &[story("Letters\\r", &[0], "/0 0", "/1 12.0")]); let art = native_with_text(&layer("Art", true, &text_object(0)), &doc); let letters: String = "Letters".chars().enumerate().map(|(i, c)| format!("{} 50 moveto ({c}) show ", 12 + 7 * i)).collect(); let page = format!("0 0 0 1 setcmykcolor /Helvetica findfont 12 scalefont setfont {letters}"); let r = import(&eps(&page, &art)).unwrap(); let mut all = vec![]; r.document.walk(|n| { if let NodeKind::Text(t) = &n.kind { all.push(t.plain_text()); } }); assert_eq!(all, ["Letters"], "{:?}", r.warnings); assert!(!r.warnings.iter().any(|w| w.contains("page's type")), "{:?}", r.warnings); } #[test] fn a_copied_area_type_object_is_framed_where_its_own_art_path_is() { // The text document's frame is the original's (art 10..60 × 20..40); this copy's own `/Art` // path puts it at 30..80 × 50..70. let area = format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(10.0, 20.0, 60.0, 40.0)); let doc = document_of(&[area], &[story("Copied\r", &[0], "/0 0", "/1 8.0")]); let copy = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n/Art :\nX=\n30 50 m\n80 50 L\n80 70 L\n30 70 L\nN\n;\n/ConfiningPath ,\n2 /TextAntialiasing ,\n;\n"; let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, copy)), &doc); let r = import(&eps(&page_square(10, 10), &art)).unwrap(); let texts = text_of(&r.document.layers[1]); assert_eq!(texts.len(), 1, "{:?}", r.warnings); let vectorcraft_doc::TextKind::Area { frame } = &texts[0].kind else { panic!("area type: {:?}", texts[0].kind) }; // Art y 50..70 is 30..50 down the 100 pt page. let b = frame.bounds().unwrap(); assert!((b.x0 - 30.0).abs() < 1e-6 && (b.y0 - 30.0).abs() < 1e-6 && (b.x1 - 80.0).abs() < 1e-6 && (b.y1 - 50.0).abs() < 1e-6, "{b:?}"); } /// A raster print page can carry a fully vector editing copy. The legacy mesh has no /// opacity operand and its vector mask hides half of it: ignoring either loses this import. #[test] fn raster_print_page_recovers_legacy_mesh_vector_mask_and_editable_curve() { let mesh = "/Mesh X!\n%_/DeviceRGB /CS X#\n\ %_[0 0 1 20 20 20 30 1 30 20 1] /N X#\n\ %_[0 0 1 20 60 30 60 1 20 50 1] /N X#\n\ %_[0 0 1 60 60 60 50 1 50 60 1] /N X#\n\ %_[0 0 1 60 20 50 20 1 60 30 1] /N X#\n%_/E X#\n/End X!\n"; let mask = "%_/ArtDictionary :\n%_/Mask :\n%_X=\n%_1 g\n%_20 20 m\n%_20 60 L\n%_40 60 L\n%_40 20 L\n%_f\n%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n%_;\n"; let curve = "0 1 1 0 1 0 0 Xa\n70 20 m\n70 50 90 50 90 20 c\nf\n"; let data = native(&layer("Editable", true, &format!("u\n{mesh}{mask}{curve}U\n"))); let samples = ("0000ff".repeat(20) + &"ffffff".repeat(20)).repeat(40); let page = format!( "gsave 20 20 translate 40 40 scale /DeviceRGB setcolorspace \ << /ImageType 1 /Width 40 /Height 40 /BitsPerComponent 8 /Decode [0 1 0 1 0 1] \ /ImageMatrix [40 0 0 -40 0 40] /DataSource <{samples}> >> image grestore \ 1 0 0 setrgbcolor 70 20 moveto 70 50 90 50 90 20 curveto closepath fill" ); // Use AI9's zlib container, too, rather than only the newer Zstandard test helper. use std::io::Write as _; let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); encoder.write_all(data.as_bytes()).unwrap(); let packed = ascii85(&encoder.finish().unwrap()); let file = format!( "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{page}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI9_DataStream\n%{packed}\n%AI9_PrivateDataEnd\n" ); let r = import(file.as_bytes()).unwrap(); assert!(r.warnings.is_empty(), "{:?}", r.warnings); assert_eq!(names(&r.document), ["Editable"]); let group = &r.document.layers[0].children().unwrap()[0]; let a = group.children().unwrap(); assert_eq!(a.len(), 2); assert!(matches!(a[0].kind, NodeKind::Mesh(_))); assert!(a[0].extras().mask.is_some()); let NodeKind::Path { path, .. } = &a[1].kind else { panic!("editable path") }; assert_eq!(path.anchor_count(), 2); assert_ne!(path.subpaths[0].anchors[0].h_out, path.subpaths[0].anchors[0].p); assert_ne!(a[0].id, a[1].id); // The normal EPS path remains available explicitly. let page = crate::import_with(file.as_bytes(), false).unwrap(); assert!(matches!(page.document.layers[0].children().unwrap()[0].kind, NodeKind::Image(_))); // A wrong editing copy must still fail the page comparison. let wrong = file.replace("%%BoundingBox: 0 0 100 100", "%%BoundingBox: 0 0 120 100"); let r = import(wrong.as_bytes()).unwrap(); assert!(r.warnings.iter().any(|w| w.contains("for a 100 × 100 pt page, not the file's 120 × 100 pt page")), "{:?}", r.warnings); } #[test] fn envelope_options_keep_the_source_text_hidden_under_its_outlined_appearance() { let doc = text_document(&[("CHONKYCATS\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); let slot: String = text_object(0).lines().map(|line| format!("%_{line}\n")).collect(); let envelope = format!("%_/ArtDictionary :\n%_1 /Int (AIDeformOptionsKey) ,\n%_198 /Real (AIDeformArtHeightKey) ,\n%_X=\n%_0 Ae\n%_u\n{slot}%_U\n%_;\n"); let art = native_with_text(&layer("Art", true, &format!("{}\n{envelope}", square(10, 10))), &doc); let r = import(&eps(&page_square(10, 10), &art)).unwrap(); assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); assert_eq!(kinds(&r.document.layers[0]), ["path", "text"], "{:?}", r.warnings); let mut texts = vec![]; r.document.walk(|n| { if let NodeKind::Text(t) = &n.kind { texts.push((t.plain_text(), n.visible)); } }); assert_eq!(texts, [("CHONKYCATS".to_string(), false)], "{:?}", r.warnings); assert!(r.warnings.is_empty(), "{:?}", r.warnings); } /// An art style section with style `Comic`: a white fill offset and repeated by Transform under a /// red fill with its own blend mode, the characters at the bottom. fn comic_style() -> String { let basic = |name: &str, body: &str| { format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n") }; let fill = |paint: &str| basic("Fill Style Filter", &format!("/FillStyle : 0 O\n{paint}\n0 1 0 0 0 Xy\n0 1 Xd\n/Def ;\n (FillStyle) ,\n")); let compound = |name: &str, kids: &[String]| format!("/CompoundFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n{};\n /Part ,\n", kids.concat()); let rim = compound( "Chain Style Filter", &[ basic("Adobe Offset Path", "3 /Real (ofst) ,\n0 /Int (jntp) ,\n"), fill("0 0 0 0 1 1 1 Xa"), basic("Adobe Transform", "6 /Int (numCopies) ,\n1 /Real (moveH_Pts) ,\n"), ], ); let red = compound( "Chain Style Filter", &[fill("0 1 1 0 1 0 0 Xa"), basic("Blend Style Filter", "/BlendStyle : 1 0.5 0 0 0 Xy\n/Def ;\n (BlendStyle) ,\n")], ); let stack = compound("Stack Style Filter", &[red, rim, basic("Conduit Filter", "")]); format!("%AI9_BeginArtStyles\n/KnownStyle :\n(Comic) /Name ,\n/ActiveStyle :\n{}%AI9_EndArtStyles\n", compound("Chain Style Filter", &[stack])) } #[test] fn type_with_a_stacked_art_style_comes_in_as_type_with_that_appearance() { // GUARDS: a style of more than one fill (or with effects on a fill) left the type as its drawn look, // outlines that can't be retyped (comic lettering made with Illustrator's Appearance panel). use vectorcraft_color::BlendMode; use vectorcraft_doc::AppearanceItem; let doc = text_document(&[("Bang\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); // The look: the style's drawing round where the type lies (its anchor is at (58.5, 61.5)). let drawing = "0 0 1 0 k\n54 56 m\n100 56 L\n100 80 L\n54 80 L\nf"; let look = format!("u\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n{drawing}\nU\n9 () XW\n"); let own: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect(); let art = native_with_text(&(comic_style() + &layer("Art", true, &format!("{look}{own}1 (Comic) XW\n"))), &doc); let check = |d: &Document, notes: &[String]| { let mut texts = vec![]; d.walk(|n| { if let NodeKind::Text(t) = &n.kind { texts.push((t.plain_text(), n.appearance.clone())); } }); let [(text, a)] = texts.as_slice() else { panic!("{texts:?} {notes:?}") }; assert_eq!(text, "Bang"); // Bottom first: the white rim with its effects, then the red fill at half opacity, multiplied. let [AppearanceItem::Fill(rim), AppearanceItem::Fill(red)] = a.items.as_slice() else { panic!("{a:?}") }; assert_eq!(rim.effects.iter().map(|e| e.id.as_str()).collect::>(), ["path.offsetPath", "distort.transform"]); assert_eq!((red.blend, red.opacity), (BlendMode::Multiply, 0.5)); assert!(!notes.iter().any(|w| w.contains("drawn look")), "{notes:?}"); }; let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); check(&d, ¬es); // From an EPS whose page draws Illustrator's rendering of the style (here, its look's rectangle): // the layers are used all the same, the style's area not being held to look like the page. let page = "0 0 1 0 setcmykcolor 54 56 moveto 100 56 lineto 100 80 lineto 54 80 lineto closepath fill"; let r = import(&eps(page, &art)).unwrap(); assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); check(&r.document, &r.warnings); } #[test] fn point_type_without_a_matrix_is_placed_by_its_layout() { // GUARDS: Illustrator 24 leaves an unturned, unscaled point type's matrix out of its frame; such // stories weren't read, and the type was left out. let with = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); let without = with.replace("/2 << /2 [ 1 0 0 1 0 0 ] >>", "/2 << >>"); assert_ne!(with, without); let place = |doc: &str| { let art = native_with_text(&layer("Spare", false, &text_object(0)), doc); let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); let texts = text_of(&d.layers[0]); assert_eq!(texts.len(), 1, "{notes:?}"); (texts[0].plain_text(), texts[0].xf) }; assert_eq!(place(&without), place(&with)); } /// A placed file is put where its matrix takes the canvas point of art space's origin (the /// centre of `%AI3_TemplateBox` on the canvas); many files put it at the corner of the box it is /// written with instead. When the layers don't draw as the page with the first, the second is /// tried. #[test] fn a_placed_file_at_its_boxs_corner_matches_the_page() { // A 2 × 2 preview of half-transparent black (premultiplied ARGB), of a file whose box is // 8000 8000 8040 8040 on the canvas: 40 × 40 units, which the matrix takes to (30, 70)–(70, // 30) in art space, a square at (30, 30)–(70, 70) on the 100 × 100 page. The template box's // canvas point (8091.5, 8241.5) would put it off the page. let pixel = "\x7f\0\0\0"; let place = format!( "%AI5_BeginPlace\n%%FileType: 1\n[1 0 0 -1 -7970 8070] 8000 8000 8040 8040 4 4 0\n(photo.png)`\n\ %AI26_BeginPlacedObjectPreview\n2 2 8 32 17 {}\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n", pixel.repeat(4) ); let data = native(&layer("Jig", true, &place)).replace("%AI5_NumLayers", "%AI3_TemplateBox: 100 50 100 50\n%AI5_NumLayers"); // The page: the square in 50% grey, as the PDF part draws the image. let mut page = Document::new(100.0, 100.0); let id = page.alloc_id(); let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 30.0, 70.0, 70.0)); let grey = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::gray(0.5))); let n = Node::path(id, path, vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(grey)], ..Default::default() }); let l = page.layers[0].id; page.insert(Some(l), 0, n).unwrap(); let (d, w, read) = crate::layered_ai(&private(&data), page, vec![], false, false, 1.0); assert!(read, "{w:?}"); assert_eq!(names(&d), ["Jig"]); let image = &d.layers[0].children().unwrap()[0]; assert!(matches!(image.kind, NodeKind::Image(_)), "{:?}", image.kind); let b = image.geometric_bounds().unwrap(); assert!([b.x0 - 30.0, b.y0 - 30.0, b.x1 - 70.0, b.y1 - 70.0].iter().all(|v| v.abs() < 0.01), "on the page's square: {b:?}"); } #[test] fn a_small_placed_file_goes_where_it_draws_closer_to_the_page() { // A 2 × 2 placed file (a small logo on a large page) whose box corner the matrix takes to // (30, 30)–(32, 32), as the page draws it. The template box's canvas point puts it at // (10, 10)–(12, 12): on the page too, and small enough that the layers pass the comparison // there all the same. let place = format!( "%AI5_BeginPlace\n%%FileType: 1\n[1 0 0 -1 -7970 8070] 8000 8000 8002 8002 4 4 0\n(logo.png)`\n\ %AI26_BeginPlacedObjectPreview\n2 2 8 32 17 {}\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n", "\x7f\0\0\0".repeat(4) ); let data = native(&layer("Jig", true, &place)).replace("%AI5_NumLayers", "%AI3_TemplateBox: 211.5 -211.5 211.5 -211.5\n%AI5_NumLayers"); let mut page = Document::new(100.0, 100.0); let id = page.alloc_id(); let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 30.0, 32.0, 32.0)); let grey = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::gray(0.5))); let n = Node::path(id, path, vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(grey)], ..Default::default() }); let l = page.layers[0].id; page.insert(Some(l), 0, n).unwrap(); let (d, w, read) = crate::layered_ai(&private(&data), page, vec![], false, false, 1.0); assert!(read, "{w:?}"); let b = d.layers[0].children().unwrap()[0].geometric_bounds().unwrap(); assert!([b.x0 - 30.0, b.y0 - 30.0, b.x1 - 32.0, b.y1 - 32.0].iter().all(|v| v.abs() < 0.01), "on the page's square: {b:?}"); } #[test] fn text_a_placed_file_covers_only_at_its_first_placement_is_kept() { const OPAQUE: &str = ""; // An opaque black 2 × 2 preview of a file whose box corner the matrix takes to (30, 30)–(70, // 70), as the page draws it. The template box's canvas point puts it 10 to the left instead, // over the 8 pt text object "Hi" at (21, 48): there the text can't be seen, and the page has // no type for it (it draws it as outlines), so the text looks like type the app doesn't draw. // The layers then differ from the page by little more than `compare` allows, so that is // weighed. With the file at its box's corner the text shows, as the page has it. let place = format!( "%AI5_BeginPlace\n%%FileType: 1\n[1 0 0 -1 -7970 8070] 8000 8000 8040 8040 4 4 0\n(photo.png)`\n\ %AI26_BeginPlacedObjectPreview\n2 2 8 32 17 {}\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n", OPAQUE.repeat(4) ); // Canvas centre 8191.5 is the template box's centre (201.5, -191.5): the anchor (8011, 7952) is // (21, 48) in art space. let document = text_document(&[("Hi\r", 0, (8011.0, 7952.0), (0.0, 0.0), 0.0)]).replace("/1 14.0", "/1 8.0"); let data = native_with_text(&layer("Art", true, &(text_object(0) + &place)), &document) .replace("%AI3_TemplateBox: 50 50 50 50", "%AI3_TemplateBox: 201.5 -191.5 201.5 -191.5"); // The page (y down): the image's black square, and the type's outlines, a magenta box where // its glyphs are. let mut page = Document::new(100.0, 100.0); let l = page.layers[0].id; for (rect, color) in [ (vectorcraft_geom::Rect::new(30.0, 30.0, 70.0, 70.0), vectorcraft_color::Color::BLACK), (vectorcraft_geom::Rect::new(21.5, 46.5, 28.0, 52.0), vectorcraft_color::Color::Cmyk { c: 0.0, m: 1.0, y: 0.0, k: 0.0 }), ] { let id = page.alloc_id(); let fill = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(color)); let n = Node::path( id, vectorcraft_geom::shapes::rectangle(rect), vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(fill)], ..Default::default() }, ); page.insert(Some(l), 0, n).unwrap(); } // The preview's pixels are opaque black (ARGB `ff 00 00 00`), bytes a `str` can't hold. let bytes = data.replace(OPAQUE, "\u{1}").into_bytes().into_iter().flat_map(|b| if b == 1 { vec![0xff, 0, 0, 0] } else { vec![b] }).collect::>(); let private = [b"%AI24_ZStandard_Data".as_slice(), &ruzstd::encoding::compress_to_vec(&bytes[..], ruzstd::encoding::CompressionLevel::Fastest)].concat(); let (d, w, read) = crate::layered_ai(&private, page, vec![], false, false, 1.0); assert!(read, "{w:?}"); let art = d.layers[0].children().unwrap(); let image = art.iter().find(|n| matches!(n.kind, NodeKind::Image(_))).expect("the image"); let b = image.geometric_bounds().unwrap(); assert!([b.x0 - 30.0, b.y0 - 30.0, b.x1 - 70.0, b.y1 - 70.0].iter().all(|v| v.abs() < 0.01), "on the page's square: {b:?}"); assert_eq!(text_of(&d.layers[0]).iter().map(|t| t.plain_text()).collect::>(), ["Hi"], "{w:?}"); assert!(!w.iter().any(|w| w.contains("doesn't draw")), "{w:?}"); }