Files
pdfcraft/vendor/hayro-interpret/src/encode.rs
PiohrunandClaude Opus 5.5 f65f733041 fix(render): faster sampled functions, image decodes across zoom levels and patch meshes (vendored hayro-interpret patches 31–33) (#2436)
* fix(render): sampled functions index a flat sample array (vendored hayro-interpret patch 31)

A Type 0 sampled function kept its samples in a HashMap keyed by each point of
its /Size grid (two SmallVecs per key, SipHash), and the renderer builds a
shading's functions again every time it is painted, so every paint hashed one
insert per sample. A 2,097,152-sample ramp painted 30 times took 38 s; the
pdf.js corpus's bug1721218 (InDesign) spent most of a 1.5 s page on it.

The samples now stay in stream order in one Vec, and a grid point is found by
strides (first input fastest). The interpolation keeps the same recursion and
arithmetic, so results are bit for bit the same, and a point the stream ended
before still gives no value. The repeated-paint page draws in well under a
second. The maximum sample value no longer computes 2^32 in a u32 (a panic with
overflow checks on at 32 bits per sample).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ

* fix(render): one decode of an image serves every size it is drawn at (vendored hayro-interpret patch 32)

The decoded-image cache (patch 24) keyed each decode by the size the image is
drawn at, but only the JPX filter decodes a reduced resolution for a size:
every other filter decodes the whole image. So a JPEG or Flate scan missed the
cache at every zoom step, was decoded again, and was kept once per zoom level.

The key now carries the target size only when JPX decodes the image, its
/SMask or its /Mask (read from /Filter or /F, erring towards keeping the
target). `InterpreterCache::decoded_image_decodes` counts the cache's decodes;
the new test draws a Flate image at five sizes and sees one decode, and a
copy whose soft mask is JPX-encoded still decodes per size. Both assertions
fail with the old keying.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ

* fix(render): patch meshes copy their vertices' colours less (vendored hayro-interpret patch 33)

A Coons or tensor-product patch is drawn as 722 triangles over a 20 x 20 grid
of points, and every vertex's colours (a SmallVec) were produced or copied up
to five times: each grid cell interpolated its four corners itself (1,444
interpolations a patch for 400 points), cloned all six vertices of its two
triangles, and the mesh sampler cloned each vertex again to move the triangle
into device space. Profiling page 8 of the annual report in #1584 put a
quarter of its time in those clones.

Each grid point's colours are now interpolated once, a cell's second triangle
takes the vertices its first copied, and the sampler moves a patch's freshly
made triangles instead of copying them (`TriangleSampler::sample_owned`; a
free-form triangle mesh, which the shading keeps, is still copied). The same
inputs give the same values: rendering the first three pages of every pdf.js
corpus file is byte for byte the same. Release build, Linux: report page 8
773 -> 696 ms, personwithdog.pdf 149 -> 129 ms, issue13520.pdf 104 -> 87 ms.

The new test pins the colours near each corner, at the centre and between them
for a Coons and a tensor-product patch (taken before the change); swapping two
vertices' grid points makes it fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ

* docs: roadmap entries for faster sampled functions, image decodes and patch meshes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:50:29 -06:00

550 lines
17 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Encoding shading patterns for easy sampling.
use crate::color::{AlphaColor, ColorComponents, ColorSpace};
use crate::interpret::state::ActiveTransferFunction;
use crate::pattern::ShadingPattern;
use crate::shading::{ShadingFunction, ShadingType, Triangle, TriangleVertex};
use kurbo::{Affine, Point};
use rustc_hash::FxHashMap;
use smallvec::{ToSmallVec, smallvec};
/// A shading pattern that was encoded so it can be sampled.
#[derive(Debug)]
pub struct EncodedShadingPattern {
/// The base transform of the shading pattern.
pub base_transform: Affine,
pub(crate) color_space: ColorSpace,
pub(crate) background_color: AlphaColor,
pub(crate) shading_type: EncodedShadingType,
pub(crate) opacity: f32,
pub(crate) transfer_function: Option<ActiveTransferFunction>,
}
impl EncodedShadingPattern {
/// Sample the shading at the given position.
#[inline]
pub fn sample(&self, pos: Point) -> [f32; 4] {
self.shading_type
.eval(pos, self.background_color, &self.color_space)
.map(|v| {
let mut components = v.components();
components[3] *= self.opacity;
if let Some(tf) = &self.transfer_function {
return tf.apply(&AlphaColor::new(components)).components();
}
components
})
.unwrap_or([0.0, 0.0, 0.0, 0.0])
}
}
impl ShadingPattern {
/// Encode the shading pattern.
pub fn encode(&self) -> EncodedShadingPattern {
self.encode_within(None)
}
/// PdfCraft patch: encode the shading pattern for drawing inside `area` (device space) only.
/// 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;
let shading_type = match self.shading.shading_type.as_ref() {
ShadingType::FunctionBased {
domain,
matrix,
function,
} => {
base_transform = (self.matrix * *matrix).inverse();
encode_function_shading(domain, function)
}
ShadingType::RadialAxial {
coords,
domain,
function,
extend,
axial,
} => {
let (encoded, initial_transform) =
encode_axial_shading(*coords, *domain, function, *extend, *axial);
base_transform = initial_transform * self.matrix.inverse();
encoded
}
ShadingType::TriangleMesh {
triangles,
function,
} => {
let full_transform = self.matrix;
let mut sampler = TriangleSampler::new(area);
sampler.sample(triangles, full_transform);
let samples = sampler.map;
base_transform = Affine::IDENTITY;
EncodedShadingType::Sampled {
samples,
function: function.clone(),
}
}
ShadingType::CoonsPatchMesh { patches, function } => {
// PdfCraft patch: one patch's triangles at a time (each is 722); collecting all
// of them first held 150 KB per patch before any limit applied.
let full_transform = self.matrix;
let mut sampler = TriangleSampler::new(area);
let mut triangles = vec![];
for patch in patches {
if !mesh_budget_left() {
break;
}
triangles.clear();
patch.to_triangles(&mut triangles);
if !sampler.sample_owned(&mut triangles, full_transform) {
break;
}
}
let samples = sampler.map;
base_transform = Affine::IDENTITY;
EncodedShadingType::Sampled {
samples,
function: function.clone(),
}
}
ShadingType::TensorProductPatchMesh { patches, function } => {
// PdfCraft patch: one patch's triangles at a time, as for Coons patches.
let full_transform = self.matrix;
let mut sampler = TriangleSampler::new(area);
let mut triangles = vec![];
for patch in patches {
if !mesh_budget_left() {
break;
}
triangles.clear();
patch.to_triangles(&mut triangles);
if !sampler.sample_owned(&mut triangles, full_transform) {
break;
}
}
let samples = sampler.map;
base_transform = Affine::IDENTITY;
EncodedShadingType::Sampled {
samples,
function: function.clone(),
}
}
ShadingType::Dummy => {
base_transform = Affine::IDENTITY;
EncodedShadingType::Dummy
}
};
let color_space = self.shading.color_space.clone();
let background_color = self
.shading
.background
.as_ref()
.map(|b| color_space.to_rgba(b, 1.0, false))
.unwrap_or(AlphaColor::TRANSPARENT);
EncodedShadingPattern {
color_space,
background_color,
shading_type,
base_transform,
opacity: self.opacity,
transfer_function: self.transfer_function.clone(),
}
}
}
fn encode_axial_shading(
coords: [f32; 6],
domain: [f32; 2],
function: &ShadingFunction,
extend: [bool; 2],
is_axial: bool,
) -> (EncodedShadingType, Affine) {
let initial_transform;
let params = if is_axial {
let [x_0, y_0, x_1, y_1, _, _] = coords;
initial_transform = ts_from_line_to_line(
Point::new(x_0 as f64, y_0 as f64),
Point::new(x_1 as f64, y_1 as f64),
Point::ZERO,
Point::new(1.0, 0.0),
);
RadialAxialParams::Axial
} else {
let [x_0, y_0, r0, x_1, y_1, r_1] = coords;
initial_transform = Affine::translate((-x_0 as f64, -y_0 as f64));
let new_x1 = x_1 - x_0;
let new_y1 = y_1 - y_0;
let p1 = Point::new(new_x1 as f64, new_y1 as f64);
let r = Point::new(r0 as f64, r_1 as f64);
RadialAxialParams::Radial { p1, r }
};
(
EncodedShadingType::RadialAxial {
function: function.clone(),
params,
domain,
extend,
},
initial_transform,
)
}
/// PdfCraft patch: pixel visits one mesh shading may make, per pixel of the area it is drawn in
/// and per triangle. Sampling walks each triangle's bounding box (rounded out to whole pixels),
/// so triangles far off the page (a fuzzed patch decoded onto ±40000) asked for 65536 × 65536
/// samples, and many triangles covering the same pixels repeat the work. A triangle's box is
/// about twice its area, and a sub-pixel triangle's box is at most 2 × 2 pixels.
const MESH_VISITS_PER_PIXEL: u64 = 8;
const MESH_VISITS_PER_TRIANGLE: u64 = 4;
/// PdfCraft patch: the visits allowed when no area is given (the public `encode`).
const MESH_VISITS_WITHOUT_AREA: u64 = 1 << 24;
/// PdfCraft patch: mesh triangles one page may sample in all, across every shading painted on
/// it. A shading is triangulated again each time it is painted (2^16 patches are 47 million
/// triangles, several seconds), so painting one a thousand times took hours. A 200 × 200
/// gradient mesh is 29 million triangles.
const MAX_PAGE_MESH_TRIANGLES: u64 = 1 << 25;
std::thread_local! {
static MESH_TRIANGLES_LEFT: core::cell::Cell<u64> =
const { core::cell::Cell::new(MAX_PAGE_MESH_TRIANGLES) };
}
/// PdfCraft patch: a page starts with a fresh mesh triangle budget (called from
/// `Context::new`).
pub(crate) fn reset_mesh_budget() {
MESH_TRIANGLES_LEFT.with(|n| n.set(MAX_PAGE_MESH_TRIANGLES));
}
/// PdfCraft patch: `false` once this page's mesh triangle budget is spent.
fn mesh_budget_left() -> bool {
MESH_TRIANGLES_LEFT.with(|n| n.get() > 0)
}
/// PdfCraft patch: samples mesh triangles into a map with one entry per pixel, only inside
/// `area` (device space), within the visit budget above.
struct TriangleSampler {
map: FxHashMap<(u16, u16), ColorComponents>,
area: kurbo::Rect,
visits_left: u64,
}
impl TriangleSampler {
fn new(area: Option<kurbo::Rect>) -> Self {
let visits_left = match area {
Some(a) => (a.width().max(0.0).ceil() as u64)
.saturating_mul(a.height().max(0.0).ceil() as u64)
.saturating_mul(MESH_VISITS_PER_PIXEL),
None => MESH_VISITS_WITHOUT_AREA,
};
Self {
map: FxHashMap::default(),
area: area.unwrap_or(kurbo::Rect::new(0.0, 0.0, 65536.0, 65536.0)),
visits_left,
}
}
/// Sample `triangles`; `false` once a budget is spent (the rest is left unsampled).
fn sample(&mut self, triangles: &[Triangle], transform: Affine) -> bool {
if !self.take_page_budget(triangles.len()) {
return false;
}
triangles.iter().all(|t| {
self.sample_one(transformed(
t.p0.clone(),
t.p1.clone(),
t.p2.clone(),
transform,
))
})
}
/// PdfCraft patch (33): [`Self::sample`] for triangles made for this sampling alone (a
/// patch's), moving their vertices instead of copying each one's colours (`triangles` is
/// left empty).
fn sample_owned(&mut self, triangles: &mut Vec<Triangle>, transform: Affine) -> bool {
if !self.take_page_budget(triangles.len()) {
return false;
}
triangles
.drain(..)
.all(|t| self.sample_one(transformed(t.p0, t.p1, t.p2, transform)))
}
/// Take `count` triangles from the page's budget; `false` (and none left) when it is short.
fn take_page_budget(&mut self, count: usize) -> bool {
let page_left = MESH_TRIANGLES_LEFT.with(core::cell::Cell::get);
if page_left < count as u64 {
MESH_TRIANGLES_LEFT.with(|n| n.set(0));
warn!("page mesh triangle budget exceeded; the rest of the shading is not drawn");
return false;
}
MESH_TRIANGLES_LEFT.with(|n| n.set(page_left - count as u64));
true
}
/// Sample one triangle in device space; `false` once the visit budget is spent.
fn sample_one(&mut self, t: Triangle) -> bool {
// Only the part of the triangle inside `area`; only such triangles add visits.
let bbox = t.bounding_box().intersect(self.area);
if bbox.width() <= 0.0 || bbox.height() <= 0.0 {
return true;
}
self.visits_left = self.visits_left.saturating_add(MESH_VISITS_PER_TRIANGLE);
for y in (bbox.y0.floor() as u16)..(bbox.y1.ceil() as u16) {
for x in (bbox.x0.floor() as u16)..(bbox.x1.ceil() as u16) {
if self.visits_left == 0 {
warn!("mesh shading visit budget exceeded; the rest is not drawn");
return false;
}
self.visits_left -= 1;
let point = Point::new(x as f64, y as f64);
if t.contains_point(point) {
self.map.insert((x, y), t.interpolate(point));
}
}
}
true
}
}
/// The triangle `v0`, `v1`, `v2` with its points mapped through `transform` (its colours as
/// they are).
fn transformed(
mut v0: TriangleVertex,
mut v1: TriangleVertex,
mut v2: TriangleVertex,
transform: Affine,
) -> Triangle {
v0.point = transform * v0.point;
v1.point = transform * v1.point;
v2.point = transform * v2.point;
Triangle::new(v0, v1, v2)
}
fn encode_function_shading(domain: &[f32; 4], function: &ShadingFunction) -> EncodedShadingType {
let domain = kurbo::Rect::new(
domain[0] as f64,
domain[2] as f64,
domain[1] as f64,
domain[3] as f64,
);
EncodedShadingType::FunctionBased {
domain,
function: function.clone(),
}
}
#[derive(Debug)]
pub(crate) enum RadialAxialParams {
Axial,
Radial { p1: Point, r: Point },
}
#[derive(Debug)]
pub(crate) enum EncodedShadingType {
FunctionBased {
domain: kurbo::Rect,
function: ShadingFunction,
},
RadialAxial {
function: ShadingFunction,
params: RadialAxialParams,
domain: [f32; 2],
extend: [bool; 2],
},
Sampled {
samples: FxHashMap<(u16, u16), ColorComponents>,
function: Option<ShadingFunction>,
},
Dummy,
}
impl EncodedShadingType {
pub(crate) fn eval(
&self,
pos: Point,
bg_color: AlphaColor,
color_space: &ColorSpace,
) -> Option<AlphaColor> {
match self {
Self::FunctionBased { domain, function } => {
if !domain.contains(pos) {
Some(bg_color)
} else {
let out = function.eval(&smallvec![pos.x as f32, pos.y as f32])?;
// TODO: Clamp out-of-range values.
Some(color_space.to_rgba(&out, 1.0, false))
}
}
Self::RadialAxial {
function,
params,
domain,
extend,
} => {
let (t0, t1) = (domain[0], domain[1]);
let mut t = match params {
RadialAxialParams::Axial => pos.x as f32,
RadialAxialParams::Radial { p1, r } => {
radial_pos(&pos, p1, *r, extend[0], extend[1]).unwrap_or(f32::MIN)
}
};
if t == f32::MIN {
return Some(bg_color);
}
if t < 0.0 {
if extend[0] {
t = 0.0;
} else {
return Some(bg_color);
}
} else if t > 1.0 {
if extend[1] {
t = 1.0;
} else {
return Some(bg_color);
}
}
let t = t0 + (t1 - t0) * t;
let val = function.eval(&smallvec![t])?;
Some(color_space.to_rgba(&val, 1.0, false))
}
Self::Sampled { samples, function } => {
let sample_point = (pos.x as u16, pos.y as u16);
if let Some(color) = samples.get(&sample_point) {
if let Some(function) = function {
let val = function.eval(&color.to_smallvec())?;
Some(color_space.to_rgba(&val, 1.0, false))
} else {
Some(color_space.to_rgba(color, 1.0, false))
}
} else {
Some(bg_color)
}
}
Self::Dummy => Some(AlphaColor::TRANSPARENT),
}
}
}
fn ts_from_line_to_line(src1: Point, src2: Point, dst1: Point, dst2: Point) -> Affine {
let unit_to_line1 = unit_to_line(src1, src2);
let line1_to_unit = unit_to_line1.inverse();
let unit_to_line2 = unit_to_line(dst1, dst2);
unit_to_line2 * line1_to_unit
}
fn unit_to_line(p0: Point, p1: Point) -> Affine {
Affine::new([
p1.y - p0.y,
p0.x - p1.x,
p1.x - p0.x,
p1.y - p0.y,
p0.x,
p0.y,
])
}
fn radial_pos(
pos: &Point,
p1: &Point,
r: Point,
min_extend: bool,
max_extend: bool,
) -> Option<f32> {
let r0 = r.x as f32;
let dx = p1.x as f32;
let dy = p1.y as f32;
let dr = r.y as f32 - r0;
let px = pos.x as f32;
let py = pos.y as f32;
let a = dx * dx + dy * dy - dr * dr;
let b = -2.0 * (px * dx + py * dy + r0 * dr);
let c = px * px + py * py - r0 * r0;
let discriminant = b * b - 4.0 * a * c;
// No solution available.
if discriminant < 0.0 {
return None;
}
if a.abs() < 1e-6 {
if b.abs() < 1e-6 {
return None;
}
let t = -c / b;
if (!min_extend && t < 0.0) || (!max_extend && t > 1.0) {
return None;
}
let r_t = r0 + dr * t;
if r_t < 0.0 {
return None;
}
return Some(t);
}
let sqrt_d = discriminant.sqrt();
let t1 = (-b - sqrt_d) / (2.0 * a);
let t2 = (-b + sqrt_d) / (2.0 * a);
let max = t1.max(t2);
let mut take_max = Some(max);
let min = t1.min(t2);
let mut take_min = Some(min);
if (!min_extend && min < 0.0) || r0 + dr * min < 0.0 {
take_min = None;
}
if (!max_extend && max > 1.0) || r0 + dr * max < 0.0 {
take_max = None;
}
match (take_min, take_max) {
(Some(_), Some(max)) => Some(max),
(Some(min), None) => Some(min),
(None, Some(max)) => Some(max),
(None, None) => None,
}
}