mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-01 05:58:12 -06:00
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:
co-authored by
Claude Opus 5.5
parent
f5cfb33773
commit
7f1b632519
@@ -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
@@ -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 |
|
||||
|
||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
+11
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
Vendored
+1
@@ -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
@@ -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.
|
||||
|
||||
Vendored
+47
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user