Merge PR #2108: fix(print): Properties… reads CUPS driver options off the GUI thread, with a deadline for lpoptions (#1818)

ATTRIBUTION.toml: kept main's sha256 lines; hashes recomputed after the track.
crates/ui-egui/src/print_ui.rs: kept both offered() (#3764) and WakeOnExit.
crates/ui-egui/tests/print.rs: kept both sides' tests (#3596 Label tests, options-reading tests).
This commit is contained in:
aiongg
2026-10-22 01:45:23 +00:00
30 changed files with 399 additions and 18 deletions
+1
View File
@@ -266,6 +266,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-14 (M3, control-click candidate):** Coordinate clicks retain hover warmup and pair press/release in one input pass so slow frames do not turn a click into a hold. Text multi-click selection accepts the actual press event when egui has already cleared its origin. Regression cases cover slow-frame button activation, disabled controls and coalesced text selection; estimates unchanged.
- **2026-10-14 (community, M7, #128 Hebrew in Add text):** Add text and `page_add_text` draw Hebrew with the craft-fonts Hebr face (Noto Sans Hebrew), through the same shaped path as Arabic and Thai: right-to-left display order with numbers and Latin in place, points and cantillation kept on their letter as one Type 3 glyph, ToUnicode and per-line ActualText so extraction, search and the editor get typing order. Builds without the face refuse Hebrew rather than write `?`. Regular style only. Estimates unchanged.
- **2026-10-14 (M9, #1787):** Changes after signing are judged by the role an object held when signed for a page's resources too: a rewritten font, XObject, graphics state or resources dictionary is page content however its new version is labelled, so a `/FT` or `/Subtype /Widget` added by the attacker no longer reads as a permitted form fill. A category dictionary that only gained a name is judged as before.
- **2026-10-14 (M10, #1818):** Properties… reads a CUPS printer's driver options off the GUI thread, the dialog saying so meanwhile, and `lpoptions` is given ten seconds before it is killed and the PPD's defaults stand; a read that dies leaves the panel with nothing rather than a frozen window. The bounded wait is tested with a stand-in command that never answers.
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged.
- **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged.
+4
View File
@@ -27,6 +27,10 @@ spool::submit(&pdf, &spool::Job { printer: None, copies: 2, ..Default::default()
- **Poster**: tile scale, overlap shared by neighbouring tiles, cut marks.
- **Comments & forms**: document, + markups, + stamps, or form fields only; annotations print
only with their Print flag, drawn from their appearance streams (Algorithm 8.1).
- **Properties… on CUPS** reads the queue's PPD and its current defaults (`lpoptions -l`) off
the GUI thread, and gives `lpoptions` ten seconds: a CUPS that has stopped answering leaves
the PPD's own defaults in force instead of holding the dialog (#1818; `spool::output_within`
kills and reaps the command at the deadline).
Each source page becomes a Form XObject (its content wrapped in q/Q, plus the printable
annotations); sheets place them with a clip. The result is a fresh, unencrypted,
+80 -4
View File
@@ -12,7 +12,7 @@ pub use craft_print::{
pub fn printer_options(printer: &str) -> Vec<PrinterOption> {
#[cfg(all(unix, not(target_arch = "wasm32")))]
{
printer_options_via(printer, std::path::Path::new("/etc/cups/ppd"), std::process::Command::new("lpoptions"))
printer_options_via_within(printer, std::path::Path::new("/etc/cups/ppd"), std::process::Command::new("lpoptions"), LPOPTIONS_LIMIT)
}
#[cfg(not(all(unix, not(target_arch = "wasm32"))))]
{
@@ -20,11 +20,86 @@ pub fn printer_options(printer: &str) -> Vec<PrinterOption> {
}
}
/// How long `lpoptions` may take: a CUPS that has stopped answering must not hold whoever asked
/// (#1818). After this the PPD's own defaults stand.
#[cfg(all(unix, not(target_arch = "wasm32")))]
const LPOPTIONS_LIMIT: std::time::Duration = std::time::Duration::from_secs(10);
/// The most of a command's output that is kept (`lpoptions -l` is a few kilobytes).
#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))]
const OUTPUT_CAP: u64 = 4 << 20;
/// Run `command` and collect its standard output, giving it `limit`: a command still running
/// then is killed and reaped, and the error is `TimedOut`. Standard input and error are not
/// connected. The exit status is not checked, as `Command::output` leaves it to the caller.
/// Only CUPS runs a command today; the tests cover every desktop platform.
#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))]
pub(crate) fn output_within(command: &mut std::process::Command, limit: std::time::Duration) -> std::io::Result<Vec<u8>> {
use std::io::Read as _;
use std::process::Stdio;
let mut child = command.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn()?;
let stop = |child: &mut std::process::Child| {
let _ = child.kill();
let _ = child.wait();
};
let Some(mut stdout) = child.stdout.take() else {
stop(&mut child);
return Err(std::io::Error::other("the command's output could not be read"));
};
// Read on a thread of its own, so a full pipe never holds the command while the deadline is
// kept here. Past the cap the rest is drained and dropped, so the command still finishes.
// The output comes back over a channel rather than through `join`: a process the command
// started can keep the pipe open after the command itself has exited or been killed, and
// the wait for the output must end at the deadline too. Such a reader is left to finish on
// its own when that process closes the pipe.
let (tx, rx) = std::sync::mpsc::channel();
let reader = std::thread::Builder::new().name("command output".into()).spawn(move || {
let mut out = Vec::new();
let read = (&mut stdout).take(OUTPUT_CAP).read_to_end(&mut out);
let _ = std::io::copy(&mut stdout, &mut std::io::sink());
let _ = tx.send(read.map(|_| out));
});
if let Err(e) = reader {
stop(&mut child);
return Err(e);
}
let timed_out = || std::io::Error::new(std::io::ErrorKind::TimedOut, format!("the command did not answer within {limit:?}"));
let deadline = std::time::Instant::now() + limit;
loop {
match child.try_wait() {
Ok(Some(_)) => {
// A moment's grace, so output written just before an exit at the deadline counts.
let left = deadline.saturating_duration_since(std::time::Instant::now()).max(std::time::Duration::from_millis(100));
return match rx.recv_timeout(left) {
Ok(read) => read,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(timed_out()),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other("the command's output was lost")),
};
}
Ok(None) if std::time::Instant::now() >= deadline => {
stop(&mut child);
return Err(timed_out());
}
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(10)),
Err(e) => {
stop(&mut child);
return Err(e);
}
}
}
}
/// Read a destination's options with the PPD directory and command supplied, so tests need
/// neither a system queue nor a physical printer. CUPS instances share their queue's PPD but
/// have their own defaults, queried with the complete `queue/instance` destination.
/// have their own defaults, queried with the complete `queue/instance` destination. `limit` is
/// how long the command may take before the PPD's defaults stand on their own.
#[cfg(all(unix, not(target_arch = "wasm32")))]
pub(crate) fn printer_options_via(printer: &str, ppd_dir: &std::path::Path, mut lpoptions: std::process::Command) -> Vec<PrinterOption> {
pub(crate) fn printer_options_via_within(
printer: &str,
ppd_dir: &std::path::Path,
mut lpoptions: std::process::Command,
limit: std::time::Duration,
) -> Vec<PrinterOption> {
let (queue, instance) = printer.split_once('/').map_or((printer, None), |(q, i)| (q, Some(i)));
// Retain the conservative name checks, applied to each component. Neither component is
// a path: reject dot components, backslashes and any additional separator before I/O.
@@ -44,7 +119,8 @@ pub(crate) fn printer_options_via(printer: &str, ppd_dir: &std::path::Path, mut
f.take(PPD_CAP).read_to_end(&mut bytes).ok().map(|_| bytes)
});
let mut options = ppd.map(|b| parse_ppd(&ppd_text(&b))).unwrap_or_default();
let current = lpoptions.args(["-p", printer, "-l"]).output().map(|o| parse_lpoptions(&String::from_utf8_lossy(&o.stdout))).unwrap_or_default();
let current =
output_within(lpoptions.args(["-p", printer, "-l"]), limit).map(|out| parse_lpoptions(&String::from_utf8_lossy(&out))).unwrap_or_default();
for (key, value) in current {
if let Some(o) = options.iter_mut().find(|o| o.key == key)
&& o.choices.iter().any(|(c, _)| *c == value)
+89 -3
View File
@@ -499,6 +499,8 @@ mod cups_options {
const RECORD: &str = "PDFCRAFT_LPOPTIONS_TEST_RECORD";
const ANSWER: &str = "PDFCRAFT_LPOPTIONS_TEST_ANSWER";
/// Generous: these tests are about what is read, not how long it takes.
const LIMIT: std::time::Duration = std::time::Duration::from_secs(60);
/// The child process stands in for lpoptions, recording its real arguments.
#[test]
@@ -506,8 +508,31 @@ mod cups_options {
let Some(record) = std::env::var_os(RECORD) else { return };
let args: Vec<String> = std::env::args().skip_while(|a| a != "--").skip(1).collect();
std::fs::write(std::path::Path::new(&record).join("args"), args.join("\n")).unwrap();
let answer = std::env::var(ANSWER).unwrap();
// A queue that never answers (CUPS stopped responding).
if answer == "HANG" {
std::thread::sleep(std::time::Duration::from_secs(60));
}
// Serial libtest prints its test label without a newline before this helper's stdout.
println!("\n{}", std::env::var(ANSWER).unwrap());
println!("\n{answer}");
}
/// `lpoptions` that never answers is stopped at the deadline: the PPD's defaults stand and
/// whoever asked is not held (#1818).
#[test]
fn a_queue_that_never_answers_is_given_up_at_the_deadline() {
let dir = folder();
std::fs::write(
dir.join("Office.ppd"),
"*OpenUI *InputSlot/Paper tray: PickOne\n*DefaultInputSlot: Auto\n*InputSlot Auto/Automatic: \"\"\n*InputSlot Tray2/Tray 2: \"\"\n*CloseUI: *InputSlot\n",
)
.unwrap();
let started = std::time::Instant::now();
let options = spool::printer_options_via_within("Office", &dir, command(&dir, "HANG"), std::time::Duration::from_millis(500));
assert!(started.elapsed() < std::time::Duration::from_secs(20), "given up at the deadline, not when the command does");
assert_eq!(options.len(), 1, "the PPD is still read");
assert_eq!(options[0].default, "Auto", "the PPD default stands when the queue does not answer");
std::fs::remove_dir_all(dir).unwrap();
}
fn command(record: &std::path::Path, answer: &str) -> std::process::Command {
@@ -557,7 +582,7 @@ mod cups_options {
(boundary_queue.as_str(), "Tray2", "Tray2"),
(boundary_instance.as_str(), "Tray2", "Tray2"),
] {
let options = spool::printer_options_via(destination, &dir, command(&dir, &format!("InputSlot/Paper tray: *{current}")));
let options = spool::printer_options_via_within(destination, &dir, command(&dir, &format!("InputSlot/Paper tray: *{current}")), LIMIT);
assert_eq!(options.len(), 1, "{destination}: the queue's PPD must be read");
assert_eq!(options[0].default, expected, "{destination}: only valid instance choices override the PPD default");
assert_eq!(options[0].choices, [("Auto".into(), "Automatic".into()), ("Tray2".into(), "Tray 2".into())]);
@@ -598,7 +623,7 @@ mod cups_options {
long_queue_instance.as_str(),
long_instance.as_str(),
] {
assert!(spool::printer_options_via(destination, &dir, command(&dir, "InputSlot: *Auto")).is_empty(), "{destination:?}");
assert!(spool::printer_options_via_within(destination, &dir, command(&dir, "InputSlot: *Auto"), LIMIT).is_empty(), "{destination:?}");
assert!(!dir.join("args").exists(), "{destination:?}: rejected before spawning");
}
std::fs::remove_dir_all(dir).unwrap();
@@ -755,3 +780,64 @@ fn imposed_pages_preserve_large_user_unit_matrices() {
}
}
}
/// `output_within` (#1818): the bounded wait every platform's `lpoptions` call goes through.
#[cfg(not(target_arch = "wasm32"))]
mod bounded_commands {
const MODE: &str = "PDFCRAFT_COMMAND_TEST_MODE";
/// The child process stands in for a command: it answers, or it never does.
#[test]
fn stand_in_for_a_command() {
match std::env::var(MODE).as_deref() {
Ok("answer") => println!("hello from the command"),
Ok("hang") => std::thread::sleep(std::time::Duration::from_secs(60)),
// Holds the output pipe it inherited for a while, as a daemon a command starts can.
Ok("linger") => std::thread::sleep(LINGER),
// Starts a lingering process on its own output, then exits or hangs itself.
Ok(mode @ ("orphan-exit" | "orphan-hang")) => {
let _ = command("linger").spawn();
println!("started");
if mode == "orphan-hang" {
std::thread::sleep(std::time::Duration::from_secs(60));
}
}
_ => {}
}
}
const LINGER: std::time::Duration = std::time::Duration::from_secs(8);
fn command(mode: &str) -> std::process::Command {
let mut c = std::process::Command::new(std::env::current_exe().unwrap());
c.args(["--exact", "tests::bounded_commands::stand_in_for_a_command", "--nocapture", "--"]).env(MODE, mode);
c
}
#[test]
fn a_command_that_answers_gives_its_output() {
let out = crate::spool::output_within(&mut command("answer"), std::time::Duration::from_secs(60)).unwrap();
assert!(String::from_utf8_lossy(&out).contains("hello from the command"));
}
#[test]
fn a_command_that_never_answers_is_stopped_at_the_deadline() {
let started = std::time::Instant::now();
let err = crate::spool::output_within(&mut command("hang"), std::time::Duration::from_millis(500)).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
assert!(started.elapsed() < std::time::Duration::from_secs(20), "stopped soon after the deadline, not when the command gives up");
}
/// A process the command started keeps the output pipe open after the command has exited,
/// or after it was killed at the deadline: the wait still ends at the deadline (#2108
/// review), not when that process lets go of the pipe.
#[test]
fn a_process_left_holding_the_output_does_not_extend_the_wait() {
for mode in ["orphan-exit", "orphan-hang"] {
let started = std::time::Instant::now();
let err = crate::spool::output_within(&mut command(mode), std::time::Duration::from_millis(1500)).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::TimedOut, "{mode}");
assert!(started.elapsed() < LINGER / 2, "{mode}: returned after {:?}, when the lingering process let go", started.elapsed());
}
}
}
+1
View File
@@ -1651,6 +1651,7 @@
Looking for printers… جارٍ البحث عن الطابعات…
Sending sheet {s} of {n} to {printer}… جارٍ إرسال الورقة {s} من {n} إلى {printer}…
No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. لم يُعثر على أي طابعة. تحقّق من أن الطابعة مثبّتة وأن خدمة التخزين المؤقت للطباعة تعمل، ثم افتح «طباعة» مرة أخرى.
Reading the printer's settings… جارٍ قراءة إعدادات الطابعة…
Sending to {printer}… جارٍ الإرسال إلى {printer}…
Choose a folder for the searchable files اختر مجلدًا للملفات القابلة للبحث
Files are combined from top to bottom. Leave Pages empty to take every page. تُدمج الملفات من الأعلى إلى الأسفل. اترك «الصفحات» فارغًا لأخذ كل الصفحات.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1612,6 +1612,7 @@
Looking for printers… Търсене на принтери…
Sending sheet {s} of {n} to {printer}… Изпращане на лист {s} от {n} до {printer}…
No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. Не са намерени принтери. Проверете дали принтерът е инсталиран и дали услугата за опашка за печат работи, след което отворете „Печат“ отново.
Reading the printer's settings… Четене на настройките на принтера…
Sending to {printer}… Изпращане до {printer}…
Choose a folder for the searchable files Изберете папка за файловете с възможност за търсене
Files are combined from top to bottom. Leave Pages empty to take every page. Файловете се обединяват отгоре надолу. Оставете „Страници“ празно, за да се вземат всички страници.
Can't render this file because it contains an unexpected character in line 8 and column 16.
+1
View File
@@ -2303,6 +2303,7 @@ certificate Subject Subjekt
Looking for printers… Hledání tiskáren…
Sending sheet {s} of {n} to {printer}… Odesílání listu {s} z {n} na {printer}…
No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. Nebyly nalezeny žádné tiskárny. Zkontrolujte, že je tiskárna nainstalovaná a zařazování tisku běží, a pak znovu otevřete Tisk.
Reading the printer's settings… Načítání nastavení tiskárny…
Sending to {printer}… Odesílání na {printer}…
Fill colour Barva výplně
Fill colour: none Barva výplně: žádná
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1645,6 +1645,7 @@
Looking for printers… Drucker werden gesucht…
Sending sheet {s} of {n} to {printer}… Blatt {s} von {n} wird an {printer} gesendet…
No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. Es wurden keine Drucker gefunden. Prüfen Sie, ob der Drucker installiert ist und die Druckwarteschlange läuft, und öffnen Sie dann „Drucken“ erneut.
Reading the printer's settings… Druckereinstellungen werden gelesen …
Sending to {printer}… Wird an {printer} gesendet…
Choose a folder for the searchable files Wähle einen Ordner für die durchsuchbaren Dateien
Files are combined in this order. Leave Pages empty to take every page. Die Dateien werden in dieser Reihenfolge kombiniert. Lass „Seiten“ leer, um alle Seiten zu übernehmen.
Can't render this file because it contains an unexpected character in line 6 and column 51.
+1
View File
@@ -2287,6 +2287,7 @@ comment menu Edit Επεξεργασία
Looking for printers… Αναζήτηση εκτυπωτών…
Sending sheet {s} of {n} to {printer}… Αποστολή φύλλου {s} από {n} στον {printer}…
No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. Δεν βρέθηκαν εκτυπωτές. Ελέγξτε ότι ο εκτυπωτής είναι εγκατεστημένος και ότι η ουρά εκτύπωσης εκτελείται και, στη συνέχεια, ανοίξτε ξανά την Εκτύπωση.
Reading the printer's settings… Ανάγνωση των ρυθμίσεων του εκτυπωτή…
Sending to {printer}… Αποστολή στον {printer}…
Enter permissions password… Εισαγωγή κωδικού δικαιωμάτων…
Permissions password Κωδικός δικαιωμάτων
Can't render this file because it contains an unexpected character in line 477 and column 9.
+1
View File
@@ -1655,6 +1655,7 @@
Looking for printers… Buscando impresoras…
Sending sheet {s} of {n} to {printer}… Enviando la hoja {s} de {n} a {printer}…
No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. No se encontró ninguna impresora. Compruebe que la impresora está instalada y que la cola de impresión está en marcha, y vuelva a abrir Imprimir.
Reading the printer's settings… Leyendo la configuración de la impresora…
Sending to {printer}… Enviando a {printer}…
Choose a folder for the searchable files Elige una carpeta para los archivos con búsqueda
Files are combined in this order. Leave Pages empty to take every page. Los archivos se combinan en este orden. Deja Páginas vacío para incluir todas las páginas.
Can't render this file because it contains an unexpected character in line 6 and column 60.
+1
View File
@@ -1640,6 +1640,7 @@
Looking for printers… Recherche des imprimantes…
Sending sheet {s} of {n} to {printer}… Envoi de la feuille {s} sur {n} à {printer}…
No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. Aucune imprimante trouvée. Vérifiez que l’imprimante est installée et que le spouleur d’impression fonctionne, puis rouvrez Imprimer.
Reading the printer's settings… Lecture des réglages de l’imprimante…
Sending to {printer}… Envoi à {printer}…
Choose a folder for the searchable files Choisissez un dossier pour les fichiers recherchables
Files are combined in this order. Leave Pages empty to take every page. Les fichiers sont combinés dans cet ordre. Laissez Pages vide pour prendre toutes les pages.
Can't render this file because it contains an unexpected character in line 6 and column 39.
+1
View File
@@ -1630,6 +1630,7 @@
Looking for printers… Nyomtatók keresése…
Sending sheet {s} of {n} to {printer}… {s}. lap / {n} küldése ide: {printer}…
No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. Nem található nyomtató. Ellenőrizze, hogy a nyomtató telepítve van-e, és fut-e a nyomtatási sorkezelő, majd nyissa meg újra a Nyomtatást.
Reading the printer's settings… A nyomtató beállításainak beolvasása…
Sending to {printer}… Küldés ide: {printer}…
Choose a folder for the searchable files Válassz mappát a kereshető fájloknak
Files are combined in this order. Leave Pages empty to take every page. A fájlok ebben a sorrendben egyesülnek. Hagyd üresen az Oldalak mezőt, ha minden oldalt kéred.
Can't render this file because it contains an unexpected character in line 7 and column 44.
+1
View File
@@ -1656,6 +1656,7 @@
Looking for printers… Ricerca delle stampanti…
Sending sheet {s} of {n} to {printer}… Invio del foglio {s} di {n} a {printer}…
No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. Nessuna stampante trovata. Verifica che la stampante sia installata e che lo spooler di stampa sia in esecuzione, quindi riapri Stampa.
Reading the printer's settings… Lettura delle impostazioni della stampante…
Sending to {printer}… Invio a {printer}…
Choose a folder for the searchable files Scegli una cartella per i file ricercabili
Files are combined from top to bottom. Leave Pages empty to take every page. I file vengono combinati dall'alto verso il basso. Lascia vuoto Pagine per prendere tutte le pagine.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1661,6 +1661,7 @@
Looking for printers… プリンターを検索しています…
Sending sheet {s} of {n} to {printer}… {printer} に用紙 {s} / {n} を送信しています…
No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. プリンターが見つかりません。プリンターがインストールされ、印刷スプーラーが動作していることを確認してから、もう一度[印刷]を開いてください。
Reading the printer's settings… プリンターの設定を読み込んでいます…
Sending to {printer}… {printer} に送信しています…
Choose a folder for the searchable files 検索可能なファイルの保存先フォルダーを選択
Files are combined from top to bottom. Leave Pages empty to take every page. ファイルは上から順に結合されます。すべてのページを使用する場合は「ページ」を空欄にしてください。
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2277,6 +2277,7 @@ key Space Бос орын
Looking for printers… Принтерлер ізделуде…
Sending sheet {s} of {n} to {printer}… {n} парақтың {s}-парағы {printer} құрылғысына жіберілуде…
No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. Принтерлер табылмады. Принтердің орнатылғанын және басып шығару кезегі қызметінің жұмыс істеп тұрғанын тексеріп, «Басып шығару» терезесін қайта ашыңыз.
Reading the printer's settings… Принтер параметрлері оқылуда…
Sending to {printer}… {printer} құрылғысына жіберілуде…
Enter permissions password… Рұқсаттар құпиясөзін енгізу…
Permissions password Рұқсаттар құпиясөзі
Can't render this file because it contains an unexpected character in line 856 and column 9.
+1
View File
@@ -741,6 +741,7 @@
Looking for PDFs… Ieškoma PDF failų…
Looking for printers… Ieškoma spausdintuvų…
No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. Spausdintuvų nerasta. Patikrinkite, ar spausdintuvas įdiegtas ir ar veikia spausdinimo eilė, tada vėl atverkite Spausdinti.
Reading the printer's settings… Skaitomi spausdintuvo nustatymai…
Low Resolution (150 dpi) Maža raiška (150 dpi)
Low resolution Maža raiška
Magnification Didinimas
Can't render this file because it contains an unexpected character in line 404 and column 9.
+1
View File
@@ -1644,6 +1644,7 @@
Looking for printers… Wyszukiwanie drukarek…
Sending sheet {s} of {n} to {printer}… Wysyłanie arkusza {s} z {n} do {printer}…
No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. Nie znaleziono drukarek. Sprawdź, czy drukarka jest zainstalowana, a bufor wydruku działa, i ponownie otwórz Drukuj.
Reading the printer's settings… Odczytywanie ustawień drukarki…
Sending to {printer}… Wysyłanie do {printer}…
Choose a folder for the searchable files Wybierz folder dla plików z możliwością wyszukiwania
Files are combined from top to bottom. Leave Pages empty to take every page. Pliki są łączone od góry do dołu. Pozostaw pole Strony puste, aby uwzględnić wszystkie strony.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1546,6 +1546,7 @@
Looking for printers… Procurando impressoras…
Sending sheet {s} of {n} to {printer}… Enviando a folha {s} de {n} para {printer}…
No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. Nenhuma impressora encontrada. Verifique se a impressora está instalada e se o spooler de impressão está em execução e abra Imprimir novamente.
Reading the printer's settings… Lendo as configurações da impressora…
Sending to {printer}… Enviando para {printer}…
Choose a folder for the searchable files Escolha uma pasta para os arquivos pesquisáveis
Files are combined in this order. Leave Pages empty to take every page. Os arquivos são combinados nesta ordem. Deixe Páginas em branco para incluir todas as páginas.
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -1652,6 +1652,7 @@
Looking for printers… Поиск принтеров…
Sending sheet {s} of {n} to {printer}… Отправка листа {s} из {n} на {printer}…
No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. Принтеры не найдены. Проверьте, что принтер установлен, а диспетчер печати запущен, и снова откройте «Печать».
Reading the printer's settings… Чтение настроек принтера…
Sending to {printer}… Отправка на {printer}…
Choose a folder for the searchable files Выберите папку для доступных для поиска файлов
Files are combined from top to bottom. Leave Pages empty to take every page. Файлы объединяются сверху вниз. Оставьте «Страницы» пустыми, чтобы взять все страницы.
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -2266,6 +2266,7 @@ key Space Preslednica
Looking for printers… Iskanje tiskalnikov…
Sending sheet {s} of {n} to {printer}… Pošiljanje lista {s} od {n} na {printer}…
No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. Ni najdenih tiskalnikov. Preverite, ali je tiskalnik nameščen in ali tiskalna vrsta deluje, nato znova odprite Natisni.
Reading the printer's settings… Branje nastavitev tiskalnika…
Sending to {printer}… Pošiljanje na {printer}…
Float panel Odtrgaj razdelek
Inspector Inšpektor
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -1631,6 +1631,7 @@ action wizard Start ప్రారంభించు
Looking for printers… ప్రింటర్ల కోసం వెతుకుతోంది…
Sending sheet {s} of {n} to {printer}… {printer}కు {n}లో {s}వ షీట్ పంపుతోంది…
No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. ప్రింటర్లు ఏవీ కనుగొనబడలేదు. ప్రింటర్ ఇన్‌స్టాల్ అయిందో, ప్రింట్ స్పూలర్ నడుస్తోందో తనిఖీ చేసి, ముద్రణను మళ్లీ తెరవండి.
Reading the printer's settings… ప్రింటర్ సెట్టింగ్‌లను చదువుతోంది…
Sending to {printer}… {printer}కు పంపుతోంది…
Choose a folder for the searchable files వెతకగలిగే ఫైల్‌ల కోసం ఒక ఫోల్డర్‌ను ఎంచుకోండి
Files are combined in this order. Leave Pages empty to take every page. ఫైల్‌లు ఈ క్రమంలో కలపబడతాయి. అన్ని పేజీలను తీసుకోవడానికి పేజీలు ఫీల్డ్‌ను ఖాళీగా వదలండి.
Can't render this file because it contains an unexpected character in line 13 and column 16.
+1
View File
@@ -2221,6 +2221,7 @@ key Space Boşluk
Looking for printers… Yazıcılar aranıyor…
Sending sheet {s} of {n} to {printer}… {n} yapraktan {s}. yaprak {printer} yazıcısına gönderiliyor…
No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. Yazıcı bulunamadı. Yazıcının yüklü olduğunu ve yazdırma biriktiricisinin çalıştığını denetleyip Yazdır'ı yeniden açın.
Reading the printer's settings… Yazıcı ayarları okunuyor…
Sending to {printer}… {printer} yazıcısına gönderiliyor…
Column select Sütun seçimi
@plural {n} object selected|{n} objects selected {n} nesne seçildi
Can't render this file because it contains an unexpected character in line 855 and column 9.
+1
View File
@@ -1649,6 +1649,7 @@
Looking for printers… Пошук принтерів…
Sending sheet {s} of {n} to {printer}… Надсилання аркуша {s} із {n} на {printer}…
No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. Принтери не знайдено. Перевірте, що принтер установлено, а диспетчер друку працює, і знову відкрийте «Друк».
Reading the printer's settings… Читання налаштувань принтера…
Sending to {printer}… Надсилання на {printer}…
Choose a folder for the searchable files Виберіть теку для файлів із пошуком
Files are combined from top to bottom. Leave Pages empty to take every page. Файли об’єднуються згори донизу. Залиште «Сторінки» порожнім, щоб узяти всі сторінки.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -2734,3 +2734,6 @@ key Space Space
# Historical snapshot notice bar (View signed version / View revision).
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Bạn đang xem phiên bản đã ký của “{from}” (bản sửa đổi {rev}). Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc.
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Bạn đang xem bản sửa đổi {rev} của “{from}”. Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc.
# AI-assisted additions for reading printer options off the GUI thread (#1818) (2026-10-16); not reviewed by a native speaker.
Reading the printer's settings… Đang đọc cài đặt của máy in…
Can't render this file because it contains an unexpected character in line 491 and column 12.
+1
View File
@@ -1619,6 +1619,7 @@
Looking for printers… 正在查找打印机…
Sending sheet {s} of {n} to {printer}… 正在将第 {s} 张(共 {n} 张)发送到{printer}…
No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. 未找到打印机。请检查打印机是否已安装、打印后台处理程序是否在运行,然后重新打开“打印”。
Reading the printer's settings… 正在读取打印机设置…
Sending to {printer}… 正在发送到{printer}…
Choose a folder for the searchable files 选择可搜索文件的文件夹
Files are combined from top to bottom. Leave Pages empty to take every page. 文件按从上到下的顺序合并。页码留空表示取全部页面。
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -1563,6 +1563,7 @@
Looking for printers… 正在尋找印表機…
Sending sheet {s} of {n} to {printer}… 正在將第 {s} 張(共 {n} 張)傳送到{printer}…
No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. 找不到印表機。請確認印表機已安裝、列印多工緩衝處理器正在執行,然後重新開啟「列印」。
Reading the printer's settings… 正在讀取印表機設定…
Sending to {printer}… 正在傳送到{printer}…
Choose a folder for the searchable files 選擇可搜尋檔案的資料夾
Files are combined from top to bottom. Leave Pages empty to take every page. 檔案將按從上到下的順序合併。頁碼留空表示取全部頁面。
Can't render this file because it contains an unexpected character in line 9 and column 16.
+4 -1
View File
@@ -107,7 +107,7 @@ mod signature_drag;
pub mod units;
mod web_print;
mod word_count_ui;
pub use print_ui::{Handling as PrintHandling, PrintDraft, PrinterSource, Which as PrintWhich, system_printers};
pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Which as PrintWhich, system_printers};
mod redact_ui;
pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode};
pub mod i18n;
@@ -564,6 +564,8 @@ pub struct PdfCraftApp {
/// Lists the Print dialog's printers in the background (the desktop app sets it; see
/// [`PrinterSource`]).
pub printer_source: Option<PrinterSource>,
/// Where Properties… reads a printer's driver options: the spooler's unless a test stands in.
pub options_source: Option<OptionsSource>,
// A browser has no background print work: the type is empty there.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(crate) print_tasks: print_ui::PrintTasks,
@@ -938,6 +940,7 @@ impl PdfCraftApp {
update_source: None,
updates: updates::Updates::default(),
printer_source: None,
options_source: None,
print_tasks: print_ui::PrintTasks::default(),
palette_open: false,
palette_query: String::new(),
+98 -8
View File
@@ -96,6 +96,11 @@ pub struct PrintDraft {
pub crops: Vec<Option<print::Crop>>,
/// The pages `crops` were read for, so a changed page selection reads them again.
pub crop_pages: Option<Vec<usize>>,
/// The printer whose options are being read in the background (CUPS runs `lpoptions`,
/// which a stuck CUPS can hold up; #1818). The panel says so meanwhile.
pub driver_options_loading: Option<String>,
/// Properties… asked for this printer's options; the app starts the read on the next frame.
pub read_options_for: Option<String>,
}
impl Default for PrintDraft {
@@ -142,6 +147,8 @@ impl Default for PrintDraft {
label_flip: false,
crops: Vec::new(),
crop_pages: None,
driver_options_loading: None,
read_options_for: None,
}
}
}
@@ -244,6 +251,10 @@ impl PrintDraft {
/// the network. Without a source the dialog reads them as it opens, so tests stay deterministic.
pub type PrinterSource = std::sync::Arc<dyn Fn() -> Vec<spool::Printer> + Send + Sync>;
/// Where a printer's driver options come from: the spooler's (`spool::printer_options`) unless a
/// test stands in. Called off the GUI thread.
pub type OptionsSource = std::sync::Arc<dyn Fn(&str) -> Vec<spool::PrinterOption> + Send + Sync>;
/// The system spooler's printers, as the desktop app's [`PrinterSource`].
pub fn system_printers() -> PrinterSource {
std::sync::Arc::new(spool::printers)
@@ -260,6 +271,9 @@ pub(crate) struct PrintTasks {
/// The progress card is showing a print job (it is shared with other long jobs).
#[cfg(not(target_arch = "wasm32"))]
showing_progress: bool,
/// The driver options being read, per printer (`read_options_in_background`).
#[cfg(not(target_arch = "wasm32"))]
options: Vec<(String, std::sync::mpsc::Receiver<Vec<spool::PrinterOption>>)>,
/// The browser print being drawn, a sheet a frame (`web_print`).
#[cfg(target_arch = "wasm32")]
web_job: Option<crate::web_print::WebPrintJob>,
@@ -323,6 +337,22 @@ fn offered(printer: Option<&str>, printers: &[spool::Printer]) -> bool {
printer.is_none_or(|name| printers.iter().any(|p| p.name == name))
}
/// Asks for a frame when a worker thread ends, also when it panics: [`PdfCraftApp::poll_print`]
/// then sees the answer, or the disconnected channel of a worker that died, without waiting
/// for some other event to draw (#2108 review). Declared before the worker's sender, so the
/// sender is dropped first and the frame finds the channel closed.
#[cfg(not(target_arch = "wasm32"))]
struct WakeOnExit(Option<egui::Context>);
#[cfg(not(target_arch = "wasm32"))]
impl Drop for WakeOnExit {
fn drop(&mut self) {
if let Some(ctx) = &self.0 {
ctx.request_repaint();
}
}
}
impl PdfCraftApp {
pub fn open_print(&mut self) {
let Some((i, _)) = self.active_ids() else { return };
@@ -417,23 +447,76 @@ impl PdfCraftApp {
return;
}
let (tx, rx) = std::sync::mpsc::channel();
let ctx = self.ctx.clone();
let wake = WakeOnExit(self.ctx.clone());
std::thread::spawn(move || {
let _wake = wake;
let tx = tx;
// The receiver may be gone (the app quit): nothing to report to then.
let _ = tx.send(source());
if let Some(ctx) = ctx {
ctx.request_repaint();
}
});
self.print_tasks.printers = Some(rx);
}
/// Pick up a printer list or a spooled job that finished (each frame).
/// Read `printer`'s driver options off the GUI thread (#1818); `poll_print` picks them up.
/// A read already under way for the same printer is left to finish.
#[cfg(not(target_arch = "wasm32"))]
fn read_options_in_background(&mut self, printer: String) {
if self.print_tasks.options.iter().any(|(p, _)| *p == printer) {
return;
}
let source: OptionsSource = self.options_source.clone().unwrap_or_else(|| std::sync::Arc::new(|p: &str| spool::printer_options(p)));
let (tx, rx) = std::sync::mpsc::channel();
let wake = WakeOnExit(self.ctx.clone());
let name = printer.clone();
let spawned = std::thread::Builder::new().name("printer options".into()).spawn(move || {
let _wake = wake;
let tx = tx;
// The receiver may be gone (the app quit): nothing to report to then.
let _ = tx.send(source(&name));
});
match spawned {
Ok(_) => self.print_tasks.options.push((printer, rx)),
// No thread to read them on: the printer offers nothing this time.
Err(_) => self.receive_options(printer, Vec::new()),
}
}
/// The options read for `printer` arrived, or the read died. A read that a later click
/// superseded (another printer's is awaited) is dropped.
#[cfg(not(target_arch = "wasm32"))]
fn receive_options(&mut self, printer: String, options: Vec<spool::PrinterOption>) {
let d = &mut self.print_draft;
if d.driver_options_loading.as_deref() == Some(printer.as_str()) {
d.driver_options_loading = None;
d.driver_options = Some((printer, options));
}
}
/// Pick up a printer list, driver options or a spooled job that finished (each frame).
pub(crate) fn poll_print(&mut self) {
#[cfg(target_arch = "wasm32")]
self.poll_web_print();
#[cfg(not(target_arch = "wasm32"))]
{
if let Some(printer) = self.print_draft.read_options_for.take() {
self.read_options_in_background(printer);
}
let mut arrived = Vec::new();
self.print_tasks.options.retain(|(printer, rx)| match rx.try_recv() {
Ok(options) => {
arrived.push((printer.clone(), options));
false
}
Err(std::sync::mpsc::TryRecvError::Empty) => true,
// A read that died offers nothing; the panel says so rather than wait.
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
arrived.push((printer.clone(), Vec::new()));
false
}
});
for (printer, options) in arrived {
self.receive_options(printer, options);
}
if let Some(rx) = &self.print_tasks.printers {
let printers = match rx.try_recv() {
Ok(p) => Some(p),
@@ -893,9 +976,12 @@ pub(crate) fn body(
if spool::HAS_PRINTER_PREFERENCES {
d.driver_error = spool::open_printer_preferences(&p).err().map(|e| e.to_string());
} else {
if d.driver_options.as_ref().is_none_or(|(read_for, _)| *read_for != p) {
d.driver_options = Some((p.clone(), spool::printer_options(&p)));
if d.driver_options.as_ref().is_none_or(|(read_for, _)| *read_for != p) && d.driver_options_loading.as_deref() != Some(p.as_str()) {
// Read off the GUI thread (#1818): `poll_print` starts it next frame.
d.driver_options = None;
d.driver_choices.clear();
d.driver_options_loading = Some(p.clone());
d.read_options_for = Some(p);
}
d.show_driver_options = !d.show_driver_options;
}
@@ -973,7 +1059,11 @@ pub(crate) fn body(
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Couldn't open the printer's preferences: {e}"), &[("e", e)])).small().color(t.text_muted));
}
if d.show_driver_options {
driver_options_panel(ui, d, t);
if d.driver_options.is_none() && d.driver_options_loading.is_some() {
ui.label(egui::RichText::new(tl!("Reading the printer's settings…")).small().color(t.text_muted));
} else {
driver_options_panel(ui, d, t);
}
}
if let Some(&source) = sizes.get(d.current_page) {
ui.horizontal_wrapped(|ui| {
+97
View File
@@ -589,3 +589,100 @@ fn a_paper_picked_from_the_menu_while_in_label_is_kept() {
assert_eq!(h.state().print_draft.paper, letter, "the paper picked in Label is kept");
h.get_by_value("US Letter");
}
/// Properties… reads the printer's driver options off the GUI thread: the dialog keeps drawing
/// while CUPS answers, and a read that dies leaves the panel with nothing rather than waiting
/// (#1818). The click's request is made directly, so the test runs where Properties… opens the
/// driver's own window instead (Windows).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn printer_options_are_read_off_the_gui_thread_and_a_dead_read_offers_nothing() {
use pdfcraft_engine::print::spool::PrinterOption;
use std::sync::{Arc, Mutex};
let mut h = harness();
// The source waits until the test lets it answer, so the dialog is seen while it reads.
let gate = Arc::new(Mutex::new(()));
let hold = gate.lock().unwrap();
let wait = gate.clone();
h.state_mut().options_source = Some(Arc::new(move |printer: &str| {
assert_ne!(printer, "Dead", "the read died");
let _open = wait.lock();
vec![PrinterOption {
key: "InputSlot".into(),
label: "Paper tray".into(),
group: String::new(),
choices: vec![("Auto".into(), "Automatic".into()), ("Tray2".into(), "Tray 2".into())],
default: "Auto".into(),
}]
}));
assert!(h.state_mut().execute("print.dialog"));
h.run_steps(2);
let ask = |h: &mut Harness<'static, PdfCraftApp>, printer: &str| {
let d = &mut h.state_mut().print_draft;
d.driver_options = None;
d.driver_options_loading = Some(printer.into());
d.read_options_for = Some(printer.into());
d.show_driver_options = true;
};
let settle = |h: &mut Harness<'static, PdfCraftApp>| {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while h.state().print_draft.driver_options_loading.is_some() && std::time::Instant::now() < deadline {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
};
ask(&mut h, "Office");
h.run_steps(3);
assert_eq!(h.state().print_draft.driver_options_loading.as_deref(), Some("Office"), "frames keep coming while the read waits");
assert!(h.state().print_draft.driver_options.is_none());
h.get_by_label("Reading the printer's settings…");
drop(hold);
settle(&mut h);
let (read_for, options) = h.state().print_draft.driver_options.clone().expect("the options arrived");
assert_eq!(read_for, "Office");
assert_eq!(options.len(), 1);
assert_eq!(options[0].key, "InputSlot");
h.get_by_label("Paper tray");
ask(&mut h, "Dead");
settle(&mut h);
assert_eq!(h.state().print_draft.driver_options, Some(("Dead".into(), Vec::new())), "a dead read offers nothing, and the panel says so");
h.get_by_label("This printer offers no settings of its own.");
}
/// A read of the printer's options that dies still asks for a frame, so an idle dialog stops
/// saying it is reading without waiting for the mouse to move (#2108 review).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_dead_options_read_wakes_an_idle_dialog() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
let mut h = harness();
let gate = Arc::new(Mutex::new(()));
let hold = gate.lock().unwrap();
let wait = gate.clone();
let dying = Arc::new(AtomicBool::new(false));
let flag = dying.clone();
h.state_mut().options_source = Some(Arc::new(move |_: &str| {
let _open = wait.lock();
flag.store(true, Ordering::SeqCst);
panic!("the read died");
}));
assert!(h.state_mut().execute("print.dialog"));
h.run_steps(2);
let d = &mut h.state_mut().print_draft;
d.driver_options = None;
d.driver_options_loading = Some("Office".into());
d.read_options_for = Some("Office".into());
d.show_driver_options = true;
h.run();
assert!(!h.ctx.has_requested_repaint(), "the dialog is idle while the read waits");
drop(hold);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while !(dying.load(Ordering::SeqCst) && h.ctx.has_requested_repaint()) && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(h.ctx.has_requested_repaint(), "the dead read asked for a frame");
h.run();
assert_eq!(h.state().print_draft.driver_options, Some(("Office".into(), Vec::new())));
}
+2 -2
View File
@@ -7948,8 +7948,8 @@ milestone = "M10"
status = "shipped"
commands = ["print.dialog"]
tools = ["printers", "printer_options", "doc_print"]
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked", "crates/ui-egui/src/web_print.rs::resolution_falls_with_the_sheet_count_and_long_jobs_are_refused", "crates/ui-egui/src/web_print.rs::large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused", "crates/ui-egui/src/web_print.rs::a_sink_error_or_a_page_that_isnt_there_stops_the_job"]
notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows shared craft-print lists the queues (EnumPrinters), the default (GetDefaultPrinter) and each driver's two-sided support in this process, and draws jobs as vectors with Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling; portrait and landscape sheets in one job via /Rotate), starting no PowerShell, so printing works under App Control script enforcement (#1107); the printer list and submission run on workers; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't)"
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked", "crates/ui-egui/src/web_print.rs::resolution_falls_with_the_sheet_count_and_long_jobs_are_refused", "crates/ui-egui/src/web_print.rs::large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused", "crates/ui-egui/src/web_print.rs::a_sink_error_or_a_page_that_isnt_there_stops_the_job", "crates/print/src/tests.rs::a_command_that_never_answers_is_stopped_at_the_deadline", "crates/ui-egui/tests/print.rs::printer_options_are_read_off_the_gui_thread_and_a_dead_read_offers_nothing"]
notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows shared craft-print lists the queues (EnumPrinters), the default (GetDefaultPrinter) and each driver's two-sided support in this process, and draws jobs as vectors with Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling; portrait and landscape sheets in one job via /Rotate), starting no PowerShell, so printing works under App Control script enforcement (#1107); the printer list and submission run on workers; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't). Properties… on CUPS reads the driver options off the GUI thread and gives lpoptions ten seconds before the PPD defaults stand (#1818)"
[[feature]]
id = "print.copies-collate"