`transparency.rs` implemented all sixteen blend modes and unit-tested them against the specification's formulas. Nothing ever called them with a backdrop. The Makepad renderer's `SetBlendMode` pushed a `TransparencyError::Unsupported` and then painted the source colour, so a /Multiply highlight and a /Normal one produced byte-identical output and every test passed — because every test asked "was the right command issued", not "does the page look right". Overprint had no code at all. /OP, /op and /OPM were not parsed, so an overprinting object knocked out the inks under it. That is not a missing feature, it is the inverse of the instruction: on a press it is the difference between a colour and a hole. - `composite.rs`: a straight-alpha RGBA `Canvas` implementing §11.3.6's union formula, weighted by backdrop alpha so a Multiply over transparency is the source rather than black. Constant alpha and per-pixel soft masks. Transparency groups composite as a unit; knockout groups are refused by name rather than silently treated as non-knockout. - Overprint as `composite_cmyk`, separate from the RGB path rather than a flag on it: overprint is a statement about inks and RGB has none. /op defaults to /OP per table 58 — defaulting it to false makes the common `<< /OP true >>` knock out every fill. §10.7.5's "no effect on an RGB device" is asserted, so our doing nothing there is the spec rather than an omission. - `raster.rs`: a CPU rasteriser that replays a command list onto a canvas. Not on the display path, no anti-aliasing, no fonts; it exists so compositing has a verifiable output. In pdf-graphics and not pdf-makepad because a test that needs a GPU is a test that does not run. - Golden **pixels** for shading, mesh, blend and overprint pages — Phase 7's exit criterion, which the Phase 2 command-text goldens cannot meet. ASCII grids with a colour legend, quantised to quarter steps; each test asserts its exact colours before comparing, so a wrong-but-stable render cannot be blessed by an UPDATE_GOLDEN run. Six mutations, all killed, including the two that describe the old behaviour: discarding the blend result, and ignoring the overprint flag. 1364 tests pass. ADR 0030.
481 lines
16 KiB
Rust
481 lines
16 KiB
Rust
//! 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<u8> {
|
|
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<RenderCommand>,
|
|
errors: Vec<TransparencyError>,
|
|
}
|
|
|
|
/// 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<String, PdfObj>,
|
|
ext_gstates: &HashMap<String, PdfObj>,
|
|
) -> 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<f64> {
|
|
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<f64> = 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<f64> = 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<BlendMode> = 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<BlendMode> = 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<Vec<u8>>)> { None };
|
|
let mut lookup = |_: &str| -> Option<PdfObj> { 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",
|
|
"overprint.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"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------- overprint
|
|
//
|
|
// `/OP`, `/op` and `/OPM` were not parsed at all. An overprinting object
|
|
// therefore knocked out the inks underneath it, which is the opposite of
|
|
// what the document asked for: on a press that is the difference between a
|
|
// colour and a hole. See ADR 0030.
|
|
|
|
#[test]
|
|
fn overprint_entries_are_parsed_from_a_real_page() {
|
|
use nigig_pdf_graphics::composite::Overprint;
|
|
|
|
let bytes = corpus("transparency/overprint.pdf");
|
|
let mut doc = PdfDocument::parse(&bytes).expect("parses");
|
|
let page = doc.page(0).expect("page 0");
|
|
let (states, errors) = resolve_page_ext_gstates(&page.ext_gstates);
|
|
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
|
|
|
|
let over = states
|
|
.get("GSOver")
|
|
.expect("/GSOver is in the page resources");
|
|
assert_eq!(
|
|
over.overprint,
|
|
Some(Overprint {
|
|
stroke: true,
|
|
fill: true,
|
|
mode: 1,
|
|
}),
|
|
"the three overprint entries must survive to the graphics state"
|
|
);
|
|
|
|
let mode0 = states.get("GSMode0").expect("/GSMode0");
|
|
assert_eq!(mode0.overprint.map(|o| o.mode), Some(0));
|
|
|
|
// /op explicitly false with /OP true: the two flags must stay
|
|
// distinct, or a stroking-only overprint knocks out every fill.
|
|
let stroke_only = states.get("GSStrokeOnly").expect("/GSStrokeOnly");
|
|
let stroke_only = stroke_only.overprint.expect("present");
|
|
assert!(stroke_only.stroke);
|
|
assert!(!stroke_only.fill);
|
|
}
|
|
|
|
#[test]
|
|
fn an_ext_gstate_that_names_no_overprint_leaves_it_unset() {
|
|
// `None` means "unchanged", not "off". Defaulting here would make
|
|
// every `gs` operator reset overprint.
|
|
let bytes = corpus("transparency/constant_alpha.pdf");
|
|
let mut doc = PdfDocument::parse(&bytes).expect("parses");
|
|
let page = doc.page(0).expect("page 0");
|
|
let (states, _) = resolve_page_ext_gstates(&page.ext_gstates);
|
|
assert!(!states.is_empty(), "the fixture has ExtGStates");
|
|
for (name, state) in &states {
|
|
assert!(
|
|
state.overprint.is_none(),
|
|
"/{name} names no overprint entry but reported {:?}",
|
|
state.overprint
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_overprinting_fill_over_cyan_keeps_the_cyan_ink() {
|
|
// End to end: read the real page's ExtGState, then composite with it.
|
|
// The fixture paints magenta over a cyan ground with /OPM 1, so the
|
|
// result must be blue. A knockout would give magenta.
|
|
use nigig_pdf_graphics::composite::{composite_cmyk, CompositeState, ProcessColorants};
|
|
|
|
let bytes = corpus("transparency/overprint.pdf");
|
|
let mut doc = PdfDocument::parse(&bytes).expect("parses");
|
|
let page = doc.page(0).expect("page 0");
|
|
let (states, _) = resolve_page_ext_gstates(&page.ext_gstates);
|
|
let overprint = states
|
|
.get("GSOver")
|
|
.and_then(|s| s.overprint)
|
|
.expect("/GSOver carries overprint");
|
|
|
|
let state = CompositeState {
|
|
overprint,
|
|
colorants: ProcessColorants::Cmyk,
|
|
alpha: 1.0,
|
|
..Default::default()
|
|
};
|
|
let cyan = [1.0, 0.0, 0.0, 0.0];
|
|
let magenta = [0.0, 1.0, 0.0, 0.0];
|
|
assert_eq!(
|
|
composite_cmyk(cyan, magenta, &state, 1.0),
|
|
[1.0, 1.0, 0.0, 0.0],
|
|
"overprinting magenta over cyan must give blue, not a magenta hole"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_blend_mode_from_a_real_page_composites_differently_from_normal() {
|
|
// `BlendMode` reached the device before this, and nothing composited
|
|
// it: a /Multiply highlight and a /Normal one produced the same
|
|
// pixels. This drives the compositor from a real page's ExtGState.
|
|
use nigig_pdf_graphics::composite::{composite_pixel, CompositeState, Pixel};
|
|
|
|
let bytes = corpus("transparency/blend_modes.pdf");
|
|
let mut doc = PdfDocument::parse(&bytes).expect("parses");
|
|
let page = doc.page(0).expect("page 0");
|
|
let (states, _) = resolve_page_ext_gstates(&page.ext_gstates);
|
|
|
|
let multiply = states
|
|
.values()
|
|
.find_map(|s| (s.blend_mode == Some(BlendMode::Multiply)).then_some(BlendMode::Multiply))
|
|
.expect("the fixture has a /Multiply state");
|
|
|
|
let backdrop = Pixel::opaque([0.5, 0.5, 0.5]);
|
|
let source = Pixel::opaque([0.9, 0.3, 0.1]);
|
|
let blended = composite_pixel(
|
|
backdrop,
|
|
source,
|
|
&CompositeState {
|
|
blend_mode: multiply,
|
|
alpha: 1.0,
|
|
..Default::default()
|
|
},
|
|
1.0,
|
|
);
|
|
let plain = composite_pixel(
|
|
backdrop,
|
|
source,
|
|
&CompositeState {
|
|
alpha: 1.0,
|
|
..Default::default()
|
|
},
|
|
1.0,
|
|
);
|
|
assert_ne!(
|
|
blended, plain,
|
|
"the page asked for /Multiply and got the same pixels as /Normal"
|
|
);
|
|
assert!(
|
|
(blended.rgb[0] - 0.45).abs() < 1e-9,
|
|
"0.5 x 0.9 is 0.45, got {:?}",
|
|
blended.rgb
|
|
);
|
|
}
|