mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 11:16:53 -04:00
Profile prompts on open: one file at a time, before it opens, and the answer goes to that file (#3471)
* Profile prompts on open: one file at a time, before it opens, like Photoshop With Ask When Opening on, files opened together put up every Embedded Profile Mismatch prompt at once, each answer went to the active document rather than its own file, and no prompt named its file. Checked in Photoshop 27.11: one prompt at a time, naming the file, before that file opens; OK opens it and asks about the next; Cancel opens neither it nor the files still to come. A file that asks is now held back until answered: the engine's background open parks the decoded document (ColorState::open_question, Session::take_held_open) and the shell queues it (open_prompt). The answer is applied to that document (ColorState::resolve_open) before it opens. Files that finish reading meanwhile wait their turn. A developed raw has no embedded profile, so it doesn't ask. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Profile prompts on open: the prompt's text is localized in every catalog The two messages are templates now, one line each so the catalog check covers them: the sentence naming the file and the mode, "Embedded: {embedded}" and "Working: {working}". The mode reads as the catalogs name it (French "RVB"), and the file's name goes in last, so braces in it are never taken for a placeholder. Rows added to all 17 catalogs; main's two "colour profile" templates (Czech and Italian only) are removed, nothing uses them. The Camera Raw control test from main expected a mismatch prompt when a raw opens. With this change a developed raw is not asked about (it has no embedded profile), so the test now checks that no prompt stands before Camera Raw. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Profile prompts: rows for the hu, sr and tr catalogs; tests hold for any read order The prompt's four templates join the Hungarian, Serbian and Turkish catalogs that landed on main. Files read in the background ask in the order their reads finish, which is the order given unless a later read overtakes an earlier one. Three tests assumed the order given and one failed once under a loaded test run; they now follow whichever file the prompt names (and still require the order given for files read on the spot). New test: a file held for its prompt still reports type in a font that isn't installed, once. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
01b354a340
commit
50631b54f7
34 files changed
+747
-105
No files matched your search
@@ -315,6 +315,60 @@ impl ColorState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether opening `doc` asks about its profile first (Ask When Opening, for a mismatched or
|
||||
/// missing profile), and if so what the prompt shows: the report [`Self::open_policy`]
|
||||
/// would give, with `ask: true` and `action` the answer the policy suggests. Nothing is
|
||||
/// changed: the answer is applied with [`Self::resolve_open`] before the document opens.
|
||||
pub fn open_question(&self, doc: &Document) -> Option<Value> {
|
||||
let space = mode_space(doc.mode);
|
||||
if !matches!(space, ColorSpace::Rgb | ColorSpace::Cmyk | ColorSpace::Gray) {
|
||||
return None;
|
||||
}
|
||||
let working = self.working(doc.mode);
|
||||
let policy = self.settings.policy(doc.mode);
|
||||
let embedded = doc.icc_profile.as_ref().and_then(|b| profile_from_bytes(b).ok()).filter(|p| p.color_space == space);
|
||||
match embedded {
|
||||
Some(emb) => {
|
||||
let linear_hdr =
|
||||
doc.depth == SampleType::F32 && (emb.same_colors(Builtin::LinearSrgb.profile()) || emb.same_colors(photocraft_cms::builtin::linear_gray()));
|
||||
if !self.settings.ask_on_mismatch || emb.same_colors(&working) || linear_hdr {
|
||||
return None;
|
||||
}
|
||||
let action = match policy {
|
||||
Policy::Preserve => "preserve",
|
||||
Policy::Off => "discard",
|
||||
Policy::Convert => "convert",
|
||||
};
|
||||
Some(
|
||||
json!({"embedded": emb.description, "working": working.description, "mismatch": true, "policy": policy.id(), "action": action, "ask": true}),
|
||||
)
|
||||
}
|
||||
None if self.settings.ask_on_missing => {
|
||||
Some(json!({"working": working.description, "policy": policy.id(), "missing": true, "action": "preserve", "ask": true}))
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the answer to an [`Self::open_question`] to a document not opened yet:
|
||||
/// `preserve` (keep the embedded profile, or leave an untagged file as is), `convert` to the
|
||||
/// working space, `discard` the embedded profile, or `assignWorking` (untagged files).
|
||||
pub fn resolve_open(&self, doc: &mut Document, action: &str) -> Result<()> {
|
||||
match action {
|
||||
"preserve" | "useEmbedded" => Ok(()),
|
||||
"convert" | "convertToWorking" => convert_document(doc, &self.working(doc.mode), self.settings.intent(), self.settings.bpc),
|
||||
"discard" | "off" => {
|
||||
doc.icc_profile = None;
|
||||
Ok(())
|
||||
}
|
||||
"assignWorking" => {
|
||||
doc.icc_profile = Some(self.working(doc.mode).to_bytes());
|
||||
Ok(())
|
||||
}
|
||||
other => Err(EngineError::Other(format!("unknown answer `{other}` (preserve|convert|discard|assignWorking)"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Proofing state of a document (defaults: coated CMYK, relative colorimetric + BPC, off).
|
||||
pub fn proof(&self, doc: DocId) -> ProofView {
|
||||
self.proofs.get(&doc).cloned().unwrap_or_else(|| ProofView { gamut_threshold: photocraft_cms::gamut::DEFAULT_THRESHOLD, ..Default::default() })
|
||||
@@ -1398,6 +1452,35 @@ mod settings_tests {
|
||||
// The Blend Text Colors Using Gamma test is `tests/text_gamma.rs`: it changes the
|
||||
// process-wide text gamma, which would race every unit test here that composites type.
|
||||
|
||||
#[test]
|
||||
fn open_question_asks_only_when_set_and_resolve_open_applies_each_answer() {
|
||||
let mut s = Session::new();
|
||||
// Ask When Opening off: nothing to ask, mismatched or missing.
|
||||
s.execute("edit.colorSettings", json!({"askOnMismatch": false, "askOnMissing": false})).unwrap();
|
||||
assert!(s.color.open_question(&tagged("adobe-rgb-compat")).is_none());
|
||||
let mut untagged = tagged("srgb");
|
||||
untagged.icc_profile = None;
|
||||
assert!(s.color.open_question(&untagged).is_none());
|
||||
// On: a mismatch asks, suggesting the policy's answer; a matching profile never asks.
|
||||
s.execute("edit.colorSettings", json!({"askOnMismatch": true, "askOnMissing": true, "policyRgb": "convert"})).unwrap();
|
||||
let q = s.color.open_question(&tagged("adobe-rgb-compat")).unwrap();
|
||||
assert_eq!((q["mismatch"].as_bool(), q["action"].as_str()), (Some(true), Some("convert")));
|
||||
assert!(q["embedded"].as_str().unwrap().starts_with("Adobe RGB"));
|
||||
assert!(s.color.open_question(&tagged("srgb")).is_none());
|
||||
assert_eq!(s.color.open_question(&untagged).unwrap()["missing"], true);
|
||||
// Asking changes nothing; the answer is applied to the document.
|
||||
let mut d = tagged("adobe-rgb-compat");
|
||||
s.color.resolve_open(&mut d, "preserve").unwrap();
|
||||
assert!(desc(&d).unwrap().starts_with("Adobe RGB"));
|
||||
s.color.resolve_open(&mut d, "convert").unwrap();
|
||||
assert_eq!(desc(&d).as_deref(), Some("sRGB IEC61966-2.1"));
|
||||
s.color.resolve_open(&mut d, "discard").unwrap();
|
||||
assert_eq!(desc(&d), None);
|
||||
s.color.resolve_open(&mut d, "assignWorking").unwrap();
|
||||
assert_eq!(desc(&d).as_deref(), Some("sRGB IEC61966-2.1"));
|
||||
assert!(s.color.resolve_open(&mut d, "explode").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_policies() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -412,7 +412,10 @@ impl Session {
|
||||
/// Open a file in the background: read (for a path) and decode on a worker, with progress
|
||||
/// (per layer for PSD/PSB) and cancellation, then add the document like
|
||||
/// [`Session::open_document`] (Color Settings policies apply). `name` names the document.
|
||||
/// The job's result is `{document, name, warnings, color}`; on wasm it runs inline.
|
||||
/// The job's result is `{document, name, warnings, color}`; on wasm it runs inline. When
|
||||
/// Color Settings ask about the file's profile, the document is held back instead and the
|
||||
/// result is `{held, name, warnings, color}` (`color` the question): take it with
|
||||
/// [`Session::take_held_open`] once answered.
|
||||
pub fn start_open(&mut self, name: &str, source: OpenSource) -> Result<Started> {
|
||||
let label = format!("Opening {name}");
|
||||
let name_w = name.to_string();
|
||||
@@ -438,6 +441,13 @@ impl Session {
|
||||
})
|
||||
},
|
||||
move |s, r: photocraft_io::ImportResult| {
|
||||
// Ask When Opening: held back until the prompt is answered (`take_held_open`). A
|
||||
// developed raw has no embedded profile to ask about.
|
||||
let raw = r.warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE));
|
||||
if let Some(question) = s.color.open_question(&r.document).filter(|_| !raw) {
|
||||
let held = s.hold_open(r.document, r.source_read_only);
|
||||
return Ok(json!({"held": held, "name": name_a, "warnings": r.warnings, "color": question}));
|
||||
}
|
||||
let (index, color) = s.open_document(r.document, None);
|
||||
if let Some(st) = s.active_mut() {
|
||||
st.source_read_only = r.source_read_only;
|
||||
|
||||
@@ -463,6 +463,33 @@ fn open_runs_as_a_job_and_cancel_adds_nothing() {
|
||||
assert!(matches!(wait_event(&mut s, id).outcome, JobOutcome::Failed(_)));
|
||||
}
|
||||
|
||||
/// Ask When Opening: a file whose profile doesn't match is decoded but not added; the shell takes
|
||||
/// it once the prompt is answered. Without Ask it opens as usual.
|
||||
#[test]
|
||||
fn an_open_that_asks_about_its_profile_holds_the_document_back() {
|
||||
let mut src = session(8, 8);
|
||||
let mut doc = (*src.active().unwrap().doc).clone();
|
||||
doc.icc_profile = Some(photocraft_cms::Builtin::from_id("adobe-rgb-compat").unwrap().profile().to_bytes());
|
||||
let bytes = Arc::new(photocraft_io::export(&doc, "x.png", &Default::default()).unwrap().bytes);
|
||||
src.close(0);
|
||||
let mut s = Session::new();
|
||||
s.execute("edit.colorSettings", json!({"askOnMismatch": true, "policyRgb": "preserve"})).unwrap();
|
||||
let id = job(s.start_open("x.png", OpenSource::Bytes(Arc::clone(&bytes))).unwrap());
|
||||
let JobOutcome::Done(v) = wait_event(&mut s, id).outcome else { panic!() };
|
||||
assert!(s.documents().is_empty(), "held back, not opened");
|
||||
assert_eq!((v["color"]["mismatch"].as_bool(), v["color"]["ask"].as_bool(), v["color"]["action"].as_str()), (Some(true), Some(true), Some("preserve")));
|
||||
let held = v["held"].as_u64().unwrap();
|
||||
let (taken, _) = s.take_held_open(held).expect("the held document");
|
||||
assert!(taken.icc_profile.is_some());
|
||||
assert!(s.take_held_open(held).is_none(), "taken once");
|
||||
|
||||
s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap();
|
||||
let id = job(s.start_open("x.png", OpenSource::Bytes(bytes)).unwrap());
|
||||
let JobOutcome::Done(v) = wait_event(&mut s, id).outcome else { panic!() };
|
||||
assert!(v.get("held").is_none());
|
||||
assert_eq!(s.documents().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_job_blocks_until_applied() {
|
||||
let mut s = session(400, 300);
|
||||
|
||||
@@ -459,6 +459,11 @@ pub struct Session {
|
||||
/// A finished live stroke offered to the `paint.stroke` / `paint.pencil` that commits it
|
||||
/// ([`Session::offer_live_stroke`]).
|
||||
pub(crate) live_commit: Option<brush_cmds::LiveCommit>,
|
||||
/// Documents a background open decoded but held back until the user answers the profile
|
||||
/// prompt ([`ColorState::open_question`](color_cmds::ColorState::open_question)): `(id,
|
||||
/// document, source read-only)`. The shell takes them with [`Session::take_held_open`].
|
||||
held_opens: Vec<(u64, Document, bool)>,
|
||||
next_held_open: u64,
|
||||
}
|
||||
|
||||
/// Move item `i` of `v` to position `to`, clamped to the end. Returns where it went; `None` when
|
||||
@@ -544,6 +549,21 @@ impl Session {
|
||||
i
|
||||
}
|
||||
|
||||
/// Holds a decoded document back from opening (see `held_opens`); returns its id.
|
||||
pub(crate) fn hold_open(&mut self, doc: Document, source_read_only: bool) -> u64 {
|
||||
self.next_held_open = self.next_held_open.wrapping_add(1);
|
||||
self.held_opens.push((self.next_held_open, doc, source_read_only));
|
||||
self.next_held_open
|
||||
}
|
||||
|
||||
/// Takes a document a background open held back for the profile prompt: the document and
|
||||
/// whether its source is read-only. `None` when there is no such document (already taken).
|
||||
pub fn take_held_open(&mut self, id: u64) -> Option<(Document, bool)> {
|
||||
let i = self.held_opens.iter().position(|(h, _, _)| *h == id)?;
|
||||
let (_, doc, read_only) = self.held_opens.remove(i);
|
||||
Some((doc, read_only))
|
||||
}
|
||||
|
||||
pub fn close(&mut self, index: usize) -> Option<DocState> {
|
||||
if index >= self.docs.len() {
|
||||
return None;
|
||||
|
||||
@@ -1435,6 +1435,9 @@ mod tests {
|
||||
let ctx = egui::Context::default();
|
||||
let warnings = app.open_bytes("IMG_0001.dng", &dng).unwrap();
|
||||
assert!(warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)), "{warnings:?}");
|
||||
// A raw has no embedded profile (ProPhoto RGB is the space it is developed into), so Ask
|
||||
// When Opening (on by default) has nothing to ask about.
|
||||
assert!(app.ui.dialogs.is_empty(), "no profile prompt for a raw");
|
||||
let raw = app.pending_raw_open.take().expect("a raw open queues the dialog");
|
||||
open_raw(&mut app, &ctx, raw).unwrap();
|
||||
let d = app.camera_raw.as_ref().unwrap();
|
||||
|
||||
@@ -1386,20 +1386,10 @@ mod tests {
|
||||
app.open_bytes("control.dng", &dng).unwrap();
|
||||
ctx.run_ui(Default::default(), |ui| crate::camera_raw_ui::show(&mut app, ui.ctx())).textures_delta.clear();
|
||||
assert!(app.camera_raw.is_some());
|
||||
// The raw develops in ProPhoto, so opening it also asks about the working-space
|
||||
// mismatch. Answer that prompt through the same control path before editing.
|
||||
assert_eq!(app.ui.dialogs.len(), 1);
|
||||
let mismatch = app.ui.dialogs.first().unwrap();
|
||||
assert_eq!(mismatch.fields["__prefsui"], "mismatch");
|
||||
assert_eq!(mismatch.fields["action"], "preserve");
|
||||
assert_eq!(mismatch.fields["applied"], "preserve");
|
||||
let mismatch_id = mismatch.id;
|
||||
let update = json!({"id":"filter.cameraRaw","params":{"ui":{"set":{"exposure":0.5}}}});
|
||||
assert_eq!(call(&mut app, &ctx, "ui.menu.invoke", update.clone())["ok"], false);
|
||||
let confirmed = call(&mut app, &ctx, "ui.dialog.confirm", json!({"dialog":mismatch_id}));
|
||||
assert_eq!(confirmed["ok"], true, "{confirmed}");
|
||||
assert_eq!(confirmed["result"]["action"], "preserve");
|
||||
// A developed raw has no embedded profile to ask about (`open_prompt`), so no profile
|
||||
// prompt stands between the open and the Camera Raw dialog.
|
||||
assert!(app.ui.dialogs.is_empty());
|
||||
let update = json!({"id":"filter.cameraRaw","params":{"ui":{"set":{"exposure":0.5}}}});
|
||||
let original = app.session.active().unwrap().doc.clone();
|
||||
let changed = call(&mut app, &ctx, "ui.menu.invoke", update);
|
||||
assert_eq!(changed["ok"], true, "{changed}");
|
||||
|
||||
@@ -629,6 +629,10 @@ pub fn cancel(app: &mut PhotocraftApp, id: u64) -> Result<Value, String> {
|
||||
if crate::guide_layout_ui::owns(&closed.fields) {
|
||||
app.ui.dialogs.retain(|child| child.fields.get("__dialog").and_then(Value::as_u64) != Some(id));
|
||||
}
|
||||
// Cancel on a profile prompt opens neither its file nor the ones still to come.
|
||||
if crate::open_prompt::owns(&closed.fields) {
|
||||
crate::open_prompt::answer(app, None)?;
|
||||
}
|
||||
app.ui.dialogs.retain(|d| !crate::layer_style::color_picker::child_of(&d.fields, id));
|
||||
app.filter_preview = None;
|
||||
app.color_range = None;
|
||||
|
||||
@@ -36,12 +36,19 @@ pub enum DropTarget {
|
||||
/// `warnings` from an import, plus one for each font family the active document's type uses that
|
||||
/// isn't installed (#4559): Photoshop warns about missing fonts as a file opens, and the CLI and
|
||||
/// automation already report them; the type would otherwise silently render in a fallback face.
|
||||
pub(crate) fn with_missing_fonts(app: &PhotocraftApp, mut warnings: Vec<String>) -> Vec<String> {
|
||||
if let Some(st) = app.session.active() {
|
||||
for w in photocraft_engine::type_extra_cmds::missing_font_warnings(&st.doc) {
|
||||
if !warnings.contains(&w) {
|
||||
warnings.push(w);
|
||||
}
|
||||
pub(crate) fn with_missing_fonts(app: &PhotocraftApp, warnings: Vec<String>) -> Vec<String> {
|
||||
match app.session.active() {
|
||||
Some(st) => with_missing_fonts_of(&st.doc, warnings),
|
||||
None => warnings,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`with_missing_fonts`] for `doc` itself: a file held back for its profile prompt
|
||||
/// (`open_prompt`) isn't the active document yet.
|
||||
pub(crate) fn with_missing_fonts_of(doc: &photocraft_doc::Document, mut warnings: Vec<String>) -> Vec<String> {
|
||||
for w in photocraft_engine::type_extra_cmds::missing_font_warnings(doc) {
|
||||
if !warnings.contains(&w) {
|
||||
warnings.push(w);
|
||||
}
|
||||
}
|
||||
warnings
|
||||
@@ -71,9 +78,7 @@ impl PhotocraftApp {
|
||||
crate::jobs_ui::start_open(self, &display_name(path), Some(path.to_string()), crate::jobs_ui::bytes(bytes))?;
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let warnings = self.open_bytes(&display_name(path), bytes)?;
|
||||
self.opened_from(path);
|
||||
Ok(warnings)
|
||||
self.open_bytes_from(&display_name(path), bytes, Some(path))
|
||||
}
|
||||
|
||||
/// Record that the active document was just opened from `path`: File › Save writes back to it
|
||||
|
||||
@@ -2209,6 +2209,10 @@
|
||||
No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Není k dispozici žádná cesta, ze které by šlo vytvořit výběr: nakreslete ji nástrojem Pero nebo ji vyberte na panelu Cesty.
|
||||
Cancel opening Zrušit otevírání
|
||||
Cancelled opening {name} Otevírání {name} zrušeno
|
||||
The document “{name}” does not have an embedded {mode} profile. Dokument „{name}“ nemá vložený profil {mode}.
|
||||
The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokument „{name}“ má vložený barevný profil, který neodpovídá aktuálnímu pracovnímu prostoru barev {mode}.
|
||||
Embedded: {embedded} Vložený: {embedded}
|
||||
Working: {working} Pracovní: {working}
|
||||
Developing {name} Vyvolávání {name}
|
||||
Cancelled {label} Zrušeno: {label}
|
||||
Esc to cancel Esc pro zrušení
|
||||
@@ -3667,8 +3671,6 @@ curves Smooth Vyhladit
|
||||
fallback náhradní
|
||||
|
||||
# Composed labels and Navigator zoom context
|
||||
The document does not have an embedded colour profile. Working space: {working}. Dokument nemá vložený barevný profil. Pracovní prostor barev: {working}.
|
||||
The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Dokument má vložený barevný profil, který neodpovídá pracovnímu prostoru barev.\nVložený: {embedded}\nPracovní: {working}
|
||||
Importing {what} from {file}… Importují se {what} ze souboru {file}…
|
||||
brushes štětce
|
||||
gradients přechody
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2179,6 +2179,10 @@
|
||||
No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Kein Pfad für eine Auswahl vorhanden: zeichne einen mit dem Stift-Werkzeug oder wähle einen im Pfade-Bedienfeld.
|
||||
Cancel opening Öffnen abbrechen
|
||||
Cancelled opening {name} Öffnen von {name} abgebrochen
|
||||
The document “{name}” does not have an embedded {mode} profile. Das Dokument „{name}“ hat kein eingebettetes {mode}-Profil.
|
||||
The document “{name}” has an embedded color profile that does not match the current {mode} working space. Das Dokument „{name}“ hat ein eingebettetes Farbprofil, das nicht zum aktuellen {mode}-Arbeitsfarbraum passt.
|
||||
Embedded: {embedded} Eingebettet: {embedded}
|
||||
Working: {working} Arbeitsfarbraum: {working}
|
||||
Developing {name} {name} wird entwickelt
|
||||
Cancelled {label} {label} abgebrochen
|
||||
Esc to cancel Esc zum Abbrechen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1610,6 +1610,10 @@
|
||||
No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Δεν υπάρχει διαδρομή για δημιουργία επιλογής: σχεδιάστε μία με το εργαλείο Πένα (Pen Tool) ή επιλέξτε μία στο πλαίσιο Διαδρομές.
|
||||
Cancel opening Ακύρωση ανοίγματος
|
||||
Cancelled opening {name} Ακυρώθηκε το άνοιγμα του {name}
|
||||
The document “{name}” does not have an embedded {mode} profile. Το έγγραφο «{name}» δεν έχει ενσωματωμένο προφίλ {mode}.
|
||||
The document “{name}” has an embedded color profile that does not match the current {mode} working space. Το έγγραφο «{name}» έχει ενσωματωμένο χρωματικό προφίλ που δεν ταιριάζει με τον τρέχοντα χώρο εργασίας {mode}.
|
||||
Embedded: {embedded} Ενσωματωμένο: {embedded}
|
||||
Working: {working} Χώρος εργασίας: {working}
|
||||
Cancelled {label} Ακυρώθηκε: {label}
|
||||
Esc to cancel Esc για ακύρωση
|
||||
Opened {name} Άνοιξε το {name}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1029 and column 18.
|
@@ -2195,6 +2195,10 @@
|
||||
No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. No hay ningún trazado para crear una selección: dibuje uno con la herramienta Pluma o seleccione uno en el panel Trazados.
|
||||
Cancel opening Cancelar apertura
|
||||
Cancelled opening {name} Se canceló la apertura de {name}
|
||||
The document “{name}” does not have an embedded {mode} profile. El documento «{name}» no tiene un perfil {mode} incrustado.
|
||||
The document “{name}” has an embedded color profile that does not match the current {mode} working space. El documento «{name}» tiene un perfil de color incrustado que no coincide con el espacio de trabajo {mode} actual.
|
||||
Embedded: {embedded} Incrustado: {embedded}
|
||||
Working: {working} Espacio de trabajo: {working}
|
||||
Developing {name} Revelando {name}
|
||||
Cancelled {label} Se canceló {label}
|
||||
Esc to cancel Esc para cancelar
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2155,6 +2155,10 @@
|
||||
No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Aucun tracé à partir duquel créer une sélection : dessinez-en un avec l'outil Plume ou sélectionnez-en un dans le panneau Tracés.
|
||||
Cancel opening Annuler l'ouverture
|
||||
Cancelled opening {name} Ouverture de {name} annulée
|
||||
The document “{name}” does not have an embedded {mode} profile. Le document « {name} » n'a pas de profil {mode} incorporé.
|
||||
The document “{name}” has an embedded color profile that does not match the current {mode} working space. Le document « {name} » a un profil colorimétrique incorporé qui ne correspond pas à l'espace de travail {mode} actuel.
|
||||
Embedded: {embedded} Incorporé : {embedded}
|
||||
Working: {working} Espace de travail : {working}
|
||||
Developing {name} Développement de {name}
|
||||
Cancelled {label} {label} : annulé
|
||||
Esc to cancel Échap pour annuler
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2484,6 +2484,10 @@ navigator Zoom Nagyítás mértéke
|
||||
Camera Raw Filter ({layer}) Camera Raw szűrő ({layer})
|
||||
Camera Raw isn't open A Camera Raw nincs megnyitva
|
||||
Cancelled opening {name} Megnyitás megszakítva: {name}
|
||||
The document “{name}” does not have an embedded {mode} profile. A(z) „{name}” dokumentumnak nincs beágyazott {mode}-profilja.
|
||||
The document “{name}” has an embedded color profile that does not match the current {mode} working space. A(z) „{name}” dokumentum beágyazott színprofilja nem egyezik az aktuális {mode}-munkaszíntérrel.
|
||||
Embedded: {embedded} Beágyazott: {embedded}
|
||||
Working: {working} Munkaszíntér: {working}
|
||||
Cancelled {label} Megszakítva: {label}
|
||||
Clarity Tisztaság
|
||||
Color Detail Színrészletesség
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2148,6 +2148,10 @@
|
||||
No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Tidak ada path untuk dibuat seleksi: gambar dengan alat Pen atau pilih salah satu di panel Path.
|
||||
Cancel opening Batalkan pembukaan
|
||||
Cancelled opening {name} Pembukaan {name} dibatalkan
|
||||
The document “{name}” does not have an embedded {mode} profile. Dokumen “{name}” tidak memiliki profil {mode} tertanam.
|
||||
The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokumen “{name}” memiliki profil warna tertanam yang tidak cocok dengan working space {mode} saat ini.
|
||||
Embedded: {embedded} Tertanam: {embedded}
|
||||
Working: {working} Working space: {working}
|
||||
Developing {name} Mengembangkan {name}
|
||||
Cancelled {label} {label} dibatalkan
|
||||
Esc to cancel Esc untuk batal
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2191,6 +2191,10 @@
|
||||
No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Nessun tracciato da cui creare selezione: disegnane uno con Penna o selezionane uno nel pannello Tracciati.
|
||||
Cancel opening Annulla apertura
|
||||
Cancelled opening {name} Apertura {name} annullata
|
||||
The document “{name}” does not have an embedded {mode} profile. Il documento “{name}” non ha un profilo {mode} incorporato.
|
||||
The document “{name}” has an embedded color profile that does not match the current {mode} working space. Il documento “{name}” ha un profilo colore incorporato che non corrisponde allo spazio di lavoro {mode} corrente.
|
||||
Embedded: {embedded} Incorporato: {embedded}
|
||||
Working: {working} Di lavoro: {working}
|
||||
Developing {name} Sviluppo di {name}
|
||||
Cancelled {label} {label} annullato
|
||||
Esc to cancel Esc per annullare
|
||||
@@ -4043,8 +4047,6 @@ layerLabel No Color Nessun colore
|
||||
Monitor profile on {display}: {profile} Profilo monitor su {display}: {profile}
|
||||
manual manuale
|
||||
fallback ripiego
|
||||
The document does not have an embedded colour profile. Working space: {working}. Il documento non ha un profilo colore incorporato. Spazio di lavoro: {working}.
|
||||
The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Il documento ha un profilo colore incorporato che non corrisponde allo spazio di lavoro.\nIncorporato: {embedded}\nDi lavoro: {working}
|
||||
Importing {what} from {file}… Importazione di {what} da {file}…
|
||||
brushes pennelli
|
||||
gradients sfumature
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2644 and column 53.
|
@@ -2189,6 +2189,10 @@
|
||||
No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. 選択範囲を作成するパスがありません。ペンツールで描くか、パスパネルでパスを選択してください。
|
||||
Cancel opening 開くのをキャンセル
|
||||
Cancelled opening {name} {name} を開くのをキャンセルしました
|
||||
The document “{name}” does not have an embedded {mode} profile. ドキュメント「{name}」には埋め込み {mode} プロファイルがありません。
|
||||
The document “{name}” has an embedded color profile that does not match the current {mode} working space. ドキュメント「{name}」の埋め込みカラープロファイルは、現在の {mode} 作業用スペースと一致しません。
|
||||
Embedded: {embedded} 埋め込み: {embedded}
|
||||
Working: {working} 作業用スペース: {working}
|
||||
Developing {name} {name} を現像中
|
||||
Cancelled {label} {label} をキャンセルしました
|
||||
Esc to cancel Esc キーでキャンセル
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2150,6 +2150,10 @@
|
||||
No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. 선택 영역으로 만들 패스가 없습니다: 펜 도구로 그리거나 패스 패널에서 선택하세요.
|
||||
Cancel opening 열기 취소
|
||||
Cancelled opening {name} {name} 열기를 취소했습니다
|
||||
The document “{name}” does not have an embedded {mode} profile. 문서 “{name}”에 포함된 {mode} 프로파일이 없습니다.
|
||||
The document “{name}” has an embedded color profile that does not match the current {mode} working space. 문서 “{name}”에 포함된 색상 프로파일이 현재 {mode} 작업 색상 공간과 일치하지 않습니다.
|
||||
Embedded: {embedded} 포함됨: {embedded}
|
||||
Working: {working} 작업 색상 공간: {working}
|
||||
Developing {name} {name} 현상 중
|
||||
Cancelled {label} {label} 작업을 취소했습니다
|
||||
Esc to cancel Esc 키로 취소
|
||||
|
||||
|
@@ -2157,6 +2157,10 @@
|
||||
No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Geen pad om een selectie van te maken: teken er een met het pengereedschap of selecteer er een in het paneel Paden.
|
||||
Cancel opening Openen annuleren
|
||||
Cancelled opening {name} Openen van {name} geannuleerd
|
||||
The document “{name}” does not have an embedded {mode} profile. Het document “{name}” heeft geen ingesloten {mode}-profiel.
|
||||
The document “{name}” has an embedded color profile that does not match the current {mode} working space. Het document “{name}” heeft een ingesloten kleurprofiel dat niet overeenkomt met de huidige {mode}-werkruimte.
|
||||
Embedded: {embedded} Ingesloten: {embedded}
|
||||
Working: {working} Werkruimte: {working}
|
||||
Developing {name} {name} wordt ontwikkeld
|
||||
Cancelled {label} {label} geannuleerd
|
||||
Esc to cancel Esc om te annuleren
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -2176,6 +2176,10 @@
|
||||
No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Brak ścieżki, z której można utworzyć zaznaczenie: narysuj ją narzędziem Pióro lub wybierz w panelu Ścieżki.
|
||||
Cancel opening Anuluj otwieranie
|
||||
Cancelled opening {name} Anulowano otwieranie {name}
|
||||
The document “{name}” does not have an embedded {mode} profile. Dokument „{name}” nie ma osadzonego profilu {mode}.
|
||||
The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokument „{name}” ma osadzony profil kolorów, który nie pasuje do bieżącej przestrzeni roboczej {mode}.
|
||||
Embedded: {embedded} Osadzony: {embedded}
|
||||
Working: {working} Przestrzeń robocza: {working}
|
||||
Cancelled {label} Anulowano: {label}
|
||||
Esc to cancel Esc, aby anulować
|
||||
Opened {name} Otworzono: {name}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2151,6 +2151,10 @@
|
||||
No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Nenhum demarcador para criar a seleção: desenhe um com a ferramenta Caneta ou selecione um no painel Demarcadores.
|
||||
Cancel opening Cancelar abertura
|
||||
Cancelled opening {name} Abertura de {name} cancelada
|
||||
The document “{name}” does not have an embedded {mode} profile. O documento “{name}” não tem um perfil {mode} incorporado.
|
||||
The document “{name}” has an embedded color profile that does not match the current {mode} working space. O documento “{name}” tem um perfil de cores incorporado que não corresponde ao espaço de trabalho {mode} atual.
|
||||
Embedded: {embedded} Incorporado: {embedded}
|
||||
Working: {working} Espaço de trabalho: {working}
|
||||
Developing {name} Revelando {name}
|
||||
Cancelled {label} {label} cancelado
|
||||
Esc to cancel Esc para cancelar
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2471,6 +2471,10 @@
|
||||
No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Нет контура для создания выделения: нарисуйте его инструментом «Перо» или выберите на панели «Контуры».
|
||||
Cancel opening Отменить открытие
|
||||
Cancelled opening {name} Открытие {name} отменено
|
||||
The document “{name}” does not have an embedded {mode} profile. В документе «{name}» нет встроенного профиля {mode}.
|
||||
The document “{name}” has an embedded color profile that does not match the current {mode} working space. Встроенный цветовой профиль документа «{name}» не совпадает с текущим рабочим пространством {mode}.
|
||||
Embedded: {embedded} Встроенный: {embedded}
|
||||
Working: {working} Рабочее пространство: {working}
|
||||
Developing {name} Обработка {name}
|
||||
Cancelled {label} {label}: отменено
|
||||
Esc to cancel Esc — отмена
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2135,6 +2135,10 @@
|
||||
No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Nema putanje od koje bi se napravila selekcija: nacrtajte je alatkom pero ili je izaberite u panelu Putanje.
|
||||
Cancel opening Otkaži otvaranje
|
||||
Cancelled opening {name} Otkazano otvaranje: {name}
|
||||
The document “{name}” does not have an embedded {mode} profile. Dokument „{name}“ nema ugrađeni {mode} profil.
|
||||
The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokument „{name}“ ima ugrađeni profil boja koji se ne slaže sa trenutnim {mode} radnim prostorom.
|
||||
Embedded: {embedded} Ugrađeni: {embedded}
|
||||
Working: {working} Radni prostor: {working}
|
||||
Developing {name} Razvijanje: {name}
|
||||
Cancelled {label} Otkazano: {label}
|
||||
Esc to cancel Esc za otkazivanje
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2132,6 +2132,10 @@
|
||||
No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Seçim yapılabilecek yol yok: Kalem aracıyla bir yol çizin veya Yollar panelinden birini seçin.
|
||||
Cancel opening Açılışı iptal et
|
||||
Cancelled opening {name} {name} açılışı iptal edildi
|
||||
The document “{name}” does not have an embedded {mode} profile. '{name}' belgesinde gömülü bir {mode} profili yok.
|
||||
The document “{name}” has an embedded color profile that does not match the current {mode} working space. '{name}' belgesinin gömülü renk profili geçerli {mode} çalışma alanıyla eşleşmiyor.
|
||||
Embedded: {embedded} Gömülü: {embedded}
|
||||
Working: {working} Çalışma alanı: {working}
|
||||
Developing {name} {name} geliştiriliyor
|
||||
Cancelled {label} {label} iptal edildi
|
||||
Esc to cancel İptal etmek için Esc
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -2444,6 +2444,10 @@
|
||||
No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Немає контуру для створення виділення: намалюйте його пером або виберіть на панелі контурів.
|
||||
Cancel opening Скасувати відкриття
|
||||
Cancelled opening {name} Відкриття {name} скасовано
|
||||
The document “{name}” does not have an embedded {mode} profile. Документ «{name}» не має вбудованого профілю {mode}.
|
||||
The document “{name}” has an embedded color profile that does not match the current {mode} working space. Вбудований колірний профіль документа «{name}» не відповідає поточному робочому простору {mode}.
|
||||
Embedded: {embedded} Вбудований: {embedded}
|
||||
Working: {working} Робочий простір: {working}
|
||||
Developing {name} Обробка {name}
|
||||
Cancelled {label} Скасовано: {label}
|
||||
Esc to cancel Esc для скасування
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1384 and column 18.
|
@@ -1562,6 +1562,10 @@ cameraRaw Shadows Vùng tối
|
||||
Esc to cancel Esc để hủy
|
||||
Cancel opening Hủy mở tệp
|
||||
Cancelled opening {name} Đã hủy mở {name}
|
||||
The document “{name}” does not have an embedded {mode} profile. Tài liệu “{name}” không có hồ sơ {mode} nhúng.
|
||||
The document “{name}” has an embedded color profile that does not match the current {mode} working space. Tài liệu “{name}” có hồ sơ màu nhúng không khớp với không gian làm việc {mode} hiện tại.
|
||||
Embedded: {embedded} Nhúng: {embedded}
|
||||
Working: {working} Không gian làm việc: {working}
|
||||
Cancelled {label} Đã hủy {label}
|
||||
Cancel transform (Esc) Hủy biến đổi (Esc)
|
||||
Cancel warp (Esc) Hủy biến dạng (Esc)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2256,6 +2256,10 @@ cameraRaw Blending 混合
|
||||
# Layer navigation, copy transforms and cancellable opening (labels added on main since this catalog).
|
||||
Cancel opening 取消打开
|
||||
Cancelled opening {name} 已取消打开 {name}
|
||||
The document “{name}” does not have an embedded {mode} profile. 文档“{name}”没有嵌入的 {mode} 配置文件。
|
||||
The document “{name}” has an embedded color profile that does not match the current {mode} working space. 文档“{name}”嵌入的颜色配置文件与当前的 {mode} 工作空间不匹配。
|
||||
Embedded: {embedded} 嵌入:{embedded}
|
||||
Working: {working} 工作空间:{working}
|
||||
Developing {name} 正在处理 {name}
|
||||
Cancelled {label} 已取消 {label}
|
||||
Esc to cancel 按 Esc 取消
|
||||
|
||||
|
@@ -2195,6 +2195,10 @@
|
||||
No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. 沒有可用來建立選取範圍的路徑:請使用鋼筆工具繪製,或在路徑面板中選取一個路徑。
|
||||
Cancel opening 取消開啟
|
||||
Cancelled opening {name} 已取消開啟 {name}
|
||||
The document “{name}” does not have an embedded {mode} profile. 文件「{name}」沒有嵌入的 {mode} 描述檔。
|
||||
The document “{name}” has an embedded color profile that does not match the current {mode} working space. 文件「{name}」嵌入的色彩描述檔與目前的 {mode} 工作空間不符。
|
||||
Embedded: {embedded} 嵌入:{embedded}
|
||||
Working: {working} 工作空間:{working}
|
||||
Developing {name} 正在處理 {name}
|
||||
Cancelled {label} 已取消 {label}
|
||||
Esc to cancel 按 Esc 取消
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -527,38 +527,69 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) {
|
||||
/// What [`crate::PhotocraftApp::open_bytes`] does after decoding, for a background open's result
|
||||
/// (`{document, warnings, color}`).
|
||||
fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Value, slot: Option<usize>) -> Result<(), String> {
|
||||
let warnings: Vec<String> =
|
||||
v.get("warnings").and_then(Value::as_array).map(|a| a.iter().filter_map(|w| w.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||||
let held = |doc, read_only, question, policy_applied| crate::open_prompt::Held {
|
||||
doc,
|
||||
read_only,
|
||||
name: name.to_string(),
|
||||
path: path.map(str::to_string),
|
||||
warnings: warnings.clone(),
|
||||
bytes: None,
|
||||
slot,
|
||||
question,
|
||||
policy_applied,
|
||||
};
|
||||
// Held back for the profile prompt (Ask When Opening).
|
||||
if let Some(id) = v.get("held").and_then(Value::as_u64) {
|
||||
let (doc, read_only) = app.session.take_held_open(id).ok_or("the open job's document is gone")?;
|
||||
crate::open_prompt::arrive(app, held(doc, read_only, v.get("color").cloned(), false));
|
||||
return Ok(());
|
||||
}
|
||||
let mut index = v.get("document").and_then(Value::as_u64).ok_or("the open job returned no document")? as usize;
|
||||
// An earlier file is waiting on its prompt: this one opens after it, in order.
|
||||
if crate::open_prompt::waiting(app)
|
||||
&& let Some(st) = app.session.close(index)
|
||||
{
|
||||
let read_only = st.source_read_only;
|
||||
let doc = Arc::try_unwrap(st.doc).unwrap_or_else(|d| (*d).clone());
|
||||
crate::open_prompt::arrive(app, held(doc, read_only, None, true));
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(slot) = slot
|
||||
&& let Ok(moved) = app.run("document.move", json!({"document": index, "to": slot}))
|
||||
{
|
||||
index = moved.get("document").and_then(Value::as_u64).map_or(index, |i| i as usize);
|
||||
}
|
||||
let warnings: Vec<String> =
|
||||
v.get("warnings").and_then(Value::as_array).map(|a| a.iter().filter_map(|w| w.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||||
app.session.set_active(index);
|
||||
after_open(app, name, path, &warnings, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What every open does once its document is the active one: remember where it came from, say
|
||||
/// so, show the import warnings (a raw opens in Camera Raw first, developed from `path` or
|
||||
/// `bytes`) and fire the "Open Document" script events.
|
||||
pub(crate) fn after_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, warnings: &[String], bytes: Option<&[u8]>) {
|
||||
if let Some(p) = path {
|
||||
app.opened_from(p);
|
||||
}
|
||||
app.sync_views();
|
||||
let warnings = crate::file_open::with_missing_fonts(app, warnings);
|
||||
app.ui.status = crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]);
|
||||
// Fonts the document's type uses that aren't installed join the import warnings (#4559).
|
||||
let warnings = crate::file_open::with_missing_fonts(app, warnings.to_vec());
|
||||
let warnings = warnings.as_slice();
|
||||
let opened = crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]);
|
||||
app.ui.status = opened.clone();
|
||||
app.ui.status_error = false;
|
||||
// A raw opens in Camera Raw first; the dialog says how it was developed.
|
||||
if crate::camera_raw_ui::wants_open_dialog(app, &warnings) {
|
||||
crate::camera_raw_ui::queue_open_dialog(app, name, path, None);
|
||||
notices::io_warnings(app, &crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]), &crate::camera_raw_ui::without_develop_note(&warnings));
|
||||
if crate::camera_raw_ui::wants_open_dialog(app, warnings) {
|
||||
crate::camera_raw_ui::queue_open_dialog(app, name, path, bytes);
|
||||
notices::io_warnings(app, &opened, &crate::camera_raw_ui::without_develop_note(warnings));
|
||||
} else {
|
||||
notices::io_warnings(app, &crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]), &warnings);
|
||||
notices::io_warnings(app, &opened, warnings);
|
||||
}
|
||||
// Script events bound to "Open Document".
|
||||
photocraft_engine::automate_cmds::document_opened(&mut app.session);
|
||||
app.sync_views();
|
||||
let color = v.get("color").cloned().unwrap_or(Value::Null);
|
||||
let ask = color.get("ask").and_then(Value::as_bool) == Some(true);
|
||||
if ask && (color.get("mismatch").and_then(Value::as_bool) == Some(true) || color.get("missing").is_some()) {
|
||||
crate::prefs_ui::open_mismatch(app, &color);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------- drawing
|
||||
|
||||
+37
-24
@@ -140,6 +140,7 @@ pub mod notices;
|
||||
mod numeric_expression;
|
||||
pub mod object_finder;
|
||||
mod opacity_keys;
|
||||
pub mod open_prompt;
|
||||
pub mod outline;
|
||||
pub mod paint_mouse;
|
||||
pub mod palette;
|
||||
@@ -741,6 +742,9 @@ pub struct PhotocraftApp {
|
||||
pub(crate) save_dirs: HashMap<photocraft_doc::DocId, std::path::PathBuf>,
|
||||
/// A save to an RGB Targa parked behind the Targa Options prompt (see `targa_options_ui`).
|
||||
pub(crate) targa_options: Option<targa_options_ui::Prompt>,
|
||||
/// Files read but not opened yet: the first waits on its profile prompt, the rest behind it
|
||||
/// (see `open_prompt`).
|
||||
pub(crate) held_opens: std::collections::VecDeque<open_prompt::Held>,
|
||||
/// Set once the user has agreed to quit, so the resulting close request goes through.
|
||||
pub(crate) allow_close: bool,
|
||||
/// Pen pressure/tilt from the platform (see `stylus`).
|
||||
@@ -876,6 +880,7 @@ impl PhotocraftApp {
|
||||
tiff_options: None,
|
||||
save_dirs: HashMap::new(),
|
||||
targa_options: None,
|
||||
held_opens: Default::default(),
|
||||
allow_close: false,
|
||||
stylus: Default::default(),
|
||||
background_jobs: false,
|
||||
@@ -1215,6 +1220,12 @@ impl PhotocraftApp {
|
||||
/// shown to the user). Files from disk go through [`open_file`](Self::open_file), which also
|
||||
/// remembers the path. Brush and gradient files go to the preset libraries instead.
|
||||
pub fn open_bytes(&mut self, name: &str, bytes: &[u8]) -> Result<Vec<String>, String> {
|
||||
self.open_bytes_from(name, bytes, None)
|
||||
}
|
||||
|
||||
/// [`Self::open_bytes`] for a file read from `path` (File › Save writes back there; it goes
|
||||
/// to Open Recent), when known.
|
||||
pub(crate) fn open_bytes_from(&mut self, name: &str, bytes: &[u8], path: Option<&str>) -> Result<Vec<String>, String> {
|
||||
if let Some(r) = preset_files_ui::open(self, name, bytes) {
|
||||
return r.map(|()| Vec::new());
|
||||
}
|
||||
@@ -1222,35 +1233,34 @@ impl PhotocraftApp {
|
||||
// Decoded on a worker: a tab with progress appears now, the document when it's ready
|
||||
// (warnings are shown then).
|
||||
if self.background_jobs {
|
||||
jobs_ui::start_open(self, name, None, jobs_ui::bytes(bytes))?;
|
||||
jobs_ui::start_open(self, name, path.map(str::to_string), jobs_ui::bytes(bytes))?;
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let import = self.services.import.as_ref().ok_or("no importer configured")?;
|
||||
let max_svg_group_depth = self.session.prefs().file_handling.rasterize_svg_groups_deeper_than as usize;
|
||||
let (doc, warnings) = import(name, bytes, max_svg_group_depth)?;
|
||||
// Edit › Color Settings policies apply on open; mismatches can ask what to do.
|
||||
// No path yet: a bare name isn't a location to save back to (`open_file` sets the path).
|
||||
let (_, color) = self.session.open_document(doc, None);
|
||||
if let Some(st) = self.session.active_mut() {
|
||||
st.source_read_only = photocraft_io::affinity::is_affinity(bytes);
|
||||
}
|
||||
self.sync_views();
|
||||
let warnings = file_open::with_missing_fonts(self, warnings);
|
||||
self.ui.status = crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]);
|
||||
self.ui.status_error = false;
|
||||
if camera_raw_ui::wants_open_dialog(self, &warnings) {
|
||||
camera_raw_ui::queue_open_dialog(self, name, None, Some(bytes));
|
||||
notices::io_warnings(self, &crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]), &camera_raw_ui::without_develop_note(&warnings));
|
||||
} else {
|
||||
notices::io_warnings(self, &crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]), &warnings);
|
||||
}
|
||||
// Script events bound to "Open Document".
|
||||
photocraft_engine::automate_cmds::document_opened(&mut self.session);
|
||||
self.sync_views();
|
||||
let ask = color.get("ask").and_then(Value::as_bool) == Some(true);
|
||||
if ask && (color.get("mismatch").and_then(Value::as_bool) == Some(true) || color.get("missing").is_some()) {
|
||||
prefs_ui::open_mismatch(self, &color);
|
||||
}
|
||||
// Edit › Color Settings policies apply on open; Ask When Opening holds the file back
|
||||
// until its prompt is answered, and later files wait behind it (`open_prompt`).
|
||||
// Missing fonts are read from the document itself: it may not be open yet (#4559).
|
||||
let warnings = file_open::with_missing_fonts_of(&doc, warnings);
|
||||
// A developed raw has no embedded profile to ask about.
|
||||
let developed = warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE));
|
||||
let question = self.session.color.open_question(&doc).filter(|_| !developed);
|
||||
let wants_raw = camera_raw_ui::wants_open_dialog(self, &warnings);
|
||||
open_prompt::arrive(
|
||||
self,
|
||||
open_prompt::Held {
|
||||
doc,
|
||||
read_only: photocraft_io::affinity::is_affinity(bytes),
|
||||
name: name.to_string(),
|
||||
path: path.map(str::to_string),
|
||||
warnings: warnings.clone(),
|
||||
bytes: (wants_raw && path.is_none()).then(|| std::sync::Arc::new(bytes.to_vec())),
|
||||
slot: None,
|
||||
question,
|
||||
policy_applied: false,
|
||||
},
|
||||
);
|
||||
Ok(warnings)
|
||||
}
|
||||
|
||||
@@ -2459,6 +2469,9 @@ mod navigator_gpu_tests;
|
||||
#[cfg(test)]
|
||||
mod color_swatch_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod profile_open_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod profile_prompt_tests;
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Ask When Opening (Edit › Color Settings): a file whose embedded profile doesn't match the
|
||||
//! working space (or that has none) is held back while a prompt asks what to do, as Photoshop
|
||||
//! does: one prompt at a time, naming its file, before that file opens. OK applies the answer to
|
||||
//! that file and opens it, then asks about the next; Cancel opens neither it nor the files still
|
||||
//! to come. Files that finish reading while a prompt is up wait their turn, so they open in order.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_doc::{ColorMode, Document};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// The field that marks a prompt as this module's.
|
||||
const FIELD: &str = "__heldOpen";
|
||||
|
||||
/// A file read and decoded, waiting to open.
|
||||
pub struct Held {
|
||||
pub doc: Document,
|
||||
/// The source is read-only (an Affinity file): Save doesn't write back to it.
|
||||
pub read_only: bool,
|
||||
pub name: String,
|
||||
pub path: Option<String>,
|
||||
pub warnings: Vec<String>,
|
||||
/// The file's bytes, for Camera Raw to develop from when there is no path.
|
||||
pub bytes: Option<Arc<Vec<u8>>>,
|
||||
/// The tab position a reopened file keeps.
|
||||
pub slot: Option<usize>,
|
||||
/// The profile question ([`photocraft_engine::color_cmds::ColorState::open_question`]), or
|
||||
/// `None` to open without asking.
|
||||
pub question: Option<Value>,
|
||||
/// The Color Settings policy was already applied (a background open added it before it had
|
||||
/// to wait); otherwise opening applies it.
|
||||
pub policy_applied: bool,
|
||||
}
|
||||
|
||||
/// Whether a file that just finished reading has to wait: one earlier in the queue does.
|
||||
pub fn waiting(app: &PhotocraftApp) -> bool {
|
||||
!app.held_opens.is_empty()
|
||||
}
|
||||
|
||||
/// Opens `held` now, or queues it behind the file whose prompt is up.
|
||||
pub fn arrive(app: &mut PhotocraftApp, held: Held) {
|
||||
app.held_opens.push_back(held);
|
||||
advance(app);
|
||||
}
|
||||
|
||||
/// Opens queued files in order until one needs its prompt, which is shown if it isn't yet.
|
||||
fn advance(app: &mut PhotocraftApp) {
|
||||
while let Some(front) = app.held_opens.front() {
|
||||
if front.question.is_some() {
|
||||
if !app.ui.dialogs.iter().any(|d| d.fields.contains_key(FIELD)) {
|
||||
show(app);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(held) = app.held_opens.pop_front() {
|
||||
// A failure is shown to the user (`open_failed`); the next file still opens.
|
||||
let _ = open(app, held, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the Embedded Profile Mismatch (or missing profile) prompt for the first queued file.
|
||||
fn show(app: &mut PhotocraftApp) {
|
||||
let Some(front) = app.held_opens.front() else { return };
|
||||
let Some(q) = &front.question else { return };
|
||||
// The mode reads as the catalogs name it (French "RVB"); the file name goes in last, so
|
||||
// nothing in it is taken for a placeholder.
|
||||
let mode = tl!(match front.doc.mode {
|
||||
ColorMode::Cmyk => "CMYK",
|
||||
ColorMode::Grayscale => "Gray",
|
||||
_ => "RGB",
|
||||
});
|
||||
let name = front.name.as_str();
|
||||
let working = crate::i18n::fmt(tl!("Working: {working}"), &[("working", q.get("working").and_then(Value::as_str).unwrap_or("?"))]);
|
||||
let missing = q.get("missing").and_then(Value::as_bool) == Some(true);
|
||||
let message = if missing {
|
||||
let what = crate::i18n::fmt(tl!("The document “{name}” does not have an embedded {mode} profile."), &[("mode", mode), ("name", name)]);
|
||||
format!("{what}\n{working}")
|
||||
} else {
|
||||
let what = crate::i18n::fmt(
|
||||
tl!("The document “{name}” has an embedded color profile that does not match the current {mode} working space."),
|
||||
&[("mode", mode), ("name", name)],
|
||||
);
|
||||
let embedded = crate::i18n::fmt(tl!("Embedded: {embedded}"), &[("embedded", q.get("embedded").and_then(Value::as_str).unwrap_or("?"))]);
|
||||
format!("{what}\n{embedded}\n{working}")
|
||||
};
|
||||
let action = q.get("action").and_then(Value::as_str).unwrap_or("preserve").to_string();
|
||||
crate::prefs_ui::open_mismatch(app, &message, &action, missing, FIELD);
|
||||
}
|
||||
|
||||
/// Whether `fields` are one of this module's prompts.
|
||||
pub fn owns(fields: &serde_json::Map<String, Value>) -> bool {
|
||||
fields.contains_key(FIELD)
|
||||
}
|
||||
|
||||
/// The prompt's answer: `Some(action)` (OK) opens the first queued file with it and moves on to
|
||||
/// the next; `None` (Cancel) drops every queued file and stops the reads still running.
|
||||
pub fn answer(app: &mut PhotocraftApp, action: Option<&str>) -> Result<Value, String> {
|
||||
let Some(action) = action else {
|
||||
let dropped = app.held_opens.len();
|
||||
let name = app.held_opens.front().map(|h| h.name.clone()).unwrap_or_default();
|
||||
app.held_opens.clear();
|
||||
for job in app.jobs.opens.iter().map(|o| o.job).collect::<Vec<_>>() {
|
||||
app.session.cancel_job(job);
|
||||
}
|
||||
app.ui.status = crate::i18n::fmt(tl!("Cancelled opening {name}"), &[("name", &name)]);
|
||||
app.ui.status_error = false;
|
||||
return Ok(json!({"cancelled": dropped}));
|
||||
};
|
||||
let Some(held) = app.held_opens.pop_front() else { return Err("no file is waiting to open".into()) };
|
||||
let r = open(app, held, Some(action));
|
||||
advance(app);
|
||||
r
|
||||
}
|
||||
|
||||
/// Opens a queued file, applying `action` (the prompt's answer) or else the Color Settings
|
||||
/// policy, then does what any open does afterwards.
|
||||
fn open(app: &mut PhotocraftApp, held: Held, action: Option<&str>) -> Result<Value, String> {
|
||||
let Held { mut doc, read_only, name, path, warnings, bytes, slot, question, policy_applied } = held;
|
||||
let index = if question.is_some() || policy_applied {
|
||||
if let Some(action) = action
|
||||
&& let Err(e) = app.session.color.resolve_open(&mut doc, action)
|
||||
{
|
||||
app.open_failed(&name, &e.to_string());
|
||||
return Err(e.to_string());
|
||||
}
|
||||
app.session.add_document(doc, None)
|
||||
} else {
|
||||
app.session.open_document(doc, None).0
|
||||
};
|
||||
if let Some(st) = app.session.active_mut() {
|
||||
st.source_read_only = read_only;
|
||||
}
|
||||
let index = match slot {
|
||||
Some(slot) => app
|
||||
.run("document.move", json!({"document": index, "to": slot}))
|
||||
.ok()
|
||||
.and_then(|m| m.get("document").and_then(Value::as_u64))
|
||||
.map_or(index, |i| i as usize),
|
||||
None => index,
|
||||
};
|
||||
app.session.set_active(index);
|
||||
crate::jobs_ui::after_open(app, &name, path.as_deref(), &warnings, bytes.as_deref().map(Vec::as_slice));
|
||||
Ok(json!({"document": index, "action": action}))
|
||||
}
|
||||
@@ -994,26 +994,13 @@ pub fn open_shortcuts(app: &mut PhotocraftApp, tab: u64) -> u64 {
|
||||
open_kind(app, "shortcuts", tl!("Keyboard Shortcuts and Menus"), fields)
|
||||
}
|
||||
|
||||
/// Open the Embedded Profile Mismatch prompt for the active document.
|
||||
pub fn open_mismatch(app: &mut PhotocraftApp, report: &Value) -> u64 {
|
||||
let msg = if report.get("missing").and_then(Value::as_bool) == Some(true) {
|
||||
let template = "The document does not have an embedded colour profile. Working space: {working}.";
|
||||
crate::i18n::fmt(tl!(template), &[("working", report["working"].as_str().unwrap_or("?"))])
|
||||
} else {
|
||||
let template = "The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working}";
|
||||
crate::i18n::fmt(tl!(template), &[("embedded", report["embedded"].as_str().unwrap_or("?")), ("working", report["working"].as_str().unwrap_or("?"))])
|
||||
};
|
||||
let action = match report["action"].as_str() {
|
||||
Some("converted") => "convert",
|
||||
Some("discarded") => "discard",
|
||||
_ => "preserve",
|
||||
};
|
||||
open_kind(
|
||||
app,
|
||||
"mismatch",
|
||||
tl!("Embedded Profile Mismatch"),
|
||||
json!({"message": msg, "action": action, "applied": action, "missing": report.get("missing").is_some()}),
|
||||
)
|
||||
/// Open the Embedded Profile Mismatch prompt for a file waiting to open (`open_prompt`): its
|
||||
/// `message`, the answer selected first (`action`), and `marker`, the field that routes OK and
|
||||
/// Cancel back to `open_prompt`.
|
||||
pub fn open_mismatch(app: &mut PhotocraftApp, message: &str, action: &str, missing: bool, marker: &str) -> u64 {
|
||||
let mut fields = json!({"message": message, "action": action, "missing": missing});
|
||||
fields[marker] = json!(true);
|
||||
open_kind(app, "mismatch", tl!("Embedded Profile Mismatch"), fields)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ bodies
|
||||
@@ -2011,13 +1998,10 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value,
|
||||
})
|
||||
}
|
||||
}
|
||||
// The file it asks about opens with this answer (`open_prompt`).
|
||||
"mismatch" => {
|
||||
let action = f.get("action").and_then(Value::as_str).unwrap_or("preserve");
|
||||
let applied = f.get("applied").and_then(Value::as_str).unwrap_or("preserve");
|
||||
if action == applied {
|
||||
return Ok(json!({"action": action}));
|
||||
}
|
||||
app.run("color.profileMismatch", json!({"action": action}))
|
||||
crate::open_prompt::answer(app, Some(action))
|
||||
}
|
||||
_ => Err("unknown dialog".into()),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
//! Files opened together whose embedded profile doesn't match the working space (File › Open with
|
||||
//! several selected, Ask When Opening on). Checked in Photoshop 27.11: one Embedded Profile
|
||||
//! Mismatch prompt at a time, naming its file, before that file opens; OK opens it and asks about
|
||||
//! the next; Cancel opens neither it nor the files still to come.
|
||||
|
||||
use crate::{PhotocraftApp, Services};
|
||||
use egui::{Modifiers, PointerButton, Pos2, vec2};
|
||||
use egui_kittest::{
|
||||
Harness,
|
||||
kittest::{NodeT, Queryable},
|
||||
};
|
||||
use photocraft_doc::{Color, ColorMode, Document, SampleType, Size};
|
||||
use photocraft_engine::Session;
|
||||
use serde_json::Value;
|
||||
|
||||
const USE: &str = "Use the embedded profile (instead of the working space)";
|
||||
const CONVERT: &str = "Convert document's colors to the working space";
|
||||
const DISCARD: &str = "Discard the embedded profile (don't color manage)";
|
||||
|
||||
/// A PNG tagged with Adobe RGB, which doesn't match the sRGB working space.
|
||||
fn adobe_png() -> Vec<u8> {
|
||||
let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::rgba(0.2, 0.6, 0.9, 1.0));
|
||||
d.icc_profile = Some(photocraft_cms::Builtin::from_id("adobe-rgb-compat").unwrap().profile().to_bytes());
|
||||
photocraft_io::export(&d, "t.png", &Default::default()).unwrap().bytes
|
||||
}
|
||||
|
||||
fn harness(background: bool) -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
PhotocraftApp::new(
|
||||
Session::new(),
|
||||
Services {
|
||||
import: Some(Box::new(|name, bytes, depth| {
|
||||
photocraft_io::import_with_svg_group_depth(name, bytes, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
|
||||
})),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
});
|
||||
h.state_mut().background_jobs = background;
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("edit.colorSettings", serde_json::json!({"askOnMismatch": true, "policyRgb": "preserve"})).unwrap();
|
||||
h
|
||||
}
|
||||
|
||||
/// Open a.png, b.png and c.png together and let every read finish.
|
||||
fn open_three(h: &mut Harness<'static, PhotocraftApp>) {
|
||||
let png = adobe_png();
|
||||
for name in ["a.png", "b.png", "c.png"] {
|
||||
h.state_mut().open_file(&format!("/pics/{name}"), &png).unwrap();
|
||||
}
|
||||
settle(h);
|
||||
}
|
||||
|
||||
/// Run frames until no open is still reading.
|
||||
fn settle(h: &mut Harness<'static, PhotocraftApp>) {
|
||||
for _ in 0..200 {
|
||||
h.run_steps(2);
|
||||
if h.state().jobs.opens.is_empty() && h.state().session.jobs().is_empty() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
h.run_steps(6);
|
||||
}
|
||||
|
||||
fn docs(h: &Harness<'static, PhotocraftApp>) -> Vec<String> {
|
||||
h.state().session.documents().iter().map(|d| d.doc.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// The one prompt up: its message.
|
||||
fn prompt(h: &Harness<'static, PhotocraftApp>) -> String {
|
||||
let d = &h.state().ui.dialogs;
|
||||
assert_eq!(d.len(), 1, "one prompt at a time");
|
||||
d[0].fields.get("message").and_then(Value::as_str).unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// A real click (pointer events, hit-tested like a mouse).
|
||||
fn click(h: &mut Harness<'static, PhotocraftApp>, label: &str) {
|
||||
let at: Pos2 = h.query_all_by_label(label).find(|n| !n.accesskit_node().is_disabled()).map(|n| n.rect().center()).unwrap();
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
settle(h);
|
||||
}
|
||||
|
||||
fn answer(h: &mut Harness<'static, PhotocraftApp>, choice: &str) {
|
||||
click(h, choice);
|
||||
click(h, "OK");
|
||||
}
|
||||
|
||||
fn profile(h: &Harness<'static, PhotocraftApp>, name: &str) -> Option<String> {
|
||||
let st = h.state().session.documents().iter().find(|d| d.doc.name == name).unwrap();
|
||||
st.doc.icc_profile.as_ref().map(|b| photocraft_engine::color_cmds::profile_from_bytes(b).unwrap().description.clone())
|
||||
}
|
||||
|
||||
/// The file the prompt on screen asks about.
|
||||
fn asked(h: &Harness<'static, PhotocraftApp>) -> &'static str {
|
||||
let msg = prompt(h);
|
||||
["a.png", "b.png", "c.png", "d.png"]
|
||||
.into_iter()
|
||||
.find(|n| msg.starts_with(&format!("The document “{n}” has an embedded color profile that does not match the current RGB working space.")))
|
||||
.unwrap_or_else(|| panic!("the prompt names its file: {msg}"))
|
||||
}
|
||||
|
||||
/// Files read on the spot ask in the order they were given. Read in the background, they ask in
|
||||
/// the order their reads finish, which is the same order unless a later read overtakes an earlier
|
||||
/// one: the tests hold either way.
|
||||
fn in_order(background: bool, order: &[&str]) {
|
||||
if !background {
|
||||
assert_eq!(order, &["a.png", "b.png", "c.png"][..order.len()]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_opened_together_ask_one_at_a_time_before_each_opens() {
|
||||
for background in [false, true] {
|
||||
let mut h = harness(background);
|
||||
open_three(&mut h);
|
||||
let mut answered: Vec<&str> = Vec::new();
|
||||
for _ in 0..3 {
|
||||
assert_eq!(docs(&h), answered, "background {background}: a file opens only after its prompt");
|
||||
let name = asked(&h);
|
||||
assert!(!answered.contains(&name), "background {background}: {name} asked twice");
|
||||
answer(&mut h, USE);
|
||||
answered.push(name);
|
||||
}
|
||||
in_order(background, &answered);
|
||||
assert!(h.state().ui.dialogs.is_empty());
|
||||
assert_eq!(docs(&h), answered, "background {background}: each opened when it was answered");
|
||||
assert_eq!(h.state().session.active().map(|d| d.doc.name.as_str()), answered.last().copied());
|
||||
}
|
||||
}
|
||||
|
||||
/// The prompt reads in the interface language: its sentence, the Embedded and Working lines and
|
||||
/// the mode's name (French "RVB"). The file's name goes in as it is, braces and all.
|
||||
#[test]
|
||||
fn the_prompt_is_translated_and_still_names_its_file() {
|
||||
let png = adobe_png();
|
||||
for (code, sentence, embedded, working) in [
|
||||
(
|
||||
"en",
|
||||
"The document “shot {mode}.png” has an embedded color profile that does not match the current RGB working space.",
|
||||
"Embedded: Adobe RGB",
|
||||
"Working: sRGB",
|
||||
),
|
||||
(
|
||||
"de",
|
||||
"Das Dokument „shot {mode}.png“ hat ein eingebettetes Farbprofil, das nicht zum aktuellen RGB-Arbeitsfarbraum passt.",
|
||||
"Eingebettet: Adobe RGB",
|
||||
"Arbeitsfarbraum: sRGB",
|
||||
),
|
||||
(
|
||||
"fr",
|
||||
"Le document «NBSPshot {mode}.pngNBSP» a un profil colorimétrique incorporé qui ne correspond pas à l'espace de travail RVB actuel.",
|
||||
"IncorporéNBSP: Adobe RGB",
|
||||
"Espace de travailNBSP: sRGB",
|
||||
),
|
||||
(
|
||||
"ja",
|
||||
"ドキュメント「shot {mode}.png」の埋め込みカラープロファイルは、現在の RGB 作業用スペースと一致しません。",
|
||||
"埋め込み: Adobe RGB",
|
||||
"作業用スペース: sRGB",
|
||||
),
|
||||
] {
|
||||
let services = Services {
|
||||
import: Some(Box::new(|name, bytes, depth| {
|
||||
photocraft_io::import_with_svg_group_depth(name, bytes, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
app.run("edit.colorSettings", serde_json::json!({"askOnMismatch": true, "policyRgb": "preserve"})).unwrap();
|
||||
let lang = crate::i18n::Lang::from_code(code).unwrap();
|
||||
crate::i18n::with_language(lang, || app.open_file("/pics/shot {mode}.png", &png)).unwrap();
|
||||
assert!(app.session.documents().is_empty(), "{code}: held until answered");
|
||||
let message = app.ui.dialogs[0].fields.get("message").and_then(Value::as_str).unwrap().to_string();
|
||||
let lines: Vec<&str> = message.lines().collect();
|
||||
assert_eq!(lines.len(), 3, "{code}: {message}");
|
||||
assert_eq!(lines[0], sentence.replace("NBSP", "\u{a0}"), "{code}");
|
||||
assert!(lines[1].starts_with(&embedded.replace("NBSP", "\u{a0}")), "{code}: {}", lines[1]);
|
||||
assert!(lines[2].starts_with(&working.replace("NBSP", "\u{a0}")), "{code}: {}", lines[2]);
|
||||
}
|
||||
}
|
||||
|
||||
/// #4559 with a held file: type in a font that isn't installed is reported with the import
|
||||
/// warnings while the file still waits for its profile prompt, and shown once when it opens.
|
||||
#[test]
|
||||
fn a_held_file_still_reports_its_missing_fonts() {
|
||||
const MISSING: &str = "NoSuchFamily3471";
|
||||
let warning = format!("font '{MISSING}' is not installed; text may render using a fallback face");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", serde_json::json!({"width": 40, "height": 20})).unwrap();
|
||||
s.execute("type.create", serde_json::json!({"text": "Hi", "x": 2, "y": 12, "size": 10, "font": MISSING})).unwrap();
|
||||
let mut doc = (*s.active().unwrap().doc).clone();
|
||||
doc.icc_profile = Some(photocraft_cms::Builtin::from_id("adobe-rgb-compat").unwrap().profile().to_bytes());
|
||||
let services = Services { import: Some(Box::new(move |_: &str, _: &[u8], _: usize| Ok((doc.clone(), Vec::new())))), ..Default::default() };
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
app.run("edit.colorSettings", serde_json::json!({"askOnMismatch": true, "policyRgb": "preserve"})).unwrap();
|
||||
assert_eq!(app.open_bytes("poster.psd", b"x").unwrap(), vec![warning.clone()]);
|
||||
assert!(app.session.documents().is_empty() && app.ui.notices.is_empty(), "held: nothing shown yet");
|
||||
crate::open_prompt::answer(&mut app, Some("preserve")).unwrap();
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
assert_eq!(app.ui.notices.len(), 1);
|
||||
assert_eq!(app.ui.notices[0].lines, vec![warning]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_answer_applies_to_its_own_file() {
|
||||
for background in [false, true] {
|
||||
let mut h = harness(background);
|
||||
open_three(&mut h);
|
||||
// Whichever file a prompt names gets that file's own answer.
|
||||
let mut answered: Vec<&str> = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let name = asked(&h);
|
||||
answer(
|
||||
&mut h,
|
||||
match name {
|
||||
"a.png" => CONVERT,
|
||||
"b.png" => DISCARD,
|
||||
_ => USE,
|
||||
},
|
||||
);
|
||||
answered.push(name);
|
||||
}
|
||||
in_order(background, &answered);
|
||||
assert_eq!(docs(&h), answered);
|
||||
assert_eq!(profile(&h, "a.png").as_deref(), Some("sRGB IEC61966-2.1"), "background {background}: a converted");
|
||||
assert_eq!(profile(&h, "b.png"), None, "background {background}: b discarded");
|
||||
assert!(profile(&h, "c.png").unwrap().starts_with("Adobe RGB"), "background {background}: c kept");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_opens_neither_the_file_nor_the_ones_still_to_come() {
|
||||
for background in [false, true] {
|
||||
let mut h = harness(background);
|
||||
open_three(&mut h);
|
||||
let first = asked(&h);
|
||||
in_order(background, &[first]);
|
||||
answer(&mut h, USE);
|
||||
click(&mut h, "Cancel");
|
||||
settle(&mut h);
|
||||
assert!(h.state().ui.dialogs.is_empty(), "background {background}: no prompt left");
|
||||
assert_eq!(docs(&h), [first], "background {background}");
|
||||
// A later open asks again.
|
||||
h.state_mut().open_file("/pics/d.png", &adobe_png()).unwrap();
|
||||
settle(&mut h);
|
||||
assert_eq!(asked(&h), "d.png");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Embedded Profile Mismatch prompts when several files open at once (File › Open with several
|
||||
//! selected): every prompt can be answered with a real click, one after another, even when the
|
||||
//! user clicks while the next file's prompt is appearing.
|
||||
//! Stacked dialogs of the same size in the same place (Embedded Profile Mismatch prompts used to
|
||||
//! stack like this when several files opened at once): each can be answered with a real click,
|
||||
//! one after another, even when an older one has been raised above the live one.
|
||||
|
||||
use crate::{PhotocraftApp, Services};
|
||||
use egui::{Modifiers, PointerButton, Pos2, vec2};
|
||||
@@ -8,16 +8,8 @@ use egui_kittest::{
|
||||
Harness,
|
||||
kittest::{NodeT, Queryable},
|
||||
};
|
||||
use photocraft_doc::{Color, ColorMode, Document, SampleType, Size};
|
||||
use photocraft_engine::Session;
|
||||
|
||||
/// A PNG tagged with Adobe RGB, which doesn't match the sRGB working space.
|
||||
fn adobe_png() -> Vec<u8> {
|
||||
let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::rgba(0.2, 0.6, 0.9, 1.0));
|
||||
d.icc_profile = Some(photocraft_cms::Builtin::from_id("adobe-rgb-compat").unwrap().profile().to_bytes());
|
||||
photocraft_io::export(&d, "t.png", &Default::default()).unwrap().bytes
|
||||
}
|
||||
|
||||
fn harness(background: bool) -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
@@ -36,11 +28,13 @@ fn harness(background: bool) -> Harness<'static, PhotocraftApp> {
|
||||
h
|
||||
}
|
||||
|
||||
/// Open `n` mismatched files in one go and wait until their prompts are up and laid out.
|
||||
/// Stack `n` identical Embedded Profile Mismatch dialogs (same size, same place; only the newest
|
||||
/// is live) and wait until they are up and laid out. Opening files no longer stacks them (they ask
|
||||
/// one at a time, `profile_open_tests`), so they are opened directly.
|
||||
fn open_together(h: &mut Harness<'static, PhotocraftApp>, n: usize) {
|
||||
let png = adobe_png();
|
||||
for i in 0..n {
|
||||
h.state_mut().open_file(&format!("shot{i}.png"), &png).unwrap();
|
||||
let message = "The document “shot.png” has an embedded color profile that does not match the current RGB working space.";
|
||||
for _ in 0..n {
|
||||
crate::prefs_ui::open_mismatch(h.state_mut(), message, "preserve", false, "__stacked");
|
||||
}
|
||||
for _ in 0..100 {
|
||||
h.run_steps(2);
|
||||
|
||||
Reference in new issue
Block a user