M2, #1060: axial and radial shadings draw as native gradients (backport of hayro 0.8) (#1061)

InDesign writes fades as luminosity soft masks whose group is one
extended axial `sh` over the whole page. hayro 0.7 sampled every such
shading pixel by pixel through the shading function, so a report page
with 40 of them took 10-15 s at 144 dpi.

Backport hayro 0.8.0's `gradient` module: an axial or radial shading
that a native gradient draws exactly becomes sRGB stops (within 0.01
per channel, a stop at every stitching bound), painted as a vello_cpu
gradient. Upstream's half-pixel shift is left out: it suits vello_cpu
0.3 but moves 0.0.8's gradients off pixel centres.

Page 4 of the reported file: 10.8 s -> 1.0 s.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mlkonrad
2026-10-11 13:12:16 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f5cfb33773
commit 7f1b632519
9 changed files with 644 additions and 8 deletions
+153
View File
@@ -4971,6 +4971,159 @@ trailer << /Root 1 0 R >>
}
}
/// InDesign fades are luminosity soft masks whose group is one extended axial `sh`: a
/// gradient over the whole page. hayro 0.7 sampled each one pixel by pixel through the
/// shading function, so a report page with 40 of them took 15 s at 144 dpi. Vendored hayro
/// patch (backport of hayro 0.8): axial and radial shadings that a native gradient can
/// represent exactly are drawn as one, with stops placed at stitching-function bounds.
#[test]
fn axial_and_radial_shadings_draw_as_native_gradients() {
use hayro::hayro_interpret::font::Glyph;
use hayro::hayro_interpret::hayro_syntax::Pdf;
use hayro::hayro_interpret::pattern::Pattern;
use hayro::hayro_interpret::{
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, Paint, PathDrawMode, SoftMask, TransformExt, interpret_page,
};
use kurbo::{Affine, BezPath, Rect, Shape};
/// For every shading painted, the offsets of its native gradient stops, or `None` when
/// it has to be sampled pixel by pixel.
#[derive(Default)]
struct Gradients(Vec<Option<Vec<f32>>>);
impl<'a> Device<'a> for Gradients {
fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &Paint<'a>, _: &PathDrawMode) {
if let Paint::Pattern(pattern) = paint
&& let Pattern::Shading(s) = pattern.as_ref()
{
let bbox = (transform * path.clone()).bounding_box();
let native = s.encode().as_svg_gradient(bbox, 0.01);
self.0.push(native.map(|g| g.stops.iter().map(|stop| stop.offset).collect()));
}
}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'a>>, _: 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) {}
}
// A 40 × 20 pt page painting `/Sh0 sh`, with optional extra objects from 6 0 obj on.
let pdf = |shading: &str, extra: &str| {
let content = "/Sh0 sh";
format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 20] /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj {shading} endobj
{extra}trailer << /Root 1 0 R >>
%%EOF",
content.len()
)
.into_bytes()
};
let gradients = |bytes: &[u8]| {
let parsed = Pdf::new(Arc::new(bytes.to_vec())).expect("parses");
let page = &parsed.pages()[0];
let cache = InterpreterCache::new();
let initial = page.initial_transform(true).to_kurbo();
let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 40.0, 20.0), &cache, page.xref(), InterpreterSettings::default());
let mut device = Gradients::default();
interpret_page(page, &mut ctx, &mut device);
device.0
};
let render = |bytes: Vec<u8>| {
let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
assert!(p.error.is_none(), "{:?}", p.error);
p
};
let grey = |p: &RenderedPage, x: u32| i32::from(p.rgba[((10 * p.width + x) * 4) as usize]);
let ramp = "/FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1";
// A function-based shading (always sampled) that draws the same picture is the reference;
// its /Range clamps to 0..1.
let sampled = |code: &str| {
let extra =
format!("6 0 obj << /FunctionType 4 /Domain [0 40 0 20] /Range [0 1] /Length {} >> stream\n{code}\nendstream endobj\n", code.len());
pdf("<< /ShadingType 1 /ColorSpace /DeviceGray /Domain [0 40 0 20] /Function 6 0 R >>", &extra)
};
// A steep extended axial ramp, x 10 to 14: one stop at each end, drawn at pixel centres
// (a half-pixel shift would move each pixel 32 levels) and padded on both sides.
let axial = pdf(&format!("<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [10 0 14 0] /Extend [true true] /Function << {ramp} >> >>"), "");
assert_eq!(gradients(&axial), vec![Some(vec![0.0, 1.0])], "an extended axial shading is a native gradient");
let native = render(axial);
let reference = render(sampled("{ pop 10 sub 4 div }"));
for (x, expected) in [(2, 0), (9, 0), (10, 32), (11, 96), (12, 159), (13, 223), (14, 255), (30, 255)] {
assert!((grey(&native, x) - expected).abs() <= 4, "axial, x {x}: {} instead of {expected}", grey(&native, x));
assert!(
(grey(&native, x) - grey(&reference, x)).abs() <= 4,
"axial, x {x}: native {} vs sampled {}",
grey(&native, x),
grey(&reference, x)
);
}
// An extended radial shading matches the sampled one everywhere.
let radial =
pdf(&format!("<< /ShadingType 3 /ColorSpace /DeviceGray /Coords [20 10 0 20 10 8] /Extend [true true] /Function << {ramp} >> >>"), "");
assert!(matches!(gradients(&radial).as_slice(), [Some(_)]), "an extended radial shading is a native gradient");
let native = render(radial);
let reference = render(sampled("{ 10 sub dup mul exch 20 sub dup mul add sqrt 8 div }"));
let worst = native.rgba.iter().zip(reference.rgba.iter()).map(|(a, b)| a.abs_diff(*b)).max();
assert!(worst.is_some_and(|d| d <= 4), "radial: native and sampled differ by {worst:?} levels");
// A stitched function peaking at a quarter of the way gets a stop exactly at its bound.
let stitched = pdf(
"<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 40 0] /Extend [true true] /Function << /FunctionType 3 /Domain [0 1] /Bounds [0.25] /Encode [0 1 0 1] /Functions [<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>] >> >>",
"",
);
let stops = gradients(&stitched);
assert!(matches!(stops.as_slice(), [Some(offsets)] if offsets.contains(&0.25)), "a stop at the stitching bound: {stops:?}");
let native = render(stitched);
for (x, expected) in [(0, 13), (9, 242), (10, 250), (39, 4)] {
assert!((grey(&native, x) - expected).abs() <= 4, "stitched, x {x}: {} instead of {expected}", grey(&native, x));
}
// Without /Extend the area beyond the axis keeps the background, which a native gradient
// can't leave out: still sampled. Function-based shadings are always sampled.
let unextended = pdf(&format!("<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [10 0 14 0] /Function << {ramp} >> >>"), "");
assert_eq!(gradients(&unextended), vec![None], "an axial shading without /Extend that covers more than its axis");
assert_eq!(gradients(&sampled("{ pop pop 0.5 }")), vec![None], "a function-based shading");
// The report's case: a luminosity soft mask whose group is one extended axial `sh` over
// the page. Red through a mask fading in from x 0 to 40 over white: 255, 255 − t, 255 − t.
let content = "/M gs 1 0 0 rg 0 0 40 20 re f";
let masked = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 20] /Contents 4 0 R /Resources << /ExtGState << /M 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /ExtGState /SMask << /Type /Mask /S /Luminosity /G 6 0 R >> >> endobj
6 0 obj << /Type /XObject /Subtype /Form /BBox [-32768 -32768 32767 32767] /Group << /S /Transparency /CS /DeviceGray >> /Resources << /Shading << /Sh0 << /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 40 0] /Extend [true true] /Function << {ramp} >> >> >> >> /Length 7 >> stream
/Sh0 sh
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
let p = render(masked.into_bytes());
for x in [2u32, 20, 37] {
let px = &p.rgba[((10 * p.width + x) * 4) as usize..][..4];
let faded = 255 - (255.0 * (f64::from(x) + 0.5) / 40.0).round() as i32;
assert!(px[0] == 255 && (i32::from(px[1]) - faded).abs() <= 4 && px[1] == px[2], "masked, x {x}: {px:?}, green and blue {faded}");
}
}
/// An alpha soft mask whose transparency group has no /CS (as Chrome writes gradient text)
/// must still mask. Regression test for the vendored hayro-interpret patch.
#[test]
+4 -3
View File
@@ -1,6 +1,6 @@
# Where PdfCraft falls short of Acrobat Pro
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (alpha markers removed after the gate was re-judged: no gap blocks alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** patch (gap 2: hayro patch counts after the native-gradient backport) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
Every known shortfall, one entry each, ranked by what it costs users. This is the work list: agents
choose from the top unless the owner says otherwise, and prefer these over new P2/P3 checklist
@@ -66,8 +66,8 @@ update the numbers.
- **Missing:** our own renderer (M2: `model` crate, font engine, DisplayList devices, ADR-0004), or
an owner decision to adopt another engine; and any measurement of rendering against Acrobat.
- **Evidence:** pages are drawn by vendored `hayro` 0.7 with 22 local patches to `hayro-interpret`,
9 to `hayro`, 3 to `hayro-jbig2` and 13 to `hayro-syntax` (`vendor/README.md`); `lopdf` still
- **Evidence:** pages are drawn by vendored `hayro` 0.7 with 23 local patches to `hayro-interpret`,
10 to `hayro`, 3 to `hayro-jbig2` and 13 to `hayro-syntax` (`vendor/README.md`); `lopdf` still
backs document inspection. All 7 rendering P0 features are `partial`. User reports: TikZ pattern
fills drawn wrong ([#794](https://github.com/storytold/pdfcraft/issues/794)), blurry text at
1080p/1440p ([#730](https://github.com/storytold/pdfcraft/issues/730)), fixed zoom sizes differ
@@ -302,6 +302,7 @@ update the numbers.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | patch | Gap 2: 23 hayro-interpret and 10 hayro patches after axial and radial shadings became native gradients (backport of hayro 0.8) |
| 2026-10-10 | minor | Removed the alpha markers: with the cross-app gate rule, gaps 1 and 8 are partial sub-cases, not blockers; stage alpha |
| 2026-10-10 | minor | Marked the alpha blockers (gap 1 and the Office part of gap 8) after the core-workflow gate put the stage at pre-alpha |
| 2026-10-10 | major | Created. Ranked 24 gaps from the 2026-10-10 re-measure, the open GitHub issues and the former ROADMAP.md §Where we're lacking and where we're going (renderer, hardening, fidelity, editing, Pro workflows, 1.0 polish), which this file replaces |
+2 -2
View File
File diff suppressed because one or more lines are too long
+11
View File
@@ -20,6 +20,8 @@ use std::sync::Arc;
/// The input/output type of functions.
pub(crate) type Values = SmallVec<[f32; 4]>;
/// PdfCraft patch (backport of hayro 0.8): interior breakpoints of stitching functions.
pub(crate) type StitchingBounds = SmallVec<[f32; 3]>;
type TupleVec = SmallVec<[(f32, f32); 4]>;
// PdfCraft patch: PDF functions are untrusted object graphs. A normal color/transfer function
@@ -100,6 +102,15 @@ impl Function {
FunctionType::Type4(t4) => Some(t4.eval(input)?),
}
}
/// PdfCraft patch (backport of hayro 0.8): inputs where a stitching function switches
/// sub-function, so a gradient approximation can place a stop exactly there.
pub(crate) fn stitching_bounds(&self) -> StitchingBounds {
match self.0.as_ref() {
FunctionType::Type3(t3) => t3.stitching_bounds(),
_ => SmallVec::new(),
}
}
}
#[derive(Debug, Clone)]
+46 -1
View File
@@ -1,4 +1,4 @@
use crate::function::{Clamper, Function, TupleVec, Values, interpolate};
use crate::function::{Clamper, Function, StitchingBounds, TupleVec, Values, interpolate};
use hayro_syntax::object::Array;
use hayro_syntax::object::Dict;
use hayro_syntax::object::Object;
@@ -83,6 +83,51 @@ impl Type3 {
Some(evaluated)
}
// PdfCraft patch (backport of hayro 0.8).
pub(crate) fn stitching_bounds(&self) -> StitchingBounds {
let mut stitching_bounds = StitchingBounds::new();
if self.bounds.len() > 2 {
stitching_bounds.extend_from_slice(&self.bounds[1..self.bounds.len() - 1]);
}
for (index, function) in self.functions.iter().enumerate() {
let Some(bounds_i_minus_1) = self.bounds.get(index).copied() else {
continue;
};
let Some(bounds_i) = self.bounds.get(index + 1).copied() else {
continue;
};
let Some((encode_min, encode_max)) = self.encode.get(index).copied() else {
continue;
};
if (encode_max - encode_min).abs() <= f32::EPSILON {
continue;
}
for child_bound in function.stitching_bounds() {
let bound = interpolate(
child_bound,
encode_min,
encode_max,
bounds_i_minus_1,
bounds_i,
);
let min_bound = bounds_i_minus_1.min(bounds_i);
let max_bound = bounds_i_minus_1.max(bounds_i);
if bound > min_bound && bound < max_bound {
stitching_bounds.push(bound);
}
}
}
stitching_bounds.sort_by(f32::total_cmp);
stitching_bounds.dedup_by(|a, b| (*a - *b).abs() <= f32::EPSILON);
stitching_bounds
}
}
fn find_interval(bounds: &[f32], x: f32) -> Option<usize> {
+361
View File
@@ -0,0 +1,361 @@
//! SVG-like gradient conversion for encoded shadings.
//!
//! PDF shadings are very expensive to evaluate. Because of this, at least for
//! radial-axial shadings it's useful if we can instead approximate them by
//! SVG-like sRGB gradients.
//!
//! PdfCraft patch: backported from hayro-interpret 0.8.0 (Apache-2.0 OR MIT), adapted to this
//! version's `EncodedShadingType::RadialAxial` variant. Remove when the vendored copy moves to
//! 0.8. A full-page axial soft mask was sampled pixel by pixel (InDesign fades: 15 s per page).
use crate::color::AlphaColor;
use crate::encode::{EncodedShadingPattern, EncodedShadingType, RadialAxialParams};
use crate::function::StitchingBounds;
use crate::shading::ShadingFunction;
use kurbo::{Affine, Circle, Point, Rect, Shape};
use smallvec::smallvec;
const MAX_GRADIENT_SUBDIVISION_DEPTH: u8 = 10;
const RADIAL_EPSILON: f64 = 1.0e-6;
/// An SVG-like gradient.
pub struct SvgGradient {
/// The kind of the gradient.
pub kind: SvgGradientKind,
/// The transform from encoded shading coordinates into the renderer's coordinate space.
pub transform: Affine,
/// The stops of the gradient in the sRGB color space.
pub stops: Vec<SvgGradientStop>,
}
/// The kind of the SVG gradient.
pub enum SvgGradientKind {
/// Linear gradient.
Linear {
/// The normalized start point of the gradient line.
start: Point,
/// The normalized end point of the gradient line.
end: Point,
},
/// Radial gradient.
Radial {
/// The center point of the start circle.
start_center: Point,
/// The radius of the start circle.
start_radius: f32,
/// The center point of the end circle.
end_center: Point,
/// The radius of the end circle.
end_radius: f32,
},
}
/// A color stop for an SVG-like gradient.
pub struct SvgGradientStop {
/// Normalized offset.
pub offset: f32,
/// RGBA color components in sRGB.
pub color: [f32; 4],
}
impl EncodedShadingPattern {
/// Convert an axial/radial PDF shading into an SVG-like gradient approximation, or `None`
/// for other shading types and for cases a native gradient cannot represent exactly.
pub fn as_svg_gradient(&self, path_bbox: Rect, tolerance: f32) -> Option<SvgGradient> {
let EncodedShadingType::RadialAxial {
function,
params,
domain,
extend,
} = &self.shading_type
else {
return None;
};
EncodedRadialAxialShading {
function,
params,
domain: *domain,
extend: *extend,
}
.as_svg_gradient(self, path_bbox, tolerance)
}
}
struct EncodedRadialAxialShading<'a> {
function: &'a ShadingFunction,
params: &'a RadialAxialParams,
domain: [f32; 2],
extend: [bool; 2],
}
impl EncodedRadialAxialShading<'_> {
fn as_svg_gradient(
&self,
pattern: &EncodedShadingPattern,
path_bbox: Rect,
tolerance: f32,
) -> Option<SvgGradient> {
// A couple of cases cannot be losslessly represented by an SVG gradient.
match self.params {
RadialAxialParams::Axial => {
if !self.axial_extend_covers_bbox(pattern, path_bbox) {
return None;
}
}
RadialAxialParams::Radial { .. } => {
if !self.radial_extend_covers_bbox(pattern, path_bbox)
|| pattern.background_color.components()[3] > 0.0
{
return None;
}
}
}
Some(SvgGradient {
kind: self.native_gradient_kind(),
transform: pattern.base_transform.inverse(),
stops: approximate_gradient_stops(
|t| self.sample_t(pattern, t),
&self.normalized_stitching_bounds(),
tolerance,
),
})
}
fn native_gradient_kind(&self) -> SvgGradientKind {
match self.params {
RadialAxialParams::Axial => SvgGradientKind::Linear {
start: Point::ZERO,
end: Point::new(1.0, 0.0),
},
RadialAxialParams::Radial { p1, r } => SvgGradientKind::Radial {
start_center: Point::ZERO,
start_radius: r.x as f32,
end_center: *p1,
end_radius: r.y as f32,
},
}
}
fn sample_t(&self, pattern: &EncodedShadingPattern, t: f32) -> [f32; 4] {
let (t0, t1) = (self.domain[0], self.domain[1]);
let t = t0 + (t1 - t0) * t;
let Some(out) = self.function.eval(&smallvec![t]) else {
return [0.0, 0.0, 0.0, 0.0];
};
let mut components = pattern.color_space.to_rgba(&out, 1.0, false).components();
components[3] *= pattern.opacity;
if let Some(tf) = &pattern.transfer_function {
return tf.apply(&AlphaColor::new(components)).components();
}
components
}
fn axial_extend_covers_bbox(&self, pattern: &EncodedShadingPattern, path_bbox: Rect) -> bool {
let path_bbox = pattern.base_transform.transform_rect_bbox(path_bbox);
let min_x = path_bbox.min_x();
let max_x = path_bbox.max_x();
(self.extend[0] || min_x >= 0.0) && (self.extend[1] || max_x <= 1.0)
}
fn radial_extend_covers_bbox(&self, pattern: &EncodedShadingPattern, path_bbox: Rect) -> bool {
let RadialAxialParams::Radial { p1, r } = *self.params else {
return false;
};
let path_bbox = pattern.base_transform.transform_rect_bbox(path_bbox);
if p1.x.abs() <= RADIAL_EPSILON && p1.y.abs() <= RADIAL_EPSILON {
return self.same_center_radial_extend_covers_bbox(path_bbox, r.x, r.y);
}
let start_extend_ok = self.extend[0] || r.x.abs() <= RADIAL_EPSILON;
let end_extend_ok = self.extend[1] || circle_covers_bbox(Circle::new(p1, r.y), path_bbox);
start_extend_ok && end_extend_ok
}
fn same_center_radial_extend_covers_bbox(&self, path_bbox: Rect, r0: f64, r1: f64) -> bool {
let mut inner_circle = Circle::new(Point::ZERO, r0);
let mut outer_circle = Circle::new(Point::ZERO, r1);
let mut inner_extend = self.extend[0];
let mut outer_extend = self.extend[1];
if inner_circle.radius > outer_circle.radius {
std::mem::swap(&mut inner_circle, &mut outer_circle);
std::mem::swap(&mut inner_extend, &mut outer_extend);
}
let inner_extend_ok = inner_extend
|| inner_circle.radius <= RADIAL_EPSILON
|| circle_outside_bbox(inner_circle, path_bbox);
let outer_extend_ok = outer_extend || circle_covers_bbox(outer_circle, path_bbox);
inner_extend_ok && outer_extend_ok
}
fn normalized_stitching_bounds(&self) -> StitchingBounds {
let [t0, t1] = self.domain;
let dt = t1 - t0;
if dt.abs() <= f32::EPSILON {
return StitchingBounds::new();
}
let mut bounds = self
.function
.stitching_bounds()
.into_iter()
.map(|bound| (bound - t0) / dt)
.filter(|bound| *bound > 0.0 && *bound < 1.0)
.collect::<StitchingBounds>();
if dt < 0.0 {
bounds.reverse();
}
bounds
}
}
fn circle_covers_bbox(circle: Circle, bbox: Rect) -> bool {
if circle.radius < 0.0 {
return false;
}
let radius_squared = circle.radius * circle.radius;
[
Point::new(bbox.x0, bbox.y0),
Point::new(bbox.x1, bbox.y0),
Point::new(bbox.x0, bbox.y1),
Point::new(bbox.x1, bbox.y1),
]
.into_iter()
.all(|point| {
let delta = point - circle.center;
delta.hypot2() <= radius_squared
})
}
fn circle_outside_bbox(circle: Circle, bbox: Rect) -> bool {
if circle.radius <= 0.0 {
return true;
}
let closest = Point::new(
circle.center.x.clamp(bbox.x0, bbox.x1),
circle.center.y.clamp(bbox.y0, bbox.y1),
);
!circle.contains(closest)
}
fn approximate_gradient_stops(
mut sample: impl FnMut(f32) -> [f32; 4],
breakpoints: &[f32],
tolerance: f32,
) -> Vec<SvgGradientStop> {
let mut interval_bounds = Vec::with_capacity(breakpoints.len() + 2);
interval_bounds.push(0.0);
interval_bounds.extend(breakpoints.iter().copied());
interval_bounds.push(1.0);
let mut stops = Vec::new();
for interval in interval_bounds.windows(2) {
let start_offset = interval[0];
let end_offset = interval[1];
let start_color = sample(start_offset);
stops.push(SvgGradientStop {
offset: start_offset,
color: start_color,
});
let end_color = sample(end_offset);
approximate_gradient_interval(
&mut sample,
&mut stops,
GradientSample {
offset: start_offset,
color: start_color,
},
GradientSample {
offset: end_offset,
color: end_color,
},
tolerance.max(0.0),
0,
);
}
stops
}
#[derive(Clone, Copy)]
struct GradientSample {
offset: f32,
color: [f32; 4],
}
fn approximate_gradient_interval(
sample: &mut impl FnMut(f32) -> [f32; 4],
stops: &mut Vec<SvgGradientStop>,
start: GradientSample,
end: GradientSample,
tolerance: f32,
depth: u8,
) {
let offset_delta = end.offset - start.offset;
let quarter_offset = start.offset + offset_delta * 0.25;
let mid_offset = start.offset + offset_delta * 0.5;
let three_quarter_offset = start.offset + offset_delta * 0.75;
let quarter_color = sample(quarter_offset);
let mid_color = sample(mid_offset);
let three_quarter_color = sample(three_quarter_offset);
if color_error(quarter_color, lerp_color(start.color, end.color, 0.25)) <= tolerance
&& color_error(mid_color, lerp_color(start.color, end.color, 0.5)) <= tolerance
&& color_error(
three_quarter_color,
lerp_color(start.color, end.color, 0.75),
) <= tolerance
|| depth == MAX_GRADIENT_SUBDIVISION_DEPTH
{
stops.push(SvgGradientStop {
offset: end.offset,
color: end.color,
});
return;
}
let mid = GradientSample {
offset: mid_offset,
color: mid_color,
};
approximate_gradient_interval(sample, stops, start, mid, tolerance, depth + 1);
approximate_gradient_interval(sample, stops, mid, end, tolerance, depth + 1);
}
fn lerp_color(c0: [f32; 4], c1: [f32; 4], t: f32) -> [f32; 4] {
[
c0[0] + (c1[0] - c0[0]) * t,
c0[1] + (c1[1] - c0[1]) * t,
c0[2] + (c1[2] - c0[2]) * t,
c0[3] + (c1[3] - c0[3]) * t,
]
}
fn color_error(c0: [f32; 4], c1: [f32; 4]) -> f32 {
c0.iter()
.zip(c1)
.map(|(a, b)| (a - b).abs())
.fold(0.0, f32::max)
}
+1
View File
@@ -47,6 +47,7 @@ pub use x_object::image_size_ok;
pub mod color;
pub mod encode;
pub mod font;
pub mod gradient;
pub mod pattern;
pub mod shading;
pub mod util;
+19 -1
View File
@@ -5,7 +5,7 @@
use crate::CacheKey;
use crate::cache::Cache;
use crate::color::{ColorComponents, ColorSpace};
use crate::function::{Function, Values, interpolate};
use crate::function::{Function, StitchingBounds, Values, interpolate};
use crate::util::{Float32Ext, PointExt, RectExt};
use hayro_syntax::bit_reader::BitReader;
use hayro_syntax::object::Array;
@@ -48,6 +48,24 @@ impl ShadingFunction {
}
}
}
/// PdfCraft patch (backport of hayro 0.8): stitching breakpoints of all component functions.
pub(crate) fn stitching_bounds(&self) -> StitchingBounds {
let mut bounds = StitchingBounds::new();
match self {
Self::Single(function) => bounds.extend(function.stitching_bounds()),
Self::Multiple(functions) => {
for function in functions {
bounds.extend(function.stitching_bounds());
}
}
}
bounds.sort_by(f32::total_cmp);
bounds.dedup_by(|a, b| (*a - *b).abs() <= f32::EPSILON);
bounds
}
}
/// A type of shading.
+47 -1
View File
@@ -14,9 +14,10 @@ use std::collections::HashMap;
use std::mem::size_of;
use std::rc::Rc;
use std::sync::Arc;
use vello_cpu::color::DynamicColor;
use vello_cpu::color::palette::css::BLACK;
use vello_cpu::color::{AlphaColor, PremulRgba8, Srgb};
use vello_cpu::peniko::{Compose, Fill, ImageQuality, ImageSampler, Mix};
use vello_cpu::peniko::{ColorStop, Compose, Fill, Gradient, ImageQuality, ImageSampler, Mix};
use vello_cpu::{
Image, ImageSource, Mask, PaintType, Pixmap, RenderContext, RenderSettings, peniko,
};
@@ -861,6 +862,20 @@ impl Renderer {
// PdfCraft patch: mesh shadings are sampled only inside `bbox` (and a
// 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 (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
// evaluates gradients at pixel centres, like the texture below.
if let Some(native) =
encoded.as_svg_gradient(bbox, NATIVE_GRADIENT_TOLERANCE)
{
self.ctx
.set_paint_transform(path_transform.inverse() * native.transform);
self.ctx.set_paint(native_gradient(native));
return clip_path;
}
let (image, width, height, transform, may_have_transparency) =
render_shading_texture(bbox, &encoded);
let may_have_transparency =
@@ -1397,6 +1412,37 @@ impl<'a> Device<'a> for Renderer {
}
}
/// PdfCraft patch (backport of hayro 0.8): largest per-channel error allowed when approximating
/// a shading function by gradient stops.
const NATIVE_GRADIENT_TOLERANCE: f32 = 0.01;
/// PdfCraft patch (backport of hayro 0.8): a `vello_cpu` gradient for an approximated shading.
fn native_gradient(native: hayro_interpret::gradient::SvgGradient) -> PaintType {
use hayro_interpret::gradient::SvgGradientKind;
let stops = native
.stops
.iter()
.map(|stop| ColorStop {
offset: stop.offset,
color: DynamicColor::from_alpha_color(AlphaColor::<Srgb>::new(stop.color)),
})
.collect::<Vec<_>>();
match native.kind {
SvgGradientKind::Linear { start, end } => Gradient::new_linear(start, end),
SvgGradientKind::Radial {
start_center,
start_radius,
end_center,
end_radius,
} => Gradient::new_two_point_radial(start_center, start_radius, end_center, end_radius),
}
.with_extend(peniko::Extend::Pad)
.with_stops(stops.as_slice())
.into()
}
// TODO: Deduplicate with hayro-svg?
fn render_shading_texture(
path_bbox: Rect,