mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
Merge pull request #3671 from pat26-dotcom/fix/3621-function-shading
Fix #3621: function-based PDF shadings keep their colours instead of a flat grey
This commit is contained in:
@@ -707,6 +707,32 @@ fn pdf_of(objs: &[Vec<u8>]) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// A file filling thousands of paths with function-based shadings, each a long PostScript
|
||||
/// calculator function of fine stripes, opens in bounded time: each shading is read once, and all
|
||||
/// of them within one budget of function evaluations per import (#3621 review).
|
||||
#[test]
|
||||
fn pdf_many_paths_filled_with_long_function_shadings_open_in_bounded_time() {
|
||||
let shadings = 20;
|
||||
// Five hundred no-op steps, then stripes: red, then white, along x.
|
||||
let program = format!("{{ pop {}dup truncate sub 0.5 le {{ 1 0 0 }} {{ 1 1 1 }} ifelse }}", "1 mul ".repeat(500));
|
||||
let patterns: String = (0..shadings)
|
||||
.map(|i| format!("/P{i} << /PatternType 2 /Shading << /ShadingType 1 /ColorSpace /DeviceRGB /Domain [0 {} 0 1] /Matrix [1 0 0 100 0 0] /Function {} 0 R >> >> ", 200 + i, 5 + i))
|
||||
.collect();
|
||||
let functions: Vec<Vec<u8>> = (0..shadings)
|
||||
.map(|i| {
|
||||
format!("<< /FunctionType 4 /Domain [0 {} 0 1] /Range [0 1 0 1 0 1] /Length {} >>\nstream\n{program}\nendstream", 200 + i, program.len())
|
||||
.into_bytes()
|
||||
})
|
||||
.collect();
|
||||
let content: String = (0..5000).map(|i| format!("/Pattern cs /P{} scn {} {} 2 2 re f\n", i % shadings, i % 100 * 2, i / 100 * 2)).collect();
|
||||
let pdf = handmade_pdf_with(&content, &format!("<< /Pattern << {patterns}>> >>"), "[0 0 200 100]", &functions);
|
||||
let start = std::time::Instant::now();
|
||||
let d = vectorcraft_pdf::import(&pdf).unwrap();
|
||||
let took = start.elapsed();
|
||||
assert!(!d.layers.is_empty());
|
||||
assert!(took < std::time::Duration::from_secs(30), "took {took:?}");
|
||||
}
|
||||
|
||||
/// One content-stream operation with junk operands.
|
||||
fn arb_pdf_op() -> impl Strategy<Value = String> {
|
||||
let op = prop::sample::select(vec![
|
||||
|
||||
@@ -24,7 +24,9 @@ use vectorcraft_geom::{FillRule, PathData};
|
||||
use crate::import_color::{Colors, Native};
|
||||
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover};
|
||||
use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, TextOverprint, Walked, all_on, hides_forms, scan_page, tag_key};
|
||||
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, gradient_shape, mesh_shading, shading_gradient};
|
||||
use crate::import_shading::{
|
||||
FunctionBudget, FunctionPaint, clipped, extend_clip, fold_stop_opacity, function_paint, gradient_shape, mesh_shading, shading_gradient,
|
||||
};
|
||||
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
|
||||
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
|
||||
|
||||
@@ -878,6 +880,10 @@ struct Builder<'p> {
|
||||
/// Pattern swatches made, and per (pattern, stroke) the art signatures read and their names.
|
||||
patterns: Vec<PatternDef>,
|
||||
pattern_keys: HashMap<(u128, bool), PatternReadings>,
|
||||
/// Per function-based shading (and the inks it paints with): what it comes in as, read once.
|
||||
function_paints: HashMap<(u128, Option<Vec<String>>), Paint>,
|
||||
/// What is left of the import's function evaluations for those shadings.
|
||||
function_budget: FunctionBudget,
|
||||
/// Swatch names in use.
|
||||
taken: Vec<String>,
|
||||
/// The page as walked by [`scan_page`], and how far the interpreter is through it.
|
||||
@@ -1049,6 +1055,8 @@ impl<'p> Builder<'p> {
|
||||
masks: HashMap::new(),
|
||||
patterns: vec![],
|
||||
pattern_keys: HashMap::new(),
|
||||
function_paints: HashMap::new(),
|
||||
function_budget: FunctionBudget::new(),
|
||||
taken: vec![],
|
||||
scan: Scan::default(),
|
||||
tag_at: 0,
|
||||
@@ -1433,13 +1441,20 @@ impl<'p> Builder<'p> {
|
||||
(paint, a)
|
||||
}
|
||||
craft_pdf::interpret::Paint::Pattern(pat) => match pat.as_ref() {
|
||||
Pattern::Shading(sp) => match shading_gradient(sp, &mut self.colors, inks) {
|
||||
Some((g, _)) => (Paint::Gradient(Box::new(g)), sp.opacity),
|
||||
None => {
|
||||
self.warn("function-based shadings (and very large meshes) are imported as a flat colour");
|
||||
(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), sp.opacity)
|
||||
Pattern::Shading(sp) => {
|
||||
// The ink names outlive the walk's borrow of `self`.
|
||||
let inks = inks.map(<[String]>::to_vec);
|
||||
match self.function_shading(sp, inks.as_deref()) {
|
||||
Some(paint) => (paint, sp.opacity),
|
||||
None => match shading_gradient(sp, &mut self.colors, inks.as_deref()) {
|
||||
Some((g, _)) => (Paint::Gradient(Box::new(g)), sp.opacity),
|
||||
None => {
|
||||
self.warn("shadings that can't be read as a gradient or gradient mesh (such as very large meshes) are imported as a flat grey");
|
||||
(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), sp.opacity)
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
Pattern::Tiling(t) => match self.tiling(t, stroke) {
|
||||
Some(paint) => (paint, 1.0),
|
||||
None => {
|
||||
@@ -1507,6 +1522,30 @@ impl<'p> Builder<'p> {
|
||||
spec
|
||||
}
|
||||
|
||||
/// Function-based shading `sp` (painting with `inks`) as a paint, read the first time it is
|
||||
/// used and within the import's budget of function evaluations; `None` for other shadings.
|
||||
fn function_shading(&mut self, sp: &craft_pdf::interpret::pattern::ShadingPattern, inks: Option<&[String]>) -> Option<Paint> {
|
||||
let key = (sp.cache_key(), inks.map(<[String]>::to_vec));
|
||||
if let Some(paint) = self.function_paints.get(&key) {
|
||||
return Some(paint.clone());
|
||||
}
|
||||
let paint = match function_paint(sp, &mut self.colors, inks, &self.function_budget)? {
|
||||
FunctionPaint::Gradient(g) => Paint::Gradient(g),
|
||||
FunctionPaint::Average(c) => {
|
||||
self.warn(
|
||||
"function-based shadings that aren't read as a gradient (their colour changes in two directions, they need too many stops, or the file has too many to read) are imported as their average colour",
|
||||
);
|
||||
Paint::solid(c)
|
||||
}
|
||||
FunctionPaint::Unread => {
|
||||
self.warn("function-based shadings that can't be read (their function or domain is invalid, or the file has too many to read) are imported as a flat grey");
|
||||
Paint::solid(Color::rgb(0.5, 0.5, 0.5))
|
||||
}
|
||||
};
|
||||
self.function_paints.insert(key, paint.clone());
|
||||
Some(paint)
|
||||
}
|
||||
|
||||
/// Tiling pattern `t` as a pattern swatch's paint (the swatch made the first time).
|
||||
fn tiling(&mut self, t: &TilingPattern<'_>, stroke: bool) -> Option<Paint> {
|
||||
let key = (t.cache_key(), stroke);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Shadings: axial and radial ones become gradients (their stops sampled from the shading
|
||||
//! function in its own colour model; an end that doesn't extend clips the painted area), patch
|
||||
//! and triangle meshes become gradient meshes, a gradient drawn through a luminosity mask that is
|
||||
//! function in its own colour model; an end that doesn't extend clips the painted area), a
|
||||
//! function-based one whose colour changes along one axis a linear gradient (else its average
|
||||
//! colour; all of them within one budget of function evaluations per import), patch and triangle
|
||||
//! meshes become gradient meshes, a gradient drawn through a luminosity mask that is
|
||||
//! the same gradient in grey (how stop opacity is written) gets its opacity stops back, and a shape
|
||||
//! clipping a gradient that fills it becomes one path with the gradient.
|
||||
|
||||
@@ -44,22 +46,7 @@ pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>, ink
|
||||
let t = i as f32 / N as f32;
|
||||
pts.push((t, sample(t)?.to_rgb()));
|
||||
}
|
||||
// Every sample dropped since the last stop is checked against the line to the next one, not
|
||||
// just its neighbours: a gentle curve (an exponential's N) looks straight between neighbours.
|
||||
let mut keep = vec![0usize];
|
||||
for w in 1..N {
|
||||
let (Some(&k), Some(b)) = (keep.last(), pts.get(w + 1)) else { continue };
|
||||
let Some(a) = pts.get(k) else { continue };
|
||||
let off = pts.get(k + 1..=w).unwrap_or_default().iter().fold(0.0f32, |worst, p| {
|
||||
let u = (p.0 - a.0) / (b.0 - a.0).max(1e-6);
|
||||
(0..3).map(|c| (a.1[c] + (b.1[c] - a.1[c]) * u - p.1[c]).abs()).fold(worst, f32::max)
|
||||
});
|
||||
if off > 1.5 / 255.0 {
|
||||
keep.push(w);
|
||||
}
|
||||
}
|
||||
keep.push(N);
|
||||
let ts: Vec<f32> = keep.iter().filter_map(|&k| pts.get(k)).map(|p| p.0).collect();
|
||||
let ts: Vec<f32> = linear_keep(&pts).iter().filter_map(|&k| pts.get(k)).map(|p| p.0).collect();
|
||||
let mut stop_at = |t: f32, offset: f32| -> GradientStop {
|
||||
let rgb = sample(t).unwrap_or(Color::BLACK);
|
||||
match at(t).and_then(|v| colors.native(space, &v)) {
|
||||
@@ -90,9 +77,277 @@ pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>, ink
|
||||
Some((g, *extend))
|
||||
}
|
||||
|
||||
/// The samples of `pts` (offset, RGB; at least one) a gradient keeps as stops: the first, the
|
||||
/// last, and each one that linear interpolation between its neighbouring stops doesn't reproduce.
|
||||
fn linear_keep(pts: &[(f32, [f32; 3])]) -> Vec<usize> {
|
||||
let n = pts.len().saturating_sub(1);
|
||||
// Every sample dropped since the last stop is checked against the line to the next one, not
|
||||
// just its neighbours: a gentle curve (an exponential's N) looks straight between neighbours.
|
||||
let mut keep = vec![0usize];
|
||||
for w in 1..n {
|
||||
let (Some(&k), Some(b)) = (keep.last(), pts.get(w + 1)) else { continue };
|
||||
let Some(a) = pts.get(k) else { continue };
|
||||
let off = pts.get(k + 1..=w).unwrap_or_default().iter().fold(0.0f32, |worst, p| {
|
||||
let u = (p.0 - a.0) / (b.0 - a.0).max(1e-6);
|
||||
(0..3).map(|c| (a.1[c] + (b.1[c] - a.1[c]) * u - p.1[c]).abs()).fold(worst, f32::max)
|
||||
});
|
||||
if off > 1.5 / 255.0 {
|
||||
keep.push(w);
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
keep.push(n);
|
||||
}
|
||||
keep
|
||||
}
|
||||
|
||||
/// Samples along a function-based shading's axis; a colour change between two of them is found
|
||||
/// by halving the interval.
|
||||
const FUNCTION_SAMPLES: usize = 4096;
|
||||
/// The most stops a function-based shading becomes: past it, it has no gradient.
|
||||
const MAX_FUNCTION_STOPS: usize = 2048;
|
||||
/// Grid points per side, sampling a function-based shading's domain (to tell along which axis its
|
||||
/// colour changes, or to average it).
|
||||
const FUNCTION_GRID: usize = 16;
|
||||
/// Samples across the axis a function-based shading's colour changes along, through the middle
|
||||
/// of its domain, that must all be one colour (what the grid can't tell from a periodic one).
|
||||
const FUNCTION_ACROSS: usize = 256;
|
||||
/// The function evaluations function-based shadings may take in one import, together: a file
|
||||
/// can fill any number of paths with them, each a function of any length.
|
||||
pub(crate) const MAX_FUNCTION_EVALUATIONS: usize = 200_000;
|
||||
/// What averaging a function-based shading takes ([`function_average`]).
|
||||
const AVERAGE_EVALUATIONS: usize = (FUNCTION_GRID + 1) * (FUNCTION_GRID + 1);
|
||||
|
||||
/// The function evaluations function-based shadings took (tests; imports read on threads of
|
||||
/// their own, so tests that count hold `tests_import_fidelity::SHADINGS`).
|
||||
#[cfg(test)]
|
||||
pub(crate) static EVALUATIONS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
/// What is left of one import's function evaluations ([`MAX_FUNCTION_EVALUATIONS`]).
|
||||
pub(crate) struct FunctionBudget(std::cell::Cell<usize>);
|
||||
|
||||
impl FunctionBudget {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self(std::cell::Cell::new(MAX_FUNCTION_EVALUATIONS))
|
||||
}
|
||||
|
||||
/// Take one evaluation, leaving at least `reserve`: `false` when there isn't one.
|
||||
fn take(&self, reserve: usize) -> bool {
|
||||
let left = self.0.get();
|
||||
if left <= reserve {
|
||||
return false;
|
||||
}
|
||||
self.0.set(left - 1);
|
||||
#[cfg(test)]
|
||||
EVALUATIONS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// What a function-based shading comes in as ([`function_paint`]).
|
||||
pub(crate) enum FunctionPaint {
|
||||
/// Its colour changes along one axis: a linear gradient.
|
||||
Gradient(Box<GradientPaint>),
|
||||
/// It has no gradient: the colour it averages to.
|
||||
Average(Color),
|
||||
/// Its function can't be read, or the import's budget is spent.
|
||||
Unread,
|
||||
}
|
||||
|
||||
/// Function-based shading `sp` as a gradient, else its average colour, its function evaluated
|
||||
/// within `budget` (a gradient leaves enough of it to average another shading). `None` for other
|
||||
/// shadings.
|
||||
pub(crate) fn function_paint(
|
||||
sp: &ShadingPattern,
|
||||
colors: &mut Colors<'_>,
|
||||
inks: Option<&[String]>,
|
||||
budget: &FunctionBudget,
|
||||
) -> Option<FunctionPaint> {
|
||||
if !matches!(sp.shading.shading_type.as_ref(), ShadingType::FunctionBased { .. }) {
|
||||
return None;
|
||||
}
|
||||
Some(match function_gradient(sp, colors, inks, budget) {
|
||||
Some(g) => FunctionPaint::Gradient(Box::new(g)),
|
||||
None => function_average(sp, budget).map_or(FunctionPaint::Unread, FunctionPaint::Average),
|
||||
})
|
||||
}
|
||||
|
||||
/// RGB colours closer than this in every component are the same colour.
|
||||
fn same_rgb(a: [f32; 3], b: [f32; 3]) -> bool {
|
||||
a.iter().zip(b).all(|(p, q)| (p - q).abs() <= 1.5 / 255.0)
|
||||
}
|
||||
|
||||
/// A function-based shading's function and its domain (`[x0, x1, y0, y1]`, finite and not
|
||||
/// empty); `None` for other shadings.
|
||||
fn function_domain(sp: &ShadingPattern) -> Option<(&ShadingFunction, [f32; 4])> {
|
||||
let ShadingType::FunctionBased { domain, function, .. } = sp.shading.shading_type.as_ref() else { return None };
|
||||
let [x0, x1, y0, y1] = *domain;
|
||||
(domain.iter().all(|v| v.is_finite()) && x1 > x0 && y1 > y0).then_some((function, *domain))
|
||||
}
|
||||
|
||||
/// The value of function-based shading function `function` at point (`x`, `y`) of its domain, one
|
||||
/// evaluation of `budget` leaving `reserve`.
|
||||
fn function_value(function: &ShadingFunction, x: f32, y: f32, budget: &FunctionBudget, reserve: usize) -> Option<smallvec::SmallVec<[f32; 4]>> {
|
||||
budget.take(reserve).then(|| function.eval(&smallvec::smallvec![x, y]))?
|
||||
}
|
||||
|
||||
/// The RGB of function-based shading `sp` (its function `function`) at point (`x`, `y`) of its
|
||||
/// domain, as [`function_value`].
|
||||
fn function_rgb(sp: &ShadingPattern, function: &ShadingFunction, x: f32, y: f32, budget: &FunctionBudget, reserve: usize) -> Option<[f32; 3]> {
|
||||
let [r, g, b, _] = sp.shading.color_space.to_rgba(&function_value(function, x, y, budget, reserve)?, 1.0, false).components();
|
||||
Some([r, g, b])
|
||||
}
|
||||
|
||||
/// Where grid point `i` (of [`FUNCTION_GRID`] + 1) falls across a domain, 0..1: off round
|
||||
/// fractions, where a periodic function would line up with the grid.
|
||||
fn grid_at(i: usize) -> f32 {
|
||||
(i as f32 + 0.37) / (FUNCTION_GRID as f32 + 1.0)
|
||||
}
|
||||
|
||||
/// A function-based shading (type 1) whose colour changes along one axis of its domain only (how
|
||||
/// a browser writes a repeating linear gradient) as a linear gradient across its domain: stops
|
||||
/// sampled along that axis, each hard colour change two stops at one offset. `None` when the
|
||||
/// colour changes along both axes, would take more than [`MAX_FUNCTION_STOPS`] stops, or `budget`
|
||||
/// runs out (leaving [`AVERAGE_EVALUATIONS`] for [`function_average`]).
|
||||
fn function_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>, inks: Option<&[String]>, budget: &FunctionBudget) -> Option<GradientPaint> {
|
||||
let ShadingType::FunctionBased { matrix, .. } = sp.shading.shading_type.as_ref() else { return None };
|
||||
let (function, [x0, x1, y0, y1]) = function_domain(sp)?;
|
||||
let rgb = |x: f32, y: f32| function_rgb(sp, function, x, y, budget, AVERAGE_EVALUATIONS);
|
||||
let lerp = |a: f32, b: f32, t: f32| a + (b - a) * t;
|
||||
let grid: Vec<Vec<[f32; 3]>> = (0..=FUNCTION_GRID)
|
||||
.map(|j| (0..=FUNCTION_GRID).map(|i| rgb(lerp(x0, x1, grid_at(i)), lerp(y0, y1, grid_at(j)))).collect())
|
||||
.collect::<Option<_>>()?;
|
||||
let first = grid.first()?;
|
||||
// Each row as the first: the colour may change along x only. Each row one colour: along y.
|
||||
let along_x = grid.iter().all(|row| row.iter().zip(first).all(|(a, b)| same_rgb(*a, *b)));
|
||||
let along_y = grid.iter().all(|row| row.first().is_some_and(|c0| row.iter().all(|c| same_rgb(*c, *c0))));
|
||||
// The grid can miss a change that repeats in step with it: across the middle of the domain,
|
||||
// densely, the colour must not change along the other axis. Both left (a grid of one colour),
|
||||
// the axis it changes along is the one, if any, it changes along there.
|
||||
let steady = |x_axis: bool| -> Option<bool> {
|
||||
let at = |k: usize| k as f32 / FUNCTION_ACROSS as f32;
|
||||
let probe = |k: usize| if x_axis { rgb(lerp(x0, x1, at(k)), lerp(y0, y1, 0.5)) } else { rgb(lerp(x0, x1, 0.5), lerp(y0, y1, at(k))) };
|
||||
let c0 = probe(0)?;
|
||||
for k in 1..=FUNCTION_ACROSS {
|
||||
if !same_rgb(probe(k)?, c0) {
|
||||
return Some(false);
|
||||
}
|
||||
}
|
||||
Some(true)
|
||||
};
|
||||
let along_x = match (along_x, along_y) {
|
||||
(false, false) => return None,
|
||||
(true, false) if steady(false)? => true,
|
||||
(false, true) if steady(true)? => false,
|
||||
(true, true) if steady(false)? => true,
|
||||
(true, true) if steady(true)? => false,
|
||||
_ => return None,
|
||||
};
|
||||
let point = |s: f32| if along_x { (lerp(x0, x1, s), lerp(y0, y1, 0.5)) } else { (lerp(x0, x1, 0.5), lerp(y0, y1, s)) };
|
||||
let color_of = |s: f32| {
|
||||
let (x, y) = point(s);
|
||||
rgb(x, y)
|
||||
};
|
||||
// Pieces of the axis between hard colour changes: (offset, where its colour is read, RGB).
|
||||
let start = color_of(0.0)?;
|
||||
let mut pieces: Vec<Vec<(f32, f32, [f32; 3])>> = vec![vec![(0.0, 0.0, start)]];
|
||||
let mut stops = 1;
|
||||
let mut last = (0.0, start);
|
||||
for k in 1..=FUNCTION_SAMPLES {
|
||||
let s = k as f32 / FUNCTION_SAMPLES as f32;
|
||||
let c = color_of(s)?;
|
||||
let (a, ca) = last;
|
||||
if !same_rgb(ca, c) {
|
||||
// Halve the interval while it holds the change: one that stays as it closes is a hard
|
||||
// change (a smooth one fades out).
|
||||
let (mut lo, mut hi, mut clo, mut chi) = (a, s, ca, c);
|
||||
for _ in 0..24 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
let cm = color_of(mid)?;
|
||||
if same_rgb(clo, cm) {
|
||||
(lo, clo) = (mid, cm);
|
||||
} else {
|
||||
(hi, chi) = (mid, cm);
|
||||
}
|
||||
}
|
||||
if !same_rgb(clo, chi) {
|
||||
let edge = (lo + hi) / 2.0;
|
||||
pieces.last_mut()?.push((edge, lo, clo));
|
||||
pieces.push(vec![(edge, hi, chi)]);
|
||||
stops += 2;
|
||||
}
|
||||
}
|
||||
pieces.last_mut()?.push((s, s, c));
|
||||
last = (s, c);
|
||||
if stops > MAX_FUNCTION_STOPS {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// A stop's colour in the shading's own model (RGB when that can't be read).
|
||||
let value = |s: f32| {
|
||||
let (x, y) = point(s);
|
||||
function_value(function, x, y, budget, AVERAGE_EVALUATIONS)
|
||||
};
|
||||
let space = colors.shading_space(&sp.shading.color_space, value(0.0)?.len(), inks);
|
||||
let mut out = vec![];
|
||||
for piece in &pieces {
|
||||
let pts: Vec<(f32, [f32; 3])> = piece.iter().map(|p| (p.0, p.2)).collect();
|
||||
for k in linear_keep(&pts) {
|
||||
let Some(&(offset, read, c)) = piece.get(k) else { continue };
|
||||
let rgb = Color::rgb(round3(c[0]), round3(c[1]), round3(c[2]));
|
||||
out.push(match value(read).and_then(|v| colors.native(space, &v)) {
|
||||
Some(Native { color, link: Some((name, tint)) }) => GradientStop { swatch: Some(name), tint, ..GradientStop::new(offset, color) },
|
||||
Some(Native { color, link: None }) => GradientStop::new(offset, color),
|
||||
None => GradientStop::new(offset, rgb),
|
||||
});
|
||||
}
|
||||
}
|
||||
if out.len() > MAX_FUNCTION_STOPS {
|
||||
return None;
|
||||
}
|
||||
let p = |s: f32| {
|
||||
let (x, y) = point(s);
|
||||
Point::new(x as f64, y as f64)
|
||||
};
|
||||
let mut geom = GradientGeom { start: p(0.0), end: p(1.0), aspect: 1.0, focal: None };
|
||||
geom.transform(sp.matrix * *matrix, GradientKind::Linear);
|
||||
let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, out));
|
||||
g.geom = Some(geom);
|
||||
// It paints only over its domain ([`extend_clip`]).
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// The colour a function-based shading averages to over its domain, for one that has no gradient
|
||||
/// ([`function_gradient`]); `None` for other shadings, or when `budget` runs out.
|
||||
fn function_average(sp: &ShadingPattern, budget: &FunctionBudget) -> Option<Color> {
|
||||
let (function, [x0, x1, y0, y1]) = function_domain(sp)?;
|
||||
let mut sum = [0.0f32; 3];
|
||||
for j in 0..=FUNCTION_GRID {
|
||||
for i in 0..=FUNCTION_GRID {
|
||||
let c = function_rgb(sp, function, x0 + (x1 - x0) * grid_at(i), y0 + (y1 - y0) * grid_at(j), budget, 0)?;
|
||||
sum.iter_mut().zip(c).for_each(|(s, v)| *s += v);
|
||||
}
|
||||
}
|
||||
let n = ((FUNCTION_GRID + 1) * (FUNCTION_GRID + 1)) as f32;
|
||||
Some(Color::rgb(round3(sum[0] / n), round3(sum[1] / n), round3(sum[2] / n)))
|
||||
}
|
||||
|
||||
/// Where an axial or radial shading that doesn't extend at both ends paints, as a clip path in
|
||||
/// document space (even-odd) for art filling `bounds`; `None` when it extends at both.
|
||||
/// document space (even-odd) for art filling `bounds`; `None` when it extends at both. A
|
||||
/// function-based shading paints only over its domain: that, unless it covers `bounds`.
|
||||
pub(crate) fn extend_clip(sp: &ShadingPattern, bounds: Rect) -> Option<BezPath> {
|
||||
if let ShadingType::FunctionBased { domain, matrix, .. } = sp.shading.shading_type.as_ref() {
|
||||
let m = sp.matrix * *matrix;
|
||||
if m.determinant().abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let local = m.inverse().transform_rect_bbox(bounds);
|
||||
let [x0, x1, y0, y1] = domain.map(f64::from);
|
||||
let d = Rect::new(x0, y0, x1, y1);
|
||||
let tol = 1e-3 * (d.width() + d.height());
|
||||
let covers = local.x0 >= d.x0 - tol && local.y0 >= d.y0 - tol && local.x1 <= d.x1 + tol && local.y1 <= d.y1 + tol;
|
||||
return (local.is_finite() && d.is_finite() && !covers).then(|| m * d.to_path(0.1));
|
||||
}
|
||||
let ShadingType::RadialAxial { coords, axial, extend, .. } = sp.shading.shading_type.as_ref() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -491,6 +491,162 @@ fn a_shading_with_an_exponent_keeps_its_curve() {
|
||||
assert!((v - 0.5f32.powf(1.4234)).abs() < 0.01, "{v}");
|
||||
}
|
||||
|
||||
/// A 100 × 100 square filled with a function-based shading (type 1) over `domain`, placed by
|
||||
/// `matrix`, whose PostScript calculator function is `program` (x y → r g b).
|
||||
fn function_shading(domain: &str, matrix: &str, program: &str) -> ImportReport {
|
||||
let f = first_extra(1);
|
||||
let resources = format!(
|
||||
"/Pattern << /P0 << /PatternType 2 /Shading << /ShadingType 1 /ColorSpace /DeviceRGB /Domain [{domain}] /Matrix [{matrix}] /Function {f} 0 R >> >> >>"
|
||||
);
|
||||
let function = stream(&format!("/FunctionType 4 /Domain [{domain}] /Range [0 1 0 1 0 1]"), program);
|
||||
open_with(&one_page("/Pattern cs /P0 scn 0 0 100 100 re f", &resources, &[&function]), |_| {})
|
||||
}
|
||||
|
||||
/// A browser writes a repeating linear gradient as a function-based shading whose colour changes
|
||||
/// along one axis only: it comes in as a linear gradient with hard stops, red and white as in the
|
||||
/// file, not as a flat grey (#3621).
|
||||
#[test]
|
||||
fn a_function_based_shading_along_one_axis_becomes_a_linear_gradient() {
|
||||
let _held = shadings();
|
||||
// Four stripes of red, then white, across the square (x 0..4 over 100 pt).
|
||||
let r = function_shading("0 4 0 1", "25 0 0 100 0 0", "{ pop dup truncate sub 0.5 le { 1 0 0 } { 1 1 1 } ifelse }");
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("flat")), "{:?}", r.warnings);
|
||||
let g = all(&r.document)
|
||||
.into_iter()
|
||||
.find_map(|n| match n.appearance.fill().map(|f| &f.paint) {
|
||||
Some(Paint::Gradient(g)) => Some(g.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("a gradient fill");
|
||||
assert_eq!(g.gradient.kind, GradientKind::Linear);
|
||||
let (red, white) = ([1.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
|
||||
assert!(g.gradient.stops.iter().all(|s| s.color.to_rgb() == red || s.color.to_rgb() == white), "{:?}", g.gradient.stops);
|
||||
let geom = g.geom.as_ref().unwrap();
|
||||
assert!((geom.start.x - 0.0).abs() < 0.01 && (geom.end.x - 100.0).abs() < 0.01, "across the square: {geom:?}");
|
||||
for (t, want) in [(0.06, red), (0.19, white), (0.31, red), (0.44, white), (0.56, red), (0.94, white)] {
|
||||
assert_eq!(g.gradient.sample(t).0.to_rgb(), want, "at {t}");
|
||||
}
|
||||
// The colour changes where the file's does (x = 0.5, 1 … 3.5, and at x = 4 itself, red
|
||||
// again): hard stops, not ramps.
|
||||
let edges: Vec<f32> =
|
||||
g.gradient.stops.windows(2).filter(|w| (w[0].offset - w[1].offset).abs() < 1e-4 && w[0].color != w[1].color).map(|w| w[0].offset).collect();
|
||||
assert_eq!(edges.len(), 8, "{:?}", g.gradient.stops);
|
||||
for (k, e) in edges.iter().take(7).enumerate() {
|
||||
assert!((e - (k + 1) as f32 / 8.0).abs() < 1e-4, "change {k} at {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A function-based shading paints only over its domain: a square filled with one whose domain
|
||||
/// covers its left half is clipped to that half; one whose domain covers it all isn't clipped.
|
||||
#[test]
|
||||
fn a_function_based_shading_paints_only_over_its_domain() {
|
||||
let _held = shadings();
|
||||
let clips = |matrix: &str| -> Vec<Node> {
|
||||
let r = function_shading("0 1 0 1", matrix, "{ pop 0 0 }");
|
||||
all(&r.document).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect()
|
||||
};
|
||||
let half = clips("50 0 0 100 0 0");
|
||||
let [c] = half.as_slice() else { panic!("one clip group: {half:?}") };
|
||||
let b = c.geometric_bounds().unwrap();
|
||||
assert!(b.x0.abs() < 0.01 && (b.x1 - 50.0).abs() < 0.01 && b.y0.abs() < 0.01 && (b.y1 - 100.0).abs() < 0.01, "{b:?}");
|
||||
assert!(clips("100 0 0 100 0 0").is_empty());
|
||||
}
|
||||
|
||||
/// Held by tests that import function-based shadings, so the evaluations one of them counts are
|
||||
/// its own.
|
||||
pub(crate) static SHADINGS: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn shadings() -> std::sync::MutexGuard<'static, ()> {
|
||||
SHADINGS.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The function evaluations function-based shadings take while `f` imports (hold [`shadings`]).
|
||||
fn evaluations(f: impl FnOnce() -> ImportReport) -> (ImportReport, usize) {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
crate::import_shading::EVALUATIONS.store(0, Relaxed);
|
||||
let r = f();
|
||||
(r, crate::import_shading::EVALUATIONS.load(Relaxed))
|
||||
}
|
||||
|
||||
/// Stripes across the square: red, then white, `n` times (x 0..n over 100 pt).
|
||||
const STRIPES: &str = "{ pop dup truncate sub 0.5 le { 1 0 0 } { 1 1 1 } ifelse }";
|
||||
|
||||
/// A shading used by many paths is read once (#3621 review): a file can't make the import
|
||||
/// evaluate its function once per path.
|
||||
#[test]
|
||||
fn a_function_based_shading_filling_many_paths_is_read_once() {
|
||||
let _held = shadings();
|
||||
let f = first_extra(1);
|
||||
let resources = format!(
|
||||
"/Pattern << /P0 << /PatternType 2 /Shading << /ShadingType 1 /ColorSpace /DeviceRGB /Domain [0 4 0 1] /Matrix [25 0 0 100 0 0] /Function {f} 0 R >> >> >>"
|
||||
);
|
||||
let function = stream("/FunctionType 4 /Domain [0 4 0 1] /Range [0 1 0 1 0 1]", STRIPES);
|
||||
let content: String = (0..2000).map(|i| format!("{} {} 2 2 re f ", i % 50 * 2, i / 50 * 2)).collect();
|
||||
let once = evaluations(|| function_shading("0 4 0 1", "25 0 0 100 0 0", STRIPES)).1;
|
||||
let (r, many) = evaluations(|| open_with(&one_page(&format!("/Pattern cs /P0 scn {content}"), &resources, &[&function]), |_| {}));
|
||||
assert!(once > 0 && many <= once, "one path {once}, 2000 paths {many}");
|
||||
assert!(all(&r.document).iter().any(|n| matches!(n.appearance.fill().map(|f| &f.paint), Some(Paint::Gradient(_)))));
|
||||
}
|
||||
|
||||
/// Shadings that each take many evaluations share one budget per import: past it they come in as
|
||||
/// their average colour, then grey, with a warning, and the import stays bounded.
|
||||
#[test]
|
||||
fn function_based_shadings_share_one_budget_per_import() {
|
||||
let _held = shadings();
|
||||
// 40 shadings, each other stripes (fine ones: each takes thousands of evaluations).
|
||||
let n = 40;
|
||||
let patterns: String = (0..n)
|
||||
.map(|i| {
|
||||
format!(
|
||||
"/P{i} << /PatternType 2 /Shading << /ShadingType 1 /ColorSpace /DeviceRGB /Domain [0 {w} 0 1] /Matrix [{s} 0 0 100 0 0] /Function {} 0 R >> >> ",
|
||||
first_extra(1) + i,
|
||||
w = 100 + i,
|
||||
s = 100.0 / (100 + i) as f64
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let functions: Vec<String> =
|
||||
(0..n).map(|i| stream(&format!("/FunctionType 4 /Domain [0 {} 0 1] /Range [0 1 0 1 0 1]", 100 + i), STRIPES)).collect();
|
||||
let content: String = (0..n).map(|i| format!("/Pattern cs /P{i} scn 0 {i} 100 1 re f ")).collect();
|
||||
let extra: Vec<&str> = functions.iter().map(String::as_str).collect();
|
||||
let (r, used) = evaluations(|| open_with(&one_page(&content, &format!("/Pattern << {patterns}>>"), &extra), |_| {}));
|
||||
assert!(used <= crate::import_shading::MAX_FUNCTION_EVALUATIONS, "{used}");
|
||||
let gradients = all(&r.document).iter().filter(|n| matches!(n.appearance.fill().map(|f| &f.paint), Some(Paint::Gradient(_)))).count();
|
||||
assert!(gradients > 0 && gradients < n, "{gradients} of {n} read as gradients");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("too many to read")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// A colour that changes in step with the sampling grid along y only (one colour at every grid
|
||||
/// point) is still read along y, not as a flat gradient along x.
|
||||
#[test]
|
||||
fn a_function_based_shading_in_step_with_the_grid_is_read_along_its_axis() {
|
||||
let _held = shadings();
|
||||
// Red in bands a quarter of a period wide around every grid row (17·y − 0.37 a whole number).
|
||||
let r = function_shading("0 1 0 1", "100 0 0 100 0 0", "{ exch pop 17 mul 0.37 sub 0.125 add dup floor sub 0.25 lt { 1 0 0 } { 1 1 1 } ifelse }");
|
||||
let g = all(&r.document)
|
||||
.into_iter()
|
||||
.find_map(|n| match n.appearance.fill().map(|f| &f.paint) {
|
||||
Some(Paint::Gradient(g)) => Some(g.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("a gradient fill");
|
||||
let geom = g.geom.as_ref().unwrap();
|
||||
assert!((geom.start.x - geom.end.x).abs() < 0.01 && (geom.start.y - geom.end.y).abs() > 99.0, "along y: {geom:?}");
|
||||
assert!(g.gradient.stops.iter().any(|s| s.color.to_rgb() == [1.0, 1.0, 1.0]), "{:?}", g.gradient.stops);
|
||||
}
|
||||
|
||||
/// A function-based shading whose colour changes along both axes has no gradient: it comes in as
|
||||
/// the colour it averages to, never as a grey that isn't in it.
|
||||
#[test]
|
||||
fn a_function_based_shading_along_both_axes_becomes_its_average_colour() {
|
||||
let _held = shadings();
|
||||
// r = g = x·y, b = 0 over the unit square: averages to (0.25, 0.25, 0).
|
||||
let r = function_shading("0 1 0 1", "100 0 0 100 0 0", "{ mul dup 0 }");
|
||||
let c = all(&r.document).iter().find_map(fill_color).expect("a flat fill").to_rgb();
|
||||
assert!((c[0] - 0.25).abs() < 0.03 && (c[1] - 0.25).abs() < 0.03 && c[2].abs() < 1e-6, "{c:?}");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("average")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// A diamond (10–90 × 10–90) clipping `paint`, which fills its bounds: how a shape filled with a
|
||||
/// gradient is written (`W n`, then `sh`).
|
||||
fn clipped_diamond(paint: &str) -> Document {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user