Merge main into feat/native-save-precision

This commit is contained in:
dotCooCoo
2026-10-29 11:11:17 -07:00
154 changed files with 5890 additions and 433 deletions
+1 -1
View File
@@ -125,7 +125,7 @@ pub fn oversized_tiff(bytes: &[u8]) -> Option<(u32, u32)> {
}
/// The color type of TIFF `bytes` and its decoder; `None` for [`oversized_tiff`] images too.
fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder<Cursor<&[u8]>>)> {
pub(crate) fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder<Cursor<&[u8]>>)> {
if !is_tiff(bytes) || oversized_tiff(bytes).is_some() {
return None;
}
+76
View File
@@ -0,0 +1,76 @@
//! Compound shapes (Pathfinder panel › Make Compound Shape): a group whose members each have a
//! shape mode. It draws as the one area the modes make, from the backmost member up, painted with
//! the group's own appearance; the members stay editable. Evaluated in `vectorcraft-effects`.
use serde::{Deserialize, Deserializer, Serialize};
use crate::{Node, NodeKind};
/// How a compound shape's member combines with the area of the members below it. The backmost
/// member's mode does not matter: it starts the area.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ShapeMode {
/// Adds the member's area.
#[default]
Add,
/// Takes the member's area away.
Subtract,
/// Keeps only what the member's area overlaps.
Intersect,
/// Keeps where exactly one of the area and the member's area is.
Exclude,
}
impl ShapeMode {
/// Every mode, in the Pathfinder panel's order.
pub const ALL: [ShapeMode; 4] = [ShapeMode::Add, ShapeMode::Subtract, ShapeMode::Intersect, ShapeMode::Exclude];
/// The mode's name in commands and files.
pub fn name(self) -> &'static str {
match self {
ShapeMode::Add => "add",
ShapeMode::Subtract => "subtract",
ShapeMode::Intersect => "intersect",
ShapeMode::Exclude => "exclude",
}
}
/// The mode named `s` ([`ShapeMode::name`]).
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|m| m.name() == s)
}
}
/// Files are untrusted: a mode that isn't one of the names reads as Add.
impl<'de> Deserialize<'de> for ShapeMode {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = serde_json::Value::deserialize(d)?;
Ok(v.as_str().and_then(ShapeMode::parse).unwrap_or_default())
}
}
impl Node {
/// Is this a compound shape? Only a group that doesn't clip can be one: the mark on anything
/// else (from a file) is ignored.
pub fn is_compound_shape(&self) -> bool {
matches!(self.kind, NodeKind::Group { clip: false, .. }) && self.extras().compound_shape
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modes_read_back_and_unknown_ones_are_add() {
for m in ShapeMode::ALL {
let v = serde_json::to_value(m).unwrap();
assert_eq!(v, serde_json::json!(m.name()));
assert_eq!(serde_json::from_value::<ShapeMode>(v).unwrap(), m);
}
for v in [serde_json::json!("divide"), serde_json::json!(3), serde_json::json!(null), serde_json::json!({"a": [1]})] {
assert_eq!(serde_json::from_value::<ShapeMode>(v).unwrap(), ShapeMode::Add);
}
}
}
+6 -3
View File
@@ -109,9 +109,9 @@ fn visit_paint(p: &mut Paint, f: &mut ColorVisitor) {
}
}
/// Visit every colour of a node tree (fills, strokes, gradient stops, text runs, mesh points, not
/// opacity masks) with its swatch link and tint. Mesh points have no link: changes to it are
/// dropped.
/// Visit every colour of a node tree (fills, strokes, gradient stops, text runs, mesh points, an
/// image's colorize color, not opacity masks) with its swatch link and tint. Mesh points have no
/// link: changes to it are dropped.
pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) {
for it in &mut n.appearance.items {
match it {
@@ -119,6 +119,9 @@ pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) {
AppearanceItem::Stroke(l) => visit_paint(&mut l.paint, f),
}
}
if let Some(p) = n.extras.0.as_deref_mut().and_then(|e| e.colorize.as_deref_mut()) {
visit_paint(p, f);
}
match &mut n.kind {
NodeKind::Text(t) => {
for r in &mut t.runs {
+3 -1
View File
@@ -11,6 +11,7 @@ pub mod blend;
pub mod cancel;
pub mod clipnest;
pub mod cmyk;
pub mod compound_shape;
pub mod corners;
pub mod depth;
pub mod dimension;
@@ -42,7 +43,7 @@ pub mod overprint;
pub mod pattern;
pub mod perspective;
mod pixels;
pub use pixels::MAX_RASTER_ALLOC;
pub use pixels::{GraySource, MAX_RASTER_ALLOC};
pub mod placed_document;
pub mod plugin_data;
pub mod profiles;
@@ -76,6 +77,7 @@ pub use appearance::{
StrokeAlign, StrokeLayer, WidthProfile,
};
pub use assets::ExportAsset;
pub use compound_shape::ShapeMode;
pub use corners::LiveCorners;
pub use depth::{MAX_DEPTH, MAX_SYMBOL_NODES};
pub use graph::{
+19
View File
@@ -221,6 +221,25 @@ mod tests {
assert!(matches!(m.color_at(0, 0, 0.5, 0.5).0, Color::Rgb { .. }));
}
/// #3232: the Appearance highlight lightens each point towards white in its own model, so a
/// CMYK mesh stays CMYK (K shrinks with the other inks) and a grey one stays grey.
#[test]
fn mesh_highlight_keeps_each_points_model() {
let square = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
let ink = Color::Cmyk { c: 0.0, m: 0.4, y: 0.6, k: 0.1 };
let m = GradientMesh::for_path(&square, 2, 2, ink, MeshAppearance::ToCenter, 50.0).unwrap();
assert_eq!(m.points[0].color, ink, "corners stay");
let centre = m.points[m.idx(1, 1)].color;
let Color::Cmyk { c, m: mg, y, k } = centre else { panic!("{centre:?}") };
assert!(c.abs() < 1e-6 && (mg - 0.2).abs() < 1e-6 && (y - 0.3).abs() < 1e-6 && (k - 0.05).abs() < 1e-6, "{c} {mg} {y} {k}");
assert!(matches!(m.color_at(0, 0, 0.5, 0.5).0, Color::Cmyk { .. }), "patches mix in CMYK");
let m = GradientMesh::for_path(&square, 2, 2, Color::gray(0.8), MeshAppearance::ToEdge, 100.0).unwrap();
assert_eq!((m.points[0].color, m.points[m.idx(1, 1)].color), (Color::gray(0.0), Color::gray(0.8)));
// RGB lightens in RGB as before.
let m = GradientMesh::for_path(&square, 2, 2, Color::rgb(1.0, 0.0, 0.0), MeshAppearance::ToCenter, 100.0).unwrap();
assert_eq!(m.points[m.idx(1, 1)].color.to_hex(), "#ffffff");
}
/// Alt-clicking a point of a mesh from a file with fewer points than its rows and columns need
/// deletes nothing (it indexed past the points, a panic).
#[test]
+4 -2
View File
@@ -267,7 +267,8 @@ impl GradientMesh {
}
/// Create Gradient Mesh → Appearance: lighten the points towards white, to the centre or to
/// the edges, by at most `highlight` %.
/// the edges, by at most `highlight` %. Each point lightens towards white in its own colour
/// model (paper white, no ink, for CMYK), so a CMYK or grey mesh keeps its inks (#3232).
pub fn highlight(&mut self, appearance: MeshAppearance, highlight: f64) {
let h = (highlight / 100.0).clamp(0.0, 1.0) as f32;
let (rows, cols) = (self.rows as usize, self.cols as usize);
@@ -282,7 +283,8 @@ impl GradientMesh {
};
if w > 0.0 {
let i = self.idx(r, c);
self.points[i].color = lerp_color(&self.points[i].color, &Color::WHITE, w);
let color = self.points[i].color;
self.points[i].color = lerp_color(&color, &Color::WHITE.in_model(color.model()), w);
}
}
}
+1 -1
View File
@@ -250,7 +250,7 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
if let Some(e) = n.extras_box() {
b += ALLOC + size_of::<NodeExtras>();
b += boxed(&e.mask) + boxed(&e.graph) + boxed(&e.shaper) + boxed(&e.attrs) + boxed(&e.slice) + boxed(&e.perspective);
b += boxed(&e.dimension) + boxed(&e.live_paint_gaps);
b += boxed(&e.dimension) + boxed(&e.live_paint_gaps) + boxed(&e.colorize);
b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::<Value>() + json_bytes(t));
b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
b += e.objects_on_path.as_deref().map_or(0, |o| {
+12
View File
@@ -49,6 +49,15 @@ pub struct NodeExtras {
/// Object › Objects on Path: the group's path and the objects attached to it (see
/// [`crate::objects_on_path`]).
pub objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
/// A grayscale or 1-bit image's fill color (`paint.setFill` on the image): a solid paint that
/// colorizes it. Each pixel is drawn in this color, as opaque as the pixel is dark, so white
/// pixels are transparent. `None`: the image shows its own pixels.
pub colorize: Option<Box<vectorcraft_color::Paint>>,
/// Pathfinder panel › Make Compound Shape: this group is a compound shape
/// ([`Node::is_compound_shape`], [`crate::compound_shape`]).
pub compound_shape: bool,
/// As a member of a compound shape, how this object combines with the members below it.
pub shape_mode: crate::ShapeMode,
}
/// No extras: what [`Node::extras`] gives for a node without any.
@@ -69,6 +78,9 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
intertwine: None,
live_paint_gaps: None,
objects_on_path: None,
colorize: None,
compound_shape: false,
shape_mode: crate::ShapeMode::Add,
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
+9 -1
View File
@@ -127,6 +127,7 @@ impl Node {
NodeKind::Layer { .. } => "Layer",
NodeKind::Group { clip: true, .. } => "Clip Group",
NodeKind::Group { .. } if self.extras().objects_on_path.is_some() => "Objects on Path",
NodeKind::Group { .. } if self.extras().compound_shape => "Compound Shape",
NodeKind::Group { .. } => "Group",
NodeKind::Path { live: Some(l), .. } => l.label(),
NodeKind::Path { guide: true, .. } => "Guide",
@@ -545,9 +546,16 @@ impl Node {
/// item) stands in for the topmost fill or stroke when it is of the proxy's kind. Otherwise
/// type objects paint their runs (the first run speaks for all) in text space, fitted to the
/// layout bounds; everything else paints its fill or stroke in the document, fitted to the
/// geometric bounds. Strokes fit the box grown by half their weight.
/// geometric bounds. Strokes fit the box grown by half their weight. A colorized image shows
/// its colorize paint as its fill.
pub fn proxy_paint(&self, stroke: bool, item: Option<usize>) -> Option<(&vectorcraft_color::Paint, Affine, Rect)> {
let item = self.appearance.item_of_kind(item, !stroke);
if item.is_none()
&& !stroke
&& let Some(c) = self.extras().colorize.as_deref()
{
return Some((c, Affine::IDENTITY, self.geometric_bounds().unwrap_or(Rect::ZERO)));
}
if item.is_none()
&& let NodeKind::Text(t) = &self.kind
{
+5
View File
@@ -19,6 +19,8 @@ fn extras_are_written_flat_in_order() {
e.slice = Some(Box::default());
e.perspective = Some(Box::new(crate::PerspectiveAttachment::new("left", 1.0)));
e.live_paint_gaps = Some(Box::default());
e.compound_shape = true;
e.shape_mode = crate::ShapeMode::Exclude;
}
/// The top-level keys of a JSON object, in the order written.
struct Keys(Vec<String>);
@@ -61,8 +63,11 @@ fn extras_are_written_flat_in_order() {
"bbox_angle",
"perspective",
"live_paint_gaps",
"compound_shape",
"shape_mode",
];
assert_eq!(keys, order);
assert_eq!(v["shape_mode"], "exclude");
assert_eq!(serde_json::from_value::<Node>(v).unwrap(), n);
let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art());
let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap();
+22 -5
View File
@@ -5,6 +5,12 @@ use super::kind::NodeKind;
use super::opacity::{Knockout, OpacityMask};
use super::{Appearance, BlendMode, Deserialize, Node, PathData, Serialize, one, yes};
/// Field `name` of a [`Node`] written as `v`, or skipped. A function of its own, so the long field
/// list stays one straight run of calls.
fn write_field<S: serde::ser::SerializeStruct, T: Serialize + ?Sized>(st: &mut S, skip: bool, name: &'static str, v: &T) -> Result<(), S::Error> {
if skip { st.skip_field(name) } else { st.serialize_field(name, v) }
}
/// How a [`Node`] is written: the flat field list of the file format, in its order, borrowed from
/// the node and its extras (nothing is copied). The node reads back through `NodeWire`.
impl Serialize for Node {
@@ -42,16 +48,15 @@ impl Serialize for Node {
e.intertwine.is_none(),
e.live_paint_gaps.is_none(),
e.objects_on_path.is_none(),
e.colorize.is_none(),
is_default(&e.compound_shape),
is_default(&e.shape_mode),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
macro_rules! field {
($name:literal, $v:expr) => {
if skip.next().unwrap_or(false) {
st.skip_field($name)?;
} else {
st.serialize_field($name, $v)?;
}
write_field(&mut st, skip.next().unwrap_or(false), $name, $v)?;
};
}
field!("id", &self.id);
@@ -82,6 +87,9 @@ impl Serialize for Node {
field!("intertwine", &e.intertwine);
field!("live_paint_gaps", &e.live_paint_gaps);
field!("objects_on_path", &e.objects_on_path);
field!("colorize", &e.colorize);
field!("compound_shape", &e.compound_shape);
field!("shape_mode", &e.shape_mode);
st.end()
}
}
@@ -182,6 +190,12 @@ pub(super) struct NodeWire {
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
#[serde(default)]
objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
#[serde(default)]
colorize: Option<Box<vectorcraft_color::Paint>>,
#[serde(default)]
compound_shape: bool,
#[serde(default)]
shape_mode: crate::ShapeMode,
}
impl From<NodeWire> for Node {
@@ -203,6 +217,9 @@ impl From<NodeWire> for Node {
intertwine: w.intertwine,
live_paint_gaps: w.live_paint_gaps,
objects_on_path: w.objects_on_path,
colorize: w.colorize,
compound_shape: w.compound_shape,
shape_mode: w.shape_mode,
};
Node {
id: w.id,
+117
View File
@@ -62,6 +62,61 @@ impl ImageBlob {
}
}
/// How a grayscale image's file stores its pixels ([`ImageBlob::gray_source`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct GraySource {
/// One bit a pixel: black and white only.
pub one_bit: bool,
/// The file has an alpha channel.
pub alpha: bool,
}
impl ImageBlob {
/// Whether the image's file stores grayscale or 1-bit pixels (the images a fill color
/// colorizes), and how; `None` for color images and data that isn't an image. Reads only its
/// header.
pub fn gray_source(&self) -> Option<GraySource> {
let gray = |one_bit: bool, alpha: bool| Some(GraySource { one_bit, alpha });
if let Some((kind, _)) = crate::cmyk::tiff(&self.bytes) {
return match kind {
tiff::ColorType::Gray(bits) => gray(bits == 1, false),
tiff::ColorType::GrayA(bits) => gray(bits == 1, true),
_ => None,
};
}
if crate::cmyk::jpeg_components(&self.bytes) == Some(1) {
return gray(false, false);
}
use image::{ExtendedColorType as E, ImageDecoder as _};
let reader = image::ImageReader::new(Cursor::new(self.bytes.as_slice())).with_guessed_format().ok()?;
match reader.into_decoder().ok()?.original_color_type() {
E::L1 => gray(true, false),
E::La1 => gray(true, true),
E::L2 | E::L4 | E::L8 | E::L16 => gray(false, false),
E::La2 | E::La4 | E::La8 | E::La16 => gray(false, true),
_ => None,
}
}
/// How much of a colorize paint each pixel shows, row by row from the top: how dark the pixel
/// is (255 less its Rec. 709 luma) scaled by its alpha, so white and transparent pixels are 0.
/// Returns the width, the height and one byte a pixel; `None` when the image can't be decoded
/// within [`MAX_RASTER_ALLOC`].
pub fn coverage(&self) -> Option<(u32, u32, Vec<u8>)> {
let img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?;
let (w, h) = img.dimensions();
let cover = img
.pixels()
.map(|p| {
let [r, g, b, a] = p.0;
let luma = 0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * b as f32;
((255.0 - luma).clamp(0.0, 255.0) * a as f32 / 255.0).round() as u8
})
.collect();
Some((w, h, cover))
}
}
impl ImageBlob {
/// The colour around pixel (`x`, `y`) (pixel space): the alpha-weighted average of the `size` ×
/// `size` pixels centred on it, clipped to the image, as straight RGBA. `None` outside the
@@ -298,4 +353,66 @@ mod tests {
assert!(b.map_rgb(|c| c).is_none(), "nothing changed");
assert!(ImageBlob::png(vec![1, 2, 3]).map_rgb(|c| c).is_none(), "undecodable");
}
/// An uncompressed 1-bit TIFF, BlackIsZero, of `w` × `h` pixels: `rows` holds each row's bits,
/// padded to a byte, most significant bit first.
fn bilevel_tiff(w: u16, h: u16, rows: &[u8]) -> Vec<u8> {
// Tag, type (3 short, 4 long) and value of each entry, in tag order.
let data_at = 8 + 2 + 9 * 12 + 4;
let entries: [(u16, u16, u32); 9] = [
(256, 3, w.into()),
(257, 3, h.into()),
(258, 3, 1),
(259, 3, 1),
(262, 3, 1),
(273, 4, data_at),
(277, 3, 1),
(278, 3, h.into()),
(279, 4, rows.len() as u32),
];
let mut out = b"II*\0".to_vec();
out.extend_from_slice(&8u32.to_le_bytes());
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
for (tag, kind, value) in entries {
out.extend_from_slice(&tag.to_le_bytes());
out.extend_from_slice(&kind.to_le_bytes());
out.extend_from_slice(&1u32.to_le_bytes());
out.extend_from_slice(&value.to_le_bytes());
}
out.extend_from_slice(&0u32.to_le_bytes());
out.extend_from_slice(rows);
out
}
fn png_of(img: image::DynamicImage) -> ImageBlob {
let mut png = Vec::new();
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
ImageBlob::png(png)
}
#[test]
fn grayscale_and_1_bit_files_are_told_from_color_ones() {
let gray = |one_bit, alpha| Some(GraySource { one_bit, alpha });
let l8 = png_of(image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap()));
assert_eq!(l8.gray_source(), gray(false, false));
let la8 = png_of(image::DynamicImage::ImageLumaA8(image::GrayAlphaImage::from_raw(1, 1, vec![0, 128]).unwrap()));
assert_eq!(la8.gray_source(), gray(false, true));
let mut jpeg = Vec::new();
jpeg_encoder::Encoder::new(&mut jpeg, 90).encode(&[0, 255, 0, 255], 2, 2, jpeg_encoder::ColorType::Luma).unwrap();
assert_eq!(ImageBlob::new("image/jpeg", jpeg).gray_source(), gray(false, false));
// Black, white; white, black.
let tif = ImageBlob::new("image/tiff", bilevel_tiff(2, 2, &[0b0100_0000, 0b1000_0000]));
assert_eq!(tif.gray_source(), gray(true, false));
assert_eq!(tif.coverage(), Some((2, 2, vec![255, 0, 0, 255])), "black covers, white doesn't");
// Color images and data that isn't an image.
assert_eq!(blob(&[[0, 0, 0, 255], [9, 9, 9, 255]], 2, 1).gray_source(), None);
assert_eq!(ImageBlob::png(vec![1, 2, 3]).gray_source(), None);
}
#[test]
fn coverage_is_darkness_times_alpha() {
let la8 = png_of(image::DynamicImage::ImageLumaA8(image::GrayAlphaImage::from_raw(4, 1, vec![0, 255, 255, 255, 51, 255, 0, 128]).unwrap()));
assert_eq!(la8.coverage(), Some((4, 1, vec![255, 0, 204, 128])));
assert_eq!(ImageBlob::png(vec![1, 2, 3]).coverage(), None);
}
}
+5 -3
View File
@@ -229,13 +229,14 @@ fn item_paint_mut(it: &mut AppearanceItem) -> &mut Paint {
}
}
/// The paints a node holds itself (not its children): appearance items, then text runs.
/// The paints a node holds itself (not its children): appearance items, then text runs, then an
/// image's colorize paint.
pub fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
let runs = match &n.kind {
NodeKind::Text(t) => t.runs.as_slice(),
_ => &[],
};
n.appearance.items.iter().map(item_paint).chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke]))
n.appearance.items.iter().map(item_paint).chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke])).chain(n.extras().colorize.as_deref())
}
/// Mutable [`node_paints`], in the same order.
@@ -244,7 +245,8 @@ pub fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
NodeKind::Text(t) => t.runs.as_mut_slice(),
_ => &mut [],
};
n.appearance.items.iter_mut().map(item_paint_mut).chain(runs.iter_mut().flat_map(|r| [&mut r.style.fill, &mut r.style.stroke]))
let colorize = n.extras.0.as_deref_mut().and_then(|e| e.colorize.as_deref_mut());
n.appearance.items.iter_mut().map(item_paint_mut).chain(runs.iter_mut().flat_map(|r| [&mut r.style.fill, &mut r.style.stroke])).chain(colorize)
}
/// `n` with `f` applied to its subtree's linkable colours, or `None` when nothing changed (then