mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
Integration: track t02
# Conflicts: # contributors/people.toml # crates/engine/src/file_cmds/tests.rs # crates/engine/src/print_cmds/tests.rs # crates/ui-egui/src/i18n/cs.tsv # crates/ui-egui/src/i18n/de.tsv # crates/ui-egui/src/i18n/el.tsv # crates/ui-egui/src/i18n/es.tsv # crates/ui-egui/src/i18n/fr.tsv # crates/ui-egui/src/i18n/hu.tsv # crates/ui-egui/src/i18n/id.tsv # crates/ui-egui/src/i18n/it.tsv # crates/ui-egui/src/i18n/ja.tsv # crates/ui-egui/src/i18n/ko.tsv # crates/ui-egui/src/i18n/nl.tsv # crates/ui-egui/src/i18n/pl.tsv # crates/ui-egui/src/i18n/pt-br.tsv # crates/ui-egui/src/i18n/ru.tsv # crates/ui-egui/src/i18n/sr.tsv # crates/ui-egui/src/i18n/tr.tsv # crates/ui-egui/src/i18n/uk.tsv # crates/ui-egui/src/i18n/vi.tsv # crates/ui-egui/src/i18n/zh-hans.tsv # crates/ui-egui/src/i18n/zh-hant.tsv
This commit is contained in:
@@ -201,10 +201,10 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
};
|
||||
if multiple {
|
||||
let picked = dialog.pick_files();
|
||||
Box::pin(async move { picked.await.map(|files| FileDialogAnswer::Paths(files.iter().map(path_of).collect())) })
|
||||
Box::pin(async move { picked.await.map(|files| answer(files.iter().map(|f| f.path()), false, FileDialogAnswer::Paths)) })
|
||||
} else {
|
||||
let picked = dialog.pick_file();
|
||||
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) })
|
||||
Box::pin(async move { picked.await.map(|file| answer([file.path()], false, FileDialogAnswer::Paths)) })
|
||||
}
|
||||
}
|
||||
FileDialogRequest::Folder { initial_dir } => {
|
||||
@@ -212,7 +212,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
dialog = dialog.set_directory(dir);
|
||||
}
|
||||
let picked = dialog.pick_folder();
|
||||
Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
|
||||
Box::pin(async move { picked.await.map(|folder| answer_one(folder.path(), true, FileDialogAnswer::FolderTo)) })
|
||||
}
|
||||
FileDialogRequest::Save { suggested } => {
|
||||
let filters = save_filters(&suggested);
|
||||
@@ -247,10 +247,10 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|conn| crate::portal_save::save_as(&conn, req));
|
||||
match portal {
|
||||
Ok(path) => path.map(|p| FileDialogAnswer::SaveTo(p.to_string_lossy().into_owned())),
|
||||
Ok(path) => path.map(|p| answer_one(&p, true, FileDialogAnswer::SaveTo)),
|
||||
Err(e) => {
|
||||
log::info!("portal save dialog unavailable ({e}), using rfd");
|
||||
dialog.save_file().await.map(|file| FileDialogAnswer::SaveTo(path_of(&file)))
|
||||
dialog.save_file().await.map(|file| answer_one(file.path(), true, FileDialogAnswer::SaveTo))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -259,7 +259,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
{
|
||||
let _ = filters;
|
||||
let picked = dialog.save_file();
|
||||
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) })
|
||||
Box::pin(async move { picked.await.map(|file| answer_one(file.path(), true, FileDialogAnswer::SaveTo)) })
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -273,8 +273,22 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
}
|
||||
}
|
||||
|
||||
fn path_of(file: &rfd::FileHandle) -> String {
|
||||
file.path().to_string_lossy().into_owned()
|
||||
/// The dialog's answer for the chosen `paths`, or the error for one whose name isn't valid
|
||||
/// Unicode, which a lossy copy would turn into another file's name (#6601). `saving`: the paths
|
||||
/// are to be written to.
|
||||
fn answer<'a>(paths: impl IntoIterator<Item = &'a Path>, saving: bool, make: impl FnOnce(Vec<String>) -> FileDialogAnswer) -> FileDialogAnswer {
|
||||
match paths.into_iter().map(|p| photocraft_ui_egui::file_open::path_text(p, saving)).collect() {
|
||||
Ok(paths) => make(paths),
|
||||
Err(e) => FileDialogAnswer::Unavailable(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`answer`] for a dialog that picks one path.
|
||||
fn answer_one(path: &Path, saving: bool, make: impl FnOnce(String) -> FileDialogAnswer) -> FileDialogAnswer {
|
||||
match photocraft_ui_egui::file_open::path_text(path, saving) {
|
||||
Ok(path) => make(path),
|
||||
Err(e) => FileDialogAnswer::Unavailable(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `future` to completion on this thread, sleeping until it is woken.
|
||||
@@ -757,6 +771,31 @@ mod tests {
|
||||
use photocraft_ui_egui::{PhotocraftApp, prefs_ui};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// #6601: a chosen path whose name isn't valid Unicode is reported, never handed on lossily
|
||||
/// (Open would fail with "No such file", Save would write a second, renamed file).
|
||||
#[test]
|
||||
fn a_chosen_path_that_is_not_valid_unicode_is_an_error_not_another_name() {
|
||||
#[cfg(unix)]
|
||||
let bad = {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
std::env::temp_dir().join(std::ffi::OsStr::from_bytes(b"img\xFF.png"))
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let bad = {
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
let mut wide: Vec<u16> = "img".encode_utf16().collect();
|
||||
wide.push(0xD800);
|
||||
wide.extend(".png".encode_utf16());
|
||||
std::env::temp_dir().join(std::ffi::OsString::from_wide(&wide))
|
||||
};
|
||||
let good = std::env::temp_dir().join("ok.png");
|
||||
let save = |path: &Path| answer_one(path, true, FileDialogAnswer::SaveTo);
|
||||
assert!(matches!(save(&bad), FileDialogAnswer::Unavailable(e) if e.contains("not valid Unicode") && e.contains("another name")));
|
||||
assert!(matches!(save(&good), FileDialogAnswer::SaveTo(p) if p == good.to_str().unwrap()));
|
||||
let open = answer([good.as_path(), bad.as_path()], false, FileDialogAnswer::Paths);
|
||||
assert!(matches!(open, FileDialogAnswer::Unavailable(e) if e.contains("open it again")));
|
||||
}
|
||||
|
||||
/// #6121: the file type picked in the save dialog gives the name its extension where the
|
||||
/// dialog left the one it opened with, and only there.
|
||||
#[test]
|
||||
|
||||
@@ -197,3 +197,7 @@ sync_github_name = true
|
||||
|
||||
[people.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
|
||||
[people.AndreeSalazar]
|
||||
real_name = "Andreé Salazar"
|
||||
sync_github_name = true
|
||||
|
||||
@@ -676,8 +676,8 @@ mod stereo_tests {
|
||||
|
||||
#[test]
|
||||
fn reads_both_defined_views() {
|
||||
assert_eq!(stereo(&png_with(&[0])), Some(PngStereo::Horizontal));
|
||||
assert_eq!(stereo(&png_with(&[1])), Some(PngStereo::Vertical));
|
||||
assert_eq!(stereo(&png_with(&[0])), Some(PngStereo::CrossFuse));
|
||||
assert_eq!(stereo(&png_with(&[1])), Some(PngStereo::DivergingFuse));
|
||||
assert_eq!(stereo(&png_with(&[])), None);
|
||||
// Reserved and unknown mode bytes are not a pairing we can keep.
|
||||
assert_eq!(stereo(&png_with(&[2])), None);
|
||||
@@ -687,6 +687,24 @@ mod stereo_tests {
|
||||
assert_eq!(stereo(b"not a png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ster_mode_bytes_describe_eye_order_not_vertical_packing() {
|
||||
// PNG Extensions 1.4.0 §3.6: both views occupy horizontal halves.
|
||||
// Each view is eight columns, so the second starts on an eight-column boundary.
|
||||
for (mode, left_pixel, right_pixel) in [(0, [0, 0, 255], [255, 0, 0]), (1, [255, 0, 0], [0, 0, 255])] {
|
||||
let pixels: Vec<u8> = [left_pixel; 8].into_iter().chain([right_pixel; 8]).flatten().collect();
|
||||
let img = Image::from_raw(16, 1, ChannelLayout::Rgb, SampleType::U8, pixels.clone()).unwrap();
|
||||
let pairing = if mode == 0 { PngStereo::CrossFuse } else { PngStereo::DivergingFuse };
|
||||
let opts = EncodeOptions { png_stereo: Some(pairing), ..Default::default() };
|
||||
let bytes = crate::encode(&img, Format::Png, &opts).unwrap();
|
||||
let chunk = bytes.windows(4).position(|w| w == b"sTER").unwrap();
|
||||
assert_eq!(bytes[chunk + 4], mode, "the specification's literal mode byte");
|
||||
let decoded = decode(&bytes, &Limits::default()).unwrap();
|
||||
assert_eq!(decoded.dimensions(), (16, 1));
|
||||
assert_eq!(decoded.data(), pixels);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_crc_is_not_a_stereo_mark() {
|
||||
let mut bytes = png_with(&[0]);
|
||||
@@ -699,7 +717,7 @@ mod stereo_tests {
|
||||
#[test]
|
||||
fn encode_writes_the_chunk_before_idat_and_it_reads_back() {
|
||||
let img = Image::from_raw(2, 1, ChannelLayout::Rgb, SampleType::U8, vec![9; 6]).unwrap();
|
||||
for view in [PngStereo::Horizontal, PngStereo::Vertical] {
|
||||
for view in [PngStereo::CrossFuse, PngStereo::DivergingFuse] {
|
||||
let opts = EncodeOptions { png_stereo: Some(view), ..Default::default() };
|
||||
let bytes = crate::encode(&img, Format::Png, &opts).unwrap();
|
||||
assert_eq!(stereo(&bytes), Some(view));
|
||||
@@ -716,12 +734,12 @@ mod stereo_tests {
|
||||
#[test]
|
||||
fn interlaced_and_indexed_encoders_keep_the_mark() {
|
||||
let img = Image::from_raw(2, 2, ChannelLayout::Rgb, SampleType::U8, vec![9; 12]).unwrap();
|
||||
let opts = EncodeOptions { png_stereo: Some(PngStereo::Vertical), png_interlaced: true, ..Default::default() };
|
||||
let opts = EncodeOptions { png_stereo: Some(PngStereo::DivergingFuse), png_interlaced: true, ..Default::default() };
|
||||
let bytes = crate::encode(&img, Format::Png, &opts).unwrap();
|
||||
assert_eq!(stereo(&bytes), Some(PngStereo::Vertical));
|
||||
assert_eq!(stereo(&bytes), Some(PngStereo::DivergingFuse));
|
||||
let pal = [[255, 0, 0], [0, 0, 255]];
|
||||
let bytes = encode_indexed(2, 1, &[0, 1], &pal, None, None, Some(PngStereo::Horizontal)).unwrap();
|
||||
assert_eq!(stereo(&bytes), Some(PngStereo::Horizontal));
|
||||
let bytes = encode_indexed(2, 1, &[0, 1], &pal, None, None, Some(PngStereo::CrossFuse)).unwrap();
|
||||
assert_eq!(stereo(&bytes), Some(PngStereo::CrossFuse));
|
||||
assert_eq!(palette(&bytes).unwrap().map(|p| p.colors), Some(pal.to_vec()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,3 +88,88 @@ fn rle_row(row: &[u8], bpp: usize, out: &mut Vec<u8>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn run_packets_at_the_128_pixel_boundary() {
|
||||
for bpp in 1..=4 {
|
||||
for (pixels, headers) in [(1, vec![0]), (2, vec![0x81]), (127, vec![0xfe]), (128, vec![0xff]), (129, vec![0xff, 0])] {
|
||||
let pixel: Vec<u8> = (1..=bpp as u8).collect();
|
||||
let mut encoded = Vec::new();
|
||||
rle_row(&pixel.repeat(pixels), bpp, &mut encoded);
|
||||
let expected: Vec<u8> = headers.into_iter().flat_map(|header| std::iter::once(header).chain(pixel.iter().copied())).collect();
|
||||
assert_eq!(encoded, expected, "{pixels} pixels, {bpp} bytes per pixel");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_packets_at_the_128_pixel_boundary() {
|
||||
for bpp in 1..=4 {
|
||||
for pixels in [127usize, 128, 129] {
|
||||
let row: Vec<u8> = (0..pixels).flat_map(|x| vec![x as u8; bpp]).collect();
|
||||
let mut encoded = Vec::new();
|
||||
rle_row(&row, bpp, &mut encoded);
|
||||
let mut expected = vec![(pixels.min(128) - 1) as u8];
|
||||
expected.extend_from_slice(&row[..pixels.min(128) * bpp]);
|
||||
if pixels > 128 {
|
||||
expected.push(0);
|
||||
expected.extend_from_slice(&row[128 * bpp..]);
|
||||
}
|
||||
assert_eq!(encoded, expected, "{pixels} pixels, {bpp} bytes per pixel");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_packets_stop_before_a_run_of_complete_pixels() {
|
||||
// Equal first channels alone do not make an RGBA run; alpha participates too.
|
||||
let a = [10, 20, 30, 40];
|
||||
let b = [10, 20, 30, 41];
|
||||
let c = [50, 60, 70, 80];
|
||||
let row = [a, b, c, c, a].concat();
|
||||
let mut encoded = Vec::new();
|
||||
rle_row(&row, 4, &mut encoded);
|
||||
assert_eq!(encoded, [vec![1], a.to_vec(), b.to_vec(), vec![0x81], c.to_vec(), vec![0], a.to_vec()].concat());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_sample_types_and_layouts_targa_cannot_store() {
|
||||
for sample in [SampleType::U16, SampleType::F16, SampleType::F32] {
|
||||
let img = Image::new(1, 1, ChannelLayout::Rgb, sample).unwrap();
|
||||
assert!(matches!(encode(&img), Err(CodecError::Encode { format: Format::Tga, .. })), "{sample:?}");
|
||||
}
|
||||
for layout in [ChannelLayout::Cmyk, ChannelLayout::CmykA, ChannelLayout::Lab, ChannelLayout::LabA] {
|
||||
let img = Image::new(1, 1, layout, SampleType::U8).unwrap();
|
||||
assert!(matches!(encode(&img), Err(CodecError::Encode { format: Format::Tga, .. })), "{layout:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_fit_the_unsigned_16_bit_header() {
|
||||
for (width, height) in [(65535, 1), (1, 65535)] {
|
||||
let img = Image::new(width, height, ChannelLayout::Gray, SampleType::U8).unwrap();
|
||||
let file = encode(&img).unwrap();
|
||||
assert_eq!(&file[12..14], &(width as u16).to_le_bytes());
|
||||
assert_eq!(&file[14..16], &(height as u16).to_le_bytes());
|
||||
}
|
||||
for (width, height) in [(65536, 1), (1, 65536)] {
|
||||
let img = Image::new(width, height, ChannelLayout::Gray, SampleType::U8).unwrap();
|
||||
assert!(matches!(encode(&img), Err(CodecError::Encode { format: Format::Tga, .. })), "{width}x{height}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_buffers_are_rejected_before_targa_encoding() {
|
||||
// Image keeps its buffer private, so malformed buffers must fail at construction.
|
||||
for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] {
|
||||
let len = 2 * 2 * layout.channels();
|
||||
for bytes in [len - 1, len + 1] {
|
||||
assert!(matches!(Image::from_u8(2, 2, layout, vec![0; bytes]), Err(CodecError::InvalidImage(_))), "{layout:?}: {bytes} bytes");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,18 +103,18 @@ pub enum PngCompression {
|
||||
/// How a PNG's `sTER` chunk pairs its two views (PNG Extensions 1.4.0, §3.6).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PngStereo {
|
||||
/// Left and right views side by side, left first.
|
||||
Horizontal,
|
||||
/// Left and right views stacked, left on top.
|
||||
Vertical,
|
||||
/// Side by side with the right-eye view first (cross-eyed viewing, mode 0).
|
||||
CrossFuse,
|
||||
/// Side by side with the left-eye view first (parallel viewing, mode 1).
|
||||
DivergingFuse,
|
||||
}
|
||||
|
||||
impl PngStereo {
|
||||
/// The view for an `sTER` mode byte; reserved and unknown values are `None`.
|
||||
pub fn from_mode(mode: u8) -> Option<Self> {
|
||||
match mode {
|
||||
0 => Some(Self::Horizontal),
|
||||
1 => Some(Self::Vertical),
|
||||
0 => Some(Self::CrossFuse),
|
||||
1 => Some(Self::DivergingFuse),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -122,8 +122,8 @@ impl PngStereo {
|
||||
/// The `sTER` mode byte.
|
||||
pub fn mode(self) -> u8 {
|
||||
match self {
|
||||
Self::Horizontal => 0,
|
||||
Self::Vertical => 1,
|
||||
Self::CrossFuse => 0,
|
||||
Self::DivergingFuse => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -933,7 +933,8 @@ pub struct FileEncoding {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub webp_quality: Option<u8>,
|
||||
/// PNG stereo pairing (the `sTER` chunk's mode byte, PNG Extensions 1.4.0 §3.6): `Some(0)`
|
||||
/// side by side, `Some(1)` stacked, so saving a stereo PNG keeps the mark (#6063).
|
||||
/// cross-fuse (right eye first), `Some(1)` diverging-fuse (left eye first). Both are
|
||||
/// side by side; saving a stereo PNG keeps this eye-order mark (#6063).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub png_stereo: Option<u8>,
|
||||
}
|
||||
|
||||
@@ -276,11 +276,35 @@ pub(crate) fn join(dir: &str, name: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// A file-name-safe version of a layer name.
|
||||
/// A file-name-safe version of a layer name. A Windows device name (`nul`, `con.v2`) gets a `_`
|
||||
/// before its first dot, so the file or folder is usable on Windows, wherever it was written.
|
||||
pub(crate) fn sanitize(name: &str) -> String {
|
||||
let s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect();
|
||||
let s = s.trim().trim_matches('.').to_string();
|
||||
if s.is_empty() { "layer".into() } else { s }
|
||||
if s.is_empty() { "layer".into() } else { portable_device_name(s) }
|
||||
}
|
||||
|
||||
/// `name` with a `_` before its first dot when it is a Windows device name (`nul` → `nul_`,
|
||||
/// `com1.v2` → `com1_.v2`); a `_` at the end would leave the device stem in place.
|
||||
pub(crate) fn portable_device_name(name: String) -> String {
|
||||
if !is_windows_device_name(&name) {
|
||||
return name;
|
||||
}
|
||||
match name.split_once('.') {
|
||||
Some((device, rest)) => format!("{device}_.{rest}"),
|
||||
None => format!("{name}_"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `component` names a Windows device (`CON`, `NUL`, `COM1`…, with any extension):
|
||||
/// Windows opens the device instead of a file or folder of that name. `COM` and `LPT` take the
|
||||
/// superscript digits ¹ ² ³ as well, as Windows does.
|
||||
pub(crate) fn is_windows_device_name(component: &str) -> bool {
|
||||
let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();
|
||||
let port = |n: &str| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³");
|
||||
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$")
|
||||
|| stem.strip_prefix("COM").is_some_and(port)
|
||||
|| stem.strip_prefix("LPT").is_some_and(port)
|
||||
}
|
||||
|
||||
pub(crate) fn import(name: &str, bytes: &[u8]) -> Result<Document> {
|
||||
@@ -762,7 +786,42 @@ fn xml_escape(s: &str) -> String {
|
||||
}
|
||||
|
||||
fn xml_unescape(s: &str) -> String {
|
||||
s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&")
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(i) = rest.find('&') {
|
||||
out.push_str(&rest[..i]);
|
||||
rest = &rest[i..];
|
||||
let decoded = rest.as_bytes().iter().take(13).position(|&b| b == b';').and_then(|e| {
|
||||
let c = match &rest[1..e] {
|
||||
"lt" => '<',
|
||||
"gt" => '>',
|
||||
"quot" => '"',
|
||||
"apos" => '\'',
|
||||
"amp" => '&',
|
||||
n => {
|
||||
let n = n.strip_prefix('#')?;
|
||||
let code = match n.strip_prefix(['x', 'X']) {
|
||||
Some(h) => u32::from_str_radix(h, 16).ok()?,
|
||||
None => n.parse().ok()?,
|
||||
};
|
||||
char::from_u32(code)?
|
||||
}
|
||||
};
|
||||
Some((c, e + 1))
|
||||
});
|
||||
match decoded {
|
||||
Some((c, len)) => {
|
||||
out.push(c);
|
||||
rest = &rest[len..];
|
||||
}
|
||||
None => {
|
||||
out.push('&');
|
||||
rest = &rest[1..];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// The element `<tag …>…</tag>` (first occurrence) as (start, end, inner).
|
||||
@@ -863,6 +922,18 @@ fn short_num(v: f64, decimals: usize) -> String {
|
||||
if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }
|
||||
}
|
||||
|
||||
/// `v` to three significant digits, with no trailing zeros (0.031714 → "0.0317", 0.4 → "0.4").
|
||||
/// Two decimals threw away most of a slow shutter speed: a stored 0.031714 s printed as "0.03 s",
|
||||
/// which is 5 % off, and a stored 0.0215 s printed as "0.02 s", 7 % off (#6958).
|
||||
fn sig3(v: f64) -> String {
|
||||
let magnitude = v.abs().log10().floor();
|
||||
// Clamped at both ends: a value at the large end must not ask for a negative field width, and
|
||||
// at the small end the field is widened rather than truncated, so a positive exposure never
|
||||
// reads as "0 s". 320 is beyond f64's decimal range, so it always terminates.
|
||||
let decimals = if magnitude.is_finite() { (2.0 - magnitude).clamp(0.0, 320.0) } else { 0.0 };
|
||||
short_num(v, decimals as usize)
|
||||
}
|
||||
|
||||
/// Exposure time as cameras print it: "1/250 s" for fractions of a second, "0.4 s" or "2 s"
|
||||
/// when the reciprocal isn't a whole number or the exposure is a second or longer.
|
||||
fn exposure_text(t: f64) -> Option<String> {
|
||||
@@ -870,10 +941,18 @@ fn exposure_text(t: f64) -> Option<String> {
|
||||
return None;
|
||||
}
|
||||
let r = 1.0 / t;
|
||||
if t < 1.0 && r.round() > 1.0 && (r - r.round()).abs() <= r * 0.01 {
|
||||
return Some(format!("1/{} s", r.round()));
|
||||
let n = r.round();
|
||||
// Below 1/10 s the reciprocal is above 10, so its nearest whole number puts the shown time
|
||||
// within 5 % of the stored one, and is the form a photographer expects ("1/32 s"). The test
|
||||
// used to be relative to the reciprocal, so it shrank as the exposure slowed: under 1/50 s it
|
||||
// fell below half a rounding step and nearly every fraction fell through to the decimal form
|
||||
// (#6958). Above 1/10 s the fraction is only right when the reciprocal is very nearly whole
|
||||
// (1/2 s, 1/4 s) — 0.4 s stays "0.4 s" rather than becoming "1/3 s".
|
||||
let fraction = n.is_finite() && (1.0..=1e9).contains(&n) && (t < 0.1 || (t < 1.0 && n > 1.0 && (r - n).abs() <= r * 0.01));
|
||||
if fraction {
|
||||
return Some(format!("1/{} s", n as u64));
|
||||
}
|
||||
Some(format!("{} s", short_num(t, if t < 1.0 { 2 } else { 1 })))
|
||||
Some(format!("{} s", sig3(t)))
|
||||
}
|
||||
|
||||
/// EXIF (`2024:02:17 12:40:19`) or XMP (`2024-02-17T12:40:19.00+01:00`) dates as
|
||||
|
||||
@@ -648,6 +648,54 @@ fn camera_data_formats_like_photoshop_and_falls_back_to_xmp() {
|
||||
assert_eq!(camera_data(Some(b"II*\0\xff\xff\xff\xff"), Some("<exif:FNumber>0/0</exif:FNumber> exif:ExposureTime=\"-1/0\"")), json!({}));
|
||||
}
|
||||
|
||||
/// A slow shutter speed whose reciprocal is not whole prints as the nearest fraction a camera
|
||||
/// would write, not as two decimals. The old 1 %-of-the-reciprocal test shrank as the exposure
|
||||
/// slowed, so below 1/50 s it fell below half a rounding step and nearly every such fraction fell
|
||||
/// through to the decimal form (#6958).
|
||||
#[test]
|
||||
fn camera_data_prints_a_slow_exposure_as_the_fraction_a_camera_writes() {
|
||||
let text = |t: f64| exposure_text(t).expect("an exposure");
|
||||
// The two values from the report: 31714/1000000 s and 215/10000 s.
|
||||
assert_eq!(text(0.031_714), "1/32 s", "a stored 0.031714 s is about 1/32 s");
|
||||
assert_eq!(text(0.0215), "1/47 s", "a stored 0.0215 s is about 1/47 s");
|
||||
// The rest of a slow shutter scale, and the exact reciprocals that already worked.
|
||||
assert_eq!(text(0.125), "1/8 s");
|
||||
assert_eq!(text(1.0 / 60.0), "1/60 s");
|
||||
assert_eq!(text(1.0 / 4000.0), "1/4000 s");
|
||||
assert_eq!(text(0.04), "1/25 s");
|
||||
assert_eq!(text(0.09), "1/11 s");
|
||||
// Every shown time stays within 5 % of the stored one — the point of the report.
|
||||
for t in [0.031_714, 0.0215, 0.04, 0.09, 0.125, 1.0 / 60.0, 1.0 / 4000.0] {
|
||||
let n: f64 = text(t).split('/').nth(1).and_then(|d| d.split(' ').next()).expect("1/n s").parse().expect("a number");
|
||||
assert!((1.0 / n / t - 1.0).abs() <= 0.05, "{t} shown as {} is more than 5 % off", text(t));
|
||||
}
|
||||
// A tenth of a second and longer keeps the Photoshop forms: a near-whole reciprocal is a
|
||||
// fraction, anything else is the decimal at three significant digits.
|
||||
assert_eq!(text(0.5), "1/2 s");
|
||||
assert_eq!(text(0.25), "1/4 s");
|
||||
assert_eq!(text(0.4), "0.4 s");
|
||||
assert_eq!(text(0.105), "0.105 s", "two decimals would read 0.11 s");
|
||||
assert_eq!(text(1.25), "1.25 s", "one decimal would read 1.3 s");
|
||||
assert_eq!(text(2.0), "2 s");
|
||||
// A hostile value cannot panic, and a positive exposure never reads as "0 s" (never-crash, rule 9).
|
||||
assert_ne!(exposure_text(f64::MIN_POSITIVE).as_deref(), Some("0 s"));
|
||||
assert_ne!(exposure_text(f64::MIN_POSITIVE), None);
|
||||
assert_eq!(exposure_text(0.0), None);
|
||||
assert_eq!(exposure_text(-1.0), None);
|
||||
assert_eq!(exposure_text(f64::NAN), None);
|
||||
assert_eq!(exposure_text(f64::INFINITY), None);
|
||||
assert_ne!(exposure_text(f64::MAX), None);
|
||||
// The same through a real EXIF block, so the round trip to File Info is covered too.
|
||||
let camera = |t: f64| {
|
||||
let exif = photocraft_algo::exif::build(&photocraft_algo::exif::CameraInfo { exposure_time: Some(t), ..Default::default() });
|
||||
camera_data(Some(&exif), None)["exposure"].as_str().map(str::to_owned)
|
||||
};
|
||||
assert_eq!(camera(0.031_714), Some("1/32 s".to_owned()));
|
||||
assert_eq!(camera(0.0215), Some("1/47 s".to_owned()));
|
||||
// An absurd exposure is capped to one dialog line, as every other camera field is.
|
||||
assert!(camera(f64::MAX).expect("an exposure").len() <= 200, "a huge exposure must stay on one dialog line");
|
||||
}
|
||||
|
||||
const MULTILINGUAL_XMP: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
@@ -1541,7 +1589,7 @@ fn batch_naming_rejects_outputs_that_replace_inputs_before_processing() {
|
||||
|
||||
#[test]
|
||||
fn exposure_text_near_one_second_is_not_one_over_one() {
|
||||
assert_eq!(exposure_text(0.996).as_deref(), Some("1 s"));
|
||||
assert_eq!(exposure_text(0.996).as_deref(), Some("0.996 s"), "not \"1/1 s\"; three significant digits");
|
||||
assert_eq!(exposure_text(0.99).as_deref(), Some("0.99 s"));
|
||||
assert_eq!(exposure_text(1.0 / 250.0).as_deref(), Some("1/250 s"));
|
||||
}
|
||||
@@ -1596,3 +1644,20 @@ fn save_a_copy_writes_the_tiff_compression_asked_for() {
|
||||
assert_eq!(state(&s), before, "{bad}: the document is as it was");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_keeps_windows_device_names_off_the_disk() {
|
||||
assert_eq!(sanitize("nul"), "nul_");
|
||||
assert_eq!(sanitize("CON"), "CON_");
|
||||
assert_eq!(sanitize("com1.v2"), "com1_.v2");
|
||||
assert_eq!(sanitize("lpt9 .final"), "lpt9 _.final");
|
||||
assert_eq!(sanitize("COM¹"), "COM¹_");
|
||||
assert_eq!(sanitize("LPT².txt"), "LPT²_.txt");
|
||||
for name in ["nul", "CON", "com1.v2", "lpt9 .final", "aux", "prn", "conin$", "clock$", "COM¹", "lpt³.x"] {
|
||||
assert!(!is_windows_device_name(&sanitize(name)), "{name} -> {}", sanitize(name));
|
||||
}
|
||||
// Names that only start like a device are files like any other.
|
||||
for name in ["console", "normal", "com10", "lpt0", "nullify", "con_", "com⁴", "com¹¹"] {
|
||||
assert_eq!(sanitize(name), name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +301,16 @@ pub(crate) fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resampl
|
||||
*surf = resize_surface_in_canvas(surf, sx, sy, if is_mask { Resample::Bilinear } else { filter }, canvas);
|
||||
});
|
||||
let k = ((sx + sy) / 2.0) as f32;
|
||||
for_each_layer(&mut doc.layers, &mut |l| scale_effects(&mut l.effects, k));
|
||||
for_each_layer(&mut doc.layers, &mut |l| {
|
||||
scale_effects(&mut l.effects, k);
|
||||
// Canvas resampling scales the whole appearance, including shape strokes (#8341).
|
||||
// Dash lengths and offsets are already expressed in multiples of this width.
|
||||
if let LayerContent::Shape(sh) = &mut l.content
|
||||
&& let Some(stroke) = &mut sh.stroke
|
||||
{
|
||||
stroke.width *= k;
|
||||
}
|
||||
});
|
||||
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
|
||||
ch.surface = resize_surface_in_canvas(&ch.surface, sx, sy, Resample::Bilinear, canvas);
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ choice!(UiFontSize { Tiny = "tiny", Small = "small", Medium = "medium", Large =
|
||||
choice!(LogDestination { Metadata = "metadata", TextFile = "textFile", Both = "both" } default Metadata);
|
||||
choice!(LogDetail { SessionsOnly = "sessionsOnly", Concise = "concise", Detailed = "detailed" } default Concise);
|
||||
choice!(Ask { Always = "always", Never = "never", Ask = "ask" } default Always);
|
||||
choice!(QuickExportFormat { Png = "png", Jpg = "jpg", Gif = "gif", Webp = "webp" } default Png);
|
||||
choice!(QuickExportFormat { Png = "png", Jpg = "jpg", Gif = "gif", Webp = "webp", Exr = "exr" } default Png);
|
||||
choice!(ExportLocation { Ask = "ask", SameFolder = "sameFolder" } default Ask);
|
||||
choice!(ExportMetadata { None = "none", Copyright = "copyright", All = "all" } default Copyright);
|
||||
choice!(
|
||||
|
||||
@@ -987,3 +987,16 @@ fn a_rejected_printable_area_fails_the_print_and_changes_nothing() {
|
||||
let r = s.execute("file.print", json!({"paper": "a3", "scaleToFit": true, "printable": [0, 0, 60, 1191.14], "cornerCropMarks": true, "dryRun": true}));
|
||||
assert!(r.is_err(), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_of_a_document_named_after_a_windows_device_is_written() {
|
||||
// On Windows a folder named `nul` is the NUL device: the save failed with "path not found".
|
||||
let dir = tmp("package-device-name");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 8, "name": "nul.psd"})).unwrap();
|
||||
let r = s.execute("file.package", json!({"dir": format!("{dir}/pkg")})).unwrap();
|
||||
assert_eq!(r["folder"].as_str().unwrap(), format!("{dir}/pkg/nul_"));
|
||||
let document = r["document"].as_str().unwrap();
|
||||
assert!(document.ends_with("pkg/nul_/nul_.pcraft"), "{document}");
|
||||
assert!(std::path::Path::new(document).is_file());
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ use photocraft_geom::Rect;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::file_cmds::{join, native_doc, stem, write_file};
|
||||
use crate::file_cmds::{is_windows_device_name, join, native_doc, portable_device_name, stem, write_file};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
@@ -732,7 +732,7 @@ fn slice_file(name: &str) -> String {
|
||||
/// Generated filenames should remain usable on Windows even when a document or slice is named
|
||||
/// after a reserved device. Caller-supplied path components are still rejected below.
|
||||
fn portable_generated_stem(stem: String) -> String {
|
||||
if is_windows_device_asset_name(&stem) { format!("{stem}_") } else { stem }
|
||||
portable_device_name(stem)
|
||||
}
|
||||
|
||||
fn html_escape(s: &str) -> String {
|
||||
@@ -1088,6 +1088,15 @@ fn quick_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let doc = d.doc.clone();
|
||||
if fmt == "exr" {
|
||||
// EXR keeps floating-point scene values. The ordinary exporter converts to linear
|
||||
// light; the web/sRGB pipeline would instead clamp HDR and negative values.
|
||||
let opts = photocraft_io::ExportOptions { xmp: photocraft_io::XmpEmbed::None, ..Default::default() };
|
||||
let out = photocraft_io::export(&doc, "exr", &opts).map_err(other)?;
|
||||
write_file(&path, &out.bytes)?;
|
||||
crate::automate_cmds::fire_event(s, "export");
|
||||
return Ok(json!({"path": path, "format": fmt, "bytes": out.bytes.len(), "warnings": out.warnings}));
|
||||
}
|
||||
let metadata = serde_json::to_value(prefs.metadata).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default();
|
||||
let mut o = serde_json::Map::new();
|
||||
o.insert(
|
||||
@@ -1296,13 +1305,6 @@ fn asset_settings(spec: &AssetSpec) -> WebSettings {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_windows_device_asset_name(component: &str) -> bool {
|
||||
let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();
|
||||
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$")
|
||||
|| stem.strip_prefix("COM").is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"))
|
||||
|| stem.strip_prefix("LPT").is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"))
|
||||
}
|
||||
|
||||
/// Layer names and `default` folders are document data, so reject unsafe output components.
|
||||
fn asset_path_components(relative: &str) -> Result<Vec<&str>> {
|
||||
// Split both separator styles even on Unix, where a backslash would otherwise be a legal
|
||||
@@ -1316,7 +1318,7 @@ fn asset_path_components(relative: &str) -> Result<Vec<&str>> {
|
||||
|| part.ends_with(' ')
|
||||
|| part.contains(':')
|
||||
|| part.contains('\0')
|
||||
|| is_windows_device_asset_name(part)
|
||||
|| is_windows_device_name(part)
|
||||
{
|
||||
return Err(other(format!("asset path {relative:?} has an unsafe component {part:?}")));
|
||||
}
|
||||
@@ -1537,7 +1539,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Preferences…",
|
||||
&["File", "Export"],
|
||||
None,
|
||||
r##"{"quickExportFormat":"png|jpg|gif|webp"?,"quickExportLocation":"ask|sameFolder"?,"jpegQuality":1..100?,"webpLossless":bool?,"webpQuality":1..100?,"metadata":"none|copyright|all"?,"convertToSrgb":bool?} → {values}"##,
|
||||
r##"{"quickExportFormat":"png|jpg|gif|webp|exr"?,"quickExportLocation":"ask|sameFolder"?,"jpegQuality":1..100?,"webpLossless":bool?,"webpQuality":1..100?,"metadata":"none|copyright|all"?,"convertToSrgb":bool?} → {values}"##,
|
||||
|_| Ok(()),
|
||||
export_preferences
|
||||
),
|
||||
|
||||
@@ -167,6 +167,19 @@ fn reserved_document_and_slice_names_export_with_portable_names() {
|
||||
assert!(std::path::Path::new(result["html"].as_str().unwrap()).is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dotted_device_file_name_exports_without_the_device_stem() {
|
||||
// `nul.v2_` would still be the NUL device on Windows; `nul_.v2` is a file.
|
||||
let dir = tmp("reserved-dotted-name");
|
||||
let mut s = session(8);
|
||||
s.execute("slice.new", json!({"rect": [0, 0, 16, 16], "name": "logo"})).unwrap();
|
||||
let path = format!("{dir}/nul.v2.png");
|
||||
let result = s.execute("file.export.saveForWebLegacy", json!({"path": path, "slices": "all", "format": "png24", "html": true})).unwrap();
|
||||
let html = result["html"].as_str().unwrap();
|
||||
assert!(html.ends_with("/nul_.v2.html"), "{html}");
|
||||
assert!(std::path::Path::new(html).is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_writer_failure_stops_export_and_preserves_saved_settings() {
|
||||
let mut s = session(8);
|
||||
@@ -995,3 +1008,38 @@ fn web_exif_that_does_not_fit_is_reported_and_bad_exif_is_harmless() {
|
||||
assert_eq!(decode(&path).dimensions(), (64, 48));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_export_exr_preserves_linear_hdr_and_alpha() {
|
||||
use photocraft_codecs::{ChannelLayout, Format, Image, SampleType};
|
||||
let dir = tmp("quick_exr_hdr");
|
||||
let samples = [-0.25, 2.0, 0.125, 1.0, 4.0, 0.25, 0.5, 0.5];
|
||||
let image = Image::from_f32(2, 1, ChannelLayout::Rgba, &samples).unwrap();
|
||||
let bytes = photocraft_codecs::encode(&image, Format::OpenExr, &Default::default()).unwrap();
|
||||
let doc = photocraft_io::import("hdr.exr", &bytes).unwrap().document;
|
||||
let mut s = Session::new();
|
||||
s.add_document(doc, Some(format!("{dir}/source.psd")));
|
||||
for convert in [true, false] {
|
||||
s.execute("file.export.exportPreferences", json!({"quickExportFormat": "exr", "quickExportLocation": "sameFolder", "convertToSrgb": convert})).unwrap();
|
||||
let result = s.execute("file.export.quickExport", json!({})).unwrap();
|
||||
assert_eq!(result["format"], "exr");
|
||||
let path = format!("{dir}/source.exr");
|
||||
assert_eq!(result["path"], path);
|
||||
assert_eq!(result["bytes"].as_u64(), Some(std::fs::metadata(&path).unwrap().len()));
|
||||
let decoded = decode(&path);
|
||||
assert_eq!(decoded.sample_type(), SampleType::F32);
|
||||
let actual = decoded.convert(ChannelLayout::Rgba, SampleType::F32).to_f32_samples().unwrap();
|
||||
for (got, expected) in actual.iter().zip(samples) {
|
||||
assert!((got - expected).abs() < 0.0001, "EXR preserves scene values: {actual:?}");
|
||||
}
|
||||
assert_eq!(actual.len(), samples.len());
|
||||
}
|
||||
// The format preference must never silently overwrite the source EXR.
|
||||
let own = format!("{dir}/source.exr");
|
||||
s.active_mut().unwrap().path = Some(own.clone());
|
||||
let before = std::fs::read(&own).unwrap();
|
||||
assert!(s.execute("file.export.quickExport", json!({})).is_err());
|
||||
assert_eq!(std::fs::read(&own).unwrap(), before);
|
||||
s.execute("file.export.quickExport", json!({"path": format!("{dir}/explicit.exr")})).unwrap();
|
||||
assert_eq!(decode(&format!("{dir}/explicit.exr")).dimensions(), (2, 1));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Regression coverage for #8341: canvas resizing must scale shape strokes too.
|
||||
use photocraft_doc::{LayerContent, LayerId};
|
||||
use photocraft_engine::{
|
||||
Session,
|
||||
web_cmds::{WebSettings, web_document},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn shape_session(depth: u32) -> (Session, LayerId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 1024, "height": 1024, "depth": depth, "background": "transparent"})).unwrap();
|
||||
let id = s.execute("shape.create", json!({"rect": [256, 256, 512, 512], "fill": null, "stroke": {"width": 32, "color": "#000000"}})).unwrap()["layer"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
(s, LayerId(id))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_downscale_keeps_stroke_only_shape_proportions_at_every_depth() {
|
||||
for depth in [8, 16, 32] {
|
||||
let (s, id) = shape_session(depth);
|
||||
let original = &s.active().unwrap().doc;
|
||||
for params in [json!({"percent": 25}), json!({"width": 256, "height": 256})] {
|
||||
let (scaled, sx, sy) = web_document(original, ¶ms, &WebSettings::default()).unwrap();
|
||||
assert_eq!((scaled.size.width, scaled.size.height), (256, 256));
|
||||
assert_eq!((sx, sy), (0.25, 0.25));
|
||||
let LayerContent::Shape(sh) = &scaled.layer(id).unwrap().content else {
|
||||
panic!("shape lost");
|
||||
};
|
||||
assert_eq!(sh.stroke.as_ref().unwrap().width, 8.0, "{depth}-bit stroke");
|
||||
let pixels = photocraft_compose::flatten(&scaled);
|
||||
let alpha = |x: usize| pixels.px[128 * 256 + x][3];
|
||||
assert!(alpha(62) > 0.99, "scaled stroke is visible");
|
||||
assert!(alpha(52) < 0.01, "stroke must not retain its original width");
|
||||
assert!(alpha(128) < 0.01, "no fill is introduced");
|
||||
}
|
||||
let LayerContent::Shape(sh) = &original.layer(id).unwrap().content else {
|
||||
panic!("shape lost");
|
||||
};
|
||||
assert_eq!(sh.stroke.as_ref().unwrap().width, 32.0, "export leaves the source untouched");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_size_scales_strokes_once_and_undo_restores_them() {
|
||||
let (mut s, id) = shape_session(8);
|
||||
s.execute("shape.edit", json!({"layer": id.0, "stroke": {"dashes": [2, 1], "dashOffset": 0.5}})).unwrap();
|
||||
let before = s.active().unwrap().doc.layer(id).unwrap().clone();
|
||||
s.execute("image.imageSize", json!({"width": 512, "height": 512})).unwrap();
|
||||
let LayerContent::Shape(sh) = &s.active().unwrap().doc.layer(id).unwrap().content else {
|
||||
panic!("shape lost");
|
||||
};
|
||||
assert_eq!(sh.stroke.as_ref().unwrap().width, 16.0);
|
||||
assert_eq!(sh.stroke.as_ref().unwrap().dashes, vec![2.0, 1.0], "dash lengths stay in stroke-width units");
|
||||
assert_eq!(sh.stroke.as_ref().unwrap().dash_offset, 0.5);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.layer(id).unwrap(), &before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
let LayerContent::Shape(sh) = &s.active().unwrap().doc.layer(id).unwrap().content else {
|
||||
panic!("shape lost");
|
||||
};
|
||||
assert_eq!(sh.stroke.as_ref().unwrap().width, 16.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouped_strokes_scale_up_and_unchanged_size_is_a_noop() {
|
||||
let (mut s, id) = shape_session(8);
|
||||
s.edit("Group", |doc, _| {
|
||||
let children = std::mem::take(&mut doc.layers);
|
||||
doc.layers.push(photocraft_doc::Layer::group("Outer", vec![photocraft_doc::Layer::group("Inner", children)]));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let original = &s.active().unwrap().doc;
|
||||
for (width, expected) in [(1024, 32.0), (1280, 40.0), (256, 8.0)] {
|
||||
let (scaled, _, _) = web_document(original, &json!({"width": width}), &WebSettings::default()).unwrap();
|
||||
let LayerContent::Shape(sh) = &scaled.layer(id).unwrap().content else {
|
||||
panic!("shape lost");
|
||||
};
|
||||
assert_eq!(sh.stroke.as_ref().unwrap().width, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_export_contains_the_scaled_stroke_and_keeps_the_source_document() {
|
||||
let (mut s, _) = shape_session(8);
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let mut bytes = Vec::new();
|
||||
photocraft_engine::web_cmds::save_for_web_with_writer(
|
||||
&mut s,
|
||||
&json!({"format": "png24", "path": "stroke.png", "width": 256, "height": 256}),
|
||||
&mut |_, encoded| {
|
||||
bytes = encoded.to_vec();
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let image = photocraft_codecs::decode(&bytes).unwrap().convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8);
|
||||
assert_eq!(image.dimensions(), (256, 256));
|
||||
let alpha = |x: usize| image.data()[(128 * 256 + x) * 4 + 3];
|
||||
assert_eq!(alpha(62), 255);
|
||||
assert_eq!(alpha(52), 0);
|
||||
assert_eq!(alpha(128), 0);
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use photocraft_engine::file_cmds::read_file_info;
|
||||
|
||||
#[test]
|
||||
fn numeric_character_references_are_decoded() {
|
||||
let xmp = "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF><rdf:Description>\
|
||||
<dc:description><rdf:Alt><rdf:li xml:lang=\"x-default\">Line one
Line two © & co A &#38;</rdf:li></rdf:Alt></dc:description>\
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>";
|
||||
let info = read_file_info(Some(xmp));
|
||||
assert_eq!(info["description"], "Line one\nLine two © & co A &");
|
||||
}
|
||||
|
||||
/// Many `&` with no `;` near them stay literal, and are scanned a bounded distance each (a
|
||||
/// multi-megabyte XMP once made this quadratic).
|
||||
#[test]
|
||||
fn a_long_run_of_ampersands_without_semicolons_is_left_unchanged() {
|
||||
let run = "&".repeat(200_000);
|
||||
let xmp = format!(
|
||||
"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF><rdf:Description>\
|
||||
<dc:description><rdf:Alt><rdf:li xml:lang=\"x-default\">{run}</rdf:li></rdf:Alt></dc:description>\
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"
|
||||
);
|
||||
let info = read_file_info(Some(&xmp));
|
||||
assert_eq!(info["description"], run.as_str());
|
||||
}
|
||||
@@ -844,6 +844,64 @@ mod tests {
|
||||
assert!(r.warnings.iter().all(|w| !w.contains("font")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// #4806: text placed by a `transform` (Illustrator writes `matrix(1 0 0 1 x y)` on every text
|
||||
/// element), on the text or on a group around it, lands where resvg draws it. usvg leaves the
|
||||
/// glyph outlines' absolute transforms at identity, so reading them dropped the transform: the
|
||||
/// text went off the canvas or kept its untransformed place and size.
|
||||
///
|
||||
/// Shaped with the bundled Inter alone, never a system font: on a machine without fonts (a
|
||||
/// bare CI image or VM) the test would otherwise have nothing to check, and the drawing and
|
||||
/// its reference come from the same parse, so both use that face.
|
||||
#[test]
|
||||
fn transformed_text_lands_where_resvg_draws_it() {
|
||||
fn inter() -> Arc<usvg::fontdb::Database> {
|
||||
let mut db = usvg::fontdb::Database::new();
|
||||
db.load_font_data(photocraft_text::fonts::INTER_REGULAR.to_vec());
|
||||
Arc::new(db)
|
||||
}
|
||||
assert!(inter().faces().next().is_some(), "the bundled Inter loads");
|
||||
let font = r#"font-size="24" font-family="Inter" fill="black""#;
|
||||
let cases = [
|
||||
format!(r#"<text transform="translate(10 40)" {font}>IHI</text>"#),
|
||||
format!(r#"<text transform="matrix(1 0 0 1 10 40)" {font}>IHI</text>"#),
|
||||
format!(r#"<text transform="translate(10 0)" x="20" y="40" {font}>IHI</text>"#),
|
||||
format!(r#"<text transform="scale(2)" x="5" y="25" {font}>IHI</text>"#),
|
||||
format!(r#"<g transform="translate(10 40)"><text {font}>IHI</text></g>"#),
|
||||
format!(r#"<g transform="translate(60 0)"><g transform="rotate(90)"><text x="5" y="20" {font}>IHI</text></g></g>"#),
|
||||
];
|
||||
for c in cases {
|
||||
let svg = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="120" height="60">{c}</svg>"#);
|
||||
let parsed = parse_with(svg.as_bytes(), inter).unwrap();
|
||||
assert!(!parsed.fontless_text, "{c}");
|
||||
let mut reference = tiny_skia::Pixmap::new(120, 60).unwrap();
|
||||
resvg::render(&parsed.tree, tiny_skia::Transform::default(), &mut reference.as_mut());
|
||||
let r = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap();
|
||||
let [layer] = r.document.layers.as_slice() else { panic!("one text layer, got {}: {c}", r.document.layers.len()) };
|
||||
assert!(matches!(layer.content, LayerContent::Group(_)), "text stays outline shapes: {c}");
|
||||
let imported = photocraft_compose::flatten(&r.document);
|
||||
let (mut both, mut either) = (0usize, 0usize);
|
||||
let (mut a_box, mut b_box) = ([i32::MAX, i32::MAX, i32::MIN, i32::MIN], [i32::MAX, i32::MAX, i32::MIN, i32::MIN]);
|
||||
let grow = |bx: &mut [i32; 4], x: i32, y: i32| *bx = [bx[0].min(x), bx[1].min(y), bx[2].max(x), bx[3].max(y)];
|
||||
for (i, px) in reference.pixels().iter().enumerate() {
|
||||
let (x, y) = ((i % 120) as i32, (i / 120) as i32);
|
||||
let (a, b) = (imported.get(x, y)[3] > 0.5, px.alpha() > 127);
|
||||
if a {
|
||||
grow(&mut a_box, x, y);
|
||||
}
|
||||
if b {
|
||||
grow(&mut b_box, x, y);
|
||||
}
|
||||
both += usize::from(a && b);
|
||||
either += usize::from(a || b);
|
||||
}
|
||||
assert!(either > 50, "resvg draws the text on the canvas: {c}");
|
||||
// Same place and size to the pixel; the outlines are filled by a different rasteriser,
|
||||
// so the edges of thin stems may differ by half a pixel.
|
||||
assert!(a_box.iter().zip(b_box).all(|(a, b)| (a - b).abs() <= 1), "imported {a_box:?}, resvg {b_box:?}: {c}");
|
||||
assert!(both * 10 >= either * 7, "imported glyphs cover what resvg draws ({both} of {either} px): {c}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #4266: a group clipped by one path (the PhotoCraft icon's rounded square) stays editable
|
||||
/// shapes with the clip as its vector mask, and looks as resvg draws the drawing; a clip of
|
||||
/// two shapes is still rasterised, with the warning.
|
||||
|
||||
@@ -1176,7 +1176,7 @@ fn a_lab_document_with_one_layer_is_not_reported_as_flattened() {
|
||||
fn a_stereo_png_keeps_its_stereo_mark_on_save() {
|
||||
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, PngStereo, SampleType as CS};
|
||||
let img = Image::from_raw(4, 2, ChannelLayout::Rgb, CS::U8, vec![40u8; 24]).unwrap();
|
||||
for view in [PngStereo::Horizontal, PngStereo::Vertical] {
|
||||
for view in [PngStereo::CrossFuse, PngStereo::DivergingFuse] {
|
||||
let bytes = photocraft_codecs::encode(&img, Format::Png, &EncodeOptions { png_stereo: Some(view), ..Default::default() }).unwrap();
|
||||
let doc = import("stereo.png", &bytes).unwrap().document;
|
||||
assert_eq!(doc.file_encoding.png_stereo, Some(view.mode()));
|
||||
|
||||
@@ -221,57 +221,6 @@ fn text_becomes_outline_shapes_or_warns_without_a_font() {
|
||||
assert!(dark > 4, "glyph strokes cross the middle row: {dark}");
|
||||
}
|
||||
|
||||
/// #4806: text placed by a `transform` (Illustrator writes `matrix(1 0 0 1 x y)` on every text
|
||||
/// element), on the text or on a group around it, lands where resvg draws it. usvg leaves the
|
||||
/// glyph outlines' absolute transforms at identity, so reading them dropped the transform: the
|
||||
/// text went off the canvas or kept its untransformed place and size.
|
||||
#[test]
|
||||
fn transformed_text_lands_where_resvg_draws_it() {
|
||||
let font = "font-size=\"24\" font-family=\"DejaVu Sans, Arial, Helvetica, sans-serif\" fill=\"black\"";
|
||||
let cases = [
|
||||
format!("<text transform=\"translate(10 40)\" {font}>IHI</text>"),
|
||||
format!("<text transform=\"matrix(1 0 0 1 10 40)\" {font}>IHI</text>"),
|
||||
format!("<text transform=\"translate(10 0)\" x=\"20\" y=\"40\" {font}>IHI</text>"),
|
||||
format!("<text transform=\"scale(2)\" x=\"5\" y=\"25\" {font}>IHI</text>"),
|
||||
format!("<g transform=\"translate(10 40)\"><text {font}>IHI</text></g>"),
|
||||
format!("<g transform=\"translate(60 0)\"><g transform=\"rotate(90)\"><text x=\"5\" y=\"20\" {font}>IHI</text></g></g>"),
|
||||
];
|
||||
for c in cases {
|
||||
let svg = format!("<svg {NS} width=\"120\" height=\"60\">{c}</svg>");
|
||||
let r = open(&svg);
|
||||
let d = &r.document;
|
||||
if d.layers.is_empty() {
|
||||
// No font at all on this machine: the import says so instead.
|
||||
assert!(r.warnings.iter().any(|w| w.contains("font")), "{:?}", r.warnings);
|
||||
return;
|
||||
}
|
||||
assert!(matches!(d.layers[0].content, LayerContent::Group(_)), "text stays outline shapes: {c}");
|
||||
let imported = photocraft_compose::flatten(d);
|
||||
let reference = svg::rasterize(svg.as_bytes(), 1.0).unwrap();
|
||||
let (mut both, mut either) = (0usize, 0usize);
|
||||
let (mut a_box, mut b_box) = ([i32::MAX, i32::MAX, i32::MIN, i32::MIN], [i32::MAX, i32::MAX, i32::MIN, i32::MIN]);
|
||||
let grow = |bx: &mut [i32; 4], x: i32, y: i32| *bx = [bx[0].min(x), bx[1].min(y), bx[2].max(x), bx[3].max(y)];
|
||||
for y in 0..60 {
|
||||
for x in 0..120 {
|
||||
let (a, b) = (imported.get(x, y)[3] > 0.5, reference.get(x, y)[3] > 0.5);
|
||||
if a {
|
||||
grow(&mut a_box, x, y);
|
||||
}
|
||||
if b {
|
||||
grow(&mut b_box, x, y);
|
||||
}
|
||||
both += usize::from(a && b);
|
||||
either += usize::from(a || b);
|
||||
}
|
||||
}
|
||||
assert!(either > 50, "resvg draws the text on the canvas: {c}");
|
||||
// Same place and size to the pixel; the outlines are filled by a different rasteriser, so
|
||||
// the edges of thin stems may differ by half a pixel.
|
||||
assert!(a_box.iter().zip(b_box).all(|(a, b)| (a - b).abs() <= 1), "imported {a_box:?}, resvg {b_box:?}: {c}");
|
||||
assert!(both * 10 >= either * 7, "imported glyphs cover what resvg draws ({both} of {either} px): {c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_comes_from_the_viewbox_and_huge_drawings_shrink_to_fit() {
|
||||
let r = open(&format!("<svg {NS} viewBox=\"0 0 300 150\"><rect width=\"300\" height=\"150\"/></svg>"));
|
||||
|
||||
@@ -129,10 +129,19 @@ pub fn open_layer(app: &mut PhotocraftApp, layer: photocraft_doc::LayerId) -> Re
|
||||
let ldoc = photocraft_engine::layer_menu_cmds::layer_document(&st.doc, layer).map_err(|e| e.to_string())?;
|
||||
let id = open(app)?;
|
||||
if let Some(d) = app.ui.dialogs.iter_mut().find(|d| d.id == id) {
|
||||
d.fields.insert("__layer".into(), json!(layer.0));
|
||||
d.fields.insert("__label".into(), json!(crate::i18n::fmt(crate::i18n::t("Export As: {name}"), &[("name", &ldoc.name)])));
|
||||
d.fields.insert("__w".into(), json!(ldoc.size.width));
|
||||
d.fields.insert("__h".into(), json!(ldoc.size.height));
|
||||
let f = &mut d.fields;
|
||||
f.insert("__layer".into(), json!(layer.0));
|
||||
f.insert("__label".into(), json!(crate::i18n::fmt(crate::i18n::t("Export As: {name}"), &[("name", &ldoc.name)])));
|
||||
f.insert("__w".into(), json!(ldoc.size.width));
|
||||
f.insert("__h".into(), json!(ldoc.size.height));
|
||||
// `open` seeded width and height from the document; in scale mode they follow the layer's
|
||||
// trimmed size, so the dialog shows and edits the size the layer exports at. Exact pixels
|
||||
// (sizing "pixels") are a size the user asked for, so those stay.
|
||||
if s(f, "sizing") != "pixels"
|
||||
&& let Ok(size) = resize_from_scale(f64::from(ldoc.size.width), f64::from(ldoc.size.height), n(f, "scale", 100.0))
|
||||
{
|
||||
write_size(f, size, "scale");
|
||||
}
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
@@ -565,6 +574,74 @@ fn estimate_native(doc: &Document, proxy: &Document, f: &Map<String, Value>) ->
|
||||
Some(scaled_estimate(bytes.len(), doc, proxy, f))
|
||||
}
|
||||
|
||||
/// Exports up to this many pixels get an exact size: the full image encoded as Export will
|
||||
/// write it, as Photoshop's Export As reports (#6595). Larger ones keep the proxy estimate, which
|
||||
/// reads low for detailed images (a downscaled proxy compresses better).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const EXACT_ESTIMATE_PX: f64 = 40_000_000.0;
|
||||
|
||||
/// The exported file's size in bytes, from encoding the full export, or `None` when it is too
|
||||
/// large to encode for an estimate or can't be encoded.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn exact_estimate(doc: &Document, f: &Map<String, Value>) -> Option<u64> {
|
||||
// The size `export_document` will make: exact pixels only in pixel mode, else the scale.
|
||||
let (w, h) = export_pixels(f64::from(doc.size.width), f64::from(doc.size.height), f, None).ok()?;
|
||||
let px = w * h;
|
||||
if !px.is_finite() || px > EXACT_ESTIMATE_PX {
|
||||
return None;
|
||||
}
|
||||
let full = export_document(doc, f, None).ok()?;
|
||||
let bytes = photocraft_io::export(&full, &format!("estimate.{}", s_fmt(f)), &settings(f).io_options()).ok()?.bytes;
|
||||
u64::try_from(bytes.len()).ok()
|
||||
}
|
||||
|
||||
/// The exact size for `signature`, worked out off the UI thread: `None` until it is ready (the
|
||||
/// proxy estimate shows meanwhile), then `Some(None)` when there is no exact size.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn poll_or_start_exact_estimate(ui: &mut egui::Ui, key: egui::Id, signature: &str, doc: Arc<Document>, fields: &Map<String, Value>) -> Option<Option<u64>> {
|
||||
type Done = Arc<Mutex<Option<Option<u64>>>>;
|
||||
let done_key = key.with("exact-estimate");
|
||||
if let Some((sig, size)) = ui.data(|d| d.get_temp::<(String, Option<u64>)>(done_key))
|
||||
&& sig == signature
|
||||
{
|
||||
return Some(size);
|
||||
}
|
||||
let pending_key = key.with("exact-estimate-pending");
|
||||
if let Some((sig, result)) = ui.data(|d| d.get_temp::<(String, Done)>(pending_key)) {
|
||||
if sig == signature {
|
||||
let size = result.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()?;
|
||||
ui.data_mut(|d| {
|
||||
d.remove::<(String, Done)>(pending_key);
|
||||
d.insert_temp(done_key, (sig, size));
|
||||
});
|
||||
return Some(size);
|
||||
}
|
||||
// Settings changed meanwhile: that worker's answer is for other settings.
|
||||
if result.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_none() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let result: Done = Arc::new(Mutex::new(None));
|
||||
let shared = result.clone();
|
||||
let ctx = ui.ctx().clone();
|
||||
let fields = fields.clone();
|
||||
let started = std::thread::Builder::new().name("export-estimate".into()).spawn(move || {
|
||||
let size = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let source = source_document_from(doc.clone(), &fields).unwrap_or(doc);
|
||||
exact_estimate(&source, &fields)
|
||||
}))
|
||||
.unwrap_or(None);
|
||||
*shared.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(size);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if started.is_ok() {
|
||||
ui.data_mut(|d| d.insert_temp(pending_key, (signature.to_string(), result)));
|
||||
None
|
||||
} else {
|
||||
Some(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn write_size(f: &mut Map<String, Value>, size: ExportSize, sizing: &str) {
|
||||
f.insert("width".into(), json!(size.width));
|
||||
f.insert("height".into(), json!(size.height));
|
||||
@@ -884,7 +961,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
|
||||
Some(preview_sync(app, &source, f))
|
||||
};
|
||||
let filter = preview_filter(f);
|
||||
result.map(|result| cache_preview(ui, key, sig, result, filter))
|
||||
result.map(|result| cache_preview(ui, key, sig.clone(), result, filter))
|
||||
}
|
||||
};
|
||||
let side = 300.0;
|
||||
@@ -901,7 +978,12 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
|
||||
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, tl!("Preview"), egui::FontId::proportional(12.0), t.text_dim);
|
||||
}
|
||||
ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Outside);
|
||||
let est = preview.as_ref().and_then(|preview| preview.estimate).map_or("—".to_string(), |b| crate::sizing::human_bytes(b as f64));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let exact = poll_or_start_exact_estimate(ui, key, &sig, doc.clone(), f).flatten();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let exact: Option<u64> = None;
|
||||
let est =
|
||||
exact.or_else(|| preview.as_ref().and_then(|preview| preview.estimate)).map_or("—".to_string(), |b| crate::sizing::human_bytes(b as f64));
|
||||
ui.label(egui::RichText::new(format!("{} ≈ {est}", s_fmt(f).to_uppercase())).color(t.text_dim).size(11.5));
|
||||
});
|
||||
});
|
||||
@@ -1113,6 +1195,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer › Export As opens with the layer's trimmed size, not the document's: `open` seeds the
|
||||
/// size fields from the document, and the layer replaces them in scale mode. A remembered
|
||||
/// percent scales the layer; remembered exact pixels stay the size the user asked for.
|
||||
#[test]
|
||||
fn layer_export_as_opens_with_the_layer_size() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 40, "height": 30})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("select.rect", json!({"x": 4, "y": 2, "width": 10, "height": 6})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
let layer = app.session.active().unwrap().active_layer.unwrap();
|
||||
let id = open_layer(&mut app, layer).unwrap();
|
||||
let f = &app.ui.dialog_mut(id).unwrap().fields;
|
||||
assert_eq!((f["__w"].as_f64(), f["__h"].as_f64()), (Some(10.0), Some(6.0)));
|
||||
assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(10.0), Some(6.0)));
|
||||
|
||||
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"scale": 50})));
|
||||
let id = open_layer(&mut app, layer).unwrap();
|
||||
let f = &app.ui.dialog_mut(id).unwrap().fields;
|
||||
assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(5.0), Some(3.0)));
|
||||
|
||||
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 120, "height": 80})));
|
||||
let id = open_layer(&mut app, layer).unwrap();
|
||||
let f = &app.ui.dialog_mut(id).unwrap().fields;
|
||||
assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(120.0), Some(80.0)));
|
||||
}
|
||||
|
||||
/// Only the dialog's own settings are remembered: not the destination, the document size or
|
||||
/// the layer the dialog was opened with.
|
||||
#[test]
|
||||
@@ -1284,6 +1393,116 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #6595: Export As shows the size the file will have, as Photoshop does. The proxy estimate
|
||||
/// read 36–81 % of it for a detailed image; up to `EXACT_ESTIMATE_PX` the full export is
|
||||
/// encoded off the UI thread instead.
|
||||
#[test]
|
||||
fn the_estimate_is_the_exported_files_size() {
|
||||
let mut doc = Document::with_background(
|
||||
"x",
|
||||
photocraft_doc::Size::new(900, 300),
|
||||
photocraft_doc::ColorMode::Rgb,
|
||||
photocraft_doc::SampleType::U8,
|
||||
photocraft_doc::Color::WHITE,
|
||||
);
|
||||
let id = doc.layers[0].id;
|
||||
let surf = doc.layer_mut(id).unwrap().surface_mut().unwrap();
|
||||
let mut seed = 12345u32;
|
||||
for y in (0..300).step_by(2) {
|
||||
for x in (0..900).step_by(2) {
|
||||
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
let v = (seed >> 24) as f32 / 255.0;
|
||||
surf.fill_rect(photocraft_geom::Rect::new(x, y, x + 2, y + 2), &[v, x as f32 / 900.0, y as f32 / 300.0, 1.0]);
|
||||
}
|
||||
}
|
||||
let doc = Arc::new(doc);
|
||||
let f = Map::from_iter([("format".into(), json!("jpg")), ("scale".into(), json!(100)), ("quality".into(), json!(32))]);
|
||||
let actual = photocraft_io::export(&export_document(&doc, &f, None).unwrap(), "x.jpg", &settings(&f).io_options()).unwrap().bytes.len() as u64;
|
||||
let proxy = preview_proxy(&doc, &f).unwrap();
|
||||
let old = estimate_native(&doc, &proxy, &f).unwrap();
|
||||
assert!((old as f64 / actual as f64 - 1.0).abs() > 0.1, "the proxy estimate was off: {old} vs {actual}");
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
let key = egui::Id::new("exact-estimate-test");
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
let size = loop {
|
||||
let mut size = None;
|
||||
let mut output = ctx.run_ui(Default::default(), |ctx| {
|
||||
egui::CentralPanel::default().show(ctx, |ui| size = poll_or_start_exact_estimate(ui, key, "jpg32", doc.clone(), &f));
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
if let Some(size) = size {
|
||||
break size;
|
||||
}
|
||||
assert!(std::time::Instant::now() < deadline, "the exact estimate did not arrive");
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
};
|
||||
assert_eq!(size, Some(actual));
|
||||
// Over the limit there is no exact size; the proxy estimate stays.
|
||||
let huge = Map::from_iter([
|
||||
("format".into(), json!("jpg")),
|
||||
("sizing".into(), json!("pixels")),
|
||||
("width".into(), json!(10_000)),
|
||||
("height".into(), json!(10_000)),
|
||||
]);
|
||||
assert_eq!(exact_estimate(&doc, &huge), None);
|
||||
// In scale mode the size comes from the scale, as the export's does, not from stale pixels.
|
||||
let big = Arc::new(Document::with_background(
|
||||
"big",
|
||||
photocraft_doc::Size::new(1000, 1000),
|
||||
photocraft_doc::ColorMode::Rgb,
|
||||
photocraft_doc::SampleType::U8,
|
||||
photocraft_doc::Color::WHITE,
|
||||
));
|
||||
let stale = Map::from_iter([
|
||||
("format".into(), json!("png")),
|
||||
("sizing".into(), json!("scale")),
|
||||
("scale".into(), json!(1000)),
|
||||
("width".into(), json!(100)),
|
||||
("height".into(), json!(100)),
|
||||
]);
|
||||
assert_eq!(exact_estimate(&big, &stale), None, "10000 × 10000 at 1000 %, over the limit");
|
||||
let small = Map::from_iter([
|
||||
("format".into(), json!("png")),
|
||||
("sizing".into(), json!("scale")),
|
||||
("scale".into(), json!(10)),
|
||||
("width".into(), json!(9000)),
|
||||
("height".into(), json!(9000)),
|
||||
]);
|
||||
let at_10 = photocraft_io::export(&export_document(&big, &small, None).unwrap(), "x.png", &settings(&small).io_options()).unwrap().bytes.len() as u64;
|
||||
assert_eq!(exact_estimate(&big, &small), Some(at_10), "100 × 100 at 10 %, within it");
|
||||
}
|
||||
|
||||
/// #8341: Export As and its reduced preview carry the stroke width with the path.
|
||||
#[test]
|
||||
fn export_as_downscales_stroke_only_shapes_and_preview() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut session = photocraft_engine::Session::new();
|
||||
session.execute("file.new", json!({"width": 1024, "height": 1024, "depth": depth, "background": "transparent"})).unwrap();
|
||||
let id = session.execute("shape.create", json!({"rect": [256, 256, 512, 512], "fill": null, "stroke": {"width": 32}})).unwrap()["layer"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let doc = session.active().unwrap().doc.clone();
|
||||
let fields = Map::from_iter([("format".into(), json!("png")), ("scale".into(), json!(25))]);
|
||||
for limit in [None, Some(128)] {
|
||||
let out = export_document(&doc, &fields, limit).unwrap();
|
||||
let side = if limit.is_some() { 128 } else { 256 };
|
||||
assert_eq!((out.size.width, out.size.height), (side, side));
|
||||
let shape = out.layer(photocraft_doc::LayerId(id)).unwrap();
|
||||
let photocraft_doc::LayerContent::Shape(shape) = &shape.content else {
|
||||
panic!("shape lost");
|
||||
};
|
||||
assert_eq!(shape.stroke.as_ref().unwrap().width, side as f32 / 32.0);
|
||||
let pixels = photocraft_compose::flatten(&out);
|
||||
let row = (side / 2 * side) as usize;
|
||||
assert!(pixels.px[row + (side / 4) as usize][3] > 0.99);
|
||||
assert!(pixels.px[row + (side / 4 - side / 32) as usize][3] < 0.01);
|
||||
assert!(pixels.px[row + (side / 2) as usize][3] < 0.01);
|
||||
}
|
||||
assert_eq!(session.active().unwrap().doc, doc, "export leaves the source unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_document_scales_and_flattens() {
|
||||
let doc = Document::with_background(
|
||||
|
||||
@@ -46,8 +46,9 @@ pub enum FileDialogAnswer {
|
||||
SaveTo(String),
|
||||
/// The selected folder's path (desktop).
|
||||
FolderTo(String),
|
||||
/// No dialog could be shown (on Linux: no desktop portal and no zenity, #3354); the message
|
||||
/// says what to install. Reported as an error rather than taken for Cancel.
|
||||
/// No dialog could be shown (on Linux: no desktop portal and no zenity, #3354; the message
|
||||
/// says what to install), or the file chosen can't be used (a name that isn't valid Unicode,
|
||||
/// #6601). Reported as an error rather than taken for Cancel.
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
|
||||
@@ -1070,6 +1070,45 @@ fn same_folder_quick_export_asks_before_replacing_the_documents_own_file() {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// #6597: once Save As or Save a Copy writes over the file another open document is bound to,
|
||||
/// that document no longer has the file and reads as unsaved (so closing it asks, and its Save
|
||||
/// asks for a name), and a notice says so. Documents bound to other files are left alone.
|
||||
#[test]
|
||||
fn saving_over_the_file_of_another_open_document_detaches_it() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-save-over-tab-{}", std::process::id()));
|
||||
let (a, d) = (dir.join("a.psd").to_string_lossy().into_owned(), dir.join("d.psd").to_string_lossy().into_owned());
|
||||
let (mut app, open, written) = app();
|
||||
let saved_as = |app: &mut PhotocraftApp, path: &str| {
|
||||
let st = app.session.active_mut().unwrap();
|
||||
st.path = Some(path.to_string());
|
||||
st.saved_revision = st.revision;
|
||||
};
|
||||
// A lives in a.psd and D in d.psd, both saved.
|
||||
saved_as(&mut app, &a);
|
||||
app.run("file.new", json!({"width": 4, "height": 4})).unwrap();
|
||||
saved_as(&mut app, &d);
|
||||
// B, in another tab, is saved as a.psd.
|
||||
app.run("file.new", json!({"width": 4, "height": 4})).unwrap();
|
||||
app.save_as(Some(a.clone())).unwrap();
|
||||
let docs = app.session.documents();
|
||||
assert_eq!((docs[0].path.as_deref(), docs[0].is_dirty()), (None, true), "A lost a.psd and is unsaved");
|
||||
assert_eq!((docs[1].path.as_deref(), docs[1].is_dirty()), (Some(d.as_str()), false), "D is untouched");
|
||||
assert_eq!(docs[2].path.as_deref(), Some(a.as_str()), "B lives in a.psd");
|
||||
let notice = app.ui.notices.last().expect("a notice");
|
||||
assert!(notice.title.contains("a.psd was saved over") && notice.lines.len() == 1, "{} {:?}", notice.title, notice.lines);
|
||||
// C's Save a Copy onto a.psd detaches B the same way, and C doesn't take the file.
|
||||
app.run("file.new", json!({"width": 4, "height": 4})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
menus::invoke(&mut app, &ctx, "file.saveACopy", json!({})).unwrap();
|
||||
app.poll_file_dialog(&ctx, None);
|
||||
answer(&open, Some(FileDialogAnswer::SaveTo(a.clone())));
|
||||
app.poll_file_dialog(&ctx, None);
|
||||
let docs = app.session.documents();
|
||||
assert_eq!((docs[2].path.as_deref(), docs[2].is_dirty()), (None, true), "B lost a.psd and is unsaved");
|
||||
assert_eq!(docs[3].path, None, "a copy doesn't bind C to a.psd");
|
||||
assert_eq!(*written.borrow(), [a.clone(), a]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_multi_layer_quick_export_writes_nothing() {
|
||||
let (mut app, open, written) = app();
|
||||
|
||||
@@ -68,6 +68,21 @@ fn dropped_name(f: &egui::DroppedFileHandle) -> String {
|
||||
f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into())
|
||||
}
|
||||
|
||||
/// `path` as the `String` file paths are carried in, or what to tell the user when its name isn't
|
||||
/// valid Unicode (#6601): non-UTF-8 bytes on Linux, an unpaired surrogate on Windows. A lossy copy
|
||||
/// would name another file, so Open would fail with "No such file" and Save would write a second,
|
||||
/// renamed file beside the original. `saving`: the path is to be written to.
|
||||
pub fn path_text(path: &std::path::Path, saving: bool) -> Result<String, String> {
|
||||
path.to_str().map(str::to_owned).ok_or_else(|| {
|
||||
let line = if saving {
|
||||
tl!("{path}: the path is not valid Unicode; save under another name.")
|
||||
} else {
|
||||
tl!("{path}: the path is not valid Unicode; rename the file and open it again.")
|
||||
};
|
||||
crate::i18n::fmt(line, &[("path", &path.to_string_lossy())])
|
||||
})
|
||||
}
|
||||
|
||||
/// The file name of `path` (the document's display name), or `path` itself when it has none.
|
||||
pub fn display_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| path.to_string())
|
||||
@@ -270,7 +285,7 @@ impl PhotocraftApp {
|
||||
// Opens append: a background open's tab, or a document.
|
||||
let (docs, opens) = (self.session.documents().len(), self.jobs.opens.len());
|
||||
if f.path().is_absolute() {
|
||||
self.open_path(&f.path().to_string_lossy())?;
|
||||
self.open_path(&path_text(f.path(), false)?)?;
|
||||
} else {
|
||||
self.open_bytes(name, &crate::read_dropped(&**f)?)?;
|
||||
}
|
||||
@@ -289,7 +304,7 @@ impl PhotocraftApp {
|
||||
let Some(next) = self.drop_places.pop_front() else { return };
|
||||
let (name, placed) = match next {
|
||||
DropPlace::File(f) if f.path().is_absolute() => {
|
||||
(dropped_name(&f), self.run("file.placeEmbedded", serde_json::json!({"path": f.path().to_string_lossy()})))
|
||||
(dropped_name(&f), path_text(f.path(), false).and_then(|path| self.run("file.placeEmbedded", serde_json::json!({"path": path}))))
|
||||
}
|
||||
DropPlace::File(f) => {
|
||||
let name = dropped_name(&f);
|
||||
|
||||
@@ -1002,6 +1002,37 @@ fn opening_type_in_a_missing_font_warns() {
|
||||
assert_eq!(app.open_bytes("warned.psd", b"x").unwrap(), vec![warning]);
|
||||
}
|
||||
|
||||
/// A file name that isn't valid Unicode on this platform: non-UTF-8 bytes on Unix, an unpaired
|
||||
/// surrogate on Windows.
|
||||
fn non_unicode_path() -> std::path::PathBuf {
|
||||
#[cfg(unix)]
|
||||
let name = {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
std::ffi::OsStr::from_bytes(b"img\xFF.png").to_owned()
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let name = {
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
let mut wide: Vec<u16> = "img".encode_utf16().collect();
|
||||
wide.push(0xD800);
|
||||
wide.extend(".png".encode_utf16());
|
||||
std::ffi::OsString::from_wide(&wide)
|
||||
};
|
||||
std::env::temp_dir().join(name)
|
||||
}
|
||||
|
||||
/// #6601: a lossy copy of such a name is another file's name, so it is refused instead.
|
||||
#[test]
|
||||
fn a_path_that_is_not_valid_unicode_is_refused_not_rewritten() {
|
||||
let bad = non_unicode_path();
|
||||
let open = path_text(&bad, false).unwrap_err();
|
||||
assert!(open.contains("not valid Unicode") && open.contains("open it again"), "{open}");
|
||||
let save = path_text(&bad, true).unwrap_err();
|
||||
assert!(save.contains("not valid Unicode") && save.contains("another name"), "{save}");
|
||||
let good = std::env::temp_dir().join("img é.png");
|
||||
assert_eq!(path_text(&good, true).as_deref(), Ok(good.to_str().unwrap()));
|
||||
}
|
||||
|
||||
/// #6598: opening a file that is already open shows its document, as Photoshop does, rather than
|
||||
/// a second copy bound to the same file (saving one would overwrite the other). The path is
|
||||
/// compared as the file it names: another spelling of it, a symlink to it or, with background
|
||||
|
||||
@@ -3903,6 +3903,7 @@ navigator Zoom Zvětšení
|
||||
This document's layers: {needed} of {budget} for layers Vrstvy tohoto dokumentu: {needed} z {budget} pro vrstvy
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Vrstvy tohoto dokumentu potřebují {needed}, ale mohou zabrat {budget}: překreslení celého dokumentu probíhá na CPU
|
||||
GPU Memory (MB, 0 = Automatic) Paměť GPU (MB, 0 = Automaticky)
|
||||
{path}: the path is not valid Unicode; save under another name. {path}: cesta není platný Unicode; uložte soubor pod jiným názvem.
|
||||
{name} was changed by another app after it was opened or saved here. Soubor {name} změnila jiná aplikace poté, co byl zde otevřen nebo uložen.
|
||||
Replace it with this document, or cancel and use File › Save As to keep both. Nahraďte ho tímto dokumentem, nebo zrušte a použijte Soubor › Uložit jako, abyste zachovali obojí.
|
||||
Decrease Brush Size Zmenšit velikost štětce
|
||||
@@ -3946,3 +3947,5 @@ navigator Zoom Zvětšení
|
||||
Pro Light Gray Pro (světle šedý)
|
||||
Pro Light Pro (světlý)
|
||||
Resize Windows to Fit Přizpůsobit velikost oken
|
||||
|
||||
EXR EXR
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3303,6 +3303,7 @@ curves Smooth Glätten
|
||||
This document's layers: {needed} of {budget} for layers Ebenen dieses Dokuments: {needed} von {budget} für Ebenen
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Die Ebenen dieses Dokuments brauchen {needed}, dürfen aber {budget} belegen: Das ganze Dokument wird auf der CPU neu gezeichnet
|
||||
GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch)
|
||||
{path}: the path is not valid Unicode; save under another name. {path}: Der Dateipfad enthält ungültige Zeichen. Speichere unter einem anderen Namen.
|
||||
{name} was changed by another app after it was opened or saved here. {name} wurde von einer anderen App geändert, nachdem es hier geöffnet oder gespeichert wurde.
|
||||
Replace it with this document, or cancel and use File › Save As to keep both. Ersetze die Datei durch dieses Dokument oder brich ab und nutze Datei › Speichern unter, um beide zu behalten.
|
||||
Demosaic Demosaicing
|
||||
@@ -3344,3 +3345,5 @@ curves Smooth Glätten
|
||||
Pro Light Gray Pro (Hellgrau)
|
||||
Pro Light Pro (Hell)
|
||||
Resize Windows to Fit Fenstergröße anpassen
|
||||
|
||||
EXR EXR
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3992,6 +3992,7 @@ curves Smooth Εξομάλυνση
|
||||
This document's layers: {needed} of {budget} for layers Επίπεδα αυτού του εγγράφου: {needed} από {budget} για επίπεδα
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Τα επίπεδα αυτού του εγγράφου χρειάζονται {needed} αλλά μπορούν να καταλάβουν {budget}: η επανασχεδίαση όλου του εγγράφου γίνεται στη CPU
|
||||
GPU Memory (MB, 0 = Automatic) Μνήμη GPU (MB, 0 = Αυτόματο)
|
||||
{path}: the path is not valid Unicode; save under another name. {path}: η διαδρομή δεν είναι έγκυρο Unicode· αποθηκεύστε με άλλο όνομα.
|
||||
{name} was changed by another app after it was opened or saved here. Το {name} άλλαξε από άλλη εφαρμογή αφού ανοίχτηκε ή αποθηκεύτηκε εδώ.
|
||||
Replace it with this document, or cancel and use File › Save As to keep both. Αντικαταστήστε το με αυτό το έγγραφο ή ακυρώστε και χρησιμοποιήστε Αρχείο › Αποθήκευση ως για να κρατήσετε και τα δύο.
|
||||
Decrease Brush Size Μείωση μεγέθους πινέλου
|
||||
@@ -4035,3 +4036,5 @@ curves Smooth Εξομάλυνση
|
||||
Pro Light Gray Pro ανοιχτού γκρι
|
||||
Pro Light Φωτεινό Pro
|
||||
Resize Windows to Fit Προσαρμογή μεγέθους παραθύρων
|
||||
|
||||
EXR EXR
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1030 and column 18.
|
@@ -4254,6 +4254,7 @@ navigator Zoom Zoom
|
||||
This document's layers: {needed} of {budget} for layers Capas de este documento: {needed} de {budget} para capas
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Las capas de este documento necesitan {needed} pero pueden ocupar {budget}: los redibujados de todo el documento usan la CPU
|
||||
GPU Memory (MB, 0 = Automatic) Memoria de GPU (MB, 0 = Automático)
|
||||
{path}: the path is not valid Unicode; save under another name. {path}: la ruta no es Unicode válido; guarde con otro nombre.
|
||||
{name} was changed by another app after it was opened or saved here. Otra aplicación cambió {name} después de abrirlo o guardarlo aquí.
|
||||
Replace it with this document, or cancel and use File › Save As to keep both. Reemplázalo con este documento, o cancela y usa Archivo › Guardar como para conservar ambos.
|
||||
Decrease Brush Size Reducir tamaño del pincel
|
||||
@@ -4297,3 +4298,5 @@ navigator Zoom Zoom
|
||||
Pro Light Gray Pro (gris claro)
|
||||
Pro Light Pro (claro)
|
||||
Resize Windows to Fit Redimensionar ventanas para ajustar
|
||||
|
||||
EXR EXR
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -3301,6 +3301,7 @@ curves Smooth Lisser
|
||||
This document's layers: {needed} of {budget} for layers Calques de ce document : {needed} sur {budget} pour les calques
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Les calques de ce document ont besoin de {needed} mais peuvent occuper {budget} : le document entier se redessine sur le CPU
|
||||
GPU Memory (MB, 0 = Automatic) Mémoire GPU (Mo, 0 = Automatique)
|
||||
{path}: the path is not valid Unicode; save under another name. {path} : le chemin n’est pas un Unicode valide ; enregistrez sous un autre nom.
|
||||
Replace Remplacer
|
||||
{name} was changed by another app after it was opened or saved here. {name} a été modifié par une autre application après avoir été ouvert ou enregistré ici.
|
||||
Replace it with this document, or cancel and use File › Save As to keep both. Remplacez-le par ce document, ou annulez et utilisez Fichier › Enregistrer sous pour garder les deux.
|
||||
@@ -3346,3 +3347,5 @@ curves Smooth Lisser
|
||||
Pro Light Gray Pro gris clair
|
||||
Pro Light Pro clair
|
||||
Resize Windows to Fit Redimensionner les fenêtres
|
||||
|
||||
EXR EXR
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4209,6 +4209,7 @@ cameraRaw Yellow Sárga
|
||||
This document's layers: {needed} of {budget} for layers A dokumentum rétegei: {needed} / {budget} a rétegeknek
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU A dokumentum rétegeinek {needed} kell, de {budget} foglalhatnak: a teljes dokumentum újrarajzolása a CPU-n fut
|
||||
GPU Memory (MB, 0 = Automatic) GPU-memória (MB, 0 = Automatikus)
|
||||
{path}: the path is not valid Unicode; save under another name. {path}: az elérési út nem érvényes Unicode-szöveg. Mentsd más néven.
|
||||
{name} was changed by another app after it was opened or saved here. A(z) {name} fájlt egy másik alkalmazás módosította, miután itt megnyitották vagy mentették.
|
||||
Replace it with this document, or cancel and use File › Save As to keep both. Cserélje le erre a dokumentumra, vagy válassza a Mégse gombot, és a Fájl › Mentés másként paranccsal tartsa meg mindkettőt.
|
||||
Decrease Brush Size Ecsetméret csökkentése
|
||||
@@ -4252,3 +4253,5 @@ cameraRaw Yellow Sárga
|
||||
Pro Light Gray Pro világosszürke
|
||||
Pro Light Pro világos
|
||||
Resize Windows to Fit Ablakok átméretezése a képhez
|
||||
|
||||
EXR EXR
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3304,6 +3304,7 @@ curves Smooth Haluskan
|
||||
This document's layers: {needed} of {budget} for layers Layer dokumen ini: {needed} dari {budget} untuk layer
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Layer dokumen ini membutuhkan {needed} tetapi boleh memakai {budget}: penggambaran ulang seluruh dokumen memakai CPU
|
||||
GPU Memory (MB, 0 = Automatic) Memori GPU (MB, 0 = Otomatis)
|
||||
{path}: the path is not valid Unicode; save under another name. {path}: jalur bukan Unicode yang valid; simpan dengan nama lain.
|
||||
Replace Ganti
|
||||
{name} was changed by another app after it was opened or saved here. {name} diubah oleh aplikasi lain setelah dibuka atau disimpan di sini.
|
||||
Replace it with this document, or cancel and use File › Save As to keep both. Ganti dengan dokumen ini, atau batalkan dan gunakan Berkas › Simpan Sebagai untuk menyimpan keduanya.
|
||||
@@ -3349,3 +3350,5 @@ curves Smooth Haluskan
|
||||
Pro Light Gray Pro Abu-abu Terang
|
||||
Pro Light Pro Terang
|
||||
Resize Windows to Fit Ubah ukuran jendela agar pas
|
||||
|
||||
EXR EXR
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -4413,6 +4413,7 @@ shape Appearance Aspetto
|
||||
This document's layers: {needed} of {budget} for layers Livelli di questo documento: {needed} su {budget} per i livelli
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU I livelli di questo documento richiedono {needed} ma possono occupare {budget}: i ridisegni dell'intero documento usano la CPU
|
||||
GPU Memory (MB, 0 = Automatic) Memoria GPU (MB, 0 = Automatico)
|
||||
{path}: the path is not valid Unicode; save under another name. {path}: il percorso non è Unicode valido; salva con un altro nome.
|
||||
{name} was changed by another app after it was opened or saved here. {name} è stato modificato da un'altra app dopo essere stato aperto o salvato qui.
|
||||
Replace it with this document, or cancel and use File › Save As to keep both. Sostituiscilo con questo documento, oppure annulla e usa File › Salva con nome per tenerli entrambi.
|
||||
Demosaic Demosaicizzazione
|
||||
@@ -4452,3 +4453,5 @@ shape Appearance Aspetto
|
||||
Pro Light Gray Pro grigio chiaro
|
||||
Pro Light Pro chiaro
|
||||
Resize Windows to Fit Ridimensiona finestre per adattare
|
||||
|
||||
EXR EXR
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2387 and column 12.
|
@@ -3968,6 +3968,7 @@ curves Smooth スムーズ
|
||||
This document's layers: {needed} of {budget} for layers このドキュメントのレイヤー: レイヤー用 {budget} のうち {needed}
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU このドキュメントのレイヤーには {needed} が必要ですが、使えるのは {budget} です: ドキュメント全体の再描画は CPU で行われます
|
||||
GPU Memory (MB, 0 = Automatic) GPU メモリ(MB、0 = 自動)
|
||||
{path}: the path is not valid Unicode; save under another name. {path}: パスが有効な Unicode ではありません。別の名前で保存してください。
|
||||
Replace 置き換え
|
||||
{name} was changed by another app after it was opened or saved here. {name} は、ここで開くか保存した後に別のアプリで変更されました。
|
||||
Replace it with this document, or cancel and use File › Save As to keep both. この書類で置き換えるか、キャンセルしてファイル › 名前を付けて保存で両方を残してください。
|
||||
@@ -4013,3 +4014,5 @@ curves Smooth スムーズ
|
||||
Pro Light Gray プロ(ライトグレー)
|
||||
Pro Light プロ(ライト)
|
||||
Resize Windows to Fit ウィンドウサイズを合わせる
|
||||
|
||||
EXR EXR
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3496,6 +3496,7 @@ curves Smooth 매끄럽게
|
||||
This document's layers: {needed} of {budget} for layers 이 문서의 레이어: 레이어용 {budget} 중 {needed}
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 이 문서의 레이어에 {needed}가 필요하지만 {budget}만 사용할 수 있습니다: 문서 전체 다시 그리기는 CPU를 사용합니다
|
||||
GPU Memory (MB, 0 = Automatic) GPU 메모리(MB, 0 = 자동)
|
||||
{path}: the path is not valid Unicode; save under another name. {path}: 경로가 올바른 유니코드가 아닙니다. 다른 이름으로 저장하세요.
|
||||
Replace 대치
|
||||
{name} was changed by another app after it was opened or saved here. {name}이(가) 여기서 열거나 저장한 뒤 다른 앱에서 변경되었습니다.
|
||||
Replace it with this document, or cancel and use File › Save As to keep both. 이 문서로 대치하거나, 취소하고 파일 › 다른 이름으로 저장을 사용해 둘 다 보관하세요.
|
||||
@@ -3542,3 +3543,5 @@ curves Smooth 매끄럽게
|
||||
Pro Light Gray 프로 밝은 회색
|
||||
Pro Light 프로 밝게
|
||||
Resize Windows to Fit 창 크기 맞추기
|
||||
|
||||
EXR EXR
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2928 and column 12.
|
@@ -4052,6 +4052,7 @@ curves Smooth Vloeiend maken
|
||||
This document's layers: {needed} of {budget} for layers Lagen van dit document: {needed} van {budget} voor lagen
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU De lagen van dit document hebben {needed} nodig maar mogen {budget} gebruiken: het hele document wordt op de CPU opnieuw getekend
|
||||
GPU Memory (MB, 0 = Automatic) GPU-geheugen (MB, 0 = Automatisch)
|
||||
{path}: the path is not valid Unicode; save under another name. {path}: het pad is geen geldige Unicode; sla op onder een andere naam.
|
||||
{name} was changed by another app after it was opened or saved here. {name} is door een andere app gewijzigd nadat het hier is geopend of bewaard.
|
||||
Replace it with this document, or cancel and use File › Save As to keep both. Vervang het door dit document, of annuleer en gebruik Bestand › Opslaan als om beide te houden.
|
||||
Decrease Brush Size Penseelgrootte verkleinen
|
||||
@@ -4095,3 +4096,5 @@ curves Smooth Vloeiend maken
|
||||
Pro Light Gray Pro lichtgrijs
|
||||
Pro Light Pro licht
|
||||
Resize Windows to Fit Venstergrootte aanpassen
|
||||
|
||||
EXR EXR
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -3906,6 +3906,7 @@ curves Smooth Wygładź
|
||||
This document's layers: {needed} of {budget} for layers Warstwy tego dokumentu: {needed} z {budget} na warstwy
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Warstwy tego dokumentu potrzebują {needed}, ale mogą zająć {budget}: odświeżanie całego dokumentu używa CPU
|
||||
GPU Memory (MB, 0 = Automatic) Pamięć GPU (MB, 0 = Automatycznie)
|
||||
{path}: the path is not valid Unicode; save under another name. {path}: ścieżka nie jest prawidłowym Unicode; zapisz pod inną nazwą.
|
||||
Replace Zastąp
|
||||
{name} was changed by another app after it was opened or saved here. Plik {name} został zmieniony przez inną aplikację po otwarciu lub zapisaniu go tutaj.
|
||||
Replace it with this document, or cancel and use File › Save As to keep both. Zastąp go tym dokumentem albo anuluj i użyj Plik › Zapisz jako, aby zachować oba.
|
||||
@@ -3951,3 +3952,5 @@ curves Smooth Wygładź
|
||||
Pro Light Gray Pro (jasnoszary)
|
||||
Pro Light Pro (jasny)
|
||||
Resize Windows to Fit Dopasuj rozmiar okien
|
||||
|
||||
EXR EXR
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4082,6 +4082,7 @@ layerLabel No Color Sem cor
|
||||
This document's layers: {needed} of {budget} for layers Camadas deste documento: {needed} de {budget} para camadas
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU As camadas deste documento precisam de {needed} mas podem ocupar {budget}: o documento inteiro é redesenhado na CPU
|
||||
GPU Memory (MB, 0 = Automatic) Memória da GPU (MB, 0 = Automático)
|
||||
{path}: the path is not valid Unicode; save under another name. {path}: o caminho não é Unicode válido; salve com outro nome.
|
||||
{name} was changed by another app after it was opened or saved here. {name} foi alterado por outro app depois de ser aberto ou salvo aqui.
|
||||
Replace it with this document, or cancel and use File › Save As to keep both. Substitua-o por este documento, ou cancele e use Arquivo › Salvar como para manter os dois.
|
||||
Decrease Brush Size Diminuir tamanho do pincel
|
||||
@@ -4274,3 +4275,5 @@ layerLabel No Color Sem cor
|
||||
Pro Light Gray Pro cinza claro
|
||||
Pro Light Pro claro
|
||||
Resize Windows to Fit Redimensionar janelas para caber
|
||||
|
||||
EXR EXR
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3268,6 +3268,7 @@ curves Smooth Сгладить
|
||||
This document's layers: {needed} of {budget} for layers Слои этого документа: {needed} из {budget} для слоёв
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Слоям этого документа нужно {needed}, но занимать они могут {budget}: весь документ перерисовывается на CPU
|
||||
GPU Memory (MB, 0 = Automatic) Память GPU (МБ, 0 = автоматически)
|
||||
{path}: the path is not valid Unicode; save under another name. {path}: путь не является допустимым Unicode; сохраните под другим именем.
|
||||
Replace Заменить
|
||||
{name} was changed by another app after it was opened or saved here. Файл {name} был изменён другим приложением после того, как его открыли или сохранили здесь.
|
||||
Replace it with this document, or cancel and use File › Save As to keep both. Замените его этим документом или отмените и используйте Файл › Сохранить как, чтобы сохранить оба.
|
||||
@@ -3314,3 +3315,5 @@ curves Smooth Сгладить
|
||||
Pro Light Gray Светло-серая Pro
|
||||
Pro Light Светлая Pro
|
||||
Resize Windows to Fit Подгонять размер окон
|
||||
|
||||
EXR EXR
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4364,6 +4364,7 @@ shape Appearance Izgled
|
||||
This document's layers: {needed} of {budget} for layers Slojevi ovog dokumenta: {needed} od {budget} za slojeve
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Slojevima ovog dokumenta treba {needed}, a smeju da zauzmu {budget}: ceo dokument se ponovo iscrtava na CPU-u
|
||||
GPU Memory (MB, 0 = Automatic) GPU memorija (MB, 0 = Automatski)
|
||||
{path}: the path is not valid Unicode; save under another name. {path}: putanja nije ispravan Unicode; sačuvajte pod drugim imenom.
|
||||
{name} was changed by another app after it was opened or saved here. Datoteku {name} je izmenila druga aplikacija nakon što je ovde otvorena ili sačuvana.
|
||||
Replace it with this document, or cancel and use File › Save As to keep both. Zameni je ovim dokumentom ili otkaži i koristi Datoteka › Sačuvaj kao da zadržiš obe.
|
||||
Decrease Brush Size Smanji veličinu četkice
|
||||
@@ -4407,3 +4408,5 @@ shape Appearance Izgled
|
||||
Pro Light Gray Pro svetlosiva
|
||||
Pro Light Pro svetla
|
||||
Resize Windows to Fit Prilagodi veličinu prozora
|
||||
|
||||
EXR EXR
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4022,6 +4022,7 @@ cameraData Lens Lens
|
||||
This document's layers: {needed} of {budget} for layers Bu belgenin katmanları: katmanlar için {budget} içinden {needed}
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Bu belgenin katmanlarının {needed} belleğe ihtiyacı var ama en fazla {budget} kullanabilirler: tüm belgenin yeniden çizimi CPU'yu kullanır
|
||||
GPU Memory (MB, 0 = Automatic) GPU Belleği (MB, 0 = Otomatik)
|
||||
{path}: the path is not valid Unicode; save under another name. {path}: yol geçerli bir Unicode değil; başka bir adla kaydedin.
|
||||
{name} was changed by another app after it was opened or saved here. {name}, burada açıldıktan veya kaydedildikten sonra başka bir uygulama tarafından değiştirildi.
|
||||
Replace it with this document, or cancel and use File › Save As to keep both. Bu belgeyle değiştirin ya da iptal edip ikisini de korumak için Dosya › Farklı Kaydet'i kullanın.
|
||||
Decrease Brush Size Fırça boyutunu küçült
|
||||
@@ -4065,3 +4066,5 @@ cameraData Lens Lens
|
||||
Pro Light Gray Pro Açık Gri
|
||||
Pro Light Profesyonel Işık
|
||||
Resize Windows to Fit Pencereleri sığacak şekilde boyutlandır
|
||||
|
||||
EXR EXR
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -3991,6 +3991,7 @@ curves Smooth Згладити
|
||||
This document's layers: {needed} of {budget} for layers Шари цього документа: {needed} з {budget} для шарів
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Шарам цього документа потрібно {needed}, але займати вони можуть {budget}: увесь документ перемальовується на CPU
|
||||
GPU Memory (MB, 0 = Automatic) Пам'ять GPU (МБ, 0 = автоматично)
|
||||
{path}: the path is not valid Unicode; save under another name. {path}: шлях не є коректним Unicode; збережіть під іншою назвою.
|
||||
Replace Замінити
|
||||
{name} was changed by another app after it was opened or saved here. Файл {name} змінила інша програма після того, як його тут відкрили або зберегли.
|
||||
Replace it with this document, or cancel and use File › Save As to keep both. Замініть його цим документом або скасуйте й скористайтеся Файл › Зберегти як, щоб зберегти обидва.
|
||||
@@ -4044,3 +4045,5 @@ curves Smooth Згладити
|
||||
Pro Light Gray Світло-сіра «Профі»
|
||||
Pro Light Світла «Профі»
|
||||
Resize Windows to Fit Підганяти розмір вікон
|
||||
|
||||
EXR EXR
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1394 and column 18.
|
@@ -3781,6 +3781,7 @@ curves Smooth Làm mượt
|
||||
This document's layers: {needed} of {budget} for layers Các lớp của tài liệu này: {needed} trên {budget} dành cho lớp
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Các lớp của tài liệu này cần {needed} nhưng chỉ được dùng {budget}: vẽ lại toàn bộ tài liệu dùng CPU
|
||||
GPU Memory (MB, 0 = Automatic) Bộ nhớ GPU (MB, 0 = Tự động)
|
||||
{path}: the path is not valid Unicode; save under another name. {path}: đường dẫn không phải Unicode hợp lệ; hãy lưu với tên khác.
|
||||
Replace Thay thế
|
||||
{name} was changed by another app after it was opened or saved here. {name} đã bị một ứng dụng khác thay đổi sau khi được mở hoặc lưu ở đây.
|
||||
Replace it with this document, or cancel and use File › Save As to keep both. Thay thế bằng tài liệu này, hoặc hủy và dùng Tệp › Lưu thành để giữ cả hai.
|
||||
@@ -3826,3 +3827,5 @@ curves Smooth Làm mượt
|
||||
Pro Light Gray Pro xám nhạt
|
||||
Pro Light Pro sáng
|
||||
Resize Windows to Fit Đổi cỡ cửa sổ cho vừa ảnh
|
||||
|
||||
EXR EXR
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2941,6 +2941,7 @@ curves Smooth 平滑
|
||||
This document's layers: {needed} of {budget} for layers 此文档的图层:图层可用 {budget} 中的 {needed}
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 此文档的图层需要 {needed},但只能占用 {budget}:整个文档的重绘使用 CPU
|
||||
GPU Memory (MB, 0 = Automatic) GPU 内存(MB,0 = 自动)
|
||||
{path}: the path is not valid Unicode; save under another name. {path}:路径不是有效的 Unicode;请以其他名称存储。
|
||||
Replace 替换
|
||||
{name} was changed by another app after it was opened or saved here. {name} 在此处打开或存储后已被其他应用更改。
|
||||
Replace it with this document, or cancel and use File › Save As to keep both. 用此文档替换它,或取消并使用“文件 › 另存为”以保留两者。
|
||||
@@ -2987,3 +2988,5 @@ curves Smooth 平滑
|
||||
Pro Light Gray 专业浅灰
|
||||
Pro Light 专业浅色
|
||||
Resize Windows to Fit 调整窗口大小以适合
|
||||
|
||||
EXR EXR
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2351 and column 12.
|
@@ -2910,6 +2910,7 @@ curves Smooth 平滑
|
||||
This document's layers: {needed} of {budget} for layers 此文件的圖層:圖層可用 {budget} 中的 {needed}
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 此文件的圖層需要 {needed},但只能佔用 {budget}:整份文件的重繪使用 CPU
|
||||
GPU Memory (MB, 0 = Automatic) GPU 記憶體(MB,0 = 自動)
|
||||
{path}: the path is not valid Unicode; save under another name. {path}:路徑不是有效的 Unicode;請以其他名稱儲存。
|
||||
Replace 取代
|
||||
{name} was changed by another app after it was opened or saved here. {name} 在此處開啟或儲存後已被其他 App 更改。
|
||||
Replace it with this document, or cancel and use File › Save As to keep both. 以此文件取代它,或取消並使用「檔案 › 另存新檔」以保留兩者。
|
||||
@@ -2956,3 +2957,5 @@ curves Smooth 平滑
|
||||
Pro Light Gray 專業淺灰
|
||||
Pro Light 專業淺色
|
||||
Resize Windows to Fit 調整視窗大小以符合
|
||||
|
||||
EXR EXR
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1911,6 +1911,50 @@ impl PhotocraftApp {
|
||||
Ok(Some((path, warnings)))
|
||||
}
|
||||
|
||||
/// After `saver` was written to `path`, any other open document bound to that file no longer
|
||||
/// matches it: it loses the file and is marked unsaved, so it can't stay "saved" while its file
|
||||
/// holds something else, closing it asks first, and its own Save asks for a name instead of
|
||||
/// writing over the new file (#6597). Paths are compared as files, so another letter case on a
|
||||
/// case-insensitive volume or a hard link counts. A notice names the documents affected.
|
||||
pub(crate) fn detach_others_from_file(&mut self, path: &str, saver: DocId) {
|
||||
let others: Vec<(DocId, String)> = self
|
||||
.session
|
||||
.documents()
|
||||
.iter()
|
||||
.filter(|st| st.doc.id != saver && st.path.as_deref().is_some_and(|p| photocraft_engine::file_cmds::same_file(p, path)))
|
||||
.map(|st| (st.doc.id, st.doc.name.clone()))
|
||||
.collect();
|
||||
let mut detached = Vec::new();
|
||||
for (id, name) in others {
|
||||
let done = self.with_document(id, |app| {
|
||||
let st = app.session.active_mut().ok_or("no document")?;
|
||||
st.path = None;
|
||||
// No revision ever equals this, so the document reads as unsaved until saved again.
|
||||
st.saved_revision = u64::MAX;
|
||||
Ok(())
|
||||
});
|
||||
if done.is_ok() {
|
||||
detached.push(name);
|
||||
}
|
||||
}
|
||||
if detached.is_empty() {
|
||||
return;
|
||||
}
|
||||
let name = file_open::display_name(path);
|
||||
let title = {
|
||||
let template = "{name} was saved over from another tab";
|
||||
crate::i18n::fmt(tl!(template), &[("name", name.as_str())])
|
||||
};
|
||||
let lines = detached
|
||||
.iter()
|
||||
.map(|doc| {
|
||||
let template = "\"{doc}\" no longer has a file and is marked unsaved: save it under a new name to keep it";
|
||||
crate::i18n::fmt(tl!(template), &[("doc", doc.as_str())])
|
||||
})
|
||||
.collect();
|
||||
notices::post(self, title, lines, false, None);
|
||||
}
|
||||
|
||||
/// Record a written save of document `doc` as it was at `revision`: its name, path and saved
|
||||
/// state (unless a copy), the status, script events and the warnings.
|
||||
pub(crate) fn saved(&mut self, doc: DocId, revision: u64, path: &str, warnings: &[String], copy: bool) {
|
||||
@@ -1933,6 +1977,8 @@ impl PhotocraftApp {
|
||||
}
|
||||
};
|
||||
self.ui.status = crate::i18n::fmt(tl!("Saved {name}"), &[("name", path)]);
|
||||
// The file was written, copy or not: another tab bound to it no longer matches it.
|
||||
self.detach_others_from_file(path, doc);
|
||||
if !copy {
|
||||
// A background save may finish while another document is active (or after its
|
||||
// document was closed, when there is nothing left to record).
|
||||
|
||||
@@ -1006,6 +1006,7 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec<MenuItem> {
|
||||
QuickExportFormat::Jpg => "JPG",
|
||||
QuickExportFormat::Gif => "GIF",
|
||||
QuickExportFormat::Webp => "WebP",
|
||||
QuickExportFormat::Exr => "EXR",
|
||||
}
|
||||
};
|
||||
item.label = format!("Quick Export as {format}");
|
||||
@@ -2177,7 +2178,7 @@ mod quick_export_label_tests {
|
||||
#[test]
|
||||
fn file_quick_export_menu_reflects_selected_format_without_renaming_layer_export() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
for (value, expected) in [("png", "PNG"), ("jpg", "JPG"), ("gif", "GIF"), ("webp", "WebP")] {
|
||||
for (value, expected) in [("png", "PNG"), ("jpg", "JPG"), ("gif", "GIF"), ("webp", "WebP"), ("exr", "EXR")] {
|
||||
app.run("prefs.set", json!({"values": {"export.quickExportFormat": value}})).unwrap();
|
||||
let items = menu_items(&app);
|
||||
let quick: Vec<_> = items.iter().filter(|i| i.id == "file.export.quickExportAsPng").collect();
|
||||
|
||||
@@ -1255,6 +1255,7 @@ fn humanize(key: &str) -> String {
|
||||
|
||||
fn choice_label(v: &str) -> String {
|
||||
match v {
|
||||
"exr" => "EXR".into(),
|
||||
"cm" => "Centimeters".into(),
|
||||
"mm" => "Millimeters".into(),
|
||||
"75" | "80" | "85" | "90" | "95" | "100" | "125" | "150" | "175" | "200" | "250" | "300" => format!("{v}%"),
|
||||
@@ -1512,6 +1513,7 @@ fn has_visible_fields(values: &Value, section: &str) -> bool {
|
||||
fn export_field_visible(obj: &Map<String, Value>, key: &str) -> bool {
|
||||
let format = obj.get("quickExportFormat").and_then(Value::as_str).unwrap_or("png");
|
||||
match key {
|
||||
"convertToSrgb" | "metadata" => format != "exr",
|
||||
"jpegQuality" => format == "jpg",
|
||||
"webpLossless" => format == "webp",
|
||||
"webpQuality" => format == "webp" && obj.get("webpLossless").and_then(Value::as_bool) != Some(true),
|
||||
@@ -2719,6 +2721,10 @@ mod tests {
|
||||
assert!(export_field_visible(&obj, "webpQuality"));
|
||||
assert_eq!(humanize("webpLossless"), "Lossless");
|
||||
assert_eq!(humanize("webpQuality"), "Quality");
|
||||
obj.insert("quickExportFormat".into(), json!("exr"));
|
||||
for key in ["jpegQuality", "webpLossless", "webpQuality", "convertToSrgb", "metadata"] {
|
||||
assert!(!export_field_visible(&obj, key), "EXR must not offer ignored web settings: {key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -602,6 +602,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
if crate::guide_select::keys(app, ctx) {
|
||||
return;
|
||||
}
|
||||
// A slice selected with Slice Select: ⌫/Delete deletes the slice, not the layer (#7862).
|
||||
if crate::slice_ui::keys(app, ctx) {
|
||||
return;
|
||||
}
|
||||
// Space+F (⇧Space+F backwards) cycles the pasteboard colour, as in Photoshop: F while the
|
||||
// Hand key is held, before F's own Cycle Screen Mode.
|
||||
if !matches!(focus, Focus::Text) && app.ui.text_edit.is_none() && crate::hold_keys::reposition_held(app, ctx) {
|
||||
|
||||
@@ -292,6 +292,39 @@ fn selected_id(app: &mut PhotocraftApp, promote: bool) -> Option<u32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete or Backspace with no modifiers, with the Slice Select tool and a slice selected,
|
||||
/// deletes that slice, as in Photoshop (#7862). It never reaches Layer › Delete or Edit › Clear.
|
||||
/// An auto slice has nothing to delete, and View › Lock Slices refuses with a message.
|
||||
pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
|
||||
use crate::shortcut_dispatch::Focus;
|
||||
// Text fields and widgets that delete with these keys themselves keep them.
|
||||
if app.ui.tool != Tool::SliceSelect
|
||||
|| app.ui.slices.selected.is_none()
|
||||
|| app.ui.text_edit.is_some()
|
||||
|| matches!(Focus::of(ctx), Focus::Text | Focus::DeletingWidget)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mods = ctx.input(|i| i.modifiers);
|
||||
if !mods.is_none() || !ctx.input_mut(|i| i.consume_key(mods, egui::Key::Delete) || i.consume_key(mods, egui::Key::Backspace)) {
|
||||
return false;
|
||||
}
|
||||
let result = if app.session.file_menu.slices_locked {
|
||||
Err(tl!("Slices are locked (View › Lock Slices)").to_string())
|
||||
} else {
|
||||
match selected_id(app, false) {
|
||||
Some(id) => app.run("slice.delete", json!({"slice": id})).map(|_| app.ui.slices.selected = None),
|
||||
// An auto slice is made from the user slices around it: it goes when they do.
|
||||
None => Ok(()),
|
||||
}
|
||||
};
|
||||
if let Err(e) = result {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Slice Options dialog for the selected slice (auto slices are promoted on OK).
|
||||
pub fn open_options(app: &mut PhotocraftApp) -> Result<Value, String> {
|
||||
let n = app.ui.slices.selected.ok_or("select a slice with the Slice Select tool")?;
|
||||
@@ -503,6 +536,60 @@ mod tests {
|
||||
assert_eq!(rect(&app), photocraft_geom::Rect::new(6, 11, 52, 46));
|
||||
}
|
||||
|
||||
/// Delete through the app's shortcut handling, as the canvas gets it.
|
||||
fn press(app: &mut PhotocraftApp, key: egui::Key) {
|
||||
let ctx = egui::Context::default();
|
||||
let m = egui::Modifiers::NONE;
|
||||
let raw = egui::RawInput {
|
||||
events: vec![egui::Event::ModifiersChanged(m), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m }],
|
||||
..Default::default()
|
||||
};
|
||||
ctx.begin_pass(raw);
|
||||
crate::shortcuts::handle(app, &ctx);
|
||||
ctx.end_pass().textures_delta.clear();
|
||||
}
|
||||
|
||||
/// #7862: with Slice Select, Delete (or Backspace) deletes the selected slice, never the
|
||||
/// layer: in a one-layer document that used to fail with "a document must keep at least one
|
||||
/// layer". Locked slices refuse; an auto slice has nothing to delete.
|
||||
#[test]
|
||||
fn delete_deletes_the_selected_slice_not_the_layer() {
|
||||
let m = egui::Modifiers::NONE;
|
||||
for key in [egui::Key::Delete, egui::Key::Backspace] {
|
||||
let mut app = app();
|
||||
let layers = app.session.active().unwrap().doc.layers.len();
|
||||
app.ui.tool = Tool::Slice;
|
||||
pointer(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, m);
|
||||
pointer(&mut app, ToolEvent::Up { x: 40.0, y: 30.0 }, m);
|
||||
app.ui.tool = Tool::SliceSelect;
|
||||
pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, m);
|
||||
pointer(&mut app, ToolEvent::Up { x: 20.0, y: 20.0 }, m);
|
||||
assert!(app.ui.slices.selected.is_some());
|
||||
|
||||
// Locked: the slice stays, and says why.
|
||||
app.run("view.lockSlices", json!({"on": true})).unwrap();
|
||||
press(&mut app, key);
|
||||
assert_eq!(app.session.active().unwrap().doc.slices.list.len(), 1, "{key:?}: locked");
|
||||
assert!(app.ui.status_error && app.ui.status.contains("locked"), "{key:?}: {}", app.ui.status);
|
||||
app.run("view.lockSlices", json!({"on": false})).unwrap();
|
||||
app.ui.status_error = false;
|
||||
|
||||
press(&mut app, key);
|
||||
let doc = app.session.active().unwrap().doc.clone();
|
||||
assert!(doc.slices.list.is_empty(), "{key:?}: the slice is deleted");
|
||||
assert_eq!(doc.layers.len(), layers, "{key:?}: the layer stays");
|
||||
assert!(!app.ui.status_error, "{key:?}: {}", app.ui.status);
|
||||
assert_eq!(app.ui.slices.selected, None);
|
||||
|
||||
// An auto slice: nothing to delete, and still not the layer.
|
||||
pointer(&mut app, ToolEvent::Down { x: 90.0, y: 75.0, pressure: 1.0 }, m);
|
||||
pointer(&mut app, ToolEvent::Up { x: 90.0, y: 75.0 }, m);
|
||||
press(&mut app, key);
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "{key:?}: auto slice");
|
||||
assert!(!app.ui.status_error, "{key:?}: {}", app.ui.status);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_slices_ignore_gestures() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -1525,6 +1525,7 @@ pub(crate) fn write_copy(app: &mut PhotocraftApp, doc: &photocraft_doc::Document
|
||||
let (bytes, warnings) = export(doc, path, &settings)?;
|
||||
let write = app.services.write.as_mut().ok_or("no writer configured")?;
|
||||
write(path, &bytes)?;
|
||||
app.detach_others_from_file(path, doc.id);
|
||||
let template = "Saved a copy as {path}";
|
||||
app.ui.status = crate::i18n::fmt(tl!(template), &[("path", path)]);
|
||||
app.ui.status_error = false;
|
||||
|
||||
Reference in New Issue
Block a user