mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
Merge current main and complete retained Pen messages in Nordic catalogs
This commit is contained in:
@@ -27,6 +27,10 @@ next to them.
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/sv.tsv` | Swedish UI translations | @kler, @davvarn and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/fi.tsv` | Finnish UI translations | @Janiz-zzz, @metuuu and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/da.tsv` | Danish UI translations | @pechnikov2026-star and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål UI translations | @nikolainyegaard, @Bolla76 and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
|
||||
@@ -250,8 +250,8 @@ enum SpoolError {
|
||||
|
||||
/// Sends the job and says where it went and through what. A named print server takes the IPP
|
||||
/// route. Otherwise: the app's spooler when it is preferred; else `craft-print` (`lp`, or the
|
||||
/// Windows spooler) when it is there; else CUPS over IPP; and the app's spooler when that had
|
||||
/// no route either. A route that exists but fails (an unknown printer, a refused job) is an
|
||||
/// Windows spooler) when it is there, or the app's spooler when CUPS lists no printer for `lp`;
|
||||
/// else CUPS over IPP; and the app's spooler when that had no route either. A route that exists but fails (an unknown printer, a refused job) is an
|
||||
/// error, never a fall-through.
|
||||
fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -> Result<(String, String)> {
|
||||
let app = |sp: &Spooler| (sp.run)(job).map(|m| (m, sp.name.to_string())).map_err(other);
|
||||
@@ -261,7 +261,19 @@ fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -
|
||||
}
|
||||
if let Some(name) = system_spooler() {
|
||||
require_windows_pdf()?;
|
||||
return with_spooler(|| craft_print::submit(&job.pdf, lp)).map(|m| (m, name.to_string())).map_err(other);
|
||||
// lpstat is CUPS, not a Windows driver, so it runs outside SPOOLER. A listing that
|
||||
// can't finish in time can't tell, so lp gets the job as it did before the check.
|
||||
let route = match name {
|
||||
"lp" => lp_printers(LP_LISTING_TIMEOUT).map_or_else(|| Some(lp.clone()), |list| lp_route(lp, &list)),
|
||||
_ => Some(lp.clone()),
|
||||
};
|
||||
return match (route, spooler) {
|
||||
(Some(lp), _) => with_spooler(|| craft_print::submit(&job.pdf, &lp)).map(|m| (m, name.to_string())).map_err(other),
|
||||
// CUPS lists no printer. IPP would ask the same CUPS, or (it ignores client.conf)
|
||||
// another server whose printer the user never chose, so only the portal is left.
|
||||
(None, Some(sp)) => app(sp),
|
||||
(None, None) => Err(other("no printers found; set one up in the system's printer settings, or print to PDF")),
|
||||
};
|
||||
}
|
||||
}
|
||||
match cups_spool(job) {
|
||||
@@ -274,6 +286,78 @@ fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a print job waits for CUPS' printer list (`lpstat`, whose `-e` browses the network).
|
||||
const LP_LISTING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// The most a printer listing may print; a longer one counts as no answer.
|
||||
const LP_LISTING_CAP: u64 = 1 << 20;
|
||||
|
||||
/// CUPS' printers as `craft_print::printers` lists them, or `None` when that can't be told: an
|
||||
/// `lpstat` can't start, outlasts `timeout`, prints more than `LP_LISTING_CAP`, or `lpstat -e`
|
||||
/// fails (CUPS unreachable, or listing forbidden by policy). `lpstat -p -d`'s own status is not
|
||||
/// a failure: it exits 1 whenever there are no queues, as on a host with only driverless
|
||||
/// printers. Each `lpstat` is killed and reaped at the deadline, so a hung one never outlives
|
||||
/// the job.
|
||||
fn lp_printers(timeout: std::time::Duration) -> Option<Vec<craft_print::Printer>> {
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
let (_, queues) = output_by(craft_print::lpstat_command(), deadline)?;
|
||||
// `lpstat -e`, as craft-print runs it (its builder is private): the driverless printers.
|
||||
let mut driverless = std::process::Command::new("lpstat");
|
||||
driverless.arg("-e").env("LC_ALL", "C").env("LANG", "C").env("SOFTWARE", "Craft");
|
||||
let (listed, available) = output_by(driverless, deadline)?;
|
||||
listed.then(|| craft_print::printers_parsed(&queues, Some(&available)))
|
||||
}
|
||||
|
||||
/// Whether `command` succeeded, and its standard output; `None` when it can't start, prints more
|
||||
/// than `LP_LISTING_CAP`, or is still running at `deadline`, when it is killed and reaped. The
|
||||
/// output is read on its own thread, so a full pipe can't stall the wait, and that thread ends
|
||||
/// when the process does.
|
||||
fn output_by(mut command: std::process::Command, deadline: std::time::Instant) -> Option<(bool, String)> {
|
||||
use std::io::Read as _;
|
||||
use std::process::Stdio;
|
||||
let mut child = command.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?;
|
||||
let mut stdout = child.stdout.take()?;
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let reader = std::thread::Builder::new().name("print-listing".into()).spawn(move || {
|
||||
let mut out = Vec::new();
|
||||
// One byte over the cap tells an oversized listing apart; stopping closes the pipe.
|
||||
let _ = sender.send((&mut stdout).take(LP_LISTING_CAP + 1).read_to_end(&mut out).map(|_| out));
|
||||
});
|
||||
let status = reader.ok().and_then(|_| {
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break Some(status),
|
||||
Ok(None) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(20)),
|
||||
_ => break None,
|
||||
}
|
||||
}
|
||||
});
|
||||
let Some(status) = status else {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
};
|
||||
let out = receiver.recv_timeout(deadline.saturating_duration_since(std::time::Instant::now())).ok()?.ok()?;
|
||||
let fits = u64::try_from(out.len()).is_ok_and(|n| n <= LP_LISTING_CAP);
|
||||
fits.then(|| (status.success(), String::from_utf8_lossy(&out).into_owned()))
|
||||
}
|
||||
|
||||
/// The job for `lp`, or `None` when no printer was asked for and CUPS lists none: `lp` has
|
||||
/// nowhere to send it, and the portal still may (a Flatpak bundles `lp`). A printer asked for by
|
||||
/// name always goes to `lp`, which knows it even where CUPS won't list it, and the portal could
|
||||
/// not honour the name. With no printer asked for, no default and one printer, that printer, as
|
||||
/// `resolve_printer` picks for IPP.
|
||||
fn lp_route(lp: &craft_print::Job, printers: &[craft_print::Printer]) -> Option<craft_print::Job> {
|
||||
let mut lp = lp.clone();
|
||||
if lp.printer.is_none()
|
||||
&& !printers.iter().any(|p| p.default)
|
||||
&& let [only] = printers
|
||||
{
|
||||
lp.printer = Some(only.name.clone());
|
||||
}
|
||||
(lp.printer.is_some() || !printers.is_empty()).then_some(lp)
|
||||
}
|
||||
|
||||
/// The spooler `craft-print` can talk to, as the job's `backend` name: `lp` when CUPS' command
|
||||
/// is on the path, the Windows spooler on Windows. `None` in a sandbox without `lp` and in the
|
||||
/// browser.
|
||||
|
||||
@@ -678,6 +678,14 @@ fn spool_transport_streams_pdf_and_propagates_refusal() {
|
||||
b"#!/bin/sh\nif [ \"$2\" = Refuse ]; then echo 'queue refused' >&2; exit 1; fi\nprintf '%s\\n' \"$@\" > \"$PHOTOCRAFT_TEST_PRINT_TRANSPORT/args\"\n/bin/cat > \"$PHOTOCRAFT_TEST_PRINT_TRANSPORT/received.pdf\"\necho 'request id is Office-42 (0 file(s))'\n",
|
||||
).unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
// `lp` takes a job only when CUPS lists a printer (`lp_route`), so the fake CUPS lists its queues.
|
||||
let lpstat = dir.path().join("lpstat");
|
||||
std::fs::write(
|
||||
&lpstat,
|
||||
b"#!/bin/sh\nif [ \"$1\" = -e ]; then printf 'Office\\nRefuse\\n'; else printf 'printer Office is idle.\\nprinter Refuse is idle.\\n'; fi\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&lpstat, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let result = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "cmd::print_cmds::tests::spool_transport_streams_pdf_and_propagates_refusal", "--nocapture"])
|
||||
.env(CHILD, dir.path())
|
||||
@@ -972,6 +980,50 @@ fn a_preferred_app_spooler_takes_the_job() {
|
||||
assert!(std::path::Path::new(&out).exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_printer_listing_past_its_deadline_is_killed_and_reaped() {
|
||||
let soon = || std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
let mut echo = std::process::Command::new("sh");
|
||||
echo.args(["-c", "echo printer Office is idle."]);
|
||||
assert_eq!(output_by(echo, soon()), Some((true, "printer Office is idle.\n".to_string())));
|
||||
let mut failed = std::process::Command::new("sh");
|
||||
failed.args(["-c", "exit 1"]);
|
||||
assert_eq!(output_by(failed, soon()), Some((false, String::new())), "a failed listing is told apart from an empty one");
|
||||
let mut flood = std::process::Command::new("sh");
|
||||
flood.args(["-c", "head -c 2000000 /dev/zero"]);
|
||||
assert_eq!(output_by(flood, soon()), None, "an oversized listing is no answer");
|
||||
// A hung lpstat: given up at the deadline, and killed, so nothing is left waiting on it.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pid_file = dir.path().join("pid");
|
||||
let mut hung = std::process::Command::new("sh");
|
||||
hung.arg("-c").arg(format!("echo $$ > '{}'; exec sleep 30", pid_file.display()));
|
||||
let started = std::time::Instant::now();
|
||||
assert_eq!(output_by(hung, started + std::time::Duration::from_millis(300)), None);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5), "the deadline must cut the wait short");
|
||||
let pid = std::fs::read_to_string(&pid_file).unwrap();
|
||||
// `kill -0` fails for a process that no longer exists, on every Unix.
|
||||
let alive = std::process::Command::new("kill").args(["-0", pid.trim()]).stderr(std::process::Stdio::null()).status().unwrap().success();
|
||||
assert!(!alive, "the hung process must be killed and reaped");
|
||||
assert_eq!(output_by(std::process::Command::new("/nonexistent/lpstat"), soon()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lp_takes_the_job_only_when_cups_lists_a_printer_and_picks_the_only_one() {
|
||||
let p = |name: &str, default: bool| craft_print::Printer { name: name.into(), default, two_sided: None, description: String::new() };
|
||||
let job = craft_print::Job::default();
|
||||
// No printers: no lp route, so the portal still gets the job.
|
||||
assert!(lp_route(&job, &[]).is_none());
|
||||
assert_eq!(lp_route(&job, &[p("Office", false)]).and_then(|j| j.printer), Some("Office".into()));
|
||||
assert_eq!(lp_route(&job, &[p("Office", false), p("Lab", true)]).and_then(|j| j.printer), None, "lp picks the default itself");
|
||||
assert_eq!(lp_route(&job, &[p("Office", false), p("Lab", false)]).and_then(|j| j.printer), None);
|
||||
let asked = craft_print::Job { printer: Some("Lab".into()), ..Default::default() };
|
||||
assert_eq!(lp_route(&asked, &[p("Office", false)]).and_then(|j| j.printer), Some("Lab".into()));
|
||||
// A named printer goes to lp even when CUPS lists none: listing can be forbidden while
|
||||
// printing is not, and the portal can't honour a name.
|
||||
assert_eq!(lp_route(&asked, &[]).and_then(|j| j.printer), Some("Lab".into()));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod cups {
|
||||
use super::*;
|
||||
|
||||
@@ -34,19 +34,26 @@ fn background_save_outcomes_report_failure_then_recovery() {
|
||||
let root = temp_dir("retry-failed-write");
|
||||
let blocked = root.join("recovery");
|
||||
std::fs::write(&blocked, b"not a directory").unwrap();
|
||||
let saver = Autosaver::new(&blocked, "doc");
|
||||
// Not dropped on a failure: dropping joins the worker, which waits for a write in progress,
|
||||
// so a stuck write would hold the test past its own bound until nextest kills it.
|
||||
let saver = std::mem::ManuallyDrop::new(Autosaver::new(&blocked, "doc"));
|
||||
let doc = Arc::new(rich_doc(ColorMode::Rgb, SampleType::U8));
|
||||
saver.request_checked(doc.clone(), 7, None, SaveOptions::default()).unwrap();
|
||||
|
||||
// The bound only turns a worker that never reports into a failure instead of a hang. A real
|
||||
// write flushes every tile to disk and took over 30 s on a busy Windows CI runner (normally
|
||||
// 2-3 s), so it is generous, and still under the 10 minutes after which nextest's `ci`
|
||||
// profile kills a test.
|
||||
const PATIENCE: std::time::Duration = std::time::Duration::from_secs(5 * 60);
|
||||
let receive = |saver: &Autosaver| {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
let deadline = std::time::Instant::now() + PATIENCE;
|
||||
while std::time::Instant::now() < deadline {
|
||||
if let Some(outcome) = saver.take_completed().into_iter().next() {
|
||||
return outcome;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
panic!("autosave worker did not report its completed write within 30 seconds");
|
||||
panic!("autosave worker did not report its completed write within {PATIENCE:?}");
|
||||
};
|
||||
let (revision, result) = receive(&saver);
|
||||
assert_eq!(revision, 7);
|
||||
@@ -60,7 +67,7 @@ fn background_save_outcomes_report_failure_then_recovery() {
|
||||
result.unwrap();
|
||||
assert_eq!(list_recovery(&blocked).len(), 1);
|
||||
assert_eq!(recover(&list_recovery(&blocked)[0]).unwrap(), *doc);
|
||||
drop(saver);
|
||||
drop(std::mem::ManuallyDrop::into_inner(saver));
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -632,7 +632,7 @@ impl History {
|
||||
continue;
|
||||
}
|
||||
total = total.saturating_add(tile_bytes(&s.doc, &mut seen));
|
||||
if total > self.max_bytes && keep >= 1 {
|
||||
if total > self.max_bytes && (keep >= 1 || source == Some(undo.len() - 1)) {
|
||||
break;
|
||||
}
|
||||
keep += 1;
|
||||
|
||||
@@ -222,6 +222,28 @@ fn byte_budget_keeps_the_brush_source_of_the_cleared_states() {
|
||||
assert!(Arc::ptr_eq(&h.row_doc(0, &cur).unwrap(), &source), "the History Brush paints from the same state");
|
||||
}
|
||||
|
||||
/// When the History Brush's source is the newest past state, it is the state the byte budget
|
||||
/// always keeps, and no older state is kept beside it.
|
||||
#[test]
|
||||
fn byte_budget_counts_a_newest_brush_source_as_the_kept_state() {
|
||||
let mut h = History::new(50);
|
||||
let mut cur = Arc::new(base());
|
||||
for i in 0..5 {
|
||||
edit(&mut h, &mut cur, &format!("paint {i}"), |d| {
|
||||
let id = d.layers[0].id;
|
||||
d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]);
|
||||
});
|
||||
}
|
||||
assert!(h.set_brush_source(4));
|
||||
let source = h.state(4).unwrap();
|
||||
h.max_bytes = 1;
|
||||
assert_eq!(h.trim(&cur), 4);
|
||||
assert_counter(&h, &cur);
|
||||
assert_eq!(h.past_len(), 1);
|
||||
assert_eq!(h.brush_source(), Some(0));
|
||||
assert!(Arc::ptr_eq(&h.row_doc(0, &cur).unwrap(), &source));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_edit_clears_redo() {
|
||||
let mut h = History::default();
|
||||
|
||||
@@ -124,13 +124,3 @@ fn typing_replaces_the_selected_name_and_enter_confirms() {
|
||||
assert!(h.state().ui.dialogs.is_empty());
|
||||
assert_eq!(groups(h.state())[0].name, "Clouds");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_group_dialog_labels_are_translated_in_every_catalog() {
|
||||
const LABELS: &[&str] = &["New Group…", "New Group from Layers…", "Name:", "Color:", "Mode:", "Opacity:", "Pass Through"];
|
||||
for lang in crate::i18n::Lang::all().filter(|lang| lang.complete_menus()) {
|
||||
for label in LABELS {
|
||||
assert!(crate::i18n::has(lang, label), "{} is missing New Group dialog label: {label}", lang.code());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,32 +748,25 @@ fn ok_after_a_layer_switch_leaves_the_new_layer_alone() {
|
||||
assert!(app.distort.select_mask.is_none());
|
||||
}
|
||||
|
||||
/// The reason a stale workspace was closed is user-visible text: it is shown in the status bar and
|
||||
/// returned to the control channel in the language in use, and every catalogue has it (#8896).
|
||||
/// A stale workspace is closed with a reason that is shown in the status bar and returned to the
|
||||
/// control channel (#8896).
|
||||
#[test]
|
||||
fn the_reason_a_stale_workspace_closed_is_translated() {
|
||||
fn the_reason_a_stale_workspace_closed_is_reported() {
|
||||
let document = "Select and Mask was closed: its document is no longer the active one";
|
||||
let layer = "Select and Mask was closed: another layer was selected";
|
||||
for lang in crate::i18n::Lang::all().filter(|l| l.code() != "en") {
|
||||
for (reason, english) in [("document", document), ("layer", layer)] {
|
||||
assert!(crate::i18n::has(lang, english), "{}: no row for the {reason} reason", lang.code());
|
||||
let shown = crate::i18n::tr(lang, english);
|
||||
let mut app = if reason == "document" { two_documents() } else { app_with_selection() };
|
||||
let first = app.session.active().unwrap().active_layer.unwrap();
|
||||
let other = (reason == "layer").then(|| app.run("layer.new.layer", json!({"name": "Other"})).unwrap()["layer"].as_u64().map(LayerId).unwrap());
|
||||
app.run("layer.select", json!({"layer": first.0})).unwrap();
|
||||
open(&mut app).unwrap();
|
||||
crate::i18n::with_language(lang, || {
|
||||
match other {
|
||||
Some(other) => drop(app.session.execute("layer.select", json!({"layer": other.0}))),
|
||||
None => assert!(app.session.set_active(1)),
|
||||
}
|
||||
let err = commit(&mut app).expect_err(&format!("{} / {reason}: the stale OK is refused", lang.code()));
|
||||
assert_eq!(err, shown, "{}: the error is translated", lang.code());
|
||||
assert_eq!(app.ui.status, shown, "{}: the status is translated", lang.code());
|
||||
});
|
||||
assert_ne!(shown, english, "{}: the {reason} reason reads differently from English", lang.code());
|
||||
for (reason, english) in [("document", document), ("layer", layer)] {
|
||||
let mut app = if reason == "document" { two_documents() } else { app_with_selection() };
|
||||
let first = app.session.active().unwrap().active_layer.unwrap();
|
||||
let other = (reason == "layer").then(|| app.run("layer.new.layer", json!({"name": "Other"})).unwrap()["layer"].as_u64().map(LayerId).unwrap());
|
||||
app.run("layer.select", json!({"layer": first.0})).unwrap();
|
||||
open(&mut app).unwrap();
|
||||
match other {
|
||||
Some(other) => drop(app.session.execute("layer.select", json!({"layer": other.0}))),
|
||||
None => assert!(app.session.set_active(1)),
|
||||
}
|
||||
let err = commit(&mut app).expect_err(&format!("{reason}: the stale OK is refused"));
|
||||
assert_eq!(err, english, "{reason}: the error");
|
||||
assert_eq!(app.ui.status, english, "{reason}: the status");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -844,7 +844,10 @@ fn open_paths_reports_in_the_interface_language_before_the_first_frame() {
|
||||
app.session.edit_prefs(|p| p.interface.language = "ar".into());
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
assert_eq!(app.open_paths(&paths), 1);
|
||||
assert_eq!(app.ui.status, "تم استيراد 3 نقشات من launch");
|
||||
let ar = crate::i18n::Lang::from_code("ar").unwrap();
|
||||
let want =
|
||||
crate::i18n::fmt(&crate::i18n::trn(ar, 3, "Imported {n} pattern from {file}", "Imported {n} patterns from {file}"), &[("n", "3"), ("file", "launch")]);
|
||||
assert_eq!(app.ui.status, want);
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ fn expand(packed: &str, keys: &[&str]) -> Result<String, String> {
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 23] = [
|
||||
pub static LANGUAGES: [LangInfo; 27] = [
|
||||
LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
@@ -237,6 +237,16 @@ pub static LANGUAGES: [LangInfo; 23] = [
|
||||
// Arabic (Modern Standard); `ar-SA`, `ar-EG` and the other regional locales resolve here. The
|
||||
// chrome mirrors right to left (`dir_for_code`), and egui lays each label out right to left.
|
||||
LangInfo { code: "ar", name: "العربية", source: catalog!("ar"), plural: plural_arabic, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Swedish; `sv-SE` and `sv-FI` locales resolve here.
|
||||
LangInfo { code: "sv", name: "Svenska", source: catalog!("sv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Finnish; `fi-FI` resolves here (Swedish in Finland, `sv-FI`, stays Swedish). One/other: the
|
||||
// partitive singular follows any number but 1 ("1 taso", "2 tasoa").
|
||||
LangInfo { code: "fi", name: "Suomi", source: catalog!("fi"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Danish; `da-DK` and the other `da-*` locales resolve here.
|
||||
LangInfo { code: "da", name: "Dansk", source: catalog!("da"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Norwegian Bokmål; `nb-NO`, and the macrolanguage `no` and Nynorsk `nn` from POSIX locales,
|
||||
// resolve here (see `candidates`).
|
||||
LangInfo { code: "nb", name: "Norsk bokmål", source: catalog!("nb"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
|
||||
];
|
||||
|
||||
impl LangInfo {
|
||||
@@ -366,6 +376,11 @@ fn candidates(tag: &str) -> Vec<String> {
|
||||
// The only Portuguese catalog is Brazilian; other regions use it rather than English.
|
||||
out.insert(out.len() - 1, "pt-br".to_string());
|
||||
}
|
||||
if matches!(primary, "no" | "nn") && !out.iter().any(|c| c == "nb") {
|
||||
// The only Norwegian catalog is Bokmål; the macrolanguage code and Nynorsk use it rather
|
||||
// than English.
|
||||
out.insert(out.len() - 1, "nb".to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
PhotoCraft's Korean, German, Spanish, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech,
|
||||
Turkish, Hungarian, Serbian and Russian UI translation catalogs are licensed under MIT OR
|
||||
Apache-2.0, at your option.
|
||||
Turkish, Hungarian, Serbian, Russian, Swedish, Finnish, Danish and Norwegian Bokmål UI translation
|
||||
catalogs are licensed under MIT OR Apache-2.0, at your option.
|
||||
The full license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE.
|
||||
|
||||
Korean translations: PhotoCraft contributors, original translations of
|
||||
@@ -45,3 +45,15 @@ Arabic translations: PhotoCraft contributors, combined from the catalogs contrib
|
||||
@hicbest, @hasan-sdaif, @NibrasAz7, @vfggf95565, @syrrevshin4202-prog and @test-bit-sys and
|
||||
reviewed with Claude (Anthropic) as an AI contributor; original translations of PhotoCraft's
|
||||
English labels, and no proprietary translation resources were used.
|
||||
Swedish translations: PhotoCraft contributors, combined from the catalogs contributed by @kler and
|
||||
@davvarn and reviewed with Claude (Anthropic) as an AI contributor; original translations of
|
||||
PhotoCraft's English labels, and no proprietary translation resources were used.
|
||||
Finnish translations: PhotoCraft contributors, combined from the catalogs contributed by @Janiz-zzz
|
||||
and @metuuu and reviewed with Claude (Anthropic) as an AI contributor; original translations of
|
||||
PhotoCraft's English labels, and no proprietary translation resources were used.
|
||||
Danish translations: PhotoCraft contributors, based on the catalog contributed by
|
||||
@pechnikov2026-star, completed and reviewed with Claude (Anthropic) as an AI contributor; original
|
||||
translations of PhotoCraft's English labels, and no proprietary translation resources were used.
|
||||
Norwegian Bokmål translations: PhotoCraft contributors, combined from the catalogs contributed by
|
||||
@nikolainyegaard and @Bolla76 and reviewed with Claude (Anthropic) as an AI contributor; original
|
||||
translations of PhotoCraft's English labels, and no proprietary translation resources were used.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -230,7 +230,19 @@ fn harness_with_services(services: Services) -> egui_kittest::Harness<'static, P
|
||||
|
||||
#[test]
|
||||
fn first_launch_renders_the_system_language_without_overwriting_auto() {
|
||||
for (tag, code) in [("en-US", "en"), ("zh-CN", "zh-hans"), ("ja-JP", "ja"), ("ko-KR", "ko"), ("ru-RU", "ru"), ("uk-UA", "uk"), ("fr-CA", "fr")] {
|
||||
for (tag, code) in [
|
||||
("en-US", "en"),
|
||||
("zh-CN", "zh-hans"),
|
||||
("ja-JP", "ja"),
|
||||
("ko-KR", "ko"),
|
||||
("ru-RU", "ru"),
|
||||
("uk-UA", "uk"),
|
||||
("fr-CA", "fr"),
|
||||
("sv-SE", "sv"),
|
||||
("fi-FI", "fi"),
|
||||
("da-DK", "da"),
|
||||
("nb-NO", "nb"),
|
||||
] {
|
||||
system::with_system_tags(&[tag], || {
|
||||
with_language(Lang::EN, || {
|
||||
let h = harness();
|
||||
@@ -244,7 +256,7 @@ fn first_launch_renders_the_system_language_without_overwriting_auto() {
|
||||
|
||||
#[test]
|
||||
fn missing_or_unsupported_system_languages_render_english() {
|
||||
for tags in [vec![], vec!["sv-SE", "sw-KE"]] {
|
||||
for tags in [vec![], vec!["sw-KE", "is-IS"]] {
|
||||
system::with_system_tags(&tags, || {
|
||||
with_language(Lang::EN, || {
|
||||
let h = harness();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -86,13 +86,13 @@ mod tests {
|
||||
(vec!["ja-JP", "en-US"], "ja"),
|
||||
(vec!["fr-CA", "en-US"], "fr"),
|
||||
(vec!["uk-UA", "en-US"], "uk"),
|
||||
(vec!["sv-SE", "uk_UA.UTF-8", "ru-RU"], "uk"),
|
||||
(vec!["sv-SE", "ko-KR", "fr-FR"], "ko"),
|
||||
(vec!["sw-KE", "uk_UA.UTF-8", "ru-RU"], "uk"),
|
||||
(vec!["sw-KE", "ko-KR", "fr-FR"], "ko"),
|
||||
(vec!["zh-Hant-HK", "zh-CN"], "zh-hant"),
|
||||
(vec!["zh-Hans-CN", "zh-TW"], "zh-hans"),
|
||||
(vec!["en-US", "ru-RU"], "en"),
|
||||
(vec!["sv-SE", "ar-SA"], "ar"),
|
||||
(vec!["sv-SE", "sw-KE"], "en"),
|
||||
(vec!["is-IS", "ar-SA"], "ar"),
|
||||
(vec!["sw-KE", "is-IS"], "en"),
|
||||
(vec!["de-AT", "en-US"], "de"),
|
||||
(vec!["pt-PT"], "pt-br"),
|
||||
(vec!["it-IT", "en-US"], "it"),
|
||||
@@ -100,8 +100,15 @@ mod tests {
|
||||
(vec!["hu-HU", "en-US"], "hu"),
|
||||
(vec!["tr-TR", "en-US"], "tr"),
|
||||
(vec!["sr-Latn-RS", "en-US"], "sr"),
|
||||
(vec!["sv-SE", "sr_RS@latin", "ru-RU"], "sr"),
|
||||
(vec!["sw-KE", "sr_RS@latin", "ru-RU"], "sr"),
|
||||
(vec!["hi-IN", "en-US"], "hi"),
|
||||
(vec!["sv-SE", "en-US"], "sv"),
|
||||
(vec!["sv_FI.UTF-8", "fi-FI", "en-US"], "sv"),
|
||||
(vec!["fi-FI", "sv-FI", "en-US"], "fi"),
|
||||
(vec!["da-DK", "en-US"], "da"),
|
||||
(vec!["nb-NO", "en-US"], "nb"),
|
||||
(vec!["nn_NO.UTF-8", "en-US"], "nb"),
|
||||
(vec!["is-IS", "no-NO"], "nb"),
|
||||
(vec![], "en"),
|
||||
] {
|
||||
with_system_tags(&tags, || assert_eq!(system_lang().code(), expected));
|
||||
|
||||
@@ -104,6 +104,8 @@ fn candidates_walk_from_specific_to_general() {
|
||||
assert_eq!(candidates("zh_TW"), ["zh-tw", "zh-hant", "zh"]);
|
||||
assert_eq!(candidates("zh-CN"), ["zh-cn", "zh-hans", "zh"]);
|
||||
assert_eq!(candidates("zh-Hant-HK"), ["zh-hant-hk", "zh-hant", "zh"]);
|
||||
assert_eq!(candidates("nn_NO.UTF-8"), ["nn-no", "nb", "nn"]);
|
||||
assert_eq!(candidates("no"), ["nb", "no"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -135,6 +137,65 @@ fn spanish_resolves_and_pluralises() {
|
||||
assert_eq!(forms, [1, 0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swedish_resolves_and_pluralises() {
|
||||
let sv = Lang::from_code("sv").expect("sv registered");
|
||||
assert_eq!(sv.name(), "Svenska");
|
||||
for tag in ["sv", "SV", "sv-SE", "sv_SE.UTF-8", "sv-FI", "sv_FI.UTF-8", "sv-AX"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(sv), "{tag}");
|
||||
assert_eq!(Lang::from_pref(tag), sv, "{tag}");
|
||||
}
|
||||
let forms: Vec<usize> = [0, 1, 2, 21].into_iter().map(sv.0.plural).collect();
|
||||
assert_eq!(forms, [1, 0, 1, 1]);
|
||||
assert_eq!(tr(sv, "File"), "Arkiv");
|
||||
assert_eq!(trn(sv, 1, "{n} layer", "{n} layers"), "1 lager");
|
||||
assert_eq!(trn(sv, 3, "{n} layer", "{n} layers"), "3 lager");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finnish_resolves_and_pluralises() {
|
||||
let fi = Lang::from_code("fi").expect("fi registered");
|
||||
assert_eq!(fi.name(), "Suomi");
|
||||
for tag in ["fi", "FI", "fi-FI", "fi_FI.UTF-8"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(fi), "{tag}");
|
||||
assert_eq!(Lang::from_pref(tag), fi, "{tag}");
|
||||
}
|
||||
// Filipino and Swedish in Finland don't pick Finnish.
|
||||
assert_ne!(lang_from_tag("fil"), Some(fi));
|
||||
assert_ne!(lang_from_tag("sv-FI"), Some(fi));
|
||||
assert_eq!(tr(fi, "File"), "Tiedosto");
|
||||
// The partitive singular follows any number but 1.
|
||||
for (n, layer) in [(0, "tasoa"), (1, "taso"), (2, "tasoa"), (21, "tasoa")] {
|
||||
assert_eq!(trn(fi, n, "{n} layer", "{n} layers"), format!("{n} {layer}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn danish_resolves_and_pluralises() {
|
||||
let da = Lang::from_code("da").expect("da registered");
|
||||
assert_eq!(da.name(), "Dansk");
|
||||
for tag in ["da", "DA", "da-DK", "da_DK.UTF-8", "da-GL"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(da), "{tag}");
|
||||
assert_eq!(Lang::from_pref(tag), da, "{tag}");
|
||||
}
|
||||
assert_eq!(tr(da, "File"), "Filer");
|
||||
assert_eq!(trn(da, 1, "{n} document", "{n} documents"), "1 dokument");
|
||||
assert_eq!(trn(da, 3, "{n} document", "{n} documents"), "3 dokumenter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norwegian_resolves_and_pluralises() {
|
||||
let nb = Lang::from_code("nb").expect("nb registered");
|
||||
assert_eq!(nb.name(), "Norsk bokmål");
|
||||
for tag in ["nb", "NB", "nb-NO", "nb_NO.UTF-8", "no", "no-NO", "no_NO.UTF-8", "nn", "nn-NO", "nn_NO.UTF-8"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(nb), "{tag}");
|
||||
assert_eq!(Lang::from_pref(tag), nb, "{tag}");
|
||||
}
|
||||
assert_eq!(tr(nb, "File"), "Fil");
|
||||
assert_eq!(trn(nb, 1, "{n} item", "{n} items"), "1 element");
|
||||
assert_eq!(trn(nb, 3, "{n} item", "{n} items"), "3 elementer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hungarian_resolves_and_keeps_the_singular_after_counts() {
|
||||
let hu = Lang::from_code("hu").expect("hu registered");
|
||||
@@ -203,7 +264,6 @@ fn arabic_resolves_locales_and_preferences() {
|
||||
assert_eq!(Lang::from_pref(tag), ar, "{tag}");
|
||||
}
|
||||
assert_eq!(dir_for_code("ar"), Dir::Rtl);
|
||||
assert_eq!(tr(ar, "File"), "ملف");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -212,18 +272,6 @@ fn arabic_plural_rule_has_six_forms() {
|
||||
let counts = [0, 1, 2, 3, 10, 11, 99, 100, 101, 102, 103, 111, 199, 1000, u64::MAX];
|
||||
let forms: Vec<usize> = counts.into_iter().map(ar.0.plural).collect();
|
||||
assert_eq!(forms, [0, 1, 2, 3, 3, 4, 4, 5, 5, 5, 3, 4, 4, 5, 4]);
|
||||
assert_eq!(trn(ar, 2, "{n} layer", "{n} layers"), "2 طبقتان");
|
||||
assert_eq!(trn(ar, 5, "{n} layer", "{n} layers"), "5 طبقات");
|
||||
}
|
||||
|
||||
/// The preset-import result (`preset_files_ui::open`) picks its plural pair at run time, so the
|
||||
/// literal-key coverage check can't see these rows.
|
||||
#[test]
|
||||
fn arabic_preset_import_results_are_translated() {
|
||||
let ar = Lang::from_code("ar").expect("ar registered");
|
||||
assert_eq!(trn(ar, 3, "Imported {n} pattern from {file}", "Imported {n} patterns from {file}"), "تم استيراد 3 نقشات من {file}");
|
||||
assert_eq!(trn(ar, 2, "Imported {n} gradient from {file}", "Imported {n} gradients from {file}"), "تم استيراد 2 تدرجين من {file}");
|
||||
assert_eq!(trn(ar, 1, "Imported {n} brush from {file}", "Imported {n} brushes from {file}"), "تم استيراد 1 فرشاة من {file}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns.
|
||||
# lines starting with # and blank lines are ignored.
|
||||
#
|
||||
# Status: complete. Every source key is translated (`complete_menus`), and the coverage test in
|
||||
# xtask/src/i18n_coverage.rs keeps it that way: new `tl!` strings need a Vietnamese row.
|
||||
# Status: complete when last measured. `cargo xtask i18n-coverage` lists any `tl!` string without a
|
||||
# Vietnamese row; a missing row shows in English.
|
||||
#
|
||||
# Clean-room: every entry was written from the plain meaning of the English label using ordinary
|
||||
# Vietnamese computing and image-editing vocabulary. No proprietary localisation resource, string
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -28,8 +28,6 @@ fn the_generator_checkbox_reads_enable_generator() {
|
||||
h.get_by_label("Enable Generator").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state()["generatorEnabled"], true);
|
||||
let de = crate::i18n::Lang::from_code("de").unwrap();
|
||||
assert_eq!(crate::i18n::tr(de, "Enable Generator"), "Generator aktivieren");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-28 · **Change:** minor (Arabic UI catalog; previously minor: Arabic paragraph direction buttons, #6793) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-29 · **Change:** minor (Swedish, Finnish, Danish and Norwegian Bokmål UI catalogs; previously minor: Arabic UI catalog) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
Per-language status of PhotoCraft's UI. How catalogs work and how to add one:
|
||||
[`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md),
|
||||
@@ -54,7 +54,13 @@ Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,164
|
||||
other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Turkish `tr`
|
||||
(3,731 rows) also covers every measured key. Serbian `sr` (4,022 rows, Latin script, Ekavian, added 2026-10-12) covers every
|
||||
measured key and the union of the other catalogs' dynamic labels; its one/few/many plurals reuse
|
||||
the Russian rule.
|
||||
the Russian rule. Swedish `sv` (4,552 rows, added 2026-10-28) covers every measured key and the
|
||||
other catalogs' dynamic labels; combined from the community catalogs in #5985 and #9536 with one
|
||||
term per concept; the contested terms follow a native speaker's questionnaire answers. Finnish
|
||||
`fi` (4,552 rows, combined from #5666 and #6961), Danish `da` (4,552 rows, based on #7267 and
|
||||
completed for the current UI) and Norwegian Bokmål `nb` (4,552 rows, combined from #8072 and #9025;
|
||||
`no` and `nn` locales resolve to it), all added 2026-10-29, cover the same keys with one term per
|
||||
concept; none has had a native-speaker review yet.
|
||||
|
||||
Czech UI follow-up (2026-10-11): status and notice wrappers, hardware labels, brush and
|
||||
transform accessibility names, rasterization prompts, profile mismatch messages and preset
|
||||
@@ -93,3 +99,5 @@ engine localization or native review is complete.
|
||||
| 2026-10-18 | minor | Russian: 1,229 dynamic labels added (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus, metadata, System Info, status templates, dialogs) with a terminology note ([localization-ru](localization-ru.md)) |
|
||||
| 2026-10-22 | minor | Arabic row: paragraph direction has Auto / Left-to-right / Right-to-left buttons in the Paragraph panel and Type options bar (#6793); the visual RTL caret is still missing (TYPE-218-2 partial) |
|
||||
| 2026-10-28 | minor | Arabic (`ar`) UI catalog: every measured key, six plural forms; layer names and the title bar no longer reversed on egui 0.37 |
|
||||
| 2026-10-29 | minor | Swedish (`sv`) UI catalog: every measured key plus the dynamic labels, combined from #5985 and #9536 |
|
||||
| 2026-10-29 | minor | Finnish (`fi`), Danish (`da`) and Norwegian Bokmål (`nb`) UI catalogs: every measured key plus the dynamic labels, combined from #5666, #6961, #7267, #8072 and #9025 |
|
||||
|
||||
@@ -29,6 +29,10 @@ contextual, command-ID and plural translations; missing entries fall back to Eng
|
||||
| Tiếng Việt | `vi` | one form |
|
||||
| Srpski | `sr` | one / few / many (teens use many) |
|
||||
| हिन्दी | `hi` | one / other |
|
||||
| Svenska | `sv` | one / other |
|
||||
| Suomi | `fi` | one / other (partitive after every count but 1) |
|
||||
| Dansk | `da` | one / other |
|
||||
| Norsk bokmål | `nb` | one / other |
|
||||
|
||||
Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend
|
||||
mode names, and generated preference labels. A string a catalog lacks shows in English:
|
||||
@@ -53,6 +57,9 @@ native system locale; an unsupported code follows the same fallback. Regional ta
|
||||
Simplified Chinese remain distinct. Every Portuguese locale (`pt`, `pt-BR`, `pt-PT`) uses the
|
||||
Brazilian Portuguese catalog. The Serbian catalog is written in Latin script (Ekavian); every
|
||||
Serbian locale (`sr-RS`, `sr-Latn-RS`, `sr_RS@latin`, and Cyrillic `sr-Cyrl-RS`) resolves to it.
|
||||
Swedish locales from Sweden and Finland (`sv-SE`, `sv-FI`, `sv_FI.UTF-8`) use the Swedish catalog;
|
||||
Finnish (`fi-FI`) uses the Finnish one. Every Norwegian locale (`nb-NO`, the macrolanguage `no`,
|
||||
and Nynorsk `nn-NO` from POSIX systems) uses the Bokmål catalog.
|
||||
|
||||
### First launch and system language
|
||||
|
||||
|
||||
+12
-8
@@ -25,19 +25,23 @@ the preview and generated PDF continue to share `print_cmds::layout`.
|
||||
|
||||
| Route | When | What the user sees |
|
||||
|---|---|---|
|
||||
| **`craft-print`** (`lp` on CUPS, the Windows spooler) | Whenever `lp` is on the path (macOS, Linux outside a sandbox) or on Windows | `Sent to the printer (request id is Office-42)` |
|
||||
| **CUPS over IPP** (`photocraft-ipp`) | No `lp` on the path (a Flatpak) and a print server answers: `CUPS_SERVER`, then `/run/cups/cups.sock`, `/var/run/cups/cups.sock`, `/private/var/run/cupsd`, then `localhost:631`; always for a `printServer` named in the request | `Sent to the printer (job 42 on Office)` |
|
||||
| **Print portal** (`org.freedesktop.portal.Print`, desktop app on Linux) | No print server reachable or no printer set up, or always with `PHOTOCRAFT_PRINT_PORTAL=1` | The system print dialog, seeded with the copies and paper; the outcome goes to the log |
|
||||
| **`craft-print`** (`lp` on CUPS, the Windows spooler) | `lp` is on the path (macOS, Linux, the Flatpak, which bundles it) and CUPS lists a printer, or on Windows | `Sent to the printer (request id is Office-42)` |
|
||||
| **CUPS over IPP** (`photocraft-ipp`) | No `lp` on the path and a print server answers: `CUPS_SERVER`, then `/run/cups/cups.sock`, `/var/run/cups/cups.sock`, `/private/var/run/cupsd`, then `localhost:631`; always for a `printServer` named in the request | `Sent to the printer (job 42 on Office)` |
|
||||
| **Print portal** (`org.freedesktop.portal.Print`, desktop app on Linux) | Linux only (the Flatpak included): no print server reachable or no printer set up (including when `lp` is there but CUPS lists no printer and none was named), or always with `PHOTOCRAFT_PRINT_PORTAL=1`. Elsewhere a missing route is an error | The system print dialog, seeded with the copies and paper; the outcome goes to the log |
|
||||
| **Print to PDF** | `output` given (the Output field of the dialog) | The file; nothing is spooled unless `send` is true |
|
||||
|
||||
A route that exists but fails (an unknown printer, a job the queue refuses) is an error; only a
|
||||
missing route (no `lp`, no print server, no printer) moves on to the next one.
|
||||
missing route (no `lp`, no print server, no printer) moves on to the next one. With no printer
|
||||
named and no default, the only printer takes the job on the `lp` and IPP routes; Windows needs
|
||||
a named or default printer.
|
||||
|
||||
The IPP client is in-process, pure Rust (RFC 8010/8011: `CUPS-Get-Printers`, `CUPS-Get-Classes`,
|
||||
`CUPS-Get-Default`, `Print-Job` with the PDF as the document), so no `lp` binary is needed. That
|
||||
is what makes printing work in the Flatpak: the manifest grants `--socket=cups`, which mounts the
|
||||
host's CUPS socket, and the sandbox has no `lp`. On a system without CUPS, or in a sandbox
|
||||
without the socket, the app falls back to the print portal, whose dialog runs outside the sandbox.
|
||||
`CUPS-Get-Default`, `Print-Job` with the PDF as the document), so no `lp` binary is needed. It
|
||||
only sees queues the server has, not driverless network printers, which is why the Flatpak
|
||||
bundles `lp`/`lpstat` (the manifest's `cups-client` module) and grants `--socket=cups` (the
|
||||
host's CUPS socket) and `org.freedesktop.Avahi` (`lpstat -e` finds driverless printers). On a
|
||||
system without CUPS, or in a sandbox without the socket, the app falls back to the print portal,
|
||||
whose dialog runs outside the sandbox.
|
||||
|
||||
## Which printer
|
||||
|
||||
|
||||
+2
-1
@@ -278,7 +278,8 @@ Why these formats:
|
||||
```
|
||||
|
||||
Sandbox permissions (justified in the manifest): Wayland with X11 fallback, IPC (X11
|
||||
shared memory), `dri` for the GPU, and read/write access to Pictures and Documents. Every
|
||||
shared memory), `dri` for the GPU, the CUPS socket, Avahi and `~/.cups` (read-only) for
|
||||
printing, and read/write access to Pictures and Documents. Every
|
||||
other file goes through the file-chooser and document portals. Network access enables update
|
||||
checks and makes the optional loopback `--control` server reachable from the host. Host fonts
|
||||
are read from `/run/host/fonts` and `/run/host/user-fonts`. Locally (on Linux, with `flatpak` and
|
||||
|
||||
@@ -27,9 +27,14 @@ finish-args:
|
||||
- --share=network
|
||||
# GPU access for the wgpu compositor (Vulkan, or GL as a fallback).
|
||||
- --device=dri
|
||||
# Printing: the host's CUPS socket, so File › Print reaches the print server directly (IPP,
|
||||
# in process). Without it the app shows the system print dialog through the print portal.
|
||||
# Printing: the host's CUPS socket, for the bundled lp/lpstat (the cups-client module) and the
|
||||
# in-process IPP client. Without it the app shows the system print dialog through the print portal.
|
||||
- --socket=cups
|
||||
# Driverless network printers are found through Avahi (`lpstat -e`).
|
||||
- --system-talk-name=org.freedesktop.Avahi
|
||||
# The user's CUPS settings, read-only: their default printer and saved options (lpoptions)
|
||||
# and a print server named in their client.conf, which lp reaches over the network.
|
||||
- --filesystem=~/.cups:ro
|
||||
# Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and
|
||||
# drag-and-drop through the document portal, so picked files work anywhere without broad
|
||||
# filesystem access. Pictures and Documents are granted so files passed by path (the command
|
||||
@@ -38,6 +43,50 @@ finish-args:
|
||||
- --filesystem=xdg-documents
|
||||
# Deliberately not granted: home/host filesystem access.
|
||||
modules:
|
||||
# CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions.
|
||||
# Linked statically against this release's libcups, so a runtime update can't break them;
|
||||
# the socket path matches the host's, which --socket=cups mounts.
|
||||
- name: cups-client
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups
|
||||
--with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups
|
||||
--with-dnssd=avahi --with-tls=gnutls --disable-pam --disable-dbus
|
||||
- make -C cups libs
|
||||
- make -C systemv lp lpoptions lpstat
|
||||
- install -Dm755 -t /app/libexec/cups systemv/lp systemv/lpoptions systemv/lpstat
|
||||
- for tool in lp lpoptions lpstat; do install -Dm755 cups-socket.sh "/app/bin/$tool"; done
|
||||
- install -Dm644 -t /app/share/licenses/cups LICENSE NOTICE
|
||||
sources:
|
||||
- type: archive
|
||||
url: https://github.com/OpenPrinting/cups/releases/download/v2.4.20/cups-2.4.20-source.tar.gz
|
||||
sha256: ab4d9cd7f3e58060091d2b24972223d6401675f11c49b65abf4f6ef31dea22ff
|
||||
# Runs each tool. Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock, choosing
|
||||
# it as flatpak-run-cups.c does from the host's CUPS_SERVER, else the first line spelled
|
||||
# exactly `ServerName /path` in ~/.cups/client.conf, else the default; it mounts nothing
|
||||
# when that socket is missing. CUPS reads the keyword in any case and takes the last
|
||||
# ServerName. When CUPS' socket from client.conf is the one Flatpak chose, it is pointed at
|
||||
# the mount; when it isn't, the tool fails rather than reach a different server. A
|
||||
# CUPS_SERVER is left alone: one set by `flatpak run --env` may not be what was mounted.
|
||||
- type: script
|
||||
dest-filename: cups-socket.sh
|
||||
commands:
|
||||
- 'conf="$HOME/.cups/client.conf"'
|
||||
- 'if [ -z "${CUPS_SERVER:-}" ] && [ -r "$conf" ]; then'
|
||||
- ' server=$(sed -n ''s/^[[:space:]]*[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]\{1,\}\([^[:space:]]*\).*/\1/p'' "$conf" | tail -n 1)'
|
||||
- ' case "$server" in'
|
||||
- ' /*)'
|
||||
- ' mounted=$(sed -n ''s/^[[:space:]]*ServerName \(.*\)$/\1/p'' "$conf" | sed ''s/^[[:space:]]*//'' | grep -m 1 ''^/[^:]*$'')'
|
||||
- ' if [ "$mounted" = "$server" ]; then'
|
||||
- ' export CUPS_SERVER=/var/run/cups/cups.sock'
|
||||
- ' else'
|
||||
- ' echo "${0##*/}: ~/.cups/client.conf names the CUPS socket $server, but Flatpak gave this app ${mounted:-a different one}; give each socket one ServerName line, spelled ServerName" >&2'
|
||||
- ' exit 1'
|
||||
- ' fi'
|
||||
- ' ;;'
|
||||
- ' esac'
|
||||
- 'fi'
|
||||
- 'exec "/app/libexec/cups/${0##*/}" "$@"'
|
||||
- name: photocraft
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
|
||||
@@ -28,9 +28,14 @@ finish-args:
|
||||
- --share=network
|
||||
# GPU access for the wgpu compositor (Vulkan, or GL as a fallback).
|
||||
- --device=dri
|
||||
# Printing: the host's CUPS socket, so File › Print reaches the print server directly (IPP,
|
||||
# in process). Without it the app shows the system print dialog through the print portal.
|
||||
# Printing: the host's CUPS socket, for the bundled lp/lpstat (the cups-client module) and the
|
||||
# in-process IPP client. Without it the app shows the system print dialog through the print portal.
|
||||
- --socket=cups
|
||||
# Driverless network printers are found through Avahi (`lpstat -e`).
|
||||
- --system-talk-name=org.freedesktop.Avahi
|
||||
# The user's CUPS settings, read-only: their default printer and saved options (lpoptions)
|
||||
# and a print server named in their client.conf, which lp reaches over the network.
|
||||
- --filesystem=~/.cups:ro
|
||||
# Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and
|
||||
# drag-and-drop through the document portal, so picked files work anywhere without broad
|
||||
# filesystem access. Pictures and Documents are granted so files passed by path (the command
|
||||
@@ -41,6 +46,50 @@ finish-args:
|
||||
build-options:
|
||||
append-path: /usr/lib/sdk/rust-stable/bin
|
||||
modules:
|
||||
# CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions.
|
||||
# Linked statically against this release's libcups, so a runtime update can't break them;
|
||||
# the socket path matches the host's, which --socket=cups mounts.
|
||||
- name: cups-client
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups
|
||||
--with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups
|
||||
--with-dnssd=avahi --with-tls=gnutls --disable-pam --disable-dbus
|
||||
- make -C cups libs
|
||||
- make -C systemv lp lpoptions lpstat
|
||||
- install -Dm755 -t /app/libexec/cups systemv/lp systemv/lpoptions systemv/lpstat
|
||||
- for tool in lp lpoptions lpstat; do install -Dm755 cups-socket.sh "/app/bin/$tool"; done
|
||||
- install -Dm644 -t /app/share/licenses/cups LICENSE NOTICE
|
||||
sources:
|
||||
- type: archive
|
||||
url: https://github.com/OpenPrinting/cups/releases/download/v2.4.20/cups-2.4.20-source.tar.gz
|
||||
sha256: ab4d9cd7f3e58060091d2b24972223d6401675f11c49b65abf4f6ef31dea22ff
|
||||
# Runs each tool. Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock, choosing
|
||||
# it as flatpak-run-cups.c does from the host's CUPS_SERVER, else the first line spelled
|
||||
# exactly `ServerName /path` in ~/.cups/client.conf, else the default; it mounts nothing
|
||||
# when that socket is missing. CUPS reads the keyword in any case and takes the last
|
||||
# ServerName. When CUPS' socket from client.conf is the one Flatpak chose, it is pointed at
|
||||
# the mount; when it isn't, the tool fails rather than reach a different server. A
|
||||
# CUPS_SERVER is left alone: one set by `flatpak run --env` may not be what was mounted.
|
||||
- type: script
|
||||
dest-filename: cups-socket.sh
|
||||
commands:
|
||||
- 'conf="$HOME/.cups/client.conf"'
|
||||
- 'if [ -z "${CUPS_SERVER:-}" ] && [ -r "$conf" ]; then'
|
||||
- ' server=$(sed -n ''s/^[[:space:]]*[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]\{1,\}\([^[:space:]]*\).*/\1/p'' "$conf" | tail -n 1)'
|
||||
- ' case "$server" in'
|
||||
- ' /*)'
|
||||
- ' mounted=$(sed -n ''s/^[[:space:]]*ServerName \(.*\)$/\1/p'' "$conf" | sed ''s/^[[:space:]]*//'' | grep -m 1 ''^/[^:]*$'')'
|
||||
- ' if [ "$mounted" = "$server" ]; then'
|
||||
- ' export CUPS_SERVER=/var/run/cups/cups.sock'
|
||||
- ' else'
|
||||
- ' echo "${0##*/}: ~/.cups/client.conf names the CUPS socket $server, but Flatpak gave this app ${mounted:-a different one}; give each socket one ServerName line, spelled ServerName" >&2'
|
||||
- ' exit 1'
|
||||
- ' fi'
|
||||
- ' ;;'
|
||||
- ' esac'
|
||||
- 'fi'
|
||||
- 'exec "/app/libexec/cups/${0##*/}" "$@"'
|
||||
- name: photocraft
|
||||
buildsystem: simple
|
||||
build-options:
|
||||
|
||||
@@ -31,6 +31,10 @@
|
||||
<string>hu</string>
|
||||
<string>tr</string>
|
||||
<string>vi</string>
|
||||
<string>sv</string>
|
||||
<string>fi</string>
|
||||
<string>da</string>
|
||||
<string>nb</string>
|
||||
</array>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>PhotoCraft</string>
|
||||
|
||||
Reference in New Issue
Block a user