From f4f0e889850339254531cc0dd780ea6419cc0793 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:39:40 +0000 Subject: [PATCH 01/17] hayro-interpret, hayro: port PdfCraft's paint fixes (ledger 51, 46, P4, 36) From PdfCraft 0b670caf4: - (51): the saved-state accounting (state_bytes, the saved_bytes update, num_states) uses saturating sums, so a huge dash array or clip list stays over the 64 MiB input bound on 32-bit targets instead of wrapping. - (46) and the hayro stencil paint (PdfCraft #2977): an image XObject's group applies the fill opacity, so a stencil mask's paint no longer carries it again (a mask at ca 0.5 was drawn at a quarter), a pattern paint drops the opacity it captured at scn (Pattern::without_opacity), and the mask is painted with the fill paint rather than the stroke paint. - PdfCraft #3246 (unledgered): a shading pattern takes the alpha current at paint time (Pattern::set_alpha), so a gs after scn or SCN still applies; plate paints take it the same way. - (36) (PdfCraft #3035): a mesh pixel whose corner no triangle holds strictly takes the triangle that covers its center, edges included (Triangle::covers), so shared edges leave no seam. Marked "PdfCraft patch (#3035)": the plate-mode code already uses (35) and (36) for #2770's numbers. Tests: the PdfCraft raster tests for (46), #3246 and (36), adapted in crates/pdf/tests/upstream_raster.rs. --- crates/pdf/tests/upstream_raster.rs | 139 +++++++++++++++++++ vendor/README.craft-reader.md | 13 ++ vendor/hayro-interpret/SOURCE.json | 14 +- vendor/hayro-interpret/src/color.rs | 6 + vendor/hayro-interpret/src/context.rs | 17 ++- vendor/hayro-interpret/src/encode.rs | 16 ++- vendor/hayro-interpret/src/interpret/path.rs | 5 + vendor/hayro-interpret/src/pattern.rs | 29 +++- vendor/hayro-interpret/src/shading.rs | 18 +++ vendor/hayro-interpret/src/x_object.rs | 12 +- vendor/hayro/SOURCE.json | 2 +- vendor/hayro/src/renderer.rs | 5 +- 12 files changed, 255 insertions(+), 21 deletions(-) diff --git a/crates/pdf/tests/upstream_raster.rs b/crates/pdf/tests/upstream_raster.rs index 554e129b..800497e1 100644 --- a/crates/pdf/tests/upstream_raster.rs +++ b/crates/pdf/tests/upstream_raster.rs @@ -428,3 +428,142 @@ trailer << /Root 1 0 R >> } } } + +/// A one-page PDF: `page` holds the page dictionary's entries besides its tree links and +/// contents (the media box, resources, annotations), and `objects` the objects from 5 on. +fn one_page(page: &str, content: &[u8], objects: &[u8]) -> Vec { + let mut pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R {page} /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n", + content.len() + ) + .into_bytes(); + pdf.extend_from_slice(content); + pdf.extend_from_slice(b"\nendstream endobj\n"); + pdf.extend_from_slice(objects); + pdf.extend_from_slice(b"\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + pdf +} + +/// PdfCraft ledger hayro-interpret (46) and the hayro stencil paint (#2975): a stencil mask is painted in the +/// fill color at the fill opacity, once: at `ca 0.5` it is as opaque as a rectangle filled at +/// `ca 0.5` beside it. The same holds when the fill is a pattern, an uncolored tiling pattern +/// (drawn in its fill color, not its stroke color) or a shading pattern. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn stencil_masks_are_drawn_at_the_fill_opacity_once() { + let fills = [("color", "0 0 1 rg"), ("uncolored tiling pattern", "/CsP cs 0 0 1 /Tp scn"), ("shading pattern", "/Pattern cs /Sp scn")]; + for (fill_name, fill) in fills { + for alpha in ["1", "0.5", "0.25"] { + let content = format!("/A gs {fill} q 100 0 0 100 20 50 cm /Msk Do Q 160 50 100 100 re f"); + let page = format!( + "/MediaBox [0 0 300 200] /Resources << /ExtGState << /A << /ca {alpha} >> >> /XObject << /Msk 5 0 R >> \ + /ColorSpace << /CsP [/Pattern /DeviceRGB] >> /Pattern << /Tp 6 0 R /Sp 7 0 R >> >>" + ); + let objects = b"5 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true /Length 2 >> stream\n\0\0\nendstream endobj\n\ +6 0 obj << /Type /Pattern /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length 14 >> stream\n0 0 10 10 re f\nendstream endobj\n\ +7 0 obj << /Type /Pattern /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 300 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [0 0 1] /C1 [0 0 1] /N 1 >> >> >> endobj"; + let p = render(&one_page(&page, content.as_bytes(), objects)); + // Pixel rows run top down: PDF (70, 100) is in the mask, (210, 100) in the rectangle. + let (mask, rect) = (pixel(&p, 70, 100), pixel(&p, 210, 100)); + assert!(rect[..3] != [255, 255, 255], "{fill_name}, ca {alpha}: the rectangle is painted"); + assert!(mask.iter().zip(rect).all(|(m, r)| m.abs_diff(*r) <= 1), "{fill_name}, ca {alpha}: mask {mask:?}, rectangle {rect:?}"); + } + } +} + +/// PdfCraft #3246 (#3176, with ledger hayro-interpret (46)): `CA` and `ca` belong to the graphics +/// state at paint time, so a `gs` after `SCN` or `scn` still fades a shading pattern, and a +/// stroke takes the stroke alpha. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn shading_pattern_uses_the_alpha_current_at_paint_time() { + let content = b"/Pattern CS /P0 SCN /S1 gs 10 w 0 75 m 100 75 l S /Pattern cs /P0 scn /F1 gs 0 0 100 50 re f"; + let page = "/MediaBox [0 0 100 100] /Resources << /ExtGState << /S1 << /CA 0.25 >> /F1 << /ca 0.25 >> >> /Pattern << /P0 5 0 R >> >>"; + let objects = b"5 0 obj << /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Extend [true true] \ +/Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [1 0 0] /N 1 >> >> >> endobj"; + let p = render(&one_page(page, content, objects)); + // A quarter red over white: (255, 191, 191). + for (what, y) in [("stroke", 25), ("fill", 75)] { + let px = pixel(&p, 50, y); + assert!(px[0] == 255 && (185..=197).contains(&px[1]) && (185..=197).contains(&px[2]), "{what}: {px:?}"); + } +} + +fn type4_mesh_page(data: &[u8]) -> Pixels { + let mut objects = format!( + "5 0 obj << /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 \ + /Decode [0 255 0 255 0 1 0 1 0 1] /Length {} >> stream\n", + data.len() + ) + .into_bytes(); + objects.extend_from_slice(data); + objects.extend_from_slice(b"\nendstream endobj"); + let page = render(&one_page("/MediaBox [0 0 50 50] /Resources << /Shading << /Sh0 5 0 R >> >>", b"/Sh0 sh", &objects)); + assert_eq!((page.width, page.height), (50, 50)); + page +} + +/// PdfCraft ledger hayro-interpret (36) (#3035): two triangles of a mesh that share an edge cover it +/// together. Testing only each pixel's corner, strictly inside a triangle, left the shared +/// diagonal white, and a pixel row or column along the mesh's own sides. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn mesh_triangles_sharing_an_edge_leave_no_seam() { + // A red square from 10 to 40 on a 50 × 50 page, cut along its diagonal; in device space + // the diagonal runs through pixel centers and corners alike. + let vertex = |flag: u8, x: u8, y: u8| [flag, x, y, 255, 0, 0]; + let (lo, hi) = (10, 40); + let mut triangles = Vec::new(); + for [a, b, c] in [[(lo, lo), (hi, lo), (hi, hi)], [(lo, lo), (hi, hi), (lo, hi)]] { + for (x, y) in [a, b, c] { + triangles.extend(vertex(0, x, y)); + } + } + // Each triangle as its own three vertices, and the second one as a strip (flag 1). + let mut strip = Vec::new(); + for (flag, (x, y)) in [(0, (lo, lo)), (0, (hi, lo)), (0, (hi, hi)), (2, (lo, hi))] { + strip.extend(vertex(flag, x, y)); + } + for (label, data) in [("separate triangles", triangles), ("a strip", strip)] { + let page = type4_mesh_page(&data); + let (mut white_inside, mut red_outside) = (Vec::new(), Vec::new()); + for y in 0..50 { + for x in 0..50 { + let p = pixel(&page, x, y); + let inside = (10..40).contains(&x) && (10..40).contains(&y); + if inside && p != [255, 0, 0, 255] { + white_inside.push((x, y)); + } else if !inside && p != [255, 255, 255, 255] { + red_outside.push((x, y)); + } + } + } + assert!(white_inside.is_empty(), "{label}: unshaded pixels inside the square: {white_inside:?}"); + assert!(red_outside.is_empty(), "{label}: shaded pixels outside the square: {red_outside:?}"); + } + + // One triangle alone, the square's upper left half: a pixel whose corner lies on its + // diagonal is taken by its center, so one wholly across the diagonal (x + y ≥ 50 in + // device space) is not shaded, and one wholly inside is. + let mut data = Vec::new(); + for (x, y) in [(lo, lo), (hi, hi), (lo, hi)] { + data.extend(vertex(0, x, y)); + } + let page = type4_mesh_page(&data); + let (mut spilled, mut missing) = (Vec::new(), Vec::new()); + for y in 10..40 { + for x in 10..40 { + let p = pixel(&page, x, y); + if x + y >= 50 && p == [255, 0, 0, 255] { + spilled.push((x, y)); + } else if x + y + 2 <= 50 && p != [255, 0, 0, 255] { + missing.push((x, y)); + } + } + } + assert!(spilled.is_empty(), "shaded pixels wholly across the diagonal: {spilled:?}"); + assert!(missing.is_empty(), "unshaded pixels wholly inside the triangle: {missing:?}"); +} diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index 1a560e73..4600595d 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -176,6 +176,19 @@ raster tests are adapted in `tests/upstream_raster.rs` and the text contracts in `tests/upstream_text.rs`. (P8)'s PdfCraft test needs a Japanese substitute font and is not ported. +Paint 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 (46) with its hayro counterpart +(PdfCraft #2977), a stencil mask is painted with the fill paint, not the stroke paint, at the +fill opacity once, and a pattern paint drops the opacity it captured at `scn` +(`Pattern::without_opacity`); the unledgered PdfCraft #3246, a shading pattern takes the alpha +current when it is painted (`Pattern::set_alpha`, also on a plate), so a `gs` after `scn` or +`SCN` still applies; (36), marked `PdfCraft patch (#3035)` because the plate-mode code uses +(35) and (36) for #2770's own numbers, a mesh pixel whose corner no triangle holds takes the +triangle covering its center (`Triangle::covers`), so shared edges leave no seam; and (51), +the saved-state byte and count sums saturate, so a huge dash array or clip list stays over +the bound on 32-bit targets instead of wrapping. The PdfCraft raster tests for (46), #3246 and +(36) 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 957b7220..bb2e58c7 100644 --- a/vendor/hayro-interpret/SOURCE.json +++ b/vendor/hayro-interpret/SOURCE.json @@ -182,13 +182,13 @@ "path": "src/color.rs", "source_sha256": "799ce29bb5e59c8fc94db202873e48c2047dd35962e6f26efd215156c47158c1", "source_git_blob": "56bb04f24c04ba33ab6bf99c02a24735670959ad", - "sha256": "031a4615051020c34eacb28f656464db0769e2b1b8ff6f32ed328e62a8ee3d05" + "sha256": "b6475538b4bf893739fbcc8a916864675a2ac64867d2ca74614ff1a9a72ed8db" }, { "path": "src/context.rs", "source_sha256": "dc2be553958ee3f5e5e644a77a0301723bae9ca027a352c13b9caaf549cc0e56", "source_git_blob": "d6772baa15ebb95d3ba2d1f54ce5c250ad1e8137", - "sha256": "c2c2206bed7fb375c60beaa838ad364fb7bd92323488d6841c0afc285b889e82" + "sha256": "e6b4dd369ea6b9c3b60374e46b775511a2542cd46d9da31957381a7361528d82" }, { "path": "src/convert.rs", @@ -206,7 +206,7 @@ "path": "src/encode.rs", "source_sha256": "674a97c48cc62e9a218f843246776528173c36c00aa96a1e4c291c1d912c29c6", "source_git_blob": "b3f830ba2dcdb7069ea73706be43c55be398f670", - "sha256": "674a97c48cc62e9a218f843246776528173c36c00aa96a1e4c291c1d912c29c6" + "sha256": "025ce1ce561229c005ac0f72b632e22bf782d34739d81c321334fc1dedd96933" }, { "path": "src/font/blob.rs", @@ -368,7 +368,7 @@ "path": "src/interpret/path.rs", "source_sha256": "a5defbc4b883b262662f7e7474a00ec1f97dbef8c03b8669859f624f5d02344b", "source_git_blob": "838ea099f11d918d6e333e32503368be864689f8", - "sha256": "3104a301f331bf1902318ad35a1ea2f2d8e2a1c84ec352ea66658732afc9ef06" + "sha256": "2731da799d456cbdddc18518f468ba202356b5f255a116fc7801f65a4aedb9b0" }, { "path": "src/interpret/state.rs", @@ -404,13 +404,13 @@ "path": "src/pattern.rs", "source_sha256": "17b49a465275c20e6645dea9443f2cb3a64c594925fd860e74b80b44d5344e37", "source_git_blob": "9295f51709f8915951d7b9afca9411651d65a1f8", - "sha256": "ae362a5074b40482f2c5e16ea66a0398b27ec866a7b4adb8f45fe7221c960ae1" + "sha256": "57433ea93c678bbd6f4825ca8413ccc982c2ba1961d15ece0fae60ae70b1c105" }, { "path": "src/shading.rs", "source_sha256": "5d541a2d52f69c88e87a40580bf28afa4b8a6a2d62a3385009087264384ead32", "source_git_blob": "2e619e47f2800d7fc7cbdae77c66c58f012c84c2", - "sha256": "c6008d05330877ebc2b3b5eccc269aedc0242c1f81b1e522c42ecd4ba897349d" + "sha256": "aeed7f7a4158887ed4af81ff374b1a73bc8af0220e99f1a2d1487713afc4642c" }, { "path": "src/soft_mask.rs", @@ -434,7 +434,7 @@ "path": "src/x_object.rs", "source_sha256": "e1c51e96a335c32c122011b2d05c5020c36a33c2c73342ba173c28a3e2fe0219", "source_git_blob": "21b134ce8cd9604399cd0776febf923d69c02673", - "sha256": "8ddb1ceb10fe80f62c4a01af23842a92947f5fac8d9b0fe54b3761af3e290d74" + "sha256": "32a4e229517111e49011c48e006bad1f7ef3defa78b182c225f9acebeb62d8f0" } ], "compatibility_reference": { diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index 5a7d5ec7..7e1e6730 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -1231,6 +1231,12 @@ pub struct Color { } impl Color { + /// PdfCraft patch (46): the same color, fully opaque (see `Pattern::without_opacity`). + pub(crate) fn opaque(mut self) -> Self { + self.opacity = 1.0; + self + } + pub(crate) fn new(color_space: ColorSpace, components: ColorComponents, opacity: f32) -> Self { Self { color_space, diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index b4dc8bf0..99d80e5a 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -185,10 +185,19 @@ const MAX_SAVED_STATES: usize = 4096; const MAX_SAVED_STATE_BYTES: usize = 64 << 20; /// Roughly what a saved copy of `state` holds. +/// PdfCraft patch (51): the sums saturate, so a huge dash array or clip list counts as over the +/// bound rather than wrapping on a 32-bit target. fn state_bytes(state: &State<'_>) -> usize { size_of::>() - + state.graphics_state.stroke_props.dash_array.len() * size_of::() - + state.clips.len() * size_of::() + .saturating_add( + state + .graphics_state + .stroke_props + .dash_array + .len() + .saturating_mul(size_of::()), + ) + .saturating_add(state.clips.len().saturating_mul(size_of::())) } impl<'a> Context<'a> { @@ -269,7 +278,7 @@ impl<'a> Context<'a> { } let cur = cur.clone(); - self.saved_bytes += bytes; + self.saved_bytes = self.saved_bytes.saturating_add(bytes); self.states.push(cur); } @@ -446,7 +455,7 @@ impl<'a> Context<'a> { pub(crate) fn num_states(&self) -> usize { // PdfCraft patch: saves not kept still count, so callers restore back past them. - self.states.len() + self.skipped_saves.len() + self.states.len().saturating_add(self.skipped_saves.len()) } pub(crate) fn nesting_depth(&self) -> u32 { diff --git a/vendor/hayro-interpret/src/encode.rs b/vendor/hayro-interpret/src/encode.rs index b3f830ba..872c6057 100644 --- a/vendor/hayro-interpret/src/encode.rs +++ b/vendor/hayro-interpret/src/encode.rs @@ -321,9 +321,19 @@ impl TriangleSampler { return false; } self.visits_left -= 1; - let point = Point::new(x as f64, y as f64); - if t.contains_point(point) { - self.map.insert((x, y), t.interpolate(point)); + let corner = Point::new(x as f64, y as f64); + if t.contains_point(corner) { + self.map.insert((x, y), t.interpolate(corner)); + } else if t.covers(Point::new(x as f64 + 0.5, y as f64 + 0.5)) { + // PdfCraft patch (#3035): a pixel whose corner no triangle holds strictly (it + // lies on an edge two triangles share, or on the mesh's side) still belongs + // to the triangle that covers its center, edges included. Testing only the + // corner left a white seam along every shared edge. Such a pixel takes the + // color at its corner, as every other pixel does (where MuPDF's colors + // agree), unless a triangle that holds the corner gives it one. + self.map + .entry((x, y)) + .or_insert_with(|| t.interpolate(corner)); } } } diff --git a/vendor/hayro-interpret/src/interpret/path.rs b/vendor/hayro-interpret/src/interpret/path.rs index 2850ddab..3fbd1129 100644 --- a/vendor/hayro-interpret/src/interpret/path.rs +++ b/vendor/hayro-interpret/src/interpret/path.rs @@ -178,6 +178,9 @@ pub(crate) fn get_paint<'a>(context: &Context<'a>, is_stroke: bool) -> Paint<'a> pattern.set_transfer_function(tf.clone()); } + // PdfCraft patch (#3246): `CA`/`ca` can be set by a `gs` after `scn`/`SCN`, so the + // pattern takes the alpha current at paint time. + pattern.set_alpha(data.alpha); pattern.pre_concat_transform(context.root_transform()); Paint::Pattern(Box::new(pattern)) @@ -232,6 +235,8 @@ fn plate_paint<'a>( } Pattern::Tiling(t) => t.for_plate(plate, overprint, overprint_mode), } + // PdfCraft patch (#3246): the alpha current at paint time, as in `get_paint`. + pattern.set_alpha(data.alpha); pattern.pre_concat_transform(context.root_transform()); Paint::Pattern(Box::new(pattern)) } diff --git a/vendor/hayro-interpret/src/pattern.rs b/vendor/hayro-interpret/src/pattern.rs index b916a381..287626d2 100644 --- a/vendor/hayro-interpret/src/pattern.rs +++ b/vendor/hayro-interpret/src/pattern.rs @@ -43,7 +43,6 @@ impl<'a> Pattern<'a> { Object::Dict(dict) => Some(Self::Shading(ShadingPattern::new( &dict, &ctx.interpreter_cache.object_cache, - ctx.get().graphics_state.non_stroke_alpha, )?)), Object::Stream(stream) => Some(Self::Tiling(Box::new(TilingPattern::new( stream, ctx, resources, @@ -63,6 +62,29 @@ impl<'a> Pattern<'a> { } } + /// PdfCraft patch (46): the pattern without the fill or stroke opacity it captured when it was + /// set (`scn`). A stencil mask's transparency group applies the opacity in force when the mask + /// is drawn, so a pattern paint carrying it too drew the mask at the opacity squared (#2975). + pub(crate) fn without_opacity(mut self) -> Self { + match &mut self { + Self::Shading(p) => p.opacity = 1.0, + Self::Tiling(p) => { + p.non_stroking_paint = p.non_stroking_paint.clone().opaque(); + p.stroke_paint = p.stroke_paint.clone().opaque(); + } + } + self + } + + /// PdfCraft patch (#3246): set the alpha of the graphics state in effect when the pattern is + /// painted. A `gs` can come after `scn`/`SCN`, so the alpha must not be captured when the + /// pattern is selected. + pub(crate) fn set_alpha(&mut self, alpha: f32) { + if let Self::Shading(p) = self { + p.opacity = alpha; + } + } + pub(crate) fn set_transfer_function(&mut self, tf: ActiveTransferFunction) { if let Self::Shading(p) = self { p.transfer_function = Some(tf); @@ -93,7 +115,7 @@ pub struct ShadingPattern { } impl ShadingPattern { - pub(crate) fn new(dict: &Dict<'_>, cache: &Cache, opacity: f32) -> Option { + pub(crate) fn new(dict: &Dict<'_>, cache: &Cache) -> Option { let shading = dict.get::>(SHADING).and_then(|o| { let (dict, stream) = dict_or_stream(&o)?; @@ -110,7 +132,8 @@ impl ShadingPattern { Some(Self { shading: Arc::new(shading), - opacity, + // PdfCraft patch (#3246): `get_paint` sets the alpha current at paint time. + opacity: 1.0, matrix, transfer_function: None, }) diff --git a/vendor/hayro-interpret/src/shading.rs b/vendor/hayro-interpret/src/shading.rs index 22f5cf77..dc9c38b2 100644 --- a/vendor/hayro-interpret/src/shading.rs +++ b/vendor/hayro-interpret/src/shading.rs @@ -388,6 +388,24 @@ impl Triangle { self.kurbo_tri.winding(pos) != 0 } + /// PdfCraft patch (#3035): whether the point is inside the triangle or on one of its edges. + /// Two triangles of a mesh share an edge; a sample on it belongs to both (their colors + /// agree there), where `contains_point` can give it to neither and leave a seam. A + /// degenerate triangle covers nothing. + pub fn covers(&self, pos: Point) -> bool { + // Relative to the barycentric coordinates' scale (1), well above f64 rounding. + const EDGE: f64 = 1e-9; + let (a, b, c) = (self.p0.point, self.p1.point, self.p2.point); + let (v0, v1, v2) = (b - a, c - a, pos - a); + let denom = self.d00 * self.d11 - self.d01 * self.d01; + let (d20, d21) = (v2.dot(v0), v2.dot(v1)); + let v = (self.d11 * d20 - self.d01 * d21) / denom; + let w = (self.d00 * d21 - self.d01 * d20) / denom; + // A degenerate triangle divides by zero: NaN fails every comparison, and infinite + // coordinates can't sum to at most one. + v >= -EDGE && w >= -EDGE && v + w <= 1.0 + EDGE + } + /// Return the bounding box of the triangle. pub fn bounding_box(&self) -> kurbo::Rect { self.kurbo_tri.bounding_box() diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index a7bc2320..b9de63f6 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -5,7 +5,7 @@ use crate::device::Device; use crate::function::{Function, interpolate}; use crate::interpret::path::get_paint; use crate::interpret::state::ActiveTransferFunction; -use crate::{BlendMode, CacheKey, ClipPath, Image, RasterImage, StencilImage}; +use crate::{BlendMode, CacheKey, ClipPath, Image, Paint, RasterImage, StencilImage}; use crate::{FillRule, InterpreterWarning, WarningSinkFn, interpret}; use crate::{ImageData, LumaData, RgbData}; use hayro_syntax::bit_reader::BitReader; @@ -336,10 +336,18 @@ pub(crate) fn draw_image_xobject<'a, 'b>( device.set_soft_mask(None); device.set_blend_mode(BlendMode::default()); + // PdfCraft patch (46): the group just pushed applies the fill opacity, so a stencil mask's + // paint (the fill color) must not carry it again: at `ca 0.5` a mask was drawn at a + // quarter of its opacity (#2975). The state is restored below. + context.get_mut().graphics_state.non_stroke_alpha = 1.0; let image = if x_object.is_mask { Image::Stencil(StencilImage { - paint: get_paint(context, false), + // A pattern paint keeps the opacity it was set with: drop it too (patch 46). + paint: match get_paint(context, false) { + Paint::Pattern(pattern) => Paint::Pattern(Box::new(pattern.without_opacity())), + paint => paint, + }, image_xobject: x_object.clone(), }) } else { diff --git a/vendor/hayro/SOURCE.json b/vendor/hayro/SOURCE.json index d7d97f1e..7f67a0fc 100644 --- a/vendor/hayro/SOURCE.json +++ b/vendor/hayro/SOURCE.json @@ -68,7 +68,7 @@ "path": "src/renderer.rs", "source_sha256": "f7ff7bd088ceb43823ede33151138384af331cf71403f7d23f7b2937212efc3b", "source_git_blob": "6aa53716ad2d0203a8058b2f7271515b4fc1db22", - "sha256": "d762789ffe04fae9dc37fb5cf5b81ef59a61742286f4a6b5eb64e52e0c76c39c" + "sha256": "311eb33d4544cfe06647658a01a8149700d318940ea825d582e2b959c6883700" } ] } diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index d8e8010c..42d8dbfb 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -1618,8 +1618,11 @@ impl<'a> Device<'a> for Renderer { ); self.ctx.set_transform(transform); + // PdfCraft patch: a stencil mask is painted with the fill + // (non-stroking) paint; `true` drew an uncolored tiling pattern + // in its stroke color (black by default) (#2975). let clip_path = - self.set_paint(paint, &stencil_rect.to_path(0.1), true); + self.set_paint(paint, &stencil_rect.to_path(0.1), false); if let Some(clip_path) = clip_path.as_ref() { self.push_clip_path_inner(clip_path, FillRule::NonZero); } From 429258979df556057650f08f384bcb0c57923356 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:55:26 +0000 Subject: [PATCH 02/17] hayro-interpret, hayro: port PdfCraft's font and text fixes (ledger P7, 49, 50) From PdfCraft 0b670caf4: - P7 (PdfCraft #3109, #3116): text in mode 7 (clip only) installs its clip and reaches the device as GlyphDrawMode::Clip, which the hayro renderer does not paint, so extraction that leaves out invisible OCR text keeps a heading drawn through its clip. Test: clipping_only_text_reaches_the_device_apart_from_invisible_text in crates/pdf/tests/upstream_text.rs. - (49) (PdfCraft #2949 and 09668ce88): a TrueType post table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped is accepted, and a swapped 2.0 table's glyph names are read. Unit test on a synthetic sfnt in font::blob::tests. - (50) (PdfCraft #3958): the CFF glyph-name map is built once on first use and shared between clones instead of scanning the charset per code. PdfCraft's unit test comes with it. --- crates/pdf/tests/upstream_compatibility.rs | 1 + crates/pdf/tests/upstream_text.rs | 39 +++- vendor/README.craft-reader.md | 16 ++ vendor/hayro-interpret/SOURCE.json | 6 +- vendor/hayro-interpret/src/font/blob.rs | 200 ++++++++++++++++++- vendor/hayro-interpret/src/interpret/text.rs | 10 + vendor/hayro-interpret/src/types.rs | 2 + vendor/hayro/SOURCE.json | 2 +- vendor/hayro/src/renderer.rs | 4 +- 9 files changed, 259 insertions(+), 21 deletions(-) diff --git a/crates/pdf/tests/upstream_compatibility.rs b/crates/pdf/tests/upstream_compatibility.rs index 38ffa0c7..d8aface5 100644 --- a/crates/pdf/tests/upstream_compatibility.rs +++ b/crates/pdf/tests/upstream_compatibility.rs @@ -280,6 +280,7 @@ mod painting { GlyphDrawMode::Fill => 'F', GlyphDrawMode::Stroke(_) => 'S', GlyphDrawMode::Invisible => 'I', + GlyphDrawMode::Clip => 'C', }; self.glyphs.push(GlyphEvent { text, mode, rgba: rgba(paint), origin: glyph_transform * Point::ORIGIN }); } diff --git a/crates/pdf/tests/upstream_text.rs b/crates/pdf/tests/upstream_text.rs index 471370cd..1e6fdacd 100644 --- a/crates/pdf/tests/upstream_text.rs +++ b/crates/pdf/tests/upstream_text.rs @@ -1,5 +1,6 @@ //! Text contracts adapted from PdfCraft a2ec27b4775f53c40cad3456936d9406611e3a8d (MIT OR Apache-2.0): -//! Annex D.2 alias codes, glyph names outside the Adobe Glyph List, and `/ToUnicode` identity names. +//! Annex D.2 alias codes, glyph names outside the Adobe Glyph List, and `/ToUnicode` identity names; +//! and from PdfCraft 0b670caf4a090e9d83a7f41a84b4cb2144a2d33e, clipping-only text. #![cfg(feature = "interpret")] #![allow(clippy::unwrap_used, clippy::panic)] @@ -10,10 +11,11 @@ use craft_pdf::interpret::{ use hayro_cmap::BfString; use kurbo::{Affine, BezPath, Rect}; -/// The text of each glyph drawn, `None` where the font gives none. +/// The text of each glyph drawn, `None` where the font gives none, and how it was drawn. #[derive(Default)] struct TextDevice { glyphs: Vec>, + modes: Vec, } impl Device<'_> for TextDevice { @@ -22,7 +24,13 @@ impl Device<'_> for TextDevice { fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'_>, _: &PathDrawMode) {} fn push_clip_path(&mut self, _: &ClipPath) {} fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} - fn draw_glyph(&mut self, glyph: &Glyph<'_>, _: Affine, _: Affine, _: &Paint<'_>, _: &GlyphDrawMode) { + fn draw_glyph(&mut self, glyph: &Glyph<'_>, _: Affine, _: Affine, _: &Paint<'_>, mode: &GlyphDrawMode) { + self.modes.push(match mode { + GlyphDrawMode::Fill => 'F', + GlyphDrawMode::Stroke(_) => 'S', + GlyphDrawMode::Clip => 'C', + GlyphDrawMode::Invisible => 'I', + }); self.glyphs.push(glyph.as_unicode().map(|text| match text { BfString::Char(c) => c.to_string(), BfString::String(s) => s, @@ -33,8 +41,8 @@ impl Device<'_> for TextDevice { fn pop_transparency_group(&mut self) {} } -/// The glyph texts of a one-page PDF whose font `/F1` is object 5; `extra` holds further objects. -fn glyph_texts(font: &str, extra: &str, content: &[u8]) -> Vec> { +/// The glyphs a one-page PDF draws with its font `/F1`, object 5; `extra` holds further objects. +fn drawn_glyphs(font: &str, extra: &str, content: &[u8]) -> TextDevice { let mut pdf = format!( "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ @@ -52,7 +60,11 @@ fn glyph_texts(font: &str, extra: &str, content: &[u8]) -> Vec> { Context::new(page.initial_transform(true).to_kurbo(), Rect::new(0.0, 0.0, 300.0, 300.0), &cache, page.xref(), InterpreterSettings::default()); let mut device = TextDevice::default(); craft_pdf::interpret_page(page, &mut context, &mut device).unwrap(); - device.glyphs + device +} + +fn glyph_texts(font: &str, extra: &str, content: &[u8]) -> Vec> { + drawn_glyphs(font, extra, content).glyphs } fn helvetica_texts(encoding: &str, content: &[u8]) -> Vec { @@ -121,3 +133,18 @@ fn identity_to_unicode_names_give_text() { assert_eq!(texts, ["Z", "\u{fc}", "r", "i", "c", "h", " ", "\u{20ac}", "", ""], "{name}"); } } + +/// PdfCraft #3109 / #3116: text in mode 7 (clip only) reaches the device as +/// `GlyphDrawMode::Clip`, apart from mode 3 (invisible OCR text), so extraction that leaves out +/// OCR text keeps a heading drawn through its clip. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn clipping_only_text_reaches_the_device_apart_from_invisible_text() { + let font = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; + for (mode, expected) in [(0, 'F'), (3, 'I'), (7, 'C')] { + let content = format!("q BT /F1 24 Tf {mode} Tr 30 200 Td (Hi) Tj ET 0 0 1 rg 0 0 300 300 re f Q"); + let drawn = drawn_glyphs(font, "", content.as_bytes()); + assert_eq!(drawn.glyphs, [Some("H".into()), Some("i".into())], "{mode} Tr"); + assert_eq!(drawn.modes, [expected; 2], "{mode} Tr"); + } +} diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index 1a560e73..11185c7f 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -176,6 +176,22 @@ raster tests are adapted in `tests/upstream_raster.rs` and the text contracts in `tests/upstream_text.rs`. (P8)'s PdfCraft test needs a Japanese substitute font and is not ported. +Font and text 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 (49) (PdfCraft #2949 and its +follow-up), `OpenTypeFontBlob::new` accepts a `post` table version 1.0, 2.0 or 3.0 written +with its 16-bit halves swapped (`00 00 00 03`, as ScanSoft PDF Create! embeds every font), and +`glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 +table's names serve `/Differences`; any other unknown version is still refused; (50) +(PdfCraft #3958), `CffFontBlob::glyph_index_by_name` looks names up in a map built on first +use and shared between clones instead of searching the charset for every character code, and +the first glyph with a name still wins; and the unledgered PdfCraft #3116 (#3109), marked +`PdfCraft patch (#3116)`, text in mode 7 (clip only) installs its clip and reaches the device +as `GlyphDrawMode::Clip`, which hayro does not paint, so extraction that leaves out invisible +OCR text (mode 3) keeps a heading drawn through its clip. The unit tests +`font::blob::tests::{a_post_version_with_swapped_halves_is_read_as_the_version_it_means, +glyph_names_resolve_to_the_first_glyph_with_that_name}` come with (49) and (50); the #3116 +contract is in `tests/upstream_text.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 957b7220..1c9ecaa9 100644 --- a/vendor/hayro-interpret/SOURCE.json +++ b/vendor/hayro-interpret/SOURCE.json @@ -212,7 +212,7 @@ "path": "src/font/blob.rs", "source_sha256": "30a9dadf498829ee03eb1ddc6deaeb09a06ee42415f288d46f4a67640582c575", "source_git_blob": "6eabc26b9bc30bdc51f0b5fdd5e4b556d7583ec5", - "sha256": "30a9dadf498829ee03eb1ddc6deaeb09a06ee42415f288d46f4a67640582c575" + "sha256": "109170b39c6e2c8c95a973c73a174e749b0d25e78b4c1e40c8a3ae4dfb220db7" }, { "path": "src/font/cid.rs", @@ -380,7 +380,7 @@ "path": "src/interpret/text.rs", "source_sha256": "35eafa80147aea635bdef127c33ace20e1695e11e87c7bfe1e035c886806ff30", "source_git_blob": "ddec0400ea7a639c14769ac9ee7e137d000b3efd", - "sha256": "239ca1ac3ad2a34bfce3d70935ea3483dbb5c5a689c44cd7c5a22693dab51b75" + "sha256": "34911b1b423e24638863c1c9b173a4d1ccddd3bf08789dab226765adc072c10f" }, { "path": "src/lib.rs", @@ -422,7 +422,7 @@ "path": "src/types.rs", "source_sha256": "0e66569b8a7dc243b0a384cf1d3615ca62bf660b5324236f2594609bdfddfd46", "source_git_blob": "64d2b849bdd3fdcd4ebfd76845edcef77b625fbd", - "sha256": "542fafbfcf8a472466377cacd580eda2fc2d40b834a8d5ecd1ad35d75445f532" + "sha256": "9b5cc9485c0e4104aee99be773706490e6a33f3c03e796f27940571041864125" }, { "path": "src/util.rs", diff --git a/vendor/hayro-interpret/src/font/blob.rs b/vendor/hayro-interpret/src/font/blob.rs index 6eabc26b..8df3611a 100644 --- a/vendor/hayro-interpret/src/font/blob.rs +++ b/vendor/hayro-interpret/src/font/blob.rs @@ -13,7 +13,7 @@ use skrifa::raw::{FontData as ReadFontData, FontRead}; use skrifa::{FontRef, GlyphId, MetadataProvider, OutlineGlyphCollection}; use std::collections::HashMap; use std::fmt::{Debug, Formatter}; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use yoke::{Yoke, Yokeable}; type FontData = Arc + Send + Sync>; @@ -50,7 +50,10 @@ impl Type1FontBlob { /// A font blob for CFF-based fonts. #[derive(Clone)] -pub(crate) struct CffFontBlob(Arc); +pub(crate) struct CffFontBlob(Arc, Arc>); + +/// PdfCraft patch (50): glyph name to glyph id, built on first use. +type GlyphNames = HashMap, GlyphId>; impl Debug for CffFontBlob { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { @@ -84,7 +87,7 @@ impl CffFontBlob { }); let _ = (cff, charset, subfonts); - Some(Self(Arc::new(yoke))) + Some(Self(Arc::new(yoke), Arc::new(OnceLock::new()))) } pub(crate) fn font_data(&self) -> FontData { @@ -134,11 +137,23 @@ impl CffFontBlob { } pub(crate) fn glyph_index_by_name(&self, name: &str) -> Option { - // TODO: This is probably slow to do repeatedly? - self.charset()?.iter().find_map(|(gid, sid)| { - let bytes = self.0.as_ref().get().cff.string(sid)?; - (bytes == name.as_bytes()).then_some(gid) - }) + // PdfCraft patch (50): called for every character code of a font, so the charset is + // read once. The first glyph of a name wins, as it did when the charset was searched + // in order. + self.1 + .get_or_init(|| { + let mut names = GlyphNames::new(); + if let Some(charset) = self.charset() { + for (gid, sid) in charset.iter() { + if let Some(bytes) = self.0.as_ref().get().cff.string(sid) { + names.entry(bytes.into()).or_insert(gid); + } + } + } + names + }) + .get(name.as_bytes()) + .copied() } pub(crate) fn glyph_index_by_cid(&self, cid: u16) -> Option { @@ -179,11 +194,14 @@ impl OpenTypeFontBlob { let f = FontRef::from_index(data.as_ref().as_ref(), index).ok()?; // Reject fonts with invalid post table version, fixes pdf.js issue 9462. Not sure if there // is a better fix, for some reason skrifa accepts the font which is completely invalid. + // PdfCraft patch (49): also accept 1.0, 2.0 and 3.0 with the two 16-bit halves swapped + // (`00 00 00 03` for 3.0), as ScanSoft PDF Create! writes it into every font it embeds + // (#2946). Rejecting those fonts drew their text with a substitute that has no such glyphs. let invalid = f.post().is_ok_and(|p| { !matches!( p.version().to_major_minor(), (1, 0) | (2, 0) | (2, 5) | (3, 0) - ) + ) && !matches!(p.version().to_be_bytes(), [0, 0, 0, 1..=3]) }); if invalid { @@ -253,6 +271,27 @@ impl OpenTypeFontBlob { return glyph_names; }; + // PdfCraft patch (49): a version written with its halves swapped (`00 00 00 02`, + // accepted in `new`) is read as the version it means, from a copy of the table with the + // version written correctly: read-fonts reads a 2.0 table's name index only under + // version 2.0, so otherwise its glyph names were never found (#2946). + let repaired = match post.version().to_be_bytes() { + [0, 0, 0, n @ 1..=3] => self + .font_ref() + .table_data(skrifa::raw::types::Tag::new(b"post")) + .map(|data| { + let mut bytes = data.as_bytes().to_vec(); + if let Some(version) = bytes.get_mut(..4) { + version.copy_from_slice(&[0, n, 0, 0]); + } + bytes + }), + _ => None, + }; + let repaired_post = repaired + .as_deref() + .and_then(|b| skrifa::raw::tables::post::Post::read(ReadFontData::new(b)).ok()); + let post = repaired_post.as_ref().unwrap_or(&post); match post.version().to_major_minor() { (1, 0) => { for (gid, name) in DEFAULT_GLYPH_NAMES.iter().enumerate() { @@ -333,3 +372,146 @@ struct CFFYoke<'a> { charset: Option>, subfonts: Vec, } + +#[cfg(test)] +mod tests { + use super::*; + + /// A CFF font with glyphs `.notdef`, `Alpha`, `Alpha` (a duplicate name) and `Beta`. + fn cff_with_names() -> Vec { + fn index(items: &[&[u8]]) -> Vec { + let mut out = (items.len() as u16).to_be_bytes().to_vec(); + if items.is_empty() { + return out; + } + out.push(1); + let mut offset = 1u8; + out.push(offset); + for item in items { + offset += item.len() as u8; + out.push(offset); + } + for item in items { + out.extend_from_slice(item); + } + out + } + // Operands use the 5-byte form so the Top DICT has a fixed size. + fn int(value: usize) -> Vec { + let mut out = vec![29]; + out.extend_from_slice(&(value as u32).to_be_bytes()); + out + } + fn build_top(charset: usize, char_strings: usize, private: usize) -> Vec { + let mut dict = Vec::new(); + dict.extend(int(charset)); + dict.push(15); + dict.extend(int(char_strings)); + dict.push(17); + dict.extend(int(0)); + dict.extend(int(private)); + dict.push(18); + dict + } + let header = vec![1, 0, 4, 1]; + let names = index(&[b"T"]); + let strings = index(&[b"Alpha", b"Beta"]); + let subrs = index(&[]); + // Standard strings take SIDs 0..=390, so the first custom string is 391. + let charset = vec![0, 0x01, 0x87, 0x01, 0x87, 0x01, 0x88]; + let char_strings = index(&[&[14], &[14], &[14], &[14]]); + // The Top DICT has a fixed size because every operand takes the 5-byte form. + let top_len = index(&[&build_top(0, 0, 0)]).len(); + let charset_at = header.len() + names.len() + top_len + strings.len() + subrs.len(); + let char_strings_at = charset_at + charset.len(); + let private_at = char_strings_at + char_strings.len(); + let dict = build_top(charset_at, char_strings_at, private_at); + let top = index(&[&dict]); + let mut out = header; + for part in [names, top, strings, subrs, charset, char_strings] { + out.extend(part); + } + out + } + + /// The charset search `glyph_index_by_name` replaced. + fn linear(blob: &CffFontBlob, name: &str) -> Option { + blob.charset()?.iter().find_map(|(gid, sid)| { + let bytes = blob.0.as_ref().get().cff.string(sid)?; + (bytes == name.as_bytes()).then_some(gid) + }) + } + + #[test] + fn glyph_names_resolve_to_the_first_glyph_with_that_name() { + let data: FontData = Arc::new(cff_with_names()); + let blob = CffFontBlob::new(data).expect("the test font parses"); + assert!(blob.1.get().is_none(), "the name map is built on first use"); + assert_eq!(blob.glyph_index_by_name("Alpha"), Some(GlyphId::new(1))); + assert_eq!(blob.glyph_index_by_name("Beta"), Some(GlyphId::new(3))); + assert_eq!(blob.glyph_index_by_name("Missing"), None); + assert_eq!(blob.glyph_index_by_name(""), None); + for name in ["Alpha", "Beta", "Missing", ".notdef", ""] { + assert_eq!( + blob.glyph_index_by_name(name), + linear(&blob, name), + "{name:?}" + ); + // The second lookup is answered from the stored map. + assert_eq!( + blob.glyph_index_by_name(name), + linear(&blob, name), + "{name:?}" + ); + } + assert!(blob.1.get().is_some(), "the lookups built the name map"); + let shared = blob.clone(); + assert!(Arc::ptr_eq(&blob.1, &shared.1), "clones share one name map"); + assert_eq!(shared.glyph_index_by_name("Alpha"), Some(GlyphId::new(1))); + } + + /// An sfnt with only a `post` table of the given version, naming three glyphs `.notdef`, + /// `A` (a standard name) and `W.cv07` (a name of its own) when the version is 2.0. + fn font_with_post(version: [u8; 4]) -> FontData { + let mut post = version.to_vec(); + post.extend_from_slice(&[0; 28]); + post.extend_from_slice(&3u16.to_be_bytes()); + for index in [0u16, 36, 258] { + post.extend_from_slice(&index.to_be_bytes()); + } + post.push(6); + post.extend_from_slice(b"W.cv07"); + let mut font = vec![0, 1, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0]; + font.extend_from_slice(b"post"); + font.extend_from_slice(&[0; 4]); + font.extend_from_slice(&28u32.to_be_bytes()); + font.extend_from_slice(&(post.len() as u32).to_be_bytes()); + font.extend(post); + Arc::new(font) + } + + #[test] + fn a_post_version_with_swapped_halves_is_read_as_the_version_it_means() { + for (swapped, correct) in [([0, 0, 0, 1], [0, 1, 0, 0]), ([0, 0, 0, 3], [0, 3, 0, 0])] { + assert!(OpenTypeFontBlob::new(font_with_post(correct), 0).is_some()); + assert!( + OpenTypeFontBlob::new(font_with_post(swapped), 0).is_some(), + "{swapped:?} is accepted" + ); + } + let names = |version| { + OpenTypeFontBlob::new(font_with_post(version), 0) + .unwrap() + .glyph_names() + }; + let correct = names([0, 2, 0, 0]); + assert_eq!(correct.get("A"), Some(&GlyphId::new(1))); + assert_eq!(correct.get("W.cv07"), Some(&GlyphId::new(2))); + assert_eq!( + names([0, 0, 0, 2]), + correct, + "a swapped 2.0 table names its glyphs" + ); + assert!(OpenTypeFontBlob::new(font_with_post([0, 9, 0, 0]), 0).is_none()); + } +} diff --git a/vendor/hayro-interpret/src/interpret/text.rs b/vendor/hayro-interpret/src/interpret/text.rs index 65a9a279..0bf5a996 100644 --- a/vendor/hayro-interpret/src/interpret/text.rs +++ b/vendor/hayro-interpret/src/interpret/text.rs @@ -189,6 +189,16 @@ fn show_glyph<'a>( } TextRenderingMode::Clip => { clip_glyph(ctx, glyph, glyph_transform); + // PdfCraft patch (#3116): report clipping text separately from invisible OCR text. + // Extraction filters must retain text that is filled through its + // clipping path without rendering devices drawing it directly. + device.draw_glyph( + glyph, + ctx.get().ctm, + glyph_transform, + &get_paint(ctx, false), + &GlyphDrawMode::Clip, + ); } TextRenderingMode::FillAndClip => { clip_glyph(ctx, glyph, glyph_transform); diff --git a/vendor/hayro-interpret/src/types.rs b/vendor/hayro-interpret/src/types.rs index ff7776f3..af540247 100644 --- a/vendor/hayro-interpret/src/types.rs +++ b/vendor/hayro-interpret/src/types.rs @@ -284,6 +284,8 @@ pub enum GlyphDrawMode { Fill, /// Draw using a stroke. Stroke(StrokeProps), + /// PdfCraft patch (#3116): text that shapes a clip; report it without painting a glyph. + Clip, /// Invisible text (for text extraction but not visual rendering). Invisible, } diff --git a/vendor/hayro/SOURCE.json b/vendor/hayro/SOURCE.json index d7d97f1e..c8d86001 100644 --- a/vendor/hayro/SOURCE.json +++ b/vendor/hayro/SOURCE.json @@ -68,7 +68,7 @@ "path": "src/renderer.rs", "source_sha256": "f7ff7bd088ceb43823ede33151138384af331cf71403f7d23f7b2937212efc3b", "source_git_blob": "6aa53716ad2d0203a8058b2f7271515b4fc1db22", - "sha256": "d762789ffe04fae9dc37fb5cf5b81ef59a61742286f4a6b5eb64e52e0c76c39c" + "sha256": "6e47419abe9762732b62c44ef2fcefeaa11ad95a306783f7a8780c6ed9986fc9" } ] } diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index d8e8010c..d9e58ffc 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -1876,8 +1876,8 @@ impl<'a> Device<'a> for Renderer { GlyphDrawMode::Stroke(s) => { Self::stroke_glyph(self, glyph, transform, glyph_transform, paint, s); } - GlyphDrawMode::Invisible => { - // Don't render invisible text for visual output + GlyphDrawMode::Invisible | GlyphDrawMode::Clip => { + // PdfCraft patch (#3116): neither mode paints; the interpreter installs the clip. } } } From 2cbbf81b1dfe6c9b245a4da3e242353c3cb11362 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:57:41 +0000 Subject: [PATCH 03/17] hayro-interpret: a transparent plate paint stays transparent when a stencil drops pattern opacity --- vendor/hayro-interpret/SOURCE.json | 2 +- vendor/hayro-interpret/src/color.rs | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/vendor/hayro-interpret/SOURCE.json b/vendor/hayro-interpret/SOURCE.json index bb2e58c7..51eee9b4 100644 --- a/vendor/hayro-interpret/SOURCE.json +++ b/vendor/hayro-interpret/SOURCE.json @@ -182,7 +182,7 @@ "path": "src/color.rs", "source_sha256": "799ce29bb5e59c8fc94db202873e48c2047dd35962e6f26efd215156c47158c1", "source_git_blob": "56bb04f24c04ba33ab6bf99c02a24735670959ad", - "sha256": "b6475538b4bf893739fbcc8a916864675a2ac64867d2ca74614ff1a9a72ed8db" + "sha256": "74469aa0b54d606f3c9a3e72b38d8d16e7f82cc85e82a093a8e1075831334da9" }, { "path": "src/context.rs", diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index 7e1e6730..18c2d6a0 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -1231,9 +1231,13 @@ pub struct Color { } impl Color { - /// PdfCraft patch (46): the same color, fully opaque (see `Pattern::without_opacity`). + /// PdfCraft patch (46): the same color, fully opaque (see `Pattern::without_opacity`). A + /// fully transparent color stays so: on a plate it is the paint that leaves the plate as it + /// is (`for_plate`), and making it opaque would knock the plate out. pub(crate) fn opaque(mut self) -> Self { - self.opacity = 1.0; + if self.opacity != 0.0 { + self.opacity = 1.0; + } self } From d9d02c42be093bf4765eb55367ca1394038669ad Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 07:26:55 +0000 Subject: [PATCH 04/17] hayro-syntax: port PdfCraft's trailer /Prev reference (ledger 24) A trailer /Prev written as N G R is followed: it is the offset the referenced object holds when the section's own entries place it, and otherwise offset N. Each section's entries are read before its Prev is followed. From PdfCraft #2400, with its two xref unit tests; the local xref-stream bounds are kept. --- vendor/README.craft-reader.md | 10 ++ vendor/hayro-syntax/SOURCE.json | 2 +- vendor/hayro-syntax/src/xref.rs | 166 ++++++++++++++++++++++++++------ 3 files changed, 150 insertions(+), 28 deletions(-) diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index 1a560e73..51c7195b 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -176,6 +176,16 @@ raster tests are adapted in `tests/upstream_raster.rs` and the text contracts in `tests/upstream_text.rs`. (P8)'s PdfCraft test needs a Japanese substitute font and is not ported. +Fixes from the [PdfCraft revision 0b670caf4a090e9d83a7f41a84b4cb2144a2d33e ledger](https://github.com/storytold/pdfcraft/blob/0b670caf4a090e9d83a7f41a84b4cb2144a2d33e/vendor/README.md), +by its item ids, each marked `PdfCraft patch` in the source. hayro-syntax (24), PdfCraft +#2400: `xref::prev_offset` reads a trailer `/Prev` written as a reference `N G R` as the +offset that object holds when the section's own entries place it (iText's +`indirectPrev.pdf`), and otherwise as offset N (tiff2pdf), so the earlier sections' pages +are seen. Each section's entries are read before its `Prev` is followed; the local +xref-stream bounds above apply to them unchanged. Tests: +`xref::tests::prev_as_reference_reaches_the_earlier_section` and +`xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`. + 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-syntax/SOURCE.json b/vendor/hayro-syntax/SOURCE.json index d2234ad3..c3838bc9 100644 --- a/vendor/hayro-syntax/SOURCE.json +++ b/vendor/hayro-syntax/SOURCE.json @@ -338,7 +338,7 @@ "path": "src/xref.rs", "source_sha256": "51ffeeb7d4ac035bae2cd5ba9288c180088ae06cdcefe2b29dfeda560a793aa0", "source_git_blob": "20e33a410489d42e438a3f2b87d70c7bb8d66eae", - "sha256": "fa8d24280df15ffe5628057fdcfa2a3d2a9f42539b6f9096b2feb836ac54ac30" + "sha256": "98fb56eb5c2da8b0c40202ed30b2f464874c1143436e72eda4e49f2333dd7c7f" }, { "path": "src/decode_limits_tests.rs", diff --git a/vendor/hayro-syntax/src/xref.rs b/vendor/hayro-syntax/src/xref.rs index 15858ac4..4f9de535 100644 --- a/vendor/hayro-syntax/src/xref.rs +++ b/vendor/hayro-syntax/src/xref.rs @@ -759,6 +759,34 @@ fn populate_xref_impl<'a>(data: &'a [u8], pos: usize, xref_map: &mut XrefMap, li populate_xref_impl_inner(data, pos, xref_map, &mut visited, limits) } +/// The offset of the previous xref section (`Prev`). +// PdfCraft patch: (PdfCraft #2400) some writers give `Prev` as a reference `N G R`. When this +// section's own entries (`own`) place that object and it holds an offset (iText's +// `indirectPrev.pdf`), that is the offset; otherwise its object number is (tiff2pdf, in JHOVE's +// PDF-HUL-157 files), as other readers take it. +fn prev_offset(data: &[u8], trailer: &Dict<'_>, own: &XrefMap) -> Option { + if let Some(prev) = trailer.get::(PREV) { + return usize::try_from(prev).ok(); + } + + let r = trailer.get_ref(PREV)?; + let id = ObjectIdentifier::new(r.obj_number, r.gen_number); + let held = match own.get(&id) { + Some(EntryType::Normal(at)) => { + let mut reader = Reader::new(data); + reader.jump(*at); + reader + .read_with_context::>(&ReaderContext::dummy()) + .filter(|o| o.id() == &id) + .map(|o| o.get()) + .filter(|p| *p >= 0) + } + _ => None, + }; + + usize::try_from(held.unwrap_or(r.obj_number)).ok() +} + /// Maximum number of allowed xref `Prev` pointers before we abort. const MAX_XREF_CHAIN_DEPTH: usize = 256; @@ -832,17 +860,8 @@ fn populate_from_xref_table<'a>( reader.skip_white_spaces(); let mut max_obj = 0; - - if let Some(prev) = trailer.get::(PREV) { - // First insert the entries from any previous xref tables. - populate_xref_impl_inner(data, prev as usize, insert_map, visited, limits)?; - } - - // In hybrid files, entries in `XRefStm` should have higher priority, therefore we insert them - // after looking at `PREV`. - if let Some(xref_stm) = trailer.get::(XREF_STM) { - populate_xref_impl_inner(data, xref_stm as usize, insert_map, visited, limits)?; - } + // PdfCraft patch: (PdfCraft #2400) this section's entries are read first, for `prev_offset`. + let mut own = XrefMap::default(); while let Some(header) = reader.read_without_context::() { reader.skip_white_spaces(); @@ -858,7 +877,7 @@ fn populate_from_xref_table<'a>( // Specification says we should ignore any object number > SIZE, but probably // not important? if entry.used { - insert_map.insert( + own.insert( ObjectIdentifier::new(obj_number as i32, entry.gen_number), EntryType::Normal(entry.offset), ); @@ -866,6 +885,19 @@ fn populate_from_xref_table<'a>( } } + if let Some(prev) = prev_offset(data, &trailer, &own) { + // First insert the entries from any previous xref tables. + populate_xref_impl_inner(data, prev, insert_map, visited, limits)?; + } + + // In hybrid files, entries in `XRefStm` should have higher priority, therefore we insert them + // after looking at `PREV`. + if let Some(xref_stm) = trailer.get::(XREF_STM) { + populate_xref_impl_inner(data, xref_stm as usize, insert_map, visited, limits)?; + } + + insert_map.extend(own); + Some(trailer.data()) } @@ -878,11 +910,6 @@ fn populate_from_xref_stream<'a>( ) -> Option<&'a [u8]> { let stream = reader.read_with_context::>>(&ReaderContext::dummy_with_limits(limits))?.get(); - if let Some(prev) = stream.dict().get::(PREV) { - // First insert the entries from any previous xref tables. - let _ = populate_xref_impl_inner(data, prev as usize, insert_map, visited, limits)?; - } - let size = stream.dict().get::(SIZE)?; let [f1_len, f2_len, f3_len] = stream.dict().get::<[u8; 3]>(W)?; @@ -895,6 +922,8 @@ fn populate_from_xref_stream<'a>( } let xref_data = stream.decoded().ok()?; + // PdfCraft patch: (PdfCraft #2400) this section's entries are read first, for `prev_offset`. + let mut own = XrefMap::default(); let mut xref_reader = Reader::new(xref_data.as_ref()); if let Some(arr) = stream.dict().get::>(INDEX) { @@ -908,21 +937,20 @@ fn populate_from_xref_stream<'a>( f1_len, f2_len, f3_len, - insert_map, + &mut own, )?; } } else { - xref_stream_subsection( - &mut xref_reader, - 0, - size, - f1_len, - f2_len, - f3_len, - insert_map, - )?; + xref_stream_subsection(&mut xref_reader, 0, size, f1_len, f2_len, f3_len, &mut own)?; } + if let Some(prev) = prev_offset(data, stream.dict(), &own) { + // First insert the entries from any previous xref tables. + let _ = populate_xref_impl_inner(data, prev, insert_map, visited, limits)?; + } + + insert_map.extend(own); + Some(stream.dict().data()) } @@ -1450,6 +1478,90 @@ mod tests { let _result = populate_xref_impl(pdf.as_ref(), xref_pos, &mut xref_map, DecodeLimits::default()); } + #[test] + fn prev_as_reference_reaches_the_earlier_section() { + let mut pdf = b"%PDF-1.0\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n".to_vec(); + let first_xref_pos = pdf.len(); + pdf.extend_from_slice( + format!( + "xref\n\ + 0 2\n\ + 0000000000 65535 f \n\ + 0000000009 00000 n \n\ + trailer\n<< /Size 2 /Root 1 0 R >>\n\ + startxref\n{first_xref_pos}\n%%EOF\n" + ) + .as_bytes(), + ); + let second_xref_pos = pdf.len(); + pdf.extend_from_slice( + format!( + "xref\n\ + 0 1\n\ + 0000000000 65535 f \n\ + trailer\n<< /Size 2 /Root 1 0 R /Prev {first_xref_pos} 0 R >>\n\ + startxref\n{second_xref_pos}\n%%EOF" + ) + .as_bytes(), + ); + + let mut xref_map = FxHashMap::default(); + let xref_pos = find_last_xref_pos(pdf.as_ref()).unwrap(); + assert_eq!(xref_pos, second_xref_pos); + populate_xref_impl( + pdf.as_ref(), + xref_pos, + &mut xref_map, + DecodeLimits::default(), + ) + .unwrap(); + assert!(xref_map.contains_key(&ObjectIdentifier::new(1, 0))); + } + + #[test] + fn prev_as_reference_to_an_object_holding_the_offset_reads_that_offset() { + let mut pdf = b"%PDF-1.0\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n".to_vec(); + let first_xref_pos = pdf.len(); + pdf.extend_from_slice( + format!( + "xref\n\ + 0 2\n\ + 0000000000 65535 f \n\ + 0000000009 00000 n \n\ + trailer\n<< /Size 2 /Root 1 0 R >>\n\ + startxref\n{first_xref_pos}\n%%EOF\n" + ) + .as_bytes(), + ); + // Object 3 holds the offset; read as offset 3, `Prev` would point inside the header. + let held_pos = pdf.len(); + pdf.extend_from_slice(format!("3 0 obj\n{first_xref_pos}\nendobj\n").as_bytes()); + let second_xref_pos = pdf.len(); + pdf.extend_from_slice( + format!( + "xref\n\ + 3 1\n\ + {held_pos:010} 00000 n \n\ + trailer\n<< /Size 4 /Root 1 0 R /Prev 3 0 R >>\n\ + startxref\n{second_xref_pos}\n%%EOF" + ) + .as_bytes(), + ); + + let mut xref_map = FxHashMap::default(); + let xref_pos = find_last_xref_pos(pdf.as_ref()).unwrap(); + assert_eq!(xref_pos, second_xref_pos); + populate_xref_impl( + pdf.as_ref(), + xref_pos, + &mut xref_map, + DecodeLimits::default(), + ) + .unwrap(); + assert!(xref_map.contains_key(&ObjectIdentifier::new(1, 0))); + assert!(xref_map.contains_key(&ObjectIdentifier::new(3, 0))); + } + #[test] fn find_last_xref_uses_last_startxref() { let pdf = b"%PDF-1.0\nstartxref\n5\n%%EOF\nstartxref\n42\n%%EOF"; From d2ffefcddf5148ad50fcc49b910021423a519873 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 07:32:06 +0000 Subject: [PATCH 05/17] hayro-syntax: port PdfCraft's JP2 palette without /ColorSpace (ledger P5) A JPX image whose dictionary has no /ColorSpace, or one that does not resolve, takes its colors from the JP2 palette instead of drawing its raw indices as gray. Resolved palette colors are 8-bit samples whatever the index precision. From PdfCraft #3184, with its three jpx unit tests. --- vendor/README.craft-reader.md | 10 ++ vendor/hayro-syntax/SOURCE.json | 2 +- vendor/hayro-syntax/src/filter/jpx.rs | 144 +++++++++++++++++++++++++- 3 files changed, 152 insertions(+), 4 deletions(-) diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index 51c7195b..cd135c5a 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -185,6 +185,16 @@ are seen. Each section's entries are read before its `Prev` is followed; the loc xref-stream bounds above apply to them unchanged. Tests: `xref::tests::prev_as_reference_reaches_the_earlier_section` and `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`. +hayro-syntax (P5), PdfCraft #3184: `filter::jpx::decode` applies a JP2 palette (`pclr`) when +the image dictionary has no `/ColorSpace`, or one that does not resolve, since the colors then +come from the JP2 header (ISO 32000-2 §7.4.9). With a `/ColorSpace` the raw indices are kept, +as an `/Indexed` space needs. Resolved palette colors are 8-bit samples whatever the +precision of the indices; an image without a palette keeps its own. Tests: +`filter::jpx::tests::jpx_palette_is_applied_without_a_color_space`, +`filter::jpx::tests::jpx_palette_indices_stay_raw_for_an_indexed_color_space` and +`filter::jpx::tests::palette_colours_are_eight_bit_and_other_samples_keep_their_precision`. +PdfCraft's raster test `a_jp2_palette_keeps_its_colours_whatever_the_index_precision` is not +ported: the unit tests show the same behavior. 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 diff --git a/vendor/hayro-syntax/SOURCE.json b/vendor/hayro-syntax/SOURCE.json index c3838bc9..e02661ba 100644 --- a/vendor/hayro-syntax/SOURCE.json +++ b/vendor/hayro-syntax/SOURCE.json @@ -164,7 +164,7 @@ "path": "src/filter/jpx.rs", "source_sha256": "f0a2b01c692c086d30a26562d7548f9e837af166be737d2c543de7c886a45f82", "source_git_blob": "74efd8b31e2b2935ff2bbceda2c1ffd36ea9c20d", - "sha256": "f0a2b01c692c086d30a26562d7548f9e837af166be737d2c543de7c886a45f82" + "sha256": "b0c295f815547377409a8810e9cbef35e4d6b506a3c3507669da71fcfe9d4701" }, { "path": "src/filter/lzw_flate.rs", diff --git a/vendor/hayro-syntax/src/filter/jpx.rs b/vendor/hayro-syntax/src/filter/jpx.rs index 74efd8b3..7268bb04 100644 --- a/vendor/hayro-syntax/src/filter/jpx.rs +++ b/vendor/hayro-syntax/src/filter/jpx.rs @@ -21,7 +21,13 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option Option ImageColorSpace::Gray, ColorSpace::RGB => ImageColorSpace::Rgb, @@ -98,6 +112,15 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option bool { + boxes(data) + .find(|&(kind, _, _)| kind == *b"jp2h") + .and_then(|(_, start, end)| data.get(start..end)) + .is_some_and(|header| boxes(header).any(|(kind, _, _)| kind == *b"pclr")) +} + /// The enumerated colour spaces (`EnumCS`, ISO 15444-1 I.5.3.3 and 15444-2 M.11.7.2) the decoder /// handles: CMYK, CIELab, sRGB, greyscale, sYCC, e-sRGB and ROMM-RGB. const DECODED_ENUMERATED: [u32; 7] = [12, 14, 16, 17, 18, 20, 21]; @@ -221,8 +244,9 @@ fn scale( #[cfg(test)] mod tests { - use super::{scale, without_refused_enumerated_colour}; + use super::{decode, scale, without_refused_enumerated_colour}; use crate::bit_reader::{BitReader, bit_mask}; + use crate::object::stream::{ImageColorSpace, ImageDecodeParams}; use alloc::vec::Vec; #[test] @@ -358,4 +382,118 @@ mod tests { assert_eq!(without_refused_enumerated_colour(&data), None, "{bad:#x}"); } } + + /// A 4x2 JP2 written for this test: one 8-bit component whose samples (0, 1) index a + /// two-entry sRGB palette (`pclr` + `cmap`): red, then blue. Rows: 0 1 0 1 / 1 0 1 0. + const PALETTE_JP2: &[u8] = &[ + 0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20, 0x0d, 0x0a, 0x87, 0x0a, 0x00, 0x00, 0x00, + 0x14, 0x66, 0x74, 0x79, 0x70, 0x6a, 0x70, 0x32, 0x20, 0x00, 0x00, 0x00, 0x00, 0x6a, 0x70, + 0x32, 0x20, 0x00, 0x00, 0x00, 0x55, 0x6a, 0x70, 0x32, 0x68, 0x00, 0x00, 0x00, 0x16, 0x69, + 0x68, 0x64, 0x72, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x04, 0x00, 0x01, 0x07, 0x07, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x63, 0x6f, 0x6c, 0x72, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x10, 0x00, 0x00, 0x00, 0x14, 0x70, 0x63, 0x6c, 0x72, 0x00, 0x02, 0x03, 0x07, 0x07, + 0x07, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0x00, 0x00, 0x00, 0x14, 0x63, 0x6d, 0x61, 0x70, + 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x02, 0x00, 0x00, 0x00, + 0x8c, 0x6a, 0x70, 0x32, 0x63, 0xff, 0x4f, 0xff, 0x51, 0x00, 0x29, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x04, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x07, 0x01, 0x01, 0xff, 0x52, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, + 0x04, 0x04, 0x00, 0x01, 0xff, 0x5c, 0x00, 0x07, 0x40, 0x40, 0x48, 0x48, 0x50, 0xff, 0x64, + 0x00, 0x25, 0x00, 0x01, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x20, 0x62, 0x79, 0x20, + 0x4f, 0x70, 0x65, 0x6e, 0x4a, 0x50, 0x45, 0x47, 0x20, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x20, 0x32, 0x2e, 0x35, 0x2e, 0x34, 0xff, 0x90, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x17, 0x00, 0x01, 0xff, 0x93, 0xcf, 0xb4, 0x08, 0x08, 0x67, 0x90, 0x07, 0x41, 0x08, + 0xff, 0xd9, + ]; + + /// PdfCraft #3179: with no `/ColorSpace` in the image dictionary, the JP2 palette gives the + /// colors. Raw indices (0 and 1) would come back as a gray image, drawn as noise. + #[test] + fn jpx_palette_is_applied_without_a_color_space() { + let params = ImageDecodeParams { + width: 4, + height: 2, + ..ImageDecodeParams::default() + }; + let result = decode(PALETTE_JP2, ¶ms).unwrap(); + let image = result.image_data.unwrap(); + assert!(matches!(image.color_space, Some(ImageColorSpace::Rgb))); + let red = [255, 0, 0]; + let blue = [0, 0, 255]; + let expected: Vec = [red, blue, red, blue, blue, red, blue, red].concat(); + assert_eq!(&*result.data, &expected[..]); + } + + /// With an `/Indexed` color space in the dictionary, the PDF supplies the palette, so the raw + /// indices are what the image holds. + #[test] + fn jpx_palette_indices_stay_raw_for_an_indexed_color_space() { + let params = ImageDecodeParams { + is_indexed: true, + num_components: Some(1), + width: 4, + height: 2, + ..ImageDecodeParams::default() + }; + let result = decode(PALETTE_JP2, ¶ms).unwrap(); + assert_eq!(&*result.data, &[0, 1, 0, 1, 1, 0, 1, 0][..]); + } + + /// A 1x1, 1-bit codestream (one empty packet, so its sample is the midpoint 1) as a JP2 file + /// whose header holds `extra` boxes after `ihdr` and `colr`. + fn one_bit_jp2(extra: &[u8]) -> Vec { + let jp2_box = |kind: &[u8], body: &[u8]| { + let mut b = ((8 + body.len()) as u32).to_be_bytes().to_vec(); + b.extend_from_slice(kind); + b.extend_from_slice(body); + b + }; + let mut codestream = alloc::vec![0xff, 0x4f, 0xff, 0x51, 0, 41, 0, 0]; + for value in [1_u32, 1, 0, 0, 1, 1, 0, 0] { + codestream.extend_from_slice(&value.to_be_bytes()); + } + codestream.extend_from_slice(&[0, 1, 0, 1, 1]); + codestream.extend_from_slice(&[0xff, 0x52, 0, 12, 0, 0, 0, 1, 0, 0, 4, 4, 0, 1]); + codestream.extend_from_slice(&[0xff, 0x5c, 0, 4, 0x40, 0x40]); + codestream.extend_from_slice(&[0xff, 0x90, 0, 10, 0, 0, 0, 0, 0, 15, 0, 1, 0xff, 0x93, 0]); + codestream.extend_from_slice(&[0xff, 0xd9]); + let mut header = jp2_box(b"ihdr", &[0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 7, 0, 0]); + header.extend(jp2_box(b"colr", &[1, 0, 0, 0, 0, 0, 16])); + header.extend_from_slice(extra); + let mut out = alloc::vec![0, 0, 0, 12, b'j', b'P', b' ', b' ', 0x0d, 0x0a, 0x87, 0x0a]; + out.extend(jp2_box(b"ftyp", b"jp2 \0\0\0\0jp2 ")); + out.extend(jp2_box(b"jp2h", &header)); + out.extend(jp2_box(b"jp2c", &codestream)); + out + } + + /// PdfCraft #3184: resolved palette colors are 8-bit samples whatever the indices' precision; + /// an image without a palette keeps its own (1 bit here). + #[test] + fn palette_colours_are_eight_bit_and_other_samples_keep_their_precision() { + let params = ImageDecodeParams { + width: 1, + height: 1, + ..ImageDecodeParams::default() + }; + let bpc = |data: &[u8]| { + decode(data, ¶ms) + .and_then(|r| r.image_data) + .map(|d| d.bits_per_component) + }; + let mut palette = Vec::new(); + for (kind, body) in [ + ( + &b"pclr"[..], + &[0, 2, 3, 7, 7, 7, 255, 0, 0, 64, 128, 192][..], + ), + (b"cmap", &[0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 1, 2]), + ] { + palette.extend_from_slice(&((8 + body.len()) as u32).to_be_bytes()); + palette.extend_from_slice(kind); + palette.extend_from_slice(body); + } + assert_eq!(bpc(&one_bit_jp2(&palette)), Some(8)); + assert_eq!(bpc(&one_bit_jp2(&[])), Some(1)); + } } From d118f471b87aa2ae4099d959db711b2505df4cd4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 07:32:18 +0000 Subject: [PATCH 06/17] hayro-syntax: port PdfCraft's unfiltered inline-image length (#3107) From PdfCraft #3438 with its look-ahead bound (01af6cba3): an inline image without a filter ends at the byte length its /W, /H, /CS, /BPC and /IM give when white space and EI follow there, so an EI inside its samples does not end it. Anything else keeps the EI look-ahead. A computed end past the content window waits for more data only while it fits within MAX_DECODED_STREAM. Tests: PdfCraft's six content::tests unit tests. --- vendor/README.craft-reader.md | 11 + vendor/hayro-syntax/SOURCE.json | 2 +- vendor/hayro-syntax/src/content/mod.rs | 385 ++++++++++++++++++++++++- 3 files changed, 395 insertions(+), 3 deletions(-) diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index cd135c5a..419e9ad9 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -196,6 +196,17 @@ precision of the indices; an image without a palette keeps its own. Tests: PdfCraft's raster test `a_jp2_palette_keeps_its_colours_whatever_the_index_precision` is not ported: the unit tests show the same behavior. +From [PdfCraft revision 0b670caf4a090e9d83a7f41a84b4cb2144a2d33e](https://github.com/storytold/pdfcraft/tree/0b670caf4a090e9d83a7f41a84b4cb2144a2d33e/vendor), +unledgered and marked `PdfCraft patch` in the source: hayro-syntax #3438 (issue #3107), with +its look-ahead bound from 01af6cba3. An inline image without a filter ends at the byte +length its `/W`, `/H`, `/CS`, `/BPC` and `/IM` give (rows padded to whole bytes) when white +space and `EI` follow there, so an `EI` inside its samples does not end it. Conflicting +long and short keys, filters and other color spaces keep the `EI` look-ahead. When the +computed end lies past the content window, the tokenizer asks for more data only while the +image, its header and the terminator fit within `MAX_DECODED_STREAM`, and otherwise falls +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. + 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-syntax/SOURCE.json b/vendor/hayro-syntax/SOURCE.json index e02661ba..20f4c121 100644 --- a/vendor/hayro-syntax/SOURCE.json +++ b/vendor/hayro-syntax/SOURCE.json @@ -68,7 +68,7 @@ "path": "src/content/mod.rs", "source_sha256": "b0f36badb2688c90691c27a4e5dcdfec7639858081553242744dd7bc39ffc0d0", "source_git_blob": "8e47324d0b0bd90ed15efe5ff1e9de0cb46e75d9", - "sha256": "aa0883ba92dd05655bfd8d54195584bab2c0b28111d76ec73c5381a0f23b6181" + "sha256": "ae0b0596d2304adc4109294cc7af681698071060d9e8cd6d44f0bb46baffec6b" }, { "path": "src/content/ops.rs", diff --git a/vendor/hayro-syntax/src/content/mod.rs b/vendor/hayro-syntax/src/content/mod.rs index ee749cd9..5b19d509 100644 --- a/vendor/hayro-syntax/src/content/mod.rs +++ b/vendor/hayro-syntax/src/content/mod.rs @@ -35,7 +35,7 @@ pub mod ops; use crate::content::ops::TypedInstruction; use crate::object; -use crate::object::dict::InlineImageDict; +use crate::object::dict::{Dict, InlineImageDict}; use crate::object::name::{Name, skip_name_like}; use crate::object::{Array, Null, Number, Object, Stream}; use crate::reader::Reader; @@ -242,7 +242,33 @@ impl<'a> UntypedIter<'a> { let mut lookahead_touched = false; let mut accepted = false; - 'outer: while let Some(pos) = find_needle(self.reader.tail()?, b"EI") { + // PdfCraft patch: an unfiltered inline image's byte length is determined by + // its parameters (and `/L` or `/Length` when present and consistent), so + // `EI` inside its samples does not cut the image short (#3107, PR #3438). + match unfiltered_image_end( + stream_data, + &dict, + self.complete, + start_offset.saturating_sub(self.committed), + ) { + UnfilteredEnd::Found(end_data, end_after_ei) => { + let image_data = stream_data.get(..end_data)?; + let stream = Stream::new(image_data, dict.clone()); + self.stack.push(Object::Stream(stream))?; + self.reader.read_bytes(end_after_ei)?; + self.reader.skip_white_spaces(); + accepted = true; + } + UnfilteredEnd::NeedsMore => { + self.needs_more = true; + return None; + } + UnfilteredEnd::Fallback => {} + } + + 'outer: while !accepted + && let Some(pos) = find_needle(self.reader.tail()?, b"EI") + { self.reader.read_bytes(pos)?; if self.reader.peek_bytes(2) == Some(b"EI") { @@ -858,9 +884,175 @@ mod macros { pub(crate) use op6; } +/// PdfCraft patch: outcome of checking an unfiltered inline image's deterministic byte length. +enum UnfilteredEnd { + /// Deterministic end found: `(end_data, end_after_ei)`. + Found(usize, usize), + /// Window ran out before the terminator after the expected sample bytes could be decided. + NeedsMore, + /// Not an unfiltered image with a valid terminator at its computed length; use the heuristic. + Fallback, +} + +/// PdfCraft patch: an unfiltered inline image's sample byte count, including byte padding per row, +/// and `/Length` or `/L` when present and consistent (#3107). +fn unfiltered_image_len(dict: &Dict<'_>) -> Option { + for key in [b"Filter".as_slice(), b"F"] { + if let Some(filter) = dict.get::>(key) + && !matches!(filter, Object::Null(_)) + && !matches!(&filter, Object::Array(filters) if filters.iter::>().next().is_none()) + { + return None; + } + } + for (full, short) in [ + (b"Width".as_slice(), b"W".as_slice()), + (b"Height", b"H"), + (b"ColorSpace", b"CS"), + (b"BitsPerComponent", b"BPC"), + (b"ImageMask", b"IM"), + ] { + if let (Some(full), Some(short)) = + (dict.get::>(full), dict.get::>(short)) + && full != short + { + return None; + } + } + let get = |full: &[u8], short: &[u8]| { + dict.get::>(full) + .or_else(|| dict.get::>(short)) + }; + let int = |obj: &Object<'_>| match obj { + Object::Number(n) => i32::try_from(n.as_i64()) + .ok() + .filter(|&i| Number::from_i32(i) == *n) + .map(i64::from), + _ => None, + }; + let width = usize::try_from(int(&get(b"Width", b"W")?)?) + .ok() + .filter(|n| *n > 0)?; + let height = usize::try_from(int(&get(b"Height", b"H")?)?) + .ok() + .filter(|n| *n > 0)?; + let mask = match get(b"ImageMask", b"IM") { + None | Some(Object::Null(_) | Object::Boolean(false)) => false, + Some(Object::Boolean(true)) => true, + _ => return None, + }; + let bits = match get(b"BitsPerComponent", b"BPC") { + None | Some(Object::Null(_)) => None, + Some(value) => Some(int(&value)?), + }; + let (components, bits) = if mask { + if bits.is_some_and(|bits| bits != 1) { + return None; + } + (1usize, 1usize) + } else { + let (components, max_bits) = match get(b"ColorSpace", b"CS")? { + Object::Name(name) => match name.as_ref() { + b"DeviceGray" | b"G" => (1, 16), + b"DeviceRGB" | b"RGB" => (3, 16), + b"DeviceCMYK" | b"CMYK" => (4, 16), + _ => return None, + }, + Object::Array(space) => { + let mut items = space.iter::>(); + if !matches!(items.next(), Some(Object::Name(n)) if matches!(n.as_ref(), b"Indexed" | b"I")) + || items.count() != 3 + { + return None; + } + (1, 8) + } + _ => return None, + }; + let bits = match bits? { + bits @ (1 | 2 | 4 | 8 | 16) => bits as usize, + _ => return None, + }; + if bits > max_bits { + return None; + } + (components, bits) + }; + let row = width + .checked_mul(components)? + .checked_mul(bits)? + .div_ceil(8); + row.checked_mul(height) +} + +/// PdfCraft patch: locate the deterministic `EI` terminator of an unfiltered inline image (#3107). +fn unfiltered_image_end( + data: &[u8], + dict: &Dict<'_>, + complete: bool, + header_bytes: usize, +) -> UnfilteredEnd { + let Some(end_data) = unfiltered_image_len(dict) else { + return UnfilteredEnd::Fallback; + }; + // An inline image shares the content window's budget, not the larger decoded-image + // budget. Include its dictionary/header and the shortest whitespace + EI + delimiter + // look-ahead, or one more byte beyond any separator whitespace already present. + // Missing bytes that cannot fit must use the existing heuristic, not pin the window + // and discard following content. Already-available exact ends still win. + let missing_end = if complete + || end_data + .checked_add(4) + .and_then(|minimum| data.len().checked_add(1).map(|next| minimum.max(next))) + .and_then(|needed| needed.checked_add(header_bytes)) + .is_none_or(|needed| needed > crate::MAX_DECODED_STREAM) + { + UnfilteredEnd::Fallback + } else { + UnfilteredEnd::NeedsMore + }; + let Some(after) = data.get(end_data..) else { + return missing_end; + }; + let Some(&first) = after.first() else { + return missing_end; + }; + if !is_white_space_character(first) { + return UnfilteredEnd::Fallback; + } + let Some(separator) = after.iter().position(|&b| !is_white_space_character(b)) else { + return missing_end; + }; + let Some(rest) = after.get(separator..) else { + return UnfilteredEnd::Fallback; + }; + if rest.first() != Some(&b'E') { + return UnfilteredEnd::Fallback; + } + let Some(&second) = rest.get(1) else { + return missing_end; + }; + if second != b'I' { + return UnfilteredEnd::Fallback; + } + let Some(end_after_ei) = end_data + .checked_add(separator) + .and_then(|ei| ei.checked_add(2)) + else { + return UnfilteredEnd::Fallback; + }; + match rest.get(2) { + Some(&b) if !is_regular_character(b) => UnfilteredEnd::Found(end_data, end_after_ei), + Some(_) => UnfilteredEnd::Fallback, + None if complete => UnfilteredEnd::Found(end_data, end_after_ei), + None => missing_end, + } +} + #[cfg(test)] mod tests { use super::{ContentTokenizer, UntypedIter}; + use crate::reader::ReaderExt; fn operators(data: &[u8]) -> Vec> { let mut tokenizer = UntypedIter::new(data); @@ -1081,4 +1273,193 @@ mod tests { assert_eq!(windowed, expected, "{:?}", String::from_utf8_lossy(&data)); } } + + #[test] + fn unfiltered_inline_image_with_ei_in_samples_is_not_cut_early() { + // 48-byte unfiltered DeviceGray image whose first four bytes are `0EI ` + // followed by valid page content operators (`Q BT ... Tj ET q`). + let data = b"q 100 0 0 100 72 650 cm \ +BI /W 48 /H 1 /CS /G /BPC 8 ID 0EI Q BT /F1 18 Tf 72 600 Td (Injected) Tj ET q! EI Q \ +BT /F1 14 Tf 72 550 Td (After) Tj ET"; + assert_eq!( + operators(data), + vec![ + b"q".to_vec(), + b"cm".to_vec(), + b"BI".to_vec(), + b"Q".to_vec(), + b"BT".to_vec(), + b"Tf".to_vec(), + b"Td".to_vec(), + b"Tj".to_vec(), + b"ET".to_vec(), + ] + ); + let expected = slice_instructions(data); + for piece in 1..=data.len() { + assert_eq!( + windowed_instructions(data, || piece), + expected, + "piece size {piece}" + ); + } + } + + #[test] + fn oversized_unfiltered_inline_image_does_not_hold_following_content() { + for length in [ + crate::MAX_DECODED_STREAM - 1, + crate::MAX_DECODED_STREAM, + crate::MAX_DECODED_STREAM + 1, + ] { + // The next BI supplies the existing EI heuristic's bounded look-ahead. The + // stream is not exhausted: an impossible declaration must not pin this window. + let data = format!( + "BI /W {length} /H 1 /CS /G /BPC 8 ID x EI BT (After) Tj ET \ + BI /W 1 /H 1 /CS /G /BPC 8 ID y EI Q" + ); + let mut tokenizer = UntypedIter::new_window(data.as_bytes(), false); + for expected in [b"BI".as_slice(), b"BT", b"Tj", b"ET"] { + let instruction = tokenizer + .next_instruction() + .expect("following content survives"); + assert_eq!( + instruction.operator.as_ref(), + expected, + "declared length {length}" + ); + } + } + } + + #[test] + fn unfiltered_inline_image_budget_includes_header_and_terminator() { + let mut reader = crate::reader::Reader::new(b"/W 1 /H 1 /CS /G /BPC 8 ID "); + let inline = reader + .read_without_context::>() + .expect("synthetic inline dictionary"); + let dict = inline.get_dict(); + let fits = crate::MAX_DECODED_STREAM - 5; // One sample + whitespace/EI/delimiter. + assert!(matches!( + super::unfiltered_image_end(b"", dict, false, fits), + super::UnfilteredEnd::NeedsMore + )); + assert!(matches!( + super::unfiltered_image_end(b"x ", dict, false, fits), + super::UnfilteredEnd::Fallback + )); + assert!(matches!( + super::unfiltered_image_end(b"x EI", dict, false, fits + 1), + super::UnfilteredEnd::Fallback + )); + assert!(matches!( + super::unfiltered_image_end(b"x EI", dict, true, usize::MAX), + super::UnfilteredEnd::Found(1, 4) + )); + for header in [fits + 1, usize::MAX] { + assert!(matches!( + super::unfiltered_image_end(b"", dict, false, header), + super::UnfilteredEnd::Fallback + )); + // The guard is only for missing bytes, never a reason to reinterpret available samples. + assert!(matches!( + super::unfiltered_image_end(b"x EI Q", dict, false, header), + super::UnfilteredEnd::Found(1, 4) + )); + } + } + + #[test] + fn large_unfiltered_inline_image_still_waits_for_its_samples() { + // A valid image larger than the interpreter's initial 64 KiB window must + // still use its exact length rather than execute operators in its samples. + let length = (64 << 10) + 1; + let mut data = format!("BI /W {length} /H 1 /CS /G /BPC 8 ID ").into_bytes(); + let sample_start = data.len(); + data.extend_from_slice(b"0EI Q BT (Injected) Tj ET q"); + data.resize(sample_start + length, b'0'); + data.extend_from_slice(b" EI BT (After) Tj ET"); + let mut partial = UntypedIter::new_window(&data[..64 << 10], false); + assert!(partial.next_instruction().is_none()); + assert!(partial.needs_more); + assert_eq!(partial.committed, 0); + assert_eq!( + operators(&data), + [ + b"BI".to_vec(), + b"BT".to_vec(), + b"Tj".to_vec(), + b"ET".to_vec() + ] + ); + let expected = slice_instructions(&data); + for piece in [4096, 64 << 10, data.len()] { + assert_eq!( + windowed_instructions(&data, || piece), + expected, + "piece size {piece}" + ); + } + } + + #[test] + fn unfiltered_inline_image_variants_and_fallbacks() { + // ImageMask with 1-bit row padding (width 10 -> 2 bytes per row, height 2 -> 4 bytes) + let mask_stream = b"BI /IM true /W 10 /H 2 ID 0EI\n EI Q"; + assert_eq!(operators(mask_stream), vec![b"BI".to_vec(), b"Q".to_vec()]); + let expected_mask = slice_instructions(mask_stream); + for piece in 1..=mask_stream.len() { + assert_eq!(windowed_instructions(mask_stream, || piece), expected_mask); + } + + // Indexed color space with 4-bit samples (width 5 -> 3 bytes per row, height 2 -> 6 bytes) + // and explicit /L matching computed length. + let indexed_stream = + b"BI /Width 5 /Height 2 /CS [/I /RGB 1 <000000ffffff>] /BPC 4 /L 6 ID 0EI Q! EI Q"; + assert_eq!( + operators(indexed_stream), + vec![b"BI".to_vec(), b"Q".to_vec()] + ); + let expected_indexed = slice_instructions(indexed_stream); + for piece in 1..=indexed_stream.len() { + assert_eq!( + windowed_instructions(indexed_stream, || piece), + expected_indexed + ); + } + + // Conflicting /W and /Width falls back to the heuristic scan; mismatched /L does not disrupt the verified layout. + let mismatch_l = b"BI /W 4 /H 1 /CS /G /BPC 8 /L 99 ID abcd EI Q"; + assert_eq!(operators(mismatch_l), vec![b"BI".to_vec(), b"Q".to_vec()]); + let conflict_w = b"BI /W 4 /Width 8 /H 1 /CS /G /BPC 8 ID abcd EI Q"; + assert_eq!(operators(conflict_w), vec![b"BI".to_vec(), b"Q".to_vec()]); + } + + #[test] + fn each_layout_keeps_its_computed_length() { + let inject = b" EI Q BT /F1 9 Tf (X) Tj ET q"; + let case = |params: &str, len: usize| { + let mut samples = inject.to_vec(); + samples.resize(len, b'0'); + let mut data = format!("BI {params} ID ").into_bytes(); + data.extend_from_slice(&samples); + data.extend_from_slice(b" EI Q"); + data + }; + for (params, len) in [ + ("/W 16 /H 2 /CS /RGB /BPC 8", 96), + ("/W 12 /H 2 /CS /CMYK /BPC 8", 96), + ("/W 70 /H 4 /CS /G /BPC 1", 36), + ("/IM true /W 70 /H 4", 36), + ("/W 30 /H 2 /CS [/I /RGB 1 <000000ffffff>] /BPC 4 /L 30", 30), + ("/W 15 /H 2 /CS /G /BPC 16 /L 60", 60), + ("/W 47 /H 1 /CS /G /BPC 8 /L 5", 47), + ] { + assert_eq!( + operators(&case(params, len)), + vec![b"BI".to_vec(), b"Q".to_vec()], + "{params}" + ); + } + } } From 7c7ec9c8ca0256211c7a4d900d938b153ed6c443 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 07:36:15 +0000 Subject: [PATCH 07/17] hayro-interpret: port PdfCraft's optional-content /VE expressions (#2987) An optional content membership dictionary with a visibility expression (/And, /Or, /Not over listed groups) shows its content when the expression is true, ahead of /OCGs and /P. Malformed expressions, or ones nested deeper than 32 levels, fall back to /OCGs and /P. Ledger hayro-interpret (40); the PdfCraft raster test is adapted in upstream_raster.rs. --- crates/pdf/tests/upstream_raster.rs | 51 +++++++++++++++++++ vendor/README.craft-reader.md | 8 +++ vendor/hayro-interpret/SOURCE.json | 2 +- vendor/hayro-interpret/src/ocg.rs | 76 ++++++++++++++++++++++++++++- 4 files changed, 134 insertions(+), 3 deletions(-) diff --git a/crates/pdf/tests/upstream_raster.rs b/crates/pdf/tests/upstream_raster.rs index 800497e1..dfa0598f 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,52 @@ 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"); +} diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index 0024334c..2d57c549 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -205,6 +205,14 @@ OCR text (mode 3) keeps a heading drawn through its clip. The unit tests glyph_names_resolve_to_the_first_glyph_with_that_name}` come with (49) and (50); the #3116 contract is in `tests/upstream_text.rs`. +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. Its PdfCraft raster test is 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, From 30ee2f832fa7af49e77999397fa081c5bcbc27a9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 07:38:08 +0000 Subject: [PATCH 08/17] hayro: port PdfCraft's page transparency group (#2972) The page group composites over a transparent backdrop and is then put on the background, so a blend mode over an empty part of the page keeps its color. With a color replacement, and on a plate render, the background stays inside the group as its backdrop, so plate films are as before. Ledger hayro (18); the PdfCraft raster test is adapted in upstream_raster.rs. --- crates/pdf/tests/upstream_raster.rs | 34 ++++++++++++++++++++++++++++ vendor/README.craft-reader.md | 9 ++++++-- vendor/hayro/SOURCE.json | 2 +- vendor/hayro/src/lib.rs | 35 ++++++++++++++++++++--------- 4 files changed, 67 insertions(+), 13 deletions(-) diff --git a/crates/pdf/tests/upstream_raster.rs b/crates/pdf/tests/upstream_raster.rs index dfa0598f..97c979bb 100644 --- a/crates/pdf/tests/upstream_raster.rs +++ b/crates/pdf/tests/upstream_raster.rs @@ -618,3 +618,37 @@ fn visibility_expressions_decide_what_a_membership_dictionary_shows() { 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 2d57c549..593a7fa7 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -210,8 +210,13 @@ each marked `PdfCraft patch` in the source: hayro-interpret (40) (PdfCraft #2987 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. Its PdfCraft raster test is adapted in -`tests/upstream_raster.rs`. +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 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( From 5ff42f26425eba8ba1363fa1fe68bd0bb6f8cd14 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 07:38:33 +0000 Subject: [PATCH 09/17] hayro-interpret, hayro: port PdfCraft's mesh shading extent (#3296) EncodedShadingPattern::extent records the device pixels a mesh's triangles touched when it was encoded. set_paint returns PaintSetup: the mesh texture covers that extent with Extend::Pad instead of the whole path, and a mesh with no triangle on the canvas paints nothing. Output is unchanged; the knockout erase pass and plate drawing are untouched. Adds hayro's mesh_extent_tests. --- vendor/README.craft-reader.md | 13 +++ vendor/hayro-interpret/SOURCE.json | 2 +- vendor/hayro-interpret/src/encode.rs | 48 ++++++++- vendor/hayro/SOURCE.json | 4 +- vendor/hayro/src/lib.rs | 149 +++++++++++++++++++++++++++ vendor/hayro/src/renderer.rs | 80 ++++++++++---- 6 files changed, 273 insertions(+), 23 deletions(-) diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index 8d69c843..c9acfa7a 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. +A rendering speedup from the [PdfCraft revision 0b670caf4a090e9d83a7f41a84b4cb2144a2d33e ledger](https://github.com/storytold/pdfcraft/blob/0b670caf4a090e9d83a7f41a84b4cb2144a2d33e/vendor/README.md), +hayro-interpret (35) with hayro (19), PdfCraft #3296, marked `PdfCraft patch (#3296)` because +the plate-mode code uses (35) for #2770's own numbers. `EncodedShadingPattern::extent` is the +device-space rectangle of the pixels a mesh shading's triangles touched when it was encoded +(`None` for function-based, axial and radial shadings, and for a mesh with a `/Background`). +`Renderer::set_paint` returns `PaintSetup`: a mesh texture covers that extent (and a +transparent pixel around it, padded instead of repeated) rather than the whole path, and a +mesh with no triangle on the canvas returns `PaintSetup::Nothing`, which the fill, stroke, +rect and stencil callers skip. The rendered output is the same. The knockout erase pass +returns before the extent is used, so it still clears under the whole path. Tests: +`mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, +a_mesh_off_the_canvas_paints_nothing}` in hayro. + 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..1b35655c 100644 --- a/vendor/hayro-interpret/SOURCE.json +++ b/vendor/hayro-interpret/SOURCE.json @@ -206,7 +206,7 @@ "path": "src/encode.rs", "source_sha256": "674a97c48cc62e9a218f843246776528173c36c00aa96a1e4c291c1d912c29c6", "source_git_blob": "b3f830ba2dcdb7069ea73706be43c55be398f670", - "sha256": "025ce1ce561229c005ac0f72b632e22bf782d34739d81c321334fc1dedd96933" + "sha256": "46c9773f05c329cad20f3126394f89a551ee393569c58911b0a921be8c020376" }, { "path": "src/font/blob.rs", diff --git a/vendor/hayro-interpret/src/encode.rs b/vendor/hayro-interpret/src/encode.rs index 872c6057..314d133f 100644 --- a/vendor/hayro-interpret/src/encode.rs +++ b/vendor/hayro-interpret/src/encode.rs @@ -18,6 +18,13 @@ pub struct EncodedShadingPattern { pub(crate) shading_type: EncodedShadingType, pub(crate) opacity: f32, pub(crate) transfer_function: Option, + /// PdfCraft patch (#3296): where the shading can paint at all, in device space: for a mesh + /// (types 4–7), the pixels its triangles touched when it was encoded (empty when none + /// lies in the area); `None` for the shading types that paint everywhere (function-based, + /// axial, radial). The renderer samples a texture only there, and skips a mesh that + /// touches nothing: painted over the whole canvas, 310 one-triangle meshes on a plan cost + /// 310 full tiles per tile, and the watchdog gave the page up. + pub extent: Option, } impl EncodedShadingPattern { @@ -50,6 +57,8 @@ impl ShadingPattern { /// Mesh shadings are sampled pixel by pixel, and only there (see `TriangleSampler`). pub fn encode_within(&self, area: Option) -> EncodedShadingPattern { let base_transform; + // PdfCraft patch (#3296): see `EncodedShadingPattern::extent`. + let mut extent = None; let shading_type = match self.shading.shading_type.as_ref() { ShadingType::FunctionBased { @@ -81,6 +90,7 @@ impl ShadingPattern { let full_transform = self.matrix; let mut sampler = TriangleSampler::new(area); sampler.sample(triangles, full_transform); + extent = Some(sampler.extent()); let samples = sampler.map; base_transform = Affine::IDENTITY; @@ -106,6 +116,7 @@ impl ShadingPattern { break; } } + extent = Some(sampler.extent()); let samples = sampler.map; base_transform = Affine::IDENTITY; @@ -130,6 +141,7 @@ impl ShadingPattern { break; } } + extent = Some(sampler.extent()); let samples = sampler.map; base_transform = Affine::IDENTITY; @@ -162,6 +174,13 @@ impl ShadingPattern { base_transform, opacity: self.opacity, transfer_function: self.transfer_function.clone(), + // A `/Background` is painted wherever the triangles aren't (pattern fills), so such + // a mesh has no extent to confine it to. + extent: if self.shading.background.is_some() { + None + } else { + extent + }, } } } @@ -249,6 +268,8 @@ struct TriangleSampler { map: FxHashMap<(u16, u16), ColorComponents>, area: kurbo::Rect, visits_left: u64, + /// PdfCraft patch (#3296): the pixels sampled so far, as `(x0, y0, x1, y1)` inclusive. + touched: Option<(u16, u16, u16, u16)>, } impl TriangleSampler { @@ -263,6 +284,21 @@ impl TriangleSampler { map: FxHashMap::default(), area: area.unwrap_or(kurbo::Rect::new(0.0, 0.0, 65536.0, 65536.0)), visits_left, + touched: None, + } + } + + /// PdfCraft patch (#3296): the device-space rectangle covering every pixel sampled so far + /// (whole pixels), or an empty rectangle when no triangle touched the area. + fn extent(&self) -> kurbo::Rect { + match self.touched { + Some((x0, y0, x1, y1)) => kurbo::Rect::new( + f64::from(x0), + f64::from(y0), + f64::from(x1) + 1.0, + f64::from(y1) + 1.0, + ), + None => kurbo::Rect::ZERO, } } @@ -322,8 +358,9 @@ impl TriangleSampler { } self.visits_left -= 1; let corner = Point::new(x as f64, y as f64); - if t.contains_point(corner) { + let sampled = if t.contains_point(corner) { self.map.insert((x, y), t.interpolate(corner)); + true } else if t.covers(Point::new(x as f64 + 0.5, y as f64 + 0.5)) { // PdfCraft patch (#3035): a pixel whose corner no triangle holds strictly (it // lies on an edge two triangles share, or on the mesh's side) still belongs @@ -334,6 +371,15 @@ impl TriangleSampler { self.map .entry((x, y)) .or_insert_with(|| t.interpolate(corner)); + true + } else { + false + }; + if sampled { + self.touched = Some(match self.touched { + Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)), + None => (x, y, x, y), + }); } } } diff --git a/vendor/hayro/SOURCE.json b/vendor/hayro/SOURCE.json index 134c145e..3c74e6a9 100644 --- a/vendor/hayro/SOURCE.json +++ b/vendor/hayro/SOURCE.json @@ -62,13 +62,13 @@ "path": "src/lib.rs", "source_sha256": "682db2a32fbf4d875c0e9dd7ac5dc875a6b09ade375efbce6f104de9197de078", "source_git_blob": "6333ed5808ae0cf9cf158e4730e973c1d7089bab", - "sha256": "682db2a32fbf4d875c0e9dd7ac5dc875a6b09ade375efbce6f104de9197de078" + "sha256": "644faa9797c90fbd06ab41ded1c8e2688597009c807101c371c0191e1d1032a4" }, { "path": "src/renderer.rs", "source_sha256": "f7ff7bd088ceb43823ede33151138384af331cf71403f7d23f7b2937212efc3b", "source_git_blob": "6aa53716ad2d0203a8058b2f7271515b4fc1db22", - "sha256": "ab0962cff1ef8886b42e5f4ffd01e958d787ef949260fe4a934890d2e67d5ec2" + "sha256": "02d256f43d9b8bceb68cee9f9f9ed0bcb48bb70d7068f2d1c6d6a9d85e3f4e64" } ] } diff --git a/vendor/hayro/src/lib.rs b/vendor/hayro/src/lib.rs index 6333ed58..19d7faad 100644 --- a/vendor/hayro/src/lib.rs +++ b/vendor/hayro/src/lib.rs @@ -675,3 +675,152 @@ mod color_replacement_render_tests { assert_eq!(sample(&no_line_art, 40, 25), [0, 0, 0, 255]); } } + +/// PdfCraft patch (#3296): a mesh shading paints its triangles alone (`EncodedShadingPattern::extent`), +/// and one with no triangle on the canvas paints nothing. +#[cfg(test)] +mod mesh_extent_tests { + use super::{InterpreterSettings, Pdf, Pixmap, RenderCache, RenderSettings, render}; + use vello_cpu::color::palette::css::WHITE; + + /// A one-page 200×200 pt PDF whose content is `body`, with a type 4 (free-form triangle) + /// shading `/Sh0`: one red triangle with corners (x0, y0), (x1, y0) and (x0, y1) in user + /// space, and an ExtGState `/Half` with 50% alpha. + fn fixture(body: &str, (x0, y0, x1, y1): (i32, i32, i32, i32)) -> Vec { + // Three vertices: a flag, x and y (8 bits each, decoded into the ranges) and RGB. + let mesh: [u8; 18] = [ + 0, 0, 0, 255, 0, 0, // + 0, 255, 0, 255, 0, 0, // + 0, 0, 255, 255, 0, 0, + ]; + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> \ + /ExtGState << /Half << /ca 0.5 /CA 0.5 >> >> >> >>" + .to_vec(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(), + ]; + let mut shading = format!( + "<< /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 \ + /BitsPerFlag 8 /Decode [{x0} {x1} {y0} {y1} 0 1 0 1 0 1] /Length {} >>\nstream\n", + mesh.len() + ) + .into_bytes(); + shading.extend_from_slice(&mesh); + shading.extend_from_slice(b"\nendstream"); + objs.push(shading); + + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice( + format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes(), + ); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice( + format!( + "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", + objs.len() + 1 + ) + .as_bytes(), + ); + out + } + + fn render_at(pdf: Vec, scale: f32) -> Pixmap { + let pdf = Pdf::new(pdf).unwrap(); + let cache = RenderCache::new(); + let settings = RenderSettings { + x_scale: scale, + y_scale: scale, + bg_color: WHITE, + ..RenderSettings::default() + }; + render( + pdf.pages().get(0).expect("the fixture has one page"), + &cache, + &InterpreterSettings::default(), + &settings, + ) + } + + fn px(pixmap: &Pixmap, x: u16, y: u16) -> [u8; 4] { + let p = pixmap.sample(x, y); + [p.r, p.g, p.b, p.a] + } + + fn inked(pixmap: &Pixmap) -> usize { + (0..pixmap.height()) + .flat_map(|y| (0..pixmap.width()).map(move |x| (x, y))) + .filter(|&(x, y)| px(pixmap, x, y) != [255, 255, 255, 255]) + .count() + } + + /// The triangle (100, 100)–(110, 100)–(100, 110) in a 200 pt page: in device space (y + /// down) the pixel (102, 97) is inside it, (108, 91) past its hypotenuse, and the rest of + /// the page is nowhere near it. + #[test] + fn a_mesh_paints_its_triangles_alone() { + let white = [255, 255, 255, 255]; + for scale in [1.0_f32, 4.0] { + let p = render_at(fixture("/Sh0 sh", (100, 100, 110, 110)), scale); + let at = |x: f32, y: f32| ((x * scale) as u16, (y * scale) as u16); + let (x, y) = at(102.5, 97.5); + assert_eq!( + px(&p, x, y), + [255, 0, 0, 255], + "scale {scale}: inside the triangle" + ); + let (x, y) = at(108.5, 91.5); + assert_eq!(px(&p, x, y), white, "scale {scale}: past the hypotenuse"); + let (x, y) = at(150.0, 50.0); + assert_eq!(px(&p, x, y), white, "scale {scale}: far from it"); + let (x, y) = at(10.0, 190.0); + assert_eq!( + px(&p, x, y), + white, + "scale {scale}: the texture is not repeated" + ); + // A 10 × 10 pt right triangle covers 50 pt²; the sampler counts the pixels whose + // corner lies inside it (36 at scale 1, nearer 50 as the scale grows), and nothing + // beyond its own bounding box is touched. + let area = inked(&p) as f32 / (scale * scale); + assert!( + (30.0..=60.0).contains(&area), + "scale {scale}: {area} pt² inked" + ); + } + } + + /// The alpha of the graphics state still applies: red at 50% over white is pink. + #[test] + fn a_mesh_keeps_the_graphics_state_alpha() { + let p = render_at(fixture("/Half gs /Sh0 sh", (100, 100, 110, 110)), 1.0); + let [r, g, b, a] = px(&p, 102, 97); + assert!( + r == 255 && (120..=136).contains(&g) && g == b && a == 255, + "{:?}", + [r, g, b, a] + ); + } + + /// A mesh whose triangle lies off the canvas paints nothing, and costs no texture. + #[test] + fn a_mesh_off_the_canvas_paints_nothing() { + let p = render_at(fixture("/Sh0 sh", (300, 300, 310, 310)), 1.0); + assert_eq!(inked(&p), 0); + // One straddling the edge paints its part on the canvas. + let p = render_at(fixture("/Sh0 sh", (195, 100, 205, 110)), 1.0); + assert_eq!(px(&p, 196, 98), [255, 0, 0, 255]); + assert_eq!(px(&p, 150, 50), [255, 255, 255, 255]); + } +} diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index dcc37839..ea329b81 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -427,6 +427,15 @@ fn snap_minified_image( (w.abs_diff(planned.0) <= 1 && h.abs_diff(planned.1) <= 1).then_some((w, h, x0, y0)) } +/// PdfCraft patch (#3296): what `Renderer::set_paint` found out: the clip path to apply while +/// painting (if any), or that the paint touches no pixel of the canvas, so the caller skips +/// the fill or stroke (a mesh shading whose triangles all lie outside the path or canvas). +#[must_use] +enum PaintSetup { + Clip(Option), + Nothing, +} + pub(crate) struct Renderer { pub(crate) ctx: RenderContext, /// PdfCraft patch: when set, solid text and line-art paint colours are remapped @@ -1093,23 +1102,26 @@ impl Renderer { } #[must_use] - fn set_paint(&mut self, paint: &Paint<'_>, path: &BezPath, is_stroke: bool) -> Option { + fn set_paint(&mut self, paint: &Paint<'_>, path: &BezPath, is_stroke: bool) -> PaintSetup { // Knockout patch (pdfcraft #2974): in a knockout erase pass every paint clears what is // under its shape (a transparent fill with `Compose::Copy`, weighted by coverage). if self.erasing { self.ctx.set_blend_mode(knockout_blend_mode()); self.ctx.set_paint(AlphaColor::::new([0.0, 0.0, 0.0, 0.0])); // A shading's `/BBox` clips what it paints, so it clips what it clears too. - return match paint { + return PaintSetup::Clip(match paint { Paint::Pattern(p) => match p.as_ref() { Pattern::Shading(s) => s.shading.clip_path.clone(), _ => None, }, _ => None, - }; + }); } let mut paint_transform = Affine::IDENTITY; let mut clip_path = None; + // PdfCraft patch (#3296): `Extend::Repeat` tiles the texture over the path; a mesh + // texture covers its triangles alone and must not repeat outside them. + let mut extend = peniko::Extend::Repeat; let paint: PaintType = match paint.clone() { Paint::Color(c) => { @@ -1142,6 +1154,28 @@ impl Renderer { // pixel around it: texels at a fractional edge look one pixel further). let encoded = s.encode_within(Some(bbox.inflate(1.0, 1.0))); + // PdfCraft patch (#3296): a mesh paints only where its triangles are. + // The texture covers the triangles alone (and a pixel around them, + // which stays transparent and is what `Extend::Pad` repeats), not the + // whole path (the canvas, for `sh`): sampled pixel by pixel, 310 + // one-triangle meshes on a plan cost 310 full tiles per tile, and the + // watchdog gave the page up. A mesh with no triangle on the canvas + // paints nothing at all. + if let Some(extent) = encoded.extent { + // An empty extent (no triangle in the area) is `Rect::ZERO`, which + // sits at the canvas origin: test it before intersecting, or a + // 1 × 1 transparent texture would still be painted over the whole + // path (310 of them cost 230 ms per tile). + if extent.width() <= 0.0 || extent.height() <= 0.0 { + return PaintSetup::Nothing; + } + bbox = bbox.intersect(extent.inflate(1.0, 1.0)); + if bbox.width() <= 0.0 || bbox.height() <= 0.0 { + return PaintSetup::Nothing; + } + extend = peniko::Extend::Pad; + } + // PdfCraft patch (backport of hayro 0.8): axial and radial shadings // become native gradients instead of a texture sampled pixel by pixel. // Upstream also shifts by half a pixel for vello_cpu 0.3; 0.0.8 already @@ -1157,7 +1191,7 @@ impl Renderer { self.ctx .set_paint_transform(path_transform.inverse() * native.transform); self.ctx.set_paint(native_gradient(native)); - return clip_path; + return PaintSetup::Clip(clip_path); } let (image, width, height, transform, may_have_transparency) = @@ -1176,8 +1210,8 @@ impl Renderer { let image = Image { image: ImageSource::Pixmap(Arc::new(pixmap)), sampler: ImageSampler { - x_extend: peniko::Extend::Repeat, - y_extend: peniko::Extend::Repeat, + x_extend: extend, + y_extend: extend, quality: ImageQuality::Medium, alpha: 1.0, }, @@ -1263,7 +1297,7 @@ impl Renderer { }; self.ctx.set_paint_transform(paint_transform); self.ctx.set_paint(PaintType::Image(image)); - return clip_path; + return PaintSetup::Clip(clip_path); } // PdfCraft patch: the cell pixmap is `step × scale` pixels, which only the // u16 cast below bounded (a fuzzed /XStep far beyond /BBox gave 65535² cells). @@ -1341,7 +1375,7 @@ impl Renderer { self.ctx.set_paint_transform(paint_transform); self.ctx.set_paint(paint); - clip_path + PaintSetup::Clip(clip_path) } fn stroke_path( @@ -1361,7 +1395,9 @@ impl Renderer { // PdfCraft patch: apply the reading-colours mapping before painting. let paint = self.replaced(paint, is_text); - let clip_path = self.set_paint(&paint, path, true); + let PaintSetup::Clip(clip_path) = self.set_paint(&paint, path, true) else { + return; + }; if let Some(clip_path) = clip_path.as_ref() { self.push_clip_path_inner(clip_path, FillRule::NonZero); } @@ -1390,7 +1426,9 @@ impl Renderer { // PdfCraft patch: apply the reading-colours mapping before painting. let paint = self.replaced(paint, is_text); - let clip_path = self.set_paint(&paint, path, false); + let PaintSetup::Clip(clip_path) = self.set_paint(&paint, path, false) else { + return; + }; if let Some(clip_path) = clip_path.as_ref() { self.push_clip_path_inner(clip_path, fill_rule); } @@ -1621,14 +1659,16 @@ impl<'a> Device<'a> for Renderer { // PdfCraft patch: a stencil mask is painted with the fill // (non-stroking) paint; `true` drew an uncolored tiling pattern // in its stroke color (black by default) (#2975). - let clip_path = - self.set_paint(paint, &stencil_rect.to_path(0.1), false); - if let Some(clip_path) = clip_path.as_ref() { - self.push_clip_path_inner(clip_path, FillRule::NonZero); - } - self.ctx.fill_rect(&stencil_rect); - if clip_path.is_some() { - self.ctx.pop_clip_path(); + if let PaintSetup::Clip(clip_path) = + self.set_paint(paint, &stencil_rect.to_path(0.1), false) + { + if let Some(clip_path) = clip_path.as_ref() { + self.push_clip_path_inner(clip_path, FillRule::NonZero); + } + self.ctx.fill_rect(&stencil_rect); + if clip_path.is_some() { + self.ctx.pop_clip_path(); + } } self.ctx.pop_layer(); @@ -1847,7 +1887,9 @@ impl<'a> Device<'a> for Renderer { // PdfCraft patch: apply the reading-colours mapping before painting. let paint = self.replaced(paint, false); - let clip_path = self.set_paint(&paint, &path, false); + let PaintSetup::Clip(clip_path) = self.set_paint(&paint, &path, false) else { + return; + }; if let Some(clip_path) = clip_path.as_ref() { self.push_clip_path_inner(clip_path, *fill_rule); } From 4952238c66f4ed2d94c029afe14132f074eed4ac Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 07:39:43 +0000 Subject: [PATCH 10/17] hayro-interpret: port PdfCraft's /Default color spaces (#3018) A resource dictionary's /DefaultGray, /DefaultRGB and /DefaultCMYK replace the device spaces that color operators, cs/CS, images and shadings select (ISO 32000-2 8.6.5.6). Decoded images and shadings are cached per default. Defaults follow this tree's resource scoping (a form with /Resources sees only its own), and they do not apply on a plate, so device color separates onto the same inks as before. PdfCraft's raster tests are adapted in upstream_raster.rs, with a plate test in plates.rs. --- crates/pdf/tests/plates.rs | 22 ++ crates/pdf/tests/upstream_raster.rs | 392 ++++++++++++++++++++ vendor/README.craft-reader.md | 18 + vendor/hayro-interpret/SOURCE.json | 12 +- vendor/hayro-interpret/src/cache.rs | 6 +- vendor/hayro-interpret/src/color.rs | 145 ++++++++ vendor/hayro-interpret/src/interpret/mod.rs | 93 +++-- vendor/hayro-interpret/src/pattern.rs | 14 +- vendor/hayro-interpret/src/shading.rs | 26 +- vendor/hayro-interpret/src/x_object.rs | 49 ++- 10 files changed, 723 insertions(+), 54 deletions(-) diff --git a/crates/pdf/tests/plates.rs b/crates/pdf/tests/plates.rs index d7f0da0d..41e6d653 100644 --- a/crates/pdf/tests/plates.rs +++ b/crates/pdf/tests/plates.rs @@ -515,3 +515,25 @@ fn a_knockout_group_separates_with_only_the_top_object_in_the_overlap() { let blended = plates(&group(""), &inks(&[]), true, None); assert_eq!(blended.at(20, 20), [25, 50, 0, 0], "both squares without /K"); } + +/// PdfCraft #3018: `/DefaultCMYK` and `/DefaultGray` change how device color looks, not the +/// inks it prints with. Under them DeviceCMYK still separates as its components (and keeps the +/// nonzero overprint mode), a DeviceCMYK image sample by sample, and DeviceGray on black alone, +/// with or without a simulation whose profile differs from the default. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn default_color_spaces_leave_device_color_on_its_plates() { + let image = stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceCMYK", &[51, 102, 153, 204]); + let objects = [stream("/N 4", CGATS), stream("/N 1", &synthetic_icc::gray_profile("Gray 2.2")), image]; + let content = "0 0 0 1 k 0 0 10 40 re f 1 0 0 0 k 10 0 10 40 re f /M1 gs 0 1 0 0 k 10 0 10 40 re f \ + q 10 0 0 40 20 0 cm /Im1 Do Q 0.25 g 30 0 10 40 re f"; + let resources = "/ExtGState << /M1 << /OP true /OPM 1 >> >> /XObject << /Im1 7 0 R >>"; + let calibrated = format!("{resources} /ColorSpace << /DefaultCMYK [/ICCBased 5 0 R] /DefaultGray [/ICCBased 6 0 R] >>"); + let expected: [[u32; 4]; 4] = [[0, 0, 0, 100], [100, 100, 0, 0], [20, 40, 60, 80], [0, 0, 0, 75]]; + for simulation in [None, Some(press_simulation())] { + let s = plates(&page_pdf(content, &calibrated, &objects), &inks(&[]), true, simulation.as_ref()); + for (x, want) in [5, 15, 25, 35].into_iter().zip(expected) { + assert_eq!(s.at(x, 20), want, "x {x}, simulated: {}", simulation.is_some()); + } + } +} diff --git a/crates/pdf/tests/upstream_raster.rs b/crates/pdf/tests/upstream_raster.rs index 800497e1..49d9b26f 100644 --- a/crates/pdf/tests/upstream_raster.rs +++ b/crates/pdf/tests/upstream_raster.rs @@ -567,3 +567,395 @@ 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 #3018 (ISO 32000-2 §8.6.5.6): the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` +/// color spaces of a resource dictionary stand in for the device spaces its content selects, +/// with color operators, images and shadings. The defaults are synthetic ICC profiles that send +/// device color somewhere it cannot go uncalibrated: red to green (or blue), black to mid gray, +/// black ink to blue. +mod default_color_spaces { + use super::{Pixels, render}; + + const WIDTH: usize = 130; + const RED: [u8; 3] = [255, 0, 0]; + const GREEN: [u8; 3] = [0, 255, 0]; + const BLUE: [u8; 3] = [0, 0, 255]; + const BLACK: [u8; 3] = [0, 0, 0]; + /// Device black through `gray_profile`: half the luminance of white, sRGB-encoded. + const MID_GRAY: [u8; 3] = [188, 188, 188]; + /// Full black ink through `cmyk_profile`: CIELAB (40, 20, -60) relative to D50. + const INK_BLUE: [u8; 3] = [67, 86, 194]; + const ONE_PIXEL_RGB: &str = "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 3 >>"; + + /// An s15Fixed16Number (ICC.1 §4.6). + fn s15f16(value: f64) -> [u8; 4] { + ((value * 65536.0).round() as i32).to_be_bytes() + } + + /// An ICC v2 profile of device class `class` for the color space `space`, with `tags` + /// (signature, data) in that order. The device model stays blank: hayro skips the transform + /// of a profile whose model reads `sRGB`. + fn icc(class: &[u8; 4], space: &[u8; 4], pcs: &[u8; 4], tags: &[([u8; 4], Vec)]) -> Vec { + let start = 128 + 4 + 12 * tags.len(); + let (mut table, mut data) = (Vec::new(), Vec::new()); + for (signature, tag) in tags { + table.extend_from_slice(signature); + table.extend_from_slice(&u32::try_from(start + data.len()).unwrap().to_be_bytes()); + table.extend_from_slice(&u32::try_from(tag.len()).unwrap().to_be_bytes()); + data.extend_from_slice(tag); + data.resize(data.len().next_multiple_of(4), 0); + } + let mut profile = vec![0; 128]; + profile[0..4].copy_from_slice(&u32::try_from(start + data.len()).unwrap().to_be_bytes()); + profile[8..12].copy_from_slice(&[2, 0x10, 0, 0]); + profile[12..16].copy_from_slice(class); + profile[16..20].copy_from_slice(space); + profile[20..24].copy_from_slice(pcs); + profile[36..40].copy_from_slice(b"acsp"); + for (i, value) in [0.9642, 1.0, 0.8249].into_iter().enumerate() { + profile[68 + 4 * i..72 + 4 * i].copy_from_slice(&s15f16(value)); + } + profile.extend_from_slice(&u32::try_from(tags.len()).unwrap().to_be_bytes()); + profile.extend(table); + profile.extend(data); + profile + } + + fn xyz([x, y, z]: [f64; 3]) -> Vec { + [b"XYZ ".as_slice(), &[0; 4], &s15f16(x), &s15f16(y), &s15f16(z)].concat() + } + + /// A `curveType` tag: one entry is a gamma (u8Fixed8), more are points spread evenly over 0..1. + fn curve(points: &[u16]) -> Vec { + let mut tag = [b"curv".as_slice(), &[0; 4], &u32::try_from(points.len()).unwrap().to_be_bytes()].concat(); + for point in points { + tag.extend_from_slice(&point.to_be_bytes()); + } + tag + } + + /// sRGB's red, green and blue colorants, adapted to D50. + const SRGB_COLORANTS: [[f64; 3]; 3] = [[0.4361, 0.2225, 0.0139], [0.3851, 0.7169, 0.0971], [0.1431, 0.0606, 0.7141]]; + + /// A linear RGB profile whose red, green and blue take the sRGB colorants `primaries` names: + /// with `[1, 2, 0]` red is drawn as sRGB green. + fn rgb_profile(primaries: [usize; 3]) -> Vec { + let mut tags = Vec::new(); + for (signature, primary) in [*b"rXYZ", *b"gXYZ", *b"bXYZ"].into_iter().zip(primaries) { + tags.push((signature, xyz(SRGB_COLORANTS[primary]))); + } + for signature in [*b"rTRC", *b"gTRC", *b"bTRC"] { + tags.push((signature, curve(&[0x0100]))); + } + icc(b"mntr", b"RGB ", b"XYZ ", &tags) + } + + /// A gray profile whose tone curve rises from half the luminance of white, so `0 g` is mid gray. + fn gray_profile() -> Vec { + icc(b"mntr", b"GRAY", b"XYZ ", &[(*b"kTRC", curve(&[0x8000, 0xffff]))]) + } + + /// A CMYK profile (`lut8Type` into CIELAB) that draws full black ink as L*a*b* (40, 20, -60) + /// and no black ink as white, whatever the other inks. + fn cmyk_profile() -> Vec { + let mut lut = [b"mft1".as_slice(), &[0; 4], &[4, 3, 2, 0]].concat(); + for value in [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0] { + lut.extend_from_slice(&s15f16(value)); + } + for _ in 0..4 { + lut.extend(0..=255_u8); + } + // Two grid points per ink, black varying fastest; Lab encoded as L* · 2.55, a* + 128, b* + 128. + for point in 0..16 { + lut.extend_from_slice(if point % 2 == 1 { &[102, 148, 68] } else { &[255, 128, 128] }); + } + for _ in 0..3 { + lut.extend(0..=255_u8); + } + icc(b"prtr", b"CMYK", b"Lab ", &[(*b"A2B0", lut)]) + } + + fn profile_stream(n: u8, profile: Vec) -> (String, Vec) { + (format!("<< /N {n} /Length {} >>", profile.len()), profile) + } + + /// A form XObject covering the page, with the dictionary entries `extra`. + fn form(extra: &str, content: &str) -> (String, Vec) { + (format!("<< /Type /XObject /Subtype /Form /BBox [0 0 130 70] {extra} /Length {} >>", content.len()), content.as_bytes().to_vec()) + } + + /// Draw `content` with `resources` on a 130 × 70 pt page whose objects 5, 6, … are the + /// streams `objects`, and give the color at each of `points` (from the top left). + fn colors(content: &str, resources: &str, objects: &[(String, Vec)], points: &[(usize, usize)]) -> Vec<[u8; 3]> { + let mut pdf = format!( + "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 130 70] /Contents 4 0 R /Resources {resources} >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", + content.len() + ) + .into_bytes(); + for (i, (dict, data)) in objects.iter().enumerate() { + pdf.extend(format!("{} 0 obj {dict} stream\n", i + 5).bytes()); + pdf.extend_from_slice(data); + pdf.extend_from_slice(b"\nendstream endobj\n"); + } + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF"); + let Pixels { width, height, rgba } = render(&pdf); + assert_eq!((width as usize, height), (WIDTH, 70)); + points.iter().map(|&(x, y)| rgba[(y * WIDTH + x) * 4..][..3].try_into().unwrap()).collect() + } + + #[track_caller] + fn assert_near(got: [u8; 3], want: [u8; 3], what: &str) { + assert!(got.iter().zip(want).all(|(got, want)| got.abs_diff(want) <= 6), "{what}: got {got:?}, expected about {want:?}"); + } + + /// Device CMYK black ink through hayro's own CMYK profile: near black. + #[track_caller] + fn assert_dark(got: [u8; 3], what: &str) { + assert!(got.iter().all(|c| *c < 90), "{what}: got {got:?}, expected near black"); + } + + /// `rg`, `RG`, `/DeviceRGB cs` and a `/ColorSpace` resource naming DeviceRGB all draw in the + /// page's DefaultRGB; without one they draw device red. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn device_rgb_operators_draw_in_default_rgb() { + let content = "1 0 0 rg 0 0 30 70 re f /DeviceRGB cs 1 0 0 sc 32 0 30 70 re f /CS0 cs 1 0 0 sc 64 0 30 70 re f \ + 1 0 0 RG 20 w 111 0 m 111 70 l S"; + let points = [(15, 35), (47, 35), (79, 35), (111, 35)]; + let what = ["rg", "/DeviceRGB cs", "/CS0 cs (/DeviceRGB)", "RG"]; + let objects = [profile_stream(3, rgb_profile([1, 2, 0]))]; + let calibrated = colors(content, "<< /ColorSpace << /DefaultRGB [/ICCBased 5 0 R] /CS0 /DeviceRGB >> >>", &objects, &points); + for (got, what) in calibrated.into_iter().zip(what) { + assert_near(got, GREEN, &format!("{what} with DefaultRGB")); + } + let device = colors(content, "<< /ColorSpace << /CS0 /DeviceRGB >> >>", &objects, &points); + for (got, what) in device.into_iter().zip(what) { + assert_eq!(got, RED, "{what} without DefaultRGB"); + } + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn device_cmyk_operators_draw_in_default_cmyk() { + let content = "0 0 0 1 k 0 0 40 70 re f /DeviceCMYK cs 0 0 0 1 sc 45 0 40 70 re f 0 0 0 1 K 20 w 110 0 m 110 70 l S"; + let points = [(20, 35), (65, 35), (110, 35)]; + let what = ["k", "/DeviceCMYK cs", "K"]; + let objects = [profile_stream(4, cmyk_profile())]; + let calibrated = colors(content, "<< /ColorSpace << /DefaultCMYK [/ICCBased 5 0 R] >> >>", &objects, &points); + for (got, what) in calibrated.into_iter().zip(what) { + assert_near(got, INK_BLUE, &format!("{what} with DefaultCMYK")); + } + for (got, what) in colors(content, "<< >>", &objects, &points).into_iter().zip(what) { + assert_dark(got, &format!("{what} without DefaultCMYK")); + } + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn device_gray_operators_draw_in_default_gray() { + let content = "0 g 0 0 40 70 re f /DeviceGray cs 0 sc 45 0 40 70 re f 0 G 20 w 110 0 m 110 70 l S"; + let points = [(20, 35), (65, 35), (110, 35)]; + let what = ["g", "/DeviceGray cs", "G"]; + let objects = [profile_stream(1, gray_profile())]; + let calibrated = colors(content, "<< /ColorSpace << /DefaultGray [/ICCBased 5 0 R] >> >>", &objects, &points); + for (got, what) in calibrated.into_iter().zip(what) { + assert_near(got, MID_GRAY, &format!("{what} with DefaultGray")); + } + for (got, what) in colors(content, "<< >>", &objects, &points).into_iter().zip(what) { + assert_eq!(got, BLACK, "{what} without DefaultGray"); + } + } + + /// Image XObjects and inline images (whose `/RGB` and `/G` abbreviate the device spaces, or + /// that name a `/ColorSpace` resource that is DeviceRGB) in a device space draw in its default. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn device_images_draw_in_the_default_color_spaces() { + let content = "q 20 0 0 70 0 0 cm /R Do Q q 20 0 0 70 22 0 cm /G Do Q q 20 0 0 70 44 0 cm /K Do Q \ + q 20 0 0 70 66 0 cm BI /W 1 /H 1 /CS /RGB /BPC 8 /F /AHx ID ff0000> EI Q \ + q 20 0 0 70 88 0 cm BI /W 1 /H 1 /CS /CS0 /BPC 8 /F /AHx ID ff0000> EI Q \ + q 20 0 0 70 110 0 cm BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 00> EI Q"; + let points = [(10, 35), (32, 35), (54, 35), (76, 35), (98, 35), (120, 35)]; + let image = |space: &str, data: &[u8]| { + let dict = + format!("<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /{space} /BitsPerComponent 8 /Length {} >>", data.len()); + (dict, data.to_vec()) + }; + let objects = [ + profile_stream(3, rgb_profile([1, 2, 0])), + profile_stream(1, gray_profile()), + profile_stream(4, cmyk_profile()), + image("DeviceRGB", &[255, 0, 0]), + image("DeviceGray", &[0]), + image("DeviceCMYK", &[0, 0, 0, 255]), + ]; + let x_objects = "/XObject << /R 8 0 R /G 9 0 R /K 10 0 R >>"; + let defaults = "/DefaultRGB [/ICCBased 5 0 R] /DefaultGray [/ICCBased 6 0 R] /DefaultCMYK [/ICCBased 7 0 R]"; + let calibrated = colors(content, &format!("<< /ColorSpace << {defaults} /CS0 /DeviceRGB >> {x_objects} >>"), &objects, &points); + assert_near(calibrated[0], GREEN, "DeviceRGB image"); + assert_near(calibrated[1], MID_GRAY, "DeviceGray image"); + assert_near(calibrated[2], INK_BLUE, "DeviceCMYK image"); + assert_near(calibrated[3], GREEN, "inline /RGB image"); + assert_near(calibrated[4], GREEN, "inline image in /CS0 (/DeviceRGB)"); + assert_near(calibrated[5], MID_GRAY, "inline /G image"); + + let device = colors(content, &format!("<< /ColorSpace << /CS0 /DeviceRGB >> {x_objects} >>"), &objects, &points); + assert_eq!(device[0], RED, "DeviceRGB image without DefaultRGB"); + assert_eq!(device[1], BLACK, "DeviceGray image without DefaultGray"); + assert_dark(device[2], "DeviceCMYK image without DefaultCMYK"); + assert_eq!(device[3], RED, "inline /RGB image without DefaultRGB"); + assert_eq!(device[4], RED, "inline /CS0 image without DefaultRGB"); + assert_eq!(device[5], BLACK, "inline /G image without DefaultGray"); + } + + /// Shadings in a device space, painted by `sh` or as a shading pattern, with colors from a + /// function (axial) or from their vertices (a Type 4 mesh), draw in its default. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn device_shadings_draw_in_the_default_color_spaces() { + let axial = |space: &str, color: &str| { + format!( + "<< /ShadingType 2 /ColorSpace /{space} /Coords [0 0 130 0] /Extend [true true] \ + /Function << /FunctionType 2 /Domain [0 1] /C0 [{color}] /C1 [{color}] /N 1 >> >>" + ) + }; + // Two red triangles over the page: a flag, x, y and red, green and blue, a byte each. + let mut mesh: Vec = Vec::new(); + for (x, y) in [(0, 0), (255, 0), (0, 255), (255, 0), (255, 255), (0, 255)] { + mesh.extend_from_slice(&[0, x, y, 255, 0, 0]); + } + let objects = [ + profile_stream(3, rgb_profile([1, 2, 0])), + profile_stream(1, gray_profile()), + ( + format!( + "<< /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 \ + /Decode [0 255 0 255 0 1 0 1 0 1] /Length {} >>", + mesh.len() + ), + mesh, + ), + ]; + let content = "q 0 0 30 70 re W n /A sh Q /Pattern cs /P scn 32 0 30 70 re f \ + q 64 0 30 70 re W n /M sh Q q 96 0 30 70 re W n /G sh Q"; + let points = [(15, 35), (47, 35), (79, 35), (111, 35)]; + let shadings = format!( + "/Shading << /A {} /M 7 0 R /G {} >> /Pattern << /P << /PatternType 2 /Shading {} >> >>", + axial("DeviceRGB", "1 0 0"), + axial("DeviceGray", "0"), + axial("DeviceRGB", "1 0 0") + ); + let calibrated = colors( + content, + &format!("<< /ColorSpace << /DefaultRGB [/ICCBased 5 0 R] /DefaultGray [/ICCBased 6 0 R] >> {shadings} >>"), + &objects, + &points, + ); + assert_near(calibrated[0], GREEN, "sh, an axial shading in DeviceRGB"); + assert_near(calibrated[1], GREEN, "a shading pattern in DeviceRGB"); + assert_near(calibrated[2], GREEN, "sh, a Type 4 mesh in DeviceRGB"); + assert_near(calibrated[3], MID_GRAY, "sh, an axial shading in DeviceGray"); + + let device = colors(content, &format!("<< {shadings} >>"), &objects, &points); + assert_near(device[0], RED, "sh, an axial shading without DefaultRGB"); + assert_near(device[1], RED, "a shading pattern without DefaultRGB"); + assert_near(device[2], RED, "sh, a Type 4 mesh without DefaultRGB"); + assert_near(device[3], BLACK, "sh, an axial shading without DefaultGray"); + } + + /// One image drawn by a page with DefaultRGB and by a form that switches it off is decoded + /// for each: the decoded-image cache keys the default. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn an_image_drawn_with_and_without_a_default_keeps_both_colors() { + let objects = [ + profile_stream(3, rgb_profile([1, 2, 0])), + (ONE_PIXEL_RGB.to_owned(), vec![255, 0, 0]), + form("/Resources << /ColorSpace << /DefaultRGB /DeviceRGB >> /XObject << /R 6 0 R >> >>", "q 40 0 0 70 70 0 cm /R Do Q"), + ]; + let got = colors( + "q 40 0 0 70 0 0 cm /R Do Q /F Do", + "<< /ColorSpace << /DefaultRGB [/ICCBased 5 0 R] >> /XObject << /R 6 0 R /F 7 0 R >> >>", + &objects, + &[(20, 35), (90, 35)], + ); + assert_near(got[0], GREEN, "the image on the page, in DefaultRGB"); + assert_eq!(got[1], RED, "the same image in the form without DefaultRGB"); + } + + /// A form XObject draws device color in the defaults of the resources in its scope: the + /// page's when it has no `/Resources` (directly or nested in another form), else only its + /// own, as for every other resource here. PdfCraft, Poppler and MuPDF also fall back to the + /// page's default from a form with `/Resources` of its own. A form whose own default is a + /// device space or not a usable default draws device color. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn forms_draw_in_the_default_color_spaces_of_their_resource_scope() { + let blue = rgb_profile([2, 0, 1]); + let objects = [ + profile_stream(3, rgb_profile([1, 2, 0])), + // hayro caches a profile by its stream's dictionary, which would otherwise match the first. + (format!("<< /N 3 /Alternate /DeviceRGB /Length {} >>", blue.len()), blue), + form("", "1 0 0 rg 22 37 20 33 re f"), + form("/Resources << /ProcSet [/PDF] >>", "1 0 0 rg 44 37 20 33 re f"), + form("/Resources << /ColorSpace << /DefaultRGB [/ICCBased 6 0 R] >> /XObject << /N 12 0 R >> >>", "1 0 0 rg 66 37 20 33 re f /N Do"), + form("/Resources << /ColorSpace << /DefaultRGB /DeviceRGB >> >>", "1 0 0 rg 88 37 20 33 re f"), + form("/Resources << /ColorSpace << /DefaultRGB [/Lab << /WhitePoint [0.9642 1 0.8249] >>] >> >>", "1 0 0 rg 110 37 20 33 re f"), + form("", "1 0 0 rg 0 0 20 33 re f"), + form("/Resources << /ColorSpace << /CS1 /DeviceGray >> >>", "1 0 0 rg 22 0 20 33 re f"), + ]; + let got = colors( + "1 0 0 rg 0 37 20 33 re f /A Do /B Do /C Do /D Do /E Do /F Do", + "<< /ColorSpace << /DefaultRGB [/ICCBased 5 0 R] >> /XObject << /A 7 0 R /B 8 0 R /C 9 0 R /D 10 0 R /E 11 0 R /F 13 0 R >> >>", + &objects, + &[(10, 17), (32, 17), (54, 17), (76, 17), (98, 17), (120, 17), (10, 53), (32, 53)], + ); + assert_near(got[0], GREEN, "the page"); + assert_near(got[1], GREEN, "a form without /Resources"); + assert_eq!(got[2], RED, "a form whose /Resources has no /ColorSpace"); + assert_near(got[3], BLUE, "a form with a DefaultRGB of its own"); + assert_eq!(got[4], RED, "a form whose DefaultRGB is /DeviceRGB"); + assert_eq!(got[5], RED, "a form whose DefaultRGB is a Lab space"); + assert_near(got[6], BLUE, "a form nested in the one with its own DefaultRGB"); + assert_eq!(got[7], RED, "a form whose /ColorSpace has no DefaultRGB"); + } + + /// A default that cannot stand in for its device space (Lab, Indexed, Pattern, another device + /// space, the wrong number of components, or no color space) leaves device color as it is, + /// and the dictionary's other defaults still apply. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn unusable_defaults_leave_device_color_alone() { + let objects = [ + profile_stream(1, gray_profile()), + profile_stream(4, cmyk_profile()), + (String::from("<< /N 3 /Length 13 >>"), b"not a profile".to_vec()), + ]; + for default in [ + "[/Lab << /WhitePoint [0.9642 1 0.8249] >>]", + "[/CalGray << /WhitePoint [0.9642 1 0.8249] >>]", + "[/Indexed /DeviceRGB 0 <00FF00>]", + "/Pattern", + "[/Pattern /DeviceRGB]", + "/DeviceCMYK", + "/DeviceGray", + "[/ICCBased 6 0 R]", + "[/ICCBased 7 0 R]", + "42", + "[/NoSuchSpace]", + "/DefaultRGB", + ] { + let got = colors( + "1 0 0 rg 0 0 60 70 re f 0 g 70 0 60 70 re f", + &format!("<< /ColorSpace << /DefaultRGB {default} /DefaultGray [/ICCBased 5 0 R] >> >>"), + &objects, + &[(30, 35), (100, 35)], + ); + assert_eq!(got[0], RED, "DefaultRGB {default}: device RGB is drawn as it is"); + assert_near(got[1], MID_GRAY, &format!("DefaultRGB {default}: DefaultGray still applies")); + } + } +} diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index 0024334c..db194dde 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -205,6 +205,24 @@ OCR text (mode 3) keeps a heading drawn through its clip. The unit tests glyph_names_resolve_to_the_first_glyph_with_that_name}` come with (49) and (50); the #3116 contract is in `tests/upstream_text.rs`. +Default color spaces from the [PdfCraft revision 0b670caf4a090e9d83a7f41a84b4cb2144a2d33e ledger](https://github.com/storytold/pdfcraft/blob/0b670caf4a090e9d83a7f41a84b4cb2144a2d33e/vendor/README.md): +hayro-interpret (38) (PdfCraft #3018), marked `PdfCraft patch (#3018)` because the plate-mode +code labels #2770's changes by that pull request's own patch numbers. The `/DefaultGray`, +`/DefaultRGB` and `/DefaultCMYK` color spaces of the resources in scope (ISO 32000-2 +§8.6.5.6) replace the device space that `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` select (by +name, or through a `/ColorSpace` resource that is a device space), and the device +`/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a +shading pattern (`DefaultColorSpaces`, looked up once per content window). A default that is +a device, Lab, Indexed or Pattern space, or has the wrong number of components, is ignored. +Decoded images and shadings are cached per default. Two local differences: a form XObject +with `/Resources` of its own sees only its own defaults, as it sees only its own other +resources here (`Resources::from_scope`), where PdfCraft falls back to the page's; and on a +plate (patch 35) no default applies, so DeviceCMYK still separates as its components with +the nonzero overprint mode, and DeviceGray on black alone, rather than through a DefaultCMYK +or DefaultGray profile and the CMYK simulation of patch 36. PdfCraft's eight raster tests are +adapted in `tests/upstream_raster.rs` (`default_color_spaces`); the plate case is +`default_color_spaces_leave_device_color_on_its_plates` in `tests/plates.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..2e7bc3d5 100644 --- a/vendor/hayro-interpret/SOURCE.json +++ b/vendor/hayro-interpret/SOURCE.json @@ -176,13 +176,13 @@ "path": "src/cache.rs", "source_sha256": "76b5dc066330666a50a32124856306b72c98e646346263bd8a269bc99e5a0d42", "source_git_blob": "e253a7b8c7dbfbfa2f7dc1f6c582559962060b94", - "sha256": "76b5dc066330666a50a32124856306b72c98e646346263bd8a269bc99e5a0d42" + "sha256": "137d210732f88f45716e72cd5be654dc25ce83b39dcec697a5715f98831781bd" }, { "path": "src/color.rs", "source_sha256": "799ce29bb5e59c8fc94db202873e48c2047dd35962e6f26efd215156c47158c1", "source_git_blob": "56bb04f24c04ba33ab6bf99c02a24735670959ad", - "sha256": "74469aa0b54d606f3c9a3e72b38d8d16e7f82cc85e82a093a8e1075831334da9" + "sha256": "190013651fdeb71cb16345e6766e29ce437a682d1efd265ea3e7a567a8eac555" }, { "path": "src/context.rs", @@ -362,7 +362,7 @@ "path": "src/interpret/mod.rs", "source_sha256": "2b002323055bc19aa497e92c81ad8e06dc60ee03686cad89366df4ac7c7a06b2", "source_git_blob": "fe08fc284ba7c914d1414b68a5a346674a6f910f", - "sha256": "cf79add26a1b39c188c00ba5c6aeb199d5f26297c4ce199b823118079fb2a742" + "sha256": "c83663220d27ecf7f80419aab07d44a7ab1894d4932b71b9f5dee83e7e2ba221" }, { "path": "src/interpret/path.rs", @@ -404,13 +404,13 @@ "path": "src/pattern.rs", "source_sha256": "17b49a465275c20e6645dea9443f2cb3a64c594925fd860e74b80b44d5344e37", "source_git_blob": "9295f51709f8915951d7b9afca9411651d65a1f8", - "sha256": "57433ea93c678bbd6f4825ca8413ccc982c2ba1961d15ece0fae60ae70b1c105" + "sha256": "e57d4de60126eafa0da2113af12bbbde1aabd15554f1ee26f6551b8219f4ede5" }, { "path": "src/shading.rs", "source_sha256": "5d541a2d52f69c88e87a40580bf28afa4b8a6a2d62a3385009087264384ead32", "source_git_blob": "2e619e47f2800d7fc7cbdae77c66c58f012c84c2", - "sha256": "aeed7f7a4158887ed4af81ff374b1a73bc8af0220e99f1a2d1487713afc4642c" + "sha256": "a8f21185291afb336771581cb6d9cad848c79beef5f8e2b8809d7bd6773b939a" }, { "path": "src/soft_mask.rs", @@ -434,7 +434,7 @@ "path": "src/x_object.rs", "source_sha256": "e1c51e96a335c32c122011b2d05c5020c36a33c2c73342ba173c28a3e2fe0219", "source_git_blob": "21b134ce8cd9604399cd0776febf923d69c02673", - "sha256": "32a4e229517111e49011c48e006bad1f7ef3defa78b182c225f9acebeb62d8f0" + "sha256": "f9cdf468e939f4b6eaf6c5ba89f538a77ee5cb7cf940878e176826a67b6c78b0" } ], "compatibility_reference": { diff --git a/vendor/hayro-interpret/src/cache.rs b/vendor/hayro-interpret/src/cache.rs index e253a7b8..7914ce32 100644 --- a/vendor/hayro-interpret/src/cache.rs +++ b/vendor/hayro-interpret/src/cache.rs @@ -73,7 +73,10 @@ pub(crate) struct DecodedImageCache { /// `/BitsPerComponent`, filters, …) and its object (`obj`: the decryption key depends on it); /// - whether it is decoded as a mask, a stencil mask, raster or mask output, interpolation, /// the decode target size (only for an image a JPX stream decodes, which is the only -/// decode that reads it: PdfCraft patch 32), and preview or final quality. +/// decode that reads it: PdfCraft patch 32), and preview or final quality; +/// - the default color space drawn in place of the image's device space, if any (the cache +/// key of its object, PdfCraft patch #3018): the same image can be drawn by resources with a +/// `/DefaultRGB` and by resources without one. /// /// A transfer function (graphics state `/TR`) also changes the pixels and has no cheap key, so /// an image drawn under one is not cached; neither is an inline image, whose dictionary lives in @@ -88,6 +91,7 @@ pub(crate) struct DecodedImageKey { pub(crate) is_mask: bool, pub(crate) is_stencil_mask: bool, pub(crate) interpolate: bool, + pub(crate) default_space: Option, pub(crate) target: Option<(u32, u32)>, pub(crate) preview: bool, } diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index 18c2d6a0..3123974f 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -10,12 +10,14 @@ use hayro_syntax::object::Name; use hayro_syntax::object::Object; use hayro_syntax::object::Stream; use hayro_syntax::object::dict::keys::*; +use hayro_syntax::page::Resources; use moxcms::{ ColorProfile, DataColorSpace, Layout, ProfileText, RenderingIntent, Transform8BitExecutor, TransformF32Executor, TransformOptions, }; use smallvec::{SmallVec, ToSmallVec, smallvec}; use std::borrow::Cow; +use std::cell::OnceCell; use std::fmt::{Debug, Formatter}; use std::ops::Deref; use std::sync::{Arc, LazyLock, OnceLock}; @@ -361,6 +363,149 @@ impl ColorSpace { self.to_alpha_color(c, opacity, manual_scale) .unwrap_or(AlphaColor::BLACK) } + + /// PdfCraft patch (#3018): whether this space can be the default color space of `family` + /// (ISO 32000-2 §8.6.5.6): it has as many components as the device space and is not a + /// Lab, Indexed or Pattern space. Nor is a device space: a default that names one is + /// ignored, so substitution never recurses. + fn can_stand_in_for(&self, family: DeviceFamily) -> bool { + !matches!( + self.0.as_ref(), + ColorSpaceType::DeviceGray + | ColorSpaceType::DeviceRgb + | ColorSpaceType::DeviceCmyk + | ColorSpaceType::Lab(_) + | ColorSpaceType::Indexed(_) + | ColorSpaceType::Pattern(_) + ) && self.num_components() == family.num_components() + } +} + +/// PdfCraft patch (#3018): a device color space (ISO 32000-2 §8.6.4), which the resources' +/// default color space for it replaces wherever content selects it (§8.6.5.6). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DeviceFamily { + Gray, + Rgb, + Cmyk, +} + +impl DeviceFamily { + /// The device space a color space operand names: `/DeviceGray`, `/DeviceRGB` or + /// `/DeviceCMYK` (or an abbreviation read as one), alone or at the head of an array. `None` + /// for any other space, including the ICC-based and calibrated ones drawn as device color + /// here (an sRGB profile, `/CalCMYK`): they are not device spaces. + pub(crate) fn of_object(object: &Object<'_>) -> Option { + match object { + Object::Name(name) => Self::of_name(name), + Object::Array(array) => array + .flex_iter() + .next::>() + .and_then(|name| Self::of_name(&name)), + _ => None, + } + } + + /// The device space `name` names, as [`DeviceFamily::of_object`]. + pub(crate) fn of_name(name: &Name<'_>) -> Option { + match name.deref() { + DEVICE_GRAY | G => Some(Self::Gray), + DEVICE_RGB | RGB => Some(Self::Rgb), + DEVICE_CMYK | CMYK => Some(Self::Cmyk), + _ => None, + } + } + + /// The device space itself. + fn space(self) -> ColorSpace { + match self { + Self::Gray => ColorSpace::device_gray(), + Self::Rgb => ColorSpace::device_rgb(), + Self::Cmyk => ColorSpace::device_cmyk(), + } + } + + fn num_components(self) -> u8 { + match self { + Self::Gray => 1, + Self::Rgb => 3, + Self::Cmyk => 4, + } + } + + /// The `/ColorSpace` resource that holds the default for this space. + fn default_key(self) -> &'static [u8] { + match self { + Self::Gray => DEFAULT_GRAY, + Self::Rgb => DEFAULT_RGB, + Self::Cmyk => DEFAULT_CMYK, + } + } +} + +/// PdfCraft patch (#3018): the default color spaces (`/DefaultGray`, `/DefaultRGB` and +/// `/DefaultCMYK`, ISO 32000-2 §8.6.5.6) of the resources content is drawn with, each read the +/// first time the content selects its device space. Like any named resource, a default comes +/// from the resources in scope: a form XObject without `/Resources` draws in its page's +/// defaults, one with `/Resources` only in its own. +/// +/// On a plate (patch 35) device color keeps its device space: plates separate DeviceCMYK +/// by its components, with nonzero overprint mode, and a DefaultCMYK profile would route it +/// through the CMYK simulation (patch 36) instead. Defaults change how device color looks, +/// not which inks it is printed with. +pub(crate) struct DefaultColorSpaces<'r, 'a> { + resources: &'r Resources<'a>, + cache: Cache, + on_plate: bool, + gray: OnceCell>, + rgb: OnceCell>, + cmyk: OnceCell>, +} + +impl<'r, 'a> DefaultColorSpaces<'r, 'a> { + pub(crate) fn new(resources: &'r Resources<'a>, cache: &Cache, on_plate: bool) -> Self { + Self { + resources, + cache: cache.clone(), + on_plate, + gray: OnceCell::new(), + rgb: OnceCell::new(), + cmyk: OnceCell::new(), + } + } + + /// The default for `family`, with the cache key of the object it was read from. `None` + /// when the resources have none, or theirs is not a color space that can stand in for + /// the device space, or on a plate: device color is then drawn as it is. + pub(crate) fn get(&self, family: DeviceFamily) -> Option<(ColorSpace, u128)> { + if self.on_plate { + return None; + } + let found = match family { + DeviceFamily::Gray => &self.gray, + DeviceFamily::Rgb => &self.rgb, + DeviceFamily::Cmyk => &self.cmyk, + }; + found + .get_or_init(|| { + let object = self + .resources + .get_color_space(&Name::new_unescaped(family.default_key()))?; + let key = object.cache_key(); + let space = self + .cache + .get_or_insert_with(key, || ColorSpace::new(object.clone(), &self.cache))?; + space.can_stand_in_for(family).then_some((space, key)) + }) + .clone() + } + + /// The color space content that selects `family` draws in: its default, else the device + /// space itself. + pub(crate) fn device(&self, family: DeviceFamily) -> ColorSpace { + self.get(family) + .map_or_else(|| family.space(), |(space, _)| space) + } } impl ToRgb for ColorSpace { diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index 8cd80a08..44cbdc0c 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -1,5 +1,5 @@ use crate::{BlendMode, FillRule}; -use crate::color::ColorSpace; +use crate::color::{ColorSpace, DefaultColorSpaces, DeviceFamily}; use crate::context::Context; use crate::convert::{convert_line_cap, convert_line_join}; use crate::device::Device; @@ -347,6 +347,30 @@ fn draw_highlight_without_appearance<'a>( context.restore_state(device); } +/// PdfCraft patch (#3018): the color space `cs` or `CS` selects by `name`: a device space, else +/// the `/ColorSpace` resource `name`. A device space, named directly or by a resource, is +/// replaced by its default from `defaults` (ISO 32000-2 §8.6.5.6). +fn named_color_space<'a>( + name: &hayro_syntax::object::Name<'_>, + resources: &Resources<'a>, + defaults: &DefaultColorSpaces<'_, '_>, + context: &mut Context<'a>, +) -> Option { + if let Some(named) = ColorSpace::new_from_name(name) { + let default = DeviceFamily::of_name(name).and_then(|family| defaults.get(family)); + return Some(default.map_or(named, |(space, _)| space)); + } + let resource = resources.get_color_space(name); + if let Some((space, _)) = resource + .as_ref() + .and_then(DeviceFamily::of_object) + .and_then(|family| defaults.get(family)) + { + return Some(space); + } + context.get_color_space(resources, name) +} + /// Interprets the instructions that `ops` yields, until they run out or the caller cancels. It runs /// once per window of a content stream, between one `save_state` and its restore (see /// `interpret_content`). @@ -356,6 +380,13 @@ fn interpret_window<'a>( context: &mut Context<'a>, device: &mut impl Device<'a>, ) { + // PdfCraft patch (#3018): the resources' DefaultGray, DefaultRGB and DefaultCMYK replace the + // device spaces that color operators and images select (ISO 32000-2 §8.6.5.6). + let defaults = DefaultColorSpaces::new( + resources, + &context.interpreter_cache.object_cache, + context.settings.plate.is_some(), + ); while let Some(op) = ops.next() { // PdfCraft patch: stop when the caller cancelled (see `InterpreterSettings::cancelled`). if context.settings.cancelled.as_ref().is_some_and(|c| c.load(Ordering::Relaxed)) { @@ -364,18 +395,21 @@ fn interpret_window<'a>( match op { TypedInstruction::SaveState(_) => context.save_state(), TypedInstruction::StrokeColorDeviceRgb(s) => { - context.get_mut().graphics_state.stroke_cs = ColorSpace::device_rgb(); + // PdfCraft patch (#3018): DeviceRGB, or the DefaultRGB that replaces it. + context.get_mut().graphics_state.stroke_cs = defaults.device(DeviceFamily::Rgb); context.get_mut().graphics_state.stroke_color = smallvec![s.0.as_f32(), s.1.as_f32(), s.2.as_f32()]; context.get_mut().graphics_state.stroke_pattern = None; } TypedInstruction::StrokeColorDeviceGray(s) => { - context.get_mut().graphics_state.stroke_cs = ColorSpace::device_gray(); + // PdfCraft patch (#3018): DeviceGray, or the DefaultGray that replaces it. + context.get_mut().graphics_state.stroke_cs = defaults.device(DeviceFamily::Gray); context.get_mut().graphics_state.stroke_color = smallvec![s.0.as_f32()]; context.get_mut().graphics_state.stroke_pattern = None; } TypedInstruction::StrokeColorCmyk(s) => { - context.get_mut().graphics_state.stroke_cs = ColorSpace::device_cmyk(); + // PdfCraft patch (#3018): DeviceCMYK, or the DefaultCMYK that replaces it. + context.get_mut().graphics_state.stroke_cs = defaults.device(DeviceFamily::Cmyk); context.get_mut().graphics_state.stroke_color = smallvec![s.0.as_f32(), s.1.as_f32(), s.2.as_f32(), s.3.as_f32()]; context.get_mut().graphics_state.stroke_pattern = None; @@ -439,18 +473,24 @@ fn interpret_window<'a>( fill_stroke_path(context, device, FillRule::NonZero); } TypedInstruction::NonStrokeColorDeviceGray(s) => { - context.get_mut().graphics_state.none_stroke_cs = ColorSpace::device_gray(); + // PdfCraft patch (#3018): DeviceGray, or the DefaultGray that replaces it. + context.get_mut().graphics_state.none_stroke_cs = + defaults.device(DeviceFamily::Gray); context.get_mut().graphics_state.non_stroke_color = smallvec![s.0.as_f32()]; context.get_mut().graphics_state.non_stroke_pattern = None; } TypedInstruction::NonStrokeColorDeviceRgb(s) => { - context.get_mut().graphics_state.none_stroke_cs = ColorSpace::device_rgb(); + // PdfCraft patch (#3018): DeviceRGB, or the DefaultRGB that replaces it. + context.get_mut().graphics_state.none_stroke_cs = + defaults.device(DeviceFamily::Rgb); context.get_mut().graphics_state.non_stroke_color = smallvec![s.0.as_f32(), s.1.as_f32(), s.2.as_f32()]; context.get_mut().graphics_state.non_stroke_pattern = None; } TypedInstruction::NonStrokeColorCmyk(s) => { - context.get_mut().graphics_state.none_stroke_cs = ColorSpace::device_cmyk(); + // PdfCraft patch (#3018): DeviceCMYK, or the DefaultCMYK that replaces it. + context.get_mut().graphics_state.none_stroke_cs = + defaults.device(DeviceFamily::Cmyk); context.get_mut().graphics_state.non_stroke_color = smallvec![s.0.as_f32(), s.1.as_f32(), s.2.as_f32(), s.3.as_f32()]; context.get_mut().graphics_state.non_stroke_pattern = None; @@ -539,13 +579,9 @@ fn interpret_window<'a>( // Ignore for now. } TypedInstruction::ColorSpaceStroke(c) => { - let cs = if let Some(named) = ColorSpace::new_from_name(c.0) { - named - } else { - context - .get_color_space(resources, c.0) - .unwrap_or(ColorSpace::device_gray()) - }; + // PdfCraft patch (#3018): see `named_color_space`. + let cs = named_color_space(c.0, resources, &defaults, context) + .unwrap_or(ColorSpace::device_gray()); if !cs.is_pattern() { context.get_mut().graphics_state.stroke_pattern = None; @@ -554,13 +590,9 @@ fn interpret_window<'a>( context.get_mut().graphics_state.stroke_cs = cs; } TypedInstruction::ColorSpaceNonStroke(c) => { - let cs = if let Some(named) = ColorSpace::new_from_name(c.0) { - named - } else { - context - .get_color_space(resources, c.0) - .unwrap_or(ColorSpace::device_gray()) - }; + // PdfCraft patch (#3018): see `named_color_space`. + let cs = named_color_space(c.0, resources, &defaults, context) + .unwrap_or(ColorSpace::device_gray()); if !cs.is_pattern() { context.get_mut().graphics_state.non_stroke_pattern = None; @@ -583,7 +615,7 @@ fn interpret_window<'a>( context.get_mut().graphics_state.non_stroke_pattern = n.1.and_then(|name| { resources .get_pattern(name) - .and_then(|d| Pattern::new(d, context, resources)) + .and_then(|d| Pattern::new(d, context, resources, &defaults)) }); } TypedInstruction::StrokeColorNamed(n) => { @@ -592,7 +624,7 @@ fn interpret_window<'a>( context.get_mut().graphics_state.stroke_pattern = n.1.and_then(|name| { resources .get_pattern(name) - .and_then(|d| Pattern::new(d, context, resources)) + .and_then(|d| Pattern::new(d, context, resources, &defaults)) }); } TypedInstruction::BeginMarkedContentWithProperties(bdc) => { @@ -793,6 +825,8 @@ fn interpret_window<'a>( &cache, Some(&decoded_cache), transfer_function.clone(), + // PdfCraft patch (#3018): an image in a device space draws in its default. + &defaults, ) }) { draw_xobject(&x_object, resources, context, device); @@ -803,14 +837,17 @@ fn interpret_window<'a>( let transfer_function = context.get().graphics_state.transfer_function.clone(); let cache = context.interpreter_cache.object_cache.clone(); // PdfCraft patch: inline images are not cached (see `DecodedImageKey`). + // PdfCraft patch (#3018): an inline image in a device space, named directly or by a + // resource, draws in its default. if let Some(x_object) = ImageXObject::new( i.0, - |name| context.get_color_space(resources, name), + |name| named_color_space(name, resources, &defaults, context), &warning_sink, &cache, None, false, transfer_function, + Some(&defaults), ) { draw_image_xobject(&x_object, context, device); } @@ -829,7 +866,13 @@ fn interpret_window<'a>( .get_shading(s.0) .and_then(|o| { let (dict, stream) = dict_or_stream(&o)?; - Shading::new(dict, stream, &context.interpreter_cache.object_cache) + // PdfCraft patch (#3018): a shading in a device space draws in its default. + Shading::new( + dict, + stream, + &context.interpreter_cache.object_cache, + &defaults, + ) }) .map(|s| { Pattern::Shading(ShadingPattern { diff --git a/vendor/hayro-interpret/src/pattern.rs b/vendor/hayro-interpret/src/pattern.rs index 287626d2..00686161 100644 --- a/vendor/hayro-interpret/src/pattern.rs +++ b/vendor/hayro-interpret/src/pattern.rs @@ -1,7 +1,7 @@ //! PDF patterns. use crate::cache::Cache; -use crate::color::{Color, ColorSpace, Plate}; +use crate::color::{Color, ColorSpace, DefaultColorSpaces, Plate}; use crate::context::{Context, InterpreterCache}; use crate::device::Device; use crate::font::Glyph; @@ -34,15 +34,19 @@ pub enum Pattern<'a> { } impl<'a> Pattern<'a> { + /// PdfCraft patch (#3018): `defaults` are those of the resources the pattern is selected + /// with, for a shading pattern's shading in a device space. pub(crate) fn new( object: Object<'a>, ctx: &Context<'a>, resources: &Resources<'a>, + defaults: &DefaultColorSpaces<'_, '_>, ) -> Option { match object { Object::Dict(dict) => Some(Self::Shading(ShadingPattern::new( &dict, &ctx.interpreter_cache.object_cache, + defaults, )?)), Object::Stream(stream) => Some(Self::Tiling(Box::new(TilingPattern::new( stream, ctx, resources, @@ -115,11 +119,15 @@ pub struct ShadingPattern { } impl ShadingPattern { - pub(crate) fn new(dict: &Dict<'_>, cache: &Cache) -> Option { + pub(crate) fn new( + dict: &Dict<'_>, + cache: &Cache, + defaults: &DefaultColorSpaces<'_, '_>, + ) -> Option { let shading = dict.get::>(SHADING).and_then(|o| { let (dict, stream) = dict_or_stream(&o)?; - Shading::new(dict, stream, cache) + Shading::new(dict, stream, cache, defaults) })?; let matrix = dict .get::<[f64; 6]>(MATRIX) diff --git a/vendor/hayro-interpret/src/shading.rs b/vendor/hayro-interpret/src/shading.rs index dc9c38b2..5290219f 100644 --- a/vendor/hayro-interpret/src/shading.rs +++ b/vendor/hayro-interpret/src/shading.rs @@ -4,9 +4,9 @@ use crate::CacheKey; use crate::cache::Cache; -use crate::color::{ColorComponents, ColorSpace}; +use crate::color::{ColorComponents, ColorSpace, DefaultColorSpaces, DeviceFamily}; use crate::function::{Function, StitchingBounds, Values, interpolate}; -use crate::util::{Float32Ext, PointExt, RectExt}; +use crate::util::{Float32Ext, PointExt, RectExt, hash128}; use hayro_syntax::bit_reader::BitReader; use hayro_syntax::object::Array; use hayro_syntax::object::Dict; @@ -144,7 +144,7 @@ impl Shading { pub(crate) fn for_plate(&mut self, plate: &crate::color::Plate) { self.color_space = self.color_space.plate(plate); let simulation = plate.simulation.as_ref().map(|s| s.digest()); - self.cache_key = crate::util::hash128(&( + self.cache_key = hash128(&( self.cache_key, &plate.ink, simulation, @@ -152,12 +152,24 @@ impl Shading { )); } - pub(crate) fn new(dict: &Dict<'_>, stream: Option<&Stream<'_>>, cache: &Cache) -> Option { - let cache_key = dict.cache_key(); - + /// PdfCraft patch (#3018): a shading in a device space is drawn in its default color space + /// from `defaults`, the resources it is painted with (ISO 32000-2 §8.6.5.6), and keyed by + /// it as an image is. + pub(crate) fn new( + dict: &Dict<'_>, + stream: Option<&Stream<'_>>, + cache: &Cache, + defaults: &DefaultColorSpaces<'_, '_>, + ) -> Option { let shading_num = dict.get::(SHADING_TYPE)?; - let color_space = ColorSpace::new(dict.get(COLORSPACE)?, cache)?; + let color_space_obj = dict.get::>(COLORSPACE)?; + let default = + DeviceFamily::of_object(&color_space_obj).and_then(|family| defaults.get(family)); + let (color_space, cache_key) = match default { + Some((space, key)) => (space, hash128(&(dict.cache_key(), key))), + None => (ColorSpace::new(color_space_obj, cache)?, dict.cache_key()), + }; let shading_type = match shading_num { 1 => { diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index b9de63f6..d196d914 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -1,5 +1,7 @@ use crate::cache::{Cache, DecodedImageCache, DecodedImageKey}; -use crate::color::{ColorComponents, ColorSpace, InkUse, Plate, ToRgb}; +use crate::color::{ + ColorComponents, ColorSpace, DefaultColorSpaces, DeviceFamily, InkUse, Plate, ToRgb, +}; use crate::context::Context; use crate::device::Device; use crate::function::{Function, interpolate}; @@ -36,6 +38,7 @@ impl<'a> XObject<'a> { cache: &Cache, decoded_cache: Option<&DecodedImageCache>, transfer_function: Option, + defaults: &DefaultColorSpaces<'_, '_>, ) -> Option { let dict = stream.dict(); match dict.get::>(SUBTYPE)?.deref() { @@ -47,6 +50,7 @@ impl<'a> XObject<'a> { decoded_cache, false, transfer_function, + Some(defaults), )?)), FORM => Some(Self::FormXObject(FormXObject::new(stream)?)), _ => None, @@ -395,6 +399,9 @@ pub(crate) struct ImageXObject<'a> { warning_sink: WarningSinkFn, /// PdfCraft patch (35): the plate the image is drawn on, if any. plate: Option, + /// PdfCraft patch (#3018): the cache key of the default color space drawn in place of the + /// image's device space, if one is. + default_space: Option, } impl<'a> ImageXObject<'a> { @@ -409,6 +416,9 @@ impl<'a> ImageXObject<'a> { } } + /// PdfCraft patch (#3018): an image in a device space is drawn in its default color space + /// from `defaults`. Masks have none to replace, and `resolve_cs` (an inline image's named + /// color space) substitutes its own. pub(crate) fn new( stream: &Stream<'a>, resolve_cs: impl FnOnce(&Name<'_>) -> Option, @@ -417,6 +427,7 @@ impl<'a> ImageXObject<'a> { decoded_cache: Option<&DecodedImageCache>, mut is_mask: bool, transfer_function: Option, + defaults: Option<&DefaultColorSpaces<'_, '_>>, ) -> Option { let dict = stream.dict(); @@ -426,6 +437,7 @@ impl<'a> ImageXObject<'a> { .unwrap_or(false); is_mask |= is_stencil_mask; + let mut default_space = None; let image_cs = if is_mask { // Masks are always single-channel. Some(ColorSpace::device_gray()) @@ -434,15 +446,25 @@ impl<'a> ImageXObject<'a> { .get::>(CS) .or_else(|| dict.get::>(COLORSPACE)); - cs_obj - .clone() - .and_then(|c| ColorSpace::new(c, cache)) - // Inline images can also refer to color spaces by name. - .or_else(|| { - cs_obj - .and_then(|c| c.into_name()) - .and_then(|n| resolve_cs(&n)) - }) + let default = cs_obj + .as_ref() + .and_then(DeviceFamily::of_object) + .zip(defaults) + .and_then(|(family, defaults)| defaults.get(family)); + if let Some((space, key)) = default { + default_space = Some(key); + Some(space) + } else { + cs_obj + .clone() + .and_then(|c| ColorSpace::new(c, cache)) + // Inline images can also refer to color spaces by name. + .or_else(|| { + cs_obj + .and_then(|c| c.into_name()) + .and_then(|n| resolve_cs(&n)) + }) + } }; let interpolate = dict @@ -470,6 +492,7 @@ impl<'a> ImageXObject<'a> { is_mask, is_stencil_mask, plate: None, + default_space, }) } @@ -533,6 +556,8 @@ impl<'a> ImageXObject<'a> { is_mask: self.is_mask, is_stencil_mask: self.is_stencil_mask, interpolate: self.interpolate, + // PdfCraft patch (#3018): the same image drawn in a default color space or without one. + default_space: self.default_space, // PdfCraft patch (32): see `decode_reads_target`. target: if self.decode_reads_target() { target_dimension @@ -1009,7 +1034,7 @@ fn resolve_alpha( .get::>(SMASK) .or_else(|| dict.get::>(MASK)) { - let obj = ImageXObject::new(&s_mask, |_| None, &obj.warning_sink, &obj.cache, obj.decoded_cache.as_ref(), true, None)?; + let obj = ImageXObject::new(&s_mask, |_| None, &obj.warning_sink, &obj.cache, obj.decoded_cache.as_ref(), true, None, None)?; decode_mask(&obj, target_dimension).map(|decoded| decoded.luma) } else if let Some(color_key_mask) = dict.get::>(MASK) { @@ -1069,7 +1094,7 @@ fn resolve_matte( let mut matte_rgb = [0_u8; 3]; color_space.convert_f32(&matte, &mut matte_rgb, false); - let mask_obj = ImageXObject::new(&s_mask, |_| None, &obj.warning_sink, &obj.cache, obj.decoded_cache.as_ref(), true, None)?; + let mask_obj = ImageXObject::new(&s_mask, |_| None, &obj.warning_sink, &obj.cache, obj.decoded_cache.as_ref(), true, None, None)?; let alpha = decode_mask(&mask_obj, target_dimension)?.luma; Some((alpha, matte_rgb)) From f8cbf0e1c2445baae367bf65ed2125a0aacc720c Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 07:42:37 +0000 Subject: [PATCH 11/17] pdf: keep both sides' upstream_raster tests from the /Default color spaces merge --- crates/pdf/tests/upstream_raster.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/pdf/tests/upstream_raster.rs b/crates/pdf/tests/upstream_raster.rs index 97e55077..7c899a73 100644 --- a/crates/pdf/tests/upstream_raster.rs +++ b/crates/pdf/tests/upstream_raster.rs @@ -650,6 +650,8 @@ fn blend_modes_over_an_empty_part_of_the_page_keep_their_color() { 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"); + } +} /// PdfCraft #3018 (ISO 32000-2 §8.6.5.6): the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` /// color spaces of a resource dictionary stand in for the device spaces its content selects, From 3648e60159cb7c28c2e50f0ebac02e54513897e3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 07:42:37 +0000 Subject: [PATCH 12/17] pdf: keep both sides' upstream_raster tests from the /Default color spaces merge --- crates/pdf/tests/upstream_raster.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/pdf/tests/upstream_raster.rs b/crates/pdf/tests/upstream_raster.rs index 97e55077..7c899a73 100644 --- a/crates/pdf/tests/upstream_raster.rs +++ b/crates/pdf/tests/upstream_raster.rs @@ -650,6 +650,8 @@ fn blend_modes_over_an_empty_part_of_the_page_keep_their_color() { 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"); + } +} /// PdfCraft #3018 (ISO 32000-2 §8.6.5.6): the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` /// color spaces of a resource dictionary stand in for the device spaces its content selects, From 89a5b2cf2eebdc007beba80a9b1c6582359d3c6a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 07:46:17 +0000 Subject: [PATCH 13/17] hayro-interpret: port PdfCraft's ICC profile cache key (#3020) Cache the color space of an /ICCBased profile by its stream object, and on a miss by its dictionary key and raw bytes, instead of the dictionary's bytes alone, so two profiles with the same /N and /Length each draw through their own profile. InterpreterCache::icc_profile_reads counts profile reads. PdfCraft's two raster tests are adapted in tests/upstream_raster.rs. --- crates/pdf/tests/upstream_raster.rs | 125 ++++++++++++++++++++++++-- vendor/README.craft-reader.md | 13 +++ vendor/hayro-interpret/SOURCE.json | 6 +- vendor/hayro-interpret/src/cache.rs | 18 +++- vendor/hayro-interpret/src/color.rs | 98 ++++++++++++++------ vendor/hayro-interpret/src/context.rs | 7 ++ 6 files changed, 226 insertions(+), 41 deletions(-) diff --git a/crates/pdf/tests/upstream_raster.rs b/crates/pdf/tests/upstream_raster.rs index 7c899a73..44170098 100644 --- a/crates/pdf/tests/upstream_raster.rs +++ b/crates/pdf/tests/upstream_raster.rs @@ -662,9 +662,9 @@ mod default_color_spaces { use super::{Pixels, render}; const WIDTH: usize = 130; - const RED: [u8; 3] = [255, 0, 0]; + pub(super) const RED: [u8; 3] = [255, 0, 0]; const GREEN: [u8; 3] = [0, 255, 0]; - const BLUE: [u8; 3] = [0, 0, 255]; + pub(super) const BLUE: [u8; 3] = [0, 0, 255]; const BLACK: [u8; 3] = [0, 0, 0]; /// Device black through `gray_profile`: half the luminance of white, sRGB-encoded. const MID_GRAY: [u8; 3] = [188, 188, 188]; @@ -724,7 +724,7 @@ mod default_color_spaces { /// A linear RGB profile whose red, green and blue take the sRGB colorants `primaries` names: /// with `[1, 2, 0]` red is drawn as sRGB green. - fn rgb_profile(primaries: [usize; 3]) -> Vec { + pub(super) fn rgb_profile(primaries: [usize; 3]) -> Vec { let mut tags = Vec::new(); for (signature, primary) in [*b"rXYZ", *b"gXYZ", *b"bXYZ"].into_iter().zip(primaries) { tags.push((signature, xyz(SRGB_COLORANTS[primary]))); @@ -760,7 +760,7 @@ mod default_color_spaces { icc(b"prtr", b"CMYK", b"Lab ", &[(*b"A2B0", lut)]) } - fn profile_stream(n: u8, profile: Vec) -> (String, Vec) { + pub(super) fn profile_stream(n: u8, profile: Vec) -> (String, Vec) { (format!("<< /N {n} /Length {} >>", profile.len()), profile) } @@ -769,9 +769,9 @@ mod default_color_spaces { (format!("<< /Type /XObject /Subtype /Form /BBox [0 0 130 70] {extra} /Length {} >>", content.len()), content.as_bytes().to_vec()) } - /// Draw `content` with `resources` on a 130 × 70 pt page whose objects 5, 6, … are the - /// streams `objects`, and give the color at each of `points` (from the top left). - fn colors(content: &str, resources: &str, objects: &[(String, Vec)], points: &[(usize, usize)]) -> Vec<[u8; 3]> { + /// A 130 × 70 pt page that draws `content` with `resources`, whose objects 5, 6, … are the + /// streams `objects`. + pub(super) fn page_pdf(content: &str, resources: &str, objects: &[(String, Vec)]) -> Vec { let mut pdf = format!( "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ @@ -786,13 +786,19 @@ mod default_color_spaces { pdf.extend_from_slice(b"\nendstream endobj\n"); } pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF"); - let Pixels { width, height, rgba } = render(&pdf); + pdf + } + + /// Draw `content` with `resources` on a 130 × 70 pt page whose objects 5, 6, … are the + /// streams `objects`, and give the color at each of `points` (from the top left). + pub(super) fn colors(content: &str, resources: &str, objects: &[(String, Vec)], points: &[(usize, usize)]) -> Vec<[u8; 3]> { + let Pixels { width, height, rgba } = render(&page_pdf(content, resources, objects)); assert_eq!((width as usize, height), (WIDTH, 70)); points.iter().map(|&(x, y)| rgba[(y * WIDTH + x) * 4..][..3].try_into().unwrap()).collect() } #[track_caller] - fn assert_near(got: [u8; 3], want: [u8; 3], what: &str) { + pub(super) fn assert_near(got: [u8; 3], want: [u8; 3], what: &str) { assert!(got.iter().zip(want).all(|(got, want)| got.abs_diff(want) <= 6), "{what}: got {got:?}, expected about {want:?}"); } @@ -1044,3 +1050,104 @@ mod default_color_spaces { } } } + +/// PdfCraft ledger hayro-interpret (48) and the ICC profile cache key (#3020): two `/ICCBased` +/// profiles whose stream dictionaries are byte for byte the same (`/N 3` and one `/Length`) +/// each draw through their own profile. hayro cached the color space it read from a profile +/// stream by the dictionary's bytes alone, so the second profile drew through whichever was +/// read first. The profiles are linear sRGB and the same profile with red and blue swapped, +/// which draws red as blue; they have the same length. +mod icc_profile_cache { + use super::default_color_spaces::{BLUE, RED, assert_near, colors, page_pdf, profile_stream, rgb_profile}; + use craft_pdf::interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, + TransformExt, font::Glyph, + }; + use kurbo::{Affine, BezPath, Rect}; + + /// An `/ICCBased` space whose profile stream is written directly in the space's array, not + /// as an object of its own: the profile as ASCIIHex digits, each a token of its own, in + /// upper case (a lone `f` reads as a broken `false`). Its dictionary reports the object + /// holding the array, which is the page for every one of them. + fn direct_space(profile: &[u8]) -> String { + let hex: String = profile.iter().flat_map(|b| [b >> 4, b & 0xf]).map(|d| format!("{d:X} ")).collect(); + format!("[/ICCBased << /N 3 /Filter /ASCIIHexDecode /Length {} >> stream\n{hex}\nendstream]", hex.len()) + } + + /// Six 20 pt squares, side by side, filled with `1 0 0 sc` (red) in `/A` (object 5, linear + /// sRGB), `/B` (object 6, red and blue swapped), `/A2` (object 5 again, through an array of + /// other bytes), `/A3` (object 7, a copy of object 5), `/C` and `/D` (the two profiles + /// again, each written directly in its array). Objects 5, 6 and 7 have the same dictionary, + /// and so do the two direct streams. + fn page() -> (String, String, Vec<(String, Vec)>) { + let (srgb, swapped) = (rgb_profile([0, 1, 2]), rgb_profile([2, 1, 0])); + assert_eq!(srgb.len(), swapped.len(), "the profiles have the same length, so the same dictionary"); + assert_ne!(srgb, swapped); + let resources = format!( + "<< /ColorSpace << /A [/ICCBased 5 0 R] /B [/ICCBased 6 0 R] /A2 [ /ICCBased 5 0 R ] /A3 [/ICCBased 7 0 R] /C {} /D {} >> >>", + direct_space(&srgb), + direct_space(&swapped) + ); + let content = ["A", "B", "A2", "A3", "C", "D"] + .iter() + .enumerate() + .map(|(i, name)| format!("/{name} cs 1 0 0 sc {} 20 20 30 re f", 2 + 21 * i)) + .collect::>() + .join("\n"); + (content, resources, vec![profile_stream(3, srgb.clone()), profile_stream(3, swapped), profile_stream(3, srgb)]) + } + + /// Each square is drawn through its own profile, whether the profile is an object or + /// written in its array, and object 5 looked up again and its copy still draw red. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn icc_profiles_with_identical_dictionaries_draw_their_own_colors() { + let (content, resources, objects) = page(); + let points: Vec<_> = (0..6).map(|i| (12 + 21 * i, 35)).collect(); + let got = colors(&content, &resources, &objects, &points); + let expected = [ + ("/A, object 5, linear sRGB", RED), + ("/B, object 6, red and blue swapped", BLUE), + ("/A2, object 5 again", RED), + ("/A3, object 7, a copy of object 5", RED), + ("/C, linear sRGB written in its array", RED), + ("/D, red and blue swapped written in its array", BLUE), + ]; + for (got, (what, want)) in got.into_iter().zip(expected) { + assert_near(got, want, what); + } + } + + /// Draws nothing: only the color spaces the page selects matter here. + struct Nothing; + impl<'a> Device<'a> for Nothing { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + + /// The profiles are still cached: the four different ones (objects 5 and 6, and the two + /// written in their arrays) are read once each, and neither object 5 looked up again nor its + /// copy, object 7, is read again. A second pass through the same cache reads none. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn icc_profiles_used_again_are_read_once() { + let (content, resources, objects) = page(); + let bytes = page_pdf(&content, &resources, &objects); + let pdf = craft_pdf::open_slice(&bytes, None).unwrap(); + let page = &pdf.pages()[0]; + let cache = InterpreterCache::new(); + for pass in ["first", "second"] { + let initial = page.initial_transform(true).to_kurbo(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 130.0, 70.0), &cache, page.xref(), InterpreterSettings::default()); + craft_pdf::interpret_page(page, &mut ctx, &mut Nothing).unwrap(); + assert_eq!(cache.icc_profile_reads(), 4, "after the {pass} pass: each different profile is read once"); + } + } +} diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index 5058fbca..436295fb 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -280,6 +280,19 @@ returns before the extent is used, so it still clears under the whole path. Test `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` in hayro. +An ICC profile cache fix from the [PdfCraft revision 0b670caf4a090e9d83a7f41a84b4cb2144a2d33e ledger](https://github.com/storytold/pdfcraft/blob/0b670caf4a090e9d83a7f41a84b4cb2144a2d33e/vendor/README.md): +hayro-interpret (48) (PdfCraft #3020), marked `PdfCraft patch (#3020)`. The color space read +from an `/ICCBased` profile stream is cached by `IccProfileKey`, not by the stream +dictionary's bytes alone, so two profiles with the same `/N` and `/Length` no longer share +whichever was read first. A stream object is looked up by its object identifier without +reading it, and on a miss by its dictionary key and raw (decrypted, undecoded) bytes, so +copies of a profile are read once; a stream written directly in its array has only the +content key. `InterpreterCache::icc_profile_reads` counts the reads. The CMYK simulation of +patch 36 doesn't use this cache: it is built from the output intent's bytes by the caller, +and the digest that tells its profile from a document profile is still a hash of the +decoded profile bytes. PdfCraft's two raster tests are adapted in `tests/upstream_raster.rs` +(`icc_profile_cache`). + 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 5b2626fb..4b565798 100644 --- a/vendor/hayro-interpret/SOURCE.json +++ b/vendor/hayro-interpret/SOURCE.json @@ -176,19 +176,19 @@ "path": "src/cache.rs", "source_sha256": "76b5dc066330666a50a32124856306b72c98e646346263bd8a269bc99e5a0d42", "source_git_blob": "e253a7b8c7dbfbfa2f7dc1f6c582559962060b94", - "sha256": "137d210732f88f45716e72cd5be654dc25ce83b39dcec697a5715f98831781bd" + "sha256": "c63ed890ead39cc6205746063f90689b7a617e8c14a35e6ee1b955550d1230a5" }, { "path": "src/color.rs", "source_sha256": "799ce29bb5e59c8fc94db202873e48c2047dd35962e6f26efd215156c47158c1", "source_git_blob": "56bb04f24c04ba33ab6bf99c02a24735670959ad", - "sha256": "190013651fdeb71cb16345e6766e29ce437a682d1efd265ea3e7a567a8eac555" + "sha256": "85391b386b0c34b6bb1d6757c3d083ca07764062d085e0324456a377d6d520fc" }, { "path": "src/context.rs", "source_sha256": "dc2be553958ee3f5e5e644a77a0301723bae9ca027a352c13b9caaf549cc0e56", "source_git_blob": "d6772baa15ebb95d3ba2d1f54ce5c250ad1e8137", - "sha256": "e6b4dd369ea6b9c3b60374e46b775511a2542cd46d9da31957381a7361528d82" + "sha256": "98ed66aab25f1e3b8b9dfd9b6837dc0d9519a9fd7f5429b695865879abc24f6d" }, { "path": "src/convert.rs", diff --git a/vendor/hayro-interpret/src/cache.rs b/vendor/hayro-interpret/src/cache.rs index 7914ce32..d2be7543 100644 --- a/vendor/hayro-interpret/src/cache.rs +++ b/vendor/hayro-interpret/src/cache.rs @@ -4,11 +4,14 @@ use kurbo::Affine; use std::any::Any; use std::collections::HashMap; use std::collections::hash_map::Entry; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; type CacheMap = HashMap>>; +/// The objects read from a document, by key. PdfCraft patch (#3020): the second field counts +/// the ICC profiles read through it ([`Cache::icc_profile_reads`]). #[derive(Clone)] -pub(crate) struct Cache(Arc>); +pub(crate) struct Cache(Arc>, Arc); impl Default for Cache { fn default() -> Self { Self::new() @@ -17,7 +20,18 @@ impl Default for Cache { impl Cache { pub(crate) fn new() -> Self { - Self(Arc::new(Mutex::new(HashMap::new()))) + Self(Arc::new(Mutex::new(HashMap::new())), Arc::default()) + } + + /// PdfCraft patch (#3020): count one ICC profile read, the color space of an `/ICCBased` + /// profile no entry held yet being built from it. + pub(crate) fn count_icc_profile_read(&self) { + self.1.fetch_add(1, Ordering::Relaxed); + } + + /// PdfCraft patch (#3020): how many ICC profiles were read through this cache. + pub(crate) fn icc_profile_reads(&self) -> usize { + self.1.load(Ordering::Relaxed) } pub(crate) fn get_or_insert_with( diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index 3123974f..e85fd17e 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -124,6 +124,34 @@ pub(crate) enum ColorSpaceType { Plate(PlateSpace), } +/// PdfCraft patch (#3020): what the color space read from an `/ICCBased` profile stream is +/// cached by. hayro keyed it by the stream dictionary's bytes alone, so two different profiles +/// with the same `/N` and `/Length` shared whichever was read first, and colors in the second +/// were drawn through the first. +#[derive(Hash)] +enum IccProfileKey<'a> { + /// The stream object a space names, looked up without reading the stream: an object holds + /// one profile for as long as the document, and so the cache, lives. + Object(object::ObjectIdentifier), + /// What the space is built from: the stream dictionary's key (its bytes, the old key: + /// `/N`, `/Alternate`, the filters) and the stream's own bytes, decrypted but not decoded, + /// which decode to the profile. Copies of a profile share it, and a lookup never decodes: + /// decoding waits for a miss. + Content { dict: u128, data: &'a [u8] }, +} + +/// PdfCraft patch (#3020): the object of the `/ICCBased` profile `stream` that `array` names, +/// or `None` for a stream written directly in the array (its dictionary lies within the +/// array's bytes). Such a stream has no object of its own: its dictionary reports the object +/// that holds the array, which every other stream written there reports too. +fn icc_stream_object(array: &Array<'_>, stream: &Stream<'_>) -> Option { + let dict = stream.dict(); + if array.data().as_ptr_range().contains(&dict.data().as_ptr()) { + return None; + } + dict.obj_id() +} + impl ColorSpaceType { fn new(object: Object<'_>, cache: &Cache) -> Option { Self::new_inner(object, cache) @@ -142,34 +170,50 @@ impl ColorSpaceType { let dict = icc_stream.dict(); let num_components = dict.get::(N)?; - return cache.get_or_insert_with(icc_stream.cache_key(), || { - if let Some(decoded) = icc_stream.decoded().ok().as_ref() { - ICCProfile::new(decoded, num_components) - .map(|icc| { - // TODO: For SVG and PNG we can assume that the output color space is - // sRGB. If we ever implement PDF-to-PDF, we probably want to - // let the user pass the native color type and don't make this optimization - // if it's not sRGB. - if icc.is_srgb() { - Self::DeviceRgb - } else { - Self::ICCBased(icc) - } - }) - .or_else(|| { - dict.get::>(ALTERNATE) - .and_then(|o| Self::new(o, cache)) - }) - .or_else(|| match dict.get::(N) { - Some(1) => Some(Self::DeviceGray), - Some(3) => Some(Self::DeviceRgb), - Some(4) => Some(Self::DeviceCmyk), - _ => None, - }) - } else { - None + // PdfCraft patch (#3020): cached by what the profile is read from (see + // `IccProfileKey`), not by the stream dictionary's bytes alone. + let read = || { + let data = icc_stream.raw_data(); + let content = IccProfileKey::Content { + dict: icc_stream.cache_key(), + data: &data, + }; + cache.get_or_insert_with(hash128(&content), || { + cache.count_icc_profile_read(); + if let Some(decoded) = icc_stream.decoded().ok().as_ref() { + ICCProfile::new(decoded, num_components) + .map(|icc| { + // TODO: For SVG and PNG we can assume that the output color space is + // sRGB. If we ever implement PDF-to-PDF, we probably want to + // let the user pass the native color type and don't make this optimization + // if it's not sRGB. + if icc.is_srgb() { + Self::DeviceRgb + } else { + Self::ICCBased(icc) + } + }) + .or_else(|| { + dict.get::>(ALTERNATE) + .and_then(|o| Self::new(o, cache)) + }) + .or_else(|| match dict.get::(N) { + Some(1) => Some(Self::DeviceGray), + Some(3) => Some(Self::DeviceRgb), + Some(4) => Some(Self::DeviceCmyk), + _ => None, + }) + } else { + None + } + }) + }; + return match icc_stream_object(&color_array, &icc_stream) { + Some(object) => { + cache.get_or_insert_with(hash128(&IccProfileKey::Object(object)), read) } - }); + None => read(), + }; } CALCMYK => return Some(Self::DeviceCmyk), CALGRAY => { diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index 99d80e5a..e4d84130 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -156,6 +156,13 @@ impl<'a> InterpreterCache<'a> { pub fn decoded_image_decodes(&self) -> usize { self.decoded_cache.decodes() } + + /// PdfCraft patch (#3020): how many `/ICCBased` profiles were read through this cache, for + /// diagnostics and tests. A profile used again, by the same stream object or by a copy with + /// the same dictionary and bytes, is read once. + pub fn icc_profile_reads(&self) -> usize { + self.object_cache.icc_profile_reads() + } } /// A per-page interpretation context that borrows shared data from an [`InterpreterCache`]. From d0f4ab75a8e0d3ad3da04514c83a6e6445fc2349 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 08:07:16 +0000 Subject: [PATCH 14/17] hayro-interpret, hayro-syntax: review fixes for the reader-sync batch - Patch-mesh triangles are rebuilt after inflation, so the stored dot products match the vertices. - A stencil image restores the fill alpha itself when the saved-state bound skipped its save. - A resolved JP2 palette decodes to 8 bits whatever /BitsPerComponent says. --- vendor/README.craft-reader.md | 7 +++++++ vendor/hayro-interpret/SOURCE.json | 4 ++-- vendor/hayro-interpret/src/shading.rs | 19 ++++++++++++++----- vendor/hayro-interpret/src/x_object.rs | 5 ++++- vendor/hayro-syntax/SOURCE.json | 2 +- vendor/hayro-syntax/src/filter/jpx.rs | 16 +++++++++++++--- 6 files changed, 41 insertions(+), 12 deletions(-) diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index 436295fb..45662035 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -293,6 +293,13 @@ and the digest that tells its profile from a document profile is still a hash of decoded profile bytes. PdfCraft's two raster tests are adapted in `tests/upstream_raster.rs` (`icc_profile_cache`). +Local fixes on top of these ports, marked `Craft patch` or in the comments of the ported +code: an inflated patch-mesh triangle is rebuilt with `Triangle::new`, so its stored dot +products match its vertices (moving the points alone skewed barycentric coordinates by about +5 %, in `covers` and in interpolation); a stencil image puts the fill alpha back itself, since +`restore_state` doesn't when the saved-state bound skipped the save; and a resolved JP2 +palette decodes to 8 bits whatever `/BitsPerComponent` the dictionary gives. + 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 4b565798..b1a9faac 100644 --- a/vendor/hayro-interpret/SOURCE.json +++ b/vendor/hayro-interpret/SOURCE.json @@ -410,7 +410,7 @@ "path": "src/shading.rs", "source_sha256": "5d541a2d52f69c88e87a40580bf28afa4b8a6a2d62a3385009087264384ead32", "source_git_blob": "2e619e47f2800d7fc7cbdae77c66c58f012c84c2", - "sha256": "a8f21185291afb336771581cb6d9cad848c79beef5f8e2b8809d7bd6773b939a" + "sha256": "861ad2a40d1d3f09dc1fdb4a240a36f2873ffe2b5bedf54bb601f6ac8e5b05bb" }, { "path": "src/soft_mask.rs", @@ -434,7 +434,7 @@ "path": "src/x_object.rs", "source_sha256": "e1c51e96a335c32c122011b2d05c5020c36a33c2c73342ba173c28a3e2fe0219", "source_git_blob": "21b134ce8cd9604399cd0776febf923d69c02673", - "sha256": "f9cdf468e939f4b6eaf6c5ba89f538a77ee5cb7cf940878e176826a67b6c78b0" + "sha256": "6adbf88e6bcdde2321c517525c1291ce7ead55a7989c930757ed384c309589d4" } ], "compatibility_reference": { diff --git a/vendor/hayro-interpret/src/shading.rs b/vendor/hayro-interpret/src/shading.rs index 5290219f..98cd68f3 100644 --- a/vendor/hayro-interpret/src/shading.rs +++ b/vendor/hayro-interpret/src/shading.rs @@ -828,13 +828,22 @@ where // Converting to triangles is lossy and can lead to gaps. To make this a bit better, // we slightly inflate the triangles. - let inflate = |mut triangle: Triangle| { + // Craft patch: the inflated triangle is built anew, so its stored dot products + // (and `kurbo_tri`) match its vertices; moving the points alone skewed the + // barycentric coordinates by about 5 %. + let inflate = |triangle: Triangle| { let mid = triangle.kurbo_tri.centroid(); - triangle.p0.point = inflate_point(triangle.p0.point, mid); - triangle.p1.point = inflate_point(triangle.p1.point, mid); - triangle.p2.point = inflate_point(triangle.p2.point, mid); + let Triangle { + mut p0, + mut p1, + mut p2, + .. + } = triangle; + p0.point = inflate_point(p0.point, mid); + p1.point = inflate_point(p1.point, mid); + p2.point = inflate_point(p2.point, mid); - triangle + Triangle::new(p0, p1, p2) }; // PdfCraft patch (33): the second triangle takes the vertices the first copied. diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index d196d914..60af0748 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -342,7 +342,9 @@ pub(crate) fn draw_image_xobject<'a, 'b>( device.set_blend_mode(BlendMode::default()); // PdfCraft patch (46): the group just pushed applies the fill opacity, so a stencil mask's // paint (the fill color) must not carry it again: at `ca 0.5` a mask was drawn at a - // quarter of its opacity (#2975). The state is restored below. + // quarter of its opacity (#2975). The alpha is put back below: `restore_state` doesn't + // when the saved-state bound skipped the save. + let fill_alpha = context.get().graphics_state.non_stroke_alpha; context.get_mut().graphics_state.non_stroke_alpha = 1.0; let image = if x_object.is_mask { @@ -361,6 +363,7 @@ pub(crate) fn draw_image_xobject<'a, 'b>( device.draw_image(image, transform); device.pop_transparency_group(); + context.get_mut().graphics_state.non_stroke_alpha = fill_alpha; context.restore_state(device); if has_oc { diff --git a/vendor/hayro-syntax/SOURCE.json b/vendor/hayro-syntax/SOURCE.json index 20f4c121..fbfe83ec 100644 --- a/vendor/hayro-syntax/SOURCE.json +++ b/vendor/hayro-syntax/SOURCE.json @@ -164,7 +164,7 @@ "path": "src/filter/jpx.rs", "source_sha256": "f0a2b01c692c086d30a26562d7548f9e837af166be737d2c543de7c886a45f82", "source_git_blob": "74efd8b31e2b2935ff2bbceda2c1ffd36ea9c20d", - "sha256": "b0c295f815547377409a8810e9cbef35e4d6b506a3c3507669da71fcfe9d4701" + "sha256": "5364837caa8ed2abc33a1977b5c91f6895be17c899ae0cc6fe1ebc0723a62448" }, { "path": "src/filter/lzw_flate.rs", diff --git a/vendor/hayro-syntax/src/filter/jpx.rs b/vendor/hayro-syntax/src/filter/jpx.rs index 7268bb04..2db5b427 100644 --- a/vendor/hayro-syntax/src/filter/jpx.rs +++ b/vendor/hayro-syntax/src/filter/jpx.rs @@ -51,11 +51,13 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option ImageColorSpace::Gray, ColorSpace::RGB => ImageColorSpace::Rgb, @@ -495,5 +497,13 @@ mod tests { } assert_eq!(bpc(&one_bit_jp2(&palette)), Some(8)); assert_eq!(bpc(&one_bit_jp2(&[])), Some(1)); + let one_bit = ImageDecodeParams { + bpc: Some(1), + ..params.clone() + }; + let palette_bpc = decode(&one_bit_jp2(&palette), &one_bit) + .and_then(|r| r.image_data) + .map(|d| d.bits_per_component); + assert_eq!(palette_bpc, Some(8), "/BitsPerComponent 1 with a palette"); } } From 13d766e0746bc31b1bc55668bfa3fb009429b385 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 04:29:32 -0400 Subject: [PATCH 15/17] security: bound vendored hayro CCITT rows of unknown height and JPEG 2000 codestream size (#255) * security: bound vendored hayro CCITT rows of unknown height and JPEG 2000 codestream size - CCITT: `/Rows 0` decoded to the end of the data; a bit of 2D code can repeat a whole row, so a small stream produced gigabytes. Rows are now capped by the output limit (in the stream's 1-bit rows) and 2^28 pixels. - JPEG 2000: the decoder allocated its output from the codestream's own dimensions, infallibly. Over 2^28 pixels or the output limit (at least 64 MiB) is refused before decoding, and the buffer is reserved fallibly. - SOURCE.json local hashes updated (ascii_85.rs's had not been after #253). Co-Authored-By: Claude Opus 5.5 * fix(ccitt): pass the output limit in the columns test --------- Co-authored-by: Claude Opus 5.5 --- vendor/README.craft-reader.md | 6 ++++ vendor/hayro-syntax/SOURCE.json | 8 ++--- vendor/hayro-syntax/src/filter/ccitt.rs | 14 +++++++- vendor/hayro-syntax/src/filter/jpx.rs | 44 +++++++++++++++++++++++-- vendor/hayro-syntax/src/filter/mod.rs | 32 +++++++++++++++--- 5 files changed, 92 insertions(+), 12 deletions(-) diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index 1a560e73..55821de4 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -175,6 +175,12 @@ comma. The unit tests of all these items come with them; the CCITT, Type 3 and c raster tests are adapted in `tests/upstream_raster.rs` and the text contracts in `tests/upstream_text.rs`. (P8)'s PdfCraft test needs a Japanese substitute font and is not ported. +- A CCITT image with `/Rows 0` (unknown height) decodes at most the rows that fit the stream's + output limit (counted in 1-bit rows) and 2^28 pixels; a known height is clamped the same way. + Before, it decoded to the end of the data, and a bit of 2D code can repeat a whole row. +- A JPEG 2000 image over 2^28 pixels, or over its output limit (at least 64 MiB), by the + codestream's own dimensions is refused before decoding, and its buffer is reserved fallibly: + the image dictionary's smaller size no longer stands in for the size the decoder allocates. 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 diff --git a/vendor/hayro-syntax/SOURCE.json b/vendor/hayro-syntax/SOURCE.json index d2234ad3..bacbb5fe 100644 --- a/vendor/hayro-syntax/SOURCE.json +++ b/vendor/hayro-syntax/SOURCE.json @@ -146,7 +146,7 @@ "path": "src/filter/ccitt.rs", "source_sha256": "45d7eb89934d28cff97a00c7b264010bccfd14f3d511e1307ad50a9fef3b53c8", "source_git_blob": "685d680d2fbadce608a9bd3ca925c2ccc6c5d641", - "sha256": "b0fef2271417c3009a1de569c9573b05dd464ff76c1a5f7220f634bb5feb44e2" + "sha256": "67d17a339639eb0fef4cc6c745b579c6ba9ae3f2f67fd3bacb1bf1389aa5ffab" }, { "path": "src/filter/dct.rs", @@ -162,9 +162,9 @@ }, { "path": "src/filter/jpx.rs", - "source_sha256": "f0a2b01c692c086d30a26562d7548f9e837af166be737d2c543de7c886a45f82", + "source_sha256": "c9f068629c9cbe79af6793c3c125df4631ca1455691cb1335b44ff43cb1b4056", "source_git_blob": "74efd8b31e2b2935ff2bbceda2c1ffd36ea9c20d", - "sha256": "f0a2b01c692c086d30a26562d7548f9e837af166be737d2c543de7c886a45f82" + "sha256": "c9f068629c9cbe79af6793c3c125df4631ca1455691cb1335b44ff43cb1b4056" }, { "path": "src/filter/lzw_flate.rs", @@ -176,7 +176,7 @@ "path": "src/filter/mod.rs", "source_sha256": "0af7145ab16b15b7a60c4b57efecb9bd5fd0cfdc9bb5f9388abb695386e63e49", "source_git_blob": "06bda29487d62c25dc39c1ee109091cb17e3867f", - "sha256": "e33e74ee55dcc850db9eb64811c56b4ffe1932908ab9684dfba07f4277b1eea9" + "sha256": "e43d7d309a4993f4a1f698208997122431b4a8b0db838bb07d4358ccbc00f02f" }, { "path": "src/filter/run_length.rs", diff --git a/vendor/hayro-syntax/src/filter/ccitt.rs b/vendor/hayro-syntax/src/filter/ccitt.rs index b83e2c41..a7921a5d 100644 --- a/vendor/hayro-syntax/src/filter/ccitt.rs +++ b/vendor/hayro-syntax/src/filter/ccitt.rs @@ -8,10 +8,12 @@ use alloc::vec::Vec; use core::iter; use hayro_ccitt::{DecodeSettings, Decoder, DecoderContext, EncodingMode}; +/// PdfCraft patch: `limit` is the most bytes the filter may produce (see `decode_limit`). pub(crate) fn decode( data: &[u8], params: &Dict<'_>, image_params: &ImageDecodeParams, + limit: usize, ) -> Option> { let k = params.get::(K).unwrap_or(0); @@ -52,6 +54,16 @@ pub(crate) fn decode( if !super::ccitt_size_ok(settings.columns, settings.rows) { return None; } + // PdfCraft patch: the decoder stops after `rows` rows; with `/Rows 0` (unknown) it ran to the + // end of the data, and a bit of 2D code can repeat a whole row, so a small stream produced + // gigabytes. Decode at most the rows that fit the output limit and the pixel cap. The limit + // counts the stream's own 1-bit rows (`decode_limit`), whatever depth the rows are widened to. + let mut settings = settings; + let fit = super::ccitt_max_rows(settings.columns, settings.columns.div_ceil(8), limit); + settings.rows = if settings.rows == 0 { fit } else { settings.rows.min(fit) }; + if settings.rows == 0 { + return None; + } // Whenever possible (if we don't have an indexed color space), we convert // the data as 8-bit instead of 1-bit, so that it can be easier converted @@ -181,7 +193,7 @@ mod tests { #[test] fn columns_past_u32_max_are_refused_not_wrapped() { let params = ImageDecodeParams { width: 8, height: 4, ..Default::default() }; - let decoded = |dict: &[u8]| decode(&ALL_BLACK_8X4, &Dict::from_bytes(dict).unwrap(), ¶ms); + let decoded = |dict: &[u8]| decode(&ALL_BLACK_8X4, &Dict::from_bytes(dict).unwrap(), ¶ms, 1 << 20); assert!(decoded(b"<< /K -1 /Columns 8 >>").is_some()); // 2^32 + 8 would wrap to 8 columns. assert!(decoded(b"<< /K -1 /Columns 4294967304 >>").is_none()); diff --git a/vendor/hayro-syntax/src/filter/jpx.rs b/vendor/hayro-syntax/src/filter/jpx.rs index 74efd8b3..9feb7074 100644 --- a/vendor/hayro-syntax/src/filter/jpx.rs +++ b/vendor/hayro-syntax/src/filter/jpx.rs @@ -17,7 +17,15 @@ impl ImageColorSpace { } } -pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option> { +/// PdfCraft patch: the most pixels a JPEG 2000 image may decode to, as for JBIG2. +const MAX_PIXELS: u64 = 1 << 28; + +/// PdfCraft patch: the least output `limit` a JPEG 2000 image is held to: the limit comes from the +/// image dictionary's size, which a decoder may round differently from the codestream's. +const MIN_LIMIT: usize = 64 << 20; + +/// PdfCraft patch: `limit` is the most bytes the filter may produce (see `decode_limit`). +pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams, limit: usize) -> Option> { use crate::object::stream::ImageColorSpace; let settings = DecodeSettings { @@ -58,7 +66,23 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option MAX_PIXELS { + return None; + } + let channels = usize::from(cs.num_components()) + usize::from(has_alpha); + let len = (width as usize).checked_mul(height as usize)?.checked_mul(channels)?; + let output_limit = limit.max(MIN_LIMIT); + if len > output_limit { + return None; + } + let mut bitmap = Vec::new(); + bitmap.try_reserve_exact(len).ok()?; + bitmap.resize(len, 0); + let mut context = hayro_jpeg2000::DecoderContext::default(); + image.decode_into(&mut bitmap, &mut context).ok()?; let (mut data, mut alpha) = if !has_alpha { (bitmap, None) @@ -82,6 +106,18 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option output_limit { + return None; + } data = scale(&data, bpc, cs.num_components(), width, height)?; alpha = alpha.and_then(|alpha| scale(&alpha, bpc, cs.num_components(), width, height)); } @@ -198,7 +234,9 @@ fn scale( .checked_mul(num_components as usize)? .checked_mul(bit_per_component as usize)?; let input_len = bits_per_row.div_ceil(8).checked_mul(height as usize)?; - let mut input = vec![0; input_len]; + let mut input = Vec::new(); + input.try_reserve_exact(input_len).ok()?; + input.resize(input_len, 0); let mut writer = BitWriter::new(&mut input, bit_per_component)?; let components_per_row = (num_components as usize).checked_mul(width as usize)?; diff --git a/vendor/hayro-syntax/src/filter/mod.rs b/vendor/hayro-syntax/src/filter/mod.rs index e3622795..c6ee6ed2 100644 --- a/vendor/hayro-syntax/src/filter/mod.rs +++ b/vendor/hayro-syntax/src/filter/mod.rs @@ -110,11 +110,11 @@ impl Filter { #[cfg(feature = "images")] Self::DctDecode => dct::decode(data, params, image_params).ok_or(DecodeFailure::ImageDecode), #[cfg(feature = "images")] - Self::CcittFaxDecode => ccitt::decode(data, params, image_params).ok_or(DecodeFailure::ImageDecode), + Self::CcittFaxDecode => ccitt::decode(data, params, image_params, limit).ok_or(DecodeFailure::ImageDecode), #[cfg(feature = "images")] Self::Jbig2Decode => jbig2::decode(data, params, image_params).ok_or(DecodeFailure::ImageDecode), #[cfg(feature = "images")] - Self::JpxDecode => jpx::decode(data, image_params).ok_or(DecodeFailure::ImageDecode), + Self::JpxDecode => jpx::decode(data, image_params, limit).ok_or(DecodeFailure::ImageDecode), #[cfg(not(feature = "images"))] Self::DctDecode | Self::CcittFaxDecode | Self::Jbig2Decode | Self::JpxDecode => { warn!("image decoding is not supported (enable the `images` feature)"); @@ -168,6 +168,30 @@ pub(crate) fn decode_limit(params: &ImageDecodeParams, last: bool, limits: crate /// real fax lines are a few thousand pixels wide. pub fn ccitt_size_ok(columns: u32, rows: u32) -> bool { const MAX_COLUMNS: u32 = 1 << 20; - const MAX_PIXELS: u64 = 1 << 28; - columns > 0 && columns <= MAX_COLUMNS && u64::from(columns) * u64::from(rows.max(1)) <= MAX_PIXELS + columns > 0 && columns <= MAX_COLUMNS && u64::from(columns) * u64::from(rows.max(1)) <= MAX_CCITT_PIXELS +} + +/// PdfCraft patch: the most pixels a CCITT image may decode to. +const MAX_CCITT_PIXELS: u64 = 1 << 28; + +/// PdfCraft patch: the most rows of `columns` pixels, `row_bytes` bytes each once decoded, that fit +/// both `limit` bytes and the pixel cap; at least one when one row fits. +pub(crate) fn ccitt_max_rows(columns: u32, row_bytes: u32, limit: usize) -> u32 { + let by_pixels = MAX_CCITT_PIXELS / u64::from(columns.max(1)); + let by_bytes = u64::try_from(limit).unwrap_or(u64::MAX) / u64::from(row_bytes.max(1)); + u32::try_from(by_pixels.min(by_bytes)).unwrap_or(u32::MAX) +} + +#[cfg(test)] +mod ccitt_tests { + use super::ccitt_max_rows; + + /// PdfCraft patch: an image of unknown height decodes at most the rows that fit both the output + /// limit (in 1-bit rows) and the 2^28-pixel cap. + #[test] + fn unknown_ccitt_rows_fit_the_limit_and_the_pixel_cap() { + assert_eq!(ccitt_max_rows(1728, 216, 216 * 100), 100); + assert_eq!(ccitt_max_rows(1 << 20, 1 << 17, usize::MAX), 1 << 8); + assert_eq!(ccitt_max_rows(1728, 216, 100), 0); + } } From e179d424186122e4089cdeb9f0d45c9bd7fa6990 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:47:54 -0400 Subject: [PATCH 16/17] craft-pdf: write URI links only for http(s) with a host and mailto (#282) * craft-pdf: write URI links only for http(s) with a host and mailto A document from someone else can carry any link target, and an export that turns every hyperlink into a PDF URI action hands those targets to whoever opens the file: script and data URIs, OS protocol handlers, UNC paths, //host/share, file:// and smb:// links to a host, and Windows Search links naming a share. safe_url allows only http and https URLs with a host and mailto with an address, after trimming whitespace and comparing the scheme case-insensitively. It is the rule DeckCraft uses for its slide show and export, and is available without any feature. write::uri_target returns the Krilla link target for an allowed URL and None otherwise, so an app that builds its URI links with it writes no link annotation for a refused target and leaves the page content as drawn. * craft-pdf: safe_url needs a real http(s) host and a mailto recipient --- crates/pdf/README.md | 6 +++ crates/pdf/src/lib.rs | 2 + crates/pdf/src/link.rs | 84 +++++++++++++++++++++++++++++++++++++ crates/pdf/src/write.rs | 8 ++++ crates/pdf/tests/writing.rs | 37 ++++++++++++++++ 5 files changed, 137 insertions(+) create mode 100644 crates/pdf/src/link.rs diff --git a/crates/pdf/README.md b/crates/pdf/README.md index 657a7fda..8e654138 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -119,6 +119,12 @@ serialization uses the same backend version as before extraction. It provides: postprocessing. App errors pass through; serialization errors retain backend diagnostics. Caught panic messages retain up to 1024 Unicode scalar values; the installed panic hook remains responsible for logging source locations and full diagnostics. +- `write::uri_target`: the Krilla link target for a URI link, or `None` when `safe_url` + refuses it. Apps build URI links with it instead of `backend::action::LinkAction`, so a + link target from someone else's document becomes a PDF link only when it is an `http`/`https` + URL with a host or a `mailto` address. A refused target writes no link annotation. +- `safe_url` (crate root, any feature): the same rule, for apps that open links themselves, + such as a slide show. ### Print color diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index e540157a..9814c469 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -6,8 +6,10 @@ #[cfg(feature = "interpret")] pub use hayro_interpret as interpret; +mod link; #[cfg(feature = "read")] pub use hayro_syntax as syntax; +pub use link::safe_url; #[cfg(feature = "read")] mod read; diff --git a/crates/pdf/src/link.rs b/crates/pdf/src/link.rs new file mode 100644 index 00000000..f4c7b6a7 --- /dev/null +++ b/crates/pdf/src/link.rs @@ -0,0 +1,84 @@ +//! Which link targets from a document may become clickable links in a written PDF. + +/// Whether a link target from a (possibly untrusted) document may be opened by whoever opens +/// the file: `http`/`https` URLs with a host, and `mailto` with an address. +/// +/// Everything else is refused: `file:`, `javascript:`, `data:`, relative paths, network +/// shares (`\\host\share`, `//host/share`, `smb:`) and other schemes, which an operating system +/// may hand to a protocol handler. Leading and trailing whitespace is ignored; schemes compare +/// case-insensitively. +pub fn safe_url(url: &str) -> bool { + let u = url.trim(); + let Some((scheme, rest)) = u.split_once(':') else { return false }; + match scheme.to_ascii_lowercase().as_str() { + "http" | "https" => rest.strip_prefix("//").is_some_and(|r| !host(r).is_empty()), + "mailto" => has_recipient(rest), + _ => false, + } +} + +/// The host of an authority-first URL remainder: before the path, query or fragment, after any +/// userinfo, without the port. +fn host(rest: &str) -> &str { + let authority = rest.split(['/', '?', '#', '\\']).next().unwrap_or(""); + let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + match host_port.strip_prefix('[') { + Some(v6) => v6.split_once(']').map_or("", |(h, _)| h), + None => host_port.split(':').next().unwrap_or(""), + } +} + +/// Whether a `mailto:` remainder names an address, before its headers or in a `to` header. +fn has_recipient(rest: &str) -> bool { + let (to, headers) = rest.split_once('?').unwrap_or((rest, "")); + !to.trim().is_empty() || headers.split('&').any(|h| h.split_once('=').is_some_and(|(k, v)| k.eq_ignore_ascii_case("to") && !v.trim().is_empty())) +} + +#[cfg(test)] +mod tests { + use super::safe_url; + + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), test)] + fn only_web_and_mail_links_are_safe_to_open() { + for url in [ + "https://example.com", + " HTTP://example.com/x", + "https://user@example.com:8443/x?q#f", + "https://[::1]/", + "mailto:someone@example.com", + "mailto:?to=someone@example.com&subject=hi", + ] { + assert!(safe_url(url), "{url}"); + } + let refused = [ + "https://", + "https://?q=x", + "https:///x", + "https://user@/x", + "https://:80/", + "mailto:", + "mailto:?subject=hello", + "file:///C:/notes.pdf", + "notes.pdf", + "javascript:alert(1)", + "\t JavaScript:alert(1)", + "java\nscript:alert(1)", + "vbscript:MsgBox(1)", + "data:text/html,", + "ms-msdt:/id PCWDiagnostic", + "ms-word:ofe|u|\\\\attacker.example\\share\\x.docx", + "ldap://attacker.example/x", + "//attacker.example/share", + "\\\\attacker.example\\share", + "file://attacker.example/share/x", + "file:////attacker.example/share/x", + "smb://attacker.example/share", + "search-ms:query=x&crumb=location:\\\\attacker.example\\share", + "search:query=x&crumb=location:\\\\attacker.example\\share", + ]; + for url in refused { + assert!(!safe_url(url), "{url}"); + } + } +} diff --git a/crates/pdf/src/write.rs b/crates/pdf/src/write.rs index 3731a99b..057e1559 100644 --- a/crates/pdf/src/write.rs +++ b/crates/pdf/src/write.rs @@ -139,6 +139,14 @@ impl Writer { } } +/// The link target that opens `url`, or `None` when [`safe_url`](crate::safe_url) refuses it. +/// Build URI links with this instead of `backend::action::LinkAction` so a refused target +/// writes no link annotation; the page content stays as drawn. The URI is written trimmed. +pub fn uri_target(url: &str) -> Option { + let url = url.trim(); + crate::safe_url(url).then(|| backend::annotation::Target::Action(backend::action::LinkAction::new(url.to_owned()).into())) +} + fn check_font_input(size: usize, variations: Option<&[([u8; 4], f32)]>) -> Result<(), WriteError> { if size > MAX_FONT_BYTES { return Err(WriteError::FontLimit); diff --git a/crates/pdf/tests/writing.rs b/crates/pdf/tests/writing.rs index da611b61..9d4e0fe9 100644 --- a/crates/pdf/tests/writing.rs +++ b/crates/pdf/tests/writing.rs @@ -317,3 +317,40 @@ fn empty_language_is_absent_and_keeps_required_language_validation() { } assert_eq!(outputs[0], outputs[1], "empty language and absent language must serialize identically"); } + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn only_web_and_mail_link_targets_become_uri_links() { + use backend::annotation::{Annotation, LinkAnnotation}; + use hayro_syntax::object::{Array, Dict, String as PdfString}; + let urls = [ + " https://example.com/deck ", + "mailto:someone@example.com", + "javascript:alert(1)", + "data:text/html,", + "file://attacker.example/share/x", + "\\\\attacker.example\\share\\x", + "//attacker.example/share/x", + "ms-msdt:/id PCWDiagnostic", + "search-ms:query=x&crumb=location:\\\\attacker.example\\share", + ]; + let settings = backend::SerializeSettings { configuration: write::configuration(PdfVersion::Pdf17, None).unwrap(), ..Default::default() }; + let mut writer = Writer::new(settings, Metadata::default()); + let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(200.0, 400.0).unwrap())); + for (i, url) in (0u8..).zip(urls) { + let rect = Rect::from_xywh(10.0, 10.0 + 30.0 * f32::from(i), 100.0, 20.0).unwrap(); + if let Some(target) = write::uri_target(url) { + page.add_annotation(Annotation::new_link(LinkAnnotation::new(rect, target), None)); + } + } + page.finish(); + let bytes = writer.finish().unwrap(); + let pdf = hayro_syntax::Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + let annotations = pdf.pages()[0].raw().get::>(b"Annots").unwrap(); + let uris: Vec = annotations + .iter::>() + .filter_map(|a| a.get::>(b"A")?.get::>(b"URI")) + .map(|s| String::from_utf8_lossy(s.as_bytes()).into_owned()) + .collect(); + assert_eq!(uris, ["https://example.com/deck", "mailto:someone@example.com"]); +} From 2ab6049d9031bc8fae7f7df669b5fccaf5b9966f Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:38:24 -0400 Subject: [PATCH 17/17] krilla: link annotations and page boxes refuse non-finite geometry (patch 13) (#283) * krilla: link annotations refuse non-finite or degenerate geometry (patch 13) Link quad points that are empty or non-finite, link rects and page boxes that overflow when flipped into PDF coordinates, and XYZ destinations with a missing page or a non-finite point made krilla panic, which aborts a WASM app. Document::finish returns InvalidAnnotationGeometry, InvalidPageBox or InvalidDestination instead. Constructor signatures are unchanged. * krilla: error messages format page indices without overflow (patch 13) --- crates/pdf/tests/link_annotations.rs | 66 ++++ vendor/krilla/README.craft.md | 20 ++ vendor/krilla/SOURCE.json | 10 +- .../krilla/patches/13-annotation-bounds.patch | 299 ++++++++++++++++++ vendor/krilla/src/error.rs | 23 ++ vendor/krilla/src/interactive/annotation.rs | 60 ++-- vendor/krilla/src/interactive/destination.rs | 28 +- vendor/krilla/src/page.rs | 14 +- vendor/krilla/src/serialize.rs | 2 +- 9 files changed, 477 insertions(+), 45 deletions(-) create mode 100644 crates/pdf/tests/link_annotations.rs create mode 100644 vendor/krilla/patches/13-annotation-bounds.patch diff --git a/crates/pdf/tests/link_annotations.rs b/crates/pdf/tests/link_annotations.rs new file mode 100644 index 00000000..e637b3c7 --- /dev/null +++ b/crates/pdf/tests/link_annotations.rs @@ -0,0 +1,66 @@ +//! Link annotations, destinations and page boxes whose geometry has no finite place on the +//! page make `Writer::finish` return an error instead of panicking (krilla patch 13). +#![cfg(feature = "write")] +#![allow(clippy::unwrap_used, clippy::panic)] + +use backend::action::LinkAction; +use backend::annotation::{LinkAnnotation, Target}; +use backend::destination::XyzDestination; +use backend::error::KrillaError; +use backend::geom::{Point, Quadrilateral, Rect}; +use backend::page::PageSettings; +use craft_pdf::write::{Metadata, WriteError, Writer, backend}; + +fn link() -> Target { + Target::Action(LinkAction::new("https://example.com".into()).into()) +} + +fn quad(x: f32, y: f32) -> Quadrilateral { + Quadrilateral([Point::from_xy(x, y); 4]) +} + +fn write(settings: PageSettings, annotation: Option) -> Result, KrillaError> { + let mut writer = Writer::new(Default::default(), Metadata::default()); + let mut page = writer.document_mut().start_page_with(settings); + if let Some(annotation) = annotation { + page.add_annotation(annotation.into()); + } + page.finish(); + match writer.finish() { + Ok(pdf) => Ok(pdf), + Err(WriteError::Serialization(error)) => Err(*error), + Err(error) => panic!("unexpected error: {error}"), + } +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn unplaceable_geometry_is_an_error() { + let page = || PageSettings::from_wh(40.0, 20.0).unwrap(); + let big_page = || PageSettings::from_wh(40.0, 2e38).unwrap(); + + let ok = write(page(), Some(LinkAnnotation::new_with_quad_points(vec![quad(1.0, 2.0)], link()))); + assert!(ok.is_ok(), "{ok:?}"); + + let quads = [vec![], vec![quad(f32::NAN, f32::NAN)], vec![quad(1.0, 2.0), quad(f32::INFINITY, 2.0)], vec![quad(-3e38, 0.0), quad(3e38, 0.0)]]; + for (case, quads) in quads.into_iter().enumerate() { + let result = write(page(), Some(LinkAnnotation::new_with_quad_points(quads, link()))); + assert!(matches!(result, Err(KrillaError::InvalidAnnotationGeometry(None))), "case {case}: {result:?}"); + } + + // Finite, but 2e38 + 2e38 overflows when flipped into PDF coordinates. + let far = Rect::from_ltrb(0.0, -2e38, 10.0, -1e38).unwrap(); + let result = write(big_page(), Some(LinkAnnotation::new(far, link()))); + assert!(matches!(result, Err(KrillaError::InvalidAnnotationGeometry(None))), "{result:?}"); + + let result = write(big_page().with_crop_box(Some(far)), None); + assert!(matches!(result, Err(KrillaError::InvalidPageBox(0))), "{result:?}"); + + let missing_page = Target::Destination(XyzDestination::new(5, Point::from_xy(1.0, 1.0)).into()); + let result = write(page(), Some(LinkAnnotation::new(Rect::from_xywh(0.0, 0.0, 5.0, 5.0).unwrap(), missing_page))); + assert!(matches!(result, Err(KrillaError::InvalidDestination(5))), "{result:?}"); + + let far_point = Target::Destination(XyzDestination::new(0, Point::from_xy(1.0, -3e38)).into()); + let result = write(big_page(), Some(LinkAnnotation::new(Rect::from_xywh(0.0, 0.0, 5.0, 5.0).unwrap(), far_point))); + assert!(matches!(result, Err(KrillaError::InvalidDestination(0))), "{result:?}"); +} diff --git a/vendor/krilla/README.craft.md b/vendor/krilla/README.craft.md index 3b55e756..c47779c7 100644 --- a/vendor/krilla/README.craft.md +++ b/vendor/krilla/README.craft.md @@ -22,6 +22,7 @@ itself omits those texts. Its examples and four CC0 ICC profiles are retained wi | `patches/10-mesh-gradients.patch` | Mesh gradients | `content.rs`, `error.rs`, `graphics/{mask,paint,shading_function}.rs`, `serialize.rs`, `surface.rs` | | `patches/11-png-decode-cap.patch` | PNG decode cap | `graphics/image.rs` | | `patches/12-optional-content.patch` | Optional content | `chunk_container.rs`, `configure/{PDF_A1,PDF_A2,PDF_A4,PDF_UA1}.md`, `configure/validate.rs`, `content.rs`, `error.rs`, `graphics/{mod,optional_content}.rs`, `lib.rs`, `resource.rs`, `serialize.rs`, `surface.rs` | +| `patches/13-annotation-bounds.patch` | Annotation and destination bounds | `error.rs`, `interactive/{annotation,destination}.rs`, `page.rs`, `serialize.rs` | Each patch applies with `patch -p1` from this directory, in numeric order, to the pristine crate. `SOURCE.json` records, for every retained file, the upstream hash (`upstream_sha256`) @@ -336,3 +337,22 @@ get small hooks. `CRAFT_OPTIONAL_CONTENT` marks the patch; craft-pdf requires it at compile time. Tests: `crates/pdf/tests/optional_content.rs`. + +## 13: annotation and destination bounds + +Upstream panics on link geometry that has no finite place on the page, which aborts a WASM +app. `Document::finish` returns an error instead and writes nothing for the object: + +| Input | Upstream | Error | +|---|---|---| +| `LinkAnnotation::new_with_quad_points` with no quads, a non-finite point, or a bounding box whose width or height overflows | `assert!` or `unwrap` | `InvalidAnnotationGeometry(location)` | +| A link rect or quad bounding box that overflows when flipped into PDF coordinates (`page_height - y`) | `unwrap` | `InvalidAnnotationGeometry(location)` | +| A media, crop, bleed, trim or art box that overflows the same way | `unwrap` | `InvalidPageBox(page_index)` | +| An `XyzDestination` page index past the last page | `panic!` | `InvalidDestination(page_index)` | +| An `XyzDestination` point that maps to a non-finite coordinate | writes `inf` or `NaN` | `InvalidDestination(page_index)` | + +The constructors keep their signatures: `LinkAnnotation` holds an `Option` and the check +runs at serialization, before the annotation object starts. Refusing a single annotation +instead of skipping it keeps the indices of tagged annotations in the structure tree valid. + +Tests: `crates/pdf/tests/link_annotations.rs`. diff --git a/vendor/krilla/SOURCE.json b/vendor/krilla/SOURCE.json index ce4d6ed2..be542dd6 100644 --- a/vendor/krilla/SOURCE.json +++ b/vendor/krilla/SOURCE.json @@ -137,7 +137,7 @@ { "path": "src/error.rs", "upstream_sha256": "656b260bd99d250535099f17f2db8e6e6135e72c74fb6dff304371db9f646be5", - "sha256": "fadd496a1d3e782ef8dfc40a59bc0c070406846d8a24b8b21eaa6130d4c58f5a" + "sha256": "8d06efd85136668d1a491f2f04ba2d69e82347a89f275d9299998445f28172ac" }, { "path": "src/geom.rs", @@ -232,12 +232,12 @@ { "path": "src/interactive/annotation.rs", "upstream_sha256": "45b01acfef124a5ab9b6799ab4c83734f929b3573fdb28c218b108e220afad39", - "sha256": "45b01acfef124a5ab9b6799ab4c83734f929b3573fdb28c218b108e220afad39" + "sha256": "50f2a4bdc99d82bda275533e3a269eca6e7ae3ecb68b781203160122c744e55e" }, { "path": "src/interactive/destination.rs", "upstream_sha256": "fbe7ce81ce195dbc86502e8555268c026a7c795cfe8a94a975e23cab16b657c3", - "sha256": "fbe7ce81ce195dbc86502e8555268c026a7c795cfe8a94a975e23cab16b657c3" + "sha256": "3043468f3caafe0fd0d3cac6cdd6bafe5ccbd89c4c3a5817ce505d5fe9403c36" }, { "path": "src/interactive/mod.rs", @@ -302,7 +302,7 @@ { "path": "src/page.rs", "upstream_sha256": "ef85a771013896b185c9393414175ee91e5541c9a3fad4760c161764088ac177", - "sha256": "0c85a6b7be5c1e07180cea54f62b53e5a9e6c5c37790122f2ef5c9f8adc4efd7" + "sha256": "9822a623229000c1017d636867971a9f7aa3a4cc3094ba3057786ee146ee3dcd" }, { "path": "src/pdf.rs", @@ -317,7 +317,7 @@ { "path": "src/serialize.rs", "upstream_sha256": "4f2c439dbd20006eea112e056b2f1a5ba98a1ede9227a45f66a6df46fb1a07e8", - "sha256": "99cda1c62910281776726de63b1600830484be0972b8d60adbca2d9b341cd49b" + "sha256": "f5ccb6b92f546e1068e51e0791dbac8b802f07f29df0c2a6ccd1dcf8a1bdc938" }, { "path": "src/stream.rs", diff --git a/vendor/krilla/patches/13-annotation-bounds.patch b/vendor/krilla/patches/13-annotation-bounds.patch new file mode 100644 index 00000000..f7945f15 --- /dev/null +++ b/vendor/krilla/patches/13-annotation-bounds.patch @@ -0,0 +1,299 @@ +diff --git a/src/error.rs b/src/error.rs +index a19ebf6..7158052 100644 +--- a/src/error.rs ++++ b/src/error.rs +@@ -43,6 +43,15 @@ pub enum KrillaError { + /// Optional content marks were not balanced: an end without a start, a mark still open + /// when its stream finished, or a mark overlapping a tagged section. + UnbalancedOptionalContent(Option), ++ /// A link annotation has no finite bounding box on its page: its quad points are empty ++ /// or not finite, or its rect doesn't map to finite page coordinates. ++ InvalidAnnotationGeometry(Option), ++ /// An XYZ destination targets a page the document doesn't have (the index is ++ /// zero-based), or a point that isn't finite on that page. ++ InvalidDestination(usize), ++ /// A page's media, crop, bleed, trim or art box doesn't map to finite PDF coordinates. ++ /// Holds the zero-based page index. ++ InvalidPageBox(usize), + /// An error while attempting to embed a font. + Font(Font, String), + /// A list of validation errors. Can only occur if you set the `validator` in +@@ -96,6 +105,20 @@ impl Display for KrillaError { + f.write_str("optional content marks are not balanced")?; + write_location(f, *location) + } ++ KrillaError::InvalidAnnotationGeometry(location) => { ++ f.write_str("a link annotation has no finite bounding box")?; ++ write_location(f, *location) ++ } ++ KrillaError::InvalidDestination(page_index) => write!( ++ f, ++ "a destination on page {} is not in the document or not finite", ++ page_index.saturating_add(1) ++ ), ++ KrillaError::InvalidPageBox(page_index) => write!( ++ f, ++ "a page box on page {} doesn't map to finite coordinates", ++ page_index.saturating_add(1) ++ ), + KrillaError::Font(_, message) => write!(f, "failed to embed font: {message}"), + KrillaError::Validation(errors) => { + let count = errors.len(); +diff --git a/src/interactive/annotation.rs b/src/interactive/annotation.rs +index 91c4cff..f2121f1 100644 +--- a/src/interactive/annotation.rs ++++ b/src/interactive/annotation.rs +@@ -14,7 +14,7 @@ use pdf_writer::{Finish, Name, Ref, TextStr}; + use crate::chunk_container::ChunkContainer; + use crate::color::Color; + use crate::configure::{PdfVersion, ValidationError}; +-use crate::error::KrillaResult; ++use crate::error::{KrillaError, KrillaResult}; + use crate::geom::{Quadrilateral, Rect}; + use crate::interactive::action::Action; + use crate::interactive::destination::Destination; +@@ -70,15 +70,20 @@ impl Annotation { + root_ref: Ref, + page_height: f32, + ) -> KrillaResult<()> { ++ let AnnotationType::Link(l) = &self.annotation_type; ++ // Checked before the object starts, so a refused annotation writes nothing. ++ let rect = l ++ .page_rect(page_height) ++ .ok_or(KrillaError::InvalidAnnotationGeometry(self.location))?; ++ + let chunk = &mut chunk_container.non_stream.annotations; + let mut annotation = chunk + .indirect(root_ref) + .start::(); + + self.annotation_type +- .serialize_type(sc, &mut annotation, page_height)?; ++ .serialize_type(sc, &mut annotation, page_height, rect)?; + +- let AnnotationType::Link(l) = &self.annotation_type; + // Only set the print flag when really necessary (only PDF/A). Don't + // set it by default, so annotations with color borders will be shown + // on a screen but not printed. +@@ -124,9 +129,10 @@ impl AnnotationType { + sc: &mut SerializeContext, + annotation: &mut pdf_writer::writers::Annotation, + page_height: f32, ++ rect: Rect, + ) -> KrillaResult<()> { + match self { +- AnnotationType::Link(l) => l.serialize_type(sc, annotation, page_height), ++ AnnotationType::Link(l) => l.serialize_type(sc, annotation, page_height, rect), + } + } + } +@@ -157,7 +163,8 @@ impl LinkBorder { + + /// A link annotation. + pub struct LinkAnnotation { +- pub(crate) rect: Rect, ++ /// `None` when the quad points have no finite bounding box. ++ pub(crate) rect: Option, + pub(crate) quad_points: Option>, + pub(crate) target: Target, + pub(crate) border: Option, +@@ -170,7 +177,7 @@ impl LinkAnnotation { + /// `target`: The target of the link annotation. + pub fn new(rect: Rect, target: Target) -> Self { + Self { +- rect, ++ rect: Some(rect), + quad_points: None, + target, + border: None, +@@ -183,8 +190,16 @@ impl LinkAnnotation { + /// `quad_points`: An array of quadrilaterals that define where the link + /// annotation should be activated. This is useful if you for example have + /// a link annotation that is broken to one or multiple lines. ++ /// ++ /// An empty list, or a point that isn't finite, makes [`Document::finish`] return ++ /// [`KrillaError::InvalidAnnotationGeometry`]. ++ /// ++ /// [`Document::finish`]: crate::Document::finish + pub fn new_with_quad_points(quad_points: Vec, target: Target) -> Self { +- assert!(!quad_points.is_empty()); ++ let finite = quad_points ++ .iter() ++ .flat_map(|q| q.0) ++ .all(|p| p.x.is_finite() && p.y.is_finite()); + + let mut min_x = f32::INFINITY; + let mut min_y = f32::INFINITY; +@@ -202,13 +217,16 @@ impl LinkAnnotation { + // that sometimes prevents the quadpoints from being used if the quad + // points lie exactly on the bounding rectangle. + const EPSILON: f32 = 0.001; +- let rect = Rect::from_ltrb( +- min_x - EPSILON, +- min_y - EPSILON, +- max_x + EPSILON, +- max_y + EPSILON, +- ) +- .unwrap(); ++ let rect = finite ++ .then(|| { ++ Rect::from_ltrb( ++ min_x - EPSILON, ++ min_y - EPSILON, ++ max_x + EPSILON, ++ max_y + EPSILON, ++ ) ++ }) ++ .flatten(); + + Self { + rect, +@@ -227,19 +245,23 @@ impl LinkAnnotation { + } + } + ++ /// The rect in PDF page coordinates, or `None` when there is no rect or it doesn't ++ /// map to finite coordinates. The quad points lie inside the rect, so they map to ++ /// finite coordinates whenever it does. ++ fn page_rect(&self, page_height: f32) -> Option { ++ self.rect?.transform(page_root_transform(page_height)) ++ } ++ + fn serialize_type( + &self, + sc: &mut SerializeContext, + annotation: &mut pdf_writer::writers::Annotation, + page_height: f32, ++ rect: Rect, + ) -> KrillaResult<()> { + annotation.subtype(pdf_writer::types::AnnotationType::Link); + +- let actual_rect = self +- .rect +- .transform(page_root_transform(page_height)) +- .unwrap(); +- annotation.rect(actual_rect.to_pdf_rect()); ++ annotation.rect(rect.to_pdf_rect()); + annotation.border( + 0.0, + 0.0, +diff --git a/src/interactive/destination.rs b/src/interactive/destination.rs +index de40085..ec50951 100644 +--- a/src/interactive/destination.rs ++++ b/src/interactive/destination.rs +@@ -117,7 +117,10 @@ impl From for Destination { + impl XyzDestination { + /// Create a new XYZ destination. `page_index` should be the index (i.e. number) of the + /// target page, and point indicates the specific location on that page that should be +- /// targeted. If the `page_index` is out of range, export will panic. ++ /// targeted. A `page_index` out of range, or a point that isn't finite, makes ++ /// [`Document::finish`] return [`KrillaError::InvalidDestination`]. ++ /// ++ /// [`Document::finish`]: crate::Document::finish + pub fn new(page_index: usize, point: Point) -> Self { + Self(Arc::new(XyzDestRepr { page_index, point })) + } +@@ -127,17 +130,9 @@ impl XyzDestination { + sc: &mut SerializeContext, + chunk_container: &mut ChunkContainer, + root_ref: Ref, +- ) { +- let chunk = &mut chunk_container.non_stream.destinations; +- let destination = chunk.destination(root_ref); +- +- let page_info = sc.page_infos().get(self.0.page_index).unwrap_or_else(|| { +- panic!( +- "attempted to link to page {}, but document only has {} pages", +- self.0.page_index + 1, +- sc.page_infos().len() +- ) +- }); ++ ) -> KrillaResult<()> { ++ let invalid = || KrillaError::InvalidDestination(self.0.page_index); ++ let page_info = sc.page_infos().get(self.0.page_index).ok_or_else(invalid)?; + + let (ref_, surface_size) = match page_info { + PageInfo::Krilla { +@@ -153,10 +148,17 @@ impl XyzDestination { + // Convert to PDF coordinates + let invert_transform = Transform::from_row(1.0, 0.0, 0.0, -1.0, 0.0, page_size); + invert_transform.map_point(&mut mapped_point); ++ if !(mapped_point.x.is_finite() && mapped_point.y.is_finite()) { ++ return Err(invalid()); ++ } + +- destination ++ chunk_container ++ .non_stream ++ .destinations ++ .destination(root_ref) + .page(page_ref) + .xyz(mapped_point.x, mapped_point.y, None); ++ Ok(()) + } + } + +diff --git a/src/page.rs b/src/page.rs +index 3b3227b..c596677 100644 +--- a/src/page.rs ++++ b/src/page.rs +@@ -12,7 +12,7 @@ use crate::chunk_container::ChunkContainer; + use crate::configure::validate::VersionedFeature; + use crate::configure::ValidationError; + use crate::content::ContentBuilder; +-use crate::error::KrillaResult; ++use crate::error::{KrillaError, KrillaResult}; + use crate::geom::{Rect, Size, Transform}; + use crate::graphics::xobject::write_group_color_space; + use crate::interactive::annotation::Annotation; +@@ -437,31 +437,31 @@ impl InternalPage { + rect.transform(page_root_transform( + self.page_settings.surface_size().height(), + )) +- .unwrap() ++ .ok_or(KrillaError::InvalidPageBox(self.page_index)) + }; + + // media box is mandatory, so we need to fall back to the default bbox +- let media_box = transform_rect(self.page_settings.media_box().unwrap_or(self.bbox)); ++ let media_box = transform_rect(self.page_settings.media_box().unwrap_or(self.bbox))?; + page.media_box(media_box.to_pdf_rect()); + + // the remaining type of box are not mandatory, so we only set them if they are present + if let Some(crop_box) = self.page_settings.crop_box() { +- let crop_box = transform_rect(crop_box); ++ let crop_box = transform_rect(crop_box)?; + page.crop_box(crop_box.to_pdf_rect()); + } + + if let Some(bleed_box) = self.page_settings.bleed_box() { +- let bleed_box = transform_rect(bleed_box); ++ let bleed_box = transform_rect(bleed_box)?; + page.bleed_box(bleed_box.to_pdf_rect()); + } + + if let Some(trim_box) = self.page_settings.trim_box() { +- let trim_box = transform_rect(trim_box); ++ let trim_box = transform_rect(trim_box)?; + page.trim_box(trim_box.to_pdf_rect()); + } + + if let Some(art_box) = self.page_settings.art_box() { +- let art_box = transform_rect(art_box); ++ let art_box = transform_rect(art_box)?; + page.art_box(art_box.to_pdf_rect()); + } + +diff --git a/src/serialize.rs b/src/serialize.rs +index 4f4af4c..f2986fc 100644 +--- a/src/serialize.rs ++++ b/src/serialize.rs +@@ -1019,7 +1019,7 @@ impl SerializeContext { + ) -> KrillaResult<()> { + let xyz_destinations = self.global_objects.xyz_destinations.take(); + for (ref_, dest) in &xyz_destinations { +- dest.serialize(self, chunk_container, *ref_); ++ dest.serialize(self, chunk_container, *ref_)?; + } + + Ok(()) diff --git a/vendor/krilla/src/error.rs b/vendor/krilla/src/error.rs index a19ebf62..71580526 100644 --- a/vendor/krilla/src/error.rs +++ b/vendor/krilla/src/error.rs @@ -43,6 +43,15 @@ pub enum KrillaError { /// Optional content marks were not balanced: an end without a start, a mark still open /// when its stream finished, or a mark overlapping a tagged section. UnbalancedOptionalContent(Option), + /// A link annotation has no finite bounding box on its page: its quad points are empty + /// or not finite, or its rect doesn't map to finite page coordinates. + InvalidAnnotationGeometry(Option), + /// An XYZ destination targets a page the document doesn't have (the index is + /// zero-based), or a point that isn't finite on that page. + InvalidDestination(usize), + /// A page's media, crop, bleed, trim or art box doesn't map to finite PDF coordinates. + /// Holds the zero-based page index. + InvalidPageBox(usize), /// An error while attempting to embed a font. Font(Font, String), /// A list of validation errors. Can only occur if you set the `validator` in @@ -96,6 +105,20 @@ impl Display for KrillaError { f.write_str("optional content marks are not balanced")?; write_location(f, *location) } + KrillaError::InvalidAnnotationGeometry(location) => { + f.write_str("a link annotation has no finite bounding box")?; + write_location(f, *location) + } + KrillaError::InvalidDestination(page_index) => write!( + f, + "a destination on page {} is not in the document or not finite", + page_index.saturating_add(1) + ), + KrillaError::InvalidPageBox(page_index) => write!( + f, + "a page box on page {} doesn't map to finite coordinates", + page_index.saturating_add(1) + ), KrillaError::Font(_, message) => write!(f, "failed to embed font: {message}"), KrillaError::Validation(errors) => { let count = errors.len(); diff --git a/vendor/krilla/src/interactive/annotation.rs b/vendor/krilla/src/interactive/annotation.rs index 91c4cff6..f2121f1a 100644 --- a/vendor/krilla/src/interactive/annotation.rs +++ b/vendor/krilla/src/interactive/annotation.rs @@ -14,7 +14,7 @@ use pdf_writer::{Finish, Name, Ref, TextStr}; use crate::chunk_container::ChunkContainer; use crate::color::Color; use crate::configure::{PdfVersion, ValidationError}; -use crate::error::KrillaResult; +use crate::error::{KrillaError, KrillaResult}; use crate::geom::{Quadrilateral, Rect}; use crate::interactive::action::Action; use crate::interactive::destination::Destination; @@ -70,15 +70,20 @@ impl Annotation { root_ref: Ref, page_height: f32, ) -> KrillaResult<()> { + let AnnotationType::Link(l) = &self.annotation_type; + // Checked before the object starts, so a refused annotation writes nothing. + let rect = l + .page_rect(page_height) + .ok_or(KrillaError::InvalidAnnotationGeometry(self.location))?; + let chunk = &mut chunk_container.non_stream.annotations; let mut annotation = chunk .indirect(root_ref) .start::(); self.annotation_type - .serialize_type(sc, &mut annotation, page_height)?; + .serialize_type(sc, &mut annotation, page_height, rect)?; - let AnnotationType::Link(l) = &self.annotation_type; // Only set the print flag when really necessary (only PDF/A). Don't // set it by default, so annotations with color borders will be shown // on a screen but not printed. @@ -124,9 +129,10 @@ impl AnnotationType { sc: &mut SerializeContext, annotation: &mut pdf_writer::writers::Annotation, page_height: f32, + rect: Rect, ) -> KrillaResult<()> { match self { - AnnotationType::Link(l) => l.serialize_type(sc, annotation, page_height), + AnnotationType::Link(l) => l.serialize_type(sc, annotation, page_height, rect), } } } @@ -157,7 +163,8 @@ impl LinkBorder { /// A link annotation. pub struct LinkAnnotation { - pub(crate) rect: Rect, + /// `None` when the quad points have no finite bounding box. + pub(crate) rect: Option, pub(crate) quad_points: Option>, pub(crate) target: Target, pub(crate) border: Option, @@ -170,7 +177,7 @@ impl LinkAnnotation { /// `target`: The target of the link annotation. pub fn new(rect: Rect, target: Target) -> Self { Self { - rect, + rect: Some(rect), quad_points: None, target, border: None, @@ -183,8 +190,16 @@ impl LinkAnnotation { /// `quad_points`: An array of quadrilaterals that define where the link /// annotation should be activated. This is useful if you for example have /// a link annotation that is broken to one or multiple lines. + /// + /// An empty list, or a point that isn't finite, makes [`Document::finish`] return + /// [`KrillaError::InvalidAnnotationGeometry`]. + /// + /// [`Document::finish`]: crate::Document::finish pub fn new_with_quad_points(quad_points: Vec, target: Target) -> Self { - assert!(!quad_points.is_empty()); + let finite = quad_points + .iter() + .flat_map(|q| q.0) + .all(|p| p.x.is_finite() && p.y.is_finite()); let mut min_x = f32::INFINITY; let mut min_y = f32::INFINITY; @@ -202,13 +217,16 @@ impl LinkAnnotation { // that sometimes prevents the quadpoints from being used if the quad // points lie exactly on the bounding rectangle. const EPSILON: f32 = 0.001; - let rect = Rect::from_ltrb( - min_x - EPSILON, - min_y - EPSILON, - max_x + EPSILON, - max_y + EPSILON, - ) - .unwrap(); + let rect = finite + .then(|| { + Rect::from_ltrb( + min_x - EPSILON, + min_y - EPSILON, + max_x + EPSILON, + max_y + EPSILON, + ) + }) + .flatten(); Self { rect, @@ -227,19 +245,23 @@ impl LinkAnnotation { } } + /// The rect in PDF page coordinates, or `None` when there is no rect or it doesn't + /// map to finite coordinates. The quad points lie inside the rect, so they map to + /// finite coordinates whenever it does. + fn page_rect(&self, page_height: f32) -> Option { + self.rect?.transform(page_root_transform(page_height)) + } + fn serialize_type( &self, sc: &mut SerializeContext, annotation: &mut pdf_writer::writers::Annotation, page_height: f32, + rect: Rect, ) -> KrillaResult<()> { annotation.subtype(pdf_writer::types::AnnotationType::Link); - let actual_rect = self - .rect - .transform(page_root_transform(page_height)) - .unwrap(); - annotation.rect(actual_rect.to_pdf_rect()); + annotation.rect(rect.to_pdf_rect()); annotation.border( 0.0, 0.0, diff --git a/vendor/krilla/src/interactive/destination.rs b/vendor/krilla/src/interactive/destination.rs index de40085d..ec509511 100644 --- a/vendor/krilla/src/interactive/destination.rs +++ b/vendor/krilla/src/interactive/destination.rs @@ -117,7 +117,10 @@ impl From for Destination { impl XyzDestination { /// Create a new XYZ destination. `page_index` should be the index (i.e. number) of the /// target page, and point indicates the specific location on that page that should be - /// targeted. If the `page_index` is out of range, export will panic. + /// targeted. A `page_index` out of range, or a point that isn't finite, makes + /// [`Document::finish`] return [`KrillaError::InvalidDestination`]. + /// + /// [`Document::finish`]: crate::Document::finish pub fn new(page_index: usize, point: Point) -> Self { Self(Arc::new(XyzDestRepr { page_index, point })) } @@ -127,17 +130,9 @@ impl XyzDestination { sc: &mut SerializeContext, chunk_container: &mut ChunkContainer, root_ref: Ref, - ) { - let chunk = &mut chunk_container.non_stream.destinations; - let destination = chunk.destination(root_ref); - - let page_info = sc.page_infos().get(self.0.page_index).unwrap_or_else(|| { - panic!( - "attempted to link to page {}, but document only has {} pages", - self.0.page_index + 1, - sc.page_infos().len() - ) - }); + ) -> KrillaResult<()> { + let invalid = || KrillaError::InvalidDestination(self.0.page_index); + let page_info = sc.page_infos().get(self.0.page_index).ok_or_else(invalid)?; let (ref_, surface_size) = match page_info { PageInfo::Krilla { @@ -153,10 +148,17 @@ impl XyzDestination { // Convert to PDF coordinates let invert_transform = Transform::from_row(1.0, 0.0, 0.0, -1.0, 0.0, page_size); invert_transform.map_point(&mut mapped_point); + if !(mapped_point.x.is_finite() && mapped_point.y.is_finite()) { + return Err(invalid()); + } - destination + chunk_container + .non_stream + .destinations + .destination(root_ref) .page(page_ref) .xyz(mapped_point.x, mapped_point.y, None); + Ok(()) } } diff --git a/vendor/krilla/src/page.rs b/vendor/krilla/src/page.rs index 3b3227b8..c5966776 100644 --- a/vendor/krilla/src/page.rs +++ b/vendor/krilla/src/page.rs @@ -12,7 +12,7 @@ use crate::chunk_container::ChunkContainer; use crate::configure::validate::VersionedFeature; use crate::configure::ValidationError; use crate::content::ContentBuilder; -use crate::error::KrillaResult; +use crate::error::{KrillaError, KrillaResult}; use crate::geom::{Rect, Size, Transform}; use crate::graphics::xobject::write_group_color_space; use crate::interactive::annotation::Annotation; @@ -437,31 +437,31 @@ impl InternalPage { rect.transform(page_root_transform( self.page_settings.surface_size().height(), )) - .unwrap() + .ok_or(KrillaError::InvalidPageBox(self.page_index)) }; // media box is mandatory, so we need to fall back to the default bbox - let media_box = transform_rect(self.page_settings.media_box().unwrap_or(self.bbox)); + let media_box = transform_rect(self.page_settings.media_box().unwrap_or(self.bbox))?; page.media_box(media_box.to_pdf_rect()); // the remaining type of box are not mandatory, so we only set them if they are present if let Some(crop_box) = self.page_settings.crop_box() { - let crop_box = transform_rect(crop_box); + let crop_box = transform_rect(crop_box)?; page.crop_box(crop_box.to_pdf_rect()); } if let Some(bleed_box) = self.page_settings.bleed_box() { - let bleed_box = transform_rect(bleed_box); + let bleed_box = transform_rect(bleed_box)?; page.bleed_box(bleed_box.to_pdf_rect()); } if let Some(trim_box) = self.page_settings.trim_box() { - let trim_box = transform_rect(trim_box); + let trim_box = transform_rect(trim_box)?; page.trim_box(trim_box.to_pdf_rect()); } if let Some(art_box) = self.page_settings.art_box() { - let art_box = transform_rect(art_box); + let art_box = transform_rect(art_box)?; page.art_box(art_box.to_pdf_rect()); } diff --git a/vendor/krilla/src/serialize.rs b/vendor/krilla/src/serialize.rs index 4f4af4c9..f2986fc8 100644 --- a/vendor/krilla/src/serialize.rs +++ b/vendor/krilla/src/serialize.rs @@ -1019,7 +1019,7 @@ impl SerializeContext { ) -> KrillaResult<()> { let xyz_destinations = self.global_objects.xyz_destinations.take(); for (ref_, dest) in &xyz_destinations { - dest.serialize(self, chunk_container, *ref_); + dest.serialize(self, chunk_container, *ref_)?; } Ok(())