mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-15 21:55:41 +00:00
Merge remote-tracking branch 'origin/main' into integration-batch-17
# Conflicts: # docs/gaps.md
This commit is contained in:
@@ -3,8 +3,9 @@
|
||||
//! 1. At a reduced working resolution every pixel gets a *sky likelihood*: a colour prior
|
||||
//! (blue and bright, or bright and unsaturated for overcast / cloud, or a bright warm glow
|
||||
//! for sunsets) times a smoothness term (sky has little gradient energy).
|
||||
//! 2. Seeds are likely-sky pixels in the top rows; the sky region grows from them through
|
||||
//! 4-connected neighbours that stay likely-sky and change colour gradually.
|
||||
//! 2. Seeds are likely-sky pixels in the top rows, falling back to substantial high-confidence
|
||||
//! components in the upper half when the top is obstructed. The sky region grows from them
|
||||
//! through 4-connected neighbours that stay likely-sky and change colour gradually.
|
||||
//! 3. Sky seen through gaps (between branches) is not connected to the top: likely-sky pixels
|
||||
//! that fit a colour model (GMM) of the grown sky are added too. Small holes are filled.
|
||||
//! 4. The binary working-resolution mask is upsampled and refined at full resolution with a
|
||||
@@ -30,22 +31,31 @@ pub struct SkyParams {
|
||||
}
|
||||
|
||||
impl Default for SkyParams {
|
||||
/// Default parameters (threshold 50, softness 50).
|
||||
fn default() -> Self {
|
||||
Self { threshold: 50.0, softness: 50.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Hermite interpolation between `e0` and `e1` with cubic smoothing.
|
||||
#[inline]
|
||||
fn smooth(e0: f32, e1: f32, x: f32) -> f32 {
|
||||
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// Rec. 601 perceptual luminance of an RGB triple.
|
||||
#[inline]
|
||||
fn luma(p: [f32; 3]) -> f32 {
|
||||
0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]
|
||||
}
|
||||
|
||||
/// Euclidean distance between two RGB colours.
|
||||
#[inline]
|
||||
fn rgb_dist(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// Colour prior: how sky-like a colour is on its own (0–1).
|
||||
pub fn colour_prior(p: [f32; 3]) -> f32 {
|
||||
let [r, g, b] = p;
|
||||
@@ -79,7 +89,7 @@ pub fn likelihood(img: &RgbImage) -> Vec<f32> {
|
||||
img.px.iter().zip(&tex).map(|(p, t)| colour_prior(*p) * (1.0 - smooth(0.015, 0.07, *t))).collect()
|
||||
}
|
||||
|
||||
/// Binary sky mask at working resolution (`None` when no sky touches the top of the image).
|
||||
/// Binary sky mask at working resolution (`None` when no plausible sky region is found).
|
||||
pub fn sky_mask_low(img: &RgbImage, threshold: f32) -> Option<Vec<bool>> {
|
||||
let (w, h) = (img.w, img.h);
|
||||
if w < 2 || h < 2 {
|
||||
@@ -97,11 +107,22 @@ pub fn sky_mask_low(img: &RgbImage, threshold: f32) -> Option<Vec<bool>> {
|
||||
stack.push(i);
|
||||
}
|
||||
}
|
||||
let max_step = 0.12f32;
|
||||
if stack.len() * 20 < top * w {
|
||||
return None; // less than 5 % of the top edge looks like sky
|
||||
// An overhang can hide the entire top band. Discard the sparse top seeds before
|
||||
// looking for more certain, connected sky patches below it.
|
||||
mask.fill(false);
|
||||
stack = obstructed_sky_seeds(img, &like, t + 0.1, max_step);
|
||||
if stack.is_empty() {
|
||||
return None;
|
||||
}
|
||||
for &i in &stack {
|
||||
if let Some(m) = mask.get_mut(i) {
|
||||
*m = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Region growing with a gradual-change constraint.
|
||||
let max_step = 0.12f32;
|
||||
while let Some(i) = stack.pop() {
|
||||
let (x, y) = (i % w, i / w);
|
||||
let n = [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)];
|
||||
@@ -110,8 +131,7 @@ pub fn sky_mask_low(img: &RgbImage, threshold: f32) -> Option<Vec<bool>> {
|
||||
continue;
|
||||
}
|
||||
let (a, b) = (img.px[i], img.px[j]);
|
||||
let d = ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
|
||||
if d < max_step {
|
||||
if rgb_dist(a, b) < max_step {
|
||||
mask[j] = true;
|
||||
stack.push(j);
|
||||
}
|
||||
@@ -138,6 +158,56 @@ pub fn sky_mask_low(img: &RgbImage, threshold: f32) -> Option<Vec<bool>> {
|
||||
mask.iter().any(|v| *v).then_some(mask)
|
||||
}
|
||||
|
||||
/// Without border evidence, require a connected patch of at least 0.5% of the image (and
|
||||
/// 16 pixels). Searching only the upper half avoids using water or blue ground as seeds;
|
||||
/// counting each component separately keeps scattered bright specks from passing the gate.
|
||||
/// Adjacent pixels must satisfy the same gradual colour constraint (`max_step`) as region growing.
|
||||
fn obstructed_sky_seeds(img: &RgbImage, like: &[f32], threshold: f32, max_step: f32) -> Vec<usize> {
|
||||
let (w, h) = (img.w, img.h);
|
||||
let Some(area) = w.checked_mul(h) else { return Vec::new() };
|
||||
let Some(upper) = w.checked_mul(h.div_ceil(2)) else { return Vec::new() };
|
||||
if img.px.len() < upper {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(like) = like.get(..upper) else { return Vec::new() };
|
||||
let minimum = area.div_ceil(200).max(16);
|
||||
let mut seen = vec![false; upper];
|
||||
let mut seeds = Vec::new();
|
||||
let mut component = Vec::new();
|
||||
for (start, &score) in like.iter().enumerate() {
|
||||
if score <= threshold || seen.get(start).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
component.clear();
|
||||
component.push(start);
|
||||
if let Some(s) = seen.get_mut(start) {
|
||||
*s = true;
|
||||
}
|
||||
let mut cursor = 0;
|
||||
while let Some(&i) = component.get(cursor) {
|
||||
cursor += 1;
|
||||
let x = i % w;
|
||||
let neighbours = [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), i.checked_sub(w), i.checked_add(w).filter(|j| *j < upper)];
|
||||
for j in neighbours.into_iter().flatten() {
|
||||
if like.get(j).is_some_and(|v| *v > threshold)
|
||||
&& let Some(s) = seen.get_mut(j)
|
||||
&& !*s
|
||||
{
|
||||
let (a, b) = (img.px[i], img.px[j]);
|
||||
if rgb_dist(a, b) < max_step {
|
||||
*s = true;
|
||||
component.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if component.len() >= minimum {
|
||||
seeds.extend_from_slice(&component);
|
||||
}
|
||||
}
|
||||
seeds
|
||||
}
|
||||
|
||||
/// A neutral region that also covers most of the bottom rows is a studio or product backdrop,
|
||||
/// not an overcast sky (which never reaches the bottom of a photo); blue sky is never one.
|
||||
fn is_backdrop(img: &RgbImage, mask: &[bool], rows: usize) -> bool {
|
||||
@@ -291,6 +361,89 @@ mod tests {
|
||||
assert!(r.at(30, 10) > 0.9 && r.at(30, 50) < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_obstructed_blue_and_overcast_sky_in_disconnected_openings() {
|
||||
for sky in [[0.35, 0.6, 0.95], [0.85, 0.86, 0.88]] {
|
||||
// A dark arch with two openings: neither sky component touches an image edge.
|
||||
let img =
|
||||
RgbImage::from_fn(
|
||||
160,
|
||||
100,
|
||||
|x, y| {
|
||||
if (15..65).contains(&y) && ((15..65).contains(&x) || (95..145).contains(&x)) { sky } else { [0.12, 0.2, 0.1] }
|
||||
},
|
||||
);
|
||||
for threshold in [0.0, 50.0, 100.0] {
|
||||
let p = SkyParams { threshold, ..SkyParams::default() };
|
||||
let r = select_sky(&ImageSampler { img: &img, origin: (0, 0) }, Rect::new(0, 0, 160, 100), p).expect("sky beneath arch");
|
||||
assert!(r.at(40, 30) > 0.9 && r.at(120, 30) > 0.9, "both disconnected openings: {sky:?}, {threshold}");
|
||||
assert!(r.at(40, 55) > 0.9, "growth beyond the upper-half seed search");
|
||||
assert!(r.at(40, 5) < 0.1, "overhead obstruction");
|
||||
assert!(r.at(80, 30) < 0.1, "arch pillar");
|
||||
assert!(r.at(40, 85) < 0.1, "ground");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obstructed_fallback_rejects_small_patches_and_lower_half_objects() {
|
||||
for colour in [[0.35, 0.6, 0.95], [0.9, 0.9, 0.92]] {
|
||||
let img = RgbImage::from_fn(160, 100, |x, y| {
|
||||
if ((20..26).contains(&x) && (20..26).contains(&y)) || ((20..140).contains(&x) && (65..95).contains(&y)) { colour } else { [0.12, 0.2, 0.1] }
|
||||
});
|
||||
assert!(sky_mask_low(&img, 50.0).is_none(), "small upper patch and large lower object: {colour:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obstructed_neutral_backdrop_is_still_rejected() {
|
||||
let img = RgbImage::from_fn(160, 100, |_, y| if y < 12 { [0.12, 0.2, 0.1] } else { [0.95, 0.95, 0.94] });
|
||||
assert!(sky_mask_low(&img, 50.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_requires_individually_large_four_connected_components() {
|
||||
let (w, h) = (80, 80);
|
||||
let img = RgbImage { w, h, px: vec![[0.35, 0.6, 0.95]; w * h] };
|
||||
// Enough total likely pixels to pass the area gate, but no large component.
|
||||
let mut like: Vec<f32> = (0..w * h).map(|i| if i % w % 4 == 0 && i / w % 4 == 0 { 1.0 } else { 0.0 }).collect();
|
||||
assert!(obstructed_sky_seeds(&img, &like, 0.5, 0.12).is_empty());
|
||||
// Adjacent row ends must not join across the horizontal image boundary.
|
||||
like.fill(0.0);
|
||||
for y in 10..20 {
|
||||
for x in 0..2 {
|
||||
like[y * w + x] = 1.0;
|
||||
like[(y - 1) * w + w - 1 - x] = 1.0;
|
||||
}
|
||||
}
|
||||
assert!(obstructed_sky_seeds(&img, &like, 0.5, 0.12).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obstructed_fallback_excludes_attached_differently_coloured_patch() {
|
||||
let (w, h) = (160, 100);
|
||||
// Minimum qualifying component size: area.div_ceil(200).max(16) = 16000 / 200 = 80.
|
||||
// Sky component is 40x40 = 1600 pixels in rows 15..55, cols 20..60 (qualifies).
|
||||
// Attached differently coloured patch is 2x5 = 10 pixels in rows 20..25, cols 60..62 (< 80 min).
|
||||
// Both patches have high likelihood, but their colour distance exceeds max_step (0.12).
|
||||
let img = RgbImage::from_fn(w, h, |x, y| {
|
||||
if (15..55).contains(&y) && (20..60).contains(&x) {
|
||||
[0.35, 0.6, 0.95]
|
||||
} else if (20..25).contains(&y) && (60..62).contains(&x) {
|
||||
[0.85, 0.86, 0.88]
|
||||
} else {
|
||||
[0.12, 0.2, 0.1]
|
||||
}
|
||||
});
|
||||
let mask = sky_mask_low(&img, 50.0).expect("sky mask");
|
||||
assert!(mask[30 * w + 40], "qualifying blue sky must be in mask");
|
||||
for y in 20..25 {
|
||||
for x in 60..62 {
|
||||
assert!(!mask[y * w + x], "attached patch at ({x}, {y}) must be excluded from mask");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #4634: a near-white studio backdrop around a product is not an overcast sky, but blue sky
|
||||
/// reaching the bottom (a photo taken upward) still is.
|
||||
#[test]
|
||||
|
||||
@@ -2071,6 +2071,12 @@ fn camera_data_formats_like_photoshop_and_falls_back_to_xmp() {
|
||||
assert_eq!(info(Some(0.5), Some(5.6), None), json!({"exposure": "1/2 s", "fNumber": "f/5.6"}));
|
||||
assert_eq!(info(Some(0.4), None, None), json!({"exposure": "0.4 s"}));
|
||||
assert_eq!(info(Some(1.0 / 2000.0), None, None), json!({"exposure": "1/2000 s"}));
|
||||
// Non-standard fractions round to the nearest 1/n instead of losing digits (#6958).
|
||||
assert_eq!(info(Some(0.031714), None, None), json!({"exposure": "1/32 s"}));
|
||||
assert_eq!(info(Some(0.0215), None, None), json!({"exposure": "1/47 s"}));
|
||||
assert_eq!(info(Some(1.0 / 3.0), None, None), json!({"exposure": "1/3 s"}));
|
||||
// Too far from any whole denominator: three significant digits, not two decimals.
|
||||
assert_eq!(info(Some(0.2731), None, None), json!({"exposure": "0.273 s"}));
|
||||
assert_eq!(info(Some(0.0), Some(0.0), Some(0.0)), json!({}));
|
||||
// No EXIF block (a raw import, XMP-only files): Photoshop's exif:/tiff:/aux: properties.
|
||||
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
|
||||
+3
-1
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
# PhotoCraft vs Adobe Photoshop: target-app parity
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (Art History Brush Opacity on the options bar, #10263; no estimate changed; Automation Batch Esc Continue/Stop and read-only input previews now follow the measured cancellation flow; full live tabs remain partial, AUTO-219-5, #4579; Automation recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and scales point text placement; paragraph boxes remain open, #4514; Automation Batch output naming and UTF-16 logs now follow measured Photoshop behavior; progress and cancel prompt remain partial, AUTO-219-5, #4579; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; Automation Batch Esc Continue/Stop and read-only input previews now follow the measured cancellation flow; full live tabs remain partial, AUTO-219-5, #4579; Automation recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and scales point text placement; paragraph boxes remain open, #4514; Automation Batch output naming and UTF-16 logs now follow measured Photoshop behavior; progress and cancel prompt remain partial, AUTO-219-5, #4579; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The
|
||||
one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md);
|
||||
@@ -167,7 +167,7 @@ parity for the area and are already contained in the dimension rows above.
|
||||
| Area | % | Hours | Notes |
|
||||
|---|---:|---:|---|
|
||||
| Layers, groups, masks, blend modes, smart objects, layer styles | 65% | 150–250 | Broad and GPU-accelerated. Open: a single pixel layer copies its pixels without its mask and styles (UI-217-23: smart object, type, shape, group, fill and adjustment layers and multi-layer selections copy and paste whole), smart-object perspective (FILE-215-6), a user contour library (FILE-215-15). Multi-layer Smart Object conversion (#2052, #2651), Stamp Visible (UI-217-22) and mask targeting by command (#2458) are done |
|
||||
| Selections, Select and Mask, channels | 55% | 120–200 | [Selected colour sets](multi-channel-editing.md) edit raster drawing, tone and supported filters; partial-set geometry remains open. All selection tools, single row/column marquee included (#2837). Select and Mask is Photoshop's workspace with its own tools (Quick Selection, Refine Edge Brush, Brush, Object Selection, Lasso, Polygonal Lasso), view modes and six outputs (TOOL-214-1, #7069, #8148), no Refine Hair (#2671), Object Aware or Presets; Select Subject/Sky classical, 559 ms p50 on 24 MP vs a 100 ms budget |
|
||||
| Selections, Select and Mask, channels | 55% | 120–200 | [Selected colour sets](multi-channel-editing.md) edit raster drawing, tone and supported filters; partial-set geometry remains open. All selection tools, single row/column marquee included (#2837). Select and Mask is Photoshop's workspace with its own tools (Quick Selection, Refine Edge Brush, Brush, Object Selection, Lasso, Polygonal Lasso), view modes and six outputs (TOOL-214-1, #7069, #8148), no Refine Hair (#2671), Object Aware or Presets; Select Subject/Sky classical; Sky has an upper-half component fallback for obstructed top bands (2026-10-26), still without semantic recognition. Select Subject: 559 ms p50 on 24 MP vs a 100 ms budget |
|
||||
| Painting and brush engine | 55% | 150–250 | Every Brush Settings section exists; feel is the gap: lag on big brushes, pressure jumps, smoothing and edge quality reports ([brush-parity.md](brush-parity.md)) |
|
||||
| Retouching (healing, clone, patch, remove, content-aware) | 50% | 100–180 | Remove tool non-generative; Content-Aware Fill has no workspace (TOOL-214-15); no live stroke preview for the Mixer Brush yet (TOOL-213-7) |
|
||||
| Transform, warp, puppet, perspective, liquify | 55% | 100–180 | Free Transform modifiers right, with a right-click transform menu (TOOL-214-5) and H/V skew fields; Puppet Warp mesh follows the object outline (TOOL-214-6); Liquify called "useless" by a user (#2606) |
|
||||
@@ -601,6 +601,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md).
|
||||
| 2026-10-23 | minor | Frame Tool interaction and persistence work (#8864); documented remaining native Photoshop frame metadata and independent-oracle limits; no percentage changed |
|
||||
| 2026-10-25 | minor | Selected colour channel sets: drawing, combined and per-component Levels/Curves, adjustments and supported filters; [scope, evidence and benchmarks](multi-channel-editing.md). Move/transform remains open; percentages unchanged |
|
||||
| 2026-10-23 | minor | Select and Mask is Photoshop's workspace with its own tools (#7069, #8148); TOOL-214-1 stays partial (Refine Hair, Object Aware, Presets); percentages and estimates unchanged |
|
||||
| 2026-10-26 | minor | Selections: classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; percentages and estimates unchanged |
|
||||
| 2026-10-25 | minor | UI / UX and Workspace rows: 12 of 178 preferences do nothing, the scorecard's count. Camera Raw row: the Calibration panel landed on 2026-10-13 (#5128); what is missing is per-camera colour for non-DNG cameras ([gaps.md](gaps.md) G14). Percentages and estimates unchanged |
|
||||
| 2026-10-23 | minor | Colour management and modes: PSD spot plates are stored as Photoshop stores them (ink 0 = solid), and PSDs from earlier PhotoCraft versions keep their ink (#7415 split, part 4); estimates unchanged |
|
||||
| 2026-10-23 | minor | Colour management and modes: spot plates now save and open in TIFF (#7415); estimates unchanged |
|
||||
|
||||
Reference in New Issue
Block a user