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:
aiongg
2026-10-22 15:38:14 +00:00
49 changed files with 1116 additions and 107 deletions
+47 -8
View File
@@ -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]
+4
View File
@@ -197,3 +197,7 @@ sync_github_name = true
[people.webdevtodayjason]
display_name = "Jason Brashear"
[people.AndreeSalazar]
real_name = "Andreé Salazar"
sync_github_name = true
+25 -7
View File
@@ -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()));
}
}
+85
View File
@@ -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");
}
}
}
}
+8 -8
View File
@@ -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,
}
}
}
+2 -1
View File
@@ -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>,
}
+85 -6
View File
@@ -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("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&")
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
+66 -1
View File
@@ -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);
}
}
+10 -1
View File
@@ -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);
}
+1 -1
View File
@@ -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!(
+13
View File
@@ -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());
}
+13 -11
View 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
),
+48
View File
@@ -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));
}
+105
View File
@@ -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, &params, &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);
}
+24
View File
@@ -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&#xA;Line two &#169; &#38; co &#x41; &amp;#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 &#38;");
}
/// 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());
}
+58
View File
@@ -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.
+1 -1
View File
@@ -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()));
-51
View File
@@ -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>"));
+225 -6
View File
@@ -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(
+3 -2
View File
@@ -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),
}
+39
View File
@@ -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();
+17 -2
View File
@@ -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);
+31
View File
@@ -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
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+46
View File
@@ -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).
+2 -1
View File
@@ -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();
+6
View File
@@ -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]
+4
View File
@@ -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) {
+87
View File
@@ -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();
+1
View File
@@ -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;