mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-13 05:33:14 +00:00
Camera Raw sidecars: apply the crop, stop reporting RawFileName
A raw opened with a Lightroom or Camera Raw sidecar reported "3 not supported: CropBottom, CropRight, RawFileName" (#9985): the crop keys fell through to the unsupported list (CropTop/CropLeft were only spared because they were 0), and the raw's file name was treated as a setting. The XMP reader now resolves the crop from the public crs: schema: crs:HasCrop="True" with CropTop/CropLeft/CropBottom/CropRight, fractions of the developed frame in the raw's stored orientation. A missing or False HasCrop means no crop (the keys are stale and are not reported). Fractions are clamped to 0..1; an inverted, empty or unreadable crop, and a straightened one (CropAngle not 0, whose rectangle is measured on the rotated frame), is not applied and its keys are named as not supported. RawFileName is bookkeeping. Camera Raw's settings have no crop stage, so the crop is applied the way the sidecar's other raw-stage settings are: Open re-develops the raw (RawTuning::crop) and crops the upright frame after mapping the fractions through the raw's Orientation, before the Camera Raw step, so the post-crop vignette centres on the crop. This works with the dialog and with Open in Camera Raw off; the dialog previews the whole frame. PhotoCraft's own sidecar keeps the crop when it is rewritten, and Load Settings in the Camera Raw Filter names a crop as not supported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2fccdcba8e
commit
2780df1f30
9 files changed
+376
-24
No files matched your search
@@ -12,6 +12,13 @@
|
||||
//! `IncrementalTemperature` / `IncrementalTint` (−100..=100), which are PhotoCraft's Temperature
|
||||
//! and Tint. The absolute `Temperature` (kelvin) / `Tint` of raw-file settings have no
|
||||
//! counterpart and are ignored.
|
||||
//!
|
||||
//! Crop (#9985): `crs:HasCrop="True"` with `crs:CropTop` / `CropLeft` / `CropBottom` /
|
||||
//! `CropRight`, fractions of the developed frame in the raw's stored orientation (before its
|
||||
//! Orientation tag turns it upright), is read into [`LoadReport::crop`]; the raw open crops the
|
||||
//! document with it ([`crate::raw::RawTuning::crop`]). The settings have no crop (Camera Raw
|
||||
//! Filter has none). Without `HasCrop`, or with `HasCrop="False"`, the crop keys are stale and
|
||||
//! mean nothing. A straightened crop (`CropAngle` other than 0) isn't applied.
|
||||
|
||||
use photocraft_algo::camera_raw::{CameraRaw, validate_curve};
|
||||
|
||||
@@ -126,7 +133,6 @@ const METADATA: &[&str] = &[
|
||||
"Version",
|
||||
"ProcessVersion",
|
||||
"HasSettings",
|
||||
"HasCrop",
|
||||
"WhiteBalance",
|
||||
"ToneCurveName2012",
|
||||
"CameraProfile",
|
||||
@@ -147,16 +153,114 @@ const METADATA: &[&str] = &[
|
||||
"SortName",
|
||||
"Group",
|
||||
"Description",
|
||||
// The raw file the sidecar belongs to (Lightroom and Camera Raw name it).
|
||||
"RawFileName",
|
||||
];
|
||||
|
||||
/// The crop's keys besides `HasCrop`, and their values without a crop (the whole frame, level).
|
||||
pub const CROP_KEYS: [&str; 5] = ["CropTop", "CropLeft", "CropBottom", "CropRight", "CropAngle"];
|
||||
const NO_CROP: [f32; 5] = [0.0, 0.0, 1.0, 1.0, 0.0];
|
||||
|
||||
/// A Camera Raw crop: fractions of the developed frame in the raw's stored orientation, before
|
||||
/// its Orientation tag turns it upright, with `0 <= left < right <= 1` and
|
||||
/// `0 <= top < bottom <= 1`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Crop {
|
||||
pub top: f32,
|
||||
pub left: f32,
|
||||
pub bottom: f32,
|
||||
pub right: f32,
|
||||
}
|
||||
|
||||
impl Crop {
|
||||
/// The crop of the frame turned upright by TIFF orientation `o` (1–8; anything else is 1),
|
||||
/// as fractions of the upright frame: the corners map as the pixels do.
|
||||
pub fn oriented(self, o: u16) -> Crop {
|
||||
let map = |u: f32, v: f32| match o {
|
||||
2 => (1.0 - u, v),
|
||||
3 => (1.0 - u, 1.0 - v),
|
||||
4 => (u, 1.0 - v),
|
||||
5 => (v, u),
|
||||
6 => (1.0 - v, u),
|
||||
7 => (1.0 - v, 1.0 - u),
|
||||
8 => (v, 1.0 - u),
|
||||
_ => (u, v),
|
||||
};
|
||||
let (ax, ay) = map(self.left, self.top);
|
||||
let (bx, by) = map(self.right, self.bottom);
|
||||
Crop { top: ay.min(by), left: ax.min(bx), bottom: ay.max(by), right: ax.max(bx) }
|
||||
}
|
||||
|
||||
/// The crop on a `w × h` frame in pixels, `(x0, y0, x1, y1)` with `x1` and `y1` exclusive:
|
||||
/// at least one pixel, inside the frame. `None` for an empty frame.
|
||||
pub fn pixels(self, w: usize, h: usize) -> Option<(usize, usize, usize, usize)> {
|
||||
let edge = |f: f32, n: usize| ((f64::from(f).clamp(0.0, 1.0) * n as f64).round() as usize).min(n);
|
||||
let span = |a: f32, b: f32, n: usize| {
|
||||
let lo = edge(a, n).min(n.checked_sub(1)?);
|
||||
Some((lo, edge(b, n).max(lo + 1)))
|
||||
};
|
||||
let (x0, x1) = span(self.left, self.right, w)?;
|
||||
let (y0, y1) = span(self.top, self.bottom, h)?;
|
||||
Some((x0, y0, x1, y1))
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`load`] applied.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct LoadReport {
|
||||
/// How many settings were applied.
|
||||
/// How many settings were applied (a crop counts as one).
|
||||
pub applied: usize,
|
||||
/// `crs:` settings without a PhotoCraft control (set to something other than their neutral
|
||||
/// value), or with an unusable value, in file order.
|
||||
/// value), or with an unusable value, in file order; then the keys of a crop that isn't
|
||||
/// applied (straightened, unreadable or empty).
|
||||
pub ignored: Vec<String>,
|
||||
/// The file's crop, when it has one that isn't the whole frame. The settings hold no crop,
|
||||
/// so the caller applies it (a raw open crops the document) or reports it.
|
||||
pub crop: Option<Crop>,
|
||||
}
|
||||
|
||||
/// The crop keys as read, resolved by [`crop`] once the whole file is read.
|
||||
#[derive(Default)]
|
||||
struct CropFields {
|
||||
has: Option<String>,
|
||||
values: [Option<String>; 5],
|
||||
}
|
||||
|
||||
/// The file's crop from its keys: applied when `HasCrop` is `True` and the values make a level,
|
||||
/// non-empty rectangle (fractions are clamped to 0..1); otherwise its keys are reported.
|
||||
fn crop(fields: &CropFields, report: &mut LoadReport) {
|
||||
match fields.has.as_deref().map(str::trim) {
|
||||
// No crop: the other keys are what Camera Raw left from an earlier or reset crop.
|
||||
None => return,
|
||||
Some(v) if v.eq_ignore_ascii_case("false") => return,
|
||||
Some(v) if v.eq_ignore_ascii_case("true") => {}
|
||||
Some(_) => {
|
||||
report.ignored.push("HasCrop".into());
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut v = NO_CROP;
|
||||
let mut usable = true;
|
||||
for (slot, text) in v.iter_mut().zip(&fields.values) {
|
||||
if let Some(text) = text {
|
||||
match number(text) {
|
||||
Some(n) => *slot = n,
|
||||
None => usable = false,
|
||||
}
|
||||
}
|
||||
}
|
||||
let [top, left, bottom, right] = [v[0], v[1], v[2], v[3]].map(|f| f.clamp(0.0, 1.0));
|
||||
// A straightened crop's rectangle is measured on the rotated frame: not applied.
|
||||
usable &= v[4] == 0.0 && top < bottom && left < right;
|
||||
if !usable {
|
||||
let present = CROP_KEYS.iter().zip(&fields.values).filter(|(_, t)| t.is_some());
|
||||
report.ignored.extend(present.map(|(k, _)| k.to_string()));
|
||||
return;
|
||||
}
|
||||
if [top, left, bottom, right] != [0.0, 0.0, 1.0, 1.0] {
|
||||
report.crop = Some(Crop { top, left, bottom, right });
|
||||
report.applied += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn number(s: &str) -> Option<f32> {
|
||||
@@ -197,8 +301,14 @@ fn neutral(name: &str, value: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(params: &mut CameraRaw, report: &mut LoadReport, name: &str, value: &str) {
|
||||
if let Some((_, field, lo, hi)) = SCALARS.iter().find(|(n, ..)| *n == name) {
|
||||
fn apply(params: &mut CameraRaw, report: &mut LoadReport, crop: &mut CropFields, name: &str, value: &str) {
|
||||
if name == "HasCrop" {
|
||||
crop.has = Some(value.to_string());
|
||||
} else if let Some(i) = CROP_KEYS.iter().position(|k| *k == name) {
|
||||
if let Some(slot) = crop.values.get_mut(i) {
|
||||
*slot = Some(value.to_string());
|
||||
}
|
||||
} else if let Some((_, field, lo, hi)) = SCALARS.iter().find(|(n, ..)| *n == name) {
|
||||
match number(value) {
|
||||
Some(v) => {
|
||||
*field(params) = v.clamp(*lo, *hi);
|
||||
@@ -232,10 +342,11 @@ pub fn load(params: &mut CameraRaw, bytes: &[u8]) -> Result<LoadReport, String>
|
||||
let mut next = params.clone();
|
||||
let mut report = LoadReport::default();
|
||||
let mut found = false;
|
||||
let mut crop_fields = CropFields::default();
|
||||
for desc in doc.descendants().filter(|n| n.is_element() && n.tag_name().namespace() == Some(RDF_NS) && n.tag_name().name() == "Description") {
|
||||
for a in desc.attributes().filter(|a| a.namespace() == Some(CRS_NS)) {
|
||||
found = true;
|
||||
apply(&mut next, &mut report, a.name(), a.value());
|
||||
apply(&mut next, &mut report, &mut crop_fields, a.name(), a.value());
|
||||
}
|
||||
for c in desc.children().filter(|c| c.is_element() && c.tag_name().namespace() == Some(CRS_NS)) {
|
||||
found = true;
|
||||
@@ -254,13 +365,14 @@ pub fn load(params: &mut CameraRaw, bytes: &[u8]) -> Result<LoadReport, String>
|
||||
report.ignored.push(name.to_string());
|
||||
}
|
||||
} else {
|
||||
apply(&mut next, &mut report, name, c.text().unwrap_or(""));
|
||||
apply(&mut next, &mut report, &mut crop_fields, name, c.text().unwrap_or(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Err("no Camera Raw settings in the file".into());
|
||||
}
|
||||
crop(&crop_fields, &mut report);
|
||||
*params = next;
|
||||
Ok(report)
|
||||
}
|
||||
@@ -295,6 +407,11 @@ pub fn is_photocraft_xmp(bytes: &[u8]) -> bool {
|
||||
|
||||
/// Write `params` as a Camera Raw settings file Photoshop's Load Settings… reads.
|
||||
pub fn save(params: &CameraRaw) -> String {
|
||||
save_with_crop(params, None)
|
||||
}
|
||||
|
||||
/// [`save`] with a raw file's crop, so rewriting a sidecar keeps the crop it was opened with.
|
||||
pub fn save_with_crop(params: &CameraRaw, crop: Option<Crop>) -> String {
|
||||
let mut p = params.clone();
|
||||
let wb = if p.temperature != 0.0 || p.tint != 0.0 { "Custom" } else { "As Shot" };
|
||||
let mut out = String::from(
|
||||
@@ -315,6 +432,12 @@ pub fn save(params: &CameraRaw) -> String {
|
||||
}
|
||||
let style = STYLES.iter().find(|(_, s)| *s == p.vignette_style).map_or("1", |(n, _)| n);
|
||||
out.push_str(&format!(" crs:PostCropVignetteStyle=\"{}\"\n", escape(style)));
|
||||
if let Some(c) = crop {
|
||||
out.push_str(&format!(
|
||||
" crs:HasCrop=\"True\"\n crs:CropTop=\"{:.6}\"\n crs:CropLeft=\"{:.6}\"\n crs:CropBottom=\"{:.6}\"\n crs:CropRight=\"{:.6}\"\n crs:CropAngle=\"0\"\n",
|
||||
c.top, c.left, c.bottom, c.right
|
||||
));
|
||||
}
|
||||
out.push_str(" crs:ToneCurveName2012=\"Custom\"\n crs:HasSettings=\"True\">\n");
|
||||
for (name, field) in CURVES {
|
||||
let points = field(&mut p);
|
||||
@@ -433,7 +556,7 @@ mod tests {
|
||||
<crs:Clarity2012>abc</crs:Clarity2012></rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
||||
let r = load(&mut p, xmp).unwrap();
|
||||
assert_eq!((p.exposure, p.contrast, p.clarity), (-5.0, 40.0, 0.0));
|
||||
assert_eq!(r, LoadReport { applied: 1, ignored: vec!["Clarity2012".into()] });
|
||||
assert_eq!(r, LoadReport { applied: 1, ignored: vec!["Clarity2012".into()], crop: None });
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -479,4 +602,111 @@ mod tests {
|
||||
assert!(!is_photocraft_xmp(other), "{}", String::from_utf8_lossy(other));
|
||||
}
|
||||
}
|
||||
|
||||
/// A settings file with the `crs:` attributes `attrs`.
|
||||
fn with(attrs: &str) -> String {
|
||||
format!(
|
||||
"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"><rdf:Description xmlns:crs=\"{CRS_NS}\" crs:RawFileName=\"IMG_0004.CR2\" {attrs}/></rdf:RDF></x:xmpmeta>"
|
||||
)
|
||||
}
|
||||
|
||||
fn report(attrs: &str) -> LoadReport {
|
||||
load(&mut CameraRaw::default(), with(attrs).as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
/// #9985: Lightroom's sidecar named the crop's bottom and right edges and the raw's file name
|
||||
/// as unsupported; the crop is read now, and the file name is bookkeeping.
|
||||
#[test]
|
||||
fn reads_the_crop_and_not_the_file_name() {
|
||||
let r = report(r#"crs:HasCrop="True" crs:CropTop="0.1" crs:CropLeft="0.2" crs:CropBottom="0.9" crs:CropRight="0.75" crs:CropAngle="0""#);
|
||||
assert!(r.ignored.is_empty(), "{:?}", r.ignored);
|
||||
assert_eq!(r.crop, Some(Crop { top: 0.1, left: 0.2, bottom: 0.9, right: 0.75 }));
|
||||
assert_eq!(r.applied, 1);
|
||||
// Element form, and the keys spread over two descriptions, read the same.
|
||||
let xmp = format!(
|
||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"><rdf:Description xmlns:crs=\"{CRS_NS}\"><crs:HasCrop>True</crs:HasCrop><crs:CropBottom>0.5</crs:CropBottom></rdf:Description><rdf:Description xmlns:crs=\"{CRS_NS}\" crs:CropRight=\"0.5\"/></rdf:RDF>"
|
||||
);
|
||||
let r = load(&mut CameraRaw::default(), xmp.as_bytes()).unwrap();
|
||||
assert_eq!(r.crop, Some(Crop { top: 0.0, left: 0.0, bottom: 0.5, right: 0.5 }), "missing edges are the frame's");
|
||||
assert!(r.ignored.is_empty(), "{:?}", r.ignored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_crop_without_has_crop_true() {
|
||||
let keys = r#"crs:CropTop="0.1" crs:CropLeft="0.2" crs:CropBottom="0.9" crs:CropRight="0.75" crs:CropAngle="3""#;
|
||||
for has in ["", r#"crs:HasCrop="False""#, r#"crs:HasCrop="false""#] {
|
||||
let r = report(&format!("{has} {keys}"));
|
||||
assert_eq!((r.crop, r.ignored.clone(), r.applied), (None, Vec::new(), 0), "{has:?}: {r:?}");
|
||||
}
|
||||
// The whole frame is no crop, and nothing to report.
|
||||
let r = report(r#"crs:HasCrop="True" crs:CropTop="0" crs:CropLeft="0" crs:CropBottom="1" crs:CropRight="1" crs:CropAngle="0""#);
|
||||
assert_eq!((r.crop, r.ignored.len()), (None, 0));
|
||||
let r = report(r#"crs:HasCrop="yes" crs:CropBottom="0.5""#);
|
||||
assert_eq!((r.crop, r.ignored), (None, vec!["HasCrop".to_string()]));
|
||||
}
|
||||
|
||||
/// Hostile and straightened crops never panic: out-of-range fractions are clamped to the
|
||||
/// frame; an inverted, empty, unreadable or straightened crop isn't applied and its keys are
|
||||
/// named.
|
||||
#[test]
|
||||
fn unusable_crops_are_reported_not_applied() {
|
||||
let r = report(r#"crs:HasCrop="True" crs:CropTop="-0.5" crs:CropLeft="0.25" crs:CropBottom="7" crs:CropRight="1.0000001""#);
|
||||
assert_eq!(r.crop, Some(Crop { top: 0.0, left: 0.25, bottom: 1.0, right: 1.0 }));
|
||||
assert!(r.ignored.is_empty(), "{:?}", r.ignored);
|
||||
let all = || CROP_KEYS.iter().map(|k| k.to_string()).collect::<Vec<_>>();
|
||||
for bad in [
|
||||
r#"crs:CropTop="0.8" crs:CropLeft="0.1" crs:CropBottom="0.2" crs:CropRight="0.9" crs:CropAngle="0""#,
|
||||
r#"crs:CropTop="0.5" crs:CropLeft="0.1" crs:CropBottom="0.5" crs:CropRight="0.9" crs:CropAngle="0""#,
|
||||
r#"crs:CropTop="2" crs:CropLeft="0.1" crs:CropBottom="3" crs:CropRight="0.9" crs:CropAngle="0""#,
|
||||
r#"crs:CropTop="NaN" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="0""#,
|
||||
r#"crs:CropTop="0.1" crs:CropLeft="inf" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="0""#,
|
||||
r#"crs:CropTop="0.1" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="" crs:CropAngle="0""#,
|
||||
r#"crs:CropTop="0.1" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="1.38""#,
|
||||
r#"crs:CropTop="0.1" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="NaN""#,
|
||||
] {
|
||||
let r = report(&format!(r#"crs:HasCrop="True" {bad}"#));
|
||||
assert_eq!((r.crop, r.ignored, r.applied), (None, all(), 0), "{bad}");
|
||||
}
|
||||
let r = report(r#"crs:HasCrop="True" crs:CropAngle="-2.5""#);
|
||||
assert_eq!(r.ignored, vec!["CropAngle".to_string()], "only the keys the file has");
|
||||
}
|
||||
|
||||
/// Corners map as `photocraft-raw`'s orientation maps pixels (`develop::source_xy`).
|
||||
#[test]
|
||||
fn crops_follow_the_orientation() {
|
||||
let c = Crop { top: 0.1, left: 0.2, bottom: 0.6, right: 0.5 };
|
||||
let want = [
|
||||
(1, Crop { top: 0.1, left: 0.2, bottom: 0.6, right: 0.5 }),
|
||||
(2, Crop { top: 0.1, left: 0.5, bottom: 0.6, right: 0.8 }),
|
||||
(3, Crop { top: 0.4, left: 0.5, bottom: 0.9, right: 0.8 }),
|
||||
(4, Crop { top: 0.4, left: 0.2, bottom: 0.9, right: 0.5 }),
|
||||
(5, Crop { top: 0.2, left: 0.1, bottom: 0.5, right: 0.6 }),
|
||||
(6, Crop { top: 0.2, left: 0.4, bottom: 0.5, right: 0.9 }),
|
||||
(7, Crop { top: 0.5, left: 0.4, bottom: 0.8, right: 0.9 }),
|
||||
(8, Crop { top: 0.5, left: 0.1, bottom: 0.8, right: 0.6 }),
|
||||
(0, Crop { top: 0.1, left: 0.2, bottom: 0.6, right: 0.5 }),
|
||||
];
|
||||
for (o, w) in want {
|
||||
let got = c.oriented(o);
|
||||
let close = [got.top - w.top, got.left - w.left, got.bottom - w.bottom, got.right - w.right].iter().all(|d| d.abs() < 1e-6);
|
||||
assert!(close, "orientation {o}: {got:?}");
|
||||
}
|
||||
assert_eq!(c.pixels(10, 20), Some((2, 2, 5, 12)));
|
||||
assert_eq!(Crop { top: 0.5, left: 0.5, bottom: 0.5001, right: 0.5001 }.pixels(10, 10), Some((5, 5, 6, 6)), "at least a pixel");
|
||||
assert_eq!(Crop { top: 0.999, left: 0.999, bottom: 1.0, right: 1.0 }.pixels(10, 10), Some((9, 9, 10, 10)), "inside the frame");
|
||||
assert_eq!(c.pixels(0, 10), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_with_crop_round_trips() {
|
||||
let p = CameraRaw { contrast: 20.0, ..CameraRaw::default() };
|
||||
let crop = Crop { top: 0.125, left: 0.25, bottom: 0.875, right: 0.5 };
|
||||
let text = save_with_crop(&p, Some(crop));
|
||||
assert!(is_photocraft_xmp(text.as_bytes()));
|
||||
let mut back = CameraRaw::default();
|
||||
let r = load(&mut back, text.as_bytes()).unwrap();
|
||||
assert_eq!((back, r.crop), (p.clone(), Some(crop)));
|
||||
assert!(r.ignored.is_empty(), "{:?}", r.ignored);
|
||||
assert_eq!(load(&mut CameraRaw::default(), save(&p).as_bytes()).unwrap().crop, None);
|
||||
}
|
||||
}
|
||||
+30
-3
@@ -50,7 +50,7 @@ fn upright_preview(raw: &[u8], jpeg: &[u8], limits: &photocraft_raw::Limits) ->
|
||||
|
||||
/// Camera Raw's Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture and Dehaze,
|
||||
/// applied while developing the sensor data (the open-time Camera Raw dialog) instead of to the
|
||||
/// developed RGB pixels, and the demosaic method.
|
||||
/// developed RGB pixels, the demosaic method, and the crop.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct RawTuning {
|
||||
/// −100…100, as `photocraft_algo::camera_raw::CameraRaw::temperature`.
|
||||
@@ -71,6 +71,9 @@ pub struct RawTuning {
|
||||
pub texture: f32,
|
||||
/// −100…100, as `CameraRaw::dehaze`.
|
||||
pub dehaze: f32,
|
||||
/// The crop of the raw's sidecar ([`crate::camera_raw_xmp::Crop`], in the stored
|
||||
/// orientation): the developed frame is cropped to it once it is upright.
|
||||
pub crop: Option<crate::camera_raw_xmp::Crop>,
|
||||
}
|
||||
|
||||
/// Develops a raw file with `tuning` applied at the raw stage: Exposure as develop exposure,
|
||||
@@ -106,7 +109,15 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result<
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
let dev = photocraft_raw::develop_sensor(&sensor, &opts).map_err(IoError::Raw)?;
|
||||
let mut dev = photocraft_raw::develop_sensor(&sensor, &opts).map_err(IoError::Raw)?;
|
||||
let cropped = match tuning.crop {
|
||||
Some(crop) => {
|
||||
let o = if opts.orient { dev.info.orientation } else { 1 };
|
||||
crop_developed(&mut dev, crop.oriented(o))?;
|
||||
format!(", cropped to {}×{}", dev.width, dev.height)
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
let wb = if wb_applied {
|
||||
format!("as-shot white balance, temperature {:+.0}, tint {:+.0}", tuning.temperature, tuning.tint)
|
||||
} else {
|
||||
@@ -117,10 +128,26 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result<
|
||||
.filter(|(_, v)| *v != 0.0)
|
||||
.map(|(k, v)| format!(", {k} {v:+.0}"))
|
||||
.collect::<String>();
|
||||
let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb}, {} demosaic)", tuning.exposure, tuning.demosaic.id());
|
||||
let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb}, {} demosaic{cropped})", tuning.exposure, tuning.demosaic.id());
|
||||
Ok((developed_document(name, bytes, format, dev, &summary)?, wb_applied))
|
||||
}
|
||||
|
||||
/// Crops the upright developed frame to `crop` (fractions of it), as Camera Raw crops a raw.
|
||||
fn crop_developed(dev: &mut photocraft_raw::Developed, crop: crate::camera_raw_xmp::Crop) -> Result<(), IoError> {
|
||||
let (w, h) = (dev.width as usize, dev.height as usize);
|
||||
let bad = || IoError::Raw(RawError::Malformed(format!("the developed {w}x{h} frame holds fewer samples than its size")));
|
||||
let Some((x0, y0, x1, y1)) = crop.pixels(w, h) else { return Ok(()) };
|
||||
let mut rgb = Vec::with_capacity((x1 - x0) * (y1 - y0) * 3);
|
||||
for y in y0..y1 {
|
||||
let row = y.checked_mul(w).ok_or_else(bad)?;
|
||||
rgb.extend_from_slice(dev.rgb.get((row + x0) * 3..(row + x1) * 3).ok_or_else(bad)?);
|
||||
}
|
||||
dev.rgb = rgb;
|
||||
dev.width = u32::try_from(x1 - x0).map_err(|_| bad())?;
|
||||
dev.height = u32::try_from(y1 - y0).map_err(|_| bad())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The tone curve `bytes` develop with by default: the fitted look's for a file without colour
|
||||
/// calibration (see [`import_raw_with`]), else the camera profile's (empty: none, or not a raw
|
||||
/// this crate decodes). For previews of the open-time raw-stage sliders
|
||||
|
||||
@@ -420,6 +420,36 @@ fn uncalibrated_raw_takes_its_colour_from_the_camera_jpeg() {
|
||||
assert_eq!(r.document.depth, SampleType::U16);
|
||||
}
|
||||
|
||||
/// #9985: a sidecar's crop is measured on the raw as stored, before its Orientation turns it
|
||||
/// upright: the developed frame is cropped to the same sensor pixels, whichever way it is turned.
|
||||
#[test]
|
||||
fn camera_raw_tuning_crops_in_the_stored_orientation() {
|
||||
use photocraft_io::camera_raw_xmp::Crop;
|
||||
use photocraft_io::raw::{RawTuning, import_raw_tuned};
|
||||
let (w, h) = (40, 24);
|
||||
// Stored pixels x 4..24, y 6..18; then the upright frame's size and the same pixels in it.
|
||||
let crop = Crop { top: 0.25, left: 0.1, bottom: 0.75, right: 0.6 };
|
||||
for (orientation, size, [x0, y0, x1, y1]) in
|
||||
[(1, (40, 24), [4, 6, 24, 18]), (3, (40, 24), [16, 6, 36, 18]), (6, (24, 40), [6, 4, 18, 24]), (8, (24, 40), [6, 16, 18, 36])]
|
||||
{
|
||||
let mut spec = DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535));
|
||||
spec.as_shot_neutral = Some([0.6, 1.0, 0.8]);
|
||||
spec.orientation = orientation;
|
||||
let bytes = spec.build();
|
||||
let (full, _) = import_raw_tuned("shot.dng", &bytes, &RawTuning::default()).unwrap();
|
||||
let (cut, _) = import_raw_tuned("shot.dng", &bytes, &RawTuning { crop: Some(crop), ..Default::default() }).unwrap();
|
||||
assert_eq!((full.document.size.width, full.document.size.height), size, "orientation {orientation}");
|
||||
assert_eq!((cut.document.size.width, cut.document.size.height), (x1 - x0, y1 - y0), "orientation {orientation}");
|
||||
assert!(cut.warnings.iter().any(|n| n.contains(&format!("cropped to {}×{}", x1 - x0, y1 - y0))), "{:?}", cut.warnings);
|
||||
let full = photocraft_io::document_to_image(&full.document, &mut Vec::new()).unwrap();
|
||||
let cut = photocraft_io::document_to_image(&cut.document, &mut Vec::new()).unwrap();
|
||||
// 16-bit RGB: 6 bytes a pixel.
|
||||
let row = |y: u32| &full.data()[((y * size.0 + x0) * 6) as usize..((y * size.0 + x1) * 6) as usize];
|
||||
let expected: Vec<u8> = (y0..y1).flat_map(|y| row(y).to_vec()).collect();
|
||||
assert_eq!(cut.data(), expected, "orientation {orientation}: the crop is the same pixels of the uncropped develop");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_raw_tuning_keeps_the_default_look_and_applies_user_gains_after_fitting() {
|
||||
use photocraft_io::raw::{RawTuning, fit_preview_look, import_raw_tuned};
|
||||
|
||||
@@ -54,6 +54,7 @@ fn identity_app() -> (PhotocraftApp, RawOpen, Control) {
|
||||
bytes: Some(bytes),
|
||||
developed_with: Default::default(),
|
||||
demosaic: Default::default(),
|
||||
crop: None,
|
||||
};
|
||||
let mut app = PhotocraftApp::new(session, crate::Services::default());
|
||||
app.sync_views();
|
||||
|
||||
@@ -16,9 +16,9 @@ pub(super) enum Target {
|
||||
/// A raw file opened interactively, shown in Camera Raw before it is kept (as Photoshop opens
|
||||
/// raws in Adobe Camera Raw first). The document is already developed with the defaults; Open
|
||||
/// re-develops it when Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture,
|
||||
/// Dehaze or the demosaic method changed (those act on the sensor data, see
|
||||
/// [`photocraft_io::raw::import_raw_tuned`]) and applies the other settings as one Camera Raw
|
||||
/// step, then marks the document unmodified. Cancel closes it.
|
||||
/// Dehaze or the demosaic method changed, or the sidecar has a crop (those act on the sensor
|
||||
/// data, see [`photocraft_io::raw::import_raw_tuned`]) and applies the other settings as one
|
||||
/// Camera Raw step, then marks the document unmodified. Cancel closes it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RawOpen {
|
||||
pub document: photocraft_doc::DocId,
|
||||
@@ -30,6 +30,9 @@ pub struct RawOpen {
|
||||
pub developed_with: photocraft_io::raw::Demosaic,
|
||||
/// The method chosen in the dialog: Open re-develops when it differs from `developed_with`.
|
||||
pub demosaic: photocraft_io::raw::Demosaic,
|
||||
/// The crop of the file's sidecar (in the raw's stored orientation): Open re-develops and
|
||||
/// crops to it. The settings have no crop, so the dialog previews the whole frame.
|
||||
pub crop: Option<photocraft_io::camera_raw_xmp::Crop>,
|
||||
}
|
||||
|
||||
/// Should an interactive open of `name` show the open-time Camera Raw dialog? `warnings` are
|
||||
@@ -68,12 +71,13 @@ pub fn queue_open_dialog(
|
||||
bytes: bytes.map(|b| std::sync::Arc::new(b.to_vec())),
|
||||
developed_with: method,
|
||||
demosaic: method,
|
||||
crop: None,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens the queued open-time dialog on its document's (only) layer.
|
||||
pub(super) fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpen) -> Result<(), String> {
|
||||
pub(super) fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, mut raw: RawOpen) -> Result<(), String> {
|
||||
let index = document_index(app, raw.document).ok_or("the raw document was closed")?;
|
||||
app.session.set_active(index);
|
||||
let (layer, surf, _) = crate::distort_ui::active_pixels(app)?;
|
||||
@@ -83,6 +87,7 @@ pub(super) fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpe
|
||||
let mut from_sidecar = false;
|
||||
if let Some((loaded, report, file)) = raw.path.as_deref().and_then(read_sidecar) {
|
||||
params = loaded;
|
||||
raw.crop = report.crop;
|
||||
from_sidecar = true;
|
||||
app.ui.status = if report.ignored.is_empty() {
|
||||
crate::i18n::fmt(tl!("Settings from {name}"), &[("name", &file)])
|
||||
@@ -179,6 +184,9 @@ pub(super) fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw:
|
||||
clarity: params.clarity,
|
||||
texture: params.texture,
|
||||
dehaze: params.dehaze,
|
||||
// The sidecar's crop: re-developing crops the document, before the Camera Raw step, so
|
||||
// the post-crop vignette centres on the crop as in Camera Raw.
|
||||
crop: raw.crop,
|
||||
};
|
||||
// The open itself: the default settings, developed with the method it was opened with.
|
||||
let as_opened = photocraft_io::raw::RawTuning { demosaic: raw.developed_with, ..Default::default() };
|
||||
@@ -349,9 +357,13 @@ pub fn opened_without_dialog(app: &mut PhotocraftApp, index: usize, name: &str,
|
||||
}
|
||||
let Some(document) = app.session.documents().get(index).map(|st| st.doc.id) else { return };
|
||||
let method = photocraft_io::raw::developed_demosaic(warnings).unwrap_or_else(|| app.session.prefs().raw_defaults.demosaic.method());
|
||||
let raw = RawOpen { document, name: name.to_string(), path: path.map(str::to_string), bytes: None, developed_with: method, demosaic: method };
|
||||
let mut raw =
|
||||
RawOpen { document, name: name.to_string(), path: path.map(str::to_string), bytes: None, developed_with: method, demosaic: method, crop: None };
|
||||
let mut params = CameraRaw::default();
|
||||
let sidecar = path.and_then(read_sidecar).map(|(loaded, _, _)| loaded);
|
||||
let sidecar = path.and_then(read_sidecar).map(|(loaded, report, _)| {
|
||||
raw.crop = report.crop;
|
||||
loaded
|
||||
});
|
||||
if let Some(loaded) = &sidecar {
|
||||
params = loaded.clone();
|
||||
} else if app.session.prefs().raw_defaults.apply_auto_tone
|
||||
@@ -359,7 +371,7 @@ pub fn opened_without_dialog(app: &mut PhotocraftApp, index: usize, name: &str,
|
||||
{
|
||||
photocraft_algo::camera_raw::auto_tone(&px, &mut params);
|
||||
}
|
||||
let r = if params.is_identity() {
|
||||
let r = if params.is_identity() && raw.crop.is_none() {
|
||||
let defaults = app.session.prefs().raw_defaults.clone();
|
||||
finish_open_raw(app, index, params, None, &raw, &defaults)
|
||||
} else {
|
||||
|
||||
@@ -68,7 +68,8 @@ pub(super) fn write_sidecar(app: &mut PhotocraftApp, raw: &RawOpen, params: &Cam
|
||||
None if params.is_identity() => return,
|
||||
_ => {}
|
||||
}
|
||||
let bytes = photocraft_io::camera_raw_xmp::save(params).into_bytes();
|
||||
// The crop it opened with stays in the file, though the dialog has no control for it.
|
||||
let bytes = photocraft_io::camera_raw_xmp::save_with_crop(params, raw.crop).into_bytes();
|
||||
let Some(write) = app.services.write.as_mut() else { return };
|
||||
if let Err(e) = write(&sidecar, &bytes) {
|
||||
crate::notices::error(app, crate::i18n::fmt(tl!("Could not write {name}: {error}"), &[("name", &name), ("error", &e)]));
|
||||
@@ -124,7 +125,12 @@ pub(super) fn load_settings(app: &mut PhotocraftApp) -> Result<Value, String> {
|
||||
let file = crate::file_open::display_name(&name);
|
||||
let d = app.camera_raw.as_mut().ok_or(tl!("Camera Raw isn't open"))?;
|
||||
let mut next = d.params.clone();
|
||||
let report = photocraft_io::camera_raw_xmp::load(&mut next, &bytes).map_err(|e| format!("{file}: {e}"))?;
|
||||
let mut report = photocraft_io::camera_raw_xmp::load(&mut next, &bytes).map_err(|e| format!("{file}: {e}"))?;
|
||||
// A crop only applies as a raw file opens; the filter has none, so it is not supported.
|
||||
if report.crop.take().is_some() {
|
||||
report.applied = report.applied.saturating_sub(1);
|
||||
report.ignored.extend(photocraft_io::camera_raw_xmp::CROP_KEYS.iter().take(4).map(|k| k.to_string()));
|
||||
}
|
||||
next.validate_changes(&d.original).map_err(|e| format!("{file}: {e}"))?;
|
||||
if d.params.point_curve != next.point_curve {
|
||||
d.curve_state = Default::default();
|
||||
|
||||
@@ -558,7 +558,8 @@ fn cancel_closes_the_raw_and_the_preference_skips_the_dialog() {
|
||||
path: None,
|
||||
bytes: None,
|
||||
developed_with: Default::default(),
|
||||
demosaic: Default::default()
|
||||
demosaic: Default::default(),
|
||||
crop: None
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
@@ -801,6 +802,47 @@ fn a_rating_only_sidecar_is_left_alone() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #9985: the crop of a Lightroom sidecar crops the opened raw (with the dialog's Open and
|
||||
/// without the dialog), and neither its edges nor the raw's file name are named as unsupported.
|
||||
/// A straightened crop isn't applied and is named. PhotoCraft's own sidecar keeps the crop.
|
||||
#[test]
|
||||
fn a_sidecar_crop_crops_the_opened_raw() {
|
||||
let lightroom = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 7.0"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:RawFileName="IMG_0100.cr2" crs:HasCrop="True" crs:CropTop="0.25" crs:CropLeft="0.125" crs:CropBottom="0.75" crs:CropRight="0.625" crs:CropAngle="0"/></rdf:RDF></x:xmpmeta>"#;
|
||||
let (mut app, dng, dir, raw_path, written) = raw_with_sidecar("crop", Some(lightroom));
|
||||
let ctx = egui::Context::default();
|
||||
let size = |app: &PhotocraftApp| app.session.active().map(|st| (st.doc.size.width, st.doc.size.height));
|
||||
open_dialog_at(&mut app, &ctx, &dng, &raw_path);
|
||||
assert!(app.ui.status.contains("IMG_0100.xmp") && !app.ui.status.contains("not supported"), "{}", app.ui.status);
|
||||
assert_eq!(size(&app), Some((48, 32)), "the dialog previews the whole frame");
|
||||
menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap();
|
||||
assert_eq!(size(&app), Some((24, 16)), "Open crops the 48x32 frame to x 6..30, y 8..24");
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.saved_revision, st.revision, "the crop is part of opening: unmodified");
|
||||
assert!(written.lock().unwrap().is_empty(), "Lightroom's sidecar is kept");
|
||||
app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": false})).unwrap();
|
||||
app.open_file(&raw_path, &dng).unwrap();
|
||||
assert_eq!(size(&app), Some((24, 16)), "without the dialog too");
|
||||
app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": true})).unwrap();
|
||||
// Straightened: the rectangle is measured on the turned frame, so nothing is cropped.
|
||||
std::fs::write(sidecar_path(&raw_path), lightroom.replace(r#"crs:CropAngle="0""#, r#"crs:CropAngle="1.38""#)).unwrap();
|
||||
open_dialog_at(&mut app, &ctx, &dng, &raw_path);
|
||||
assert!(app.ui.status.contains("CropAngle") && !app.ui.status.contains("RawFileName"), "{}", app.ui.status);
|
||||
menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap();
|
||||
assert_eq!(size(&app), Some((48, 32)));
|
||||
// PhotoCraft's own sidecar: a changed setting rewrites it, with the crop it was opened with.
|
||||
let crop = photocraft_io::camera_raw_xmp::Crop { top: 0.25, left: 0.125, bottom: 0.75, right: 0.625 };
|
||||
std::fs::write(sidecar_path(&raw_path), photocraft_io::camera_raw_xmp::save_with_crop(&CameraRaw::default(), Some(crop))).unwrap();
|
||||
open_dialog_at(&mut app, &ctx, &dng, &raw_path);
|
||||
menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 15}, "commit": true}})).unwrap().unwrap();
|
||||
assert_eq!(size(&app), Some((24, 16)));
|
||||
let w = written.lock().unwrap().clone();
|
||||
assert_eq!(w.len(), 1);
|
||||
let mut back = CameraRaw::default();
|
||||
let r = photocraft_io::camera_raw_xmp::load(&mut back, &w[0].1).unwrap();
|
||||
assert_eq!((back.contrast, r.crop), (15.0, Some(crop)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// With Open in Camera Raw off, opening a raw develops it with its sidecar and writes nothing
|
||||
/// back: nothing changed.
|
||||
#[test]
|
||||
|
||||
@@ -529,7 +529,10 @@ Open also applies the other Camera Raw Defaults (Sharpen For as part of the Came
|
||||
colour space, bit depth and Open as Smart Object, so the step works on the pixels the dialog
|
||||
previewed; Apply auto tone fills the dialog), reported as `defaults` in the commit's reply; the
|
||||
resolution is set when the file opens. With "Open in Camera Raw" off the sidecar and the defaults
|
||||
apply without the dialog.
|
||||
apply without the dialog. A sidecar's crop (`crs:HasCrop="True"` with `CropTop` / `CropLeft` /
|
||||
`CropBottom` / `CropRight`, fractions of the raw as stored, before its Orientation) crops the
|
||||
document as Open re-develops it, before the Camera Raw step; the dialog previews the whole frame.
|
||||
A straightened crop (`CropAngle` not 0) is named as not supported and not applied.
|
||||
|
||||
**Opening a raw file.** An interactive open (File › Open, Open Recent, drag and drop, the
|
||||
command line) of a camera raw developed from its sensor data shows this dialog first, titled
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-24 · **Change:** minor (PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-28 · **Change:** minor (Camera Raw: an `.xmp` sidecar's crop crops the opened raw, and `crs:RawFileName` is no longer named as unsupported, #9985; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
**PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import.
|
||||
|
||||
@@ -74,7 +74,7 @@ supports many more, including CR3).
|
||||
| Olympus ORF | uncompressed only | |
|
||||
| Fujifilm RAF (Bayer and X-Trans) | uncompressed only; no Fuji colour calibration | |
|
||||
| Others (CRW, 3FR, IIQ, X3F, SRW, MRW, ERF…) | embedded preview only | |
|
||||
| .xmp sidecars, highlight modes, raw preferences | missing | FILE-215-11 |
|
||||
| .xmp sidecars, highlight modes, raw preferences | partial (sidecars and preferences; no highlight modes) | 2026-10-28: a sidecar's crop (`crs:HasCrop` with `CropTop` / `CropLeft` / `CropBottom` / `CropRight`, fractions of the developed frame as stored, mapped through its Orientation) crops the opened raw: Open re-develops and crops before the Camera Raw step, so the post-crop vignette centres on the crop; the dialog previews the whole frame. A straightened crop (`CropAngle` not 0) isn't applied and is named, as are unreadable or empty ones; `HasCrop` missing or `False` means no crop; `RawFileName` is bookkeeping (#9985; `crates/io/src/camera_raw_xmp.rs` tests, `crates/io/tests/it/raw.rs` `camera_raw_tuning_crops_in_the_stored_orientation`, `camera_raw_ui/tests.rs` `a_sidecar_crop_crops_the_opened_raw`). 2026-10-13: sidecars fill the open-time dialog and Open writes them back; Camera Raw Defaults wired. FILE-215-11 |
|
||||
| Camera Raw as a PSD smart filter | partial | 2026-10-07: WB, Light/Presence, curves, HSL, grading, detail, grain, vignette mapped (FILE-215-12) |
|
||||
|
||||
2026-10-11: on #2135's embedded-JPEG rendering work, open-time Camera Raw tuning
|
||||
@@ -144,6 +144,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-28 | minor | Camera Raw `.xmp` sidecars: the crop (`crs:HasCrop` with `CropTop` / `CropLeft` / `CropBottom` / `CropRight`, fractions of the raw as stored, mapped through its Orientation) crops the opened raw, with the dialog's Open and without the dialog; out-of-range fractions are clamped, and inverted, unreadable or straightened (`CropAngle` not 0) crops are named as not supported instead of applied; PhotoCraft's own sidecar keeps the crop when it is rewritten; `crs:RawFileName` is no longer named as unsupported (#9985) |
|
||||
| 2026-10-24 | minor | Layered TIFF: the `LMsk` block in tag 37724 carries Photoshop's default global layer mask (14 bytes) instead of none; with an empty block Photoshop read only the composite (#9095). PSD output unchanged |
|
||||
| 2026-10-24 | minor | Layered TIFF writes the TySh text version and descriptor in PSD big-endian order while retaining container order for the block header, transform matrix, warp section and bounds (#9098); the mixed-order read path remains covered by #4758/#4771. |
|
||||
| 2026-10-23 | minor | PSD: spot channels are stored and read as Photoshop 27.11's PSDs store them (ink 0 = solid; all 7 of its spot PSDs store the 100/25/75/0 % columns as 0/191/64/255), and resource 1053 numbers the extra channels 3, 4, 5, … after 0 for the merged transparency. A PSD with spot channels whose 1057 is exactly what PhotoCraft 0.1.0–0.16.0 wrote, and with no 1053 or one kept from an opened Photoshop file that doesn't list every channel, is read as those versions stored it (straight past the channels that 1053 lists), so it keeps its ink. Multichannel is unchanged (#7415 split, part 4) |
|
||||
|
||||
Reference in new issue
Block a user