//! Transparency acceptance tests. //! //! The merge criteria from `REVIEWS/adr/0009-pdf-transparency.md`. //! //! The headline defect these guard against is not "transparency was //! missing" — it is that `gs` **parsed as `CloseStroke`**, so every page //! that set a graphics state gained a stroked path the document never //! asked for, and lost its alpha, blend mode and soft mask silently. use std::collections::HashMap; use std::path::PathBuf; use nigig_pdf_cos::PdfObj; use nigig_pdf_document::PdfDocument; use nigig_pdf_graphics::colorspace::resolve_page_color_spaces; use nigig_pdf_graphics::content::{interpret_ops, parse_content_stream}; use nigig_pdf_graphics::recording::{RecordingDevice, RenderCommand}; use nigig_pdf_graphics::transparency::{ resolve_page_ext_gstates, BlendMode, SoftMaskKind, TransparencyError, }; fn corpus(relative: &str) -> Vec { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../tests/corpus") .join(relative); std::fs::read(&path).unwrap_or_else(|e| panic!("missing {}: {e}", path.display())) } struct Rendered { commands: Vec, errors: Vec, } /// Interpret page 0 the way the render worker does. fn render(relative: &str) -> Rendered { let bytes = corpus(relative); let mut doc = PdfDocument::parse(&bytes).expect("fixture should parse"); let page = doc.page(0).expect("page 0"); render_page(&page.content_data, &page.color_spaces, &page.ext_gstates) } fn render_page( content: &[u8], color_spaces: &HashMap, ext_gstates: &HashMap, ) -> Rendered { let mut device = RecordingDevice::new(); for (name, space) in resolve_page_color_spaces(color_spaces) { if let Ok(space) = space { device.register_color_space(&name, space); } } let (states, mut errors) = resolve_page_ext_gstates(ext_gstates); for (name, state) in states { device.register_ext_gstate(&name, state); } let ops = parse_content_stream(content).expect("content should parse"); interpret_ops(&ops, &mut device).expect("interpretation should not fail"); errors.extend(device.transparency_errors().iter().cloned()); Rendered { commands: device.into_commands(), errors, } } fn fill_alphas(cmds: &[RenderCommand]) -> Vec { cmds.iter() .filter_map(|c| match c { RenderCommand::SetFillAlpha(a) => Some(*a), _ => None, }) .collect() } #[test] fn gs_no_longer_injects_a_spurious_stroke() { let r = render("transparency/constant_alpha.pdf"); assert!( !r.commands .iter() .any(|c| matches!(c, RenderCommand::Stroke)), "the page strokes nothing, but a stroke reached the device: {:?}", r.commands ); } #[test] fn constant_alpha_reaches_the_device() { let r = render("transparency/constant_alpha.pdf"); let alphas = fill_alphas(&r.commands); assert!( alphas.contains(&0.5), "ca 0.5 never reached the device: {alphas:?}" ); assert!( alphas.contains(&0.1), "the second ca never reached the device: {alphas:?}" ); let stroke: Vec = r .commands .iter() .filter_map(|c| match c { RenderCommand::SetStrokeAlpha(a) => Some(*a), _ => None, }) .collect(); assert!(stroke.contains(&0.25), "CA 0.25 was lost: {stroke:?}"); } #[test] fn an_extgstate_only_applies_the_entries_it_names() { // /GS1 sets ca but not CA; it must not reset the stroke alpha. let r = render("transparency/constant_alpha.pdf"); let stroke_alphas: Vec = r .commands .iter() .filter_map(|c| match c { RenderCommand::SetStrokeAlpha(a) => Some(*a), _ => None, }) .collect(); assert_eq!( stroke_alphas.len(), 1, "only /GS0 names CA, so only one stroke alpha may be emitted: {stroke_alphas:?}" ); } #[test] fn blend_modes_reach_the_device() { let r = render("transparency/blend_modes.pdf"); let modes: Vec = r .commands .iter() .filter_map(|c| match c { RenderCommand::SetBlendMode(m) => Some(*m), _ => None, }) .collect(); for expected in [ BlendMode::Multiply, BlendMode::Screen, BlendMode::Overlay, BlendMode::Darken, BlendMode::Lighten, BlendMode::ColorDodge, BlendMode::ColorBurn, BlendMode::HardLight, BlendMode::SoftLight, BlendMode::Difference, BlendMode::Exclusion, BlendMode::Luminosity, ] { assert!( modes.contains(&expected), "{expected:?} never reached the device: {modes:?}" ); } assert!(r.errors.is_empty(), "unexpected errors: {:?}", r.errors); } #[test] fn an_unknown_blend_mode_falls_back_to_normal_and_is_reported() { let r = render("transparency/unknown_blend_mode.pdf"); let modes: Vec = r .commands .iter() .filter_map(|c| match c { RenderCommand::SetBlendMode(m) => Some(*m), _ => None, }) .collect(); assert_eq!( modes, vec![BlendMode::Normal], "§11.3.5.2 requires an unknown mode to render as Normal" ); assert!( r.errors .iter() .any(|e| matches!(e, TransparencyError::UnknownBlendMode(n) if n == "Frobnicate")), "the unknown mode must be named: {:?}", r.errors ); } #[test] fn a_soft_mask_reaches_the_device_with_its_transfer_function() { let r = render("transparency/soft_mask.pdf"); let mask = r .commands .iter() .find_map(|c| match c { RenderCommand::SetSoftMask(m) => Some(m.clone()), _ => None, }) .expect("the soft mask never reached the device"); assert_eq!(mask.kind, SoftMaskKind::Luminosity); assert!(mask.group.is_some(), "/G must be carried through"); assert_eq!(mask.backdrop, Some(vec![0.0]), "/BC must be carried"); // /TR squares its input, so a 0.5 mask value becomes 0.25. This is the // ADR 0006 function evaluator being used, which is why 0006 was the // prerequisite for this ADR. assert!( (mask.apply_transfer(0.5) - 0.25).abs() < 1e-9, "the transfer function was not applied: {}", mask.apply_transfer(0.5) ); } #[test] fn smask_none_clears_the_mask_rather_than_being_ignored() { let r = render("transparency/smask_none.pdf"); let installed = r .commands .iter() .position(|c| matches!(c, RenderCommand::SetSoftMask(_))); let cleared = r .commands .iter() .position(|c| matches!(c, RenderCommand::ClearSoftMask)); let installed = installed.expect("the first gs installs a mask"); let cleared = cleared.expect("/SMask /None must emit a clear"); assert!( cleared > installed, "the clear must come after the mask it clears" ); } #[test] fn a_missing_ext_gstate_is_reported_not_ignored() { let r = render("transparency/missing_gstate.pdf"); assert!( r.errors .iter() .any(|e| matches!(e, TransparencyError::UnresolvedExtGState(n) if n == "Nope")), "an unresolvable /ExtGState must be named: {:?}", r.errors ); // And the page still renders its fill rather than being abandoned. assert!( r.commands .iter() .any(|c| matches!(c, RenderCommand::FillWinding)), "one bad gs must not cost the page its content" ); } #[test] fn a_transparency_group_is_parsed_from_a_form_xobject() { use nigig_pdf_graphics::transparency::TransparencyGroup; let bytes = corpus("transparency/group.pdf"); let mut doc = PdfDocument::parse(&bytes).expect("parses"); let page = doc.page(0).expect("page 0"); let form = page.xobjects.get("Fm0").expect("the form xobject"); let obj = doc.resolve_ref(form.obj_ref).expect("resolves"); let dict = obj.as_stream().map(|s| s.dict.clone()).expect("a stream"); let group_obj = dict.get("Group").expect("/Group"); let group_dict = group_obj.as_dict().expect("a dict"); let mut resolve = |_: &PdfObj| -> Option<(PdfObj, Option>)> { None }; let mut lookup = |_: &str| -> Option { None }; let group = TransparencyGroup::parse(group_dict, &mut resolve, &mut lookup).expect("a group"); assert!(group.isolated, "/I true"); assert!(group.knockout, "/K true"); assert!(group.color_space.is_some(), "/CS DeviceRGB"); } #[test] fn page_ext_gstates_are_resolved_and_decoded_by_the_document_layer() { // The transfer function is an indirect object. If the document layer // did not dereference it, /TR would silently be dropped. let bytes = corpus("transparency/soft_mask.pdf"); let mut doc = PdfDocument::parse(&bytes).expect("parses"); let page = doc.page(0).expect("page 0"); let gs = page .ext_gstates .get("GS0") .expect("/GS0 should be in the page resources"); let smask = gs .as_dict() .and_then(|d| d.get("SMask")) .and_then(|s| s.as_dict()) .expect("/SMask should be a resolved dict"); assert!( smask.get("TR").and_then(|t| t.as_dict()).is_some(), "/TR must be dereferenced, not left as a reference" ); } #[test] fn every_transparency_fixture_parses_without_panicking() { for name in [ "constant_alpha.pdf", "blend_modes.pdf", "soft_mask.pdf", "smask_none.pdf", "group.pdf", "unknown_blend_mode.pdf", "missing_gstate.pdf", ] { let bytes = corpus(&format!("transparency/{name}")); let mut doc = PdfDocument::parse(&bytes) .unwrap_or_else(|e| panic!("transparency/{name} should parse: {e}")); let page = doc .page(0) .unwrap_or_else(|e| panic!("transparency/{name} page 0: {e}")); let _ = render_page(&page.content_data, &page.color_spaces, &page.ext_gstates); } } /// `PdfPage::xobjects` was empty for every page of every document: /// `extract_xobjects` called `doc.resolve()`, which *follows* the /// reference, and then asked the resolved object for its `as_ref()` — which /// is always `None`. Nothing downstream could resolve a `Do` operator. /// /// This is the same defect that once destroyed annotation object /// references, so it gets its own regression test rather than being left /// implicit in the transparency-group test that found it. #[test] fn page_xobjects_carry_their_object_reference() { for (fixture, name, subtype) in [ ("transparency/group.pdf", "Fm0", "Form"), ("images/xobject.pdf", "Im1", "Image"), ] { let bytes = corpus(fixture); let mut doc = PdfDocument::parse(&bytes).expect("parses"); let page = doc.page(0).expect("page 0"); let entry = page .xobjects .get(name) .unwrap_or_else(|| panic!("{fixture}: /{name} missing from the xobject map")); assert_eq!(entry.subtype, subtype, "{fixture}: wrong subtype"); // The reference must actually resolve. assert!( doc.resolve_ref(entry.obj_ref).is_ok(), "{fixture}: /{name} has an unresolvable reference" ); } }