diff --git a/crates/pdf/tests/upstream_raster.rs b/crates/pdf/tests/upstream_raster.rs index 800497e1..97c979bb 100644 --- a/crates/pdf/tests/upstream_raster.rs +++ b/crates/pdf/tests/upstream_raster.rs @@ -1,10 +1,12 @@ //! Portable regressions adapted from PdfCraft 7437bde378f43856eada40af25b983812e615e8a and //! a2ec27b4775f53c40cad3456936d9406611e3a8d (MIT OR Apache-2.0). #![cfg(feature = "raster")] +use craft_pdf::interpret::InterpreterSettings; use craft_pdf::{ open_slice, raster::{self, Options, RenderCache}, }; +use std::sync::Arc; struct Pixels { width: u32, @@ -567,3 +569,86 @@ fn mesh_triangles_sharing_an_edge_leave_no_seam() { assert!(spilled.is_empty(), "shaded pixels wholly across the diagonal: {spilled:?}"); assert!(missing.is_empty(), "unshaded pixels wholly inside the triangle: {missing:?}"); } + +/// PdfCraft ledger hayro-interpret (40) (#2987): an optional content membership dictionary +/// with a visibility expression (`/VE`, ISO 32000-2 §8.11.2.2) shows its content only when the +/// expression is true, and the expression takes precedence over `/OCGs` and `/P`. A malformed +/// expression, one naming an object that isn't a listed group, or one nested too deep falls +/// back to `/OCGs`. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn visibility_expressions_decide_what_a_membership_dictionary_shows() { + let render = |ocmd: &str, off: &[i32]| { + let catalog = "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 0 R] >> >> >>"; + let objects = format!("5 0 obj << /Type /OCG /Name (A) >> endobj\n6 0 obj << /Type /OCG /Name (B) >> endobj\n7 0 obj {ocmd} endobj"); + let pdf = String::from_utf8(one_page( + "/MediaBox [0 0 100 100] /Resources << /Properties << /M1 7 0 R >> >>", + b"/OC /M1 BDC 1 0 0 rg 0 0 100 100 re f EMC", + objects.as_bytes(), + )) + .unwrap() + .replacen("<< /Type /Catalog /Pages 2 0 R >>", catalog, 1); + let doc = open_slice(pdf.as_bytes(), None).unwrap(); + let settings = InterpreterSettings { ocg_overrides: Arc::new(off.iter().map(|n| (*n, 0, false)).collect()), ..Default::default() }; + let options = Options { background: [255; 4], scale: [0.5, 0.5], ..Default::default() }; + let raster = raster::render(&doc.pages()[0], &RenderCache::new(), &settings, &options).unwrap(); + raster.bytes()[..4] == [255, 0, 0, 255] + }; + let and = "<< /Type /OCMD /VE [/And 5 0 R 6 0 R] >>"; + assert!(render(and, &[]), "And: both on"); + assert!(!render(and, &[5]), "And: one off"); + let or = "<< /Type /OCMD /VE [/Or 5 0 R 6 0 R] >>"; + assert!(render(or, &[5]), "Or: one on"); + assert!(!render(or, &[5, 6]), "Or: both off"); + let not = "<< /Type /OCMD /VE [/Not 5 0 R] >>"; + assert!(!render(not, &[]), "Not: the group is on"); + assert!(render(not, &[5]), "Not: the group is off"); + let nested = "<< /Type /OCMD /VE [/And [/Not 5 0 R] 6 0 R] >>"; + assert!(render(nested, &[5]), "nested: A off, B on"); + assert!(!render(nested, &[]), "nested: A on"); + let both = "<< /Type /OCMD /OCGs [5 0 R] /P /AllOn /VE [/Not 5 0 R] >>"; + assert!(render(both, &[5]), "the expression, not /P, decides"); + // Malformed: an unknown operator, a wrong operand count, a bare group, a string operand, + // a missing object, and the page instead of a group. + for bad in ["[/Xor 5 0 R]", "[/Not 5 0 R 6 0 R]", "[/And]", "[5 0 R]", "[/And (5)]", "[/Or 8 0 R]", "[/Or 3 0 R]"] { + let ocmd = format!("<< /Type /OCMD /OCGs [5 0 R] /VE {bad} >>"); + assert!(render(&ocmd, &[]), "{bad}: /OCGs on"); + assert!(!render(&ocmd, &[5]), "{bad}: /OCGs off"); + } + let deep = format!("<< /Type /OCMD /OCGs [5 0 R] /VE {}5 0 R{} >>", "[/Not ".repeat(41), "]".repeat(41)); + assert!(!render(&deep, &[5]), "too deep: /OCGs decides"); +} + +/// PdfCraft ledger hayro (18) (#2972): a page's objects composite with a transparent backdrop +/// and the page group is then put on the paper, so a blend mode only blends where something +/// was drawn. A green fill with `/BM /Screen` (or Lighten, Difference…) half over a red square +/// takes the mode's color over the red and stays plain green over the empty page. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn blend_modes_over_an_empty_part_of_the_page_keep_their_color() { + for (mode, over_red) in [ + ("Normal", Some([0, 255, 0])), + ("Multiply", Some([0, 0, 0])), + ("Screen", Some([255, 255, 0])), + ("Lighten", Some([255, 255, 0])), + ("Difference", Some([255, 255, 0])), + ("Exclusion", Some([255, 255, 0])), + ("ColorDodge", Some([255, 0, 0])), + ("Overlay", Some([255, 0, 0])), + // The non-separable modes mix hue, saturation and luminosity; over an empty part of + // the page they too keep the source color. + ("Hue", None), + ("Saturation", None), + ("Color", None), + ("Luminosity", None), + ] { + let page = format!("/MediaBox [0 0 300 200] /Resources << /ExtGState << /G1 << /Type /ExtGState /BM /{mode} >> >> >>"); + let p = render(&one_page(&page, b"1 0 0 rg 20 20 150 150 re f /G1 gs 0 1 0 rg 100 50 150 120 re f", b"")); + // Pixel rows run top down: PDF (210, 100) and (130, 100) are rows 100 from the top. + assert_eq!(pixel(&p, 210, 100), [0, 255, 0, 255], "{mode} over the empty page"); + if let Some(over_red) = over_red { + assert_eq!(pixel(&p, 130, 100)[..3], over_red, "{mode} over the red square"); + } + assert_eq!(pixel(&p, 280, 10), [255, 255, 255, 255], "{mode}: the paper stays white"); + } +} diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index 8d69c843..290ff01f 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -236,6 +236,19 @@ image, its header and the terminator fit within `MAX_DECODED_STREAM`, and otherw back to the look-ahead. Its unit tests in `content::tests` come with it. PdfCraft's raster test is not ported: the windowed tokenizer tests cover the same content streams. +Rendering fixes from the [PdfCraft revision 0b670caf4a090e9d83a7f41a84b4cb2144a2d33e ledger](https://github.com/storytold/pdfcraft/blob/0b670caf4a090e9d83a7f41a84b4cb2144a2d33e/vendor/README.md), +each marked `PdfCraft patch` in the source: hayro-interpret (40) (PdfCraft #2987), an optional +content membership dictionary with a visibility expression (`/VE`: `/And`, `/Or` and `/Not` +over groups the document lists in `/OCProperties /OCGs`) shows its content when the +expression is true, ahead of `/OCGs` and `/P`; a malformed expression, or one nested deeper +than 32 levels, is ignored and `/OCGs` and `/P` decide; and hayro (18) (PdfCraft #2972), the +page renders as one transparency group over a transparent backdrop that is then put on the +background, so a blend mode over an empty part of the page keeps its color; with a color +replacement (reading colors) the background stays inside the group as its backdrop. A plate +render (`InterpreterSettings::plate`) also keeps the background inside, marked `Craft patch`, +so blend modes on a separation's film blend with the paper. The PdfCraft raster tests of both +are adapted in `tests/upstream_raster.rs`. + This remains the patched 0.7 backend. It retains the existing Bradford Lab conversion, floating component precision and codec versions, rather than claiming full 0.8 equivalence. The independent color oracles, backend tests and facade contracts cover diff --git a/vendor/hayro-interpret/SOURCE.json b/vendor/hayro-interpret/SOURCE.json index 16289605..77085f07 100644 --- a/vendor/hayro-interpret/SOURCE.json +++ b/vendor/hayro-interpret/SOURCE.json @@ -398,7 +398,7 @@ "path": "src/ocg.rs", "source_sha256": "439455f65b8ad6ed9b0306536c2f75fa5f3fdda42ed4ca351ec3d340c38dc330", "source_git_blob": "c59b70df4be6a83485360dacd76bead9f80c7f94", - "sha256": "439455f65b8ad6ed9b0306536c2f75fa5f3fdda42ed4ca351ec3d340c38dc330" + "sha256": "802fd4c330f5066bcb2070aa0986dc869cd6f1f443e8e92857a598f7336266b7" }, { "path": "src/pattern.rs", diff --git a/vendor/hayro-interpret/src/ocg.rs b/vendor/hayro-interpret/src/ocg.rs index c59b70df..156bda2e 100644 --- a/vendor/hayro-interpret/src/ocg.rs +++ b/vendor/hayro-interpret/src/ocg.rs @@ -1,9 +1,14 @@ -use hayro_syntax::object::dict::keys::{BASE_STATE, D, OCGS, OCMD, OCPROPERTIES, OFF, ON, P, TYPE}; -use hayro_syntax::object::{Array, Dict, Name, ObjectIdentifier}; +use hayro_syntax::object::dict::keys::{ + BASE_STATE, D, OCGS, OCMD, OCPROPERTIES, OFF, ON, P, TYPE, VE, +}; +use hayro_syntax::object::{Array, Dict, MaybeRef, Name, Object, ObjectIdentifier}; use std::collections::HashSet; pub(crate) struct OcgState { inactive_ocgs: HashSet, + /// PdfCraft patch (40): every group the document lists in `/OCProperties /OCGs`, so a + /// visibility expression naming anything else is known to be malformed. + known_ocgs: HashSet, visibility_stack: Vec, } @@ -11,6 +16,7 @@ impl OcgState { fn dummy() -> Self { Self { inactive_ocgs: HashSet::default(), + known_ocgs: HashSet::default(), visibility_stack: vec![], } } @@ -25,6 +31,15 @@ impl OcgState { }; let mut inactive = HashSet::new(); + let known: HashSet = oc_properties + .get::>(OCGS) + .map(|ocgs| { + ocgs.raw_iter() + .filter_map(|item| item.as_obj_ref()) + .map(Into::into) + .collect() + }) + .unwrap_or_default(); let base_state = config .get::>(BASE_STATE) @@ -61,6 +76,7 @@ impl OcgState { Self { inactive_ocgs: inactive, + known_ocgs: known, visibility_stack: Vec::new(), } } @@ -72,6 +88,17 @@ impl OcgState { } pub(crate) fn begin_ocmd(&mut self, ocmd: &Dict<'_>) { + // PdfCraft patch (40): a visibility expression takes precedence over /OCGs and /P + // (ISO 32000-2 §8.11.2.2); a malformed one is ignored and they decide instead. + if let Some(visible) = ocmd + .get::>(VE) + .and_then(|ve| self.visibility_expression(&ve, 0)) + { + let visible = self.is_visible() && visible; + self.visibility_stack.push(visible); + return; + } + let policy = ocmd .get::>(P) .and_then(|n| OcmdPolicy::from_name(n.as_ref())) @@ -104,6 +131,48 @@ impl OcgState { self.visibility_stack.push(visible); } + /// PdfCraft patch (40): evaluate a visibility expression (`/VE`, PDF 1.6): `[/And e…]`, + /// `[/Or e…]` or `[/Not e]`, each `e` an optional content group (by reference) or a nested + /// expression. `None` for a malformed expression: an unknown operator, a `/Not` without + /// exactly one operand, an `/And` or `/Or` without any, an operand that is neither (a + /// reference to an object the document doesn't list as a group is neither), or nesting + /// deeper than `MAX_VISIBILITY_EXPRESSION_DEPTH`. + fn visibility_expression(&self, expr: &Array<'_>, depth: usize) -> Option { + if depth > MAX_VISIBILITY_EXPRESSION_DEPTH { + return None; + } + let mut items = expr.raw_iter(); + let operator = match items.next()? { + MaybeRef::NotRef(Object::Name(name)) => name, + _ => return None, + }; + let (mut count, mut all, mut any) = (0usize, true, false); + for item in items { + let value = match item { + MaybeRef::Ref(r) => { + let id: ObjectIdentifier = r.into(); + if !self.known_ocgs.contains(&id) { + return None; + } + !self.inactive_ocgs.contains(&id) + } + MaybeRef::NotRef(Object::Array(nested)) => { + self.visibility_expression(&nested, depth + 1)? + } + _ => return None, + }; + count = count.saturating_add(1); + all &= value; + any |= value; + } + match (operator.as_ref(), count) { + (b"And", 1..) => Some(all), + (b"Or", 1..) => Some(any), + (b"Not", 1) => Some(!all), + _ => None, + } + } + pub(crate) fn begin_ocg(&mut self, props: &Dict<'_>, ref_id: ObjectIdentifier) { match props.get::>(TYPE).as_deref() { Some(OCMD) => self.begin_ocmd(props), @@ -140,6 +209,9 @@ impl Default for OcgState { } } +/// PdfCraft patch (40): how deep visibility expressions may nest; deeper ones are malformed. +const MAX_VISIBILITY_EXPRESSION_DEPTH: usize = 32; + #[derive(Debug, PartialEq, Eq, Copy, Clone)] enum BaseState { On, diff --git a/vendor/hayro/SOURCE.json b/vendor/hayro/SOURCE.json index 134c145e..ca0be0c3 100644 --- a/vendor/hayro/SOURCE.json +++ b/vendor/hayro/SOURCE.json @@ -62,7 +62,7 @@ "path": "src/lib.rs", "source_sha256": "682db2a32fbf4d875c0e9dd7ac5dc875a6b09ade375efbce6f104de9197de078", "source_git_blob": "6333ed5808ae0cf9cf158e4730e973c1d7089bab", - "sha256": "682db2a32fbf4d875c0e9dd7ac5dc875a6b09ade375efbce6f104de9197de078" + "sha256": "8396b90516603079bb43012621e8089621fec58fdd3d9c9aa9a8d2aa840265da" }, { "path": "src/renderer.rs", diff --git a/vendor/hayro/src/lib.rs b/vendor/hayro/src/lib.rs index 6333ed58..e41de6fd 100644 --- a/vendor/hayro/src/lib.rs +++ b/vendor/hayro/src/lib.rs @@ -254,15 +254,22 @@ pub fn render_into<'a>( interpreter_settings.preview, ); - // PdfCraft patch (18): the page renders as one transparency group with the background - // painted *inside* it, as the initial backdrop. A group isolates what it wraps from what - // was painted before it, so with the background outside, a Multiply highlight blended - // only with the group's transparent backdrop and stayed its own colour — over a dark - // reading-colours background the whole quad became one flat bar and the words under it - // vanished. Inside the group the highlight multiplies with the background too: the words - // keep the highlight's colour and the band between them takes the background (white - // backgrounds are unaffected — multiplying with white is the identity). - device.push_transparency_group(1.0, None, BlendMode::Normal); + // PdfCraft patch (18): the page renders as one transparency group (the page group). Its + // objects composite with an initially transparent backdrop, and the group is then put on + // the paper (ISO 32000-2, page group), so a blend mode only blends where something was + // drawn: a /BM /Screen fill over an empty part of the page keeps its color (#2970). + // Under reading colors the background is painted *inside* the group instead, as its + // initial backdrop, so a Multiply highlight blends with the dark background: outside it, + // the highlight kept its own flat color and the words under it vanished into the band + // (#1548). Reading colors replace the page's colors anyway; for document colors the + // background stays outside, or every separable mode but Normal, Multiply, Darken and + // HardLight would blend with the white paper. Craft patch: a plate render keeps the + // background inside as well, so blend modes on a separation's film blend with the paper. + let backdrop_inside = + render_settings.color_replacement.is_some() || interpreter_settings.plate.is_some(); + if backdrop_inside { + device.push_transparency_group(1.0, None, BlendMode::Normal); + } device.ctx.set_paint(render_settings.bg_color); device @@ -274,11 +281,19 @@ pub fn render_into<'a>( path: clip_path, fill: FillRule::NonZero, }); + if !backdrop_inside { + device.push_transparency_group(1.0, None, BlendMode::Normal); + } interpret_page(page, &mut state, &mut device); + if !backdrop_inside { + device.pop_transparency_group(); + } device.pop_clip_path(); - device.pop_transparency_group(); + if backdrop_inside { + device.pop_transparency_group(); + } let mut resources = vello_cpu::Resources::default(); device.ctx.render_to_buffer(