Merge remote-tracking branch 'origin/main' into integration-batch-7

# Conflicts:
#	crates/automation/src/rpc.rs
#	crates/automation/src/server.rs
#	crates/gpu/src/health.rs
#	crates/text/src/tests.rs
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/panels.rs
#	docs/control-protocol.md
#	docs/ui-parity.md
This commit is contained in:
David Mann
2026-10-24 00:23:15 +02:00
76 changed files with 3299 additions and 211 deletions
Generated
+31 -31
View File
@@ -4638,7 +4638,7 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"arboard",
"async-io",
@@ -4685,7 +4685,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"crc32fast",
"flate2",
@@ -4695,7 +4695,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4709,7 +4709,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"base64 0.22.1",
"cap-std",
@@ -4732,7 +4732,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
@@ -4747,7 +4747,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"moxcms",
"rayon",
@@ -4755,7 +4755,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"exr",
"flate2",
@@ -4776,7 +4776,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"photocraft-cms",
"serde",
@@ -4784,7 +4784,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"criterion",
"photocraft-cms",
@@ -4800,7 +4800,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4811,7 +4811,7 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"blake3",
"chrono",
@@ -4852,7 +4852,7 @@ dependencies = [
[[package]]
name = "photocraft-format"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"blake3",
"crc32fast",
@@ -4875,14 +4875,14 @@ dependencies = [
[[package]]
name = "photocraft-geom"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"half",
"log",
@@ -4900,14 +4900,14 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"blake3",
"craft-docpath",
@@ -4938,18 +4938,18 @@ dependencies = [
[[package]]
name = "photocraft-ipp"
version = "0.15.0"
version = "0.15.1"
[[package]]
name = "photocraft-ops"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4960,7 +4960,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4976,7 +4976,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4987,7 +4987,7 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4998,7 +4998,7 @@ dependencies = [
[[package]]
name = "photocraft-raw"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"flate2",
"image",
@@ -5013,7 +5013,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -5025,7 +5025,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"memory-stats",
"serde_json",
@@ -5033,7 +5033,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"flate2",
"parley",
@@ -5048,7 +5048,7 @@ dependencies = [
[[package]]
name = "photocraft-ui-egui"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"base64 0.22.1",
"craft-print",
@@ -5091,7 +5091,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -5102,7 +5102,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"eframe",
"egui",
@@ -5124,7 +5124,7 @@ dependencies = [
[[package]]
name = "photocraft-web-boot"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"flate2",
"js-sys",
@@ -8766,7 +8766,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"chrono",
"serde",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.15.0"
version = "0.15.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+9 -1
View File
@@ -1,6 +1,11 @@
//! Desktop colour capture starts only on an explicit UI request. No snapshots are written to
//! disk, transmitted or retained after a pick. Wayland delegates to its permission portal.
use photocraft_ui_egui::screen_picker::{Capture, MAX_PIXELS, Pending, ScreenImage, Service};
use photocraft_ui_egui::screen_picker::{Capture, Pending, Service};
// Only the platform capture backends (and the tests) build images and check sizes.
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
use photocraft_ui_egui::screen_picker::MAX_PIXELS;
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
use photocraft_ui_egui::screen_picker::ScreenImage;
pub fn service(cc: &eframe::CreationContext<'_>, wayland: bool) -> Option<Service> {
if !cfg!(any(target_os = "linux", target_os = "macos", target_os = "windows")) {
@@ -34,6 +39,7 @@ pub fn service(cc: &eframe::CreationContext<'_>, wayland: bool) -> Option<Servic
}))
}
#[derive(Clone, Copy)]
#[cfg_attr(not(any(target_os = "linux", target_os = "macos", target_os = "windows", test)), allow(dead_code))]
struct Placement {
index: usize,
x: i32,
@@ -41,9 +47,11 @@ struct Placement {
width: u32,
height: u32,
}
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
fn find_monitor(placements: &[Placement], x: i32, y: i32, w: u32, h: u32) -> Option<usize> {
placements.iter().find(|p| p.x == x && p.y == y && p.width == w && p.height == h).map(|p| p.index)
}
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
fn count(width: usize, height: usize) -> Result<usize, String> {
width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_PIXELS).ok_or_else(|| "Screen capture exceeds the 64 MP memory budget".into())
}
+3
View File
@@ -254,3 +254,6 @@ sync_github_name = true
[people.benandez]
real_name = "Martin Ball"
[people.drunkeninja42]
real_name = "Kushagra Sharma"
+67 -22
View File
@@ -191,6 +191,9 @@ struct StrokeCursor {
/// 125,000); smaller ones are not worth the hand-off.
const PARALLEL_DAB_NODES: usize = 16_384;
/// Bound completed output buffers independently of the document's size.
const RENDER_BATCH_TILES: usize = 64;
/// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a
/// drag is many small pushes and its edge stays smooth.
fn dab_spacing(r: f64) -> f64 {
@@ -593,14 +596,11 @@ impl LiquifyField {
}
/// Catmull-Rom (bicubic) displacement at grid coordinates.
fn sample_cubic(&self, gx: f64, gy: f64, wx: &[f64; 4], wy: &[f64; 4]) -> [f64; 2] {
let (ix, iy) = (gx.floor() as isize, gy.floor() as isize);
fn sample_cubic(&self, xs: &[(usize, f64); 4], ys: &[(usize, f64); 4]) -> [f64; 2] {
let mut acc = [0.0f64; 2];
for (jj, wyj) in wy.iter().enumerate() {
let y = (iy - 1 + jj as isize).clamp(0, self.h as isize - 1) as usize;
for &(y, wyj) in ys {
let row = &self.d[y * self.w..(y + 1) * self.w];
for (ii, wxi) in wx.iter().enumerate() {
let x = (ix - 1 + ii as isize).clamp(0, self.w as isize - 1) as usize;
for &(x, wxi) in xs {
let k = wxi * wyj;
acc[0] += f64::from(row[x][0]) * k;
acc[1] += f64::from(row[x][1]) * k;
@@ -660,6 +660,13 @@ fn catmull_rom(t: f64) -> [f64; 4] {
[-0.5 * t3 + t2 - 0.5 * t, 1.5 * t3 - 2.5 * t2 + 1.0, -1.5 * t3 + 2.0 * t2 + 0.5 * t, 0.5 * t3 - 0.5 * t2]
}
/// A field axis's clamped nodes and weights, reusable across a tile's rows or columns.
fn cubic_taps(g: f64, len: usize) -> [(usize, f64); 4] {
let i = g.floor() as isize;
let weights = catmull_rom(g - g.floor());
std::array::from_fn(|k| ((i - 1 + k as isize).clamp(0, len as isize - 1) as usize, weights[k]))
}
/// Resamples `src` through the field: inside `field.bounds` each pixel shows the source at
/// `p + d(p)` (bicubic field, bicubic premultiplied source, edge pixels repeat past the field
/// bounds); everything outside the bounds is kept. Same format as `src`.
@@ -680,19 +687,24 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
let work = |t: &Rect| -> Option<(Rect, Vec<f32>)> {
let w = t.width() as usize;
let h = t.height() as usize;
// Field per pixel (bicubic), rows share their y weights.
// Field per pixel (bicubic), with axis taps shared across rows and columns.
let mut disp = vec![[0.0f64; 2]; w * h];
let (mut fx0, mut fy0, mut fx1, mut fy1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
let mut moved = false;
let columns: Vec<_> = (0..w)
.map(|x| {
let px = f64::from(t.x0) + x as f64 + 0.5;
let gx = (px - f64::from(b.x0) - 0.5) / field.cell;
(px, cubic_taps(gx, field.w))
})
.collect();
for y in 0..h {
let py = f64::from(t.y0) + y as f64 + 0.5;
let gy = (py - f64::from(b.y0) - 0.5) / field.cell;
let wy = catmull_rom(gy - gy.floor());
for x in 0..w {
let px = f64::from(t.x0) + x as f64 + 0.5;
let gx = (px - f64::from(b.x0) - 0.5) / field.cell;
let wx = catmull_rom(gx - gx.floor());
let mut d = field.sample_cubic(gx, gy, &wx, &wy);
let ys = cubic_taps(gy, field.h);
for (x, (px, xs)) in columns.iter().enumerate() {
let px = *px;
let mut d = field.sample_cubic(xs, &ys);
if has_freeze {
let mask = f64::from(field.freeze_at(px, py)).clamp(0.0, 1.0);
if mask > 0.0 {
@@ -774,15 +786,10 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
}
Some((*t, outp))
};
#[cfg(not(target_arch = "wasm32"))]
let done: Vec<(Rect, Vec<f32>)> = {
use rayon::prelude::*;
tiles.par_iter().filter_map(work).collect()
};
#[cfg(target_arch = "wasm32")]
let done: Vec<(Rect, Vec<f32>)> = tiles.iter().filter_map(work).collect();
for (r, v) in done {
out.write_region(r, &v);
for batch in tiles.chunks(RENDER_BATCH_TILES) {
for (r, v) in par::map(batch.len(), true, |i| batch.get(i).and_then(work)).into_iter().flatten() {
out.write_region(r, &v);
}
}
if alpha {
out.prune();
@@ -1318,6 +1325,44 @@ mod tests {
}
}
#[test]
fn a_wide_integer_translation_keeps_channels_and_tile_edges_at_every_format() {
let b = Rect::new(13, -1, 13 + 70 * 256, 1);
let w = b.width() as usize;
for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk] {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let format = PixelFormat::new(mode, depth, true);
let n = format.channels();
let mut data = Vec::new();
for i in 0..w * 2 {
let alpha = if i % 13 == 0 { 0.0 } else { 0.5 };
for c in 0..mode.color_channels() {
data.push(if alpha == 0.0 { 0.0 } else { ((i * 19 + c * 43) % 251 + 2) as f32 / 255.0 });
}
data.push(alpha);
}
let mut source = Surface::new(format);
source.write_region(b, &data);
source.write_pixel(b.x0 - 1, b.y0 - 1, &vec![0.75; n]);
let before = source.read_region(b);
let mut field = LiquifyField::new(b, 2.5);
field.d.fill([-1.0, 0.0]);
let output = apply_liquify(&source, &field);
let after = output.read_region(b);
let tolerance = if depth == SampleType::F32 { 4.0 * f32::EPSILON } else { 0.0 };
for (original, moved) in before.chunks_exact(w * n).zip(after.chunks_exact(w * n)) {
for (x, pixel) in moved.chunks_exact(n).enumerate() {
let offset = x.saturating_sub(1) * n;
for (actual, expected) in pixel.iter().zip(&original[offset..offset + n]) {
assert!((actual - expected).abs() <= tolerance, "{mode:?}/{depth:?} at column {x}");
}
}
}
assert_eq!(output.pixel(b.x0 - 1, b.y0 - 1), source.pixel(b.x0 - 1, b.y0 - 1));
}
}
}
#[test]
fn no_strokes_is_identity_at_every_depth() {
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
@@ -0,0 +1,107 @@
//! Final-render coverage for #7989, extending the boundary fix in #8012.
use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, LiquifyTool, apply_liquify};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
fn bounds() -> Rect {
// Cross the tile boundary at x=256, with a nonzero origin on both axes.
Rect::new(192, -32, 320, 96)
}
fn source(mode: ColorMode, sample: SampleType) -> Surface {
let format = PixelFormat::new(mode, sample, true);
let mut pixels = Vec::with_capacity(128 * 128 * format.channels());
for y in 0..128 {
for x in 0..128 {
let alpha = if (48..52).contains(&x) { 0.0 } else { ((x + 3 * y) % 5 + 1) as f32 / 5.0 };
for channel in 0..mode.color_channels() {
pixels.push(if alpha == 0.0 { 0.0 } else { ((x * 19 + y * 7 + channel * 43) % 251 + 2) as f32 / 255.0 });
}
pixels.push(alpha);
}
}
let mut source = Surface::new(format);
source.write_region(bounds(), &pixels);
source
}
fn warp(points: &[[f64; 2]], size: f64, pressure: f64) -> LiquifyStroke {
let mut stroke = LiquifyStroke::new(LiquifyTool::ForwardWarp, size);
stroke.pressure = pressure;
stroke.points = points.iter().map(|point| point.to_vec()).collect();
stroke
}
fn freeze_left() -> LiquifyStroke {
let mut stroke = LiquifyStroke::new(LiquifyTool::LassoMask, 1.0);
stroke.amount = Some(1.0);
stroke.points = vec![vec![192.0, -32.0], vec![256.0, -32.0], vec![256.0, 96.0], vec![192.0, 96.0]];
stroke
}
fn check_frozen_output(prior_warp: bool) {
for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk] {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let source = source(mode, sample);
for cell in [2.5, 4.0] {
let mut strokes = Vec::new();
if prior_warp {
strokes.push(warp(&[[238.0, 20.0], [261.0, 28.0]], 64.0, 55.0));
}
strokes.push(freeze_left());
let mut field = LiquifyField::from_strokes(bounds(), cell, &strokes);
// #8012 establishes the bilinear mapping when the mask is applied.
// This test checks later edits, not exact pre-mask cubic preservation.
let before = apply_liquify(&source, &field);
assert_eq!(before.format(), source.format());
assert_eq!(before == source, !prior_warp, "the fixture must retain an earlier warp and keep a fresh mask-only operation unchanged");
strokes.push(warp(&[[268.0, 32.0], [252.0, 32.0]], 48.0, 80.0));
field.apply_stroke(&strokes[strokes.len() - 1]);
let after = apply_liquify(&source, &field);
assert_eq!(after.format(), source.format());
let (mut protected, mut fractional_alpha, mut unfrozen_changes) = (0, 0, 0);
let mut frozen_warped = 0;
for y in bounds().y0..bounds().y1 {
for x in bounds().x0..bounds().x1 {
let pixel = before.pixel(x, y);
if field.freeze_at(f64::from(x) + 0.5, f64::from(y) + 0.5) == 1.0 {
protected += 1;
frozen_warped += usize::from(pixel != source.pixel(x, y));
let alpha = pixel[mode.color_channels()];
fractional_alpha += usize::from(alpha > 0.0 && alpha < 1.0);
// An unchanged F32 pixel can round on premultiply/unpremultiply.
// Integer-depth pixels must remain exactly equal.
let tolerance = if sample == SampleType::F32 { 4.0 * f32::EPSILON } else { 0.0 };
for (channel, (actual, expected)) in after.pixel(x, y).iter().zip(&pixel).enumerate() {
assert!(
(actual - expected).abs() <= tolerance,
"{mode:?}/{sample:?}, mesh {cell}, prior warp {prior_warp}: frozen channel {channel} changed at {x},{y}: {expected} -> {actual}"
);
}
} else if field.freeze_at(f64::from(x) + 0.5, f64::from(y) + 0.5) == 0.0 {
unfrozen_changes += usize::from(after.pixel(x, y) != pixel);
}
}
}
assert!(protected > 7_000 && fractional_alpha > 3_000, "the fixture must cover frozen pixels with fractional alpha");
assert_eq!(frozen_warped > 0, prior_warp, "the fixture must retain the earlier warp inside the fully frozen region");
assert!(unfrozen_changes > 0, "the adjacent unfrozen region must still move");
let replay = LiquifyField::from_strokes(bounds(), cell, &strokes);
assert!(field == replay, "stroke replay must reproduce the field exactly");
assert!(apply_liquify(&source, &replay) == after, "stroke replay must reproduce the final pixels exactly");
}
}
}
}
#[test]
fn fresh_frozen_output_preserves_color_and_alpha_across_formats() {
check_frozen_output(false);
}
#[test]
fn existing_frozen_mapping_survives_adjacent_warps_across_formats() {
check_frozen_output(true);
}
+1
View File
@@ -1,5 +1,6 @@
//! The crate's integration tests, linked into one test binary.
mod liquify_freeze_formats;
mod liquify_source_footprint;
mod retouch_blur_exact;
mod warp_clipped;
+8 -2
View File
@@ -193,10 +193,16 @@ impl Headless {
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
};
let mut outputs = workspace.export_sequence(dir)?;
let written = file_cmds::export_layers_with(&this.session, params, |name, bytes| {
let report = file_cmds::export_layers_report_with(&this.session, params, |name, bytes| {
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
});
exported(dir, written, &mut outputs)
let (written, skipped) = match report {
Ok(report) => (Ok(report.files), report.skipped),
Err(error) => (Err(error), Vec::new()),
};
let mut result = exported(dir, written, &mut outputs)?;
result["skipped"] = json!(skipped);
Ok(result)
})
}
+53
View File
@@ -804,6 +804,59 @@ mod tests {
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn doc_export_layers_reports_skips_in_workspace_and_trusted_sessions() {
for trusted in [false, true] {
let base = std::env::temp_dir().join(format!("pc-export-skips-{}-{trusted}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let mut h =
if trusted { Headless::trusted_local() } else { Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&base), Some(&base)).unwrap()) };
h.handle("doc.new", json!({"width": 4, "height": 4, "background": "white", "name": "card"})).unwrap();
let adjustment = h.handle("engine.execute", json!({"command": "layer.newAdjustmentLayer.levels"})).unwrap()["layer"].clone();
h.handle("engine.execute", json!({"command": "layer.setProps", "params": {"name": "Levels"}})).unwrap();
let hidden = h.handle("engine.execute", json!({"command": "layer.new.layer", "params": {"name": "Hidden"}})).unwrap()["layer"].clone();
h.handle("engine.execute", json!({"command": "layer.setProps", "params": {"visible": false}})).unwrap();
// Exercise the actual JSON-lines serve envelope, not only direct dispatch.
let h = Mutex::new(h);
let out_dir = base.join("out").to_string_lossy().into_owned();
let request = json!({"id": 8491, "method": "doc.exportLayers", "params": {"dir": out_dir}}).to_string() + "\n";
let mut output = Vec::new();
serve_lines(&h, request.as_bytes(), &mut output).unwrap();
let reply: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(reply["id"], 8491);
assert_eq!(reply["ok"], true, "{reply}");
assert_eq!(reply["result"]["files"], json!([format!("{out_dir}/card_0001_Background.png")]));
if !trusted {
assert_eq!(reply["result"]["count"], 1);
}
assert_eq!(
reply["result"]["skipped"],
json!([
{"layer": hidden, "name": "Hidden", "reason": "hidden by visibleOnly"},
{"layer": adjustment, "name": "Levels", "reason": "adjustment layer has no pixels of its own"}
])
);
assert_eq!(std::fs::read_dir(base.join("out")).unwrap().count(), 1);
let mut h = h.into_inner().unwrap();
let all_dir = base.join("all").to_string_lossy().into_owned();
let all = h.export_layers(None, &all_dir, &json!({"visibleOnly": false})).unwrap();
assert_eq!(all["files"].as_array().unwrap().len(), 2);
assert_eq!(all["skipped"].as_array().unwrap().len(), 1);
// Workspace no-overwrite errors still fail and remove only newly created files.
if !trusted {
let collision = base.join("collision");
std::fs::create_dir_all(&collision).unwrap();
std::fs::write(collision.join("card_0002_Background.png"), b"keep").unwrap();
assert!(h.export_layers(None, &collision.to_string_lossy(), &json!({"visibleOnly": false})).is_err());
assert_eq!(std::fs::read_dir(&collision).unwrap().count(), 1);
assert_eq!(std::fs::read(collision.join("card_0002_Background.png")).unwrap(), b"keep");
}
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
}
#[test]
fn doc_export_layers_writes_only_beneath_the_write_root() {
let base = std::env::temp_dir().join(format!("pc-doc-export-layers-{}", std::process::id()));
+33 -5
View File
@@ -427,6 +427,14 @@ fn strict_node(v: &mut Value, defs: &serde_json::Map<String, Value>, depth: usiz
}
}
/// Both the bare engine list and older bridge wrappers own their rows already.
fn into_command_rows(mut reply: Value) -> Vec<Value> {
match reply {
Value::Array(rows) => rows,
_ => reply.get_mut("commands").and_then(Value::as_array_mut).map(std::mem::take).unwrap_or_default(),
}
}
fn ok_json(v: &Value) -> CallToolResult {
let text = match json_bytes(v).and_then(|bytes| String::from_utf8(bytes).map_err(|error| AutomationError::Other(error.to_string()))) {
Ok(text) => text,
@@ -678,7 +686,7 @@ impl PhotocraftMcp {
}
#[tool(
description = "Export each exported layer of the document as its own file beneath the write root (the capability-scoped Layers to Files). A file that already exists is an error, not an overwrite."
description = "Export top-level layers as files (groups merged). Returns files and skipped: [{layer, name, reason}] for hidden layers (visible_only) and adjustments with no pixels of their own. Workspace exports resolve beneath the write root, also return count of written files, and reject existing files instead of overwriting them. Trusted-local/bridge exports use the engine command's file access policy."
)]
async fn doc_export_layers(&self, Parameters(p): Parameters<ExportLayersParams>) -> Result<CallToolResult, McpError> {
let mut opts = serde_json::Map::new();
@@ -868,10 +876,7 @@ impl PhotocraftMcp {
};
let needle = p.filter.map(|f| f.to_lowercase());
let enabled_only = p.enabled_only.unwrap_or(false);
let items: Vec<Value> = all
.as_array()
.cloned()
.unwrap_or_else(|| all.get("commands").and_then(Value::as_array).cloned().unwrap_or_default())
let items: Vec<Value> = into_command_rows(all)
.into_iter()
.filter(|c| {
needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n))
@@ -1124,6 +1129,29 @@ pub fn render_document_png(doc: &photocraft_doc::Document, max_side: u32) -> Res
mod tests {
use super::*;
#[test]
fn command_rows_keep_bare_and_wrapped_replies_compatible() {
let rows = json!([{"id":"first","params":{"nested":[1,2]}}, {"id":"second","enabled":false}]);
assert_eq!(into_command_rows(rows.clone()), rows.as_array().unwrap().clone());
assert_eq!(into_command_rows(json!({"commands":rows,"other":"ignored"})), rows.as_array().unwrap().clone());
for reply in [json!(null), json!(false), json!(3), json!("invalid"), json!({}), json!({"commands":null}), json!({"commands":{}}), json!([])] {
assert!(into_command_rows(reply).is_empty());
}
}
#[test]
fn command_rows_reuse_the_owned_array_and_nested_strings() {
for wrapped in [false, true] {
let rows = json!([{"id":"first","params":"documentation".repeat(64)}]);
let array = rows.as_array().unwrap().as_ptr();
let text = rows[0]["params"].as_str().unwrap().as_ptr();
let reply = if wrapped { Value::Object([("commands".into(), rows)].into_iter().collect()) } else { rows };
let moved = into_command_rows(reply);
assert_eq!(moved.as_ptr(), array, "wrapped={wrapped}: move the original allocation");
assert_eq!(moved[0]["params"].as_str().unwrap().as_ptr(), text, "wrapped={wrapped}: retain nested allocations");
}
}
#[test]
fn strict_schema_inlines_refs_and_stops_on_recursive_definitions() {
// A definition that refers to itself: inlining stops at a depth instead of looping.
+37
View File
@@ -799,6 +799,43 @@ async fn doc_exports_and_data_set_import_are_capability_scoped() {
cleanup(&base);
}
/// Skipped-layer details survive the MCP tool result, including the all-skipped case.
#[tokio::test(flavor = "multi_thread")]
async fn doc_export_layers_reports_skipped_layers() {
let base = tmp("skipped-layers");
let client = connect(headless_in(&base)).await;
json_of(&call(&client, "doc_new", json!({"width": 4, "height": 4, "background": "white", "name": "card"})).await);
let background = json_of(&call(&client, "doc_inspect", json!({})).await)["layers"][0]["id"].clone();
let adjustment = json_of(&call(&client, "command_run", json!({"id": "layer.newAdjustmentLayer.levels"})).await)["layer"].clone();
json_of(&call(&client, "command_run", json!({"id": "layer.setProps", "params": {"name": "Levels/α"}})).await);
let hidden = json_of(&call(&client, "command_run", json!({"id": "layer.new.layer", "params": {"name": "Hidden"}})).await)["layer"].clone();
json_of(&call(&client, "command_run", json!({"id": "layer.setProps", "params": {"visible": false}})).await);
let result = json_of(&call(&client, "doc_export_layers", json!({"dir": "layers"})).await);
assert_eq!(result["count"], 1);
assert_eq!(result["files"], json!(["layers/card_0001_Background.png"]));
assert_eq!(
result["skipped"],
json!([
{"layer": hidden, "name": "Hidden", "reason": "hidden by visibleOnly"},
{"layer": adjustment, "name": "Levels/α", "reason": "adjustment layer has no pixels of its own"}
])
);
let all = json_of(&call(&client, "doc_export_layers", json!({"dir": "all", "visible_only": false})).await);
assert_eq!(all["count"], 2);
assert_eq!(all["skipped"].as_array().unwrap().len(), 1);
assert_eq!(std::fs::read_dir(base.join("layers")).unwrap().count(), 1);
assert_eq!(std::fs::read_dir(base.join("all")).unwrap().count(), 2);
// Every top-level layer is still accounted for when none can be exported.
json_of(&call(&client, "command_run", json!({"id": "layer.setProps", "params": {"layer": background, "visible": false}})).await);
let none = json_of(&call(&client, "doc_export_layers", json!({"dir": "none"})).await);
assert_eq!(none["files"], json!([]));
assert_eq!(none["count"], 0);
assert_eq!(none["skipped"].as_array().unwrap().len(), 3);
assert_eq!(std::fs::read_dir(base.join("none")).unwrap().count(), 0);
client.cancel().await.unwrap();
cleanup(&base);
}
/// The template workflow stays in memory, including saving nested contents back to the parent.
#[tokio::test(flavor = "multi_thread")]
async fn embedded_smart_objects_can_be_edited_saved_and_unpacked_without_file_authority() {
+40 -1
View File
@@ -221,7 +221,14 @@ pub fn parse_look(text: &str) -> Result<LutFile, LutError> {
};
let size: usize = tag("size").and_then(|s| s.parse().ok()).ok_or_else(|| LutError("no <size> in .look".into()))?;
let (entries, _) = checked_data_len(size)?;
let hex: Vec<u8> = tag("data").ok_or_else(|| LutError("no <data> in .look".into()))?.bytes().filter(u8::is_ascii_hexdigit).collect();
let hex: Vec<u8> = tag("data").ok_or_else(|| LutError("no <data> in .look".into()))?.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
if hex.iter().any(|b| !b.is_ascii_hexdigit()) {
return err("invalid hexadecimal <data> in .look");
}
// Each float needs eight hex digits; as_chunks would silently discard a partial value.
if !hex.len().is_multiple_of(8) {
return err("incomplete float in .look <data>");
}
let nib = |c: u8| (c as char).to_digit(16).unwrap_or(0) as u8;
let bytes: Vec<u8> = hex.as_chunks::<2>().0.iter().map(|p| nib(p[0]) << 4 | nib(p[1])).collect();
let floats: Vec<f32> = bytes.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
@@ -456,6 +463,38 @@ DOMAIN_MAX 0.5 1 1
assert_eq!(parse("a.look", xml.as_bytes()).unwrap().data, id.data);
}
#[test]
fn look_rgb_and_rgba_allow_whitespace_in_hex_data() {
let id = LutFile::identity(2);
for channels in [3, 4] {
let mut hex = String::new();
for rgb in id.data.as_chunks::<3>().0 {
for value in rgb.iter().copied().chain((channels == 4).then_some(1.0)) {
for byte in value.to_le_bytes() {
hex.push_str(&format!("{byte:02x} \t\r\n"));
}
}
}
let xml = format!("<look><size>2</size><data>\"{hex}\"</data></look>");
assert_eq!(parse("valid.look", xml.as_bytes()).unwrap().data, id.data);
}
}
#[test]
fn look_rejects_partial_floats_and_invalid_hex() {
let id = LutFile::identity(2);
let hex: String = id.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect();
for suffix in ["0", "00", "0000", "000000", "G", "é"] {
let xml = format!("<look><size>2</size><data>{hex}{suffix}</data></look>");
assert!(parse("bad.look", xml.as_bytes()).is_err(), "accepted malformed suffix {suffix:?}");
}
let invalid = hex.replacen('0', "G", 1);
let xml = format!("<look><size>2</size><data>{invalid}</data></look>");
assert!(parse("bad.look", xml.as_bytes()).unwrap_err().0.contains("invalid hexadecimal"));
let xml = format!("<look><size>2</size><data>{hex}00</data></look>");
assert!(parse("bad.look", xml.as_bytes()).unwrap_err().0.contains("incomplete float"));
}
#[test]
fn look_3d_size_exponent_overflow_is_rejected() {
let text = format!("<look><size>{}</size><data>00</data></look>", usize::MAX);
+3
View File
@@ -106,6 +106,9 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
give `DecodeWarning::MoreElements`; the image orientation is applied on import. Writing is
big-endian, one unencoded element, at `EncodeOptions::dpx_bits` (8/10/12/16; by default 8 for
U8 and 16 otherwise), packing 1 for 10 and 12 bits; gray with alpha is written as luma.
`FidelityWarning::BitsReduced` reports a 10- or 12-bit save (and every Cineon save) that drops
precision: always for float samples, for U16 ones unless they are already code values at that
depth.
Other descriptors, signed or run-length-encoded data and other bit depths return
`CodecError::Unsupported` naming the field.
+20
View File
@@ -537,6 +537,26 @@ pub(crate) fn bits_for(opt: Option<DpxBits>, sample: SampleType) -> u8 {
}
}
/// The bits `format` stores below its U16 plan: DPX at 10 or 12 bits, Cineon's 10. `None` when
/// the stored depth is the plan's sample type.
pub(crate) fn packed_bits(format: Format, opts: &EncodeOptions) -> Option<u8> {
match format {
Format::Dpx => opts.dpx_bits.filter(|b| matches!(b, DpxBits::Bits10 | DpxBits::Bits12)).map(DpxBits::bits),
Format::Cineon => Some(10),
_ => None,
}
}
/// `true` when every sample of a U16 image is already a `bits`-bit code value, so writing it at
/// `bits` and reading it back gives the same samples (a frame opened from such a file).
pub(crate) fn keeps_u16_samples(img: &Image, bits: u8) -> bool {
if img.sample_type() != SampleType::U16 || !(1..16).contains(&bits) {
return false;
}
let max = (1u64 << bits) - 1;
img.data().as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes(*c)).all(|v| widen(narrow(v, max), max) == v)
}
/// The plan's sample type for the chosen bits: U8 for 8 bits, U16 for 10, 12 and 16.
pub(crate) fn plan_sample(opt: Option<DpxBits>, src: SampleType) -> SampleType {
match opt {
+32 -4
View File
@@ -128,9 +128,8 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
enc.set_icc_profile(icc.clone());
}
if opts.embed_metadata {
if let Some(exif) = &img.meta.exif {
// The pixels are written as they are shown: never let a viewer rotate them again.
enc.set_exif_metadata(crate::resolution::export_exif(exif, img.meta.dpi).into_owned());
if let Some(exif) = export_exif(&img) {
enc.set_exif_metadata(exif);
}
if let Some(xmp) = &img.meta.xmp {
enc.set_xmp_metadata(crate::resolution::export_xmp(xmp, img.meta.dpi).as_bytes().to_vec());
@@ -162,11 +161,40 @@ fn encode_lossy(img: &Image, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError
None
};
let icc = if opts.embed_icc { img.icc.as_deref() } else { None };
let exif = if opts.embed_metadata { img.meta.exif.as_deref().map(|e| crate::resolution::export_exif(e, img.meta.dpi)) } else { None };
let exif = if opts.embed_metadata { export_exif(img) } else { None };
let xmp = if opts.embed_metadata { img.meta.xmp.as_deref().map(|x| crate::resolution::export_xmp(x, img.meta.dpi)) } else { None };
Ok(riff(w, h, &frame, alpha.as_deref(), icc, exif.as_deref(), xmp.as_deref().map(str::as_bytes)))
}
/// The EXIF to write: the image's, upright (the pixels are written as they are shown, so a viewer
/// must never rotate them again), carrying the resolution. WebP has no resolution field of its own,
/// so the resolution goes into EXIF, added when the image has none (#2399).
pub(crate) fn export_exif(img: &Image) -> Option<Vec<u8>> {
let exif = img.meta.exif.as_deref().unwrap_or_default();
// A WebP without EXIF already reads as 72 ppi, so the default needs no block of its own (and
// the extended container it would bring to every web export).
if exif.is_empty() && img.meta.dpi.is_none_or(is_default_ppi) {
return None;
}
let out = crate::resolution::export_exif_adding_missing_resolution(exif, img.meta.dpi).into_owned();
(!out.is_empty()).then_some(out)
}
/// Whether a WebP of `img` keeps its resolution: the EXIF written carries it. Not when the image's
/// own EXIF can't be parsed: that block is written as it is rather than replaced, so the
/// resolution is dropped (and reported) instead of the metadata.
pub(crate) fn keeps_dpi(img: &Image) -> bool {
let bare_default = img.meta.exif.as_deref().is_none_or(<[u8]>::is_empty) && img.meta.dpi.is_none_or(is_default_ppi);
// The XMP written beside it carries the resolution too, and readers look there first.
let in_xmp = img.meta.xmp.as_deref().is_some_and(|x| crate::resolution::xmp_resolution(&crate::resolution::export_xmp(x, img.meta.dpi)).is_some());
bare_default || in_xmp || export_exif(img).is_some_and(|e| crate::resolution::exif_resolution(&e).is_some())
}
/// 72 ppi each way: what a file without a resolution opens as.
fn is_default_ppi((x, y): (f32, f32)) -> bool {
(x - 72.0).abs() < 0.01 && (y - 72.0).abs() < 0.01
}
/// A RIFF chunk: FourCC, little-endian size, payload, padded to an even length.
fn chunk(out: &mut Vec<u8>, fourcc: &[u8; 4], payload: &[u8]) {
out.extend_from_slice(fourcc);
+17 -3
View File
@@ -4,6 +4,7 @@
use std::fmt;
use crate::codecs::dpx;
use crate::format::{Format, caps};
use crate::image::{ChannelLayout, Image, SampleType};
use crate::options::{DdsCompression, EncodeOptions};
@@ -20,6 +21,12 @@ pub enum FidelityWarning {
from: SampleType,
to: SampleType,
},
/// Samples will be stored with fewer bits than their sample type holds: DPX at 10 or 12
/// bits, Cineon at 10.
BitsReduced {
from: SampleType,
bits: u8,
},
/// Float samples outside `[0, 1]` will be clipped by integer storage.
RangeClipped,
/// Non-opaque alpha will be discarded.
@@ -82,6 +89,7 @@ impl fmt::Display for FidelityWarning {
write!(f, "{} cannot be written in this build", format.name())
}
DepthReduced { from, to } => write!(f, "{} will be reduced to {}", depth_name(*from), depth_name(*to)),
BitsReduced { from, bits } => write!(f, "{} will be reduced to {bits}-bit", depth_name(*from)),
RangeClipped => write!(f, "HDR values outside 0..1 will be clipped"),
AlphaDiscarded => write!(f, "alpha will be discarded"),
AlphaBinarized => write!(f, "alpha will be reduced to on/off transparency"),
@@ -161,7 +169,7 @@ pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan
// The sample type follows the chosen bits (U8 for 8, U16 for 10, 12 and 16).
Format::Dpx => Plan {
layout: if layout == ChannelLayout::GrayA { ChannelLayout::Gray } else { pick_layout(layout, c.layouts, c.alpha) },
sample: crate::codecs::dpx::plan_sample(opts.dpx_bits, sample),
sample: dpx::plan_sample(opts.dpx_bits, sample),
},
// CIELab has no float form: float Lab is written with 16 bits.
Format::Tiff if layout.is_lab() && sample.is_float() => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: SampleType::U16 },
@@ -201,7 +209,13 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
(SampleType::F16, SampleType::F32) => false,
_ => true,
};
if lossy_depth {
// DPX at 10 or 12 bits and Cineon keep fewer bits than their U16 plan: float samples always
// lose precision there, 16-bit ones unless they are already code values at that depth.
if let Some(bits) = dpx::packed_bits(format, opts).filter(|_| ss != SampleType::U8) {
if ss.is_float() || !dpx::keeps_u16_samples(&image.converted(p.layout, SampleType::U16), bits) {
w.push(W::BitsReduced { from: ss, bits });
}
} else if lossy_depth {
w.push(W::DepthReduced { from: ss, to: p.sample });
}
// FITS stores floats as 0..1 (see `codecs::fits`), so out-of-range values are clipped too.
@@ -254,7 +268,7 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
w.push(W::MetadataTooLarge { what: "XMP", bytes: xmp.len() });
}
}
if m.dpi.is_some() && !c.dpi {
if m.dpi.is_some() && (!c.dpi || (format == Format::WebP && !crate::codecs::webp::keeps_dpi(image))) {
w.push(W::DpiDropped);
}
// DPX keeps its own header fields, not free-form text.
+2 -1
View File
@@ -256,7 +256,8 @@ pub fn caps(format: Format) -> FormatCaps {
}
Format::Jpeg => FormatCaps { layouts: &[L::Gray, L::Rgb, L::Cmyk], alpha: false, icc: true, exif: true, xmp: true, dpi: true, lossy: true, ..base },
Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: TIFF_LAYOUTS, icc: true, exif: true, xmp: true, dpi: true, text: true, ..base },
Format::WebP => FormatCaps { icc: true, exif: true, xmp: true, animation: true, ..base },
// Resolution travels in EXIF (WebP has no field of its own).
Format::WebP => FormatCaps { icc: true, exif: true, xmp: true, dpi: true, animation: true, ..base },
Format::Gif => FormatCaps { layouts: &[L::Rgba], animation: true, lossy: true, ..base },
Format::Bmp => base,
Format::Tga => FormatCaps { layouts: RGB_GRAY, ..base },
+43
View File
@@ -323,6 +323,49 @@ fn chosen_bit_depth_sets_the_header_and_packing() {
assert!(fidelity_warnings(&float, Format::Dpx).contains(&FidelityWarning::DepthReduced { from: SampleType::F32, to: SampleType::U16 }));
}
/// The depth warnings of `img` written as `format` with `o`.
fn depth_warnings(img: &Image, format: Format, o: &EncodeOptions) -> Vec<FidelityWarning> {
fidelity_warnings_with(img, format, o)
.into_iter()
.filter(|w| matches!(w, FidelityWarning::DepthReduced { .. } | FidelityWarning::BitsReduced { .. }))
.collect()
}
#[test]
fn saving_sixteen_bits_at_ten_or_twelve_warns_with_the_stored_bits() {
let img = Image::from_u16(5, 2, ChannelLayout::Rgb, &(0..30u16).map(|i| i * 2000 + 1).collect::<Vec<_>>()).unwrap();
for (choice, bits) in [(DpxBits::Bits10, 10), (DpxBits::Bits12, 12)] {
assert_eq!(depth_warnings(&img, Format::Dpx, &opts(Some(choice))), [FidelityWarning::BitsReduced { from: SampleType::U16, bits }]);
assert_eq!(FidelityWarning::BitsReduced { from: SampleType::U16, bits }.to_string(), format!("16-bit will be reduced to {bits}-bit"));
// Float samples name the stored bits too, not the 16-bit words they are packed in.
let float = img.convert(ChannelLayout::Rgb, SampleType::F32);
assert_eq!(depth_warnings(&float, Format::Dpx, &opts(Some(choice))), [FidelityWarning::BitsReduced { from: SampleType::F32, bits }]);
// 8-bit samples widen into 10 and 12 bits without loss.
let eight = img.convert(ChannelLayout::Rgb, SampleType::U8);
assert_eq!(depth_warnings(&eight, Format::Dpx, &opts(Some(choice))), []);
}
assert_eq!(depth_warnings(&img, Format::Dpx, &opts(Some(DpxBits::Bits16))), []);
assert_eq!(depth_warnings(&img, Format::Dpx, &opts(None)), []);
assert_eq!(depth_warnings(&img, Format::Dpx, &opts(Some(DpxBits::Bits8))), [FidelityWarning::DepthReduced { from: SampleType::U16, to: SampleType::U8 }]);
assert_eq!(depth_warnings(&img, Format::Cineon, &EncodeOptions::default()), [FidelityWarning::BitsReduced { from: SampleType::U16, bits: 10 }]);
}
#[test]
fn a_frame_opened_at_ten_or_twelve_bits_saves_back_without_a_warning() {
let img = Image::from_u16(5, 2, ChannelLayout::Rgb, &(0..30u16).map(|i| i * 2000 + 1).collect::<Vec<_>>()).unwrap();
for (choice, bits) in [(DpxBits::Bits10, 10), (DpxBits::Bits12, 12)] {
let opened = decode(&encode(&img, Format::Dpx, &opts(Some(choice))).unwrap()).unwrap();
assert_eq!(depth_warnings(&opened, Format::Dpx, &opts(Some(choice))), [], "{bits}-bit");
assert_eq!(decode(&encode(&opened, Format::Dpx, &opts(Some(choice))).unwrap()).unwrap(), opened, "{bits}-bit");
}
// A 12-bit frame saved at 10 bits does lose its two low bits.
let twelve = decode(&encode(&img, Format::Dpx, &opts(Some(DpxBits::Bits12))).unwrap()).unwrap();
assert_eq!(depth_warnings(&twelve, Format::Dpx, &opts(Some(DpxBits::Bits10))), [FidelityWarning::BitsReduced { from: SampleType::U16, bits: 10 }]);
let cineon = decode(&cineon_bytes(3, 4, 2, &cineon_codes(24))).unwrap();
assert_eq!(depth_warnings(&cineon, Format::Cineon, &EncodeOptions::default()), []);
assert_eq!(depth_warnings(&cineon, Format::Dpx, &opts(Some(DpxBits::Bits10))), []);
}
#[test]
fn gray_with_alpha_is_written_as_luma_with_a_warning() {
let img = Image::from_u8(2, 1, ChannelLayout::GrayA, vec![10, 128, 200, 255]).unwrap();
+15 -3
View File
@@ -99,13 +99,14 @@ fn exif_xmp_dpi_text_dropped_for_qoi() {
assert!(w.contains(&x), "{x:?}");
}
}
/// WebP keeps the resolution in EXIF (#2399); only free-form text has nowhere to go.
#[test]
fn webp_drops_dpi_and_text_only() {
fn webp_drops_text_only() {
let mut img = test_image(ChannelLayout::Rgb, SampleType::U8);
img.meta =
Metadata { exif: Some(sample_exif()), xmp: Some("x".into()), dpi: Some((72.0, 72.0)), text: vec![("a".into(), "b".into())], ..Default::default() };
let w = warns(&img, Format::WebP);
assert_eq!(w, vec![W::DpiDropped, W::TextDropped]);
assert_eq!(w, vec![W::TextDropped]);
}
#[test]
fn float_to_png_reduces_and_clips() {
@@ -180,6 +181,7 @@ fn every_warning_has_a_message() {
let all = [
W::WriteUnsupported { format: Format::Avif },
W::DepthReduced { from: SampleType::F32, to: SampleType::F16 },
W::BitsReduced { from: SampleType::U16, bits: 10 },
W::RangeClipped,
W::AlphaDiscarded,
W::AlphaBinarized,
@@ -209,9 +211,19 @@ fn warnings_match_actual_roundtrip_behaviour() {
let img = test_image(l, s);
let w = warns(&img, f);
let back = decode(&encode(&img, f, &EncodeOptions::default()).unwrap()).unwrap();
let depth_reduced = w.iter().any(|w| matches!(w, W::DepthReduced { .. }));
let depth_reduced = w.iter().any(|w| match w {
W::DepthReduced { .. } => true,
W::BitsReduced { from, .. } => from.is_float(),
_ => false,
});
let lost_depth = back.sample_type() != s && back.sample_type() != SampleType::F32 && s != SampleType::U8;
assert_eq!(depth_reduced, lost_depth, "{f:?} {l:?} {s:?} {w:?} -> {:?}", back.sample_type());
// Cineon stores 10 bits: 16-bit samples come back changed exactly when warned.
if f == Format::Cineon && s == SampleType::U16 && back.sample_type() == s {
let bits_reduced = w.iter().any(|w| matches!(w, W::BitsReduced { .. }));
let changed = back.to_u16_samples() != img.convert(back.layout(), s).to_u16_samples();
assert_eq!(bits_reduced, changed, "{f:?} {l:?} {w:?}");
}
let alpha_lost = l.has_alpha() && !back.layout().has_alpha();
assert_eq!(w.contains(&W::AlphaDiscarded), alpha_lost, "{f:?} {l:?} {s:?}");
let cmyk_lost = l.is_cmyk() && !back.layout().is_cmyk();
+43
View File
@@ -38,6 +38,12 @@ fn check_format(format: Format) {
// TIFF writes the block's directories as its own and rebuilds a block from them on
// reading (tests/it/tiff_exif.rs); IFD0's orientation here is the image's.
assert!(!warnings.contains(&FidelityWarning::ExifDropped), "{format:?}");
} else if c.exif && format == Format::WebP {
// WebP's resolution lives in EXIF, so the block comes back with resolution tags added.
let back_exif = back.meta.exif.as_deref().unwrap_or_else(|| panic!("{format:?} lost exif"));
assert_eq!(photocraft_codecs::resolution::exif_resolution(back_exif).map(|(x, y)| (x.round(), y.round())), Some((300.0, 150.0)));
// The rest of the block survives too: the Orientation tag (0x0112, little-endian).
assert!(back_exif.windows(2).any(|w| w == b"\x12\x01"), "{format:?} lost Orientation");
} else if c.exif {
assert_eq!(back.meta.exif, img.meta.exif, "{format:?} exif");
} else {
@@ -295,3 +301,40 @@ fn metadata_survives_depth_conversion() {
assert_eq!(back.icc, img.icc);
assert_eq!(back.meta.xmp, img.meta.xmp);
}
/// #2399: a WebP export keeps the resolution even when the image has no EXIF (WebP has no
/// resolution field of its own, so a minimal EXIF block carries it), lossless and lossy alike,
/// and no longer warns that it is dropped.
#[test]
fn webp_keeps_dpi_without_source_exif() {
let mut img = test_image(ChannelLayout::Rgb, SampleType::U8);
img.meta = Metadata { dpi: Some((300.0, 150.0)), ..Default::default() };
assert!(!fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped));
for lossless in [true, false] {
let opts = EncodeOptions { webp_lossless: lossless, ..Default::default() };
let back = decode(&encode(&img, Format::WebP, &opts).unwrap()).unwrap();
let (x, y) = back.meta.dpi.unwrap_or_else(|| panic!("lossless {lossless}: dpi lost"));
assert!((x - 300.0).abs() < 0.05 && (y - 150.0).abs() < 0.05, "lossless {lossless}: {x},{y}");
}
// EXIF that can't be parsed is written as it is, so the resolution is reported as dropped.
img.meta = Metadata { exif: Some(b"Exif\0\0garbage".to_vec()), dpi: Some((300.0, 150.0)), ..Default::default() };
assert!(fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped));
let back = decode(&encode(&img, Format::WebP, &EncodeOptions::default()).unwrap()).unwrap();
assert!(back.meta.exif.is_some());
// ...unless the XMP written beside it records the resolution, which readers look at first.
img.meta.xmp = Some(r#"<x:xmpmeta><rdf:Description tiff:XResolution="300/1" tiff:YResolution="150/1" tiff:ResolutionUnit="2"/></x:xmpmeta>"#.into());
assert!(!fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped));
let back = decode(&encode(&img, Format::WebP, &EncodeOptions::default()).unwrap()).unwrap();
let (x, y) = back.meta.dpi.expect("dpi from XMP");
assert!((x - 300.0).abs() < 0.05 && (y - 150.0).abs() < 0.05, "{x},{y}");
// The default 72 ppi needs no EXIF (a WebP without one opens at 72), so a plain export stays
// a simple-format file; nor does an image without a resolution.
img.meta = Metadata { dpi: Some((72.0, 72.0)), ..Default::default() };
assert!(!fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped));
let bytes = encode(&img, Format::WebP, &EncodeOptions { webp_lossless: false, ..Default::default() }).unwrap();
assert_eq!(bytes.get(12..16), Some(&b"VP8 "[..]));
// Without a resolution or EXIF there is still no EXIF chunk.
img.meta = Metadata::default();
let back = decode(&encode(&img, Format::WebP, &EncodeOptions::default()).unwrap()).unwrap();
assert!(back.meta.exif.is_none());
}
+43 -3
View File
@@ -1,7 +1,7 @@
//! Photoshop and Paint.NET blend modes (scalar reference implementation).
//!
//! Formulas follow the public PDF / ISO 32000-2 and W3C Compositing definitions,
//! with Photoshop's variants where they differ (Soft Light, Hard Mix, Darker/Lighter Color,
//! with Photoshop's variants where they differ (Hard Mix, Darker/Lighter Color,
//! Linear Burn/Dodge, Vivid/Linear/Pin Light, Subtract, Divide). This is the CPU reference
//! that the GPU kernels must match.
@@ -323,10 +323,19 @@ fn hard_light(cb: f32, cs: f32) -> f32 {
}
}
/// Photoshop's Soft Light (differs from the W3C/PDF definition in the upper half).
/// Photoshop's Soft Light: the W3C/PDF curve. In the upper half (`cs > 0.5`) the backdrop moves
/// towards `D(cb)`, which is `sqrt(cb)` above a quarter and the cubic `((16·cb − 12)·cb + 4)·cb`
/// below it; with `sqrt` throughout, dark backdrops came out up to 20 levels too light (#8804,
/// measured on psd-tools `blend-modes/rgb-blend-modes.psd`).
#[inline]
fn soft_light_ps(cb: f32, cs: f32) -> f32 {
if cs <= 0.5 { 2.0 * cb * cs + cb * cb * (1.0 - 2.0 * cs) } else { 2.0 * cb * (1.0 - cs) + cb.max(0.0).sqrt() * (2.0 * cs - 1.0) }
if cs <= 0.5 {
2.0 * cb * cs + cb * cb * (1.0 - 2.0 * cs)
} else {
let b = cb.max(0.0);
let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() };
2.0 * cb * (1.0 - cs) + d * (2.0 * cs - 1.0)
}
}
// ---- non-separable helpers (W3C Compositing Level 1) ----
@@ -572,6 +581,37 @@ mod tests {
assert!(close(blend_channel(BlendMode::HardMix, 0.6, 0.3), 0.0));
}
/// #8804: Photoshop's Soft Light over a dark backdrop (cb ≤ 0.25, cs > 0.5) follows the W3C
/// curve `D(cb) = ((16·cb − 12)·cb + 4)·cb`, not `sqrt(cb)`. Measured on psd-tools
/// `blend-modes/rgb-blend-modes.psd` (Photoshop's merged image), in 8-bit levels:
/// (backdrop, source, Photoshop).
#[test]
fn soft_light_follows_photoshop_over_dark_backdrops() {
for (cb, cs, ps) in [(4u8, 247u8, 11.0f32), (6, 254, 19.0), (9, 252, 25.0), (10, 237, 32.0), (14, 253, 46.0)] {
let got = blend_channel(BlendMode::SoftLight, f32::from(cb) / 255.0, f32::from(cs) / 255.0) * 255.0;
assert!((got - ps).abs() <= 7.0, "cb {cb} cs {cs}: {got} vs Photoshop {ps}");
}
}
#[test]
fn soft_light_is_the_w3c_formula() {
let d = |cb: f32| if cb <= 0.25 { ((16.0 * cb - 12.0) * cb + 4.0) * cb } else { cb.sqrt() };
let w3c = |cb: f32, cs: f32| if cs <= 0.5 { cb - (1.0 - 2.0 * cs) * cb * (1.0 - cb) } else { cb + (2.0 * cs - 1.0) * (d(cb) - cb) };
for b in (0..=255u8).step_by(3) {
for s in (0..=255u8).step_by(5) {
let (cb, cs) = (f32::from(b) / 255.0, f32::from(s) / 255.0);
let got = blend_channel(BlendMode::SoftLight, cb, cs);
assert!((got - w3c(cb, cs)).abs() < 1e-5, "cb {b} cs {s}: {got} vs {}", w3c(cb, cs));
}
}
// Continuous where the two halves of D meet (both give 0.5 at cb = 0.25).
let below = blend_channel(BlendMode::SoftLight, 0.25, 1.0);
let above = blend_channel(BlendMode::SoftLight, 0.25 + 1e-6, 1.0);
assert!(close(below, 0.5) && (above - below).abs() < 1e-4, "{below} {above}");
// A negative backdrop (32-bit documents) counts as black under a light source, as before.
assert!(close(blend_channel(BlendMode::SoftLight, -0.1, 1.0), 0.0));
}
#[test]
fn vivid_light_source_extremes_and_interior_values() {
for cb in [0.0, 0.25, 0.5, 0.75, 1.0] {
+62 -9
View File
@@ -77,6 +77,12 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
/// unrounded). Integer depths work on whole levels like Photoshop's (see [`levels_q`] and
/// `adjustment_quantum`), 32-bit documents get its float Levels (see [`levels_float`]).
pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option<SampleType>) {
apply_depth_mode(adj, buf, transfer, depth, ColorMode::Rgb);
}
/// [`apply_depth`] in a document of colour `mode` (which decides whether float curves extend,
/// see [`float_tone_extrapolates`]).
fn apply_depth_mode(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option<SampleType>, mode: ColorMode) {
match adj {
Adjustment::Invert => map_rgb(buf, |c| [1.0 - c[0], 1.0 - c[1], 1.0 - c[2]]),
Adjustment::Threshold { level } => map_rgb(buf, |c| [if threshold_white(c, *level, depth) { 1.0 } else { 0.0 }; 3]),
@@ -143,7 +149,7 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
return;
}
let luts = tone_luts_depth(adj, depth);
apply_tone(buf, *space, &luts, identity);
apply_tone(buf, *space, &luts, identity, float_tone_extrapolates(adj, depth, mode));
}
Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => {
let table = (!*colorize && ranges.iter().any(|r| !r.is_neutral())).then(|| hue_range_tables(ranges));
@@ -198,8 +204,11 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
}
Adjustment::BlackWhite { weights, tint } => {
let hdr = depth == Some(SampleType::F32);
// 32-bit greys keep their superwhites: Photoshop 27.11 maps negatives to 0 and caps
// neutrals at 16, but leaves 2.0 at 2.0 (#8537).
let hi = if hdr { 16.0 } else { 1.0 };
map_rgb(buf, |c| {
let g = black_white_gray(c, weights);
let g = black_white_gray(c, weights).clamp(0.0, hi);
match tint {
// Photoshop tints like the Color blend mode: the tint's hue and saturation at
// the grey's luminosity (SetLum + ClipColor), measured in Photoshop 27.11. In
@@ -335,7 +344,7 @@ pub fn apply_in(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: O
});
}
} else {
apply_depth(adj, buf, transfer, depth);
apply_depth_mode(adj, buf, transfer, depth, mode);
}
}
@@ -563,8 +572,8 @@ fn apply_lab(adj: &Adjustment, buf: &mut Buffer, depth: Option<SampleType>) {
}
}
/// Float RGB curve channels with no edit must preserve samples outside the LUT domain.
/// Edited curves retain the existing spline/LUT behaviour until HDR extrapolation has an oracle.
/// Float RGB curve channels with no edit must preserve samples outside the LUT domain; edited
/// ones extend past it (see [`float_tone_extrapolates`]).
pub fn float_tone_identity(adj: &Adjustment, depth: Option<SampleType>) -> [bool; 3] {
match adj {
Adjustment::Curves { master, per_channel, space: ToneSpace::Rgb, .. } if depth == Some(SampleType::F32) => {
@@ -574,9 +583,18 @@ pub fn float_tone_identity(adj: &Adjustment, depth: Option<SampleType>) -> [bool
}
}
pub(crate) fn apply_tone(buf: &mut Buffer, space: ToneSpace, luts: &[Vec<f32>; 4], identity: [bool; 3]) {
/// Whether an edited Curves adjustment continues past 0..1 instead of clipping: in a 32-bit RGB
/// document Photoshop extends each curve beyond its end points along the slope of its end
/// segments (#7157: `[[0,0],[255,128]]` maps -0.5 to -0.250980407 and 2 to 1.003921628). Only
/// in RGB documents: the curve's space is not the document's, and other modes keep the table.
pub fn float_tone_extrapolates(adj: &Adjustment, depth: Option<SampleType>, mode: ColorMode) -> bool {
matches!(adj, Adjustment::Curves { space: ToneSpace::Rgb, .. }) && depth == Some(SampleType::F32) && mode == ColorMode::Rgb
}
pub(crate) fn apply_tone(buf: &mut Buffer, space: ToneSpace, luts: &[Vec<f32>; 4], identity: [bool; 3], extrapolate: bool) {
let sample = if extrapolate { lut_extended } else { lut };
match space {
ToneSpace::Rgb => map_rgb(buf, |c| std::array::from_fn(|i| if identity[i] { c[i] } else { lut(&luts[i], c[i]) })),
ToneSpace::Rgb => map_rgb(buf, |c| std::array::from_fn(|i| if identity[i] { c[i] } else { sample(&luts[i], c[i]) })),
ToneSpace::Cmyk | ToneSpace::Lab => map_rgb(buf, |c| tone_in_space(space, luts, c)),
}
}
@@ -744,7 +762,8 @@ pub fn range_lightness(c: [f32; 3], l: f32) -> [f32; 3] {
/// Black & White grey value. Each colour is the sum of a grey part (its smallest channel), a
/// secondary-colour part (yellow, cyan or magenta: middle − smallest) and a primary part (red,
/// green or blue: largest − middle); the sliders (percent, Photoshop defaults 40 60 40 60 20 80 for
/// reds, yellows, greens, cyans, blues, magentas) weight the two colour parts.
/// reds, yellows, greens, cyans, blues, magentas) weight the two colour parts. Unclipped: the
/// document's depth decides the range.
pub fn black_white_gray(c: [f32; 3], weights: &[f32; 6]) -> f32 {
let (r, g, b) = (c[0], c[1], c[2]);
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
@@ -764,7 +783,7 @@ pub fn black_white_gray(c: [f32; 3], weights: &[f32; 6]) -> f32 {
} else {
5 // red + blue = magenta
};
(min + (mid - min) * weights[secondary] / 100.0 + (max - mid) * weights[primary] / 100.0).clamp(0.0, 1.0)
min + (mid - min) * weights[secondary] / 100.0 + (max - mid) * weights[primary] / 100.0
}
/// Ordered-dither offset in -0.5..0.5 (4×4 Bayer matrix) for document pixel (x, y).
@@ -992,6 +1011,24 @@ fn lut(table: &[f32], v: f32) -> f32 {
table[i] * (1.0 - f) + table[j] * f
}
/// Table steps the end slopes of [`lut_extended`] are measured over: one step's difference near
/// 0.5 carries f32 rounding (2e-4 of the slope), while 16 steps are still only 0.4 % of the domain.
pub const TONE_SLOPE_SPAN: usize = 16;
/// [`lut`] continued past 0..1 along the slope of the table's end segments, as Photoshop applies
/// a curve to float samples ([`float_tone_extrapolates`]). Inside the domain it is [`lut`];
/// non-finite samples stay with it too, so an infinite one can't become NaN on a flat end.
#[inline]
fn lut_extended(table: &[f32], v: f32) -> f32 {
let n = table.len();
if !v.is_finite() || (0.0..=1.0).contains(&v) || n < 2 {
return lut(table, v);
}
let k = TONE_SLOPE_SPAN.min(n - 1);
let per_unit = (n - 1) as f32 / k as f32;
if v < 0.0 { table[0] + v * (table[k] - table[0]) * per_unit } else { table[n - 1] + (v - 1.0) * (table[n - 1] - table[n - 1 - k]) * per_unit }
}
/// Photo Filter's linear-light RGB matrix. Photoshop multiplies the pixel's D50 XYZ, relative to
/// white, by the filter colour's (mixed in by `density`): a fixed linear map, so it is folded into
/// one matrix. With `normalize_y` the map keeps luminance Y (32-bit Preserve Luminosity; 8/16-bit
@@ -2202,6 +2239,22 @@ mod tone_tests {
assert!(black_white_gray([1.0, 0.0, 0.0], &w) > black_white_gray([1.0, 0.0, 0.0], &d));
}
/// #8537: 32-bit superwhites stay above 1 (Photoshop 27.11: 2.0 stays 2.0, neutrals cap at
/// 16, negatives go to 0); 8-bit still clips at 1.
#[test]
fn black_white_keeps_32_bit_superwhites() {
let adj = Adjustment::BlackWhite { weights: [40.0, 60.0, 40.0, 60.0, 20.0, 80.0], tint: None };
let run = |depth, c: f32| {
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[c, c, c, 1.0]] };
apply_depth(&adj, &mut b, Transfer::Srgb, Some(depth));
b.px[0][0]
};
assert_eq!(run(SampleType::F32, 2.0), 2.0);
assert_eq!(run(SampleType::F32, 20.0), 16.0);
assert_eq!(run(SampleType::F32, -0.5), 0.0);
assert_eq!(run(SampleType::U8, 2.0), 1.0);
}
/// Photoshop 27.11, measured: Black & White (default weights) with Tint over greys. The tint
/// is blended like the Color blend mode: its hue and saturation at the grey's luminosity.
#[test]
+5 -3
View File
@@ -270,10 +270,12 @@ fn interior_effects_blend_over_the_layer_mode_unless_grouped() {
fn interior_effects_blend_with_the_backdrop_through_a_see_through_normal_layer() {
// #6232: #04170F under a Normal layer at fill 0 with a yellow Soft Light colour overlay. The
// overlay soft-lights the dark green it sees through the layer (Photoshop's soft light of
// yellow: sqrt(b) for red and green, b² for blue), not transparency, which would leave plain
// yellow. A half-transparent part of the layer mixes it in at half strength.
// yellow: the W3C D(b) for red and green, which is the cubic ((16b − 12)b + 4)b below a
// quarter (#8804), and b² for blue), not transparency, which would leave plain yellow. A
// half-transparent part of the layer mixes it in at half strength.
let bg: [f32; 3] = [4.0 / 255.0, 23.0 / 255.0, 15.0 / 255.0];
let lit = [bg[0].sqrt(), bg[1].sqrt(), bg[2] * bg[2]];
let dark = |b: f32| ((16.0 * b - 12.0) * b + 4.0) * b;
let lit = [dark(bg[0]), dark(bg[1]), bg[2] * bg[2]];
let half = |c: usize| (bg[c] + lit[c]) / 2.0;
for depth in DEPTHS {
let mut d = doc(depth, false);
+2 -1
View File
@@ -924,7 +924,8 @@ impl<'a> Ctx<'a> {
if identity == [true; 3] {
return;
}
adjust::apply_tone(buf, *space, luts.get_or_init(|| adjust::tone_luts_depth(adj, Some(self.depth))), identity);
let extrapolate = adjust::float_tone_extrapolates(adj, Some(self.depth), self.mode);
adjust::apply_tone(buf, *space, luts.get_or_init(|| adjust::tone_luts_depth(adj, Some(self.depth))), identity, extrapolate);
} else {
adjust::apply_in(adj, buf, self.transfer, Some(self.depth), self.mode);
}
+48 -1
View File
@@ -94,7 +94,7 @@ fn identity_float_curves_match_previous_mapping_bits() {
px: vec![[2.0, -0.0, -0.2, 1.0], [f32::from_bits(0x7fc01234), f32::INFINITY, f32::NEG_INFINITY, 0.6], [0.7, -0.0, 1.5, 0.0], [0.1; 4]],
};
let mut previous = input.clone();
adjust::apply_tone(&mut previous, ToneSpace::Rgb, &adjust::tone_luts_depth(&adj, Some(SampleType::F32)), [true; 3]);
adjust::apply_tone(&mut previous, ToneSpace::Rgb, &adjust::tone_luts_depth(&adj, Some(SampleType::F32)), [true; 3], false);
let mut actual = input.clone();
adjust::apply_depth(&adj, &mut actual, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
assert_buffer_bits(&actual, &previous);
@@ -2822,6 +2822,53 @@ fn exposure_scales_32_bit_samples_as_stored() {
}
}
/// #7157, measured on Photoshop 27.11: an edited Curves in a 32-bit RGB document continues past
/// 0..1 along its end segments instead of clipping. The straight curve `[[0,0],[255,128]]` maps
/// -0.5, 0.5 and 2 to -0.250980407, 0.250980407 and 1.003921628, through Image › Adjustments and
/// through an adjustment layer alike. 8/16-bit documents still clip to the table.
#[test]
fn float_curves_extend_past_the_unit_range_like_photoshop() {
use photocraft_doc::adjust::CurvePoint;
let line = vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 128.0 / 255.0 }];
let ident = vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }];
let adj = Adjustment::Curves { master: line, per_channel: std::array::from_fn(|_| ident.clone()), space: ToneSpace::Rgb, black: Vec::new(), pencil: false };
let input = Buffer { rect: Rect::new(0, 0, 3, 1), px: vec![[-0.5, -0.5, -0.5, 1.0], [0.5, 0.5, 0.5, 1.0], [2.0, 2.0, 2.0, 1.0]] };
// Photoshop's samples, compared in f64 so the reference keeps its printed digits.
let want = [-0.250980407f64, 0.250980407, 1.003921628];
let check = |got: &Buffer, what: &str| {
for (p, w) in got.px.iter().zip(want) {
assert!(p[..3].iter().all(|v| (f64::from(*v) - w).abs() < 2e-5), "{what}: {p:?} vs {w}");
assert_eq!(p[3], 1.0);
}
};
let mut destructive = input.clone();
adjust::apply_depth(&adj, &mut destructive, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
check(&destructive, "Image › Adjustments");
let doc = Document::new("hdr", Size::new(3, 1), ColorMode::Rgb, SampleType::F32);
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
let cx = Ctx::for_doc(&doc, &patterns);
let mut layer = input.clone();
cx.apply_adjustment(&adj, &mut layer);
check(&layer, "adjustment layer");
// Not at 8 or 16 bits: those samples never leave 0..1, and the table's ends hold.
for depth in [SampleType::U8, SampleType::U16] {
let mut clipped = input.clone();
adjust::apply_depth(&adj, &mut clipped, adjust::Transfer::for_document(ColorMode::Rgb, depth), Some(depth));
assert!(clipped.px[2][..3].iter().all(|v| *v <= 128.0 / 255.0 + 1e-4), "{depth:?}: {:?}", clipped.px[2]);
}
// Only in RGB documents: a 32-bit grayscale document keeps the table's end.
let mut gray = input.clone();
adjust::apply_in(&adj, &mut gray, adjust::Transfer::Gamma(1.0), Some(SampleType::F32), ColorMode::Grayscale);
assert!(gray.px[2][..3].iter().all(|v| *v <= 128.0 / 255.0 + 1e-4), "grayscale: {:?}", gray.px[2]);
let mut rgb = input.clone();
adjust::apply_in(&adj, &mut rgb, adjust::Transfer::Gamma(1.0), Some(SampleType::F32), ColorMode::Rgb);
check(&rgb, "apply_in in RGB");
// Infinite samples don't become NaN on the way out.
let mut odd = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[f32::INFINITY, f32::NEG_INFINITY, 0.5, 1.0]] };
adjust::apply_depth(&adj, &mut odd, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
assert!(odd.px[0].iter().all(|v| !v.is_nan()), "{:?}", odd.px[0]);
}
#[test]
fn float_adjustments_preserve_hdr_range() {
let identity = vec![photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 }, photocraft_doc::adjust::CurvePoint { input: 1.0, output: 1.0 }];
+167
View File
@@ -1167,6 +1167,173 @@ mod tests {
}
}
/// #7845: dragging a layer in the Layers panel while recording keeps the drop target by name,
/// so the action plays on another document (whose layer ids differ) instead of failing with
/// "no such layer". The reporter's steps, replayed on a new document.
#[test]
fn recorded_layer_drag_replays_on_another_document() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 16, "height": 16, "depth": depth})).unwrap();
s.execute("actions.record", json!({"name": "Retouch"})).unwrap();
s.execute("layer.stampVisible", json!({})).unwrap();
let layer1 = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layerViaCopy", json!({})).unwrap();
let layer2 = s.active().unwrap().active_layer.unwrap();
s.execute("layer.select", json!({"layer": layer1.0, "mode": "replace"})).unwrap();
s.execute("filter.noise.median", json!({"radius": 6})).unwrap();
s.execute("layer.new.layerViaCopy", json!({})).unwrap();
let copy = s.active().unwrap().active_layer.unwrap();
s.execute("layer.moveTo", json!({"layer": copy.0, "target": layer2.0, "position": "above"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let (id, step) = s.actions.list[0].steps.last().unwrap();
assert_eq!((id.as_str(), step), ("layer.moveTo", &json!({"target": "Layer 1 copy", "position": "above"})));
let names = |s: &Session| s.active().unwrap().doc.layers.iter().rev().map(|l| l.name.clone()).collect::<Vec<_>>();
let recorded = names(&s);
let saved = serde_json::to_string(&s.actions.list).unwrap();
let mut replay = Session::new();
replay.actions.list = serde_json::from_str(&saved).unwrap();
replay.execute("file.new", json!({"width": 16, "height": 16, "depth": depth})).unwrap();
let result = replay.execute("actions.play", json!({"action": 0})).unwrap();
assert!(result.get("failed").is_none(), "{depth}: {result}");
assert_eq!(names(&replay), recorded, "{depth}: the same stack");
}
// Several layers of the drop target's name: the recorded occurrence picks the right one.
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap();
let upper = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({"name": "Mover"})).unwrap();
let mover = s.active().unwrap().active_layer.unwrap();
s.execute("actions.record", json!({"name": "Drop"})).unwrap();
s.execute("layer.moveTo", json!({"layer": mover.0, "target": upper.0, "position": "below"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps[0].1, json!({"target": "Twin", "targetOccurrence": 1, "position": "below"}));
// The moved layer shares the target's name: the occurrence counts the other "Twin"s only,
// so it means the same layer before the move (on replay) as after it (when recorded).
let build = |s: &mut Session| {
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
for _ in 0..3 {
s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap();
}
s.active().unwrap().doc.layers.iter().map(|l| l.id).collect::<Vec<_>>()
};
let mut s = Session::new();
let ids = build(&mut s);
let (lowest, top) = (ids[1], ids[3]);
s.execute("actions.record", json!({"name": "Twins"})).unwrap();
s.execute("layer.moveTo", json!({"layer": top.0, "target": lowest.0, "position": "below"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps[0].1, json!({"target": "Twin", "targetOccurrence": 0, "position": "below"}));
let order = |s: &Session| s.active().unwrap().doc.layers.iter().map(|l| l.id).collect::<Vec<_>>();
let want: Vec<_> = order(&s).iter().map(|id| ids.iter().position(|x| x == id).unwrap()).collect();
let saved = serde_json::to_string(&s.actions.list).unwrap();
let mut replay = Session::new();
replay.actions.list = serde_json::from_str(&saved).unwrap();
let ids2 = build(&mut replay);
let result = replay.execute("actions.play", json!({"action": 0})).unwrap();
assert!(result.get("failed").is_none(), "{result}");
let got: Vec<_> = order(&replay).iter().map(|id| ids2.iter().position(|x| x == id).unwrap()).collect();
assert_eq!(got, want, "the same layer became the lowest");
// A moved group holding a same-named layer: its contents don't count either, or moving the
// group past another "Twin" would shift the occurrence between recording and replay.
let build_group = |s: &mut Session| {
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap();
s.execute("layer.groupLayers", json!({})).unwrap();
let group = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap();
let top = s.active().unwrap().active_layer.unwrap();
s.execute("layer.select", json!({"layer": group.0, "mode": "replace"})).unwrap();
(group, top)
};
let mut g = Session::new();
let (group, top) = build_group(&mut g);
g.execute("actions.record", json!({"name": "Group"})).unwrap();
g.execute("layer.moveTo", json!({"layer": group.0, "target": top.0, "position": "above"})).unwrap();
g.execute("actions.stop", json!({})).unwrap();
let recorded = g.actions.list[0].steps[0].1.clone();
let saved = serde_json::to_string(&g.actions.list).unwrap();
let mut replay = Session::new();
replay.actions.list = serde_json::from_str(&saved).unwrap();
let (group2, _) = build_group(&mut replay);
let result = replay.execute("actions.play", json!({"action": 0})).unwrap();
assert!(result.get("failed").is_none(), "{recorded}: {result}");
let top_layer = |s: &Session| s.active().unwrap().doc.layers.last().map(|l| l.id);
assert_eq!(top_layer(&replay), Some(group2), "{recorded}: the group went to the top");
// Several moved layers named like the target are left out too, so the name is unambiguous.
let mut m = Session::new();
m.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let a = photocraft_doc::LayerId(m.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap());
let b = photocraft_doc::LayerId(m.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap());
let target = photocraft_doc::LayerId(m.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap());
m.execute("layer.moveTo", json!({"layers": [a.0, b.0], "target": "Twin", "position": "above"})).unwrap();
assert_eq!(m.active().unwrap().doc.layers.last().map(|l| l.id), Some(b), "both moved above the third Twin");
assert_eq!(m.active().unwrap().doc.layers.iter().rev().nth(2).map(|l| l.id), Some(target));
// An ⌥-drag copy of the selected layer beside itself replays by name too.
let mut c = Session::new();
c.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let src = photocraft_doc::LayerId(c.execute("layer.new.layer", json!({"name": "Solo"})).unwrap()["layer"].as_u64().unwrap());
c.execute("actions.record", json!({"name": "Copy"})).unwrap();
c.execute("layer.moveTo", json!({"layer": src.0, "target": src.0, "position": "above", "copy": true})).unwrap();
c.execute("actions.stop", json!({})).unwrap();
assert_eq!(c.actions.list[0].steps[0].1, json!({"target": "Solo", "position": "above", "copy": true}));
let saved = serde_json::to_string(&c.actions.list).unwrap();
let mut replay = Session::new();
replay.actions.list = serde_json::from_str(&saved).unwrap();
replay.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
replay.execute("layer.new.layer", json!({"name": "Solo"})).unwrap();
let before = replay.active().unwrap().doc.layers.len();
let result = replay.execute("actions.play", json!({"action": 0})).unwrap();
assert!(result.get("failed").is_none(), "{result}");
assert_eq!(replay.active().unwrap().doc.layers.len(), before + 1, "one copy");
// Copying a group holding a layer named like the target: the new copy's contents (made
// before the step is recorded) don't count, so the occurrence matches the replay.
let build_copy = |s: &mut Session| {
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap();
s.execute("layer.groupLayers", json!({})).unwrap();
let group = s.active().unwrap().active_layer.unwrap();
let a = photocraft_doc::LayerId(s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap());
let b = photocraft_doc::LayerId(s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap());
s.execute("layer.select", json!({"layer": group.0, "mode": "replace"})).unwrap();
(group, a, b)
};
let mut gc = Session::new();
let (group, a, _) = build_copy(&mut gc);
gc.execute("actions.record", json!({"name": "Copy group"})).unwrap();
// Below `a`: the copy's "Twin" then comes before `a` in document order.
gc.execute("layer.moveTo", json!({"layer": group.0, "target": a.0, "position": "below", "copy": true})).unwrap();
gc.execute("actions.stop", json!({})).unwrap();
let saved = serde_json::to_string(&gc.actions.list).unwrap();
let mut replay = Session::new();
replay.actions.list = serde_json::from_str(&saved).unwrap();
let (_, a2, _) = build_copy(&mut replay);
let result = replay.execute("actions.play", json!({"action": 0})).unwrap();
assert!(result.get("failed").is_none(), "{result}");
let layers = &replay.active().unwrap().doc.layers;
let at = layers.iter().position(|l| l.id == a2).unwrap();
let below = at.checked_sub(1).and_then(|i| layers.get(i)).map(|l| l.name.as_str());
assert_eq!(below, Some("Group 1 copy"), "the copy landed below the same Twin");
// A drag of several layers keeps its ids (not covered by name).
s.execute("actions.record", json!({"name": "Several"})).unwrap();
s.execute("layer.moveTo", json!({"layers": [ids[2].0, ids[3].0], "target": ids[1].0, "position": "above"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[1].steps[0].1["target"], json!(ids[1].0));
// Bad targets are errors, not panics.
for bad in [
json!({"target": "Nobody"}),
json!({"target": "Twin"}),
json!({"target": "Twin", "targetOccurrence": 9}),
json!({"target": "Twin", "targetOccurrence": -1}),
json!({"target": true}),
] {
assert!(s.execute("layer.moveTo", bad.clone()).is_err(), "{bad}");
}
}
/// An Alt-drag copy records `inPlace` (#7200), which only matters for an artboard: a plain
/// layer's recorded copy must still export to .atn, while an artboard's must be refused rather
/// than exported as a copy beside the original.
+34 -6
View File
@@ -395,13 +395,11 @@ fn channel_key(d: &Descriptor) -> Option<Option<&'static str>> {
})
}
/// A Levels or Curves entry's channel: `Chnl.Cmps` for the composite, as Photoshop records it.
/// Photoshop silently skips a Set whose composite is `Ordn.Trgt` (#8543).
fn channel_ref(key: Option<&str>) -> D {
let value = key.and_then(|k| lookup_back(CHANNELS, k));
D::Reference(vec![ReferenceItem::Enumerated {
class: class("Chnl"),
type_id: code(if value.is_some() { "Chnl" } else { "Ordn" }),
value: code(value.unwrap_or("Trgt")),
}])
D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code(value.unwrap_or("Cmps")) }])
}
// ------------------------------------------------------------------ field tables
@@ -1688,7 +1686,37 @@ fn set_to(id: &str, p: &Value) -> Option<Descriptor> {
"layer.setAdjustment" => {
// The adjustment kind isn't in the params: Photoshop needs it, so only steps that
// name one (`kind`) export.
let a = adjustment_by_suffix(jstr(p, "kind")?)?;
let kind = jstr(p, "kind")?;
// `layer.setAdjustment` changes only the settings it names. Color Balance and
// Hue/Saturation descriptors carry every setting, filling absent ones with defaults,
// so a partial edit would replay as one that also resets the rest (#8531).
// Present and of the shape the writer reads: anything else it would write as 0.
let number = |p: &Value, k: &str| p.get(k).and_then(Value::as_f64).is_some_and(f64::is_finite);
let numbers = |p: &Value, keys: &[&str]| keys.iter().all(|k| number(p, k));
let flag = |k: &str| p.get(k).is_some_and(Value::is_boolean);
let triplet =
|k: &str| p.get(k).and_then(Value::as_array).is_some_and(|a| a.len() == 3 && a.iter().all(|v| v.as_f64().is_some_and(f64::is_finite)));
let complete = match kind {
"colorBalance" => ["shadows", "midtones", "highlights"].iter().all(|k| triplet(k)) && flag("preserveLuminosity"),
// Every colour range written carries its own hue, saturation and lightness too.
"hueSaturation" => {
flag("colorize")
&& numbers(p, &["hue", "saturation", "lightness"])
&& HUE_RANGES.iter().filter_map(|r| p.get(*r).filter(|v| !v.is_null())).all(|r| {
// Bounds, when given, are the four whole numbers `hue_entry` writes (as integers);
// otherwise it drops or rounds them.
let bounds = r.get("range").filter(|v| !v.is_null()).is_none_or(|v| {
v.as_array().is_some_and(|a| a.len() == 4 && a.iter().all(|b| b.as_f64().is_some_and(|b| b.is_finite() && b.fract() == 0.0)))
});
r.is_object() && numbers(r, &["hue", "saturation", "lightness"]) && bounds
})
}
_ => true,
};
if !complete {
return None;
}
let a = adjustment_by_suffix(kind)?;
desc("null").with("null", target_ref("AdjL")).with("T ", D::Descriptor((a.to)(p)?))
}
_ => return None,
+75
View File
@@ -2172,6 +2172,55 @@ fn options_the_descriptors_cannot_carry_refuse_strict_export() {
}
}
/// #8531: a partial `layer.setAdjustment` of Color Balance or Hue/Saturation can't be written
/// (their descriptors carry every setting, filling absent ones with defaults), so strict export
/// refuses it instead of replaying an edit that also resets the rest. Complete ones still export.
#[test]
fn partial_color_balance_and_hue_saturation_edits_refuse_strict_export() {
let one = |p: Value| Action { name: "Probe".into(), steps: vec![("layer.setAdjustment".into(), p)], ..Default::default() };
for p in [
json!({"kind": "colorBalance", "shadows": [32, -24, 16]}),
json!({"kind": "colorBalance", "preserveLuminosity": true}),
json!({"kind": "hueSaturation", "hue": 20}),
json!({"kind": "hueSaturation", "reds": {"hue": 10}}),
] {
let err = export("Audit8", &[&one(p.clone())], &[], false).unwrap_err();
assert!(err.contains("`layer.setAdjustment`"), "{p}: {err}");
let (_, skipped) = export("Audit8", &[&one(p.clone())], &[], true).unwrap();
assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "layer.setAdjustment".into() }], "{p}");
}
let full = json!({"kind": "colorBalance", "shadows": [32, -24, 16], "midtones": [-17, 27, -31], "highlights": [11, -19, 23], "preserveLuminosity": false});
let (bytes, _) = export("Audit8", &[&one(full)], &[], false).unwrap();
let steps = import(&bytes).unwrap().actions[0].steps.clone();
let (id, p) = steps.first().unwrap();
assert_eq!(id, "layer.setAdjustment");
assert_eq!(p.get("midtones"), Some(&json!([-17, 27, -31])), "{p}");
assert_eq!(p.get("preserveLuminosity"), Some(&json!(false)), "{p}");
let hue = json!({"kind": "hueSaturation", "colorize": false, "hue": 20, "saturation": -5, "lightness": 0});
assert!(export("Audit8", &[&one(hue)], &[], false).is_ok());
// A colour range written without all of its own settings would reset the rest too.
let range = json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 10}});
assert!(export("Audit8", &[&one(range)], &[], false).is_err());
let range =
json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 10, "saturation": 5, "lightness": -5}});
assert!(export("Audit8", &[&one(range)], &[], false).is_ok());
let bounded = json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 10, "saturation": 5, "lightness": -5, "range": [315, 345, 15, 45]}});
assert!(export("Audit8", &[&one(bounded)], &[], false).is_ok());
// Values of the wrong shape would be written as 0: refused too.
for bad in [
json!({"kind": "colorBalance", "shadows": "invalid", "midtones": [0, 0, 0], "highlights": [0, 0, 0], "preserveLuminosity": true}),
json!({"kind": "colorBalance", "shadows": [1, 2], "midtones": [0, 0, 0], "highlights": [0, 0, 0], "preserveLuminosity": true}),
json!({"kind": "colorBalance", "shadows": [0, 0, 0], "midtones": [0, 0, 0], "highlights": [0, 0, 0], "preserveLuminosity": "yes"}),
json!({"kind": "hueSaturation", "colorize": false, "hue": "20", "saturation": 0, "lightness": 0}),
json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": 5}),
json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 1, "saturation": 0, "lightness": 0, "range": [0, 20, 40]}}),
json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 1, "saturation": 0, "lightness": 0, "range": "invalid"}}),
json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 1, "saturation": 0, "lightness": 0, "range": [315, 345.5, 15, 45]}}),
] {
assert!(export("Audit8", &[&one(bad.clone())], &[], false).is_err(), "{bad}");
}
}
/// Photoshop's Close has no document target: a Close of a specific document must not be
/// exported as a plain Close, which would close the active document on playback.
#[test]
@@ -2522,6 +2571,32 @@ fn defaults_photoshop_plays_differently_are_written() {
assert_eq!(get_list(&balance, "ShdL").map(<[D]>::len), Some(3));
}
/// Photoshop records the composite of a Levels or Curves entry as `Chnl.Cmps` and silently skips
/// a Set that names it `Ordn.Trgt` (#8543). A colour channel keeps its own code.
#[test]
fn levels_and_curves_name_the_composite_channel_as_photoshop_records_it() {
let exported = |id: &str, p: Value| export_item(id, &p, "S").and_then(|i| i.descriptor).unwrap_or_else(|| panic!("{id} does not export"));
let channels = |d: &Descriptor| -> Vec<(String, String)> {
get_list(d, "Adjs")
.unwrap_or_default()
.iter()
.filter_map(|e| match e {
D::Descriptor(e) => match get_ref(e, "Chnl")?.first()? {
ReferenceItem::Enumerated { type_id, value, .. } => Some((trimmed(type_id), trimmed(value))),
_ => None,
},
_ => None,
})
.collect()
};
let composite = ("Chnl".to_string(), "Cmps".to_string());
let red = ("Chnl".to_string(), "Rd".to_string());
let levels = exported("image.adjustments.levels", json!({"inBlack": 31, "red": {"inBlack": 2}}));
assert_eq!(channels(&levels), vec![composite.clone(), red.clone()]);
let curves = exported("image.adjustments.curves", json!({"points": [[0, 0], [128, 140], [255, 255]], "red": [[0, 10], [255, 245]]}));
assert_eq!(channels(&curves), vec![composite, red]);
}
/// Photoshop sets one colour per step: a step that sets both doesn't export rather than drop one.
#[test]
fn set_colors_exports_only_a_single_colour() {
+20 -1
View File
@@ -282,6 +282,10 @@ pub(crate) fn color_param(cmd: &str, p: &Value, key: &str, default: [f32; 4]) ->
}
pub(crate) fn parse_hex(s: &str) -> Option<[f32; 4]> {
let s = s.trim_start_matches('#');
// `from_str_radix` accepts a leading '+', so "+f" would read as 0x0f.
if !s.bytes().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let b = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok().map(|v| v as f32 / 255.0);
match s.len() {
6 => Some([b(0)?, b(2)?, b(4)?, 1.0]),
@@ -1136,7 +1140,7 @@ fn build() -> Vec<CommandSpec> {
"Reorder Layer",
[],
None,
r##"{"layer":id?| "layers":[id,…]?, "target":id,"position":"above|below|into"="above","copy":bool=false} (layers: one undoable move, document order preserved; copy: place duplicates there and leave the layers, as ⌥-dragging a Layers panel row)"##,
r##"{"layer":id?| "layers":[id,…]?, "target":id|name,"targetOccurrence":n? (which of several layers named `target`, in document order),"position":"above|below|into"="above","copy":bool=false} (layers: one undoable move, document order preserved; copy: place duplicates there and leave the layers, as ⌥-dragging a Layers panel row; a recorded action keeps the target by name)"##,
has_layer,
crate::layer_multi_cmds::move_to
),
@@ -1844,3 +1848,18 @@ mod set_props_tests {
assert_eq!(layer(&s), (0.0, 1.0));
}
}
#[cfg(test)]
mod parse_hex_tests {
use super::parse_hex;
#[test]
fn signs_and_other_non_hex_characters_are_rejected() {
for bad in ["#+f+f+f", "+f+f+f+f", "#-f0000", "#gg0000", "#0f0f+f"] {
assert_eq!(parse_hex(bad), None, "{bad:?} is not hex digits");
}
let g = 15.0 / 255.0;
assert_eq!(parse_hex("#0f0f0f"), Some([g, g, g, 1.0]));
assert_eq!(parse_hex("#0f0f0fff"), Some([g, g, g, 1.0]));
}
}
+46 -13
View File
@@ -1668,16 +1668,41 @@ fn flatten_all_masks(s: &mut Session) -> Result<Value> {
fn layers_to_files(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.export.layersToFiles";
let dir = str_param(p, "dir", cmd).or_else(|_| str_param(p, "output", cmd))?.to_string();
let names = export_layers_with(s, p, |name, bytes| write_file(&join(&dir, name), bytes))?;
let files: Vec<String> = names.iter().map(|name| join(&dir, name)).collect();
Ok(json!({"files": files}))
let report = export_layers_report_with(s, p, |name, bytes| write_file(&join(&dir, name), bytes))?;
let files: Vec<String> = report.files.iter().map(|name| join(&dir, name)).collect();
Ok(json!({"files": files, "skipped": report.skipped}))
}
/// The bodies of `file.export.layersToFiles`, streamed to `write` as a single-component filename
/// plus its bytes: one file per exported layer (top to bottom, like Photoshop's numbering),
/// encoded but never touched as a path. Automation writes exactly these bytes beneath its write
/// root (#3216), so the two paths cannot drift. Returns the filenames written, in order.
pub fn export_layers_with(s: &Session, p: &Value, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Vec<String>> {
pub fn export_layers_with(s: &Session, p: &Value, write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Vec<String>> {
export_layers_report_with(s, p, write).map(|report| report.files)
}
/// A top-level layer omitted from a successful Layers to Files export.
#[derive(Debug, serde::Serialize)]
pub struct SkippedExportLayer {
/// Session layer id (names need not be unique).
pub layer: u64,
/// Original name, not the sanitized filename component.
pub name: String,
/// Why the layer produced no file.
pub reason: &'static str,
}
/// Filenames written and layers skipped, each in top-to-bottom order.
#[derive(Debug, Default)]
pub struct LayerExportReport {
pub files: Vec<String>,
pub skipped: Vec<SkippedExportLayer>,
}
/// The same export as [`export_layers_with`], with an explanation for every omitted top-level
/// layer. Groups export merged, so their children are not separate exports or skips. Encoding
/// and write failures remain errors, never successful skips. Existing numbering is preserved.
pub fn export_layers_report_with(s: &Session, p: &Value, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<LayerExportReport> {
let cmd = "file.export.layersToFiles";
let format = p.get("format").and_then(Value::as_str).unwrap_or("png").trim_start_matches('.').to_ascii_lowercase();
let visible_only = p.get("visibleOnly").and_then(Value::as_bool).unwrap_or(true);
@@ -1688,24 +1713,32 @@ pub fn export_layers_with(s: &Session, p: &Value, mut write: impl FnMut(&str, &[
let d = s.active().ok_or(EngineError::NoDocument)?;
let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&d.doc.name));
let doc = d.doc.clone();
let mut written = Vec::new();
// Top-level layers (groups export merged), top to bottom like Photoshop's numbering.
let layers: Vec<&Layer> = doc.layers.iter().rev().filter(|l| l.visible || !visible_only).collect();
for (i, l) in layers.iter().enumerate() {
let mut report = LayerExportReport::default();
// Hidden layers never consumed a filename index; adjustments that pass the visibility
// filter did. Keep those filenames stable while reporting both kinds of omission.
let mut i = 0usize;
for l in doc.layers.iter().rev() {
if !l.visible && visible_only {
report.skipped.push(SkippedExportLayer { layer: l.id.0, name: l.name.clone(), reason: "hidden by visibleOnly" });
continue;
}
let index = i;
i += 1;
if matches!(l.content, LayerContent::Adjustment(_)) {
report.skipped.push(SkippedExportLayer { layer: l.id.0, name: l.name.clone(), reason: "adjustment layer has no pixels of its own" });
continue;
}
let mut one = (*doc).clone();
let mut only = (*l).clone();
let mut only = l.clone();
only.visible = true;
only.clipped = false;
one.layers = vec![only];
let name = format!("{}_{:04}_{}.{format}", sanitize(&prefix), i, sanitize(&l.name));
let name = format!("{}_{:04}_{}.{format}", sanitize(&prefix), index, sanitize(&l.name));
let (bytes, _warnings) = encode(&one, &name, save)?;
write(&name, &bytes)?;
written.push(name);
report.files.push(name);
}
Ok(written)
Ok(report)
}
/// Adjustment layers that can be represented as an RGB-only LUT, in document stack order.
@@ -2061,7 +2094,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Guide Layout…",
&["View"],
None,
r##"{"columns":n=0,"width":px?,"gutter":px=0,"rows":n=0,"height":px?,"rowGutter":px=gutter,"margin":px|[top,left,bottom,right]=0,"centerColumns":bool=false,"clearExisting":bool=false,"target":"document|selectedArtboards"="document"}"##,
r##"{"columns":n=0,"width":len?,"gutter":len=0,"rows":n=0,"height":len?,"rowGutter":len=gutter,"margin":len|[top,left,bottom,right]=0,"centerColumns":bool=false,"clearExisting":bool=false,"target":"document|selectedArtboards"="document"} (len: pixels, or text with px, in, cm, mm, pt, pc or % as typed in Photoshop, e.g. "1 cm"; % is of the target's width for columns, gutters and left/right margins, its height for rows and top/bottom margins)"##,
has_doc,
new_guide_layout
),
+107
View File
@@ -1080,6 +1080,69 @@ fn flatten_all_layer_effects_keeps_a_pattern_overlay() {
}
}
#[test]
fn layers_to_files_reports_skipped_layers_without_changing_files() {
for depth in [8, 16, 32] {
let dir = tmp(&format!("skipped-layers-{depth}"));
let mut s = session(4, 4, depth);
let adjustment = s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap()["layer"].clone();
s.execute("layer.setProps", json!({"name": "Levels/α"})).unwrap();
let hidden = s.execute("layer.new.layer", json!({"name": "Levels/α"})).unwrap()["layer"].clone();
s.execute("layer.setProps", json!({"visible": false})).unwrap();
let before = s.active().unwrap().doc.clone();
let result = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": "doc"})).unwrap();
assert_eq!(
result["skipped"],
json!([
{"layer": hidden, "name": "Levels/α", "reason": "hidden by visibleOnly"},
{"layer": adjustment, "name": "Levels/α", "reason": "adjustment layer has no pixels of its own"}
])
);
assert_eq!(result["files"], json!([join(&dir, "doc_0001_Background.png")]));
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
assert_eq!(s.active().unwrap().doc.layers.len(), before.layers.len());
assert!(!s.active().unwrap().doc.layer(LayerId(hidden.as_u64().unwrap())).unwrap().visible);
let all = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": "all", "visibleOnly": false})).unwrap();
assert_eq!(
all["skipped"],
json!([
{"layer": adjustment, "name": "Levels/α", "reason": "adjustment layer has no pixels of its own"}
])
);
assert_eq!(all["files"], json!([join(&dir, "all_0000_Levels_α.png"), join(&dir, "all_0002_Background.png")]));
}
}
#[test]
fn layers_to_files_reports_groups_as_exports_and_failures_as_errors() {
let mut s = session(4, 4, 8);
s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap();
s.execute("layer.new.groupFromLayers", json!({"name": "Pack"})).unwrap();
let params = json!({"prefix": "doc"});
let mut names = Vec::new();
let report = export_layers_report_with(&s, &params, |name, bytes| {
assert!(!bytes.is_empty());
names.push(name.to_string());
Ok(())
})
.unwrap();
assert_eq!(names, vec!["doc_0000_Pack.png", "doc_0001_Background.png"]);
assert_eq!(report.files, names);
assert!(report.skipped.is_empty(), "a group's adjustment is not an omitted top-level layer");
assert_eq!(export_layers_with(&s, &params, |_, _| Ok(())).unwrap(), names, "legacy Rust helper remains compatible");
assert!(export_layers_report_with(&s, &params, |_, _| Err(EngineError::Other("disk full".into()))).unwrap_err().to_string().contains("disk full"));
assert!(export_layers_report_with(&s, &json!({"format": "not-a-format"}), |_, _| panic!("must not write")).is_err());
assert!(export_layers_report_with(&Session::new(), &params, |_, _| panic!("must not write")).is_err());
s.edit("Empty", |doc, _| {
doc.layers.clear();
Ok(())
})
.unwrap();
let empty = export_layers_report_with(&s, &params, |_, _| panic!("must not write")).unwrap();
assert!(empty.files.is_empty());
assert!(empty.skipped.is_empty());
}
#[test]
fn layers_to_files_writes_each_visible_layer() {
let dir = tmp("layers");
@@ -1242,6 +1305,50 @@ fn exported_selected_cube_round_trips_through_lookup_importer() {
assert_eq!(s.active().unwrap().doc.layers.len(), 3);
}
/// #4984: New Guide Layout takes lengths with units as typed in Photoshop's dialog, at the
/// document's resolution; percent is of the target's width or height, by axis.
#[test]
fn guide_layouts_take_units() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 1200, "height": 600, "resolution": 300})).unwrap();
let close = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01);
// 1 cm at 300 ppi is 118.11 px; 0.25 in is 75 px.
s.execute("view.newGuideLayout", json!({"columns": 2, "gutter": "1 cm", "margin": "0.25in"})).unwrap();
let g = &doc(&s).guides;
let col = (1050.0 - 118.110_236) / 2.0;
assert!(close(&g.vertical, &[75.0, 1125.0, 75.0 + col, 75.0 + col + 118.110_236]), "{:?}", g.vertical);
assert!(close(&g.horizontal, &[75.0, 525.0]), "{:?}", g.horizontal);
// Percent of the width for columns, of the height for rows and top/bottom margins; points
// follow Units & Rulers (72 per inch: 36 pt is 150 px, centred like the columns).
s.execute(
"view.newGuideLayout",
json!({"columns": 1, "width": "25%", "centerColumns": true, "rows": 1, "height": "36 pt", "margin": ["10%", 0, "10%", 0], "clearExisting": true}),
)
.unwrap();
let g = &doc(&s).guides;
assert!(close(&g.vertical, &[0.0, 1200.0, 450.0, 750.0]), "{:?}", g.vertical);
assert!(close(&g.horizontal, &[60.0, 540.0, 225.0, 375.0]), "{:?}", g.horizontal);
// With selected artboards, percent is of each board, not of the document.
s.execute("layer.new.artboard", json!({"rect": [200, 100, 140, 40]})).unwrap();
s.execute(
"view.newGuideLayout",
json!({"columns": 1, "width": "50%", "centerColumns": true, "rows": 1, "height": "50%", "target": "selectedArtboards", "clearExisting": true}),
)
.unwrap();
let g = &doc(&s).guides;
// Clear Existing removes guides inside the board only; those outside it stay.
assert!(close(&g.vertical, &[0.0, 1200.0, 450.0, 750.0, 235.0, 305.0]), "{:?}", g.vertical);
assert!(close(&g.horizontal, &[60.0, 540.0, 225.0, 375.0, 110.0, 130.0]), "{:?}", g.horizontal);
for bad in [
json!({"columns": 2, "gutter": "1 furlong"}),
json!({"columns": 2, "gutter": "-1 cm"}),
json!({"columns": 2, "margin": [0, "x", 0, 0]}),
json!({"columns": 2, "gutter": true}),
] {
assert!(s.execute("view.newGuideLayout", bad.clone()).is_err(), "{bad}");
}
}
#[test]
fn guide_layouts() {
let mut s = session(100, 50, 8);
+55 -10
View File
@@ -4,6 +4,7 @@ use photocraft_doc::Guides;
use photocraft_geom::Rect;
use serde_json::Value;
use crate::prefs::Unit;
use crate::{EngineError, Result, Session};
const COMMAND: &str = "view.newGuideLayout";
@@ -14,10 +15,46 @@ fn bad(message: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: COMMAND.into(), msg: message.into() }
}
fn number(p: &Value, key: &str, default: f64) -> Result<f64> {
/// A length as given: a number of pixels, or text with a unit as typed in Photoshop's New Guide
/// Layout fields ("2 cm", "0.25in", "12 pt", "10%"), resolved against each target (#4984).
#[derive(Clone, Copy, Debug, PartialEq)]
enum Length {
Px(f64),
In(f64, Unit),
}
impl Length {
/// Pixels at the document's `dpi`; percent is of `extent` (the target's width for column
/// sizes and left/right margins, its height for row sizes and top/bottom margins).
fn px(self, key: &str, dpi: f64, extent: f64, ppi: f64) -> Result<f64> {
let v = match self {
Length::Px(v) => v,
Length::In(v, unit) => unit.to_px(v, dpi, extent, ppi),
};
if v.is_finite() && v >= 0.0 { Ok(v) } else { Err(bad(format!("{key} must be a finite non-negative length"))) }
}
}
fn length_of(key: &str, v: &Value) -> Result<Length> {
let invalid = || bad(format!("{key} must be a non-negative number of pixels, or text with px, in, cm, mm, pt, pc or %"));
let length = match (v.as_f64(), v.as_str()) {
(Some(n), _) => Length::Px(n),
(None, Some(text)) => match Unit::parse_length(text).ok_or_else(invalid)? {
(n, None | Some(Unit::Pixels)) => Length::Px(n),
(n, Some(unit)) => Length::In(n, unit),
},
_ => return Err(invalid()),
};
match length {
Length::Px(n) | Length::In(n, _) if n.is_finite() && n >= 0.0 => Ok(length),
_ => Err(invalid()),
}
}
fn number(p: &Value, key: &str, default: Length) -> Result<Length> {
match p.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v) => v.as_f64().filter(|n| n.is_finite() && *n >= 0.0).ok_or_else(|| bad(format!("{key} must be a finite non-negative number"))),
Some(v) => length_of(key, v),
}
}
@@ -110,9 +147,10 @@ pub fn calculate(s: &Session, p: &Value) -> Result<Layout> {
let st = s.active().ok_or(EngineError::NoDocument)?;
let cols = count(p, "columns")?;
let rows = count(p, "rows")?;
let width = number(p, "width", 0.0)?;
let height = number(p, "height", 0.0)?;
let gap = number(p, "gutter", 0.0)?;
let zero = Length::Px(0.0);
let width = number(p, "width", zero)?;
let height = number(p, "height", zero)?;
let gap = number(p, "gutter", zero)?;
let row_gap = number(p, "rowGutter", gap)?;
let center = flag(p, "centerColumns")?;
let clear = flag(p, "clearExisting")?;
@@ -121,15 +159,16 @@ pub fn calculate(s: &Session, p: &Value) -> Result<Layout> {
if a.len() != 4 {
return Err(bad("margin must contain top, left, bottom and right"));
}
let mut m = [0.0; 4];
let mut m = [zero; 4];
for (out, value) in m.iter_mut().zip(a) {
*out = value.as_f64().filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| bad("margins must be finite non-negative numbers"))?;
*out = length_of("margin", value)?;
}
m
}
_ => [number(p, "margin", 0.0)?; 4],
_ => [number(p, "margin", zero)?; 4],
};
let [top, left, bottom, right] = margins;
let dpi = f64::from(st.doc.resolution_dpi);
let ppi = s.prefs().units_and_rulers.point_size.per_inch();
let bounds = targets(s, p)?;
// Bound the aggregate work as well as each count, including multi-artboard requests.
if (cols + rows + 4).saturating_mul(2).saturating_mul(bounds.len() as u64) > MAX_EDGES {
@@ -137,16 +176,22 @@ pub fn calculate(s: &Session, p: &Value) -> Result<Layout> {
}
let mut added = Guides::default();
for rect in &bounds {
let (across, down) = (f64::from(rect.width()), f64::from(rect.height()));
let [top, left, bottom, right] =
[(margins[0], down), (margins[1], across), (margins[2], down), (margins[3], across)].map(|(m, extent)| m.px("margin", dpi, extent, ppi));
let (top, left, bottom, right) = (top?, left?, bottom?, right?);
let (x0, y0, x1, y1) = (f64::from(rect.x0) + left, f64::from(rect.y0) + top, f64::from(rect.x1) - right, f64::from(rect.y1) - bottom);
if x0 >= x1 || y0 >= y1 {
return Err(bad("margins must leave space inside the target"));
}
if margins.iter().any(|x| *x != 0.0) {
if [top, left, bottom, right].iter().any(|x| *x != 0.0) {
push_unique(&mut added.vertical, x0)?;
push_unique(&mut added.vertical, x1)?;
push_unique(&mut added.horizontal, y0)?;
push_unique(&mut added.horizontal, y1)?;
}
let (width, gap) = (width.px("width", dpi, across, ppi)?, gap.px("gutter", dpi, across, ppi)?);
let (height, row_gap) = (height.px("height", dpi, down, ppi)?, row_gap.px("rowGutter", dpi, down, ppi)?);
axis(x0, x1, cols, width, gap, center, &mut added.vertical)?;
axis(y0, y1, rows, height, row_gap, center, &mut added.horizontal)?;
}
+85 -1
View File
@@ -118,6 +118,86 @@ pub(crate) fn recorded_selection(s: &Session, mut p: Value) -> Value {
p
}
/// The layers named `name`, in document order, other than the ones being moved and (groups) their
/// contents. Moving layers never changes the others' order, so an occurrence counted among these
/// means the same layer before the move (when an action replays it) and after it (when the step
/// is recorded).
fn same_named_targets(doc: &photocraft_doc::Document, name: &str, moved: &[LayerId]) -> Vec<LayerId> {
fn subtree(l: &Layer, out: &mut Vec<LayerId>, depth: usize) {
out.push(l.id);
if depth < 256
&& let Some(children) = l.children()
{
children.iter().for_each(|c| subtree(c, out, depth + 1));
}
}
let mut skip = Vec::new();
for l in moved.iter().filter_map(|id| doc.layer(*id)) {
subtree(l, &mut skip, 0);
}
doc.walk().into_iter().filter(|(_, _, l)| l.name == name && !skip.contains(&l.id)).map(|(_, _, l)| l.id).collect()
}
/// A Layers-panel drag of the selected layer records its drop target by name (and which of
/// several same-named layers it is), as [`recorded_selection`] does: the id means nothing in the
/// document the action plays in later, so playback failed with "no such layer" (#7845). Drags of
/// several layers, or of a layer that isn't the selected one, keep their ids.
fn recorded_move(s: &Session, mut p: Value, pre_selected: Option<&[LayerId]>) -> Value {
if p.get("layers").is_some() {
return p;
}
let Some(st) = s.active() else { return p };
let moved = match p.get("layer") {
None => st.active_layer,
Some(v) => match v.as_u64().map(LayerId) {
Some(id) if pre_selected.map_or_else(|| st.selected_layers().as_slice() == [id], |sel| sel == [id]) => Some(id),
_ => return p,
},
};
if let Some(fields) = p.as_object_mut() {
fields.remove("layer");
}
let Some(id) = p.get("target").and_then(Value::as_u64).map(LayerId) else { return p };
let Some(layer) = st.doc.layer(id) else { return p };
// A copy leaves its source where it is (it may even land beside it), so the source stays a
// candidate; the new copy (the active layer once the step has run, which is when it is
// recorded) is left out instead, as it doesn't exist yet when the step replays.
let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false);
let skip = if copy { st.active_layer.filter(|a| Some(*a) != moved) } else { moved };
let matches = same_named_targets(&st.doc, &layer.name, skip.as_slice());
let Some(occurrence) = matches.iter().position(|candidate| *candidate == id) else { return p };
if let Some(fields) = p.as_object_mut() {
fields.insert("target".into(), json!(layer.name));
if matches.len() > 1 {
fields.insert("targetOccurrence".into(), json!(occurrence));
}
}
p
}
/// `layer.moveTo`'s drop target: an id, or a name with `targetOccurrence` choosing among layers
/// that share it (in document order, the `moved` layers aside; required when several do).
fn move_target(s: &Session, p: &Value, moved: &[LayerId]) -> Result<LayerId> {
const CMD: &str = "layer.moveTo";
match p.get("target") {
Some(Value::String(name)) => {
let occurrence = p
.get("targetOccurrence")
.map(|v| v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(CMD, "`targetOccurrence` must be a non-negative integer")))
.transpose()?;
let st = s.active().ok_or(EngineError::NoDocument)?;
let matches = same_named_targets(&st.doc, name, moved);
if matches.len() > 1 && occurrence.is_none() {
return Err(bad(CMD, format!("multiple layers named `{name}`; specify `targetOccurrence`")));
}
let index = occurrence.unwrap_or(0);
matches.get(index).copied().ok_or_else(|| bad(CMD, format!("no layer named `{name}` at occurrence {index}")))
}
Some(v) => v.as_u64().map(LayerId).ok_or_else(|| bad(CMD, "`target` must be a layer id or name")),
None => Err(bad(CMD, "missing `target`")),
}
}
/// Normalise parameters when recording an action step.
///
/// For `layer.select`, captures the layer by name. For other commands, if a numeric `layer`
@@ -128,6 +208,9 @@ pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Optio
if id == "layer.select" {
return recorded_selection(s, p);
}
if id == "layer.moveTo" {
return recorded_move(s, p, pre_selected);
}
let Some(layer_id) = p.get("layer").and_then(Value::as_u64).map(LayerId) else { return p };
let is_sole_selected = match pre_selected {
Some(sel) => sel == [layer_id],
@@ -1267,7 +1350,6 @@ fn apply_move(doc: &mut Document, moved_ids: &[LayerId], target: LayerId, pos: &
/// selected nodes are detached, and every reference is validated before editing.
pub fn move_to(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "layer.moveTo";
let target = LayerId(p.get("target").and_then(Value::as_u64).ok_or_else(|| bad(CMD, "missing `target`"))?);
let pos = p.get("position").and_then(Value::as_str).unwrap_or("above");
if !matches!(pos, "above" | "below" | "into") {
return Err(bad(CMD, "position must be above, below, or into"));
@@ -1293,6 +1375,8 @@ pub fn move_to(s: &mut Session, p: &Value) -> Result<Value> {
} else {
vec![crate::commands::layer_param(s, p)?]
};
// A copy moves nothing, so its source stays a candidate (⌥-dragging a layer beside itself).
let target = move_target(s, p, if copy { &[] } else { &requested })?;
let st = s.active().ok_or(EngineError::NoDocument)?;
let doc = &st.doc;
let target_path = doc.path_of(target).ok_or(EngineError::NoLayer(target))?;
+88 -2
View File
@@ -540,14 +540,37 @@ pub fn style_paragraphs(t: &mut TextLayer, a: usize, b: usize, f: &dyn Fn(&mut P
/// Replaces bytes `a..b` with `new`, keeping styles: replacing a selection keeps the style of its
/// first character (as Photoshop does, so retyping a smaller second line stays small); inserting
/// at a caret takes the style of the character before it (or after it at the start).
/// The part of `old` that differs from `new`: its byte range in `old` and the text that replaces
/// it, after the longest common prefix and then the longest common suffix that doesn't overlap it
/// (both on character boundaries).
fn changed_span<'a>(old: &str, new: &'a str) -> (usize, usize, &'a str) {
let prefix = old.char_indices().zip(new.chars()).find(|((_, a), b)| a != b).map_or_else(|| old.len().min(new.len()), |((i, _), _)| i);
let (old_rest, new_rest) = (&old[prefix..], &new[prefix..]);
let suffix: usize = old_rest.chars().rev().zip(new_rest.chars().rev()).take_while(|(a, b)| a == b).map(|(a, _)| a.len_utf8()).sum();
(prefix, old.len() - suffix, &new[prefix..new.len() - suffix])
}
pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) {
fn adjust<S: Clone + PartialEq>(runs: Vec<(usize, S)>, a: usize, b: usize, ins: usize, trailing_paragraph: bool) -> Vec<(usize, S)> {
let mut out: Vec<(usize, S)> = Vec::new();
let mut at = 0;
// Paragraph runs only: the non-empty paragraph that starts exactly at an insertion point.
let starts_here = || {
let mut s = 0;
runs.iter().position(|r| {
let here = s == a && r.0 > 0;
s += r.0;
here
})
};
let host = if trailing_paragraph && a == b && runs.last().is_some_and(|r| r.0 == 0) && a == runs.iter().map(|r| r.0).sum::<usize>() {
// Inserted text belongs to a styled final empty paragraph, not to
// the preceding paragraph whose break ends at this byte offset.
runs.len() - 1
} else if let Some(next) = (trailing_paragraph && a == b && a > 0).then(starts_here).flatten() {
// Text inserted at the start of a paragraph belongs to that paragraph, not to the one
// whose break ends there (#8489).
next
} else {
runs.iter()
.scan(0, |acc, r| {
@@ -965,8 +988,11 @@ pub fn specs() -> Vec<CommandSpec> {
});
}
if let Some(v) = p.get("text").and_then(Value::as_str) {
let n = t.text.len();
replace_text(t, 0, n, v);
// Replace only what changed, so the unchanged text keeps its character and
// paragraph styles (#8489) instead of the whole text taking the first one.
let v = norm_text(v);
let (a, b, new) = changed_span(&t.text, &v);
replace_text(t, a, b, new);
}
if let Some(r) = p.get("replace") {
let a = byte_at(&t.text, r.get("start").and_then(Value::as_u64).unwrap_or(0) as usize);
@@ -1676,6 +1702,66 @@ mod tests {
out
}
/// #8489: `type.edit {"text"}` changes only what differs, so the rest keeps its styles: an
/// added character takes the style beside it, and a change inside one run stays in it.
#[test]
fn edit_text_keeps_the_styles_of_unchanged_text() {
// (characters, colour) per run.
fn runs(s: &Session, id: u64) -> Vec<(usize, [u8; 4])> {
let t = text_layer(s, id);
let mut at = 0;
t.char_runs()
.into_iter()
.map(|r| {
let n = t.text[at..at + r.len].chars().count();
at += r.len;
(n, r.style.color.to_rgba8())
})
.collect()
}
let (dark, purple) = ([0x1b, 0x1f, 0x4b, 255], [0x5a, 0x3d, 0xf0, 255]);
let mut s = session();
let id = s.execute("type.create", json!({"x": 20, "y": 50, "text": "Craft Suite in Workshop", "size": 40, "color": "#1b1f4b"})).unwrap()["layer"]
.as_u64()
.unwrap();
s.execute("type.setStyle", json!({"layer": id, "range": [15, 23], "color": "#5a3df0"})).unwrap();
assert_eq!(runs(&s, id), [(15, dark), (8, purple)]);
s.execute("type.edit", json!({"layer": id, "text": "Craft Suite in Workshop!"})).unwrap();
assert_eq!(runs(&s, id), [(15, dark), (9, purple)], "the added character joins the last run");
s.execute("type.edit", json!({"layer": id, "text": "Craft Suite in Worksh\u{f6}p!"})).unwrap();
assert_eq!(runs(&s, id), [(15, dark), (9, purple)], "a change inside a run stays in it");
s.execute("type.edit", json!({"layer": id, "text": "Kraft Suite in Worksh\u{f6}p!"})).unwrap();
assert_eq!(runs(&s, id), [(15, dark), (9, purple)]);
// Replacing everything leaves one style, as before.
s.execute("type.edit", json!({"layer": id, "text": "Hello"})).unwrap();
assert_eq!(text_layer(&s, id).text, "Hello");
assert_eq!(runs(&s, id).iter().map(|r| r.0).sum::<usize>(), 5);
s.execute("type.edit", json!({"layer": id, "text": ""})).unwrap();
assert_eq!(text_layer(&s, id).text, "");
// Text inserted at the start of a paragraph joins that paragraph, which keeps its style.
let id = s
.execute(
"type.create",
json!({"x": 20, "y": 50, "text": "a
b", "size": 20}),
)
.unwrap()["layer"]
.as_u64()
.unwrap();
s.execute("type.setStyle", json!({"layer": id, "range": [2, 3], "align": "right"})).unwrap();
s.execute(
"type.edit",
json!({"layer": id, "text": "a
Xb"}),
)
.unwrap();
let paragraphs: Vec<(usize, photocraft_doc::text::TextAlign)> =
text_layer(&s, id).paragraph_runs().into_iter().map(|r| (r.len, r.style.align)).collect();
assert_eq!(paragraphs.first().map(|p| p.0), Some(2), "{paragraphs:?}");
assert_eq!(paragraphs.get(1).copied(), Some((2, photocraft_doc::text::TextAlign::Right)), "{paragraphs:?}");
}
/// #206: `type.edit {"kerning"}` over a range takes a number (manual, 1/1000 em) or a mode.
#[test]
fn edit_sets_kerning_over_a_range() {
+32 -2
View File
@@ -189,14 +189,16 @@ impl Affine {
pub fn determinant(&self) -> f64 {
self.m[0] * self.m[3] - self.m[1] * self.m[2]
}
/// Returns `None` for a near-singular matrix or non-finite determinant/inverse coefficients.
pub fn inverse(&self) -> Option<Affine> {
let det = self.determinant();
if det.abs() < 1e-12 {
if !det.is_finite() || det.abs() < 1e-12 {
return None;
}
let [a, b, c, d, e, f] = self.m;
let inv = 1.0 / det;
Some(Affine { m: [d * inv, -b * inv, -c * inv, a * inv, (c * f - d * e) * inv, (b * e - a * f) * inv] })
let inverse = Affine { m: [d * inv, -b * inv, -c * inv, a * inv, (c * f - d * e) * inv, (b * e - a * f) * inv] };
inverse.m.iter().all(|v| v.is_finite()).then_some(inverse)
}
}
@@ -253,6 +255,34 @@ mod tests {
assert!(Affine::scale(0.0).inverse().is_none());
}
#[test]
fn affine_inverse_rejects_non_finite_coefficients() {
for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
for index in 0..6 {
let mut transform = Affine::IDENTITY;
transform.m[index] = value;
assert!(transform.inverse().is_none(), "coefficient {index}: {value}");
}
}
}
#[test]
fn affine_inverse_rejects_determinant_overflow() {
assert!(Affine::scale(f64::MAX).inverse().is_none());
}
#[test]
fn affine_inverse_rejects_translation_overflow() {
let transform = Affine { m: [1.0, 0.0, 1.0, 1.0, -f64::MAX, f64::MAX] };
assert_eq!(transform.determinant(), 1.0);
assert!(transform.inverse().is_none());
}
#[test]
fn affine_inverse_keeps_large_finite_translations() {
assert_eq!(Affine::translate(f64::MAX, -f64::MAX).inverse(), Some(Affine::translate(-f64::MAX, f64::MAX)));
}
#[test]
fn affine_then_order() {
let t = Affine::translate(10.0, 0.0).then(&Affine::scale(2.0));
+82
View File
@@ -0,0 +1,82 @@
//! Styled shape group translation: synchronized frame time and effect-map rebuild counters.
use photocraft_color::{Color, ColorMode, SampleType};
use photocraft_doc::{Document, Fill, Layer, LayerContent, Path, ShapeLayer, Subpath};
use photocraft_geom::{Affine, Size};
use photocraft_gpu::Compositor;
use std::time::Instant;
/// Translate the fixture's paths and raster caches together, as a group drag preview does.
fn moved(base: &Document, offset: i32) -> Document {
let mut doc = base.clone();
if let Some(group) = doc.layers.last_mut().and_then(Layer::children_mut) {
for layer in group {
if let LayerContent::Shape(shape) = &mut layer.content {
shape.path = shape.path.transform(&Affine::translate(f64::from(offset), f64::from(offset)));
if let Some(cache) = &mut shape.cache {
*cache = cache.translated(offset, offset, photocraft_compose::bounds::content_bounds(cache));
}
}
}
}
doc
}
/// Measure synchronized drag frames; quick mode also verifies fresh-render parity.
fn main() -> Result<(), Box<dyn std::error::Error>> {
let quick = std::env::args().any(|a| a == "--quick");
let size = if quick { Size::new(1600, 1200) } else { Size::new(6000, 4000) };
let mut doc = Document::with_background("styled shapes", size, ColorMode::Rgb, SampleType::U8, Color::WHITE);
let mut children = Vec::new();
for i in 0..12 {
let x = 100.1 + f64::from(i % 4) * f64::from(size.width) / 5.0;
let y = 100.2 + f64::from(i / 4) * f64::from(size.height) / 4.0;
let w = f64::from(size.width) / 7.0;
let h = f64::from(size.height) / 6.0;
let mut shape = ShapeLayer {
path: Path::new(vec![Subpath::polygon(&[(x, y), (x + w, y), (x + w, y + h), (x, y + h)])]),
fill: Some(Fill::Solid(Color::rgb(0.2, 0.4, 0.8))),
..Default::default()
};
shape.cache = Some(photocraft_vector::render_shape(&shape, doc.pixel_format(), doc.bounds()));
let mut layer = Layer::new(format!("shape {i}"), LayerContent::Shape(shape));
layer.effects.items.push(photocraft_doc::Effect::default_drop_shadow());
children.push(layer);
}
doc.layers.push(Layer::group("group", children));
let adapter = pollster::block_on(
wgpu::Instance::default()
.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }),
)?;
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?;
println!("adapter={:?} document={}x{}", adapter.get_info().name, size.width, size.height);
let mut comp = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter))?;
let mut samples = Vec::new();
for frame in 0..8 {
let start = Instant::now();
let current = moved(&doc, frame * 3);
let preview_ms = start.elapsed().as_secs_f64() * 1000.0;
let stats = comp.render(&device, &queue, &current, current.bounds(), |_, _| {})?;
device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None })?;
let ms = start.elapsed().as_secs_f64() * 1000.0;
println!(
"frame={frame} ms={ms:.3} preview_ms={preview_ms:.3} fx_shapes={} fx_programs={} fx_pixels={}",
stats.fx_shapes, stats.fx_programs, stats.fx_pixels
);
if frame > 1 {
samples.push(ms);
}
}
samples.sort_by(f64::total_cmp);
println!("warm_median_ms={:.3}", samples.get(samples.len() / 2).copied().unwrap_or_default());
if quick {
let current = moved(&doc, 21);
let warm = photocraft_gpu::render_to_vec(&mut comp, &device, &queue, &current, current.bounds())?;
let mut fresh = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter))?;
let expected = photocraft_gpu::render_to_vec(&mut fresh, &device, &queue, &current, current.bounds())?;
let difference = warm.iter().zip(&expected).flat_map(|(a, b)| a.iter().zip(b).map(|(a, b)| (a - b).abs())).fold(0.0f32, f32::max);
println!("fresh_render_max_difference={difference}");
if difference >= 1.0 / 255.0 {
return Err("reused effect maps differ from fresh rendering".into());
}
}
Ok(())
}
+23 -4
View File
@@ -139,9 +139,13 @@ fn hard_light(cb: f32, cs: f32) -> f32 {
let s = 2.0 * cs - 1.0;
return cb + s - cb * s;
}
// The W3C curve, as Photoshop: D(cb) is the cubic below a quarter, sqrt above it (#8804).
fn soft_light_ps(cb: f32, cs: f32) -> f32 {
if (cs <= 0.5) { return 2.0 * cb * cs + cb * cb * (1.0 - 2.0 * cs); }
return 2.0 * cb * (1.0 - cs) + sqrt(max(cb, 0.0)) * (2.0 * cs - 1.0);
let b = max(cb, 0.0);
var d = sqrt(b);
if (b <= 0.25) { d = ((16.0 * b - 12.0) * b + 4.0) * b; }
return 2.0 * cb * (1.0 - cs) + d * (2.0 * cs - 1.0);
}
fn vivid_light_ps(cb: f32, cs: f32) -> f32 {
if (cs <= 0.5) {
@@ -612,8 +616,22 @@ fn levels_float(record: i32, v: f32) -> f32 {
return lut_at(base + 3) + mapped * (lut_at(base + 4) - lut_at(base + 3));
}
fn tone_channel(row: i32, v: f32, identity: f32) -> f32 {
fn tone_channel(row: i32, v: f32, identity: f32, extrapolate: f32) -> f32 {
if (identity > 0.5) { return v; }
// Mirrors compose::adjust::lut_extended: past 0..1 along the end segments' slopes, measured
// over TONE_SLOPE_SPAN (16) table steps.
if (extrapolate > 0.5 && abs(v) <= 3.0e38) {
if (v < 0.0) {
let a = textureLoad(lut_tex, vec2(0, row), 0).r;
let b = textureLoad(lut_tex, vec2(16, row), 0).r;
return a + v * (b - a) * (4095.0 / 16.0);
}
if (v > 1.0) {
let a = textureLoad(lut_tex, vec2(4079, row), 0).r;
let b = textureLoad(lut_tex, vec2(4095, row), 0).r;
return b + (v - 1.0) * (b - a) * (4095.0 / 16.0);
}
}
return lut(row, v);
}
@@ -647,7 +665,7 @@ fn adjust(c: vec3<f32>) -> vec3<f32> {
return clamp(v, vec3(0.0), vec3(1.0));
}
case 6: { return vec3(exposure(c.r, p0), exposure(c.g, p0), exposure(c.b, p0)); } // Exposure
case 7: { return vec3(tone_channel(0, c.r, p0.x), tone_channel(1, c.g, p0.y), tone_channel(2, c.b, p0.z)); } // Levels / Curves / modern Brightness/Contrast (compose::adjust::modern_lut)
case 7: { return vec3(tone_channel(0, c.r, p0.x, p0.w), tone_channel(1, c.g, p0.y, p0.w), tone_channel(2, c.b, p0.z, p0.w)); } // Levels / Curves / modern Brightness/Contrast (compose::adjust::modern_lut)
case 17: { return vec3(levels_float(0, levels_float(1, c.r)), levels_float(0, levels_float(2, c.g)), levels_float(0, levels_float(3, c.b))); }
case 8: { // Hue/Saturation
let hsl = rgb_to_hsl(c);
@@ -747,7 +765,8 @@ fn adjust(c: vec3<f32>) -> vec3<f32> {
if (c.r >= c.g && c.r >= c.b) { primary = 0; } else if (c.g >= c.b) { primary = 2; }
var secondary = 5;
if (c.b <= c.r && c.b <= c.g) { secondary = 1; } else if (c.r <= c.g) { secondary = 3; }
let g = clamp(mn + (mid - mn) * w[secondary] / 100.0 + (mx - mid) * w[primary] / 100.0, 0.0, 1.0);
// 32-bit: superwhites stay, up to Photoshop's 16
let g = clamp(mn + (mid - mn) * w[secondary] / 100.0 + (mx - mid) * w[primary] / 100.0, 0.0, select(1.0, 16.0, hdr));
if (p1.z > 0.5) {
if (hdr) { return set_lum(p2.rgb, g, F32_MAX); } // 32-bit: nothing clips at 1
return clamp(set_lum(p2.rgb, g, 1.0), vec3(0.0), vec3(1.0)); // Color blend of the tint
+53 -1
View File
@@ -664,7 +664,23 @@ pub(crate) fn shape_key(layer: &Layer, canvas: Rect) -> u64 {
LayerContent::Shape(sh) => {
std::mem::discriminant(&layer.content).hash(&mut h);
if photocraft_compose::effect_outline(layer).is_some() {
format!("{:?}", sh.path).hash(&mut h);
// The cached maps live in region-local coordinates. Preserve their identity
// under translation so sync_fx can verify coverage and reuse them after a move.
let mut outline =
sh.path.control_bounds().map_or_else(|| sh.path.clone(), |(x, y, _, _)| sh.path.transform(&photocraft_geom::Affine::translate(-x, -y)));
// Integer translation can perturb fractional f64 coordinates in the low bits.
// Quantize only the fingerprint (1/65536 px); rendering keeps the original path,
// and sync_fx still requires exact coverage equality before reusing moved maps.
for subpath in &mut outline.subpaths {
for knot in &mut subpath.knots {
for point in [&mut knot.anchor, &mut knot.in_ctrl, &mut knot.out_ctrl] {
// Adding zero also canonicalizes negative zero in Debug output.
point.x = (point.x * 65536.0).round() / 65536.0 + 0.0;
point.y = (point.y * 65536.0).round() / 65536.0 + 0.0;
}
}
}
format!("{outline:?}").hash(&mut h);
}
}
other => std::mem::discriminant(other).hash(&mut h),
@@ -746,6 +762,42 @@ mod tests {
use photocraft_color::BlendMode;
use photocraft_doc::FxCommon;
/// Filled-shape identity survives integer moves, including fractional anchors and handles.
#[test]
fn filled_shape_key_preserves_translation_but_detects_deformation() {
use photocraft_doc::{Fill, Path, ShapeLayer, Subpath};
let mut layer = Layer::new(
"shape",
LayerContent::Shape(ShapeLayer {
path: Path::new(vec![Subpath::polygon(&[(20.0, 20.0), (70.0, 20.0), (70.0, 60.0), (20.0, 60.0)])]),
fill: Some(Fill::Solid(photocraft_color::Color::WHITE)),
..Default::default()
}),
);
let canvas = Rect::new(0, 0, 200, 200);
let before = shape_key(&layer, canvas);
let LayerContent::Shape(shape) = &mut layer.content else { unreachable!() };
shape.path = shape.path.transform(&photocraft_geom::Affine::translate(13.0, 7.0));
assert_eq!(before, shape_key(&layer, canvas));
let LayerContent::Shape(shape) = &mut layer.content else { unreachable!() };
shape.path.subpaths[0].knots[1].anchor.x += 3.0;
assert_ne!(before, shape_key(&layer, canvas));
let LayerContent::Shape(shape) = &mut layer.content else { unreachable!() };
shape.path = Path::new(vec![Subpath::polygon(&[(0.1, 0.2), (0.3, 0.2), (0.3, 1.7), (0.1, 1.7)])]);
shape.path.subpaths[0].knots[1].out_ctrl = photocraft_geom::Point::new(0.45, 0.65);
let original = shape.path.clone();
let before = shape_key(&layer, canvas);
for (dx, dy) in [(1.0, 1.0), (-13.0, 7.0), (100_000.0, -100_000.0)] {
let LayerContent::Shape(shape) = &mut layer.content else { unreachable!() };
shape.path = original.transform(&photocraft_geom::Affine::translate(dx, dy));
assert_eq!(before, shape_key(&layer, canvas), "fractional path translated by {dx}, {dy}");
}
let LayerContent::Shape(shape) = &mut layer.content else { unreachable!() };
shape.path.subpaths[0].knots[1].out_ctrl.x += 0.01;
assert_ne!(before, shape_key(&layer, canvas), "a handle deformation invalidates the key");
}
#[test]
fn programs_key_on_geometry_not_colour() {
let light = GlobalLight::default();
+50 -11
View File
@@ -30,6 +30,14 @@ impl Fault {
matches!(self, Fault::Lost(_))
}
/// An out-of-memory error on a device that still works: freeing memory and trying again can
/// succeed, unlike after a loss or a validation error (#6564). Only wgpu's own out-of-memory
/// error is recorded with exactly [`OUT_OF_MEMORY`]; a validation error that merely mentions
/// memory is not one.
pub fn is_out_of_memory(&self) -> bool {
matches!(self, Fault::Error(s) if s == OUT_OF_MEMORY)
}
/// The driver's or wgpu's message.
pub fn detail(&self) -> &str {
match self {
@@ -48,6 +56,9 @@ impl fmt::Display for Fault {
}
}
/// The message an out-of-memory [`Fault::Error`] carries (wgpu's `Error::OutOfMemory`).
pub const OUT_OF_MEMORY: &str = "Out of Memory";
#[derive(Default)]
struct Inner {
faulted: AtomicBool,
@@ -99,7 +110,7 @@ impl DeviceHealth {
self.0.errors.fetch_add(1, Ordering::Relaxed);
let text = e.to_string();
log::error!("uncaptured wgpu error: {text}");
self.mark(fault_of(&text));
self.mark(fault_of(e));
}
/// Record an error an error scope caught.
@@ -114,26 +125,35 @@ impl DeviceHealth {
/// until every in-flight submission drained (or forever, if the device is wedged). A
/// non-blocking poll loses nothing here: wgpu still reads the fence and fires the
/// device-lost callback for a lost device, it just never blocks waiting for one.
///
/// A device that only recorded an error is still polled and marked, so a loss or an
/// out-of-memory error can upgrade that fault as [`DeviceHealth::mark`] allows (#6564).
pub fn caught(&self, device: &wgpu::Device, error: &wgpu::Error) {
if !self.is_ok() {
if self.lost() {
return;
}
#[cfg(not(target_arch = "wasm32"))]
self.poll(device);
#[cfg(target_arch = "wasm32")]
let _ = device;
if !self.is_ok() {
if self.lost() {
return;
}
self.mark(fault_of(&error.to_string()));
self.mark(fault_of(error));
}
/// Mark the device unusable. The first fault is kept; a later device loss upgrades an error.
fn lost(&self) -> bool {
self.fault().is_some_and(|f| f.is_lost())
}
/// Mark the device unusable. The first fault is kept; a later device loss upgrades an error,
/// and a later out-of-memory error upgrades another one, so the canvas can still recover by
/// itself when memory ran out after a validation error in the same frame (#6564).
pub fn mark(&self, fault: Fault) {
let mut slot = self.0.fault.lock().unwrap_or_else(PoisonError::into_inner);
let replace = match slot.as_ref() {
None => true,
Some(old) => !old.is_lost() && fault.is_lost(),
Some(old) => !old.is_lost() && (fault.is_lost() || (fault.is_out_of_memory() && !old.is_out_of_memory())),
};
if replace {
*slot = Some(fault);
@@ -214,12 +234,19 @@ impl DeviceHealth {
}
}
/// The fault a wgpu error message stands for. Errors about invalid or lost objects follow a
/// device loss whose callback may not have run yet (it fires on the next poll), so they count as
/// a loss rather than as an error.
fn fault_of(text: &str) -> Fault {
/// The fault a wgpu error stands for. Errors about invalid or lost objects follow a device loss
/// whose callback may not have run yet (it fires on the next poll), so they count as a loss
/// rather than as an error. Only wgpu's own out-of-memory error is [`OUT_OF_MEMORY`].
fn fault_of(e: &wgpu::Error) -> Fault {
let text = e.to_string();
let lower = text.to_ascii_lowercase();
if lower.contains("lost") || lower.contains("destroyed") { Fault::Lost(first_line(text)) } else { Fault::Error(first_line(text)) }
if matches!(e, wgpu::Error::OutOfMemory { .. }) {
Fault::Error(OUT_OF_MEMORY.into())
} else if lower.contains("lost") || lower.contains("destroyed") {
Fault::Lost(first_line(&text))
} else {
Fault::Error(first_line(&text))
}
}
fn first_line(s: &str) -> String {
@@ -259,5 +286,17 @@ mod tests {
assert_eq!(Fault::Error("x".into()).to_string(), "GPU error: x");
assert_eq!(first_line("\n Buffer 'pc' is invalid\nmore"), "Buffer 'pc' is invalid");
assert_eq!(first_line(&"a".repeat(300)).chars().count(), 201);
assert!(Fault::Error("Out of Memory".into()).is_out_of_memory());
assert!(!Fault::Error("Validation Error".into()).is_out_of_memory());
assert!(!Fault::Error("Validation Error: buffer allocation would run out of memory".into()).is_out_of_memory());
// A later out-of-memory error replaces another error, but never a loss.
let h = DeviceHealth::new();
h.mark(Fault::Error("Validation Error".into()));
h.mark(Fault::Error(OUT_OF_MEMORY.into()));
assert!(h.fault().is_some_and(|f| f.is_out_of_memory()));
h.mark(Fault::Lost("reset".into()));
h.mark(Fault::Error(OUT_OF_MEMORY.into()));
assert!(h.fault().is_some_and(|f| f.is_lost()));
assert!(!Fault::Lost("out of memory".into()).is_out_of_memory());
}
}
+5 -3
View File
@@ -1027,7 +1027,7 @@ impl<'a> Planner<'a> {
}
let mut p = Pass::new(Kernel::Adjust, 0);
p.a = Some(src);
let (kind, params, lut) = adjustment_program(adj, self.cx.transfer, self.cx.depth);
let (kind, params, lut) = adjustment_program(adj, self.cx.transfer, self.cx.depth, self.cx.mode);
p.adjust_kind = kind;
p.params = params;
p.lut = lut;
@@ -1656,7 +1656,7 @@ fn transfer_exponent(t: Transfer) -> f32 {
}
/// Adjustment → (kernel kind, parameters, LUT rows). Kinds are the `switch` in `adjust()`.
pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraft_color::SampleType) -> Program {
pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraft_color::SampleType, mode: photocraft_color::ColorMode) -> Program {
let mut p = [[0.0f32; 4]; 4];
match adj {
Adjustment::Invert => (1, p, None),
@@ -1711,6 +1711,8 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf
for (v, identity) in p[0].iter_mut().zip(adjust::float_tone_identity(adj, Some(depth))) {
*v = if identity { 1.0 } else { 0.0 };
}
// p0.w: continue the curve past 0..1 (32-bit RGB Curves, #7157).
p[0][3] = if adjust::float_tone_extrapolates(adj, Some(depth), mode) { 1.0 } else { 0.0 };
(7, p, Some(adjust::tone_luts_depth(adj, Some(depth)).iter().take(3).map(|t| to_row(t)).collect()))
}
Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => {
@@ -1996,7 +1998,7 @@ mod tests {
fn color_lookup_with_an_overflowing_stored_size_uploads_no_table() {
for size in [1 << 22, u32::MAX] {
let a = Adjustment::ColorLookup { name: "x".into(), lut: Some(std::sync::Arc::new(vec![0.5; 24])), size, tetrahedral: false, dither: false };
let (kind, _, rows) = adjustment_program(&a, Transfer::Srgb, SampleType::U8);
let (kind, _, rows) = adjustment_program(&a, Transfer::Srgb, SampleType::U8, photocraft_color::ColorMode::Rgb);
assert_eq!((kind, rows.is_none()), (0, true), "size {size}");
}
}
+9
View File
@@ -57,6 +57,14 @@ fn hdr_adjustments_preserve_range_and_cpu_parity() {
black: Vec::new(),
pencil: false,
},
// #7157: an edited curve continues past 0..1 along its end segments (Photoshop's `[[0,0],[255,128]]`).
Adjustment::Curves {
master: vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 128.0 / 255.0 }],
per_channel: std::array::from_fn(|_| identity.clone()),
space: Default::default(),
black: Vec::new(),
pencil: false,
},
// 32-bit Photoshop rules: unclipped, legacy pivot 127, the mixer's 1/1024 steps and halved constant.
Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy: true },
Adjustment::BrightnessContrast { brightness: -20.0, contrast: -30.0, legacy: true },
@@ -65,6 +73,7 @@ fn hdr_adjustments_preserve_range_and_cpu_parity() {
Adjustment::PhotoFilter { color: [0.97, 0.76, 0.0], density: 0.4, preserve_luminosity: true },
Adjustment::PhotoFilter { color: [0.2, 0.6, 0.9], density: 0.3, preserve_luminosity: false },
Adjustment::BlackWhite { weights: [70.0, 20.0, 50.0, 10.0, 90.0, 30.0], tint: Some([0.9, 0.7, 0.5]) },
Adjustment::BlackWhite { weights: [40.0, 60.0, 40.0, 60.0, 20.0, 80.0], tint: None },
] {
let mut d = Document::new("HDR adjustments", Size::new(3, 1), ColorMode::Rgb, SampleType::F32);
let mut layer = Layer::raster("pixels", PixelFormat::RGBA32F);
+4 -7
View File
@@ -77,9 +77,9 @@ pub(crate) fn krita_blend(id: &str) -> Option<(BlendMode, bool)> {
"dodge" => (B::ColorDodge, false),
"burn" => (B::ColorBurn, false),
"hard_light" => (B::HardLight, false),
"soft_light" => (B::SoftLight, false),
// The W3C soft light curve, close to but not Photoshop's.
"soft_light_svg" => (B::SoftLight, true),
// Krita's own Soft Light keeps sqrt(cb) over dark backdrops; Photoshop's is the W3C curve.
"soft_light" => (B::SoftLight, true),
"soft_light_svg" => (B::SoftLight, false),
"diff" => (B::Difference, false),
"exclusion" => (B::Exclusion, false),
"linear_burn" => (B::LinearBurn, false),
@@ -124,7 +124,7 @@ pub(crate) fn krita_id(mode: BlendMode) -> Option<&'static str> {
B::LinearDodge => "linear_dodge",
B::LighterColor => "lighter color",
B::Overlay => "overlay",
B::SoftLight => "soft_light",
B::SoftLight => "soft_light_svg",
B::HardLight => "hard_light",
B::VividLight => "vivid_light",
B::LinearLight => "linear light",
@@ -167,9 +167,6 @@ const SVG_OPS: [(&str, BlendMode); 15] = [
/// A composite-op attribute as a blend mode; unknown names fall back to Normal with a warning.
fn composite_op(op: &str, layer: &str, warnings: &mut Warnings) -> BlendMode {
if let Some((_, mode)) = SVG_OPS.iter().find(|(name, _)| *name == op) {
if op == "svg:soft-light" {
warnings.note("svg:soft-light (the W3C curve) opens as Photoshop's Soft Light, which differs slightly");
}
return *mode;
}
match op {
+21 -1
View File
@@ -327,9 +327,29 @@ fn reads_other_applications_stacks() {
assert_eq!(get("Strange").blend, BlendMode::Normal);
assert!(get("Lost").surface().unwrap().content_bounds().is_empty());
let all = r.warnings.join("\n");
for needle in ["svg:dst-in", "<text>", "missing from the archive", "krita:hard mix", "svg:soft-light"] {
for needle in ["svg:dst-in", "<text>", "missing from the archive", "krita:hard mix"] {
assert!(all.contains(needle), "{needle} not in {all}");
}
// svg:soft-light is the W3C curve, which is Photoshop's Soft Light (#8804): no warning.
assert!(!all.contains("soft-light"), "{all}");
}
#[test]
fn krita_soft_light_ids_map_to_photoshop_soft_light() {
// Krita's own soft_light keeps sqrt(cb) over dark backdrops, so it is the approximation;
// soft_light_svg is the W3C curve Photoshop uses (#8804).
assert_eq!(super::krita_blend("soft_light"), Some((BlendMode::SoftLight, true)));
assert_eq!(super::krita_blend("soft_light_svg"), Some((BlendMode::SoftLight, false)));
assert_eq!(super::krita_id(BlendMode::SoftLight), Some("soft_light_svg"));
let xml = r#"<image w="2" h="2"><stack>
<layer name="Own" src="a.png" composite-op="krita:soft_light"/>
<layer name="W3C" src="a.png" composite-op="krita:soft_light_svg"/>
</stack></image>"#;
let r = crate::import("soft.ora", &archive(xml, &[("a.png", solid_png(2, 2, [40, 40, 40, 255]))])).unwrap();
assert!(r.document.layers.iter().all(|l| l.blend == BlendMode::SoftLight));
let all = r.warnings.join("\n");
assert!(all.contains("krita:soft_light blend mode is approximated"), "{all}");
assert!(!all.contains("soft_light_svg"), "{all}");
}
#[test]
+64 -4
View File
@@ -9,7 +9,7 @@ use std::sync::Arc;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::{Document, Pattern};
use photocraft_geom::Rect;
use photocraft_psd::patterns::{PatternBudget, PsdPattern, block_key, mode_channels, parse_pattern_block_with, write_pattern_block};
use photocraft_psd::patterns::{PatternBudget, PsdPattern, block_has_user_mask, block_key, mode_channels, parse_pattern_block_with, write_pattern_block};
use photocraft_raster::Surface;
use crate::pixels::{deinterleave, interleave, max_sample, psd_depth, sample_for_depth, zero_sample};
@@ -124,12 +124,13 @@ pub fn from_global_blocks(doc: &Document) -> Vec<Pattern> {
.collect()
}
/// Global blocks to write: the raw ones while they still match [`Document::patterns`], else the
/// raw pattern blocks replaced by one written from the document (at the first one's position).
/// Global blocks to write: the raw ones while they still match [`Document::patterns`] (see
/// [`repair_legacy_blocks`]), else the raw pattern blocks replaced by one written from the
/// document (at the first one's position).
pub fn export_global_blocks(doc: &Document) -> Vec<photocraft_doc::PsdGlobalBlock> {
let raw = &doc.metadata.psd_global_blocks;
if from_global_blocks(doc) == doc.patterns {
return raw.clone();
return repair_legacy_blocks(raw);
}
let key = block_key(psd_depth(doc.depth));
let fresh = write_pattern_block(&doc.patterns.iter().map(to_psd).collect::<Vec<_>>()).unwrap_or_default();
@@ -151,6 +152,31 @@ pub fn export_global_blocks(doc: &Document) -> Vec<photocraft_doc::PsdGlobalBloc
out
}
/// `raw` with each pattern block that keeps transparency where older PhotoCraft builds put it
/// (which Photoshop refuses, #8054) rewritten in place from its own patterns. Every other block,
/// unreadable pattern blocks included, is kept verbatim. Blocks are decoded in order with one
/// shared budget, as [`from_global_blocks`] decodes them.
fn repair_legacy_blocks(raw: &[photocraft_doc::PsdGlobalBlock]) -> Vec<photocraft_doc::PsdGlobalBlock> {
if !raw.iter().any(|(_, k, d)| PATTERN_KEYS.contains(k) && block_has_user_mask(d)) {
return raw.to_vec();
}
let mut budget = PatternBudget::default();
raw.iter()
.map(|b @ (sig, key, data)| {
if !PATTERN_KEYS.contains(key) {
return b.clone();
}
match parse_pattern_block_with(data, &mut budget) {
Ok(ps) if block_has_user_mask(data) => match write_pattern_block(&ps) {
Ok(fresh) => (*sig, *key, Arc::new(fresh)),
Err(_) => b.clone(),
},
_ => b.clone(),
}
})
.collect()
}
/// Reads a `.pat` pattern file.
pub fn read_pat(bytes: &[u8]) -> Result<Vec<Pattern>, String> {
let ps = photocraft_psd::patterns::parse_pat_file(bytes).map_err(|e| format!("not a readable .pat file: {e}"))?;
@@ -217,4 +243,38 @@ mod tests {
assert_eq!(from_global_blocks(&d2), d.patterns);
assert_eq!(read_pat(&write_pat(&d.patterns).unwrap()).unwrap(), d.patterns);
}
#[test]
fn transparent_pattern_blocks_from_older_builds_are_rewritten_on_save() {
// #8054: Define Pattern over transparency, then a Pattern Fill layer; Photoshop refused
// the PSD because the pattern's transparency sat in the user mask array.
let mut d = Document::new("x", photocraft_geom::Size { width: 4, height: 4 }, ColorMode::Rgb, SampleType::U16);
d.patterns.push(pattern(ColorMode::Rgb, SampleType::U16, true));
let out = export_global_blocks(&d);
assert_eq!(out.len(), 1);
assert!(!block_has_user_mask(&out[0].2));
// Move the transparency into the user mask array, as older builds wrote it: a 16-bit
// pattern's array is raw, so it is the last 8 + 23 + 5 × 3 × 2 bytes of the pattern.
let mut legacy = out[0].2.to_vec();
let end = 4 + u32::from_be_bytes([legacy[0], legacy[1], legacy[2], legacy[3]]) as usize;
legacy[end - (8 + 23 + 30) - 4..end].rotate_left(4);
assert!(block_has_user_mask(&legacy));
// A pattern block that doesn't decode (pattern version 2) sits beside it.
let unreadable = (*b"8BIM", *b"Patt", Arc::new(vec![0, 0, 0, 8, 0, 0, 0, 2, 0, 0, 0, 0]));
let other = (*b"8BIM", *b"Txt2", Arc::new(vec![1]));
d.metadata.psd_global_blocks = vec![other.clone(), (*b"8BIM", *b"Pat2", Arc::new(legacy)), unreadable.clone()];
assert_eq!(from_global_blocks(&d), d.patterns, "older files still open");
// The patterns are unchanged: only the legacy block is rewritten, in place; the other
// blocks are kept verbatim.
let out = export_global_blocks(&d);
assert_eq!(out.len(), 3);
assert_eq!((&out[0], &out[2]), (&other, &unreadable));
assert_eq!(out[1].1, *b"Pat2");
assert!(!block_has_user_mask(&out[1].2));
let mut back = d.clone();
back.metadata.psd_global_blocks = out;
assert_eq!(from_global_blocks(&back), d.patterns);
// Saving again keeps every block as it is.
assert_eq!(export_global_blocks(&back), back.metadata.psd_global_blocks);
}
}
+20 -6
View File
@@ -205,15 +205,17 @@ impl<'a> Cursor<'a> {
/// A STRING: `n+1` then `n` bytes and a NUL (0 for the empty string).
fn string(&mut self) -> Result<String> {
Ok(String::from_utf8_lossy(self.string_bytes()?).into_owned())
}
/// The same bounded STRING read without allocating or decoding an unused field.
fn string_bytes(&mut self) -> Result<&'a [u8]> {
let n = self.u32()? as usize;
if n == 0 {
return Ok(String::new());
}
if n > MAX_STRING {
return Err(invalid(format!("a string of {n} bytes exceeds the limit")));
}
let s = self.take(n)?;
Ok(String::from_utf8_lossy(s.strip_suffix(&[0]).unwrap_or(s)).into_owned())
Ok(s.strip_suffix(&[0]).unwrap_or(s))
}
fn skip(&mut self, n: usize) -> Result<()> {
@@ -227,8 +229,8 @@ struct Prop<'a> {
data: &'a [u8],
}
/// Reads a property list; `fixed` gives the native payload sizes the spec says GIMP trusts over
/// the length word. Ends at PROP_END (type 0) or at the end of the file.
/// Reads a property list, measuring known payloads whose length words GIMP writes incorrectly.
/// Ends at PROP_END (type 0).
fn props<'a>(c: &mut Cursor<'a>) -> Result<Vec<Prop<'a>>> {
let mut out = Vec::new();
loop {
@@ -239,12 +241,14 @@ fn props<'a>(c: &mut Cursor<'a>) -> Result<Vec<Prop<'a>>> {
}
// PROP_COLORMAP's length word was wrong in some releases (n+4 for 3n+4 bytes); its real
// size follows from the count. PROP_USER_UNIT's length is wrong in every release.
// PROP_LINK_LAYER's length can be four bytes short (#8857); read all its fields.
let len = match kind {
1 => {
let n = c.at(c.pos).u32()? as usize;
4usize.checked_add(n.checked_mul(3).ok_or_else(|| invalid("colormap overflow"))?).ok_or_else(|| invalid("colormap overflow"))?
}
24 => user_unit_len(c)?,
48 => link_layer_len(c)?,
_ => len,
};
if len > MAX_PAYLOAD {
@@ -258,6 +262,16 @@ fn props<'a>(c: &mut Cursor<'a>) -> Result<Vec<Prop<'a>>> {
}
}
/// A PROP_LINK_LAYER payload: flags, a path STRING, then the source width and height.
/// The dimensions are present for raster links too, where their values are unused.
fn link_layer_len(c: &Cursor<'_>) -> Result<usize> {
let mut p = c.at(c.pos);
p.skip(4)?;
p.string_bytes()?;
p.skip(8)?;
Ok(p.pos - c.pos)
}
/// The real size of a PROP_USER_UNIT payload: a float, a count and three (XCF 21+) or five
/// strings. Measured without consuming.
fn user_unit_len(c: &Cursor<'_>) -> Result<usize> {
+76
View File
@@ -577,6 +577,82 @@ fn clipping_text_vector_effects_and_floating_selections_are_reported() {
assert!(w.contains("text layer") && w.contains("vector layer") && w.contains("floating selection"), "{w}");
}
/// #8857: GIMP's link-layer length omits one u32. Trusting it shifts the hierarchy pointer
/// by four bytes, even though the cached pixels and every offset in the file are valid.
#[test]
fn linked_layers_open_cached_pixels_with_short_or_correct_property_lengths() {
for (precision, depth, data) in [
(150, SampleType::U8, vec![51, 102, 153, 255]),
(250, SampleType::U16, [13107u16, 26214, 39321, 65535].into_iter().flat_map(u16::to_be_bytes).collect()),
(600, SampleType::F32, [0.2f32, 0.4, 0.6, 1.0].into_iter().flat_map(f32::to_be_bytes).collect()),
] {
for (path, width, height) in [("assets/étiquette.svg", 2000, 1270), ("assets/label.png", 0, 0), ("", 0, 0)] {
let mut payload = W { out: Vec::new(), wide: true };
payload.u32(1);
payload.string(path);
payload.u32(width);
payload.u32(height);
let payload = payload.out;
let marker = [48u32.to_be_bytes(), (payload.len() as u32).to_be_bytes()].concat();
let mut img = TestImage::rgb8(1, 1);
img.version = 25;
img.precision = precision;
let mut linked = TestLayer::rgba("Linked", 1, 1, data.clone());
// Later properties must still be read, including unknown ones skipped by length.
linked.props = vec![(48, payload.clone()), (99, vec![1, 2, 3]), (33, 0.375f32.to_be_bytes().to_vec()), (8, 0u32.to_be_bytes().to_vec())];
img.layers.push(linked);
img.layers.push(TestLayer::rgba("Bottom", 1, 1, data.clone()));
for short in [false, true] {
let mut bytes = img.write();
let at = bytes.windows(marker.len()).position(|w| w == marker).unwrap();
if short {
bytes[at + 4..at + 8].copy_from_slice(&((payload.len() - 4) as u32).to_be_bytes());
}
let r = open(&bytes).unwrap_or_else(|e| panic!("precision {precision}, path {path:?}, short {short}: {e}"));
assert!(r.source_read_only);
assert_eq!(r.document.depth, depth);
assert_eq!(r.document.layers.len(), 2);
let linked = layer_at(&r.document.layers, "Linked");
assert_eq!(linked.opacity, 0.375);
assert!(!linked.visible);
for layer in [linked, layer_at(&r.document.layers, "Bottom")] {
for (got, expected) in pixel(&layer, 0, 0).into_iter().zip([0.2, 0.4, 0.6, 1.0]) {
assert!((got - expected).abs() < 1e-5, "precision {precision}: {got} != {expected}");
}
}
assert!(r.warnings.iter().any(|w| w.contains("is a link layer and opens as pixels")));
}
}
}
}
#[test]
fn truncated_and_oversized_link_layer_fields_are_errors() {
let mut payload = W { out: Vec::new(), wide: true };
payload.u32(1);
payload.string("assets/label.svg");
payload.u32(2000);
payload.u32(1270);
let payload = payload.out;
let marker = [48u32.to_be_bytes(), (payload.len() as u32).to_be_bytes()].concat();
let mut img = TestImage::rgb8(1, 1);
img.version = 25;
let mut linked = TestLayer::rgba("Linked", 1, 1, vec![51, 102, 153, 255]);
linked.props.push((48, payload.clone()));
img.layers.push(linked);
let bytes = img.write();
let start = bytes.windows(marker.len()).position(|w| w == marker).unwrap() + 8;
// Cut within the flags, string length, path and either dimension.
for end in start..start + payload.len() {
assert!(open(&bytes[..end]).is_err(), "truncated at {end}");
}
for len in [(MAX_STRING + 1) as u32, u32::MAX] {
let mut oversized = bytes.clone();
oversized[start + 4..start + 8].copy_from_slice(&len.to_be_bytes());
assert!(open(&oversized).is_err(), "path length {len}");
}
}
#[test]
fn version_3_files_use_32_bit_pointers_and_legacy_modes() {
let mut img = TestImage::rgb8(5, 5);
+67
View File
@@ -0,0 +1,67 @@
//! Blend modes against Photoshop's own merged image of psd-tools `blend-modes/rgb-blend-modes.psd`:
//! one 64 × 64 cell per mode, a duck over a rainbow backdrop.
#![cfg(feature = "corpus")]
use photocraft_color::BlendMode;
use photocraft_geom::Rect;
use photocraft_io::{import, merged_composite};
use photocraft_psd::PsdFile;
const CELL: i32 = 64;
/// The largest and mean difference in 8-bit levels between our flatten and Photoshop's merged image
/// over the cell of `mode`, on the pixels where that cell's duck is fully opaque (its anti-aliased
/// edge mixes in the backdrop, which says nothing about the mode).
fn cell_error(mode: BlendMode) -> (f32, f32) {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd-tools/blend-modes/rgb-blend-modes.psd");
let bytes = std::fs::read(path).expect("run cargo xtask corpus --psd-tools");
let doc = import("rgb-blend-modes.psd", &bytes).expect("opens").document;
let file = PsdFile::from_bytes(&bytes).expect("parses");
let merged = merged_composite(&file).expect("merged image");
let ours = photocraft_compose::flatten(&doc).px;
let width = doc.size.width as i32;
let duck = doc.walk().into_iter().map(|(_, _, l)| l).find(|l| l.name == "rubber_duck_PNG1" && l.blend == mode).expect("the mode's duck");
let surface = duck.surface().expect("duck pixels");
let bounds = surface.content_bounds();
let (x0, y0) = ((bounds.x0 / CELL) * CELL, (bounds.y0 / CELL) * CELL);
let mut alpha = vec![[0.0f32; 4]; (CELL * CELL) as usize];
surface.read_rgba_into(Rect::from_xywh(x0, y0, CELL as u32, CELL as u32), &mut alpha);
let (mut max, mut sum, mut n) = (0.0f32, 0.0f32, 0u32);
for y in 1..CELL - 1 {
for x in 1..CELL - 1 {
// Opaque, and so are its four neighbours (one pixel in from the edge).
let opaque = |dx: i32, dy: i32| alpha[((y + dy) * CELL + x + dx) as usize][3] >= 1.0;
if !(opaque(0, 0) && opaque(-1, 0) && opaque(1, 0) && opaque(0, -1) && opaque(0, 1)) {
continue;
}
let i = ((y0 + y) * width + x0 + x) as usize;
for c in 0..3 {
let d = ((ours[i][c] * 255.0).round() - (merged[i][c] * 255.0).round()).abs();
max = max.max(d);
sum += d;
n += 1;
}
}
}
assert!(n > 3000, "{mode:?}: only {n} opaque samples");
(max, sum / n as f32)
}
/// #8804: Soft Light over the dark parts of the rainbow. With `sqrt(cb)` below a quarter the cell
/// was up to 20 levels too light; the W3C curve Photoshop uses is within 7.
#[test]
fn soft_light_matches_photoshop_over_dark_backdrops() {
let (max, mean) = cell_error(BlendMode::SoftLight);
eprintln!("Soft Light: max {max} mean {mean:.3}");
assert!(max <= 7.0 && mean < 0.6, "max {max} mean {mean}");
}
/// The modes #8804 checked next to Soft Light stay where they were.
#[test]
fn separable_light_modes_match_photoshop() {
for mode in [BlendMode::Multiply, BlendMode::Screen, BlendMode::Overlay, BlendMode::HardLight] {
let (max, mean) = cell_error(mode);
eprintln!("{mode:?}: max {max} mean {mean:.3}");
assert!(max <= 3.0, "{mode:?}: max {max} mean {mean}");
}
}
+1
View File
@@ -8,6 +8,7 @@ mod affinity_corpus;
mod annotations;
mod artboard_guide_order;
mod banded;
mod blend_modes_corpus;
mod cancel;
mod channels;
mod cmyk_composite_export;
+45 -2
View File
@@ -26,6 +26,9 @@ const MAX_GRID_STEPS: f64 = 1e7;
const MIN_SPEED_STEP: f64 = 0.5;
/// Maximum airbrush catch-up dabs for one input segment.
const MAX_AIRBRUSH_DABS_PER_SEGMENT: usize = 512;
/// Most steps one distance-spaced segment emits: two far-apart points at a quarter-pixel spacing
/// would otherwise be billions of steps, all buffered before the first is drawn.
const MAX_DISTANCE_STEPS_PER_SEGMENT: usize = 1 << 20;
/// The dab centre of the pixel-grid cell holding `v` (see [`PathWalker::pixel_grid`]): the
/// pixel's centre for `offset` 0.5, the nearest pixel corner for 0. A value within rounding
@@ -336,12 +339,17 @@ impl PathWalker {
match self.speed.filter(|_| dt > 0.0 && dt.is_finite()) {
None => {
if !self.grid.is_some_and(|g| self.grid_steps(&a, &p, g, out)) {
while self.next_at <= len + 1e-9 {
let mut emitted = 0usize;
while self.next_at <= len + 1e-9 && emitted < MAX_DISTANCE_STEPS_PER_SEGMENT {
let q = lerp_pt(&a, &p, (self.next_at / len).min(1.0));
self.emit(q, out);
self.next_at += step_len(&q).max(0.25);
emitted += 1;
}
self.next_at -= len;
// Cut short: the rest of the segment is skipped, spacing restarts at its end.
// Only when the cap held back a step that was still due; a segment that ends right at the cap keeps its carry-over.
let cut_short = emitted == MAX_DISTANCE_STEPS_PER_SEGMENT && self.next_at <= len + 1e-9;
self.next_at = if cut_short { step_len(&p).max(0.25) } else { self.next_at - len };
}
}
Some(iv) => {
@@ -815,3 +823,38 @@ impl DabGenerator {
}
}
}
#[cfg(test)]
mod walker_budget_tests {
use super::*;
#[test]
fn one_far_segment_emits_a_bounded_number_of_steps() {
let steps = std::cell::Cell::new(0usize);
let mut count = |_: StepInput| steps.set(steps.get() + 1);
let mut walker = PathWalker::new(None);
walker.push(StrokePoint::new(0.0, 0.0, 1.0), &|_: &StrokePoint| 1.0, &mut count);
// At a quarter pixel, a billion-pixel segment is four billion steps.
walker.push(StrokePoint::new(1.0e9, 0.0, 1.0), &|_: &StrokePoint| 0.25, &mut count);
assert!(steps.get() <= MAX_DISTANCE_STEPS_PER_SEGMENT + 1, "{}", steps.get());
// Spacing carries on from the end of the cut segment.
let before = steps.get();
walker.push(StrokePoint::new(1.0e9 + 10.0, 0.0, 1.0), &|_: &StrokePoint| 1.0, &mut count);
assert!(steps.get() > before && steps.get() - before <= 11, "{} more steps", steps.get() - before);
}
#[test]
fn a_segment_ending_at_the_cap_keeps_its_carry_over() {
let steps = std::cell::Cell::new(0usize);
let mut count = |_: StepInput| steps.set(steps.get() + 1);
let mut walker = PathWalker::new(None);
let one = |_: &StrokePoint| 1.0;
walker.push(StrokePoint::new(0.0, 0.0, 1.0), &one, &mut count);
// Exactly the cap in steps, then 0.75 left before the next is due.
let end = MAX_DISTANCE_STEPS_PER_SEGMENT as f64 + 0.25;
walker.push(StrokePoint::new(end, 0.0, 1.0), &one, &mut count);
let before = steps.get();
walker.push(StrokePoint::new(end + 0.8, 0.0, 1.0), &one, &mut count);
assert_eq!(steps.get(), before + 1);
}
}
+27 -1
View File
@@ -87,8 +87,20 @@ pub fn chalk_tip(n: u32, seed: u32) -> GrayTile {
})
}
/// Most primitives one generated tip is drawn from, and most (pixel × primitive) visits it costs:
/// every pixel is tested against every primitive, so both counts are the caller's to multiply.
const MAX_PRIMITIVES: u32 = 4096;
const MAX_TIP_WORK: u64 = 1 << 27;
/// `count` limited so that an `n`×`n` tip tests at most [`MAX_TIP_WORK`] pixel/primitive pairs.
fn clamp_primitives(n: u32, count: u32) -> u32 {
let per_primitive = (u64::from(n) * u64::from(n)).max(1);
count.min(MAX_PRIMITIVES).min(u32::try_from(MAX_TIP_WORK / per_primitive).unwrap_or(u32::MAX))
}
/// A spatter tip: a cluster of round droplets of different sizes.
pub fn spatter_tip(n: u32, seed: u32, drops: u32) -> GrayTile {
let drops = clamp_primitives(n, drops);
let salt = u64::from(seed) + 202;
let s = n as f32;
let blobs: Vec<(f32, f32, f32)> = (0..drops)
@@ -162,6 +174,7 @@ pub fn leaf_tip(n: u32) -> GrayTile {
/// Grass: a tuft of tapered, slightly curved blades rising from the bottom edge.
pub fn grass_tip(n: u32, seed: u32, blades: u32) -> GrayTile {
let blades = clamp_primitives(n, blades);
let salt = u64::from(seed) + 505;
let s = n as f32;
let bl: Vec<(f32, f32, f32, f32)> = (0..blades)
@@ -217,7 +230,7 @@ pub fn star_tip(n: u32) -> GrayTile {
pub fn rake_tip(n: u32, seed: u32, bristles: u32) -> GrayTile {
let salt = u64::from(seed) + 707;
let s = n as f32;
let k = bristles.max(1);
let k = clamp_primitives(n, bristles).max(1);
let dots: Vec<(f32, f32, f32)> = (0..k)
.map(|i| {
let x = s * (i as f32 + 0.5) / k as f32;
@@ -239,6 +252,19 @@ pub fn rake_tip(n: u32, seed: u32, bristles: u32) -> GrayTile {
mod tests {
use super::*;
#[test]
fn tips_with_absurd_primitive_counts_stay_cheap() {
// 4 billion drops over a 1024² tip would be 4·10¹⁵ pixel tests.
let t = std::time::Instant::now();
assert_eq!(spatter_tip(1024, 1, u32::MAX).width, 1024);
assert_eq!(grass_tip(1024, 1, u32::MAX).height, 1024);
assert_eq!(rake_tip(1024, 1, u32::MAX).width, 1024);
assert!(t.elapsed().as_secs() < 60, "{:?}", t.elapsed());
assert!(clamp_primitives(1024, u32::MAX) <= MAX_PRIMITIVES);
assert!(clamp_primitives(60_000, 100) < 100, "a huge tip gets fewer primitives");
assert_eq!(clamp_primitives(0, 7), 7);
}
#[test]
fn patterns_are_seamless_and_in_range() {
for st in [PatternStyle::Noise, PatternStyle::Paper, PatternStyle::Canvas, PatternStyle::Dots] {
+108 -5
View File
@@ -18,7 +18,9 @@
//! `.pat` file is `8BPT`, version u16 (1), count u32, then the patterns without length prefixes.
//! Photoshop writes the channel count as 24 and only marks the real channels as written; like
//! ag-psd (MIT) we take the first written arrays as the colour channels and the next one as
//! transparency.
//! transparency. Photoshop stores a pattern's transparency in the last (sheet mask) array and
//! leaves the user mask unwritten (psd-tools multichannel-pattern-fill.psd, Photoshop CC 2019); a
//! file with transparency in the user mask array is refused by Photoshop (#8054).
use crate::compression::{Compression, MAX_DECODED_BYTES, PlaneLayout, decode_planes, encode_planes};
use crate::error::{PsdError, Result};
@@ -143,7 +145,15 @@ impl<'a> Rd<'a> {
}
}
fn read_pattern(r: &mut Rd, budget: &mut PatternBudget) -> Result<PsdPattern> {
/// A pattern's mode, name, id and palette, up to its virtual memory array list.
struct PatternHeader {
mode: u32,
name: String,
id: String,
palette: Option<Vec<u8>>,
}
fn read_header(r: &mut Rd) -> Result<PatternHeader> {
let version = r.u32()?;
if version != 1 {
return Err(PsdError::invalid(format!("pattern version {version}")));
@@ -169,12 +179,22 @@ fn read_pattern(r: &mut Rd, budget: &mut PatternBudget) -> Result<PsdPattern> {
} else {
None
};
Ok(PatternHeader { mode, name, id, palette })
}
/// The body of a pattern's virtual memory array list (after its version and length).
fn read_vma_body<'a>(r: &mut Rd<'a>) -> Result<&'a [u8]> {
let vver = r.u32()?;
if vver != 3 {
return Err(PsdError::invalid(format!("virtual memory array list version {vver}")));
}
let len = r.u32()? as usize;
let body = r.take(len)?;
r.take(len)
}
fn read_pattern(r: &mut Rd, budget: &mut PatternBudget) -> Result<PsdPattern> {
let PatternHeader { mode, name, id, palette } = read_header(r)?;
let body = read_vma_body(r)?;
let mut v = Rd { b: body, p: 0 };
let [top, left, bottom, right] = v.rect()?;
let w = right.checked_sub(left).ok_or(PsdError::LimitExceeded("pattern width overflow"))?;
@@ -413,12 +433,12 @@ fn write_pattern(p: &PsdPattern, out: &mut Vec<u8>) -> Result<()> {
for _ in nc..SLOTS as usize {
body.extend_from_slice(&0u32.to_be_bytes());
}
// User mask slot: transparency; sheet mask slot: unused.
// User mask slot: unused; sheet mask slot: transparency, where Photoshop keeps it (#8054).
body.extend_from_slice(&0u32.to_be_bytes());
match &p.alpha {
Some(a) => array(a, &mut body)?,
None => body.extend_from_slice(&0u32.to_be_bytes()),
}
body.extend_from_slice(&0u32.to_be_bytes());
out.extend_from_slice(&3u32.to_be_bytes());
out.extend_from_slice(&(body.len() as u32).to_be_bytes());
out.extend_from_slice(&body);
@@ -448,6 +468,48 @@ pub fn parse_pattern_block_with(data: &[u8], budget: &mut PatternBudget) -> Resu
Ok(out)
}
/// Whether `Patt` / `Pat2` / `Pat3` block data keeps a pattern's transparency in the user mask
/// array, as PhotoCraft wrote it before #8054. Photoshop refuses such a file, so a save must
/// rewrite the block rather than keep it. Unreadable data counts as `false`.
pub fn block_has_user_mask(data: &[u8]) -> bool {
let mut r = Rd { b: data, p: 0 };
while r.p + 4 <= data.len() {
let Ok(len) = r.u32() else { return false };
if len == 0 {
break;
}
let Ok(body) = r.take(len as usize) else { return false };
if pattern_has_user_mask(&mut Rd { b: body, p: 0 }).unwrap_or(false) {
return true;
}
let pad = (4 - len as usize % 4) % 4;
r.p = (r.p + pad).min(data.len());
}
false
}
fn pattern_has_user_mask(r: &mut Rd) -> Result<bool> {
read_header(r)?;
let mut v = Rd { b: read_vma_body(r)?, p: 0 };
v.rect()?;
let count = v.u32()?;
// Every array takes at least 4 bytes, so the body bounds this loop whatever `count` says.
for i in 0..count.saturating_add(2) {
if v.p >= v.b.len() {
break;
}
if v.u32()? == 0 {
continue;
}
let alen = v.u32()? as usize;
if i == count && alen != 0 {
return Ok(true);
}
v.take(alen)?;
}
Ok(false)
}
/// Serializes patterns as `Patt` / `Pat2` / `Pat3` block data.
pub fn write_pattern_block(patterns: &[PsdPattern]) -> Result<Vec<u8>> {
let mut out = Vec::new();
@@ -536,6 +598,47 @@ mod tests {
}
}
/// Offsets of the virtual memory arrays of a one-pattern block (each at its "written" flag),
/// with whether each is written, and the end of the array list.
fn vma_slots(block: &[u8], p: &PsdPattern) -> (Vec<(usize, bool)>, usize) {
let rect = pattern_vma_rect_offset(4, p);
let end = rect + u32::from_be_bytes(block[rect - 4..rect].try_into().unwrap()) as usize;
let (mut at, mut slots) = (rect + 20, Vec::new());
while at < end {
let written = u32::from_be_bytes(block[at..at + 4].try_into().unwrap()) != 0;
slots.push((at, written));
at += 4;
if written {
at += 4 + u32::from_be_bytes(block[at..at + 4].try_into().unwrap()) as usize;
}
}
(slots, end)
}
#[test]
fn transparency_goes_in_the_sheet_mask_array_like_photoshop() {
// #8054: transparency in the user mask array (slot 24) made Photoshop refuse the file;
// Photoshop keeps it in the sheet mask array (slot 25).
for depth in [8, 16, 32] {
let p = pat(depth, true);
let mut block = write_pattern_block(std::slice::from_ref(&p)).unwrap();
let (slots, end) = vma_slots(&block, &p);
let written: Vec<usize> = slots.iter().enumerate().filter(|(_, s)| s.1).map(|(i, _)| i).collect();
assert_eq!((slots.len(), written), (26, vec![0, 1, 2, 25]), "{depth}-bit");
assert!(!block_has_user_mask(&block));
// The layout older PhotoCraft builds wrote still opens, and is detected.
block[slots[24].0..end].rotate_left(4);
let (slots, _) = vma_slots(&block, &p);
assert!(slots[24].1 && !slots[25].1);
assert!(block_has_user_mask(&block));
assert_eq!(parse_pattern_block(&block).unwrap(), vec![p]);
}
assert!(!block_has_user_mask(&write_pattern_block(&[pat(8, false)]).unwrap()));
for junk in [&[][..], &[0, 0, 0, 9, 1], &[0xff; 64]] {
assert!(!block_has_user_mask(junk));
}
}
#[test]
fn malformed_input_errors_without_panicking() {
let blk = write_pattern_block(&[pat(8, true)]).unwrap();
+15
View File
@@ -342,6 +342,9 @@ fn format_real(r: f64) -> String {
if matches!(s.as_str(), "0." | "-0.") {
return "0.0".into();
}
if s.ends_with('.') {
s.push('0');
}
if let Some(rest) = s.strip_prefix("0.") {
s = format!(".{rest}");
} else if let Some(rest) = s.strip_prefix("-0.") {
@@ -356,6 +359,18 @@ mod tests {
const SAMPLE: &[u8] = b"\n\n<<\n\t/EngineDict\n\t<<\n\t\t/Editor\n\t\t<<\n\t\t\t/Text (\xFE\xFF\x00H\x00i\x00\\)\x00\r)\n\t\t>>\n\t\t/Nums [ 1.0 .5 -2 0 ]\n\t\t/Flag true\n\t\t/Kind /Roman\n\t\t/Runs [\n\t\t<<\n\t\t\t/A 1\n\t\t>>\n\t\t]\n\t>>\n>>";
#[test]
fn format_real_never_ends_with_bare_dot() {
assert_eq!(format_real(5.000001), "5.0");
assert_eq!(format_real(12.000004), "12.0");
assert_eq!(format_real(-5.000001), "-5.0");
assert_eq!(format_real(1e20), "100000000000000000000.0");
assert_eq!(format_real(0.5), ".5");
assert_eq!(format_real(-0.25), "-.25");
assert_eq!(format_real(1e-9), "0.0");
assert_eq!(format_real(3.0), "3.0");
}
#[test]
fn parses_sample() {
let v = parse(SAMPLE).unwrap();
+217
View File
@@ -251,3 +251,220 @@ fn craft_font_collections_keep_their_face_families() {
assert!(db.has_family("Craft Test A") && db.has_family("Craft Test B"), "{:?}", db.families());
assert!(!db.has_family("Craft Test Collection"));
}
/// A 16.16 fixed-point number as `fvar` stores it.
fn fixed(v: f32) -> [u8; 4] {
((v * 65536.0).round() as i32).to_be_bytes()
}
/// `source` rebuilt as a variable font with `wght` 100–900 and `wdth` 75–100 axes and two named
/// instances, without redistributing a variable font (#8797): "Light Condensed" (300, 75) has no
/// PostScript name of its own, so its name is derived as `InstanceTest-LightCondensed`;
/// "Heavy" (900, 100) names itself `InstanceTest-Black`. Outlines do not vary, but an `HVAR` table
/// (see [`width_hvar`]) narrows every advance on `wdth`, so layout width shows which instance drew.
fn instance_font(source: &[u8]) -> Vec<u8> {
let names = [
(1u16, "Instance Test"),
(2, "Regular"),
(6, "InstanceTest-Regular"),
(16, "Instance Test"),
(256, "Weight"),
(257, "Width"),
(258, "Light Condensed"),
(259, "InstanceTest-Black"),
(260, "Heavy"),
];
let mut name = Vec::new();
let mut strings = Vec::new();
for value in [0u16, names.len() as u16, (6 + names.len() * 12) as u16] {
name.extend(value.to_be_bytes());
}
for (id, value) in names {
let encoded: Vec<_> = value.encode_utf16().flat_map(u16::to_be_bytes).collect();
for n in [3, 1, 0x409, id, encoded.len() as u16, strings.len() as u16] {
name.extend(n.to_be_bytes());
}
strings.extend(encoded);
}
name.extend(strings);
// Two axes of 20 bytes, then two instances of 4 + 2 * 4 + 2 bytes (with PostScript name IDs).
let mut fvar = Vec::new();
for n in [1u16, 0, 16, 2, 2, 20, 2, 14] {
fvar.extend(n.to_be_bytes());
}
for (tag, min, default, max, name_id) in [(b"wght", 100.0, 400.0, 900.0, 256u16), (b"wdth", 75.0, 100.0, 100.0, 257)] {
fvar.extend(tag);
for v in [min, default, max] {
fvar.extend(fixed(v));
}
fvar.extend([0, 0]);
fvar.extend(name_id.to_be_bytes());
}
for (subfamily, coords, ps) in [(258u16, [300.0, 75.0], 0xFFFFu16), (260, [900.0, 100.0], 259)] {
fvar.extend(subfamily.to_be_bytes());
fvar.extend([0, 0]);
for v in coords {
fvar.extend(fixed(v));
}
fvar.extend(ps.to_be_bytes());
}
// Rebuild the table directory: every table but `name` as it was, plus the new `name`, `fvar`
// and `HVAR`.
let count = u16::from_be_bytes(source[4..6].try_into().unwrap()) as usize;
let mut tables: Vec<([u8; 4], Vec<u8>)> = (0..count)
.map(|i| {
let r = 12 + i * 16;
let at = u32::from_be_bytes(source[r + 8..r + 12].try_into().unwrap()) as usize;
let len = u32::from_be_bytes(source[r + 12..r + 16].try_into().unwrap()) as usize;
(source[r..r + 4].try_into().unwrap(), source[at..at + len].to_vec())
})
.filter(|(tag, _)| tag != b"name")
.collect();
let table = |tag: &[u8; 4]| tables.iter().find(|(t, _)| t == tag).map(|(_, data)| data.as_slice()).unwrap();
let hvar = width_hvar(table(b"hhea"), table(b"hmtx"), table(b"maxp"));
tables.push((*b"name", name));
tables.push((*b"fvar", fvar));
tables.push((*b"HVAR", hvar));
tables.sort_by_key(|(tag, _)| *tag);
let mut out = source[0..4].to_vec();
for n in [tables.len() as u16, 0, 0, 0] {
out.extend(n.to_be_bytes());
}
let mut at = 12 + 16 * tables.len();
for (tag, data) in &tables {
out.extend(tag);
out.extend(0u32.to_be_bytes());
out.extend((at as u32).to_be_bytes());
out.extend((data.len() as u32).to_be_bytes());
at += data.len().next_multiple_of(4);
}
for (_, data) in &tables {
out.extend(data);
out.resize(out.len().next_multiple_of(4), 0);
}
out
}
/// An `HVAR` table for [`instance_font`]'s axes (`wght`, then `wdth`) that takes 30% off every
/// glyph's advance at `wdth` 75 and nothing at the default 100, scaled linearly between. Advances
/// come from `hmtx`; glyph IDs map to deltas directly, as no advance mapping is given.
fn width_hvar(hhea: &[u8], hmtx: &[u8], maxp: &[u8]) -> Vec<u8> {
let glyphs = u16::from_be_bytes(maxp[4..6].try_into().unwrap());
let metrics = u16::from_be_bytes(hhea[34..36].try_into().unwrap()) as usize;
let advance = |gid: usize| {
let at = 4 * gid.min(metrics - 1);
i32::from(u16::from_be_bytes(hmtx[at..at + 2].try_into().unwrap()))
};
let mut out = Vec::new();
// Header: version 1.0, item variation store at 20, no advance, LSB or RSB mappings.
for n in [1u16, 0] {
out.extend(n.to_be_bytes());
}
for n in [20u32, 0, 0, 0] {
out.extend(n.to_be_bytes());
}
// Item variation store: format 1, region list at 12, one item variation data at 28.
out.extend(1u16.to_be_bytes());
out.extend(12u32.to_be_bytes());
out.extend(1u16.to_be_bytes());
out.extend(28u32.to_be_bytes());
// One region over two axes as (start, peak, end) in F2Dot14: `wght` unused, `wdth` -1..0
// peaking at -1 (75).
for n in [2u16, 1, 0, 0, 0, 0xC000, 0xC000, 0] {
out.extend(n.to_be_bytes());
}
// Item variation data: one 16-bit delta per glyph for region 0.
for n in [glyphs, 1, 1, 0] {
out.extend(n.to_be_bytes());
}
for gid in 0..glyphs as usize {
out.extend((-(advance(gid) * 3 / 10) as i16).to_be_bytes());
}
out
}
/// The axis values `select_named_face` gives a style of the synthesized variable family that
/// carries the PostScript name `ps`, sorted by axis.
fn instance_axes(db: &mut FontDb, ps: &str, variations: Vec<photocraft_doc::text::FontVariation>) -> Vec<(String, f32)> {
let mut style = CharStyle { font_family: "Instance Test".into(), postscript_name: Some(ps.into()), weight: 300, variations, ..Default::default() };
db.select_named_face(&mut style);
let mut axes: Vec<_> = style.variations.into_iter().map(|v| (v.axis, v.value)).collect();
axes.sort_by(|a, b| a.0.cmp(&b.0));
axes
}
/// #8797: a PostScript name that names a named instance of a variable font (as a PSD stores
/// "Bahnschrift-LightCondensed") selects that instance's axis values, whether the instance names
/// itself or its name is derived from the family and subfamily names. A name no instance has
/// leaves the style at the default instance, and axes the style sets itself are kept.
#[test]
fn postscript_name_of_a_named_instance_selects_its_axis_values() {
use photocraft_doc::text::FontVariation;
let mut db = FontDb::new();
db.register_font_data(instance_font(&INTER_REGULAR));
assert_eq!(db.faces("Instance Test").len(), 1, "a variable family is one face");
let axes = |pairs: &[(&str, f32)]| pairs.iter().map(|(a, v)| (a.to_string(), *v)).collect::<Vec<_>>();
assert_eq!(instance_axes(&mut db, "InstanceTest-LightCondensed", vec![]), axes(&[("wdth", 75.0), ("wght", 300.0)]));
assert_eq!(instance_axes(&mut db, "InstanceTest-Black", vec![]), axes(&[("wdth", 100.0), ("wght", 900.0)]));
assert_eq!(instance_axes(&mut db, "InstanceTest-Missing", vec![]), axes(&[]));
let own = vec![FontVariation { axis: "wght".into(), value: 350.0 }];
assert_eq!(instance_axes(&mut db, "InstanceTest-LightCondensed", own), axes(&[("wdth", 75.0), ("wght", 350.0)]));
}
/// A static face whose PostScript name matches exactly is selected as before (#8797 only adds
/// a fallback for names no face has), and gets no axis values.
#[test]
fn exact_static_face_gets_no_instance_axes() {
let mut db = FontDb::new();
db.register_font_data(numeric_font(&INTER_SEMIBOLD, "30", "StaticPS30"));
let mut style = CharStyle { font_family: "Numeric Test".into(), postscript_name: Some("StaticPS30".into()), ..Default::default() };
db.select_named_face(&mut style);
assert_ne!(style.font_family, "Numeric Test", "the exact face is selected through its alias");
assert!(style.variations.is_empty(), "{:?}", style.variations);
}
/// A style whose `font_style` and weight also match the variable face itself ("Regular", 400) still
/// gets the named instance its PostScript name gives: the instance is tried before the
/// style-and-weight fallback, which would select the face at its default instance (#8797).
#[test]
fn named_instance_wins_over_a_matching_font_style() {
let mut db = FontDb::new();
db.register_font_data(instance_font(&INTER_REGULAR));
let mut style = CharStyle {
font_family: "Instance Test".into(),
font_style: "Regular".into(),
weight: 400,
postscript_name: Some("InstanceTest-LightCondensed".into()),
..Default::default()
};
db.select_named_face(&mut style);
assert_ne!(style.font_family, "Instance Test", "the variable face is selected through its alias");
let mut axes: Vec<_> = style.variations.into_iter().map(|v| (v.axis, v.value)).collect();
axes.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(axes, vec![("wdth".to_string(), 75.0), ("wght".to_string(), 300.0)]);
}
/// #8797 end to end without an installed font: text whose style carries a named instance's
/// PostScript name lays out as wide as that instance's axis values, which the synthesized `HVAR`
/// makes narrower than the default instance. The style's `font_style` and weight match the
/// variable face itself, so the default instance would be chosen if the instance were skipped.
#[test]
fn postscript_name_of_a_named_instance_lays_out_at_that_instance() {
use photocraft_doc::TextLayer;
use photocraft_doc::text::{FontVariation, TextRun};
let mut engine = crate::TextEngine::new();
engine.fonts.register_font_data(instance_font(&INTER_REGULAR));
let text = "Hamburgefonstiv 0123";
let mut width = |style: CharStyle| {
let layer = TextLayer { text: text.into(), runs: vec![TextRun { len: text.len(), style }], ..Default::default() };
let l = engine.layout(&layer, 96.0);
l.lines[0].x1 - l.lines[0].x0
};
let base = CharStyle { font_family: "Instance Test".into(), font_style: "Regular".into(), size_pt: 72.0, weight: 400, ..Default::default() };
let axes = vec![FontVariation { axis: "wght".into(), value: 300.0 }, FontVariation { axis: "wdth".into(), value: 75.0 }];
let family = width(base.clone());
let instance = width(CharStyle { postscript_name: Some("InstanceTest-LightCondensed".into()), ..base.clone() });
let explicit = width(CharStyle { variations: axes, ..base });
assert!(explicit < family * 0.8, "wdth 75 is narrower: {explicit} vs {family}");
assert!((instance - explicit).abs() < 0.5, "instance: {instance} px, its axes: {explicit} px, default instance: {family} px");
}
+58 -1
View File
@@ -483,10 +483,29 @@ impl FontDb {
/// Select an exact face for shaping. Private aliases are runtime-only: the document keeps
/// its public family and PostScript name. CSS matching alone cannot distinguish e.g. the
/// numeric subfamilies of Yoon fonts, all of which declare weight 400 / normal.
///
/// A PostScript name that no face has may name an instance of a variable face
/// ("Bahnschrift-LightCondensed"): that face is selected and the instance's axis values are
/// added to the style's variations, keeping any axis the style sets itself (#8797). Faces are
/// tried in this order: exact PostScript name, named instance, then style, weight and italic.
pub(crate) fn select_named_face(&mut self, style: &mut photocraft_doc::text::CharStyle) {
let family = style.font_family.clone();
let faces = self.faces(&family);
let index = style.postscript_name.as_ref().and_then(|ps| faces.iter().position(|f| f.postscript_name.as_ref() == Some(ps))).or_else(|| {
let index = style.postscript_name.as_ref().and_then(|ps| faces.iter().position(|f| f.postscript_name.as_ref() == Some(ps)));
// The named instance is tried before the style-and-weight fallback: a variable face's own
// style ("Regular") can match a populated `font_style` and would drop the instance's axes.
let instance = match (index, &style.postscript_name) {
(None, Some(ps)) => self.named_instance(&family, &faces, ps),
_ => None,
};
if let Some((_, axes)) = &instance {
for (axis, value) in axes {
if !style.variations.iter().any(|v| &v.axis == axis) {
style.variations.push(photocraft_doc::text::FontVariation { axis: axis.clone(), value: *value });
}
}
}
let index = index.or(instance.map(|(i, _)| i)).or_else(|| {
faces.iter().position(|f| {
!style.font_style.is_empty()
&& f.style.eq_ignore_ascii_case(&style.font_style)
@@ -514,6 +533,44 @@ impl FontDb {
style.italic = face.italic;
}
/// The variable face of `family` (index into `faces`) with a named instance whose PostScript
/// name is `ps`, and that instance's axis values. An instance without a PostScript name of its
/// own is named as Adobe Technical Note #5902 derives it: the variations prefix (name 25, else
/// the family name) and the subfamily name without spaces, joined by a hyphen. Names compare
/// without punctuation or case, and the subfamily alone matches the part of `ps` after the
/// first hyphen, since the prefix a document stored can differ from the font's.
fn named_instance(&mut self, family: &str, faces: &[FaceInfo], ps: &str) -> Option<(usize, Vec<(String, f32)>)> {
let key = |s: &str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect::<String>();
let wanted = key(ps);
let wanted_style = ps.split_once('-').map(|(_, s)| key(s));
let info = self.fcx.collection.family_by_name(family)?;
for (index, face) in info.fonts().iter().enumerate() {
if faces.get(index).is_none_or(|f| f.axes.is_empty()) {
continue;
}
let Some(blob) = face.load(Some(&mut self.fcx.source_cache)) else { continue };
let Ok(font) = skrifa::FontRef::from_index(blob.as_ref(), face.index()) else { continue };
let name = |id: StringId| font.localized_strings(id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty());
let prefix = name(StringId::VARIATIONS_POSTSCRIPT_NAME_PREFIX)
.or_else(|| name(StringId::TYPOGRAPHIC_FAMILY_NAME))
.or_else(|| name(StringId::FAMILY_NAME))
.unwrap_or_default();
for instance in font.named_instances().iter() {
let subfamily = name(instance.subfamily_name_id()).unwrap_or_default();
let own = instance.postscript_name_id().and_then(name);
let matches = match own {
Some(own) => key(&own) == wanted,
None => key(&prefix) + &key(&subfamily) == wanted || (!subfamily.is_empty() && wanted_style.as_deref() == Some(key(&subfamily).as_str())),
};
if matches {
let axes = font.axes().iter().map(|a| a.tag().to_string()).zip(instance.user_coords()).collect();
return Some((index, axes));
}
}
}
None
}
/// Finds a face by PostScript name (as stored in PSD files): exact match by reading the
/// `name` table of candidate faces, else a heuristic split (`Arial-BoldMT` → Arial, 700).
pub fn resolve_postscript(&mut self, ps: &str) -> ResolvedFont {
+10 -1
View File
@@ -88,7 +88,7 @@ impl Dictionary {
/// Whether `word` is spelled correctly (also accepting `user` words, compared without case).
pub fn check(&self, word: &str, user: &HashSet<String>) -> bool {
let w = normalize(word);
let w = w.strip_suffix("'s").filter(|b| !b.is_empty()).unwrap_or(&w);
let w = w.strip_suffix("'s").or_else(|| w.strip_suffix("'S")).filter(|b| !b.is_empty()).unwrap_or(&w);
let w = w.trim_matches('\'');
if w.is_empty() || w.chars().any(|c| c.is_numeric()) || !w.chars().any(char::is_alphabetic) {
return true;
@@ -258,6 +258,15 @@ mod tests {
HashSet::new()
}
#[test]
fn all_caps_possessive_is_accepted() {
let d = Dictionary::english();
for w in ["MOTHER'S", "MONDAY'S", "Mother's", "mother's", "MOTHER’S"] {
assert!(d.check(w, &none()), "{w}");
}
assert!(!d.check("MOTHER'SX", &none()));
}
#[test]
fn bundled_dictionary_loads() {
let d = Dictionary::english();
+81
View File
@@ -847,6 +847,38 @@ fn variable_font_axes() {
assert!(heavy > light * 1.5, "{light} → {heavy}");
}
/// #8797: a style that carries the PostScript name of a variable font's named condensed light
/// instance lays out as wide as that instance's axis values, not at the default instance. Uses
/// Bahnschrift (Windows) or Ubuntu Sans (Ubuntu) when installed, and skips otherwise; the
/// synthesized-font tests in `font_names_tests` cover the instance lookup and its layout width
/// everywhere.
#[test]
fn postscript_name_of_a_variable_instance_lays_out_at_that_instance() {
use photocraft_doc::text::FontVariation;
let candidates = [
("C:\\Windows\\Fonts\\bahnschrift.ttf", "Bahnschrift-LightCondensed"),
("/usr/share/fonts/truetype/ubuntu/UbuntuSans[wdth,wght].ttf", "UbuntuSans-CondensedLight"),
];
let Some((bytes, ps)) = candidates.iter().find_map(|(p, ps)| std::fs::read(p).ok().map(|b| (b, *ps))) else {
return;
};
let mut e = TextEngine::new();
let Some(fam) = e.fonts.register_font_data(bytes).first().cloned() else {
return;
};
let width = |e: &mut TextEngine, style: CharStyle| {
let l = e.layout(&styled("Hamburgefonstiv 0123", style), 96.0);
l.lines[0].x1 - l.lines[0].x0
};
let base = CharStyle { font_family: fam.clone(), size_pt: 72.0, weight: 300, ..Default::default() };
let axes = vec![FontVariation { axis: "wght".into(), value: 300.0 }, FontVariation { axis: "wdth".into(), value: 75.0 }];
let family = width(&mut e, base.clone());
let instance = width(&mut e, CharStyle { postscript_name: Some(ps.into()), ..base.clone() });
let explicit = width(&mut e, CharStyle { variations: axes, ..base });
assert!(explicit < family * 0.9, "the condensed light axes are narrower: {explicit} vs {family}");
assert!((instance - explicit).abs() < 0.5, "{ps}: {instance} px, its axes: {explicit} px, default instance: {family} px");
}
#[test]
fn warp_bends_rendered_text_and_outlines() {
use photocraft_doc::text::TextWarp;
@@ -3030,3 +3062,52 @@ fn area_justify_fills_each_span_but_the_last_line() {
let (s0, s1) = span_of(last);
assert!((last.x0 - s0).abs() < 1.0 && last.x1 < s1 - 12.0, "{last:?} in {s0}..{s1}");
}
/// #8602: each colour is rasterised only over its own glyphs instead of the whole text, and the
/// pixels match a full-size pass per colour (plain, faux bold, underlined, rotated and warped
/// text; RGB and CMYK), up to float rounding in the coverage sums.
#[test]
fn per_colour_passes_match_full_size_passes() {
use photocraft_doc::text::TextWarp;
let mut e = TextEngine::new();
let text = "Many coloured runs of text, one colour a letter, across two lines\nof a point text layer";
let colours = [Color::rgb(0.9, 0.1, 0.1), Color::rgb(0.1, 0.7, 0.2), Color::rgb(0.1, 0.2, 0.9), Color::rgb(0.6, 0.6, 0.1)];
let runs = |bold: bool| {
text.char_indices()
.map(|(i, ch)| {
let colour = colours[i % colours.len()];
let style = CharStyle { size_pt: 28.0, color: colour, faux_bold: bold && i % 3 == 0, underline: i % 5 == 0, ..Default::default() };
TextRun { len: ch.len_utf8(), style }
})
.collect::<Vec<_>>()
};
let cases = [
TextLayer { text: text.into(), runs: runs(false), transform: Affine::translate(10.0, 40.0), ..Default::default() },
TextLayer { text: text.into(), runs: runs(true), transform: Affine::rotate(0.3).then(&Affine::translate(30.0, 90.0)), ..Default::default() },
TextLayer {
text: text.into(),
runs: runs(true),
transform: Affine::translate(10.0, 60.0),
warp: Some(TextWarp { style: "warpArc".into(), value: 40.0, horizontal_distortion: 0.0, vertical_distortion: 0.0, horizontal: true }),
..Default::default()
},
];
for (k, t) in cases.iter().enumerate() {
let l = e.layout(t, 72.0);
let warp = crate::render::layout_deform(&l, t);
for format in [PixelFormat::RGBA8, PixelFormat::CMYKA8] {
let fast = crate::render::rasterize_passes(&l, &t.transform, format, t.antialias, warp.as_ref(), true);
let full = crate::render::rasterize_passes(&l, &t.transform, format, t.antialias, warp.as_ref(), false);
assert_eq!(fast.rect, full.rect, "case {k}");
let (a, b) = (fast.surface.read_region(fast.rect), full.surface.read_region(full.rect));
assert!(!a.is_empty());
// Compare what shows: colour weighted by alpha (a fully transparent pixel's colour is moot).
let ch = format.channels();
let shown = |px: &[f32]| {
px.chunks_exact(ch).flat_map(|p| p[..ch - 1].iter().map(|c| c * p[ch - 1]).chain([p[ch - 1]]).collect::<Vec<_>>()).collect::<Vec<_>>()
};
let worst = shown(&a).iter().zip(&shown(&b)).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max);
assert!(worst <= 1.0 / 255.0, "case {k} {format:?}: largest difference {worst}");
}
}
}
+97 -2
View File
@@ -3,8 +3,9 @@
//! - **Default mode** (Photoshop's default, Use Classic Mode off): the crop box stays upright on
//! screen and the image turns behind it. A drag outside the box turns the image about the box's
//! centre (⇧ in 15° steps; the readout beside the pointer is the image's turn, clockwise
//! positive), a drag inside moves the image under the box (the box stays put), the arrow keys
//! nudge the image, and the handles resize the box on screen as usual. With **Auto Center
//! positive), a drag inside moves the image under the box (the box stays put; with View › Snap
//! on, the upright frame's edges and centre snap as a Classic Mode frame's do, #8580), the arrow
//! keys nudge the image, and the handles resize the box on screen as usual. With **Auto Center
//! Preview** on (the default), a box being resized keeps its centre where it is on screen (also
//! after a Space pan) while the handle follows the pointer, a drawn box moves to the middle of
//! the canvas on release, and X re-centres it. A drag inside never re-centres the box
@@ -425,6 +426,100 @@ mod tests {
assert!(r.iter().zip([20.0, 30.0, 80.0, 70.0]).all(|(a, b)| (a - b).abs() < 1e-3), "{r:?}");
}
/// Moves the image under the box from document point `from` by `d` (both in the view at the
/// press), checks the snap lines before release, and returns where the box's centre started
/// on screen.
fn image_drag(app: &mut PhotocraftApp, from: [f32; 2], d: [f32; 2], line: Option<(bool, f64)>) -> Pos2 {
let (a, b) = (xf(app).to_screen(from[0], from[1]), xf(app).to_screen(from[0] + d[0], from[1] + d[1]));
let mid = mid_of(screen_box(app));
let p = xf(app).to_doc(a);
tool_event(app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, NONE);
assert_eq!(app.prefs_rt.snap.as_ref().map(|s| &s.gesture), Some(&crate::snap_ui::Gesture::CropImage));
for t in [0.5, 1.0] {
let p = xf(app).to_doc(a + (b - a) * t);
tool_event(app, ToolEvent::Move { x: p[0], y: p[1], pressure: 1.0 }, NONE);
}
if let Some((vertical, pos)) = line {
assert!(app.prefs_rt.snap_lines.iter().any(|l| l.vertical == vertical && l.pos == pos), "{:?}", app.prefs_rt.snap_lines);
}
let p = xf(app).to_doc(b);
tool_event(app, ToolEvent::Up { x: p[0], y: p[1] }, NONE);
mid
}
fn assert_frame(app: &PhotocraftApp, want: [f64; 4], what: &str) {
let r = app.ui.crop_rect.unwrap();
assert!(r.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-3), "{what}: {r:?}, want {want:?}");
}
/// #8580: moving the image under the box snaps the frame's edges and centre with View › Snap
/// on, so the content can be centred in it without leaving the Crop tool. The box stays put.
#[test]
fn moving_the_image_snaps_the_frame_to_the_document_centre() {
for (snap, zoom, flip) in [(false, 1.0, false), (true, 1.0, false), (true, 2.0, false), (true, 1.0, true)] {
let mut app = app();
app.ui.extras.snap = snap;
app.ui.views[0].zoom = zoom;
app.ui.view.flip_horizontal = flip;
with_frame(&mut app);
// The frame [40, 30, 100, 70] goes 27 px right over the image: its centre lands 3 px
// short of the document's (100, 50), inside the snap radius at either zoom.
let mid = image_drag(&mut app, [70.0, 50.0], [-27.0, 0.0], snap.then_some((true, 100.0)));
let what = format!("snap {snap}, zoom {zoom}, flip {flip}");
assert_frame(&app, if snap { [70.0, 30.0, 130.0, 70.0] } else { [67.0, 30.0, 127.0, 70.0] }, &what);
assert!(mid_of(screen_box(&app)).distance(mid) < 0.01, "{what}: the box moved on screen");
assert!(app.prefs_rt.snap.is_none() && app.prefs_rt.snap_lines.is_empty());
}
}
/// The issue's case: a box larger than the canvas (to extend it) centres on the content's
/// layer, both ways. A turned box doesn't snap (its edges don't line up with anything).
#[test]
fn moving_the_image_centres_a_larger_box_on_a_layer() {
let mut app = app();
app.ui.extras.snap = true;
app.run("layer.new.layer", json!({"name": "logo"})).unwrap();
app.run("select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap();
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
app.run("select.deselect", json!({})).unwrap();
crate::crop_ui::ensure_frame(&mut app);
app.ui.crop_rect = Some([-40.0, -60.0, 140.0, 120.0]);
app.crop.default_frame = false;
// Its centre (50, 30) goes to (42, 38): 2 px from the logo's centre (40, 40).
image_drag(&mut app, [50.0, 30.0], [8.0, -8.0], Some((false, 40.0)));
assert_frame(&app, [-50.0, -50.0, 130.0, 130.0], "centred on the logo");
app.ui.crop_angle = 30.0;
sync(&mut app, None);
let p = xf(&app).to_doc(mid_of(screen_box(&app)));
tool_event(&mut app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, NONE);
assert!(matches!(app.crop.drag, Some(CropDrag::MoveImage { .. })));
assert!(app.prefs_rt.snap.is_none(), "a turned box doesn't snap");
tool_event(&mut app, ToolEvent::Up { x: p[0], y: p[1] }, NONE);
}
/// With View › Snap To › Layers off, smart guides still centre the box on a layer, although
/// the document's bounds are snap targets that aren't within reach.
#[test]
fn moving_the_image_snaps_to_smart_guides_beside_out_of_reach_targets() {
for smart in [false, true] {
let mut app = app();
app.ui.extras.snap = true;
app.ui.view.snap_to.layers = false;
app.ui.view.show.smart_guides = smart;
app.run("layer.new.layer", json!({"name": "logo"})).unwrap();
app.run("select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap();
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
app.run("select.deselect", json!({})).unwrap();
crate::crop_ui::ensure_frame(&mut app);
app.ui.crop_rect = Some([-40.0, -60.0, 140.0, 120.0]);
app.crop.default_frame = false;
image_drag(&mut app, [50.0, 30.0], [8.0, -8.0], smart.then_some((false, 40.0)));
let want = if smart { [-50.0, -50.0, 130.0, 130.0] } else { [-48.0, -52.0, 132.0, 128.0] };
assert_frame(&app, want, &format!("smart guides {smart}"));
}
}
/// #7088: the box's own Undo/Redo of a move (or a turn) in default mode puts the frame back
/// and moves the image, not the box: it stays put on screen (Auto Center Preview off) or goes
/// to the middle of the canvas (on).
+43
View File
@@ -71,6 +71,14 @@ pub fn intercept(app: &mut PhotocraftApp, id: &str, params: &Value) -> bool {
if docs.is_empty() {
return false;
}
// Once the window's GPU device is lost nothing can be drawn (`gpu_status::check_window`), so
// the prompt would never show. While saving for recovery keeps failing, quitting (the close
// button, File › Exit) must still go through rather than leave a blank window that can't be
// closed.
if id == EXIT && app.window_lost {
log::warn!("quitting a window that lost its GPU device before its unsaved work could be saved for recovery");
return false;
}
let prompt = Prompt { id: id.to_string(), params: params.clone(), target, docs, focused: None };
match &app.discard {
None => ask(app, prompt),
@@ -1042,6 +1050,41 @@ mod tests {
assert!(app.discard.is_some());
}
/// After the window's GPU device is lost nothing can be drawn, so a prompt could never be seen
/// or answered. While unsaved work can't be written for recovery (the save keeps retrying), the
/// close button must still quit, instead of opening a prompt that never shows.
#[test]
fn window_close_goes_through_once_the_window_device_is_lost() {
let mut app = app_with_docs(1);
make_dirty(&mut app, 0);
app.window_lost = true;
assert!(!press_window_close(&mut app), "the close isn't cancelled");
assert!(app.discard.is_none(), "no prompt opened");
}
/// The title bar's close button and File › Exit run `file.exit` through `menus::invoke`, which
/// parks it behind the prompt before any close request is made: once the window's device is
/// lost they must close instead.
#[test]
fn file_exit_quits_without_a_prompt_once_the_window_device_is_lost() {
let exit = |app: &mut PhotocraftApp| {
let mut out = egui::Context::default().run_ui(egui::RawInput::default(), |ui| {
let _ = crate::menus::invoke(app, ui.ctx(), EXIT, json!({}));
});
out.textures_delta.clear();
let commands = out.viewport_output.remove(&egui::ViewportId::ROOT).map(|o| o.commands).unwrap_or_default();
commands.iter().any(|c| matches!(c, egui::ViewportCommand::Close))
};
let mut app = app_with_docs(1);
make_dirty(&mut app, 0);
assert!(!exit(&mut app), "a window that can draw asks first");
assert!(app.discard.is_some());
app.discard = None;
app.window_lost = true;
assert!(exit(&mut app), "the window is asked to close");
assert!(app.discard.is_none(), "no prompt opened");
}
#[test]
fn unsaved_work_is_any_dirty_document_until_closing_is_confirmed() {
let mut app = app_with_docs(2);
+60 -3
View File
@@ -19,10 +19,25 @@ const STARTED_MAX_FRAMES: u64 = 120;
/// Hook run once the app has rendered its first frames (see [`PhotocraftApp::on_started`]).
pub type StartedHook = Box<dyn FnOnce(&mut PhotocraftApp)>;
/// Per-frame check: switch to the CPU canvas when the GPU faulted, and run the started hook.
/// Out-of-memory errors after which the GPU canvas is rebuilt by itself; past them the CPU
/// renderer stays until the user chooses Retry GPU, so a document that never fits doesn't rebuild
/// the canvas on every frame.
pub const OOM_AUTO_RETRIES: u32 = 3;
/// Per-frame check: switch to the CPU canvas when the GPU faulted, and run the started hook. An
/// out-of-memory error on a device that still works is not the end of the GPU for the session
/// (#6564): the dropped canvas freed its memory, so a new one is built at once and the views
/// redraw through it, up to [`OOM_AUTO_RETRIES`] times.
pub fn check(app: &mut PhotocraftApp, ctx: &egui::Context) {
if let Some(fault) = app.gpu.as_ref().and_then(|g| g.fault()) {
fall_back(app, &fault);
if fault.is_out_of_memory() && app.gpu_oom_retries < OOM_AUTO_RETRIES && can_retry_now(app) {
app.gpu_oom_retries += 1;
if retry_now(app) {
log::warn!("{fault}; rebuilt the GPU canvas (automatic retry {} of {OOM_AUTO_RETRIES})", app.gpu_oom_retries);
app.ui.gpu_fallback_notice = None;
}
}
ctx.request_repaint();
}
if app.started.is_some() {
@@ -372,6 +387,47 @@ mod tests {
assert_eq!(saves.get(), 2, "retry choice writes once");
}
/// #6564: an out-of-memory error on a device that still works rebuilds the GPU canvas by itself,
/// with no notice and the documents untouched, a few times a session; then the CPU renderer
/// stays until Retry GPU. Other errors and a loss still fall back at once.
#[test]
fn out_of_memory_rebuilds_the_gpu_canvas_by_itself() {
let _gpu = photocraft_testkit::gpu_lock();
let rs = std::panic::catch_unwind(|| egui_kittest::wgpu::create_render_state(crate::gpu_canvas::wgpu_setup(), Default::default()));
let Ok(rs) = rs else {
eprintln!("skipping: no GPU adapter");
return;
};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.set_wgpu(rs);
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
let ctx = egui::Context::default();
let oom = || photocraft_gpu::Fault::Error("Out of Memory".into());
for n in 1..=OOM_AUTO_RETRIES {
app.gpu_health().unwrap().mark(oom());
check(&mut app, &ctx);
assert!(app.gpu_active(), "retry {n}: the GPU canvas is back");
assert!(app.gpu_health().unwrap().is_ok(), "retry {n}: with a fresh health flag");
assert_eq!(app.perf.gpu_info.canvas, "gpu");
assert!(app.ui.gpu_fallback_notice.is_none(), "retry {n}: no recovery dialog");
assert_eq!(app.session.documents().len(), 1);
}
// Past the limit the CPU renderer stays and the dialog offers Retry GPU.
app.gpu_health().unwrap().mark(oom());
check(&mut app, &ctx);
assert!(!app.gpu_active() && app.ui.gpu_fallback_notice.is_some());
assert_eq!(app.perf.gpu_info.canvas, "cpu");
// A validation error is not retried by itself.
let rs = egui_kittest::wgpu::create_render_state(crate::gpu_canvas::wgpu_setup(), Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.set_wgpu(rs);
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
app.gpu_health().unwrap().mark(photocraft_gpu::Fault::Error("Validation Error".into()));
check(&mut app, &ctx);
assert!(!app.gpu_active() && app.ui.gpu_fallback_notice.is_some());
}
/// #6577: after a GPU error on a device that still works (out of memory, validation), Retry
/// GPU builds a new GPU canvas at once, without a restart or closing documents. After a loss
/// the device can't be used again, so the retry still waits for the next launch.
@@ -388,8 +444,9 @@ mod tests {
app.set_wgpu(rs);
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
let ctx = egui::Context::default();
// An error on a live device: Retry GPU rebuilds the canvas now.
app.gpu_health().unwrap().mark(photocraft_gpu::Fault::Error("Out of Memory".into()));
// An error on a live device (one not retried by itself, see #6564): Retry GPU rebuilds
// the canvas now.
app.gpu_health().unwrap().mark(photocraft_gpu::Fault::Error("Validation Error".into()));
check(&mut app, &ctx);
assert!(!app.gpu_active() && app.ui.gpu_fallback_notice.is_some());
assert!(can_retry_now(&app));
+10 -2
View File
@@ -3017,7 +3017,7 @@ curves Smooth 平滑
Next Brush 下一个画笔
First Brush 第一个画笔
Last Brush 最后一个画笔
Preview size Preview size
Preview size 预览大小
Memory Usage 内存使用
Per-document RAM limit (MB) 每个文档的 RAM 上限 (MB)
Installed RAM: {ram} MB; this limit: {percent}% 已安装 RAM:{ram} MB;此上限:{percent}%
@@ -3063,7 +3063,8 @@ curves Smooth 平滑
Right-to-left paragraph direction 从右到左的段落方向
Not rendered: this adjustment ({key}) is kept as imported. 未渲染:此调整({key})按导入时的原样保留。
# Print preview image (#8236)
Print preview image Print preview image
Print preview image 打印预览图像
# Color panel menu: modes, copy commands and colour ramps (#6000)
Hue Cube 色相立方体
Brightness Cube 亮度立方体
@@ -3081,3 +3082,10 @@ curves Smooth 平滑
Grayscale Ramp 灰度色带
Current Colors 当前颜色
Make Ramp Web Safe 使色带成为 Web 安全色
# File opening, export status and graphics driver information
Couldn't open {name}: {error} 无法打开 {name}:{error}
Exported {path} ({size}) 已导出 {path}({size})
Exported {path} 已导出 {path}
Exported {n} files 已导出 {n} 个文件
Driver: {driver} 驱动程序:{driver}
Can't render this file because it contains an unexpected character in line 2374 and column 12.
@@ -163,3 +163,18 @@ fn dragging_opacity_and_fill_labels_scrubs_percentages_in_one_history_step() {
assert!((value(&layer(&h)) - 1.0).abs() < 1e-6, "{name}: undo restores the original value");
}
}
#[test]
fn opacity_and_fill_stay_visible_but_disabled_with_no_layer_selected() {
let mut h = harness();
h.state_mut().session.execute("select.deselectLayers", json!({})).unwrap();
h.run_steps(4);
assert!(h.state().session.active().unwrap().active_layer.is_none());
for name in ["Opacity", "Fill"] {
let node = h
.query_all_by_role(Role::Button)
.find(|n| n.accesskit_node().label().as_deref() == Some(name))
.unwrap_or_else(|| panic!("{name}: the control disappeared with no layer selected"));
assert!(node.accesskit_node().is_disabled(), "{name}: should be greyed out");
}
}
+5
View File
@@ -962,6 +962,8 @@ pub struct PhotocraftApp {
gpu_render_state: Option<eframe::egui_wgpu::RenderState>,
/// Why the GPU canvas was dropped (see `gpu_status::fall_back`).
gpu_fault: Option<photocraft_gpu::Fault>,
/// Out-of-memory errors this session after which the GPU canvas was rebuilt by itself (#6564).
gpu_oom_retries: u32,
/// Run once the first frames have rendered (see [`Self::on_started`]).
started: Option<gpu_status::StartedHook>,
/// Frame and canvas-upload timings (exposed via `ui.inspect`).
@@ -1144,6 +1146,7 @@ impl PhotocraftApp {
window_recovery_retry: None,
gpu_render_state: None,
gpu_fault: None,
gpu_oom_retries: 0,
started: None,
perf: Default::default(),
stroke_trace: stroke_trace::StrokeTrace::from_env(),
@@ -2374,6 +2377,8 @@ impl eframe::App for PhotocraftApp {
wheel_nav::fold_legacy_pinch(ctx, raw_input);
}
raw_input.events.extend(self.take_synthetic_step());
// After the control channel's own events (`ui.type`) join the OS's, before any widget.
widgets::cap_text_events(&mut raw_input.events);
self.control_click.normalize(&mut raw_input.events, cfg!(target_os = "macos"));
// A pen held still is no touch screen's press-and-hold right click (stylus.rs).
let pen_down = self.stylus.pen_contacts.observe(&raw_input.events);
+25
View File
@@ -549,6 +549,10 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
};
*slot = !*slot;
let on = *slot;
if id == "view.show.grid" && on {
// Like the other Show items, enabling Grid also restores hidden Extras.
app.ui.view.extras = true;
}
if id == "view.rulers" {
crate::prefs_ui::remember_rulers(app, on);
}
@@ -1655,6 +1659,27 @@ pub fn apply_workspace(app: &mut PhotocraftApp) {
mod tests {
use super::*;
#[test]
fn showing_grid_restores_hidden_extras() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
let ctx = egui::Context::default();
app.ui.extras.grid = false;
app.ui.view.extras = false;
assert_eq!(invoke(&mut app, &ctx, "view.show.grid", json!({})).unwrap(), json!(true));
assert_eq!(checked(&app, "view.show.grid"), Some(true));
assert!(app.ui.view.extras, "showing Grid must make the overlay visible when Extras was hidden");
assert_eq!(invoke(&mut app, &ctx, "view.show.grid", json!({})).unwrap(), json!(false));
assert!(!app.ui.extras.grid);
assert!(app.ui.view.extras);
app.ui.extras.grid = true;
app.ui.view.extras = false;
assert_eq!(invoke(&mut app, &ctx, "view.show.grid", json!({})).unwrap(), json!(false));
assert!(!app.ui.view.extras, "hiding Grid must leave hidden Extras alone");
}
#[test]
fn sync_with_system_menu_keeps_saved_slots_and_its_checkmark_across_palettes() {
use photocraft_engine::prefs::{AppearanceMode, DarkTheme, LightTheme};
+76 -10
View File
@@ -1208,7 +1208,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if fixed_size {
picked = fixed_size_field(ui, &mut o.marquee_width, units, size[0]);
} else {
widgets::value_field(ui, &mut o.marquee_width, 0.001..=999.0, "", 62.0);
widgets::value_field(ui, &mut o.marquee_width, crate::tool_memory::style_range(&o.marquee_style), "", 62.0);
}
if icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked() {
std::mem::swap(&mut o.marquee_width, &mut o.marquee_height);
@@ -1217,7 +1217,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if fixed_size {
picked = fixed_size_field(ui, &mut o.marquee_height, units, size[1]).or(picked);
} else {
widgets::value_field(ui, &mut o.marquee_height, 0.001..=999.0, "", 62.0);
widgets::value_field(ui, &mut o.marquee_height, crate::tool_memory::style_range(&o.marquee_style), "", 62.0);
}
if let Some(name) = picked {
o.marquee_unit = name.into();
@@ -1602,7 +1602,7 @@ fn body_text_width(ui: &egui::Ui, s: &str) -> f32 {
/// Options-bar label: Photoshop writes "Size:" with a colon.
/// An options-bar label; one right before a value field scrubs it when dragged (Photoshop).
fn opt_label(ui: &mut egui::Ui, s: &str) {
pub(crate) fn opt_label(ui: &mut egui::Ui, s: &str) {
let t = Tokens::get(ui.ctx());
let text = if t.pro && !s.ends_with(':') { format!("{s}:") } else { s.to_string() };
let label = ui.add(egui::Label::new(RichText::new(tl!(&text)).color(t.text_dim)).selectable(false));
@@ -1610,7 +1610,7 @@ fn opt_label(ui: &mut egui::Ui, s: &str) {
}
/// The largest Fixed Size side, in pixels.
const FIXED_SIZE_MAX_PX: f64 = 300_000.0;
pub(crate) const FIXED_SIZE_MAX_PX: f64 = 300_000.0;
/// The resolution Fixed Size's units convert at (the active document's, 72 ppi without one) and
/// the document's size in pixels, which a percentage is of. The unit is the caller's.
@@ -2493,6 +2493,62 @@ fn opacity_row_layout(available: f32, label_width: f32, gap: f32) -> (f32, bool)
((available - reserved).max(40.0), show_label)
}
/// Blend mode, Opacity, the locks and Fill with no layer selected: Photoshop keeps them in place,
/// greyed out at Normal, 100% and unlocked, instead of removing them (#8880). Same layout as the
/// active layer's rows in `layers`.
fn inactive_blend_rows(ui: &mut egui::Ui, pro: bool) {
ui.add_enabled_ui(false, |ui| {
ui.horizontal(|ui| {
let opacity_label = if pro { tl!("Opacity:") } else { tl!("Opacity") };
let (blend_width, show_opacity_label) = opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x);
let mut m = BlendMode::Normal;
let options = blend_options(false, m);
widgets::dropdown_wheel_hovered(ui, "blend", &mut m, &options, blend_width);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut o = 100.0;
widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W);
if show_opacity_label {
scrub_pct_label(ui, opacity_label, &mut o, ui.available_width());
}
});
});
ui.add_space((theme::ROW_GAP - ui.spacing().item_spacing.y).max(0.0));
ui.horizontal(|ui| {
let lock_label = if pro { tl!("Lock:") } else { tl!("Lock") };
let lang = crate::i18n::current();
let fill_label = if pro { crate::i18n::tr_ctx(lang, "layers", "Fill:") } else { crate::i18n::tr_ctx(lang, "layers", "Fill") };
let gap = ui.spacing().item_spacing.x;
let icons_w = if pro { 5.0 * 20.0 + gap } else { 22.0 + gap };
let fits = body_text_width(ui, lock_label) + icons_w + body_text_width(ui, fill_label) + LAYER_PCT_W + 2.0 * gap <= ui.available_width();
if fits {
label(ui, lock_label);
}
if pro {
ui.scope(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
for (icon, tip) in [
("grid-3x3", tl!("Lock transparent pixels")),
("brush", tl!("Lock image pixels")),
("move", tl!("Lock position")),
("scan", tl!("Prevent auto-nesting in and out of Artboards and Frames")),
("lock", tl!("Lock all")),
] {
icons::button(ui, icon, 20.0, false, tip);
}
});
} else {
icons::button(ui, "lock-open", 22.0, false, tl!("Lock layer"));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut f = 100.0;
widgets::popup_value_field(ui, fill_label, &mut f, 0.0..=100.0, "%", LAYER_PCT_W);
scrub_pct_label(ui, fill_label, &mut f, ui.available_width());
});
});
ui.add_space(2.0);
});
}
fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
// A new active layer opens its parent groups and is scrolled into view (#152).
let reveal = crate::layer_reveal::track(app, ui.ctx());
@@ -2621,6 +2677,8 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
});
});
ui.add_space(2.0);
} else {
inactive_blend_rows(ui, t.pro);
}
// Top of the stack first, groups above their contents, closed groups' contents hidden (#126).
@@ -2683,6 +2741,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
crate::smart_ui::filter_rows(app, ui, &doc, l, depth, &mut actions);
}
}
// The bottom of everything drawn: the last layer's effect and Smart Filter rows sit
// under its own row (#8764).
let rows_bottom = ui.min_rect().bottom();
// #1721: the list takes drops everywhere, not only on a row (Photoshop): the space
// between two rows lands above the lower one, the empty space below the bottom-most
// row below it. Both copy with ⌥ held, move without.
@@ -2693,8 +2754,10 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if let Some(&(last, last_rect)) = drawn.last() {
let below = Rect::from_min_max(pos2(last_rect.left(), last_rect.bottom()), pos2(last_rect.right(), ui.clip_rect().bottom()));
layer_drop_zone(app, &ctx, ui, below, (last, last_rect), "below", &mut actions);
// A click on that empty space deselects every layer, as in Photoshop (#8100).
if below.height() > 0.0 && ui.interact(below, ui.id().with("layers-empty-space"), Sense::click()).clicked() && !selection.is_empty() {
// A click on that empty space deselects every layer, as in Photoshop (#8100). It
// starts under the last layer's sub-rows, which take their own clicks.
let empty = Rect::from_min_max(pos2(below.left(), rows_bottom), below.max);
if empty.height() > 0.0 && ui.interact(empty, ui.id().with("layers-empty-space"), Sense::click()).clicked() && !selection.is_empty() {
actions.push(("select.deselectLayers".into(), json!({})));
}
}
@@ -4606,9 +4669,10 @@ fn start_fx_drag(ctx: &egui::Context, layer: LayerId, effect: Option<usize>) {
fn fx_drag_feedback(ctx: &egui::Context, doc: &photocraft_doc::Document) {
let Some((from, effect)) = ctx.data(|d| d.get_temp::<FxDrag>(fx_drag_key())) else { return };
let Some(p) = ctx.input(|i| i.pointer.interact_pos()) else { return };
// `Effect::label` is the English key; translate for the drag ghost like the Layers panel rows.
let label = match effect {
Some(i) => doc.layer(LayerId(from)).and_then(|l| l.effects.items.get(i)).map_or("", |e| e.label()),
None => "Effects",
Some(i) => doc.layer(LayerId(from)).and_then(|l| l.effects.items.get(i)).map(|e| tl!(e.label())).unwrap_or_default(),
None => tl!("Effects"),
};
crate::layer_transfer::ghost(ctx, p, label);
if ctx.input(|i| i.modifiers.alt) {
@@ -4648,10 +4712,12 @@ fn fx_drop(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: Rect, actions: &
fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) {
let t = Tokens::get(ui.ctx());
let indent = 30.0 + depth as f32 * 14.0 + 34.0;
let mut rows: Vec<(String, bool, Option<&'static str>)> = vec![("Effects".into(), l.effects.enabled, None)];
// English `Effect::label` keys stay for kind matching; display goes through `tl!` (catalogs
// already have Drop Shadow → 投影, Effects → 效果, …).
let mut rows: Vec<(String, bool, Option<&'static str>)> = vec![(tl!("Effects").into(), l.effects.enabled, None)];
for e in &l.effects.items {
let kind = crate::layer_style::KINDS.iter().find(|k| k.1 == e.label()).map(|k| k.0);
rows.push((e.label().to_string(), e.enabled(), kind));
rows.push((tl!(e.label()).into(), e.enabled(), kind));
}
for (i, (name, on, kind)) in rows.into_iter().enumerate() {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag());
+203 -4
View File
@@ -110,6 +110,44 @@ fn rect_from(a: [f64; 2], b: [f64; 2]) -> photocraft_geom::Rect {
photocraft_geom::Rect::new(x0, y0, x1, y1)
}
/// The new slice drawn from `start` to `cur` under the Slice tool's Style (Normal, Fixed Ratio,
/// Fixed Size), as the marquee's (#8360). A fixed style is placed on the pixel the press is in.
/// Fixed Size has exactly its pixels. Fixed Ratio's longer side follows the drag to within a pixel
/// and the shorter side takes the ratio from it, as close as whole pixels allow: rounding each edge of
/// a fractional drag apart would make 2 : 1 into 5 × 3, and keeping to whole multiples of the
/// ratio would grow a 1366 : 768 slice in steps of 683 px.
fn new_rect(app: &PhotocraftApp, start: [f64; 2], cur: [f64; 2]) -> photocraft_geom::Rect {
let o = &app.ui.tool_options;
let (w, h) = (f64::from(o.slice_width), f64::from(o.slice_height));
let size = match o.slice_style.as_str() {
"fixedSize" => [w.round().max(1.0), h.round().max(1.0)],
"fixedRatio" if w > 0.0 && h > 0.0 => {
let end = crate::chrome_ui::marquee_end("fixedRatio", w, h, false, start, cur);
let k = (end[0] - start[0]).abs() / w;
// A drag shorter than half a pixel is a click: no size.
let follow = |long: f64, short: f64| {
let l = (k * long).round();
[l, if l > 0.0 { (l * short / long).round().max(1.0) } else { 0.0 }]
};
if w >= h {
follow(w, h)
} else {
let [l, s] = follow(h, w);
[s, l]
}
}
_ => return rect_from(start, crate::chrome_ui::marquee_end(&o.slice_style, w, h, false, start, cur)),
};
// The drag's extent and direction are from the press itself; only the slice is placed on the
// pixel the press is in.
let sign = |d: f64| if d < 0.0 { -1.0 } else { 1.0 };
// A slice drawn left or up grows from the far side of that pixel, so it holds the pixel: the
// edges a Normal slice gets from the same press (`rect_from`), also on a pixel's edge.
let dir = [sign(cur[0] - start[0]), sign(cur[1] - start[1])];
let corner = [0, 1].map(|i| if dir[i] < 0.0 { start[i].ceil() } else { start[i].floor() });
rect_from(corner, [corner[0] + dir[0] * size[0], corner[1] + dir[1] * size[1]])
}
/// Pointer input for the two slice tools. Returns true when the event was theirs.
pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, _mods: egui::Modifiers) -> bool {
let tool = app.ui.tool;
@@ -156,8 +194,11 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, _mods: egui::Modifiers) -
},
ToolEvent::Up { x, y } => match app.ui.slices.drag.take() {
Some(SliceDrag::New { start, .. }) => {
let r = rect_from(start, [x, y]);
if r.width() >= 1 && r.height() >= 1 && (r.width() > 2 || r.height() > 2) {
let r = new_rect(app, start, [x, y]);
// A drag of a pixel or two is a click, which selects; a Fixed Size slice is made
// by a click, at any size.
let fixed_size = app.ui.tool_options.slice_style == "fixedSize";
if r.width() >= 1 && r.height() >= 1 && (fixed_size || r.width() > 2 || r.height() > 2) {
if let Ok(v) = app.run("slice.new", json!({"rect": [r.x0, r.y0, r.width(), r.height()]})) {
app.ui.slices.selected = v.get("number").and_then(Value::as_u64).map(|n| n as usize);
}
@@ -263,7 +304,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
}
match &app.ui.slices.drag {
Some(SliceDrag::New { start, cur }) => {
let sr = to_screen(rect_from(*start, *cur));
let sr = to_screen(new_rect(app, *start, *cur));
painter.rect_stroke(sr, 0.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Middle);
let pts = [sr.left_top(), sr.right_top(), sr.right_bottom(), sr.left_bottom(), sr.left_top()];
painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, color), 4.0, 4.0));
@@ -368,7 +409,33 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
let locked = app.session.file_menu.slices_locked;
let t = crate::theme::Tokens::get(ui.ctx());
if tool == Tool::Slice {
ui.label(egui::RichText::new(tl!("Style: Normal")).color(t.text_dim));
// Style, Width and Height, as the marquee's (#8360).
let o = &mut app.ui.tool_options;
crate::panels::opt_label(ui, tl!("Style"));
let styles = [("normal".to_string(), tl!("Normal")), ("fixedRatio".to_string(), tl!("Fixed Ratio")), ("fixedSize".to_string(), tl!("Fixed Size"))];
if crate::widgets::dropdown(ui, "slice-style", &mut o.slice_style, &styles, 96.0) {
(o.slice_width, o.slice_height) = if o.slice_style == "fixedSize" { (64.0, 64.0) } else { (1.0, 1.0) };
}
let fixed_size = o.slice_style == "fixedSize";
let range = crate::tool_memory::style_range(&o.slice_style);
// Fixed Size shows whole pixels, the size the slice gets (`new_rect` rounds the same way),
// however the value was typed, stepped or scrubbed.
let field = |ui: &mut egui::Ui, v: &mut f32| {
if fixed_size {
crate::widgets::value_field_prec(ui, v, range.clone(), "px", 62.0, 0)
} else {
crate::widgets::value_field(ui, v, range.clone(), "", 62.0)
}
};
ui.add_enabled_ui(o.slice_style != "normal", |ui| {
crate::panels::opt_label(ui, tl!("Width"));
field(ui, &mut o.slice_width);
if crate::icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked() {
std::mem::swap(&mut o.slice_width, &mut o.slice_height);
}
crate::panels::opt_label(ui, tl!("Height"));
field(ui, &mut o.slice_height);
});
crate::widgets::vline(ui, 22.0);
if ui.add_enabled_ui(has_doc && !locked, |ui| crate::widgets::secondary_button(ui, tl!("Slices From Guides"), 0.0)).inner.clicked() {
let _ = app.run("slice.fromGuides", json!({}));
@@ -476,6 +543,138 @@ mod tests {
assert!(app.ui.slices.selected.is_some());
}
/// #8360: the Slice tool's Style, as the marquee's. Fixed Ratio keeps the drag's larger extent
/// and gives the other the ratio; Fixed Size draws a slice of that size where the drag starts,
/// towards the drag, also on a plain click.
#[test]
fn slice_style_fixes_the_ratio_or_the_size() {
let mut app = app();
app.ui.tool = Tool::Slice;
let m = egui::Modifiers::NONE;
let draw = |app: &mut PhotocraftApp, a: [f64; 2], b: [f64; 2]| {
pointer(app, ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, m);
pointer(app, ToolEvent::Move { x: b[0], y: b[1], pressure: 1.0 }, m);
pointer(app, ToolEvent::Up { x: b[0], y: b[1] }, m);
app.session.active().unwrap().doc.slices.list.last().unwrap().rect
};
let o = &mut app.ui.tool_options;
(o.slice_style, o.slice_width, o.slice_height) = ("fixedRatio".into(), 2.0, 1.0);
assert_eq!(draw(&mut app, [10.0, 10.0], [30.0, 40.0]), photocraft_geom::Rect::new(10, 10, 70, 40));
let o = &mut app.ui.tool_options;
(o.slice_style, o.slice_width, o.slice_height) = ("fixedSize".into(), 24.0, 16.0);
assert_eq!(draw(&mut app, [5.0, 5.0], [5.0, 5.0]), photocraft_geom::Rect::new(5, 5, 29, 21), "a click");
assert_eq!(draw(&mut app, [90.0, 70.0], [80.0, 60.0]), photocraft_geom::Rect::new(66, 54, 90, 70), "up and to the left");
// The preview while dragging shows the same rectangle the slice gets.
pointer(&mut app, ToolEvent::Down { x: 40.0, y: 40.0, pressure: 1.0 }, m);
pointer(&mut app, ToolEvent::Move { x: 41.0, y: 41.0, pressure: 1.0 }, m);
let Some(SliceDrag::New { start, cur }) = app.ui.slices.drag.clone() else { panic!("drawing a slice") };
assert_eq!(new_rect(&app, start, cur), photocraft_geom::Rect::new(40, 40, 64, 56));
pointer(&mut app, ToolEvent::Up { x: 41.0, y: 41.0 }, m);
// A press inside a pixel starts the slice on that pixel, still 24 x 16 pixels.
assert_eq!(draw(&mut app, [10.25, 30.75], [12.5, 31.5]), photocraft_geom::Rect::new(10, 30, 34, 46), "a press inside a pixel");
assert_eq!(
draw(&mut app, [70.5, 70.5], [60.25, 60.25]),
photocraft_geom::Rect::new(47, 55, 71, 71),
"and towards the upper left, holding the pressed pixel"
);
// A short drag up and to the left inside the pressed pixel goes up and to the left.
assert_eq!(draw(&mut app, [50.75, 50.75], [50.25, 50.25]), photocraft_geom::Rect::new(27, 35, 51, 51), "up-left within the pixel");
// A Fixed Size that isn't whole pixels (from a settings file) is rounded, not grown a pixel.
let o = &mut app.ui.tool_options;
(o.slice_width, o.slice_height) = (1.5, 2.4);
assert_eq!(draw(&mut app, [5.5, 5.5], [5.5, 5.5]), photocraft_geom::Rect::new(5, 5, 7, 7), "1.5 x 2.4 is 2 x 2");
// A size of a pixel or two is made too, not taken for a click that selects.
let o = &mut app.ui.tool_options;
(o.slice_width, o.slice_height) = (1.0, 2.0);
assert_eq!(draw(&mut app, [3.0, 3.0], [3.0, 3.0]), photocraft_geom::Rect::new(3, 3, 4, 5), "a 1 x 2 slice");
}
/// #8360: Fixed Ratio's longer side follows a fractional drag to the pixel and the shorter takes
/// the ratio from it, as close as whole pixels allow.
#[test]
fn fixed_ratio_slices_keep_their_ratio_from_a_fractional_drag() {
let mut app = app();
app.ui.tool = Tool::Slice;
let m = egui::Modifiers::NONE;
let mut draw = |w: f32, h: f32, a: [f64; 2], b: [f64; 2]| {
let o = &mut app.ui.tool_options;
(o.slice_style, o.slice_width, o.slice_height) = ("fixedRatio".into(), w, h);
pointer(&mut app, ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, m);
pointer(&mut app, ToolEvent::Up { x: b[0], y: b[1] }, m);
let r = app.session.active().unwrap().doc.slices.list.last().unwrap().rect;
(r.x0, r.y0, r.width(), r.height())
};
assert_eq!(draw(2.0, 1.0, [10.5, 10.5], [14.5, 11.0]), (10, 10, 4, 2), "not 5 x 2");
assert_eq!(draw(2.0, 1.0, [10.5, 10.5], [31.3, 13.0]), (10, 10, 21, 11), "a 20.8 px drag: 21 px, and half of it as near as pixels allow");
assert_eq!(draw(16.0, 9.0, [0.5, 0.5], [40.3, 10.0]), (0, 0, 40, 23), "a 39.8 px drag");
assert_eq!(draw(1366.0, 768.0, [0.0, 0.0], [40.0, 1.0]), (0, 0, 40, 22), "not in steps of 683 px");
assert_eq!(draw(9.0, 16.0, [0.0, 0.0], [1.0, 32.2]), (0, 0, 18, 32), "the longer side is the height");
assert_eq!(draw(4.0, 2.0, [60.7, 60.7], [50.2, 58.0]), (50, 55, 11, 6), "towards the upper left, holding the pressed pixel");
assert_eq!(draw(1.5, 1.0, [0.0, 40.0], [30.0, 41.0]), (0, 40, 30, 20), "a fractional ratio");
// A click inside a pixel doesn't move the pointer: it selects, as with Normal, and makes no
// slice (the drag is measured from the press, not from the pixel's corner).
let n = app.session.active().unwrap().doc.slices.list.len();
let o = &mut app.ui.tool_options;
(o.slice_width, o.slice_height) = (4.0, 1.0);
pointer(&mut app, ToolEvent::Down { x: 10.75, y: 10.75, pressure: 1.0 }, m);
pointer(&mut app, ToolEvent::Up { x: 10.75, y: 10.75 }, m);
assert_eq!(app.session.active().unwrap().doc.slices.list.len(), n, "a click makes no slice");
}
/// #8360: remembered Slice tool options are checked when loaded: an unknown Style is Normal
/// again, and Fixed Size is whole pixels.
#[test]
fn remembered_slice_options_are_checked() {
let mut o = crate::state::ToolOptions::default();
(o.slice_style, o.slice_width, o.slice_height) = ("fixedsize".into(), 0.5, 2.0);
crate::tool_memory::sanitize(&mut o);
assert_eq!(o.slice_style, "normal");
(o.slice_style, o.slice_width, o.slice_height) = ("fixedSize".into(), 1.5, 63.6);
crate::tool_memory::sanitize(&mut o);
assert_eq!((o.slice_width, o.slice_height), (2.0, 64.0));
}
/// #8360: the options bar's Style picks Fixed Size, which starts at 64 × 64 px, and the slice
/// drawn next has that size.
#[test]
fn options_bar_picks_the_slice_style() {
use egui::accesskit::Role;
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
let mut app = app();
app.sync_views();
app.ui.tool = Tool::Slice;
let mut h = Harness::builder().with_size(vec2(1400.0, 200.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
crate::panels::options_bar(app, ui);
}
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro);
h.run_steps(4);
let fields = |h: &Harness<'static, PhotocraftApp>| {
h.query_all_by_role(Role::SpinButton)
.filter(|n| !n.accesskit_node().is_disabled())
.filter_map(|n| n.accesskit_node().numeric_value())
.collect::<Vec<_>>()
};
assert!(fields(&h).is_empty(), "Normal has no Width or Height to set");
h.get_all_by_role(Role::ComboBox).find(|n| n.accesskit_node().value().as_deref() == Some("Normal")).unwrap().click();
h.run_steps(3);
h.get_by_label("Fixed Size").click();
h.run_steps(4);
let o = &h.state().ui.tool_options;
assert_eq!((o.slice_style.as_str(), o.slice_width, o.slice_height), ("fixedSize", 64.0, 64.0));
assert_eq!(fields(&h), [64.0, 64.0]);
let (app, m) = (h.state_mut(), egui::Modifiers::NONE);
pointer(app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, m);
pointer(app, ToolEvent::Up { x: 10.0, y: 10.0 }, m);
assert_eq!(app.session.active().unwrap().doc.slices.list[0].rect, photocraft_geom::Rect::new(10, 10, 74, 74));
}
#[test]
fn dragging_a_piece_moves_the_whole_covered_slice() {
// #7770: the slice under a later one is drawn as pieces; dragging one moves all of it.
@@ -317,3 +317,25 @@ fn with_the_layers_panel_closed_delete_leaves_the_filter() {
h.run_steps(4);
assert!(h.state().session.active().unwrap().doc.layer(LayerId(a)).is_none(), "Delete reached Smart A itself");
}
/// #8764: the bottom layer's filter rows sit where the empty space below the last row begins, so
/// that space's deselect-all click (#8100) must start under them: a click on a filter of a new
/// document's only layer, converted for Smart Filters, selects the filter.
#[test]
fn a_click_selects_a_filter_of_the_bottom_layer() {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
let id = s.execute("filter.convertForSmartFilters", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("filter.blur.gaussianBlur", json!({"radius": 1})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 900.0)).with_max_steps(64).with_step_dt(1.0 / 60.0).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
PhotocraftApp::new(s, crate::Services::default())
});
h.run_steps(4);
let blur = at(&h, "Gaussian Blur");
click_at(&mut h, blur);
let st = h.state().session.active().unwrap();
assert_eq!(st.active_layer, Some(LayerId(id)), "the smart object stays active");
assert_eq!(st.selected_layers(), [LayerId(id)], "the click did not deselect the layers");
assert!(selected(&h, "Gaussian Blur"));
}
+67 -3
View File
@@ -36,6 +36,9 @@ pub enum Gesture {
DrawMove { rect: [f64; 4] },
/// A guide being moved with the Move tool.
Guide,
/// Default crop mode, a drag inside an upright box: the image moves under it, so the frame
/// moves the other way over the document and its edges and centre snap ([`crop_image`]).
CropImage,
}
/// Snapping state of the drag in progress.
@@ -201,7 +204,9 @@ pub(crate) fn retarget_move(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Mo
fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) {
app.prefs_rt.snap = None;
app.prefs_rt.snap_lines.clear();
if app.session.active().is_none() {
// Puppet Warp's pins and Perspective Warp's quads go where they are dragged, as in Photoshop:
// the press is theirs, not the tool's (with the Move tool it took a layer move's Smart Guides).
if app.session.active().is_none() || app.distort.active() {
return;
}
// The tool the press acts as: ⌘ makes a painting tool the Move tool, and a selection tool
@@ -238,12 +243,21 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) {
// its edges.
let gesture = if app.crop.default_frame { Gesture::Point } else { Gesture::Move { rect } };
Some((gesture, Vec::new()))
} else if tool == Tool::Crop
&& crate::crop_mode::moves_image(app)
&& !app.crop.default_frame
&& crate::crop_ui::angle(app) == 0.0
&& app.ui.crop_rect.is_some_and(|r| crate::crop_ui::hit(r, p, crop_tol) == crate::crop_ui::Hit::Inside)
{
// The default mode moves the image under an upright box: the frame's edges and centre
// snap, as an edited Classic Mode frame's do, so content can be centred in it (#8580).
Some((Gesture::CropImage, Vec::new()))
} else if tool == Tool::Crop
&& (crate::crop_ui::turns_at(app, p)
|| app.ui.crop_rect.is_some_and(|r| crate::crop_ui::hit_turned(r, crate::crop_ui::angle(app), p, crop_tol) == crate::crop_ui::Hit::Inside))
{
// Turning the frame, moving a turned one (its edges don't line up with anything), or in
// the default mode moving or turning the image under the box: no snapping.
// the default mode turning the image or moving it under a turned box: no snapping.
None
} else if let Some(cut) = crate::canvas::selection_drag_kind(app, tool, p, mods) {
// A marquee or lasso inside the selection moves the outline (⌘: cuts its pixels into a
@@ -276,7 +290,11 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) {
// A moving selection never snaps back to where it started.
targets = targets.filtered(|k| k != SnapKind::Selection);
}
let smart = if snap_on(app) && matches!(gesture, Gesture::Move { .. }) && smart_on(app) { build(app, &exclude, true) } else { SnapTargets::default() };
let smart = if snap_on(app) && matches!(gesture, Gesture::Move { .. } | Gesture::CropImage) && smart_on(app) {
build(app, &exclude, true)
} else {
SnapTargets::default()
};
app.prefs_rt.snap = Some(ActiveSnap { gesture, start: p, targets, smart, disabled: false });
}
@@ -324,6 +342,12 @@ fn apply(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2
let (adj, lines) = set.snap_rect(moved, tol);
([p[0] + adj[0], p[1] + adj[1]], lines)
}
Gesture::CropImage => {
// Smart guides still snap when nothing in View › Snap To is within reach: the
// document's bounds alone keep `targets` from ever being empty.
let snapped = |set| crop_image(app, p, set, tol).filter(|(_, lines)| !lines.is_empty());
snapped(&snap.targets).or_else(|| snapped(&snap.smart)).unwrap_or((p, Vec::new()))
}
};
app.prefs_rt.snap_lines = lines;
out
@@ -413,6 +437,22 @@ fn crop_handle(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers, targets:
Some((view.map_or(q, |view| crate::crop_mode::from_press(app, view, q)), lines))
}
/// Snap the image being moved under the crop box (default mode): the frame moves by the image's
/// travel the other way over the document, so its edges and centre snap and the pointer moves by
/// the frame's correction the other way too (#8580). As `crop_ui` reads this drag, the pointer is
/// read in the camera at the press, since the view pans with the frame meanwhile.
fn crop_image(app: &PhotocraftApp, p: [f64; 2], targets: &SnapTargets, tol: f64) -> Option<([f64; 2], Vec<SnapLine>)> {
let crate::crop_ui::CropDrag::MoveImage { start, rect, view } = app.crop.drag? else { return None };
let q = crate::crop_mode::to_press(app, view, p);
let d = [q[0] - start[0], q[1] - start[1]];
let (adj, lines) = targets.snap_rect([rect[0] - d[0], rect[1] - d[1], rect[2] - d[0], rect[3] - d[1]], tol);
if adj == [0.0, 0.0] {
return Some((p, lines));
}
let q = [q[0] - adj[0], q[1] - adj[1]];
Some((view.map_or(q, |view| crate::crop_mode::from_press(app, view, q)), lines))
}
/// The bounds `[x0, y0, x1, y1]` of what drag `d` draws so far, for the tools whose drag Space
/// repositions and that snap (`None` for the rest, or a shape still too small to draw).
fn drawn_rect(app: &PhotocraftApp, d: &crate::canvas::Drag) -> Option<[f64; 4]> {
@@ -673,6 +713,30 @@ mod tests {
assert!(app.prefs_rt.snap.is_none());
}
/// Puppet Warp's pins go where they are dragged: they don't snap, with View › Snap on or
/// off, whatever tool was active when it started. With the Move tool a pin drag took the
/// layer move's Smart Guides and jumped to the other layer's edges.
#[test]
fn puppet_warp_pins_never_snap() {
for (tool, snap) in [(Tool::Move, false), (Tool::Move, true), (Tool::RectMarquee, true), (Tool::Brush, false)] {
let mut app = app_with_box();
app.ui.tool = tool;
app.ui.extras.snap = snap;
app.ui.view.show.smart_guides = true;
app.run("view.newGuide", json!({"orientation": "vertical", "position": 321})).unwrap();
let ctx = egui::Context::default();
crate::distort_ui::menu(&mut app, &ctx, "edit.puppetWarp", &json!({})).unwrap().unwrap();
let m = egui::Modifiers::NONE;
crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, m);
// The mover's left edge 3 px right of the target's, the pin 2 px from the guide.
crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 323.0, y: 100.0, pressure: 1.0 }, m);
assert!(app.prefs_rt.snap_lines.is_empty(), "{tool:?}, snap {snap}: {:?}", app.prefs_rt.snap_lines);
crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 323.0, y: 100.0 }, m);
let pins = &app.distort.puppet.as_ref().unwrap().warp.pins;
assert_eq!(pins.iter().map(|p| p.dst).collect::<Vec<_>>(), vec![[323.0, 100.0]], "{tool:?}, snap {snap}");
}
}
/// ⌘ turns a painting tool, or a marquee outside the selection, into the Move tool for the
/// drag: it gets the Move tool's box and smart-guide snapping, not the selected tool's.
#[test]
+11
View File
@@ -732,6 +732,14 @@ pub struct ToolOptions {
/// picked by right-clicking them); the size itself stays in pixels (#7910).
#[serde(default = "default_marquee_unit")]
pub marquee_unit: String,
/// Slice tool options bar: the same Styles as the marquee's, with the ratio or the size in px
/// (#8360).
#[serde(default = "default_marquee_style")]
pub slice_style: String,
#[serde(default = "one")]
pub slice_width: f32,
#[serde(default = "one")]
pub slice_height: f32,
/// Move tool: Auto-Select (with "layer" or "group" target) and Show Transform Controls.
/// Auto-Select is on with the "group" target by default, as in current Photoshop;
/// ⌘/Ctrl-click inverts it.
@@ -1003,6 +1011,9 @@ impl Default for ToolOptions {
marquee_width: 1.0,
marquee_height: 1.0,
marquee_unit: default_marquee_unit(),
slice_style: default_marquee_style(),
slice_width: 1.0,
slice_height: 1.0,
move_auto_select: true,
move_target: default_move_target(),
move_show_transform: false,
+19
View File
@@ -729,6 +729,25 @@ mod tests {
assert!(cmds.iter().any(|c| matches!(c, ViewportCommand::Close)), "{cmds:?}");
}
/// After the window's GPU device is lost, the prompt asking about unsaved work could never be
/// drawn: the close button quits instead of opening it (see `discard_ui::intercept`).
#[test]
fn close_quits_without_a_prompt_once_the_window_device_is_lost() {
let mut w = Win::new(1100.0);
w.app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
w.app.run("layer.new.layer", json!({})).unwrap();
w.frame(vec![]);
let close = w.caption(Caption::Close).center();
let asks = w.click(close);
assert!(!asks.iter().any(|c| matches!(c, ViewportCommand::Close)), "a window that can draw asks first: {asks:?}");
assert!(w.app.discard.is_some());
w.app.discard = None;
w.app.window_lost = true;
let quits = w.click(close);
assert!(quits.iter().any(|c| matches!(c, ViewportCommand::Close)), "{quits:?}");
assert!(w.app.discard.is_none(), "no prompt opened");
}
#[test]
fn minimize_and_maximize_follow_the_window_state() {
let mut w = Win::new(1100.0);
+18 -2
View File
@@ -198,6 +198,12 @@ pub fn restore(app: &mut PhotocraftApp) {
restore_brushes(app);
}
/// The values Width and Height take under a marquee or Slice tool Style: a size in px for Fixed
/// Size, else a ratio.
pub(crate) fn style_range(style: &str) -> std::ops::RangeInclusive<f32> {
if style == "fixedSize" { 1.0..=crate::panels::FIXED_SIZE_MAX_PX as f32 } else { 0.001..=999.0 }
}
/// Bring remembered options into the ranges their options-bar fields allow, so a hand-edited or
/// damaged file can't start a tool with a value its own controls could never set (a 1e30 px
/// feather, a 0-sided polygon). A NaN (JSON can't hold one, but it costs nothing to rule out)
@@ -221,10 +227,20 @@ pub(crate) fn sanitize(o: &mut ToolOptions) {
fit(&mut o.corner_radius, 0.0, 10000.0, d.corner_radius);
fit(&mut o.line_weight, 1.0, 1000.0, d.line_weight);
fit(&mut o.curve_fit, 0.5, 10.0, d.curve_fit);
// The marquee's Width and Height are a ratio or a size in px, as its Style says.
let (lo, hi) = if o.marquee_style == "fixedSize" { (1.0, 300_000.0) } else { (0.001, 999.0) };
// The marquee's and the Slice tool's Width and Height are a ratio or a size in px, as their
// Style says.
let (lo, hi) = style_range(&o.marquee_style).into_inner();
fit(&mut o.marquee_width, lo, hi, d.marquee_width);
fit(&mut o.marquee_height, lo, hi, d.marquee_height);
if !matches!(o.slice_style.as_str(), "normal" | "fixedRatio" | "fixedSize") {
o.slice_style = d.slice_style.clone();
}
let (lo, hi) = style_range(&o.slice_style).into_inner();
fit(&mut o.slice_width, lo, hi, d.slice_width);
fit(&mut o.slice_height, lo, hi, d.slice_height);
if o.slice_style == "fixedSize" {
(o.slice_width, o.slice_height) = (o.slice_width.round(), o.slice_height.round());
}
fit(&mut o.crop_custom_ratio_width, 0.0, f32::MAX, d.crop_custom_ratio_width);
fit(&mut o.crop_custom_ratio_height, 0.0, f32::MAX, d.crop_custom_ratio_height);
fit(&mut o.magic_eraser_opacity, 1.0, 100.0, d.magic_eraser_opacity);
+1 -1
View File
@@ -206,6 +206,7 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] {
Tool::Smudge => reset!(strength, finger_painting),
Tool::Dodge | Tool::Burn => reset!(tone_range, exposure, protect_tones),
Tool::Sponge => reset!(sponge_mode, vibrance),
Tool::Slice => reset!(slice_style, slice_width, slice_height),
Tool::Pen => reset!(vector_mode, pen_auto_add_delete),
Tool::CurvaturePen => reset!(vector_mode),
Tool::FreeformPen => reset!(vector_mode, curve_fit),
@@ -222,7 +223,6 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] {
| Tool::HistoryBrush
| Tool::PathSelection
| Tool::DirectSelection
| Tool::Slice
| Tool::SliceSelect
| Tool::AddAnchorPoint
| Tool::DeleteAnchorPoint
+140 -7
View File
@@ -814,6 +814,40 @@ pub fn popup_value_field(ui: &mut Ui, name: &str, value: &mut f32, range: std::o
out
}
/// The most characters a [`text_field`] holds unless its caller sets a limit: far beyond any
/// search, name or hex value, yet a pasted multi-gigabyte string is cut at the field instead of
/// being laid out every frame (#2150).
pub const TEXT_FIELD_LIMIT: usize = 1024;
/// The most characters one typed, pasted or IME-committed text event carries into the app
/// ([`cap_text_events`]): a megabyte of text, more than any field or text layer is pasted.
pub const INPUT_EVENT_LIMIT: usize = 1 << 20;
/// Cuts every text, paste and IME event to [`INPUT_EVENT_LIMIT`] characters (at a character
/// boundary) before any widget sees it, so no text field -- whatever its own limit -- has to
/// lay out an 8 GB paste (#2150).
pub fn cap_text_events(events: &mut [egui::Event]) {
fn cut(text: &mut String) -> bool {
let Some((end, _)) = text.char_indices().nth(INPUT_EVENT_LIMIT) else { return false };
text.truncate(end);
true
}
for event in events {
match event {
egui::Event::Text(text) | egui::Event::Paste(text) | egui::Event::Ime(egui::ImeEvent::Commit(text)) => {
cut(text);
}
// The highlighted range indexes the old text; past the cut it would point nowhere.
egui::Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => {
if cut(text) && active_range_chars.as_ref().is_some_and(|r| r.end > INPUT_EVENT_LIMIT) {
*active_range_chars = None;
}
}
_ => {}
}
}
}
/// Height of a [`text_field`]'s box, matching a [`value_field`].
pub const TEXT_FIELD_H: f32 = 24.0;
@@ -832,7 +866,8 @@ pub fn text_field(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, widt
text_field_ex(ui, id, text, hint, width, mono, None)
}
/// [`text_field`] limited to `char_limit` characters (a hex field's six digits, say).
/// [`text_field`] limited to `char_limit` characters (a hex field's six digits, say), or to
/// [`TEXT_FIELD_LIMIT`] without one.
pub fn text_field_ex(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, width: f32, mono: bool, char_limit: Option<usize>) -> Response {
let t = Tokens::get(ui.ctx());
let (rect, _) = ui.allocate_exact_size(vec2(width.max(16.0), TEXT_FIELD_H), Sense::hover());
@@ -842,13 +877,25 @@ pub fn text_field_ex(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, w
}
let font = if mono { theme::mono(12.0) } else { egui::FontId::proportional(12.5) };
let inner = rect.shrink2(TEXT_FIELD_PAD);
let limit = char_limit.unwrap_or(TEXT_FIELD_LIMIT);
// A value already past the limit (restored from a state file, set by automation) is shown and
// edited as its first `limit` characters, so it is never laid out whole; the caller's string
// changes only when that copy is edited.
let mut bounded = text.char_indices().nth(limit).map(|(end, _)| text[..end].to_string());
let shown = match bounded.as_mut() {
Some(b) => b,
None => &mut *text,
};
// TextEdit draws its text at the top of its rect; fill the box and centre the line in it, so
// the text sits on the field's middle like a dropdown's or value field's.
let mut edit =
egui::TextEdit::singleline(text).id(id).hint_text(hint).frame(egui::Frame::NONE).font(font).min_size(inner.size()).vertical_align(egui::Align::Center);
if let Some(limit) = char_limit {
edit = edit.char_limit(limit);
}
let edit = egui::TextEdit::singleline(shown)
.id(id)
.hint_text(hint)
.frame(egui::Frame::NONE)
.font(font)
.min_size(inner.size())
.vertical_align(egui::Align::Center)
.char_limit(limit);
// A child Ui keeps the field's selection colour local: `Visuals::selection` also fills selected
// list rows and slider trails, which must keep the theme's.
let layout = egui::Layout::centered_and_justified(egui::Direction::TopDown);
@@ -856,7 +903,13 @@ pub fn text_field_ex(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, w
// The theme's 24 pt interact height would make the edit taller than the box's inside.
child.spacing_mut().interact_size.y = inner.height();
child.style_mut().visuals.selection.bg_fill = t.field_selection();
child.add(edit)
let response = child.add(edit);
if let Some(b) = bounded
&& response.changed()
{
*text = b;
}
response
}
fn first_field_id() -> egui::Id {
@@ -2225,6 +2278,86 @@ mod tests {
assert_eq!(h.state().0, "abc", "typing reaches the bound string");
}
/// #2150: a huge paste into a plain text field stops at its limit instead of growing the
/// string (and its laid-out text) without bound.
#[test]
fn text_field_caps_a_huge_paste() {
use egui::accesskit::Role;
let mut h = Harness::new_ui_state(|ui, s: &mut String| drop(super::text_field(ui, egui::Id::new("tf"), s, "", 140.0, false)), String::new());
crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
h.run();
h.get_by_role(Role::TextInput).click();
h.run_steps(1);
h.event(egui::Event::Paste("9".repeat(200_000)));
h.run_steps(1);
assert_eq!(h.state().chars().count(), super::TEXT_FIELD_LIMIT);
// A second paste adds nothing past the limit.
h.event(egui::Event::Paste("9".repeat(10)));
h.run_steps(1);
assert_eq!(h.state().chars().count(), super::TEXT_FIELD_LIMIT);
}
/// #2150: a value already past the limit is laid out only up to it, and stays the caller's
/// until the field is edited, which keeps the bounded copy.
#[test]
fn text_field_bounds_an_oversized_existing_value() {
use egui::accesskit::Role;
let long = "9".repeat(super::TEXT_FIELD_LIMIT * 4);
let mut h = Harness::new_ui_state(|ui, s: &mut String| drop(super::text_field(ui, egui::Id::new("tf"), s, "", 140.0, false)), long.clone());
crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
h.run();
assert_eq!(*h.state(), long, "drawing alone leaves the caller's value alone");
assert_eq!(h.get_by_role(Role::TextInput).value().map(|v| v.chars().count()), Some(super::TEXT_FIELD_LIMIT));
h.get_by_role(Role::TextInput).click();
h.run_steps(1);
h.event(egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE });
h.run_steps(1);
assert!(h.state().chars().count() < super::TEXT_FIELD_LIMIT, "an edit keeps the bounded copy");
}
/// #2150: text queued by the control channel (`ui.type`) joins the input in `raw_input_hook`
/// and is cut like the OS's, so the cap must run after it is appended.
#[test]
fn raw_input_hook_caps_synthetic_text() {
use eframe::App as _;
let mut app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.synthetic.push(egui::Event::Text("x".repeat(super::INPUT_EVENT_LIMIT + 10)));
let ctx = egui::Context::default();
let mut raw = egui::RawInput::default();
app.raw_input_hook(&ctx, &mut raw);
let texts: Vec<usize> = raw.events.iter().filter_map(|e| if let egui::Event::Text(t) = e { Some(t.chars().count()) } else { None }).collect();
assert_eq!(texts, [super::INPUT_EVENT_LIMIT]);
}
/// #2150: every text-carrying event is cut to the app-wide limit at a character boundary
/// before any widget sees it; other events and short text pass untouched.
#[test]
fn oversized_text_events_are_cut_before_widgets_see_them() {
let long = "é".repeat(super::INPUT_EVENT_LIMIT + 5);
let mut events = vec![
egui::Event::Paste(long.clone()),
egui::Event::Text(long.clone()),
egui::Event::Ime(egui::ImeEvent::Commit(long.clone())),
egui::Event::Ime(egui::ImeEvent::Preedit { text: long, active_range_chars: Some(0..super::INPUT_EVENT_LIMIT + 2) }),
egui::Event::Text("ok".into()),
egui::Event::Copy,
];
super::cap_text_events(&mut events);
for e in &events[..4] {
let (egui::Event::Paste(t)
| egui::Event::Text(t)
| egui::Event::Ime(egui::ImeEvent::Commit(t))
| egui::Event::Ime(egui::ImeEvent::Preedit { text: t, .. })) = e
else {
panic!("{e:?}")
};
assert_eq!(t.chars().count(), super::INPUT_EVENT_LIMIT);
}
assert!(matches!(&events[3], egui::Event::Ime(egui::ImeEvent::Preedit { active_range_chars: None, .. })), "the range past the cut is dropped");
assert_eq!(events[4], egui::Event::Text("ok".into()));
assert_eq!(events[5], egui::Event::Copy);
}
/// Sets up a test with one value field. Its state is its value and how many times it changed.
fn field(value: f32, range: std::ops::RangeInclusive<f32>) -> Harness<'static, (f32, u32)> {
let mut h = Harness::new_ui_state(
+1 -1
View File
@@ -394,7 +394,7 @@ no MCP framing, no app start-up per command. Configure its file access with the
| `doc.open` / `doc.new` | `{path}` / `file.new` params |
| `doc.place` | `{path, scale? (percent), fit? (true), center? ([x, y]), index?}`: places a file as a Smart Object layer above the active one and returns `{layer, scale, bounds}`. `path` resolves beneath the read root, so this is the capability-scoped `file.placeEmbedded` — that command itself still refuses an ambient path |
| `doc.replaceSmartObject` | `{path, layer? (id or name; default the active layer), index?}` → the command's `{layer, instances}`: replaces a Smart Object's contents (one history step, transform and filters kept). `path` resolves beneath the read root, so this is the capability-scoped `layer.smartObjects.replaceContents`, which itself still refuses an ambient path (#8061) |
| `doc.exportLayers` | `{dir, format? (png), prefix?, visibleOnly? (true), quality?, index?}` → `{files, count}`: one file per exported layer, named like `file.export.layersToFiles` names them. `dir` resolves beneath the write root, so the ambient command stays refused. The bytes come from the command's own core, so both paths write the same files; the write capability refuses to overwrite an existing file |
| `doc.exportLayers` | `{dir, format? (png), prefix?, visibleOnly? (true), quality?, index?}` → `{files, skipped}` (plus `count` for workspace exports): one file per exported top-level layer, named like `file.export.layersToFiles` names them. `count` is present only for workspace exports and counts written files only. `skipped` is always an array (empty when nothing is omitted), in top-to-bottom order, of `{layer, name, reason}`: the session layer id, original unsanitized name, and `"hidden by visibleOnly"` or `"adjustment layer has no pixels of its own"`. Visibility takes precedence for hidden adjustments; `visibleOnly:false` exports hidden non-adjustment layers. Groups export merged; their children are not separate exports or skips. Existing filenames/numbering are unchanged. `dir` resolves beneath the write root, so the ambient command stays refused. The bytes come from the command's own core, so both paths write the same files; the write capability refuses to overwrite an existing file. Encode/write failures are errors, not skips |
| `doc.exportDataSets` | `{dir, format? (png), dataSets? (all), naming? ("{name}\|{index}\|{document}"), index?}` → `{files, count}`: the capability-scoped `file.export.dataSetsAsFiles`. The batch is planned first and colliding names are refused before any file is written, as in the command |
| `doc.importDataSets` | `{path, delimiter? (,), index?}` → `{imported}`: the capability-scoped `file.import.variableDataSets`; the CSV is read beneath the read root |
| `doc.save` | `{path?, format?, quality?, index?, tiffLayers?, tgaBits?, ddsCompression?, ddsMipmaps?}` (`.pcraft` native, else export by extension; set `tiffLayers: true` to preserve TIFF layers rather than exporting a flattened TIFF; `tgaBits` 24 or 32 sets a TGA's bits per pixel, and `ddsCompression`/`ddsMipmaps` a DDS texture's compression and mip chain, as `app.save` does). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged. A successful layered save (PSD, PSB or `.pcraft`) records the current revision as saved and makes the file the document's path, so `session.list` reports `dirty: false`; a flat export is a copy and leaves the document dirty |
+10
View File
@@ -263,6 +263,16 @@ Tools:
- `jobs_list` (running background jobs with progress, then recently finished ones with their result or error), `jobs_cancel` (one job by id, or every running job)
- bridge only: `ui_inspect`, `ui_screenshot`, `ui_pointer`, `ui_menu_invoke`, `ui_set`, `control_call`
`doc_export_layers` in workspace headless mode returns `{files, count, skipped}`. `count` is
only the number of files written. Each skipped top-level layer is reported as
`{layer, name, reason}` (session id, original name, explanation); `skipped` is `[]` when every
layer exports. Hidden layers are reported when `visible_only` is true (the default; `visibleOnly` in the engine/serve params), and
standalone adjustments are reported because they have no pixels of their own. Groups still
export merged, so their children are not separate skips. Filename numbering is unchanged;
encoding or write failures remain errors. The engine command and trusted-local exports add
the same `skipped` array to their existing `{files}` response. See the
[`doc.exportLayers` protocol entry](control-protocol.md#headless-server) for details.
Claude Code (`.mcp.json` in the repo root, or `claude mcp add`):
```json
+3 -2
View File
@@ -43,8 +43,9 @@ everything: the document keeps its file and its unsaved changes. Automation foll
OpenRaster elements other than `stack` and `layer` (MyPaint's text, for example), missing
layer images and unknown composite operations open with a warning instead of failing the file.
Krita's W3C Soft Light (`svg:soft-light`, `krita:soft_light_svg`) and its own Hard Mix open as
Photoshop's Soft Light and Hard Mix, which differ slightly, also with a warning.
Krita's W3C Soft Light (`svg:soft-light`, `krita:soft_light_svg`) is Photoshop's Soft Light and
opens as it. Krita's own Soft Light (`krita:soft_light`, `sqrt` over dark backdrops) and its own
Hard Mix open as Photoshop's Soft Light and Hard Mix, which differ slightly, with a warning.
## Limits and safety
+3 -1
View File
@@ -1,6 +1,6 @@
# UI parity: tools, handles, modifiers and feel
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026
How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the
handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation,
@@ -148,6 +148,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
| Overlays (thirds, grid, diagonal, triangle, golden ratio/spiral), O / ⇧O | done | TOOL-214-20 |
| Crop shield, Show Cropped Area (H) | done | TOOL-214-22 |
| Default mode: the image turns and moves behind an upright box; Auto Center Preview; Use Classic Mode | done | TOOL-214-23 |
| Default mode: moving the image under an upright box snaps the frame's edges and centre (View › Snap), so content centres in the box without the Move tool | done | `crop_mode.rs::moving_the_image_snaps_the_frame_to_the_document_centre`, `crop_mode.rs::moving_the_image_centres_a_larger_box_on_a_layer` (#8580, 2026-10-22) |
| W × H × Resolution with units, built-in size presets, Front Image | done | TOOL-214-9 (`crop_size.rs`) |
| Saved custom crop presets (New / Delete Crop Preset, kept with the preferences; moving them to another computer is not offered) | done | TOOL-214-9 (`crop_presets.rs`, `tool.presets.*` with tool `crop`, #2723) |
| Fill: Content-Aware Fill: a turned or extending crop fills the uncovered corners and strips from the image (each piece with its own sampling window), on the Background or else the active pixel layer, in the crop's one undo step; Generative Expand not offered | done | `tests/it/crop_content_aware.rs`, `crop_ui.rs::commit_passes_the_fill_choice` (#3823, 2026-10-12) |
@@ -242,6 +243,7 @@ Paragraph; the rest open as in-app floating windows.
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | minor | Crop default mode: a drag inside an upright box snaps the frame's edges and centre to the document, layers, guides and grid, as a Classic Mode frame's move does (#8580); no percentage changed |
| 2026-10-21 | minor | Adjustment clipping moved above Properties editors to remain visible in compact docks (#5231); scope, undo/redo and 8/16/32-bit pixel regressions; readiness estimate unchanged |
| 2026-10-19 | minor | A guide being moved or dragged out of a ruler shows an `X:` / `Y:` position readout beside the pointer, from the zero point in the ruler unit (#7680) |
| 2026-10-17 | minor | The wheel steps value fields: a focused field takes it wherever the pointer is (the canvas and panels don't scroll), an unfocused one only a gesture that starts over it (#5473, #4061); no percentage changed |