//! Import fidelity: soft masks → opacity masks, isolated and knockout groups, tiling patterns → //! pattern swatches, text → point type, mesh shadings → gradient meshes, gradient stop opacity, //! shading Extend flags and shapes filled with a shading → gradient-filled paths; strokes stay //! live strokes. use std::sync::Arc; use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Knockout, LineCap, LineJoin, Node, NodeId, NodeKind, OpacityMask}; use vectorcraft_geom::{Rect, shapes}; use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with}; use crate::*; /// A stream object with dictionary entries `dict`. pub(crate) fn stream(dict: &str, data: &str) -> String { format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()) } /// A 100 × 100 pt page drawing `content` with `resources`, plus `extra` objects. fn one_page(content: &str, resources: &str, extra: &[&str]) -> Vec { let page = PdfPage { resources: resources.into(), ..PdfPage::new(100.0, 100.0, content) }; pdf_with(&[page], extra, None) } fn open(bytes: &[u8]) -> Document { import(bytes).unwrap() } fn open_with(bytes: &[u8], f: impl FnOnce(&mut ImportOptions)) -> ImportReport { let mut o = ImportOptions::default(); f(&mut o); import_with_report(bytes, &o).unwrap() } /// Every node of the document, depth first. fn all(d: &Document) -> Vec { let mut out = vec![]; d.walk(|n| out.push(n.clone())); out } fn rect(r: Rect, c: Color) -> Node { Node::path(NodeId(0), shapes::rectangle(r), Appearance::basic(Paint::solid(c), Paint::None, 0.0)) } fn add(d: &mut Document, mut n: Node) { n.id = d.alloc_id(); let l = d.layers[0].id; d.insert(Some(l), usize::MAX, n).unwrap(); } fn roundtrip(d: &Document) -> Document { open(&export(d, &PdfOptions::default()).unwrap()) } /// The fill colour of a node's first fill. fn fill_color(n: &Node) -> Option { n.appearance.fill().and_then(|f| f.paint.color()) } fn near(a: Rect, b: Rect) -> bool { [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|v| v.abs() < 0.6) } // ---------- soft masks ---------- /// A page filling a red square through soft mask `smask` (its group, object 5, draws `mask`). fn masked(smask: &str, mask: &str) -> Vec { let g = first_extra(1); let group = stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB >>", mask); one_page("/GS0 gs 1 0 0 rg 0 0 100 100 re f", &format!("/ExtGState << /GS0 << /SMask << /Type /Mask {smask} /G {g} 0 R >> >> >>"), &[&group]) } fn the_mask(d: &Document) -> (Node, OpacityMask) { let n = all(d).into_iter().find(|n| n.extras().mask.is_some()).expect("a masked object"); let m = n.extras().mask.as_deref().unwrap().clone(); (n, m) } /// The two pixels of the image a soft mask of kind `s` draws: black, opaque on the left and clear /// on the right (its own `/SMask`). fn mask_image_pixels(s: &str) -> [[u8; 4]; 2] { let g = first_extra(1); let group = stream( &format!( "/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB >> /Resources << /XObject << /Im0 {} 0 R >> >>", g + 1 ), "q 100 0 0 100 0 0 cm /Im0 Do Q", ); let image = stream( &format!( "/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /SMask {} 0 R", g + 2 ), "000000000000>", ); let smask = stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode", "FF00>"); let bytes = one_page( "/GS0 gs 1 0 0 rg 0 0 100 100 re f", &format!("/ExtGState << /GS0 << /SMask << /Type /Mask /S /{s} /G {g} 0 R >> >> >>"), &[&group, &image, &smask], ); let d = open(&bytes); let (_, m) = the_mask(&d); let mut keys = vec![]; m.art.walk(&mut |n| { if let NodeKind::Image(im) = &n.kind { keys.push(im.key.clone()); } }); let [key] = keys.as_slice() else { panic!("one image in the mask art: {keys:?}") }; let px = image::load_from_memory(&d.images[key].bytes).unwrap().to_rgba8(); [px.get_pixel(0, 0).0, px.get_pixel(1, 0).0] } /// An alpha soft mask whose art is an image with its own alpha (some PDF writers rasterize masks /// so): the image is read white with that alpha, so the mask's luminance is the alpha, not the /// image's colour (ISO 32000-1 11.5.2: an alpha mask takes the group's alpha, not its colour). #[test] fn an_alpha_soft_mask_of_an_image_uses_the_images_alpha() { assert_eq!(mask_image_pixels("Alpha"), [[255, 255, 255, 255], [255, 255, 255, 0]]); } /// A luminosity soft mask takes the group's colour (ISO 32000-1 11.5.3): its image stays black. #[test] fn a_luminosity_soft_mask_of_an_image_keeps_the_images_colour() { assert_eq!(mask_image_pixels("Luminosity"), [[0, 0, 0, 255], [0, 0, 0, 0]]); } #[test] fn a_luminosity_soft_mask_becomes_an_opacity_mask() { let d = open(&masked("/S /Luminosity", "1 1 1 rg 0 0 50 100 re f")); let (n, m) = the_mask(&d); assert!(n.path_data().is_some(), "the mask is on the square itself"); assert!(m.clip && !m.invert); assert_eq!(fill_color(&m.art).map(|c| c.to_rgb()), Some([1.0, 1.0, 1.0])); assert!(near(m.art.geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 50.0, 100.0)), "{:?}", m.art.geometric_bounds()); // A white backdrop: outside the art stays visible (no clip); an inverting transfer function inverts. let (_, m) = the_mask(&open(&masked("/S /Luminosity /BC [1 1 1]", "0 0 0 rg 0 0 50 100 re f"))); assert!(!m.clip); let tr = "/TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>"; let (_, m) = the_mask(&open(&masked(&format!("/S /Luminosity {tr}"), "1 1 1 rg 0 0 50 100 re f"))); assert!(m.invert && m.clip); } #[test] fn an_alpha_soft_mask_becomes_an_opacity_mask_of_its_shape() { // The art is painted white with its opacity kept: its luminance is its alpha. let bytes = { let g = first_extra(1); let group = stream( "/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB >> /Resources << /ExtGState << /GA << /ca 0.5 >> >> >>", "/GA gs 0 0 1 rg 0 0 50 100 re f", ); one_page( "/GS0 gs 1 0 0 rg 0 0 100 100 re f", &format!("/ExtGState << /GS0 << /SMask << /Type /Mask /S /Alpha /G {g} 0 R >> >> >>"), &[&group], ) }; let (_, m) = the_mask(&open(&bytes)); assert!(m.clip && !m.invert); let f = m.art.appearance.fill().unwrap(); assert_eq!(f.paint.color().map(|c| c.to_rgb()), Some([1.0, 1.0, 1.0])); assert!((f.opacity * m.art.opacity - 0.5).abs() < 0.01); } #[test] fn opacity_masks_round_trip_through_pdf() { for (clip, invert) in [(true, false), (false, false), (true, true), (false, true)] { let mut d = Document::new(100.0, 100.0); let mut n = rect(Rect::new(10.0, 10.0, 90.0, 90.0), Color::rgb(1.0, 0.0, 0.0)); let art = rect(Rect::new(10.0, 10.0, 50.0, 90.0), Color::gray(0.25)); n.extras_mut().mask = Some(Box::new(OpacityMask { invert, ..OpacityMask::new(art, clip) })); add(&mut d, n); let back = roundtrip(&d); let (n, m) = the_mask(&back); assert!(near(n.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 90.0))); assert_eq!((m.clip, m.invert), (clip, invert), "clip {clip}, invert {invert}"); assert!(near(m.art.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 50.0, 90.0)), "{:?}", m.art.geometric_bounds()); let lum = crate::import_mask::luminance(&fill_color(&m.art).unwrap()); assert!((lum - 0.75).abs() < 0.02, "{lum}"); } } // ---------- isolation and knockout ---------- /// A 50% group (isolated or not) holding a Multiply square over a backdrop. fn half_group(isolate: bool) -> Document { let mut d = Document::new(100.0, 100.0); add(&mut d, rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(0.8, 0.6, 0.2))); let mut m = rect(Rect::new(20.0, 20.0, 80.0, 80.0), Color::rgb(0.5, 0.5, 1.0)); (m.id, m.blend) = (d.alloc_id(), BlendMode::Multiply); let mut g = Node::group(NodeId(0), vec![Arc::new(m)]); (g.opacity, g.isolate) = (0.5, isolate); add(&mut d, g); d } #[test] fn isolated_and_non_isolated_groups_round_trip() { let back = roundtrip(&half_group(true)); let groups: Vec = all(&back).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: false, .. })).collect(); let [g] = groups.as_slice() else { panic!("one group: {groups:?}") }; assert!(g.isolate && g.extras().mask.is_none()); assert!((g.opacity - 0.5).abs() < 0.01, "{}", g.opacity); assert_eq!(g.children().unwrap()[0].blend, BlendMode::Multiply); // Not isolated: its 50% (a constant alpha mask in the file) is opacity again, here on the // only object inside, which blends with the backdrop as before. let back = roundtrip(&half_group(false)); let nodes = all(&back); assert!(nodes.iter().all(|n| !n.isolate && n.extras().mask.is_none())); let m = nodes.iter().find(|n| n.blend == BlendMode::Multiply).unwrap(); let opacity: f32 = nodes.iter().filter(|n| n.children().is_some_and(|c| c.iter().any(|c| c.id == m.id))).map(|g| g.opacity).product::() * m.opacity; assert!((opacity - 0.5).abs() < 0.01, "{opacity}"); } #[test] fn isolate_and_knockout_flags_of_a_group_are_kept() { let x = first_extra(1); let form = stream( "/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true /K true >> /Resources << /ExtGState << /M << /BM /Multiply >> >> >>", "/M gs 1 0 0 rg 10 10 50 50 re f 0 0 1 rg 30 30 50 50 re f", ); let d = open(&one_page("0 1 0 rg 0 0 100 100 re f /X0 Do", &format!("/XObject << /X0 {x} 0 R >>"), &[&form])); let groups: Vec = all(&d).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { .. })).collect(); let [g] = groups.as_slice() else { panic!("one group: {groups:?}") }; assert!(g.isolate); assert_eq!(g.knockout, Knockout::On); assert_eq!(g.children().unwrap().len(), 2); // Without the flags, the form's group is just its art. let plain = stream( "/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency >>", "1 0 0 rg 10 10 50 50 re f 0 0 1 rg 30 30 50 50 re f", ); let d = open(&one_page("/X0 Do", &format!("/XObject << /X0 {x} 0 R >>"), &[&plain])); assert!(all(&d).iter().all(|n| !matches!(n.kind, NodeKind::Group { .. }))); } // ---------- tiling patterns ---------- #[test] fn tiling_patterns_become_pattern_swatches() { let p = first_extra(1); let colored = stream("/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>", "1 0 0 rg 0 0 5 5 re f"); let d = open(&one_page("/Pattern cs /P0 scn 0 0 100 50 re f 0 50 100 50 re f", &format!("/Pattern << /P0 {p} 0 R >>"), &[&colored])); assert_eq!(d.patterns.len(), 1, "one swatch for both fills"); let def = &d.patterns[0]; assert!(near(def.tile, Rect::new(0.0, 0.0, 10.0, 10.0)), "{:?}", def.tile); assert_eq!(def.art.len(), 1); assert_eq!(fill_color(&def.art[0]), Some(Color::rgb(1.0, 0.0, 0.0))); assert!(d.swatches.iter().any(|s| matches!(&s.paint, Paint::Pattern { pattern, .. } if *pattern == def.name)), "a pattern swatch"); let fills: Vec = all(&d).iter().filter_map(|n| n.appearance.fill().map(|f| f.paint.clone())).collect(); assert_eq!(fills.len(), 2); assert!(fills.iter().all(|f| matches!(f, Paint::Pattern { pattern, .. } if *pattern == def.name)), "{fills:?}"); // An uncoloured pattern draws in the current colour: one swatch per colour. let stencil = stream("/PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>", "0 0 5 5 re f"); let d = open(&one_page( "/CS0 cs 1 0 0 /P0 scn 0 0 100 50 re f 0 0 1 /P0 scn 0 50 100 50 re f", &format!("/Pattern << /P0 {p} 0 R >> /ColorSpace << /CS0 [/Pattern /DeviceRGB] >>"), &[&stencil], )); let colors: Vec = d.patterns.iter().filter_map(|p| fill_color(&p.art[0])).collect(); assert_eq!(colors, vec![Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)]); } // ---------- text ---------- const HELVETICA: &str = "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> /F2 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> >>"; fn texts(d: &Document) -> Vec { all(d).into_iter().filter_map(|n| if let NodeKind::Text(t) = n.kind { Some(*t) } else { None }).collect() } #[test] fn text_becomes_point_type() { let bytes = one_page( "BT /F1 24 Tf 1 0 0 rg 10 30 Td (Hello) Tj [(Wor) -20 (ld)] TJ ET BT /F1 12 Tf 10 70 Td [(Next) -1000 (line)] TJ /F2 12 Tf 0 0 1 rg ( in bold) Tj ET", HELVETICA, &[], ); let r = open_with(&bytes, |_| {}); let t = texts(&r.document); assert_eq!(t.len(), 2, "{t:?}"); let text = |o: &vectorcraft_doc::TextObject| o.runs.iter().map(|r| r.text.as_str()).collect::(); assert_eq!(text(&t[0]), "HelloWorld"); let st = &t[0].runs[0].style; assert_eq!((st.font_family.as_str(), st.font_style.as_str(), st.size), ("Helvetica", "Regular", 24.0)); assert_eq!(st.fill.color(), Some(Color::rgb(1.0, 0.0, 0.0))); // The baseline origin, in y-down page space. let o = t[0].xf * kurbo::Point::ORIGIN; assert!((o.x - 10.0).abs() < 0.01 && (o.y - 70.0).abs() < 0.01, "{o:?}"); // A gap of an em between words reads as a space, tracked on its own to keep the gap's width // (#508); another font on the line is another run. assert_eq!(text(&t[1]), "Next line in bold"); let runs: Vec<(&str, &str)> = t[1].runs.iter().map(|r| (r.text.as_str(), r.style.font_style.as_str())).collect(); assert_eq!(runs, [("Next", "Regular"), (" ", "Regular"), ("line", "Regular"), (" in bold", "Bold")]); assert!(t[1].runs[1].style.tracking > t[1].runs[0].style.tracking + 100.0, "{:?}", t[1].runs); assert_eq!(t[1].runs[3].style.fill.color(), Some(Color::rgb(0.0, 0.0, 1.0))); assert!(!r.warnings.iter().any(|w| w.contains("outlines")), "{:?}", r.warnings); // As outlines: paths, no type. let r = open_with(&bytes, |o| o.text_as = TextAs::Outlines); assert!(texts(&r.document).is_empty()); assert!(all(&r.document).iter().any(|n| n.name.as_deref() == Some(""))); } #[test] fn font_names_resolve_to_available_families() { let f = crate::import_text::Families::available(); let (family, style, found) = f.resolve("ABCDEF+SourceSans3-Bold", None, false); assert_eq!((family.as_str(), style.as_str(), found), ("Source Sans 3", "Bold", true)); let (family, style, found) = f.resolve("TimesNewRomanPS-BoldItalicMT", None, false); assert_eq!((family.as_str(), style.as_str()), ("Times New Roman", "Bold Italic")); let _ = found; let (family, style, found) = f.resolve("MissingSansPro", Some(700), true); assert_eq!((family.as_str(), style.as_str(), found), ("Missing Sans Pro", "Bold Italic", false)); } // ---------- meshes, stops and extend ---------- fn hex(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02X}")).collect::>().join("") + ">" } #[test] fn a_coons_patch_mesh_becomes_a_gradient_mesh() { // One patch over (10, 10)–(90, 90): corner colours red (0, 0), green (0, 1), blue (1, 1), // white (1, 0) in u/v. let pts: [(u8, u8); 12] = [(10, 10), (10, 37), (10, 63), (10, 90), (37, 90), (63, 90), (90, 90), (90, 63), (90, 37), (90, 10), (63, 10), (37, 10)]; let mut data = vec![0u8]; for (x, y) in pts { data.extend([x, y]); } data.extend([255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]); let s = first_extra(1); let sh = stream( "/ShadingType 6 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Filter /ASCIIHexDecode", &hex(&data), ); let d = open(&one_page("/Sh0 sh", &format!("/Shading << /Sh0 {s} 0 R >>"), &[&sh])); let meshes: Vec = all(&d).into_iter().filter_map(|n| if let NodeKind::Mesh(m) = n.kind { Some(m) } else { None }).collect(); let [m] = meshes.as_slice() else { panic!("one mesh: {meshes:?}") }; assert_eq!((m.rows, m.cols), (1, 1)); let at = |r, c| &m.points[m.idx(r, c)]; // Row 0 is v = 0 (PDF y 10 → page y 90), column 1 is u = 1. assert!((at(0, 0).p - kurbo::Point::new(10.0, 90.0)).hypot() < 0.01, "{:?}", at(0, 0).p); assert!((at(1, 1).p - kurbo::Point::new(90.0, 10.0)).hypot() < 0.01, "{:?}", at(1, 1).p); assert_eq!(at(0, 0).color.to_rgb(), [1.0, 0.0, 0.0]); assert_eq!(at(1, 0).color.to_rgb(), [0.0, 1.0, 0.0]); assert_eq!(at(1, 1).color.to_rgb(), [0.0, 0.0, 1.0]); assert_eq!(at(0, 1).color.to_rgb(), [1.0, 1.0, 1.0]); assert!(!all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })), "the mesh lies inside the page"); } #[test] fn gradient_stop_opacity_round_trips() { let mut d = Document::new(100.0, 100.0); let mut stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; stops[1].opacity = 0.2; let g = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); let n = Node::path( NodeId(0), shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0), ); add(&mut d, n); let back = roundtrip(&d); let nodes = all(&back); assert!(nodes.iter().all(|n| n.extras().mask.is_none()), "the opacity is in the stops, not a mask"); let g = nodes.iter().find_map(|n| match n.appearance.fill().map(|f| &f.paint) { Some(Paint::Gradient(g)) => Some(g.gradient.clone()), _ => None, }); let g = g.expect("a gradient fill"); let first = g.stops.first().unwrap(); let last = g.stops.last().unwrap(); assert!((first.opacity - 1.0).abs() < 0.02 && (last.opacity - 0.2).abs() < 0.02, "{:?}", g.stops); assert_eq!(last.color.to_rgb(), [0.0, 0.0, 1.0]); } #[test] fn a_shading_that_does_not_extend_paints_only_between_its_ends() { let shading = |extend: &str| { format!( "/Shading << /Sh0 << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [20 0 80 0] /Extend [{extend}] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> >> >>" ) }; let d = open(&one_page("/Sh0 sh", &shading("false false"), &[])); let clips: Vec = all(&d).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect(); let [c] = clips.as_slice() else { panic!("one clip group: {clips:?}") }; let b = c.geometric_bounds().unwrap(); assert!((b.x0 - 20.0).abs() < 0.01 && (b.x1 - 80.0).abs() < 0.01 && b.y0 <= 0.0 && b.y1 >= 100.0, "{b:?}"); let d = open(&one_page("/Sh0 sh", &shading("true true"), &[])); assert!(!all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. }))); // Extended at the start only: from the left edge to the end. let d = open(&one_page("/Sh0 sh", &shading("true false"), &[])); let b = all(&d).into_iter().find(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).unwrap().geometric_bounds().unwrap(); assert!(b.x0 <= 0.0 && (b.x1 - 80.0).abs() < 0.01, "{b:?}"); } /// A diamond (10–90 × 10–90) clipping `paint`, which fills its bounds: how a shape filled with a /// gradient is written (`W n`, then `sh`). fn clipped_diamond(paint: &str) -> Document { let shading = "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [10 0 90 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> >>"; let resources = format!("/Shading << /Sh0 {shading} >> /Pattern << /P0 << /PatternType 2 /Shading {shading} >> >>"); open(&one_page(&format!("q 10 50 m 50 90 l 90 50 l 50 10 l h W n {paint} Q"), &resources, &[])) } #[test] fn a_shape_filled_with_a_shading_becomes_one_path_with_the_gradient() { let d = clipped_diamond("/Sh0 sh"); assert!(!all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { .. })), "no group"); let objs = objects(&d); let [n] = objs.as_slice() else { panic!("one object: {objs:#?}") }; let p = n.path_data().unwrap(); assert!(p.is_closed() && p.anchor_count() == 4, "the diamond: {p:?}"); assert!(near(n.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 90.0)), "{:?}", n.geometric_bounds()); let Some(Paint::Gradient(g)) = n.appearance.fill().map(|f| &f.paint) else { panic!("a gradient fill: {:?}", n.appearance.items) }; assert_eq!(g.gradient.kind, GradientKind::Linear); assert_eq!(g.gradient.stops.first().map(|s| s.color.to_rgb()), Some([1.0, 0.0, 0.0])); assert_eq!(g.gradient.stops.last().map(|s| s.color.to_rgb()), Some([0.0, 0.0, 1.0])); let geom = g.geom.as_ref().unwrap(); assert!((geom.start.x - 10.0).abs() < 0.01 && (geom.end.x - 90.0).abs() < 0.01, "placed as before: {geom:?}"); assert!(n.appearance.stroke().is_none()); // A gradient that doesn't cover the clip, and a flat colour, stay clipped. for paint in ["/Pattern cs /P0 scn 40 40 20 20 re f", "1 0 0 rg 0 0 100 100 re f"] { let d = clipped_diamond(paint); assert!(all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })), "{paint}"); } } #[test] fn appearance_items_are_untouched_for_plain_art() { // Plain art keeps importing as before: no masks, groups or clips appear. let d = open(&one_page("q 1 0 0 rg 10 10 30 30 re f Q 0 0 1 RG 2 w 50 50 30 30 re S", "", &[])); let nodes = all(&d); assert_eq!(nodes.iter().filter(|n| n.path_data().is_some()).count(), 2); assert!(nodes.iter().all(|n| n.extras().mask.is_none() && !matches!(n.kind, NodeKind::Group { .. }))); assert!(matches!(nodes.iter().find(|n| n.appearance.stroke().is_some()).unwrap().appearance.items[0], AppearanceItem::Stroke(_))); } /// Type set on a path (each glyph placed and turned along a curve, as apps write it) comes back /// as type on a path through the glyphs, not as a straight line; straight type stays point type. #[test] fn glyphs_turned_along_a_curve_become_type_on_a_path() { // "ARCHING" along an arc of radius 100 around (150, 0), each glyph where the one before // it ends (Helvetica's advances), clockwise from 135° (PDF y up: the arc bulges upwards). let mut content = String::from("BT /F1 18 Tf "); let mut a = 135f64.to_radians(); for (c, adv) in "ARCHING".chars().zip([667.0, 722.0, 722.0, 722.0, 278.0, 722.0, 778.0]) { let (x, y) = (150.0 + 100.0 * a.cos(), 100.0 * a.sin()); // The baseline's direction is the tangent, clockwise along the arc. let (dx, dy) = (a.sin(), -a.cos()); content.push_str(&format!("{dx:.5} {dy:.5} {:.5} {dx:.5} {x:.3} {y:.3} Tm ({c}) Tj ", -dy)); a -= adv / 1000.0 * 18.0 / 100.0; } content.push_str("ET"); let t = texts(&open(&one_page(&content, HELVETICA, &[]))); assert_eq!(t.len(), 1, "one text object: {t:?}"); let text: String = t[0].runs.iter().map(|r| r.text.as_str()).collect(); assert_eq!(text, "ARCHING"); let vectorcraft_doc::TextKind::OnPath { path, .. } = &t[0].kind else { panic!("type on a path: {:?}", t[0].kind) }; let b = path.bounds().unwrap(); assert!(b.height() > 15.0, "the path curves: {b:?}"); // Every glyph fits on the path, the last one too. assert_eq!(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t[0]).glyphs.len(), 7); // Straight type with a slight rotation stays one point type object. let straight = texts(&open(&one_page("BT /F1 18 Tf 0.98481 0.17365 -0.17365 0.98481 20 40 Tm (Straight) Tj ET", HELVETICA, &[]))); assert_eq!(straight.len(), 1); assert!(matches!(straight[0].kind, vectorcraft_doc::TextKind::Point), "{:?}", straight[0].kind); } /// The leaves (objects that aren't groups or clipping paths) of a document. fn objects(d: &Document) -> Vec { all(d).into_iter().filter(|n| !n.is_container() && !matches!(n.kind, NodeKind::Path { clipping: true, .. })).collect() } #[test] fn an_object_written_as_a_fill_and_a_closed_stroke_stays_one_path_with_a_live_stroke() { // The fill of the outline left open, then the stroke of it closed, each in its own // transform. Two subpaths: one ending on its start, one not. let outline = "0 0 m -20 1 l -30 -12 l -30 30 l 0 0 l 5 5 m 10 5 l 10 10 l"; let content = format!( "0.2 0.2 0.2 rg q 1 0 0 1 50 50 cm {outline} f Q 1 1 1 RG 1.5 w 1 J 1 j 4 M [3 2] 1 d q 1 0 0 1 50 50 cm {} S Q", outline.replace(" 5 5 m", " h 5 5 m") + " h" ); let d = open(&one_page(&content, "", &[])); let objs = objects(&d); assert_eq!(objs.len(), 1, "one object, not a fill and a separate stroke: {objs:#?}"); let n = &objs[0]; assert_eq!(fill_color(n), Some(Color::rgb(0.2, 0.2, 0.2))); let st = n.appearance.stroke().expect("a live stroke"); assert_eq!(st.paint.color(), Some(Color::WHITE)); assert!((st.width - 1.5).abs() < 1e-6); assert_eq!((st.cap, st.join), (LineCap::Round, LineJoin::Round)); assert!((st.miter_limit - 4.0).abs() < 1e-6); let dash = st.dash.as_ref().expect("dashed"); assert_eq!((dash.pattern.as_slice(), dash.offset), (&[3.0, 2.0][..], 1.0)); // The stroke's outline: both subpaths closed, as they were stroked. let p = n.path_data().unwrap(); assert_eq!(p.subpaths.len(), 2); assert!(p.is_closed()); } #[test] fn a_fill_in_a_translucent_group_doesnt_take_the_stroke_drawn_after_it() { // A fill at 75 % under an opaque stroke of the same outline: the fill in a transparency group // drawn at 75 %, the stroke after the group. let x = first_extra(1); let outline = "10 10 m 60 10 l 60 60 l 10 60 l h"; let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency >>", &format!("1 0 0 rg {outline} f")); let content = format!("q /G gs /X0 Do Q 0 0 1 RG 2 w {outline} S"); let d = open(&one_page(&content, &format!("/ExtGState << /G << /ca 0.75 /CA 0.75 >> >> /XObject << /X0 {x} 0 R >>"), &[&form])); let objs = objects(&d); let stroke = objs.iter().find(|n| n.appearance.stroke().is_some()).expect("a stroke"); let alpha = all(&d) .iter() .filter(|n| n.appearance.stroke().is_some() || n.children().is_some_and(|c| c.iter().any(|k| k.id == stroke.id))) .map(|n| n.opacity) .product::(); assert_eq!(alpha, 1.0, "the stroke stays opaque: {objs:#?}"); let fill = objs.iter().find(|n| n.appearance.fill().is_some()).expect("a fill"); assert!((fill.opacity * fill.appearance.fill().unwrap().opacity - 0.75).abs() < 1e-3, "{fill:#?}"); } #[test] fn a_fill_in_a_soft_masked_group_doesnt_take_the_stroke_drawn_after_it() { // ISO 32000-1 §11.6.5.2: the soft mask in the graphics state applies to the group drawn under // it, not to the stroke after `Q`. let (m, x) = (first_extra(1), first_extra(1) + 1); let outline = "10 10 m 60 10 l 60 60 l 10 60 l h"; let mask = stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB >>", "1 1 1 rg 0 0 35 100 re f"); let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency >>", &format!("1 0 0 rg {outline} f")); let content = format!("q /GS gs /X0 Do Q 0 0 1 RG 2 w {outline} S"); let resources = format!("/ExtGState << /GS << /SMask << /Type /Mask /S /Luminosity /G {m} 0 R >> >> >> /XObject << /X0 {x} 0 R >>"); let d = open(&one_page(&content, &resources, &[&mask, &form])); let nodes = all(&d); let stroke = nodes.iter().find(|n| n.appearance.stroke().is_some()).expect("a stroke"); assert!(stroke.appearance.fill().is_none() && stroke.extras().mask.is_none(), "the stroke is its own unmasked object: {stroke:#?}"); let masked = nodes.iter().filter(|n| n.extras().mask.is_some()).collect::>(); assert!(!masked.is_empty() && masked.iter().all(|n| n.appearance.stroke().is_none()), "{masked:#?}"); } #[test] fn different_outlines_filled_and_stroked_stay_apart() { let d = open(&one_page("1 0 0 rg 10 10 m 40 10 l 40 40 l f 0 0 1 RG 10 10 m 40 10 l 40 40 l 10 40 l h S 0 g 50 50 m 60 60 l S", "", &[])); let objs = objects(&d); assert_eq!(objs.len(), 3); assert!(objs[0].appearance.stroke().is_none() && objs[1].appearance.fill().is_none()); } #[test] fn a_stroke_under_a_stretched_transform_stays_a_stroke() { // Stretched twice as wide: the stroke keeps its average width (√2 × 2 pt) rather than // becoming a filled outline. let d = open(&one_page("0 0 1 RG 2 w q 2 0 0 1 0 0 cm 10 10 m 40 10 l 40 40 l S Q", "", &[])); let objs = objects(&d); assert_eq!(objs.len(), 1); assert!(objs[0].appearance.fill().is_none()); let st = objs[0].appearance.stroke().expect("a stroke"); assert!((st.width - 2.0 * 2f64.sqrt()).abs() < 1e-6, "{}", st.width); let b = objs[0].geometric_bounds().unwrap(); assert!((b.x0 - 20.0).abs() < 1e-6 && (b.x1 - 80.0).abs() < 1e-6, "{b:?}"); } #[test] fn art_past_the_page_clip_keeps_the_clip() { // A page-sized clip, then a backdrop far larger than the page and a square on it: the square // needs no clip, the backdrop keeps it, so the art's bounds stay on the page. let r = open_with(&one_page("0 0 100 100 re W n 0 g -500 -500 1100 1100 re f 1 0 0 rg 10 10 20 20 re f", "", &[]), |_| {}); let d = &r.document; let art = d.art_bounds().expect("art"); assert!(art.x0 >= -0.01 && art.y0 >= -0.01 && art.x1 <= 100.01 && art.y1 <= 100.01, "the art stays on the page: {art:?}"); let clips: Vec = all(d).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect(); let [c] = clips.as_slice() else { panic!("one clip group, the backdrop's: {clips:?}") }; assert_eq!(c.children().map(Vec::len), Some(2), "the clip path and the backdrop"); assert!(r.warnings.iter().any(|w| w == crate::import::PAST_PAGE_NOTE), "{:?}", r.warnings); // Art inside the page gets no clip and no warning. let r = open_with(&one_page("0 0 100 100 re W n 1 0 0 rg 10 10 20 20 re f", "", &[]), |_| {}); assert!(!all(&r.document).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. }))); assert!(!r.warnings.iter().any(|w| w == crate::import::PAST_PAGE_NOTE), "{:?}", r.warnings); } /// Glyphs closed up by up to half an em (the proportional widths of CJK punctuation) still /// continue their line. #[test] fn glyphs_closed_up_by_half_an_em_stay_one_line() { let t = texts(&open(&one_page("BT /F1 20 Tf 10 40 Td [(T) 400 (i) 450 (g) 0 (h) 0 (t)] TJ ET", HELVETICA, &[]))); assert_eq!(t.len(), 1, "one line: {:?}", t.iter().map(|t| t.plain_text()).collect::>()); assert_eq!(t[0].plain_text(), "Tight"); } /// #1542: a clip only counts as covering the page when it does; a diamond whose bounding box is /// the page still clips, a page rectangle or a turned square around it doesn't make a group. #[test] fn clips_with_the_page_as_bounding_box_still_clip() { let clipped = |clip: &str| { let d = open(&one_page(&format!("q {clip} W n 1 0.5 0 rg 0 0 100 100 re f Q"), "", &[])); all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })) }; assert!(clipped("50 0 m 100 50 l 50 100 l 0 50 l h"), "diamond"); assert!(!clipped("0 0 100 100 re"), "the page"); assert!(!clipped("50 -60 m 160 50 l 50 160 l -60 50 l h"), "a turned square around the page"); }