mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 20:26:57 -06:00
Merge branch 'print-color-gradients' into print-color
# Conflicts: # crates/pdf/src/lib.rs
This commit is contained in:
@@ -349,6 +349,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
Newest first.
|
||||
|
||||
- **2026-10-20** · Print color (craft-libs#190): CMYK print PDFs blend in DeviceCMYK and carry the CMYK working space as their output intent through craft-pdf's print settings instead of byte rewrites; overprint (Overprint Fill, Stroke and Gap, overprinting text, 100% [Black]) is written as `/op` `/OP` `/OPM 1`; Knockout Group writes `/K true` instead of becoming an image; File › Print prints the PDF Export PDF writes. No readiness percentage change.
|
||||
- **2026-10-20** · Spot gradients print on their plates. A gradient stop keeps the swatch it was given (Gradient panel drop, `swatch.create {gradient}`, `object.gradient`, IDML `StopColor`), following its renames and edits. PDF export shades one spot with its tints or [Paper] as a Separation, and a spot with process inks or a second spot as DeviceN; a process gradient to [Paper] prints in DeviceCMYK. Documents with plain-colour stops export as before. No readiness percentage change.
|
||||
- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
|
||||
- **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change.
|
||||
- **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change.
|
||||
|
||||
@@ -164,10 +164,7 @@ pub fn synthetic(spec: &Spec) -> Result<Document, String> {
|
||||
value: SwatchValue::Gradient {
|
||||
gradient: Gradient {
|
||||
kind: GradientKind::Linear,
|
||||
stops: vec![
|
||||
GradientStop { offset: 0.0, color: Color::cmyk(0.6, 0.9, 0.3, 0.2), opacity: 1.0, midpoint: 0.5 },
|
||||
GradientStop { offset: 1.0, color: Color::cmyk(0.0, 0.6, 0.8, 0.0), opacity: 1.0, midpoint: 0.5 },
|
||||
],
|
||||
stops: vec![GradientStop::new(0.0, Color::cmyk(0.6, 0.9, 0.3, 0.2)), GradientStop::new(1.0, Color::cmyk(0.0, 0.6, 0.8, 0.0))],
|
||||
},
|
||||
},
|
||||
locked: false,
|
||||
|
||||
@@ -24,16 +24,29 @@ impl GradientKind {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GradientStop {
|
||||
/// 0..=1 along the gradient.
|
||||
pub offset: f32,
|
||||
/// The display colour. For a stop with a `swatch`, the swatch's colour when it was last
|
||||
/// resolved ([`GradientStop::swatch`]).
|
||||
pub color: Color,
|
||||
#[serde(default = "one")]
|
||||
pub opacity: f32,
|
||||
/// Midpoint to the next stop, 0.13..=0.87 (InDesign's diamond), default 0.5.
|
||||
#[serde(default = "half")]
|
||||
pub midpoint: f32,
|
||||
/// The colour swatch the stop takes its ink from: a spot or process colour, a tint of one,
|
||||
/// or `[Paper]`. Print export writes the stop in that swatch's inks; `None` is a plain colour.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub swatch: Option<String>,
|
||||
}
|
||||
|
||||
impl GradientStop {
|
||||
/// A plain-colour stop, opaque, with the midpoint halfway to the next stop.
|
||||
pub fn new(offset: f32, color: Color) -> Self {
|
||||
Self { offset, color, opacity: 1.0, midpoint: 0.5, swatch: None }
|
||||
}
|
||||
}
|
||||
|
||||
fn one() -> f32 {
|
||||
@@ -52,13 +65,7 @@ pub struct Gradient {
|
||||
impl Default for Gradient {
|
||||
/// InDesign's default "White, Black" gradient.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
kind: GradientKind::Linear,
|
||||
stops: vec![
|
||||
GradientStop { offset: 0.0, color: Color::WHITE, opacity: 1.0, midpoint: 0.5 },
|
||||
GradientStop { offset: 1.0, color: Color::BLACK, opacity: 1.0, midpoint: 0.5 },
|
||||
],
|
||||
}
|
||||
Self { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +245,16 @@ mod tests {
|
||||
assert!(matches!(Color::rgb(1.0, 0.0, 0.0).lerp(&Color::cmyk(1.0, 0.0, 0.0, 0.0), 0.5), Color::Rgb { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_stops_without_a_swatch_are_plain_colours() {
|
||||
let old = r#"{"offset": 0.0, "color": {"model": "cmyk", "c": 0.0, "m": 0.3, "y": 0.9, "k": 0.1}, "opacity": 1.0, "midpoint": 0.5}"#;
|
||||
let stop: GradientStop = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(stop, GradientStop::new(0.0, Color::cmyk(0.0, 0.3, 0.9, 0.1)));
|
||||
assert!(!serde_json::to_string(&stop).unwrap().contains("swatch"), "plain stops save as before");
|
||||
let on = GradientStop { swatch: Some("Gold".into()), ..stop };
|
||||
assert_eq!(serde_json::from_str::<GradientStop>(&serde_json::to_string(&on).unwrap()).unwrap(), on);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midpoint_shifts() {
|
||||
let mut g = Gradient::default();
|
||||
@@ -272,7 +289,7 @@ mod tests {
|
||||
// With several stops, the reversed gradient is the original mirrored.
|
||||
let mut g = Gradient::default();
|
||||
g.stops[0].midpoint = 0.3;
|
||||
g.stops.insert(1, GradientStop { offset: 0.4, color: Color::rgb(1.0, 0.0, 0.0), opacity: 0.5, midpoint: 0.8 });
|
||||
g.stops.insert(1, GradientStop { offset: 0.4, color: Color::rgb(1.0, 0.0, 0.0), opacity: 0.5, midpoint: 0.8, swatch: None });
|
||||
let original = g.clone();
|
||||
g.reverse();
|
||||
for t in [0.1, 0.3, 0.5, 0.7, 0.9] {
|
||||
|
||||
@@ -142,9 +142,50 @@ impl Names<'_> {
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Give gradient stops that name a colour swatch that swatch's current colour, after a
|
||||
/// swatch edit. Stops whose swatch is gone or isn't a colour keep the colour they have.
|
||||
pub fn refresh_gradient_stops(&mut self) {
|
||||
let mut fresh: Vec<(usize, usize, designcraft_color::Color)> = Vec::new();
|
||||
for (wi, w) in self.swatches.iter().enumerate() {
|
||||
let SwatchValue::Gradient { gradient } = &w.value else { continue };
|
||||
for (si, stop) in gradient.stops.iter().enumerate() {
|
||||
let Some(name) = &stop.swatch else { continue };
|
||||
if designcraft_color::swatch::resolve_gradient(&self.swatches, name).is_some() {
|
||||
continue;
|
||||
}
|
||||
if let Some(c) = self.resolve_color(name, 1.0)
|
||||
&& c != stop.color
|
||||
{
|
||||
fresh.push((wi, si, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (wi, si, c) in fresh {
|
||||
if let Some(SwatchValue::Gradient { gradient }) = self.swatches.get_mut(wi).map(|w| &mut w.value)
|
||||
&& let Some(stop) = gradient.stops.get_mut(si)
|
||||
{
|
||||
stop.color = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn gradient stops that name swatch `name` into plain colours (the swatch is being
|
||||
/// deleted); each keeps the colour it shows.
|
||||
pub fn forget_gradient_stop_swatch(&mut self, name: &str) {
|
||||
for w in &mut self.swatches {
|
||||
let SwatchValue::Gradient { gradient } = &mut w.value else { continue };
|
||||
for stop in &mut gradient.stops {
|
||||
if stop.swatch.as_deref() == Some(name) {
|
||||
stop.swatch = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Point every reference to swatch `from` at `to`: tints based on it, colour groups, inks,
|
||||
/// styles, page items (fills, strokes, gaps, effects, column rules), text, kenten and ruby
|
||||
/// colours, rules, shading, borders and border gaps, tables (with stroke gaps), footnotes,
|
||||
/// gradient stops,
|
||||
/// endnotes and the footnote rule. The swatch itself is renamed by the
|
||||
/// caller.
|
||||
pub fn rename_swatch_refs(&mut self, from: &str, to: &str) {
|
||||
@@ -153,8 +194,14 @@ impl Document {
|
||||
}
|
||||
let n = Names { from, to };
|
||||
for w in &mut self.swatches {
|
||||
if let SwatchValue::Tint { base, .. } = &mut w.value {
|
||||
n.s(base);
|
||||
match &mut w.value {
|
||||
SwatchValue::Tint { base, .. } => n.s(base),
|
||||
SwatchValue::Gradient { gradient } => {
|
||||
for stop in &mut gradient.stops {
|
||||
n.o(&mut stop.swatch);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for g in &mut self.color_groups {
|
||||
|
||||
@@ -404,7 +404,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gradient",
|
||||
[],
|
||||
None,
|
||||
"{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255)|{model: rgb|cmyk|gray, …}(0..1) | swatch: name (its colour, as dropped on the Gradient panel), opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), target?: fill|stroke, ids?} → {swatch, kind, stops} — edits the fill's (or the stroke's) gradient (an unnamed gradient unless it equals a gradient swatch)",
|
||||
"{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255)|{model: rgb|cmyk|gray, …}(0..1) | swatch: a colour swatch's name (as dropped on the Gradient panel: its colour on screen, its inks in print), opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), target?: fill|stroke, ids?} → {swatch, kind, stops: [{location, color, swatch?, opacity, midpoint}]} — edits the fill's (or the stroke's) gradient (an unnamed gradient unless it equals a gradient swatch)",
|
||||
super::has_selection,
|
||||
apply_gradient
|
||||
),
|
||||
@@ -490,6 +490,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return Err(bad("swatch.delete", "special swatches can't be deleted"));
|
||||
}
|
||||
d.swatches.retain(|w| w.name != name);
|
||||
d.forget_gradient_stop_swatch(&name);
|
||||
for g in &mut d.color_groups {
|
||||
g.swatches.retain(|w| *w != name);
|
||||
}
|
||||
@@ -1295,13 +1296,14 @@ fn create_gradient_swatch(s: &mut Session, p: &Value, g: &Value) -> Result<Value
|
||||
let doc = &s.doc()?.doc;
|
||||
let mut stops = Vec::new();
|
||||
for st in g.get("stops").and_then(Value::as_array).ok_or_else(|| bad(ID, "a gradient needs stops"))? {
|
||||
let color = match (st.get("swatch").and_then(Value::as_str), st.get("color")) {
|
||||
// The stop keeps the swatch, so print export writes it in that swatch's inks.
|
||||
let (color, swatch) = match (st.get("swatch").and_then(Value::as_str), st.get("color")) {
|
||||
(Some(n), _) => match doc.swatch(n).map(|w| &w.value) {
|
||||
Some(SwatchValue::Gradient { .. }) => return Err(bad(ID, format!("`{n}` is a gradient; a stop takes a colour"))),
|
||||
Some(_) => doc.resolve_color(n, 1.0).ok_or_else(|| bad(ID, format!("`{n}` has no colour")))?,
|
||||
Some(_) => (doc.resolve_color(n, 1.0).ok_or_else(|| bad(ID, format!("`{n}` has no colour")))?, Some(n.to_string())),
|
||||
None => return Err(bad(ID, format!("no swatch named `{n}`"))),
|
||||
},
|
||||
(None, Some(c)) => parse_color(c).ok_or_else(|| bad(ID, "a stop has a bad color"))?,
|
||||
(None, Some(c)) => (parse_color(c).ok_or_else(|| bad(ID, "a stop has a bad color"))?, None),
|
||||
(None, None) => return Err(bad(ID, "a stop needs a swatch or a color")),
|
||||
};
|
||||
let pct = |k: &str, d: f64| st.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(d).clamp(0.0, 100.0) as f32 / 100.0;
|
||||
@@ -1310,6 +1312,7 @@ fn create_gradient_swatch(s: &mut Session, p: &Value, g: &Value) -> Result<Value
|
||||
color,
|
||||
opacity: pct("opacity", 100.0),
|
||||
midpoint: pct("midpoint", 50.0).clamp(0.13, 0.87),
|
||||
swatch,
|
||||
});
|
||||
}
|
||||
if stops.len() < 2 {
|
||||
@@ -1660,7 +1663,13 @@ fn gradient_json(name: &str, g: &designcraft_color::Gradient) -> Value {
|
||||
let stops: Vec<Value> = g
|
||||
.stops
|
||||
.iter()
|
||||
.map(|s| json!({"location": (s.offset as f64 * 1000.0).round() / 10.0, "color": s.color.to_hex(), "opacity": (s.opacity * 100.0).round(), "midpoint": (s.midpoint * 100.0).round()}))
|
||||
.map(|s| {
|
||||
let mut v = json!({"location": (s.offset as f64 * 1000.0).round() / 10.0, "color": s.color.to_hex(), "opacity": (s.opacity * 100.0).round(), "midpoint": (s.midpoint * 100.0).round()});
|
||||
if let (Some(o), Some(w)) = (v.as_object_mut(), &s.swatch) {
|
||||
o.insert("swatch".into(), json!(w));
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
json!({"swatch": name, "kind": if g.kind == designcraft_color::GradientKind::Radial { "radial" } else { "linear" }, "stops": stops})
|
||||
}
|
||||
@@ -1678,13 +1687,18 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = &s.doc()?.doc;
|
||||
let mut v = Vec::new();
|
||||
for st in a {
|
||||
// A swatch dropped on a stop gives it the swatch's colour (a tint's mixed in).
|
||||
let color = match st.get("swatch").and_then(Value::as_str) {
|
||||
Some(name) => doc
|
||||
.resolve_color(name, 1.0)
|
||||
.filter(|_| designcraft_color::swatch::resolve_gradient(&doc.swatches, name).is_none())
|
||||
.ok_or_else(|| bad("object.gradient", format!("`{name}` isn't a colour swatch")))?,
|
||||
None => st.get("color").and_then(parse_color).ok_or_else(|| bad("object.gradient", "a stop needs a color or a swatch"))?,
|
||||
// A swatch dropped on a stop gives it the swatch's colour (a tint's mixed in) and
|
||||
// its inks for print.
|
||||
// A stop naming a swatch that's gone keeps the colour given with it.
|
||||
let color = st.get("color").and_then(parse_color);
|
||||
let (color, swatch) = match st.get("swatch").and_then(Value::as_str) {
|
||||
Some(name) => {
|
||||
match doc.resolve_color(name, 1.0).filter(|_| designcraft_color::swatch::resolve_gradient(&doc.swatches, name).is_none()) {
|
||||
Some(c) => (c, Some(name.to_string())),
|
||||
None => (color.ok_or_else(|| bad("object.gradient", format!("`{name}` isn't a colour swatch")))?, None),
|
||||
}
|
||||
}
|
||||
None => (color.ok_or_else(|| bad("object.gradient", "a stop needs a color or a swatch"))?, None),
|
||||
};
|
||||
let pct = |k: &str, d: f64| st.get(k).and_then(Value::as_f64).unwrap_or(d).clamp(0.0, 100.0) as f32 / 100.0;
|
||||
v.push(GradientStop {
|
||||
@@ -1692,6 +1706,7 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
color,
|
||||
opacity: pct("opacity", 100.0),
|
||||
midpoint: pct("midpoint", 50.0).clamp(0.13, 0.87),
|
||||
swatch,
|
||||
});
|
||||
}
|
||||
if v.len() < 2 {
|
||||
@@ -2107,6 +2122,7 @@ fn swatch_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let slot = d.swatches.iter_mut().find(|x| x.name == name).ok_or_else(|| bad(ID, format!("no swatch `{name}`")))?;
|
||||
*slot = w2;
|
||||
d.rename_swatch_refs(&name, &new);
|
||||
d.refresh_gradient_stops();
|
||||
Ok(json!({"name": new}))
|
||||
})?;
|
||||
if let Some(new) = result.get("name").and_then(Value::as_str) {
|
||||
@@ -3445,6 +3461,31 @@ mod gradient_swatch_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_stops_follow_their_swatch() {
|
||||
let mut s = doc();
|
||||
s.execute("swatch.create", &json!({"name": "Gold", "color": {"c": 0, "m": 30, "y": 90, "k": 10}, "spot": true})).unwrap();
|
||||
s.execute(
|
||||
"swatch.create",
|
||||
&json!({"name": "G", "gradient": {"stops": [{"location": 0, "swatch": "Gold"}, {"location": 100, "swatch": "[Paper]"}]}}),
|
||||
)
|
||||
.unwrap();
|
||||
let stops = |s: &crate::Session| match &s.doc().unwrap().doc.swatch("G").unwrap().value {
|
||||
SwatchValue::Gradient { gradient } => gradient.stops.clone(),
|
||||
v => panic!("{v:?}"),
|
||||
};
|
||||
assert_eq!(stops(&s)[0].swatch.as_deref(), Some("Gold"));
|
||||
assert_eq!(stops(&s)[1].swatch.as_deref(), Some("[Paper]"));
|
||||
// Renamed and edited: the stop names the new name and shows the new colour.
|
||||
s.execute("swatch.options", &json!({"name": "Gold", "to": "Gold 871", "color": {"c": 0, "m": 20, "y": 80, "k": 20}})).unwrap();
|
||||
assert_eq!(stops(&s)[0].swatch.as_deref(), Some("Gold 871"));
|
||||
assert_eq!(stops(&s)[0].color, designcraft_color::Color::cmyk(0.0, 0.2, 0.8, 0.2));
|
||||
// Deleted: the stop keeps its colour as a plain one.
|
||||
s.execute("swatch.delete", &json!({"name": "Gold 871"})).unwrap();
|
||||
assert_eq!(stops(&s)[0].swatch, None);
|
||||
assert_eq!(stops(&s)[0].color, designcraft_color::Color::cmyk(0.0, 0.2, 0.8, 0.2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_swatch_names_the_selections_gradient_or_duplicates_a_swatch() {
|
||||
let mut s = doc();
|
||||
|
||||
@@ -127,10 +127,7 @@ fn build() -> designcraft_doc::Result<Document> {
|
||||
value: SwatchValue::Gradient {
|
||||
gradient: Gradient {
|
||||
kind: GradientKind::Linear,
|
||||
stops: vec![
|
||||
GradientStop { offset: 0.0, color: Color::cmyk(0.62, 0.95, 0.30, 0.25), opacity: 1.0, midpoint: 0.5 },
|
||||
GradientStop { offset: 1.0, color: Color::cmyk(0.0, 0.62, 0.78, 0.0), opacity: 1.0, midpoint: 0.5 },
|
||||
],
|
||||
stops: vec![GradientStop::new(0.0, Color::cmyk(0.62, 0.95, 0.30, 0.25)), GradientStop::new(1.0, Color::cmyk(0.0, 0.62, 0.78, 0.0))],
|
||||
},
|
||||
},
|
||||
locked: false,
|
||||
|
||||
@@ -594,9 +594,16 @@ impl<'a> Ex<'a> {
|
||||
.attr("Visible", "true")
|
||||
.attr("SwatchCreatorID", "7937");
|
||||
for (i, stop) in gradient.stops.iter().enumerate() {
|
||||
let cid = match by_value.iter().find(|(c, _)| *c == stop.color) {
|
||||
Some((_, id)) => id.clone(),
|
||||
None => {
|
||||
// A stop on a swatch names it; a plain colour names an equal colour swatch or
|
||||
// an unnamed colour.
|
||||
let named = stop
|
||||
.swatch
|
||||
.as_deref()
|
||||
.filter(|n| d.swatch(n).is_some_and(|w| !matches!(w.value, SwatchValue::Gradient { .. } | SwatchValue::None)));
|
||||
let cid = match (named, by_value.iter().find(|(c, _)| *c == stop.color)) {
|
||||
(Some(n), _) => self.sw(n),
|
||||
(None, Some((_, id))) => id.clone(),
|
||||
(None, None) => {
|
||||
let cid = format!("Color/{}", self.fresh());
|
||||
colors.push(color_el(&cid, "$ID/", "Process", &stop.color, "Normal", true, true, false));
|
||||
by_value.push((stop.color, cid.clone()));
|
||||
|
||||
@@ -976,12 +976,17 @@ impl<'r> Importer<'r> {
|
||||
let Some(id) = e.get("Self") else { continue };
|
||||
let mut stops: Vec<GradientStop> = Vec::new();
|
||||
for s in e.find_all("GradientStop") {
|
||||
let c = s.get("StopColor").map(|r| self.color_for_ref(r, &order)).unwrap_or(Color::BLACK);
|
||||
let r = s.get("StopColor");
|
||||
let c = r.map(|r| self.color_for_ref(r, &order)).unwrap_or(Color::BLACK);
|
||||
// A stop on a named swatch (a spot, a tint, [Paper]) keeps it for its inks;
|
||||
// unnamed colours stay plain. The id becomes the swatch's name below, once
|
||||
// repeated names are numbered.
|
||||
let swatch = r.filter(|r| self.swatch_names.get(*r).is_some_and(|n| n != swatch::NONE)).map(str::to_string);
|
||||
let loc = s.num("Location").unwrap_or(0.0) / 100.0;
|
||||
if let (Some(prev), Some(m)) = (stops.last_mut(), s.num("Midpoint")) {
|
||||
prev.midpoint = (m / 100.0) as f32;
|
||||
}
|
||||
stops.push(GradientStop { offset: loc as f32, color: c, opacity: 1.0, midpoint: 0.5 });
|
||||
stops.push(GradientStop { swatch, ..GradientStop::new(loc as f32, c) });
|
||||
}
|
||||
let kind = if e.get("Type") == Some("Radial") { GradientKind::Radial } else { GradientKind::Linear };
|
||||
let unnamed = e.get("Visible") == Some("false") || e.get("Name").is_none_or(|n| n.is_empty() || n == "$ID/");
|
||||
@@ -1006,6 +1011,7 @@ impl<'r> Importer<'r> {
|
||||
order.sort_by_key(|(id, _)| group_order.iter().position(|g| g == id).unwrap_or(usize::MAX));
|
||||
}
|
||||
let mut names: HashSet<String> = self.swatches.iter().map(|s| s.name.clone()).collect();
|
||||
let first_new = self.swatches.len();
|
||||
for (id, mut s) in order {
|
||||
if names.contains(&s.name) {
|
||||
let base = s.name.clone();
|
||||
@@ -1015,6 +1021,16 @@ impl<'r> Importer<'r> {
|
||||
names.insert(s.name.clone());
|
||||
self.swatches.push(s);
|
||||
}
|
||||
let stop_names = |stops: &mut Vec<GradientStop>, ids: &HashMap<String, String>| {
|
||||
for stop in stops {
|
||||
stop.swatch = stop.swatch.take().and_then(|id| ids.get(&id).cloned());
|
||||
}
|
||||
};
|
||||
for s in self.swatches.iter_mut().skip(first_new).chain(self.hidden_swatches.values_mut()) {
|
||||
if let SwatchValue::Gradient { gradient } = &mut s.value {
|
||||
stop_names(&mut gradient.stops, &self.swatch_names);
|
||||
}
|
||||
}
|
||||
// Colour groups other than the root one become Swatches panel folders.
|
||||
for g in top.iter().filter(|e| e.local() == "ColorGroup" && e.get("IsRootColorGroup") != Some("true")) {
|
||||
let swatches: Vec<String> =
|
||||
|
||||
@@ -707,6 +707,8 @@ fn imports_hand_written_fixture() {
|
||||
assert_eq!(gradient.kind, designcraft_color::GradientKind::Radial);
|
||||
assert_eq!(gradient.stops[1].color, Color::cmyk(0.1, 0.2, 0.3, 0.4));
|
||||
assert!((gradient.stops[0].midpoint - 0.3).abs() < 1e-6);
|
||||
assert_eq!(gradient.stops[0].swatch.as_deref(), Some("Brand"), "a stop on a swatch keeps it");
|
||||
assert_eq!(gradient.stops[1].swatch, None, "an unnamed colour stays plain");
|
||||
// The parent rectangle used an unnamed colour → value-named swatch.
|
||||
let mr = &d.parents[0].items[0];
|
||||
assert_eq!(mr.fill.swatch, "C=10 M=20 Y=30 K=40");
|
||||
@@ -988,6 +990,29 @@ fn round_trips_small_document() {
|
||||
assert!(back.swatch("Brand 30").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_stops_keep_their_swatches_through_a_round_trip() {
|
||||
use designcraft_color::{ColorType, Gradient, GradientKind, GradientStop};
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let gold = Color::cmyk(0.0, 0.3, 0.9, 0.1);
|
||||
d.swatches.push(Swatch { value: SwatchValue::Color { color: gold, color_type: ColorType::Spot }, ..Swatch::color("Gold", gold) });
|
||||
d.swatches.push(Swatch { value: SwatchValue::Tint { base: "Gold".into(), tint: 0.4 }, ..Swatch::color("Gold 40%", gold) });
|
||||
let on = |offset: f32, name: &str| GradientStop {
|
||||
swatch: Some(name.into()),
|
||||
..GradientStop::new(offset, designcraft_color::swatch::resolve(&d.swatches, name, 1.0).unwrap())
|
||||
};
|
||||
let stops = vec![on(0.0, "Gold"), on(0.3, "Gold 40%"), GradientStop::new(0.6, Color::cmyk(0.5, 0.0, 0.0, 0.0)), on(1.0, "[Paper]")];
|
||||
d.swatches.push(Swatch {
|
||||
value: SwatchValue::Gradient { gradient: Gradient { kind: GradientKind::Linear, stops } },
|
||||
..Swatch::color("Gold fade", gold)
|
||||
});
|
||||
let back = import_idml(&export_idml(&d)).unwrap();
|
||||
let SwatchValue::Gradient { gradient } = &back.swatch("Gold fade").unwrap().value else { panic!("gradient") };
|
||||
let names: Vec<Option<&str>> = gradient.stops.iter().map(|s| s.swatch.as_deref()).collect();
|
||||
assert_eq!(names, [Some("Gold"), Some("Gold 40%"), None, Some("[Paper]")]);
|
||||
assert_eq!(gradient.stops[2].color, Color::cmyk(0.5, 0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_overridden_parent_items() {
|
||||
let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() });
|
||||
|
||||
+45
-36
@@ -559,11 +559,11 @@ pub(crate) fn norm(v: f32) -> NormalizedF32 {
|
||||
NormalizedF32::new(if v.is_finite() { v.clamp(0.0, 1.0) } else { 1.0 }).unwrap_or(NormalizedF32::ONE)
|
||||
}
|
||||
|
||||
fn q(v: f32) -> u8 {
|
||||
pub(crate) fn q(v: f32) -> u8 {
|
||||
(v.clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
}
|
||||
|
||||
fn regular(c: &Color, rgb_only: bool) -> krilla::color::RegularColor {
|
||||
pub(crate) fn regular(c: &Color, rgb_only: bool) -> krilla::color::RegularColor {
|
||||
use krilla::color::RegularColor as R;
|
||||
if rgb_only && matches!(c, Color::Cmyk { .. }) {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
@@ -1104,41 +1104,10 @@ impl Exporter<'_> {
|
||||
let (swatch, tint) = (fill.swatch.as_str(), fill.tint);
|
||||
if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) {
|
||||
let kind = g.kind;
|
||||
let mut expanded = g.expanded_stops();
|
||||
// Grey and CMYK stops blend as inks, so a grey stop beside a CMYK one is the K ink.
|
||||
if !self.rgb_only && expanded.iter().any(|(_, c, _)| matches!(c, Color::Cmyk { .. })) {
|
||||
for (_, c, _) in &mut expanded {
|
||||
if let Color::Gray { k } = *c {
|
||||
*c = Color::cmyk(0.0, 0.0, 0.0, k);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A PDF shading needs one colour space. Midpoint expansion can introduce RGB even
|
||||
// when every authored stop is CMYK/Gray; PDF/A can also change the output space.
|
||||
let components = |c: &Color| match c {
|
||||
Color::Cmyk { .. } if !self.rgb_only => 4,
|
||||
Color::Gray { .. } => 1,
|
||||
_ => 3,
|
||||
let stops = match self.ink_gradient_stops(g, swatch) {
|
||||
Some(stops) => stops,
|
||||
None => self.device_gradient_stops(g, swatch),
|
||||
};
|
||||
let mixed = expanded.first().is_some_and(|(_, first, _)| expanded.iter().any(|(_, c, _)| components(c) != components(first)));
|
||||
if mixed {
|
||||
self.warn(format!("gradient '{swatch}': mixed color spaces converted to RGB (colour appearance and separations may change)"));
|
||||
}
|
||||
let mut stops: Vec<Stop> = Vec::new();
|
||||
let mut last = 0.0f32;
|
||||
for (o, c, a) in expanded {
|
||||
let o = o.clamp(last, 1.0);
|
||||
last = o;
|
||||
let color = if mixed && !matches!(c, Color::Rgb { .. }) {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
rgb::Color::new(q(r), q(g), q(b)).into()
|
||||
} else {
|
||||
// Preserve RGB values, including already-interpolated display RGB, and
|
||||
// leave every homogeneous gradient on its existing device-space path.
|
||||
device(&c, self.rgb_only)
|
||||
};
|
||||
stops.push(Stop { offset: norm(o), color, opacity: norm(a) });
|
||||
}
|
||||
if stops.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -1182,6 +1151,46 @@ impl Exporter<'_> {
|
||||
self.swatch_color(swatch, tint).map(Into::into)
|
||||
}
|
||||
|
||||
/// A gradient's stops in device colours: CMYK, RGB or Gray, as one space.
|
||||
fn device_gradient_stops(&mut self, g: &designcraft_color::Gradient, swatch: &str) -> Vec<Stop> {
|
||||
let mut expanded = g.expanded_stops();
|
||||
// Grey and CMYK stops blend as inks, so a grey stop beside a CMYK one is the K ink.
|
||||
if !self.rgb_only && expanded.iter().any(|(_, c, _)| matches!(c, Color::Cmyk { .. })) {
|
||||
for (_, c, _) in &mut expanded {
|
||||
if let Color::Gray { k } = *c {
|
||||
*c = Color::cmyk(0.0, 0.0, 0.0, k);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A PDF shading needs one colour space. Midpoint expansion can introduce RGB even
|
||||
// when every authored stop is CMYK/Gray; PDF/A can also change the output space.
|
||||
let components = |c: &Color| match c {
|
||||
Color::Cmyk { .. } if !self.rgb_only => 4,
|
||||
Color::Gray { .. } => 1,
|
||||
_ => 3,
|
||||
};
|
||||
let mixed = expanded.first().is_some_and(|(_, first, _)| expanded.iter().any(|(_, c, _)| components(c) != components(first)));
|
||||
if mixed {
|
||||
self.warn(format!("gradient '{swatch}': mixed color spaces converted to RGB (colour appearance and separations may change)"));
|
||||
}
|
||||
let mut stops: Vec<Stop> = Vec::new();
|
||||
let mut last = 0.0f32;
|
||||
for (o, c, a) in expanded {
|
||||
let o = o.clamp(last, 1.0);
|
||||
last = o;
|
||||
let color = if mixed && !matches!(c, Color::Rgb { .. }) {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
rgb::Color::new(q(r), q(g), q(b)).into()
|
||||
} else {
|
||||
// Preserve RGB values, including already-interpolated display RGB, and
|
||||
// leave every homogeneous gradient on its existing device-space path.
|
||||
device(&c, self.rgb_only)
|
||||
};
|
||||
stops.push(Stop { offset: norm(o), color, opacity: norm(a) });
|
||||
}
|
||||
stops
|
||||
}
|
||||
|
||||
fn stroke(&mut self, s: &mut Surface, it: &Item, bp: &BezPath, path: &Path) {
|
||||
let st = &it.stroke;
|
||||
// A gradient swatch runs across the path's box, as on a fill.
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Gradients written in their inks. A gradient whose stops name a spot swatch shades in that
|
||||
//! spot: one spot with paper or tints of itself is a Separation shading, a spot with process
|
||||
//! inks or a second spot a DeviceN shading (process colorants in CMYK order, then the spots).
|
||||
//! In print, a process gradient with a `[Paper]` stop shades in DeviceCMYK.
|
||||
|
||||
use craft_pdf::write::backend as krilla;
|
||||
use designcraft_color::swatch::{ColorType, SwatchValue, apply_tint};
|
||||
use designcraft_color::{Color, Gradient};
|
||||
use krilla::color::device_n::{self, Colorant, DeviceNSpace, Process};
|
||||
use krilla::color::separation::{self, SeparationColorant, SeparationSpace};
|
||||
use krilla::paint::Stop;
|
||||
|
||||
use crate::export::{Exporter, norm, q, regular};
|
||||
|
||||
/// A stop as inks.
|
||||
enum Ink {
|
||||
/// CMYK tints (0..=1); `[Paper]` is all zero.
|
||||
Process([f32; 4]),
|
||||
Spot {
|
||||
name: String,
|
||||
alternate: Color,
|
||||
tint: f32,
|
||||
},
|
||||
}
|
||||
|
||||
/// Inks of a plain colour: CMYK as it is, grey as black. RGB has none.
|
||||
fn process_inks(c: Color) -> Option<[f32; 4]> {
|
||||
let tint = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
match c {
|
||||
Color::Cmyk { c, m, y, k } => Some([tint(c), tint(m), tint(y), tint(k)]),
|
||||
Color::Gray { k } => Some([0.0, 0.0, 0.0, tint(k)]),
|
||||
Color::Rgb { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The shading's color space.
|
||||
enum Space {
|
||||
Separation(SeparationSpace),
|
||||
DeviceN(DeviceNSpace),
|
||||
}
|
||||
|
||||
impl Exporter<'_> {
|
||||
/// The inks a stop prints with, following tints and the Ink Manager as solid fills do.
|
||||
/// `None` when they can't go into an ink shading (RGB, `[Registration]`).
|
||||
fn stop_ink(&self, stop: &designcraft_color::GradientStop) -> Option<Ink> {
|
||||
let Some(mut n) = stop.swatch.as_deref() else { return process_inks(stop.color).map(Ink::Process) };
|
||||
let mut t = 1.0f32;
|
||||
for _ in 0..8 {
|
||||
// A swatch that's gone leaves the stop's own colour.
|
||||
let Some(sw) = self.doc.swatch(n) else { return process_inks(stop.color).map(Ink::Process) };
|
||||
match &sw.value {
|
||||
SwatchValue::Paper { .. } => return Some(Ink::Process([0.0; 4])),
|
||||
SwatchValue::Tint { base, tint } => {
|
||||
t *= tint;
|
||||
n = base;
|
||||
}
|
||||
SwatchValue::Color { color_type: ColorType::Spot, .. } => {
|
||||
let (ink, process) = self.doc.inks.resolve(&sw.name);
|
||||
let target =
|
||||
self.doc.swatch(ink).filter(|w| matches!(w.value, SwatchValue::Color { color_type: ColorType::Spot, .. })).unwrap_or(sw);
|
||||
let SwatchValue::Color { color, .. } = &target.value else { return None };
|
||||
if process {
|
||||
return process_inks(apply_tint(*color, t)).map(Ink::Process);
|
||||
}
|
||||
let tint = if t.is_finite() { t.clamp(0.0, 1.0) } else { 1.0 };
|
||||
return Some(Ink::Spot { name: target.name.clone(), alternate: *color, tint });
|
||||
}
|
||||
SwatchValue::Color { color, .. } => return process_inks(apply_tint(*color, t)).map(Ink::Process),
|
||||
SwatchValue::Registration | SwatchValue::None | SwatchValue::Gradient { .. } => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The gradient's stops in its inks, or `None` to write it in device colours (no spot, or
|
||||
/// inks that can't share one shading space, which is warned about).
|
||||
pub(crate) fn ink_gradient_stops(&mut self, g: &Gradient, swatch: &str) -> Option<Vec<Stop>> {
|
||||
// Plain-colour stops (all older documents) keep their device colours.
|
||||
if g.stops.iter().all(|s| s.swatch.is_none()) {
|
||||
return None;
|
||||
}
|
||||
let inks: Vec<Option<Ink>> = g.stops.iter().map(|s| self.stop_ink(s)).collect();
|
||||
let has_spot = inks.iter().any(|i| matches!(i, Some(Ink::Spot { .. })));
|
||||
let on_paper = g.stops.iter().any(|s| s.swatch.as_deref() == Some(designcraft_color::swatch::PAPER));
|
||||
if !has_spot && !(on_paper && self.for_print() && !self.rgb_only) {
|
||||
return None;
|
||||
}
|
||||
let Some(inks) = inks.into_iter().collect::<Option<Vec<Ink>>>() else {
|
||||
return self.unshaded(swatch, "a stop has no inks (RGB or [Registration])", has_spot);
|
||||
};
|
||||
// Spots in the order the stops name them; process inks a stop sets.
|
||||
let mut spots: Vec<(String, Color)> = Vec::new();
|
||||
let mut process = [false; 4];
|
||||
for i in &inks {
|
||||
match i {
|
||||
Ink::Process(v) => {
|
||||
for (used, v) in process.iter_mut().zip(v) {
|
||||
*used |= *v > 1e-6;
|
||||
}
|
||||
}
|
||||
Ink::Spot { name, alternate, .. } => {
|
||||
if !spots.iter().any(|(n, _)| n == name) {
|
||||
spots.push((name.clone(), *alternate));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let process: Vec<usize> = (0..4).filter(|j| process.get(*j).copied().unwrap_or(false)).collect();
|
||||
let space = match (spots.as_slice(), process.is_empty()) {
|
||||
([], _) => None,
|
||||
([(name, alternate)], true) => {
|
||||
Some(Space::Separation(SeparationSpace::new(SeparationColorant::Custom(name.clone()), regular(alternate, self.rgb_only))))
|
||||
}
|
||||
_ => {
|
||||
if self.rgb_only {
|
||||
return self.unshaded(swatch, "DeviceN isn't allowed in PDF/A", true);
|
||||
}
|
||||
let mut colorants: Vec<Colorant> = process.iter().filter_map(|j| Process::ALL.get(*j).copied().map(Colorant::Process)).collect();
|
||||
for (name, alternate) in &spots {
|
||||
let Color::Cmyk { c, m, y, k } = *alternate else {
|
||||
return self.unshaded(swatch, &format!("'{name}' has no CMYK values"), true);
|
||||
};
|
||||
colorants.push(Colorant::Spot { name: name.clone(), alternate: krilla::color::cmyk::Color::new(q(c), q(m), q(y), q(k)) });
|
||||
}
|
||||
match DeviceNSpace::new(colorants) {
|
||||
Some(s) => Some(Space::DeviceN(s)),
|
||||
None => return self.unshaded(swatch, "its inks don't fit one DeviceN space", true),
|
||||
}
|
||||
}
|
||||
};
|
||||
// Tints per colorant at each authored stop.
|
||||
let tints: Vec<Vec<f32>> = inks
|
||||
.iter()
|
||||
.map(|i| {
|
||||
let mut v = vec![0.0f32; process.len() + spots.len()];
|
||||
match i {
|
||||
Ink::Process(p) => {
|
||||
for (slot, j) in v.iter_mut().zip(&process) {
|
||||
*slot = p.get(*j).copied().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
Ink::Spot { name, tint, .. } => {
|
||||
if let Some(slot) = spots.iter().position(|(n, _)| n == name).and_then(|k| v.get_mut(process.len() + k)) {
|
||||
*slot = *tint;
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
// A midpoint becomes a stop halfway between its neighbours' tints, as the display does.
|
||||
let mut expanded: Vec<(f32, Vec<f32>, f32)> = Vec::with_capacity(tints.len() * 2);
|
||||
for (k, (s, v)) in g.stops.iter().zip(&tints).enumerate() {
|
||||
expanded.push((s.offset, v.clone(), s.opacity));
|
||||
if let (Some(n), Some(nv)) = (g.stops.get(k + 1), tints.get(k + 1))
|
||||
&& (s.midpoint - 0.5).abs() > 1e-3
|
||||
{
|
||||
let mid: Vec<f32> = v.iter().zip(nv).map(|(a, b)| (a + b) / 2.0).collect();
|
||||
expanded.push((s.offset + (n.offset - s.offset) * s.midpoint, mid, (s.opacity + n.opacity) / 2.0));
|
||||
}
|
||||
}
|
||||
let mut stops = Vec::with_capacity(expanded.len());
|
||||
let mut last = 0.0f32;
|
||||
for (o, v, a) in expanded {
|
||||
let o = if o.is_finite() { o.clamp(last, 1.0) } else { last };
|
||||
last = o;
|
||||
let color: krilla::color::Color = match &space {
|
||||
Some(Space::Separation(sp)) => separation::Color::new(q(v.first().copied().unwrap_or(0.0)), sp.clone()).into(),
|
||||
Some(Space::DeviceN(sp)) => device_n::Color::new(v.iter().map(|t| q(*t)).collect(), sp.clone())?.into(),
|
||||
// Process inks only (a gradient to [Paper] in print).
|
||||
None => {
|
||||
let mut cmyk = [0.0f32; 4];
|
||||
for (t, j) in v.iter().zip(&process) {
|
||||
if let Some(slot) = cmyk.get_mut(*j) {
|
||||
*slot = *t;
|
||||
}
|
||||
}
|
||||
let [c, m, y, k] = cmyk;
|
||||
krilla::color::cmyk::Color::new(q(c), q(m), q(y), q(k)).into()
|
||||
}
|
||||
};
|
||||
stops.push(Stop { offset: norm(o), color, opacity: norm(a) });
|
||||
}
|
||||
(!stops.is_empty()).then_some(stops)
|
||||
}
|
||||
|
||||
/// Falls back to device colours, warning when that loses a spot ink.
|
||||
fn unshaded(&mut self, swatch: &str, why: &str, has_spot: bool) -> Option<Vec<Stop>> {
|
||||
if has_spot {
|
||||
self.warn(format!("gradient '{swatch}': spot inks printed as process colour ({why})"));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,11 @@
|
||||
//! - **Colour:** CMYK as DeviceCMYK, RGB as DeviceRGB, Gray as DeviceGray, spot swatches (and their
|
||||
//! tints) as `/Separation` with the swatch's own values as the alternate space, `[Registration]`
|
||||
//! as `/Separation /All`.
|
||||
//! Gradients with mixed output colour spaces are converted to RGB with an export warning;
|
||||
//! homogeneous gradients keep their existing colour space (subject to PDF/A conversion).
|
||||
//! Gradient stops on swatches shade in their inks: one spot with tints of itself or `[Paper]`
|
||||
//! as a `/Separation` shading, a spot with process inks or a second spot as `/DeviceN`
|
||||
//! (process colorants first; not in PDF/A, which goes to process colour with a warning), and in
|
||||
//! print a process gradient to `[Paper]` as DeviceCMYK. Plain-colour stops keep their own
|
||||
//! space; gradients mixing RGB with CMYK or Gray are converted to RGB with an export warning.
|
||||
//! - **Overprint:** Overprint Fill, Stroke and Gap, overprinting text and 100% [Black] (when
|
||||
//! Appearance of Black says so) as `/op` and `/OP` with `/OPM 1`; left out of PDF/A.
|
||||
//! - **Images:** clipped to their frame; JPEG data is passed through, PNG/GIF/WebP are embedded
|
||||
@@ -32,6 +35,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod export;
|
||||
mod gradient_inks;
|
||||
mod links;
|
||||
mod marks;
|
||||
mod text;
|
||||
|
||||
@@ -14,7 +14,7 @@ fn document(colors: &[Color], kind: GradientKind, midpoint: f32, opacity: f32) -
|
||||
let stops = colors
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &color)| GradientStop { offset: i as f32 / (colors.len() - 1) as f32, color, opacity, midpoint })
|
||||
.map(|(i, &color)| GradientStop { offset: i as f32 / (colors.len() - 1) as f32, color, opacity, midpoint, swatch: None })
|
||||
.collect();
|
||||
doc.swatches.push(Swatch {
|
||||
name: "Mixed inks".into(),
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Gradients on spot swatches shade in their inks: Separation for one spot, DeviceN for a spot
|
||||
//! with process inks or a second spot.
|
||||
use designcraft_color::{Color, ColorType, Gradient, GradientKind, GradientStop, Swatch, SwatchValue};
|
||||
use designcraft_compose::Cache;
|
||||
use designcraft_doc::{Document, Fill, Item, ItemId, Shape, SpreadRef, build::NewDocument};
|
||||
use designcraft_geom::{Rect, shapes};
|
||||
use designcraft_pdf::{PdfOptions, Standard, export_pdf_with_report};
|
||||
use hayro_syntax::{
|
||||
Pdf,
|
||||
object::{Array, Dict, Name},
|
||||
};
|
||||
|
||||
const CYAN: &str = "C=100 M=0 Y=0 K=0";
|
||||
|
||||
fn spot(d: &mut Document, name: &str, color: Color) {
|
||||
d.swatches.push(Swatch { value: SwatchValue::Color { color, color_type: ColorType::Spot }, ..Swatch::color(name, color) });
|
||||
}
|
||||
|
||||
/// A document with spot inks Gold and Silver, a 40 % tint of Gold, and one box filled with a gradient on `stops`
|
||||
/// (swatch name or plain colour, location).
|
||||
fn document(stops: &[(Result<&str, Color>, f32)], midpoint: f32) -> Document {
|
||||
let mut d = Document::new(&NewDocument { width: 120.0, height: 120.0, facing_pages: false, ..Default::default() });
|
||||
spot(&mut d, "Gold", Color::cmyk(0.0, 0.3, 0.9, 0.1));
|
||||
spot(&mut d, "Silver", Color::cmyk(0.2, 0.15, 0.1, 0.3));
|
||||
d.swatches.push(Swatch { value: SwatchValue::Tint { base: "Gold".into(), tint: 0.4 }, ..Swatch::color("Gold 40%", Color::BLACK) });
|
||||
let stops = stops
|
||||
.iter()
|
||||
.map(|(s, offset)| match s {
|
||||
Ok(name) => GradientStop { swatch: Some(name.to_string()), midpoint, ..GradientStop::new(*offset, d.resolve_color(name, 1.0).unwrap()) },
|
||||
Err(c) => GradientStop { midpoint, ..GradientStop::new(*offset, *c) },
|
||||
})
|
||||
.collect();
|
||||
d.swatches.push(Swatch {
|
||||
value: SwatchValue::Gradient { gradient: Gradient { kind: GradientKind::Linear, stops } },
|
||||
..Swatch::color("Fade", Color::BLACK)
|
||||
});
|
||||
let mut item = Item::new(ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 110.0)));
|
||||
item.fill = Fill::swatch("Fade");
|
||||
d.insert_item(SpreadRef::Doc(0), item, None).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn endpoint(function: &Dict<'_>, first: bool) -> Vec<f32> {
|
||||
if let Some(functions) = function.get::<Array<'_>>(b"Functions") {
|
||||
let functions: Vec<_> = functions.iter::<Dict<'_>>().collect();
|
||||
return endpoint(if first { functions.first().unwrap() } else { functions.last().unwrap() }, first);
|
||||
}
|
||||
function.get::<Array<'_>>(if first { b"C0" } else { b"C1" }).unwrap().iter::<f32>().collect()
|
||||
}
|
||||
|
||||
/// Each shading on the page: its color space family, colorant names, and the colours at its
|
||||
/// two ends.
|
||||
struct Shading {
|
||||
family: String,
|
||||
colorants: Vec<String>,
|
||||
start: Vec<f32>,
|
||||
end: Vec<f32>,
|
||||
}
|
||||
|
||||
fn export(d: &Document, standard: Standard) -> (Vec<Shading>, Vec<String>) {
|
||||
let report = export_pdf_with_report(d, &Cache::new(), &PdfOptions { standard, compress: false, ..Default::default() }).unwrap();
|
||||
let pdf = Pdf::new(report.bytes).unwrap();
|
||||
let patterns = &pdf.pages()[0].resources().patterns;
|
||||
let mut out = Vec::new();
|
||||
for (name, _) in patterns.entries() {
|
||||
let pattern = patterns.get::<Dict<'_>>(name.as_ref()).unwrap();
|
||||
let shading = pattern.get::<Dict<'_>>(b"Shading").unwrap();
|
||||
let text = |n: Name<'_>| String::from_utf8_lossy(n.as_ref()).into_owned();
|
||||
let (family, colorants) = match shading.get::<Name<'_>>(b"ColorSpace") {
|
||||
Some(n) => (text(n), Vec::new()),
|
||||
None => {
|
||||
let space = shading.get::<Array<'_>>(b"ColorSpace").unwrap();
|
||||
let mut it = space.flex_iter();
|
||||
let family = text(it.next::<Name<'_>>().unwrap());
|
||||
let colorants = match family.as_str() {
|
||||
"Separation" => vec![text(it.next::<Name<'_>>().unwrap())],
|
||||
"DeviceN" => it.next::<Array<'_>>().unwrap().iter::<Name<'_>>().map(text).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
(family, colorants)
|
||||
}
|
||||
};
|
||||
let function = shading.get::<Dict<'_>>(b"Function").unwrap();
|
||||
out.push(Shading { family, colorants, start: endpoint(&function, true), end: endpoint(&function, false) });
|
||||
}
|
||||
assert!(!out.is_empty(), "the gradient is a shading");
|
||||
(out, report.warnings)
|
||||
}
|
||||
|
||||
fn close(a: &[f32], b: &[f32]) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01)
|
||||
}
|
||||
|
||||
fn no_conversion(warnings: &[String]) {
|
||||
assert!(!warnings.iter().any(|w| w.contains("Fade")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spot_to_paper_gradient_is_a_separation_shading() {
|
||||
for standard in [Standard::None, Standard::PdfX4] {
|
||||
let (shadings, warnings) = export(&document(&[(Ok("Gold"), 0.0), (Ok("[Paper]"), 1.0)], 0.5), standard);
|
||||
no_conversion(&warnings);
|
||||
let s = &shadings[0];
|
||||
assert_eq!((s.family.as_str(), s.colorants.as_slice()), ("Separation", &["Gold".to_string()][..]), "{standard:?}");
|
||||
assert!(close(&s.start, &[1.0]) && close(&s.end, &[0.0]), "{:?} → {:?}", s.start, s.end);
|
||||
}
|
||||
// A tint of the spot shades in the same Separation.
|
||||
let (shadings, _) = export(&document(&[(Ok("Gold"), 0.0), (Ok("Gold 40%"), 1.0)], 0.5), Standard::PdfX4);
|
||||
assert_eq!(shadings[0].family, "Separation");
|
||||
assert!(close(&shadings[0].end, &[0.4]), "{:?}", shadings[0].end);
|
||||
// Converted to process in the Ink Manager: CMYK.
|
||||
let mut d = document(&[(Ok("Gold"), 0.0), (Ok("[Paper]"), 1.0)], 0.5);
|
||||
d.inks.to_process.push("Gold".into());
|
||||
let (shadings, _) = export(&d, Standard::PdfX4);
|
||||
assert_eq!(shadings[0].family, "DeviceCMYK");
|
||||
assert!(close(&shadings[0].start, &[0.0, 0.3, 0.9, 0.1]), "{:?}", shadings[0].start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spot_to_cyan_gradient_is_a_device_n_shading() {
|
||||
let (shadings, warnings) = export(&document(&[(Ok("Gold"), 0.0), (Ok(CYAN), 1.0)], 0.5), Standard::PdfX4);
|
||||
no_conversion(&warnings);
|
||||
let s = &shadings[0];
|
||||
assert_eq!(s.family, "DeviceN");
|
||||
assert_eq!(s.colorants, ["Cyan", "Gold"]);
|
||||
assert!(close(&s.start, &[0.0, 1.0]) && close(&s.end, &[1.0, 0.0]), "{:?} → {:?}", s.start, s.end);
|
||||
// A midpoint stays in the inks: the colours at the ends are unchanged and in DeviceN.
|
||||
let (shadings, _) = export(&document(&[(Ok("Gold"), 0.0), (Ok(CYAN), 1.0)], 0.25), Standard::PdfX4);
|
||||
assert_eq!(shadings[0].family, "DeviceN");
|
||||
assert!(close(&shadings[0].start, &[0.0, 1.0]) && close(&shadings[0].end, &[1.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_two_spot_gradient_is_a_device_n_shading() {
|
||||
let (shadings, warnings) = export(&document(&[(Ok("Gold"), 0.0), (Ok("Silver"), 1.0)], 0.5), Standard::PdfX4);
|
||||
no_conversion(&warnings);
|
||||
let s = &shadings[0];
|
||||
assert_eq!(s.family, "DeviceN");
|
||||
assert_eq!(s.colorants, ["Gold", "Silver"]);
|
||||
assert!(close(&s.start, &[1.0, 0.0]) && close(&s.end, &[0.0, 1.0]), "{:?} → {:?}", s.start, s.end);
|
||||
// Stop opacity is a soft mask over the same inks.
|
||||
let mut d = document(&[(Ok("Gold"), 0.0), (Ok("Silver"), 1.0)], 0.5);
|
||||
if let Some(SwatchValue::Gradient { gradient }) = d.swatches.iter_mut().find(|w| w.name == "Fade").map(|w| &mut w.value) {
|
||||
gradient.stops[0].opacity = 0.4;
|
||||
}
|
||||
let (shadings, warnings) = export(&d, Standard::PdfX4);
|
||||
no_conversion(&warnings);
|
||||
assert!(shadings.iter().any(|s| s.family == "DeviceN" && s.colorants == ["Gold", "Silver"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_process_gradient_to_paper_prints_in_cmyk() {
|
||||
let (shadings, warnings) = export(&document(&[(Ok(CYAN), 0.0), (Ok("[Paper]"), 1.0)], 0.5), Standard::PdfX4);
|
||||
no_conversion(&warnings);
|
||||
assert_eq!(shadings[0].family, "DeviceCMYK");
|
||||
assert!(close(&shadings[0].start, &[1.0, 0.0, 0.0, 0.0]) && close(&shadings[0].end, &[0.0, 0.0, 0.0, 0.0]));
|
||||
}
|
||||
|
||||
/// PDF/A forbids DeviceN's PostScript tint transform: the gradient goes to process colour, with
|
||||
/// a warning, and the export still succeeds.
|
||||
#[test]
|
||||
fn pdf_a_writes_mixed_spot_gradients_in_process_colour() {
|
||||
let (shadings, warnings) = export(&document(&[(Ok("Gold"), 0.0), (Ok(CYAN), 1.0)], 0.5), Standard::PdfA2b);
|
||||
assert!(warnings.iter().any(|w| w.contains("'Fade'") && w.contains("spot inks printed as process")), "{warnings:?}");
|
||||
assert!(shadings.iter().all(|s| s.family != "DeviceN"));
|
||||
}
|
||||
|
||||
/// Stops with plain colours (every document saved before stops named swatches) export as they
|
||||
/// did: the spot's values in DeviceCMYK.
|
||||
#[test]
|
||||
fn plain_colour_stops_keep_their_device_space() {
|
||||
let gold = Color::cmyk(0.0, 0.3, 0.9, 0.1);
|
||||
let (shadings, warnings) = export(&document(&[(Err(gold), 0.0), (Err(Color::cmyk(1.0, 0.0, 0.0, 0.0)), 1.0)], 0.5), Standard::PdfX4);
|
||||
no_conversion(&warnings);
|
||||
assert_eq!(shadings[0].family, "DeviceCMYK");
|
||||
assert!(close(&shadings[0].start, &[0.0, 0.3, 0.9, 0.1]));
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use designcraft_render::{Placed, RenderOptions, Renderer};
|
||||
#[test]
|
||||
fn a_cmyk_gradient_is_c50_m50_halfway() {
|
||||
let mut d = Document::new(&NewDocument { width: 100.0, height: 20.0, facing_pages: false, ..Default::default() });
|
||||
let stop = |offset: f32, color: Color| GradientStop { offset, color, opacity: 1.0, midpoint: 0.5 };
|
||||
let stop = |offset: f32, color: Color| GradientStop::new(offset, color);
|
||||
let gradient =
|
||||
Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, Color::cmyk(0.0, 1.0, 0.0, 0.0)), stop(1.0, Color::cmyk(1.0, 0.0, 0.0, 0.0))] };
|
||||
d.swatches.push(Swatch { name: "Magenta to Cyan".into(), value: SwatchValue::Gradient { gradient }, locked: false, named: true, hidden: false });
|
||||
|
||||
@@ -11,7 +11,7 @@ use designcraft_render::{Placed, RenderOptions, Rendered, Renderer};
|
||||
/// A 100×200 rectangle at (50, 20) filled with a black-to-white radial gradient.
|
||||
fn portrait(vector: Option<[f64; 4]>) -> Document {
|
||||
let mut d = Document::new(&NewDocument { width: 200.0, height: 240.0, facing_pages: false, ..Default::default() });
|
||||
let stop = |offset: f32, color: Color| GradientStop { offset, color, opacity: 1.0, midpoint: 0.5 };
|
||||
let stop = |offset: f32, color: Color| GradientStop::new(offset, color);
|
||||
d.swatches.push(Swatch {
|
||||
name: "Radial".into(),
|
||||
value: SwatchValue::Gradient {
|
||||
|
||||
@@ -586,11 +586,17 @@ fn color_json(c: &designcraft_color::Color) -> serde_json::Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops in `object.gradient` form.
|
||||
/// Stops in `object.gradient` form: a stop on a swatch also names it, so it keeps its inks.
|
||||
fn stops_json(g: &designcraft_color::Gradient) -> Vec<serde_json::Value> {
|
||||
g.stops
|
||||
.iter()
|
||||
.map(|s| json!({"location": s.offset as f64 * 100.0, "color": color_json(&s.color), "opacity": s.opacity as f64 * 100.0, "midpoint": s.midpoint as f64 * 100.0}))
|
||||
.map(|s| {
|
||||
let mut v = json!({"location": s.offset as f64 * 100.0, "color": color_json(&s.color), "opacity": s.opacity as f64 * 100.0, "midpoint": s.midpoint as f64 * 100.0});
|
||||
if let (Some(o), Some(w)) = (v.as_object_mut(), &s.swatch) {
|
||||
o.insert("swatch".to_string(), json!(w));
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -847,6 +853,10 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let mut rgb = [(r * 255.0) as u8, (gg * 255.0) as u8, (bb * 255.0) as u8];
|
||||
if egui::color_picker::color_edit_button_srgb(ui, &mut rgb).changed() {
|
||||
let mut stops = stops_json(&g);
|
||||
// A picked colour replaces the stop's swatch.
|
||||
if let Some(o) = stops.get_mut(i).and_then(serde_json::Value::as_object_mut) {
|
||||
o.remove("swatch");
|
||||
}
|
||||
set_stop(&mut stops, i, "color", json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
|
||||
send = Some(json!({"stops": stops}));
|
||||
}
|
||||
@@ -980,6 +990,8 @@ mod gradient_panel_tests {
|
||||
assert_eq!(g.stops[1].color, Color::gray(0.6));
|
||||
assert!((g.stops[1].offset - 0.8).abs() < 1e-6);
|
||||
assert_eq!(g.stops[2].color, Color::gray(0.01), "a 1 % grey stays 1 %");
|
||||
assert_eq!(g.stops[0].swatch.as_deref(), Some("C=0 M=100 Y=0 K=0"), "a stop keeps its swatch");
|
||||
assert_eq!(g.stops[1].swatch, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -436,7 +436,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
- View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough).
|
||||
- OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes.
|
||||
- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`.
|
||||
- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel (InDesign's layout: thumbnail, Type, Location, Angle, Reverse) sets type, angle, reverse and edits stops and midpoints on a ramp (drag, add, drag off to remove, location; drop a swatch from the Swatches panel on a stop or the ramp; stops keep their CMYK), and follows the Fill/Stroke proxy. Edited gradients are unnamed swatches until the Swatches panel's New Swatch names one "New Gradient Swatch" (`swatch.new`, which also duplicates a named swatch); `swatch.create {gradient}` builds one from colour swatches; `object.gradient` (`target: stroke` for a stroke's gradient, drawn across the path's box on screen and in PDF); the vector round-trips through IDML (`GradientFill…`/`GradientStroke…`). A radial gradient stretches with the object's box as InDesign's does (an ellipse reaching the sides of a non-square frame, on screen and in PDF). Text takes a gradient swatch as its fill or stroke, laid across its frame's box as in InDesign (screen and PDF).
|
||||
- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel (InDesign's layout: thumbnail, Type, Location, Angle, Reverse) sets type, angle, reverse and edits stops and midpoints on a ramp (drag, add, drag off to remove, location; drop a swatch from the Swatches panel on a stop or the ramp; stops keep their CMYK, and a stop on a swatch keeps it, so spot stops shade as Separation or DeviceN in PDF), and follows the Fill/Stroke proxy. Edited gradients are unnamed swatches until the Swatches panel's New Swatch names one "New Gradient Swatch" (`swatch.new`, which also duplicates a named swatch); `swatch.create {gradient}` builds one from colour swatches; `object.gradient` (`target: stroke` for a stroke's gradient, drawn across the path's box on screen and in PDF); the vector round-trips through IDML (`GradientFill…`/`GradientStroke…`). A radial gradient stretches with the object's box as InDesign's does (an ellipse reaching the sides of a non-square frame, on screen and in PDF). Text takes a gradient swatch as its fill or stroke, laid across its frame's box as in InDesign (screen and PDF).
|
||||
- New Document (⌘N) as in InDesign: Recent, Saved, Print, Web and Mobile tabs of page cards (double-click creates) beside the preset's details: document name, size with arrows, units, orientation, pages, start page #, facing pages, primary text frame, columns, margins, bleed and slug per edge (linked or not); Save Document Preset (`file.savePreset`, `file.deletePreset`; saved presets are kept with the preferences, listed by `file.presets` and made by `file.new {preset: name}`); `file.new` takes `intent`, `units`, `startPage` and per-edge `bleed`/`slug`. Preview shows a document with the window's settings in the workspace while it is open; Create keeps it, Close removes it.
|
||||
- Document Setup (⌥⌘P), laid out like InDesign's: intent, number of pages, start page # (an even start begins with a left page), facing pages, binding, Page Size (presets keeping the orientation, width and height with arrows, orientation), margins for every page and parent page, bleed and slug per edge (each group linked or not), and Adjust Layout (objects follow the new page size and margins), all in one undo step; measures are read in the document's units, and a value that can't be read keeps the dialog open; `layout.documentSetup {}` reports the setup, margins included.
|
||||
- Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots; with text selected (or text frames selected with Formatting Affects Text on) it shows the text's stroke weight, colour and Stroke Behind Fill.
|
||||
|
||||
Reference in New Issue
Block a user