diff --git a/src/rendering/ext_gstate.rs b/src/rendering/ext_gstate.rs index 5ce278c3b..383bbdfcc 100644 --- a/src/rendering/ext_gstate.rs +++ b/src/rendering/ext_gstate.rs @@ -10,10 +10,10 @@ use crate::document::PdfDocument; use crate::error::Result; use crate::object::Object; -/// Parsed effects of a PDF `ExtGState` dictionary. Only the fields actually -/// applied during rendering are captured (fill/stroke alpha, blend mode, and -/// the overprint parameters from ISO 32000-1 §11.7.4). Anything else -/// (TK / SMask / AIS) is intentionally ignored so the cached entry stays tiny. +/// Parsed effects of a PDF `ExtGState` dictionary. Captures fill/stroke +/// alpha, blend mode, overprint parameters (§11.7.4), and the soft-mask +/// reference (§11.6.5.2). Anything else (TK / AIS) is intentionally ignored +/// so the cached entry stays tiny. #[derive(Clone, Debug, Default)] pub(crate) struct ParsedExtGState { pub(crate) fill_alpha: Option, @@ -25,6 +25,32 @@ pub(crate) struct ParsedExtGState { pub(crate) fill_overprint: Option, /// Overprint mode (ExtGState `/OPM`, §11.7.4). 0 = standard, 1 = nonzero. pub(crate) overprint_mode: Option, + /// Soft-mask reference (§11.6.5.2). The renderer materialises this into + /// an alpha mask pixmap after parsing — the parser stays pure, just + /// captures the dict / `/None` sentinel for the caller to act on. + pub(crate) soft_mask: Option, + /// Cached materialised soft-mask alpha buffer, populated by the renderer + /// on first use. The outer cache (the `ext_g_state_cache` HashMap in + /// `execute_operators`) is keyed by `dict_name`; this field is a + /// validity check against `cached_install_transform` so a repeat `gs` + /// call at a *different* CTM correctly re-rasterises the group instead + /// of reusing a stale buffer. + pub(crate) cached_soft_mask_alpha: Option, + /// CTM that produced `cached_soft_mask_alpha`. Compared bitwise to the + /// current install-time CTM to decide whether the cache is reusable. + pub(crate) cached_install_transform: Option, +} + +/// What an ExtGState `/SMask` entry tells the renderer to do. +#[derive(Clone, Debug)] +pub(crate) enum SoftMaskSpec { + /// `/SMask /None` — clear any currently-active soft mask. + None, + /// `/SMask ` — the dict is captured verbatim so the renderer can + /// read `/S` (subtype) and `/G` (the transparency-group Form XObject) + /// at render time, when it has the page pixmap context needed to + /// rasterise the group. + Dict(Object), } impl ParsedExtGState { @@ -106,6 +132,17 @@ pub(crate) fn parse_ext_g_state_inner( out.overprint_mode = Some(if opm == 1 { 1 } else { 0 }); } + // §11.6.5.2: `/SMask /None` clears any active soft mask; `/SMask ` + // installs a new one. We capture the dict here and defer the actual + // group rasterisation to the renderer where the page pixmap exists. + if let Some(smask) = state_dict.get("SMask") { + let resolved = doc.resolve_object(smask)?; + out.soft_mask = Some(match &resolved { + Object::Name(n) if n == "None" => SoftMaskSpec::None, + _ => SoftMaskSpec::Dict(resolved), + }); + } + Ok(out) } diff --git a/src/rendering/mod.rs b/src/rendering/mod.rs index d4814e72c..5643b9293 100644 --- a/src/rendering/mod.rs +++ b/src/rendering/mod.rs @@ -75,10 +75,15 @@ pub(crate) fn create_stroke_paint(gs: &GraphicsState, blend_mode: &str) -> Paint paint } -/// Convert PDF blend mode to tiny-skia. +/// Convert PDF blend mode (ISO 32000-1 §11.3.5) to tiny-skia. The four +/// non-separable modes (`Hue`, `Saturation`, `Color`, `Luminosity` — +/// §11.3.5.3) are dispatched to tiny-skia's native HSL-based blend +/// stages, which match the W3C / Skia / Acrobat formulas. The separable +/// modes are the §11.3.5.2 set. pub(crate) fn pdf_blend_mode_to_skia(mode: &str) -> tiny_skia::BlendMode { match mode { "Normal" => tiny_skia::BlendMode::SourceOver, + // Separable (§11.3.5.2) "Multiply" => tiny_skia::BlendMode::Multiply, "Screen" => tiny_skia::BlendMode::Screen, "Overlay" => tiny_skia::BlendMode::Overlay, @@ -90,6 +95,12 @@ pub(crate) fn pdf_blend_mode_to_skia(mode: &str) -> tiny_skia::BlendMode { "SoftLight" => tiny_skia::BlendMode::SoftLight, "Difference" => tiny_skia::BlendMode::Difference, "Exclusion" => tiny_skia::BlendMode::Exclusion, + // Non-separable (§11.3.5.3). These collapse to SourceOver if the + // tiny-skia version ever drops the HSL stages. + "Hue" => tiny_skia::BlendMode::Hue, + "Saturation" => tiny_skia::BlendMode::Saturation, + "Color" => tiny_skia::BlendMode::Color, + "Luminosity" => tiny_skia::BlendMode::Luminosity, _ => tiny_skia::BlendMode::SourceOver, } } diff --git a/src/rendering/page_renderer.rs b/src/rendering/page_renderer.rs index 65cd38a97..5a4c5c40c 100644 --- a/src/rendering/page_renderer.rs +++ b/src/rendering/page_renderer.rs @@ -19,7 +19,157 @@ use crate::content::parser::parse_content_stream; use crate::document::PdfDocument; use crate::error::{Error, Result}; use crate::object::{Object, ObjectRef}; -use crate::rendering::ext_gstate::{parse_ext_g_state_inner, ParsedExtGState}; +use crate::rendering::ext_gstate::{parse_ext_g_state_inner, ParsedExtGState, SoftMaskSpec}; +use std::borrow::Cow; + +/// Parse a Form XObject's `/Matrix` entry into a `tiny_skia::Transform`. +/// Defaults to identity when absent or malformed (§8.10.1). +fn parse_form_matrix(dict: &std::collections::HashMap) -> tiny_skia::Transform { + match dict.get("Matrix") { + Some(Object::Array(arr)) => { + let get = |i: usize, default: f32| -> f32 { + match arr.get(i) { + Some(Object::Real(v)) => *v as f32, + Some(Object::Integer(v)) => *v as f32, + _ => default, + } + }; + tiny_skia::Transform::from_row( + get(0, 1.0), + get(1, 0.0), + get(2, 0.0), + get(3, 1.0), + get(4, 0.0), + get(5, 0.0), + ) + }, + _ => tiny_skia::Transform::identity(), + } +} + +/// Knockout transparency group threshold for the alpha short-circuit +/// (§11.6.6.2). A fully opaque paint with `BM=Normal` is visually +/// identical between the knockout and non-knockout paths, so we +/// short-circuit the buffer dance when `alpha >= 1.0 - eps`. Slightly +/// under 1.0 to absorb f32 rounding. +const KNOCKOUT_ALPHA_OPAQUE: f32 = 0.9999; + +/// Compute the value to feed `knockout_aware_paint`'s `effective_alpha`. +/// For `BM=Normal` it's just the GS alpha; for any non-separable / non- +/// Normal blend mode the formula reads the destination, so even a fully +/// opaque paint needs the backdrop-redirect dance in a knockout group. +/// Returning `0.0` forces the helper to take the dance path regardless of +/// the actual GS alpha — `gs_alpha` itself isn't used downstream past the +/// `< KNOCKOUT_ALPHA_OPAQUE` comparison. +fn knockout_paint_alpha(gs_alpha: f32, blend_mode: &str) -> f32 { + if blend_mode == "Normal" { + gs_alpha + } else { + 0.0 + } +} + +/// Run a paint operation in a knockout-aware fashion. When the enclosing +/// group has a knockout backdrop and the effective alpha is below +/// [`KNOCKOUT_ALPHA_OPAQUE`], the paint targets a temp pixmap initialised +/// from the backdrop and the result is merged via [`knockout_merge`] — +/// each painted pixel replaces whatever the previous paint left there. +/// Fully opaque paints (and any paint outside a knockout group) target +/// `pixmap` directly, with zero overhead. +fn knockout_aware_paint( + pixmap: &mut Pixmap, + knockout_backdrop: Option<&Pixmap>, + effective_alpha: f32, + paint_fn: F, +) -> R +where + F: FnOnce(&mut Pixmap) -> R, +{ + if effective_alpha < KNOCKOUT_ALPHA_OPAQUE { + if let Some(backdrop) = knockout_backdrop { + // Per-paint clone is O(W·H). Tracked for a follow-up that + // hoists a single scratch pixmap across the knockout group + // and writes via `copy_from_slice` instead of `clone`. + let mut temp = backdrop.clone(); + // If `paint_fn` errors mid-paint, `temp` is left in whatever + // partial state the rasterizer wrote. We still run + // `knockout_merge` so the caller observes the same partial- + // write semantics as the non-knockout path (which would + // half-paint `pixmap` directly on the same error). + let result = paint_fn(&mut temp); + knockout_merge(pixmap, &temp, backdrop); + return result; + } + } + paint_fn(pixmap) +} + +/// Merge a temp pixmap (a paint rendered against the knockout backdrop) +/// back into the group's accumulating buffer. For each pixel where +/// `temp` differs from `backdrop` — i.e. each pixel the paint demonstrably +/// changed from the initial backdrop — `temp`'s value replaces the +/// destination. Pixels that compare equal to the backdrop remain whatever +/// the previous paint left in `dest`. +/// +/// Caveat: a paint that legitimately produces a byte-identical result to +/// the backdrop pixel (e.g. drawing white onto white, or fully transparent +/// over fully transparent) is treated as "untouched" and the prior paint's +/// value survives. In practice the affected cases are visually +/// indistinguishable; a coverage-mask-based detector would be needed for +/// strict §11.6.6.2 conformance in those corner cases. +/// +/// All three pixmaps must share dimensions; callers always allocate them +/// at the group pixmap's W*H so this holds. +fn knockout_merge(dest: &mut Pixmap, temp: &Pixmap, backdrop: &Pixmap) { + let temp_data = temp.data(); + let backdrop_data = backdrop.data(); + let dest_data = dest.data_mut(); + let len = dest_data.len(); + // chunks_exact(4) over RGBA pixels. Pixmap allocates W*H*4 bytes by + // construction so no trailing partial pixel. + let mut i = 0; + while i + 4 <= len { + if temp_data[i..i + 4] != backdrop_data[i..i + 4] { + dest_data[i..i + 4].copy_from_slice(&temp_data[i..i + 4]); + } + i += 4; + } +} + +/// Returns the current effective clip for paint operators — the intersection +/// of the active clipping-path mask and the active ExtGState soft-mask +/// (§11.6.5.2). The intersection composes the two alphas multiplicatively +/// per pixel (`out = a * b / 255`), which is the §11.3.4 "shape × opacity" +/// rule for 8-bpc alpha. Allocation only happens when *both* stacks +/// contribute a mask at the current level. +fn effective_clip<'a>( + clip_stack: &'a [Option], + soft_mask_stack: &'a [Option], +) -> Option> { + let clip = clip_stack.last().and_then(|c| c.as_ref()); + let smask = soft_mask_stack.last().and_then(|s| s.as_ref()); + match (clip, smask) { + (None, None) => None, + (Some(c), None) => Some(Cow::Borrowed(c)), + (None, Some(s)) => Some(Cow::Borrowed(s)), + (Some(c), Some(s)) => { + if c.width() != s.width() || c.height() != s.height() { + // Mismatched mask dimensions — fall back to the clip alone + // rather than mixing buffers of different sizes. The MVP + // soft-mask code renders the group at page pixmap dimensions, + // so this should not fire in practice. + return Some(Cow::Borrowed(c)); + } + let mut out = c.clone(); + let dst = out.data_mut(); + let src = s.data(); + for (d, &v) in dst.iter_mut().zip(src.iter()) { + *d = ((*d as u32 * v as u32) / 255) as u8; + } + Some(Cow::Owned(out)) + }, + } +} use crate::rendering::path_rasterizer::PathRasterizer; use crate::rendering::text_rasterizer::TextRasterizer; @@ -152,8 +302,19 @@ pub struct PageRenderer { /// access per `render_page` invocation. Stays `None` (no allocation) when /// the set is empty — the common case. excluded_layers_snapshot: Option>>, + /// Re-entrancy depth of `materialise_soft_mask_alpha`. The chain + /// SMask → /G content stream → `Do` → /GS → SMask is legal but + /// adversarial PDFs can construct self-referential cycles that would + /// stack-overflow the process. Capped at [`MAX_SMASK_DEPTH`]. + smask_depth: u32, } +/// Hard cap on nested ExtGState soft-mask materialisations within a single +/// page render. Cyclic `/G` references would otherwise recurse without +/// bound. 32 levels is well above any legitimate artwork; deeper than this +/// strongly indicates a malformed or adversarial fixture. +const MAX_SMASK_DEPTH: u32 = 32; + impl PageRenderer { /// Create a new page renderer with the specified options. pub fn new(options: RenderOptions) -> Self { @@ -164,6 +325,7 @@ impl PageRenderer { fonts: HashMap::new(), color_spaces: HashMap::new(), excluded_layers_snapshot: None, + smask_depth: 0, } } @@ -278,7 +440,15 @@ impl PageRenderer { }; // Execute operators - self.execute_operators(&mut pixmap, transform, &operators, doc, page_num, &resources)?; + self.execute_operators( + &mut pixmap, + transform, + &operators, + doc, + page_num, + &resources, + None, + )?; // Render annotations (if requested and present) if self.options.render_annotations { @@ -392,6 +562,7 @@ impl PageRenderer { /// OCG layer exclusion is sourced from `self.options.excluded_layers`; /// BDC/EMC operators referencing matching layers cause graphical operators /// inside that scope to be silently dropped. + #[allow(clippy::too_many_arguments)] fn execute_operators( &mut self, pixmap: &mut Pixmap, @@ -400,6 +571,7 @@ impl PageRenderer { doc: &PdfDocument, page_num: usize, resources: &Object, + knockout_backdrop: Option<&Pixmap>, ) -> Result<()> { // Per-render snapshot lives on `self.excluded_layers_snapshot` (filled // by `render_page_with_options`). Recursive calls into this function @@ -426,6 +598,17 @@ impl PageRenderer { let mut current_path = PathBuilder::new(); let mut pending_clip: Option<(tiny_skia::Path, tiny_skia::FillRule)> = None; let mut clip_stack: Vec> = vec![None]; // Start with no clip at depth 0 + // §11.6.5.2 soft-mask stack — mirrors `clip_stack` so q/Q save/restore + // the active mask along with the rest of the graphics state. The + // `Option` is a pre-rendered alpha buffer (subtype `/Alpha`); + // see `materialise_soft_mask_alpha` for how it is built. + let mut soft_mask_stack: Vec> = vec![None]; + // §11.6.6.2 knockout backdrop. When the enclosing transparency group + // has /K true the parent calls us with a snapshot of the group's + // initial pixmap; every translucent paint inside this content stream + // composites against that backdrop rather than the accumulating + // group buffer. `Option` keeps the non-knockout path zero-cost. + let knockout_backdrop: Option = knockout_backdrop.cloned(); // OCG layer exclusion tracking. // `excluded_layer_depth` counts how many nested BDC/OC scopes we are @@ -465,6 +648,8 @@ impl PageRenderer { // This allows the current level to modify its clip without affecting parents let current_clip = clip_stack.last().cloned().flatten(); clip_stack.push(current_clip); + let current_smask = soft_mask_stack.last().cloned().flatten(); + soft_mask_stack.push(current_smask); log::debug!( "q (SaveState), depth={}, clip_stack depth={}", gs_stack.depth(), @@ -477,6 +662,9 @@ impl PageRenderer { if clip_stack.len() > 1 { clip_stack.pop(); } + if soft_mask_stack.len() > 1 { + soft_mask_stack.pop(); + } log::debug!( "Q (RestoreState), depth={}, clip_stack depth={}", gs_stack.depth(), @@ -1044,12 +1232,21 @@ impl PageRenderer { base_transform, &gs_stack, ); - let clip = clip_stack.last().and_then(|c| c.as_ref()); + let clip_owned = effective_clip(&clip_stack, &soft_mask_stack); + let clip = clip_owned.as_deref(); if let Some(path) = current_path.finish() { let gs = gs_stack.current(); let transform = combine_transforms(base_transform, &gs.ctm); - self.path_rasterizer - .stroke_path_clipped(pixmap, &path, transform, gs, clip); + let path_rasterizer = &mut self.path_rasterizer; + knockout_aware_paint( + pixmap, + knockout_backdrop.as_ref(), + knockout_paint_alpha(gs.stroke_alpha, &gs.blend_mode), + |target| { + path_rasterizer + .stroke_path_clipped(target, &path, transform, gs, clip); + }, + ); } } else { let _ = current_path.finish(); @@ -1065,17 +1262,26 @@ impl PageRenderer { base_transform, &gs_stack, ); - let clip = clip_stack.last().and_then(|c| c.as_ref()); + let clip_owned = effective_clip(&clip_stack, &soft_mask_stack); + let clip = clip_owned.as_deref(); if let Some(path) = current_path.finish() { let gs = gs_stack.current(); let transform = combine_transforms(base_transform, &gs.ctm); - self.path_rasterizer.fill_path_clipped( + let path_rasterizer = &mut self.path_rasterizer; + knockout_aware_paint( pixmap, - &path, - transform, - gs, - tiny_skia::FillRule::Winding, - clip, + knockout_backdrop.as_ref(), + knockout_paint_alpha(gs.fill_alpha, &gs.blend_mode), + |target| { + path_rasterizer.fill_path_clipped( + target, + &path, + transform, + gs, + tiny_skia::FillRule::Winding, + clip, + ); + }, ); } } else { @@ -1094,7 +1300,8 @@ impl PageRenderer { base_transform, &gs_stack, ); - let clip = clip_stack.last().and_then(|c| c.as_ref()); + let clip_owned = effective_clip(&clip_stack, &soft_mask_stack); + let clip = clip_owned.as_deref(); if let Some(path) = current_path.finish() { let gs = gs_stack.current(); let transform = combine_transforms(base_transform, &gs.ctm); @@ -1103,10 +1310,26 @@ impl PageRenderer { } else { tiny_skia::FillRule::Winding }; - self.path_rasterizer - .fill_path_clipped(pixmap, &path, transform, gs, fill_rule, clip); - self.path_rasterizer - .stroke_path_clipped(pixmap, &path, transform, gs, clip); + let path_rasterizer = &mut self.path_rasterizer; + knockout_aware_paint( + pixmap, + knockout_backdrop.as_ref(), + knockout_paint_alpha(gs.fill_alpha, &gs.blend_mode), + |target| { + path_rasterizer.fill_path_clipped( + target, &path, transform, gs, fill_rule, clip, + ); + }, + ); + knockout_aware_paint( + pixmap, + knockout_backdrop.as_ref(), + knockout_paint_alpha(gs.stroke_alpha, &gs.blend_mode), + |target| { + path_rasterizer + .stroke_path_clipped(target, &path, transform, gs, clip); + }, + ); } } else { let _ = current_path.finish(); @@ -1122,21 +1345,38 @@ impl PageRenderer { base_transform, &gs_stack, ); - let clip = clip_stack.last().and_then(|c| c.as_ref()); + let clip_owned = effective_clip(&clip_stack, &soft_mask_stack); + let clip = clip_owned.as_deref(); if let Some(path) = current_path.finish() { let gs = gs_stack.current(); let transform = combine_transforms(base_transform, &gs.ctm); - self.path_rasterizer.fill_path_clipped( + let path_rasterizer = &mut self.path_rasterizer; + knockout_aware_paint( pixmap, - &path, - transform, - gs, - tiny_skia::FillRule::EvenOdd, - clip, + knockout_backdrop.as_ref(), + knockout_paint_alpha(gs.fill_alpha, &gs.blend_mode), + |target| { + path_rasterizer.fill_path_clipped( + target, + &path, + transform, + gs, + tiny_skia::FillRule::EvenOdd, + clip, + ); + }, ); if matches!(op, Operator::FillStrokeEvenOdd) { - self.path_rasterizer - .stroke_path_clipped(pixmap, &path, transform, gs, clip); + knockout_aware_paint( + pixmap, + knockout_backdrop.as_ref(), + knockout_paint_alpha(gs.stroke_alpha, &gs.blend_mode), + |target| { + path_rasterizer.stroke_path_clipped( + target, &path, transform, gs, clip, + ); + }, + ); } } } else { @@ -1204,17 +1444,20 @@ impl PageRenderer { if in_text_object { let gs = gs_stack.current(); let advance = if excluded_layer_depth == 0 { - let clip = clip_stack.last().and_then(|c| c.as_ref()); + let clip_owned = effective_clip(&clip_stack, &soft_mask_stack); + let clip = clip_owned.as_deref(); let transform = combine_transforms(base_transform, &gs.ctm); - self.text_rasterizer.render_text( + let text_rasterizer = &mut self.text_rasterizer; + let fonts = &self.fonts; + knockout_aware_paint( pixmap, - text, - transform, - gs, - resources, - doc, - clip, - &self.fonts, + knockout_backdrop.as_ref(), + knockout_paint_alpha(gs.fill_alpha, &gs.blend_mode), + |target| { + text_rasterizer.render_text( + target, text, transform, gs, resources, doc, clip, fonts, + ) + }, )? } else { self.text_rasterizer.measure_text(text, gs, &self.fonts) @@ -1236,26 +1479,20 @@ impl PageRenderer { let gs = gs_stack.current(); let advance = if excluded_layer_depth == 0 { - let clip = clip_stack.last().and_then(|c| c.as_ref()); + let clip_owned = effective_clip(&clip_stack, &soft_mask_stack); + let clip = clip_owned.as_deref(); let transform = combine_transforms(base_transform, &gs.ctm); - log::debug!( - "' (Quote): rendering text at Tm=[{}, {}, {}, {}, {}, {}]", - gs.text_matrix.a, - gs.text_matrix.b, - gs.text_matrix.c, - gs.text_matrix.d, - gs.text_matrix.e, - gs.text_matrix.f - ); - self.text_rasterizer.render_text( + let text_rasterizer = &mut self.text_rasterizer; + let fonts = &self.fonts; + knockout_aware_paint( pixmap, - text, - transform, - gs, - resources, - doc, - clip, - &self.fonts, + knockout_backdrop.as_ref(), + knockout_paint_alpha(gs.fill_alpha, &gs.blend_mode), + |target| { + text_rasterizer.render_text( + target, text, transform, gs, resources, doc, clip, fonts, + ) + }, )? } else { self.text_rasterizer.measure_text(text, gs, &self.fonts) @@ -1270,7 +1507,8 @@ impl PageRenderer { if in_text_object { let gs = gs_stack.current(); let advance = if excluded_layer_depth == 0 { - let clip = clip_stack.last().and_then(|c| c.as_ref()); + let clip_owned = effective_clip(&clip_stack, &soft_mask_stack); + let clip = clip_owned.as_deref(); let transform = combine_transforms(base_transform, &gs.ctm); log::debug!( "TJ: rendering array at Tm=[{}, {}, {}, {}, {}, {}]", @@ -1281,15 +1519,17 @@ impl PageRenderer { gs.text_matrix.e, gs.text_matrix.f ); - self.text_rasterizer.render_tj_array( + let text_rasterizer = &mut self.text_rasterizer; + let fonts = &self.fonts; + knockout_aware_paint( pixmap, - array, - transform, - gs, - resources, - doc, - clip, - &self.fonts, + knockout_backdrop.as_ref(), + knockout_paint_alpha(gs.fill_alpha, &gs.blend_mode), + |target| { + text_rasterizer.render_tj_array( + target, array, transform, gs, resources, doc, clip, fonts, + ) + }, )? } else { self.text_rasterizer @@ -1319,7 +1559,8 @@ impl PageRenderer { let gs = gs_stack.current(); let advance = if excluded_layer_depth == 0 { - let clip = clip_stack.last().and_then(|c| c.as_ref()); + let clip_owned = effective_clip(&clip_stack, &soft_mask_stack); + let clip = clip_owned.as_deref(); let transform = combine_transforms(base_transform, &gs.ctm); log::debug!( "\" (DoubleQuote): rendering text at Tm=[{}, {}, {}, {}, {}, {}]", @@ -1330,15 +1571,17 @@ impl PageRenderer { gs.text_matrix.e, gs.text_matrix.f ); - self.text_rasterizer.render_text( + let text_rasterizer = &mut self.text_rasterizer; + let fonts = &self.fonts; + knockout_aware_paint( pixmap, - text, - transform, - gs, - resources, - doc, - clip, - &self.fonts, + knockout_backdrop.as_ref(), + knockout_paint_alpha(gs.fill_alpha, &gs.blend_mode), + |target| { + text_rasterizer.render_text( + target, text, transform, gs, resources, doc, clip, fonts, + ) + }, )? } else { self.text_rasterizer.measure_text(text, gs, &self.fonts) @@ -1355,10 +1598,23 @@ impl PageRenderer { if excluded_layer_depth == 0 { let gs = gs_stack.current(); let transform = combine_transforms(base_transform, &gs.ctm); - let clip = clip_stack.last().and_then(|c| c.as_ref()); + let clip_owned = effective_clip(&clip_stack, &soft_mask_stack); + let clip = clip_owned.as_deref(); log::debug!("Do: rendering XObject '{}'", name); - self.render_xobject( - pixmap, name, transform, gs, resources, doc, page_num, clip, + // §11.6.6.2: treat the Do as one element for + // knockout purposes — render the image or form into + // a backdrop-relative temp and merge so it replaces + // (rather than blends with) prior paints in the group. + let alpha = knockout_paint_alpha(gs.fill_alpha, &gs.blend_mode); + knockout_aware_paint( + pixmap, + knockout_backdrop.as_ref(), + alpha, + |target| { + self.render_xobject( + target, name, transform, gs, resources, doc, page_num, clip, + ) + }, )?; } }, @@ -1440,6 +1696,64 @@ impl PageRenderer { ParsedExtGState::default() }); entry.apply(gs_stack.current_mut()); + // §11.6.5.2 soft mask handling. `/SMask /None` clears + // the active mask; `/SMask ` rasterises the + // group into a pixmap and stashes its alpha channel + // as the new mask. Rasterisation is deferred to here + // (rather than the parser) because it needs the page + // pixmap context and the renderer's own `render_form_xobject`. + if let Some(spec) = entry.soft_mask.clone() { + match spec { + SoftMaskSpec::None => { + if let Some(slot) = soft_mask_stack.last_mut() { + *slot = None; + } + }, + SoftMaskSpec::Dict(dict_obj) => { + // §11.6.5.2: the SMask group is rendered at the + // CTM that was current at install time, not the + // page-level base transform alone. + let install_transform = + combine_transforms(base_transform, &gs_stack.current().ctm); + // Cache reuse: only when the install CTM + // matches bitwise. A different CTM produces a + // different mask, so falling through to the + // materialise path is required for correctness. + let cache_hit = entry + .cached_install_transform + .map(|t| t == install_transform) + .unwrap_or(false) + && entry.cached_soft_mask_alpha.is_some(); + if cache_hit { + if let Some(slot) = soft_mask_stack.last_mut() { + *slot = entry.cached_soft_mask_alpha.clone(); + } + } else { + match self.materialise_soft_mask_alpha( + &dict_obj, + pixmap.width(), + pixmap.height(), + install_transform, + doc, + page_num, + resources, + ) { + Ok(mask) => { + entry.cached_soft_mask_alpha = Some(mask.clone()); + entry.cached_install_transform = + Some(install_transform); + if let Some(slot) = soft_mask_stack.last_mut() { + *slot = Some(mask); + } + }, + Err(e) => { + log::warn!("Skipping SMask on /{}: {}", dict_name, e); + }, + } + } + }, + } + } }, // EndPath (n operator): discard current path without painting, @@ -1468,8 +1782,19 @@ impl PageRenderer { if excluded_layer_depth == 0 { let gs = gs_stack.current(); let transform = combine_transforms(base_transform, &gs.ctm); - let clip = clip_stack.last().and_then(|c| c.as_ref()); - self.render_shading(pixmap, name, transform, gs, resources, doc, clip)?; + let clip_owned = effective_clip(&clip_stack, &soft_mask_stack); + let clip = clip_owned.as_deref(); + let alpha = knockout_paint_alpha(gs.fill_alpha, &gs.blend_mode); + knockout_aware_paint( + pixmap, + knockout_backdrop.as_ref(), + alpha, + |target| { + self.render_shading( + target, name, transform, gs, resources, doc, clip, + ) + }, + )?; } }, @@ -2200,6 +2525,374 @@ impl PageRenderer { Ok(()) } +} + +/// Which channel of the rendered SMask group becomes the alpha mask buffer +/// (ISO 32000-1 §11.6.5). +#[derive(Clone, Copy)] +enum SoftMaskKind { + /// Subtype `/Alpha` (§11.6.5.2): use the group's alpha channel. + Alpha, + /// Subtype `/Luminosity` (§11.6.5.3): use the per-pixel BT.601 luma of + /// the group's premultiplied RGB. + Luminosity, +} + +/// SMask `/TR` transfer function. PDF functions can be of several types; +/// only the ones plausibly used for soft masks are implemented here. +/// Identity is represented by `None` at the call site. +#[derive(Clone, Debug)] +enum SoftMaskTransfer { + /// Type 2 exponential: `y = C0 + x^N * (C1 - C0)`. + /// For SMask mask buffers `C0` and `C1` are always 1-vector entries. + Type2 { c0: f64, c1: f64, n: f64 }, +} + +impl SoftMaskTransfer { + /// Apply the transfer function to a mask value in `[0, 1]`. + fn apply(&self, x: f64) -> f64 { + match *self { + SoftMaskTransfer::Type2 { c0, c1, n } => c0 + x.powf(n) * (c1 - c0), + } + } +} + +/// Parse the SMask `/TR` entry. Returns `None` when the entry is absent, +/// is the name `/Identity`, or is a function of an unsupported type — the +/// caller treats those identically to "skip transfer". Unknown shapes log +/// a `debug` so production logs don't flood. +fn parse_soft_mask_transfer( + tr_obj: Option<&Object>, + doc: &PdfDocument, +) -> Option { + let tr = tr_obj?; + let resolved = doc.resolve_object(tr).ok()?; + if matches!(&resolved, Object::Name(n) if n == "Identity") { + return None; + } + let dict = resolved.as_dict()?; + let func_type = dict.get("FunctionType").and_then(|o| o.as_integer())?; + match func_type { + 2 => { + // §7.10.3: Type 2 produces `n`-component output where + // `n = len(C0) = len(C1)`. SMask /TR is single-component; + // reject multi-component functions outright rather than + // silently picking C0[0] / C1[0] from a wider vector. + let c0_arr = dict.get("C0").and_then(|o| o.as_array()); + let c1_arr = dict.get("C1").and_then(|o| o.as_array()); + let c0 = if let Some(arr) = c0_arr { + if arr.len() != 1 { + log::debug!("SMask /TR Type 2 has {}-component /C0; expected 1", arr.len()); + return None; + } + as_f64(arr.first()?).unwrap_or(0.0) + } else { + 0.0 + }; + let c1 = if let Some(arr) = c1_arr { + if arr.len() != 1 { + log::debug!("SMask /TR Type 2 has {}-component /C1; expected 1", arr.len()); + return None; + } + as_f64(arr.first()?).unwrap_or(1.0) + } else { + 1.0 + }; + let n = dict.get("N").and_then(as_f64).unwrap_or(1.0); + // §7.10.3 requires N > 0 (and for non-integer N, Domain must + // exclude 0). Reject N <= 0 — `0_f64.powf(0.0)` returns 1.0 + // (IEEE 754) which would flip a "blocked" mask pixel into + // "fully passes", exactly the wrong direction for a malformed + // function. + if !(n > 0.0 && n.is_finite()) { + log::debug!("SMask /TR Type 2 has invalid /N = {n}; skipping"); + return None; + } + Some(SoftMaskTransfer::Type2 { c0, c1, n }) + }, + other => { + log::debug!("SMask /TR FunctionType {other} not supported; skipping"); + None + }, + } +} + +/// Coerce a PDF numeric object to `f64`. Returns `None` for anything else. +fn as_f64(obj: &Object) -> Option { + match obj { + Object::Real(v) => Some(*v), + Object::Integer(v) => Some(*v as f64), + _ => None, + } +} + +/// Parse the SMask `/BC` (backdrop colour) entry into an opaque RGBA pixel. +/// `group_cs` is the group's blend colour space name (e.g. `DeviceRGB`, +/// `DeviceGray`, `DeviceCMYK`); other / array spaces fall through to +/// "treat the components as RGB if 3, gray if 1, CMYK if 4". +/// +/// Returns `None` for the default backdrop (black in the group CS), which +/// already matches the all-zero initial state of `Pixmap::new`. The caller +/// only needs to pre-fill when a non-default backdrop is present. +fn parse_soft_mask_backdrop(bc_obj: Option<&Object>, group_cs: &str) -> Option<[u8; 4]> { + let arr = bc_obj?.as_array()?; + let get = |i: usize| -> Option { arr.get(i).and_then(as_f64).map(|v| v as f32) }; + // Determine component count from /CS, falling back to array length when + // the CS is unknown. + let (r, g, b) = match (group_cs, arr.len()) { + ("DeviceGray" | "CalGray", _) | (_, 1) => { + let v = get(0)?; + let q = (v.clamp(0.0, 1.0) * 255.0).round() as u8; + (q, q, q) + }, + ("DeviceRGB" | "CalRGB" | "ICCBased", _) | (_, 3) => { + let r = (get(0)?.clamp(0.0, 1.0) * 255.0).round() as u8; + let g = (get(1)?.clamp(0.0, 1.0) * 255.0).round() as u8; + let b = (get(2)?.clamp(0.0, 1.0) * 255.0).round() as u8; + (r, g, b) + }, + ("DeviceCMYK" | "CalCMYK", _) | (_, 4) => { + // Approximate CMYK→RGB without ICC: R = (1 - C)(1 - K) etc. + // Correct for the common "K-only" and "process-CMYK" backdrops + // we expect from real artwork; full ICC fidelity is out of scope. + let c = get(0)?.clamp(0.0, 1.0); + let m = get(1)?.clamp(0.0, 1.0); + let y = get(2)?.clamp(0.0, 1.0); + let k = get(3)?.clamp(0.0, 1.0); + let r = ((1.0 - c) * (1.0 - k) * 255.0).round() as u8; + let g = ((1.0 - m) * (1.0 - k) * 255.0).round() as u8; + let b = ((1.0 - y) * (1.0 - k) * 255.0).round() as u8; + (r, g, b) + }, + _ => return None, + }; + // Default backdrop (black) requires no pre-fill; the pixmap is already + // (0, 0, 0, 0). + if r == 0 && g == 0 && b == 0 { + return None; + } + Some([r, g, b, 255]) +} + +impl PageRenderer { + /// Render an ExtGState `/SMask` group into an offscreen pixmap and + /// return its mask buffer as a `tiny_skia::Mask` for use as a clip on + /// subsequent paint operations (ISO 32000-1 §11.6.5.2 / §11.6.5.3). + /// + /// Subtypes: + /// - `/Alpha`: the rendered group's alpha channel is the mask. + /// `/BC` is ignored per spec. + /// - `/Luminosity`: per-pixel BT.601 luma of the rendered group's + /// premultiplied RGB. Always BT.601 on the rasterised RGB — + /// there is no `/CS`-aware luma dispatch. Implications: + /// * Valid DeviceGray groups (`R = G = B`) collapse to + /// `Y = R`, matching the spec result. + /// * Valid DeviceRGB groups get the spec result up to the + /// BT.601 vs Rec.709 vs ICC-defined luma weighting choice. + /// * Valid DeviceCMYK groups go through the renderer's + /// CMYK→RGB pre-conversion before luma is read; this is an + /// approximation and will drift from a spec-correct + /// CMYK-blend-space luma calculation. + /// * Malformed groups (e.g. `/CS /DeviceGray` with RGB paint + /// operators) get BT.601 on the actual RGB; see + /// `tests/test_smask_alpha.rs::ext_gstate_luminosity_smask_malformed_devicegray_with_rgb_paint_uses_bt601`. + /// + /// A proper `/CS` dispatch would need a non-RGB blend buffer + /// (separate gray / CMYK pixmaps) which the renderer does not + /// currently provide. + /// + /// `/BC` (backdrop colour) for Luminosity: parsed against the group's + /// declared `/CS` (DeviceGray / DeviceRGB / DeviceCMYK; other CS fall + /// back to component-count inference) and pre-filled into the offscreen + /// pixmap so unpainted areas contribute the right luminance. ICC and + /// Lab conversions for `/BC` are not implemented. + /// + /// `/TR` (transfer function): applied pointwise after the subtype + /// buffer is computed. Type 2 (exponential) is supported directly; + /// `/Identity`, missing `/TR`, and other types (0 sampled, 3 stitching, + /// 4 PostScript) are no-ops. + #[allow(clippy::too_many_arguments)] + fn materialise_soft_mask_alpha( + &mut self, + smask_dict_obj: &Object, + width: u32, + height: u32, + base_transform: Transform, + doc: &PdfDocument, + page_num: usize, + resources: &Object, + ) -> Result { + if self.smask_depth >= MAX_SMASK_DEPTH { + return Err(crate::error::Error::InvalidPdf(format!( + "SMask nesting exceeded {MAX_SMASK_DEPTH} levels — possible cyclic /G reference", + ))); + } + + let smask_dict = smask_dict_obj.as_dict().ok_or_else(|| { + crate::error::Error::InvalidPdf("SMask is not a dictionary".to_string()) + })?; + + // §11.6.5.2 Table 144 marks /S as required. Real-world PDFs + // occasionally omit it; rather than picking a wrong default and + // mis-rasterising the group, skip-with-debug. The outer + // SetExtGState handler logs a `warn` for the skip itself. + let subtype = smask_dict + .get("S") + .and_then(|o| o.as_name()) + .ok_or_else(|| { + crate::error::Error::InvalidPdf( + "SMask dict missing required /S (subtype) — skipping".to_string(), + ) + })?; + let smask_kind = match subtype { + "Alpha" => SoftMaskKind::Alpha, + "Luminosity" => SoftMaskKind::Luminosity, + other => { + return Err(crate::error::Error::InvalidPdf(format!( + "SMask subtype /{other} not recognised" + ))); + }, + }; + + // §11.6.5.3 /TR — a function applied to each mask value after the + // subtype-specific buffer is computed. Most SMasks have no /TR or + // use /Identity, in which case `transfer` stays `None`. + let transfer = parse_soft_mask_transfer(smask_dict.get("TR"), doc); + + let group_obj = smask_dict.get("G").ok_or_else(|| { + crate::error::Error::InvalidPdf("SMask missing /G transparency group".to_string()) + })?; + let group_resolved = doc.resolve_object(group_obj)?; + let group_dict = match &group_resolved { + Object::Stream { dict, .. } => dict.clone(), + _ => { + return Err(crate::error::Error::InvalidPdf("SMask /G is not a stream".to_string())) + }, + }; + let group_data = if let Some(stream_ref) = group_obj.as_reference() { + doc.decode_stream_with_encryption(&group_resolved, stream_ref)? + } else { + group_resolved.decode_stream_data()? + }; + + // Render the group into a fresh pixmap matching the page's dimensions. + // Form /Matrix + /BBox position the painted content inside that buffer. + // Areas outside the group's painted region keep their initial alpha = 0, + // which is the correct subtractive default for `/S /Alpha`. + let mut group_pixmap = Pixmap::new(width, height).ok_or_else(|| { + crate::error::Error::InvalidPdf("Failed to allocate SMask group pixmap".to_string()) + })?; + + // §7.8.3: /Resources may be an indirect reference. The previous code + // grabbed it raw and `load_resources` would short-circuit because it + // only handles Dictionaries — fonts/colorspaces declared by the SMask + // group itself silently failed to load. + let form_resources = match group_dict.get("Resources") { + Some(o) => doc.resolve_object(o)?, + None => resources.clone(), + }; + let old_fonts = self.fonts.clone(); + let old_cs = self.color_spaces.clone(); + self.load_resources(doc, &form_resources)?; + + // §11.6.5.3 /BC + §11.6.6 Group /CS — pre-fill the group pixmap + // with the backdrop colour for Luminosity masks. Alpha masks ignore + // /BC by spec (the alpha channel of "no paint" is 0 regardless). + // /BC is only honoured for Luminosity; the default black backdrop + // requires no pre-fill since `Pixmap::new` already gives (0,0,0,0). + // §11.6.6 group /CS may be a name or an array (`[/ICCBased ]`, + // `[/CalRGB ]`, etc.). Resolve the array form to its first- + // element name so /BC interprets components against the right CS + // family; defaults to DeviceRGB when neither shape is present. + let group_cs = group_dict + .get("Group") + .and_then(|g| g.as_dict()) + .and_then(|gd| gd.get("CS")) + .and_then(|cs| match cs { + Object::Name(n) => Some(n.as_str()), + Object::Array(a) => a.first().and_then(|o| o.as_name()), + _ => None, + }) + .unwrap_or("DeviceRGB"); + if matches!(smask_kind, SoftMaskKind::Luminosity) { + if let Some(bg) = parse_soft_mask_backdrop(smask_dict.get("BC"), group_cs) { + for chunk in group_pixmap.data_mut().chunks_exact_mut(4) { + chunk.copy_from_slice(&bg); + } + } + } + + // Render the form's contents directly into `group_pixmap`. We + // deliberately bypass `render_form_xobject`: that path would detect + // the form's `/Group /S /Transparency` and allocate a *second* + // page-sized pixmap to act as the transparency-group buffer. But + // `group_pixmap` *is* the transparency-group buffer — it starts + // fully transparent (Pixmap::new) which is the correct isolated- + // group initial backdrop, so the double allocation is pure waste. + // Nested Form XObjects inside the SMask group still go through + // `render_form_xobject` via `Operator::Do`, so their own + // transparency groups are honoured. + let form_matrix = parse_form_matrix(&group_dict); + let install_transform = base_transform.pre_concat(form_matrix); + let operators = parse_content_stream(&group_data)?; + self.smask_depth += 1; + let render_res = self.execute_operators( + &mut group_pixmap, + install_transform, + &operators, + doc, + page_num, + &form_resources, + None, + ); + self.smask_depth -= 1; + + self.fonts = old_fonts; + self.color_spaces = old_cs; + render_res?; + + // Build the Mask buffer from the group pixmap. Source pixels are + // tiny-skia's premultiplied RGBA; for /Luminosity we read straight + // from the premultiplied R/G/B which is correct for the default + // black /BC (unpainted pixels contribute zero, painted pixels' + // luminance scales with their own alpha — both spec-aligned for the + // common case). + let mut mask = tiny_skia::Mask::new(width, height).ok_or_else(|| { + crate::error::Error::InvalidPdf("Failed to allocate SMask buffer".to_string()) + })?; + let mask_data = mask.data_mut(); + match smask_kind { + SoftMaskKind::Alpha => { + for (i, chunk) in group_pixmap.data().chunks_exact(4).enumerate() { + mask_data[i] = chunk[3]; + } + }, + SoftMaskKind::Luminosity => { + for (i, chunk) in group_pixmap.data().chunks_exact(4).enumerate() { + // BT.601 luma: Y = 0.299·R + 0.587·G + 0.114·B. + // Integer form with weights × 256 (77 + 150 + 29 = 256) + // and `>> 8` so the result stays inside u8. + let r = chunk[0] as u32; + let g = chunk[1] as u32; + let b = chunk[2] as u32; + mask_data[i] = ((r * 77 + g * 150 + b * 29) >> 8) as u8; + } + }, + } + + // §11.6.5 /TR — apply the transfer function pointwise to the + // computed mask buffer. Mask byte 0..=255 maps to function input + // 0.0..=1.0; the function's output is clamped back to 0..=255. + if let Some(tr) = transfer.as_ref() { + for byte in mask_data.iter_mut() { + let input = *byte as f64 / 255.0; + let output = tr.apply(input).clamp(0.0, 1.0); + *byte = (output * 255.0).round() as u8; + } + } + + Ok(mask) + } /// Render a Form XObject by parsing its content stream recursively. /// @@ -2215,40 +2908,17 @@ impl PageRenderer { page_num: usize, parent_resources: &Object, ) -> Result<()> { - // Parse /Matrix from form dict (default: identity) - let form_matrix = if let Some(Object::Array(arr)) = dict.get("Matrix") { - let get_f32 = |i: usize| -> f32 { - match arr.get(i) { - Some(Object::Real(v)) => *v as f32, - Some(Object::Integer(v)) => *v as f32, - _ => { - if i == 0 || i == 3 { - 1.0 - } else { - 0.0 - } - }, - } - }; - Transform::from_row( - get_f32(0), - get_f32(1), - get_f32(2), - get_f32(3), - get_f32(4), - get_f32(5), - ) - } else { - Transform::identity() - }; + let form_matrix = parse_form_matrix(dict); // Combine parent transform with form matrix let combined_transform = parent_transform.pre_concat(form_matrix); - // Check for transparency group (PDF spec section 11.6.6) - let is_transparency_group = dict - .get("Group") - .and_then(|g| g.as_dict()) + // Check for transparency group (PDF spec section 11.6.6). /Group may be + // an indirect reference (`/Group 12 0 R`) in real-world output; resolve + // it before reading its fields. + let group_obj = dict.get("Group").and_then(|g| doc.resolve_object(g).ok()); + let group_dict = group_obj.as_ref().and_then(|g| g.as_dict()); + let is_transparency_group = group_dict .map(|gd| gd.get("S").and_then(|s| s.as_name()) == Some("Transparency")) .unwrap_or(false); @@ -2271,17 +2941,31 @@ impl PageRenderer { // Per PDF spec 11.6.6: Render transparency group to a separate pixmap, // then composite onto the parent. For isolated groups (I=true), the // initial backdrop is fully transparent. - let is_isolated = dict - .get("Group") - .and_then(|g| g.as_dict()) - .and_then(|gd| gd.get("I")) - .map(|i| match i { + // + // Accept boolean true *or* a non-zero integer for /I and /K — + // some legacy tools emit `/K 1` instead of `/K true`. + let parse_flag = |v: &Object| -> bool { + match v { Object::Boolean(b) => *b, + Object::Integer(n) => *n != 0, _ => false, - }) + } + }; + let is_isolated = group_dict + .and_then(|gd| gd.get("I")) + .map(&parse_flag) + .unwrap_or(false); + // §11.6.6.2 knockout flag — when true, each painted element + // composites against the group's *initial backdrop* rather than + // the accumulating result. + let is_knockout = group_dict + .and_then(|gd| gd.get("K")) + .map(&parse_flag) .unwrap_or(false); - log::debug!("Rendering transparency group (isolated={})", is_isolated); + log::debug!( + "Rendering transparency group (isolated={is_isolated}, knockout={is_knockout})" + ); // Create a separate pixmap for the group let mut group_pixmap = @@ -2295,6 +2979,15 @@ impl PageRenderer { } // Isolated groups start fully transparent (default Pixmap state) + // For knockout groups, snapshot the initial pixmap state — every + // paint inside the group's content stream needs to composite + // against this backdrop instead of the accumulating buffer. + let knockout_backdrop = if is_knockout { + Some(group_pixmap.clone()) + } else { + None + }; + // Execute operators into the group pixmap self.execute_operators( &mut group_pixmap, @@ -2303,6 +2996,7 @@ impl PageRenderer { doc, page_num, &form_resources, + knockout_backdrop.as_ref(), )?; if is_isolated { @@ -2328,6 +3022,7 @@ impl PageRenderer { doc, page_num, &form_resources, + None, )?; } diff --git a/tests/test_knockout_group.rs b/tests/test_knockout_group.rs new file mode 100644 index 000000000..5f27b7984 --- /dev/null +++ b/tests/test_knockout_group.rs @@ -0,0 +1,426 @@ +//! Knockout transparency-group tests (ISO 32000-1 §11.6.6.2). +//! +//! In a knockout group each painted element composites against the group's +//! *initial backdrop* rather than against the accumulating result of +//! preceding paints. Where shapes overlap, the later shape entirely +//! replaces the earlier — no blend math, no carry-forward. +//! +//! Implementation: per-paint-operator backdrop redirect, gated on +//! `effective_alpha < 1.0` so fully opaque paints short-circuit to the +//! normal paint path (visually identical to non-knockout for opaque +//! content; spec-aligned). + +#![cfg(feature = "rendering")] + +use pdf_oxide::rendering::{render_page, RenderOptions}; +use pdf_oxide::PdfDocument; + +fn finalize_pdf(mut buf: Vec, offsets: Vec) -> Vec { + let xref_offset = buf.len(); + buf.extend_from_slice(b"xref\n"); + buf.extend_from_slice(format!("0 {}\n", offsets.len() + 1).as_bytes()); + buf.extend_from_slice(b"0000000000 65535 f \n"); + for off in &offsets { + buf.extend_from_slice(format!("{:010} 00000 n \n", off).as_bytes()); + } + buf.extend_from_slice( + format!( + "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n", + offsets.len() + 1, + xref_offset + ) + .as_bytes(), + ); + buf +} + +fn decode_png(bytes: &[u8]) -> image::RgbaImage { + let cursor = std::io::Cursor::new(bytes); + image::load(cursor, image::ImageFormat::Png) + .expect("decode PNG") + .to_rgba8() +} + +/// Build a PDF whose page invokes a Form XObject. The form is an isolated +/// transparency group with `/K knockout`; its content paints a 50%-alpha +/// red rectangle followed by a 50%-alpha blue rectangle covering the same +/// area. With knockout, the blue replaces the red (composited against the +/// group's transparent backdrop); without knockout, the blue blends with +/// the red and the centre pixel retains a red component. +/// +/// `knockout` controls the `/K` flag emitted on the form's `/Group` dict +/// so the same fixture can produce both the "with" and "without" cases. +fn build_pdf_with_overlapping_translucent_rects(knockout: bool) -> Vec { + let page_content = b"/F1 Do\n"; + let form_content = b"/GS1 gs\n1 0 0 rg\n0 0 100 100 re\nf\n0 0 1 rg\n0 0 100 100 re\nf\n"; + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + buf.extend_from_slice(b"%PDF-1.4\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R \ + /Resources << /XObject << /F1 5 0 R >> >> \ + /Group << /Type /Group /S /Transparency >> >>\nendobj\n", + ); + offsets.push(buf.len()); + let hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", page_content.len()); + buf.extend_from_slice(hdr.as_bytes()); + buf.extend_from_slice(page_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + // Form XObject with /K knockout (when `knockout` is true) and an + // ExtGState resource for the half-alpha fill. + offsets.push(buf.len()); + let k_entry = if knockout { " /K true" } else { "" }; + let form_hdr = format!( + "5 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] \ + /Group << /Type /Group /S /Transparency /I true{k_entry} >> \ + /Resources << /ExtGState << /GS1 6 0 R >> >> /Length {} >>\nstream\n", + form_content.len() + ); + buf.extend_from_slice(form_hdr.as_bytes()); + buf.extend_from_slice(form_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"6 0 obj\n<< /Type /ExtGState /ca 0.5 >>\nendobj\n"); + + finalize_pdf(buf, offsets) +} + +/// Two 50%-alpha overlapping rectangles in a knockout group: the blue +/// fill must replace the red fill rather than blending with it. The +/// centre pixel should therefore have very little red — significantly +/// less than the same scene without `/K`. +/// Build a PDF whose knockout group paints a fully opaque red rect then a +/// fully opaque blue rect with `BM=Multiply`. The multiply blend reads +/// the destination, so the spec-correct outcome differs by which +/// destination is used: knockout uses the group's initial backdrop +/// (transparent), non-knockout uses the prior paint (the red). +/// +/// `knockout` toggles `/K`. `BM=Multiply` is set via an ExtGState; both +/// paints stay at `ca = 1.0` so the alpha short-circuit would (wrongly) +/// fire if not gated on the blend mode. +fn build_pdf_with_opaque_multiply_in_group(knockout: bool) -> Vec { + let page_content = b"/F1 Do\n"; + // /GS1 sets BM=Multiply only on the second fill; first fill is normal. + let form_content = b"1 0 0 rg\n0 0 100 100 re\nf\n/GS1 gs\n0 0 1 rg\n0 0 100 100 re\nf\n"; + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + buf.extend_from_slice(b"%PDF-1.4\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R \ + /Resources << /XObject << /F1 5 0 R >> >> \ + /Group << /Type /Group /S /Transparency >> >>\nendobj\n", + ); + offsets.push(buf.len()); + let hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", page_content.len()); + buf.extend_from_slice(hdr.as_bytes()); + buf.extend_from_slice(page_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + offsets.push(buf.len()); + let k_entry = if knockout { " /K true" } else { "" }; + let form_hdr = format!( + "5 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] \ + /Group << /Type /Group /S /Transparency /I true{k_entry} >> \ + /Resources << /ExtGState << /GS1 6 0 R >> >> /Length {} >>\nstream\n", + form_content.len() + ); + buf.extend_from_slice(form_hdr.as_bytes()); + buf.extend_from_slice(form_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"6 0 obj\n<< /Type /ExtGState /BM /Multiply >>\nendobj\n"); + + finalize_pdf(buf, offsets) +} + +#[test] +fn knockout_group_opaque_non_normal_blend_redirects_to_backdrop() { + let ko = decode_png( + &render_page( + &PdfDocument::from_bytes(build_pdf_with_opaque_multiply_in_group(true)).expect("parse"), + 0, + &RenderOptions::with_dpi(72), + ) + .expect("render") + .data, + ); + let nk = decode_png( + &render_page( + &PdfDocument::from_bytes(build_pdf_with_opaque_multiply_in_group(false)) + .expect("parse"), + 0, + &RenderOptions::with_dpi(72), + ) + .expect("render") + .data, + ); + + let ko_c = ko.get_pixel(50, 50); + let nk_c = nk.get_pixel(50, 50); + + // Knockout: blue Multiply against transparent backdrop on a white page + // ends up pure blue — no red contribution. + // Non-knockout: blue Multiply against the prior red layer = (255*0, + // 0*0, 0*255) = (0, 0, 0) compositied over white → mostly black. + // The two must diverge — the previous alpha short-circuit (which + // fired on ca = 1.0 regardless of blend mode) silently dropped the + // knockout dance and produced identical output for both. + assert!( + (ko_c[0] as i32 - nk_c[0] as i32).abs() > 30 + || (ko_c[1] as i32 - nk_c[1] as i32).abs() > 30 + || (ko_c[2] as i32 - nk_c[2] as i32).abs() > 30, + "Opaque non-Normal blend must redirect to backdrop in knockout group; \ + knockout {ko_c:?} matched non-knockout {nk_c:?} (alpha short-circuit fired)" + ); +} + +/// Build a fixture whose knockout group paints `extra_setup` (a content +/// stream snippet) followed by a 50%-alpha blue rect. When `extra_setup` +/// is empty, only the blue is painted; when it contains a 50%-alpha red +/// rect, the knockout semantics demand that the blue's contribution be +/// *identical* either way — the red is fully knocked out where the blue +/// covers. +fn build_pdf_knockout_extra_then_blue(extra_setup: &str) -> Vec { + let page_content = b"/F1 Do\n"; + let form_content = + format!("/GS1 gs\n{extra_setup}0 0 1 rg\n0 0 100 100 re\nf\n", extra_setup = extra_setup,); + let form_content = form_content.as_bytes(); + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + buf.extend_from_slice(b"%PDF-1.4\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R \ + /Resources << /XObject << /F1 5 0 R >> >> \ + /Group << /Type /Group /S /Transparency >> >>\nendobj\n", + ); + offsets.push(buf.len()); + let hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", page_content.len()); + buf.extend_from_slice(hdr.as_bytes()); + buf.extend_from_slice(page_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + offsets.push(buf.len()); + let form_hdr = format!( + "5 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] \ + /Group << /Type /Group /S /Transparency /I true /K true >> \ + /Resources << /ExtGState << /GS1 6 0 R >> >> /Length {} >>\nstream\n", + form_content.len() + ); + buf.extend_from_slice(form_hdr.as_bytes()); + buf.extend_from_slice(form_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"6 0 obj\n<< /Type /ExtGState /ca 0.5 >>\nendobj\n"); + + finalize_pdf(buf, offsets) +} + +/// Pixel-exact knockout invariant (§11.6.6.2): in a knockout group, a +/// prior shape that the later shape completely covers leaves *no trace* +/// in the final composited output. Render two scenes that differ only in +/// whether the red layer is present; assert every pixel in the overlap +/// region is byte-identical. +/// +/// A half-implemented knockout (e.g. reset on first paint only, or merge +/// that uses the accumulating buffer instead of the backdrop) produces +/// channel deltas under the +30 thresholds elsewhere; byte equality does +/// not let those through. +#[test] +fn knockout_group_pixel_exact_replacement() { + let with_red = decode_png( + &render_page( + &PdfDocument::from_bytes(build_pdf_knockout_extra_then_blue( + "1 0 0 rg\n0 0 100 100 re\nf\n", + )) + .expect("parse"), + 0, + &RenderOptions::with_dpi(72), + ) + .expect("render") + .data, + ); + let only_blue = decode_png( + &render_page( + &PdfDocument::from_bytes(build_pdf_knockout_extra_then_blue("")).expect("parse"), + 0, + &RenderOptions::with_dpi(72), + ) + .expect("render") + .data, + ); + + // The two scenes must produce byte-identical output everywhere the + // blue covers (the full page in this fixture). One mismatched pixel + // anywhere = a knockout bug. + let mut mismatches = 0; + for y in 0..100 { + for x in 0..100 { + if with_red.get_pixel(x, y) != only_blue.get_pixel(x, y) { + mismatches += 1; + } + } + } + assert_eq!( + mismatches, + 0, + "Knockout must replace covered prior shapes pixel-exactly; \ + {mismatches} pixels differ between with-red and only-blue scenes. \ + Sample at (50, 50): with_red={:?}, only_blue={:?}", + with_red.get_pixel(50, 50), + only_blue.get_pixel(50, 50) + ); +} + +/// Same fixture as `knockout_group_opaque_non_normal_blend_redirects_to_backdrop` +/// but parameterised across all four non-separable blend modes. The +/// `knockout_paint_alpha` helper returns 0.0 for any non-Normal mode and +/// the backdrop-redirect dance must fire for each. A bug that only +/// matches some mode names — e.g. lowercasing or substring matching +/// "Multiply" but missing "Hue" — fails closed here. +fn build_pdf_with_opaque_blend_in_knockout(blend_mode: &str, knockout: bool) -> Vec { + let page_content = b"/F1 Do\n"; + let form_content = b"1 0 0 rg\n0 0 100 100 re\nf\n/GS1 gs\n0 0 1 rg\n0 0 100 100 re\nf\n"; + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + buf.extend_from_slice(b"%PDF-1.4\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R \ + /Resources << /XObject << /F1 5 0 R >> >> \ + /Group << /Type /Group /S /Transparency >> >>\nendobj\n", + ); + offsets.push(buf.len()); + let hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", page_content.len()); + buf.extend_from_slice(hdr.as_bytes()); + buf.extend_from_slice(page_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + offsets.push(buf.len()); + let k_entry = if knockout { " /K true" } else { "" }; + let form_hdr = format!( + "5 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] \ + /Group << /Type /Group /S /Transparency /I true{k_entry} >> \ + /Resources << /ExtGState << /GS1 6 0 R >> >> /Length {} >>\nstream\n", + form_content.len() + ); + buf.extend_from_slice(form_hdr.as_bytes()); + buf.extend_from_slice(form_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + offsets.push(buf.len()); + let ext = format!("6 0 obj\n<< /Type /ExtGState /BM /{blend_mode} >>\nendobj\n"); + buf.extend_from_slice(ext.as_bytes()); + + finalize_pdf(buf, offsets) +} + +#[test] +fn knockout_redirects_all_four_non_separable_blend_modes() { + for mode in ["Hue", "Saturation", "Color", "Luminosity"] { + let ko = decode_png( + &render_page( + &PdfDocument::from_bytes(build_pdf_with_opaque_blend_in_knockout(mode, true)) + .expect("parse"), + 0, + &RenderOptions::with_dpi(72), + ) + .expect("render") + .data, + ); + let nk = decode_png( + &render_page( + &PdfDocument::from_bytes(build_pdf_with_opaque_blend_in_knockout(mode, false)) + .expect("parse"), + 0, + &RenderOptions::with_dpi(72), + ) + .expect("render") + .data, + ); + let ko_c = ko.get_pixel(50, 50); + let nk_c = nk.get_pixel(50, 50); + let diverged = (ko_c[0] as i32 - nk_c[0] as i32).abs() > 20 + || (ko_c[1] as i32 - nk_c[1] as i32).abs() > 20 + || (ko_c[2] as i32 - nk_c[2] as i32).abs() > 20; + assert!( + diverged, + "BM={mode}: opaque non-Normal blend must redirect to backdrop in \ + knockout group; knockout {ko_c:?} matched non-knockout {nk_c:?}" + ); + } +} + +#[test] +fn knockout_group_blue_replaces_red() { + let knockout = render_page( + &PdfDocument::from_bytes(build_pdf_with_overlapping_translucent_rects(true)) + .expect("parse"), + 0, + &RenderOptions::with_dpi(72), + ) + .expect("render"); + let blend = render_page( + &PdfDocument::from_bytes(build_pdf_with_overlapping_translucent_rects(false)) + .expect("parse"), + 0, + &RenderOptions::with_dpi(72), + ) + .expect("render"); + let ko = decode_png(&knockout.data); + let nb = decode_png(&blend.data); + + let ko_centre = ko.get_pixel(50, 50); + let nb_centre = nb.get_pixel(50, 50); + + // With knockout the centre is pure-blue-over-white: roughly + // (127, 127, 255). Without knockout the red layer blends in and pulls + // green and blue down: roughly (127, 63, 191). The R channel + // coincidentally lands at 127 in both because white's R = 255 is the + // dominant source over a 50%-alpha blue tip; the meaningful + // distinguishers are G and B. + assert!( + (ko_centre[1] as i32) > (nb_centre[1] as i32) + 30, + "Knockout should leave more green from the white background \ + (expect G_ko ≳ G_nb + 30): knockout {ko_centre:?} vs non-knockout {nb_centre:?}" + ); + assert!( + (ko_centre[2] as i32) > (nb_centre[2] as i32) + 30, + "Knockout should leave the blue layer more saturated \ + (expect B_ko ≳ B_nb + 30): knockout {ko_centre:?} vs non-knockout {nb_centre:?}" + ); +} diff --git a/tests/test_non_separable_blend_modes.rs b/tests/test_non_separable_blend_modes.rs new file mode 100644 index 000000000..54a8e51be --- /dev/null +++ b/tests/test_non_separable_blend_modes.rs @@ -0,0 +1,101 @@ +//! Pins ISO 32000-1 §11.3.5.3 non-separable blend modes +//! (`Hue`, `Saturation`, `Color`, `Luminosity`) in the page renderer. +//! +//! Until this commit `pdf_blend_mode_to_skia` silently degraded all four +//! to `SourceOver` — a layer rendered with `BM=Luminosity` over a colored +//! backdrop would lose its blend math and simply overwrite. The four +//! modes are native variants in `tiny_skia::BlendMode` since 0.12, so +//! the fix is a four-arm match-table extension. This test asserts the +//! mapping yields a *non-Normal* blend result for `BM=Luminosity`. + +#![cfg(feature = "rendering")] + +use pdf_oxide::rendering::{render_page, RenderOptions}; +use pdf_oxide::PdfDocument; + +fn finalize_pdf(mut buf: Vec, offsets: Vec) -> Vec { + let xref_offset = buf.len(); + buf.extend_from_slice(b"xref\n"); + buf.extend_from_slice(format!("0 {}\n", offsets.len() + 1).as_bytes()); + buf.extend_from_slice(b"0000000000 65535 f \n"); + for off in &offsets { + buf.extend_from_slice(format!("{:010} 00000 n \n", off).as_bytes()); + } + buf.extend_from_slice( + format!( + "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n", + offsets.len() + 1, + xref_offset + ) + .as_bytes(), + ); + buf +} + +/// Build a PDF that paints a pure-red full-page rectangle, then paints a +/// pure-blue full-page rectangle on top using ExtGState `/BM /Luminosity`. +/// +/// Under `SourceOver` (the silent-fallback bug) the blue overwrites the +/// red entirely, so the resulting pixel is RGB ≈ (0, 0, 255). +/// +/// Under proper `Luminosity` semantics, the *luminance* of the source +/// (blue, Y ≈ 29) replaces the destination's luminance while keeping the +/// destination's hue (red). The composited pixel ends up as a very dark +/// red — high R relative to G/B, but each channel small enough that the +/// SourceOver outcome (huge B, zero R) is impossible. +fn build_pdf_with_luminosity_blend() -> Vec { + let page_content = b"q\n1 0 0 rg\n0 0 100 100 re\nf\n/GS1 gs\n0 0 1 rg\n0 0 100 100 re\nf\nQ\n"; + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + buf.extend_from_slice(b"%PDF-1.4\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R \ + /Resources << /ExtGState << /GS1 5 0 R >> >> \ + /Group << /Type /Group /S /Transparency >> >>\nendobj\n", + ); + offsets.push(buf.len()); + let hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", page_content.len()); + buf.extend_from_slice(hdr.as_bytes()); + buf.extend_from_slice(page_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"5 0 obj\n<< /Type /ExtGState /BM /Luminosity >>\nendobj\n"); + + finalize_pdf(buf, offsets) +} + +fn decode_png(bytes: &[u8]) -> image::RgbaImage { + let cursor = std::io::Cursor::new(bytes); + image::load(cursor, image::ImageFormat::Png) + .expect("decode PNG") + .to_rgba8() +} + +#[test] +fn luminosity_blend_mode_does_not_overwrite_with_source() { + let doc = PdfDocument::from_bytes(build_pdf_with_luminosity_blend()).expect("parse"); + let img = render_page(&doc, 0, &RenderOptions::with_dpi(72)).expect("render"); + let rgba = decode_png(&img.data); + + let centre = rgba.get_pixel(50, 50); + // SourceOver fallback would give B ≈ 255 (the blue source overwrites + // the red backdrop). Luminosity must NOT produce that — it keeps + // some hue/saturation of the red backdrop. + assert!( + centre[2] < 200, + "Luminosity blend collapsed to SourceOver (blue overwrote red); \ + got R={} G={} B={} A={}", + centre[0], + centre[1], + centre[2], + centre[3] + ); +} diff --git a/tests/test_smask_alpha.rs b/tests/test_smask_alpha.rs new file mode 100644 index 000000000..9a12daaa9 --- /dev/null +++ b/tests/test_smask_alpha.rs @@ -0,0 +1,1310 @@ +//! ExtGState soft-mask (`/SMask /S /Alpha`) tests for the page renderer. +//! +//! ISO 32000-1 §11.6.5.2: an ExtGState `/SMask` modulates subsequent paint +//! operations through a transparency group rendered into its own buffer. +//! For subtype `/Alpha` the *alpha channel* of the rendered group is the +//! mask — opaque (α = 1) pixels in the group let paint through, transparent +//! (α = 0) pixels block it. +//! +//! Test plan: render a synthetic PDF whose SMask group paints an opaque +//! rectangle in the top half of the page in PDF user space. With the mask +//! correctly applied, a full-page black fill under that ExtGState produces +//! black in the top half of the rendered image and the white background in +//! the bottom half. Without SMask handling the mask is ignored and the +//! whole image is black. + +#![cfg(feature = "rendering")] + +use pdf_oxide::rendering::{render_page, RenderOptions}; +use pdf_oxide::PdfDocument; + +fn finalize_pdf(mut buf: Vec, offsets: Vec) -> Vec { + let xref_offset = buf.len(); + buf.extend_from_slice(b"xref\n"); + buf.extend_from_slice(format!("0 {}\n", offsets.len() + 1).as_bytes()); + buf.extend_from_slice(b"0000000000 65535 f \n"); + for off in &offsets { + buf.extend_from_slice(format!("{:010} 00000 n \n", off).as_bytes()); + } + buf.extend_from_slice( + format!( + "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n", + offsets.len() + 1, + xref_offset + ) + .as_bytes(), + ); + buf +} + +/// Build a 100×100 PDF that paints a black full-page rectangle through an +/// ExtGState whose `/SMask` group fills `0 50 100 50` (the top half in PDF +/// user space). Mask subtype is `/S /Alpha`. +fn build_pdf_with_alpha_smask() -> Vec { + let page_content = b"q\n/GS1 gs\n0 g\n0 0 100 100 re\nf\nQ\n"; + let group_content = b"0 50 100 50 re\nf\n"; + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + buf.extend_from_slice(b"%PDF-1.4\n"); + + // 1: Catalog + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + + // 2: Pages + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + + // 3: Page (with a /Group dict so the page is a transparency root) + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R \ + /Resources << /ExtGState << /GS1 5 0 R >> >> \ + /Group << /Type /Group /S /Transparency >> >>\nendobj\n", + ); + + // 4: Page content stream + offsets.push(buf.len()); + let hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", page_content.len()); + buf.extend_from_slice(hdr.as_bytes()); + buf.extend_from_slice(page_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + // 5: ExtGState referencing the soft-mask dict + offsets.push(buf.len()); + buf.extend_from_slice(b"5 0 obj\n<< /Type /ExtGState /SMask 6 0 R >>\nendobj\n"); + + // 6: Soft-mask dict (/S /Alpha, /G is the form XObject) + offsets.push(buf.len()); + buf.extend_from_slice(b"6 0 obj\n<< /Type /Mask /S /Alpha /G 7 0 R >>\nendobj\n"); + + // 7: Form XObject (transparency group) — paints opaque black in top half + offsets.push(buf.len()); + let form_hdr = format!( + "7 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] \ + /Group << /Type /Group /S /Transparency >> \ + /Resources << >> /Length {} >>\nstream\n", + group_content.len() + ); + buf.extend_from_slice(form_hdr.as_bytes()); + buf.extend_from_slice(group_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + finalize_pdf(buf, offsets) +} + +fn decode_png(bytes: &[u8]) -> image::RgbaImage { + let cursor = std::io::Cursor::new(bytes); + image::load(cursor, image::ImageFormat::Png) + .expect("decode rendered PNG") + .to_rgba8() +} + +/// Build a PDF that paints two black rectangles. The first is painted under +/// `/GS1` (alpha mask: top half opaque, bottom half transparent). The +/// second is painted after `/SMask /None` clears the mask — so the second +/// fill should land everywhere regardless of what the first mask blocked. +fn build_pdf_with_smask_then_none() -> Vec { + let page_content = b"q\n/GS1 gs\n0 g\n0 0 100 100 re\nf\n/GS2 gs\n0 0 100 50 re\nf\nQ\n"; + let group_content = b"0 50 100 50 re\nf\n"; + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + buf.extend_from_slice(b"%PDF-1.4\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R \ + /Resources << /ExtGState << /GS1 5 0 R /GS2 8 0 R >> >> \ + /Group << /Type /Group /S /Transparency >> >>\nendobj\n", + ); + offsets.push(buf.len()); + let hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", page_content.len()); + buf.extend_from_slice(hdr.as_bytes()); + buf.extend_from_slice(page_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"5 0 obj\n<< /Type /ExtGState /SMask 6 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"6 0 obj\n<< /Type /Mask /S /Alpha /G 7 0 R >>\nendobj\n"); + offsets.push(buf.len()); + let form_hdr = format!( + "7 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] \ + /Group << /Type /Group /S /Transparency >> \ + /Resources << >> /Length {} >>\nstream\n", + group_content.len() + ); + buf.extend_from_slice(form_hdr.as_bytes()); + buf.extend_from_slice(group_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"8 0 obj\n<< /Type /ExtGState /SMask /None >>\nendobj\n"); + + finalize_pdf(buf, offsets) +} + +#[test] +fn ext_gstate_smask_none_clears_active_soft_mask() { + let doc = PdfDocument::from_bytes(build_pdf_with_smask_then_none()).expect("parse"); + let img = render_page(&doc, 0, &RenderOptions::with_dpi(72)).expect("render"); + let rgba = decode_png(&img.data); + + // First fill (under /GS1's mask) lands in the top half — PNG rows + // 0..50. Without the first mask installing at all (the regression mode + // the standalone basic test guards against), the FIRST fill would have + // painted the whole page black and the post-/None second fill would be + // a no-op — the bottom-half assertion alone wouldn't distinguish that + // failure from the correct path. + let top = rgba.get_pixel(50, 25); + assert!( + top[0] < 60, + "first fill under /GS1 must still land on the top half; \ + got R={} G={} B={} A={}", + top[0], + top[1], + top[2], + top[3] + ); + + // After `/SMask /None`, the second fill (PDF y = 0..50, the bottom half + // in user space — PNG rows 50..100) lands. Without /None handling the + // first mask would still be active and the bottom half would stay white. + let bottom = rgba.get_pixel(50, 75); + assert!( + bottom[0] < 60, + "after /SMask /None, second fill should paint the bottom half; \ + got R={} G={} B={} A={}", + bottom[0], + bottom[1], + bottom[2], + bottom[3] + ); +} + +/// Same fixture as the basic alpha test, but the page paints inside a +/// nested `q`/`Q` block AND paints a second rectangle after the `Q`. +/// The soft mask installed before `q` must be in effect inside, and must +/// still be in effect after `Q` (because the stack pop must not lose the +/// outer-level mask). Pins the soft-mask stack push/pop in lockstep with +/// the clip stack. +fn build_pdf_with_smask_through_q_save_restore() -> Vec { + let page_content = b"/GS1 gs\nq\n0 g\n0 0 100 100 re\nf\nQ\n0 0 100 100 re\nf\n"; + let group_content = b"0 50 100 50 re\nf\n"; + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + buf.extend_from_slice(b"%PDF-1.4\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R \ + /Resources << /ExtGState << /GS1 5 0 R >> >> \ + /Group << /Type /Group /S /Transparency >> >>\nendobj\n", + ); + offsets.push(buf.len()); + let hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", page_content.len()); + buf.extend_from_slice(hdr.as_bytes()); + buf.extend_from_slice(page_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"5 0 obj\n<< /Type /ExtGState /SMask 6 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"6 0 obj\n<< /Type /Mask /S /Alpha /G 7 0 R >>\nendobj\n"); + offsets.push(buf.len()); + let form_hdr = format!( + "7 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] \ + /Group << /Type /Group /S /Transparency >> \ + /Resources << >> /Length {} >>\nstream\n", + group_content.len() + ); + buf.extend_from_slice(form_hdr.as_bytes()); + buf.extend_from_slice(group_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + finalize_pdf(buf, offsets) +} + +#[test] +fn ext_gstate_smask_survives_q_save_restore() { + let doc = + PdfDocument::from_bytes(build_pdf_with_smask_through_q_save_restore()).expect("parse"); + let img = render_page(&doc, 0, &RenderOptions::with_dpi(72)).expect("render"); + let rgba = decode_png(&img.data); + + // Same expectation as the basic test: the mask installed before `q` + // applies to the paint inside the `q`/`Q` block. + let top = rgba.get_pixel(50, 25); + let bottom = rgba.get_pixel(50, 75); + assert!(top[0] < 60, "top under mask should be black; got {top:?}"); + assert!(bottom[0] > 200, "bottom under mask should be white; got {bottom:?}"); +} + +/// Build a fixture where the page applies a scale CTM *before* installing +/// the SMask. The form's `/BBox` is small (`0..2`) but a `50 0 0 50 0 0 cm` +/// runs before `/GS1 gs`, so per §11.6.5.2 the mask group must be rendered +/// at the CTM that was current at install time — i.e. scaled 50× so the +/// form's `0..2` BBox spans `0..100` device pixels and covers the whole +/// painted area. Without that, the mask renders at identity into a 2×2-px +/// region and blocks 99% of the paint. +fn build_pdf_with_smask_under_active_ctm() -> Vec { + let page_content = b"q\n50 0 0 50 0 0 cm\n/GS1 gs\n0 g\n0 0 2 2 re\nf\nQ\n"; + let group_content = b"0 1 2 1 re\nf\n"; + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + buf.extend_from_slice(b"%PDF-1.4\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R \ + /Resources << /ExtGState << /GS1 5 0 R >> >> \ + /Group << /Type /Group /S /Transparency >> >>\nendobj\n", + ); + offsets.push(buf.len()); + let hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", page_content.len()); + buf.extend_from_slice(hdr.as_bytes()); + buf.extend_from_slice(page_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"5 0 obj\n<< /Type /ExtGState /SMask 6 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"6 0 obj\n<< /Type /Mask /S /Alpha /G 7 0 R >>\nendobj\n"); + offsets.push(buf.len()); + let form_hdr = format!( + "7 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 2 2] \ + /Group << /Type /Group /S /Transparency >> \ + /Resources << >> /Length {} >>\nstream\n", + group_content.len() + ); + buf.extend_from_slice(form_hdr.as_bytes()); + buf.extend_from_slice(group_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + finalize_pdf(buf, offsets) +} + +/// Adversarial fixture: the SMask `/G` form's `/Resources` declares the same +/// `/GS1` ExtGState (which has the same `/SMask /G` pointing back at the +/// form), and the form's content stream invokes `/GS1 gs`. Every gs in the +/// chain triggers another mask rasterisation, which renders the form again, +/// which invokes gs again. Without a depth cap this stack-overflows. +fn build_pdf_with_cyclic_smask() -> Vec { + // Page content paints a black square through /GS1. + let page_content = b"q\n/GS1 gs\n0 g\n0 0 100 100 re\nf\nQ\n"; + // Form's content paints the top half *and* re-invokes /GS1, which + // closes the cycle. + let group_content = b"/GS1 gs\n0 50 100 50 re\nf\n"; + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + buf.extend_from_slice(b"%PDF-1.4\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R \ + /Resources << /ExtGState << /GS1 5 0 R >> >> \ + /Group << /Type /Group /S /Transparency >> >>\nendobj\n", + ); + offsets.push(buf.len()); + let hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", page_content.len()); + buf.extend_from_slice(hdr.as_bytes()); + buf.extend_from_slice(page_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"5 0 obj\n<< /Type /ExtGState /SMask 6 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"6 0 obj\n<< /Type /Mask /S /Alpha /G 7 0 R >>\nendobj\n"); + offsets.push(buf.len()); + // The form's /Resources references the same /GS1 → 5 0 R, closing the cycle. + let form_hdr = format!( + "7 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] \ + /Group << /Type /Group /S /Transparency >> \ + /Resources << /ExtGState << /GS1 5 0 R >> >> /Length {} >>\nstream\n", + group_content.len() + ); + buf.extend_from_slice(form_hdr.as_bytes()); + buf.extend_from_slice(group_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + finalize_pdf(buf, offsets) +} + +#[test] +fn ext_gstate_smask_cyclic_g_does_not_stack_overflow() { + let doc = PdfDocument::from_bytes(build_pdf_with_cyclic_smask()).expect("parse"); + // Should render without panic or stack overflow. The depth guard kicks + // in after MAX_SMASK_DEPTH (32) levels of nested SMask materialisation, + // logs a warning, and drops the mask on overflow — subsequent paints + // land normally. The page output is non-empty PNG. + let img = render_page(&doc, 0, &RenderOptions::with_dpi(72)).expect("render"); + assert!(img.data.len() > 200, "cyclic SMask render produced empty PNG"); +} + +#[test] +fn ext_gstate_alpha_smask_honours_install_time_ctm() { + let doc = PdfDocument::from_bytes(build_pdf_with_smask_under_active_ctm()).expect("parse"); + let img = render_page(&doc, 0, &RenderOptions::with_dpi(72)).expect("render"); + let rgba = decode_png(&img.data); + + // With the scaled CTM applied to the SMask rasterisation, the form's + // `0 1 2 1 re f` fills the *top* half of the 100×100 device region. + // The paint covers the whole 100×100, so the top half lands black and + // the bottom stays white. + let top = rgba.get_pixel(50, 25); + let bottom = rgba.get_pixel(50, 75); + assert!(top[0] < 60, "scaled-CTM SMask: top of paint area should be black; got {top:?}"); + assert!( + bottom[0] > 200, + "scaled-CTM SMask: bottom of paint area should be background white; \ + got {bottom:?}" + ); +} + +/// Build a fixture exercising `/S /Luminosity`. The mask `/G` paints a +/// mid-grey rectangle (`0.5 g … 0 50 100 50 re f`) in the top half of its +/// BBox and leaves the bottom half unpainted. Under Luminosity: +/// - top half luminance ≈ 128 → mask passes paint at ~50% intensity, so a +/// full-page black fill composites to mid-grey. +/// - bottom half luminance = 0 (default black backdrop on unpainted +/// pixels) → mask blocks paint; the white page background stays. +/// +/// This distinguishes from `/S /Alpha` (which would see alpha = 255 in the +/// top half and paint fully black) and from "no mask installed" (which +/// would paint the whole page fully black). +fn build_pdf_with_luminosity_smask() -> Vec { + let page_content = b"q\n/GS1 gs\n0 g\n0 0 100 100 re\nf\nQ\n"; + let group_content = b"0.5 g\n0 50 100 50 re\nf\n"; + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + buf.extend_from_slice(b"%PDF-1.4\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R \ + /Resources << /ExtGState << /GS1 5 0 R >> >> \ + /Group << /Type /Group /S /Transparency >> >>\nendobj\n", + ); + offsets.push(buf.len()); + let hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", page_content.len()); + buf.extend_from_slice(hdr.as_bytes()); + buf.extend_from_slice(page_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"5 0 obj\n<< /Type /ExtGState /SMask 6 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"6 0 obj\n<< /Type /Mask /S /Luminosity /G 7 0 R >>\nendobj\n"); + offsets.push(buf.len()); + let form_hdr = format!( + "7 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] \ + /Group << /Type /Group /S /Transparency >> \ + /Resources << >> /Length {} >>\nstream\n", + group_content.len() + ); + buf.extend_from_slice(form_hdr.as_bytes()); + buf.extend_from_slice(group_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + finalize_pdf(buf, offsets) +} + +#[test] +fn ext_gstate_luminosity_smask_modulates_paint_by_group_luma() { + let doc = PdfDocument::from_bytes(build_pdf_with_luminosity_smask()).expect("parse"); + let img = render_page(&doc, 0, &RenderOptions::with_dpi(72)).expect("render"); + let rgba = decode_png(&img.data); + + let top = rgba.get_pixel(50, 25); + let bottom = rgba.get_pixel(50, 75); + + // Top half: mid-grey luminance ≈ 128 modulates black-over-white + // SourceOver to ~128. Allow a generous tolerance window for JPEG-free + // rasteriser rounding. Must not be full black (Alpha would give that) + // and must not be near white (no mask would not). + assert!( + top[0] > 80 && top[0] < 200, + "Luminosity SMask top should composite mid-grey; got R={} (G={} B={} A={})", + top[0], + top[1], + top[2], + top[3] + ); + + // Bottom half: unpainted /G → luminance 0 → paint blocked → background white. + assert!( + bottom[0] > 200, + "Luminosity SMask bottom should be the white background; got R={} (G={} B={} A={})", + bottom[0], + bottom[1], + bottom[2], + bottom[3] + ); +} + +/// `/BC` (backdrop colour) test: an empty `/G` form (no paint at all) under +/// `/S /Luminosity`. With `/BC [1 1 1]` the unpainted backdrop is white, so +/// luminance = 255 and the page paint passes through. Without `/BC` +/// (default black), luminance = 0 and the page paint is fully blocked. +fn build_pdf_with_luminosity_smask_backdrop_white() -> Vec { + let page_content = b"q\n/GS1 gs\n0 g\n0 0 100 100 re\nf\nQ\n"; + // /G paints nothing — the entire mask area is "backdrop only". + let group_content = b""; + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + buf.extend_from_slice(b"%PDF-1.4\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R \ + /Resources << /ExtGState << /GS1 5 0 R >> >> \ + /Group << /Type /Group /S /Transparency >> >>\nendobj\n", + ); + offsets.push(buf.len()); + let hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", page_content.len()); + buf.extend_from_slice(hdr.as_bytes()); + buf.extend_from_slice(page_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"5 0 obj\n<< /Type /ExtGState /SMask 6 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"6 0 obj\n<< /Type /Mask /S /Luminosity /G 7 0 R /BC [1 1 1] >>\nendobj\n", + ); + offsets.push(buf.len()); + let form_hdr = format!( + "7 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] \ + /Group << /Type /Group /S /Transparency /CS /DeviceRGB >> \ + /Resources << >> /Length {} >>\nstream\n", + group_content.len() + ); + buf.extend_from_slice(form_hdr.as_bytes()); + buf.extend_from_slice(group_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + finalize_pdf(buf, offsets) +} + +#[test] +fn ext_gstate_luminosity_smask_bc_white_backdrop_passes_paint() { + let doc = + PdfDocument::from_bytes(build_pdf_with_luminosity_smask_backdrop_white()).expect("parse"); + let img = render_page(&doc, 0, &RenderOptions::with_dpi(72)).expect("render"); + let rgba = decode_png(&img.data); + + let centre = rgba.get_pixel(50, 50); + assert!( + centre[0] < 60, + "/BC [1 1 1] backdrop should give luma 255 → paint passes (black); \ + got R={} (G={} B={} A={})", + centre[0], + centre[1], + centre[2], + centre[3] + ); +} + +/// `/TR` transfer function test: Luminosity SMask whose /G paints mid-grey +/// (luma ≈ 128) over its entire BBox. With `/TR Identity` (or no /TR) the +/// page paint composites at ~50%. With a Type 2 exponential `/TR +/// {N: 2}`, the mask is squared: 0.5² = 0.25, so the paint composites at +/// ~25%, leaving the rendered pixel substantially closer to white. +fn build_pdf_with_luminosity_smask_squared_transfer() -> Vec { + let page_content = b"q\n/GS1 gs\n0 g\n0 0 100 100 re\nf\nQ\n"; + let group_content = b"0.5 g\n0 0 100 100 re\nf\n"; + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + buf.extend_from_slice(b"%PDF-1.4\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R \ + /Resources << /ExtGState << /GS1 5 0 R >> >> \ + /Group << /Type /Group /S /Transparency >> >>\nendobj\n", + ); + offsets.push(buf.len()); + let hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", page_content.len()); + buf.extend_from_slice(hdr.as_bytes()); + buf.extend_from_slice(page_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"5 0 obj\n<< /Type /ExtGState /SMask 6 0 R >>\nendobj\n"); + offsets.push(buf.len()); + // /TR is an indirect reference to a Type 2 exponential function: + // Domain [0 1], Range [0 1], C0 [0], C1 [1], N 2 → y = x² + buf.extend_from_slice( + b"6 0 obj\n<< /Type /Mask /S /Luminosity /G 7 0 R /TR 8 0 R >>\nendobj\n", + ); + offsets.push(buf.len()); + let form_hdr = format!( + "7 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] \ + /Group << /Type /Group /S /Transparency >> \ + /Resources << >> /Length {} >>\nstream\n", + group_content.len() + ); + buf.extend_from_slice(form_hdr.as_bytes()); + buf.extend_from_slice(group_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"8 0 obj\n<< /FunctionType 2 /Domain [0 1] /Range [0 1] \ + /C0 [0] /C1 [1] /N 2 >>\nendobj\n", + ); + + finalize_pdf(buf, offsets) +} + +#[test] +fn ext_gstate_luminosity_smask_tr_type2_squared_attenuates_paint() { + let doc = + PdfDocument::from_bytes(build_pdf_with_luminosity_smask_squared_transfer()).expect("parse"); + let img = render_page(&doc, 0, &RenderOptions::with_dpi(72)).expect("render"); + let rgba = decode_png(&img.data); + + let centre = rgba.get_pixel(50, 50); + // Without /TR: 0.5 luma → 50% black over white → R ≈ 128. + // With /TR squaring: 0.25 luma → 25% black over white → R ≈ 192. + // Use a wide-but-positioned assertion so the test fails closed if /TR + // is silently ignored (R stays ~128). + assert!( + centre[0] > 160, + "/TR squaring should attenuate the paint (expect R ≳ 192); got R={} \ + (without /TR this lands ~128)", + centre[0] + ); +} + +/// `/Group /CS /DeviceGray` test: the SMask group declares a single-component +/// blend space. Luma calculation should treat the rendered gray channel as +/// luma directly. For a uniform 50%-gray /G, the resulting mask should +/// produce ~50% composited paint regardless of /CS (since gray's BT.601 +/// reduces to the gray value itself), but the impl must not crash or +/// silently drop the mask when /CS is non-RGB. +fn build_pdf_with_luminosity_smask_gray_cs() -> Vec { + let page_content = b"q\n/GS1 gs\n0 g\n0 0 100 100 re\nf\nQ\n"; + let group_content = b"0.5 g\n0 0 100 100 re\nf\n"; + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + buf.extend_from_slice(b"%PDF-1.4\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R \ + /Resources << /ExtGState << /GS1 5 0 R >> >> \ + /Group << /Type /Group /S /Transparency >> >>\nendobj\n", + ); + offsets.push(buf.len()); + let hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", page_content.len()); + buf.extend_from_slice(hdr.as_bytes()); + buf.extend_from_slice(page_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"5 0 obj\n<< /Type /ExtGState /SMask 6 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"6 0 obj\n<< /Type /Mask /S /Luminosity /G 7 0 R >>\nendobj\n"); + offsets.push(buf.len()); + let form_hdr = format!( + "7 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] \ + /Group << /Type /Group /S /Transparency /CS /DeviceGray >> \ + /Resources << >> /Length {} >>\nstream\n", + group_content.len() + ); + buf.extend_from_slice(form_hdr.as_bytes()); + buf.extend_from_slice(group_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + finalize_pdf(buf, offsets) +} + +/// Pins the current behaviour for a malformed group: `/CS /DeviceGray` +/// declared but the form's content uses an RGB paint operator (`1 0 0 rg`, +/// pure red). pdf_oxide's renderer rasterises into an RGB pixmap regardless +/// of the declared `/CS`, and `materialise_soft_mask_alpha` runs BT.601 +/// luma unconditionally — there is no `/CS` dispatch for the luma path. +/// +/// For valid gray content (`R = G = B`) BT.601 reduces to the gray value, +/// so a hypothetical "DeviceGray → read channel 0" shortcut would behave +/// identically. For non-gray content the two paths diverge: BT.601 gives a +/// weighted blend (red → luma 76); single-channel R-as-luma would give 255. +/// This test fails on the single-channel interpretation, locking in the +/// BT.601-unconditional choice so a future refactor that adds /CS dispatch +/// is forced to think about the malformed case. +fn build_pdf_with_devicegray_group_painting_red() -> Vec { + let page_content = b"q\n/GS1 gs\n0 g\n0 0 100 100 re\nf\nQ\n"; + // Pure red paint inside a group declared as /CS /DeviceGray. + let group_content = b"1 0 0 rg\n0 0 100 100 re\nf\n"; + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + buf.extend_from_slice(b"%PDF-1.4\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R \ + /Resources << /ExtGState << /GS1 5 0 R >> >> \ + /Group << /Type /Group /S /Transparency >> >>\nendobj\n", + ); + offsets.push(buf.len()); + let hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", page_content.len()); + buf.extend_from_slice(hdr.as_bytes()); + buf.extend_from_slice(page_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"5 0 obj\n<< /Type /ExtGState /SMask 6 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"6 0 obj\n<< /Type /Mask /S /Luminosity /G 7 0 R >>\nendobj\n"); + offsets.push(buf.len()); + let form_hdr = format!( + "7 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] \ + /Group << /Type /Group /S /Transparency /CS /DeviceGray >> \ + /Resources << >> /Length {} >>\nstream\n", + group_content.len() + ); + buf.extend_from_slice(form_hdr.as_bytes()); + buf.extend_from_slice(group_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + finalize_pdf(buf, offsets) +} + +#[test] +fn ext_gstate_luminosity_smask_malformed_devicegray_with_rgb_paint_uses_bt601() { + let doc = + PdfDocument::from_bytes(build_pdf_with_devicegray_group_painting_red()).expect("parse"); + let img = render_page(&doc, 0, &RenderOptions::with_dpi(72)).expect("render"); + let rgba = decode_png(&img.data); + + let centre = rgba.get_pixel(50, 50); + // BT.601 luma of pure red is 0.299·255 ≈ 76, so a black fill composites + // at ~30% over the white background: R ≈ 0.7·255 + 0.3·0 ≈ 179. + // A single-channel "DeviceGray reads R" interpretation would instead + // give luma 255 → full black → R ≈ 0. + assert!( + centre[0] > 130 && centre[0] < 220, + "Malformed DeviceGray group painting red: BT.601 luma ≈ 76 should \ + compose to R ≈ 179; got R={} (single-channel R-as-luma would give R ≈ 0)", + centre[0] + ); +} + +#[test] +fn ext_gstate_luminosity_smask_group_cs_devicegray_yields_50pct_paint() { + let doc = PdfDocument::from_bytes(build_pdf_with_luminosity_smask_gray_cs()).expect("parse"); + let img = render_page(&doc, 0, &RenderOptions::with_dpi(72)).expect("render"); + let rgba = decode_png(&img.data); + + let centre = rgba.get_pixel(50, 50); + // 50% gray luma → 50% black over white → R ≈ 128. + assert!( + centre[0] > 80 && centre[0] < 200, + "Luminosity SMask with /CS /DeviceGray should still composite ~mid-grey; \ + got R={}", + centre[0] + ); +} + +/// Build a PDF that paints two rows of text — one at PDF y=70 (top half, +/// mask passes), one at y=20 (bottom half, mask blocks) — under an Alpha +/// SMask. The text rasterizer calls into the same `effective_clip` +/// machinery as paths and images, so the bottom-row glyphs should be +/// blocked and the top-row glyphs should render. +fn build_pdf_with_text_under_smask() -> Vec { + // Text at y=70 (top half, PNG row ~30) and y=20 (bottom half, PNG row ~80). + let page_content = b"q\n/GS1 gs\nBT\n/F1 18 Tf\n0 g\n1 0 0 1 10 70 Tm\n(TOP) Tj\n1 0 0 1 10 20 Tm\n(BOTTOM) Tj\nET\nQ\n"; + let group_content = b"0 50 100 50 re\nf\n"; + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + buf.extend_from_slice(b"%PDF-1.4\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R \ + /Resources << /ExtGState << /GS1 5 0 R >> \ + /Font << /F1 8 0 R >> >> \ + /Group << /Type /Group /S /Transparency >> >>\nendobj\n", + ); + offsets.push(buf.len()); + let hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", page_content.len()); + buf.extend_from_slice(hdr.as_bytes()); + buf.extend_from_slice(page_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"5 0 obj\n<< /Type /ExtGState /SMask 6 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"6 0 obj\n<< /Type /Mask /S /Alpha /G 7 0 R >>\nendobj\n"); + offsets.push(buf.len()); + let form_hdr = format!( + "7 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] \ + /Group << /Type /Group /S /Transparency >> \ + /Resources << >> /Length {} >>\nstream\n", + group_content.len() + ); + buf.extend_from_slice(form_hdr.as_bytes()); + buf.extend_from_slice(group_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"8 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n", + ); + + finalize_pdf(buf, offsets) +} + +#[test] +fn smask_clips_text_paint() { + let doc = PdfDocument::from_bytes(build_pdf_with_text_under_smask()).expect("parse"); + let img = render_page(&doc, 0, &RenderOptions::with_dpi(72)).expect("render"); + let rgba = decode_png(&img.data); + + // PNG rows 0..50 = top half (mask passes) — TOP text should produce + // dark pixels among the white background. Scan the band where the + // 18-pt baseline-at-y=70 glyphs land (PDF y 70..82 → PNG rows 18..30). + let mut top_has_dark = false; + for y in 18..40 { + for x in 10..50 { + if rgba.get_pixel(x, y)[0] < 100 { + top_has_dark = true; + break; + } + } + if top_has_dark { + break; + } + } + assert!(top_has_dark, "TOP text should leave dark pixels (mask passes)"); + + // PNG rows 50..100 = bottom half (mask blocks). Scan the band where + // the BOTTOM glyphs would have landed (PDF y 20..32 → PNG rows 68..80) + // and assert no dark pixels appear. + for y in 65..85 { + for x in 10..70 { + let p = rgba.get_pixel(x, y); + assert!( + p[0] > 200, + "BOTTOM text leaked through the masked region at ({x}, {y}); \ + got {p:?}" + ); + } + } +} + +/// Build a PDF that paints a black-filled image under an Alpha SMask whose +/// `/G` paints the top half opaque. The mask is applied via +/// `effective_clip` at the `Do` paint site, so the image's pixels in the +/// bottom half (mask alpha = 0) should be transparent and reveal the +/// white background. +fn build_pdf_with_image_under_smask() -> Vec { + let page_content = b"q\n/GS1 gs\n50 0 0 50 25 25 cm\n/Im1 Do\nQ\n"; + let group_content = b"0 50 100 50 re\nf\n"; + + // 1×1 fully-opaque solid black image: DeviceRGB, 1 pixel = (0, 0, 0). + let img_bytes: &[u8] = &[0u8, 0u8, 0u8]; + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + buf.extend_from_slice(b"%PDF-1.4\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R \ + /Resources << /ExtGState << /GS1 5 0 R >> \ + /XObject << /Im1 8 0 R >> >> \ + /Group << /Type /Group /S /Transparency >> >>\nendobj\n", + ); + offsets.push(buf.len()); + let hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", page_content.len()); + buf.extend_from_slice(hdr.as_bytes()); + buf.extend_from_slice(page_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"5 0 obj\n<< /Type /ExtGState /SMask 6 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"6 0 obj\n<< /Type /Mask /S /Alpha /G 7 0 R >>\nendobj\n"); + offsets.push(buf.len()); + let form_hdr = format!( + "7 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] \ + /Group << /Type /Group /S /Transparency >> \ + /Resources << >> /Length {} >>\nstream\n", + group_content.len() + ); + buf.extend_from_slice(form_hdr.as_bytes()); + buf.extend_from_slice(group_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + let img_hdr = format!( + "8 0 obj\n<< /Type /XObject /Subtype /Image /Width 1 /Height 1 \ + /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length {} >>\nstream\n", + img_bytes.len() + ); + buf.extend_from_slice(img_hdr.as_bytes()); + buf.extend_from_slice(img_bytes); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + finalize_pdf(buf, offsets) +} + +#[test] +fn smask_clips_image_paint() { + let doc = PdfDocument::from_bytes(build_pdf_with_image_under_smask()).expect("parse"); + let img = render_page(&doc, 0, &RenderOptions::with_dpi(72)).expect("render"); + let rgba = decode_png(&img.data); + + // Image bbox in PDF user space is (25..75, 25..75) — PNG rows 25..75. + // Mask covers top half of page (PDF y 50..100 → PNG rows 0..50). + // So inside the image bbox: + // - PNG row 30 (top, mask passes): image's black should land. + // - PNG row 70 (bottom, mask blocks): white background visible. + let top = rgba.get_pixel(50, 30); + let bottom = rgba.get_pixel(50, 70); + assert!(top[0] < 60, "SMask should let image black through in the top half; got {top:?}"); + assert!( + bottom[0] > 200, + "SMask should block image paint in the bottom half; got {bottom:?}" + ); +} + +/// Build a Luminosity-SMask fixture where `/G` paints a full-page +/// uniform colour given by `(r, g, b)` (each in 0..=255). The page then +/// fills full black through `/GS1`; the rendered centre pixel's R should +/// reflect the BT.601 luminance of `(r,g,b)` composited over the white +/// page background as `255 - luma`. +fn build_pdf_with_luminosity_uniform_color_smask(r: u8, g: u8, b: u8) -> Vec { + let page_content = b"q\n/GS1 gs\n0 g\n0 0 100 100 re\nf\nQ\n"; + let group_content = format!( + "{r:.3} {g:.3} {b:.3} rg\n0 0 100 100 re\nf\n", + r = r as f32 / 255.0, + g = g as f32 / 255.0, + b = b as f32 / 255.0 + ); + let group_content = group_content.as_bytes(); + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + buf.extend_from_slice(b"%PDF-1.4\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R \ + /Resources << /ExtGState << /GS1 5 0 R >> >> \ + /Group << /Type /Group /S /Transparency >> >>\nendobj\n", + ); + offsets.push(buf.len()); + let hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", page_content.len()); + buf.extend_from_slice(hdr.as_bytes()); + buf.extend_from_slice(page_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"5 0 obj\n<< /Type /ExtGState /SMask 6 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"6 0 obj\n<< /Type /Mask /S /Luminosity /G 7 0 R >>\nendobj\n"); + offsets.push(buf.len()); + let form_hdr = format!( + "7 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] \ + /Group << /Type /Group /S /Transparency >> \ + /Resources << >> /Length {} >>\nstream\n", + group_content.len() + ); + buf.extend_from_slice(form_hdr.as_bytes()); + buf.extend_from_slice(group_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + finalize_pdf(buf, offsets) +} + +/// BT.601 weight pinning — pure red. Luma = 0.299·255 ≈ 76; composite +/// black over white = 255 − 76 ≈ 179. A weight bug that gave luma 150 +/// (green-dominant) would land at R ≈ 105; a swap to blue-dominant would +/// give R ≈ 226. The tight band ±6 catches both. +#[test] +fn bt601_luma_pure_red_yields_r_approx_179() { + let doc = PdfDocument::from_bytes(build_pdf_with_luminosity_uniform_color_smask(255, 0, 0)) + .expect("parse"); + let img = render_page(&doc, 0, &RenderOptions::with_dpi(72)).expect("render"); + let r = decode_png(&img.data).get_pixel(50, 50)[0] as i32; + assert!((r - 179).abs() <= 6, "Pure red under BT.601 luma must give R ≈ 179; got {r}"); +} + +/// BT.601 weight pinning — pure green. Luma = 0.587·255 ≈ 150; composite +/// black over white = 255 − 150 ≈ 105. +#[test] +fn bt601_luma_pure_green_yields_r_approx_105() { + let doc = PdfDocument::from_bytes(build_pdf_with_luminosity_uniform_color_smask(0, 255, 0)) + .expect("parse"); + let img = render_page(&doc, 0, &RenderOptions::with_dpi(72)).expect("render"); + let r = decode_png(&img.data).get_pixel(50, 50)[0] as i32; + assert!((r - 105).abs() <= 6, "Pure green under BT.601 luma must give R ≈ 105; got {r}"); +} + +/// BT.601 weight pinning — pure blue. Luma = 0.114·255 ≈ 29; composite +/// black over white = 255 − 29 ≈ 226. +#[test] +fn bt601_luma_pure_blue_yields_r_approx_226() { + let doc = PdfDocument::from_bytes(build_pdf_with_luminosity_uniform_color_smask(0, 0, 255)) + .expect("parse"); + let img = render_page(&doc, 0, &RenderOptions::with_dpi(72)).expect("render"); + let r = decode_png(&img.data).get_pixel(50, 50)[0] as i32; + assert!((r - 226).abs() <= 6, "Pure blue under BT.601 luma must give R ≈ 226; got {r}"); +} + +/// Build a Luminosity-SMask fixture where `/G` paints uniform gray at the +/// given level (`/ g`), and the SMask carries a Type-2 exponential +/// `/TR` with the given `N`. Used to multi-sample the transfer function. +fn build_pdf_with_luminosity_smask_tr_type2(gray: f32, n: f32) -> Vec { + let page_content = b"q\n/GS1 gs\n0 g\n0 0 100 100 re\nf\nQ\n"; + let group_content = format!("{gray:.3} g\n0 0 100 100 re\nf\n"); + let group_content = group_content.as_bytes(); + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + buf.extend_from_slice(b"%PDF-1.4\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R \ + /Resources << /ExtGState << /GS1 5 0 R >> >> \ + /Group << /Type /Group /S /Transparency >> >>\nendobj\n", + ); + offsets.push(buf.len()); + let hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", page_content.len()); + buf.extend_from_slice(hdr.as_bytes()); + buf.extend_from_slice(page_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"5 0 obj\n<< /Type /ExtGState /SMask 6 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"6 0 obj\n<< /Type /Mask /S /Luminosity /G 7 0 R /TR 8 0 R >>\nendobj\n", + ); + offsets.push(buf.len()); + let form_hdr = format!( + "7 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] \ + /Group << /Type /Group /S /Transparency >> \ + /Resources << >> /Length {} >>\nstream\n", + group_content.len() + ); + buf.extend_from_slice(form_hdr.as_bytes()); + buf.extend_from_slice(group_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + let tr = format!( + "8 0 obj\n<< /FunctionType 2 /Domain [0 1] /Range [0 1] \ + /C0 [0] /C1 [1] /N {n} >>\nendobj\n" + ); + buf.extend_from_slice(tr.as_bytes()); + + finalize_pdf(buf, offsets) +} + +/// `/TR` Type-2 N=2 pin at three luma input points: 0.25, 0.5, 0.75. +/// Mask after transfer: 0.0625, 0.25, 0.5625. Composite over white: +/// R ≈ 239, 191, 112. A bug that uses x¹, x³, or x⁴ would fail at least +/// one assertion (different curvature shows up at the endpoints). +#[test] +fn tr_type2_squared_pins_exponent_at_three_points() { + let cases = [ + (0.25_f32, 239_i32), + (0.50_f32, 191_i32), + (0.75_f32, 112_i32), + ]; + for (gray, expected) in cases { + let doc = PdfDocument::from_bytes(build_pdf_with_luminosity_smask_tr_type2(gray, 2.0)) + .expect("parse"); + let img = render_page(&doc, 0, &RenderOptions::with_dpi(72)).expect("render"); + let r = decode_png(&img.data).get_pixel(50, 50)[0] as i32; + assert!( + (r - expected).abs() <= 8, + "/TR Type 2 N=2 at gray={gray} must give R ≈ {expected}; got {r}" + ); + } +} + +/// `/TR` Type-2 with `N <= 0` must be rejected (§7.10.3). The mask should +/// then come straight from BT.601 luma with no transfer applied: 0.5 g → +/// luma ≈ 128 → R ≈ 127. A regression that drops the `N > 0 && is_finite` +/// guard would let `0_f64.powf(0.0) = 1.0` invert the mask everywhere +/// and produce R ≈ 0. +#[test] +fn tr_type2_invalid_n_falls_through_to_identity() { + let doc = PdfDocument::from_bytes(build_pdf_with_luminosity_smask_tr_type2(0.5, -1.0)) + .expect("parse"); + let img = render_page(&doc, 0, &RenderOptions::with_dpi(72)).expect("render"); + let r = decode_png(&img.data).get_pixel(50, 50)[0] as i32; + assert!( + (r - 127).abs() <= 8, + "/TR with N=-1 must be rejected, falling through to identity (R ≈ 127); got {r}" + ); +} + +/// Cache CTM invalidation: a single content stream invokes the SAME +/// `/GS1` twice at two different CTMs. The first invocation populates the +/// cache at the identity CTM; the second must re-rasterise the group +/// because the CTM has changed. A cache that skipped the install-transform +/// check (or compared on something other than the transform) would serve +/// the stale identity-CTM mask on the second invocation, leaving the +/// scaled-CTM paint mostly unmasked. +/// +/// Fixture: `/GS1` carries an SMask whose `/G` paints the top half of a +/// 5×5 BBox. Identity CTM puts that mask in the top 5 PNG rows (a tiny +/// blocking strip); a 20× scale CTM grows the mask to cover the top half +/// of the full 100×100 device pixmap. The second paint (full black at +/// the scaled CTM) tests which mask is actually applied: under the +/// correctly-invalidated cache the top half of the page paints black and +/// the bottom stays white; under a poisoned cache (identity-CTM mask +/// reused) most of the top stays white because the tiny identity mask +/// doesn't cover the scaled paint region. +fn build_pdf_with_smask_invoked_at_two_ctms() -> Vec { + // First invocation at identity (no paint, just install + drop). + // Second invocation at 20× scale, paint 5×5 unit square (= 100×100 device). + let page_content = b"q\n/GS1 gs\nQ\n\ + q\n20 0 0 20 0 0 cm\n/GS1 gs\n0 g\n0 0 5 5 re\nf\nQ\n"; + // /G paints top half of its 5×5 BBox. + let group_content = b"0 2.5 5 2.5 re\nf\n"; + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + buf.extend_from_slice(b"%PDF-1.4\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R \ + /Resources << /ExtGState << /GS1 5 0 R >> >> \ + /Group << /Type /Group /S /Transparency >> >>\nendobj\n", + ); + offsets.push(buf.len()); + let hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", page_content.len()); + buf.extend_from_slice(hdr.as_bytes()); + buf.extend_from_slice(page_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"5 0 obj\n<< /Type /ExtGState /SMask 6 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"6 0 obj\n<< /Type /Mask /S /Alpha /G 7 0 R >>\nendobj\n"); + offsets.push(buf.len()); + let form_hdr = format!( + "7 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 5 5] \ + /Group << /Type /Group /S /Transparency >> \ + /Resources << >> /Length {} >>\nstream\n", + group_content.len() + ); + buf.extend_from_slice(form_hdr.as_bytes()); + buf.extend_from_slice(group_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + finalize_pdf(buf, offsets) +} + +#[test] +fn smask_cache_invalidates_when_ctm_changes() { + let doc = PdfDocument::from_bytes(build_pdf_with_smask_invoked_at_two_ctms()).expect("parse"); + let img = render_page(&doc, 0, &RenderOptions::with_dpi(72)).expect("render"); + let rgba = decode_png(&img.data); + + // Under correct cache invalidation: scaled mask covers the top half + // of the device pixmap → top is painted black, bottom is white + // background. + let top = rgba.get_pixel(50, 25); + let bottom = rgba.get_pixel(50, 75); + assert!( + top[0] < 60, + "Cache must re-rasterise at the new CTM: top half should be painted; \ + got {top:?}. Stale identity-CTM mask would leave most of the top white." + ); + assert!( + bottom[0] > 200, + "Bottom half should stay white (blocked by the scaled mask); got {bottom:?}" + ); +} + +/// SMask installed *inside* a Form XObject invoked via `Do`. Real-world +/// PDFs (Acrobat, Illustrator, InDesign output) commonly nest content +/// like this: the page's content stream invokes a master Form, and the +/// Form's own content stream sets ExtGStates. The mask must rasterise +/// against the *page-sized* pixmap (so subsequent paints on the page +/// align), not the form's local coordinate buffer. +fn build_pdf_with_smask_inside_nested_form() -> Vec { + let page_content = b"/F1 Do\n"; + // Form F1's content sets /GS1 (SMask) and paints full-page black. + let form_content = b"/GS1 gs\n0 g\n0 0 100 100 re\nf\n"; + // /G paints opaque top half of its BBox. + let group_content = b"0 50 100 50 re\nf\n"; + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + buf.extend_from_slice(b"%PDF-1.4\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R \ + /Resources << /XObject << /F1 5 0 R >> >> \ + /Group << /Type /Group /S /Transparency >> >>\nendobj\n", + ); + offsets.push(buf.len()); + let hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", page_content.len()); + buf.extend_from_slice(hdr.as_bytes()); + buf.extend_from_slice(page_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + // F1 declares its own ExtGState dict referencing the SMask. + let form_hdr = format!( + "5 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] \ + /Resources << /ExtGState << /GS1 6 0 R >> >> /Length {} >>\nstream\n", + form_content.len() + ); + buf.extend_from_slice(form_hdr.as_bytes()); + buf.extend_from_slice(form_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"6 0 obj\n<< /Type /ExtGState /SMask 7 0 R >>\nendobj\n"); + offsets.push(buf.len()); + buf.extend_from_slice(b"7 0 obj\n<< /Type /Mask /S /Alpha /G 8 0 R >>\nendobj\n"); + offsets.push(buf.len()); + let smask_form_hdr = format!( + "8 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] \ + /Group << /Type /Group /S /Transparency >> \ + /Resources << >> /Length {} >>\nstream\n", + group_content.len() + ); + buf.extend_from_slice(smask_form_hdr.as_bytes()); + buf.extend_from_slice(group_content); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + finalize_pdf(buf, offsets) +} + +#[test] +fn smask_applies_to_paint_inside_nested_do() { + let doc = PdfDocument::from_bytes(build_pdf_with_smask_inside_nested_form()).expect("parse"); + let img = render_page(&doc, 0, &RenderOptions::with_dpi(72)).expect("render"); + let rgba = decode_png(&img.data); + + // Mask covers PDF y = 50..100 (top half in user space → PNG rows 0..50). + let top = rgba.get_pixel(50, 25); + let bottom = rgba.get_pixel(50, 75); + assert!( + top[0] < 60, + "SMask installed inside a nested form's content stream must clip \ + that form's paints; top should be black, got {top:?}" + ); + assert!( + bottom[0] > 200, + "Bottom half should be background white (blocked by mask); got {bottom:?}" + ); +} + +#[test] +fn ext_gstate_alpha_smask_blocks_paint_under_transparent_mask() { + let pdf = build_pdf_with_alpha_smask(); + let doc = PdfDocument::from_bytes(pdf).expect("parse PDF"); + let img = render_page(&doc, 0, &RenderOptions::with_dpi(72)).expect("render"); + let rgba = decode_png(&img.data); + + // PDF y goes up; PNG rows go top-to-bottom. The SMask group fills PDF + // y = 50..100 (the *top* half in user space) which lands in PNG rows + // 0..50 (top of the rendered image). The bottom half of the PNG + // corresponds to the *transparent* region of the mask. + let top = rgba.get_pixel(50, 25); + let bottom = rgba.get_pixel(50, 75); + + // Top half: mask α = 1 → black fill should land. The R channel should be + // close to 0 (pure black on a white background). + assert!( + top[0] < 60, + "top-half pixel should be black under opaque mask region; got R={} G={} B={} A={}", + top[0], + top[1], + top[2], + top[3] + ); + + // Bottom half: mask α = 0 → the fill should be blocked, leaving the + // white background visible (R close to 255). + assert!( + bottom[0] > 200, + "bottom-half pixel should be white where the mask is transparent; \ + got R={} G={} B={} A={}", + bottom[0], + bottom[1], + bottom[2], + bottom[3] + ); +}