Fix #3621: bound function-based shading reads per import

Review follow-up. A function-based shading is read once per import (kept
per shading pattern and inks), and all of them draw on one budget of
200,000 function evaluations; past it a shading comes in as its average
colour, then flat grey, each with a warning that names the cause. A
gradient read leaves enough of the budget to average another shading.

A colour that changes in step with the sampling grid is checked across
the middle of the domain before an axis is chosen, so it isn't read
along the wrong one. The grey fallback warning for other shadings names
only them.

Tests: the same shading on 2,000 paths is read once; 40 distinct
shadings stay within the budget; a hostile file (5,000 fills, 20
shadings with 500-step functions) opens in 0.8 s, 253 s before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Lenzen
2026-10-24 19:27:44 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 095d2bd2fd
commit 504b549a78
5 changed files with 270 additions and 35 deletions
+26
View File
@@ -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![
+47 -14
View File
@@ -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, function_average, 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,19 +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 => match function_average(sp) {
Some(c) => {
self.warn("function-based shadings whose colour changes in two directions are imported as their average colour");
(Paint::solid(c), sp.opacity)
}
None => {
self.warn("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 => {
@@ -1513,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);
+110 -20
View File
@@ -1,8 +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), a
//! function-based one whose colour changes along one axis a linear gradient (else its average
//! colour), patch
//! and triangle meshes become gradient meshes, a gradient drawn through a luminosity mask that is
//! 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.
@@ -28,7 +28,7 @@ const MAX_PATCHES: usize = 4096;
/// its end. `inks`: the colorant names of its space, from the page's walk ([`Colors::solid`]).
pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>, inks: Option<&[String]>) -> Option<(GradientPaint, [bool; 2])> {
let ShadingType::RadialAxial { coords, domain, function, axial, extend } = sp.shading.shading_type.as_ref() else {
return function_gradient(sp, colors, inks);
return None;
};
let m = sp.matrix;
let cs = &sp.shading.color_space;
@@ -109,6 +109,68 @@ 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 {
@@ -123,10 +185,16 @@ fn function_domain(sp: &ShadingPattern) -> Option<(&ShadingFunction, [f32; 4])>
(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.
fn function_rgb(sp: &ShadingPattern, function: &ShadingFunction, x: f32, y: f32) -> Option<[f32; 3]> {
let [r, g, b, _] = sp.shading.color_space.to_rgba(&function.eval(&smallvec::smallvec![x, y])?, 1.0, false).components();
/// 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])
}
@@ -139,31 +207,52 @@ fn grid_at(i: usize) -> f32 {
/// 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, or would take more than [`MAX_FUNCTION_STOPS`] stops.
fn function_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>, inks: Option<&[String]>) -> Option<(GradientPaint, [bool; 2])> {
/// 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);
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 changes along x. Each row one colour: along y.
// 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))));
if !along_x && !along_y {
return None;
}
// 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 mut pieces: Vec<Vec<(f32, f32, [f32; 3])>> = vec![vec![(0.0, 0.0, color_of(0.0)?)]];
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, color_of(0.0)?);
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)?;
@@ -194,9 +283,10 @@ fn function_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>, inks: Option<
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.eval(&smallvec::smallvec![x, y])
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![];
@@ -224,17 +314,17 @@ fn function_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>, inks: Option<
let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, out));
g.geom = Some(geom);
// It paints only over its domain ([`extend_clip`]).
Some((g, [false, false]))
Some(g)
}
/// The colour a function-based shading averages to over its domain, for one that has no gradient
/// ([`shading_gradient`]); `None` for other shadings.
pub(crate) fn function_average(sp: &ShadingPattern) -> Option<Color> {
/// ([`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))?;
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);
}
}
+86
View File
@@ -507,6 +507,7 @@ fn function_shading(domain: &str, matrix: &str, program: &str) -> ImportReport {
/// 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);
@@ -539,6 +540,7 @@ fn a_function_based_shading_along_one_axis_becomes_a_linear_gradient() {
/// 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()
@@ -550,10 +552,94 @@ fn a_function_based_shading_paints_only_over_its_domain() {
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();
+1 -1
View File
@@ -894,7 +894,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs and the images in an `.ai` or EPS file's editing data, an alpha channel coming in as a grayscale opacity mask) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
In imported PDF type, a space glyph the file draws between two words also keeps the next word where the file has it when the gap beside the space is wide, as VectorCraft's own export draws a tracked space: the space gets tracking of its own, and the wrapped lines of a justified paragraph still come back as one area type object when the word gaps on each line are alike (part of #2384).
Letter-spaced PDF type, whether set with character spacing (`Tc`) or glyph by glyph, keeps its word spaces and gains none between its letters: the spacing a line's letters share is taken off each gap before a gap reads as a space. That holds on a line that also shows where its words break, with spaces the file draws or gaps wider than the rest; evenly spaced glyphs alone, which could be words of one letter, keep every gap as a space, as do glyphs set more than half an em apart, such as figures in columns.
A function-based PDF shading (type 1) whose colour changes along one axis only, as a browser writes a repeating linear gradient, comes in as a linear gradient over its domain, each hard colour change two stops at one offset; one whose colour changes in two directions comes in as its average colour, with a warning. Either paints only over the shading's domain. Both came in as a flat grey that wasn't in the file (#3621).
A function-based PDF shading (type 1) whose colour changes along one axis only, as a browser writes a repeating linear gradient, comes in as a linear gradient over its domain, each hard colour change two stops at one offset; one whose colour changes in two directions comes in as its average colour, with a warning. Either paints only over the shading's domain. Both came in as a flat grey that wasn't in the file (#3621). Each such shading is read once per import, and all of them within one budget of 200,000 function evaluations, so a file filling thousands of paths with long functions still opens in about a second (it took minutes); past the budget they come in as their average colour, then grey, with a warning.
A gap between glyphs of two sizes is measured against the smaller one, so a word set beside a much larger one with no space glyph between them (a browser's margin between two sizes, as in `≤ 4` and a small `hours`) keeps its word space (#3623).
SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `<style>` rules for its id, its classes and its element name, and the rules whose selector ends in none of these (`*`, `[x]`).
A `.svgz` file copied in a file manager pastes as its art, as a copied `.svg` file does.