Merge pull request #4431 from dotCooCoo/feat/colorize-gray-images

Images: a fill color colorizes grayscale and 1-bit images
This commit is contained in:
BFlat
2026-10-30 00:44:29 +07:00
committed by GitHub
52 changed files with 1097 additions and 52 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;
}
+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 {
+1 -1
View File
@@ -42,7 +42,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;
+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| {
+5
View File
@@ -49,6 +49,10 @@ 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>>,
}
/// No extras: what [`Node::extras`] gives for a node without any.
@@ -69,6 +73,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
intertwine: None,
live_paint_gaps: None,
objects_on_path: None,
colorize: None,
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
+8 -1
View File
@@ -545,9 +545,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
@@ -42,6 +42,7 @@ impl Serialize for Node {
e.intertwine.is_none(),
e.live_paint_gaps.is_none(),
e.objects_on_path.is_none(),
e.colorize.is_none(),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
@@ -82,6 +83,7 @@ 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);
st.end()
}
}
@@ -182,6 +184,8 @@ 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>>,
}
impl From<NodeWire> for Node {
@@ -203,6 +207,7 @@ 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,
};
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
+2 -1
View File
@@ -645,6 +645,7 @@ fn set_spot(s: &mut Session, p: &Value) -> Result<Value> {
}
if sw.spot != spot {
let n = name.clone();
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
s.edit("Swatch Options", |d, _| {
if let Some(w) = d.swatch_mut(&n) {
w.spot = spot;
@@ -652,7 +653,7 @@ fn set_spot(s: &mut Session, p: &Value) -> Result<Value> {
w.global = true;
}
}
Ok(())
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))
})?;
}
Ok(json!({"name": name, "spot": spot}))
+96 -1
View File
@@ -19,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
has_doc,
|s, p| set_paint(s, p, true)
),
@@ -465,6 +465,12 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
// The gradient mesh point last clicked with the Mesh tool or Direct Selection: a solid fill
// recolours just that point of its mesh.
let mesh_point = if fill && matches!(paint, Paint::Solid { .. }) { focused_mesh_point(s) } else { None };
// Without `item`, unless the Appearance panel's active fill item takes the paint.
let colorized = if fill && p.get("item").is_none() && item == ItemTarget::Top {
colorized_images(&s.doc()?.doc, &ids, &paint).map_err(|e| bad(cmd, e))?
} else {
vec![]
};
let label = if fill { "Fill Color" } else { "Stroke Color" };
edit_items_then(
s,
@@ -474,6 +480,10 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
label,
fill,
|n, index| {
if index.is_none() && colorized.contains(&n.id) {
n.extras_mut().colorize = paint.color().map(|_| Box::new(paint.clone()));
return Ok(());
}
if index.is_none()
&& let Paint::Solid { color, .. } = &paint
&& let Some((_, i)) = mesh_point.filter(|(id, _)| *id == n.id)
@@ -594,6 +604,91 @@ fn placed_across(d: &Document, ids: &[NodeId], paint: Paint, p: &Value, fill: bo
}
}
/// The images among `ids` that a fill of `paint` colorizes: grayscale and 1-bit images, for a solid
/// color (or none on a colorized image, which takes the color off). The color may be black, white,
/// a process color or a spot color; on an image with an alpha channel, only a spot color, black or
/// white, and other colors are an error.
fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result::Result<Vec<NodeId>, String> {
let mut out = vec![];
for id in ids {
let Some(n) = d.node(*id) else { continue };
let NodeKind::Image(im) = &n.kind else { continue };
// `none` takes a color off whatever the image's pixels have become since.
if matches!(paint, Paint::None) && n.extras().colorize.is_some() {
out.push(*id);
continue;
}
let Some(gray) = d.image_blob(im).and_then(|b| b.gray_source()) else { continue };
match paint {
// Without a color, `none` goes to the image's fill item.
Paint::None => continue,
Paint::Solid { .. } => {
if gray.alpha && !colorizes_transparency(d, paint) {
return Err(PROCESS_ON_TRANSPARENCY.into());
}
}
_ => continue,
}
out.push(*id);
}
Ok(out)
}
/// The message for a process color on a grayscale image with an alpha channel.
const PROCESS_ON_TRANSPARENCY: &str = "a process color can't colorize a grayscale image with transparency: use a spot color, black or white";
/// Can `paint` colorize a grayscale image with an alpha channel: a color linked to a spot swatch
/// of `d`, black or white?
fn colorizes_transparency(d: &Document, paint: &Paint) -> bool {
match paint {
Paint::Solid { color, swatch, .. } => swatch.as_deref().and_then(|name| d.swatch(name)).is_some_and(|w| w.spot) || black_or_white(color),
_ => true,
}
}
/// An error when an image with an alpha channel in `d` (its art, opacity masks, symbols, graph
/// designs and pattern tiles) is colorized with a color that [`colorizes_transparency`] refuses,
/// as a swatch edit that turns a spot color into a process color or recolors a process swatch
/// can leave one.
pub(super) fn check_colorized(d: &Document) -> std::result::Result<(), String> {
let mut refused = false;
let mut visit = |n: &Node| {
vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| {
if let (NodeKind::Image(im), Some(paint)) = (&m.kind, m.extras().colorize.as_deref())
&& !refused
&& !colorizes_transparency(d, paint)
&& d.image_blob(im).and_then(|b| b.gray_source()).is_some_and(|g| g.alpha)
{
refused = true;
}
})
};
d.layers
.iter()
.chain(d.symbols.iter().map(|s| &s.art))
.chain(d.graph_designs.iter().map(|g| &g.art))
.chain(d.patterns.iter().flat_map(|p| &p.art))
.for_each(|n| visit(n));
if refused { Err(PROCESS_ON_TRANSPARENCY.into()) } else { Ok(()) }
}
/// [`check_colorized`] after an edit, when the document passed it before the edit (`was_ok`), so
/// an edit doesn't fail over an image it didn't change.
pub(super) fn check_colorized_kept(was_ok: bool, d: &Document) -> std::result::Result<(), String> {
if was_ok { check_colorized(d) } else { Ok(()) }
}
/// Is `c` black or white (white, or black alone at 100%)?
fn black_or_white(c: &Color) -> bool {
let (none, full) = (|v: f32| v <= 0.0, |v: f32| v >= 1.0);
match *c {
Color::Gray { k } => none(k) || full(k),
Color::Cmyk { c, m, y, k } => none(c) && none(m) && none(y) && (none(k) || full(k)),
Color::Rgb { r, g, b } => [r, g, b].into_iter().all(none) || [r, g, b].into_iter().all(full),
Color::Lab { .. } => false,
}
}
/// The mesh point the active tool has focused (its `meshPoint` option: the Mesh tool's or Direct
/// Selection's point last clicked).
fn focused_mesh_point(s: &Session) -> Option<(NodeId, usize)> {
+14 -3
View File
@@ -252,12 +252,15 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
}
let mut unlink =
|_: &mut Color, link: &mut Option<String>, _: &mut f32| link.as_ref().is_some_and(|l| global.contains(l)) && link.take().is_some();
let colorized_ok = super::paint::check_colorized(d).is_ok();
let unlinked = s.edit("Delete Swatch", |d, _| {
d.swatch_groups.retain(|g| !names.contains(&g.name));
for n in &names {
d.remove_swatch(n);
}
Ok(map_swatch_links(d, &mut unlink))
let unlinked = map_swatch_links(d, &mut unlink);
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
Ok(unlinked)
})?;
map_default_paints(s, &mut unlink);
Ok(json!({"deleted": names, "unlinked": unlinked}))
@@ -703,6 +706,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
let global = spot || bool_or(p, "global", sw.global);
let to = name_param(p, "newName").filter(|n| *n != name).map_or_else(|| name.clone(), |n| d.free_swatch_name(&n));
let relink = Relink { from: name.clone(), to: to.clone(), color: color.filter(|_| global).map(|c| d.linked_color(c, spot)), keep: global };
let colorized_ok = super::paint::check_colorized(d).is_ok();
let relinked = s.edit("Swatch Options", |d, _| {
let w = d.swatch_mut(&name).ok_or_else(|| bad(C, format!("no swatch `{name}`")))?;
w.name = to.clone();
@@ -716,7 +720,9 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
Ok(map_swatch_links(d, &mut |c, l, t| relink.apply(c, l, t)))
let relinked = map_swatch_links(d, &mut |c, l, t| relink.apply(c, l, t));
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
Ok(relinked)
})?;
relink.defaults(s);
Ok(json!({"name": to, "relinked": relinked}))
@@ -867,11 +873,14 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result<Value> {
*l = link.clone();
true
};
let colorized_ok = super::paint::check_colorized(d).is_ok();
let relinked = s.edit("Merge Swatches", |d, _| {
for n in merged {
d.remove_swatch(n);
}
Ok(map_swatch_links(d, &mut relink))
let relinked = map_swatch_links(d, &mut relink);
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
Ok(relinked)
})?;
map_default_paints(s, &mut relink);
Ok(json!({"name": keep, "merged": merged, "relinked": relinked}))
@@ -980,6 +989,7 @@ pub(super) fn rewrite_group(d: &mut Document, group: &str, colors: &[Color], ren
{
return Err(bad(cmd, format!("`{}` can't be edited or removed", w.name)));
}
let colorized_ok = super::paint::check_colorized(d).is_ok();
let mut relinks = vec![];
let mut swatches = std::mem::take(&mut d.swatch_groups[gi].swatches);
for gone in swatches.split_off(colors.len().min(swatches.len())) {
@@ -1005,6 +1015,7 @@ pub(super) fn rewrite_group(d: &mut Document, group: &str, colors: &[Color], ren
(g.name, g.swatches) = (name.clone(), swatches);
let names = g.swatches.iter().map(|w| w.name.clone()).collect();
let relinked = if relinks.is_empty() { 0 } else { map_swatch_links(d, &mut |c, l, t| relinks.iter().any(|r| r.apply(c, l, t))) };
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?;
Ok(GroupEdit { name, swatches: names, relinks, relinked })
}
+2
View File
@@ -657,6 +657,7 @@ fn reset_defaults(s: &mut Session, p: &Value) -> Result<Value> {
// With replace, links to swatches that are no longer global ones are dropped (the art keeps
// its colour); filled in by the edit.
let mut live: Vec<String> = vec![];
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
let added = s.edit("Default Swatches", |d, _| {
let mut added = vec![];
if replace {
@@ -665,6 +666,7 @@ fn reset_defaults(s: &mut Session, p: &Value) -> Result<Value> {
added.extend(d.swatches_iter().filter(|w| !before.contains(&w.name)).map(|w| w.name.clone()));
live.extend(d.swatches_iter().filter(|w| w.global).map(|w| w.name.clone()));
map_swatch_links(d, &mut |_, l, _| unlink_dead(&live, l));
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad("swatch.resetDefaults", e))?;
return Ok(added);
}
for (i, w) in defaults.into_iter().enumerate() {
+1
View File
@@ -26,6 +26,7 @@ mod cmdsplit;
mod cmykflatten;
mod colorguide;
mod colorguidelib;
mod colorize;
pub(super) mod colormgmt;
mod colorthemes;
mod colourfonts;
+183
View File
@@ -0,0 +1,183 @@
//! A fill color colorizes grayscale and 1-bit images (`paint.setFill` on the image).
use serde_json::json;
use vectorcraft_color::Paint;
use vectorcraft_doc::{ImageBlob, Node, NodeKind};
use crate::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 0, "m": 100, "y": 100, "k": 0}, "spot": true})).unwrap();
s
}
fn png(img: image::DynamicImage) -> ImageBlob {
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
ImageBlob::png(out)
}
/// A gray 2 × 1 image (black, white), with an alpha channel when `alpha`.
fn gray(alpha: bool) -> ImageBlob {
if alpha {
png(image::DynamicImage::ImageLumaA8(image::GrayAlphaImage::from_raw(2, 1, vec![0, 255, 255, 128]).unwrap()))
} else {
png(image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap()))
}
}
/// A selected embedded 2 × 1 image of `blob`.
fn image(s: &mut Session, key: &str, blob: ImageBlob) -> NodeId {
let key = key.to_string();
s.edit("Place", |d, sel| {
d.images.insert(key.clone(), blob);
let id = d.alloc_id();
let im = vectorcraft_doc::ImageObject {
key,
width: 2,
height: 1,
xf: Default::default(),
link: None,
placement: Default::default(),
pixels: None,
};
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
sel.set([id]);
Ok(id)
})
.unwrap()
}
fn colorize(s: &Session, id: NodeId) -> Option<Paint> {
s.doc().unwrap().doc.node(id).unwrap().extras().colorize.as_deref().cloned()
}
#[test]
fn a_fill_color_colorizes_a_gray_image_and_none_takes_it_off() {
let mut s = session();
let id = image(&mut s, "gray", gray(false));
s.execute("paint.setFill", &json!({"swatch": "Ink", "tint": 40})).unwrap();
let Some(Paint::Solid { swatch, tint, .. }) = colorize(&s, id) else { panic!("not colorized: {:?}", colorize(&s, id)) };
assert_eq!((swatch.as_deref(), tint), (Some("Ink"), 0.4));
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
assert!(n.appearance.items.is_empty(), "no fill item over the image");
assert_eq!(s.proxy_paints().0, colorize(&s, id).unwrap(), "the Fill proxy shows the color");
// A process color too.
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
assert!(matches!(colorize(&s, id), Some(Paint::Solid { swatch: None, .. })));
s.execute("edit.undo", &json!({})).unwrap();
assert!(matches!(colorize(&s, id), Some(Paint::Solid { swatch: Some(_), .. })), "undo restores the spot color");
s.execute("paint.setFill", &json!({"none": true})).unwrap();
assert_eq!(colorize(&s, id), None);
// `none` takes the color off after an edit of the pixels leaves the image in color.
s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap();
s.execute("edit.colors.invert", &json!({"includeImages": true})).unwrap();
let d = &s.doc().unwrap().doc;
let NodeKind::Image(im) = &d.node(id).unwrap().kind else { panic!() };
assert_eq!(d.image_blob(im).and_then(|b| b.gray_source()), None, "the pixels are no longer gray");
s.execute("paint.setFill", &json!({"none": true})).unwrap();
assert_eq!(colorize(&s, id), None);
}
#[test]
fn an_image_with_alpha_takes_a_spot_color_black_or_white_only() {
let mut s = session();
let id = image(&mut s, "alpha", gray(true));
let e = s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap_err().to_string();
assert!(e.contains("a process color can't colorize a grayscale image with transparency"), "{e}");
assert_eq!(colorize(&s, id), None);
s.execute("paint.setFill", &json!({"color": {"gray": 100}})).unwrap();
assert!(colorize(&s, id).is_some(), "black");
s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap();
assert!(matches!(colorize(&s, id), Some(Paint::Solid { swatch: Some(_), .. })));
}
/// A swatch edit that would leave an image with alpha in a process color is refused, as
/// `paint.setFill` refuses one.
#[test]
fn swatch_edits_keep_an_image_with_alpha_out_of_process_colors() {
let mut s = session();
s.execute("swatch.new", &json!({"name": "Other", "color": {"c": 100, "m": 0, "y": 0, "k": 0}, "global": true})).unwrap();
let id = image(&mut s, "alpha", gray(true));
s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap();
let before = colorize(&s, id);
let message = "a process color can't colorize a grayscale image with transparency";
for (cmd, p) in [
("swatch.edit", json!({"name": "Ink", "spot": false})),
("swatch.edit", json!({"name": "Ink", "spot": false, "global": false})),
("swatch.setSpot", json!({"name": "Ink", "spot": false})),
("swatch.delete", json!({"names": ["Ink"]})),
("swatch.merge", json!({"names": ["Other", "Ink"]})),
("swatch.resetDefaults", json!({"replace": true})),
] {
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains(message), "{cmd} {p}: {e}");
assert_eq!(colorize(&s, id), before, "{cmd} {p} left the image as it was");
}
// A new color for the spot swatch keeps it a spot color.
s.execute("swatch.edit", &json!({"name": "Ink", "color": {"c": 100, "m": 0, "y": 0, "k": 0}})).unwrap();
// An image without alpha takes a process color.
let mut s = session();
let id = image(&mut s, "gray", gray(false));
s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap();
s.execute("swatch.edit", &json!({"name": "Ink", "spot": false})).unwrap();
assert!(colorize(&s, id).is_some());
}
/// With the Appearance panel's active item on a fill of the image, the paint goes to that fill.
#[test]
fn the_active_fill_item_takes_the_paint_instead() {
let mut s = session();
let id = image(&mut s, "alpha", gray(true));
s.execute("appearance.addFill", &json!({})).unwrap();
let index = s.doc().unwrap().doc.node(id).unwrap().appearance.items.iter().position(|it| it.is_fill()).unwrap();
s.execute("appearance.setActiveItem", &json!({"index": index})).unwrap();
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
assert_eq!(colorize(&s, id), None);
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let blue = vectorcraft_color::Color::rgb(0.0, 0.0, 1.0);
assert_eq!(n.appearance.fill().and_then(|f| f.paint.color()), Some(blue), "the fill item took the color");
}
/// `none` on a gray image without a color takes the paint off its fill item.
#[test]
fn none_on_a_gray_image_without_a_color_clears_its_fill_item() {
let mut s = session();
let id = image(&mut s, "gray", gray(false));
s.execute("appearance.addFill", &json!({})).unwrap();
let index = s.doc().unwrap().doc.node(id).unwrap().appearance.items.iter().position(|it| it.is_fill()).unwrap();
s.execute("appearance.setActiveItem", &json!({"index": index})).unwrap();
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
s.execute("appearance.setActiveItem", &json!({"index": null})).unwrap();
s.execute("paint.setFill", &json!({"none": true})).unwrap();
assert_eq!(colorize(&s, id), None);
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
assert_eq!(n.appearance.fill().map(|f| &f.paint), Some(&Paint::None), "the fill item's paint is none");
}
#[test]
fn a_color_image_takes_a_fill_item_as_before() {
let mut s = session();
let rgb = png(image::DynamicImage::ImageRgb8(image::RgbImage::from_raw(2, 1, vec![255, 0, 0, 0, 0, 255]).unwrap()));
let id = image(&mut s, "rgb", rgb);
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
assert_eq!(colorize(&s, id), None);
assert!(s.doc().unwrap().doc.node(id).unwrap().appearance.fill().is_some(), "a fill item, as before");
}
#[test]
fn colorized_images_save_and_open_with_their_color() {
let mut s = session();
let id = image(&mut s, "gray", gray(false));
s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap();
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
assert_eq!(&back.layers, &d.layers);
assert!(back.node(id).unwrap().extras().colorize.is_some());
// Swatch edits reach it as they reach fills.
s.execute("swatch.edit", &json!({"name": "Ink", "color": {"c": 100, "m": 0, "y": 0, "k": 0}})).unwrap();
let Some(Paint::Solid { color, .. }) = colorize(&s, id) else { panic!() };
assert_eq!(color, vectorcraft_color::Color::Cmyk { c: 1.0, m: 0.0, y: 0.0, k: 0.0 });
}
+101 -2
View File
@@ -1,7 +1,106 @@
use super::exporter::{UNDECODED, writable, xf};
use super::{BlendMode, Exporter, Image, NodeKind, Rect, Size, Surface};
use super::exporter::{UNDECODED, q, writable, xf};
use super::{Arc, BlendMode, Color, Exporter, Image, NodeKind, Paint, Rect, Size, Surface, cmyk, krilla};
use krilla::color::device_n::{Colorant, DeviceNSpace, Process};
use krilla::image::{BitsPerComponent, CustomImage, ImageColorspace};
/// A colorized image's pixels: one color in every pixel, shown as much as its soft mask says.
#[derive(Clone, Hash)]
struct Tinted {
color: Arc<Vec<u8>>,
alpha: Arc<Vec<u8>>,
width: u32,
height: u32,
space: ImageColorspace,
device_n: Option<DeviceNSpace>,
}
impl CustomImage for Tinted {
fn color_channel(&self) -> &[u8] {
&self.color
}
fn alpha_channel(&self) -> Option<&[u8]> {
Some(&self.alpha)
}
fn bits_per_component(&self) -> BitsPerComponent {
BitsPerComponent::Eight
}
fn size(&self) -> (u32, u32) {
(self.width, self.height)
}
fn icc_profile(&self) -> Option<&[u8]> {
None
}
fn color_space(&self) -> ImageColorspace {
self.space
}
fn device_n_space(&self) -> Option<DeviceNSpace> {
self.device_n.clone()
}
}
impl Exporter<'_> {
/// Image `im` colorized with `paint` ([`vectorcraft_doc::NodeExtras::colorize`]): every pixel
/// in the paint's color, with the image's coverage ([`vectorcraft_doc::ImageBlob::coverage`])
/// as its soft mask, so white pixels are transparent. A spot color is written as its tint of
/// that ink (a DeviceN space of the one ink, which lists its Separation space), Registration in
/// every process ink and every spot ink of the document, other colors in gray, RGB or CMYK as
/// the Output settings write them. The coverage is resampled as the Compression settings ask
/// ([`crate::images::coverage_sized`]). `None` when the paint isn't a solid color or the pixels
/// can't be decoded; cached like [`Self::load_image`]. With `interpolate` (and the export's own
/// setting), viewers may smooth it.
pub(super) fn colorized_image(&mut self, im: &vectorcraft_doc::ImageObject, paint: &Paint, interpolate: bool) -> Option<Image> {
let Paint::Solid { color, swatch, tint } = paint else { return None };
let size = placed_size(im);
let interpolate = interpolate && self.interpolate;
let mut cache = format!("{}\u{0}{paint:?}\u{0}{:.3}x{:.3}", im.key, size.0, size.1);
if !interpolate && self.interpolate {
cache.push_str("\u{0}pixelated");
}
if let Some(i) = self.images.get(&cache) {
return i.clone();
}
let doc = self.doc;
let (width, height, alpha) = doc
.image_blob(im)
.filter(|_| !self.left_out.contains(&im.key))
.and_then(|b| b.coverage())
.and_then(|(w, h, cover)| crate::images::coverage_sized(w, h, cover, size, self.compression))?;
let n = alpha.len();
let registration = swatch.as_deref() == Some(vectorcraft_color::swatch::REGISTRATION);
let inks: Vec<(String, [f32; 4])> = if registration {
let spots: Vec<String> = doc.swatches_iter().filter(|w| w.spot).map(|w| w.name.clone()).collect();
spots.iter().filter_map(|name| self.spot_ink(name)).collect()
} else {
swatch.as_deref().and_then(|name| self.spot_ink(name)).into_iter().collect()
};
let spot_colorants =
inks.into_iter().map(|(name, full)| Colorant::Spot { name, alternate: cmyk::Color::new(q(full[0]), q(full[1]), q(full[2]), q(full[3])) });
let device_n = if registration {
DeviceNSpace::new(Process::ALL.into_iter().map(Colorant::Process).chain(spot_colorants))
} else {
DeviceNSpace::new(spot_colorants)
};
let (samples, space) = match &device_n {
Some(d) if registration => (vec![q(*tint); d.colorants().len()], ImageColorspace::DeviceN),
Some(_) => (vec![q(*tint)], ImageColorspace::DeviceN),
None if registration => (vec![q(*tint); 4], ImageColorspace::Cmyk),
None => match self.page_color(color) {
Color::Gray { k } => (vec![q(1.0 - k)], ImageColorspace::Luma),
Color::Cmyk { c, m, y, k } => ([c, m, y, k].map(q).to_vec(), ImageColorspace::Cmyk),
other => (other.to_rgb().map(q).to_vec(), ImageColorspace::Rgb),
},
};
let color = Arc::new(samples.repeat(n));
let img = Image::from_custom(Tinted { color, alpha: Arc::new(alpha), width, height, space, device_n }, interpolate).ok();
self.images.insert(cache, img.clone());
img
}
/// Image `im` placed `size` points wide and high, resampled and compressed as the Compression
/// settings say ([`crate::images::recode`], [`crate::images::recode_cmyk`] for CMYK images);
/// cached by key (and size when images are resampled). With `interpolate` (and the export's
+4 -1
View File
@@ -182,7 +182,10 @@ impl Exporter<'_> {
self.shape(s, n, &bp, *rule, page);
}
NodeKind::Text(t) => self.text(s, n, t, page),
NodeKind::Image(im) => self.image(s, im, alpha, n.extras().pixelated),
NodeKind::Image(im) => {
let colorized = n.extras().colorize.as_deref().and_then(|p| self.colorized_image(im, p, !n.extras().pixelated));
self.image(s, im, colorized.or(alpha), n.extras().pixelated)
}
NodeKind::SymbolInstance { symbol, xf } => {
// Within the objects a frame may draw of symbol instances, as on screen: nested
// instances multiply.
+16 -4
View File
@@ -9,6 +9,12 @@ impl Exporter<'_> {
/// conversion, RGB and Lab colours of CMYK documents are separated into CMYK through the
/// document's colour spaces, so the file carries press values.
fn col(&mut self, c: &Color) -> krilla::color::Color {
let c = self.page_color(c);
color(&c)
}
/// Color `c` as [`Self::col`] writes it, before it becomes the PDF writer's color.
pub(super) fn page_color(&mut self, c: &Color) -> Color {
let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk;
if let (true, true, Color::Rgb { r, g, b }) = (cmyk_doc, self.convert, c) {
self.rgb_in_cmyk.colours.insert([r.to_bits(), g.to_bits(), b.to_bits()]);
@@ -18,13 +24,21 @@ impl Exporter<'_> {
let note = note.clone();
self.warn(note);
}
color(&c)
c
}
/// The Separation colour space of spot swatch `name` (its CMYK equivalent is the alternate
/// space; a Lab spot colour also gets a Lab one, [`crate::lab_spot`], unless the Spot Colors
/// options use CMYK values); `None` when `name` isn't a spot colour.
fn separation(&mut self, name: &str) -> Option<SeparationSpace> {
let (ink, full) = self.spot_ink(name)?;
let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new(q(full[0]), q(full[1]), q(full[2]), q(full[3])));
Some(SeparationSpace::new(SeparationColorant::Custom(ink), alt))
}
/// The ink name of spot swatch `name` and its CMYK equivalent at 100% (a Lab spot color is
/// noted for [`crate::lab_spot`]); `None` when `name` isn't a spot color.
pub(super) fn spot_ink(&mut self, name: &str) -> Option<(String, [f32; 4])> {
let doc = self.doc;
let sw = doc.swatch(name).filter(|s| s.spot)?;
let color = doc.linked_color(sw.paint.color()?, true);
@@ -33,9 +47,7 @@ impl Exporter<'_> {
{
self.lab_spots.push((name.to_string(), vectorcraft_color::cms::Lab::new(l, a, b)));
}
let full = self.out.cmyk(&color, self.intent);
let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new(q(full[0]), q(full[1]), q(full[2]), q(full[3])));
Some(SeparationSpace::new(SeparationColorant::Custom(sw.name.clone()), alt))
Some((sw.name.clone(), self.out.cmyk(&color, self.intent)))
}
/// A solid paint, in the Separation colour space at its tint when it's linked to a spot swatch
+22
View File
@@ -277,6 +277,28 @@ pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, in
Recoded { image, warning: rule.instead }
}
/// A colorized image's coverage (one sample a pixel, `w` × `h`, see
/// [`ImageBlob::coverage`]) placed `size` points wide and high, resampled as the Compression
/// settings `c` ask for monochrome images (when every sample is 0 or 255) or grayscale ones.
pub(crate) fn coverage_sized(w: u32, h: u32, cover: Vec<u8>, size: (f64, f64), c: &CompressionSettings) -> Option<(u32, u32, Vec<u8>)> {
let mono = cover.iter().all(|v| *v == 0 || *v == 255);
let rule = rule(c, if mono { Kind::Mono } else { Kind::Gray });
let to = sized(&rule, (w, h), size);
if to == (w, h) {
return Some((w, h, cover));
}
let img = DynamicImage::ImageLuma8(image::GrayImage::from_raw(w, h, cover)?);
let mut out = resample(img, &rule, to).into_luma8();
if mono {
// Resampling greys the edges: back to covered and not.
for p in out.pixels_mut() {
p[0] = if p[0] >= 128 { 255 } else { 0 };
}
}
let (w, h) = out.dimensions();
Some((w, h, out.into_raw()))
}
/// [`recode`] of CMYK image `blob` ([`ImageBlob::cmyk`]): a JPEG already as asked is kept as it
/// is when its numbers are (`convert` is `None`); otherwise its ink amounts are resampled,
/// converted by `convert` and compressed again. With `alpha` (one sample a pixel, see
+39 -1
View File
@@ -1,6 +1,7 @@
//! Separations: the inks of a job ([`print_inks`]) and the document as one ink's plate, every
//! colour (and image pixel) replaced by the grey of that ink's coverage.
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use vectorcraft_color::Color;
@@ -8,7 +9,7 @@ use vectorcraft_color::cms::{self, Cms, Model, ProfileKind};
use vectorcraft_color::swatch::REGISTRATION;
use vectorcraft_doc::inks::{Link, inks, plates, visit_node_colors};
use vectorcraft_doc::overprint::darken_overprints;
use vectorcraft_doc::{ColorMode, Document, Node};
use vectorcraft_doc::{ColorMode, Document, Node, NodeKind};
use super::{DEFAULT_FREQUENCY, PrintInk, PrintSettings};
@@ -99,10 +100,20 @@ impl<'a> Separator<'a> {
pub fn plate(&self, plate: &str) -> Document {
let mut d = self.doc.clone();
let discard_white = d.setup.discard_white_overprint;
let mut colorized = BTreeSet::new();
let nodes = d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.patterns.iter_mut().flat_map(|p| p.art.iter_mut()));
for n in nodes {
darken_overprints(n, discard_white);
self.node(plate, Arc::make_mut(n));
keep_colorized_pixels(n, &mut colorized);
}
// A colorized image's pixels say where its color prints, on every plate: they are kept
// under their own key, and only the color is separated.
let mut kept = BTreeMap::new();
for key in colorized {
if let Some(blob) = d.images.get(&key) {
kept.insert(colorized_key(&key), blob.clone());
}
}
for blob in d.images.values_mut() {
let gray = blob.map_rgb(|[r, g, b]| {
@@ -114,8 +125,35 @@ impl<'a> Separator<'a> {
*blob = gray;
}
}
d.images.extend(kept);
// Every colour is a grey now: nothing to separate on the way out.
d.color_mode = ColorMode::Rgb;
d
}
}
/// The key a plate keeps colorized image `key`'s own pixels under.
fn colorized_key(key: &str) -> String {
format!("{key}\u{0}colorized")
}
/// Point the colorized images in `n`'s subtree at [`colorized_key`], collecting their keys in
/// `keys`.
fn keep_colorized_pixels(n: &mut Arc<Node>, keys: &mut BTreeSet<String>) {
let colorized = |n: &Node| matches!(n.kind, NodeKind::Image(ref im) if im.pixels.is_none()) && n.extras().colorize.is_some();
if !colorized(n) && n.children().is_none_or(|ch| ch.is_empty()) {
return;
}
let node = Arc::make_mut(n);
if colorized(node)
&& let NodeKind::Image(im) = &mut node.kind
{
keys.insert(im.key.clone());
im.key = colorized_key(&im.key);
}
if let Some(ch) = node.children_mut() {
for c in ch.iter_mut() {
keep_colorized_pixels(c, keys);
}
}
}
+1
View File
@@ -12,6 +12,7 @@ mod blend;
mod charstroke;
mod cmykblend;
mod cmykimages;
mod colorize;
mod compression;
mod dashalign;
mod editing;
+170
View File
@@ -0,0 +1,170 @@
//! Grayscale and 1-bit images colorized by a fill color are written in that color, a spot color
//! as its ink, with the image's coverage as the soft mask.
use std::sync::Arc;
use craft_pdf::syntax::Pdf;
use craft_pdf::syntax::object::{Array, Name, Object, Stream};
use vectorcraft_color::{Color, Paint, Swatch};
use vectorcraft_doc::{ColorMode, Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::Affine;
use crate::*;
/// A page with a 2 × 1 gray image (black, white), colorized with `colorize`.
fn doc(mode: ColorMode, colorize: Option<Paint>) -> Document {
let mut d = Document::new_with_mode(40.0, 20.0, mode);
d.swatches.push(Swatch { name: "Ink".into(), paint: Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)), global: true, spot: true });
let gray = image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap();
let mut png = vec![];
gray.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("pic".into(), ImageBlob::png(png));
let im = ImageObject { key: "pic".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default(), pixels: None };
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
n.extras_mut().colorize = colorize.map(Box::new);
d.insert(d.default_layer(), 0, n).unwrap();
d
}
/// Each image XObject of `pdf`: its color space's name (the first of an array) with the names
/// in the array's second item, and whether it has a soft mask.
fn images(pdf: &[u8]) -> Vec<(String, Vec<String>, bool)> {
let file = Pdf::new(Arc::new(pdf.to_vec())).unwrap();
let mut out = vec![];
for o in file.objects() {
let Object::Stream(s) = o else { continue };
let d = s.dict();
if d.get::<Name<'_>>(b"Subtype").is_none_or(|n| n.as_ref() != b"Image") || d.get::<bool>(b"ImageMask") == Some(true) {
continue;
}
let (space, names) = match (d.get::<Name<'_>>(b"ColorSpace"), d.get::<Array<'_>>(b"ColorSpace")) {
(Some(n), _) => (n.as_str().to_string(), vec![]),
(_, Some(a)) => {
let mut it = a.flex_iter();
let name = it.next::<Name<'_>>().unwrap().as_str().to_string();
let names = it.next::<Array<'_>>().map(|a| a.iter::<Name<'_>>().map(|n| n.as_str().to_string()).collect()).unwrap_or_default();
(name, names)
}
_ => continue,
};
out.push((space, names, d.get::<Stream<'_>>(b"SMask").is_some()));
}
out
}
fn pdf(d: &Document) -> Vec<u8> {
export_with_report(d, &PdfOptions { created: Some(1_700_000_000), ..PdfOptions::uncompressed() }).unwrap().bytes
}
#[test]
fn a_spot_color_writes_the_image_in_its_ink() {
let spot = Paint::Solid { color: Color::cmyk(0.0, 0.5, 0.5, 0.0), swatch: Some("Ink".into()), tint: 0.5 };
let out = pdf(&doc(ColorMode::Cmyk, Some(spot)));
let found = images(&out);
assert!(found.contains(&("DeviceN".into(), vec!["Ink".into()], true)), "{found:?}");
assert!(String::from_utf8_lossy(&out).contains("/Separation/Ink"), "the ink's Separation space");
}
#[test]
fn a_process_color_writes_the_image_in_that_color_with_a_soft_mask() {
let out = pdf(&doc(ColorMode::Cmyk, Some(Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)))));
// The image and its soft mask, a gray image.
assert_eq!(images(&out), [("DeviceGray".into(), vec![], false), ("DeviceCMYK".into(), vec![], true)]);
// Uncolorized: the gray image itself, opaque.
assert_eq!(images(&pdf(&doc(ColorMode::Cmyk, None))), [("DeviceGray".into(), vec![], false)]);
}
/// Registration prints on every plate: the process inks and the document's spot inks.
#[test]
fn registration_writes_the_image_in_every_ink() {
let reg = Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint: 1.0 };
let found = images(&pdf(&doc(ColorMode::Cmyk, Some(reg.clone()))));
let all = ["Cyan", "Magenta", "Yellow", "Black", "Ink"].map(String::from).to_vec();
assert!(found.contains(&("DeviceN".into(), all, true)), "{found:?}");
// A spot swatch in a color group is one of the document's inks too.
let mut d = doc(ColorMode::Cmyk, Some(reg));
let grouped = Swatch { name: "Grouped Ink".into(), paint: Paint::solid(Color::cmyk(1.0, 0.0, 1.0, 0.0)), global: true, spot: true };
d.swatch_groups.push(vectorcraft_color::SwatchGroup { name: "Group".into(), swatches: vec![grouped] });
let found = images(&pdf(&d));
let all = ["Cyan", "Magenta", "Yellow", "Black", "Ink", "Grouped Ink"].map(String::from).to_vec();
assert!(found.contains(&("DeviceN".into(), all, true)), "{found:?}");
}
/// The Compression settings resample a colorized image as they do a grayscale one.
#[test]
fn colorized_images_are_downsampled() {
let mut d = Document::new(100.0, 100.0);
let gray = image::GrayImage::from_fn(600, 600, |x, y| image::Luma([((x + y) % 200) as u8]));
let mut png = vec![];
gray.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("pic".into(), ImageBlob::png(png));
// One inch wide: 600 ppi.
let im = ImageObject {
key: "pic".into(),
width: 600,
height: 600,
xf: Affine::scale(72.0 / 600.0),
link: None,
placement: Default::default(),
pixels: None,
};
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
n.extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0))));
d.insert(d.default_layer(), 0, n).unwrap();
let mut settings: PdfSettings =
serde_json::from_value(serde_json::json!({"compression": {"gray": {"downsample": "bicubic", "ppi": 150}}})).unwrap();
settings.compression.compress_text = false;
let out = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes;
let text = String::from_utf8_lossy(&out);
assert!(text.contains("/Width 150") && !text.contains("/Width 600"), "resampled to 150 ppi");
}
/// Printed separations keep a colorized image's pixels on every plate and separate its color:
/// a spot-colorized image prints on its ink's plate only. An uncolorized image of the same pixels
/// is separated as before.
#[test]
fn separations_print_the_image_on_its_ink_plate() {
let spot = Paint::Solid { color: Color::cmyk(0.0, 1.0, 1.0, 0.0), swatch: Some("Ink".into()), tint: 1.0 };
let mut d = doc(ColorMode::Cmyk, Some(spot));
let im = ImageObject { key: "pic".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default(), pixels: None };
let plain = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
d.insert(d.default_layer(), 1, plain).unwrap();
let settings: PrintSettings = serde_json::from_value(serde_json::json!({"output": {"mode": "separations"}})).unwrap();
let names: Vec<String> = print_inks(&d, &settings).0.into_iter().map(|i| i.name).collect();
let job = plan(&d, &PrintOptions { settings, ..Default::default() }).unwrap();
assert_eq!(names.len(), job.plates.len());
for (name, p) in names.iter().zip(&job.plates) {
let images: Vec<_> = p.layers[0]
.children()
.unwrap()
.iter()
.filter_map(|n| match &n.kind {
NodeKind::Image(im) => Some((p.image_blob(im).and_then(|b| b.coverage()).unwrap().2, n.extras().colorize.as_deref().cloned())),
_ => None,
})
.collect();
let [(colorized, Some(Paint::Solid { color, .. })), (plain, None)] = images.as_slice() else { panic!("{name}: {images:?}") };
assert_eq!(colorized, &[255, 0], "{name}: the black pixel still covers");
let k = if name == "Ink" { 1.0 } else { 0.0 };
assert_eq!(color, &Color::Gray { k }, "{name}: the color is the plate's coverage");
// The gray image itself has no spot ink.
if name == "Ink" {
assert_eq!(plain, &[0, 0], "{name}: the uncolorized image");
}
}
}
/// A pixelated colorized image (an envelope's with Anti-Alias off) is written without
/// /Interpolate, as an uncolorized one is.
#[test]
fn a_pixelated_colorized_image_asks_viewers_not_to_smooth_it() {
for pixelated in [false, true] {
let mut d = doc(ColorMode::Cmyk, Some(Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0))));
let id = d.layers[0].children().unwrap()[0].id;
d.node_mut(id).unwrap().extras_mut().pixelated = pixelated;
let out = pdf(&d);
assert_eq!(images(&out), [("DeviceGray".into(), vec![], false), ("DeviceCMYK".into(), vec![], true)], "pixelated: {pixelated}");
assert_eq!(String::from_utf8_lossy(&out).contains("/Interpolate"), !pixelated, "pixelated: {pixelated}");
}
}
+9 -2
View File
@@ -12,8 +12,15 @@ pub(crate) fn lookup(key: &images::ImageKey, touch: bool) -> Option<Arc<Pixmap>>
pub(crate) fn blob_key(blob: &vectorcraft_doc::ImageBlob) -> (images::ImageKey, Option<Arc<ink::InkLut>>, Option<(u16, u16)>) {
let id = images::blob_id(blob);
let lut = id.cmyk.then(ink::display_lut);
let key =
images::ImageKey { content: id.content, adjust: None, grey: false, ink: Ink::Display, luts: (lut.as_ref().map_or(0, |l| l.1), 0), mip: 0 };
let key = images::ImageKey {
content: id.content,
adjust: None,
grey: false,
tint: None,
ink: Ink::Display,
luts: (lut.as_ref().map_or(0, |l| l.1), 0),
mip: 0,
};
(key, lut.map(|l| l.0), id.size)
}
+18 -1
View File
@@ -85,6 +85,8 @@ pub(crate) struct ImageKey {
pub adjust: Option<u64>,
/// Greyscale (outline mode).
pub grey: bool,
/// Colorized with this display color ([`colorized`]).
pub tint: Option<[u8; 3]>,
/// The ink plane painted ([`Ink::Display`]: the colours themselves).
pub ink: Ink,
/// Generations of the colour tables the pixels were made with (0 = none): a CMYK image's
@@ -377,6 +379,21 @@ pub(crate) fn half(pm: &Pixmap) -> Pixmap {
Pixmap::from_parts(data, ow as u16, oh as u16)
}
/// Pixels `pm` colorized with `rgb`: each pixel takes the color, as opaque as the pixel is dark
/// (255 less its Rec. 709 luma) times its alpha, so white pixels are transparent.
pub(crate) fn colorized(pm: &Pixmap, [r, g, b]: [u8; 3]) -> Pixmap {
let mut out = pm.clone();
for px in out.data_mut() {
// The luma of premultiplied components is the premultiplied luma: the alpha less it is
// the darkness times the alpha.
let luma = 0.2126 * px.r as f32 + 0.7152 * px.g as f32 + 0.0722 * px.b as f32;
let a = (px.a as f32 - luma).clamp(0.0, 255.0);
let mul = |c: u8| (c as f32 * a / 255.0).round() as u8;
*px = PremulRgba8 { r: mul(r), g: mul(g), b: mul(b), a: a.round() as u8 };
}
out
}
/// How many times to halve an image drawn with `m` (image pixels → device pixels): while a pixel
/// of the halved image still covers at most one device pixel each way, at most down to one pixel.
pub(crate) fn mip_level(m: vectorcraft_geom::Affine, w: u16, h: u16) -> u8 {
@@ -397,7 +414,7 @@ mod tests {
use super::*;
fn key(content: &str) -> ImageKey {
ImageKey { content: content.into(), adjust: None, grey: false, ink: Ink::Display, luts: (0, 0), mip: 0 }
ImageKey { content: content.into(), adjust: None, grey: false, tint: None, ink: Ink::Display, luts: (0, 0), mip: 0 }
}
#[test]
+9 -2
View File
@@ -523,8 +523,15 @@ mod tests {
#[test]
fn darkness_image_cache_keeps_amount_and_coverage() {
let key =
ImageKey { content: Arc::from("darkness-rgb-cache-regression"), adjust: None, grey: false, ink: Ink::Display, luts: (0, 0), mip: 0 };
let key = ImageKey {
content: Arc::from("darkness-rgb-cache-regression"),
adjust: None,
grey: false,
tint: None,
ink: Ink::Display,
luts: (0, 0),
mip: 0,
};
let pm =
Arc::new(Pixmap::from_parts(vec![PremulRgba8 { r: 204, g: 102, b: 51, a: 255 }, PremulRgba8 { r: 102, g: 51, b: 26, a: 128 }], 2, 1));
let (k25, p25) = ink_image(&key, &pm, Ink::Display.darkened(0.25), None, false);
+1 -1
View File
@@ -386,7 +386,7 @@ impl Renderer {
if blob.is_proxy()
&& let Some(im) = p.preview_image(&blob.bytes)
{
self.draw_image(ctx, f, &im);
self.draw_image(ctx, f, &im, None);
}
return;
};
+64 -18
View File
@@ -1,23 +1,33 @@
use super::{
Affine, Arc, BezPath, Document, FillRule, Frame, HashMap, Pixmap, Rect, RenderContext, Renderer, Shape, blob_key, blob_pixmap, fill_rule,
image_mip, ink, lookup, mip_level, peniko, vello_cpu,
Affine, Arc, BezPath, Document, FillRule, Frame, HashMap, Ink, Node, Pixmap, Rect, RenderContext, Renderer, Shape, blob_key, blob_pixmap,
fill_rule, image_mip, ink, lookup, mip_level, peniko, vello_cpu,
};
use crate::images;
/// The color image node `n` is colorized with ([`vectorcraft_doc::NodeExtras::colorize`]), if it
/// is, as `ink` paints it: the display color, or on an ink plane the color's own inks, as a fill
/// of that color paints them.
pub(super) fn image_tint(n: &Node, ink: Ink) -> Option<[u8; 3]> {
let c = n.extras().colorize.as_deref()?.color()?;
Some(ink.rgb(&c).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8))
}
impl Renderer {
pub(crate) fn draw_image(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject) {
self.draw_image_in(ctx, f, im, None, false);
/// Draw an image, colorized with `tint` when given ([`image_tint`]).
pub(crate) fn draw_image(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject, tint: Option<[u8; 3]>) {
self.draw_image_in(ctx, f, im, None, tint, false);
}
/// Draw an image, or with `area` (a region in the document inside the image's frame, see
/// [`plain_image`]) the image painted into that region; with `pixelated`
/// ([`vectorcraft_doc::NodeExtras::pixelated`]) without smoothing.
/// [`plain_image`]) the image painted into that region; colorized with `tint` when given; with
/// `pixelated` ([`vectorcraft_doc::NodeExtras::pixelated`]) without smoothing.
pub(super) fn draw_image_in(
&mut self,
ctx: &mut RenderContext,
f: &Frame,
im: &vectorcraft_doc::ImageObject,
area: Option<&(BezPath, FillRule)>,
tint: Option<[u8; 3]>,
pixelated: bool,
) {
let rect = Rect::new(0.0, 0.0, im.width as f64, im.height as f64);
@@ -29,13 +39,15 @@ impl Renderer {
// pixelated image is drawn from its full pixels: a mip averages them.
let mips = f.opts.image_mips && !pixelated;
let unit = f.view * im.xf * Affine::scale_non_uniform(im.width as f64, im.height as f64);
// The outline view shows the image's own pixels.
let tint = tint.filter(|_| !outline);
// With `kept_images_only`, an image still to be decoded: it draws as a light box, not as
// an undecoded image.
let mut pending = false;
let shown = if outline && !f.doc.setup.outline_images {
None
} else if f.opts.kept_images_only {
let kept = self.nearest_kept(f, &im.key, im.pixels.as_deref(), outline, unit);
let kept = self.nearest_kept(f, &im.key, im.pixels.as_deref(), outline, tint, unit);
pending = kept.is_none() && !self.noted_undecodable(f.doc, &im.key, im.pixels.as_deref());
if pending {
// Not decoded: a light box where the image will be, as a placed document's
@@ -54,11 +66,16 @@ impl Renderer {
}
}
kept
} else if let Some(pm) = mips.then(|| self.kept_mip(f, &im.key, im.pixels.as_deref(), outline, unit)).flatten() {
} else if let Some(pm) = mips.then(|| self.kept_mip(f, &im.key, im.pixels.as_deref(), outline, tint, unit)).flatten() {
Some(pm)
} else {
self.image_pixmap(f.doc, &im.key, im.pixels.as_deref(), outline, !mips).map(|(key, pm)| {
let (key, pm) = if outline { (key, pm) } else { ink::ink_image(&key, &pm, f.ink, f.doc.image_blob(im), !mips) };
let (key, pm) = match tint {
Some(rgb) => colorized_pixmap(key, &pm, rgb, !mips),
None => (key, pm),
};
// A colorized image is already in the frame's ink ([`image_tint`]).
let (key, pm) = if outline || tint.is_some() { (key, pm) } else { ink::ink_image(&key, &pm, f.ink, f.doc.image_blob(im), !mips) };
if mips { image_mip(&key, pm, unit) } else { pm }
})
};
@@ -220,11 +237,20 @@ impl Renderer {
}
/// The mip [`image_mip`] draws image `key` (or `own`) with through `m`, in greyscale with
/// `grey` (the outline view's), when the store holds it, marked used there; `None` when it
/// draws the full pixels or the mip is yet to be made.
fn kept_mip(&self, f: &Frame, key: &str, own: Option<&vectorcraft_doc::ImageBlob>, grey: bool, m: Affine) -> Option<Arc<Pixmap>> {
/// `grey` (the outline view's) or colorized with `tint`, when the store holds it, marked used
/// there; `None` when it draws the full pixels or the mip is yet to be made.
fn kept_mip(
&self,
f: &Frame,
key: &str,
own: Option<&vectorcraft_doc::ImageBlob>,
grey: bool,
tint: Option<[u8; 3]>,
m: Affine,
) -> Option<Arc<Pixmap>> {
let (key, (w, h)) = self.image_key(f.doc, key, own, grey)?;
let key = if grey { key } else { ink::ink_key(&key, f.ink) };
let key = images::ImageKey { tint, ..key };
let key = if grey || tint.is_some() { key } else { ink::ink_key(&key, f.ink) };
let level = mip_level(m, w, h);
if level == 0 {
return None;
@@ -233,12 +259,21 @@ impl Renderer {
}
/// The pixels of image `key` (or `own`) that [`RenderOptions::kept_images_only`] draws through
/// `m`, in greyscale with `grey` (the outline view's): of the mips the store holds, the one
/// [`image_mip`] would draw, else the nearest finer one (the full pixels last), else the
/// nearest coarser one. `None` when the store holds none. Nothing is marked used.
fn nearest_kept(&self, f: &Frame, key: &str, own: Option<&vectorcraft_doc::ImageBlob>, grey: bool, m: Affine) -> Option<Arc<Pixmap>> {
/// `m`, in greyscale with `grey` (the outline view's) or colorized with `tint`: of the mips the
/// store holds, the one [`image_mip`] would draw, else the nearest finer one (the full pixels
/// last), else the nearest coarser one. `None` when the store holds none. Nothing is marked used.
fn nearest_kept(
&self,
f: &Frame,
key: &str,
own: Option<&vectorcraft_doc::ImageBlob>,
grey: bool,
tint: Option<[u8; 3]>,
m: Affine,
) -> Option<Arc<Pixmap>> {
let (key, (w, h)) = self.image_key(f.doc, key, own, grey)?;
let key = if grey { key } else { ink::ink_key(&key, f.ink) };
let key = images::ImageKey { tint, ..key };
let key = if grey || tint.is_some() { key } else { ink::ink_key(&key, f.ink) };
let level = mip_level(m, w, h);
let s = images::store();
let at = |l: u8| s.peek(&images::ImageKey { mip: l, ..key.clone() });
@@ -257,3 +292,14 @@ impl Renderer {
}
}
}
/// Image pixels `pm` (kept as `key`) colorized with `rgb` ([`images::colorized`]), kept with the
/// images, and their key. `touch` marks them used there.
fn colorized_pixmap(key: images::ImageKey, pm: &Pixmap, rgb: [u8; 3], touch: bool) -> (images::ImageKey, Arc<Pixmap>) {
let key = images::ImageKey { tint: Some(rgb), ..key };
if let Some(p) = lookup(&key, touch) {
return (key, p);
}
let pm = images::store().insert(key.clone(), Arc::new(images::colorized(pm, rgb)));
(key, pm)
}
+5 -5
View File
@@ -1,8 +1,8 @@
use super::{
Affine, Arc, BezPath, ClipEntry, ClipPaintEntry, Compose, Composite, Document, FillRule, Frame, GeomEntry, ISOLATION_DIM, Ink, MAX_GUIDE_DASHES,
Node, NodeId, NodeKind, Rect, RenderContext, RenderOptions, Renderer, Shape, SymbolBoxes, TraceView, blend_mode, brush_fx, effects, fx, group,
holds_instance, holds_plugin_effect, kurbo, lru, mask_value, painted_items, plain_image, polygon_length, rects_overlap, shape_masked_image,
single_plain_fill, text_geom_snapped, white_shape_mask,
holds_instance, holds_plugin_effect, images, kurbo, lru, mask_value, painted_items, plain_image, polygon_length, rects_overlap,
shape_masked_image, single_plain_fill, text_geom_snapped, white_shape_mask,
};
impl Renderer {
@@ -153,7 +153,7 @@ impl Renderer {
&& let Some(region) = self.clip_of(&m.art)
&& shape_masked_image(a, im, &region.0)
{
self.draw_image_in(ctx, f, im, Some(&region), a.extras().pixelated);
self.draw_image_in(ctx, f, im, Some(&region), images::image_tint(a, f.ink), a.extras().pixelated);
self.stats.drawn += 1;
return;
}
@@ -388,7 +388,7 @@ impl Renderer {
&& let NodeKind::Image(im) = &image.kind
&& plain_image(image, im, &region.0)
{
self.draw_image_in(ctx, f, im, Some(&region), image.extras().pixelated);
self.draw_image_in(ctx, f, im, Some(&region), images::image_tint(image, f.ink), image.extras().pixelated);
self.stats.drawn += 1;
return;
}
@@ -442,7 +442,7 @@ impl Renderer {
self.draw_shape(ctx, f, n, &bp, *rule);
}
NodeKind::Text(t) => self.draw_text(ctx, f, n, t),
NodeKind::Image(im) => self.draw_image_in(ctx, f, im, None, n.extras().pixelated),
NodeKind::Image(im) => self.draw_image_in(ctx, f, im, None, images::image_tint(n, f.ink), n.extras().pixelated),
NodeKind::SymbolInstance { symbol, xf } => {
if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol)
&& self.charge_symbol(&sym.art)
+1
View File
@@ -11,6 +11,7 @@ mod changed;
mod charstroke;
mod clip;
mod cmykblend;
mod colorize;
mod colrglyphs;
mod container;
mod dither;
+166
View File
@@ -0,0 +1,166 @@
//! Grayscale and 1-bit images colorized by a fill color draw in that color, white pixels
//! transparent.
use crate::*;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::shapes;
/// An uncompressed 1-bit TIFF, BlackIsZero, of 2 × 1 pixels: black, white.
fn bilevel_tiff() -> Vec<u8> {
let entries: [(u16, u16, u32); 9] =
[(256, 3, 2), (257, 3, 1), (258, 3, 1), (259, 3, 1), (262, 3, 1), (273, 4, 122), (277, 3, 1), (278, 3, 1), (279, 4, 1)];
let mut out = b"II*\0".to_vec();
out.extend(8u32.to_le_bytes());
out.extend((entries.len() as u16).to_le_bytes());
for (tag, kind, value) in entries {
out.extend(tag.to_le_bytes());
out.extend(kind.to_le_bytes());
out.extend(1u32.to_le_bytes());
out.extend(value.to_le_bytes());
}
out.extend(0u32.to_le_bytes());
out.push(0b0100_0000);
out
}
/// A 2 × 1 gray PNG: black, white.
fn gray_png() -> Vec<u8> {
let img = image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap();
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
png
}
/// A 40 × 20 pt page: a green band behind the image of `blob` (black, white), 20 pt a pixel,
/// colorized with `colorize`.
fn doc(blob: ImageBlob, colorize: Option<Paint>) -> Document {
doc_of(blob, colorize, (2, 1), 20.0)
}
/// [`doc`] for an image of `size` pixels, `scale` pt a pixel.
fn doc_of(blob: ImageBlob, colorize: Option<Paint>, (width, height): (u32, u32), scale: f64) -> Document {
let mut d = Document::new(40.0, 20.0);
let layer = Some(d.layers[0].id);
let band = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)),
Appearance::basic(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0),
);
d.insert(layer, 0, band).unwrap();
d.images.insert("pic".into(), blob);
let im = ImageObject { key: "pic".into(), width, height, xf: Affine::scale(scale), link: None, placement: Default::default(), pixels: None };
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
n.extras_mut().colorize = colorize.map(Box::new);
d.insert(layer, 1, n).unwrap();
d
}
#[test]
fn gray_and_1_bit_images_draw_in_their_spot_color_with_white_transparent() {
let ink = Color::Cmyk { c: 0.0, m: 1.0, y: 1.0, k: 0.0 };
let spot = Paint::Solid { color: ink, swatch: Some("Ink".into()), tint: 1.0 };
let sharp = RenderOptions { smooth_images: false, ..Default::default() };
let shown = Renderer::new().render(&doc(ImageBlob::new("image/png", gray_png()), None), 40, 20, Affine::IDENTITY, &sharp);
assert_eq!(shown.pixel(10, 10), [0, 0, 0, 255], "uncolorized: black");
let [r, g, b] = ink.to_rgb().map(|v| (v * 255.0).round() as u8);
for (what, blob) in [("1-bit", ImageBlob::new("image/tiff", bilevel_tiff())), ("gray", ImageBlob::new("image/png", gray_png()))] {
for image_mips in [false, true] {
let d = doc(blob.clone(), Some(spot.clone()));
let out = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { image_mips, smooth_images: false, ..Default::default() });
let px = out.pixel(10, 10);
assert!(px[0].abs_diff(r) <= 2 && px[1].abs_diff(g) <= 2 && px[2].abs_diff(b) <= 2, "{what}: the black pixel takes the ink, got {px:?}");
assert_eq!(out.pixel(30, 10), [0, 255, 0, 255], "{what}: the white pixel shows the band below");
}
}
}
/// A shrunk copy kept from one frame draws colorized in the next, and the uncolorized image keeps
/// its own copy.
#[test]
fn shrunk_copies_are_kept_per_color() {
let black = image::GrayImage::from_raw(64, 64, vec![0; 64 * 64]).unwrap();
let mut png = vec![];
black.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let blob = ImageBlob::new("image/png", png);
let d = doc_of(blob.clone(), Some(Paint::solid(Color::rgb(0.0, 0.0, 1.0))), (64, 64), 1.0);
let plain = doc_of(blob, None, (64, 64), 1.0);
let opts = RenderOptions { image_mips: true, ..Default::default() };
let view = Affine::scale(0.25);
let mut r = Renderer::new();
for _ in 0..2 {
let px = r.render(&d, 16, 16, view, &opts).pixel(8, 8);
assert!(px[2] >= 250 && px[0] <= 5 && px[1] <= 5, "blue, got {px:?}");
let px = r.render(&plain, 16, 16, view, &opts).pixel(8, 8);
assert!(px[..3].iter().all(|v| *v <= 5), "black, got {px:?}");
}
}
/// With `kept_images_only` (the canvas's low-resolution fill), a colorized image draws from the
/// colorized pixels the store holds, not from the image's own.
#[test]
fn kept_images_only_draw_the_colorized_pixels() {
let black = image::GrayImage::from_raw(48, 48, vec![0; 48 * 48]).unwrap();
let mut png = vec![];
black.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let d = doc_of(ImageBlob::new("image/png", png), Some(Paint::solid(Color::rgb(0.0, 0.0, 1.0))), (48, 48), 1.0);
let view = Affine::scale(0.25);
let mut r = Renderer::new();
let _ = r.render(&d, 12, 12, view, &RenderOptions { image_mips: true, ..Default::default() });
let px = r.render(&d, 12, 12, view, &RenderOptions { kept_images_only: true, image_mips: true, ..Default::default() }).pixel(6, 6);
assert!(px[2] >= 250 && px[0] <= 5 && px[1] <= 5, "blue, got {px:?}");
}
/// Drawn as ink amounts (CMYK exports, CMYK documents that show transparency), a colorized image
/// keeps its color's own inks, as a fill of that color does.
#[test]
fn ink_planes_keep_the_colors_own_inks() {
for (color, want) in [(Color::cmyk(0.0, 0.0, 0.0, 1.0), [0u8, 0, 0, 255]), (Color::cmyk(1.0, 0.0, 0.0, 0.0), [255, 0, 0, 0])] {
let mut d = Document::new_with_mode(40.0, 20.0, vectorcraft_doc::ColorMode::Cmyk);
d.images.insert("pic".into(), ImageBlob::new("image/png", gray_png()));
let im =
ImageObject { key: "pic".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default(), pixels: None };
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
n.extras_mut().colorize = Some(Box::new(Paint::solid(color)));
d.insert(Some(d.layers[0].id), 0, n).unwrap();
let opts = RenderOptions { smooth_images: false, ..Default::default() };
let inks = Renderer::new().render_region_inks(&d, Rect::new(0.0, 0.0, 40.0, 20.0), 1.0, &opts);
let at = |x: usize| inks[(10 * 40 + x) * 4..][..4].to_vec();
assert!(at(10).iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 1), "{color:?}: the black pixel, got {:?}", at(10));
assert_eq!(at(30), [0, 0, 0, 0], "{color:?}: the white pixel has no ink");
}
}
#[test]
fn colorize_dims_with_the_grays() {
let gray = image::GrayImage::from_raw(2, 1, vec![128, 255]).unwrap();
let mut png = vec![];
gray.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let d = doc(ImageBlob::new("image/png", png), Some(Paint::solid(Color::rgb(0.0, 0.0, 1.0))));
let px = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images: false, ..Default::default() }).pixel(10, 10);
// Half blue over the green band.
assert!(px[1].abs_diff(128) <= 3 && px[2].abs_diff(127) <= 3 && px[0] <= 2, "got {px:?}");
}
/// Separations Preview shows a spot-colorized image on its ink's plate only.
#[test]
fn separations_preview_shows_the_image_on_its_spot_plate() {
let mut d = Document::new_with_mode(40.0, 20.0, vectorcraft_doc::ColorMode::Cmyk);
let ink = Color::cmyk(0.0, 1.0, 1.0, 0.0);
d.swatches.push(vectorcraft_color::Swatch { name: "Ink".into(), paint: Paint::solid(ink), global: true, spot: true });
d.images.insert("pic".into(), ImageBlob::new("image/png", gray_png()));
let im = ImageObject { key: "pic".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default(), pixels: None };
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
n.extras_mut().colorize = Some(Box::new(Paint::Solid { color: ink, swatch: Some("Ink".into()), tint: 1.0 }));
d.insert(Some(d.layers[0].id), 0, n).unwrap();
let shown = |plates: &[&str]| {
let proof = proof::ProofSetup { separations: Some(plates.iter().map(|p| p.to_string()).collect()), ..Default::default() };
let opts = RenderOptions { proof: Some(proof), overprint_preview: true, smooth_images: false, ..Default::default() };
Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &opts).pixel(10, 10)
};
let on = shown(&["Ink"]);
// One plate shows as black ink.
assert!(on[..3].iter().all(|v| *v < 128), "the Ink plate shows the image, got {on:?}");
let off = shown(&["Cyan", "Magenta", "Yellow", "Black"]);
assert!(off[..3].iter().all(|v| *v >= 250), "the process plates show paper, got {off:?}");
}
+1
View File
@@ -4281,6 +4281,7 @@ imageTrace Outline Obrys
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` je procesní barva: tóny platí pro globální a přímé barvy
@msg `{name}` is no recovery entry name `{name}` není název položky obnovení
@msg `{name}` isn't a colour swatch `{name}` není barevná položka vzorníku
@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white procesní barva nemůže obarvit obraz ve stupních šedi s průhledností: použijte přímou barvu, černou nebo bílou
@msg `{path}` must be a bool or an object of bools `{path}` musí být bool nebo objekt hodnot bool
@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` není barva (#rrggbb nebo název přednastavení)
@msg `{tool}` isn't a Liquify tool `{tool}` není nástroj zkapalnění
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3894,6 +3894,7 @@ theme Appearance Erscheinungsbild
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` ist eine Prozessfarbe: Tonwerte gelten für globale Farben und Volltonfarben
@msg `{name}` is no recovery entry name `{name}` ist kein Name eines Wiederherstellungseintrags
@msg `{name}` isn't a colour swatch `{name}` ist kein Farbfeld mit einer Farbe
@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white eine Prozessfarbe kann ein Graustufenbild mit Transparenz nicht einfärben: Verwenden Sie eine Volltonfarbe, Schwarz oder Weiß
@msg `{path}` must be a bool or an object of bools `{path}` muss ein boolescher Wert oder ein Objekt mit booleschen Werten sein
@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` ist keine Farbe (#rrggbb oder ein Vorgabename)
@msg `{tool}` isn't a Liquify tool `{tool}` ist kein Verflüssigen-Werkzeug
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2211,6 +2211,7 @@ axis Both Και τα δύο
@msg `{name}` is a process colour: tints apply to global and spot colours Το `{name}` είναι χρώμα τετραχρωμίας: οι αποχρώσεις εφαρμόζονται σε καθολικά και πλακάτα χρώματα
@msg `{name}` is no recovery entry name Το `{name}` δεν είναι όνομα καταχώρισης ανάκτησης
@msg `{name}` isn't a colour swatch Το `{name}` δεν είναι δείγμα χρώματος
@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white ένα χρώμα τετραχρωμίας δεν μπορεί να χρωματίσει εικόνα κλίμακας του γκρι με διαφάνεια: χρησιμοποιήστε πλακάτο χρώμα, μαύρο ή λευκό
@msg `{path}` must be a bool or an object of bools Το `{path}` πρέπει να είναι λογική τιμή ή αντικείμενο λογικών τιμών
@msg `{s}` is not a colour (#rrggbb or a preset name) Το `{s}` δεν είναι χρώμα (#rrggbb ή όνομα προεπιλογής)
@msg `{tool}` isn't a Liquify tool Το `{tool}` δεν είναι εργαλείο ρευστοποίησης
Can't render this file because it contains an unexpected character in line 164 and column 5.
+1
View File
@@ -3974,6 +3974,7 @@ axis Both Ambos
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` es un color de cuatricromía: los matices se aplican a colores globales y planos
@msg `{name}` is no recovery entry name `{name}` no es el nombre de una entrada de recuperación
@msg `{name}` isn't a colour swatch `{name}` no es una muestra de color
@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white un color de cuatricromía no puede colorear una imagen en escala de grises con transparencia: use un color plano, negro o blanco
@msg `{path}` must be a bool or an object of bools `{path}` debe ser un booleano o un objeto de booleanos
@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` no es un color (#rrggbb o el nombre de un preajuste)
@msg `{tool}` isn't a Liquify tool `{tool}` no es una herramienta de Licuar
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4195,6 +4195,7 @@ imageTrace Outline Ääriviiva
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` on prosessiväri: sävytteet koskevat globaaleja värejä ja spottivärejä
@msg `{name}` is no recovery entry name `{name}` ei ole palautusmerkinnän nimi
@msg `{name}` isn't a colour swatch `{name}` ei ole värimalli
@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white prosessiväri ei voi värittää läpinäkyvyyttä sisältävää harmaasävykuvaa: käytä spottiväriä, mustaa tai valkoista
@msg `{path}` must be a bool or an object of bools `{path}` on oltava totuusarvo tai totuusarvojen objekti
@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` ei ole väri (#rrggbb tai esiasetuksen nimi)
@msg `{tool}` isn't a Liquify tool `{tool}` ei ole Nesteytä-työkalu
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3971,6 +3971,7 @@ axis Both Les deux
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` est une couleur quadrichromique : les teintes s’appliquent aux couleurs globales et aux tons directs
@msg `{name}` is no recovery entry name `{name}` n’est pas le nom d’une entrée de récupération
@msg `{name}` isn't a colour swatch `{name}` n’est pas une nuance de couleur
@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white une couleur quadrichromique ne peut pas coloriser une image en niveaux de gris avec transparence : utilisez un ton direct, le noir ou le blanc
@msg `{path}` must be a bool or an object of bools `{path}` doit être un booléen ou un objet de booléens
@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` n’est pas une couleur (#rrggbb ou le nom d’un paramètre prédéfini)
@msg `{tool}` isn't a Liquify tool `{tool}` n’est pas un outil Fluidité
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4503,6 +4503,7 @@ imageTrace Outline Körvonal
@msg `{name}` is a process colour: tints apply to global and spot colours skálaszín: `{name}`; tónus csak globális és direktszínhez adható
@msg `{name}` is no recovery entry name nem helyreállítási bejegyzés neve: `{name}`
@msg `{name}` isn't a colour swatch nem színes színminta: `{name}`
@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white skálaszín nem színezhet átlátszóságot tartalmazó szürkeárnyalatos képet: használjon direktszínt, feketét vagy fehéret
@msg `{path}` must be a bool or an object of bools `{path}`: logikai érték vagy logikai értékeket tartalmazó objektum kell
@msg `{s}` is not a colour (#rrggbb or a preset name) nem szín: `{s}` (#rrggbb vagy előbeállítás neve)
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: a lépés eredményében nincs ilyen érték: {i} (a $ jelhez írj $$-t)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4014,6 +4014,7 @@ axis Both Entrambi
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` è un colore quadricromia: le tinte si applicano ai colori globali e spot
@msg `{name}` is no recovery entry name `{name}` non è il nome di una voce di recupero
@msg `{name}` isn't a colour swatch `{name}` non è un campione di colore
@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white un colore quadricromia non può colorare un'immagine in scala di grigi con trasparenza: usa un colore spot, il nero o il bianco
@msg `{path}` must be a bool or an object of bools `{path}` deve essere un booleano o un oggetto di booleani
@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` non è un colore (#rrggbb o il nome di un preset)
@msg `{tool}` isn't a Liquify tool `{tool}` non è uno strumento Fluidifica
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4149,6 +4149,7 @@ swatch Bright Violet ビビッドバイオレット
@msg the object already has an opacity mask オブジェクトにはすでに不透明マスクがあります
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` はプロセスカラーです: 濃淡を適用できるのはグローバルカラーと特色だけです
@msg `{name}` isn't a colour swatch `{name}` はカラースウォッチではありません
@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white プロセスカラーでは透明部分のあるグレースケール画像に色を付けられません: 特色、黒、白のいずれかを使用してください
@msg a document needs at least one artboard ドキュメントには少なくとも 1 つのアートボードが必要です
@msg empty path パスが空です
@msg join needs open paths 連結にはオープンパスが必要です
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3765,6 +3765,7 @@ font-technology Variable ცვლადი
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` პროცესული ფერია: ტონირება გამოიყენება გლობალურ და სპოტ-ფერებზე
@msg `{name}` is no recovery entry name `{name}` აღდგენის ჩანაწერის სახელი არ არის
@msg `{name}` isn't a colour swatch `{name}` ფერის ნიმუში არ არის
@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white პროცესული ფერი ვერ გააფერადებს გამჭვირვალობის მქონე ნაცრისფერ გამოსახულებას: გამოიყენეთ სპოტ-ფერი, შავი ან თეთრი
@msg `{path}` must be a bool or an object of bools `{path}` უნდა იყოს bool ან bool-ების ობიექტი
@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` ფერი არ არის (#rrggbb ან პრესეტის სახელი)
@msg `{tool}` isn't a Liquify tool `{tool}` გათხევადების ხელსაწყო არ არის
Can't render this file because it is too large.
+1
View File
@@ -4081,6 +4081,7 @@ theme Appearance 외관
@msg the object already has an opacity mask 개체에 이미 불투명 마스크가 있습니다
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}`은(는) 프로세스 색상입니다. 색조는 글로벌 색상과 별색에만 적용됩니다
@msg `{name}` isn't a colour swatch `{name}`은(는) 색상 견본이 아닙니다
@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white 프로세스 색상으로는 투명도가 있는 그레이스케일 이미지를 색칠할 수 없습니다. 별색, 검정 또는 흰색을 사용하십시오
@msg a document needs at least one artboard 문서에는 아트보드가 하나 이상 있어야 합니다
@msg empty path 빈 패스입니다
@msg join needs open paths 연결하려면 열린 패스가 필요합니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4105,6 +4105,7 @@ imageTrace Outline Kontur
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` to kolor triadowy: tinty dotyczą kolorów globalnych i dodatkowych
@msg `{name}` is no recovery entry name `{name}` nie jest nazwą wpisu odzyskiwania
@msg `{name}` isn't a colour swatch `{name}` nie jest próbką koloru
@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white kolor triadowy nie może zabarwić obrazu w skali szarości z przezroczystością: użyj koloru dodatkowego, czarnego lub białego
@msg `{path}` must be a bool or an object of bools `{path}` musi być wartością logiczną lub obiektem wartości logicznych
@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` nie jest kolorem (#rrggbb lub nazwa ustawienia)
@msg `{tool}` isn't a Liquify tool `{tool}` nie jest narzędziem upłynniania
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3558,6 +3558,7 @@ axis Both Ambos
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` é uma cor de seleção: os matizes se aplicam a cores globais e especiais
@msg `{name}` is no recovery entry name `{name}` não é o nome de uma entrada de recuperação
@msg `{name}` isn't a colour swatch `{name}` não é uma amostra de cor
@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white uma cor de seleção não pode colorir uma imagem em escala de cinza com transparência: use uma cor especial, preto ou branco
@msg `{path}` must be a bool or an object of bools `{path}` deve ser um booleano ou um objeto de booleanos
@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` não é uma cor (#rrggbb ou o nome de uma predefinição)
@msg `{tool}` isn't a Liquify tool `{tool}` não é uma ferramenta de Liquidificar
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -4010,6 +4010,7 @@ axis Both Оба
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` — составной цвет: оттенки применяются к глобальным и плашечным цветам
@msg `{name}` is no recovery entry name `{name}` не является именем записи восстановления
@msg `{name}` isn't a colour swatch `{name}` не является цветовым образцом
@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white составной цвет не может раскрасить изображение в оттенках серого с прозрачностью: используйте плашечный цвет, чёрный или белый
@msg `{path}` must be a bool or an object of bools `{path}` должно быть логическим значением или объектом логических значений
@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` не является цветом (#rrggbb или именем набора)
@msg `{tool}` isn't a Liquify tool `{tool}` не является инструментом пластики
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3890,6 +3890,7 @@ axis Both Båda
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` är en processfärg: nyanser gäller för globala färger och dekorfärger
@msg `{name}` is no recovery entry name `{name}` är inget namn på en återställningspost
@msg `{name}` isn't a colour swatch `{name}` är inte en färgruta
@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white en processfärg kan inte färglägga en gråskalebild med genomskinlighet: använd en dekorfärg, svart eller vitt
@msg `{path}` must be a bool or an object of bools `{path}` måste vara ett booleskt värde eller ett objekt med booleska värden
@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` är inte en färg (#rrggbb eller namnet på en förinställning)
@msg `{tool}` isn't a Liquify tool `{tool}` är inte ett verktyg för Gör flytande
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -3777,6 +3777,7 @@ axis Both Her İkisi
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` bir proses renktir: renk tonları global ve spot renklere uygulanır
@msg `{name}` is no recovery entry name `{name}`, kurtarma girişi adı değil
@msg `{name}` isn't a colour swatch `{name}` bir renk örneği değil
@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white proses renk, saydamlık içeren gri tonlamalı bir görüntüyü renklendiremez: spot renk, siyah veya beyaz kullanın
@msg `{path}` must be a bool or an object of bools `{path}` bir bool veya bool nesnesi olmalıdır
@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` bir renk değildir (#rrggbb veya önceden ayarlanmış bir ad)
@msg `{tool}` isn't a Liquify tool `{tool}` bir Sıvılaştırma aracı değil
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -3998,6 +3998,7 @@ axis Both Обидві
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` — складовий колір: відтінки застосовуються до глобальних та плашкових кольорів.
@msg `{name}` is no recovery entry name `{name}` не є ім’ям запису відновлення
@msg `{name}` isn't a colour swatch `{name}` не є колірним зразком
@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white складовий колір не може розфарбувати зображення у відтінках сірого з прозорістю: використайте плашковий колір, чорний або білий
@msg `{path}` must be a bool or an object of bools `{path}` має бути логічним значенням чи об’єктом логічних значень
@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` не є кольором (#rrggbb або ім’ям набору)
@msg `{tool}` isn't a Liquify tool `{tool}` не є інструментом пластики
Can't render this file because it contains an unexpected character in line 178 and column 5.
+1
View File
@@ -4153,6 +4153,7 @@ theme Appearance Giao diện
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` là màu quy trình: sắc độ áp dụng cho màu toàn cục và màu chuyên biệt
@msg `{name}` is no recovery entry name `{name}` không phải là tên mục phục hồi
@msg `{name}` isn't a colour swatch `{name}` không phải là mẫu màu
@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white màu quy trình không thể tô màu ảnh thang xám có độ trong suốt: hãy dùng màu chuyên biệt, đen hoặc trắng
@msg `{path}` must be a bool or an object of bools `{path}` phải là bool hoặc đối tượng bool
@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` không phải là màu (#rrggbb hoặc tên thiết lập sẵn)
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: kết quả bước {i} không có giá trị này (viết $$ cho ký tự $)
Can't render this file because it contains an unexpected character in line 200 and column 5.
+1
View File
@@ -3882,6 +3882,7 @@ imageTrace Outline 轮廓
@msg the object already has an opacity mask 该对象已有不透明度蒙版
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` 是印刷色:色调仅适用于全局色和专色
@msg `{name}` isn't a colour swatch `{name}` 不是颜色色板
@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white 印刷色无法为带透明度的灰度图像上色:请使用专色、黑色或白色
@msg a document needs at least one artboard 文档至少需要一个画板
@msg empty path 路径为空
@msg join needs open paths 连接需要开放路径
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1826,6 +1826,7 @@ file-filter Templates 範本
@msg the object already has an opacity mask 物件已有不透明度遮色片
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` 是印刷色:色調只適用於全域色和特別色
@msg `{name}` isn't a colour swatch `{name}` 不是顏色色票
@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white 印刷色無法為帶有透明度的灰階影像上色:請使用特別色、黑色或白色
@msg a document needs at least one artboard 文件至少需要一個工作區域
@msg empty path 路徑是空的
@msg join needs open paths 連接需要開放路徑
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -396,6 +396,9 @@ when given; `paint` lists the stroke options applied.
A gradient given to several objects fits each one's own bounds; `paint.setFill` / `paint.setStroke` with
`span: "selection"` place a linear or radial gradient once on the union of the targets' bounds instead, so one
ramp runs across them.
`paint.setFill` without `item` on a grayscale or 1-bit image sets the color the image is drawn in: each pixel takes
the color as far as the pixel is dark, and white pixels are transparent. `none` removes the color. An image with an
alpha channel takes only a spot color, black or white; a process color is refused.
For `draw_shape` and `draw_path`, omitted paint fields inherit the current [new-art appearance](#new-art).
For a filled shape or path without an outline, pass an explicit `fill` and `stroke: "none"`.
Lines start with no fill and a visible stroke.
+1
View File
@@ -892,6 +892,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged.
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
Editing, renaming, deleting or merging a global swatch also changes the colors linked to it in graph designs, the art of brushes and the tile that pattern editing's Cancel puts back, in the same undo step as the rest of the art; Default Swatches with replace unlinks them there too.
- **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. EPS and SVG write the image's own pixels.
- **Missing library files:** a persistent swatch or graphic style library panel whose file is gone at startup reopens and stays in the preferences, showing the file's path with Locate Library… (`ui.libraryPanel.locate`), which reads the file from where it is now into the same panel.
- **Swatch library and Recolor Artwork parameters:** the `swatch.library.*` commands, `swatch.resetDefaults`, the graphic style library save, load and add, the `recolor.*` commands and `color.harmony`'s `limitTo` refuse parameters of the wrong form, such as `names: "Grays"`, `limitTo: 5` or `includeImages: "false"`. Errors name the parameter, except that a `limitTo` that is not a string, such as `5`, gets an unknown-library error. A `null` counts as left out except for `recolor.apply`'s `model`, which must be a string when given.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike (the built-in Generic CMYK compensates both ways, so RGB black separated to a rich black displays as black again). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).