mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 23:00:35 +00:00
Merge pull request #5724 from mockydevs/fix/pdf-devicen-spot-3235
PDF import: a DeviceN gradient of a spot ink over process plates keeps the ink and the plates, and PDF export writes it as DeviceN again (#3235)
This commit is contained in:
@@ -151,16 +151,23 @@ pub struct GradientStop {
|
||||
/// Tint of the linked swatch (0..1; 1 without a link), as on a solid paint.
|
||||
#[serde(default = "crate::full_tint", skip_serializing_if = "crate::is_full_tint")]
|
||||
pub tint: f32,
|
||||
/// Process inks (C, M, Y, K, 0..1) printed together with the linked spot swatch's tint: a
|
||||
/// DeviceN colour of a file that mixes a spot ink with process plates. `color` is how the mix
|
||||
/// looks; separations print the spot ink at `tint` and these plates. `None`: the ink alone.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub plates: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
impl GradientStop {
|
||||
/// An opaque, unlinked stop of `color` at `offset` with a centred midpoint.
|
||||
pub fn new(offset: f32, color: Color) -> Self {
|
||||
Self { offset, color, opacity: 1.0, midpoint: 0.5, swatch: None, tint: 1.0 }
|
||||
Self { offset, color, opacity: 1.0, midpoint: 0.5, swatch: None, tint: 1.0, plates: None }
|
||||
}
|
||||
/// Give the stop `color`, linked to `link` (a global swatch and tint) or unlinked.
|
||||
/// Give the stop `color`, linked to `link` (a global swatch and tint) or unlinked, without
|
||||
/// process plates.
|
||||
pub fn set_color(&mut self, color: Color, link: Option<(String, f32)>) {
|
||||
self.color = color;
|
||||
self.plates = None;
|
||||
(self.swatch, self.tint) = match link {
|
||||
Some((n, t)) => (Some(n), t),
|
||||
None => (None, 1.0),
|
||||
|
||||
@@ -197,7 +197,8 @@ pub(crate) fn with_stored_stops<'a>(g: &'a Value, old: &Paint, stored: &StopColo
|
||||
/// Parse `stops` params: at least two, each with a numeric `offset` (clamped to 0..1) and a valid
|
||||
/// `color`; `opacity` is 0..1 (values above 1 are percentages) and `midpoint` is clamped to the
|
||||
/// diamond's range; `swatch` and `tint` (%) keep a stop's link as given (resolve them first with
|
||||
/// [`link_stops`]). The result is sorted by offset.
|
||||
/// [`link_stops`]), and `plates` (C, M, Y, K, 0..1) the process inks a linked stop prints with its
|
||||
/// spot ink. The result is sorted by offset.
|
||||
pub(crate) fn parse_stops(v: &Value) -> Parsed<Vec<GradientStop>> {
|
||||
let arr = v.as_array().ok_or("`stops` must be an array")?;
|
||||
if arr.len() < 2 {
|
||||
@@ -213,6 +214,10 @@ pub(crate) fn parse_stops(v: &Value) -> Parsed<Vec<GradientStop>> {
|
||||
let opacity = num("opacity")?.map(|o| if o > 1.0 { o / 100.0 } else { o }).unwrap_or(1.0);
|
||||
let swatch = str_param(st, "swatch").map(str::to_string);
|
||||
let tint = num("tint")?.filter(|_| swatch.is_some()).map_or(1.0, |t| (t / 100.0).clamp(0.0, 1.0) as f32);
|
||||
let plates = match st.get("plates").filter(|_| swatch.is_some()) {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(p) => Some(plates_param(p).ok_or_else(|| format!("stop {i}: `plates` must be four numbers from 0 to 1 (C, M, Y, K)"))?),
|
||||
};
|
||||
Ok(GradientStop {
|
||||
offset: offset.clamp(0.0, 1.0) as f32,
|
||||
color,
|
||||
@@ -220,6 +225,7 @@ pub(crate) fn parse_stops(v: &Value) -> Parsed<Vec<GradientStop>> {
|
||||
midpoint: num("midpoint")?.unwrap_or(0.5).clamp(0.13, 0.87) as f32,
|
||||
swatch,
|
||||
tint,
|
||||
plates,
|
||||
})
|
||||
})
|
||||
.collect::<Parsed<Vec<_>>>()?;
|
||||
@@ -227,6 +233,17 @@ pub(crate) fn parse_stops(v: &Value) -> Parsed<Vec<GradientStop>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A stop's `plates` param: the process inks (C, M, Y, K, 0..1) printed with its spot swatch.
|
||||
fn plates_param(v: &Value) -> Option<[f32; 4]> {
|
||||
let a = v.as_array().filter(|a| a.len() == 4)?;
|
||||
let mut out = [0.0f32; 4];
|
||||
for (o, x) in out.iter_mut().zip(a) {
|
||||
let x = x.as_f64().filter(|x| x.is_finite())?;
|
||||
*o = x.clamp(0.0, 1.0) as f32;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Parse a gradient paint object (the `gradient` param of `paint.setFill`). Lossless for everything
|
||||
/// `vectorcraft_tools::params::gradient_params` writes.
|
||||
pub(crate) fn parse_gradient(g: &Value) -> Parsed<GradientPaint> {
|
||||
|
||||
@@ -192,6 +192,27 @@ fn spot_gradients_mixed_with_other_inks_export_as_device_n_shadings() {
|
||||
assert!(!warnings.iter().any(|w| w.contains("spot")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_stops_with_process_plates_export_both_in_device_n_shadings() {
|
||||
// #3235: Ink 0 % to 100 % over a constant 30 % cyan plate, as a DeviceN gradient imported
|
||||
// from a PDF has it.
|
||||
let (mut s, id) = setup();
|
||||
let cyan = json!({"c": 0.3, "m": 0, "y": 0, "k": 0});
|
||||
run(&mut s, "paint.editGradient", json!({"stops": [{"offset": 0, "color": cyan}, {"offset": 1, "swatch": "Ink", "plates": [0.3, 0, 0, 0]}]}));
|
||||
let (pdf, warnings) = pdf_of(&s);
|
||||
assert!(pdf.contains("/DeviceN[/Cyan/Ink]/DeviceCMYK"), "a DeviceN shading of Cyan and Ink");
|
||||
assert!(pdf.contains("/C0[0.3 0]/C1[0.3 1]"), "the cyan plate runs under the whole gradient");
|
||||
assert!(!warnings.iter().any(|w| w.contains("spot")), "{warnings:?}");
|
||||
let stops = gradient(&s, id).gradient.stops;
|
||||
assert_eq!((stops[1].swatch.as_deref(), stops[1].plates), (Some("Ink"), Some([0.3, 0.0, 0.0, 0.0])), "{stops:?}");
|
||||
// The plates are part of the stop's params, so moving the vector keeps them.
|
||||
run(&mut s, "paint.editGradient", json!({"angle": 45}));
|
||||
assert_eq!(gradient(&s, id).gradient.stops[1].plates, Some([0.3, 0.0, 0.0, 0.0]));
|
||||
// An unlinked stop has none.
|
||||
run(&mut s, "paint.editGradient", json!({"stops": [{"offset": 0, "color": cyan}, {"offset": 1, "color": "#ffffff", "plates": [0.3, 0, 0, 0]}]}));
|
||||
assert_eq!(gradient(&s, id).gradient.stops[1].plates, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_spot_gradients_mixed_with_other_inks_export_as_device_n_shadings() {
|
||||
let (mut s, _) = setup();
|
||||
|
||||
@@ -751,7 +751,8 @@ impl<'a> Scene<'a> {
|
||||
/// The stops of a gradient with a spot ink as tints of its inks: a Separation space when every
|
||||
/// stop is a tint of one spot ink or paper white, else (at Level 3) a DeviceN space of the
|
||||
/// process inks the stops use, in CMYK order, and the spot inks, in the order the stops name
|
||||
/// them. Paper white adds no ink and a gray stop is black ink. An off-center midpoint is a stop
|
||||
/// them. Paper white adds no ink, a gray stop is black ink, and a spot stop's process plates
|
||||
/// print with its ink. An off-center midpoint is a stop
|
||||
/// with the inks halfway between its two stops. `None` for a gradient without a spot ink, and,
|
||||
/// with a warning, for one whose inks can't be kept or that is mixed by the Perceptual method;
|
||||
/// it is then written in process colors.
|
||||
@@ -799,6 +800,10 @@ impl<'a> Scene<'a> {
|
||||
if let Some(i) = names.iter().position(|m| *m == n) {
|
||||
v[4 + i] = unit(s.tint);
|
||||
}
|
||||
// Process plates printed with the spot ink (a DeviceN mix from a file).
|
||||
if let Some(p) = s.plates {
|
||||
v[..4].copy_from_slice(&p.map(unit));
|
||||
}
|
||||
}
|
||||
None if paper(&s.color) => {}
|
||||
None => match self.process(&s.color) {
|
||||
|
||||
@@ -352,6 +352,16 @@ fn gradients_mixing_spot_inks_with_other_inks_are_device_n_shadings() {
|
||||
assert!(page.contains("/C0 [0 0 1] /C1 [0.6 0 0]") && page.contains("/C0 [0.6 0 0] /C1 [0 0.4 0]"), "{page}");
|
||||
assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{:?}", out.warnings);
|
||||
|
||||
// Gold Ink 0% to 100% over a constant 30% cyan plate (a DeviceN gradient read from a PDF,
|
||||
// #3235): Cyan and Gold Ink.
|
||||
let mut over = spot_stop(1.0, "Gold Ink", GOLD, 1.0);
|
||||
over.plates = Some([0.3, 0.0, 0.0, 0.0]);
|
||||
let plated = spot_gradient_doc(vec![GradientStop::new(0.0, Color::cmyk(0.3, 0.0, 0.0, 0.0)), over]);
|
||||
let out = export(&plated, &opts(&plated), None, None).unwrap();
|
||||
let ps = text(&out);
|
||||
assert!(ps.contains("/VCdn0 [/DeviceN [/Cyan (Gold Ink) cvn ] /DeviceCMYK {2 array astore"), "{ps}");
|
||||
assert!(checked_page(&ps).contains("/C0 [0.3 0] /C1 [0.3 1]"), "{ps}");
|
||||
|
||||
// Read back as bands, the tint transforms give each ink's CMYK equivalent.
|
||||
for (d, end) in [(&two, [0.5, 0.0, 0.5, 0.0]), (&mixed, [0.0, 0.0, 0.0, 0.4])] {
|
||||
let out = export(d, &EpsOptions { compatible_gradients: true, ..opts(d) }, None, None).unwrap();
|
||||
|
||||
@@ -92,7 +92,8 @@ impl Exporter<'_> {
|
||||
.stops
|
||||
.iter()
|
||||
.map(|s| match s.swatch.as_deref() {
|
||||
Some(n) if n == ink => Some(s.tint),
|
||||
// A stop that also prints process plates needs a DeviceN space.
|
||||
Some(n) if n == ink && s.plates.is_none() => Some(s.tint),
|
||||
_ if self.paper_stop(s) => Some(0.0),
|
||||
_ => None,
|
||||
})
|
||||
@@ -119,7 +120,8 @@ impl Exporter<'_> {
|
||||
/// a DeviceN space of the process inks the stops use (in CMYK order) and the spot inks (in
|
||||
/// the order the stops name them), with midpoints as explicit stops (the tints and opacity
|
||||
/// there are halfway), or for a Perceptual gradient the stops on its curve
|
||||
/// ([`vectorcraft_color::Gradient::ink_mixes`]). Paper white adds no ink. `None`, with a
|
||||
/// ([`vectorcraft_color::Gradient::ink_mixes`]). Paper white adds no ink; a spot stop with
|
||||
/// process plates ([`vectorcraft_color::GradientStop::plates`]) prints them too. `None`, with a
|
||||
/// warning, when a stop is written in RGB or Lab or the spot inks can't share a DeviceN space;
|
||||
/// the gradient is then written in the colors of its stops.
|
||||
fn device_n_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<Vec<(f32, krilla::color::Color, f32)>> {
|
||||
@@ -145,6 +147,9 @@ impl Exporter<'_> {
|
||||
Some(n) => {
|
||||
let i = spots.iter().position(|(m, _)| *m == n)?;
|
||||
v[4 + i] = s.tint;
|
||||
if let Some(p) = s.plates {
|
||||
v[..4].copy_from_slice(&p.map(|t| t.clamp(0.0, 1.0)));
|
||||
}
|
||||
}
|
||||
None if self.paper_stop(s) => {}
|
||||
None => match self.page_color(&s.color) {
|
||||
@@ -379,7 +384,7 @@ impl Exporter<'_> {
|
||||
fn one_spot_ink<'g>(&self, g: &'g vectorcraft_color::Gradient) -> Option<&'g str> {
|
||||
let spot = |s: &'g vectorcraft_color::GradientStop| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot));
|
||||
let ink = g.stops.iter().find_map(spot)?;
|
||||
g.stops.iter().all(|s| spot(s) == Some(ink) || self.paper_stop(s)).then_some(ink)
|
||||
g.stops.iter().all(|s| (spot(s) == Some(ink) && s.plates.is_none()) || self.paper_stop(s)).then_some(ink)
|
||||
}
|
||||
|
||||
/// Can every stop of `g` be inks of a DeviceN space ([`Self::device_n_stops`]): is a stop
|
||||
|
||||
@@ -323,6 +323,41 @@ impl<'p> Colors<'p> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient stop's colour that paints one spot ink together with process plates (a
|
||||
/// DeviceN colour of an ink and process colorants): the ink's swatch (marked used), its tint
|
||||
/// and the process plates (C, M, Y, K). `None` for any other colour ([`Colors::native`] takes
|
||||
/// it).
|
||||
pub fn ink_with_plates(&mut self, space: Space, comps: &[f32]) -> Option<(String, f32, [f32; 4])> {
|
||||
let Space::Inks(i) = space else { return None };
|
||||
let colorants = &self.spaces.get(i)?.colorants;
|
||||
let mut cmyk = [0.0f32; 4];
|
||||
let mut ink = None;
|
||||
for (c, t) in colorants.iter().zip(comps) {
|
||||
let t = round3(t.clamp(0.0, 1.0));
|
||||
match *c {
|
||||
Colorant::Unknown => return None,
|
||||
Colorant::None => {}
|
||||
Colorant::Process(ch) => {
|
||||
if let Some(v) = cmyk.get_mut(ch) {
|
||||
*v = v.max(t);
|
||||
}
|
||||
}
|
||||
// A second ink (or every plate) painting too can't be one stop.
|
||||
Colorant::Ink(_) | Colorant::All if t > 0.0 && ink.is_some() => return None,
|
||||
Colorant::All if t > 0.0 => return None,
|
||||
Colorant::Ink(k) if t > 0.0 => ink = Some((k, t)),
|
||||
Colorant::Ink(_) | Colorant::All => {}
|
||||
}
|
||||
}
|
||||
let (k, t) = ink?;
|
||||
if cmyk.iter().all(|v| *v <= 0.0) {
|
||||
return None;
|
||||
}
|
||||
let ink = self.inks.get_mut(k)?;
|
||||
ink.used = true;
|
||||
Some((ink.name.clone(), t, cmyk))
|
||||
}
|
||||
|
||||
/// Mark each ink that components `comps` of `space` paint with (a tint above 0) as used, so it
|
||||
/// becomes a spot swatch; nothing for a space with a colorant that can't be read.
|
||||
pub fn mark_inks(&mut self, space: Space, comps: &[f32]) {
|
||||
|
||||
@@ -17,7 +17,7 @@ use vectorcraft_doc::{AppearanceItem, Node, NodeKind, OpacityMask};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
use crate::import::{closed_subpaths, round3};
|
||||
use crate::import_color::{Colors, Native};
|
||||
use crate::import_color::{Colors, Native, Space};
|
||||
use crate::import_mask::{contains, is_rectangle, luminance};
|
||||
|
||||
/// The most mesh patches (or triangles) imported per shading: past it, the shading is a flat
|
||||
@@ -51,14 +51,8 @@ pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>, ink
|
||||
}
|
||||
}
|
||||
let ts: Vec<f32> = linear_keep(&pts).iter().filter_map(|&k| pts.get(k)).map(|p| p.0).collect();
|
||||
let mut stop_at = |t: f32, offset: f32| -> GradientStop {
|
||||
let rgb = sample(t).unwrap_or(Color::BLACK);
|
||||
match at(t).and_then(|v| colors.native(space, &v)) {
|
||||
Some(Native { color, link: Some((name, tint)) }) => GradientStop { swatch: Some(name), tint, ..GradientStop::new(offset, color) },
|
||||
Some(Native { color, link: None }) => GradientStop::new(offset, color),
|
||||
None => GradientStop::new(offset, rgb),
|
||||
}
|
||||
};
|
||||
let mut stop_at =
|
||||
|t: f32, offset: f32| -> GradientStop { shading_stop(colors, space, at(t).as_deref(), offset, sample(t).unwrap_or(Color::BLACK)) };
|
||||
let c = |x: f32, y: f32| m * Point::new(x as f64, y as f64);
|
||||
let (kind, geom, stops) = if *axial {
|
||||
let geom = GradientGeom { start: c(coords[0], coords[1]), end: c(coords[2], coords[3]), aspect: 1.0, focal: None };
|
||||
@@ -81,6 +75,21 @@ pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>, ink
|
||||
Some((g, *extend))
|
||||
}
|
||||
|
||||
/// A shading's stop at `offset` of components `comps` of `space` in their own model, linked to
|
||||
/// its spot swatch at its tint (with the process plates it prints with, looking like `rgb`, the
|
||||
/// colour the file gives it); `rgb` when the components can't be read or kept.
|
||||
fn shading_stop(colors: &mut Colors<'_>, space: Space, comps: Option<&[f32]>, offset: f32, rgb: Color) -> GradientStop {
|
||||
let Some(v) = comps else { return GradientStop::new(offset, rgb) };
|
||||
if let Some((name, tint, plates)) = colors.ink_with_plates(space, v) {
|
||||
return GradientStop { swatch: Some(name), tint, plates: Some(plates), ..GradientStop::new(offset, rgb) };
|
||||
}
|
||||
match colors.native(space, v) {
|
||||
Some(Native { color, link: Some((name, tint)) }) => GradientStop { swatch: Some(name), tint, ..GradientStop::new(offset, color) },
|
||||
Some(Native { color, link: None }) => GradientStop::new(offset, color),
|
||||
None => GradientStop::new(offset, rgb),
|
||||
}
|
||||
}
|
||||
|
||||
/// The samples of `pts` (offset, RGB; at least one) a gradient keeps as stops: the first, the
|
||||
/// last, and each one that linear interpolation between its neighbouring stops doesn't reproduce.
|
||||
fn linear_keep(pts: &[(f32, [f32; 3])]) -> Vec<usize> {
|
||||
@@ -299,11 +308,7 @@ fn function_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>, inks: Option<
|
||||
for k in linear_keep(&pts) {
|
||||
let Some(&(offset, read, c)) = piece.get(k) else { continue };
|
||||
let rgb = Color::rgb(round3(c[0]), round3(c[1]), round3(c[2]));
|
||||
out.push(match value(read).and_then(|v| colors.native(space, &v)) {
|
||||
Some(Native { color, link: Some((name, tint)) }) => GradientStop { swatch: Some(name), tint, ..GradientStop::new(offset, color) },
|
||||
Some(Native { color, link: None }) => GradientStop::new(offset, color),
|
||||
None => GradientStop::new(offset, rgb),
|
||||
});
|
||||
out.push(shading_stop(colors, space, value(read).as_deref(), offset, rgb));
|
||||
}
|
||||
}
|
||||
if out.len() > MAX_FUNCTION_STOPS {
|
||||
|
||||
@@ -178,12 +178,65 @@ fn a_spot_ink_mixed_with_a_process_plate_keeps_its_swatch() {
|
||||
for (what, page) in [("fill", fill), ("shading", sh)] {
|
||||
let r = import_with_report(&pdf_with(&[page], &[func], None), &ImportOptions::default()).unwrap();
|
||||
let d = &r.document;
|
||||
assert!(r.warnings.iter().any(|w| w.contains("several inks")), "{what}: {:?}", r.warnings);
|
||||
assert!(links(d).iter().all(Option::is_none), "{what}: the art stays RGB");
|
||||
let gold = d.swatch("Gold").unwrap_or_else(|| panic!("{what}: no Gold swatch"));
|
||||
assert!(gold.spot && gold.global, "{what}: Gold is a spot swatch");
|
||||
assert!(near(gold.paint.color().unwrap(), Color::cmyk(0.0, 0.0, 1.0, 0.0)), "{what}: {gold:?}");
|
||||
assert_eq!(d.swatches.iter().filter(|s| s.spot).count(), 1, "{what}: Cyan is a process plate");
|
||||
if what == "fill" {
|
||||
// A solid paint can't link to an ink and process plates together.
|
||||
assert!(r.warnings.iter().any(|w| w.contains("several inks")), "{what}: {:?}", r.warnings);
|
||||
assert!(links(d).iter().all(Option::is_none), "{what}: the art stays RGB");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient stop of `d`'s only gradient, in paint order.
|
||||
fn gradient_stops(d: &Document) -> Vec<GradientStop> {
|
||||
let mut out = vec![];
|
||||
d.walk(|n: &Node| {
|
||||
for i in &n.appearance.items {
|
||||
if let Paint::Gradient(g) = i.paint() {
|
||||
out.extend(g.gradient.stops.iter().cloned());
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_n_gradient_of_a_spot_ink_over_a_process_plate_keeps_both_inks() {
|
||||
// #3235: an axial shading in [/DeviceN [/Gold /Cyan] /DeviceCMYK …], Gold 0 to 1 over Cyan
|
||||
// at 0.3 (ISO 32000-1:2008, 8.6.6.5 and 8.7.4.5.3). Its stops link to Gold, with the cyan
|
||||
// plate kept, and PDF export (PDF/X-4 too) writes it again as a DeviceN shading of both inks.
|
||||
let space = format!("[/DeviceN [/Gold /Cyan] /DeviceCMYK {} 0 R]", first_extra(1));
|
||||
let func = "<< /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1 0 1] /Length 17 >>\nstream\n{ exch 0 exch 0 }\nendstream";
|
||||
let shading = format!(
|
||||
"<< /ShadingType 2 /ColorSpace {space} /Coords [0 0 50 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0 0.3] /C1 [1 0.3] /N 1 >> /Extend [true true] >>"
|
||||
);
|
||||
let page = PdfPage { resources: format!("/Shading << /Sh0 {shading} >>"), ..PdfPage::new(100.0, 50.0, "q 0 0 50 50 re W n /Sh0 sh Q") };
|
||||
let r = import_with_report(&pdf_with(&[page], &[func], None), &ImportOptions::default()).unwrap();
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("several inks")), "{:?}", r.warnings);
|
||||
let d = &r.document;
|
||||
let stops = gradient_stops(d);
|
||||
let (Some(first), Some(last)) = (stops.first(), stops.last()) else { panic!("no gradient: {stops:?}") };
|
||||
// Gold at 0 % is cyan alone; Gold at 100 % keeps the cyan plate.
|
||||
assert_eq!((first.swatch.as_deref(), first.plates), (None, None), "{first:?}");
|
||||
assert!(near(first.color, Color::cmyk(0.3, 0.0, 0.0, 0.0)), "{first:?}");
|
||||
assert_eq!(last.swatch.as_deref(), Some("Gold"), "{last:?}");
|
||||
assert!((last.tint - 1.0).abs() < 1e-3, "{last:?}");
|
||||
let plates = last.plates.unwrap_or_else(|| panic!("no plates: {last:?}"));
|
||||
assert!((plates[0] - 0.3).abs() < 1e-3 && plates[1..].iter().all(|p| *p == 0.0), "{plates:?}");
|
||||
// The stop looks as the file draws it: cyan and yellow, a green.
|
||||
let [r_, g_, b_] = last.color.to_rgb();
|
||||
assert!(g_ > r_ && g_ > b_, "{:?}", last.color);
|
||||
for standard in [Standard::None, Standard::PdfX4] {
|
||||
let mut o = PdfOptions::uncompressed();
|
||||
(o.settings.standard, o.settings.compatibility) = (standard, standard.version());
|
||||
let text = String::from_utf8_lossy(&export(d, &o).unwrap()).into_owned();
|
||||
assert!(text.contains("/DeviceN [/Cyan /Gold]") || text.contains("/DeviceN[/Cyan/Gold]"), "{standard:?}: no DeviceN space of Cyan and Gold");
|
||||
assert!(text.contains("/Separation /Gold") || text.contains("/Separation/Gold"), "{standard:?}: Gold has no Separation colorant");
|
||||
assert!(text.contains("/C0 [0.3 0]") || text.contains("/C0[0.3 0]"), "{standard:?}: the first stop is cyan alone");
|
||||
assert!(text.contains("/C1 [0.3 1]") || text.contains("/C1[0.3 1]"), "{standard:?}: the last stop is cyan and Gold");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ pub fn stops_json(stops: &[GradientStop]) -> Value {
|
||||
if let Some(n) = &s.swatch {
|
||||
v["swatch"] = json!(n);
|
||||
v["tint"] = json!(s.tint * 100.0);
|
||||
if let Some(p) = s.plates {
|
||||
v["plates"] = json!(p);
|
||||
}
|
||||
}
|
||||
v
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (native format: dynamic symbols, #4938) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (PDF import: DeviceN gradients of a spot ink over process plates, #3235) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
|
||||
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
|
||||
@@ -169,6 +169,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-05 | minor | PDF import: the stops of an axial or radial shading in a DeviceN space of one spot ink and process plates link to the ink's swatch and keep the plates (`GradientStop.plates` in the native format); PDF and EPS export write them in a DeviceN space again (#3235) |
|
||||
| 2026-11-01 | minor | Native format: a symbol's Symbol Type (`dynamic`) and a dynamic symbol instance's appearance changes to objects of its art (`symbol_overrides`) round-trip; both are left out when unset, so other documents save unchanged (#4938). `.ai` reading and writing of dynamic symbols remains. |
|
||||
| 2026-11-01 | minor | Exports draw each artboard's background color behind its art (#4937): PNG, JPEG, GIF, TIFF, WebP, BMP, Targa and PSD (a layer of its own; on a transparent background too), PDF, SVG and EPS pages, a `.ai` file's PDF pages and PostScript and PDF print; `includeArtboardFill: false` leaves it out. Files with editing data (Preserve Editing, `.ai`, EPS, native) keep it as the artboard's color and reopen without extra art. Not read from the reference app's own `.ai` data, nor written to it. DXF, EMF and WMF leave it out |
|
||||
| 2026-10-31 | minor | PDF import finds an installed font whose PDF name holds hyphens (`Noto-Sans-Bold` is Noto Sans, Bold: the longest part before a hyphen that names an installed family) and a composite font by its CIDFont's name rather than its base name, which ends in the CMap's (`Name-Identity-H`, [ISO 32000-1 §9.7.6.1](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf)); a missing font is split only before a style part (`Helvetica-BoldOblique`), so it is reported as `MS PGothic`, not `MS`; scores unchanged (estimated) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Transform Each: Relative and Absolute scaling and the documented panel order; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (PDF import keeps DeviceN gradients of a spot ink over process plates, #3235; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -24,6 +24,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-04 | minor | Transform Each's Scale has the documented Relative and Absolute radio buttons (`object.transformEach` `relative`: percentages of each object's size, or the width and height it becomes, measured on each object's own bounds) and the link that keeps the two scales in proportion; the panel lists Scale, Move, Rotate, the Options in two columns, the Reference Point and Preview (level with Copy, Cancel and OK) in the documented order. Scores and estimates unchanged. |
|
||||
| 2026-11-05 | minor | PDF import: a DeviceN gradient of a spot ink over process plates keeps the ink and the plates, and PDF export writes it as a DeviceN shading again (#3235); scores unchanged |
|
||||
| 2026-11-05 | minor | Optical kerning moves each pair part of the way toward the font's own stem spacing, with a minimum gap and tighter limits, and leaves ideographs, kana, fullwidth forms and dashes on the font's spacing, so text is no longer squeezed (#5336). Scores and estimates unchanged. |
|
||||
| 2026-11-04 | minor | Type core: a Type-range Copy or Cut publishes styled art alongside plain text, so canvas Paste keeps font family, size and color after editing ends (#5636); scores and estimates unchanged. |
|
||||
| 2026-11-04 | minor | Selection Tools add-on: Select by Artboards selects the objects overlapping, fully inside or fully outside several chosen artboards, from a dialog that lists, searches and multi-selects the artboards and fits one in the window on double-click (#5525, #5548, #5549, a VectorCraft addition); scores and estimates unchanged. |
|
||||
@@ -1134,7 +1135,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Files (M4):**
|
||||
- PDF export and print composition use the optional shared guarded writer ([pdf-backend.md](pdf-backend.md)); app drawing and output policy and desktop printer submission remain in their existing layers. The shared reader keeps the app-owned import adapter; synthetic tests cover layer and overprint state across windows, long operands, inline images, failed decoding, document limits and incomplete tails. Empty and populated metadata key presence is regression-tested, and CI checks the immutable backend source.
|
||||
Invisible text in a PDF (rendering mode 3, which carries a scanned page's recognized text over the scan) opens as type with no fill or stroke, a run of its own beside visible text on the same line; text that can't come in as type (opened as outlines, Type 3 fonts) is left out with a warning. PDF export, and printing, write characters with no fill or stroke as real text in rendering mode 3, so they draw nothing but stay selectable and searchable, also in PDF/X-1a and PDF/A-2b (#2675, `pdf/src/tests/import_fidelity.rs`, `pdf/src/tests/realtext.rs`).
|
||||
A PDF, or a `.ai` read through its PDF part, whose spot inks have the same values but different names opens with a spot swatch for each ink. The solid fills and strokes of paths and type, and the stops of gradients on paths, link to the swatch of the ink they are painted in, the points of a gradient mesh in one of these inks keep the ink's color, and PDF export writes each of these inks as its own separation (`pdf/src/tests/import_color.rs`). Paint in these inks inside pattern cells, soft masks and type 3 glyphs, and gradients on type in these inks, still import as RGB. Type in one of these inks can also import as RGB when type in another Separation or DeviceN ink is shown with no path drawn between them.
|
||||
A PDF, or a `.ai` read through its PDF part, whose spot inks have the same values but different names opens with a spot swatch for each ink. The solid fills and strokes of paths and type, and the stops of gradients on paths, link to the swatch of the ink they are painted in, the points of a gradient mesh in one of these inks keep the ink's color, and PDF export writes each of these inks as its own separation (`pdf/src/tests/import_color.rs`). Paint in these inks inside pattern cells, soft masks and type 3 glyphs, and gradients on type in these inks, still import as RGB. Type in one of these inks can also import as RGB when type in another Separation or DeviceN ink is shown with no path drawn between them. The stops of an axial or radial shading in a DeviceN space that mixes one spot ink with process plates (a spot ink over a constant cyan, for example) link to the ink's swatch at its tint and keep the process plates, and PDF and EPS (Level 3) export write those stops in a DeviceN space of the ink and those plates, so the spot plate is not lost (#3235, `import_color::a_device_n_gradient_of_a_spot_ink_over_a_process_plate_keeps_both_inks`). Solid fills that mix a spot ink with process plates, and stops that mix several spot inks, still import as RGB, with the spot inks kept as swatches.
|
||||
PDF export writes a linear or radial gradient that mixes a spot color with process colors, or two spot colors, as a DeviceN shading of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. A gradient that mixes a spot color with RGB or Lab colors is still written without the spot ink, with a warning. A Perceptual gradient of spot inks is written as a Separation or DeviceN shading of the stops on its curve; a dithered gradient fill, and a Perceptual gradient with RGB or Lab stops other than white or more than 4,096 stops on its curve, are written as images in process colors, with a warning. A Lab spot color in such a shading is named by the same Separation space, with the Lab alternate, as its flat fills (`engine/src/tests/linked_stops.rs`, `engine/src/tests/labspots.rs`).
|
||||
PDF export writes a spot color named All or None (in a Separation space, the names of every plate and of no plate: [ISO 32000-1:2008](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf), 8.6.6.4) as the ink All Spot or None Spot (All Spot 2 and on when a spot swatch already has that name) in its fills, strokes, gradients, colorized images and Lab alternates; Registration still prints on every plate (`pdf/src/tests/output.rs`).
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604).
|
||||
|
||||
Reference in New Issue
Block a user