Merge main into trimmed integration; reconcile blur, pattern and Liquify behavior

This commit is contained in:
FredApps
2026-10-29 23:47:53 +01:00
76 changed files with 3132 additions and 442 deletions
Generated
+1
View File
@@ -5023,6 +5023,7 @@ dependencies = [
name = "photocraft-algo"
version = "0.17.0"
dependencies = [
"criterion",
"photocraft-color",
"photocraft-geom",
"photocraft-raster",
+4 -1
View File
@@ -83,7 +83,10 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) ->
h.session.actions.list = set.clone();
h.open(input)?;
for (id, p) in &actions {
if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) {
if photocraft_engine::actions_cmds::shell_view_command(id)
|| photocraft_engine::actions_cmds::shell_save_command(id)
|| photocraft_engine::actions_cmds::noop_step(&h.session, id, p)
{
continue;
}
let r = h.command_run(id, p.clone()).map_err(|source| CliError::Command { id: id.clone(), source })?;
+5
View File
@@ -93,6 +93,11 @@ pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) ->
}
let cmds = command_list(a)?;
for (id, params) in cmds {
// A step that is a silent no-op in action playback (Deselect with nothing selected, an Arrange that cannot move the layer) is one in a script too (#8802).
if photocraft_engine::actions_cmds::noop_step(&h.session, &id, &params) {
print_json(out, &json!({"command": id, "result": null}), JsonStyle::Compact)?;
continue;
}
let r = h.command_run(&id, params).map_err(|source| CliError::Command { id: id.clone(), source })?;
if let Some(message) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
return Err(CliError::NestedFailure { id, message });
+75
View File
@@ -1421,3 +1421,78 @@ fn control_token_env_vars_are_the_flags_fallbacks() {
let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(free_port().to_string()).env("PHOTOCRAFT_CONTROL_TOKEN", ""));
assert!(err.contains("photocraft-cli serving on") && err.contains("photocraft-cli control token: "), "{err}");
}
/// #8802: Deselect with nothing selected is skipped in a `run`, as action playback does (#6744),
/// so a script that deselects after Reveal Selection still finishes and saves.
#[test]
fn run_skips_deselect_with_nothing_selected() {
let folder = tmp("deselect-run");
let native = folder.join("out.pcraft").to_string_lossy().to_string();
let Invoke { code, stdout, stderr } = invoke(&[
"run",
"--new",
r#"{"width":16,"height":16}"#,
"--cmd",
"select.rect",
"--params",
r#"{"x":0,"y":0,"width":8,"height":16}"#,
"--cmd",
"layer.new.layer",
"--cmd",
"layer.layerMask.revealSelection",
"--cmd",
"select.deselect",
"--cmd",
"layer.new.layer",
"--params",
r#"{"name":"after"}"#,
"--out",
&native,
]);
assert_eq!(code, 0, "{stderr}");
let results: Vec<Value> = stdout.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(results.len(), 5, "every command reports a result: {stdout}");
assert_eq!(results[3], json!({"command": "select.deselect", "result": null}));
let Invoke { code, stdout, stderr } = invoke(&["info", &native, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(info["layers"][0]["name"], "after", "the steps after Deselect ran: {info}");
// Deselect with a selection still deselects: Inverse afterwards finds nothing to invert.
let Invoke { code, stderr, .. } = invoke(&[
"run",
"--new",
r#"{"width":16,"height":16}"#,
"--cmd",
"select.all",
"--cmd",
"select.deselect",
"--cmd",
"select.inverse",
"--out",
&native,
]);
assert_eq!(code, 1, "{stderr}");
assert!(stderr.contains("select.inverse") && stderr.contains("no selection"), "{stderr}");
let _ = std::fs::remove_dir_all(&folder);
}
/// #8802: a batched action that starts with Deselect plays on files with nothing selected, as the
/// same action does in the Actions panel (#6744).
#[test]
fn batch_skips_deselect_with_nothing_selected() {
let dir = tmp("deselect-batch");
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
std::fs::create_dir_all(dir.join("in")).unwrap();
let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]);
assert_eq!(code, 0, "{stderr}");
std::fs::write(dir.join("steps.json"), json!([["select.deselect", {}], ["image.imageRotation.90cw", {}]]).to_string()).unwrap();
let Invoke { code, stdout, stderr } = invoke(&["batch", "--actions", &path("steps.json"), "--in", &path("in"), "--out", &path("out")]);
assert_eq!(code, 0, "{stdout}{stderr}");
assert!(stdout.contains("1 succeeded, 0 failed"), "{stdout}");
let Invoke { code, stdout, stderr } = invoke(&["info", &path("out/a.png"), "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "the step after Deselect ran: {info}");
let _ = std::fs::remove_dir_all(&dir);
}
+1 -7
View File
@@ -635,12 +635,6 @@ fn shell_word(s: &str) -> String {
if s.bytes().all(|b| b.is_ascii_alphanumeric() || b"/._-+:,@".contains(&b)) { s.to_string() } else { format!("'{}'", s.replace('\'', r"'\''")) }
}
/// The release-check transport, or `None` inside Flatpak: `flatpak update` owns updates there.
/// The sandbox has network (for the `--control` server), which does not change this.
fn update_transport(flatpak_id: Option<&std::ffi::OsStr>) -> Option<photocraft_engine::updates_cmds::CheckFn> {
flatpak_id.is_none().then(crate::updates::service)
}
pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> Services {
let clip: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
let clip_unreadable: Rc<Cell<bool>> = Rc::default();
@@ -705,7 +699,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
inbox: None,
open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))),
beep: Some(platform_beep),
check_updates: update_transport(std::env::var_os("FLATPAK_ID").as_deref()),
check_updates: Some(crate::updates::service()),
clipboard_set_image: Some({
let clip = clip.clone();
guarded_clipboard_set(move |w: u32, h: u32, px: &[u8]| {
+2 -3
View File
@@ -182,9 +182,8 @@ fn open_filters_keep_literal_extensions_on_windows_and_macos() {
}
#[test]
fn flatpak_builds_have_no_update_transport() {
assert!(update_transport(Some(std::ffi::OsStr::new("ai.storyteller.photocraft"))).is_none());
assert!(update_transport(None).is_some());
fn native_services_provide_update_transport() {
assert!(native(None).check_updates.is_some());
}
#[test]
+5
View File
@@ -23,3 +23,8 @@ rayon = { workspace = true }
[dev-dependencies]
photocraft-testkit = { workspace = true }
serde_json = { workspace = true }
criterion = { workspace = true }
[[bench]]
name = "blur"
harness = false
+33
View File
@@ -0,0 +1,33 @@
//! `cargo bench -p photocraft-algo`
use criterion::{Criterion, criterion_group, criterion_main};
use photocraft_algo::{FilterParams, apply};
use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
fn textured(n: u32) -> Surface {
let r = Rect::new(0, 0, n as i32, n as i32);
let mut s = Surface::new(PixelFormat::RGBA8);
let bytes: Vec<u8> = (0..n).flat_map(|y| (0..n).flat_map(move |x| [(x ^ y) as u8, (x / 3) as u8, (y * 7) as u8, 255])).collect();
s.write_interleaved(r, &bytes);
s
}
/// Gaussian Blur's direct-kernel path (radius up to 4; larger radii use box passes), including
/// the variance-matched kernels below radius 1 (#8544).
fn gaussian_direct(c: &mut Criterion) {
let n = 2048;
let s = textured(n);
let area = Rect::new(0, 0, n as i32, n as i32);
for radius in [0.3f32, 0.8, 3.0] {
let p = FilterParams::GaussianBlur { radius };
c.bench_function(&format!("gaussian_blur_{radius}px_2048"), |b| b.iter(|| apply(&s, &p, area, area, None)));
}
}
criterion_group! {
name = benches;
config = Criterion::default().sample_size(10);
targets = gaussian_direct
}
criterion_main!(benches);
+37
View File
@@ -31,6 +31,43 @@ pub(crate) fn gaussian_kernel(sigma: f32) -> Vec<f32> {
k.into_iter().map(|v| v / s).collect()
}
/// Gaussian Blur's kernel for `radius` (its standard deviation). Sampling the curve at whole
/// pixels leaves a small radius almost no blur: at 0.3 the neighbour weight is about 0.004, a
/// tenth of the spread asked for (#8544). Below 1 the sampling width is instead chosen so the
/// kernel has exactly the variance `radius²`; from 1 up the sampled kernel is within about 0.4%
/// of it (its 3σ truncation), so larger radii are unchanged and the blur grows smoothly from 0.05.
pub(crate) fn gaussian_blur_kernel(radius: f32) -> Vec<f32> {
if !(0.05..1.0).contains(&radius) {
return gaussian_kernel(radius);
}
let variance = |k: &[f32]| {
let r = (k.len() / 2) as f32;
k.iter().enumerate().map(|(i, w)| w * (i as f32 - r).powi(2)).sum::<f32>()
};
// Sampled at `width` with one fixed half-width for every radius below 1: its variance then
// rises steadily with `width` (the search can't settle on a step where taps are added), and
// the blur grows without the dips a width chosen per radius made at 1/3 and 2/3. Three is the
// half-width `gaussian_kernel` uses at radius 1, so the hand-over is smooth too.
let half = 3;
let sampled = |width: f32| {
let k: Vec<f32> = (-half..=half).map(|i| (-(i * i) as f32 / (2.0 * width * width)).exp()).collect();
let s: f32 = k.iter().sum();
k.into_iter().map(|v| v / s).collect::<Vec<f32>>()
};
let target = radius * radius;
// The sampled kernel's variance only falls short of sigma², so the width lies above `radius`.
let (mut lo, mut hi) = (radius, radius + 1.0);
for _ in 0..40 {
let mid = 0.5 * (lo + hi);
if variance(&sampled(mid)) < target {
lo = mid;
} else {
hi = mid;
}
}
sampled(0.5 * (lo + hi))
}
/// Separable convolution of `src` (premultiplied internally) for `out`.
/// `src` must cover `out` grown by the kernel radii.
pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bool) -> Vec<f32> {
+70
View File
@@ -2,6 +2,76 @@ use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::Surface;
/// #8544, rendered: Gaussian Blur at 0.1–0.3 px through the filter itself (the direct-kernel
/// path) visibly softens a hard edge, more with each step, as the kernel predicts.
#[test]
fn small_gaussian_radii_soften_a_rendered_edge() {
let canvas = Rect::new(0, 0, 20, 4);
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(canvas, &[0.0, 0.0, 0.0, 1.0]);
s.fill_rect(Rect::new(10, 0, 20, 4), &[1.0, 1.0, 1.0, 1.0]);
let mut last = 0.0f32;
for radius in [0.1f32, 0.2, 0.3] {
let p = FilterParams::GaussianBlur { radius };
assert!(box_widths(&p).is_none(), "the direct-kernel path");
let out = crate::apply(&s, &p, canvas, canvas, None);
// The last black pixel takes the white side's share of the kernel.
let got = out.pixel(9, 1)[0];
let k = gaussian_blur_kernel(radius);
let want: f32 = k[k.len() / 2 + 1..].iter().sum();
assert!((got - want).abs() <= 1.0 / 255.0, "radius {radius}: {got} vs {want}");
assert!(got >= 1.0 / 255.0 && got > last, "radius {radius}: {got} after {last}");
last = got;
}
}
/// #8544: Gaussian Blur spreads a hard edge for every radius from 0.1, growing smoothly
/// with the radius (no dead zone up to 0.3, then a jump); from 1 up the kernel is unchanged.
#[test]
fn small_gaussian_radii_blur_smoothly() {
let spread = |k: &[f32]| {
let r = (k.len() / 2) as f32;
k.iter().enumerate().map(|(i, w)| w * (i as f32 - r).powi(2)).sum::<f32>()
};
let mut last = 0.0f32;
for step in 1..=20 {
let radius = step as f32 * 0.05;
let k = gaussian_blur_kernel(radius);
assert!((k.iter().sum::<f32>() - 1.0).abs() < 1e-5, "normalised at {radius}");
if radius < 1.0 {
assert!((spread(&k) - radius * radius).abs() < 1e-4, "variance at {radius}: {}", spread(&k));
}
// A hard 0 → 1 edge: the first pixel past it takes the kernel's tail beyond the centre.
let c = k.len() / 2;
let edge: f32 = k[c + 1..].iter().sum();
assert!(edge > last, "radius {radius}: edge {edge} after {last}");
if radius > 0.1 {
assert!(edge - last < 0.05, "radius {radius}: jump {} from {last}", edge - last);
}
last = edge;
}
assert!(gaussian_blur_kernel(0.1).len() >= 3, "0.1 blurs");
// No step where the variance-matched range hands over to plain sampling.
let edge = |r: f32| {
let k = gaussian_blur_kernel(r);
k[k.len() / 2 + 1..].iter().sum::<f32>()
};
assert!((edge(0.999) - edge(1.0)).abs() < 0.005, "{} vs {}", edge(0.999), edge(1.0));
// No dip where a per-radius width used to add taps (1/3 and 2/3).
for pair in [(0.33f32, 0.34f32), (0.66, 0.67)] {
assert!(edge(pair.1) > edge(pair.0), "edge falls from {} to {}", pair.0, pair.1);
}
// Exact variance right up to the hand-over, past where `gaussian_kernel` changes width.
for radius in [0.98f32, 0.995, 0.998, 0.9995] {
let k = gaussian_blur_kernel(radius);
assert!((spread(&k) - radius * radius).abs() < 2e-5, "variance at {radius}: {}", spread(&k));
}
for radius in [1.0, 1.5, 3.0, 4.0] {
assert_eq!(gaussian_blur_kernel(radius), gaussian_kernel(radius), "{radius}");
}
assert_eq!(gaussian_blur_kernel(0.0), vec![1.0]);
}
#[test]
fn radial_blur_samples_large_arcs_beyond_64_intervals() {
let bounds = Rect::new(0, 0, 1000, 1000);
+3
View File
@@ -566,6 +566,9 @@ impl FilterParams {
pub fn halo(&self) -> Halo {
let g = |sigma: f32| Halo::Radius(((sigma.max(0.0) * 3.0).ceil() as i32).saturating_add(1));
match self {
// Small Gaussian radii use the calibrated seven-tap kernel as well: its reach is
// three pixels even when ceil(3 * radius) + 1 would be smaller.
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } if (0.05..1.0).contains(radius) => Halo::Radius(3),
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius),
FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)),
FilterParams::SmartSharpen { radius, .. } => g(*radius),
+14 -5
View File
@@ -25,7 +25,7 @@ impl Separable {
match *params {
FilterParams::GaussianBlur { radius } => Some(match blur::box_widths(params) {
Some(boxes) => Separable::Boxes(boxes),
None => Separable::Kernel(blur::gaussian_kernel(radius)),
None => Separable::Kernel(blur::gaussian_blur_kernel(radius)),
}),
_ => None,
}
@@ -75,7 +75,7 @@ mod tests {
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::{Interrupt, Surface};
const RADII: [f32; 9] = [0.01, 0.3, 0.8, 2.0, 4.0, 4.5, 12.0, 40.0, 250.0];
const RADII: [f32; 11] = [0.01, 0.05, 0.1, 0.3, 0.8, 2.0, 4.0, 4.5, 12.0, 40.0, 250.0];
/// The CPU blur's own choice: the exact kernel up to radius 4, boxes beyond; and its halo
/// (how far the CPU grows an output area) covers the reach.
@@ -87,7 +87,7 @@ mod tests {
match &blur {
Separable::Kernel(k) => {
assert!(radius <= 4.0, "radius {radius}");
assert_eq!(k, &blur::gaussian_kernel(radius));
assert_eq!(k, &blur::gaussian_blur_kernel(radius));
assert_eq!(k.len() % 2, 1);
assert!((k.iter().sum::<f32>() - 1.0).abs() < 1e-5);
}
@@ -135,8 +135,17 @@ mod tests {
// Nothing past the reach; the exact kernel reaches all of it. (A single pixel's box
// tails are smaller than the running sums' residue, so they aren't checked for that.)
assert!(dot.inflate(reach).contains_rect(&touched), "radius {radius}: {touched:?} past the reach {reach}");
if matches!(Separable::of(&params), Some(Separable::Kernel(_))) {
assert_eq!(touched, dot.inflate(reach), "radius {radius}");
if let Some(Separable::Kernel(k)) = Separable::of(&params) {
// Calibrated subpixel kernels have tails below the visibility threshold.
let center = k.len() / 2;
let visible = k
.iter()
.enumerate()
.filter(|(_, w)| **w * k[center] > 1e-6)
.map(|(i, _)| i.abs_diff(center))
.max()
.unwrap_or(0);
assert_eq!(touched, dot.inflate(visible as i32), "radius {radius}");
}
}
}
+280 -41
View File
@@ -8,16 +8,27 @@
//! minified tile is box-filtered instead of aliased; at 100 % and above it is a single tap.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock, Weak};
use photocraft_doc::{ColorMode, Pattern, PixelFormat, Rect, SampleType};
pub(crate) const PREPARED_PATTERN_BYTES: usize = 64 << 20;
/// What one rendering call may keep of its patterns' working copies: every pattern it paints with
/// stays converted for the call (four of the largest 8-bit ones on desktop), so none is converted
/// once per output tile. A ceiling, not an allocation: only the patterns a call paints with are
/// converted. Past it a pattern is converted under one lock and shared while in use (see
/// [`PreparedPatterns::get`]). The web build keeps a quarter of that.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) const PREPARED_PATTERN_BYTES: usize = 1 << 30;
/// See the desktop value above.
#[cfg(target_arch = "wasm32")]
pub(crate) const PREPARED_PATTERN_BYTES: usize = 256 << 20;
const MAX_PREPARED_PATTERNS: usize = 64;
/// Absolute cap on a single pattern's RGBA conversion on the uncached path. A pattern this
/// large cannot be cached, so re-expanding it for every output tile would be a DoS; above the
/// cap it is skipped instead. Independent of the cache budget so small patterns still convert.
const MAX_UNCACHED_PATTERN_BYTES: usize = 256 << 20;
/// Absolute cap on a single pattern's working copy on the uncached path: the document's own cap
/// on what paints (`Pattern::is_paintable`). A pattern above it is skipped, not converted.
const MAX_UNCACHED_PATTERN_BYTES: usize = photocraft_doc::pattern::MAX_WORKING_BYTES as usize;
// A call keeps at least two of the largest, or a second large fill is converted for every tile.
const _: () = assert!(2 * photocraft_doc::pattern::MAX_WORKING_BYTES <= PREPARED_PATTERN_BYTES as u64);
type PreparedSlot = Arc<OnceLock<Option<Arc<Tile>>>>;
struct PreparedState {
@@ -25,6 +36,15 @@ struct PreparedState {
reserved_bytes: usize,
}
/// Patterns past the call's budget: converted one at a time under this lock, and shared by the
/// workers using one at the same moment (a `Weak` per pattern), so the extra copies in memory are
/// the patterns in use, not one per worker.
#[derive(Default)]
struct OverBudget {
live: Mutex<HashMap<usize, Weak<Tile>>>,
warned: AtomicBool,
}
/// Demand-prepared pixels owned by one rendering call, never by the document.
pub(crate) struct PreparedPatterns<'a> {
patterns: &'a [Pattern],
@@ -32,11 +52,18 @@ pub(crate) struct PreparedPatterns<'a> {
/// The colour mode the patterns are painted into (see [`pattern_rgba`]).
mode: ColorMode,
state: Mutex<PreparedState>,
over_budget: OverBudget,
}
impl<'a> PreparedPatterns<'a> {
pub(crate) fn new(patterns: &'a [Pattern], budget: usize) -> Self {
Self { patterns, budget, mode: ColorMode::Rgb, state: Mutex::new(PreparedState { slots: HashMap::new(), reserved_bytes: 0 }) }
Self {
patterns,
budget,
mode: ColorMode::Rgb,
state: Mutex::new(PreparedState { slots: HashMap::new(), reserved_bytes: 0 }),
over_budget: OverBudget::default(),
}
}
/// Patterns painted into a document of colour mode `mode`.
@@ -54,10 +81,7 @@ impl<'a> PreparedPatterns<'a> {
if !p.is_paintable() {
return None;
}
let bytes = usize::try_from(p.width)
.ok()
.and_then(|w| usize::try_from(p.height).ok().and_then(|h| w.checked_mul(h)))
.and_then(|pixels| pixels.checked_mul(std::mem::size_of::<[f32; 4]>()));
let bytes = usize::try_from(p.working_bytes()).ok();
// The borrowed immutable slice pins these addresses for the complete call.
let key = std::ptr::from_ref(p) as usize;
let slot = {
@@ -84,14 +108,40 @@ impl<'a> PreparedPatterns<'a> {
// Run on the requesting worker to preserve its active CMYK profile.
slot.get_or_init(|| Tile::new_in(p, self.mode).map(Arc::new)).clone()
}
// Uncached path: a pattern whose full conversion would exceed an absolute cap is
// not materialized at all. Otherwise every output tile would re-expand the entire
// sparse pattern into RGBA floats (and could do so concurrently), exhausting CPU and
// memory even though cache admission already refused to retain it. The cap is
// independent of the (possibly zero) cache budget, so small patterns still convert
// on demand when nothing is cached.
// Uncached path: a pattern whose working copy would exceed an absolute cap is not
// materialized at all. One past the call's budget is converted under one lock, and
// shared while some worker still holds it, so workers painting tiles at the same
// time do not each convert their own copy (CPU and memory), and it says so once.
// The cap is independent of the (possibly zero) cache budget, so small patterns
// still convert on demand when nothing is cached.
None if bytes.is_none_or(|b| b > MAX_UNCACHED_PATTERN_BYTES) => None,
None => Tile::new_in(p, self.mode).map(Arc::new),
None => {
// The lock is held through the conversion, so conversions of different
// over-budget patterns run one at a time. That is the point (one extra copy
// being built at once, not one per worker) and fine for a fallback: patterns
// that fit the budget never come here.
let mut live = self.over_budget.live.lock().unwrap_or_else(|e| e.into_inner());
if let Some(shared) = live.get(&key).and_then(Weak::upgrade) {
return Some(shared);
}
if !self.over_budget.warned.swap(true, Ordering::Relaxed) {
// Written, not printed: a warning must never panic, even with no stderr.
#[cfg(not(target_arch = "wasm32"))]
{
use std::io::Write as _;
let _ = writeln!(
std::io::stderr(),
"photocraft: pattern \"{}\" is past this render's pattern memory ({} MiB) and is converted again as tiles need it",
p.display_name(),
self.budget >> 20
);
}
}
let tile = Tile::new_in(p, self.mode).map(Arc::new)?;
live.retain(|_, w| w.strong_count() > 0);
live.insert(key, Arc::downgrade(&tile));
Some(tile)
}
}
}
}
@@ -103,29 +153,80 @@ impl<'a> PreparedPatterns<'a> {
/// pattern in colour in its picker, filled in grey). Empty for a pattern that isn't
/// [paintable](Pattern::is_paintable).
pub fn pattern_rgba(p: &Pattern, mode: ColorMode) -> Vec<[f32; 4]> {
let Some(n) = p.is_paintable().then(|| usize::try_from(p.pixel_count()).ok()).flatten() else {
return Vec::new();
};
let mut px = vec![[0.0f32; 4]; n];
p.surface.read_rgba_into(p.rect(), &mut px);
let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk);
if paints_in && p.surface.format().mode != mode {
let fmt = PixelFormat::new(mode, SampleType::F32, true);
let mut v = [0.0f32; 5];
for q in &mut px {
let n = photocraft_raster::from_rgba_into(&fmt, *q, &mut v);
*q = photocraft_raster::to_rgba(&fmt, v.get(..n).unwrap_or_default());
}
let mut px = Vec::new();
if p.is_paintable() && pattern_rgba_rows(p, mode, 0, p.height, &mut px).is_none() {
px = Vec::new();
}
px
}
/// A pattern converted once to straight-alpha RGBA for sampling.
/// Rows `top..bottom` of [`pattern_rgba`] (of a paintable pattern), replacing `out`: a large
/// pattern is read a band at a time instead of as one copy at 16 bytes per pixel. `None` when the
/// memory for the rows cannot be had (on the web a failed allocation aborts the tab, so it is
/// asked for, not assumed).
pub fn pattern_rgba_rows(p: &Pattern, mode: ColorMode, top: u32, bottom: u32, out: &mut Vec<[f32; 4]>) -> Option<()> {
let bottom = bottom.min(p.height);
let top = top.min(bottom);
out.clear();
let pixels = (p.width as usize).checked_mul((bottom - top) as usize)?;
out.try_reserve_exact(pixels).ok()?;
out.resize(pixels, [0.0f32; 4]);
p.surface.read_rgba_into(Rect::new(0, top as i32, p.width as i32, bottom as i32), out);
let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk);
if paints_in && p.surface.format().mode != mode {
let fmt = PixelFormat::new(mode, SampleType::F32, true);
let mut v = [0.0f32; 5];
for q in out.iter_mut() {
let n = photocraft_raster::from_rgba_into(&fmt, *q, &mut v);
*q = photocraft_raster::to_rgba(&fmt, v.get(..n).unwrap_or_default());
}
}
Some(())
}
/// The pixels of a paintable pattern as they paint in a document of colour mode `mode`, rounded
/// to the pattern's own sample grid by `quantize`, read a band of rows at a time: no full-size
/// `f32` copy. A pattern with its own colours comes through exactly (its samples are on that
/// grid); one converted to a grey or CMYK document is rounded to the grid it was drawn on. `None`
/// when the memory cannot be had.
fn quantized_rgba<T>(p: &Pattern, mode: ColorMode, quantize: impl Fn(f32) -> T) -> Option<Vec<[T; 4]>> {
let mut out = Vec::new();
out.try_reserve_exact((p.width as usize).checked_mul(p.height as usize)?).ok()?;
let mut band = Vec::new();
for top in (0..p.height).step_by(photocraft_doc::pattern::BAND_ROWS as usize) {
pattern_rgba_rows(p, mode, top, top + photocraft_doc::pattern::BAND_ROWS, &mut band)?;
out.extend(band.iter().map(|c| [quantize(c[0]), quantize(c[1]), quantize(c[2]), quantize(c[3])]));
}
Some(out)
}
/// `u8 / 255` as `f32`, for every `u8`: the value an 8-bit sample reads as.
const UNIT8: [f32; 256] = {
let mut table = [0.0f32; 256];
let mut i = 0;
while i < 256 {
table[i] = i as f32 / 255.0;
i += 1;
}
table
};
/// A pattern's pixels as the sampler holds them.
enum Texels {
/// Premultiplied `f32` RGBA, 16 bytes per pixel: any pattern that fits.
Premultiplied(Vec<[f32; 4]>),
/// Straight RGBA8, 4 bytes per pixel, premultiplied as each tap reads it (see [`UNIT8`]): a
/// large 8-bit pattern, with the values the expanded copy would hold.
Rgba8(Vec<[u8; 4]>),
/// Straight RGBA16, 8 bytes per pixel, premultiplied as each tap reads it: a large 16-bit one.
Rgba16(Vec<[u16; 4]>),
}
/// A pattern converted once for sampling.
pub struct Tile {
w: usize,
h: usize,
/// Premultiplied RGBA.
px: Vec<[f32; 4]>,
texels: Texels,
}
impl Tile {
@@ -139,21 +240,51 @@ impl Tile {
if !p.is_paintable() {
return None;
}
Tile::build(p, mode, p.pixel_count().saturating_mul(16) > photocraft_doc::pattern::MAX_WORKING_BYTES)
}
/// [`Tile::new_in`], held at the pattern's own depth when `compact` and it has a smaller form.
fn build(p: &Pattern, mode: ColorMode, compact: bool) -> Option<Tile> {
let (w, h) = (p.width as usize, p.height as usize);
let mut px = pattern_rgba(p, mode);
for q in &mut px {
for c in 0..3 {
q[c] *= q[3];
let texels = match p.surface.format().sample {
// A large pattern is held at its own depth (`Pattern::working_bytes`).
SampleType::U8 if compact => Texels::Rgba8(quantized_rgba(p, mode, |v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)?),
SampleType::U16 if compact => Texels::Rgba16(quantized_rgba(p, mode, |v| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16)?),
_ => {
let mut px = pattern_rgba(p, mode);
if px.len() as u64 != p.pixel_count() {
return None;
}
for q in &mut px {
for c in 0..3 {
q[c] *= q[3];
}
}
Texels::Premultiplied(px)
}
}
Some(Tile { w, h, px })
};
Some(Tile { w, h, texels })
}
#[inline]
fn at(&self, x: i64, y: i64) -> [f32; 4] {
let xi = x.rem_euclid(self.w as i64) as usize;
let yi = y.rem_euclid(self.h as i64) as usize;
self.px[yi * self.w + xi]
let i = yi * self.w + xi;
match &self.texels {
Texels::Premultiplied(px) => px.get(i).copied().unwrap_or_default(),
Texels::Rgba8(px) => {
let [r, g, b, a] = px.get(i).copied().unwrap_or_default();
let a = UNIT8[usize::from(a)];
[UNIT8[usize::from(r)] * a, UNIT8[usize::from(g)] * a, UNIT8[usize::from(b)] * a, a]
}
Texels::Rgba16(px) => {
let [r, g, b, a] = px.get(i).copied().unwrap_or_default();
let unit = |v: u16| f32::from(v) / 65535.0;
let a = unit(a);
[unit(r) * a, unit(g) * a, unit(b) * a, a]
}
}
}
/// Bilinear, wrapping sample at continuous tile coordinates (pixel centres at `i + 0.5`).
@@ -329,6 +460,114 @@ mod tests {
assert!(PreparedPatterns::new(&list, 0).get("big", "").is_none());
}
/// Review of #8902: what `Pattern::is_paintable` admits, the compositor expands. A pattern that
/// fits is expanded as before; a large one is held at its own depth, so Define Pattern's
/// 8000 x 8000 fits at 8 bits. (Only the size is declared: the patterns themselves are empty.)
#[test]
fn the_most_paintable_pixels_still_expand() {
let sparse = |name: &str, (width, height): (u32, u32), format: PixelFormat| Pattern {
id: name.into(),
name: name.into(),
width,
height,
surface: Surface::new(format),
};
let float = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true);
let list = [
sparse("max8", (8000, 8000), PixelFormat::RGBA8),
sparse("max16", (5792, 5792), PixelFormat::RGBA16),
sparse("max32", (4096, 4096), float),
sparse("over8", (9000, 8000), PixelFormat::RGBA8),
sparse("over16", (8000, 8000), PixelFormat::RGBA16),
sparse("over32", (4097, 4096), float),
];
assert_eq!(list.each_ref().map(|p| p.is_paintable()), [true, true, true, false, false, false]);
let prepared = PreparedPatterns::new(&list, PREPARED_PATTERN_BYTES);
let held = |name: &str| prepared.get(name, "").map(|t| (matches!(t.texels, Texels::Rgba8(_)), matches!(t.texels, Texels::Rgba16(_))));
assert_eq!(held("max8"), Some((true, false)), "an 8-bit 8000 x 8000 pattern expands to 4 bytes a pixel");
assert_eq!(held("max16"), Some((false, true)), "a 16-bit one to 8");
for name in ["over8", "over16", "over32"] {
assert!(prepared.get(name, "").is_none(), "{name} is skipped, not allocated");
}
assert!(PreparedPatterns::new(&list, 0).get("max8", "").is_some(), "uncached too, at the cap");
}
/// A render that paints with two large patterns (a pattern fill and a pattern overlay, say)
/// converts each once, not once per output tile: both stay in the call's budget. A third that
/// does not fit is converted when asked for (correct, just not kept).
#[test]
fn two_large_patterns_are_each_converted_once_per_call() {
let sparse = |name: &str| Pattern { id: name.into(), name: name.into(), width: 5000, height: 5000, surface: Surface::new(PixelFormat::RGBA8) };
let list = [sparse("a"), sparse("b"), sparse("c")];
assert!(list.iter().all(|p| p.working_bytes() == 100_000_000));
let prepared = PreparedPatterns::new(&list, 250_000_000);
let same = |name: &str| {
let (first, second) = (prepared.get(name, "").unwrap(), prepared.get(name, "").unwrap());
Arc::ptr_eq(&first, &second)
};
assert!(same("a"), "the first is kept");
assert!(same("b"), "and the second");
// A third past the budget is converted under one lock and shared while a worker holds it,
// so concurrent tiles use one copy; it is converted again once nobody holds it.
assert!(same("c"), "shared while in use");
let held = prepared.get("c", "").unwrap();
let other = prepared.get("c", "").unwrap();
assert!(Arc::ptr_eq(&held, &other), "two workers at once get one copy");
drop((held, other));
assert!(prepared.get("c", "").is_some(), "and it converts again after");
// The real budget holds two of the largest paintable ones.
assert!(2 * photocraft_doc::pattern::MAX_WORKING_BYTES <= PREPARED_PATTERN_BYTES as u64);
}
/// A large pattern's compact copy samples exactly as the expanded one: every 8-bit and 16-bit
/// value survives the round trip through `f32`, and a tap reads the same premultiplied numbers.
#[test]
fn the_compact_copies_read_the_same_as_the_expanded_one() {
for v in 0..=255u8 {
assert_eq!(((f32::from(v) / 255.0) * 255.0 + 0.5) as u8, v);
assert_eq!(UNIT8[usize::from(v)], f32::from(v) / 255.0);
}
for v in 0..=u16::MAX {
assert_eq!(((f32::from(v) / 65535.0) * 65535.0 + 0.5) as u16, v);
}
for (format, grid) in [(PixelFormat::RGBA8, 255.0f32), (PixelFormat::RGBA16, 65535.0)] {
let mut s = Surface::new(format);
for y in 0..16i32 {
for x in 0..16i32 {
let level = |v: i32| (v as f32 / 255.0 * grid).round() / grid;
s.fill_rect(Rect::new(x, y, x + 1, y + 1), &[level(x * 17), level(y * 17), level((x + y) * 8), level(255 - x * 13 - y * 3)]);
}
}
let p = Pattern::new("ramp", s, 16, 16);
let (expanded, compact) = (Tile::build(&p, ColorMode::Rgb, false).unwrap(), Tile::build(&p, ColorMode::Rgb, true).unwrap());
assert!(matches!(expanded.texels, Texels::Premultiplied(_)));
assert!(matches!((&compact.texels, grid as u32), (Texels::Rgba8(_), 255) | (Texels::Rgba16(_), 65535)));
for y in 0..16 {
for x in 0..16 {
assert_eq!(compact.at(x, y), expanded.at(x, y), "{grid} ({x}, {y})");
}
}
// And rendered, scaled, rotated and shifted across the wrap.
let place = Placement::new(Rect::new(3, 5, 40, 40), true, (2.0, 1.0), 0.6, 20.0);
assert_eq!(render(&compact, &place, Rect::new(-9, -7, 30, 25)), render(&expanded, &place, Rect::new(-9, -7, 30, 25)));
}
}
/// Memory for the rows is asked for, not assumed: a pattern whose rows cannot be had (here a
/// declared size past what any allocation could be) gives `None`, never an aborted allocation.
#[test]
fn rows_that_cannot_be_allocated_give_none() {
let huge = Pattern { id: "huge".into(), name: "huge".into(), width: u32::MAX, height: u32::MAX, surface: Surface::new(PixelFormat::RGBA8) };
let mut out = Vec::new();
assert!(pattern_rgba_rows(&huge, ColorMode::Rgb, 0, u32::MAX, &mut out).is_none());
assert!(quantized_rgba(&huge, ColorMode::Rgb, |v| v as u8).is_none());
assert!(pattern_rgba(&huge, ColorMode::Rgb).is_empty());
assert!(Tile::new(&huge).is_none());
let small = Pattern { id: "small".into(), name: "small".into(), width: 3, height: 2, surface: Surface::new(PixelFormat::RGBA8) };
assert_eq!(pattern_rgba_rows(&small, ColorMode::Rgb, 0, 2, &mut out), Some(()));
assert_eq!(out.len(), 6);
}
#[test]
fn link_phase_scale_and_angle_move_the_origin() {
let t = Tile::new(&checker()).unwrap();
+137 -9
View File
@@ -2,16 +2,30 @@
//! and brush textures. Pure data; rendering lives in `photocraft-compose`, the PSD mapping
//! (`Patt`/`Pat2`/`Pat3` global blocks) in `photocraft-io`.
use photocraft_color::SampleType;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
/// Largest pattern edge a file may declare (the PSD reader's limit; Define Pattern stops at 4000).
/// Largest pattern edge a file may declare (the PSD reader's limit; Define Pattern stops at 8000).
pub const MAX_EDGE: u32 = 30_000;
/// Most pixels a pattern may have to be painted. Renderers expand a pattern into a 16-byte-per-pixel
/// working copy, so this keeps that copy at 1 GiB; larger patterns paint nothing.
/// Most pixels a pattern may have to be painted (Define Pattern's 8000 × 8000 fits). Larger
/// patterns paint nothing.
pub const MAX_PAINT_PIXELS: u64 = 1 << 26;
/// Most bytes of working copy the CPU compositor holds for one pattern (`photocraft-compose`
/// checks the two agree at build time). See [`Pattern::working_bytes`]. A browser tab has far less
/// memory than a desktop process, and a failed allocation there aborts the tab, so the web build
/// holds half as much: an 8-bit pattern paints up to 33,554,432 px (about 5792²) there.
#[cfg(not(target_arch = "wasm32"))]
pub const MAX_WORKING_BYTES: u64 = 256 << 20;
/// See the desktop value above.
#[cfg(target_arch = "wasm32")]
pub const MAX_WORKING_BYTES: u64 = 128 << 20;
/// Rows of a pattern read at a time where a whole-tile `f32` copy would cost 16 bytes per pixel.
pub const BAND_ROWS: u32 = 256;
/// One pattern tile. Pixels cover `0..width × 0..height` of `surface`, in any pixel format
/// (depth and colour model are the pattern's own, as in Photoshop).
#[derive(Clone, Debug, PartialEq)]
@@ -36,13 +50,18 @@ impl Pattern {
/// defining a pattern is deterministic and replayable).
pub fn new(name: impl Into<String>, surface: Surface, width: u32, height: u32) -> Pattern {
let name = name.into();
let r = Rect::new(0, 0, width as i32, height as i32);
let px = surface.read_region(r);
let mut h1 = fnv(0xcbf2_9ce4_8422_2325, name.as_bytes());
h1 = fnv(h1, &width.to_le_bytes());
h1 = fnv(h1, &height.to_le_bytes());
for v in &px {
h1 = fnv(h1, &v.to_bits().to_le_bytes());
// The samples row by row, a band at a time: the same sequence as one read of the whole
// tile, without a full-size `f32` copy of a large pattern.
let mut band = Vec::new();
for top in (0..height).step_by(BAND_ROWS as usize) {
let bottom = (top + BAND_ROWS).min(height);
surface.read_region_into(Rect::new(0, top as i32, width as i32, bottom as i32), &mut band);
for v in &band {
h1 = fnv(h1, &v.to_bits().to_le_bytes());
}
}
let h2 = fnv(h1 ^ 0x9e37_79b9_7f4a_7c15, format!("{:?}", surface.format()).as_bytes());
let id = format!("{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", (h1 >> 32) as u32, (h1 >> 16) as u16, h1 as u16, (h2 >> 48) as u16, h2 & 0xffff_ffff_ffff);
@@ -70,9 +89,61 @@ impl Pattern {
u64::from(self.width) * u64::from(self.height)
}
/// Whether the tile has pixels and is small enough to paint (see [`MAX_PAINT_PIXELS`]).
/// Bytes of working copy the compositor holds to paint this pattern. One that fits is expanded
/// to premultiplied `f32` RGBA (16 per pixel). A pattern too large for that is held at its own
/// depth instead, straight RGBA at 4 bytes per pixel for 8 bits and 8 for 16 bits: exactly its
/// values, in a quarter or half the memory. A float one has no smaller exact form.
pub fn working_bytes(&self) -> u64 {
working_bytes_for(self.pixel_count(), self.surface.format().sample)
}
/// Why this pattern paints nothing, for the person or agent who chose it; `None` when it
/// paints. A pattern too large for the compositor's working copy renders as transparent, so
/// every command that picks a pattern, and the import that brought it in, says so.
pub fn paint_problem(&self) -> Option<String> {
if self.is_paintable() {
return None;
}
if self.is_empty() {
return Some("it is empty".into());
}
let sample = self.surface.format().sample;
Some(format!(
"it is {} × {} px at {} bits per channel, and a pattern of that depth paints up to {} px in all",
self.width,
self.height,
sample.bytes() * 8,
max_paint_pixels(sample)
))
}
/// Whether the tile has pixels and is small enough to paint (see [`MAX_PAINT_PIXELS`] and
/// [`MAX_WORKING_BYTES`]).
pub fn is_paintable(&self) -> bool {
!self.is_empty() && self.pixel_count() <= MAX_PAINT_PIXELS
!self.is_empty() && self.pixel_count() <= MAX_PAINT_PIXELS && self.working_bytes() <= MAX_WORKING_BYTES
}
}
/// [`Pattern::working_bytes`] of a pattern of `pixels` pixels with `sample` samples.
pub fn working_bytes_for(pixels: u64, sample: SampleType) -> u64 {
let expanded = pixels.saturating_mul(16);
if expanded <= MAX_WORKING_BYTES {
return expanded;
}
match sample {
SampleType::U8 => pixels.saturating_mul(4),
SampleType::U16 => pixels.saturating_mul(8),
SampleType::F32 => expanded,
}
}
/// Most pixels a pattern with `sample` samples may have and still paint: [`MAX_PAINT_PIXELS`] for
/// 8 bits, fewer where the working copy is larger per pixel.
pub fn max_paint_pixels(sample: SampleType) -> u64 {
match sample {
SampleType::U8 => MAX_PAINT_PIXELS,
SampleType::U16 => MAX_PAINT_PIXELS.min(MAX_WORKING_BYTES / 8),
SampleType::F32 => MAX_PAINT_PIXELS.min(MAX_WORKING_BYTES / 16),
}
}
@@ -118,4 +189,61 @@ mod tests {
assert!(find(&list, "nope", "water").is_some());
assert!(find(&list, "nope", "").is_none());
}
/// The id is a hash of every sample in row-major order; reading a tall pattern a band of rows
/// at a time must give the id one whole read gave, or saved documents would lose their
/// pattern links.
#[test]
fn the_id_of_a_tall_pattern_matches_a_single_read_of_it() {
let (w, h) = (7u32, BAND_ROWS * 2 + 37);
let mut s = Surface::new(PixelFormat::RGBA8);
for y in 0..h as i32 {
for x in 0..w as i32 {
s.fill_rect(Rect::new(x, y, x + 1, y + 1), &[(x % 5) as f32 / 4.0, (y % 7) as f32 / 6.0, ((x + y) % 3) as f32 / 2.0, 1.0]);
}
}
let name = "tall";
let mut h1 = fnv(0xcbf2_9ce4_8422_2325, name.as_bytes());
h1 = fnv(h1, &w.to_le_bytes());
h1 = fnv(h1, &h.to_le_bytes());
for v in s.read_region(Rect::new(0, 0, w as i32, h as i32)) {
h1 = fnv(h1, &v.to_bits().to_le_bytes());
}
let h2 = fnv(h1 ^ 0x9e37_79b9_7f4a_7c15, format!("{:?}", s.format()).as_bytes());
let expected =
format!("{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", (h1 >> 32) as u32, (h1 >> 16) as u16, h1 as u16, (h2 >> 48) as u16, h2 & 0xffff_ffff_ffff);
assert_eq!(Pattern::new(name, s, w, h).id, expected);
}
#[test]
fn working_bytes_keep_a_pattern_exact_in_less_memory() {
let sparse =
|format: PixelFormat, width: u32, height: u32| Pattern { id: String::new(), name: String::new(), width, height, surface: Surface::new(format) };
let float = PixelFormat::new(photocraft_color::ColorMode::Rgb, SampleType::F32, true);
// What fits is expanded as before.
assert_eq!(sparse(PixelFormat::RGBA8, 10, 10).working_bytes(), 1600);
assert_eq!(sparse(float, 4096, 4096).working_bytes(), MAX_WORKING_BYTES);
// Past that, a pattern is held at its own depth.
assert_eq!(sparse(PixelFormat::RGBA8, 8000, 8000).working_bytes(), 256_000_000);
assert_eq!(sparse(PixelFormat::RGBA16, 5000, 5000).working_bytes(), 200_000_000);
assert!(sparse(PixelFormat::RGBA8, 8000, 8000).is_paintable());
assert!(sparse(PixelFormat::RGBA16, 5792, 5792).is_paintable());
assert!(!sparse(PixelFormat::RGBA16, 8000, 8000).is_paintable(), "512 MB at 16 bits");
assert!(!sparse(float, 4097, 4096).is_paintable(), "float has no smaller exact form");
assert!(!sparse(PixelFormat::RGBA8, 9000, 8000).is_paintable());
assert!(!sparse(PixelFormat::RGBA8, 0, 8).is_paintable());
}
#[test]
fn a_pattern_that_paints_nothing_says_why() {
let sparse =
|format: PixelFormat, width: u32, height: u32| Pattern { id: String::new(), name: String::new(), width, height, surface: Surface::new(format) };
let float = PixelFormat::new(photocraft_color::ColorMode::Rgb, SampleType::F32, true);
assert_eq!(sparse(PixelFormat::RGBA8, 8000, 8000).paint_problem(), None);
assert_eq!(sparse(PixelFormat::RGBA8, 0, 4).paint_problem().as_deref(), Some("it is empty"));
let reason = sparse(float, 5000, 5000).paint_problem().expect("float 5000 x 5000 paints nothing");
assert!(reason.contains("5000 × 5000") && reason.contains("32 bits") && reason.contains("16777216"), "{reason}");
let reason = sparse(PixelFormat::RGBA16, 8000, 8000).paint_problem().expect("16-bit 8000 x 8000 paints nothing");
assert!(reason.contains("16 bits") && reason.contains("33554432"), "{reason}");
}
}
+13 -14
View File
@@ -168,7 +168,7 @@ pub(crate) use layer_params::{NEW_LAYER_PATH, explicit_layer, layer_listing, lay
use ops::{arrange, merge_group, set_mask, set_mask_and_deselect};
pub(crate) use ops::{layer_copy, translate_layer};
use params::{bad, set_clipped};
pub(crate) use params::{check_f32_values, color_param, int, int_i32, parse_hex, u32_id, u32_id_param};
pub(crate) use params::{check_f32_values, cmyk_param, color_param, int, int_i32, parse_hex, u32_id, u32_id_param};
pub fn command_specs() -> &'static [CommandSpec] {
&registry().specs
@@ -778,13 +778,22 @@ fn build() -> Vec<CommandSpec> {
"Solid Color…",
["Layer", "New Fill Layer"],
None,
r##"{"color":"#rrggbb"=foreground,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask, making a shape; else the selection is the layer mask)}"##,
r##"{"color":"#rrggbb"=foreground,"cmyk":[c,m,y,k]? (inks 0..1 the colour was picked as: a CMYK document's fill holds exactly these; default the foreground's CMYK pick),"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask, making a shape; else the selection is the layer mask)}"##,
has_doc,
|s, p| {
let c = color_param("layer.newFillLayer.solidColor", p, "color", s.tools.foreground)?;
// The inks the colour was picked as, so CMYK 50/50/50/100 stays 50/50/50/100 instead
// of what its sRGB stand-in converts back to through the document profile (#8568).
let inks = match cmyk_param("layer.newFillLayer.solidColor", p, "cmyk")? {
Some(k) => Some(k),
None => s.tools.picked_cmyk([c[0], c[1], c[2]]),
};
let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true);
let id = s.edit("New Color Fill Layer", |doc, active| {
let fill = Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode);
let fill = match inks {
Some(k) if doc.mode == ColorMode::Cmyk => Fill::Solid(Color { mode: ColorMode::Cmyk, c: k, alpha: c[3] }),
_ => Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode),
};
let mut l = Layer::new(doc.next_layer_name("Color Fill"), LayerContent::Fill(fill));
mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor", default_mask)?;
let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto);
@@ -923,17 +932,7 @@ fn build() -> Vec<CommandSpec> {
r##"{"foreground":"#rrggbb"|[r,g,b,a?]?,"background":same?,"foregroundCmyk":[c,m,y,k]? (inks 0..1 the foreground was picked as: a CMYK document is filled with exactly these),"backgroundCmyk":same?}"##,
always,
|s, p| {
let cmyk = |key: &str| -> Result<Option<[f32; 4]>> {
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) };
let inks: Option<Vec<f32>> = v
.as_array()
.filter(|a| a.len() == 4)
.and_then(|a| a.iter().map(|x| x.as_f64().filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0) as f32)).collect());
match inks.as_deref() {
Some(&[c, m, y, k]) => Ok(Some([c, m, y, k])),
_ => Err(bad("tools.setColors", format!("`{key}` must be [c, m, y, k] numbers 0..1"))),
}
};
let cmyk = |key: &str| cmyk_param("tools.setColors", p, key);
let (fg_cmyk, bg_cmyk) = (cmyk("foregroundCmyk")?, cmyk("backgroundCmyk")?);
let foreground = color_param("tools.setColors", p, "foreground", s.tools.foreground)?;
let background = color_param("tools.setColors", p, "background", s.tools.background)?;
+2 -2
View File
@@ -410,8 +410,8 @@ fn stop(s: &mut Session, _p: &Value) -> Result<Value> {
/// A step Photoshop plays as a silent no-op although its menu item is greyed: Deselect with an
/// open document and nothing selected (actions often start with it to clear any selection, #6744),
/// and an Arrange that can't move the layer, such as Bring to Front on a new adjustment layer that
/// is already on top (#8453).
fn noop_step(s: &Session, id: &str, params: &Value) -> bool {
/// is already on top (#8453). The CLI's `run` and `batch` scripts skip these too (#8802).
pub fn noop_step(s: &Session, id: &str, params: &Value) -> bool {
let Some(d) = s.active() else { return false };
let dir = match id {
"select.deselect" => return d.doc.selection.is_none(),
+17 -2
View File
@@ -291,12 +291,16 @@ fn stroke_with(s: &mut Session, cmd: &str, p: &Value, label: &str, brush: BrushS
return Ok(l.bounds);
}
let sel = doc.selection.clone();
let table = crate::mode_cmds::paint_color_table(doc, p);
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
let mut brush = brush;
erase_locked(&mut brush, lock, bg);
if auto_erase {
// A stroke in the table's colour starts on what the foreground painted as.
let fg = table.as_ref().map_or(fg, |t| crate::mode_cmds::table_color(t, fg));
apply_auto_erase(&mut brush, surf, pts.first(), fg, bg);
}
let unpainted = table.as_ref().map(|_| surf.clone());
let damage = if let Some(axis) = &symmetry {
let reflected = axis.reflected_passes(&pts);
let copies: Vec<_> = reflected
@@ -321,6 +325,9 @@ fn stroke_with(s: &mut Session, cmd: &str, p: &Value, label: &str, brush: BrushS
} else {
render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom)
};
if let (Some(table), Some(pre)) = (&table, &unpainted) {
crate::mode_cmds::keep_in_color_table(table, pre, surf, damage);
}
Ok(damage)
})
})?;
@@ -605,9 +612,11 @@ impl LiveStroke {
let base = (st.doc.id, st.revision);
let mut doc = (*st.doc).clone();
let sel = doc.selection.clone();
let table = crate::mode_cmds::paint_color_table(&doc, p);
let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, layer, p)?;
erase_locked(&mut brush, lock, s.tools.background);
if pencil && flag(p, "autoErase", false) {
let fg = table.as_ref().map_or(fg, |t| crate::mode_cmds::table_color(t, fg));
apply_auto_erase(&mut brush, surf, pts.first(), fg, s.tools.background);
}
let renderer = StrokeRenderer::new(&brush, Some(surf.format()), zoom);
@@ -681,7 +690,7 @@ impl LiveStroke {
check_coords(pts, &self.cmd)?;
self.points.extend_from_slice(pts);
let cmyk = self.cmyk.clone();
photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
let dmg = photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || -> Result<Rect> {
self.renderer.push(pts);
if let Some(symmetry) = &self.symmetry {
for ((mirror, distinct), reflected) in self.mirrors.iter_mut().zip(&mut self.mirror_distinct).zip(symmetry.reflected_passes(pts)) {
@@ -727,7 +736,13 @@ impl LiveStroke {
dmg = dmg.union(&self.tail);
}
Ok(dmg)
})
})?;
// The preview shows the pixels the commit keeps (see `stroke_with`).
if let Some(table) = crate::mode_cmds::paint_color_table(&self.doc, &self.params) {
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?;
crate::mode_cmds::keep_in_color_table(&table, &self.pre, surf, dmg);
}
Ok(dmg)
}
}
@@ -1,4 +1,4 @@
use super::damage::layer_bounds;
use super::damage::align_bounds;
use super::moves::{move_layers, with_links};
use super::selection::{selected, top_level};
use super::{Document, EngineError, LayerId, Rect, Result, Session, Value, bad, json};
@@ -44,7 +44,7 @@ impl Edge {
/// Selected top-level layers that have content bounds, bottom-to-top.
fn bounded_selection(doc: &Document, sel: &[LayerId]) -> Vec<(LayerId, Rect)> {
top_level(doc, sel).into_iter().filter_map(|id| Some((id, layer_bounds(doc.layer(id)?)?))).collect()
top_level(doc, sel).into_iter().filter_map(|id| Some((id, align_bounds(doc.layer(id)?, doc.bounds(), doc.pixel_format().sample)?))).collect()
}
pub(super) fn align(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
@@ -1,3 +1,5 @@
use photocraft_color::{PixelFormat, SampleType};
use super::{Document, Layer, LayerContent, Rect, Session, VideoData};
/// Calls `f` with the rect of every tile that differs between two surfaces of the same format and
@@ -580,8 +582,9 @@ pub(crate) fn note_insert(s: &mut Session, before: &Document) {
st.last_damage = step_damage(before, &st.doc);
}
/// Content bounds used by Align/Distribute: the layer's pixels (type and shape layers use their
/// rendered pixels), or the union of a group's children. Fill and adjustment layers have none.
/// Content bounds of a layer's pixels as stored (type and shape layers: their cached render), or
/// the union of a group's children. Fill and adjustment layers have none. Cheap: Layers list,
/// artboards, copy and slices call it for every layer.
pub fn layer_bounds(l: &Layer) -> Option<Rect> {
let r = match &l.content {
LayerContent::Group(g) => g.children.iter().filter_map(layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)),
@@ -589,3 +592,68 @@ pub fn layer_bounds(l: &Layer) -> Option<Rect> {
};
(!r.is_empty()).then_some(r)
}
/// The bounds Align and Distribute move by, in a document of bounds `canvas`: [`layer_bounds`],
/// except that a shape layer is measured over everything it draws, beyond the canvas too (its
/// cache is clipped to the canvas, #8660), by rendering it again. Only those commands pay for that
/// render, once per click; `canvas` is the frame a shape's gradients are laid out in, so an opacity
/// stop measures as it renders, and `sample` is the document's sample type (see
/// [`shape_pixel_bounds`]).
pub(crate) fn align_bounds(l: &Layer, canvas: Rect, sample: SampleType) -> Option<Rect> {
let r = match &l.content {
LayerContent::Group(g) => g.children.iter().filter_map(|c| align_bounds(c, canvas, sample)).fold(Rect::EMPTY, |a, b| a.union(&b)),
LayerContent::Shape(sh) => shape_pixel_bounds(sh, canvas, sample).unwrap_or_else(|| {
let cached = l.surface().map(|s| s.content_bounds()).unwrap_or(Rect::EMPTY);
// A fill that shows everywhere covers the canvas, rendered yet or not.
if cached.is_empty() && unbounded_fill(sh) { canvas } else { cached }
}),
_ => return layer_bounds(l),
};
(!r.is_empty()).then_some(r)
}
/// Whether the shape's fill shows everywhere (an inverted path, or below 100 % density), so it has
/// no extent of its own.
fn unbounded_fill(sh: &photocraft_doc::ShapeLayer) -> bool {
sh.fill.is_some() && (sh.path.inverted || (sh.density.is_finite() && sh.density < 1.0))
}
/// Largest area [`shape_pixel_bounds`] renders to find a shape's extent; beyond it the path and
/// stroke bounds stand in.
const SHAPE_BOUNDS_MAX_PX: u64 = 64 << 20;
/// Pixels [`shape_pixel_bounds`] renders at a time.
const SHAPE_BOUNDS_BAND_PX: u64 = 1 << 20;
/// The pixels a shape layer draws, wherever they fall. Its cache is rendered clipped to the
/// canvas, so a shape partly off the canvas measured from it looked smaller and Align moved it
/// by the wrong amount, a little more on each click (#8660). The shape is rendered again over
/// everything it can draw (its path, stroke and feather), unclipped, and measured from that. A fill
/// that shows everywhere (an inverted path, or below 100 % density) has no extent of its own and
/// keeps the canvas-clipped measure (`None`).
pub(super) fn shape_pixel_bounds(sh: &photocraft_doc::ShapeLayer, canvas: Rect, sample: SampleType) -> Option<Rect> {
if unbounded_fill(sh) {
return None;
}
let reach = photocraft_compose::shape_bounds(sh)?.inflate(1);
if u64::from(reach.width()) * u64::from(reach.height()) > SHAPE_BOUNDS_MAX_PX {
return photocraft_compose::hard_shape_bounds(sh);
}
// Gradients lay out in the document's frame (as the cached render does); the feather and the
// clip cover the shape's whole reach, beyond the canvas too.
let shape = photocraft_vector::CompiledShape::with_frames(sh, photocraft_vector::DEFAULT_TOLERANCE, reach, canvas);
// Alpha at the document's own precision, as the document stores it: an 8-bit render drops the
// faint stroke of a 16-bit or float document (the opaque body then aligns by a different
// extent than the document draws), and a float one would find alpha an 8-bit document never
// stores. A band of rows at a time keeps the working copy a few MB however large the shape.
let format = PixelFormat::new(photocraft_color::ColorMode::Grayscale, sample, true);
let band_rows = i32::try_from((SHAPE_BOUNDS_BAND_PX / u64::from(reach.width().max(1))).max(1)).unwrap_or(i32::MAX);
let mut bounds = Rect::EMPTY;
let mut top = reach.y0;
while top < reach.y1 {
let bottom = top.saturating_add(band_rows).min(reach.y1);
bounds = bounds.union(&shape.render(format, Rect::new(reach.x0, top, reach.x1, bottom)).content_bounds());
top = bottom;
}
(!bounds.is_empty()).then_some(bounds)
}
@@ -242,16 +242,18 @@ fn fill_layers_without_default_masks_keep_selection_and_explicit_path_masks() {
assert!(mask.is_none() && !vector, "{cmd}: no selection, no path: no mask at all");
}
s.execute("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 6})).unwrap();
for (cmd, params) in KINDS {
s.execute("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 6})).unwrap();
let (mask, vector) = create(&mut s, cmd, params, None);
let mask = mask.unwrap_or_else(|| panic!("{cmd}: the selection still becomes the layer mask"));
assert!(!vector, "{cmd}");
assert!(mask.surface.pixel(6, 6)[0] > 0.99, "{cmd}: inside the selection reveals");
assert!(mask.surface.pixel(20, 12)[0] < 0.01, "{cmd}: outside the selection hides");
assert!(s.active().unwrap().doc.selection.is_none(), "{cmd}: the selection went into the mask (#8801)");
}
// An explicit path wins over the selection, as with the option on.
s.execute("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 6})).unwrap();
for selected in [true, false] {
if !selected {
s.execute("select.deselect", json!({})).unwrap();
@@ -1,3 +1,6 @@
use photocraft_color::SampleType;
use super::damage::{align_bounds, shape_pixel_bounds};
use super::*;
#[test]
@@ -636,6 +639,86 @@ fn align_each_edge() {
}
}
/// #8660: a shape partly off the canvas aligns by everything it draws, not the part on the
/// canvas, so one click aligns the layers and a second moves nothing.
#[test]
fn align_uses_a_shape_layers_full_extent_off_the_canvas() {
let mut s = session(8);
let drawn = |s: &Session, id: LayerId| align_bounds(doc(s).layer(id).unwrap(), doc(s).bounds(), SampleType::U8).unwrap();
let shape = LayerId(s.execute("shape.create", json!({"kind": "rect", "rect": [-20, 10, 40, 20], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap());
let measured = align_bounds(doc(&s).layer(shape).unwrap(), doc(&s).bounds(), SampleType::U8);
assert_eq!(measured, Some(Rect::new(-20, 10, 20, 30)), "Align: the shape's own extent, not the canvas-clipped render");
// Everything else keeps the cheap measure of the cached render (no re-render per layer).
assert_eq!(bounds(&s, shape), Rect::new(0, 10, 20, 30), "layer_bounds: the cache, clipped to the canvas");
let pixels = rect_layer(&mut s, Rect::new(30, 40, 60, 50));
select_all(&mut s, &[shape, pixels]);
s.execute("layer.align.leftEdges", json!({})).unwrap();
assert_eq!(bounds(&s, pixels), Rect::new(-20, 40, 10, 50));
assert_eq!(drawn(&s, shape), Rect::new(-20, 10, 20, 30));
s.execute("layer.align.leftEdges", json!({})).unwrap();
assert_eq!(bounds(&s, pixels), Rect::new(-20, 40, 10, 50), "a second click moves nothing");
// Centred: the union is -20..20 wide on the shape's side and 30 px for the pixel layer.
s.execute("layer.align.horizontalCenters", json!({})).unwrap();
assert_eq!(drawn(&s, shape), Rect::new(-20, 10, 20, 30));
assert_eq!(bounds(&s, pixels), Rect::new(-15, 40, 15, 50));
// A feathered shape is measured by its soft edge too, which reaches past the path.
let soft = LayerId(s.execute("shape.create", json!({"kind": "rect", "rect": [-20, 50, 40, 20], "fill": "#00ff00"})).unwrap()["layer"].as_u64().unwrap());
s.edit("feather", |doc, _| {
if let Some(LayerContent::Shape(sh)) = doc.layer_mut(soft).map(|l| &mut l.content) {
sh.feather = 4.0;
}
Ok(())
})
.unwrap();
// Measured as Align does, in the document's frame: the feather off the canvas still counts.
let b = drawn(&s, soft);
assert!(b.x0 < -20 && b.x1 > 20 && b.y0 < 50 && b.y1 > 70, "{b:?}");
// A fill that shows everywhere keeps the canvas-clipped measure.
s.edit("invert", |doc, _| {
if let Some(LayerContent::Shape(sh)) = doc.layer_mut(soft).map(|l| &mut l.content) {
sh.feather = 0.0;
sh.path.inverted = true;
}
Ok(())
})
.unwrap();
let LayerContent::Shape(sh) = &doc(&s).layer(soft).unwrap().content else { panic!("shape") };
assert_eq!(shape_pixel_bounds(sh, doc(&s).bounds(), SampleType::U8), None);
// Without a cached render yet, such a fill still measures as the canvas, not as empty.
let mut uncached = doc(&s).layer(soft).unwrap().clone();
if let LayerContent::Shape(sh) = &mut uncached.content {
sh.cache = None;
}
assert_eq!(align_bounds(&uncached, doc(&s).bounds(), SampleType::U8), Some(doc(&s).bounds()));
}
/// Review of #8779: shape extents are measured at the document's own alpha precision. A faint
/// stroke (0.001 opacity) is stored by a 16-bit or float document, so it counts in the extent
/// and Align moves the opaque body as the document draws it; an 8-bit document stores it as
/// nothing, so it does not widen the measure.
#[test]
fn align_bounds_follow_the_documents_alpha_precision() {
for (depth, sample, expected) in [
(8, SampleType::U8, Rect::new(60, 60, 120, 120)),
(16, SampleType::U16, Rect::new(50, 50, 130, 130)),
(32, SampleType::F32, Rect::new(50, 50, 130, 130)),
] {
let mut s = session(depth);
let id = LayerId(s.execute("shape.create", json!({"kind": "rect", "rect": [60, 60, 60, 60], "fill": "#000000"})).unwrap()["layer"].as_u64().unwrap());
s.edit("faint stroke", |doc, _| {
if let Some(LayerContent::Shape(sh)) = doc.layer_mut(id).map(|l| &mut l.content) {
sh.stroke = Some(photocraft_doc::ShapeStroke { width: 20.0, opacity: 0.001, ..Default::default() });
}
Ok(())
})
.unwrap();
assert_eq!(doc(&s).pixel_format().sample, sample);
let LayerContent::Shape(sh) = &doc(&s).layer(id).unwrap().content else { panic!("shape") };
assert_eq!(shape_pixel_bounds(sh, doc(&s).bounds(), sample), Some(expected), "depth {depth}");
assert_eq!(align_bounds(doc(&s).layer(id).unwrap(), doc(&s).bounds(), sample), Some(expected), "depth {depth}");
}
}
#[test]
fn align_single_layer_to_selection() {
for depth in [8, 16, 32] {
+12 -7
View File
@@ -63,7 +63,7 @@ pub fn blend_from_str(s: &str) -> Option<BlendMode> {
/// The active selection as the mask of a layer being created, or None without a selection.
/// Photoshop masks a new adjustment or fill layer with the selection, so the adjustment acts on
/// the selected area only (#1250); the selection itself stays, as Reveal Selection leaves it.
/// the selected area only (#1250). [`mask_new_layer`] then drops the selection (#8801).
pub(crate) fn selection_mask(doc: &Document) -> Result<Option<LayerMask>> {
doc.selection.as_ref().map(|sel| mask_at_depth(LayerMask { surface: sel.clone(), ..LayerMask::reveal_all() }, doc.depth)).transpose()
}
@@ -88,9 +88,10 @@ pub(crate) fn layer_panel_option(s: &Session, key: &str, fallback: bool) -> bool
/// Mask a fill or adjustment layer being created, as Photoshop does: the path selected in the
/// Paths panel (`"path"`: `"work"`, a saved path's name or a path object) becomes its vector
/// mask, which makes a Solid Color fill a shape (#1419); without one, the selection becomes its
/// layer mask ([`selection_mask`]); without either, a white mask reveals the whole layer (#1768).
/// A path that isn't there is an error.
pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str, default_mask: bool) -> Result<()> {
/// layer mask ([`selection_mask`]) and is dropped, as Reveal Selection drops it (#5251, #8801);
/// without either, a white mask reveals the whole layer (#1768). A path that isn't there is an
/// error.
pub(crate) fn mask_new_layer(doc: &mut Document, l: &mut Layer, p: &Value, cmd: &str, default_mask: bool) -> Result<()> {
let path = match p.get("path") {
None | Some(Value::Null) => None,
Some(Value::String(n)) if crate::vector_cmds::is_work(Some(n)) => Some(doc.work_path.clone().ok_or_else(|| bad(cmd, "no work path"))?),
@@ -101,9 +102,13 @@ pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str
};
match path {
Some(path) => l.vector_mask = Some(photocraft_doc::VectorMask::new(path)),
None => {
l.mask = selection_mask(doc)?.or_else(|| default_mask.then(LayerMask::reveal_all)).map(|mask| mask_at_depth(mask, doc.depth)).transpose()?;
}
None => match selection_mask(doc)? {
Some(mask) => {
l.mask = Some(mask);
doc.selection = None;
}
None => l.mask = default_mask.then(LayerMask::reveal_all).map(|mask| mask_at_depth(mask, doc.depth)).transpose()?,
},
}
Ok(())
}
+57 -1
View File
@@ -12,7 +12,7 @@ use photocraft_algo::transform::{Homography, Interp};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::adjust::CurvePoint;
use photocraft_doc::{ColorTable, Document, Duotone, DuotoneInk, Layer, LayerContent, LayerId, Size};
use photocraft_geom::Affine;
use photocraft_geom::{Affine, Rect};
use photocraft_raster::Surface;
use serde_json::{Value, json};
@@ -411,6 +411,62 @@ fn color_table(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
/// The colour table painting on `p`'s target must stay inside: an Indexed Color document's,
/// when the target is layer pixels (masks and channels are gray, not indexed).
pub(crate) fn paint_color_table(doc: &Document, p: &Value) -> Option<ColorTable> {
let pixels = crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels;
doc.color_table.clone().filter(|_| pixels && doc.mode == ColorMode::Indexed)
}
/// The colour `c` paints as in a document with `table` (see [`keep_in_color_table`]).
pub(crate) fn table_color(table: &ColorTable, c: [f32; 4]) -> [f32; 4] {
let mut px = [c];
if !table.colors.is_empty() {
quantize::quantize(&mut px, 1, &table.colors, Dither::None, 0.0, table.transparent.map(usize::from));
}
let [c] = px;
c
}
/// Map the pixels of `r` that the stroke changed (they differ from `pre`) to their nearest
/// colour-table entry, as the Indexed Color conversion does with no dither, so painting never
/// leaves the table (#8669). One tile at a time: a long stroke's bounds can span the canvas.
pub(crate) fn keep_in_color_table(table: &ColorTable, pre: &Surface, surf: &mut Surface, r: Rect) {
if table.colors.is_empty() {
return;
}
let fmt = surf.format();
let n = fmt.channels();
let mut enc = [0.0f32; 8];
for tc in r.tiles() {
let t = tc.rect().intersect(&r);
if t.is_empty() || !surf.has_tiles_in(t) {
continue;
}
let (before, mut data) = (pre.read_region(t), surf.read_region(t));
let (mut changed, mut px) = (Vec::new(), Vec::new());
for (i, (q, b)) in data.chunks_exact(n).zip(before.chunks_exact(n)).enumerate() {
if q != b {
changed.push(i);
px.push(photocraft_raster::to_rgba(&fmt, q));
}
}
if px.is_empty() {
continue;
}
let w = px.len();
quantize::quantize(&mut px, w, &table.colors, Dither::None, 0.0, table.transparent.map(usize::from));
let mut mapped = changed.into_iter().zip(px).peekable();
for (i, q) in data.chunks_exact_mut(n).enumerate() {
if let Some((_, c)) = mapped.next_if(|(j, _)| *j == i) {
photocraft_raster::from_rgba_into(&fmt, c, &mut enc);
q.iter_mut().zip(enc).for_each(|(d, v)| *d = v);
}
}
surf.write_region(t, &data);
}
}
// ---------- Bitmap ----------
/// Bitmap's Output resolution range (ppi): what a new document may have.
+92
View File
@@ -749,3 +749,95 @@ fn color_table_rejects_an_invalid_transparent_index_without_editing() {
s.execute("image.mode.colorTable", json!({"transparent": 0})).unwrap();
assert_eq!(doc(&s).color_table.as_ref().unwrap().transparent, Some(0));
}
/// A white 16×16 document in Indexed Color with the two-entry black/white table (#8669's repro).
fn black_white_indexed(transparency: bool) -> Session {
let mut s = Session::new();
let background = if transparency { "transparent" } else { "white" };
s.execute("file.new", json!({"width": 16, "height": 16, "background": background})).unwrap();
paint(&mut s, |x, _| {
if x < 2 {
[0.0; 4]
} else if x < 4 {
[0.0, 0.0, 0.0, 1.0]
} else {
[1.0; 4]
}
});
s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "transparency": transparency, "dither": "none"})).unwrap();
s
}
#[test]
fn brush_stroke_stays_in_indexed_color_table() {
let mut s = black_white_indexed(false);
assert_eq!(doc(&s).color_table.as_ref().unwrap().colors, vec![[0, 0, 0], [255, 255, 255]]);
s.execute("paint.stroke", json!({"points": [[8, 8, 1]], "size": 8, "color": "#ff0000", "opacity": 1, "flow": 1})).unwrap();
// Red's nearest entry is black.
assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(8, 8), [0.0, 0.0, 0.0, 1.0]);
// Soft edges and partial opacity blend, and the blends map back onto the table too.
s.execute("paint.stroke", json!({"points": [[4, 12], [14, 12]], "size": 6, "hardness": 0.0, "color": "#808080", "opacity": 0.5})).unwrap();
let table = doc(&s).color_table.clone().unwrap();
assert!(colours(doc(&s)).iter().all(|c| table.colors.contains(c)), "off-table pixels: {:?}", colours(doc(&s)));
assert_eq!(doc(&s).mode, ColorMode::Indexed);
}
#[test]
fn brush_stroke_keeps_indexed_transparency_binary() {
let mut s = black_white_indexed(true);
let table = doc(&s).color_table.clone().unwrap();
assert!(table.transparent.is_some());
s.execute("paint.stroke", json!({"points": [[0, 8], [15, 8]], "size": 8, "hardness": 0.0, "color": "#ff0000", "opacity": 0.7})).unwrap();
let d = doc(&s);
let surf = d.layers[0].surface().unwrap();
for (x, y) in (0..16).flat_map(|y| (0..16).map(move |x| (x, y))) {
let a = surf.rgba(x, y)[3];
assert!(a == 0.0 || a == 1.0, "alpha {a} at ({x},{y})");
}
assert!(colours(d).iter().all(|c| table.colors.contains(c)), "off-table pixels: {:?}", colours(d));
}
#[test]
fn live_brush_preview_matches_indexed_commit() {
let mut s = black_white_indexed(false);
let params = json!({"points": [[4, 8], [12, 8]], "size": 7, "hardness": 0.0, "color": "#ff0000", "opacity": 0.6, "smoothing": 0.0, "seed": 1});
let preview = crate::brush_cmds::LiveStroke::begin(&s, &params).unwrap();
let table = doc(&s).color_table.clone().unwrap();
assert!(colours(&preview.doc).iter().all(|c| table.colors.contains(c)), "preview off the table: {:?}", colours(&preview.doc));
s.execute("paint.stroke", params).unwrap();
let (live, committed) = (preview.doc.layers[0].surface().unwrap(), doc(&s).layers[0].surface().unwrap());
for (x, y) in (0..16).flat_map(|y| (0..16).map(move |x| (x, y))) {
assert_eq!(live.rgba(x, y), committed.rgba(x, y), "preview/commit at ({x},{y})");
}
}
#[test]
fn indexed_brush_stroke_leaves_pixels_it_did_not_paint() {
let mut s = black_white_indexed(false);
// An off-table pixel inside the stroke's bounds but away from its dabs (Paint Bucket can
// still leave one) is not the stroke's to remap.
s.edit("setup", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
let px = photocraft_raster::from_rgba(&surf.format(), [1.0, 0.0, 0.0, 1.0]);
surf.write_region(Rect::new(13, 2, 14, 3), &px);
Ok(())
})
.unwrap();
s.execute("paint.stroke", json!({"points": [[2, 2], [14, 14]], "size": 2, "hardness": 1.0, "color": "#ff0000", "smoothing": 0.0})).unwrap();
let surf = doc(&s).layers[0].surface().unwrap();
assert_eq!(surf.rgba(13, 2), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(surf.rgba(8, 8), [0.0, 0.0, 0.0, 1.0]);
}
#[test]
fn pencil_auto_erase_knows_the_indexed_foreground() {
let mut s = black_white_indexed(false);
let pencil = json!({"points": [[8, 8]], "size": 3, "color": "#ff0000", "autoErase": true});
s.execute("paint.pencil", pencil.clone()).unwrap();
assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(8, 8), [0.0, 0.0, 0.0, 1.0]);
// Red painted as black, so a stroke starting there auto-erases to the (white) background.
let preview = crate::brush_cmds::LiveStroke::begin_with(&s, "paint.pencil", &pencil).unwrap();
assert_eq!(preview.doc.layers[0].surface().unwrap().rgba(8, 8), [1.0, 1.0, 1.0, 1.0]);
s.execute("paint.pencil", pencil).unwrap();
assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(8, 8), [1.0, 1.0, 1.0, 1.0]);
}
+13
View File
@@ -69,6 +69,19 @@ pub(crate) fn check_f32_values(cmd: &str, value: &Value) -> Result<()> {
check(cmd, value, 0)
}
/// `p[key]` as CMYK inks 0..1 (`[c, m, y, k]`), the values a colour was picked as, or `None`
/// when absent or null. A CMYK document stores exactly these instead of what the colour's sRGB
/// stand-in converts back to (#4196, #8568).
pub(crate) fn cmyk_param(cmd: &str, p: &Value, key: &str) -> Result<Option<[f32; 4]>> {
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) };
let inks: Option<Vec<f32>> =
v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(|x| x.as_f64().filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0) as f32)).collect());
match inks.as_deref() {
Some(&[c, m, y, k]) => Ok(Some([c, m, y, k])),
_ => Err(bad(cmd, format!("`{key}` must be [c, m, y, k] numbers 0..1"))),
}
}
pub(crate) fn color_param(cmd: &str, p: &Value, key: &str, default: [f32; 4]) -> Result<[f32; 4]> {
Ok(match p.get(key) {
Some(Value::Array(a)) if a.len() >= 3 => {
+210 -28
View File
@@ -183,7 +183,7 @@ pub fn resolve(s: &Session, key: &str) -> Option<Pattern> {
pub(crate) fn resolve_param(s: &Session, cmd: &str, p: &Value) -> Result<Pattern> {
let key = p.get("pattern").and_then(Value::as_str).unwrap_or("");
if let Some(pat) = resolve(s, key) {
return Ok(pat);
return paintable(cmd, pat);
}
match library_pattern(s, key).map(|lp| full(s, lp)) {
Some(Err(e)) => Err(bad(cmd, e)),
@@ -191,6 +191,15 @@ pub(crate) fn resolve_param(s: &Session, cmd: &str, p: &Value) -> Result<Pattern
}
}
/// `pat`, or the reason it would paint nothing: a pattern too large for the compositor renders as
/// transparent, so a command that would use one says so up front.
fn paintable(cmd: &str, pat: Pattern) -> Result<Pattern> {
match pat.paint_problem() {
Some(why) => Err(bad(cmd, format!("pattern \"{}\" would paint nothing: {why}", pat.display_name()))),
None => Ok(pat),
}
}
/// Copies `pat` into the document's patterns unless it is already there (by id).
pub fn ensure_in_doc(doc: &mut Document, pat: &Pattern) {
if !doc.patterns.iter().any(|p| p.id == pat.id) {
@@ -207,6 +216,7 @@ pub fn resolve_effect(s: &Session, fx: photocraft_doc::Effect) -> Result<(photoc
Effect::PatternOverlay { common, name, id, scale, angle, link, phase } => {
let key = if id.is_empty() { name } else { id };
let pat = resolve(s, &key).ok_or_else(|| bad("layer.layerStyle.patternOverlay", format!("no pattern \"{key}\" (see pattern.list)")))?;
let pat = paintable("layer.layerStyle.patternOverlay", pat)?;
Ok((Effect::PatternOverlay { common, name: pat.name.clone(), id: pat.id.clone(), scale, angle, link, phase }, Some(pat)))
}
other => Ok((other, None)),
@@ -219,6 +229,7 @@ fn info(p: &Pattern, size: (u32, u32), in_doc: bool, in_library: bool) -> Value
"id": p.id, "name": p.display_name(), "storedName": p.name, "width": size.0, "height": size.1,
"mode": format!("{:?}", f.mode), "depth": format!("{:?}", f.sample), "alpha": f.alpha,
"inDocument": in_doc, "inLibrary": in_library,
"paintable": p.is_paintable(), "paintProblem": p.paint_problem(),
})
}
@@ -230,19 +241,45 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
Ok(Value::Array(out))
}
/// The visible composite over `r` as a surface in the document's pixel format (with alpha).
fn composite_surface(doc: &Document, r: Rect) -> Surface {
let buf = photocraft_compose::render(doc, r);
/// Pixels composited at a time by [`composite_surface`]: a band's render and conversion buffers
/// stay a few tens of MB however large the pattern.
const BAND_PIXELS: u32 = 1 << 20;
/// The visible composite over `r` as a surface in the document's pixel format (with alpha), and
/// whether every pixel of it is opaque.
fn composite_surface(doc: &Document, r: Rect) -> (Surface, bool) {
composite_surface_in_bands(doc, r, (BAND_PIXELS / r.width().max(1)).max(1))
}
/// [`composite_surface`], `band_rows` rows at a time. Rendering and converting one band at a
/// time, and noting opacity as it goes, keeps an 8000 × 8000 pattern (64 MP, about 1 GB per full
/// `f32` copy) from holding several full-size copies at once. Opacity is judged on the stored
/// samples, as the surface quantizes them: an alpha of 0.999 in an 8-bit document is stored as 255.
fn composite_surface_in_bands(doc: &Document, r: Rect, band_rows: u32) -> (Surface, bool) {
let df = doc.pixel_format();
let fmt = PixelFormat::new(df.mode, df.sample, true);
let n = fmt.channels();
let mut px = vec![0.0f32; buf.px.len() * n];
for (o, p) in px.chunks_exact_mut(n).zip(&buf.px) {
photocraft_raster::from_rgba_into(&fmt, *p, o);
}
let (width, height) = (r.width(), r.height());
let mut s = Surface::new(fmt);
s.write_region(Rect::new(0, 0, r.width() as i32, r.height() as i32), &px);
s
let mut opaque = true;
let mut px = Vec::new();
let mut top = 0;
while top < height {
let rows = band_rows.min(height - top);
let buf = photocraft_compose::render(doc, Rect::new(r.x0, r.y0 + top as i32, r.x1, r.y0 + (top + rows) as i32));
px.clear();
px.resize(buf.px.len() * n, 0.0f32);
for (o, p) in px.chunks_exact_mut(n).zip(&buf.px) {
photocraft_raster::from_rgba_into(&fmt, *p, o);
}
let band = Rect::new(0, top as i32, width as i32, (top + rows) as i32);
s.write_region(band, &px);
if opaque {
opaque = s.read_region(band).chunks_exact(n).all(|q| q[n - 1] >= 1.0);
}
top += rows;
}
(s, opaque)
}
/// The name Define Pattern gives a pattern without one: the active document's name without its
@@ -259,6 +296,11 @@ pub fn default_name(s: &Session) -> String {
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or(base)
}
/// Longest edge Define Pattern accepts. Its area is limited too, to what the compositor holds
/// (`Pattern::working_bytes`): 8000 x 8000 at 8 bits, and fewer pixels at 16 bits and float,
/// where the working copy is larger per pixel. What is accepted always paints.
const MAX_DEFINE_EDGE: u32 = 8000;
fn define(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "edit.definePattern";
let st = s.active().ok_or(EngineError::NoDocument)?;
@@ -274,14 +316,20 @@ fn define(s: &mut Session, p: &Value) -> Result<Value> {
if r.is_empty() {
return Err(bad(cmd, "the area is empty"));
}
if r.width() > 4000 || r.height() > 4000 {
return Err(bad(cmd, "patterns are limited to 4000 × 4000 px"));
if r.width() > MAX_DEFINE_EDGE || r.height() > MAX_DEFINE_EDGE {
return Err(bad(cmd, "patterns are limited to 8000 × 8000 px"));
}
let mut surf = composite_surface(doc, r);
// A pattern the compositor could not hold would define but paint nothing: 8 bits go to the
// edge limit, 16-bit documents to 33,554,432 px in all, float to 16,777,216 (4096 × 4096).
let sample = doc.pixel_format().sample;
let max_px = photocraft_doc::pattern::max_paint_pixels(sample);
if u64::from(r.width()) * u64::from(r.height()) > max_px {
return Err(bad(cmd, format!("patterns in a {}-bit document are limited to {max_px} px in all", sample.bytes() * 8)));
}
let (mut surf, opaque) = composite_surface(doc, r);
// Opaque patterns drop their alpha channel (as Photoshop does).
let fmt = surf.format();
let px = surf.read_region(Rect::new(0, 0, r.width() as i32, r.height() as i32));
if px.chunks_exact(fmt.channels()).all(|q| q[fmt.channels() - 1] >= 1.0) {
if opaque {
let fmt = surf.format();
surf = surf.convert(PixelFormat::new(fmt.mode, fmt.sample, false));
}
let name = match p.get("name").and_then(Value::as_str) {
@@ -411,15 +459,15 @@ fn brush_texture(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("brush.texturePattern", "the pattern is empty or too large to paint"));
};
// Brush textures are grey: luminance of the composite over white.
let mut px = vec![[0.0f32; 4]; n];
pat.surface.read_rgba_into(pat.rect(), &mut px);
let lum: Vec<f32> = px
.iter()
.map(|q| {
// Read a band of rows at a time: a whole-tile RGBA copy would cost 16 bytes a pixel.
let mut lum: Vec<f32> = Vec::new();
lum.try_reserve_exact(n).map_err(|_| bad("brush.texturePattern", "not enough memory for the pattern"))?;
for_each_band(&pat, pat.width, pat.height, |band| {
lum.extend(band.iter().map(|q| {
let l = 0.299 * q[0] + 0.587 * q[1] + 0.114 * q[2];
l * q[3] + (1.0 - q[3])
})
.collect();
}));
});
let tile = photocraft_paint::GrayTile::from_f32(pat.width, pat.height, &lum);
let t = &mut s.tools.brush.texture;
t.pattern = photocraft_paint::Pattern::Tile(tile);
@@ -435,13 +483,25 @@ pub fn average_color(p: &Pattern) -> Color {
// A pattern too large to paint is averaged over its paintable top-left corner.
let side = photocraft_doc::pattern::MAX_PAINT_PIXELS.isqrt() as u32;
let (w, h) = (p.width.min(side), p.height.min(side));
let mut px = vec![[0.0f32; 4]; w as usize * h as usize];
p.surface.read_rgba_into(Rect::new(0, 0, w as i32, h as i32), &mut px);
let n = px.len().max(1) as f32;
let s = px.iter().fold([0.0f32; 3], |a, q| [a[0] + q[0], a[1] + q[1], a[2] + q[2]]);
let mut s = [0.0f32; 3];
for_each_band(p, w, h, |band| s = band.iter().fold(s, |a, q| [a[0] + q[0], a[1] + q[1], a[2] + q[2]]));
let n = (u64::from(w) * u64::from(h)).max(1) as f32;
Color::rgb(s[0] / n, s[1] / n, s[2] / n)
}
/// Calls `f` with the straight RGBA pixels of the top-left `w × h` corner of `p`, a band of rows
/// at a time in row-major order.
fn for_each_band(p: &Pattern, w: u32, h: u32, mut f: impl FnMut(&[[f32; 4]])) {
let mut band = Vec::new();
for top in (0..h).step_by(photocraft_doc::pattern::BAND_ROWS as usize) {
let bottom = (top + photocraft_doc::pattern::BAND_ROWS).min(h);
band.clear();
band.resize(w as usize * (bottom - top) as usize, [0.0f32; 4]);
p.surface.read_rgba_into(Rect::new(0, top as i32, w as i32, bottom as i32), &mut band);
f(&band);
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec {
@@ -593,6 +653,128 @@ mod tests {
}
}
/// Review of the 8000 px cap: compositing a band of rows at a time gives the pixels and the
/// opacity of one pass, at every depth, whatever the band height.
#[test]
fn banded_compositing_matches_a_single_pass() {
for depth in [8u64, 16, 32] {
for transparent in [false, true] {
let mut s = Session::new();
let background = if transparent { "transparent" } else { "white" };
s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth, "background": background})).unwrap();
s.execute("select.rect", json!({"x": 3, "y": 4, "width": 20, "height": 13})).unwrap();
s.execute("edit.fill", json!({"color": "#336699"})).unwrap();
let doc = &s.active().unwrap().doc;
let area = Rect::new(2, 1, 38, 29);
let all = Rect::new(0, 0, 36, 28);
let (whole, whole_opaque) = composite_surface_in_bands(doc, area, 28);
for band_rows in [1, 5, 27] {
let (banded, opaque) = composite_surface_in_bands(doc, area, band_rows);
assert_eq!(opaque, whole_opaque, "depth {depth}, transparent {transparent}, bands of {band_rows}");
assert_eq!(banded.read_region(all), whole.read_region(all), "depth {depth}, transparent {transparent}, bands of {band_rows}");
}
assert_eq!(whole_opaque, !transparent);
}
}
}
/// Review of the banded composite: opacity is the stored samples', so an alpha an 8-bit
/// document stores as 255 is opaque and a float document's 0.999 is not.
#[test]
fn alpha_that_stores_as_opaque_counts_as_opaque() {
for (depth, opaque) in [(8u64, true), (32, false)] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth, "background": "transparent"})).unwrap();
s.execute("edit.fill", json!({"color": "#336699"})).unwrap();
s.execute("layer.setProps", json!({"opacity": 0.999})).unwrap();
let (_, all_opaque) = composite_surface_in_bands(&s.active().unwrap().doc, Rect::new(0, 0, 8, 8), 3);
assert_eq!(all_opaque, opaque, "depth {depth}");
}
}
/// Review of #8902: what Define Pattern accepts, the compositor paints. Areas over
/// 4096 x 4096, past the compositor's whole-copy cap, define and fill at 8 and 16 bits (held
/// at the pattern's own depth); a depth that has no smaller exact form is refused up front
/// with its limit, instead of defining a pattern that paints nothing.
#[test]
fn defined_patterns_over_4096_squared_paint_or_are_refused() {
for depth in [8, 16] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 4100, "height": 4100, "depth": depth})).unwrap();
s.execute("edit.fill", json!({"color": "#336699"})).unwrap();
s.execute("edit.definePattern", json!({"name": "Big"})).unwrap();
assert!(s.patterns.items.iter().any(|p| p.name == "Big" && p.is_paintable()), "depth {depth}");
s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth})).unwrap();
s.execute("layer.newFillLayer.pattern", json!({"pattern": "Big"})).unwrap();
let [r, g, b, a] = pixel(&s, 5, 5);
assert!(a == 1.0 && (r - 0.2).abs() < 0.01 && (g - 0.4).abs() < 0.01 && (b - 0.6).abs() < 0.01, "depth {depth}: {:?}", [r, g, b, a]);
}
let mut s = Session::new();
s.execute("file.new", json!({"width": 4100, "height": 4100, "depth": 32})).unwrap();
let e = s.execute("edit.definePattern", json!({})).unwrap_err().to_string();
assert!(e.contains("32-bit") && e.contains("16777216"), "{e}");
assert!(s.patterns.items.iter().all(|p| p.name != "Pattern"), "nothing was defined");
let mut s = Session::new();
s.execute("file.new", json!({"width": 6000, "height": 5600, "depth": 16})).unwrap();
let e = s.execute("edit.definePattern", json!({})).unwrap_err().to_string();
assert!(e.contains("16-bit") && e.contains("33554432"), "{e}");
}
/// A pattern of the largest area Define Pattern accepts at 8 bits is paintable: held at 4
/// bytes a pixel, 256 MB, as the compositor's cap allows, and it renders.
#[test]
fn an_8000_by_8000_pattern_is_defined_and_paints() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8000, "height": 8000, "depth": 8})).unwrap();
s.execute("edit.fill", json!({"color": "#336699"})).unwrap();
s.execute("edit.definePattern", json!({"name": "Largest"})).unwrap();
let p = s.patterns.items.iter().find(|p| p.name == "Largest").unwrap();
assert_eq!((p.width, p.height), (8000, 8000));
assert!(p.is_paintable() && p.working_bytes() == 256_000_000);
s.execute("file.new", json!({"width": 40, "height": 30, "depth": 8})).unwrap();
s.execute("layer.newFillLayer.pattern", json!({"pattern": "Largest"})).unwrap();
let [r, g, b, a] = pixel(&s, 5, 5);
assert!(a == 1.0 && (r - 0.2).abs() < 0.01 && (g - 0.4).abs() < 0.01 && (b - 0.6).abs() < 0.01, "{:?}", [r, g, b, a]);
}
/// Review of #8902: a pattern too large to paint is named by every command that would use it
/// and by pattern.list, instead of rendering as transparency with no message.
#[test]
fn a_pattern_that_would_paint_nothing_is_reported() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "depth": 8})).unwrap();
// A 32-bit 5000 x 5000 pattern (declared only: 400 MB as a working copy, past the cap).
let surface = photocraft_raster::Surface::new(photocraft_doc::PixelFormat::RGBA32F);
s.patterns.items.push(Pattern { id: "huge".into(), name: "Huge".into(), width: 5000, height: 5000, surface });
let list = s.execute("pattern.list", json!({})).unwrap();
let huge = list.as_array().unwrap().iter().find(|p| p["name"] == "Huge").unwrap().clone();
assert_eq!(huge["paintable"], json!(false));
assert!(huge["paintProblem"].as_str().unwrap().contains("32 bits"), "{huge}");
for (id, params) in [
("layer.newFillLayer.pattern", json!({"pattern": "Huge"})),
("layer.layerStyle.patternOverlay", json!({"pattern": "Huge"})),
("edit.fill", json!({"contents": "pattern", "pattern": "Huge"})),
("brush.texturePattern", json!({"pattern": "Huge"})),
] {
let e = s.execute(id, params).expect_err(id).to_string();
assert!(e.contains("Huge") && e.contains("would paint nothing"), "{id}: {e}");
}
let e = s.execute("layer.newFillLayer.pattern", json!({"pattern": "Huge"})).unwrap_err().to_string();
assert!(e.contains("would paint nothing") && e.contains("5000 × 5000"), "{e}");
assert!(list.as_array().unwrap().iter().any(|p| p["name"] != "Huge" && p["paintable"] == json!(true)), "built-in patterns paint");
}
#[test]
fn define_pattern_accepts_edges_over_4000_up_to_8000() {
for (w, ok) in [(4100, true), (8000, true), (8001, false)] {
let mut s = Session::new();
s.execute("file.new", json!({"width": w, "height": 2, "depth": 8})).unwrap();
s.execute("edit.fill", json!({"color": "#336699"})).unwrap();
let r = s.execute("edit.definePattern", json!({}));
assert_eq!(r.is_ok(), ok, "width {w}: {r:?}");
}
}
#[test]
fn pattern_fill_layer_renders_at_every_depth_and_undoes() {
for depth in [8u64, 16, 32] {
@@ -684,6 +866,6 @@ mod tests {
let huge = Pattern { id: "huge".into(), name: "Huge".into(), width: 65_536, height: 65_536, surface: Surface::new(PixelFormat::RGBA8) };
s.patterns.items.push(huge);
let e = s.execute("brush.texturePattern", json!({"pattern": "huge"})).unwrap_err();
assert!(e.to_string().contains("too large"), "{e}");
assert!(e.to_string().contains("would paint nothing") && e.to_string().contains("65536 × 65536"), "{e}");
}
}
+50 -3
View File
@@ -4,7 +4,7 @@ use photocraft_color::{Color, ColorMode};
use photocraft_doc::{Fill, LayerContent};
use serde_json::{Value, json};
use crate::commands::{CommandSpec, check_pixels_unlocked, layer_param};
use crate::commands::{CommandSpec, check_pixels_unlocked, cmyk_param, layer_param};
use crate::{EngineError, Result, Session};
pub const SET: &str = "layer.fill.solidColor.set";
@@ -57,7 +57,13 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
let id = layer_param(s, p)?;
let layer = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let LayerContent::Fill(Fill::Solid(original)) = layer.content else { return Err(bad("the target must be a Solid Color Fill layer")) };
let color = color_param(p.get("color").ok_or_else(|| bad("color is required"))?, original, st.doc.mode)?;
let mut color = color_param(p.get("color").ok_or_else(|| bad("color is required"))?, original, st.doc.mode)?;
// The inks the colour was picked as: a CMYK document keeps them exactly (#8568).
if let Some(k) = cmyk_param(SET, p, "cmyk")?
&& st.doc.mode == ColorMode::Cmyk
{
color = Color { mode: ColorMode::Cmyk, c: k, alpha: color.alpha };
}
if color != original {
check_pixels_unlocked(&st.doc, id)?;
if st.doc.effective_locks(id).transparency && color.alpha != original.alpha {
@@ -78,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Solid Color…",
menu: &[],
shortcut: None,
params: r##"{"layer":id? (default active),"document":id? (reject a stale dialog),"color":"#rrggbb"|"#rrggbbaa"|[r,g,b,a?]|{mode,c:[f32;4],alpha:f32}}; RGB values without alpha preserve it; stored Color preserves its model and precision"##,
params: r##"{"layer":id? (default active),"document":id? (reject a stale dialog),"color":"#rrggbb"|"#rrggbbaa"|[r,g,b,a?]|{mode,c:[f32;4],alpha:f32},"cmyk":[c,m,y,k]? (inks 0..1 the colour was picked as: a CMYK document keeps exactly these)}; RGB values without alpha preserve it; stored Color preserves its model and precision"##,
enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()),
run: set,
journal: true,
@@ -89,6 +95,47 @@ pub fn specs() -> Vec<CommandSpec> {
mod tests {
use super::*;
/// #8568: CMYK 50/50/50/100 entered for a Solid Color fill in a CMYK document stays exactly
/// those inks, on creation and on edit, instead of what its sRGB stand-in converts back to
/// through the document profile. RGB documents ignore the inks.
#[test]
fn cmyk_inks_survive_solid_color_fills_in_a_cmyk_document() {
let inks = [0.5, 0.5, 0.5, 1.0];
let hex = |k: [f32; 4]| {
let [r, g, b] = photocraft_color::convert::cmyk_to_rgb(k).map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8);
format!("#{r:02x}{g:02x}{b:02x}")
};
let stored = |s: &Session, id: u64| match s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content {
LayerContent::Fill(Fill::Solid(c)) => c,
_ => panic!("not a solid fill"),
};
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("image.mode.cmyk", json!({})).unwrap();
let id = s.execute("layer.newFillLayer.solidColor", json!({"color": hex(inks), "cmyk": inks})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(stored(&s, id), Color { mode: ColorMode::Cmyk, c: inks, alpha: 1.0 });
// The sRGB stand-in alone does drift: that is what the inks prevent.
let drifted = s.execute("layer.newFillLayer.solidColor", json!({"color": hex(inks)})).unwrap()["layer"].as_u64().unwrap();
assert_ne!(stored(&s, drifted).c, inks);
// Editing keeps the new inks too.
let edit = [0.2, 0.3, 0.4, 0.5];
s.execute(SET, json!({"layer": id, "color": hex(edit), "cmyk": edit})).unwrap();
assert_eq!(stored(&s, id), Color { mode: ColorMode::Cmyk, c: edit, alpha: 1.0 });
// A foreground picked as CMYK seeds a new fill with its inks, as fills do (#4196).
s.execute("tools.setColors", json!({"foreground": hex(edit), "foregroundCmyk": edit})).unwrap();
let seeded = s.execute("layer.newFillLayer.solidColor", json!({})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(stored(&s, seeded).c, edit);
for bad in [json!([0.1, 0.2, 0.3]), json!("50,50,50,100"), json!([0.1, 0.2, 0.3, f64::NAN])] {
assert!(s.execute("layer.newFillLayer.solidColor", json!({"cmyk": bad})).is_err(), "{bad}");
assert!(s.execute(SET, json!({"layer": id, "color": "#000000", "cmyk": bad})).is_err(), "{bad}");
}
// An RGB document takes the colour, not the inks.
let mut rgb = Session::new();
rgb.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let id = rgb.execute("layer.newFillLayer.solidColor", json!({"color": "#336699", "cmyk": inks})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(stored(&rgb, id).mode, ColorMode::Rgb);
}
#[test]
fn edit_preserves_layer_and_masks_at_all_depths_and_undo_redo() {
for depth in [8, 16, 32] {
@@ -345,7 +345,6 @@ fn cached_gradient_fill() -> Session {
let mut s = session();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
s.execute("layer.newFillLayer.gradient", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.edit("cache", |doc, active| {
let (fmt, canvas) = (doc.pixel_format(), doc.bounds());
let l = doc.layer_mut(active.unwrap()).unwrap();
+44 -1
View File
@@ -49,6 +49,18 @@ pub struct KysSet {
pub windows: bool,
}
/// The set name in the root `filename` attribute (`$$$/.../{name}={name}`). The name may itself
/// contain `/` or `=`, so split on the key prefix and the doubled name, not on the last separator.
fn filename_to_name(f: &str) -> &str {
let Some(rest) = f.strip_prefix("$$$/") else { return f.rsplit(['=', '/']).next().unwrap_or(f).trim() };
let rest = rest.strip_prefix("FileName/Presets/KeyboardCustomization/").unwrap_or(rest);
let mid = rest.len() / 2;
if rest.len() % 2 == 1 && rest.is_char_boundary(mid) && rest.as_bytes()[mid] == b'=' && rest[..mid] == rest[mid + 1..] {
return rest[..mid].trim();
}
rest.split_once('=').map_or(rest, |(_, n)| n).trim()
}
/// Parse a `.kys` file (or Photoshop's live `Keyboard Shortcuts.psp`, the same XML). Rows
/// without a name or a shortcut are skipped.
pub fn parse(text: &str) -> Result<KysSet, String> {
@@ -61,7 +73,7 @@ pub fn parse(text: &str) -> Result<KysSet, String> {
if root.tag_name().name() != "photoshop-keyboard-shortcuts" {
return Err("not a keyboard shortcut set (.kys): no <photoshop-keyboard-shortcuts> root".into());
}
let name = root.attribute("filename").map(|f| f.rsplit(['=', '/']).next().unwrap_or(f).trim()).filter(|n| !n.is_empty()).map(str::to_string);
let name = root.attribute("filename").map(filename_to_name).filter(|n| !n.is_empty()).map(str::to_string);
let mut set = KysSet { name, ..Default::default() };
for node in root.children().filter(|n| n.is_element()) {
match node.tag_name().name() {
@@ -119,6 +131,7 @@ pub fn to_ours(set: &KysSet, sc: &str) -> Option<String> {
}
let (mods, key) = match sc.strip_suffix("++") {
Some(m) => (m, "+"),
None if sc == "+" => ("", "+"),
None => sc.rsplit_once('+').unwrap_or(("", sc)),
};
let mods: Vec<&str> = mods
@@ -262,6 +275,7 @@ pub fn photoshop_key(sc: &str) -> Option<String> {
// The key is the last part; `Cmd++` ends in an empty part, so take it from the string.
let (mods, key) = match n.strip_suffix("++") {
Some(m) => (m, "+"),
None if n == "+" => ("", "+"),
None => n.rsplit_once('+').unwrap_or(("", n.as_str())),
};
let has = |m: &str| mods.split('+').any(|x| x == m);
@@ -309,6 +323,13 @@ pub fn write(name: &str, entries: &[Entry]) -> String {
mod tests {
use super::*;
#[test]
fn set_name_with_separators_round_trips() {
for n in ["Retouch 1/2", "a=b", "x/y=z"] {
assert_eq!(parse(&write(n, &[])).unwrap().name.as_deref(), Some(n));
}
}
const SET: &str = "\u{feff}<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<photoshop-keyboard-shortcuts version=\"4\" filename=\"$$$/FileName/Presets/KeyboardCustomization/texcuts=texcuts\">
\t<command kind=\"static\" name=\"Levels...\" id=\"1801\"><shortcut>Cmd+L</shortcut></command>
@@ -323,6 +344,28 @@ mod tests {
Candidate { id: id.into(), label: label.into(), default: default.map(str::to_string) }
}
#[test]
fn a_bare_plus_key_is_kept_when_saving() {
assert_eq!(photoshop_key("+").as_deref(), Some("+"));
assert_eq!(photoshop_key("Cmd++").as_deref(), Some("Cmd++"));
let e = Entry { id: "tools.zoom".into(), label: "Zoom Tool".into(), tool: true, keys: vec!["+".into()] };
assert!(write("Mine", &[e]).contains(">+</tool>"));
}
#[test]
fn a_bare_plus_command_survives_a_windows_set_with_a_ctrl_row() {
let xml = "<photoshop-keyboard-shortcuts>\
<command name=\"Copy\"><shortcut>Ctrl+C</shortcut></command>\
<command name=\"Zoom In\"><shortcut>+</shortcut></command>\
</photoshop-keyboard-shortcuts>";
let set = parse(xml).unwrap();
assert!(set.windows);
assert_eq!(to_ours(&set, "+").as_deref(), Some("+"));
let p = plan([c("edit.copy", "Copy", Some("Cmd+C")), c("view.zoomIn", "Zoom In", None)], &set);
assert_eq!(p.set.get("view.zoomIn").map(String::as_str), Some("+"));
assert!(p.unreadable.is_empty());
}
#[test]
fn rows_match_by_label_and_the_default_holder_wins_a_shared_label() {
let set = parse(SET).unwrap();
+27 -2
View File
@@ -265,8 +265,9 @@ fn new_adjustment_and_fill_layers_are_masked_by_the_selection() {
assert_eq!(mask.value(10, 10), 1.0, "{id} at {depth}-bit: the mask hides the selected area");
assert_eq!(mask.value(2, 2), 0.0, "{id} at {depth}-bit: the mask reveals outside the selection");
assert!(mask.linked && mask.enabled, "{id}");
// The selection stays, as after Layer › Layer Mask › Reveal Selection.
assert!(d.doc.selection.is_some(), "{id} dropped the selection");
// The selection went into the mask, so Photoshop drops it, as Layer › Layer Mask ›
// Reveal Selection does (#5251, #8801).
assert!(d.doc.selection.is_none(), "{id} at {depth}-bit kept the selection");
// The white canvas is untouched outside the selection.
assert_eq!(px(&mut s, 2, 2), vec![1.0, 1.0, 1.0, 1.0], "{id} at {depth}-bit changed pixels outside the selection");
if blackens {
@@ -274,6 +275,29 @@ fn new_adjustment_and_fill_layers_are_masked_by_the_selection() {
}
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active().unwrap().doc.layer(layer).is_none(), "{id}: undo keeps the layer");
let sel = s.active().unwrap().doc.selection.as_ref().unwrap_or_else(|| panic!("{id}: undo does not bring the selection back"));
assert_eq!(sel.content_bounds(), photocraft_geom::Rect::new(8, 8, 24, 24), "{id}: undo restores the same selection");
}
}
}
#[test]
fn an_edit_after_a_new_fill_layer_on_a_selection_reaches_the_whole_layer() {
// #8801: the selection that masked a new fill or adjustment layer is gone, so the next
// pixel edit on another layer is not limited to it.
for (id, params) in [("layer.newFillLayer.solidColor", json!({"color": "#000000"})), ("layer.newAdjustmentLayer.invert", json!({}))] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 20})).unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#3366cc"})).unwrap();
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
s.execute(id, params).unwrap();
s.execute("layer.setProps", json!({"visible": false})).unwrap();
s.execute("layer.selectBottom", json!({})).unwrap();
s.execute("layer.duplicate", json!({})).unwrap();
s.execute("image.adjustments.desaturate", json!({})).unwrap();
for x in [5, 35] {
let p = px(&mut s, x, 10);
assert!((p[0] - p[1]).abs() < 1e-3 && (p[1] - p[2]).abs() < 1e-3, "{id}: ({x}, 10) is not grey: {p:?}");
}
}
}
@@ -351,6 +375,7 @@ fn new_fill_and_adjustment_layers_take_the_active_path_as_their_vector_mask() {
let want = if path_name == "work" { d.doc.work_path.clone().unwrap() } else { d.doc.paths[0].path.clone() };
assert_eq!(vm.path, want, "{id}: the vector mask is the path");
assert!(l.mask.is_none(), "{id}: the path wins over the selection");
assert!(d.doc.selection.is_some(), "{id}: an unused selection stays");
for (x, y) in [(2, 2), (42, 32), (30, 30)] {
assert_eq!(px(&mut s, x, y), vec![1.0, 1.0, 1.0, 1.0], "{id} ({path_name}) at {depth}-bit changed pixels outside the path at ({x}, {y})");
}
@@ -0,0 +1,34 @@
//! Cross-PR acceptance checks for the merged parity batch: each test pins one fix through the
//! public command surface, so a bad merge between the PRs shows up here.
use photocraft_engine::Session;
use serde_json::json;
fn session(width: u32, height: u32) -> Session {
let mut session = Session::new();
session.execute("file.new", json!({"width": width, "height": height})).unwrap();
session
}
#[test]
fn a_new_fill_layer_consumes_the_selection_it_took_as_mask() {
let mut s = session(32, 24);
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 8})).unwrap();
s.execute("layer.newFillLayer.solidColor", json!({"color": "#4080c0"})).unwrap();
assert!(s.active().unwrap().doc.selection.is_none());
}
#[test]
fn define_pattern_accepts_a_canvas_wider_than_the_old_4000_px_cap() {
let mut s = session(4500, 8);
s.execute("edit.definePattern", json!({"name": "Wide"})).unwrap();
let mut s = session(8001, 8);
assert!(s.execute("edit.definePattern", json!({"name": "TooWide"})).is_err());
}
#[test]
fn gaussian_blur_accepts_radii_below_one_without_failing() {
for radius in [0.1, 0.3, 0.7, 1.0] {
let mut s = session(24, 16);
s.execute("filter.blur.gaussianBlur", json!({"radius": radius})).unwrap();
}
}
+6 -8
View File
@@ -45,12 +45,10 @@ fn newly_created_layer_masks_follow_document_depth() {
assert_eq!(m.value(20, 20), white);
if selected {
assert_eq!(m.value(0, 0), 0.0);
if command == "layer.layerMask.revealSelection" {
// The selection became the mask: Photoshop drops it, as Hide Selection does.
assert!(d.doc.selection.is_none(), "{command}: the selection is dropped");
} else {
assert_eq!(d.doc.selection, before.selection, "the selection is unchanged");
}
// The selection became the mask: Photoshop drops it, as Hide Selection does
// (#5251; new fill and adjustment layers too, #8801).
assert!(d.doc.selection.is_none(), "{command}: the selection is dropped");
assert!(before.selection.is_some());
} else {
assert_eq!(m.surface.tile_count(), 0, "uniform masks stay sparse");
assert_eq!(m.value(-300_000, 300_000), white);
@@ -72,7 +70,6 @@ fn soft_mask_edits_match_after_native_and_psd_reopen() {
let mut s = session(depth, "rgb");
s.execute("select.rect", json!({"x": 8, "y": 6, "width": 40, "height": 32, "antiAlias": false})).unwrap();
s.execute("layer.newFillLayer.solidColor", json!({"color": "#000000"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
let before = s.active().unwrap().doc.clone();
let native = photocraft_format::save_to_bytes(&before, &Default::default()).unwrap();
let psd = photocraft_io::export(&before, "psd", &Default::default()).unwrap();
@@ -138,7 +135,8 @@ fn mask_creation_preserves_selection_coverage_and_existing_masks() {
before.selection.as_ref().unwrap().read_region(before.bounds()),
"{depth}-bit coverage is unchanged"
);
let kept = if command == "layer.layerMask.revealSelection" { None } else { before.selection.clone() };
// The selection went into the mask, except for the Gradient tool's fill layer.
let kept = if command == "gradient.fill.create" { before.selection.clone() } else { None };
assert_eq!(after.selection, kept, "{command}");
assert_eq!(after.layers[0].mask, before.layers[0].mask, "creating a mask does not requantize an existing mask");
}
+1
View File
@@ -37,6 +37,7 @@ mod guide_layout_limit;
mod guide_layout_preview;
mod hdr_gamma_direction;
mod huge_doc_flatten;
mod integration_batch;
mod jpeg_exif_resolution;
mod keyboard_shortcut_bulk;
mod kra;
@@ -101,7 +101,6 @@ fn merging_a_masked_fill_layer_keeps_content_bounds_in_every_mode() {
let (mut s, a, _) = scene(mode, depth);
s.execute("select.rect", json!({"x": B[0], "y": B[1], "width": B[2], "height": B[3]})).unwrap();
let fill = id_of(&s.execute("layer.newFillLayer.solidColor", json!({"color": "#3060c0"})).unwrap());
s.execute("select.deselect", json!({})).unwrap();
(s, a, fill)
};
for &(mode, depth) in CASES {
+3
View File
@@ -33,6 +33,9 @@ pub const RESIDENT_BUDGET: u64 = 4 << 30;
/// GPU memory pattern textures may hold before the least recently painted are dropped (those the
/// current frame paints with always stay).
pub const PATTERN_BUDGET: u64 = 256 << 20;
/// The most a single pattern's RGBA32F texture may take. A pattern over it paints on the CPU, which
/// holds a large 8-bit pattern at 4 bytes a pixel instead of 16 (`Pattern::working_bytes`).
pub(crate) const MAX_PATTERN_TEXTURE_BYTES: u64 = 256 << 20;
/// A pattern texture no frame has painted with for this many frames is dropped.
pub const PATTERN_IDLE_FRAMES: u64 = 240;
+18 -5
View File
@@ -1,5 +1,5 @@
use super::batch::tile_grid;
use super::consts::{ACC_FORMAT, CHUNK, CHUNK_UNIFORM, FX_BUDGET, MAP16, MAP32, OP_UNIFORM, PAGE, PATTERN_BUDGET, RESIDENT_BUDGET};
use super::consts::{ACC_FORMAT, CHUNK, CHUNK_UNIFORM, FX_BUDGET, MAP16, MAP32, MAX_PATTERN_TEXTURE_BYTES, OP_UNIFORM, PAGE, PATTERN_BUDGET, RESIDENT_BUDGET};
use super::kit::{Kit, SHADER, tex_entry, uniform_entry};
use super::paging::{cell_rect, field_reaches, first_error, grid_rects};
use super::textures::{FxEntry, Resident, Tex, TexKind};
@@ -372,19 +372,32 @@ impl Compositor {
if s.lut.as_ref().is_some_and(|l| l.len() > 4096) {
return Err(Unsupported(format!("effect blur on `{}` too wide for the GPU path", f.layer.name)));
}
if let Some(pat) = &s.pattern {
self.check_pattern(pat)?;
}
}
}
}
for pass in &p.passes {
if let Some(pat) = pass.pattern
&& (pat.width > self.device_max || pat.height > self.device_max)
{
return Err(Unsupported(format!("pattern `{}` larger than the GPU texture limit", pat.name)));
if let Some(pat) = pass.pattern {
self.check_pattern(pat)?;
}
}
Ok(())
}
/// A pattern's texture must fit the device and the per-pattern cap; a larger one renders on
/// the CPU compositor.
fn check_pattern(&self, pat: &photocraft_doc::Pattern) -> Result<(), Unsupported> {
if pat.width > self.device_max || pat.height > self.device_max {
return Err(Unsupported(format!("pattern `{}` larger than the GPU texture limit", pat.name)));
}
if u64::from(pat.width) * u64::from(pat.height) * 16 > MAX_PATTERN_TEXTURE_BYTES {
return Err(Unsupported(format!("pattern `{}` too large for a GPU texture; the CPU path paints it", pat.name)));
}
Ok(())
}
/// Drop GPU textures of a closed document.
pub fn forget_doc(&mut self, doc: DocId) {
self.residents.retain(|_, r| r.doc != doc);
+21
View File
@@ -2686,6 +2686,27 @@ fn closed_documents_release_their_pattern_textures() {
check(&mut g, &a, "the pattern is uploaded again after being forgotten");
}
/// A pattern whose RGBA32F texture would pass the per-pattern cap (Define Pattern accepts 8-bit
/// ones up to 8000 x 8000) is declined by the GPU path and painted by the CPU compositor, which
/// holds a large 8-bit pattern at a quarter of the texture's size.
#[test]
fn a_pattern_too_large_for_a_gpu_texture_falls_back_to_the_cpu() {
let Some(mut g) = gpu() else { return };
let mut d = Document::new("big pattern", Size::new(8, 6), ColorMode::Rgb, SampleType::U8);
// 100 MB as RGBA8, 400 MB as an RGBA32F texture.
let surface = photocraft_raster::Surface::with_default(PixelFormat::RGBA8, &[1.0, 0.0, 0.0, 1.0]);
let pat = Pattern { id: "big".into(), name: "big".into(), width: 5000, height: 5000, surface };
assert!(pat.is_paintable());
d.layers.push(Layer::new(
"pattern",
LayerContent::Fill(Fill::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) }),
));
d.patterns.push(pat);
assert!(render_to_vec(&mut g.comp, &g.device, &g.queue, &d, d.bounds()).is_err(), "the GPU path declines it");
assert_eq!(g.comp.pattern_cache_bytes(), 0, "nothing was uploaded");
assert!(photocraft_compose::flatten(&d).px.iter().all(|p| *p == [1.0, 0.0, 0.0, 1.0]), "the CPU compositor paints it");
}
/// #4971: pattern textures are bounded while their documents stay open, by a byte budget and
/// by age. What the current frame paints with is never dropped.
#[test]
+8 -2
View File
@@ -69,9 +69,15 @@ const BLEND_LONG_NAMES: [(BlendMode, &str); 17] = [
(BlendMode::Luminosity, "luminosity"),
];
/// A blend-mode enum from either its four-character code or its long string ID.
/// A blend-mode enum from its four-character code, its long string ID, or the layer record's key:
/// Photoshop 2026 writes `vLit`, `lddg`, `lbrn`… as an effect's `Md ` for some modes (#8567).
pub(crate) fn blend_from_id(v: &[u8]) -> Option<BlendMode> {
BLEND_NAMES.iter().chain(&BLEND_LONG_NAMES).find(|(_, n)| n.as_bytes() == v).map(|(m, _)| *m)
BLEND_NAMES
.iter()
.chain(&BLEND_LONG_NAMES)
.find(|(_, n)| n.as_bytes() == v)
.map(|(m, _)| *m)
.or_else(|| <[u8; 4]>::try_from(v).ok().and_then(BlendMode::from_psd_key))
}
fn blend_of(d: &Descriptor, key: &str, default: BlendMode) -> BlendMode {
+29
View File
@@ -239,6 +239,35 @@ fn blend_names_are_unique() {
}
}
#[test]
fn layer_record_blend_keys_parse() {
for (key, mode) in [
(b"vLit", BlendMode::VividLight),
(b"lLit", BlendMode::LinearLight),
(b"pLit", BlendMode::PinLight),
(b"hMix", BlendMode::HardMix),
(b"lbrn", BlendMode::LinearBurn),
(b"lddg", BlendMode::LinearDodge),
(b"dkCl", BlendMode::DarkerColor),
(b"lgCl", BlendMode::LighterColor),
(b"fsub", BlendMode::Subtract),
(b"fdiv", BlendMode::Divide),
] {
assert_eq!(blend_from_id(key), Some(mode), "{}", String::from_utf8_lossy(key));
}
assert_eq!(blend_from_id(b"zzzz"), None);
// The issue's Color Overlay: Vivid Light stored as `vLit`, not left as Normal.
let key = |v: &str| Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new(v) };
let d = Descriptor::new("null")
.with("masterFXSwitch", Value::Boolean(true))
.with("SoFi", Value::Descriptor(Descriptor::new("SoFi").with("enab", Value::Boolean(true)).with("Md ", key("vLit"))));
let mut data = 0u32.to_be_bytes().to_vec();
data.extend(VersionedDescriptor { version: 16, descriptor: d }.to_bytes());
let (_, fx) = parse_lfx2(&data).unwrap();
let modes: Vec<BlendMode> = fx.iter().filter_map(|e| if let Effect::ColorOverlay { common, .. } = e { Some(common.blend) } else { None }).collect();
assert_eq!(modes, vec![BlendMode::VividLight]);
}
#[test]
fn long_string_blend_ids_parse() {
// Some writers store effect modes as long string IDs (`BlnM` `multiply`, `colorDodge`)
+29
View File
@@ -110,6 +110,12 @@ pub fn to_psd(p: &Pattern) -> PsdPattern {
}
}
/// A warning for each of `patterns` too large to paint: such a pattern fills with transparency,
/// so the file says so on opening.
pub fn unpaintable_warnings(patterns: &[Pattern]) -> Vec<String> {
patterns.iter().filter_map(|p| Some(format!("pattern \"{}\" would paint nothing: {}", p.display_name(), p.paint_problem()?))).collect()
}
/// Patterns decoded from the document's raw pattern blocks (malformed blocks are skipped). All
/// blocks share one memory budget, so repeating a block can't multiply it.
pub fn from_global_blocks(doc: &Document) -> Vec<Pattern> {
@@ -278,3 +284,26 @@ mod tests {
assert_eq!(export_global_blocks(&back), back.metadata.psd_global_blocks);
}
}
#[cfg(test)]
mod unpaintable_tests {
use super::*;
use photocraft_doc::PixelFormat;
use photocraft_raster::Surface;
#[test]
fn a_pattern_too_large_to_paint_is_named_on_opening() {
let sparse = |name: &str, format: PixelFormat, side: u32| Pattern {
id: name.into(),
name: name.into(),
width: side,
height: side,
surface: Surface::new(format),
};
let patterns =
[sparse("Small", PixelFormat::RGBA8, 64), sparse("Huge float", PixelFormat::RGBA32F, 5000), sparse("Largest 8-bit", PixelFormat::RGBA8, 8000)];
let warnings = unpaintable_warnings(&patterns);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains("Huge float") && warnings[0].contains("would paint nothing") && warnings[0].contains("32 bits"), "{warnings:?}");
}
}
+1
View File
@@ -1122,6 +1122,7 @@ fn read_initial_metadata(file: &PsdFile, global: &[PsdGlobalBlock], doc: &mut Do
));
}
doc.patterns = crate::pattern_map::from_global_blocks(doc);
warnings.extend(crate::pattern_map::unpaintable_warnings(&doc.patterns));
doc.notes = crate::annotations_map::notes_from_blocks(doc);
if crate::annotations_map::anno_unreadable(doc) {
warnings.push("the notes (Anno block) could not be read; they are kept unchanged on PSD save unless a note is added".into());
+13
View File
@@ -154,6 +154,19 @@ fn halftone_pattern_unknown_nested_class_stays_verbatim() {
}
}
/// Keeps a Gallery stack opaque when its own descriptor holds a setting beside the effect list,
/// which a save would otherwise drop.
#[test]
fn halftone_pattern_unknown_outer_setting_stays_verbatim() {
let valid = native_effect("ScrD", 2, 5);
let gallery = Descriptor::new(GALLERY_CLASS).with("GEfs", Value::List(vec![Value::Descriptor(valid)])).with("futureSetting", Value::Integer(7));
let item = native_item(gallery);
let parsed = Descriptor::from_bytes(&item.to_bytes()).unwrap();
let filter = filter_from_item(&parsed);
assert_eq!(filter.command, UNSUPPORTED_FILTER);
assert_eq!(item_for_filter(&filter).unwrap().to_bytes(), item.to_bytes());
}
/// Rejects unsupported export values instead of silently changing their meaning.
#[test]
fn halftone_pattern_invalid_export_settings_are_reported() {
+5 -1
View File
@@ -710,7 +710,11 @@ fn gallery_params(item: &Descriptor, f: &Descriptor) -> Option<J> {
let mut params = Map::new();
if g.key == "halftonePattern" {
// Do not turn an unfamiliar shape or malformed setting into the engine's defaults:
// keeping the whole item opaque preserves it through a subsequent save.
// keeping the whole item opaque preserves it through a subsequent save. That covers
// the stack's own descriptor too: a key beside `GEfs` would not be written back.
if f.get("GEfs").is_some() && !(f.class_id.is(GALLERY_CLASS) && only_keys(f, &["GEfs"])) {
return None;
}
if !e.class_id.is(GALLERY_CLASS)
|| !only_keys(e, &["GEfk", "HlSz", "Cntr", "ScrT", "GELv"])
|| !matches!(e.get("GEfk"), Some(Value::Enumerated { type_id, .. }) if type_id.is("GEft"))
+16 -3
View File
@@ -199,14 +199,22 @@ type Reopened = (String, Option<i32>, Vec<String>);
/// `TextIndex`, bottom first), with a type layer "Hi" made the way the Type tool makes one
/// (`build_tysh`, no index of its own) inserted at `at`, saved and reopened: each layer's
/// index and font features (but `calt`, on by default and written on as Photoshop does), and
/// "Hi"'s style run in the saved `Txt2`.
/// "Hi"'s style run in the saved `Txt2`. The kept layers are styled as their objects are (font
/// and size too), so their objects are saved unchanged, which this checks.
fn add_type_layer(objects: &[(&str, &str)], kept: &[(&str, i32)], at: usize) -> (Vec<Reopened>, E) {
let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8);
for (text, index) in kept {
let mut t = layer(text, &[(text.len(), Kerning::Metrics, 0.0)]);
// `photoshop_txt2` writes MyriadPro-Regular at 12 pt (the document is at 72 dpi).
t.runs[0].style.size_pt = 12.0;
t.runs[0].style.postscript_name = Some("MyriadPro-Regular".into());
t.sync_summary();
photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format());
let raw = photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None);
t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, *index).unwrap()));
let raw = photocraft_text::psd::set_text_index(&photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None), *index).unwrap();
// As opening the file does: a kept layer's style holds what its `Txt2` object says (#8415:
// a layer whose style disagrees with its object is saved as a style edit).
photocraft_text::psd::apply_txt2(&mut t, &raw, &photocraft_text::psd::parse_txt2(&photoshop_txt2(objects)).unwrap());
t.psd_raw = Some(std::sync::Arc::new(raw));
doc.layers.push(Layer::new(*text, LayerContent::Text(t)));
}
doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Txt2", std::sync::Arc::new(photoshop_txt2(objects)))];
@@ -228,6 +236,11 @@ fn add_type_layer(objects: &[(&str, &str)], kept: &[(&str, i32)], at: usize) ->
(l.name.clone(), index, t.char_runs().iter().flat_map(|r| r.style.features.iter().filter(|f| f.tag != "calt").map(|f| f.tag.clone())).collect())
})
.collect();
let source = photocraft_text::psd::parse_txt2(&photoshop_txt2(objects)).unwrap();
let object = |txt2: &E, i: i32| txt2.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.get(usize::try_from(i).ok()?)).cloned();
for (text, index) in kept {
assert_eq!(object(&txt2, *index), object(&source, *index), "{text}: the kept layer's object is saved unchanged");
}
let hi = usize::try_from(layers.iter().find(|l| l.0 == "Hi").and_then(|l| l.1).unwrap()).unwrap();
let run = txt2.path(&["1", "1"]).and_then(E::as_array).unwrap()[hi].path(&["0", "6", "0"]).and_then(E::as_array).unwrap()[0].clone();
(layers, run)
+62
View File
@@ -315,3 +315,65 @@ fn created_text_layer_roundtrips_through_psd() {
assert_eq!(bt.transform, t.transform);
assert!(bt.cache.as_ref().is_some_and(|c| c.content_bounds().width() > 10));
}
/// Review of #8888: the older Photoshop's text objects (`/DocumentObjects /TextObjects`) in the
/// real psd-tools `text.psd`. Saving the type layer unedited leaves them (and the font list they
/// index) exactly as the file has them; editing only the size changes the size of the legacy style
/// run and nothing else in it, and the object's text, paragraph runs and view are untouched.
#[cfg(feature = "corpus")]
#[test]
fn corpus_text_psd_legacy_objects_are_edited_in_place() {
use photocraft_text::engine_data::Value as E;
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd-tools/text.psd");
assert!(path.is_file(), "{} is missing: run `cargo xtask corpus --all`", path.display());
let bytes = std::fs::read(&path).unwrap();
let doc = photocraft_io::import("text.psd", &bytes).unwrap().document;
let txt2 = doc.metadata.psd_global_blocks.iter().find(|b| &b.1 == b"Txt2").map(|b| b.2.clone()).expect("text.psd has a Txt2 block");
let mut all = Vec::new();
walk(&doc.layers, &mut all);
let layer = all.iter().find_map(|l| if let LayerContent::Text(t) = &l.content { Some(t.clone()) } else { None }).expect("a type layer");
let before = photocraft_text::psd::parse_txt2(&txt2).expect("the file's Txt2 parses");
let legacy = |block: &E| block.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(<[E]>::first).cloned().expect("the legacy object");
let features = |entry: &E| {
entry
.path(&["Model", "StyleRun", "RunArray"])
.and_then(E::as_array)
.and_then(<[E]>::first)
.and_then(|r| r.path(&["RunData", "StyleSheet", "Features"]))
.cloned()
};
let items = |value: &E| if let E::Dict(items) = value { items.clone() } else { Vec::new() };
// Unedited: the legacy object and the font list are as the file has them.
let unedited = photocraft_text::psd::parse_txt2(&photocraft_text::psd::build_txt2_at(&[(0, &layer)], Some(&txt2), doc.resolution_dpi)).unwrap();
assert!(legacy(&unedited) == legacy(&before), "an unedited layer leaves the legacy text object alone");
assert!(unedited.path(&["DocumentResources", "FontSet"]) == before.path(&["DocumentResources", "FontSet"]), "and the font list it indexes");
// Edited: only the size.
let mut bigger = layer.clone();
for run in &mut bigger.runs {
run.style.size_pt += 7.0;
}
let edited = photocraft_text::psd::parse_txt2(&photocraft_text::psd::build_txt2_at(&[(0, &bigger)], Some(&txt2), doc.resolution_dpi)).unwrap();
let (was, now) = (features(&legacy(&before)).expect("features"), features(&legacy(&edited)).expect("features"));
let size = |f: &E| f.get("FontSize").and_then(E::as_f64).expect("FontSize");
assert!((size(&now) - size(&was) - 7.0).abs() < 1e-6, "the legacy size follows the edit: {} -> {}", size(&was), size(&now));
let rest = |f: &E| items(f).into_iter().filter(|(k, _)| k != "FontSize").collect::<Vec<_>>();
let changed: Vec<String> =
rest(&was).iter().zip(rest(&now).iter()).filter(|(a, b)| a != b).map(|(a, b)| format!("{}: {:?} -> {:?}", a.0, a.1, b.1)).collect();
assert!(rest(&was) == rest(&now), "every other style key of the run is as the file has it; changed: {changed:?}");
let others = |entry: &E, key: &str| entry.path(&["Model", key]).cloned();
for key in ["Text", "ParagraphRun", "KernRun"] {
assert!(others(&legacy(&edited), key) == others(&legacy(&before), key), "{key} is untouched");
}
assert!(legacy(&edited).get("View") == legacy(&before).get("View"), "the view is untouched");
let lengths = |entry: &E| {
entry
.path(&["Model", "StyleRun", "RunArray"])
.and_then(E::as_array)
.map(|rs| rs.iter().map(|r| r.get("Length").and_then(E::as_i64)).collect::<Vec<_>>())
};
assert!(lengths(&legacy(&edited)) == lengths(&legacy(&before)), "the runs still cover the text");
}
+5 -5
View File
@@ -585,10 +585,10 @@ impl History {
/// beyond it unseen (#4530). The most recent state is still always kept.
pub fn trim_with(&mut self, current: &Document, pinned: &[&Document]) -> usize {
// While Clear History can be undone, the cleared states are what history holds: the
// oldest of them go first, as the list's would.
let (undo, redo) = match &mut self.cleared {
Some(c) => (&mut c.undo, &c.redo),
None => (&mut self.undo, &self.redo),
// oldest of them go first, as the list's would. The source set before it stays pinned.
let (undo, redo, brush_source) = match &mut self.cleared {
Some(c) => (&mut c.undo, &c.redo, c.brush_source),
None => (&mut self.undo, &self.redo, self.brush_source),
};
if self.max_bytes == 0 || undo.len() <= 1 {
return 0;
@@ -622,7 +622,7 @@ impl History {
}
// The History Brush's source is kept whatever its age, so its own tiles come first; the
// newest states then fill what remains of the budget (the most recent one always).
let source = self.brush_source.and_then(|id| undo.iter().position(|s| s.id == id));
let source = brush_source.and_then(|id| undo.iter().position(|s| s.id == id));
if let Some(state) = source.and_then(|i| undo.get(i)) {
total = total.saturating_add(tile_bytes(&state.doc, &mut seen));
}
+25
View File
@@ -197,6 +197,31 @@ fn clear_states_resets_the_brush_source_until_undo() {
assert_eq!((h.brush_source(), h.row_doc(1, &cur).unwrap().name.as_str()), (Some(1), "a"));
}
/// The byte budget keeps the source set before Clear History among the cleared states, so
/// Undo Clear History brings it back as the History Brush's source.
#[test]
fn byte_budget_keeps_the_brush_source_of_the_cleared_states() {
let mut h = History::new(50);
let mut cur = Arc::new(base());
for i in 0..5 {
edit(&mut h, &mut cur, &format!("paint {i}"), |d| {
let id = d.layers[0].id;
d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]);
});
}
assert!(h.set_brush_source(1));
let source = h.state(1).unwrap();
assert!(h.clear_states());
h.max_bytes = 1;
assert_eq!(h.trim(&cur), 3);
assert_counter(&h, &cur);
assert!(h.is_cleared() && h.past_len() == 0, "the list stays cleared");
cur = h.undo(cur).unwrap().0;
assert_eq!(h.entries(), ["paint 0", "paint 3", "paint 4"], "the source is kept with the last step");
assert_eq!(h.brush_source(), Some(0));
assert!(Arc::ptr_eq(&h.row_doc(0, &cur).unwrap(), &source), "the History Brush paints from the same state");
}
#[test]
fn new_edit_clears_redo() {
let mut h = History::default();
+32 -6
View File
@@ -11,6 +11,10 @@ pub enum Value {
Dict(Vec<(String, Value)>),
Array(Vec<Value>),
String(String),
/// A string the file stored as plain bytes, without the `FE FF` mark (one char per byte).
/// Kept apart from [`Value::String`] so it is written back as the same bytes: Photoshop
/// rejects a `Txt2` whose raw strings come back as UTF-16 (#9091).
RawString(String),
Int(i64),
Real(f64),
Bool(bool),
@@ -67,7 +71,7 @@ impl Value {
}
pub fn as_str(&self) -> Option<&str> {
match self {
Value::String(s) | Value::Name(s) => Some(s),
Value::String(s) | Value::RawString(s) | Value::Name(s) => Some(s),
_ => None,
}
}
@@ -173,7 +177,7 @@ impl Parser<'_> {
}
}
}
Ok(Value::String(decode_string(&raw)))
Ok(decode_string(&raw))
}
Some(b'/') => Ok(Value::Name(self.name())),
Some(_) => {
@@ -205,12 +209,12 @@ impl Parser<'_> {
}
}
fn decode_string(raw: &[u8]) -> String {
fn decode_string(raw: &[u8]) -> Value {
if raw.len() >= 2 && raw[0] == 0xFE && raw[1] == 0xFF {
let u: Vec<u16> = raw[2..].as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
String::from_utf16_lossy(&u)
Value::String(String::from_utf16_lossy(&u))
} else {
raw.iter().map(|&c| c as char).collect()
Value::RawString(raw.iter().map(|&c| c as char).collect())
}
}
@@ -293,7 +297,18 @@ fn write_value(v: &Value, depth: usize, out: &mut Vec<u8>) {
}
out.extend_from_slice(b" ]");
}
Value::String(s) => {
Value::RawString(s) if s.chars().all(|c| u32::from(c) <= 0xFF) => {
out.push(b'(');
for c in s.chars() {
let c = u32::from(c) as u8;
if matches!(c, b'(' | b')' | b'\\') {
out.push(b'\\');
}
out.push(c);
}
out.push(b')');
}
Value::String(s) | Value::RawString(s) => {
out.push(b'(');
let mut raw = vec![0xFE, 0xFF];
for u in s.encode_utf16() {
@@ -382,6 +397,17 @@ mod tests {
assert_eq!(v.path(&["EngineDict", "Runs"]).unwrap().as_array().unwrap()[0].get("A").and_then(Value::as_i64), Some(1));
}
#[test]
fn raw_strings_are_written_back_as_the_same_bytes() {
let v = parse(b"<< /2 (0100\\)) /3 (\xFE\xFF\x00h) >>").unwrap();
assert_eq!(v.get("2"), Some(&Value::RawString("0100)".into())));
assert_eq!(v.get("2").and_then(Value::as_str), Some("0100)"));
assert_eq!(v.get("3"), Some(&Value::String("h".into())));
let bytes = write(&v);
assert!(bytes.windows(8).any(|w| w == b"(0100\\))"), "{:?}", String::from_utf8_lossy(&bytes));
assert_eq!(parse(&bytes).unwrap(), v);
}
#[test]
fn write_parse_roundtrip() {
let v = parse(SAMPLE).unwrap();
+318 -14
View File
@@ -156,19 +156,9 @@ fn txt2_mode_runs(layer: &TextLayer) -> Vec<(i64, usize)> {
out
}
/// `Txt2` style runs for `layer`, one per character run: `base` (one of the file's style runs,
/// `<< /0 << /0 << /6 style >> >> /1 length >>`, every key Photoshop wrote) with its style set
/// from the run's own: font (`/0`, an index into `fonts`, the block's `/0 /1 /0` font list, which
/// a missing font is appended to), size (`/1`), faux bold and italic, leading, scales and tracking
/// (`/2`–`/8`, EngineData's order), fill colour (`/53`), the OpenType keys and the auto-kern mode
/// (`/11`). Photoshop reads the character style from here rather than from EngineData, and quit
/// on a run holding only the auto-kern mode. `None` when `base` has no style dictionary or is
/// larger than a template may be (it is copied once per run).
fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec<E>, dpi: f32) -> Option<Vec<E>> {
if !matches!(base.path(&["0", "0", "6"]), Some(E::Dict(_))) || !fits(base, &mut { TEMPLATE_NODES }) {
return None;
}
let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 };
/// The engine text of `layer` (`\r` breaks and the trailing `\r`) as `(character run, UTF-16
/// length)` spans: one per run that has text.
fn char_spans(layer: &TextLayer) -> Vec<(usize, i64)> {
let runs = layer.char_runs();
let ed_text = format!("{}\r", layer.text.replace('\n', "\r"));
let mut spans: Vec<(usize, i64)> = Vec::new();
@@ -184,6 +174,24 @@ fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec<E>, dpi: f32) ->
_ => spans.push((ri, n)),
}
}
spans
}
/// `Txt2` style runs for `layer`, one per character run: `base` (one of the file's style runs,
/// `<< /0 << /0 << /6 style >> >> /1 length >>`, every key Photoshop wrote) with its style set
/// from the run's own: font (`/0`, an index into `fonts`, the block's `/0 /1 /0` font list, which
/// a missing font is appended to), size (`/1`), faux bold and italic, leading, scales and tracking
/// (`/2`–`/8`, EngineData's order), fill colour (`/53`), the OpenType keys and the auto-kern mode
/// (`/11`). Photoshop reads the character style from here rather than from EngineData, and quit
/// on a run holding only the auto-kern mode. `None` when `base` has no style dictionary or is
/// larger than a template may be (it is copied once per run).
fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec<E>, dpi: f32) -> Option<Vec<E>> {
if !matches!(base.path(&["0", "0", "6"]), Some(E::Dict(_))) || !fits(base, &mut { TEMPLATE_NODES }) {
return None;
}
let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 };
let runs = layer.char_runs();
let spans = char_spans(layer);
let default = CharStyle::default();
let mut out = Vec::with_capacity(spans.len());
let mut looked_up = std::collections::HashMap::new();
@@ -221,6 +229,278 @@ fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec<E>, dpi: f32) ->
Some(out)
}
/// Whether the style runs of a `Txt2` text model (`/6 /0`) agree with `layer`'s character runs
/// on every key [`txt2_style_runs`] writes, the auto-kern mode aside ([`with_modes`] refits that
/// on its own): font (by name, through `fonts`, the block's font list), size, faux bold and
/// italic, leading, scales, tracking, fill colour and the OpenType keys. A key the file's run
/// lacks, or holds as another type, is no evidence of an edit and is skipped, so an unedited
/// save still gets the file's block back byte for byte (#2374); a key that disagrees means the
/// layer's style was edited since the file was written (#8415).
fn txt2_style_matches(layer: &TextLayer, model: &E, fonts: &[E], dpi: f32) -> bool {
let Some(file_runs) = model.path(&["6", "0"]).and_then(E::as_array) else { return true };
let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 };
style_runs_match(layer, file_runs, "1", &["0", "0", "6"], |s, style| txt2_style_agrees(s, style, fonts, k))
}
/// [`txt2_style_matches`] for the older Photoshop's object at `/DocumentObjects /TextObjects`
/// (`entry`): its `Model /StyleRun /RunArray` runs hold the style under `RunData /StyleSheet
/// /Features`, named keys, with the run length under `Length`. `fonts` is the block's
/// `DocumentResources /FontSet /Resources`.
fn td_style_matches(layer: &TextLayer, entry: &E, fonts: &[E], dpi: f32) -> bool {
let Some(file_runs) = entry.path(&["Model", "StyleRun", "RunArray"]).and_then(E::as_array) else { return true };
let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 };
style_runs_match(layer, file_runs, "Length", &["RunData", "StyleSheet", "Features"], |s, style| td_style_agrees(s, style, fonts, k))
}
/// Whether `file_runs` (a run array whose runs hold their length under `len_key` and their style
/// at `style_path`) agree, run for run over the layer's engine text, with the layer's character
/// runs by `agrees`. A run without a style dictionary says nothing.
fn style_runs_match(layer: &TextLayer, file_runs: &[E], len_key: &str, style_path: &[&str], agrees: impl Fn(&CharStyle, &E) -> bool) -> bool {
let run_len = |i: usize| file_runs.get(i).and_then(|r| r.get(len_key)).and_then(E::as_i64).unwrap_or(0);
let runs = layer.char_runs();
let default = CharStyle::default();
let ed_text = format!("{}\r", layer.text.replace('\n', "\r"));
let (mut ri, mut run_end) = (0usize, runs.first().map_or(0, |r| r.len));
let (mut fi, mut left) = (0usize, run_len(0));
// Each (layer run, file run) pair is compared once, however long it is.
let mut compared = None;
for (b, ch) in ed_text.char_indices() {
while b >= run_end && ri + 1 < runs.len() {
ri += 1;
run_end = run_end.saturating_add(runs.get(ri).map_or(0, |r| r.len));
}
while left <= 0 && fi + 1 < file_runs.len() {
fi += 1;
left = run_len(fi);
}
if compared != Some((ri, fi)) {
let s = runs.get(ri).map_or(&default, |r| &r.style);
if let Some(style) = file_runs.get(fi).and_then(|r| r.path(style_path))
&& !agrees(s, style)
{
return false;
}
compared = Some((ri, fi));
}
left = left.saturating_sub(ch.len_utf16() as i64);
}
true
}
/// Whether one `Txt2` style dictionary agrees with `s` (see [`txt2_style_matches`]); `k` is the
/// document's points per PostScript point, as in [`txt2_style_runs`].
fn txt2_style_agrees(s: &CharStyle, style: &E, fonts: &[E], k: f32) -> bool {
let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0);
let number = |key: &str, want: f32| style.get(key).and_then(E::as_f64).is_none_or(|v| close(v, f64::from(want)));
let flag = |key: &str, want: bool| style.get(key).and_then(E::as_bool).is_none_or(|v| v == want);
let font = style.get("0").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).and_then(|i| fonts.get(i)).and_then(|f| f.path(&["0", "0", "0"]));
if font.and_then(E::as_str).is_some_and(|name| name != postscript_for(s)) {
return false;
}
let auto_leading = s.leading_pt.is_none();
if !(number("1", s.size_pt * k) && flag("2", s.faux_bold) && flag("3", s.faux_italic) && flag("4", auto_leading)) {
return false;
}
// An automatic leading's value is Photoshop's own; only a set one is ours to compare.
if style.get("4").and_then(E::as_bool) == Some(false) && !auto_leading && !number("5", s.leading_pt.unwrap_or(0.0) * k) {
return false;
}
if !(number("6", s.horizontal_scale) && number("7", s.vertical_scale) && number("8", s.tracking.round())) {
return false;
}
if let Some(paint) = style.get("53").and_then(|p| p.get("0")) {
let (kind, values) = fill_color(&s.color);
if paint.get("0").and_then(E::as_i64).is_some_and(|v| v != kind) {
return false;
}
if let Some(have) = paint.get("1").and_then(E::as_array) {
let same =
have.len() == values.len() && have.iter().zip(&values).all(|(a, b)| matches!((a.as_f64(), b.as_f64()), (Some(a), Some(b)) if close(a, b)));
if !same {
return false;
}
}
}
let feature = |tag: &str| s.features.iter().find(|f| f.tag == tag).map(|f| f.value > 0);
if !TXT2_FEATURES.iter().all(|&(key, tag)| flag(key, feature(tag).unwrap_or(tag == "calt"))) {
return false;
}
// A mask beyond the 20 sets is ignored on import (`add_txt2_features`), so here too.
let sets = (0..20).filter(|n| feature(&format!("ss{:02}", n + 1)) == Some(true)).fold(0i64, |m, n| m | 1 << n);
style.get(TXT2_STYLISTIC_SETS).and_then(E::as_i64).filter(|m| (0..1 << 20).contains(m)).is_none_or(|m| m == sets)
}
/// EngineData's `FontCaps` code of a style: 0 normal, 1 small caps, 2 all caps.
fn caps_code(s: &CharStyle) -> i64 {
match s.caps {
Caps::Normal => 0,
Caps::SmallCaps => 1,
Caps::AllCaps => 2,
}
}
/// EngineData's `FontBaseline` code of a style: 0 normal, 1 superscript, 2 subscript.
fn baseline_code(s: &CharStyle) -> i64 {
match s.baseline {
FontBaseline::Normal => 0,
FontBaseline::Superscript => 1,
FontBaseline::Subscript => 2,
}
}
/// The OpenType flags of a legacy style (`Features`), by key, and the feature each turns on: the
/// named keys of [`TXT2_FEATURES`].
const TD_FEATURES: [(&str, &str); 7] = [
("ContextualLigatures", "calt"),
("Fractions", "frac"),
("Ordinals", "ordn"),
("Swash", "swsh"),
("Titling", "titl"),
("StylisticAlternates", "salt"),
("Ornaments", "ornm"),
];
/// The legacy key of the Stylistic Sets bit mask, as [`TXT2_STYLISTIC_SETS`] is for `Txt2`'s own.
const TD_STYLISTIC_SETS: &str = "StylisticSets";
/// The PostScript name of font `index` in a legacy font list (`DocumentResources /FontSet
/// /Resources`: `<< /Resource << /StreamTag /CoolTypeFont /Identifier << /Name (name) /Type n >> >> >>`).
fn td_font_name(fonts: &[E], index: i64) -> Option<&str> {
fonts.get(usize::try_from(index).ok()?)?.path(&["Resource", "Identifier", "Name"])?.as_str()
}
/// Whether one legacy `Features` dictionary agrees with `s`: [`txt2_style_agrees`] for the named
/// keys. A key the file's run lacks, or holds as another type, is no evidence of an edit.
fn td_style_agrees(s: &CharStyle, style: &E, fonts: &[E], k: f32) -> bool {
let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0);
let number = |key: &str, want: f32| style.get(key).and_then(E::as_f64).is_none_or(|v| close(v, f64::from(want)));
let flag = |key: &str, want: bool| style.get(key).and_then(E::as_bool).is_none_or(|v| v == want);
if style.get("Font").and_then(E::as_i64).and_then(|i| td_font_name(fonts, i)).is_some_and(|name| name != postscript_for(s)) {
return false;
}
let auto_leading = s.leading_pt.is_none();
if !(number("FontSize", s.size_pt * k) && flag("FauxBold", s.faux_bold) && flag("FauxItalic", s.faux_italic) && flag("AutoLeading", auto_leading)) {
return false;
}
if style.get("AutoLeading").and_then(E::as_bool) == Some(false) && !auto_leading && !number("Leading", s.leading_pt.unwrap_or(0.0) * k) {
return false;
}
if !(number("HorizontalScale", s.horizontal_scale) && number("VerticalScale", s.vertical_scale) && number("Tracking", s.tracking.round())) {
return false;
}
// Per run, like the rest: a baseline shift, the case and baseline position, the underline and
// strikethrough (a non-zero position is on, whichever position the file chose), the ligatures
// and the auto-kern mode.
let whole = |key: &str, want: i64| style.get(key).and_then(E::as_i64).is_none_or(|v| v == want);
let on = |key: &str, want: bool| style.get(key).and_then(E::as_i64).is_none_or(|v| (v != 0) == want);
if !(number("BaselineShift", s.baseline_shift_pt * k)
&& whole("FontCaps", caps_code(s))
&& whole("FontBaseline", baseline_code(s))
&& on("UnderlinePosition", s.underline)
&& on("StrikethroughPosition", s.strikethrough)
&& flag("Ligatures", s.ligatures)
&& flag("DiscretionaryLigatures", s.discretionary_ligatures)
&& whole("AutoKern", txt2_mode(s)))
{
return false;
}
if let Some(paint) = style.get("FillColor").and_then(|p| p.get("Color")) {
let (kind, values) = fill_color(&s.color);
if paint.get("Type").and_then(E::as_i64).is_some_and(|v| v != kind) {
return false;
}
if let Some(have) = paint.get("Values").and_then(E::as_array) {
let same =
have.len() == values.len() && have.iter().zip(&values).all(|(a, b)| matches!((a.as_f64(), b.as_f64()), (Some(a), Some(b)) if close(a, b)));
if !same {
return false;
}
}
}
let feature = |tag: &str| s.features.iter().find(|f| f.tag == tag).map(|f| f.value > 0);
if !TD_FEATURES.iter().all(|&(key, tag)| flag(key, feature(tag).unwrap_or(tag == "calt"))) {
return false;
}
let sets = (0..20).filter(|n| feature(&format!("ss{:02}", n + 1)) == Some(true)).fold(0i64, |m, n| m | 1 << n);
style.get(TD_STYLISTIC_SETS).and_then(E::as_i64).filter(|m| (0..1 << 20).contains(m)).is_none_or(|m| m == sets)
}
/// The index of the PostScript font `name` in a legacy font list, appending it (shaped like the
/// list's first entry, with its font type) when missing. `None` for an empty list.
fn td_font(fonts: &mut Vec<E>, name: &str) -> Option<i64> {
if let Some(i) = fonts.iter().position(|f| f.path(&["Resource", "Identifier", "Name"]).and_then(E::as_str) == Some(name)) {
return i64::try_from(i).ok();
}
let mut entry = fonts.first()?.clone();
entry.get_mut("Resource")?.get_mut("Identifier")?.set("Name", E::String(name.to_string()));
fonts.push(entry);
i64::try_from(fonts.len() - 1).ok()
}
/// `entry` (an object at `/DocumentObjects /TextObjects`) with its `Model /StyleRun` runs remade
/// from the layer's character runs: the entry's first style run with its `Features` set from each
/// run's own, every other key Photoshop wrote kept, as [`txt2_style_runs`] does for the numbered
/// objects. Photoshop re-renders a type layer from this copy when the file has one, so a
/// style-only edit left here showed the old style on reopen. `None` when the first run has no
/// `Features` dictionary, or is larger than a template may be: the entry is then left as the file
/// has it.
fn td_restyled(entry: &E, layer: &TextLayer, fonts: &mut Vec<E>, dpi: f32) -> Option<E> {
let base = entry.path(&["Model", "StyleRun", "RunArray"])?.as_array()?.first()?;
if !matches!(base.path(&["RunData", "StyleSheet", "Features"]), Some(E::Dict(_))) || !fits(base, &mut { TEMPLATE_NODES }) {
return None;
}
let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 };
let runs = layer.char_runs();
let default = CharStyle::default();
let mut out = Vec::new();
for (ri, n) in char_spans(layer) {
let s = runs.get(ri).map_or(&default, |r| &r.style);
let mut run = base.clone();
let features = run.get_mut("RunData")?.get_mut("StyleSheet")?.get_mut("Features")?;
if let Some(font) = td_font(fonts, &postscript_for(s)) {
features.set("Font", E::Int(font));
}
features.set("FontSize", real(s.size_pt * k));
features.set("FauxBold", E::Bool(s.faux_bold));
features.set("FauxItalic", E::Bool(s.faux_italic));
features.set("AutoLeading", E::Bool(s.leading_pt.is_none()));
// An automatic leading's value is Photoshop's own (`.01` in the files seen): kept.
if let Some(leading) = s.leading_pt {
features.set("Leading", real(leading * k));
}
features.set("HorizontalScale", real(s.horizontal_scale));
features.set("VerticalScale", real(s.vertical_scale));
features.set("Tracking", E::Int(s.tracking.round() as i64));
features.set("AutoKern", E::Int(txt2_mode(s)));
features.set("BaselineShift", real(s.baseline_shift_pt * k));
features.set("FontCaps", E::Int(caps_code(s)));
features.set("FontBaseline", E::Int(baseline_code(s)));
// On keeps the position the file chose (a non-zero value), else the first one.
for (key, on) in [("UnderlinePosition", s.underline), ("StrikethroughPosition", s.strikethrough)] {
let kept = features.get(key).and_then(E::as_i64).filter(|v| *v != 0).unwrap_or(1);
features.set(key, E::Int(if on { kept } else { 0 }));
}
features.set("Ligatures", E::Bool(s.ligatures));
features.set("DiscretionaryLigatures", E::Bool(s.discretionary_ligatures));
if let Some(paint @ E::Dict(_)) = features.get_mut("FillColor").and_then(|p| p.get_mut("Color")) {
let (kind, values) = fill_color(&s.color);
paint.set("Type", E::Int(kind));
paint.set("Values", E::Array(values));
}
let feature = |tag: &str| s.features.iter().find(|f| f.tag == tag).map(|f| f.value > 0);
for (key, tag) in TD_FEATURES {
features.set(key, E::Bool(feature(tag).unwrap_or(tag == "calt")));
}
let sets = (0..20).filter(|n| feature(&format!("ss{:02}", n + 1)) == Some(true)).fold(0i64, |m, n| m | 1 << n);
features.set(TD_STYLISTIC_SETS, E::Int(sets));
run.set("Length", E::Int(n));
out.push(run);
}
let mut entry = entry.clone();
entry.get_mut("Model")?.get_mut("StyleRun")?.set("RunArray", E::Array(out));
Some(entry)
}
/// The index of the PostScript font `name` in a `Txt2` font list (`<< /0 << /99 /CoolTypeFont
/// /0 << /0 name /2 type >> >> >>` entries), appending it (shaped like the list's first entry)
/// when missing. `None` for an empty list: there is no entry to shape a new one on.
@@ -291,6 +571,10 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi
// in place, never grown: a layer the block has no entry for has no stale copy to fix.
let mut td: Option<Vec<E>> = prev.as_ref().and_then(|p| p.path(&["DocumentObjects", "TextObjects"])).and_then(E::as_array).map(<[E]>::to_vec);
let mut td_changed = false;
// The legacy block's font list (`DocumentResources /FontSet /Resources`), which its style runs index.
let mut td_fonts: Vec<E> =
prev.as_ref().and_then(|p| p.path(&["DocumentResources", "FontSet", "Resources"])).and_then(E::as_array).map(<[E]>::to_vec).unwrap_or_default();
let td_fonts_before = td_fonts.len();
let mut fonts: Vec<E> = prev.as_ref().and_then(|p| p.path(&["0", "1", "0"]).and_then(E::as_array)).map(<[E]>::to_vec).unwrap_or_default();
let fonts_before = fonts.len();
// Looked up once per save, not per layer: a crafted block with many objects must not cost
@@ -333,12 +617,27 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi
*slot = fixed;
td_changed = true;
}
// And its character style, when it no longer says what the layer says: a style-only
// edit (font, size, faux bold, …) left the old style here, and Photoshop reads it back.
if let Some(entry) = entries.get_mut(at)
&& !td_style_matches(layer, entry, &td_fonts, dpi)
&& let Some(restyled) = td_restyled(entry, layer, &mut td_fonts, dpi)
{
*entry = restyled;
td_changed = true;
}
}
// A stub an earlier save wrote beside Photoshop's objects counts as no object at all, so
// it is replaced like a missing one (below) and re-saving such a file repairs it, whether
// or not the layer's text changed.
let slot = prev_objects.get(at).filter(|o| !is_stub(o) || all_stubs);
let kept = slot.filter(|o| o.get("0").and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str()));
// Kept only while its style runs still say what the layer says (#8415): Photoshop reads
// the character style from them, so a style-only edit (font, faux bold, size, …) must
// restyle the object the way an edited one is, or the file's old style shows on reopen.
let kept = slot.filter(|o| {
let model = o.get("0");
model.and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str()) && model.is_some_and(|m| txt2_style_matches(layer, m, &fonts, dpi))
});
// An edited object (#6968): the file's object at this slot with its text changed. It keeps
// the file's structure — paragraph runs, the view naming its frame, the rest — with every
// run array refitted to the new text. A bare `<< /0 << /0 text /6 runs >> >>` stub in
@@ -445,6 +744,11 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi
&& let Some(objects @ E::Dict(_)) = outer.get_mut("DocumentObjects")
{
objects.set("TextObjects", E::Array(entries));
if td_fonts.len() != td_fonts_before
&& let Some(set @ E::Dict(_)) = outer.get_mut("DocumentResources").and_then(|r| r.get_mut("FontSet"))
{
set.set("Resources", E::Array(td_fonts));
}
}
// Nothing changed: the file's own bytes (writing them back would re-encode strings and numbers).
if let (Some(bytes), Some(p)) = (previous, &prev)
+2 -2
View File
@@ -36,7 +36,7 @@ fn fits_budget(v: &E, nodes: &mut usize, bytes: &mut usize) -> bool {
fits_budget(x, nodes, bytes)
}),
E::Array(items) => items.iter().all(|x| fits_budget(x, nodes, bytes)),
E::String(s) | E::Name(s) => {
E::String(s) | E::RawString(s) | E::Name(s) => {
let Some(rem) = bytes.checked_sub(s.len()) else { return false };
*bytes = rem;
true
@@ -150,7 +150,7 @@ pub(super) fn td_edited_object(object: &E, text: &str) -> Option<E> {
let E::Dict(entries) = model else { return None };
// The text must be there to replace: refitted runs over a text the model doesn't hold would
// be a half-edited object.
if !entries.iter().any(|(k, v)| k == "Text" && matches!(v, E::String(_))) {
if !entries.iter().any(|(k, v)| k == "Text" && matches!(v, E::String(_) | E::RawString(_))) {
return None;
}
let total = utf16_len(text) as usize;
+23 -10
View File
@@ -32,19 +32,32 @@ pub fn apply_txt2(layer: &mut TextLayer, tysh: &[u8], txt2: &E) {
}) else {
return;
};
let Some(model) = txt2.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.get(index)).and_then(|o| o.get("0")) else {
return;
// The numbered object, else the older Photoshop's at `/DocumentObjects /TextObjects` (named
// keys: run lengths under `Length`, the style under `RunData /StyleSheet /Features`, the
// auto-kern mode under `AutoKern`).
let numbered = txt2
.path(&["1", "1"])
.and_then(E::as_array)
.and_then(|a| a.get(index))
.and_then(|o| o.get("0"))
.and_then(|model| Some((model.get("0").and_then(E::as_str)?, model.path(&["6", "0"]).and_then(E::as_array)?)));
let legacy = || {
let model = txt2.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(|a| a.get(index))?.get("Model")?;
Some((model.get("Text").and_then(E::as_str)?, model.path(&["StyleRun", "RunArray"]).and_then(E::as_array)?))
};
let Some(text) = model.get("0").and_then(E::as_str) else {
return;
let (text, style_runs, is_numbered) = match numbered {
Some((text, runs)) => (text, runs, true),
None => {
let Some((text, runs)) = legacy() else { return };
(text, runs, false)
}
};
if text.strip_suffix('\r').unwrap_or(text).replace('\r', "\n") != layer.text {
return;
}
let Some(style_runs) = model.path(&["6", "0"]).and_then(E::as_array) else {
return;
};
let styles: Vec<(f64, Option<&E>)> = style_runs.iter().map(|r| (r.get("1").and_then(E::as_f64).unwrap_or(0.0), r.path(&["0", "0", "6"]))).collect();
let (len_key, style_path, kern_key): (&str, &[&str], &str) =
if is_numbered { ("1", &["0", "0", "6"], "11") } else { ("Length", &["RunData", "StyleSheet", "Features"], "AutoKern") };
let styles: Vec<(f64, Option<&E>)> = style_runs.iter().map(|r| (r.get(len_key).and_then(E::as_f64).unwrap_or(0.0), r.path(style_path))).collect();
let lens = utf16_to_byte_lengths(text, &styles.iter().map(|m| m.0).collect::<Vec<_>>());
// Txt2 style per byte segment, then split the model runs at segment boundaries.
let mut segs: Vec<(usize, Option<&E>)> = lens.into_iter().zip(styles.iter().map(|m| m.1)).collect();
@@ -65,13 +78,13 @@ pub fn apply_txt2(layer: &mut TextLayer, tysh: &[u8], txt2: &E) {
let txt2_style = segs.get(si).and_then(|s| s.1).filter(|_| at < seg_end);
// Txt2 has the exact mode (EngineData's reading also marks neighbours of manual
// kerns manual, like Photoshop's legacy reader).
match txt2_style.and_then(|st| st.get("11")).and_then(E::as_i64) {
match txt2_style.and_then(|st| st.get(kern_key)).and_then(E::as_i64) {
Some(0) => style.kerning = Kerning::Off,
Some(1) => style.kerning = Kerning::Metrics,
Some(2) => style.kerning = Kerning::Optical,
_ => {}
}
if let Some(st) = txt2_style {
if let Some(st) = txt2_style.filter(|_| is_numbered) {
add_txt2_features(&mut style, st);
}
let len = piece_end - at;
+332
View File
@@ -1436,6 +1436,56 @@ fn edited_text_keeps_the_files_txt2_object() {
}
}
/// #8415: a style-only edit (`type.setStyle` with no text change) reaches the file's `Txt2`.
/// Photoshop reads the character style from the object's style runs, and an object whose text
/// still matched kept the file's runs, so Photoshop reopened the layer in its old font with faux
/// bold still on. A layer whose style agrees with the runs still gets the block back byte for
/// byte (#2374); one whose font or faux bold changed is restyled like an edited object, and the
/// glyph pen positions laid out in the old style are dropped.
#[test]
fn style_only_edit_restyles_the_kept_txt2_object() {
use crate::engine_data::Value as E;
// Strings in the block are UTF-16 with a BOM; every character here is below U+0100.
let utf16 = |s: &str| format!("(\u{fe}\u{ff}{})", s.chars().map(|c| format!("\0{c}")).collect::<String>());
let font = |name: &str| format!("<< /0 << /99 /CoolTypeFont /0 << /0 {} /2 1 >> >> >> ", utf16(name));
// One object as Photoshop writes it: paragraph runs, style runs, a view and pen positions.
let prev = format!(
"\n\n/98 << /0 14 >> /0 << /1 << /0 [ {}{}] >> >> /1 << /1 [ << /0 << /0 {} /5 << /0 [ << /0 << /0 << /0 (\u{fe}\u{ff}) /5 << /7 1.2 >> >> >> /1 3 >> ] >> /6 << /0 [ << /0 << /0 << /0 (\u{fe}\u{ff}) /6 << /0 0 /1 12.0 /2 true /3 false /11 1 >> >> >> /1 3 >> ] >> >> /1 << /0 [ << /0 0 >> ] >> /21 << /1 [ 1.25 2.5 ] >> >> ] >>",
font("Arial-Black"),
font("ArialMT"),
utf16("AV\r"),
);
let prev: Vec<u8> = prev.chars().map(|c| c as u32 as u8).collect();
let style = CharStyle { postscript_name: Some("Arial-Black".into()), size_pt: 12.0, faux_bold: true, ..Default::default() };
let t = styled("AV", style.clone());
assert_eq!(crate::psd::build_txt2(&[(0, &t)], Some(&prev)), prev, "an unedited style keeps the file's block");
let font_name = |out: &E, run: &E| {
let i = run.path(&["0", "0", "6", "0"]).and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).expect("a font index");
out.path(&["0", "1", "0"]).and_then(E::as_array).and_then(|f| f.get(i)).and_then(|f| f.path(&["0", "0", "0"])).and_then(E::as_str).map(str::to_owned)
};
for (what, edited, want_font) in [
("faux bold off", CharStyle { faux_bold: false, ..style.clone() }, "Arial-Black"),
(
"font and faux bold",
CharStyle { postscript_name: Some("AppleSDGothicNeo-Heavy".into()), faux_bold: false, ..style.clone() },
"AppleSDGothicNeo-Heavy",
),
] {
let t = styled("AV", edited);
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap();
let object = &out.path(&["1", "1"]).and_then(E::as_array).unwrap()[0];
assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some("AV\r"), "{what}: the text is unchanged");
let runs = object.path(&["0", "6", "0"]).and_then(E::as_array).unwrap();
assert!(!runs.is_empty(), "{what}: the object has style runs");
for run in runs {
assert_eq!(run.path(&["0", "0", "6", "2"]).and_then(E::as_bool), Some(false), "{what}: faux bold is off in Txt2");
assert_eq!(font_name(&out, run).as_deref(), Some(want_font), "{what}: Txt2 names the layer's font");
}
assert!(object.get("21").is_none(), "{what}: pen positions of the old style are dropped");
}
}
/// A type layer the file's `Txt2` has no object for (one added before the save, #7698) gets an
/// object shaped like the file's own, with a view naming its own frame and a frame in `/0 /8 /0`,
/// instead of a bare stub: Photoshop and Photopea refused the file when the frames ran out.
@@ -3158,3 +3208,285 @@ fn per_colour_passes_match_full_size_passes() {
}
}
}
/// A legacy (Photoshop-era) `Txt2` block shaped like a real one (psd-tools' `text.psd`): the
/// fonts in `DocumentResources /FontSet /Resources`, and each object's `Model /StyleRun` runs
/// holding their style under `RunData /StyleSheet /Features` (named keys) beside `Length`.
fn styled_era_txt2(text: &str, font: &str, size: f64, auto_kern: i64) -> Vec<u8> {
use crate::engine_data::Value as E;
let font_entry = |name: &str| {
E::Dict(vec![(
"Resource".into(),
E::Dict(vec![
("StreamTag".into(), E::Name("CoolTypeFont".into())),
("Identifier".into(), E::Dict(vec![("Name".into(), E::String(name.into())), ("Type".into(), E::Int(1))])),
]),
)])
};
let features = E::Dict(vec![
("Font".into(), E::Int(0)),
("FontSize".into(), E::Real(size)),
("FauxBold".into(), E::Bool(false)),
("FauxItalic".into(), E::Bool(false)),
("AutoLeading".into(), E::Bool(true)),
("Leading".into(), E::Real(0.01)),
("HorizontalScale".into(), E::Real(1.0)),
("VerticalScale".into(), E::Real(1.0)),
("Tracking".into(), E::Int(0)),
("AutoKern".into(), E::Int(auto_kern)),
("BaselineShift".into(), E::Real(0.0)),
("FontCaps".into(), E::Int(0)),
("FontBaseline".into(), E::Int(0)),
("UnderlinePosition".into(), E::Int(0)),
("StrikethroughPosition".into(), E::Int(0)),
("Ligatures".into(), E::Bool(true)),
("DiscretionaryLigatures".into(), E::Bool(false)),
("ContextualLigatures".into(), E::Bool(true)),
("StylisticSets".into(), E::Int(0)),
(
"FillColor".into(),
E::Dict(vec![
("StreamTag".into(), E::Name("SimplePaint".into())),
(
"Color".into(),
E::Dict(vec![("Type".into(), E::Int(1)), ("Values".into(), E::Array(vec![E::Real(1.0), E::Real(0.0), E::Real(0.0), E::Real(0.0)]))]),
),
]),
),
]);
let total = format!("{text}\r").encode_utf16().count() as i64;
let run = |len: i64, data: E| E::Dict(vec![("RunData".into(), data), ("Length".into(), E::Int(len))]);
let placeholder = || E::Dict(vec![("0".into(), E::String(String::new()))]);
let style_data = E::Dict(vec![(
"StyleSheet".into(),
E::Dict(vec![("Name".into(), E::String(String::new())), ("Parent".into(), E::Int(0)), ("Features".into(), features)]),
)]);
let entry = E::Dict(vec![
(
"Model".into(),
E::Dict(vec![
("Text".into(), E::String(format!("{text}\r"))),
("ParagraphRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total, placeholder())]))])),
("StyleRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total, style_data)]))])),
("KernRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total, placeholder())]))])),
]),
),
("View".into(), E::Dict(vec![("0".into(), E::Array(vec![E::Dict(vec![("0".into(), E::Int(0))])]))])),
]);
crate::engine_data::write_bare(&[
("DocumentResources".into(), E::Dict(vec![("FontSet".into(), E::Dict(vec![("Resources".into(), E::Array(vec![font_entry(font)]))]))])),
("DocumentObjects".into(), E::Dict(vec![("TextObjects".into(), E::Array(vec![entry]))])),
])
}
/// The length and `Features` of each style run of the first legacy object.
fn era_runs(block: &crate::engine_data::Value) -> Vec<(i64, crate::engine_data::Value)> {
use crate::engine_data::Value as E;
block
.path(&["DocumentObjects", "TextObjects"])
.and_then(E::as_array)
.and_then(<[E]>::first)
.and_then(|e| e.path(&["Model", "StyleRun", "RunArray"]))
.and_then(E::as_array)
.map(|rs| {
rs.iter()
.map(|r| (r.get("Length").and_then(E::as_i64).unwrap_or(-1), r.path(&["RunData", "StyleSheet", "Features"]).cloned().unwrap_or(E::Int(0))))
.collect()
})
.unwrap_or_default()
}
/// Review of #8888 (CodeRabbit, psd.rs): a style-only edit (no text change) restyles the older
/// Photoshop's copy too, which Photoshop re-renders from. Its font list gains the new font, the
/// run keeps every key Photoshop wrote, and the lengths still cover the text.
#[test]
fn a_style_only_edit_restyles_the_photoshop_era_objects() {
use crate::engine_data::Value as E;
let prev = styled_era_txt2("Line 1", "ArialMT", 13.0, 1);
let style = CharStyle {
postscript_name: Some("Courier".into()),
size_pt: 20.0,
faux_bold: true,
color: Color::rgb(0.0, 0.5, 1.0),
tracking: 25.0,
..Default::default()
};
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &styled("Line 1", style))], Some(&prev))).unwrap();
let runs = era_runs(&out);
assert_eq!(runs.len(), 1);
let (length, f) = &runs[0];
assert_eq!(*length, 7, "the run covers the text and its trailing return");
assert_eq!(f.get("FontSize").and_then(E::as_f64), Some(20.0));
assert_eq!(f.get("FauxBold").and_then(E::as_bool), Some(true));
assert_eq!(f.get("Tracking").and_then(E::as_i64), Some(25));
assert_eq!(f.get("Leading").and_then(E::as_f64), Some(0.01), "an automatic leading keeps Photoshop's own value");
assert_eq!(f.path(&["FillColor", "Color", "Type"]).and_then(E::as_i64), Some(1));
assert_eq!(f.path(&["FillColor", "Color", "Values"]).and_then(E::as_array).map(<[E]>::len), Some(4));
assert_eq!(f.path(&["FillColor", "StreamTag"]).and_then(E::as_str), Some("SimplePaint"), "a key the file held is kept");
// The font went into the block's list and the run names it.
let fonts = out.path(&["DocumentResources", "FontSet", "Resources"]).and_then(E::as_array).expect("the font list");
let index = f.get("Font").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).expect("the font index");
let name = |i: usize| fonts.get(i).and_then(|r| r.path(&["Resource", "Identifier", "Name"])).and_then(E::as_str);
assert_eq!(name(index), Some("Courier"));
assert_eq!(name(0), Some("ArialMT"), "the file's font stays first");
// The text did not change.
let text =
out.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(<[E]>::first).and_then(|e| e.path(&["Model", "Text"])).and_then(E::as_str);
assert_eq!(text, Some("Line 1\r"));
}
/// A layer whose style already agrees with the file's run leaves the legacy objects as they are.
#[test]
fn an_unedited_style_leaves_the_photoshop_era_objects_alone() {
let prev = styled_era_txt2("Line 1", "ArialMT", 13.0, 1);
let same = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 13.0, ..Default::default() };
let out = crate::psd::build_txt2(&[(0, &styled("Line 1", same))], Some(&prev));
assert_eq!(era_runs(&crate::psd::parse_txt2(&prev).unwrap()), era_runs(&crate::psd::parse_txt2(&out).unwrap()));
}
/// Both object lists, as CodeRabbit asked: the numbered object and the older copy are restyled by
/// the same style-only edit.
#[test]
fn a_style_only_edit_restyles_both_object_lists() {
use crate::engine_data::Value as E;
let mut prev = photoshop_txt2(&[(4, 1)]);
prev.extend_from_slice(&styled_era_txt2("AVA", "ArialMT", 13.0, 1));
let style = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 31.0, faux_italic: true, ..Default::default() };
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &styled("AVA", style))], Some(&prev))).unwrap();
let legacy = era_runs(&out);
let first = legacy.first().map(|(l, f)| (*l, f.get("FontSize").and_then(E::as_f64), f.get("FauxItalic").and_then(E::as_bool)));
assert_eq!(first, Some((4, Some(31.0), Some(true))));
let numbered = out
.path(&["1", "1"])
.and_then(E::as_array)
.and_then(<[E]>::first)
.and_then(|o| o.path(&["0", "6", "0"]))
.and_then(E::as_array)
.and_then(<[E]>::first)
.and_then(|r| r.path(&["0", "0", "6"]));
assert_eq!(numbered.and_then(|s| s.get("1")).and_then(E::as_f64), Some(31.0), "the numbered object is restyled too");
}
/// Two character runs give two style runs in the legacy copy, with the lengths of the runs.
#[test]
fn a_two_run_layer_gives_the_photoshop_era_copy_two_style_runs() {
use crate::engine_data::Value as E;
let prev = styled_era_txt2("Hello", "ArialMT", 13.0, 1);
let small = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 10.0, ..Default::default() };
let big = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 40.0, ..Default::default() };
let t = runs_of("Hello", &[(2, small), (3, big)]);
let runs = era_runs(&crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap());
let seen: Vec<(i64, Option<f64>)> = runs.iter().map(|(l, f)| (*l, f.get("FontSize").and_then(E::as_f64))).collect();
assert_eq!(seen, vec![(2, Some(10.0)), (4, Some(40.0))], "the last run takes the trailing return");
}
/// Review of #8888: every key the legacy restyle writes is written per run, so a multi-run edit
/// does not copy the first run's baseline shift, case, underline or strikethrough to the others.
#[test]
fn each_photoshop_era_style_run_gets_its_own_values() {
use crate::engine_data::Value as E;
use photocraft_doc::text::{Caps, FontBaseline};
let prev = styled_era_txt2("Hello", "ArialMT", 13.0, 1);
let base = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 13.0, ..Default::default() };
let decorated = CharStyle { baseline_shift_pt: 3.0, caps: Caps::SmallCaps, baseline: FontBaseline::Superscript, underline: true, ..base.clone() };
let struck = CharStyle { strikethrough: true, caps: Caps::AllCaps, ligatures: false, discretionary_ligatures: true, ..base.clone() };
let t = runs_of("Hello", &[(2, decorated), (2, base.clone()), (1, struck)]);
let runs = era_runs(&crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap());
let key = |i: usize, k: &str| runs.get(i).and_then(|(_, f)| f.get(k)).cloned();
assert_eq!(runs.iter().map(|r| r.0).collect::<Vec<_>>(), vec![2, 2, 2]);
assert_eq!(key(0, "BaselineShift").and_then(|v| v.as_f64()), Some(3.0));
assert_eq!(key(1, "BaselineShift").and_then(|v| v.as_f64()), Some(0.0), "the second run is not the first");
assert_eq!([0, 1, 2].map(|i| key(i, "FontCaps").and_then(|v| v.as_i64())), [Some(1), Some(0), Some(2)]);
assert_eq!([0, 1, 2].map(|i| key(i, "FontBaseline").and_then(|v| v.as_i64())), [Some(1), Some(0), Some(0)]);
assert_eq!([0, 1, 2].map(|i| key(i, "UnderlinePosition").and_then(|v| v.as_i64()).map(|v| v != 0)), [Some(true), Some(false), Some(false)]);
assert_eq!([0, 1, 2].map(|i| key(i, "StrikethroughPosition").and_then(|v| v.as_i64()).map(|v| v != 0)), [Some(false), Some(false), Some(true)]);
assert_eq!([0, 1, 2].map(|i| key(i, "Ligatures").and_then(|v| v.as_bool())), [Some(true), Some(true), Some(false)]);
assert_eq!([0, 1, 2].map(|i| key(i, "DiscretionaryLigatures").and_then(|v| v.as_bool())), [Some(false), Some(false), Some(true)]);
let _ = E::Int(0);
}
/// Each of those keys alone is enough to restyle the legacy copy (an edit of only that key), and
/// a layer that agrees on all of them leaves the block's bytes alone.
#[test]
fn every_legacy_style_key_is_compared() {
use photocraft_doc::text::{Caps, FontBaseline, Kerning};
let prev = styled_era_txt2("Hello", "ArialMT", 13.0, 1);
let base = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 13.0, ..Default::default() };
let unedited = crate::psd::build_txt2(&[(0, &styled("Hello", base.clone()))], Some(&prev));
assert!(
era_runs(&crate::psd::parse_txt2(&unedited).unwrap()) == era_runs(&crate::psd::parse_txt2(&prev).unwrap()),
"an agreeing layer leaves the legacy copy as it is"
);
let edits = [
CharStyle { baseline_shift_pt: 2.0, ..base.clone() },
CharStyle { caps: Caps::AllCaps, ..base.clone() },
CharStyle { baseline: FontBaseline::Subscript, ..base.clone() },
CharStyle { underline: true, ..base.clone() },
CharStyle { strikethrough: true, ..base.clone() },
CharStyle { ligatures: false, ..base.clone() },
CharStyle { discretionary_ligatures: true, ..base.clone() },
CharStyle { kerning: Kerning::Optical, ..base.clone() },
];
for (i, edit) in edits.into_iter().enumerate() {
let out = crate::psd::build_txt2(&[(0, &styled("Hello", edit))], Some(&prev));
assert!(era_runs(&crate::psd::parse_txt2(&out).unwrap()) != era_runs(&crate::psd::parse_txt2(&prev).unwrap()), "edit {i} restyles the legacy copy");
}
}
/// Review of #8888: the legacy `AutoKern` mode is read on import, as `/11` is for the numbered
/// objects, so the layer a legacy file opens as agrees with its legacy copy.
#[test]
fn legacy_auto_kern_is_read_on_import() {
use photocraft_doc::text::Kerning;
let t = styled("AVA", CharStyle::default());
let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, None), 0);
for (mode, want) in [(0, Kerning::Off), (1, Kerning::Metrics), (2, Kerning::Optical)] {
let txt2 = crate::psd::parse_txt2(&styled_era_txt2("AVA", "ArialMT", 13.0, mode)).unwrap();
let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
crate::psd::apply_txt2(&mut back, &tysh, &txt2);
assert!(
back.char_runs().iter().all(|r| r.style.kerning == want),
"AutoKern {mode}: {:?}",
back.char_runs().iter().map(|r| r.style.kerning).collect::<Vec<_>>()
);
}
// Other text, or another object, changes nothing.
let txt2 = crate::psd::parse_txt2(&styled_era_txt2("AVX", "ArialMT", 13.0, 2)).unwrap();
let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
crate::psd::apply_txt2(&mut back, &tysh, &txt2);
assert!(back.char_runs().iter().all(|r| r.style.kerning == Kerning::Metrics));
}
/// Review of the batch (FredApps, with #9104): a style-only save rewrites `Txt2`, and the raw
/// (non-BOM) strings Photoshop wrote in it, `/2 (0100)` and `/3 (01110)` under `/98`, must come
/// back as the same bytes: re-encoded as `FE FF` UTF-16 they make Photoshop refuse the file.
#[test]
fn a_style_only_save_keeps_the_raw_strings_of_the_file() {
let mut prev = photoshop_txt2(&[(4, 1)]);
let version = b"/98 << /0 14 >>";
let at = prev.windows(version.len()).position(|w| w == version).expect("the version key");
prev.splice(at..at + version.len(), b"/98 << /0 14 /2 (0100) /3 (01110) >>".iter().copied());
// A size the file's runs do not have: the block is rewritten.
let style = CharStyle { size_pt: 31.0, faux_bold: false, ..Default::default() };
let out = crate::psd::build_txt2(&[(0, &styled("AVA", style))], Some(&prev));
assert_ne!(out, prev, "the style edit rewrites the block");
let has = |needle: &[u8]| out.windows(needle.len()).any(|w| w == needle);
assert!(has(b"(0100)") && has(b"(01110)"), "the raw strings come back as the same bytes: {:?}", String::from_utf8_lossy(&out));
assert!(!has(b"(\xfe\xff\x000\x001\x000\x000)"), "and not as UTF-16");
}
/// The same for the older Photoshop's objects: restyling the legacy copy leaves a raw string
/// elsewhere in the block as the file wrote it.
#[test]
fn restyling_the_photoshop_era_objects_keeps_the_raw_strings_of_the_file() {
use crate::engine_data::Value as E;
let era = crate::psd::parse_txt2(&styled_era_txt2("Line 1", "ArialMT", 13.0, 1)).unwrap();
let E::Dict(mut items) = era else { panic!("a dictionary") };
items.push(("98".into(), E::Dict(vec![("0".into(), E::Int(14)), ("2".into(), E::RawString("0100".into()))])));
let prev = crate::engine_data::write_bare(&items);
let style = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 24.0, ..Default::default() };
let out = crate::psd::build_txt2(&[(0, &styled("Line 1", style))], Some(&prev));
assert!(out.windows(6).any(|w| w == b"(0100)"), "{:?}", String::from_utf8_lossy(&out));
let runs = era_runs(&crate::psd::parse_txt2(&out).unwrap());
assert_eq!(runs.first().and_then(|(_, f)| f.get("FontSize")).and_then(E::as_f64), Some(24.0), "and the legacy copy was restyled");
}
+6
View File
@@ -22,6 +22,8 @@
//! `--right-click-at X,Y` opens a screen-space context menu after the script, including panel
//! and document-tab menus that are outside the document-coordinate control pointer.
//! `--click-at X,Y` opens a screen-space menu (for example the top Select menu) after the script.
//! `--hover-at X,Y` leaves the pointer there without a click, for what a tool shows under it (the
//! brush outline, the Clone Source overlay).
use photocraft_ui_egui::control::{ControlRequest, Outcome, handle};
use photocraft_ui_egui::{PhotocraftApp, Services};
@@ -170,6 +172,10 @@ fn main() {
harness.event(egui::Event::PointerButton { pos, button, pressed: false, modifiers: egui::Modifiers::NONE });
harness.run_steps(4);
}
if let Some((x, y)) = arg(&args, "--hover-at").and_then(|s| s.split_once(',').and_then(|(x, y)| Some((x.parse::<f32>().ok()?, y.parse::<f32>().ok()?)))) {
harness.event(egui::Event::PointerMoved(egui::pos2(x, y)));
harness.run_steps(4);
}
let t_settle = std::time::Instant::now();
while t_settle.elapsed() < std::time::Duration::from_millis(settle_ms) {
harness.step();
+108 -48
View File
@@ -6,14 +6,73 @@ use super::{Color32, DRect, PhotocraftApp, Pos2, Stroke, Tool, json, pos2};
pub(crate) struct ClonePreviewCache {
pub(super) key: String,
texture: egui::TextureHandle,
/// The brush a clipped overlay took its coverage from: a hardness or tip change redraws it.
pub(super) brush: Option<photocraft_paint::BrushSettings>,
/// The document area the texture covers.
pub(super) rect: DRect,
pub(super) texture: egui::TextureHandle,
}
/// A transient source image beneath the brush outline; never part of the document render.
/// The most document pixels one dab's coverage is rendered over for the overlay: a 1000 px brush,
/// the largest the overlay draws, at hardness 0 reaches about 1.73 × its radius (`tip_reach`).
const CLONE_PREVIEW_MAX_PIXELS: u64 = 1800 * 1800;
/// The largest side `retouch_cmds::clone_preview_periodic` samples, for the overlay's area and for
/// the source area it reads (`Mapping::source_rect`: the overlay's, mapped, with a 2 px apron).
const CLONE_PREVIEW_MAX_SIDE: u32 = 1024;
/// The coverage of one dab of `brush` at `at`, as the brush engine paints it with opacity and flow
/// at full: its tip, angle, roundness and hardness (a soft tip reaches past its radius). The area
/// it covers and its `width × height` coverage values in 0..1. A very large soft tip, or a source
/// the Clone Source panel scales down (which reads a larger area than it covers), is cropped
/// around `at` to the sampling budget, keeping the middle of the dab.
fn clone_dab_coverage(
brush: &photocraft_paint::BrushSettings,
at: [f64; 2],
map: &photocraft_engine::presets::clone_source::Mapping,
) -> Option<(DRect, Vec<f32>)> {
let brush = photocraft_paint::BrushSettings { opacity: 1.0, flow: 1.0, erase: false, ..brush.clone() };
let stroke = photocraft_paint::Stroke { brush, points: vec![photocraft_paint::StrokePoint::new(at[0], at[1], 1.0)] };
let (rect, cov) = photocraft_paint::retouch::stroke_coverage_within(&stroke, CLONE_PREVIEW_MAX_PIXELS)?;
let fits = |r: DRect| r.width() <= CLONE_PREVIEW_MAX_SIDE && r.height() <= CLONE_PREVIEW_MAX_SIDE;
let (cx, cy) = (at[0].floor() as i32, at[1].floor() as i32);
let around = |half: i32| rect.intersect(&DRect::new(cx.saturating_sub(half), cy.saturating_sub(half), cx.saturating_add(half), cy.saturating_add(half)));
// The source area scales with the overlay's: shrink by what overflows, a few times for the
// apron's rounding.
let mut half = i32::try_from((CLONE_PREVIEW_MAX_SIDE - 4) / 2).unwrap_or(510);
let mut window = around(half);
for _ in 0..8 {
let src = map.source_rect(window);
if fits(window) && fits(src) {
break;
}
let over = f64::from(src.width().max(src.height())) / f64::from(CLONE_PREVIEW_MAX_SIDE - 4);
half = ((f64::from(half) / over.max(1.0)).floor() as i32).saturating_sub(2).max(1);
window = around(half);
}
if window.is_empty() || !fits(window) || !fits(map.source_rect(window)) {
return None;
}
if window == rect {
return Some((rect, cov));
}
let stride = rect.width() as usize;
let mut cropped = Vec::with_capacity(window.width() as usize * window.height() as usize);
for y in window.y0..window.y1 {
let row = (y - rect.y0) as usize * stride;
let from = row + (window.x0 - rect.x0) as usize;
cropped.extend_from_slice(cov.get(from..from + window.width() as usize)?);
}
Some((window, cropped))
}
/// A transient source image beneath the brush outline; never part of the document render. The
/// Clone Source panel drives the Clone Stamp and the Healing Brush alike, so both show it.
pub(super) fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, at: [f64; 2], output: Option<u32>) {
use photocraft_engine::presets::clone_source::{Mapping, overlay_blend, transform_matrix};
let overlay = app.session.presets.clone.overlay.clone();
if app.ui.tool != Tool::CloneStamp || !overlay.show || (overlay.auto_hide && app.drag.is_some()) {
let tool = app.ui.tool;
if !matches!(tool, Tool::CloneStamp | Tool::Healing) || !overlay.show || (overlay.auto_hide && app.drag.is_some()) {
return;
}
let slot = app.session.presets.clone.active().clone();
@@ -22,30 +81,43 @@ pub(super) fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painte
if !radius.is_finite() || radius <= 0.0 || radius > 500.0 || !at.iter().all(|v| v.is_finite() && v.abs() < 1_000_000.0) {
return;
}
// View › Pattern Preview repeats the source for the Clone Stamp only (`paint.cloneStamp`'s
// `wrap`); the Healing Brush samples it as it is.
let wrap = tool == Tool::CloneStamp && app.ui.view.pattern_preview;
let stroke_start = app.drag.as_ref().map(|d| d.start);
let anchor = if app.ui.tool_options.clone_aligned { slot.anchor.or(stroke_start) } else { stroke_start }.unwrap_or(at);
let map =
Mapping { source: (source[0], source[1]), anchor: (anchor[0], anchor[1]), m: transform_matrix(slot.scale, slot.rotation, slot.flip_h, slot.flip_v) };
let rect = DRect::new((at[0] - radius).floor() as i32, (at[1] - radius).floor() as i32, (at[0] + radius).ceil() as i32, (at[1] + radius).ceil() as i32);
let Some(st) = app.session.active() else { return };
let (display, display_key) = canvas_display(app, &st.doc, output);
// A shaped tip (sampled bitmap, or a rotated / flattened round one) clips the overlay to its
// outline instead of a circle, as Photoshop does with Clipped on.
let tip_rings = if overlay.clipped { crate::brush_cursor::rings(painter.ctx(), &app.session.tools.brush, true) } else { None };
let tip_key = tip_rings.as_ref().map(|r| {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
r.iter().flatten().for_each(|p| (p[0].to_bits(), p[1].to_bits()).hash(&mut h));
(h.finish(), radius.to_bits(), at.map(f64::to_bits))
});
let key = format!(
"{:?}:{}:{:?}:{rect:?}:{map:?}:{}:{}:{}:{}:{}:{tip_key:?}",
st.doc.id, st.revision, st.active_layer, app.ui.tool_options.clone_sample, display_key, overlay.invert, overlay.blend, app.ui.view.pattern_preview
"{:?}:{}:{:?}:{:?}:{map:?}:{}:{}:{}:{}:{wrap}:{}:{}",
st.doc.id,
st.revision,
st.active_layer,
at.map(f64::to_bits),
app.ui.tool_options.clone_sample,
display_key,
overlay.invert,
overlay.blend,
overlay.clipped,
radius.to_bits()
);
if app.clone_preview.as_ref().is_none_or(|c| c.key != key) {
let Ok(mut buf) =
photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &map, &app.ui.tool_options.clone_sample, app.ui.view.pattern_preview)
else {
let brush = overlay.clipped.then_some(&app.session.tools.brush);
if app.clone_preview.as_ref().is_none_or(|c| c.key != key || c.brush.as_ref() != brush) {
// Clipped (Photoshop's default): the dab the tool paints here, so a soft brush previews
// soft and a shaped tip keeps its outline. Unclipped: the brush's whole square.
let (rect, coverage) = match brush {
Some(b) => {
let Some((rect, coverage)) = clone_dab_coverage(b, at, &map) else { return };
(rect, Some(coverage))
}
None => (
DRect::new((at[0] - radius).floor() as i32, (at[1] - radius).floor() as i32, (at[0] + radius).ceil() as i32, (at[1] + radius).ceil() as i32),
None,
),
};
let Ok(mut buf) = photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &map, &app.ui.tool_options.clone_sample, wrap) else {
return;
};
if overlay.invert {
@@ -59,55 +131,43 @@ pub(super) fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painte
// composite, whatever Sample reads), so Difference turns black where they line up.
if overlay.blend != "normal" {
let here = Mapping { source: (0.0, 0.0), anchor: (0.0, 0.0), m: [1.0, 0.0, 0.0, 1.0] };
let Ok(under) = photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &here, "all", app.ui.view.pattern_preview) else {
let Ok(under) = photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &here, "all", wrap) else {
return;
};
for (px, d) in buf.px.iter_mut().zip(&under.px) {
*px = overlay_blend(&overlay.blend, *px, *d);
}
}
if let Some(rings) = &tip_rings {
let mask = crate::brush_cursor::tip_mask(rings, at, radius, [rect.x0, rect.y0, rect.x1, rect.y1]);
for (px, inside) in buf.px.iter_mut().zip(mask) {
if !inside {
*px = [0.0; 4];
}
if let Some(coverage) = &coverage {
for (px, c) in buf.px.iter_mut().zip(coverage) {
px[3] *= c.clamp(0.0, 1.0);
}
}
let image = display_image(display.as_deref(), &buf);
let brush = brush.cloned();
match app.clone_preview.as_mut() {
Some(cache) => {
cache.texture.set(image, canvas_texture_options(1));
cache.key = key;
cache.brush = brush;
cache.rect = rect;
}
None => {
let texture = painter.ctx().load_texture("clone-preview", image, canvas_texture_options(1));
app.clone_preview = Some(ClonePreviewCache { key, brush, rect, texture });
}
None => app.clone_preview = Some(ClonePreviewCache { key, texture: painter.ctx().load_texture("clone-preview", image, canvas_texture_options(1)) }),
}
}
let Some(cache) = &app.clone_preview else { return };
let rect = cache.rect;
let tint = Color32::from_white_alpha((overlay.opacity.clamp(0.0, 100.0) * 2.55).round() as u8);
// A textured triangle fan provides a circular clip, including mirrored canvas views.
// A textured quad over the covered area: its texels carry the clip, in mirrored and rotated
// canvas views too.
let mut mesh = egui::Mesh::with_texture(cache.texture.id());
let vertex = |x: f64, y: f64| egui::epaint::Vertex {
pos: xf.to_screen(x as f32, y as f32),
uv: pos2((x - f64::from(rect.x0)) as f32 / rect.width() as f32, (y - f64::from(rect.y0)) as f32 / rect.height() as f32),
color: tint,
};
if !overlay.clipped || tip_rings.is_some() {
for (x, y) in [(rect.x0, rect.y0), (rect.x1, rect.y0), (rect.x1, rect.y1), (rect.x0, rect.y1)] {
mesh.vertices.push(vertex(f64::from(x), f64::from(y)));
}
mesh.indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]);
painter.add(egui::Shape::mesh(mesh));
return;
}
mesh.vertices.push(vertex(at[0], at[1]));
for i in 0..=64 {
let angle = f64::from(i) * std::f64::consts::TAU / 64.0;
mesh.vertices.push(vertex(at[0] + radius * angle.cos(), at[1] + radius * angle.sin()));
}
for i in 1..=64 {
mesh.indices.extend_from_slice(&[0, i, i + 1]);
for (x, y, u, v) in [(rect.x0, rect.y0, 0.0, 0.0), (rect.x1, rect.y0, 1.0, 0.0), (rect.x1, rect.y1, 1.0, 1.0), (rect.x0, rect.y1, 0.0, 1.0)] {
mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(x as f32, y as f32), uv: pos2(u, v), color: tint });
}
mesh.indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]);
painter.add(egui::Shape::mesh(mesh));
}
+136 -80
View File
@@ -1175,70 +1175,151 @@ fn dragging_a_tab_off_the_strip_floats_it_in_a_window() {
}
}
/// An app with a 64 × 64 white document, a clone source and the Clone Stamp at `size` px.
fn clone_overlay_app(size: f32) -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
app.run("cloneSource.set", json!({"source": [16, 16]})).unwrap();
app.ui.tool = Tool::CloneStamp;
app.session.tools.brush.size = size;
app
}
/// One frame of the overlay at (32, 32): its shapes and texture uploads.
fn clone_overlay_frame(ctx: &egui::Context, app: &mut PhotocraftApp, xf: &ViewXform) -> egui::FullOutput {
ctx.run_ui(Default::default(), |ui| {
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test")));
draw_clone_preview(app, &painter, xf, [32.0, 32.0], None);
})
}
/// The overlay texture a frame uploaded and the alpha at a document pixel, read from the
/// document area the overlay covers (transparent outside it).
fn clone_overlay_alpha(app: &PhotocraftApp, output: &mut egui::FullOutput) -> impl Fn(i32, i32) -> u8 + use<> {
let cache = app.clone_preview.as_ref().expect("an overlay was drawn");
let image = output
.textures_delta
.set
.get(&cache.texture.id())
.and_then(|deltas| deltas.last())
.map(|d| match &d.image {
egui::ImageData::Color(c) => c.clone(),
})
.expect("the overlay uploads a texture");
output.textures_delta.clear();
let rect = cache.rect;
assert_eq!(image.size, [rect.width() as usize, rect.height() as usize], "the texture covers the overlay's area");
move |x, y| {
if x < rect.x0 || y < rect.y0 || x >= rect.x1 || y >= rect.y1 {
return 0;
}
image.pixels[(y - rect.y0) as usize * image.width() + (x - rect.x0) as usize].a()
}
}
fn clone_overlay_view(side: f32, zoom: f32) -> ViewXform {
ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(side, side)), zoom, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 }
}
#[test]
fn clone_overlay_is_circular_and_respects_visibility() {
let ctx = egui::Context::default();
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
app.run("cloneSource.set", json!({"source": [16,16]})).unwrap();
app.ui.tool = Tool::CloneStamp;
app.session.tools.brush.size = 20.0;
let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 };
let mut app = clone_overlay_app(20.0);
let xf = clone_overlay_view(100.0, 1.0);
let revision = app.session.active().unwrap().revision;
let mut output = ctx.run_ui(Default::default(), |ui| {
let ctx = ui.ctx();
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test")));
draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None);
});
output.textures_delta.clear();
let mesh = output
.shapes
.iter()
.find_map(|s| match &s.shape {
egui::Shape::Mesh(m) => Some(m),
_ => None,
})
.unwrap();
assert_eq!(mesh.indices.len(), 64 * 3);
let center = xf.to_screen(32.0, 32.0);
assert!(mesh.vertices.iter().all(|v| v.pos.distance(center) <= 10.01));
let mut output = clone_overlay_frame(&ctx, &mut app, &xf);
assert!(output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Mesh(_))), "the overlay is drawn");
let alpha = clone_overlay_alpha(&app, &mut output);
assert_eq!(alpha(32, 32), 255, "the centre shows the source");
assert_eq!(alpha(32 + 8, 32), 255, "inside the hard 10 px radius");
assert_eq!(alpha(32 + 8, 32 + 8), 0, "the brush square's corner, outside the circle, is clipped");
assert_eq!(alpha(32 + 12, 32), 0, "past the hard tip's rim");
assert_eq!(app.session.active().unwrap().revision, revision);
assert!(app.session.presets.clone.active().anchor.is_none());
let ordinary_key = app.clone_preview.as_ref().unwrap().key.clone();
app.ui.view.pattern_preview = true;
let mut periodic_output = ctx.run_ui(Default::default(), |ui| {
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test")));
draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None);
});
let mut periodic_output = clone_overlay_frame(&ctx, &mut app, &xf);
assert_ne!(app.clone_preview.as_ref().unwrap().key, ordinary_key, "toggling Pattern Preview invalidates source pixels");
assert!(!periodic_output.textures_delta.set.is_empty(), "the overlay texture is refreshed without a document edit");
periodic_output.textures_delta.clear();
assert_eq!(app.session.active().unwrap().revision, revision);
app.session.presets.clone.overlay.show = false;
let mut output = ctx.run_ui(Default::default(), |ui| {
let ctx = ui.ctx();
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test")));
draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None);
});
let mut output = clone_overlay_frame(&ctx, &mut app, &xf);
output.textures_delta.clear();
assert!(output.shapes.iter().all(|s| !matches!(s.shape, egui::Shape::Mesh(_))));
}
/// A soft brush previews soft: the overlay fades towards the rim with the brush's hardness, as
/// the stroke it previews does, and reaches past the radius as a soft tip paints. A brush
/// change redraws an overlay that hasn't moved.
#[test]
fn clone_overlay_follows_the_brush_hardness() {
let ctx = egui::Context::default();
let mut app = clone_overlay_app(20.0);
let xf = clone_overlay_view(100.0, 1.0);
let mut output = clone_overlay_frame(&ctx, &mut app, &xf);
let hard = clone_overlay_alpha(&app, &mut output);
app.session.tools.brush.hardness = 0.0;
let mut output = clone_overlay_frame(&ctx, &mut app, &xf);
let soft = clone_overlay_alpha(&app, &mut output);
assert!(hard(32, 32) == 255 && soft(32, 32) > 240, "both show the source at the centre: {} {}", hard(32, 32), soft(32, 32));
assert_eq!(hard(32 + 8, 32), 255);
assert!(soft(32 + 8, 32) < 200, "a soft tip fades before its rim: {}", soft(32 + 8, 32));
assert!(soft(32 + 4, 32) > soft(32 + 8, 32), "and fades outwards");
assert!(hard(32 + 12, 32) == 0 && soft(32 + 13, 32) > 0, "a soft tip reaches past the radius, a hard one doesn't");
// The largest brush the overlay draws still shows at its softest.
let mut big = clone_overlay_app(1000.0);
big.session.tools.brush.hardness = 0.0;
let mut output = clone_overlay_frame(&ctx, &mut big, &xf);
assert_eq!(clone_overlay_alpha(&big, &mut output)(32, 32), 255, "a 1000 px soft brush previews too");
}
/// A scaled Clone Source reads a larger source area than the overlay covers: a large brush's
/// overlay shrinks around the pointer until that area fits the sampling budget, rather than
/// vanishing.
#[test]
fn clone_overlay_of_a_scaled_source_stays_within_the_sampling_budget() {
let ctx = egui::Context::default();
let xf = clone_overlay_view(100.0, 1.0);
for (size, scale) in [(600.0, 200), (1000.0, 300), (1000.0, 50)] {
let mut app = clone_overlay_app(size);
app.run("cloneSource.set", json!({"width": scale, "height": scale})).unwrap();
let mut output = clone_overlay_frame(&ctx, &mut app, &xf);
assert!(output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Mesh(_))), "{size} px at {scale} %: the overlay shows");
assert_eq!(clone_overlay_alpha(&app, &mut output)(32, 32), 255, "{size} px at {scale} %: the source under the pointer");
}
}
/// The Healing Brush shares the Clone Stamp's source and overlay (Photoshop's Clone Source
/// panel drives both); other tools show none.
#[test]
fn clone_overlay_shows_for_the_healing_brush_too() {
let ctx = egui::Context::default();
let xf = clone_overlay_view(100.0, 1.0);
for (tool, shown) in [(Tool::Healing, true), (Tool::CloneStamp, true), (Tool::Brush, false), (Tool::SpotHealing, false)] {
let mut app = clone_overlay_app(20.0);
app.ui.tool = tool;
let mut output = clone_overlay_frame(&ctx, &mut app, &xf);
assert_eq!(output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Mesh(_))), shown, "{tool:?}");
if shown {
assert_eq!(clone_overlay_alpha(&app, &mut output)(32, 32), 255, "{tool:?}: the source shows under the brush");
}
output.textures_delta.clear();
}
}
#[test]
fn clone_overlay_keeps_magnified_pixels_crisp_on_upload_and_refresh() {
let ctx = egui::Context::default();
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
app.run("cloneSource.set", json!({"source": [16, 16]})).unwrap();
app.ui.tool = Tool::CloneStamp;
app.session.tools.brush.size = 20.0;
let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 4.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 };
let mut app = clone_overlay_app(20.0);
let xf = clone_overlay_view(200.0, 4.0);
for invert in [false, true] {
app.session.presets.clone.overlay.invert = invert;
let mut output = ctx.run_ui(Default::default(), |ui| {
draw_clone_preview(&mut app, ui.painter(), &xf, [32.0, 32.0], None);
});
let upload = output.textures_delta.set.values().flatten().find(|d| d.image.size() == [20, 20]).expect("clone overlay upload");
let id = app.clone_preview.as_ref().unwrap().texture.id();
let upload = output.textures_delta.set.get(&id).and_then(|d| d.last()).expect("clone overlay upload");
let options = upload.options;
output.textures_delta.clear();
assert_eq!(options.magnification, egui::TextureFilter::Nearest, "invert={invert}");
@@ -1249,27 +1330,20 @@ fn clone_overlay_keeps_magnified_pixels_crisp_on_upload_and_refresh() {
#[test]
fn clone_overlay_blend_mode_mixes_with_the_image_under_the_brush() {
let ctx = egui::Context::default();
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
app.run("cloneSource.set", json!({"source": [16,16]})).unwrap();
app.ui.tool = Tool::CloneStamp;
app.session.tools.brush.size = 20.0;
let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 };
// The centre texel of the overlay texture uploaded for `mode`.
let mut app = clone_overlay_app(20.0);
let xf = clone_overlay_view(100.0, 1.0);
// The overlay texel at the brush centre for `mode`.
let mut centre = |mode: &str| {
app.run("cloneSource.overlay", json!({"blend": mode})).unwrap();
let mut output = ctx.run_ui(Default::default(), |ui| {
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test")));
draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None);
});
// The brush's 20 × 20 px square (the font atlas may upload too).
let image = output.textures_delta.set.values().flatten().find_map(|d| match &d.image {
egui::ImageData::Color(c) if c.size == [20, 20] => Some(c.clone()),
_ => None,
let mut output = clone_overlay_frame(&ctx, &mut app, &xf);
let cache = app.clone_preview.as_ref().unwrap();
let image = output.textures_delta.set.get(&cache.texture.id()).and_then(|d| d.last()).map(|d| match &d.image {
egui::ImageData::Color(c) => c.clone(),
});
output.textures_delta.clear();
let image = image.expect("the overlay uploads a texture");
image.pixels[image.width() * (image.height() / 2) + image.width() / 2]
let rect = cache.rect;
image.pixels[(32 - rect.y0) as usize * image.width() + (32 - rect.x0) as usize]
};
// A white source over the white image: Normal shows white, Difference black.
assert_eq!(centre("normal"), Color32::WHITE);
@@ -1281,32 +1355,14 @@ fn clone_overlay_blend_mode_mixes_with_the_image_under_the_brush() {
#[test]
fn clone_overlay_is_clipped_to_a_shaped_tip() {
let ctx = egui::Context::default();
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
app.run("cloneSource.set", json!({"source": [16,16]})).unwrap();
app.ui.tool = Tool::CloneStamp;
app.session.tools.brush.size = 20.0;
let mut app = clone_overlay_app(20.0);
app.session.tools.brush.roundness = 0.25;
let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 };
let mut output = ctx.run_ui(Default::default(), |ui| {
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test")));
draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None);
});
let image = output
.textures_delta
.set
.values()
.flatten()
.find_map(|d| match &d.image {
egui::ImageData::Color(c) if c.size == [20, 20] => Some(c.clone()),
_ => None,
})
.expect("the overlay uploads a texture");
output.textures_delta.clear();
let alpha = |x: usize, y: usize| image.pixels[y * image.width() + x].a();
assert_eq!(alpha(10, 10), 255, "the tip's centre shows the source");
assert_eq!(alpha(10, 2), 0, "inside the circle but outside the flat tip: clipped");
assert_eq!(alpha(1, 10), 255, "the tip's long axis stays");
let xf = clone_overlay_view(100.0, 1.0);
let mut output = clone_overlay_frame(&ctx, &mut app, &xf);
let alpha = clone_overlay_alpha(&app, &mut output);
assert_eq!(alpha(32, 32), 255, "the tip's centre shows the source");
assert_eq!(alpha(32, 24), 0, "inside the circle but outside the flat tip: clipped");
assert!(alpha(25, 32) > 240, "the tip's long axis stays: {}", alpha(25, 32));
}
#[test]
@@ -326,6 +326,13 @@ where
(hex(to_rgb(v)) == hex(rgb)).then_some(v)
}
/// The CMYK values typed into the picker, while they still match its colour: the inks a command
/// should keep in a CMYK document (#4196, #8568).
pub(crate) fn picked_cmyk(f: &Map<String, Value>) -> Option<[f32; 4]> {
let rgb = f.get("color").and_then(Value::as_str).and_then(parse_hex)?;
kept::<4>(f, "__cmyk", rgb, photocraft_color::convert::cmyk_to_rgb)
}
/// Current colour, keeping the stored HSB, Lab or CMYK values while they still match `color`.
pub fn current(f: &Map<String, Value>) -> Components {
let rgb = f.get("color").and_then(Value::as_str).and_then(parse_hex).unwrap_or([0.0; 3]);
+43 -23
View File
@@ -91,7 +91,14 @@ fn remember(app: &mut PhotocraftApp, f: &Map<String, Value>) {
}
pub fn open(app: &mut PhotocraftApp) -> Result<u64, String> {
let st = app.session.active().ok_or("no document")?;
let size = app.session.active().ok_or("no document")?.doc.size;
open_sized(app, size.width, size.height)
}
/// The dialog with its size fields fitted to a `src_w` × `src_h` source: the document, or the
/// layer Layer › Export As exports. Fitting the document first would replace a remembered pixel
/// size the document can't take with the percent, even when the layer can take it.
fn open_sized(app: &mut PhotocraftApp, src_w: u32, src_h: u32) -> Result<u64, String> {
let mut f = Map::new();
f.insert("__export".into(), json!(true));
f.insert("__label".into(), json!("Export As"));
@@ -107,42 +114,54 @@ pub fn open(app: &mut PhotocraftApp) -> Result<u64, String> {
f.insert("lockAspect".into(), json!(true));
f.insert("profile".into(), json!("document"));
f.insert("embedProfile".into(), json!(true));
f.insert("width".into(), json!(st.doc.size.width));
f.insert("height".into(), json!(st.doc.size.height));
f.insert("__w".into(), json!(st.doc.size.width));
f.insert("__h".into(), json!(st.doc.size.height));
f.insert("width".into(), json!(src_w));
f.insert("height".into(), json!(src_h));
f.insert("__w".into(), json!(src_w));
f.insert("__h".into(), json!(src_h));
seed_remembered(app, &mut f);
let explicit = s(&f, "sizing") == "pixels" && f.contains_key("width") && f.contains_key("height");
if !explicit && let Ok(size) = resize_from_scale(f64::from(st.doc.size.width), f64::from(st.doc.size.height), n(&f, "scale", 100.0)) {
fit_size(&mut f, f64::from(src_w), f64::from(src_h));
Ok(app.ui.open_dialog(DialogKind::Command, f))
}
/// The size fields for exporting a `src_w` × `src_h` source. A remembered percent scales it.
/// Remembered exact pixels with Constrain proportions keep their width and take the height from
/// this source, as ticking the checkbox does: the last export's proportions would stretch an
/// image of another shape (#8738). Unlinked, the exact pixels are the size the user asked for.
/// A linked width this source can't reach within the Scale field's 1..=1000 % falls back to the
/// remembered percent.
fn fit_size(f: &mut Map<String, Value>, src_w: f64, src_h: f64) {
if s(f, "sizing") == "pixels" && f.contains_key("width") && f.contains_key("height") {
if !f.get("lockAspect").and_then(Value::as_bool).unwrap_or(true) {
return;
}
if let Ok(size) = resize_from_side(src_w, src_h, n(f, "width", src_w.max(1.0)), n(f, "height", src_h.max(1.0)), true, true)
&& (1.0..=1000.0).contains(&size.scale)
{
write_size(f, size, "pixels");
return;
}
}
if let Ok(size) = resize_from_scale(src_w, src_h, n(f, "scale", 100.0)) {
f.insert("width".into(), json!(size.width));
f.insert("height".into(), json!(size.height));
if (n(&f, "scale", 100.0) - size.scale).abs() > 1e-9 {
if (n(f, "scale", 100.0) - size.scale).abs() > 1e-9 {
f.insert("scale".into(), json!(size.scale));
}
f.insert("sizing".into(), json!("scale"));
}
Ok(app.ui.open_dialog(DialogKind::Command, f))
}
/// Layer › Export As…: the same dialog for just the active layer (trimmed to its pixels).
pub fn open_layer(app: &mut PhotocraftApp, layer: photocraft_doc::LayerId) -> Result<u64, String> {
let st = app.session.active().ok_or("no document")?;
let ldoc = photocraft_engine::layer_menu_cmds::layer_document(&st.doc, layer).map_err(|e| e.to_string())?;
let id = open(app)?;
// The size fields follow the layer's trimmed size, so the dialog shows and edits the size
// the layer exports at.
let id = open_sized(app, ldoc.size.width, ldoc.size.height)?;
if let Some(d) = app.ui.dialogs.iter_mut().find(|d| d.id == id) {
let f = &mut d.fields;
f.insert("__layer".into(), json!(layer.0));
f.insert("__label".into(), json!(crate::i18n::fmt(crate::i18n::t("Export As: {name}"), &[("name", &ldoc.name)])));
f.insert("__w".into(), json!(ldoc.size.width));
f.insert("__h".into(), json!(ldoc.size.height));
// `open` seeded width and height from the document; in scale mode they follow the layer's
// trimmed size, so the dialog shows and edits the size the layer exports at. Exact pixels
// (sizing "pixels") are a size the user asked for, so those stay.
if s(f, "sizing") != "pixels"
&& let Ok(size) = resize_from_scale(f64::from(ldoc.size.width), f64::from(ldoc.size.height), n(f, "scale", 100.0))
{
write_size(f, size, "scale");
}
}
Ok(id)
}
@@ -356,17 +375,18 @@ pub(crate) fn resize_from_scale(src_w: f64, src_h: f64, scale: f64) -> Result<Ex
Ok(ExportSize { width, height, scale })
}
/// An edited side. With `lock`, the other side keeps the source aspect; otherwise it stays.
/// An edited side. With `lock`, the other side keeps the source aspect, never below 1 px (as
/// [`scaled_size`]); otherwise it stays.
pub(crate) fn resize_from_side(src_w: f64, src_h: f64, width: f64, height: f64, lock: bool, edited_width: bool) -> Result<ExportSize, String> {
let src_w = src_w.max(1.0);
let src_h = src_h.max(1.0);
let (width, height) = if edited_width {
let width = finite_px(width)?;
let height = if lock { finite_px(src_h * width / src_w)? } else { finite_px(height)? };
let height = if lock { finite_px((src_h * width / src_w).max(1.0))? } else { finite_px(height)? };
(width, height)
} else {
let height = finite_px(height)?;
let width = if lock { finite_px(src_w * height / src_h)? } else { finite_px(width)? };
let width = if lock { finite_px((src_w * height / src_h).max(1.0))? } else { finite_px(width)? };
(width, height)
};
let scale = if edited_width { width / src_w * 100.0 } else { height / src_h * 100.0 };
@@ -99,8 +99,9 @@ fn a_replayed_export_needs_a_path_and_a_known_format() {
}
/// Layer › Export As opens with the layer's trimmed size, not the document's: `open` seeds the
/// size fields from the document, and the layer replaces them in scale mode. A remembered
/// percent scales the layer; remembered exact pixels stay the size the user asked for.
/// size fields to the document, and the layer refits them. A remembered percent scales the
/// layer; remembered exact pixels stay the size the user asked for, in the layer's proportions
/// when they are linked.
#[test]
fn layer_export_as_opens_with_the_layer_size() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
@@ -119,10 +120,105 @@ fn layer_export_as_opens_with_the_layer_size() {
let f = &app.ui.dialog_mut(id).unwrap().fields;
assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(5.0), Some(3.0)));
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 120, "height": 80})));
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 120, "height": 80, "lockAspect": false})));
let id = open_layer(&mut app, layer).unwrap();
let f = &app.ui.dialog_mut(id).unwrap().fields;
assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(120.0), Some(80.0)));
// With Constrain proportions the remembered width stays and the height follows the layer.
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 50, "height": 80, "lockAspect": true})));
let id = open_layer(&mut app, layer).unwrap();
let f = &app.ui.dialog_mut(id).unwrap().fields;
assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(50.0), Some(30.0)));
assert_eq!(f["sizing"], "pixels");
}
/// #8738: a pixel size remembered from a landscape export, with Constrain proportions on,
/// keeps its width and takes the height from the portrait image being exported, as ticking
/// the checkbox does; unlinked, both sides are the exact size asked for. A recorded action
/// step is not the remembered size: it replays with exactly the pixels it was recorded with.
#[test]
fn a_remembered_pixel_size_keeps_this_images_proportions() {
let sizes = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let log = sizes.clone();
let services = crate::Services {
export: Some(Box::new(move |doc, _, _| {
log.borrow_mut().push((doc.size.width, doc.size.height));
Ok((Vec::new(), Vec::new()))
})),
write: Some(Box::new(|_, _| Ok(()))),
..Default::default()
};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
app.run("file.new", json!({"width": 30, "height": 40})).unwrap();
let landscape = json!({"sizing": "pixels", "width": 20, "height": 15, "scale": 50, "lockAspect": true});
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), landscape.clone()));
let id = open(&mut app).unwrap();
let f = &app.ui.dialog_mut(id).unwrap().fields;
assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(20.0), Some(27.0)));
assert_eq!(f["sizing"], "pixels");
assert!((f["scale"].as_f64().unwrap() - 200.0 / 3.0).abs() < 1e-9, "{}", f["scale"]);
let mut unlinked = landscape.clone();
unlinked["lockAspect"] = json!(false);
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), unlinked));
let id = open(&mut app).unwrap();
let f = &app.ui.dialog_mut(id).unwrap().fields;
assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(20.0), Some(15.0)));
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), landscape.clone()));
let mut step = landscape;
step["path"] = json!("/x.png");
step["format"] = json!("png");
replay(&mut app, &step).unwrap();
assert_eq!(*sizes.borrow(), [(20, 15)]);
}
/// A remembered linked width this image can't take within the Scale field's 1..=1000 %
/// (2000 px from a 4000 px photo, on a 100 px image) falls back to the remembered percent.
#[test]
fn a_remembered_pixel_size_out_of_scale_falls_back_to_the_percent() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 2000, "height": 1500, "scale": 50, "lockAspect": true})));
let id = open(&mut app).unwrap();
let f = &app.ui.dialog_mut(id).unwrap().fields;
assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(50.0), Some(50.0)));
assert_eq!((f["sizing"].as_str(), f["scale"].as_f64()), (Some("scale"), Some(50.0)));
}
/// A remembered linked width whose height rounds below one pixel keeps its width: the height
/// is one pixel, as the Scale field gives at the same 10 %, and not the remembered percent.
#[test]
fn a_remembered_pixel_width_keeps_a_one_pixel_height() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 1000, "height": 1})).unwrap();
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 100, "height": 75, "scale": 100, "lockAspect": true})));
let id = open(&mut app).unwrap();
let f = &app.ui.dialog_mut(id).unwrap().fields;
assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(100.0), Some(1.0)));
assert_eq!(f["sizing"], "pixels");
// The same for an edited height on a 1 × 1000 image.
let size = resize_from_side(1.0, 1000.0, 1.0, 100.0, true, false).unwrap();
assert_eq!((size.width, size.height), (1.0, 100.0));
}
/// Layer › Export As fits the remembered size to the layer, not to the document: a remembered
/// linked width the tall document can't take (800 px wide is 320 000 px high) still fits the
/// 100 × 10 layer at 800 × 80.
#[test]
fn a_layer_export_fits_the_remembered_size_to_the_layer() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 100, "height": 40000})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.run("select.rect", json!({"x": 0, "y": 0, "width": 100, "height": 10})).unwrap();
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
let layer = app.session.active().unwrap().active_layer.unwrap();
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 800, "height": 600, "scale": 50, "lockAspect": true})));
let id = open_layer(&mut app, layer).unwrap();
let f = &app.ui.dialog_mut(id).unwrap().fields;
assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(800.0), Some(80.0)));
assert_eq!(f["sizing"], "pixels");
}
/// Only the dialog's own settings are remembered: not the destination, the document size or
+1 -1
View File
@@ -1156,7 +1156,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
let center = d.brush_resize.map_or(hp, |(anchor, _, _)| anchor);
let r = d.opts.size * 0.5 * d.zoom;
if d.brush_resize.is_some() {
crate::brush_resize::tip_preview(
crate::brush_resize::liquify_tip_preview(
&clip,
center,
r,
+45 -9
View File
@@ -27,7 +27,14 @@ pub(crate) fn open(app: &mut PhotocraftApp) -> Option<u64> {
let layer = st.active_layer?;
let LayerContent::Fill(Fill::Solid(color)) = st.doc.layer(layer)?.content else { return None };
let params = json!({"layer": layer.0, "document": st.doc.id.0});
Some(color_picker_ui::open_for_command(app, "Color Picker (Solid Color)", color.to_rgb(), cmds::SET, params))
let id = color_picker_ui::open_for_command(app, "Color Picker (Solid Color)", color.to_rgb(), cmds::SET, params);
// A CMYK fill reopens showing its own inks, not ones recomputed from its sRGB stand-in (#8568).
if color.mode == photocraft_color::ColorMode::Cmyk
&& let Some(d) = app.ui.dialog_mut(id)
{
d.fields.insert("__cmyk".into(), json!(color.c));
}
Some(id)
}
/// Creating a Solid Color fill is transactional: the document is unchanged until OK.
@@ -57,7 +64,8 @@ pub(crate) fn confirm_new(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Re
return Err("the target document is no longer active".into());
}
let color = f.get("color").and_then(Value::as_str).ok_or("missing Solid Color colour")?;
app.run("layer.newFillLayer.solidColor", json!({"color": color}))
// CMYK typed into the picker stays those inks in a CMYK document (#8568).
app.run("layer.newFillLayer.solidColor", json!({"color": color, "cmyk": color_picker_ui::picked_cmyk(f)}))
}
pub(crate) fn owns(f: &Map<String, Value>) -> bool {
@@ -72,14 +80,39 @@ pub(crate) fn thumbnail_color(app: &PhotocraftApp, document: DocId, layer: Layer
.filter(|d| owns(&d.fields))
.find_map(|d| {
let params = d.fields.get("__params")?;
if params.get("document")?.as_u64()? != document.0 || params.get("layer")?.as_u64()? != layer.0 || d.fields.get("color") == d.fields.get("__orig") {
if params.get("document")?.as_u64()? != document.0 || params.get("layer")?.as_u64()? != layer.0 {
return None;
}
cmds::color_param(d.fields.get("color")?, original, app.session.documents().iter().find(|st| st.doc.id == document)?.doc.mode).ok()
let mode = app.session.documents().iter().find(|st| st.doc.id == document)?.doc.mode;
picked_color(&d.fields, original, mode)
})
.unwrap_or(original)
}
/// The colour the picker would commit to a fill of `original` in a `mode` document, built as the
/// engine builds it: the picked hex, and in a CMYK document the inks it was picked as (#8568).
/// `None` while the picker still shows the fill as it is ([`is_edit`]).
fn picked_color(f: &Map<String, Value>, original: Color, mode: photocraft_color::ColorMode) -> Option<Color> {
if !is_edit(f, original, mode) {
return None;
}
let color = cmds::color_param(f.get("color")?, original, mode).ok()?;
Some(color_picker_ui::picked_cmyk(f).filter(|_| mode == photocraft_color::ColorMode::Cmyk).map_or(color, |c| Color {
mode: photocraft_color::ColorMode::Cmyk,
c,
alpha: color.alpha,
}))
}
/// Whether the picker differs from the fill it edits, inks included: different inks can share a
/// display colour (CMYK 0/0/0/100 and 50/50/50/100 both read black), so the hex alone doesn't
/// tell, and an RGB or Lab fill given inks in a CMYK document is an edit too.
fn is_edit(f: &Map<String, Value>, original: Color, mode: photocraft_color::ColorMode) -> bool {
let cmyk = photocraft_color::ColorMode::Cmyk;
let inks_differ = mode == cmyk && color_picker_ui::picked_cmyk(f).is_some_and(|k| original.mode != cmyk || original.c != k);
f.get("color") != f.get("__orig") || inks_differ
}
/// No-op OK preserves imported high-precision / non-RGB colour values rather than quantising
/// them to the picker's display hex. A stale document must never receive the edit.
pub(crate) fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
@@ -89,15 +122,18 @@ pub(crate) fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result
return Err("the target document is no longer active".into());
}
let layer = LayerId(params.get("layer").and_then(Value::as_u64).ok_or("missing Solid Color layer")?);
if !st.doc.layer(layer).is_some_and(|l| matches!(l.content, LayerContent::Fill(Fill::Solid(_)))) {
let Some(LayerContent::Fill(Fill::Solid(original))) = st.doc.layer(layer).map(|l| &l.content) else {
return Err("the target Solid Color Fill layer no longer exists".into());
}
if f.get("color") == f.get("__orig") {
};
if !is_edit(f, *original, st.doc.mode) {
return Ok(json!({"layer": layer.0, "unchanged": true}));
}
let color = f.get("color").cloned().ok_or("missing color")?;
let object = params.as_object_mut().ok_or("invalid Solid Color target")?;
object.insert("color".into(), color);
if let Some(inks) = color_picker_ui::picked_cmyk(f) {
object.insert("cmyk".into(), json!(inks));
}
app.run(cmds::SET, params)
}
@@ -109,12 +145,12 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Do
};
let st = app.session.documents().get(idx)?;
let params = d.fields.get("__params")?;
if params.get("document")?.as_u64()? != st.doc.id.0 || d.fields.get("color") == d.fields.get("__orig") {
if params.get("document")?.as_u64()? != st.doc.id.0 {
return None;
}
let layer = LayerId(params.get("layer")?.as_u64()?);
let LayerContent::Fill(Fill::Solid(original)) = st.doc.layer(layer)?.content else { return None };
let color = cmds::color_param(d.fields.get("color")?, original, st.doc.mode).ok()?;
let color = picked_color(&d.fields, original, st.doc.mode)?;
if let Some(p) = &app.solid_fill_preview
&& p.document == st.doc.id
&& p.revision == st.revision
@@ -269,3 +269,105 @@ fn cmyk_lab_preview_thumbnail_cancel_and_ok_are_consistent() {
assert_eq!(app.session.active().unwrap().doc.layer(id).unwrap().content, LayerContent::Fill(Fill::Solid(preview)));
}
}
/// #8568: CMYK 50/50/50/100 typed into the Solid Color picker of a CMYK document is the fill's
/// colour after OK, for a new fill and for an edited one, not what its sRGB stand-in converts back
/// to through the document profile.
#[test]
fn cmyk_typed_into_the_picker_is_the_fills_colour_in_a_cmyk_document() {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("image.mode.cmyk", json!({})).unwrap();
let mut app = PhotocraftApp::new(s, crate::Services::default());
let typed = |f: &mut serde_json::Map<String, serde_json::Value>, inks: [f32; 4]| {
let [r, g, b] = photocraft_color::convert::cmyk_to_rgb(inks).map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8);
f.insert("color".into(), json!(format!("#{r:02x}{g:02x}{b:02x}")));
f.insert("__cmyk".into(), json!(inks));
};
let fill = |app: &PhotocraftApp| {
let st = app.session.active().unwrap();
match st.doc.layer(st.active_layer.unwrap()).unwrap().content {
LayerContent::Fill(Fill::Solid(c)) => c,
_ => panic!("not a solid fill"),
}
};
let inks = [0.5, 0.5, 0.5, 1.0];
let id = open_new(&mut app).unwrap();
let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone();
typed(&mut f, inks);
confirm_new(&mut app, &f).unwrap();
assert_eq!(fill(&app), Color { mode: ColorMode::Cmyk, c: inks, alpha: 1.0 });
let edit = [0.25, 0.75, 0.0, 0.1];
let id = open(&mut app).unwrap();
let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone();
typed(&mut f, edit);
confirm(&mut app, &f).unwrap();
assert_eq!(fill(&app).c, edit);
// Reopening the picker shows the stored inks.
let id = open(&mut app).unwrap();
let shown = color_picker_ui::current(&app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields);
assert_eq!(shown.cmyk, edit);
// Other inks with the same display colour are still an edit.
let near = [0.5, 0.5, 0.499, 1.0];
let id = open(&mut app).unwrap();
let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone();
typed(&mut f, near);
confirm(&mut app, &f).unwrap();
let rich = [0.5, 0.5, 0.5, 1.0];
let id = open(&mut app).unwrap();
let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone();
typed(&mut f, rich);
assert_eq!(f.get("color"), f.get("__orig"), "both inks show as the same colour");
confirm(&mut app, &f).unwrap();
assert_eq!(fill(&app).c, rich);
}
fn with_inks(f: &mut serde_json::Map<String, serde_json::Value>, inks: [f32; 4]) {
let [r, g, b] = photocraft_color::convert::cmyk_to_rgb(inks).map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8);
f.insert("color".into(), json!(format!("#{r:02x}{g:02x}{b:02x}")));
f.insert("__cmyk".into(), json!(inks));
}
/// Inks that share the picker's hex with the fill's own are still a preview and a thumbnail, as
/// they are an edit on OK (#8568).
#[test]
fn same_hex_inks_preview_and_thumbnail_as_ok_commits_them() {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("image.mode.cmyk", json!({})).unwrap();
let mut app = PhotocraftApp::new(s, crate::Services::default());
let near = [0.5, 0.5, 0.499, 1.0];
let id = open_new(&mut app).unwrap();
let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone();
with_inks(&mut f, near);
confirm_new(&mut app, &f).unwrap();
let rich = [0.5, 0.5, 0.5, 1.0];
let id = open(&mut app).unwrap();
with_inks(&mut app.ui.dialog_mut(id).unwrap().fields, rich);
let fields = &app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields;
assert_eq!(fields.get("color"), fields.get("__orig"), "both inks show as the same colour");
let st = app.session.active().unwrap();
let (document, layer) = (st.doc.id, st.active_layer.unwrap());
let LayerContent::Fill(Fill::Solid(original)) = st.doc.layer(layer).unwrap().content else { panic!("not a solid fill") };
assert_eq!(thumbnail_color(&app, document, layer, original).c, rich);
let (shown, _) = display_doc(&mut app, 0).expect("a preview of the new inks");
let LayerContent::Fill(Fill::Solid(previewed)) = shown.layer(layer).unwrap().content else { panic!("not a solid fill") };
assert_eq!(previewed.c, rich);
}
/// An RGB fill inside a CMYK document that the picker gives inks at the unchanged hex is an edit;
/// in an RGB document, which ignores the inks, it is not.
#[test]
fn inks_given_to_a_non_cmyk_fill_in_a_cmyk_document_are_an_edit() {
let original = Color::rgba(0.2, 0.3, 0.4, 1.0);
let mut f = serde_json::Map::new();
with_inks(&mut f, [0.3, 0.2, 0.1, 0.4]);
f.insert("__orig".into(), f["color"].clone());
assert!(is_edit(&f, original, ColorMode::Cmyk));
assert!(!is_edit(&f, original, ColorMode::Rgb));
assert!(picked_color(&f, original, ColorMode::Cmyk).is_some_and(|c| c.mode == ColorMode::Cmyk && c.c == [0.3, 0.2, 0.1, 0.4]));
}
+12
View File
@@ -1472,3 +1472,15 @@ fn save_a_copy_appends_copy_unless_the_preference_says_not_to() {
);
}
}
#[test]
fn import_variable_data_sets_menu_asks_for_a_csv_and_imports_it() {
let (mut app, open, _) = app();
let ctx = egui::Context::default();
assert_eq!(crate::menus::invoke(&mut app, &ctx, "file.import.variableDataSets", json!({})).unwrap(), json!({"fileDialog": "open"}));
app.poll_file_dialog(&ctx, None);
assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Open { multiple: false, extensions: Some(e), .. }, _)] if e == &["csv", "txt"]));
answer(&open, Some(FileDialogAnswer::Contents("sets.csv".into(), b"headline\nHello\nWorld\n".to_vec())));
app.poll_file_dialog(&ctx, None);
assert_eq!(app.session.active().map(|d| d.doc.variables.data_sets.len()), Some(2));
}
@@ -638,10 +638,12 @@ fn delete_with_several_layers_selected_deletes_them_even_with_the_mask_targeted(
let first = photocraft_doc::LayerId(s.execute("layer.newAdjustmentLayer.curves", json!({})).unwrap()["layer"].as_u64().unwrap());
let second = photocraft_doc::LayerId(s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap()["layer"].as_u64().unwrap());
s.execute("layer.select", json!({"layer": first.0, "mode": "add"})).unwrap();
// The new layers took the harness's selection as their masks (#8801): select again.
s.execute("select.all", json!({})).unwrap();
let st = s.active().unwrap();
assert_eq!(st.selected_layers().len(), 2);
assert!(st.active_layer.and_then(|l| st.doc.layer(l)).is_some_and(|l| l.mask.is_some()), "the active adjustment layer has a mask");
assert!(st.doc.selection.is_some(), "the harness keeps a selection");
assert!(st.doc.selection.is_some(), "a selection is active");
h.state_mut().ui.mask_target = true;
h.run_steps(2);
let n = names(&h).len();
+19
View File
@@ -214,6 +214,18 @@ fn invoke_file_commands(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str,
"file.import.videoFramesToLayers" if params.get("path").and_then(Value::as_str).is_none() => {
app.ask_folder(|app, path| app.run("file.import.videoFramesToLayers", json!({"path": path})).map_err(|e| e.to_string()))
}
// File > Import > Variable Data Sets… asks for the CSV (#8816); with a `path` the engine reads it itself.
"file.import.variableDataSets" if params.get("path").and_then(Value::as_str).is_none() => {
let doc = app.active_doc_id()?;
let params = params.clone();
app.pick_file_bytes_filtered(&["csv", "txt"], move |app, _name, bytes| {
app.refocus(doc)?;
let text = data_sets_text(bytes)?;
let r = photocraft_engine::import_data_sets_text(&mut app.session, &text, &params).map_err(|e| e.to_string());
app.sync_views();
r
})
}
"file.save" => {
// Writes back only to a layered file that holds the whole document; anything else
// goes through Save As, so the original is never overwritten with less (#3806).
@@ -448,6 +460,13 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
}
}
/// The text of a picked Variable Data Sets file. Invalid UTF-8 is refused, as the engine's
/// path-based import refuses it, so a failed read never replaces the document's data sets with
/// U+FFFD-substituted values.
pub(super) fn data_sets_text(bytes: Vec<u8>) -> Result<String, String> {
String::from_utf8(bytes).map_err(|e| format!("the data set file is not valid UTF-8: {e}"))
}
fn open_path(app: &mut PhotocraftApp, path: &str) -> Result<Value, String> {
app.open_path(path).map(|w| json!({"warnings": w}))
}
+7
View File
@@ -1,5 +1,12 @@
use super::invoke::data_sets_text;
use super::*;
#[test]
fn data_set_files_must_be_valid_utf8() {
assert_eq!(data_sets_text(b"DataSet,headline\nS,Jos\xc3\xa9\n".to_vec()).unwrap(), "DataSet,headline\nS,José\n");
assert!(data_sets_text(b"DataSet,headline\nS\xff,Jos\xe9\n".to_vec()).is_err());
}
#[test]
fn showing_grid_restores_hidden_extras() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
+39
View File
@@ -1953,6 +1953,45 @@ mod sampler_readout_tests {
}
}
#[cfg(test)]
mod brush_chip_tests {
use super::brush_controls::brush_preset_chip;
/// The options-bar chip's tip texture for `brush`, row-major alpha (32 × 32).
fn chip_tip(brush: &photocraft_engine::BrushSettings) -> Vec<u8> {
let ctx = egui::Context::default();
let mut state = crate::state::UiState::default();
let mut out = ctx.run_ui(Default::default(), |ui| brush_preset_chip(ui, brush, &mut state));
// `Painter::image` is a textured mesh.
let id = out.shapes.iter().find_map(|c| match &c.shape {
egui::Shape::Mesh(m) if (m.calc_bounds().width() - 14.0).abs() < 0.01 => Some(m.texture_id),
_ => None,
});
let image = id.and_then(|id| out.textures_delta.set.get(&id)).and_then(|d| d.last()).map(|d| match &d.image {
egui::ImageData::Color(c) => c.clone(),
});
out.textures_delta.clear();
let image = image.expect("the chip draws its tip as an uploaded texture");
assert_eq!(image.size, [32, 32]);
image.pixels.iter().map(|p| p.a()).collect()
}
/// The chip shows the brush's own tip: a soft brush fades from its centre as its dabs do, a
/// hard one is solid to its rim, and a flattened tip is flat, not a round blob.
#[test]
fn the_brush_chip_draws_the_brushs_own_tip() {
let at = |a: &[u8], x: usize, y: usize| a[y * 32 + x];
let hard = chip_tip(&photocraft_engine::BrushSettings { hardness: 1.0, ..Default::default() });
let soft = chip_tip(&photocraft_engine::BrushSettings { hardness: 0.0, ..Default::default() });
assert!(at(&hard, 16, 16) == 255 && at(&hard, 16 + 11, 16) == 255, "hard: solid to near the rim");
assert!(at(&soft, 16, 16) > 230, "soft: full at the centre");
assert!(at(&soft, 16 + 11, 16) < 80, "soft: faint near the rim: {}", at(&soft, 16 + 11, 16));
assert!(at(&soft, 16 + 6, 16) > at(&soft, 16 + 11, 16), "and fading outwards");
let flat = chip_tip(&photocraft_engine::BrushSettings { hardness: 1.0, roundness: 0.3, ..Default::default() });
assert!(at(&flat, 16 + 11, 16) > 200 && at(&flat, 16, 16 + 11) == 0, "a flattened tip is flat");
}
}
#[cfg(test)]
mod panel_state_migration_tests {
use super::{migrate_panel_state, panel_state_id};
+5 -14
View File
@@ -1,18 +1,5 @@
use super::{Align2, BlendMode, Color32, PhotocraftApp, Rect, Sense, Stroke, StrokeKind, Tokens, c32, icons, json, pos2, vec2, widgets};
/// Photoshop's brush preset picker chip: a soft/hard round tip preview with the size underneath.
/// Draw a round brush tip preview (hard core fading to a soft edge).
fn brush_tip(p: &egui::Painter, c: egui::Pos2, rad: f32, hardness: f32, color: Color32) {
let inner = rad * hardness.clamp(0.05, 1.0);
let steps = 8;
for k in (0..=steps).rev() {
let f = k as f32 / steps as f32;
let rr = inner + (rad - inner) * f;
let a = if hardness >= 0.99 { 1.0 } else { (1.0 - f).powf(1.5) };
p.circle_filled(c, rr, color.gamma_multiply(a));
}
}
/// A painting tool's Mode dropdown over `modes` (`None` is Clear); the wheel over it steps the
/// mode. Returns the mode picked this frame.
pub(super) fn tool_mode_dropdown(ui: &mut egui::Ui, id: &str, modes: &[Option<BlendMode>], current: Option<BlendMode>) -> Option<Option<BlendMode>> {
@@ -122,7 +109,11 @@ pub(super) fn brush_preset_chip(ui: &mut egui::Ui, b: &photocraft_engine::BrushS
let c = pos2(r.left() + 14.0, r.top() + 11.0);
// Photoshop's white tip vanishes on light chrome; light themes use their icon colour.
let tip = if t.dark() { Color32::WHITE } else { t.icon };
brush_tip(ui.painter(), c, 7.0, b.hardness, tip);
// The brush's own tip, as the Brush Preset picker's thumbnails draw it: the engine's falloff
// for its hardness, its angle and roundness, a sampled tip's shape.
let tex = crate::brush_preview::tip_texture(ui.ctx(), "options-bar-chip", &b.tip, (b.hardness, b.angle, b.roundness), 32, tip);
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
ui.painter().image(tex.id(), Rect::from_center_size(c, vec2(14.0, 14.0)), uv, Color32::WHITE);
ui.painter().text(pos2(c.x, r.bottom() - 5.0), Align2::CENTER_CENTER, format!("{}", b.size.round() as i64), egui::FontId::proportional(9.5), t.text_dim);
icons::paint(ui, Rect::from_center_size(pos2(r.right() - 9.0, c.y), vec2(10.0, 10.0)), "chevron-down", 9.0, t.text_faint);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Brush Preset picker")));
-52
View File
@@ -134,43 +134,6 @@ pub(crate) fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> O
traced
}
/// Which texels of the document-pixel `rect` (`x0, y0, x1, y1`, row-major) lie inside the tip
/// `rings` (radius units, even-odd so holes stay clear) when the tip is centred on `at` with
/// `radius` document pixels. The Clone Source overlay uses it to clip to a shaped tip.
pub(crate) fn tip_mask(rings: &[Vec<[f32; 2]>], at: [f64; 2], radius: f64, rect: [i32; 4]) -> Vec<bool> {
let [x0, y0, x1, y1] = rect;
let (w, h) = (usize::try_from(x1 - x0).unwrap_or(0), usize::try_from(y1 - y0).unwrap_or(0));
let mut mask = vec![false; w.saturating_mul(h)];
let mut hits: Vec<f64> = Vec::new();
for row in 0..h {
let y = f64::from(y0) + row as f64 + 0.5;
hits.clear();
for ring in rings {
for (i, a) in ring.iter().enumerate() {
let Some(b) = ring.get((i + 1) % ring.len()) else { continue };
let (ax, ay) = (at[0] + f64::from(a[0]) * radius, at[1] + f64::from(a[1]) * radius);
let (bx, by) = (at[0] + f64::from(b[0]) * radius, at[1] + f64::from(b[1]) * radius);
if (ay <= y) != (by <= y) {
hits.push(ax + (y - ay) / (by - ay) * (bx - ax));
}
}
}
hits.sort_by(f64::total_cmp);
for pair in hits.chunks(2) {
let [a, b] = pair else { continue };
let (from, to) = (a - f64::from(x0) - 0.5, b - f64::from(x0) - 0.5);
let start = from.ceil().max(0.0) as usize;
let end = ((to.floor() + 1.0).max(0.0) as usize).min(w);
for x in start..end {
if let Some(m) = mask.get_mut(row * w + x) {
*m = true;
}
}
}
}
mask
}
#[cfg(test)]
mod tests {
use super::*;
@@ -295,19 +258,4 @@ mod tests {
assert!(ex.x.is_finite() && ex.y.is_finite() && ey.x.is_finite() && ey.y.is_finite(), "size {size}");
}
}
/// The overlay clip follows the tip: a square ring covers its own texels, a hole stays clear,
/// and a rotated flat tip leaves the corners of the brush square outside.
#[test]
fn tip_mask_follows_the_ring_shape() {
let square = vec![[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]];
let hole = vec![[-0.1, -0.1], [0.1, -0.1], [0.1, 0.1], [-0.1, 0.1]];
let mask = tip_mask(&[square.clone(), hole], [10.0, 10.0], 10.0, [0, 0, 20, 20]);
let at = |x: usize, y: usize| mask[y * 20 + x];
assert!(at(6, 10) && at(14, 10) && at(10, 6) && at(10, 14), "inside the square");
assert!(!at(3, 10) && !at(10, 16) && !at(0, 0), "outside the square");
assert!(!at(10, 10) && at(8, 10), "the hole is clear");
assert_eq!(mask.iter().filter(|m| **m).count(), 10 * 10 - 2 * 2, "10x10 square minus the 2x2 hole");
assert!(tip_mask(&[square], [10.0, 10.0], 10.0, [5, 5, 5, 9]).is_empty(), "an empty rect is harmless");
}
}
+46 -3
View File
@@ -138,14 +138,14 @@ fn update(app: &mut PhotocraftApp, r: Resize, x: f64, y: f64) {
}
/// Draw the resize feedback: the brush circle at the anchor, filled with the brush preview
/// colour whose solid core shows the hardness (Photoshop's), and the readout beside the pointer.
/// colour as a dab of the brush's hardness covers (Photoshop's), and the readout beside the pointer.
/// Returns true while a resize is in progress (the canvas then hides its cursor).
pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) -> bool {
let Some(r) = app.brush_resize else { return false };
let b = &app.session.tools.brush;
let c = xf.to_screen(r.anchor[0] as f32, r.anchor[1] as f32);
let radius = (b.size / 2.0 * xf.zoom).max(1.0);
tip_preview(painter, c, radius, b.hardness, preview_tint(app));
tip_preview(painter, c, b.size / 2.0, xf.zoom, b.hardness, preview_tint(app));
painter.circle_stroke(c, radius + 0.5, Stroke::new(1.0, Color32::from_black_alpha(140)));
painter.circle_stroke(c, radius, Stroke::new(1.0, Color32::from_white_alpha(220)));
// Photoshop hides the pointer while the circle stays put.
@@ -169,9 +169,10 @@ fn preview_tint(app: &PhotocraftApp) -> Color32 {
})
}
/// Liquify density preview, kept separate from the paint engine's hardness falloff.
/// A round tip of `radius` screen points: solid out to `hardness` of the radius, fading to clear
/// at the edge. One mesh (two rings and a centre), so its cost doesn't grow with the brush.
pub(crate) fn tip_preview(painter: &egui::Painter, c: Pos2, radius: f32, hardness: f32, color: Color32) {
pub(crate) fn liquify_tip_preview(painter: &egui::Painter, c: Pos2, radius: f32, hardness: f32, color: Color32) {
let clip = painter.clip_rect();
if !clip.expand(radius).contains(c) {
return;
@@ -199,5 +200,47 @@ pub(crate) fn tip_preview(painter: &egui::Painter, c: Pos2, radius: f32, hardnes
painter.add(mesh);
}
/// The round tip as the brush engine paints it: `radius` document pixels at `zoom` screen points
/// per document pixel, solid out to its core and then the engine's falloff (`tip_falloff`) out to
/// its reach, past the radius for a soft tip, so the disc shows what a dab of this hardness covers.
/// One mesh of rings, so its cost doesn't grow with the brush.
fn tip_preview(painter: &egui::Painter, c: Pos2, radius: f32, zoom: f32, hardness: f32, color: Color32) {
/// Rings across the falloff: the shoulder is smooth, so linear steps between them are close.
const RINGS: usize = 16;
if !(radius.is_finite() && zoom.is_finite() && radius > 0.0 && zoom > 0.0) {
return;
}
let (core, _) = photocraft_paint::tip_shape(radius, hardness);
let reach = photocraft_paint::tip_reach(radius, hardness).max(core);
let outer = reach * zoom;
let clip = painter.clip_rect();
if !clip.expand(outer).contains(c) {
return;
}
// Enough segments for a smooth edge at any size, capped so huge brushes stay cheap.
let n = ((outer * 0.5) as usize).clamp(24, 160);
let mut mesh = egui::Mesh::default();
mesh.colored_vertex(c, color);
for k in 0..=RINGS {
let d = core + (reach - core) * k as f32 / RINGS as f32;
// The last ring is the reach, where the falloff has run out.
let tint = if k == RINGS { Color32::TRANSPARENT } else { color.gamma_multiply(photocraft_paint::tip_falloff(d, radius, hardness)) };
for j in 0..n {
let a = j as f32 / n as f32 * std::f32::consts::TAU;
mesh.colored_vertex(c + vec2(a.cos(), a.sin()) * d * zoom, tint);
}
}
let n = n as u32;
let at = |ring: u32, j: u32| 1 + ring * n + j % n;
for j in 0..n {
mesh.add_triangle(0, at(0, j), at(0, j + 1));
for ring in 0..RINGS as u32 {
mesh.add_triangle(at(ring, j), at(ring + 1, j), at(ring + 1, j + 1));
mesh.add_triangle(at(ring, j), at(ring + 1, j + 1), at(ring, j + 1));
}
}
painter.add(mesh);
}
#[cfg(test)]
mod tests;
@@ -344,6 +344,45 @@ fn a_rotated_view_keeps_horizontal_for_size_and_vertical_for_hardness() {
assert_eq!(drag_in_view(90.0, true, 0.0, 30.0), (100.0, 0.2), "rotated and flipped: screen right grows");
}
/// The tip's mesh, painted at the origin: (distance from the centre, alpha) per vertex.
fn tip_mesh(radius: f32, zoom: f32, hardness: f32) -> Vec<(f32, u8)> {
let ctx = egui::Context::default();
let color = Color32::from_rgba_unmultiplied(255, 0, 0, 200);
let mut out = ctx.run_ui(Default::default(), |ui| {
let painter = ui.painter().with_clip_rect(egui::Rect::from_center_size(Pos2::ZERO, vec2(4000.0, 4000.0)));
tip_preview(&painter, Pos2::ZERO, radius, zoom, hardness, color);
});
out.textures_delta.clear();
let mesh = out
.shapes
.iter()
.find_map(|c| match &c.shape {
egui::Shape::Mesh(m) => Some(m.clone()),
_ => None,
})
.expect("the tip is drawn");
mesh.vertices.iter().map(|v| (v.pos.to_vec2().length(), v.color.a())).collect()
}
/// The disc follows the engine's round tip: a hard one is solid to its rim, a soft one fades
/// as the engine's falloff does and reaches past the radius as its dabs do.
#[test]
fn the_tip_preview_follows_the_brush_falloff() {
for zoom in [1.0, 2.5] {
let hard = tip_mesh(50.0, zoom, 1.0);
assert!(hard.iter().filter(|(d, _)| *d < 48.0 * zoom).all(|(_, a)| *a == 200), "zoom {zoom}: a hard tip is solid inside");
assert!(hard.iter().all(|(d, a)| *d <= 52.0 * zoom || *a == 0), "zoom {zoom}: and ends at its rim");
let soft = tip_mesh(50.0, zoom, 0.0);
for (d, a) in &soft {
let want = 200.0 * photocraft_paint::tip_falloff(d / zoom, 50.0, 0.0);
assert!((f32::from(*a) - want).abs() <= 3.0 || *a == 0, "zoom {zoom}: at {d}: {a}, the engine {want}");
}
let reach = photocraft_paint::tip_reach(50.0, 0.0);
assert!(soft.iter().any(|(d, a)| *d > 60.0 * zoom && *a > 0), "zoom {zoom}: a soft tip shows past its radius");
assert!(soft.iter().all(|(d, _)| *d <= reach * zoom + 0.5), "zoom {zoom}: and no further than it reaches");
}
}
/// Preferences ▸ Cursors ▸ Brush Preview colour tints the HUD's tip preview (red by default).
#[test]
fn the_tip_preview_uses_the_brush_preview_colour() {
@@ -383,3 +422,26 @@ fn stylus_barrel_button_resizes_with_alt_and_never_samples_color() {
h.run_steps(2);
assert!(h.state().brush_resize.is_none());
}
/// Liquify density keeps its radius-bounded preview; the paint engine's soft-brush reach is
/// deliberately used only by the paint HUD.
#[test]
fn liquify_density_preview_stays_inside_its_radius() {
let ctx = egui::Context::default();
let radius = 50.0;
let mut output = ctx.run_ui(Default::default(), |ui| {
let painter = ui.painter().with_clip_rect(egui::Rect::from_center_size(Pos2::ZERO, vec2(400.0, 400.0)));
liquify_tip_preview(&painter, Pos2::ZERO, radius, 0.0, Color32::RED);
});
output.textures_delta.clear();
let mesh = output
.shapes
.iter()
.find_map(|shape| match &shape.shape {
egui::Shape::Mesh(mesh) => Some(mesh),
_ => None,
})
.expect("the Liquify preview is drawn");
assert!(mesh.vertices.iter().all(|v| v.pos.to_vec2().length() <= radius + 0.01));
assert!(mesh.vertices.iter().any(|v| v.pos.to_vec2().length() >= radius - 0.01));
}
@@ -52,6 +52,28 @@ fn clone_stamp_in_pattern_preview_wraps_a_dab_across_the_right_edge() {
}
}
/// A Clone Stamp click paints with the brush's hardness (the Brush popup's), not a hard tip:
/// near the rim a soft brush paints only part of the source, a hard one all of it.
#[test]
fn clone_stamp_paints_with_the_brush_hardness() {
let rim = |hardness: f32| {
let mut app = app();
app.run("tools.setBrush", json!({"brush": {"size": 24, "hardness": hardness}})).unwrap();
app.run("paint.pencil", json!({"points": [[15, 30]], "size": 40, "color": "#ff0000"})).unwrap();
app.ui.tool = Tool::CloneStamp;
app.ui.clone_source = Some([15.0, 30.0]);
let m = egui::Modifiers::NONE;
tool_event(&mut app, ToolEvent::Down { x: 70.0, y: 30.0, pressure: 1.0 }, m);
tool_event(&mut app, ToolEvent::Up { x: 70.0, y: 30.0 }, m);
assert!(!app.ui.status_error, "{}", app.ui.status);
// 9 px from the centre of a 12 px radius: inside a hard tip, on a soft tip's falloff.
1.0 - active(&app).surface().unwrap().rgba(79, 30)[1]
};
let (hard, soft) = (rim(1.0), rim(0.0));
assert!(hard > 0.99, "a hard tip clones fully near its rim: {hard}");
assert!(soft < 0.8, "a soft tip clones only part of the source there: {soft}");
}
#[test]
fn alt_flips_dodge_burn_and_blur_sharpen() {
for (tool, flipped) in [(Tool::Dodge, Tool::Burn), (Tool::Burn, Tool::Dodge), (Tool::Blur, Tool::Sharpen), (Tool::Sharpen, Tool::Blur)] {
+4 -1
View File
@@ -1108,11 +1108,14 @@ fn new_fill_layer_takes_the_selected_path_as_its_vector_mask() {
// Deselected (or a stale name): the selection is the layer mask, no vector mask.
for sel in [None, Some("Gone".to_string())] {
app.ui.selected_path = sel;
// The selection goes into the new layer's mask, so each fill layer needs its own (#8801).
app.run("select.rect", json!({"x": 150, "y": 150, "width": 20, "height": 20})).unwrap();
let r = app.run("layer.newFillLayer.solidColor", json!({}));
let l = layer(&app, r);
assert!(l.vector_mask.is_none() && l.mask.is_some());
assert!(l.vector_mask.is_none() && l.mask.as_ref().is_some_and(|m| m.value(160, 160) == 1.0 && m.value(5, 5) == 0.0));
}
// Make Work Path selects the new work path.
app.run("select.rect", json!({"x": 150, "y": 150, "width": 20, "height": 20})).unwrap();
app.run("select.toWorkPath", json!({"tolerance": 2.0})).unwrap();
assert_eq!(app.ui.selected_path.as_deref(), Some("work"));
}
+10 -3
View File
@@ -567,14 +567,21 @@ pub struct CompiledShape {
impl CompiledShape {
/// Compiles the shape at flattening tolerance `tol`. `canvas` is the frame gradients
/// without "Align with layer" are laid out in.
/// without "Align with layer" are laid out in, and the area a feather is computed over.
pub fn new(shape: &ShapeLayer, tol: f64, canvas: Rect) -> Self {
Self::with_frames(shape, tol, canvas, canvas)
}
/// [`CompiledShape::new`] with the feather computed over `coverage` while gradients still lay
/// out in `gradient_frame`: measuring a shape beyond the canvas it is drawn in.
pub fn with_frames(shape: &ShapeLayer, tol: f64, coverage: Rect, gradient_frame: Rect) -> Self {
let pb = shape.path.control_bounds().unwrap_or((0.0, 0.0, 0.0, 0.0));
let cb = (f64::from(canvas.x0), f64::from(canvas.y0), f64::from(canvas.x1), f64::from(canvas.y1));
let g = gradient_frame;
let cb = (f64::from(g.x0), f64::from(g.y0), f64::from(g.x1), f64::from(g.y1));
let area = fill_rasterizer(&shape.path, tol);
let mut bounds = None;
let fill = shape.fill.as_ref().map(|f| {
let coverage = FillCoverage::new(shape, &area, canvas);
let coverage = FillCoverage::new(shape, &area, coverage);
bounds = coverage.bounds();
(coverage, Paint::from_fill(f, pb, cb))
});
+7 -9
View File
@@ -20,11 +20,10 @@ in the manual check window. Concurrent requests share one check.
HTTPS validates certificates and host names against the operating system's trust store.
Tags must be semantic versions (`v0.8.0` or `0.8.0`); only published stable versions newer
than the installed workspace version trigger a notice. The notice opens the official release
page to choose an installer; installation remains a user action. Drafts and prereleases do
not trigger it. Web/headless frontends and the Flatpak (no network in its sandbox; `flatpak update`
owns updates there) have no update transport. No release-workflow changes
are required: publishing a newer stable GitHub Release makes it discoverable.
than the installed workspace version trigger a notice. Drafts and prereleases do not trigger it.
Flatpak builds also check for releases; repository-managed installations are updated through
their package manager. Web/headless frontends have no update transport. No release-workflow
changes are required: publishing a newer stable GitHub Release makes it discoverable.
## Cutting a release
@@ -280,10 +279,9 @@ Why these formats:
Sandbox permissions (justified in the manifest): Wayland with X11 fallback, IPC (X11
shared memory), `dri` for the GPU, and read/write access to Pictures and Documents. Every
other file goes through the file-chooser and document portals. There's no network, so the
`--control` server is only reachable from inside the sandbox until the user runs
`flatpak override --user --share=network ai.storyteller.photocraft`. Host fonts are read from
`/run/host/fonts` and `/run/host/user-fonts`. Locally (on Linux, with `flatpak` and
other file goes through the file-chooser and document portals. Network access enables update
checks and makes the optional loopback `--control` server reachable from the host. Host fonts
are read from `/run/host/fonts` and `/run/host/user-fonts`. Locally (on Linux, with `flatpak` and
`flatpak-builder`): `packaging/linux/package.sh --formats tar && packaging/linux/flatpak-bundle.sh`.
- **Flathub**: `packaging/linux/flatpak/ai.storyteller.photocraft.yml` builds from source and
is ready for a Flathub submission (it keeps the same runtime and `finish-args` as the bundle
@@ -22,9 +22,8 @@ finish-args:
- --socket=fallback-x11
# X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails.
- --share=ipc
# Network for the optional --control server: it listens on 127.0.0.1 in the sandbox, which with
# network shared is also reachable from the host's network namespace. The in-app update check
# is disabled under Flatpak (flatpak update owns updates), so this does not enable it.
# Network for update checks and the optional --control server. The server listens on 127.0.0.1,
# which with network shared is also reachable from the host's network namespace.
- --share=network
# GPU access for the wgpu compositor (Vulkan, or GL as a fallback).
- --device=dri
@@ -23,9 +23,8 @@ finish-args:
- --socket=fallback-x11
# X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails.
- --share=ipc
# Network for the optional --control server: it listens on 127.0.0.1 in the sandbox, which with
# network shared is also reachable from the host's network namespace. The in-app update check
# is disabled under Flatpak (flatpak update owns updates), so this does not enable it.
# Network for update checks and the optional --control server. The server listens on 127.0.0.1,
# which with network shared is also reachable from the host's network namespace.
- --share=network
# GPU access for the wgpu compositor (Vulkan, or GL as a fallback).
- --device=dri