mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
Both readers skipped a clip of at most six straight segments whose bounding box contained the page. A diamond touching the page edges has the page as its bounding box, so its clip was dropped and everything it cuts away showed. geom::hit::polygon_covers_rect answers it properly (one convex polygon containing the page's corners); any other path is kept as a clip. Fixes #1542
612 lines
31 KiB
Rust
612 lines
31 KiB
Rust
//! 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<u8> {
|
||
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<Node> {
|
||
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<Color> {
|
||
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<u8> {
|
||
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<Node> = 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::<f32>() * 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<Node> = 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<Paint> = 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<Color> = 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<vectorcraft_doc::TextObject> {
|
||
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::<String>();
|
||
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("<Text Outlines>")));
|
||
}
|
||
|
||
#[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::<Vec<_>>().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<vectorcraft_doc::live::GradientMesh> =
|
||
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<Node> = 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<Node> {
|
||
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::<f32>();
|
||
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::<Vec<_>>();
|
||
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<Node> = 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::<Vec<_>>());
|
||
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");
|
||
}
|