nigig-org/crates/apps/pdf/pdf-document/tests/transparency.rs
andodeki d8d29c226d
Some checks failed
nigig-build.yml / feat(pdf): transparency, and four operator-parsing bugs it exposed (push) Failing after 0s
pdf.yml / feat(pdf): transparency, and four operator-parsing bugs it exposed (push) Failing after 0s
feat(pdf): transparency, and four operator-parsing bugs it exposed
Phase 8 #5 of REVIEWS/DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md
("Advanced transparency"). Design and merge criteria in
REVIEWS/adr/0009-pdf-transparency.md.

The headline defect was not that transparency was missing. `gs` was
MIS-PARSED as CloseStroke:

  /GS0 gs 1 0 0 rg 100 100 200 200 re f
    -> [CloseStroke, RgbFill, Rectangle, FillWinding]

so every page setting a graphics state gained a stroked path the
document never asked for, and lost its alpha, blend mode and soft mask
silently. Downstream everything was dead: StrokeExtGState/FillExtGState
were never constructed, set_fill_opacity was never called and emitted no
command when it was, and PdfPage::ext_gstate was read by no code at all.

New pdf-graphics/src/transparency.rs: full /ExtGState (ca, CA, BM,
SMask, LW, LC, LJ, ML, D, AIS, TK), all sixteen blend modes including
the four non-separable ones, soft masks with /S, /G, /BC and a /TR
evaluated through ADR 0006's PdfFunction, and /Group parsing. Wired end
to end: parser -> PdfOp::SetExtGState -> device -> RenderCommand ->
Makepad renderer.

Compositing is NOT claimed. Backdrop blending needs render-to-texture,
which an engine-neutral crate has no framebuffer for. Constant alpha is
applied because it needs no backdrop; blend modes and soft masks are
reported through TransparencyError::Unsupported rather than dropped,
because a silently ignored /Multiply looks exactly like a correct
/Normal.

The ADR required a test enumerating every multi-character operator, on
the grounds that fixing the third instance of a shadowing bug (after cm
and rg) without preventing the fourth is not a fix. It immediately found
three more live bugs, none of them transparency-related:

- `b` and `b*` dropped their close-path, mapping to FillStroke* instead
  of CloseFillStroke*, so every closed-and-stroked path drew with a gap.
- Text operators within three bytes of the end of a content stream were
  mis-parsed: the b'T' arm guarded `*i + 3 < len` while reading only two
  bytes, so a stream ending in `/F1 12 Tf` parsed as FillWinding.

And the transparency-group fixture found a fourth:

- PdfPage::xobjects was empty for every page of every document.
  extract_xobjects called doc.resolve(), which follows the reference,
  then asked the resolved object for as_ref() - always None - and only
  accepted a bare dict when every XObject is a stream. No `Do` operator
  could be resolved through the page model. Same defect as the one that
  once destroyed annotation object references; it now has its own
  regression test.

T* and BMC are genuinely unimplemented and are deliberately excluded
from the guard's table rather than papered over.

7 corpus fixtures, 12 acceptance tests, 32 unit tests asserting the
§11.3.5 formulas (not our own output), and a parse_ext_gstate fuzz
target.

TEST_TARGET=pdf 451 -> 497, TEST_TARGET=pdf-ui 495 -> 541.
rustfmt and clippy -D warnings clean.
2026-07-31 18:58:31 +00:00

341 lines
11 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",
] {
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"
);
}
}