From 5ff42f26425eba8ba1363fa1fe68bd0bb6f8cd14 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 07:38:33 +0000 Subject: [PATCH] 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); }