mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
Discord feedback: "EPS files are always rasterized: they open as bitmaps, in
other programs as vectors". Every EPS saved by Illustrator 2026 fell back to
its TIFF preview (`gcheck` was unknown), and cairo/Inkscape files stopped at
their first image (`status`), dropped masked images (ImageType 3) and failed
on mesh gradients; the warning didn't say where the program failed.
Fallback never silent:
- The warning names the PostScript error, the operator that raised it and
the named procedures it ran in, innermost first ("a PostScript error
(typecheck in `add`), in `inner` in `outer`"); errors caught by `stopped`
aren't reported. It reaches the status bar, document.open, app.open and
file.open (which now return document.open's result, so MCP's open_file over
the control channel gets it), and `vectorcraft-cli info` ("warnings").
- A file whose PostScript can't be read opens as its TIFF preview, else its
Windows metafile preview (as vectors, scaled to the bounding box), else its
EPSI bitmap. es @msg rows for the new messages.
Interpreter:
- Operators programs probe or use: gcheck/scheck, rootfont,
currentcolorrendering, currenthsbcolor, status, token, read,
bytesavailable, fileposition/setfileposition, resetfile, write*, pdfmark
(marks dropped, as Ghostscript does), execform, nulldevice, strokepath,
pathforall, user paths (ufill/ueofill/ustroke/uappend/upath/setbbox, the
encoded form too), setcachedevice2, findcolorrendering, cache, device and
VM queries, revision; (%stdout) (w) file gives a sink.
- `//name` is replaced by its value when read, inside procedures too.
- Tiling patterns become pattern swatches (their PaintProc drawn in pattern
space, one cell; uncoloured ones in the colour under them; makepattern
copies share one swatch); nested patterns stop at 8 levels.
- Type 3 fonts draw their glyphs (BuildGlyph/BuildChar, widths from
setcachedevice/setcharwidth) as outlines grouped per string, spaced by
ashow/widthshow/awidthshow/xshow/yshow/xyshow/kshow; glyphshow and
stringwidth/cshow use them.
- Shadings of types 1 and 4-7 become gradient meshes (data from arrays,
strings or files, packed or not, flags sharing edges), as fills through a
shading pattern too; sampled functions (type 0) are evaluated;
GradientMesh::coons/triangle are shared with PDF import.
- ImageType 3 masked images, interleaved by sample, by row or in separate
sources; ReusableStreamDecode reads its data when made; Flate/LZW PNG and
TIFF predictors.
- Bounded: DeviceN at 32 inks, sampled functions at 2^24 samples and 32
outputs, 4096 patches a shading, strokepath ignores dashes finer than 1e5
pieces, Type 3 glyphs nest 8 deep (a glyph that shows itself overflowed the
stack before the limit), 64-bit index maths for 32-bit targets.
Tests: a synthetic program per generator (cairo/Inkscape, its Type 3 fonts,
matplotlib, CorelDRAW, Affinity, LibreOffice, Ghostscript eps2write,
Illustrator procsets), meshes and function shadings, masked images, fault
messages, probing operators, metafile/EPSI previews, `//name`, user paths,
hostile patterns/glyphs/shadings (run against main, 12 of the 14 generator
and feature tests fail; CorelDRAW- and LibreOffice-style programs already
read there). The import fuzz vocabulary covers the new operators. Checked
against real files made here with Inkscape 1.4 (cairo) and Illustrator 2026
(its own format and the CS-to-CC compatibility formats; all fell back or
stopped early on main and open as vectors now; its Illustrator 8 format
needs the next commit); those files aren't committed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
269 lines
12 KiB
Rust
269 lines
12 KiB
Rust
//! 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, and a gradient drawn through a luminosity mask that
|
|
//! is the same gradient in grey (how stop opacity is written) gets its opacity stops back.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use hayro_interpret::pattern::ShadingPattern;
|
|
use hayro_interpret::shading::{ShadingFunction, ShadingType};
|
|
use kurbo::{BezPath, Circle, Point, Rect, Shape, Vec2};
|
|
use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
|
use vectorcraft_doc::live::{GradientMesh, lerp_color};
|
|
use vectorcraft_doc::{Node, NodeKind, OpacityMask};
|
|
|
|
use crate::import::round3;
|
|
use crate::import_color::{Colors, Native};
|
|
use crate::import_mask::luminance;
|
|
|
|
/// The most mesh patches (or triangles) imported per shading: past it, the shading is a flat
|
|
/// colour.
|
|
const MAX_PATCHES: usize = 4096;
|
|
|
|
/// An axial or radial shading as a gradient paint, with whether it extends past its start and
|
|
/// its end.
|
|
pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>) -> Option<(GradientPaint, [bool; 2])> {
|
|
let ShadingType::RadialAxial { coords, domain, function, axial, extend } = sp.shading.shading_type.as_ref() else {
|
|
return None;
|
|
};
|
|
let m = sp.matrix;
|
|
let cs = &sp.shading.color_space;
|
|
let at = |t: f32| function.eval(&smallvec::smallvec![domain[0] + (domain[1] - domain[0]) * t]);
|
|
let space = colors.shading_space(cs, at(0.0)?.len());
|
|
// RGB to decide which samples are stops; the stops keep the shading's model.
|
|
let sample = |t: f32| -> Option<Color> {
|
|
let [r, g, b, _] = cs.to_rgba(&at(t)?, 1.0, false).components();
|
|
Some(Color::rgb(round3(r), round3(g), round3(b)))
|
|
};
|
|
// Sample densely, then drop samples that linear interpolation reproduces.
|
|
const N: usize = 64;
|
|
let mut pts: Vec<(f32, [f32; 3])> = Vec::with_capacity(N + 1);
|
|
for i in 0..=N {
|
|
let t = i as f32 / N as f32;
|
|
pts.push((t, sample(t)?.to_rgb()));
|
|
}
|
|
let mut keep = vec![0usize];
|
|
for w in 1..N {
|
|
let (Some(a), Some(p), Some(b)) = (keep.last().and_then(|&k| pts.get(k)), pts.get(w), pts.get(w + 1)) else { continue };
|
|
let u = (p.0 - a.0) / (b.0 - a.0).max(1e-6);
|
|
let off = (0..3).map(|k| (a.1[k] + (b.1[k] - a.1[k]) * u - p.1[k]).abs()).fold(0.0f32, 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 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)) {
|
|
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),
|
|
}
|
|
};
|
|
let c = |x: f32, y: f32| m * Point::new(x as f64, y as f64);
|
|
let (kind, geom, stops) = if *axial {
|
|
let geom = GradientGeom { start: c(coords[0], coords[1]), end: c(coords[2], coords[3]), aspect: 1.0, focal: None };
|
|
(GradientKind::Linear, geom, ts.iter().map(|&t| stop_at(t, t)).collect::<Vec<_>>())
|
|
} else {
|
|
let (r0, r1) = (coords[2].max(0.0), coords[5].max(1e-6));
|
|
// The end circle, mapped (as an ellipse) into the page; a start circle off its centre
|
|
// gives the focal point.
|
|
let p = |x: f32, y: f32| Point::new(x as f64, y as f64);
|
|
let centre = p(coords[3], coords[4]);
|
|
let mut geom = GradientGeom { start: centre, end: centre + Vec2::new(r1 as f64, 0.0), aspect: 1.0, focal: None };
|
|
geom.set_focal(Some(p(coords[0], coords[1])));
|
|
geom.transform(m, GradientKind::Radial);
|
|
// Offsets are relative to the outer radius; an inner radius shifts them outwards.
|
|
let stops = ts.iter().map(|&t| stop_at(t, (r0 + (r1 - r0) * t) / r1)).collect();
|
|
(GradientKind::Radial, geom, stops)
|
|
};
|
|
let mut g = GradientPaint::new(Gradient { kind, stops });
|
|
g.geom = Some(geom);
|
|
Some((g, *extend))
|
|
}
|
|
|
|
/// 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.
|
|
pub(crate) fn extend_clip(sp: &ShadingPattern, bounds: Rect) -> Option<BezPath> {
|
|
let ShadingType::RadialAxial { coords, axial, extend, .. } = sp.shading.shading_type.as_ref() else {
|
|
return None;
|
|
};
|
|
if extend[0] && extend[1] {
|
|
return None;
|
|
}
|
|
let m = sp.matrix;
|
|
let inv = m.inverse();
|
|
let local = inv.transform_rect_bbox(bounds);
|
|
if !local.is_finite() || m.determinant().abs() < 1e-12 {
|
|
return None;
|
|
}
|
|
let f = |i: usize| coords.get(i).copied().unwrap_or(0.0) as f64;
|
|
let mut out = BezPath::new();
|
|
if *axial {
|
|
let (p0, p1) = (Point::new(f(0), f(1)), Point::new(f(2), f(3)));
|
|
let len = (p1 - p0).hypot();
|
|
if len < 1e-9 {
|
|
return None;
|
|
}
|
|
let u = (p1 - p0) / len;
|
|
let n = Vec2::new(-u.y, u.x);
|
|
let corners = [local.origin(), Point::new(local.x1, local.y0), Point::new(local.x1, local.y1), Point::new(local.x0, local.y1)];
|
|
let along = corners.map(|c| (c - p0).dot(u));
|
|
let across = corners.map(|c| (c - p0).dot(n));
|
|
let min = |v: [f64; 4]| v.iter().copied().fold(f64::INFINITY, f64::min);
|
|
let max = |v: [f64; 4]| v.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
|
let lo = if extend[0] { min(along) } else { min(along).max(0.0) };
|
|
let hi = if extend[1] { max(along) } else { max(along).min(len) };
|
|
let (b0, b1) = (min(across), max(across));
|
|
if hi > lo {
|
|
let q = |a: f64, b: f64| m * (p0 + u * a + n * b);
|
|
out.move_to(q(lo, b0));
|
|
out.line_to(q(hi, b0));
|
|
out.line_to(q(hi, b1));
|
|
out.line_to(q(lo, b1));
|
|
out.close_path();
|
|
}
|
|
return Some(out);
|
|
}
|
|
let start = Circle::new((f(0), f(1)), f(2).max(0.0));
|
|
let end = Circle::new((f(3), f(4)), f(5).max(0.0));
|
|
// Past the end: the end circle bounds the paint; before the start: the start circle is a hole.
|
|
let outer = if extend[1] { local.inflate(1.0, 1.0).to_path(0.1) } else { end.to_path(0.1) };
|
|
out.extend(m * outer);
|
|
if !extend[0] && start.radius > 1e-9 {
|
|
out.extend(m * start.to_path(0.1));
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
/// The colour of shading components `v` (the input of the shading's function, when it has one).
|
|
fn mesh_color(v: &[f32], function: Option<&ShadingFunction>, sp: &ShadingPattern, colors: &mut Colors<'_>) -> Color {
|
|
let comps = match function {
|
|
Some(f) => f.eval(&v.iter().copied().collect()).map(|c| c.to_vec()).unwrap_or_default(),
|
|
None => v.to_vec(),
|
|
};
|
|
let space = colors.shading_space(&sp.shading.color_space, comps.len());
|
|
colors.native(space, &comps).map(|n| n.color).unwrap_or_else(|| {
|
|
let [r, g, b, _] = sp.shading.color_space.to_rgba(&comps, 1.0, false).components();
|
|
Color::rgb(round3(r), round3(g), round3(b))
|
|
})
|
|
}
|
|
|
|
/// A patch mesh or triangle mesh shading as gradient meshes in document space (one per patch;
|
|
/// a triangle is a patch with two corners together). `None`: not a mesh, or too big.
|
|
pub(crate) fn mesh_shading(sp: &ShadingPattern, colors: &mut Colors<'_>) -> Option<Vec<GradientMesh>> {
|
|
let m = sp.matrix;
|
|
let mut out = vec![];
|
|
match sp.shading.shading_type.as_ref() {
|
|
ShadingType::CoonsPatchMesh { patches, function } => {
|
|
if patches.len() > MAX_PATCHES {
|
|
return None;
|
|
}
|
|
for p in patches {
|
|
let colors = [0, 1, 2, 3].map(|i| mesh_color(p.colors.get(i).map_or(&[][..], |c| c.as_slice()), function.as_ref(), sp, colors));
|
|
out.extend(GradientMesh::coons(&p.control_points.map(|q| m * q), colors));
|
|
}
|
|
}
|
|
// A tensor patch's boundary is a Coons patch's (its four inner points are dropped).
|
|
ShadingType::TensorProductPatchMesh { patches, function } => {
|
|
if patches.len() > MAX_PATCHES {
|
|
return None;
|
|
}
|
|
for p in patches {
|
|
let colors = [0, 1, 2, 3].map(|i| mesh_color(p.colors.get(i).map_or(&[][..], |c| c.as_slice()), function.as_ref(), sp, colors));
|
|
out.extend(p.control_points.map(|q| m * q).first_chunk::<12>().and_then(|cp| GradientMesh::coons(cp, colors)));
|
|
}
|
|
}
|
|
ShadingType::TriangleMesh { triangles, function } => {
|
|
if triangles.len() > MAX_PATCHES {
|
|
return None;
|
|
}
|
|
for t in triangles {
|
|
let v = [&t.p0, &t.p1, &t.p2].map(|v| (m * v.point, mesh_color(v.colors.as_slice(), function.as_ref(), sp, colors)));
|
|
out.extend(GradientMesh::triangle(v));
|
|
}
|
|
}
|
|
_ => return None,
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
/// The only painted leaf of mask art (through groups of one object and clip groups of one).
|
|
fn only_leaf(n: &Node) -> Option<&Node> {
|
|
match &n.kind {
|
|
NodeKind::Group { children, clip } => {
|
|
let rest = if *clip { children.get(1..)? } else { children.as_slice() };
|
|
match rest {
|
|
[one] if n.opacity >= 0.999 && n.mask.is_none() => only_leaf(one),
|
|
_ => None,
|
|
}
|
|
}
|
|
NodeKind::Path { .. } => Some(n),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The gradient a path paints with its only fill or stroke.
|
|
fn only_gradient(n: &Node) -> Option<&GradientPaint> {
|
|
match n.appearance.items.as_slice() {
|
|
[item] => match item.paint() {
|
|
Paint::Gradient(g) => Some(g),
|
|
_ => None,
|
|
},
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn same_geom(a: &GradientGeom, b: &GradientGeom) -> bool {
|
|
let near = |p: Point, q: Point| p.distance(q) < 0.5;
|
|
near(a.start, b.start) && near(a.end, b.end) && near(a.focal_point(), b.focal_point()) && (a.aspect - b.aspect).abs() < 0.01
|
|
}
|
|
|
|
/// `content` (a path painting one gradient) drawn through `mask` (clipped, not inverted, the same
|
|
/// gradient in shades of grey: how gradient stop opacity is written) as one gradient with the
|
|
/// mask's luminance as stop opacity.
|
|
pub(crate) fn fold_stop_opacity(content: &Node, mask: &OpacityMask) -> Option<Node> {
|
|
if !mask.clip || mask.invert || mask.disabled || content.is_container() {
|
|
return None;
|
|
}
|
|
let g = only_gradient(content)?;
|
|
let leaf = only_leaf(&mask.art)?;
|
|
let mg = only_gradient(leaf)?;
|
|
let opaque = |n: &Node| n.opacity >= 0.999 && n.appearance.items.iter().all(|i| i.opacity() >= 0.999);
|
|
if mg.gradient.kind != g.gradient.kind || !opaque(leaf) || !same_geom(g.geom.as_ref()?, mg.geom.as_ref()?) {
|
|
return None;
|
|
}
|
|
let mut offsets: Vec<f32> = g.gradient.stops.iter().chain(&mg.gradient.stops).map(|s| s.offset).collect();
|
|
offsets.sort_by(f32::total_cmp);
|
|
offsets.dedup_by(|a, b| (*a - *b).abs() < 1e-4);
|
|
let stops = offsets
|
|
.iter()
|
|
.map(|&t| {
|
|
let (mc, mo) = mg.gradient.sample(t);
|
|
let alpha = round3(luminance(&mc) * mo);
|
|
let mut s = match g.gradient.stops.iter().find(|s| (s.offset - t).abs() < 1e-4) {
|
|
Some(s) => s.clone(),
|
|
None => {
|
|
let (c, o) = g.gradient.sample_with(t, lerp_color);
|
|
GradientStop { opacity: o, ..GradientStop::new(t, c) }
|
|
}
|
|
};
|
|
s.opacity = round3(s.opacity * alpha);
|
|
s
|
|
})
|
|
.collect();
|
|
let mut out = content.clone();
|
|
let mut paint = (*g).clone();
|
|
paint.gradient.stops = stops;
|
|
if let Some(item) = out.appearance.items.first_mut() {
|
|
*item.paint_mut() = Paint::Gradient(Box::new(paint));
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
/// `node` (filling `bounds`) clipped to `clip`: a clip group.
|
|
pub(crate) fn clipped(clip: Node, node: Node, id: vectorcraft_doc::NodeId) -> Node {
|
|
Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(node)], clip: true })
|
|
}
|