mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
Fix #3621: function-based PDF shadings keep their colours
PDF import turned every function-based shading (ShadingType 1) into a flat mid-grey. Chromium writes a CSS repeating-linear-gradient this way, so a red and white hatch came in as a grey bar. A function-based shading whose colour changes along one axis of its domain only now becomes a linear gradient across the domain: stops sampled along that axis, each hard colour change (found by halving the interval between two samples) 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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
95436e7212
commit
095d2bd2fd
@@ -24,7 +24,7 @@ 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::{clipped, extend_clip, fold_stop_opacity, function_average, gradient_shape, mesh_shading, shading_gradient};
|
||||
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
|
||||
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
|
||||
|
||||
@@ -1435,10 +1435,16 @@ impl<'p> Builder<'p> {
|
||||
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)
|
||||
}
|
||||
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::Tiling(t) => match self.tiling(t, stroke) {
|
||||
Some(paint) => (paint, 1.0),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! 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
|
||||
//! 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
|
||||
//! 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.
|
||||
@@ -26,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 None;
|
||||
return function_gradient(sp, colors, inks);
|
||||
};
|
||||
let m = sp.matrix;
|
||||
let cs = &sp.shading.color_space;
|
||||
@@ -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,187 @@ 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;
|
||||
|
||||
/// 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 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();
|
||||
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, or would take more than [`MAX_FUNCTION_STOPS`] stops.
|
||||
fn function_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>, inks: Option<&[String]>) -> Option<(GradientPaint, [bool; 2])> {
|
||||
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 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.
|
||||
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;
|
||||
}
|
||||
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 mut stops = 1;
|
||||
let mut last = (0.0, color_of(0.0)?);
|
||||
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;
|
||||
}
|
||||
}
|
||||
let value = |s: f32| {
|
||||
let (x, y) = point(s);
|
||||
function.eval(&smallvec::smallvec![x, y])
|
||||
};
|
||||
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, [false, false]))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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))?;
|
||||
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,76 @@ 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() {
|
||||
// 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 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());
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
// 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