mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
Merge PR #3296: Renderer: sample a mesh shading over its triangles alone, not the whole path
vendor/README.md: main's rows plus the PR's hayro-interpret patch 35 and hayro patch 19 with their tests
This commit is contained in:
Vendored
+3
-3
File diff suppressed because one or more lines are too long
Vendored
+36
@@ -18,6 +18,13 @@ pub struct EncodedShadingPattern {
|
||||
pub(crate) shading_type: EncodedShadingType,
|
||||
pub(crate) opacity: f32,
|
||||
pub(crate) transfer_function: Option<ActiveTransferFunction>,
|
||||
/// PdfCraft patch (35): 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<kurbo::Rect>,
|
||||
}
|
||||
|
||||
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<kurbo::Rect>) -> EncodedShadingPattern {
|
||||
let base_transform;
|
||||
// PdfCraft patch (35): 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,9 @@ 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 +264,8 @@ struct TriangleSampler {
|
||||
map: FxHashMap<(u16, u16), ColorComponents>,
|
||||
area: kurbo::Rect,
|
||||
visits_left: u64,
|
||||
/// PdfCraft patch (35): the pixels sampled so far, as `(x0, y0, x1, y1)` inclusive.
|
||||
touched: Option<(u16, u16, u16, u16)>,
|
||||
}
|
||||
|
||||
impl TriangleSampler {
|
||||
@@ -263,6 +280,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 (35): 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,6 +356,10 @@ impl TriangleSampler {
|
||||
let point = Point::new(x as f64, y as f64);
|
||||
if t.contains_point(point) {
|
||||
self.map.insert((x, y), t.interpolate(point));
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+134
@@ -675,3 +675,137 @@ mod color_replacement_render_tests {
|
||||
assert_eq!(sample(&no_line_art, 40, 25), [0, 0, 0, 255]);
|
||||
}
|
||||
}
|
||||
|
||||
/// PdfCraft patch (19): 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<u8> {
|
||||
// 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<u8>> = 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<u8>, 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]);
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+52
-16
@@ -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 (19): 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<BezPath>),
|
||||
Nothing,
|
||||
}
|
||||
|
||||
pub(crate) struct Renderer {
|
||||
pub(crate) ctx: RenderContext,
|
||||
/// PdfCraft patch: when set, solid text and line-art paint colours are remapped
|
||||
@@ -1039,9 +1048,12 @@ impl Renderer {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn set_paint(&mut self, paint: &Paint<'_>, path: &BezPath, is_stroke: bool) -> Option<BezPath> {
|
||||
fn set_paint(&mut self, paint: &Paint<'_>, path: &BezPath, is_stroke: bool) -> PaintSetup {
|
||||
let mut paint_transform = Affine::IDENTITY;
|
||||
let mut clip_path = None;
|
||||
// PdfCraft patch (19): `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) => {
|
||||
@@ -1074,6 +1086,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 (19): a mesh paints only where its triangles are. The
|
||||
// texture below used to cover the whole path (the canvas, for `sh`)
|
||||
// and was sampled pixel by pixel: 310 one-triangle meshes on a plan
|
||||
// cost 310 full tiles per tile, and the watchdog gave the page up. It
|
||||
// now covers the triangles alone (a pixel around them, which stays
|
||||
// transparent and is what `Extend::Pad` repeats), and 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
|
||||
@@ -1089,7 +1123,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) =
|
||||
@@ -1108,8 +1142,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,
|
||||
},
|
||||
@@ -1267,7 +1301,7 @@ impl Renderer {
|
||||
self.ctx.set_paint_transform(paint_transform);
|
||||
self.ctx.set_paint(paint);
|
||||
|
||||
clip_path
|
||||
PaintSetup::Clip(clip_path)
|
||||
}
|
||||
|
||||
fn stroke_path(
|
||||
@@ -1287,7 +1321,7 @@ 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);
|
||||
}
|
||||
@@ -1316,7 +1350,7 @@ 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);
|
||||
}
|
||||
@@ -1562,14 +1596,16 @@ impl<'a> Device<'a> for Renderer {
|
||||
);
|
||||
self.ctx.set_transform(transform);
|
||||
|
||||
let clip_path =
|
||||
self.set_paint(paint, &stencil_rect.to_path(0.1), true);
|
||||
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), true)
|
||||
{
|
||||
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();
|
||||
@@ -1706,7 +1742,7 @@ 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user