mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
Integration: merge main 528491f9a, resolve conflicts
- crates/engine/src/edit_cmds.rs: copy_mask keeps #3297's filter-mask target and takes main's clipboard `source` (#8945). - crates/cms/src/lutfile.rs: #9183's shaper doc comment next to main's UTF-16 decode_text (#9129). - contributors/people.toml, docs: unions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,8 +9,8 @@ use photocraft_engine::{
|
||||
};
|
||||
|
||||
pub type Batch = BTreeMap<String, Option<Arc<[u8]>>>;
|
||||
/// Three sampled images per preset (tip, dual tip, texture), plus group/index/action records.
|
||||
pub const MAX_RECORDS: usize = MAX_GROUPS + MAX_PRESETS_PER_GROUP * 3 + 2;
|
||||
/// Three sampled images per preset (tip, dual tip, texture), plus group/index/action/pattern records.
|
||||
pub const MAX_RECORDS: usize = MAX_GROUPS + MAX_PRESETS_PER_GROUP * 3 + 3;
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
@@ -45,6 +45,7 @@ pub fn record_limit(name: &str) -> Result<u64, String> {
|
||||
match name {
|
||||
INDEX_FILE => Ok(MAX_INDEX_BYTES),
|
||||
ACTIONS_FILE => Ok(MAX_ACTIONS_BYTES),
|
||||
PATTERNS_FILE => Ok(MAX_PATTERNS_BYTES),
|
||||
_ if name.starts_with("tips/") && leaf.ends_with(".pctip") => Ok(MAX_TIP_BYTES),
|
||||
_ if !name.contains('/') && name.ends_with(".pcbrushes") => Ok(MAX_GROUP_BYTES),
|
||||
_ => Err("unknown preset record type".into()),
|
||||
|
||||
@@ -105,6 +105,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
|
||||
// Game and 3D textures; DDS Options asks for the compression and mipmaps (#1327).
|
||||
("DDS", &["dds"]),
|
||||
("Cineon", &["cin"]),
|
||||
("Amiga IFF", &["iff"]),
|
||||
];
|
||||
|
||||
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
|
||||
|
||||
@@ -267,3 +267,14 @@ real_name = "Kushagra Sharma"
|
||||
|
||||
[people.WebWork-rrs]
|
||||
display_name = "Ratul Paul"
|
||||
|
||||
[people.softwaregames1]
|
||||
sync_github_name = true
|
||||
|
||||
[people.VoiceLessQ]
|
||||
display_name = "VoiceLessQ"
|
||||
sync_github_name = false
|
||||
|
||||
[people.erenova]
|
||||
display_name = "Erenova"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -948,23 +948,25 @@ fn dehaze(px: &mut [[f32; 4]], w: usize, h: usize, amount: f32) {
|
||||
*s = s.map(|v| v / c.max(1.0));
|
||||
}
|
||||
let r = (sw.max(sh) / 60).max(2);
|
||||
let min_filter = |v: &[f32]| -> Vec<f32> {
|
||||
// The minimum (or, with `f32::max`, maximum) over each (2r + 1)² window.
|
||||
let rank_filter = |v: &[f32], start: f32, pick: fn(f32, f32) -> f32| -> Vec<f32> {
|
||||
let mut a = vec![0.0f32; sw * sh];
|
||||
for y in 0..sh {
|
||||
for x in 0..sw {
|
||||
let (x0, x1) = (x.saturating_sub(r), (x + r).min(sw - 1));
|
||||
a[y * sw + x] = (x0..=x1).map(|xx| v[y * sw + xx]).fold(f32::MAX, f32::min);
|
||||
a[y * sw + x] = (x0..=x1).map(|xx| v[y * sw + xx]).fold(start, pick);
|
||||
}
|
||||
}
|
||||
let mut b = vec![0.0f32; sw * sh];
|
||||
for y in 0..sh {
|
||||
for x in 0..sw {
|
||||
let (y0, y1) = (y.saturating_sub(r), (y + r).min(sh - 1));
|
||||
b[y * sw + x] = (y0..=y1).map(|yy| a[yy * sw + x]).fold(f32::MAX, f32::min);
|
||||
b[y * sw + x] = (y0..=y1).map(|yy| a[yy * sw + x]).fold(start, pick);
|
||||
}
|
||||
}
|
||||
b
|
||||
};
|
||||
let min_filter = |v: &[f32]| rank_filter(v, f32::MAX, f32::min);
|
||||
let dark = min_filter(&small.iter().map(|c| c[0].min(c[1]).min(c[2])).collect::<Vec<_>>());
|
||||
// Atmospheric light: mean colour of the brightest 0.1 % of the dark channel.
|
||||
let mut idx: Vec<usize> = (0..sw * sh).collect();
|
||||
@@ -978,7 +980,15 @@ fn dehaze(px: &mut [[f32; 4]], w: usize, h: usize, amount: f32) {
|
||||
}
|
||||
let air = air.map(|v| v.max(0.2));
|
||||
let norm_dark = min_filter(&small.iter().map(|c| (c[0] / air[0]).min(c[1] / air[1]).min(c[2] / air[2])).collect::<Vec<_>>());
|
||||
let t_raw: Vec<f32> = norm_dark.iter().map(|d| 1.0 - 0.95 * d).collect();
|
||||
// Opened (a maximum filter of the same radius after the minimum): the minimum grows every
|
||||
// dark object by `r`, so sky beside a dark building took its dark channel, counted as
|
||||
// haze-free and kept a bright halo (#9273). Opening shrinks dark regions back to their
|
||||
// outline and is still a lower bound of the dark channel over the window. It raises the dark
|
||||
// channel on average, so the 0.95 weight is scaled to keep the slider's overall strength.
|
||||
let opened = rank_filter(&norm_dark, f32::MIN, f32::max);
|
||||
let mean = |v: &[f32]| v.iter().sum::<f32>() / v.len().max(1) as f32;
|
||||
let weight = 0.95 * mean(&norm_dark) / mean(&opened).max(1e-6);
|
||||
let t_raw: Vec<f32> = opened.iter().map(|d| 1.0 - weight * d).collect();
|
||||
let guide: Vec<f32> = small.iter().map(|c| luma(*c)).collect();
|
||||
let t = guided(&guide, &t_raw, sw, sh, r * 2, 1e-3);
|
||||
let ks = k as f32;
|
||||
@@ -1098,6 +1108,37 @@ fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #9273: Dehaze clears the haze evenly over a sky, right up to a dark building, instead of
|
||||
/// leaving a bright halo of uncorrected haze around it. Synthetic scene: a clear sky and a
|
||||
/// dark tower, hazed with `I = J·t + A·(1 − t)`.
|
||||
#[test]
|
||||
fn dehaze_leaves_no_halo_beside_dark_objects() {
|
||||
let (w, h) = (600usize, 400usize);
|
||||
let tower = |x: usize, y: usize| (250..350).contains(&x) && y >= 120;
|
||||
let (t, air) = (0.55f32, [0.9f32, 0.9, 0.9]);
|
||||
let mut px: Vec<[f32; 4]> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
let j = if tower(x, y) { [0.06, 0.06, 0.07] } else { [0.35 + 0.1 * y as f32 / h as f32, 0.5, 0.8] };
|
||||
let c: [f32; 3] = std::array::from_fn(|k| j[k] * t + air[k] * (1.0 - t));
|
||||
[c[0], c[1], c[2], 1.0]
|
||||
})
|
||||
.collect();
|
||||
dehaze(&mut px, w, h, 1.0);
|
||||
let sky = |xs: std::ops::Range<usize>| {
|
||||
let v: Vec<f32> =
|
||||
(150..390).flat_map(|y| xs.clone().map(move |x| (x, y))).map(|(x, y)| luma([px[y * w + x][0], px[y * w + x][1], px[y * w + x][2]])).collect();
|
||||
v.iter().sum::<f32>() / v.len() as f32
|
||||
};
|
||||
// Sky 1–8 px from the tower against open sky: the haze left beside it, in 8-bit levels.
|
||||
let halo = (sky(242..250) - sky(20..80)) * 255.0;
|
||||
// 6.4 levels before the dark channel was opened, under 1 after.
|
||||
assert!(halo < 3.0, "a {halo:.1}-level halo beside the tower");
|
||||
// The open sky is still cleared: darker than the hazy input.
|
||||
let hazy = luma(std::array::from_fn(|k| [0.4, 0.5, 0.8][k] * t + air[k] * (1.0 - t)));
|
||||
assert!(sky(20..80) < hazy - 0.02, "{} vs hazy {hazy}", sky(20..80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_curves_are_validated_but_saved_curves_still_load_and_render() {
|
||||
for field in ["pointCurve", "pointCurveRed", "pointCurveGreen", "pointCurveBlue"] {
|
||||
|
||||
@@ -806,6 +806,21 @@ mod tests {
|
||||
assert!(authorize_desktop_engine_step("file.saveACopy", &serde_json::json!({})).is_err());
|
||||
}
|
||||
|
||||
// Regression test for #3763: `edit.installedProfiles` scans the OS and user ICC folders but
|
||||
// takes no path parameter, so automation let it through until it was listed as ambient.
|
||||
#[test]
|
||||
fn installed_profiles_listing_is_refused_for_automation() {
|
||||
for params in [serde_json::json!({}), serde_json::json!({"class": "output", "colorSpace": "cmyk"})] {
|
||||
assert!(authorize_engine_command("edit.installedProfiles", ¶ms).is_err(), "authorize_engine_command allowed it with {params}");
|
||||
assert!(authorize_desktop_engine_command("edit.installedProfiles", ¶ms).is_err(), "authorize_desktop_engine_command allowed it with {params}");
|
||||
assert!(authorize_engine_step("edit.installedProfiles", ¶ms).is_err(), "authorize_engine_step allowed it with {params}");
|
||||
}
|
||||
// The sandboxed session refuses it before the engine scans any folder.
|
||||
let mut headless = crate::Headless::new();
|
||||
let refused = headless.command_run("edit.installedProfiles", serde_json::json!({})).unwrap_err();
|
||||
assert!(refused.to_string().contains("ambient filesystem paths"), "{refused}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_data_set_is_judged_by_the_values_it_applies() {
|
||||
let mut headless = crate::Headless::new();
|
||||
|
||||
@@ -1,94 +1,15 @@
|
||||
//! Stored smart-filter parameters must not grant automation ambient filesystem access.
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_automation::{AuthorizedWorkspace, Headless, workspace::authorize_desktop_engine_step};
|
||||
use photocraft_doc::{Document, LayerContent, LayerId};
|
||||
#[path = "../support/smart_filter_fixture.rs"]
|
||||
mod fixture;
|
||||
|
||||
use fixture::{Fixture, assert_denied, cube, path_params};
|
||||
use photocraft_automation::{Headless, workspace::authorize_desktop_engine_step};
|
||||
use photocraft_doc::LayerContent;
|
||||
use photocraft_engine::{Session, actions_cmds::Action, jobs::Started, smart_cmds};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let base = std::env::temp_dir().join(format!("pc-filter-auth-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&base).unwrap();
|
||||
std::fs::create_dir(base.join("workspace")).unwrap();
|
||||
std::fs::create_dir(base.join("outside")).unwrap();
|
||||
let f = Self(base);
|
||||
std::fs::write(f.root().join("stripes.png"), png(|x| if (x / 2) % 2 == 0 { 0 } else { 255 })).unwrap();
|
||||
std::fs::write(f.outside("black.png"), png(|_| 0)).unwrap();
|
||||
std::fs::write(f.outside("white.png"), png(|_| 255)).unwrap();
|
||||
std::fs::write(f.outside("black.cube"), cube(0)).unwrap();
|
||||
std::fs::write(f.outside("white.cube"), cube(1)).unwrap();
|
||||
f
|
||||
}
|
||||
|
||||
fn root(&self) -> PathBuf {
|
||||
self.0.join("workspace")
|
||||
}
|
||||
fn outside(&self, name: &str) -> PathBuf {
|
||||
self.0.join("outside").join(name)
|
||||
}
|
||||
fn headless(&self) -> Headless {
|
||||
Headless::with_workspace(AuthorizedWorkspace::new(Some(&self.root()), Some(&self.root())).unwrap())
|
||||
}
|
||||
fn setup(&self, lookup: bool) -> (Headless, LayerId) {
|
||||
let mut h = self.headless();
|
||||
h.handle("doc.new", json!({"width": 32, "height": 32, "background": "#808080"})).unwrap();
|
||||
h.handle("doc.new", json!({"width": 32, "height": 32})).unwrap();
|
||||
let layer = LayerId(h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let (id, params) = if lookup {
|
||||
("image.adjustments.colorLookup", json!({"layer": layer.0, "lut": "none"}))
|
||||
} else {
|
||||
("filter.distort.displace", json!({"layer": layer.0, "mapDocument": 0, "horizontal": 50, "vertical": 0}))
|
||||
};
|
||||
h.session.execute(id, params).unwrap();
|
||||
(h, layer)
|
||||
}
|
||||
fn saved_filter(&self, lookup: bool) -> (Document, LayerId) {
|
||||
let (h, layer) = self.setup(lookup);
|
||||
let mut doc = (*h.session.active().unwrap().doc).clone();
|
||||
let LayerContent::Smart(sm) = &mut doc.layer_mut(layer).unwrap().content else { panic!("smart object") };
|
||||
sm.smart_filters[0].params = path_params(lookup, &self.outside(if lookup { "white.cube" } else { "white.png" }));
|
||||
let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap();
|
||||
std::fs::write(self.root().join("stored.pcraft"), bytes).unwrap();
|
||||
(doc, layer)
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn png(value: impl Fn(u32) -> u8) -> Vec<u8> {
|
||||
let mut pixels = Vec::new();
|
||||
for _ in 0..32 {
|
||||
for x in 0..32 {
|
||||
pixels.extend([value(x); 3]);
|
||||
}
|
||||
}
|
||||
let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, pixels).unwrap();
|
||||
photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()
|
||||
}
|
||||
fn cube(value: u8) -> String {
|
||||
format!("LUT_3D_SIZE 2\n{}", format!("{value} {value} {value}\n").repeat(8))
|
||||
}
|
||||
fn path_params(lookup: bool, path: &Path) -> Value {
|
||||
if lookup { json!({"file": path}) } else { json!({"mapPath": path, "mapDocument": null}) }
|
||||
}
|
||||
fn assert_denied<T: std::fmt::Debug>(result: Result<T, impl std::fmt::Display>) {
|
||||
match result {
|
||||
Err(error) => assert!(error.to_string().contains("ambient filesystem"), "{error}"),
|
||||
Ok(value) => panic!("ambient file access was accepted: {value:?}"),
|
||||
}
|
||||
}
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn parameter_edits_reject_outside_images_and_luts_without_changing_the_document() {
|
||||
@@ -243,21 +164,6 @@ fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache() {
|
||||
// The denial path must fail the render before any stage is cached process-wide.
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (doc, layer) = f.saved_filter(lookup);
|
||||
let mut s = Session::new();
|
||||
s.add_document(doc, None);
|
||||
s.authorize = Some(authorize_desktop_engine_step);
|
||||
let bytes = smart_cmds::stack_cache_bytes();
|
||||
assert_denied(s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})));
|
||||
assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage");
|
||||
}
|
||||
}
|
||||
|
||||
/// Choosing another Displace map through `setParams` embeds it (#7423), so the new map is read
|
||||
/// before the edit: a path outside the workspace is refused before that read, and the filter
|
||||
/// keeps the map it had.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
//! One integration-test process keeps the global smart filter stage cache isolated from the
|
||||
//! parallel tests in `tests/it`, so a denied render can be shown to have cached nothing.
|
||||
use photocraft_automation::workspace::authorize_desktop_engine_step;
|
||||
use photocraft_engine::{Session, smart_cmds};
|
||||
use serde_json::json;
|
||||
|
||||
#[path = "support/smart_filter_fixture.rs"]
|
||||
mod fixture;
|
||||
|
||||
use fixture::{Fixture, assert_denied};
|
||||
|
||||
#[test]
|
||||
fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache() {
|
||||
// The denial path must fail the render before any stage is cached process-wide.
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (doc, layer) = f.saved_filter(lookup);
|
||||
let mut s = Session::new();
|
||||
s.add_document(doc, None);
|
||||
s.authorize = Some(authorize_desktop_engine_step);
|
||||
let bytes = smart_cmds::stack_cache_bytes();
|
||||
assert_denied(s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})));
|
||||
assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! The smart filter authorization tests' fixture: a workspace with a striped image to place and
|
||||
//! maps and LUTs outside it (`#[path]`-included by `it/smart_filter_authorization.rs` and
|
||||
//! `smart_filter_stack_cache.rs`).
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use photocraft_automation::{AuthorizedWorkspace, Headless};
|
||||
use photocraft_doc::{Document, LayerContent, LayerId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
pub fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let base = std::env::temp_dir().join(format!("pc-filter-auth-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&base).unwrap();
|
||||
std::fs::create_dir(base.join("workspace")).unwrap();
|
||||
std::fs::create_dir(base.join("outside")).unwrap();
|
||||
let f = Self(base);
|
||||
std::fs::write(f.root().join("stripes.png"), png(|x| if (x / 2) % 2 == 0 { 0 } else { 255 })).unwrap();
|
||||
std::fs::write(f.outside("black.png"), png(|_| 0)).unwrap();
|
||||
std::fs::write(f.outside("white.png"), png(|_| 255)).unwrap();
|
||||
std::fs::write(f.outside("black.cube"), cube(0)).unwrap();
|
||||
std::fs::write(f.outside("white.cube"), cube(1)).unwrap();
|
||||
f
|
||||
}
|
||||
|
||||
pub fn root(&self) -> PathBuf {
|
||||
self.0.join("workspace")
|
||||
}
|
||||
pub fn outside(&self, name: &str) -> PathBuf {
|
||||
self.0.join("outside").join(name)
|
||||
}
|
||||
pub fn headless(&self) -> Headless {
|
||||
Headless::with_workspace(AuthorizedWorkspace::new(Some(&self.root()), Some(&self.root())).unwrap())
|
||||
}
|
||||
pub fn setup(&self, lookup: bool) -> (Headless, LayerId) {
|
||||
let mut h = self.headless();
|
||||
h.handle("doc.new", json!({"width": 32, "height": 32, "background": "#808080"})).unwrap();
|
||||
h.handle("doc.new", json!({"width": 32, "height": 32})).unwrap();
|
||||
let layer = LayerId(h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let (id, params) = if lookup {
|
||||
("image.adjustments.colorLookup", json!({"layer": layer.0, "lut": "none"}))
|
||||
} else {
|
||||
("filter.distort.displace", json!({"layer": layer.0, "mapDocument": 0, "horizontal": 50, "vertical": 0}))
|
||||
};
|
||||
h.session.execute(id, params).unwrap();
|
||||
(h, layer)
|
||||
}
|
||||
pub fn saved_filter(&self, lookup: bool) -> (Document, LayerId) {
|
||||
let (h, layer) = self.setup(lookup);
|
||||
let mut doc = (*h.session.active().unwrap().doc).clone();
|
||||
let LayerContent::Smart(sm) = &mut doc.layer_mut(layer).unwrap().content else { panic!("smart object") };
|
||||
sm.smart_filters[0].params = path_params(lookup, &self.outside(if lookup { "white.cube" } else { "white.png" }));
|
||||
let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap();
|
||||
std::fs::write(self.root().join("stored.pcraft"), bytes).unwrap();
|
||||
(doc, layer)
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn png(value: impl Fn(u32) -> u8) -> Vec<u8> {
|
||||
let mut pixels = Vec::new();
|
||||
for _ in 0..32 {
|
||||
for x in 0..32 {
|
||||
pixels.extend([value(x); 3]);
|
||||
}
|
||||
}
|
||||
let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, pixels).unwrap();
|
||||
photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()
|
||||
}
|
||||
pub fn cube(value: u8) -> String {
|
||||
format!("LUT_3D_SIZE 2\n{}", format!("{value} {value} {value}\n").repeat(8))
|
||||
}
|
||||
pub fn path_params(lookup: bool, path: &Path) -> Value {
|
||||
if lookup { json!({"file": path}) } else { json!({"mapPath": path, "mapDocument": null}) }
|
||||
}
|
||||
pub fn assert_denied<T: std::fmt::Debug>(result: Result<T, impl std::fmt::Display>) {
|
||||
match result {
|
||||
Err(error) => assert!(error.to_string().contains("ambient filesystem"), "{error}"),
|
||||
Ok(value) => panic!("ambient file access was accepted: {value:?}"),
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ impl LutFile {
|
||||
/// Parses a LUT, picking the format from `file_name`'s extension (falls back to sniffing).
|
||||
pub fn parse(file_name: &str, bytes: &[u8]) -> Result<LutFile, LutError> {
|
||||
let lower = file_name.to_ascii_lowercase();
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let text = decode_text(bytes);
|
||||
if lower.ends_with(".3dl") {
|
||||
parse_3dl(&text)
|
||||
} else if lower.ends_with(".look") || text.trim_start().starts_with('<') {
|
||||
@@ -100,6 +100,19 @@ pub fn parse(file_name: &str, bytes: &[u8]) -> Result<LutFile, LutError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// UTF-16 with a byte-order mark (Photoshop loads such `.look` files, #9118), else UTF-8.
|
||||
fn decode_text(bytes: &[u8]) -> std::borrow::Cow<'_, str> {
|
||||
let utf16 = |rest: &[u8], from: fn([u8; 2]) -> u16| {
|
||||
let units: Vec<u16> = rest.as_chunks::<2>().0.iter().map(|&c| from(c)).collect();
|
||||
String::from_utf16_lossy(&units).into()
|
||||
};
|
||||
match bytes {
|
||||
[0xFF, 0xFE, rest @ ..] => utf16(rest, u16::from_le_bytes),
|
||||
[0xFE, 0xFF, rest @ ..] => utf16(rest, u16::from_be_bytes),
|
||||
_ => String::from_utf8_lossy(bytes),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adobe / Resolve `.cube`. 1D LUTs (`LUT_1D_SIZE`) are expanded to a 33³ table. A Resolve file
|
||||
/// with both sizes holds a 1D shaper and then a 3D table; the shaper runs first and its output
|
||||
/// feeds the table, so the two are baked into one table ([`compose_shaper`]).
|
||||
@@ -227,12 +240,15 @@ fn compose_shaper(title: String, size1: usize, size3: usize, rows: &[f32], range
|
||||
}
|
||||
|
||||
/// Lustre `.3dl`: an optional shaper line of input values, then integer RGB rows with **blue**
|
||||
/// varying fastest; the output bit depth is inferred from the largest value.
|
||||
/// varying fastest; the output bit depth is inferred from the largest value, and is at least 12
|
||||
/// bits in a file without a header.
|
||||
pub fn parse_3dl(text: &str) -> Result<LutFile, LutError> {
|
||||
let mut rows: Vec<[f64; 3]> = Vec::new();
|
||||
let mut header = false;
|
||||
for line in text.lines() {
|
||||
let line = line.split('#').next().unwrap_or("").trim();
|
||||
if line.is_empty() || line.starts_with(|c: char| c.is_ascii_alphabetic()) {
|
||||
header |= !line.is_empty();
|
||||
continue;
|
||||
}
|
||||
let v: Vec<f64> = line.split_whitespace().filter_map(|s| s.parse().ok()).collect();
|
||||
@@ -246,7 +262,9 @@ pub fn parse_3dl(text: &str) -> Result<LutFile, LutError> {
|
||||
return err(format!("{} rows is not a cube", rows.len()));
|
||||
}
|
||||
let max = rows.iter().flatten().fold(0.0f64, |a, &b| a.max(b));
|
||||
let scale = if max <= 1.0 { 1.0 } else { [1023.0, 4095.0, 16383.0, 65535.0].into_iter().find(|&s| max <= s).unwrap_or(max) };
|
||||
// Photoshop 27.11 reads a headerless table peaking at 255 or 1023 on the 12-bit scale (#9121).
|
||||
let depths: &[f64] = if header { &[1023.0, 4095.0, 16383.0, 65535.0] } else { &[4095.0, 16383.0, 65535.0] };
|
||||
let scale = if max <= 1.0 { 1.0 } else { depths.iter().copied().find(|&s| max <= s).unwrap_or(max) };
|
||||
let mut data = vec![0.0f32; n * n * n * 3];
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let (r, g, b) = (i / (n * n), (i / n) % n, i % n);
|
||||
@@ -261,6 +279,15 @@ pub fn parse_3dl(text: &str) -> Result<LutFile, LutError> {
|
||||
/// SpeedGrade `.look`: `<size>` and a hex `<data>` string of little-endian float32 RGB triplets
|
||||
/// (red fastest).
|
||||
pub fn parse_look(text: &str) -> Result<LutFile, LutError> {
|
||||
// Comments may hold tag-like text, so drop them before looking for tags (#9109).
|
||||
let mut clean = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(i) = rest.find("<!--") {
|
||||
clean.push_str(&rest[..i]);
|
||||
rest = rest[i + 4..].find("-->").map_or("", |e| &rest[i + 4 + e + 3..]);
|
||||
}
|
||||
clean.push_str(rest);
|
||||
let text = clean.as_str();
|
||||
let tag = |name: &str| -> Option<String> {
|
||||
let open = format!("<{name}>");
|
||||
let start = text.find(&open)? + open.len();
|
||||
@@ -536,6 +563,25 @@ DOMAIN_MAX 0.5 1 1
|
||||
assert!(parse_cube(&text).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_dl_without_a_header_reads_low_nodes_as_12_bit() {
|
||||
let table = |peak: u32| -> String {
|
||||
let mut text = String::from("0 1023\n");
|
||||
for i in 0..8 {
|
||||
let on = |bit: u32| if i & bit != 0 { peak } else { 0 };
|
||||
text.push_str(&format!("{} {} {}\n", on(4), on(2), on(1)));
|
||||
}
|
||||
text
|
||||
};
|
||||
for peak in [255, 1023, 4095] {
|
||||
let white = parse_3dl(&table(peak)).unwrap().data[21..24].to_vec();
|
||||
assert_eq!(white, [peak as f32 / 4095.0; 3], "peak {peak}");
|
||||
}
|
||||
// A header keeps the inferred 10-bit depth.
|
||||
let white = parse_3dl(&format!("3DMESH\nMesh 1 10\n{}", table(1023))).unwrap().data[21..24].to_vec();
|
||||
assert_eq!(white, [1.0; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_dl_blue_fastest_and_bit_depth() {
|
||||
let mut text = String::from("0 1023\n");
|
||||
@@ -559,6 +605,29 @@ DOMAIN_MAX 0.5 1 1
|
||||
assert_eq!(parse("a.look", xml.as_bytes()).unwrap().data, id.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn look_reads_utf16_with_either_byte_order() {
|
||||
let id = LutFile::identity(2);
|
||||
let hex: String = id.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect();
|
||||
let xml = format!("<?xml version=\"1.0\" encoding=\"UTF-16\"?><look><LUT><size>\"2\"</size><data>\"{hex}\"</data></LUT></look>");
|
||||
let le: Vec<u8> = [0xFF, 0xFE].into_iter().chain(xml.encode_utf16().flat_map(u16::to_le_bytes)).collect();
|
||||
let be: Vec<u8> = [0xFE, 0xFF].into_iter().chain(xml.encode_utf16().flat_map(u16::to_be_bytes)).collect();
|
||||
assert_eq!(parse("a.look", &le).unwrap().data, id.data);
|
||||
assert_eq!(parse("a.look", &be).unwrap().data, id.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn look_ignores_tags_inside_xml_comments() {
|
||||
let hex = |l: &LutFile| -> String { l.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect() };
|
||||
let inverse = LutFile::from_fn("", 2, |[r, g, b]| [1.0 - r, 1.0 - g, 1.0 - b]);
|
||||
let xml = format!(
|
||||
"<look><!-- <size>\"3\"</size><data>\"{}\"</data> --><LUT><size>\"2\"</size><data>\"{}\"</data></LUT><!-- unterminated <data>00",
|
||||
hex(&LutFile::identity(2)),
|
||||
hex(&inverse)
|
||||
);
|
||||
assert_eq!(parse("commented.look", xml.as_bytes()).unwrap().data, inverse.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn look_rgb_and_rgba_allow_whitespace_in_hex_data() {
|
||||
let id = LutFile::identity(2);
|
||||
|
||||
+10
-5
@@ -49,7 +49,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
|
||||
| DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in |
|
||||
| Cineon | yes | yes | U16 (10-bit code values read and written exactly through U16) | Gray, RGB | no | no | no | no | no | no | no | built in |
|
||||
| DICOM | yes | **no** | U8, U16 | Gray, RGB | no | no | no | no | no | no | n/a | built in |
|
||||
| Amiga IFF (ILBM/PBM) | yes | **no** | U8 | RGB, RGBA | yes | no | no | no | no | no | n/a | built in |
|
||||
| Amiga IFF (ILBM/PBM) | yes | yes (24-bit ILBM) | U8 | RGB, RGBA | yes (1-bit mask plane on write) | no | no | no | no | no | no | built in |
|
||||
| AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` |
|
||||
| HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` |
|
||||
|
||||
@@ -134,12 +134,17 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
|
||||
interpretations return `Unsupported`/`Malformed`. There is no DICOM writer
|
||||
(`ASYMMETRIC_EXCEPTIONS`).
|
||||
|
||||
* **Amiga IFF (read-only).** `FORM ... ILBM` stores interleaved 1–8 bitplanes;
|
||||
* **Amiga IFF.** `FORM ... ILBM` stores interleaved 1–8 bitplanes;
|
||||
`FORM ... PBM ` stores 8-bit chunky palette indices with even-padded rows. Both support
|
||||
stored and ByteRun1 compression, a `CMAP` palette (grey ramp when absent, black for missing
|
||||
entries), and transparent-colour masking. ILBM also supports a separate mask plane. HAM/EHB
|
||||
viewport modes, PBM mask planes, other bit depths and compressions return
|
||||
`CodecError::Unsupported`. There is no IFF writer; save as PNG.
|
||||
entries), and transparent-color masking. ILBM also supports a separate mask plane and
|
||||
24-bitplane RGB ("deep" ILBM: 8 planes each of red, green and blue, least significant bit
|
||||
first, no palette). HAM/EHB viewport modes, PBM mask planes, a transparent color in a
|
||||
24-bitplane file, other bit depths and compressions return `CodecError::Unsupported`.
|
||||
Writing produces a 24-bitplane ILBM, ByteRun1 compressed one plane row at a time, at most
|
||||
65535 × 65535 pixels. An image with alpha gets a mask plane (masking 1): alpha of 128 or more
|
||||
is opaque, and `AlphaBinarized` is raised when any pixel is not fully opaque. Gray is
|
||||
expanded to RGB, and 16-bit and float images are reduced to 8 bits.
|
||||
|
||||
* **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused
|
||||
with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
//! Amiga IFF/ILBM reader (EA "Interchange File Format", "InterLeaved BitMap" and the chunky
|
||||
//! "PBM " variant). Written from the published IFF/ILBM layout: a `FORM` chunk of four-byte
|
||||
//! Amiga IFF/ILBM reader and writer (EA "Interchange File Format", "InterLeaved BitMap" and the
|
||||
//! chunky "PBM " variant). Written from the published IFF/ILBM layout: a `FORM` chunk of four-byte
|
||||
//! chunks (`BMHD` bitmap header, `CMAP` palette, `CAMG` Amiga viewport mode, `BODY` pixel data).
|
||||
//! Read-only: modern saves should use PNG.
|
||||
//!
|
||||
//! Supported: 1–8 bitplanes with a palette (or a grey ramp when there is none), masking 0/1/2,
|
||||
//! and both stored (0) and ByteRun1/PackBits (1) compression. Anything else is a clear
|
||||
//! `Unsupported`/`Malformed` error, never a panic.
|
||||
//! Read: 1–8 bitplanes with a palette (or a gray ramp when there is none) with masking 0/1/2, and
|
||||
//! 24-bitplane "deep" ILBMs (8 planes each of red, green and blue, least significant bit first,
|
||||
//! no palette) with masking 0/1, both stored (0) and ByteRun1/PackBits (1) compressed. Anything
|
||||
//! else is a clear `Unsupported`/`Malformed` error, never a panic.
|
||||
//!
|
||||
//! Write: a 24-bitplane ILBM, ByteRun1 compressed one plane row at a time, with a mask plane
|
||||
//! (masking 1) when the image has alpha. The mask plane is one bit per pixel: alpha of 128 or
|
||||
//! more is opaque.
|
||||
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::format::Format;
|
||||
use crate::image::{ChannelLayout, Image, SampleType};
|
||||
use crate::options::Limits;
|
||||
@@ -37,6 +42,9 @@ pub(crate) fn looks_like_iff(b: &[u8]) -> bool {
|
||||
b.get(0..4) == Some(b"FORM") && matches!(b.get(8..12), Some(b"ILBM" | b"PBM "))
|
||||
}
|
||||
|
||||
/// Bitplanes of a "deep" ILBM: red, green and blue, 8 each, least significant bit first.
|
||||
const DEEP_PLANES: u8 = 24;
|
||||
|
||||
/// The bitmap header fields decoding needs.
|
||||
struct Header {
|
||||
width: u32,
|
||||
@@ -88,12 +96,15 @@ fn parse_header(b: &[u8], chunky: bool) -> Result<Header, CodecError> {
|
||||
if width == 0 || height == 0 {
|
||||
return Err(bad("zero-sized bitmap"));
|
||||
}
|
||||
if !(1..=8).contains(&planes) {
|
||||
return Err(unsupported(format!("{planes} bitplanes (1–8, with a palette)")));
|
||||
if !(1..=8).contains(&planes) && planes != DEEP_PLANES {
|
||||
return Err(unsupported(format!("{planes} bitplanes (1–8 with a palette, or 24)")));
|
||||
}
|
||||
if masking > 2 {
|
||||
return Err(unsupported(format!("masking {masking}")));
|
||||
}
|
||||
if planes == DEEP_PLANES && masking == 2 {
|
||||
return Err(unsupported("a transparent color in a 24-bitplane ILBM"));
|
||||
}
|
||||
if compression > 1 {
|
||||
return Err(unsupported(format!("compression {compression} (only stored and ByteRun1)")));
|
||||
}
|
||||
@@ -201,7 +212,7 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
} else {
|
||||
(0..planes).filter(|&p| bit_at(&stored, (y * rows_planes + p) * plane_row, x)).fold(0usize, |acc, p| acc | (1 << p))
|
||||
};
|
||||
let c = palette(index);
|
||||
let c = if h.planes == DEEP_PLANES { [index as u8, (index >> 8) as u8, (index >> 16) as u8] } else { palette(index) };
|
||||
out.extend_from_slice(&c);
|
||||
if h.masking != 0 {
|
||||
let opaque = if h.masking == 1 { bit_at(&stored, (y * rows_planes + planes) * plane_row, x) } else { index != usize::from(h.transparent) };
|
||||
@@ -217,6 +228,104 @@ fn bit_at(stored: &[u8], base: usize, x: usize) -> bool {
|
||||
stored.get(base + x / 8).is_some_and(|byte| byte & (0x80 >> (x % 8)) != 0)
|
||||
}
|
||||
|
||||
/// ByteRun1 / PackBits encode of one plane row, appended to `out`: three or more equal bytes
|
||||
/// become a repeat (at most 128), everything else literal runs (at most 128 bytes).
|
||||
fn pack_byte_run1(row: &[u8], out: &mut Vec<u8>) {
|
||||
let mut i = 0usize;
|
||||
while let Some(&value) = row.get(i) {
|
||||
let repeat = row[i..].iter().take(128).take_while(|&&b| b == value).count();
|
||||
if repeat >= 3 {
|
||||
out.push((1 - repeat as i32) as i8 as u8);
|
||||
out.push(value);
|
||||
i += repeat;
|
||||
continue;
|
||||
}
|
||||
let start = i;
|
||||
while i < row.len() && i - start < 128 {
|
||||
if row.get(i..i + 3).is_some_and(|r| r[0] == r[1] && r[1] == r[2]) {
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out.push((i - start - 1) as u8);
|
||||
out.extend_from_slice(&row[start..i]);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_chunk(out: &mut Vec<u8>, id: &[u8; 4], data: &[u8]) -> Result<(), CodecError> {
|
||||
let len = u32::try_from(data.len()).map_err(|_| CodecError::encode(F, "chunk too large for an IFF file (4 GiB)"))?;
|
||||
out.extend_from_slice(id);
|
||||
out.extend_from_slice(&len.to_be_bytes());
|
||||
out.extend_from_slice(data);
|
||||
// Chunks are padded to an even length.
|
||||
if data.len() % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes a 24-bitplane `FORM ILBM`: `BMHD` then a ByteRun1-compressed `BODY`, each row stored as
|
||||
/// the red, green and blue planes (least significant bit first) and, for RGBA, a mask plane.
|
||||
pub(crate) fn encode(src: &Image, plan: Plan) -> Result<Vec<u8>, CodecError> {
|
||||
let enc = |msg: String| CodecError::encode(F, msg);
|
||||
let img = src.converted(plan.layout, plan.sample);
|
||||
let masked = match img.layout() {
|
||||
ChannelLayout::Rgb => false,
|
||||
ChannelLayout::Rgba => true,
|
||||
l => return Err(enc(format!("IFF/ILBM cannot store {l:?}"))),
|
||||
};
|
||||
if img.sample_type() != SampleType::U8 {
|
||||
return Err(enc(format!("IFF/ILBM cannot store {:?} samples", img.sample_type())));
|
||||
}
|
||||
let (w, h) = img.dimensions();
|
||||
let (Ok(w16), Ok(h16)) = (u16::try_from(w), u16::try_from(h)) else {
|
||||
return Err(enc("IFF/ILBM stores at most 65535 × 65535 pixels".into()));
|
||||
};
|
||||
let (w, ch) = (w as usize, img.layout().channels());
|
||||
let data = img.data();
|
||||
let row_len = w.checked_mul(ch).ok_or_else(|| enc("image too large".into()))?;
|
||||
if data.len() != row_len.checked_mul(h as usize).ok_or_else(|| enc("image too large".into()))? {
|
||||
return Err(enc("the image has the wrong number of samples".into()));
|
||||
}
|
||||
let planes = usize::from(DEEP_PLANES) + usize::from(masked);
|
||||
let mut plane_row = vec![0u8; w.div_ceil(16) * 2];
|
||||
let mut body = Vec::new();
|
||||
for px in data.chunks_exact(row_len.max(1)) {
|
||||
for plane in 0..planes {
|
||||
plane_row.fill(0);
|
||||
for (x, p) in px.chunks_exact(ch).enumerate() {
|
||||
let on = if plane == usize::from(DEEP_PLANES) { p[3] >= 128 } else { (p[plane / 8] >> (plane % 8)) & 1 == 1 };
|
||||
if on {
|
||||
plane_row[x / 8] |= 0x80 >> (x % 8);
|
||||
}
|
||||
}
|
||||
pack_byte_run1(&plane_row, &mut body);
|
||||
}
|
||||
}
|
||||
|
||||
let mut bmhd = Vec::with_capacity(20);
|
||||
bmhd.extend_from_slice(&w16.to_be_bytes());
|
||||
bmhd.extend_from_slice(&h16.to_be_bytes());
|
||||
bmhd.extend_from_slice(&[0, 0, 0, 0]); // x/y origin
|
||||
bmhd.push(DEEP_PLANES);
|
||||
bmhd.push(u8::from(masked)); // masking: 1 = a mask plane follows the color planes
|
||||
bmhd.push(1); // ByteRun1
|
||||
bmhd.push(0); // pad
|
||||
bmhd.extend_from_slice(&[0, 0]); // transparent color (unused)
|
||||
bmhd.extend_from_slice(&[1, 1]); // square pixels
|
||||
bmhd.extend_from_slice(&w16.to_be_bytes()); // page size
|
||||
bmhd.extend_from_slice(&h16.to_be_bytes());
|
||||
|
||||
let mut out = b"FORM".to_vec();
|
||||
out.extend_from_slice(&[0; 4]); // patched below
|
||||
out.extend_from_slice(b"ILBM");
|
||||
push_chunk(&mut out, b"BMHD", &bmhd)?;
|
||||
push_chunk(&mut out, b"BODY", &body)?;
|
||||
let form_len = u32::try_from(out.len() - 8).map_err(|_| enc("image too large for an IFF file (4 GiB)".into()))?;
|
||||
out[4..8].copy_from_slice(&form_len.to_be_bytes());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -339,6 +448,87 @@ mod tests {
|
||||
assert_eq!(decode(&b, &Limits::default()).unwrap().data(), &[0, 0, 0]);
|
||||
}
|
||||
|
||||
fn plan(layout: ChannelLayout) -> Plan {
|
||||
Plan { layout, sample: SampleType::U8 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_stored_24_plane_bitmap() {
|
||||
// 2×1: pixel 0 = (1, 0, 128), pixel 1 = (0, 255, 0). Each plane row is 2 bytes; red bit 0
|
||||
// has pixel 0 set, green bits 0–7 have pixel 1 set, blue bit 7 has pixel 0 set.
|
||||
let mut body = Vec::new();
|
||||
for plane in 0..24 {
|
||||
let byte = match plane {
|
||||
0 | 23 => 0x80,
|
||||
8..=15 => 0x40,
|
||||
_ => 0,
|
||||
};
|
||||
body.extend_from_slice(&[byte, 0]);
|
||||
}
|
||||
let b = ilbm(2, 1, 24, &[], 0, &body);
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!((img.width(), img.height(), img.layout()), (2, 1, ChannelLayout::Rgb));
|
||||
assert_eq!(img.data(), &[1, 0, 128, 0, 255, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_24_plane_byte_run1_that_reads_back_exactly() {
|
||||
// 37 pixels wide: three plane-row words, the last one partly padding.
|
||||
let (w, h) = (37u32, 3u32);
|
||||
let data: Vec<u8> = (0..w * h * 3).map(|i| (i * 7 % 256) as u8).collect();
|
||||
let img = Image::from_u8(w, h, ChannelLayout::Rgb, data.clone()).unwrap();
|
||||
let bytes = encode(&img, plan(ChannelLayout::Rgb)).unwrap();
|
||||
assert!(looks_like_iff(&bytes));
|
||||
assert_eq!(be32(&bytes, 4).unwrap() as usize, bytes.len() - 8, "FORM length");
|
||||
let h = parse_header(&bytes, false).unwrap();
|
||||
assert_eq!((h.width, h.height, h.planes, h.masking, h.compression), (37, 3, 24, 0, 1));
|
||||
assert!(chunks(&bytes).all(|c| &c.id != b"CMAP"), "a deep ILBM has no palette");
|
||||
let back = decode(&bytes, &Limits::default()).unwrap();
|
||||
assert_eq!(back.layout(), ChannelLayout::Rgb);
|
||||
assert_eq!(back.data(), &data[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_is_written_as_a_mask_plane() {
|
||||
let data = [10, 20, 30, 0, 40, 50, 60, 127, 70, 80, 90, 128, 1, 2, 3, 255];
|
||||
let img = Image::from_u8(4, 1, ChannelLayout::Rgba, data.to_vec()).unwrap();
|
||||
let bytes = encode(&img, plan(ChannelLayout::Rgba)).unwrap();
|
||||
assert_eq!(parse_header(&bytes, false).unwrap().masking, 1);
|
||||
let back = decode(&bytes, &Limits::default()).unwrap();
|
||||
assert_eq!(back.layout(), ChannelLayout::Rgba);
|
||||
assert_eq!(back.data(), &[10, 20, 30, 0, 40, 50, 60, 0, 70, 80, 90, 255, 1, 2, 3, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_run1_round_trips_runs_literals_and_long_rows() {
|
||||
let mut rows: Vec<Vec<u8>> = vec![vec![], vec![5], vec![5, 5], vec![5, 5, 5], vec![1, 2, 2, 2, 3], vec![9; 300]];
|
||||
rows.push((0..300).map(|i| (i % 251) as u8).collect());
|
||||
rows.push([vec![1, 2], vec![7; 129], vec![3]].concat());
|
||||
for row in rows {
|
||||
let mut packed = Vec::new();
|
||||
pack_byte_run1(&row, &mut packed);
|
||||
let mut back = vec![0; row.len()];
|
||||
assert_eq!(unpack_byte_run1(&packed, &mut back).unwrap(), packed.len(), "{row:?}");
|
||||
assert_eq!(back, row);
|
||||
}
|
||||
// A long repeat packs into two-byte runs.
|
||||
let mut packed = Vec::new();
|
||||
pack_byte_run1(&[0; 256], &mut packed);
|
||||
assert_eq!(packed, [0x81, 0, 0x81, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_what_ilbm_cannot_store() {
|
||||
let gray = Image::from_u8(1, 1, ChannelLayout::Gray, vec![0]).unwrap();
|
||||
assert!(encode(&gray, plan(ChannelLayout::Gray)).is_err());
|
||||
let wide = Image::from_u8(65536, 1, ChannelLayout::Rgb, vec![0; 65536 * 3]).unwrap();
|
||||
assert!(encode(&wide, plan(ChannelLayout::Rgb)).is_err());
|
||||
// A transparent color index has no meaning without a palette.
|
||||
let mut b = ilbm(1, 1, 24, &[], 0, &[0; 48]);
|
||||
b[29] = 2;
|
||||
assert!(matches!(decode(&b, &Limits::default()), Err(CodecError::Unsupported { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_errors_not_panics() {
|
||||
// Truncations of a valid file never panic.
|
||||
|
||||
@@ -31,7 +31,7 @@ pub enum FidelityWarning {
|
||||
RangeClipped,
|
||||
/// Non-opaque alpha will be discarded.
|
||||
AlphaDiscarded,
|
||||
/// Alpha will be reduced to fully transparent / fully opaque (GIF).
|
||||
/// Alpha will be reduced to fully transparent / fully opaque (GIF, DDS BC1, IFF).
|
||||
AlphaBinarized,
|
||||
/// CMYK data will be converted (naively, not colour-managed) to another model.
|
||||
CmykConverted {
|
||||
@@ -227,7 +227,8 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
|
||||
if sl.has_alpha() && !p.layout.has_alpha() && image.has_translucency() {
|
||||
w.push(W::AlphaDiscarded);
|
||||
}
|
||||
let binary_alpha = format == Format::Gif || (format == Format::Dds && opts.dds.compression == DdsCompression::Bc1);
|
||||
// An IFF/ILBM mask plane holds one bit per pixel.
|
||||
let binary_alpha = matches!(format, Format::Gif | Format::Iff) || (format == Format::Dds && opts.dds.compression == DdsCompression::Bc1);
|
||||
if binary_alpha && p.layout.has_alpha() && image.has_translucency() {
|
||||
w.push(W::AlphaBinarized);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ pub enum Format {
|
||||
Cineon,
|
||||
/// DICOM single-frame images (uncompressed): read-only.
|
||||
Dicom,
|
||||
/// Amiga IFF/ILBM (`FORM ... ILBM`): planar bitplanes with a palette, read-only.
|
||||
/// Amiga IFF/ILBM (`FORM ... ILBM`): planar bitplanes with a palette or 24-bit RGB.
|
||||
Iff,
|
||||
}
|
||||
|
||||
@@ -72,7 +72,6 @@ pub struct FormatCaps {
|
||||
/// The test-suite asserts that every other enabled format is read+write.
|
||||
pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
|
||||
(Format::Xpm, "XPM is a legacy Unix icon interchange format. Reading existing XPM icons is supported; writing modern image assets should use PNG instead."),
|
||||
(Format::Iff, "Amiga IFF/ILBM is supported for opening legacy Amiga artwork; this crate has no IFF writer, so save as PNG instead."),
|
||||
(
|
||||
Format::Dicom,
|
||||
"DICOM single-frame images are imported for medical review; this crate has no DICOM writer, so save an imported frame as TIFF or PNG instead.",
|
||||
@@ -226,7 +225,7 @@ impl Format {
|
||||
/// Maximum encodable dimensions, if the format has a hard limit.
|
||||
pub fn max_dimensions(self) -> Option<(u32, u32)> {
|
||||
match self {
|
||||
Format::Jpeg | Format::Gif => Some((65535, 65535)),
|
||||
Format::Jpeg | Format::Gif | Format::Iff => Some((65535, 65535)),
|
||||
Format::WebP => Some((16384, 16384)),
|
||||
Format::Ico => Some((256, 256)),
|
||||
_ => None,
|
||||
@@ -279,7 +278,7 @@ pub fn caps(format: Format) -> FormatCaps {
|
||||
Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base },
|
||||
Format::Cineon => FormatCaps { depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base },
|
||||
Format::Dicom => FormatCaps { read: true, write: false, depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base },
|
||||
Format::Iff => FormatCaps { read: true, write: false, depths: &[S::U8], layouts: &[L::Rgb, L::Rgba], alpha: true, ..base },
|
||||
Format::Iff => FormatCaps { depths: &[S::U8], layouts: &[L::Rgb, L::Rgba], alpha: true, ..base },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -303,7 +303,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
|
||||
Format::Dpx => dpx::encode(image, plan, opts),
|
||||
Format::Cineon => dpx::encode_cineon(image, plan),
|
||||
Format::Dicom => Err(CodecError::unsupported(format, "DICOM is import-only; save as TIFF or PNG instead")),
|
||||
Format::Iff => Err(CodecError::unsupported(format, "IFF/ILBM is import-only; save as PNG instead")),
|
||||
Format::Iff => iff::encode(image, plan),
|
||||
Format::OpenExr => exr::encode(image, plan, opts),
|
||||
Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")),
|
||||
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts),
|
||||
|
||||
@@ -61,6 +61,7 @@ fn default_build_formats_all_symmetric() {
|
||||
Format::Fits,
|
||||
Format::Dpx,
|
||||
Format::Cineon,
|
||||
Format::Iff,
|
||||
];
|
||||
for f in expected {
|
||||
assert!(caps(f).read && caps(f).write, "{f:?}");
|
||||
@@ -208,10 +209,11 @@ fn detect_magic_iff() {
|
||||
assert_eq!(detect(b"FORM\0\0\0\x14PBM "), Some(Format::Iff));
|
||||
assert_eq!(from_extension("art.LBM"), Some(Format::Iff));
|
||||
assert_eq!(from_extension("art.ilbm"), Some(Format::Iff));
|
||||
assert!(caps(Format::Iff).read && !caps(Format::Iff).write, "IFF is read-only");
|
||||
assert!(caps(Format::Iff).read && caps(Format::Iff).write, "IFF is read and written");
|
||||
assert!(caps(Format::Iff).alpha);
|
||||
assert!(caps(Format::Iff).layouts.contains(&photocraft_codecs::ChannelLayout::Rgba));
|
||||
assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Iff));
|
||||
assert!(!ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Iff));
|
||||
assert_eq!(Format::Iff.max_dimensions(), Some((65535, 65535)));
|
||||
// A bare FORM without a bitmap type is not IFF.
|
||||
assert_eq!(detect(b"FORM\0\0\0\x14AIFF"), None);
|
||||
assert!(decode_as(Format::Iff, b"FORM\0\0\0\x14ILBM").is_err());
|
||||
|
||||
@@ -156,6 +156,13 @@ fn gif_quantizes_and_binarizes() {
|
||||
assert!(!w.contains(&W::LossyCompression));
|
||||
}
|
||||
#[test]
|
||||
fn iff_binarizes_alpha_and_reduces_16_bit() {
|
||||
assert_eq!(warns(&test_image(ChannelLayout::Rgba, SampleType::U8), Format::Iff), vec![W::AlphaBinarized]);
|
||||
let w = warns(&test_image(ChannelLayout::Rgb, SampleType::U16), Format::Iff);
|
||||
assert!(w.contains(&W::DepthReduced { from: SampleType::U16, to: SampleType::U8 }));
|
||||
assert!(warns(&test_image(ChannelLayout::Rgb, SampleType::U8), Format::Iff).is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn hdr_is_lossy() {
|
||||
assert!(warns(&test_image(ChannelLayout::Rgb, SampleType::F32), Format::Hdr).contains(&W::LossyCompression));
|
||||
}
|
||||
|
||||
@@ -233,6 +233,23 @@ fn next_artboard_name(doc: &Document) -> String {
|
||||
(1..=names.len() + 1).map(|n| format!("Artboard {n}")).find(|n| !names.contains(&n.as_str())).unwrap_or_else(|| "Artboard".into())
|
||||
}
|
||||
|
||||
/// File › New with Artboards (#9086): one board over the whole canvas, its background the
|
||||
/// document's (white, black, a colour or transparent), holding an empty Layer 1. As in Photoshop,
|
||||
/// there is no Background layer.
|
||||
pub fn new_artboard_document(name: String, size: Size, mode: ColorMode, depth: SampleType, background: Option<Color>) -> Document {
|
||||
let mut doc = Document::new(name, size, mode, depth);
|
||||
let background = match background {
|
||||
None => ArtboardBackground::Transparent,
|
||||
Some(c) if c == Color::WHITE => ArtboardBackground::White,
|
||||
Some(c) if c == Color::BLACK => ArtboardBackground::Black,
|
||||
Some(c) => ArtboardBackground::Custom(c),
|
||||
};
|
||||
let mut board = Layer::group("Artboard 1", vec![Layer::raster("Layer 1", doc.pixel_format())]);
|
||||
set_artboard(&mut board, Artboard { rect: doc.bounds(), background, preset: String::new() });
|
||||
doc.layers.push(board);
|
||||
doc
|
||||
}
|
||||
|
||||
fn set_artboard(l: &mut Layer, a: Artboard) {
|
||||
if let LayerContent::Group(g) = &mut l.content {
|
||||
g.artboard = Some(a);
|
||||
|
||||
@@ -661,3 +661,32 @@ fn add_beside_duplicate_honors_panel_copy_name_preference() {
|
||||
assert_eq!(doc(&s), &before);
|
||||
}
|
||||
}
|
||||
|
||||
/// #9086: File › New with Artboards makes one board over the canvas, its background the chosen
|
||||
/// one, holding an empty Layer 1, and no Background layer.
|
||||
#[test]
|
||||
fn new_document_with_an_artboard() {
|
||||
for (background, want) in [
|
||||
("white", ArtboardBackground::White),
|
||||
("black", ArtboardBackground::Black),
|
||||
("transparent", ArtboardBackground::Transparent),
|
||||
("#336699", ArtboardBackground::Custom(Color::rgba(0x33 as f32 / 255.0, 0x66 as f32 / 255.0, 0x99 as f32 / 255.0, 1.0))),
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 120, "height": 90, "background": background, "artboard": true})).unwrap();
|
||||
let d = doc(&s);
|
||||
assert_eq!(d.layers.len(), 1, "{background}: only the board");
|
||||
let boards = d.artboards();
|
||||
let [(_, _, board)] = boards.as_slice() else { panic!("{background}: one artboard, got {}", boards.len()) };
|
||||
assert_eq!(board.rect, d.bounds());
|
||||
assert_eq!(board.background, want, "{background}");
|
||||
let LayerContent::Group(g) = &d.layers[0].content else { panic!("not a group") };
|
||||
assert_eq!(g.children.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Layer 1"]);
|
||||
assert!(!d.layers.iter().any(|l| d.is_background(l.id)), "{background}: no Background layer");
|
||||
}
|
||||
// Without the option nothing changes; a non-boolean is refused.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 20, "height": 10, "artboard": false})).unwrap();
|
||||
assert!(doc(&s).artboards().is_empty());
|
||||
assert!(s.execute("file.new", json!({"artboard": "yes"})).is_err());
|
||||
}
|
||||
|
||||
@@ -3948,9 +3948,9 @@ pub struct Skipped {
|
||||
fn export_item(id: &str, params: &Value, set: &str) -> Option<AtnItem> {
|
||||
if id == PHOTOSHOP_STEP {
|
||||
let event = jstr(params, "event")?;
|
||||
let event = match jstr(params, "eventKind") {
|
||||
Some("code") => AtnEvent::Code(event.as_bytes().try_into().ok()?),
|
||||
Some("name") => AtnEvent::name(event),
|
||||
let event = match params.get("eventKind") {
|
||||
Some(Value::String(kind)) if kind == "code" => AtnEvent::Code(event.as_bytes().try_into().ok()?),
|
||||
Some(Value::String(kind)) if kind == "name" => AtnEvent::name(event),
|
||||
Some(_) => return None,
|
||||
// Older PhotoCraft action data did not retain the event kind. Keep its previous
|
||||
// best-effort export behavior for compatibility.
|
||||
|
||||
@@ -986,6 +986,12 @@ fn save_preserves_the_kind_of_unsupported_atn_events() {
|
||||
round_trip.actions[0].items.iter().map(|item| item.event.clone()).collect::<Vec<_>>(),
|
||||
[AtnEvent::Name(b"make".to_vec()), AtnEvent::Code(*b"Qwer")]
|
||||
);
|
||||
|
||||
// Legacy steps may omit eventKind, but an explicitly malformed value must not be treated
|
||||
// as if it were absent.
|
||||
assert!(export_item(PHOTOSHOP_STEP, &json!({"event": "make", "eventKind": null}), "S").is_none());
|
||||
assert!(export_item(PHOTOSHOP_STEP, &json!({"event": "make", "eventKind": 4}), "S").is_none());
|
||||
assert_eq!(export_item(PHOTOSHOP_STEP, &json!({"event": "make"}), "S").unwrap().event, AtnEvent::Code(*b"make"));
|
||||
}
|
||||
|
||||
/// #6745: the events of a Photoshop 2026 retouching set (ID-photo background swap, whitening,
|
||||
|
||||
@@ -137,6 +137,9 @@ pub(crate) fn transform_layer_geometry(l: &mut Layer, a: &Affine, content: bool,
|
||||
if let Some(f) = &mut sh.fill {
|
||||
map_fill(a, f);
|
||||
}
|
||||
if let Some(st) = &mut sh.stroke {
|
||||
map_fill(a, &mut st.paint);
|
||||
}
|
||||
if content {
|
||||
crate::vector_cmds::transform_shape(sh, a);
|
||||
}
|
||||
|
||||
@@ -221,6 +221,38 @@ fn gradient_fill_offset_stays_a_fraction_of_the_frame() {
|
||||
assert!((offset.0 - 0.1).abs() < 1e-6 && (offset.1 - 0.25).abs() < 1e-6, "{offset:?}");
|
||||
}
|
||||
|
||||
/// The shape's (fill, stroke) gradient angles after `cmd` on a shape whose fill and stroke both
|
||||
/// start as a 0° (left to right) gradient.
|
||||
fn shape_gradient_angles_after(cmd: &str, p: serde_json::Value) -> (f32, f32) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": W, "height": H})).unwrap();
|
||||
let g = json!({"gradient": {"stops": [[0, "#ff0000"], [1, "#0000ff"]], "angle": 0}});
|
||||
s.execute("shape.create", json!({"kind": "rect", "rect": [10, 8, 30, 20], "fill": g, "stroke": {"width": 6, "fill": g}})).unwrap();
|
||||
s.execute(cmd, p).unwrap();
|
||||
let angle = |f: &Fill| if let Fill::Gradient { angle, .. } = f { *angle } else { panic!("not a gradient: {f:?}") };
|
||||
let sh = doc(&s).walk().into_iter().find_map(|(_, _, l)| if let LayerContent::Shape(sh) = &l.content { Some(sh.clone()) } else { None }).unwrap();
|
||||
(angle(sh.fill.as_ref().unwrap()), angle(&sh.stroke.as_ref().unwrap().paint))
|
||||
}
|
||||
|
||||
/// A gradient stroke turns and mirrors with the canvas like the fill does (#9239).
|
||||
#[test]
|
||||
fn shape_gradient_stroke_turns_with_the_canvas() {
|
||||
let same = |a: f32, b: f32| {
|
||||
let d = (a - b).rem_euclid(360.0);
|
||||
d < 1e-3 || d > 360.0 - 1e-3
|
||||
};
|
||||
for (cmd, p, want) in [
|
||||
("image.imageRotation.90cw", json!({}), -90.0),
|
||||
("image.imageRotation.flipCanvasHorizontal", json!({}), 180.0),
|
||||
("image.imageRotation.flipCanvasVertical", json!({}), 0.0),
|
||||
("image.rotation.arbitrary", json!({"angle": 30, "direction": "cw"}), -30.0),
|
||||
] {
|
||||
let (fill, stroke) = shape_gradient_angles_after(cmd, p);
|
||||
assert!(same(fill, want), "{cmd}: fill {fill}, want {want}");
|
||||
assert!(same(stroke, want), "{cmd}: stroke {stroke}, want {want}");
|
||||
}
|
||||
}
|
||||
|
||||
fn type_and_shape_origin(d: &Document) -> ([f64; 6], [f64; 2]) {
|
||||
let mut t = None;
|
||||
let mut k = None;
|
||||
|
||||
@@ -74,7 +74,7 @@ pub(crate) fn copy(s: &mut Session) -> Result<Option<Value>> {
|
||||
if bounds.is_empty() {
|
||||
return Err(EngineError::Other("Could not copy: the selected area is empty".into()));
|
||||
}
|
||||
s.clipboard = Some(Clip { surface, bounds, layers: None, resolution: Some(doc.resolution_dpi) });
|
||||
s.clipboard = Some(Clip { surface, bounds, layers: None, resolution: Some(doc.resolution_dpi), source: Some(doc.id) });
|
||||
Ok(Some(json!({"bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()], "channel": k})))
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit:
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let canvas = d.doc.bounds();
|
||||
let id = d.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
let (dx, dy) = crate::edit_cmds::paste_offset(&clip, canvas, p, in_place);
|
||||
let (dx, dy) = crate::edit_cmds::paste_offset(&clip, d.doc.id, canvas, p, in_place);
|
||||
let src = crate::edit_cmds::shifted(&clip.surface, dx, dy);
|
||||
let area = src.content_bounds().intersect(&canvas);
|
||||
s.edit(label, |doc, _| {
|
||||
|
||||
@@ -547,7 +547,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
"New…",
|
||||
["File"],
|
||||
Some("Cmd+N"),
|
||||
r##"{"width":1..300000=1920,"height":1..300000=1080 (pixel counts are rounded),"mode":"rgb|gray|grayscale|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|backgroundColor|transparent|#rrggbb"="white","backgroundColor":[r,g,b]? (0..1; defaults to toolbox colour),"resolution":1..30000=72 (ppi),"name":str,"profile":"working"|built-in id|path to an .icc file="working" (the colour profile it is tagged with; it must suit the mode)}"##,
|
||||
r##"{"width":1..300000=1920,"height":1..300000=1080 (pixel counts are rounded),"mode":"rgb|gray|grayscale|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|backgroundColor|transparent|#rrggbb"="white","backgroundColor":[r,g,b]? (0..1; defaults to toolbox colour),"resolution":1..30000=72 (ppi),"name":str,"artboard":bool=false (one artboard over the canvas, its background the chosen one, holding an empty Layer 1; no Background layer),"profile":"working"|built-in id|path to an .icc file="working" (the colour profile it is tagged with; it must suit the mode)}"##,
|
||||
always,
|
||||
|s, p| {
|
||||
use crate::document_preset_cmds::{MAX_DIMENSION, MAX_RESOLUTION, background_color, color_mode, sample_type};
|
||||
@@ -566,22 +566,28 @@ fn build() -> Vec<CommandSpec> {
|
||||
let depth = sample_type(p.get("depth").and_then(Value::as_u64).unwrap_or(8)).unwrap_or(SampleType::U8);
|
||||
let name = p.get("name").and_then(Value::as_str).unwrap_or("Untitled").to_string();
|
||||
let res = p.get("resolution").and_then(Value::as_f64).unwrap_or(72.0).clamp(1.0, MAX_RESOLUTION) as f32;
|
||||
let mut doc = match p.get("background").and_then(Value::as_str).unwrap_or("white") {
|
||||
// The background colour; `None` for Transparent.
|
||||
let background = match p.get("background").and_then(Value::as_str).unwrap_or("white") {
|
||||
"backgroundColor" => {
|
||||
let bgc = background_color(p, s.tools.background, "file.new")?;
|
||||
Document::with_background(name, Size::new(w, h), mode, depth, Color::rgba(bgc[0], bgc[1], bgc[2], 1.0))
|
||||
Some(Color::rgba(bgc[0], bgc[1], bgc[2], 1.0))
|
||||
}
|
||||
"transparent" => {
|
||||
let mut d = Document::new(name, Size::new(w, h), mode, depth);
|
||||
d.layers.push(Layer::raster("Layer 1", d.pixel_format()));
|
||||
d
|
||||
}
|
||||
"black" => Document::with_background(name, Size::new(w, h), mode, depth, Color::BLACK),
|
||||
"transparent" => None,
|
||||
"black" => Some(Color::BLACK),
|
||||
other => {
|
||||
let c = parse_hex(other).unwrap_or([1.0; 4]);
|
||||
Document::with_background(name, Size::new(w, h), mode, depth, Color::rgba(c[0], c[1], c[2], c[3]))
|
||||
Some(Color::rgba(c[0], c[1], c[2], c[3]))
|
||||
}
|
||||
};
|
||||
let mut doc = if p.get("artboard").and_then(Value::as_bool).unwrap_or(false) {
|
||||
crate::artboard_cmds::new_artboard_document(name, Size::new(w, h), mode, depth, background)
|
||||
} else if let Some(c) = background {
|
||||
Document::with_background(name, Size::new(w, h), mode, depth, c)
|
||||
} else {
|
||||
let mut d = Document::new(name, Size::new(w, h), mode, depth);
|
||||
d.layers.push(Layer::raster("Layer 1", d.pixel_format()));
|
||||
d
|
||||
};
|
||||
doc.resolution_dpi = res;
|
||||
// Photoshop's New Document › Color Profile: tag the document with another profile
|
||||
// than the working space (#321). Checked before the document opens.
|
||||
|
||||
@@ -78,6 +78,7 @@ pub(crate) fn validate_new_params(s: &Session, p: &Value) -> Result<()> {
|
||||
continue;
|
||||
}
|
||||
"name" => (value.is_string(), "a string".into()),
|
||||
"artboard" => (value.is_boolean(), "true or false".into()),
|
||||
"profile" => (value.as_str().is_some_and(|v| !v.is_empty()), "working, a built-in profile id or an .icc path".into()),
|
||||
_ => return Err(bad(cmd, format!("unknown parameter `{key}`"))),
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use photocraft_color::{ColorMode, PixelFormat};
|
||||
use photocraft_doc::adjust::LevelsChannel;
|
||||
use photocraft_doc::{Adjustment, Document, Layer, LayerContent, LayerId};
|
||||
use photocraft_doc::{Adjustment, DocId, Document, Layer, LayerContent, LayerId};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
use serde_json::{Value, json};
|
||||
@@ -31,6 +31,9 @@ pub struct Clip {
|
||||
/// The source document's resolution (ppi): File › New from the clipboard takes it, as
|
||||
/// Photoshop's Clipboard preset does. None for an image from another app.
|
||||
pub resolution: Option<f32>,
|
||||
/// The document the pixels were copied from: Paste keeps them in place only there, and
|
||||
/// centres them in any other (#8945). None for an image from another app.
|
||||
pub source: Option<DocId>,
|
||||
}
|
||||
|
||||
/// Photoshop's layer-based copy (CC 2018 on): with no selection, Copy takes the active layer
|
||||
@@ -195,7 +198,7 @@ fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Result<Clip> {
|
||||
};
|
||||
let mut out = Surface::new(with_alpha);
|
||||
if area.is_empty() {
|
||||
return Ok(Clip { surface: out, bounds: Rect::EMPTY, layers: None, resolution: None });
|
||||
return Ok(Clip { surface: out, bounds: Rect::EMPTY, layers: None, resolution: None, source: None });
|
||||
}
|
||||
// Only the selected area is read: converting the whole layer (a 24 MP Background gaining
|
||||
// alpha) cost about a second per Layer via Copy (#668).
|
||||
@@ -217,7 +220,7 @@ fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Result<Clip> {
|
||||
out.put_tiles(shared);
|
||||
out.prune();
|
||||
let bounds = out.content_bounds();
|
||||
Ok(Clip { surface: out, bounds, layers: None, resolution: None })
|
||||
Ok(Clip { surface: out, bounds, layers: None, resolution: None, source: None })
|
||||
}
|
||||
|
||||
/// [`lift`] over one rectangle: `src`'s pixels with alpha (`n` channels), the selection
|
||||
@@ -299,7 +302,7 @@ fn copy(s: &mut Session, merged: bool) -> Result<Value> {
|
||||
return Err(EngineError::Other("Could not copy: the selected area is empty".into()));
|
||||
}
|
||||
let b = clip.bounds;
|
||||
let clip = Clip { resolution: Some(d.doc.resolution_dpi), ..clip };
|
||||
let clip = Clip { resolution: Some(d.doc.resolution_dpi), source: Some(d.doc.id), ..clip };
|
||||
s.clipboard = Some(clip);
|
||||
Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()]}))
|
||||
}
|
||||
@@ -345,8 +348,8 @@ fn copy_mask(s: &mut Session, filter: bool) -> Result<Value> {
|
||||
return Err(EngineError::Other("Could not copy: the selected area is empty".into()));
|
||||
}
|
||||
let b = bounds;
|
||||
let resolution = Some(d.doc.resolution_dpi);
|
||||
s.clipboard = Some(Clip { surface, bounds: b, layers: None, resolution });
|
||||
let (resolution, source) = (Some(d.doc.resolution_dpi), Some(d.doc.id));
|
||||
s.clipboard = Some(Clip { surface, bounds: b, layers: None, resolution, source });
|
||||
Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()], "target": if filter { "filterMask" } else { "mask" }}))
|
||||
}
|
||||
|
||||
@@ -390,7 +393,7 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result<Value> {
|
||||
}
|
||||
let canvas = d.doc.bounds();
|
||||
let fmt = d.doc.pixel_format();
|
||||
let (dx, dy) = paste_offset(&clip, canvas, p, in_place);
|
||||
let (dx, dy) = paste_offset(&clip, d.doc.id, canvas, p, in_place);
|
||||
if let Some(layers) = clip.layers.as_deref().filter(|_| crate::layer_copy_cmds::layered(d.doc.mode)) {
|
||||
return paste_layers(s, layers, dx, dy);
|
||||
}
|
||||
@@ -456,8 +459,9 @@ fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32) -> Result<
|
||||
/// canvas centre, then kept on the canvas as Photoshop keeps it: pixels that fit stay wholly
|
||||
/// inside, larger ones start at the canvas's top-left corner (#6759). `"clampToCanvas": false`
|
||||
/// skips that, for Paste Into / Outside, which centre on the selection.
|
||||
pub(crate) fn paste_offset(clip: &Clip, canvas: Rect, p: &Value, in_place: bool) -> (i32, i32) {
|
||||
if in_place || (clip.bounds.intersect(&canvas) == clip.bounds && p.get("center").is_none()) {
|
||||
pub(crate) fn paste_offset(clip: &Clip, doc: DocId, canvas: Rect, p: &Value, in_place: bool) -> (i32, i32) {
|
||||
let home = clip.source == Some(doc);
|
||||
if in_place || (home && clip.bounds.intersect(&canvas) == clip.bounds && p.get("center").is_none()) {
|
||||
return (0, 0);
|
||||
}
|
||||
let c = p.get("center").and_then(Value::as_array).filter(|a| a.len() >= 2).map(|a| (a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0)));
|
||||
@@ -498,7 +502,7 @@ pub(crate) fn paste_to_target(s: &mut Session, p: &Value, in_place: bool, limit:
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let canvas = d.doc.bounds();
|
||||
let layer = d.active_layer;
|
||||
let (dx, dy) = paste_offset(&clip, canvas, p, in_place);
|
||||
let (dx, dy) = paste_offset(&clip, d.doc.id, canvas, p, in_place);
|
||||
let src = shifted(&clip.surface, dx, dy);
|
||||
let area = src.content_bounds().intersect(&canvas);
|
||||
s.edit(label, |doc, _| {
|
||||
@@ -1124,7 +1128,7 @@ mod tests {
|
||||
let mut s = Session::new();
|
||||
let mut surface = Surface::new(PixelFormat::RGBA8);
|
||||
surface.write_pixel(i32::MIN, 0, &[1.0, 0.0, 0.0, 1.0]);
|
||||
s.clipboard = Some(Clip { surface, bounds: Rect::from_xywh(i32::MIN, 0, 1, 1), layers: None, resolution: None });
|
||||
s.clipboard = Some(Clip { surface, bounds: Rect::from_xywh(i32::MIN, 0, 1, 1), layers: None, resolution: None, source: None });
|
||||
let result = new_from_clipboard(&mut s);
|
||||
assert!(result.unwrap_err().to_string().contains("clipboard origin"));
|
||||
assert!(s.execute("file.newFromClipboard", json!({})).is_err());
|
||||
@@ -1132,15 +1136,38 @@ mod tests {
|
||||
assert_eq!(s.clipboard.as_ref().unwrap().surface.rgba(i32::MIN, 0), [1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
/// #8945: pixels copied in one document land in place when pasted back into it, and centred
|
||||
/// in another, even where their old position would fit (Photoshop's Paste).
|
||||
#[test]
|
||||
fn paste_centres_pixels_from_another_document() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
|
||||
s.execute("select.all", json!({})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let r = s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(r["offset"], json!([0, 0]), "back into its own document: in place");
|
||||
s.execute("file.new", json!({"width": 100, "height": 80, "background": "white"})).unwrap();
|
||||
let r = s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(r["offset"], json!([30, 25]), "into another document: centred");
|
||||
// An image from another app has no source document: centred too.
|
||||
s.clipboard = s.clipboard.take().map(|c| Clip { source: None, ..c });
|
||||
let r = s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(r["offset"], json!([30, 25]));
|
||||
// Paste in Place still keeps the position.
|
||||
let r = s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap();
|
||||
assert_eq!(r["offset"], json!([0, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_centers_clipboard_bounds_near_coordinate_limits() {
|
||||
for origin in [i32::MAX - 512, i32::MIN + 512] {
|
||||
let clip = Clip { surface: Surface::new(PixelFormat::RGBA8), bounds: Rect::from_xywh(origin, origin, 2, 2), layers: None, resolution: None };
|
||||
let (dx, dy) = paste_offset(&clip, Rect::new(0, 0, 20, 20), &json!({}), false);
|
||||
let clip =
|
||||
Clip { surface: Surface::new(PixelFormat::RGBA8), bounds: Rect::from_xywh(origin, origin, 2, 2), layers: None, resolution: None, source: None };
|
||||
let (dx, dy) = paste_offset(&clip, DocId(1), Rect::new(0, 0, 20, 20), &json!({}), false);
|
||||
assert_eq!(i64::from(origin) + i64::from(dx), 9);
|
||||
assert_eq!(i64::from(origin) + i64::from(dy), 9);
|
||||
let clip = Clip { bounds: Rect::new(0, 0, 2, 2), ..clip };
|
||||
let (dx, dy) = paste_offset(&clip, Rect::from_xywh(origin, origin, 20, 20), &json!({}), false);
|
||||
let (dx, dy) = paste_offset(&clip, DocId(1), Rect::from_xywh(origin, origin, 20, 20), &json!({}), false);
|
||||
assert_eq!(dx, origin + 9);
|
||||
assert_eq!(dy, origin + 9);
|
||||
}
|
||||
@@ -1317,7 +1344,7 @@ mod tests {
|
||||
s.execute("file.new", json!({"width": w, "height": h})).unwrap();
|
||||
let mut surface = Surface::new(PixelFormat::RGBA8);
|
||||
surface.fill_rect(Rect::new(0, 0, cw, ch), &[1.0, 0.0, 0.0, 1.0]);
|
||||
s.clipboard = Some(Clip { surface, bounds: Rect::new(0, 0, cw, ch), layers: None, resolution: None });
|
||||
s.clipboard = Some(Clip { surface, bounds: Rect::new(0, 0, cw, ch), layers: None, resolution: None, source: None });
|
||||
s
|
||||
}
|
||||
|
||||
@@ -1378,8 +1405,9 @@ mod tests {
|
||||
bounds: Rect::new(i32::MAX - 2, i32::MAX - 2, i32::MAX, i32::MAX),
|
||||
layers: None,
|
||||
resolution: None,
|
||||
source: None,
|
||||
};
|
||||
let (dx, dy) = paste_offset(&clip, Rect::new(i32::MIN, i32::MIN, i32::MIN + 4, i32::MIN + 4), &json!({}), false);
|
||||
let (dx, dy) = paste_offset(&clip, DocId(1), Rect::new(i32::MIN, i32::MIN, i32::MIN + 4, i32::MIN + 4), &json!({}), false);
|
||||
assert_eq!((dx, dy), (i32::MIN, i32::MIN), "an unrepresentable shift saturates instead of wrapping");
|
||||
}
|
||||
|
||||
@@ -2474,13 +2502,13 @@ mod tests {
|
||||
};
|
||||
let mut out = Surface::new(with_alpha);
|
||||
if area.is_empty() {
|
||||
return Clip { surface: out, bounds: Rect::EMPTY, layers: None, resolution: None };
|
||||
return Clip { surface: out, bounds: Rect::EMPTY, layers: None, resolution: None, source: None };
|
||||
}
|
||||
let px = lift_region(src, sel, area, with_alpha.channels()).unwrap();
|
||||
out.try_write_region(area, &px).unwrap();
|
||||
out.prune();
|
||||
let bounds = out.content_bounds();
|
||||
Clip { surface: out, bounds, layers: None, resolution: None }
|
||||
Clip { surface: out, bounds, layers: None, resolution: None, source: None }
|
||||
}
|
||||
|
||||
fn noise(fmt: PixelFormat, r: Rect, seed: u32) -> Surface {
|
||||
|
||||
@@ -400,7 +400,38 @@ fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result<Value> {
|
||||
/// The canvas grown to every layer's pixels: Image › Reveal All's new canvas, and what the Crop
|
||||
/// tool shows while a frame is edited.
|
||||
pub fn reveal_all_bounds(doc: &Document) -> Rect {
|
||||
doc.walk().into_iter().filter_map(|(_, _, l)| l.surface().map(|s| s.content_bounds())).fold(doc.bounds(), |a, b| a.union(&b))
|
||||
let canvas = doc.bounds();
|
||||
doc.walk().into_iter().filter_map(|(_, _, l)| l.surface().map(|s| visible_bounds(s, canvas))).fold(canvas, |a, b| a.union(&b))
|
||||
}
|
||||
|
||||
/// Bounds of `s`'s pixels outside `canvas` with alpha > 0, as Photoshop's layer bounds (#9379):
|
||||
/// the Eraser only clears alpha, so an erased pixel keeps its colour bytes and `content_bounds`
|
||||
/// counts it. Pixels inside `canvas` can't grow the result and aren't read.
|
||||
fn visible_bounds(s: &photocraft_raster::Surface, canvas: Rect) -> Rect {
|
||||
use rayon::prelude::*;
|
||||
let fmt = s.format();
|
||||
if !fmt.alpha || s.default_pixel().last().is_none_or(|a| *a > 0.0) {
|
||||
return s.content_bounds();
|
||||
}
|
||||
let (n, bpp, side) = (fmt.channels(), fmt.bytes_per_pixel(), photocraft_geom::TILE_SIZE);
|
||||
let tiles: Vec<_> = s.tiles().filter(|(c, _)| !canvas.contains_rect(&c.rect())).collect();
|
||||
let tile_bounds = |o: Rect, bytes: &[u8]| {
|
||||
let mut out = Rect::EMPTY;
|
||||
for (ly, row) in bytes.chunks_exact(bpp * side as usize).enumerate() {
|
||||
let y = o.y0.saturating_add(ly as i32);
|
||||
let on_canvas = (canvas.y0..canvas.y1).contains(&y);
|
||||
let visible = |lx: &i32| {
|
||||
let x = o.x0.saturating_add(*lx);
|
||||
!(on_canvas && (canvas.x0..canvas.x1).contains(&x))
|
||||
&& row.get(*lx as usize * bpp..).is_some_and(|px| photocraft_color::read_sample(px, fmt.sample, n - 1) > 0.0)
|
||||
};
|
||||
let Some(first) = (0..side).find(visible) else { continue };
|
||||
let last = (first..side).rfind(visible).unwrap_or(first);
|
||||
out = out.union(&Rect::new(o.x0.saturating_add(first), y, o.x0.saturating_add(last).saturating_add(1), y.saturating_add(1)));
|
||||
}
|
||||
out
|
||||
};
|
||||
tiles.par_iter().map(|(c, t)| tile_bounds(c.rect(), t.bytes())).reduce(|| Rect::EMPTY, |a, b| a.union(&b))
|
||||
}
|
||||
|
||||
// ---------- layers ----------
|
||||
@@ -1760,6 +1791,28 @@ mod tests {
|
||||
assert_eq!(s.execute("image.revealAll", json!({})).unwrap()["changed"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_all_skips_erased_pixels() {
|
||||
// #9379: the Eraser only clears alpha, so erased pixels keep their colour bytes; Reveal
|
||||
// All grew the canvas to them although nothing there is visible.
|
||||
// No Background: its off-canvas pixels are opaque and would be revealed anyway.
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth, "background": "transparent"})).unwrap();
|
||||
paint_square(&mut s, Rect::new(0, 0, 40, 30), [1.0, 0.0, 0.0, 1.0]);
|
||||
let erase = json!({"points": [[32, -10], [32, 40]], "size": 28, "erase": true, "brush": {"hardness": 1.0}});
|
||||
s.execute("paint.stroke", erase).unwrap();
|
||||
assert_eq!(pixel(&s, 30, 15)[3], 0.0, "{depth}: erased");
|
||||
s.execute("image.canvasSize", json!({"width": 20, "height": 30, "anchor": "topLeft"})).unwrap();
|
||||
assert_eq!(s.execute("image.revealAll", json!({})).unwrap()["changed"], false, "{depth}");
|
||||
assert_eq!((doc(&s).size.width, doc(&s).size.height), (20, 30), "{depth}");
|
||||
// A pixel that is only partly erased is still there to reveal.
|
||||
paint_square(&mut s, Rect::new(25, 5, 26, 6), [0.0, 0.0, 1.0, 0.5]);
|
||||
assert_eq!(s.execute("image.revealAll", json!({})).unwrap()["changed"], true, "{depth}");
|
||||
assert_eq!((doc(&s).size.width, doc(&s).size.height), (26, 30), "{depth}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_fill_and_all_in_one_step() {
|
||||
let mut s = session(8);
|
||||
|
||||
@@ -123,6 +123,8 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
// Rotating the whole image moves locked layers too, including inside groups.
|
||||
with_released_movement_locks(l, |l| crate::transform_cmds::transform_layer(None, photocraft_doc::Locks::default(), l, &h, Some(a), interp))?;
|
||||
// Free Transform moves only linked masks; the whole image turns them all (#9201).
|
||||
warp_unlinked_masks(l, &h, interp)?;
|
||||
}
|
||||
// What Free Transform leaves alone: unlinked vector masks, gradient angles, artboards…
|
||||
crate::canvas_geom::transform_layer_geometry(l, &a, false, dpi);
|
||||
@@ -142,6 +144,24 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"width": size.width, "height": size.height}))
|
||||
}
|
||||
|
||||
/// Warps the unlinked pixel masks of `l` and its descendants, which [`transform_layer`] leaves in
|
||||
/// place.
|
||||
///
|
||||
/// [`transform_layer`]: crate::transform_cmds::transform_layer
|
||||
fn warp_unlinked_masks(l: &mut Layer, h: &Homography, interp: Interp) -> Result<()> {
|
||||
if let Some(m) = l.mask.as_mut()
|
||||
&& !m.linked
|
||||
{
|
||||
m.surface = crate::transform_cmds::warp_gray(&m.surface, h, interp)?;
|
||||
}
|
||||
if let LayerContent::Group(g) = &mut l.content {
|
||||
for c in &mut g.children {
|
||||
warp_unlinked_masks(c, h, interp)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs `f` with movement locks (`position` and `all`) temporarily released on `l` and all its
|
||||
/// descendants, so canvas-wide rotation moves every layer, then restores each lock.
|
||||
fn with_released_movement_locks<R>(l: &mut Layer, f: impl FnOnce(&mut Layer) -> Result<R>) -> Result<R> {
|
||||
|
||||
@@ -144,6 +144,59 @@ fn rotate_arbitrary_rotates_grouped_locked_children_and_preserves_lock() {
|
||||
assert_eq!(photocraft_compose::flatten(&d_arb).px, photocraft_compose::flatten(d_cw).px);
|
||||
}
|
||||
|
||||
/// Rotating the whole image turns every layer mask, linked or not, as Rotate 180° does (#9201).
|
||||
#[test]
|
||||
fn rotate_arbitrary_turns_unlinked_layer_masks() {
|
||||
for depth in [8, 16] {
|
||||
for grouped in [false, true] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth, "background": "white"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name": "Paint"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("layer.layerMask.revealSelection", json!({})).unwrap();
|
||||
s.execute("layer.layerMask.linked", json!({"linked": false})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
if grouped {
|
||||
s.execute("layer.groupLayers", json!({})).unwrap();
|
||||
}
|
||||
let mask = |s: &Session| doc(s).layer(id).unwrap().mask.clone().unwrap();
|
||||
let base = s.active().unwrap().doc.clone();
|
||||
s.execute("image.rotation.arbitrary", json!({"angle": 180, "interpolation": "nearest"})).unwrap();
|
||||
let m = mask(&s);
|
||||
assert!(!m.linked);
|
||||
let at = |x, y| m.surface.sample_channel(x, y, 0);
|
||||
assert!(at(34, 24) > 0.9, "{depth}-bit, grouped {grouped}: the mask turned with its layer");
|
||||
assert!(at(4, 4) < 0.1, "{depth}-bit, grouped {grouped}: nothing left at the old place");
|
||||
// Rotate 180° and a crop turned by 180° over the whole canvas give the same mask.
|
||||
for (id_, p) in [("image.imageRotation.180", json!({})), ("image.crop", json!({"x": 0, "y": 0, "width": 40, "height": 30, "angle": 180}))] {
|
||||
s.active_mut().unwrap().doc = base.clone();
|
||||
s.execute(id_, p).unwrap();
|
||||
let turned = mask(&s);
|
||||
for y in 0..30 {
|
||||
for x in 0..40 {
|
||||
let (a, b) = (at(x, y), turned.surface.sample_channel(x, y, 0));
|
||||
assert!((a - b).abs() < 1e-3, "{id_}, {depth}-bit, grouped {grouped}: ({x},{y}) {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 90° pins the direction too (180° is its own inverse).
|
||||
s.active_mut().unwrap().doc = base.clone();
|
||||
s.execute("image.rotation.arbitrary", json!({"angle": 90, "interpolation": "nearest"})).unwrap();
|
||||
let arbitrary = mask(&s);
|
||||
s.active_mut().unwrap().doc = base.clone();
|
||||
s.execute("image.imageRotation.90cw", json!({})).unwrap();
|
||||
let turned = mask(&s);
|
||||
for y in 0..40 {
|
||||
for x in 0..30 {
|
||||
let (a, b) = (arbitrary.surface.sample_channel(x, y, 0), turned.surface.sample_channel(x, y, 0));
|
||||
assert!((a - b).abs() < 1e-3, "90cw, {depth}-bit, grouped {grouped}: ({x},{y}) {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flattened_mode_pixels_match_the_allocating_conversion_at_every_channel_count() {
|
||||
let px = [[0.0, 0.25, 0.75, 0.0], [0.9, 0.3, 0.2, 0.2], [0.1, 0.7, 0.4, 0.7], [1.0; 4]];
|
||||
|
||||
@@ -22,14 +22,36 @@ use crate::{EngineError, Result, Session};
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PatternLibrary {
|
||||
pub items: Vec<Pattern>,
|
||||
/// Bumped when a pattern's pixels are replaced under the same id (an imported `.pat` whose
|
||||
/// id is already in the library), which its id, name and size don't show.
|
||||
pub pixels_rev: u64,
|
||||
}
|
||||
|
||||
impl Default for PatternLibrary {
|
||||
fn default() -> Self {
|
||||
PatternLibrary { items: builtin() }
|
||||
PatternLibrary { items: builtin(), pixels_rev: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl PatternLibrary {
|
||||
/// Add `pat`, or replace the pattern with its id (marking the pixels changed).
|
||||
pub fn upsert(&mut self, pat: Pattern) {
|
||||
match self.items.iter_mut().find(|q| q.id == pat.id) {
|
||||
Some(q) => {
|
||||
*q = pat;
|
||||
self.pixels_rev += 1;
|
||||
}
|
||||
None => self.items.push(pat),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `p` is a built-in pattern as shipped (same id, name, size and pixels): those are never
|
||||
/// saved. A built-in replaced by an import, or renamed, is the user's and is.
|
||||
pub(crate) fn is_pristine_builtin(p: &Pattern, builtins: &[Pattern]) -> bool {
|
||||
builtins.iter().any(|b| b.id == p.id && b.name == p.name && (b.width, b.height) == (p.width, p.height) && b.surface == p.surface)
|
||||
}
|
||||
|
||||
fn hash01(x: u32, y: u32, seed: u32) -> f32 {
|
||||
let mut h = x.wrapping_mul(0x8da6_b343) ^ y.wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f);
|
||||
h ^= h >> 13;
|
||||
@@ -278,10 +300,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut ids = Vec::new();
|
||||
for pat in pats {
|
||||
ids.push(pat.id.clone());
|
||||
match s.patterns.items.iter_mut().find(|q| q.id == pat.id) {
|
||||
Some(q) => *q = pat,
|
||||
None => s.patterns.items.push(pat),
|
||||
}
|
||||
s.patterns.upsert(pat);
|
||||
}
|
||||
Ok(json!({"imported": ids, "count": ids.len()}))
|
||||
}
|
||||
|
||||
@@ -829,12 +829,62 @@ fn merge_to_hdr(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// ---------- Crop and Straighten Photos ----------
|
||||
|
||||
/// Paints the pixels outside the selection (holes, gaps between regions) in
|
||||
/// the median colour of the selection's edge, so detection sees them as
|
||||
/// background and finds no photo there. Returns false when no pixel is at
|
||||
/// least half selected (a faint feathered selection), so the caller refuses
|
||||
/// instead of searching the unmasked area.
|
||||
fn mask_unselected(sel: &Surface, area: Rect, w: usize, h: usize, rgba: &mut [[f32; 4]]) -> bool {
|
||||
let mut cov = Vec::new();
|
||||
sel.read_region_into(area, &mut cov);
|
||||
let ch = sel.channels().max(1);
|
||||
let inside: Vec<bool> = cov.chunks_exact(ch).map(|p| p[0] >= 0.5).collect();
|
||||
if inside.len() != w * h || rgba.len() != w * h {
|
||||
return true;
|
||||
}
|
||||
if !inside.iter().any(|&b| b) {
|
||||
return false;
|
||||
}
|
||||
if inside.iter().all(|&b| b) {
|
||||
return true;
|
||||
}
|
||||
let is_in = |x: usize, y: usize| inside.get(y * w + x).copied().unwrap_or(false);
|
||||
let mut edge = Vec::new();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let on_edge = x == 0 || y == 0 || x + 1 == w || y + 1 == h || !is_in(x - 1, y) || !is_in(x + 1, y) || !is_in(x, y - 1) || !is_in(x, y + 1);
|
||||
if is_in(x, y)
|
||||
&& on_edge
|
||||
&& let Some(q) = rgba.get(y * w + x)
|
||||
{
|
||||
edge.push(*q);
|
||||
}
|
||||
}
|
||||
}
|
||||
let bg: [f32; 4] = std::array::from_fn(|c| {
|
||||
let mut v: Vec<f32> = edge.iter().map(|p| p[c]).collect();
|
||||
v.sort_by(f32::total_cmp);
|
||||
v.get(v.len() / 2).copied().unwrap_or(1.0)
|
||||
});
|
||||
for (q, &keep) in rgba.iter_mut().zip(&inside) {
|
||||
if !keep {
|
||||
*q = bg;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn crop_and_straighten(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let doc = st.doc.clone();
|
||||
let fmt = doc.pixel_format();
|
||||
let flat = flattened(&doc, fmt);
|
||||
let area = doc.bounds();
|
||||
let canvas = doc.bounds();
|
||||
// Like Photoshop, only the selected area is searched when there is a selection.
|
||||
let area = doc.selection.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas));
|
||||
if area.is_empty() {
|
||||
return Err(EngineError::Other("the selection is empty".into()));
|
||||
}
|
||||
let (w, h) = (area.width() as usize, area.height() as usize);
|
||||
let mut rgba = vec![[0.0f32; 4]; w * h];
|
||||
flat.read_rgba_into(area, &mut rgba);
|
||||
@@ -847,7 +897,15 @@ fn crop_and_straighten(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let found = scancrop::find_photos(w, h, &rgba);
|
||||
if let Some(sel) = &doc.selection
|
||||
&& !mask_unselected(sel, area, w, h, &mut rgba)
|
||||
{
|
||||
return Err(EngineError::Other("no pixels are more than 50% selected".into()));
|
||||
}
|
||||
let mut found = scancrop::find_photos(w, h, &rgba);
|
||||
for f in &mut found {
|
||||
f.center = [f.center[0] + f64::from(area.x0), f.center[1] + f64::from(area.y0)];
|
||||
}
|
||||
if found.is_empty() {
|
||||
return Err(EngineError::Other("no photos found on a uniform background".into()));
|
||||
}
|
||||
@@ -857,7 +915,7 @@ fn crop_and_straighten(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let corners = f.corners();
|
||||
let hq = Homography::rect_to_quad([0.0, 0.0, pw as f64, ph as f64], corners).and_then(|h| h.inverse());
|
||||
let Some(hm) = hq else { continue };
|
||||
let warped = warp_surface(&flat, area, &hm, Interp::Bicubic)?;
|
||||
let warped = warp_surface(&flat, canvas, &hm, Interp::Bicubic)?;
|
||||
let keep = Rect::new(0, 0, pw as i32, ph as i32);
|
||||
let mut px = Surface::new(fmt);
|
||||
px.write_region(keep, &warped.read_region(keep));
|
||||
@@ -924,7 +982,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Crop and Straighten Photos",
|
||||
menu: &["File", "Automate"],
|
||||
shortcut: None,
|
||||
params: "{} → one new document per photo found on the scan",
|
||||
params: "{} → one new document per photo found on the scan (inside the selection when there is one)",
|
||||
enabled: not_indexed,
|
||||
run: crop_and_straighten,
|
||||
journal: true,
|
||||
@@ -1173,6 +1231,87 @@ mod tests {
|
||||
assert_eq!(s.documents().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_and_straighten_only_processes_the_selection() {
|
||||
// Photoshop: with a selection, only the photos inside it are split off.
|
||||
let (w, h) = (480usize, 340usize);
|
||||
let mut px = vec![[0.98f32, 0.98, 0.98, 1.0]; w * h];
|
||||
// Upright photos: red 160x110 at (40, 60), blue 150x120 at (290, 180).
|
||||
for (x0, y0, pw, ph, c) in [(40, 60, 160, 110, [0.6f32, 0.1, 0.1, 1.0]), (290, 180, 150, 120, [0.1, 0.2, 0.6, 1.0])] {
|
||||
for y in y0..y0 + ph {
|
||||
px[y * w + x0..y * w + x0 + pw].fill(c);
|
||||
}
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.add_document(doc_from("scan", &px, w, h, SampleType::U8), None);
|
||||
s.execute("select.rect", json!({"x": 270, "y": 160, "width": 190, "height": 160})).unwrap();
|
||||
let r = s.execute("file.automate.cropAndStraightenPhotos", json!({})).unwrap();
|
||||
let found = r["photos"].as_array().unwrap();
|
||||
assert_eq!(found.len(), 1, "{r}");
|
||||
assert_eq!(s.documents().len(), 2);
|
||||
// The reported centre is in the scan's coordinates.
|
||||
let c = &found[0]["center"];
|
||||
assert!((c[0].as_f64().unwrap() - 365.0).abs() < 3.0 && (c[1].as_f64().unwrap() - 240.0).abs() < 3.0, "{c}");
|
||||
let d = &s.documents()[1].doc;
|
||||
assert!((d.size.width as i64 - 150).abs() <= 4 && (d.size.height as i64 - 120).abs() <= 4, "{:?}", d.size);
|
||||
let q = d.layers[0].surface().unwrap().rgba(d.size.width as i32 / 2, d.size.height as i32 / 2);
|
||||
assert!(q[2] > 0.5 && q[0] < 0.2, "{q:?}");
|
||||
// Without a selection both photos are found.
|
||||
s.set_active(0);
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let r = s.execute("file.automate.cropAndStraightenPhotos", json!({})).unwrap();
|
||||
assert_eq!(r["photos"].as_array().unwrap().len(), 2, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_and_straighten_skips_photos_between_selected_regions() {
|
||||
// Two selected rectangles: a photo in the gap between them lies inside
|
||||
// their bounding box but outside the selection, so it is not split off.
|
||||
let (w, h) = (480usize, 340usize);
|
||||
let mut px = vec![[0.75f32, 0.75, 0.75, 1.0]; w * h];
|
||||
// Red 160x110 at (40, 60) and blue 150x120 at (290, 180) are selected; green 130x80 at (280, 50) is not.
|
||||
for (x0, y0, pw, ph, c) in
|
||||
[(40, 60, 160, 110, [0.6f32, 0.1, 0.1, 1.0]), (290, 180, 150, 120, [0.1, 0.2, 0.6, 1.0]), (280, 50, 130, 80, [0.1, 0.6, 0.1, 1.0])]
|
||||
{
|
||||
for y in y0..y0 + ph {
|
||||
px[y * w + x0..y * w + x0 + pw].fill(c);
|
||||
}
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.add_document(doc_from("scan", &px, w, h, SampleType::U8), None);
|
||||
s.execute("select.rect", json!({"x": 20, "y": 40, "width": 200, "height": 150})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 270, "y": 160, "width": 190, "height": 160, "mode": "add"})).unwrap();
|
||||
let r = s.execute("file.automate.cropAndStraightenPhotos", json!({})).unwrap();
|
||||
let found = r["photos"].as_array().unwrap();
|
||||
assert_eq!(found.len(), 2, "{r}");
|
||||
for f in found {
|
||||
let c = &f["center"];
|
||||
let (cx, cy) = (c[0].as_f64().unwrap(), c[1].as_f64().unwrap());
|
||||
let near = |x: f64, y: f64| (cx - x).abs() < 3.0 && (cy - y).abs() < 3.0;
|
||||
assert!(near(120.0, 115.0) || near(365.0, 240.0), "{c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_and_straighten_refuses_a_selection_under_half_coverage() {
|
||||
// A faint (strongly feathered) selection has no pixel at or above 50%:
|
||||
// like Photoshop, nothing counts as selected, so no photo is split off
|
||||
// from the unmasked bounding box.
|
||||
let (w, h) = (480usize, 340usize);
|
||||
let mut px = vec![[0.98f32, 0.98, 0.98, 1.0]; w * h];
|
||||
for y in 180..300 {
|
||||
px[y * w + 290..y * w + 440].fill([0.1, 0.2, 0.6, 1.0]);
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.add_document(doc_from("scan", &px, w, h, SampleType::U8), None);
|
||||
let mut m = Surface::new(photocraft_color::PixelFormat::GRAY8);
|
||||
m.fill_rect(Rect::new(270, 160, 460, 320), &[0.3]);
|
||||
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).selection = Some(m);
|
||||
let e = s.execute("file.automate.cropAndStraightenPhotos", json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("50%"), "{e}");
|
||||
assert_eq!(s.documents().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_and_straighten_makes_documents() {
|
||||
let (w, h) = (500usize, 360usize);
|
||||
|
||||
@@ -38,6 +38,11 @@
|
||||
//! The Actions list (`actions.json`, see [`crate::actions_cmds`]) lives in the same directory.
|
||||
//! A missing file is an empty list. A corrupt or oversized file is skipped with a warning.
|
||||
//! Headless sessions have no store, so their actions stay in memory.
|
||||
//!
|
||||
//! So do the patterns the user defined or imported (`patterns.pat`, Photoshop's own pattern file
|
||||
//! format, #9317): the whole user library in its order, rewritten when its ids, names or order
|
||||
//! change. The built-in patterns are regenerated on start and never written. Like `actions.json`,
|
||||
//! a file that fails to load is warned about and never overwritten.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::io::Read;
|
||||
@@ -67,6 +72,10 @@ pub const MAX_INDEX_BYTES: u64 = 4 << 20;
|
||||
pub const ACTIONS_FILE: &str = "actions.json";
|
||||
/// Largest Actions file read or written.
|
||||
pub const MAX_ACTIONS_BYTES: u64 = 16 << 20;
|
||||
/// The user's pattern library (`.pat`).
|
||||
pub const PATTERNS_FILE: &str = "patterns.pat";
|
||||
/// Largest pattern library read or written.
|
||||
pub const MAX_PATTERNS_BYTES: u64 = 256 << 20;
|
||||
/// The whole store never grows beyond this; further groups are skipped with a warning.
|
||||
pub const MAX_STORE_BYTES: u64 = 2 << 30;
|
||||
/// Largest tip side accepted from disk.
|
||||
@@ -550,6 +559,10 @@ pub struct PresetStore {
|
||||
actions_rev: u64,
|
||||
/// `actions.json` exists but failed to load: left on disk untouched, so it is never written.
|
||||
actions_skipped: bool,
|
||||
/// [`patterns_key`] of the user patterns last written (or loaded).
|
||||
patterns_key: u64,
|
||||
/// `patterns.pat` exists but failed to load: left on disk untouched, so it is never written.
|
||||
patterns_skipped: bool,
|
||||
warnings: Vec<String>,
|
||||
/// Full tips loaded for brushes in use (behind a lock so `&Session` paths can load).
|
||||
cache: Mutex<TipCache>,
|
||||
@@ -568,6 +581,8 @@ pub struct Opened {
|
||||
pub warnings: Vec<String>,
|
||||
/// Recorded actions (`actions.json`). Empty when the file is missing or unreadable.
|
||||
pub actions: Vec<crate::actions_cmds::Action>,
|
||||
/// User patterns (`patterns.pat`), in library order. Empty when the file is missing or unreadable.
|
||||
pub patterns: Vec<photocraft_doc::Pattern>,
|
||||
}
|
||||
|
||||
/// Load a store (any thread; the desktop app does this in the background at start).
|
||||
@@ -644,6 +659,9 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
|
||||
let loaded_actions = load_actions(backend.as_ref(), &mut warnings);
|
||||
let actions_skipped = loaded_actions.is_none();
|
||||
let actions = loaded_actions.unwrap_or_default();
|
||||
let loaded_patterns = load_patterns(backend.as_ref(), &mut warnings);
|
||||
let patterns_skipped = loaded_patterns.is_none();
|
||||
let patterns = loaded_patterns.unwrap_or_default();
|
||||
let mut store = PresetStore {
|
||||
backend,
|
||||
synced_rev: None,
|
||||
@@ -653,6 +671,8 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
|
||||
index: None,
|
||||
actions_rev: 0,
|
||||
actions_skipped,
|
||||
patterns_key: patterns_key(&patterns, 0),
|
||||
patterns_skipped,
|
||||
warnings: Vec::new(),
|
||||
cache: Mutex::new(TipCache { limit: TIP_CACHE_BYTES, ..Default::default() }),
|
||||
};
|
||||
@@ -710,7 +730,41 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
|
||||
let hidden_builtins = index.as_ref().map(|i| i.hidden_builtins.clone()).unwrap_or_default();
|
||||
let order = index.as_ref().map(|i| i.order.clone()).unwrap_or_default();
|
||||
store.index = index;
|
||||
Opened { store, presets, hidden_builtins, order, warnings, actions }
|
||||
Opened { store, presets, hidden_builtins, order, warnings, actions, patterns }
|
||||
}
|
||||
|
||||
/// `patterns.pat`: a missing file is no patterns. Anything else unreadable is a warning.
|
||||
/// The saved patterns; `None` when `patterns.pat` exists but could not be loaded.
|
||||
fn load_patterns(backend: &dyn PresetBackend, warnings: &mut Vec<String>) -> Option<Vec<photocraft_doc::Pattern>> {
|
||||
let bytes = match backend.read(PATTERNS_FILE, MAX_PATTERNS_BYTES) {
|
||||
Ok(b) => b,
|
||||
Err(e) if missing_file(&e) => return Some(Vec::new()),
|
||||
Err(e) => {
|
||||
warnings.push(format!("patterns: {PATTERNS_FILE} skipped: {e}"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match photocraft_io::pattern_map::read_pat(&bytes) {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
warnings.push(format!("patterns: {PATTERNS_FILE} skipped: {e}"));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What decides whether the user patterns need rewriting: their ids, names, sizes and order, and
|
||||
/// the library's [`pixels_rev`](crate::pattern_cmds::PatternLibrary::pixels_rev) (pixels replaced
|
||||
/// under the same id).
|
||||
fn patterns_key(patterns: &[photocraft_doc::Pattern], pixels_rev: u64) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
for p in patterns {
|
||||
(&p.id, &p.name, p.width, p.height).hash(&mut h);
|
||||
}
|
||||
patterns.len().hash(&mut h);
|
||||
pixels_rev.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// `actions.json`: a missing file is an empty list. Anything else unreadable is a warning.
|
||||
@@ -1075,6 +1129,49 @@ impl PresetStore {
|
||||
}
|
||||
}
|
||||
|
||||
impl PresetStore {
|
||||
/// Write the user patterns (the library minus the built-ins) when they changed since the last
|
||||
/// load or write. An oversized library is warned once per change and not written. An I/O
|
||||
/// error is warned and retried on the next command.
|
||||
pub fn sync_patterns(&mut self, library: &[photocraft_doc::Pattern], pixels_rev: u64) {
|
||||
let builtins = crate::pattern_cmds::builtin();
|
||||
let user: Vec<photocraft_doc::Pattern> = library.iter().filter(|p| !crate::pattern_cmds::is_pristine_builtin(p, &builtins)).cloned().collect();
|
||||
let key = patterns_key(&user, pixels_rev);
|
||||
if self.patterns_key == key {
|
||||
return;
|
||||
}
|
||||
self.patterns_key = key;
|
||||
if self.patterns_skipped {
|
||||
self.warnings.push(format!("Couldn't save patterns: {PATTERNS_FILE} could not be read, so it was left unchanged"));
|
||||
return;
|
||||
}
|
||||
if user.is_empty() {
|
||||
if let Err(e) = self.backend.remove(PATTERNS_FILE) {
|
||||
self.warnings.push(format!("Couldn't save patterns: {e}"));
|
||||
// Try again after the next command.
|
||||
self.patterns_key = key.wrapping_add(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let bytes = match photocraft_io::pattern_map::write_pat(&user) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
self.warnings.push(format!("Couldn't save patterns: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if bytes.len() as u64 > MAX_PATTERNS_BYTES {
|
||||
self.warnings.push(format!("patterns: the pattern library is too large to save ({} MB limit)", MAX_PATTERNS_BYTES >> 20));
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self.backend.write(PATTERNS_FILE, &bytes) {
|
||||
self.warnings.push(format!("Couldn't save patterns: {e}"));
|
||||
// Try again after the next command.
|
||||
self.patterns_key = key.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_names() -> &'static [String] {
|
||||
static NAMES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
|
||||
NAMES.get_or_init(|| photocraft_paint::presets::builtin().into_iter().map(|p| p.name).collect())
|
||||
@@ -1091,7 +1188,24 @@ impl Session {
|
||||
/// built-in of the same name; presets created before the store finished loading win), hide
|
||||
/// deleted built-ins, then sync. Returns the load warnings.
|
||||
pub fn attach_preset_store(&mut self, opened: Opened) -> Vec<String> {
|
||||
let Opened { store, presets, hidden_builtins, order, mut warnings, actions: disk } = opened;
|
||||
let Opened { store, presets, hidden_builtins, order, mut warnings, actions: disk, patterns } = opened;
|
||||
// Saved patterns join the library after the built-ins, and a saved built-in (replaced by
|
||||
// an import or renamed) takes the shipped one's place; a pattern defined or imported
|
||||
// before the store finished loading keeps its place (and the same id isn't added twice).
|
||||
let builtins = crate::pattern_cmds::builtin();
|
||||
for p in patterns {
|
||||
match self.patterns.items.iter_mut().find(|q| q.id == p.id) {
|
||||
Some(q) if crate::pattern_cmds::is_pristine_builtin(q, &builtins) => *q = p,
|
||||
// The earlier copy wins; other pixels than the saved ones must still be written,
|
||||
// which its id, name and size don't show.
|
||||
Some(q) => {
|
||||
if q.surface != p.surface {
|
||||
self.patterns.pixels_rev += 1;
|
||||
}
|
||||
}
|
||||
None => self.patterns.items.push(p),
|
||||
}
|
||||
}
|
||||
let lib = &mut self.tools.presets;
|
||||
lib.retain(|p| !(p.builtin && hidden_builtins.iter().any(|h| h.eq_ignore_ascii_case(&p.name))));
|
||||
for p in presets {
|
||||
@@ -1136,7 +1250,7 @@ impl Session {
|
||||
warnings
|
||||
}
|
||||
|
||||
/// Write pending brush preset and Actions changes to the attached store (no-op without one).
|
||||
/// Write pending brush preset, Actions and pattern changes to the attached store (no-op without one).
|
||||
pub fn sync_preset_store(&mut self) {
|
||||
let rev = self.tools.presets_rev;
|
||||
let actions_rev = self.actions.rev;
|
||||
@@ -1151,6 +1265,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
st.sync_actions(&self.actions.list, actions_rev);
|
||||
st.sync_patterns(&self.patterns.items, self.patterns.pixels_rev);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -703,3 +703,170 @@ fn an_actions_file_that_failed_to_load_is_not_overwritten() {
|
||||
assert_eq!(mem.files.lock().unwrap().get(ACTIONS_FILE), Some(&newer), "an actions file the store could not read was overwritten");
|
||||
assert!(!s.preset_store.as_mut().unwrap().take_warnings().is_empty());
|
||||
}
|
||||
|
||||
/// #9317: a defined or imported pattern survives a restart, as in Photoshop; the built-in
|
||||
/// patterns are never written, and deleting the last user pattern removes the file.
|
||||
#[test]
|
||||
fn user_patterns_survive_a_restart() {
|
||||
let dir = TempDir::new("patterns");
|
||||
let builtin = Session::new().patterns.items.len();
|
||||
let (mut s, w) = session(&dir);
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
s.execute("file.new", json!({"width": 12, "height": 10})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#1ea05a"})).unwrap();
|
||||
let id = s.execute("edit.definePattern", json!({"name": "Green Tile"})).unwrap()["pattern"].as_str().unwrap().to_string();
|
||||
s.sync_preset_store();
|
||||
assert!(dir.files().contains(&PATTERNS_FILE.to_string()), "{:?}", dir.files());
|
||||
// Only the user's pattern is in the file.
|
||||
let bytes = std::fs::read(dir.0.join(PATTERNS_FILE)).unwrap();
|
||||
let saved = photocraft_io::pattern_map::read_pat(&bytes).unwrap();
|
||||
assert_eq!(saved.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Green Tile"]);
|
||||
drop(s);
|
||||
|
||||
let (mut t, w) = session(&dir);
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
assert_eq!(t.patterns.items.len(), builtin + 1);
|
||||
let back = t.patterns.items.iter().find(|p| p.id == id).expect("the defined pattern is back after a restart");
|
||||
assert_eq!((back.name.as_str(), back.width, back.height), ("Green Tile", 12, 10));
|
||||
assert_eq!(back.surface, saved[0].surface, "with its pixels");
|
||||
// Renames are kept; deleting the last user pattern removes the file.
|
||||
t.execute("pattern.rename", json!({"pattern": id, "name": "Moss"})).unwrap();
|
||||
t.sync_preset_store();
|
||||
let (u, _) = session(&dir);
|
||||
assert!(u.patterns.items.iter().any(|p| p.id == id && p.name == "Moss"));
|
||||
t.execute("pattern.delete", json!({"pattern": id})).unwrap();
|
||||
t.sync_preset_store();
|
||||
assert!(!dir.files().contains(&PATTERNS_FILE.to_string()), "{:?}", dir.files());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_patterns_file_that_failed_to_load_is_not_overwritten() {
|
||||
let mem = MemBackend::default();
|
||||
let unreadable = b"not a pattern file".to_vec();
|
||||
mem.files.lock().unwrap().insert(PATTERNS_FILE.into(), unreadable.clone());
|
||||
let mut s = Session::new();
|
||||
let w = s.attach_preset_store(open(Box::new(mem.clone())));
|
||||
assert!(w.iter().any(|w| w.contains(PATTERNS_FILE)), "{w:?}");
|
||||
s.execute("file.new", json!({"width": 4, "height": 4})).unwrap();
|
||||
s.execute("edit.definePattern", json!({"name": "New"})).unwrap();
|
||||
s.sync_preset_store();
|
||||
assert_eq!(mem.files.lock().unwrap().get(PATTERNS_FILE), Some(&unreadable), "a patterns file the store could not read was overwritten");
|
||||
assert!(!s.preset_store.as_mut().unwrap().take_warnings().is_empty());
|
||||
}
|
||||
|
||||
/// A `.pat` of one 4×4 pattern with id `id`, filled with `rgba`.
|
||||
fn pat_bytes(id: &str, rgba: [f32; 4]) -> String {
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
let mut surface = Surface::new(photocraft_color::PixelFormat::RGBA8);
|
||||
surface.fill_rect(Rect::new(0, 0, 4, 4), &rgba);
|
||||
let mut p = photocraft_doc::Pattern::new("Same", surface, 4, 4);
|
||||
p.id = id.into();
|
||||
photocraft_paint::tile::b64_encode(&photocraft_io::pattern_map::write_pat(&[p]).unwrap())
|
||||
}
|
||||
|
||||
/// #9318 review: re-importing a pattern under the same id, name and size but with other pixels
|
||||
/// (an edited `.pat`) is written, so a restart doesn't bring the old pixels back.
|
||||
#[test]
|
||||
fn a_same_id_pattern_replacement_survives_a_restart() {
|
||||
let mem = MemBackend::default();
|
||||
let mut s = Session::new();
|
||||
s.attach_preset_store(open(Box::new(mem.clone())));
|
||||
let id = "6f1c2c1e-0000-4000-8000-000000009318";
|
||||
s.execute("pattern.import", json!({"data": pat_bytes(id, [1.0, 0.0, 0.0, 1.0])})).unwrap();
|
||||
s.sync_preset_store();
|
||||
s.execute("pattern.import", json!({"data": pat_bytes(id, [0.0, 0.0, 1.0, 1.0])})).unwrap();
|
||||
s.sync_preset_store();
|
||||
let mut t = Session::new();
|
||||
t.attach_preset_store(open(Box::new(mem.clone())));
|
||||
let back = t.patterns.items.iter().find(|p| p.id == id).expect("the pattern is back");
|
||||
let want = s.patterns.items.iter().find(|p| p.id == id).unwrap();
|
||||
assert_eq!(back.surface, want.surface, "with the replacement's pixels, not the first import's");
|
||||
}
|
||||
|
||||
/// A [`MemBackend`] whose removals fail while `fail` is set.
|
||||
#[derive(Clone, Default)]
|
||||
struct FlakyRemove {
|
||||
mem: MemBackend,
|
||||
fail: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
impl PresetBackend for FlakyRemove {
|
||||
fn list(&self) -> Result<Vec<(String, u64)>, String> {
|
||||
self.mem.list()
|
||||
}
|
||||
fn read(&self, name: &str, max: u64) -> Result<Vec<u8>, String> {
|
||||
self.mem.read(name, max)
|
||||
}
|
||||
fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
self.mem.write(name, bytes)
|
||||
}
|
||||
fn remove(&self, name: &str) -> Result<(), String> {
|
||||
if self.fail.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
return Err(format!("{name}: busy"));
|
||||
}
|
||||
self.mem.remove(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// #9318 review: deleting the last user pattern while `patterns.pat` can't be removed is
|
||||
/// warned and retried, so the deleted pattern doesn't come back after a restart.
|
||||
#[test]
|
||||
fn a_failed_patterns_removal_is_retried() {
|
||||
let backend = FlakyRemove::default();
|
||||
let mut s = Session::new();
|
||||
s.attach_preset_store(open(Box::new(backend.clone())));
|
||||
s.execute("file.new", json!({"width": 4, "height": 4})).unwrap();
|
||||
let id = s.execute("edit.definePattern", json!({"name": "Gone"})).unwrap()["pattern"].as_str().unwrap().to_string();
|
||||
s.sync_preset_store();
|
||||
assert!(backend.mem.files.lock().unwrap().contains_key(PATTERNS_FILE));
|
||||
backend.fail.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
s.execute("pattern.delete", json!({"pattern": id})).unwrap();
|
||||
s.sync_preset_store();
|
||||
assert!(!s.preset_store.as_mut().unwrap().take_warnings().is_empty(), "the failure is warned");
|
||||
backend.fail.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
s.execute("file.new", json!({"width": 4, "height": 4})).unwrap();
|
||||
s.sync_preset_store();
|
||||
assert!(!backend.mem.files.lock().unwrap().contains_key(PATTERNS_FILE), "the removal is retried");
|
||||
}
|
||||
|
||||
/// #9318 review: an imported `.pat` that replaces a built-in pattern's pixels (same id) is saved
|
||||
/// and replaces the built-in after a restart, once; untouched built-ins are still never written.
|
||||
#[test]
|
||||
fn an_imported_builtin_replacement_survives_a_restart() {
|
||||
let mem = MemBackend::default();
|
||||
let mut s = Session::new();
|
||||
s.attach_preset_store(open(Box::new(mem.clone())));
|
||||
let builtin = s.patterns.items[0].id.clone();
|
||||
s.execute("pattern.import", json!({"data": pat_bytes(&builtin, [0.9, 0.1, 0.4, 1.0])})).unwrap();
|
||||
s.sync_preset_store();
|
||||
let saved = photocraft_io::pattern_map::read_pat(mem.files.lock().unwrap().get(PATTERNS_FILE).expect("written")).unwrap();
|
||||
assert_eq!(saved.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(), [builtin.as_str()], "only the replaced built-in");
|
||||
let mut t = Session::new();
|
||||
t.attach_preset_store(open(Box::new(mem.clone())));
|
||||
let back: Vec<_> = t.patterns.items.iter().filter(|p| p.id == builtin).collect();
|
||||
assert_eq!(back.len(), 1, "once");
|
||||
let want = s.patterns.items.iter().find(|p| p.id == builtin).unwrap();
|
||||
assert_eq!(back[0].surface, want.surface, "with the imported pixels");
|
||||
}
|
||||
|
||||
/// #9318 review: a pattern imported before the store finished loading, with the id, name and
|
||||
/// size of a saved one but other pixels, wins the merge and is written, so a restart keeps it.
|
||||
#[test]
|
||||
fn an_early_same_id_import_wins_and_is_saved() {
|
||||
let mem = MemBackend::default();
|
||||
let id = "6f1c2c1e-0000-4000-8000-00000000e4a1";
|
||||
let mut s = Session::new();
|
||||
s.attach_preset_store(open(Box::new(mem.clone())));
|
||||
s.execute("pattern.import", json!({"data": pat_bytes(id, [1.0, 0.0, 0.0, 1.0])})).unwrap();
|
||||
s.sync_preset_store();
|
||||
// Next start: the same pattern with other pixels arrives before the store is attached.
|
||||
let mut t = Session::new();
|
||||
t.execute("pattern.import", json!({"data": pat_bytes(id, [0.0, 1.0, 0.0, 1.0])})).unwrap();
|
||||
let early = t.patterns.items.iter().find(|p| p.id == id).unwrap().surface.clone();
|
||||
t.attach_preset_store(open(Box::new(mem.clone())));
|
||||
t.sync_preset_store();
|
||||
let mut u = Session::new();
|
||||
u.attach_preset_store(open(Box::new(mem.clone())));
|
||||
assert_eq!(u.patterns.items.iter().find(|p| p.id == id).unwrap().surface, early, "the early import's pixels were saved");
|
||||
}
|
||||
|
||||
@@ -1143,12 +1143,31 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for (_, _, l) in doc.walk() {
|
||||
let LayerContent::Smart(so) = &l.content else { continue };
|
||||
let SmartSource::Linked { path } = &so.source else { continue };
|
||||
if missing.contains(path) {
|
||||
continue;
|
||||
}
|
||||
let target = match copied.iter().find(|(src, _)| src == path) {
|
||||
Some((_, t)) => t.clone(),
|
||||
None => {
|
||||
let mut fname = file_name(path);
|
||||
if copied.iter().any(|(_, t)| file_name(t) == fname) {
|
||||
fname = format!("{}-{}", copied.len(), fname);
|
||||
// A name no earlier copy has, ignoring case (macOS and Windows folders do): a
|
||||
// renamed copy can't take the name of another link either. A file already in
|
||||
// Links (an earlier package into the same folder) is never overwritten.
|
||||
let base = file_name(path);
|
||||
let taken = |n: &str| {
|
||||
copied.iter().any(|(_, t)| file_name(t).to_lowercase() == n.to_lowercase())
|
||||
|| std::path::Path::new(&join(&links_dir, n)).exists()
|
||||
|| std::fs::read_dir(&links_dir)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.any(|entry| entry.file_name().to_string_lossy().to_lowercase() == n.to_lowercase())
|
||||
};
|
||||
let mut fname = base.clone();
|
||||
let mut k = copied.len();
|
||||
while taken(&fname) {
|
||||
fname = format!("{k}-{base}");
|
||||
k += 1;
|
||||
}
|
||||
let t = join(&links_dir, &fname);
|
||||
// A path that is not followed is packaged as missing, like a file that is gone.
|
||||
|
||||
@@ -463,6 +463,72 @@ fn package_copies_links_and_relinks() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Links whose names collide get distinct copies, even when a renamed copy's name is already
|
||||
/// another link's name (`b/2-x.png` copied before the third `x.png` would be renamed to it).
|
||||
#[test]
|
||||
fn package_never_overwrites_one_link_copy_with_another() {
|
||||
let dir = tmp("package-collide");
|
||||
let mut s = session("rgb", 8);
|
||||
let mut links = Vec::new();
|
||||
for (sub, name, colour) in [("a", "x.png", "#ff0000"), ("b", "2-x.png", "#00ff00"), ("c", "x.png", "#0000ff")] {
|
||||
std::fs::create_dir_all(format!("{dir}/{sub}")).unwrap();
|
||||
let link = format!("{dir}/{sub}/{name}");
|
||||
let mut src = Session::new();
|
||||
src.execute("file.new", json!({"width": 4, "height": 4, "background": colour})).unwrap();
|
||||
crate::file_cmds::save_doc(&src.active().unwrap().doc, &link, None).unwrap();
|
||||
s.execute("file.placeLinked", json!({"path": link})).unwrap();
|
||||
links.push(std::fs::read(&link).unwrap());
|
||||
}
|
||||
let r = s.execute("file.package", json!({"dir": format!("{dir}/pkg")})).unwrap();
|
||||
let copies: Vec<&str> = r["links"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
|
||||
assert_eq!(copies.len(), 3);
|
||||
let unique: std::collections::HashSet<&&str> = copies.iter().collect();
|
||||
assert_eq!(unique.len(), 3, "{copies:?}");
|
||||
for (copy, bytes) in copies.iter().zip(&links) {
|
||||
assert_eq!(&std::fs::read(copy).unwrap(), bytes, "{copy}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A second package into the same folder copies its links beside the first one's, never over them.
|
||||
#[test]
|
||||
fn package_keeps_files_already_in_links() {
|
||||
let dir = tmp("package-again");
|
||||
let link = format!("{dir}/art.png");
|
||||
let mut src = Session::new();
|
||||
src.execute("file.new", json!({"width": 4, "height": 4, "background": "#ff0000"})).unwrap();
|
||||
crate::file_cmds::save_doc(&src.active().unwrap().doc, &link, None).unwrap();
|
||||
let mut s = session("rgb", 8);
|
||||
s.execute("file.placeLinked", json!({"path": link})).unwrap();
|
||||
let first = s.execute("file.package", json!({"dir": format!("{dir}/pkg")})).unwrap();
|
||||
let first = first["links"][0].as_str().unwrap().to_string();
|
||||
let kept = std::fs::read(&first).unwrap();
|
||||
let second = s.execute("file.package", json!({"dir": format!("{dir}/pkg")})).unwrap();
|
||||
let second = second["links"][0].as_str().unwrap();
|
||||
assert_ne!(second, first);
|
||||
assert!(second.ends_with("Links/0-art.png"), "{second}");
|
||||
assert_eq!(std::fs::read(&first).unwrap(), kept);
|
||||
assert_eq!(std::fs::read(second).unwrap(), kept);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A missing link placed twice is reported once.
|
||||
#[test]
|
||||
fn package_lists_a_missing_link_once() {
|
||||
let dir = tmp("package-missing-once");
|
||||
let link = format!("{dir}/gone.png");
|
||||
let mut src = Session::new();
|
||||
src.execute("file.new", json!({"width": 4, "height": 4})).unwrap();
|
||||
crate::file_cmds::save_doc(&src.active().unwrap().doc, &link, None).unwrap();
|
||||
let mut s = session("rgb", 8);
|
||||
s.execute("file.placeLinked", json!({"path": link})).unwrap();
|
||||
s.execute("file.placeLinked", json!({"path": link})).unwrap();
|
||||
std::fs::remove_file(&link).unwrap();
|
||||
let r = s.execute("file.package", json!({"dir": format!("{dir}/pkg")})).unwrap();
|
||||
assert_eq!(r["missing"], json!([link]));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_export_as_illustrator_postscript() {
|
||||
let mut s = session("rgb", 8);
|
||||
|
||||
@@ -55,10 +55,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut pattern_ids = Vec::new();
|
||||
for pat in imp.patterns {
|
||||
pattern_ids.push(pat.id.clone());
|
||||
match s.patterns.items.iter_mut().find(|q| q.id == pat.id) {
|
||||
Some(q) => *q = pat,
|
||||
None => s.patterns.items.push(pat),
|
||||
}
|
||||
s.patterns.upsert(pat);
|
||||
}
|
||||
let styles = &mut s.presets.styles;
|
||||
if replace {
|
||||
@@ -241,6 +238,24 @@ mod tests {
|
||||
assert!((f.opacity - l.opacity).abs() < 1e-4);
|
||||
}
|
||||
|
||||
/// #9318 review: an ASL that replaces a library pattern by id marks its pixels changed, so the
|
||||
/// preset store writes the replacement (see `PatternLibrary::pixels_rev`).
|
||||
#[test]
|
||||
fn an_asl_pattern_replacement_bumps_pixels_rev() {
|
||||
let mut s = session();
|
||||
let mut pat = s.patterns.items[1].clone();
|
||||
pat.id = "asl-replaced-pattern".into();
|
||||
let asl = |p: &photocraft_doc::Pattern| {
|
||||
let style = AslStyle { name: "Pat".into(), effects: vec![], blend: None, opacity: None, fill_opacity: None };
|
||||
b64_encode(&asl_map::write_asl(&[style], std::slice::from_ref(p)).unwrap())
|
||||
};
|
||||
s.execute(IMPORT, json!({"data": asl(&pat), "group": "A"})).unwrap();
|
||||
let rev = s.patterns.pixels_rev;
|
||||
pat.surface = s.patterns.items[2].surface.clone();
|
||||
s.execute(IMPORT, json!({"data": asl(&pat), "group": "B"})).unwrap();
|
||||
assert!(s.patterns.pixels_rev > rev, "the same-id replacement is marked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_patterns_reach_the_library_and_the_document() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -421,7 +421,16 @@ pub fn export_data_sets_with(s: &Session, p: &Value, mut write: impl FnMut(&str,
|
||||
}
|
||||
// Filename template: `{name}` (sanitised data-set name), `{index}` (1-based). Default `{name}`.
|
||||
let template = p.get("naming").and_then(Value::as_str).unwrap_or("{name}");
|
||||
let doc_stem = original.name.rsplit_once('.').map_or(original.name.as_str(), |(a, _)| a).to_string();
|
||||
// A document name is not a path: a separator in it, or a stem of `.` / `..` before a `/` in
|
||||
// the template, must not leave `dir`.
|
||||
let doc_stem: String = original
|
||||
.name
|
||||
.rsplit_once('.')
|
||||
.map_or(original.name.as_str(), |(a, _)| a)
|
||||
.chars()
|
||||
.map(|c| if c.is_control() || "/\\<>:\"|?*".contains(c) { '_' } else { c })
|
||||
.collect();
|
||||
let doc_stem = if matches!(doc_stem.as_str(), "" | "." | "..") { "_".to_string() } else { doc_stem };
|
||||
// Plan the whole batch before writing: sanitised names or a constant template may collide.
|
||||
// Match the case-insensitive comparison used by the other batch exporters.
|
||||
let mut claimed = std::collections::HashMap::new();
|
||||
@@ -429,7 +438,15 @@ pub fn export_data_sets_with(s: &Session, p: &Value, mut write: impl FnMut(&str,
|
||||
for (n, set) in sets.iter().enumerate() {
|
||||
let safe: String = set.name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
|
||||
let stem = template.replace("{name}", &safe).replace("{index}", &format!("{:03}", n + 1)).replace("{document}", &doc_stem);
|
||||
let name = format!("{stem}.{format}");
|
||||
// Each folder the template makes is checked too: `{document}/{name}` of `nul.psd` would
|
||||
// otherwise write into the `nul` device.
|
||||
let name: String = format!("{stem}.{format}")
|
||||
.split_inclusive(['/', '\\'])
|
||||
.map(|part| {
|
||||
let sep = ["/", "\\"].into_iter().find(|s| part.ends_with(s)).unwrap_or("");
|
||||
crate::file_cmds::portable_device_name(part.strip_suffix(sep).unwrap_or(part).to_string()) + sep
|
||||
})
|
||||
.collect();
|
||||
if let Some(first) = claimed.insert(name.to_lowercase(), &set.name) {
|
||||
return Err(bad(
|
||||
"file.export.dataSetsAsFiles",
|
||||
|
||||
@@ -421,6 +421,39 @@ fn export_data_sets_rejects_collisions_before_writing() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_data_sets_keeps_document_derived_names_inside_the_folder() {
|
||||
// A document name is not a path: `{document}` must not climb out of `dir` or into a subfolder,
|
||||
// and a data set named after a Windows device gets the `_` every other exporter gives it.
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let root = root.path().to_string_lossy().into_owned();
|
||||
let dir = format!("{root}/out");
|
||||
let mut s = visibility_export_session(&["nul", "b"]);
|
||||
s.execute("image.duplicate", json!({"name": "../esc/aped.psd"})).unwrap();
|
||||
let r = s.execute("file.export.dataSetsAsFiles", json!({"dir": dir, "naming": "{document}_{index}"})).unwrap();
|
||||
assert_eq!(r["files"], json!([format!("{dir}/.._esc_aped_001.png"), format!("{dir}/.._esc_aped_002.png")]));
|
||||
let r = s.execute("file.export.dataSetsAsFiles", json!({"dir": dir, "dataSets": ["nul"]})).unwrap();
|
||||
assert_eq!(r["files"], json!([format!("{dir}/nul_.png")]));
|
||||
// `...` has the stem `..`: before a folder in the caller's template it would climb out too.
|
||||
s.execute("image.duplicate", json!({"name": "..."})).unwrap();
|
||||
let r = s.execute("file.export.dataSetsAsFiles", json!({"dir": dir, "dataSets": ["b"], "naming": "{document}/{name}"})).unwrap();
|
||||
assert_eq!(r["files"], json!([format!("{dir}/_/b.png")]));
|
||||
// `.` has an empty stem: `{document}` alone would be a nameless `.png`.
|
||||
s.execute("image.duplicate", json!({"name": "."})).unwrap();
|
||||
let r = s.execute("file.export.dataSetsAsFiles", json!({"dir": dir, "dataSets": ["b"], "naming": "{document}"})).unwrap();
|
||||
assert_eq!(r["files"], json!([format!("{dir}/_.png")]));
|
||||
// A folder the template makes from a device-named document gets the `_` as well.
|
||||
s.execute("image.duplicate", json!({"name": "nul.psd"})).unwrap();
|
||||
let r = s.execute("file.export.dataSetsAsFiles", json!({"dir": dir, "dataSets": ["b"], "naming": "{document}/{name}"})).unwrap();
|
||||
assert_eq!(r["files"], json!([format!("{dir}/nul_/b.png")]));
|
||||
let mut written: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
written.sort();
|
||||
assert_eq!(written, [".._esc_aped_001.png", ".._esc_aped_002.png", "_", "_.png", "nul_", "nul_.png"]);
|
||||
assert!(std::path::Path::new(&format!("{dir}/nul_/b.png")).is_file());
|
||||
assert!(!std::path::Path::new(&format!("{root}/esc")).exists());
|
||||
assert!(!std::path::Path::new(&format!("{root}/b.png")).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_data_sets_unique_paths_preserve_variations_and_document() {
|
||||
for (label, names, naming, selected, expected) in [
|
||||
|
||||
@@ -1491,12 +1491,12 @@ pub fn generate_assets(doc: &Document, dir: &str) -> (Vec<String>, Vec<Value>) {
|
||||
(files, errors)
|
||||
}
|
||||
|
||||
/// `<document folder>/<document name>-assets`.
|
||||
/// Whether any pixel of `doc` is not fully transparent.
|
||||
fn has_pixels(doc: &Document) -> bool {
|
||||
photocraft_compose::render(doc, doc.bounds()).px.iter().any(|q| q[3] > 0.0)
|
||||
}
|
||||
|
||||
/// `<document folder>/<document name>-assets`.
|
||||
pub fn assets_dir(doc_path: &str) -> String {
|
||||
let p = std::path::Path::new(doc_path);
|
||||
let dir = p.parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
|
||||
@@ -126,10 +126,17 @@ pub fn read_entry(file: &mut impl std::io::Read, len: u64, max: usize, path: &st
|
||||
/// The saver checks the same bound, so every bundle it writes loads again.
|
||||
const MAX_MANIFEST_DEPTH: usize = 3 * (convert::MAX_GROUP_DEPTH + 1) + 64;
|
||||
|
||||
/// Rejects JSON nested deeper than [`MAX_MANIFEST_DEPTH`] with a linear scan (no recursion), so
|
||||
/// parsing with serde_json's recursion limit disabled can't exhaust the stack on hostile input.
|
||||
/// Most JSON items (arrays and objects opened, plus elements and members after a comma) a
|
||||
/// manifest may hold. Parsing builds a `serde_json::Value` tree of tens of bytes per item, so
|
||||
/// `[0,0,…]` costs about 16× its size in memory; a typical manifest spends 20 bytes or more per
|
||||
/// item, so this still admits manifests of about 80 MiB.
|
||||
const MAX_MANIFEST_ITEMS: usize = 4 << 20;
|
||||
|
||||
/// Rejects JSON nested deeper than [`MAX_MANIFEST_DEPTH`] or holding more than
|
||||
/// [`MAX_MANIFEST_ITEMS`] items with a linear scan (no recursion), so parsing with serde_json's
|
||||
/// recursion limit disabled can't exhaust the stack, nor tiny values the memory, on hostile input.
|
||||
fn check_manifest_depth(json: &[u8]) -> Result<()> {
|
||||
let (mut depth, mut in_str, mut escaped) = (0usize, false, false);
|
||||
let (mut depth, mut items, mut in_str, mut escaped) = (0usize, 0usize, false, false);
|
||||
for &b in json {
|
||||
if in_str {
|
||||
match b {
|
||||
@@ -151,6 +158,12 @@ fn check_manifest_depth(json: &[u8]) -> Result<()> {
|
||||
b']' | b'}' => depth = depth.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
if matches!(b, b'[' | b'{' | b',') {
|
||||
items += 1;
|
||||
if items > MAX_MANIFEST_ITEMS {
|
||||
return Err(FormatError::LimitExceeded(format!("manifest JSON holds more than {MAX_MANIFEST_ITEMS} values")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -128,6 +128,24 @@ fn over_deep_manifest_fails_cleanly() {
|
||||
assert!(matches!(e, FormatError::Corrupt(_)), "{e}");
|
||||
}
|
||||
|
||||
/// A manifest of millions of tiny values (`[0,0,…]` deflates about 1000:1) is refused before
|
||||
/// it is parsed into a JSON tree, which would cost about 16 bytes of memory per input byte.
|
||||
#[test]
|
||||
fn manifest_with_too_many_values_is_refused() {
|
||||
let n = (4 << 20) + 16;
|
||||
let mut m = Vec::with_capacity(2 * n + 1);
|
||||
m.push(b'[');
|
||||
for i in 0..n {
|
||||
m.extend_from_slice(if i == 0 { b"0" } else { b",0" });
|
||||
}
|
||||
m.push(b']');
|
||||
let b = rebuild(&sample(), |name, d| Some(if name == "manifest.json" { m.clone() } else { d }));
|
||||
let e = load_from_bytes(&b).unwrap_err();
|
||||
assert!(matches!(e, FormatError::LimitExceeded(_)), "{e}");
|
||||
let e = read_manifest(&b).unwrap_err();
|
||||
assert!(matches!(e, FormatError::LimitExceeded(_)), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_new_version_rejected() {
|
||||
let b = rebuild(&sample(), |n, d| {
|
||||
|
||||
@@ -211,16 +211,29 @@ impl DeviceHealth {
|
||||
/// wait).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn wait(&self, device: &wgpu::Device, index: Option<wgpu::SubmissionIndex>) -> bool {
|
||||
self.wait_idle(device, index);
|
||||
self.is_ok()
|
||||
}
|
||||
|
||||
/// Like [`Self::wait`], but returns whether the wait completed: `false` also when the poll
|
||||
/// itself failed, so the work may still be running.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn wait_idle(&self, device: &wgpu::Device, index: Option<wgpu::SubmissionIndex>) -> bool {
|
||||
if !self.is_ok() {
|
||||
return false;
|
||||
}
|
||||
let poll = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| device.poll(wgpu::PollType::Wait { submission_index: index, timeout: None })));
|
||||
match poll {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => log::warn!("wgpu poll: {e}"),
|
||||
Err(_) => self.lost_by_poll(),
|
||||
Ok(Ok(_)) => self.is_ok(),
|
||||
Ok(Err(e)) => {
|
||||
log::warn!("wgpu poll: {e}");
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
self.lost_by_poll();
|
||||
false
|
||||
}
|
||||
}
|
||||
self.is_ok()
|
||||
}
|
||||
|
||||
/// Non-blocking poll (reclaims staging memory), skipped once the device is unusable.
|
||||
|
||||
+25
-7
@@ -493,6 +493,9 @@ pub struct Compositor {
|
||||
layer_alloc: Option<String>,
|
||||
/// Bytes of pages dropped to admit new ones, not yet counted towards an intermediate submit.
|
||||
admit_freed: u64,
|
||||
/// Test hook ([`Compositor::fail_layer_allocs`]): how many more layer page textures fail as
|
||||
/// out of memory.
|
||||
fail_allocs: u32,
|
||||
acc_format: wgpu::TextureFormat,
|
||||
/// Whether the effect-map pipelines could be built (else documents with layer effects are
|
||||
/// [`Unsupported`] and use the CPU compositor).
|
||||
@@ -807,6 +810,7 @@ impl Compositor {
|
||||
recorded: 0,
|
||||
layer_alloc: None,
|
||||
admit_freed: 0,
|
||||
fail_allocs: 0,
|
||||
acc_format,
|
||||
effect_maps: map_error.is_none(),
|
||||
cmyk: 0,
|
||||
@@ -847,6 +851,13 @@ impl Compositor {
|
||||
self.page
|
||||
}
|
||||
|
||||
/// Test hook: the next `n` layer page textures fail as if the device were out of memory
|
||||
/// (a real one can't be forced), so the fallback paths of #6563 can be tested.
|
||||
#[doc(hidden)]
|
||||
pub fn fail_layer_allocs(&mut self, n: u32) {
|
||||
self.fail_allocs = n;
|
||||
}
|
||||
|
||||
/// Set the GPU memory resident layer pages may hold (default [`RESIDENT_BUDGET`]), exactly:
|
||||
/// nothing is lent to it ([`Self::fit_layers`]) until the next [`Self::set_memory_budget`].
|
||||
pub fn set_resident_budget(&mut self, bytes: u64) {
|
||||
@@ -1358,12 +1369,15 @@ impl Compositor {
|
||||
self.layer_alloc = None;
|
||||
submit_batch(device, queue, encoder, self.health.as_ref(), self.timer.as_mut(), &mut last_submit);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
match self.health.as_ref() {
|
||||
Some(h) => {
|
||||
h.wait(device, last_submit.clone());
|
||||
}
|
||||
None => {
|
||||
let _ = device.poll(wgpu::PollType::Wait { submission_index: last_submit.clone(), timeout: None });
|
||||
{
|
||||
let idle = match self.health.as_ref() {
|
||||
Some(h) => h.wait_idle(device, last_submit.clone()),
|
||||
None => device.poll(wgpu::PollType::Wait { submission_index: last_submit.clone(), timeout: None }).is_ok(),
|
||||
};
|
||||
// The GPU has run every timestamp submitted so far, so a retry that fails too
|
||||
// can drop this measurement without switching timing off for good.
|
||||
if idle && let Some(t) = self.timer.as_mut() {
|
||||
t.drained();
|
||||
}
|
||||
}
|
||||
self.staged = 0;
|
||||
@@ -1506,7 +1520,11 @@ impl Compositor {
|
||||
return Some(device.create_texture(desc));
|
||||
}
|
||||
for retry in [false, true] {
|
||||
let (texture, error) = first_error(device, || device.create_texture(desc));
|
||||
let (texture, mut error) = first_error(device, || device.create_texture(desc));
|
||||
if error.is_none() && self.fail_allocs > 0 {
|
||||
self.fail_allocs -= 1;
|
||||
error = Some(wgpu::Error::OutOfMemory { source: "out of memory (test hook)".into() });
|
||||
}
|
||||
match error {
|
||||
None => return Some(texture),
|
||||
Some(wgpu::Error::OutOfMemory { .. }) if !retry => {
|
||||
|
||||
@@ -95,8 +95,9 @@ pub struct GpuTimer {
|
||||
/// A recording was abandoned after it had already submitted timestamps. Writing a query
|
||||
/// again resets that slot (wgpu-core resets per write), and resetting one an in-flight
|
||||
/// submission is still writing is undefined, with no way to learn when it has drained.
|
||||
/// That only happens when a refresh fails after a flush, which on this path means the
|
||||
/// device faulted and the canvas is about to drop to the CPU compositor anyway, so
|
||||
/// That only happens when a refresh fails after a flush that nothing waited for (a refresh
|
||||
/// whose page retry waited for the GPU calls [`Self::drained`] first), which is rare: usually
|
||||
/// the device faulted and the canvas is about to drop to the CPU compositor anyway. So
|
||||
/// measuring simply stops: no number, which is the contract everywhere else here.
|
||||
stopped: bool,
|
||||
/// Timestamps and pixels of the measurement being read back, and its readback flag.
|
||||
@@ -236,6 +237,12 @@ impl GpuTimer {
|
||||
self.flushed = false;
|
||||
}
|
||||
|
||||
/// The caller waited until the GPU finished everything submitted so far: no timestamp of
|
||||
/// this recording is still in flight, so [`Self::cancel`] can reuse its slots safely.
|
||||
pub fn drained(&mut self) {
|
||||
self.flushed = false;
|
||||
}
|
||||
|
||||
/// Whether a measurement is being read back (so this refresh won't be measured).
|
||||
pub fn busy(&self) -> bool {
|
||||
self.in_flight.is_some()
|
||||
|
||||
@@ -2915,6 +2915,59 @@ fn layer_pages_are_admitted_under_a_zero_budget_and_stay_on_the_gpu() {
|
||||
check(&mut g, &d, "after zero-budget renders");
|
||||
}
|
||||
|
||||
/// A layer page texture the device has no memory for (#6563): the compositor drops the pages
|
||||
/// this cell doesn't use and tries again, then submits, waits and runs the cell once more, and
|
||||
/// only then ends this refresh on the CPU. The device stays healthy and the next refresh is back
|
||||
/// on the GPU. The test hook makes the next `n` page textures fail as out of memory.
|
||||
#[test]
|
||||
fn a_layer_page_out_of_memory_retries_then_ends_only_that_refresh_on_the_cpu() {
|
||||
let Some(mut g) = gpu() else { return };
|
||||
let mut d = base_doc(600, 500);
|
||||
d.layers.push(noise_layer("mid", PixelFormat::RGBA8, Rect::new(-20, 10, 590, 480), 71, 0.0));
|
||||
let mut top = noise_layer("top", PixelFormat::RGBA8, Rect::new(30, -15, 620, 510), 72, 0.2);
|
||||
top.blend = BlendMode::Multiply;
|
||||
d.layers.push(top);
|
||||
g.paged.set_composite_cache_budget(0);
|
||||
let cpu = photocraft_compose::flatten(&d);
|
||||
let encode = |g: &mut Gpu, d: &Document| {
|
||||
let mut encoder = g.device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
|
||||
g.paged.encode(&g.device, &g.queue, &mut encoder, d, d.bounds(), &mut |_, _| {})
|
||||
};
|
||||
let render = |g: &mut Gpu, d: &Document| photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, d, d.bounds());
|
||||
let matches = |out: &[[f32; 4]], what: &str| {
|
||||
let worst = worst_diff(&cpu.px, out);
|
||||
assert!(worst.0 <= TOL, "{what}: max diff {:.2}/255 at {}", worst.0 * 255.0, worst.1);
|
||||
};
|
||||
// Every case starts with no pages, so the first page texture is the one that fails.
|
||||
let fresh = |g: &mut Gpu, d: &Document, n: u32| {
|
||||
g.paged.forget_doc(d.id);
|
||||
g.paged.fail_layer_allocs(n);
|
||||
};
|
||||
|
||||
// One failure: the second try right away succeeds (`encode` can't submit, so this is the
|
||||
// only retry it has).
|
||||
fresh(&mut g, &d, 1);
|
||||
encode(&mut g, &d).unwrap_or_else(|e| panic!("one failed page texture is retried at once: {}", e.0));
|
||||
// Two: both tries fail, and `encode` gives the refresh to the CPU.
|
||||
fresh(&mut g, &d, 2);
|
||||
let e = encode(&mut g, &d).expect_err("encode can't submit to free memory");
|
||||
assert_eq!(e.0, photocraft_gpu::OVER_BUDGET);
|
||||
assert!(g.paged.fault().is_none(), "out of memory for a page doesn't lose the device");
|
||||
// `render` submits, waits and runs the cell once more, which then succeeds.
|
||||
fresh(&mut g, &d, 2);
|
||||
let (out, s) = render(&mut g, &d).unwrap_or_else(|e| panic!("render retries after submitting: {}", e.0));
|
||||
assert!(s.flushes > 0, "{s:?}");
|
||||
matches(&out, "after the submit-and-retry");
|
||||
// Four: the retry after the submit fails as well, so this refresh goes to the CPU...
|
||||
fresh(&mut g, &d, 4);
|
||||
let e = render(&mut g, &d).expect_err("every try failed");
|
||||
assert_eq!(e.0, photocraft_gpu::OVER_BUDGET);
|
||||
assert!(g.paged.fault().is_none(), "out of memory for a page doesn't lose the device");
|
||||
// ...and the next one is back on the GPU.
|
||||
let (out, _) = render(&mut g, &d).unwrap_or_else(|e| panic!("the refresh after an out-of-memory one is on the GPU: {}", e.0));
|
||||
matches(&out, "the refresh after the CPU one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_focused_view_stays_resident_after_a_full_refresh() {
|
||||
let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
@@ -287,3 +287,25 @@ fn a_failed_refresh_that_recorded_nothing_keeps_the_timer_alive() {
|
||||
let after = after.expect("the timer still measures after a refresh that recorded nothing");
|
||||
assert_eq!(after.px, 4096, "the measurement belongs to the refresh that produced it");
|
||||
}
|
||||
|
||||
/// A refresh whose layer pages ran out of GPU memory even after the submit-and-retry ends on the
|
||||
/// CPU (#6563), but measuring goes on: the retry waited for the GPU, so none of the dropped
|
||||
/// measurement's timestamps were still in flight.
|
||||
#[test]
|
||||
fn an_out_of_memory_refresh_does_not_stop_gpu_timing() {
|
||||
let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let Some((device, queue, mut comp)) = gpu() else { return };
|
||||
let doc = document(Size::new(640, 480));
|
||||
let full = doc.bounds();
|
||||
// The first page texture fails twice, and twice more in the retry after the submit.
|
||||
comp.fail_layer_allocs(4);
|
||||
assert!(comp.render(&device, &queue, &doc, full, |_, _| {}).is_err(), "every try failed");
|
||||
let mut timing = None;
|
||||
for _ in 0..4 {
|
||||
timing = measure(&device, &queue, &mut comp, &doc, full).2;
|
||||
if timing.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(timing.is_some(), "GPU timing stopped for good after one out-of-memory refresh");
|
||||
}
|
||||
|
||||
@@ -352,6 +352,22 @@ fn krita_soft_light_ids_map_to_photoshop_soft_light() {
|
||||
assert!(!all.contains("soft_light_svg"), "{all}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_light_exports_as_svg_soft_light() {
|
||||
// The W3C id, not krita:soft_light_svg (which import also accepts): other applications read it.
|
||||
let mut d = doc(SampleType::U8);
|
||||
let mut soft = block("Soft", PixelFormat::RGBA8, 0, 0, 10, 10, 5);
|
||||
soft.blend = BlendMode::SoftLight;
|
||||
d.layers.push(soft);
|
||||
let out = export(&d).unwrap();
|
||||
let xml = String::from_utf8(ZipReader::new(&out.bytes).unwrap().read_by_name("stack.xml", 1 << 20).unwrap()).unwrap();
|
||||
assert!(xml.contains("name=\"Soft\"") && xml.contains("composite-op=\"svg:soft-light\""), "{xml}");
|
||||
assert!(!xml.contains("soft_light"), "{xml}");
|
||||
let (back, exported, imported) = round_trip(&d);
|
||||
assert!(back.layers.iter().any(|l| l.name == "Soft" && l.blend == BlendMode::SoftLight));
|
||||
assert!(!exported.iter().chain(&imported).any(|w| w.to_lowercase().contains("soft")), "{exported:?} {imported:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_surrounding_whitespace_in_layer_and_group_names() {
|
||||
let xml = r#"<image w="2" h="2"><stack>
|
||||
|
||||
@@ -203,6 +203,86 @@ fn system_generic_family(_generic: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Most font file bytes [`in_memory`] keeps; past it a face stays on disk (slow, still right).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_CACHED_FONT_BYTES: usize = 256 << 20;
|
||||
|
||||
/// `id`, or the same face loaded from memory when the database names a file for it. fontdb
|
||||
/// re-reads a system font's whole file for every glyph usvg outlines (about a millisecond a
|
||||
/// character with Times New Roman), so a short entity-expanded text could keep an import busy
|
||||
/// for minutes. Each file is read once per process; the parse's own database copy then holds
|
||||
/// every face of that file from memory.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn in_memory(id: usvg::fontdb::ID, db: &mut Arc<usvg::fontdb::Database>) -> usvg::fontdb::ID {
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
use usvg::fontdb::Source;
|
||||
type Files = (HashMap<PathBuf, Arc<Vec<u8>>>, usize);
|
||||
static FILES: OnceLock<Mutex<Files>> = OnceLock::new();
|
||||
static LOADING: Mutex<()> = Mutex::new(());
|
||||
let Some((Source::File(path), index)) = db.face_source(id) else { return id };
|
||||
let files = || FILES.get_or_init(Mutex::default).lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let fits = |files: &Files, len: usize| files.1.saturating_add(len) <= MAX_CACHED_FONT_BYTES;
|
||||
let cached = files().0.get(&path).cloned();
|
||||
// One miss at a time, so imports wanting the same file read it once: a second one waits
|
||||
// here, then takes the bytes the first one kept.
|
||||
let loading = cached.is_none().then(|| LOADING.lock().unwrap_or_else(PoisonError::into_inner));
|
||||
let cached = cached.or_else(|| files().0.get(&path).cloned());
|
||||
let bytes = match cached {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
// The size first: a file past the budget isn't read at all, on this lookup or the next.
|
||||
let Ok(len) = std::fs::metadata(&path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)) else { return id };
|
||||
if !fits(&files(), len) {
|
||||
return id;
|
||||
}
|
||||
let Ok(b) = std::fs::read(&path) else { return id };
|
||||
let b = Arc::new(b);
|
||||
// The face has to load from the bytes before they're kept or the file's faces leave
|
||||
// the database: a file replaced or cut short since fontdb indexed it stays on disk.
|
||||
let mut probe = usvg::fontdb::Database::new();
|
||||
let ids = probe.load_font_source(Source::Binary(b.clone()));
|
||||
if !ids.iter().any(|&n| probe.face_source(n).is_some_and(|(_, i)| i == index)) {
|
||||
return id;
|
||||
}
|
||||
let mut files = files();
|
||||
if !fits(&files, b.len()) {
|
||||
return id;
|
||||
}
|
||||
files.1 = files.1.saturating_add(b.len());
|
||||
files.0.insert(path.clone(), b.clone());
|
||||
b
|
||||
}
|
||||
};
|
||||
drop(loading);
|
||||
let db = Arc::make_mut(db);
|
||||
let same_file: Vec<_> = db.faces().filter(|f| matches!(&f.source, Source::File(p) if *p == path)).map(|f| f.id).collect();
|
||||
// Last first: fontdb's slots are reused last-freed-first, so the file's faces come back in
|
||||
// their old places and order, and the fallback search (first face with the character)
|
||||
// still finds the face it finds on disk.
|
||||
for f in same_file.into_iter().rev() {
|
||||
db.remove_face(f);
|
||||
}
|
||||
let loaded = db.load_font_source(Source::Binary(bytes));
|
||||
loaded.into_iter().find(|&n| db.face_source(n).is_some_and(|(_, i)| i == index)).unwrap_or(id)
|
||||
}
|
||||
|
||||
/// usvg's font choice, with the chosen faces read from memory ([`in_memory`]).
|
||||
fn font_resolver() -> usvg::FontResolver<'static> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let select = usvg::FontResolver::default_font_selector();
|
||||
let fallback = usvg::FontResolver::default_fallback_selector();
|
||||
usvg::FontResolver {
|
||||
select_font: Box::new(move |font, db| select(font, db).map(|id| in_memory(id, db))),
|
||||
select_fallback: Box::new(move |c, used, db| fallback(c, used, db).map(|id| in_memory(id, db))),
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
usvg::FontResolver::default()
|
||||
}
|
||||
|
||||
/// Parse options for an untrusted drawing. `<image href>` resolves only `data:` URLs: a file path
|
||||
/// or relative href is never read, so opening an SVG cannot pull local files (`/etc/...`,
|
||||
/// `~/.ssh/...`) into the document. Embedded SVG images get no `<image>` resolution at all
|
||||
@@ -239,6 +319,7 @@ fn options(fontdb: Arc<usvg::fontdb::Database>) -> usvg::Options<'static> {
|
||||
resources_dir: None,
|
||||
image_href_resolver: usvg::ImageHrefResolver { resolve_data, resolve_string: Box::new(|_, _| None) },
|
||||
fontdb,
|
||||
font_resolver: font_resolver(),
|
||||
..usvg::Options::default()
|
||||
}
|
||||
}
|
||||
@@ -1092,6 +1173,154 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #9406: a font installed on disk (how the system fonts load) is read once, not once per
|
||||
/// glyph: fontdb re-reads a file source's whole font on every glyph it outlines, about a
|
||||
/// millisecond a character. The face text is set in comes from memory, and the outlines
|
||||
/// match the same font loaded from memory in the first place.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn text_in_a_font_on_disk_is_set_from_memory() {
|
||||
fn inter_on_disk() -> Arc<usvg::fontdb::Database> {
|
||||
let mut db = usvg::fontdb::Database::new();
|
||||
let _ = db.load_font_file(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/Inter-Regular.ttf"));
|
||||
Arc::new(db)
|
||||
}
|
||||
assert!(inter_on_disk().faces().all(|f| matches!(f.source, usvg::fontdb::Source::File(_))));
|
||||
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"120\" height=\"60\"><text x=\"5\" y=\"45\" font-size=\"48\" font-family=\"Inter\">IHI</text></svg>";
|
||||
let parsed = parse_with(svg, inter_on_disk).unwrap();
|
||||
let faces: Vec<_> = parsed.tree.fontdb().faces().map(|f| matches!(f.source, usvg::fontdb::Source::Binary(_))).collect();
|
||||
assert_eq!(faces, [true], "the face text was set in is held in memory");
|
||||
let on_disk = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap();
|
||||
let in_memory = import_parsed("t", parse_with(svg, inter_only).unwrap(), MAX_GROUP_DEPTH).unwrap();
|
||||
assert_eq!(on_disk.document.layers.first().map(|l| l.name.as_str()), Some("IHI"));
|
||||
let outlines = |r: &ImportResult| {
|
||||
let groups = r.document.layers.iter().filter_map(|l| if let LayerContent::Group(g) = &l.content { Some(g) } else { None });
|
||||
groups
|
||||
.flat_map(|g| &g.children)
|
||||
.filter_map(|l| if let LayerContent::Shape(s) = &l.content { Some(s.path.clone()) } else { None })
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert!(outlines(&on_disk).iter().any(|p| !p.subpaths.is_empty()), "the text has outlines");
|
||||
assert_eq!(outlines(&on_disk), outlines(&in_memory));
|
||||
}
|
||||
|
||||
/// A font file that no longer loads (replaced or cut short since fontdb indexed it) keeps its
|
||||
/// faces in the database and isn't remembered: once the file is whole again, text is set
|
||||
/// from it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_font_file_gone_bad_since_indexing_is_not_cached() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static BAD: AtomicBool = AtomicBool::new(true);
|
||||
fn indexed_then_overwritten() -> Arc<usvg::fontdb::Database> {
|
||||
let path = std::env::temp_dir().join(format!("photocraft-svg-test-bad-{}.ttf", std::process::id()));
|
||||
std::fs::write(&path, include_bytes!("../../../assets/fonts/Inter-Regular.ttf")).unwrap();
|
||||
let mut db = usvg::fontdb::Database::new();
|
||||
db.load_font_file(&path).unwrap();
|
||||
if BAD.load(Ordering::SeqCst) {
|
||||
std::fs::write(&path, b"not a font any more").unwrap();
|
||||
}
|
||||
Arc::new(db)
|
||||
}
|
||||
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"120\" height=\"60\"><text x=\"5\" y=\"45\" font-family=\"Inter\">IHI</text></svg>";
|
||||
let parsed = parse_with(svg, indexed_then_overwritten).unwrap();
|
||||
let sources: Vec<_> = parsed.tree.fontdb().faces().map(|f| matches!(f.source, usvg::fontdb::Source::File(_))).collect();
|
||||
assert_eq!(sources, [true], "the face stays in the database, on disk");
|
||||
BAD.store(false, Ordering::SeqCst);
|
||||
let parsed = parse_with(svg, indexed_then_overwritten).unwrap();
|
||||
let sources: Vec<_> = parsed.tree.fontdb().faces().map(|f| matches!(f.source, usvg::fontdb::Source::Binary(_))).collect();
|
||||
assert_eq!(sources, [true], "the whole file is read into memory now");
|
||||
let layers = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap().document.layers;
|
||||
assert_eq!(layers.first().map(|l| l.name.as_str()), Some("IHI"));
|
||||
assert!(
|
||||
layers.iter().any(|l| matches!(&l.content, LayerContent::Group(g) if g.children.iter().any(|c| matches!(&c.content, LayerContent::Shape(s) if !s.path.subpaths.is_empty())))),
|
||||
"the text has outlines"
|
||||
);
|
||||
}
|
||||
|
||||
/// Imports that miss the cache for the same file at once read it once: every one sets its
|
||||
/// text from the same bytes, not a copy of its own next to the kept one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn imports_missing_the_same_font_at_once_share_one_read() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
static ROUND: AtomicUsize = AtomicUsize::new(0);
|
||||
fn fresh_file() -> Arc<usvg::fontdb::Database> {
|
||||
let round = ROUND.load(Ordering::SeqCst);
|
||||
let path = std::env::temp_dir().join(format!("photocraft-svg-test-shared-{}-{round}.ttf", std::process::id()));
|
||||
let mut db = usvg::fontdb::Database::new();
|
||||
db.load_font_file(&path).unwrap();
|
||||
Arc::new(db)
|
||||
}
|
||||
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"120\" height=\"60\"><text x=\"5\" y=\"45\" font-family=\"Inter\">IHI</text></svg>";
|
||||
for round in 0..5 {
|
||||
ROUND.store(round, Ordering::SeqCst);
|
||||
let path = std::env::temp_dir().join(format!("photocraft-svg-test-shared-{}-{round}.ttf", std::process::id()));
|
||||
std::fs::write(&path, include_bytes!("../../../assets/fonts/Inter-Regular.ttf")).unwrap();
|
||||
let start = Arc::new(std::sync::Barrier::new(8));
|
||||
let threads: Vec<_> = (0..8)
|
||||
.map(|_| {
|
||||
let start = start.clone();
|
||||
std::thread::spawn(move || {
|
||||
start.wait();
|
||||
let parsed = parse_with(svg, fresh_file).unwrap();
|
||||
let bytes: Vec<_> = parsed
|
||||
.tree
|
||||
.fontdb()
|
||||
.faces()
|
||||
.filter_map(|f| if let usvg::fontdb::Source::Binary(b) = &f.source { Some(Arc::as_ptr(b).cast::<u8>() as usize) } else { None })
|
||||
.collect();
|
||||
bytes
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let bytes: Vec<_> = threads.into_iter().flat_map(|t| t.join().unwrap()).collect();
|
||||
assert_eq!(bytes.len(), 8, "every import set its text from memory");
|
||||
assert!(bytes.iter().all(|&b| b == bytes[0]), "round {round}: {bytes:x?}");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
/// #9406: moving a font collection into memory keeps its faces in their order, so the
|
||||
/// fallback search for a later text still finds the face it found on disk first.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_collection_in_memory_keeps_its_face_order() {
|
||||
fn collection_on_disk() -> Arc<usvg::fontdb::Database> {
|
||||
// A two-face .ttc of the bundled Inter Regular and SemiBold: the `ttcf` header, then
|
||||
// each font with its table offsets moved by where it starts.
|
||||
let fonts: [&[u8]; 2] = [include_bytes!("../../../assets/fonts/Inter-Regular.ttf"), include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf")];
|
||||
let mut ttc = b"ttcf | ||||