diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index db3769ec9a..bebfd6df12 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -731,6 +731,10 @@ fn unit_pt(units: &str) -> f64 { /// A number field in `units` for a value kept in points; true when edited. fn length_field(ui: &mut egui::Ui, label: &str, pt: &mut f64, units: &str) -> bool { + length_field_prec(ui, label, pt, units, 0) +} + +fn length_field_prec(ui: &mut egui::Ui, label: &str, pt: &mut f64, units: &str, decimals: u32) -> bool { let t = Tokens::get(ui.ctx()); let k = unit_pt(units); let mut v = (*pt / k) as f32; @@ -738,7 +742,7 @@ fn length_field(ui: &mut egui::Ui, label: &str, pt: &mut f64, units: &str) -> bo ui.horizontal(|ui| { ui.label(egui::RichText::new(label).color(t.text_dim)); let before = v; - crate::widgets::value_field(ui, &mut v, 0.0..=10000.0, units, 70.0); + crate::widgets::value_field_prec(ui, &mut v, 0.0..=10000.0, units, 70.0, decimals); if (v - before).abs() > 1e-4 && v.is_finite() { *pt = f64::from(v) * k; changed = true; @@ -747,6 +751,53 @@ fn length_field(ui: &mut egui::Ui, label: &str, pt: &mut f64, units: &str) -> bo changed } +/// Unrotated paper dimensions in points, for either the current preset or a custom array. +fn print_paper_size(value: Option<&Value>) -> (f64, f64) { + let default = (612.0, 792.0); + match value { + Some(Value::Array(a)) => (a.first().and_then(Value::as_f64).unwrap_or(default.0), a.get(1).and_then(Value::as_f64).unwrap_or(default.1)), + Some(Value::String(name)) => photocraft_engine::print_cmds::PAPERS.iter().find(|p| p.0.eq_ignore_ascii_case(name)).map_or(default, |p| (p.1, p.2)), + _ => default, + } +} + +fn print_paper_controls(ui: &mut egui::Ui, f: &mut Map) { + let previous = f.get("paper").cloned(); + let mut selected = if previous.as_ref().is_some_and(Value::is_array) { "custom".to_string() } else { s(f, "paper", "letter") }; + ui.horizontal(|ui| { + let mut papers: Vec<_> = photocraft_engine::print_cmds::PAPERS.iter().map(|p| (p.0.to_string(), p.0)).collect(); + papers.push(("custom".to_string(), tl!("Custom"))); + if crate::widgets::dropdown(ui, "print-paper", &mut selected, &papers, 110.0) { + if selected == "custom" { + let (w, h) = print_paper_size(f.get("__customPaper").or(previous.as_ref())); + f.insert("paper".into(), json!([w, h])); + } else { + if let Some(paper @ Value::Array(_)) = previous { + f.insert("__customPaper".into(), paper); + } + f.insert("paper".into(), json!(selected)); + } + } + dropdown_str(ui, "print-orient", f, "orientation", &[("portrait", tl!("Portrait")), ("landscape", tl!("Landscape"))], 110.0); + }); + if f.get("paper").is_some_and(Value::is_array) { + let (mut w, mut h) = print_paper_size(f.get("paper")); + let units = s(f, "units", "cm"); + let changed = ui + .horizontal(|ui| { + let wc = length_field_prec(ui, tl!("Paper Width"), &mut w, &units, 2); + let hc = length_field_prec(ui, tl!("Paper Height"), &mut h, &units, 2); + wc || hc + }) + .inner; + if changed { + // Keep points in the command, and let the engine apply orientation and validation. + f.insert("paper".into(), json!([w, h])); + f.insert("__customPaper".into(), json!([w, h])); + } + } +} + fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { let t = Tokens::get(ui.ctx()); let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; @@ -834,11 +885,7 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = photocraft_engine::print_cmds::PAPERS.iter().map(|p| (p.0, p.0)).collect(); - dropdown_str(ui, "print-paper", f, "paper", &papers, 110.0); - dropdown_str(ui, "print-orient", f, "orientation", &[("portrait", tl!("Portrait")), ("landscape", tl!("Landscape"))], 110.0); - }); + print_paper_controls(ui, f); head(ui, tl!("Color Management")); dropdown_str( ui, diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 17630d87b8..ea65085c36 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -20,6 +20,10 @@ # dialog commands is kept. # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Šířka papíru + Paper Height Výška papíru Columns Sloupce Rows Řádky Number Počet diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b2882792ac..b1d22f83fe 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -16,6 +16,10 @@ # letters; the trailing "…" of dialog commands is kept. Plural rule: one|other (n != 1 -> other). # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Papierbreite + Paper Height Papierhöhe Columns Spalten Rows Zeilen Number Anzahl diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e697582a61..0b5bdb81cd 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4,6 +4,10 @@ # Covers every source label the i18n coverage check lists. # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Πλάτος χαρτιού + Paper Height Ύψος χαρτιού Rows Γραμμές Number Πλήθος Top: Επάνω: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a27a6478b9..78c4087f56 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -13,6 +13,10 @@ # letters; the trailing "…" of dialog commands is kept. # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Ancho del papel + Paper Height Altura del papel Columns Columnas Rows Filas Number Número diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1f23a0be36..f5fecc2a95 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -20,6 +20,10 @@ # dialog commands is kept. # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Largeur du papier + Paper Height Hauteur du papier Rows Lignes Number Nombre Margin Marge diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c8fd0724ba..2a0eace598 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -17,6 +17,10 @@ # singular noun in Hungarian, so every @plural entry has a single form. # Plural messages and command id overrides +# Custom paper dimensions in Print Settings. + Paper Width Papír szélessége + Paper Height Papír magassága + @id edit.purge.undo Visszavonási adatok @id edit.stroke Körvonalazás… @id edit.transform.scale Méretezés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 311cd13ffe..deb42f1bd7 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -13,6 +13,10 @@ # letters; the trailing "…" of dialog commands is kept. # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Lebar kertas + Paper Height Tinggi kertas Rows Baris Number Jumlah Margin Margin diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index db7a98944d..9f10a8fc30 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -13,6 +13,10 @@ # letters; the trailing … of dialog commands is kept. # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Larghezza della carta + Paper Height Altezza della carta Columns Colonne Rows Righe Number Numero diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9cf72823bb..94ec6fad24 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -16,6 +16,10 @@ # letters; the trailing "…" of dialog commands is kept. # Guide layout dialog + +# Custom print paper (#3820) + Paper Width 用紙の幅 + Paper Height 用紙の高さ Rows 行 Number 数 Top: 上: diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 8e80eb5bce..815a54c17a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -8,6 +8,10 @@ # Product names and technology names retain their spelling. # Guide layout dialog + +# Custom print paper (#3820) + Paper Width 용지 너비 + Paper Height 용지 높이 Columns 열 Rows 행 Number 개수 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 89d2e90539..55163dd7c6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -9,6 +9,10 @@ # informally ("je") and uses sentence case. # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Papierbreedte + Paper Height Papierhoogte Rows Rijen Number Aantal Top: Boven: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 95099d354c..cd4a9b5616 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -19,6 +19,10 @@ # Zapisz jako…), sentence case as in the English source. Quotes: „…”. # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Szerokość papieru + Paper Height Wysokość papieru Rows Wiersze Number Liczba Top: Góra: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f5f69f9f44..75af646558 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -16,6 +16,10 @@ # stay as they are; the trailing "…" of dialog commands is kept. # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Largura do papel + Paper Height Altura do papel Rows Linhas Number Número Margin Margem diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9ee6e311fd..7b43459580 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -20,6 +20,10 @@ # Menus # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Ширина бумаги + Paper Height Высота бумаги Rows Строки Number Количество Margin Поля diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 75ceb71673..11604ef8b9 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4185,3 +4185,7 @@ shape Appearance Izgled Import .GRD Uvezi .GRD Choose a gradient file (.grd). Izaberite datoteku gradijenta (.grd). Auto Add/Delete Automatsko dodavanje/brisanje + +# Custom print paper (#3820) + Paper Width Širina papira + Paper Height Visina papira diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e912f26c27..0d25e02043 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -7,6 +7,10 @@ # entries the same way. Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) # stay as they are; the trailing "…" of dialog commands is kept. # +# Custom paper dimensions in Print Settings. + Paper Width Kağıt Genişliği + Paper Height Kağıt Yüksekliği + Rows Satırlar Number Sayı Top: Üst: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0af7c871da..d3d227a1aa 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -11,6 +11,10 @@ # Menus # Guide layout dialog + +# Custom print paper (#3820) + Paper Width Ширина паперу + Paper Height Висота паперу Rows Рядки Number Кількість Top: Зверху: diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f01c25ce7f..3192be995d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -21,6 +21,10 @@ # Phase 3.1 — top-level menus, File menu, Edit menu +# Custom paper dimensions in Print Settings. + Paper Width Chiều rộng giấy + Paper Height Chiều cao giấy + Again Lặp lại AI Integrations… Tích hợp AI… All Tất cả diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 31ecc7b5a0..b8c4e8a6b3 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -12,6 +12,10 @@ # Product and technology names retain their spelling. See docs/localization-zh-hans.md. # Guide layout dialog + +# Custom print paper (#3820) + Paper Width 纸张宽度 + Paper Height 纸张高度 Columns 列 Rows 行 Number 数量 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 3b43fff17a..877e666c4e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -20,6 +20,10 @@ # separators as in the English. # Guide layout dialog + +# Custom print paper (#3820) + Paper Width 紙張寬度 + Paper Height 紙張高度 Columns 欄 Rows 列 Number 數量 diff --git a/crates/ui-egui/tests/custom_print_paper.rs b/crates/ui-egui/tests/custom_print_paper.rs new file mode 100644 index 0000000000..7361468bbc --- /dev/null +++ b/crates/ui-egui/tests/custom_print_paper.rs @@ -0,0 +1,142 @@ +//! #3820: edit custom paper through the real Print dialog, preview it, and print safely to PDF. +use egui::{Event, Key, Modifiers, PointerButton, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use photocraft_ui_egui::{PhotocraftApp, file_ui::open_print}; +use serde_json::{Map, Value, json}; + +fn app() -> (PhotocraftApp, u64) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), photocraft_ui_egui::Services::default()); + app.run("file.new", json!({"width": 120, "height": 80, "name": "Custom paper"})).unwrap(); + let id = open_print(&mut app); + // Every UI path in these fixtures is safe even if an unexpected Enter confirms the dialog. + app.ui.dialog_mut(id).unwrap().fields.insert("dryRun".into(), json!(true)); + (app, id) +} + +fn show(ui: &mut egui::Ui, app: &mut PhotocraftApp) { + if ui.ctx().fonts(|fonts| fonts.families().contains(&egui::FontFamily::Name("medium".into()))) { + photocraft_ui_egui::dialogs::show(app, ui.ctx()); + } +} + +fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1000.0, 900.0)).with_max_steps(64).build_ui_state(show, app); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h +} + +fn click(h: &mut Harness<'_, PhotocraftApp>, label: &str) { + if let Some(node) = h.query_by_label(label) { + node.click(); + } else { + h.get_by_value(label).click(); + } + h.run_steps(3); +} + +fn shown(h: &Harness<'_, PhotocraftApp>, text: &str) -> bool { + h.query_by_label(text).is_some() || h.query_by_value(text).is_some() +} + +fn enter_length(h: &mut Harness<'_, PhotocraftApp>, label: &str, value: &str) { + // Anchor the pointer to the real label; the existing length widget immediately follows it. + let pos = h.get_by_label(label).rect().right_center() + vec2(35.0, 0.0); + h.event(Event::PointerMoved(pos)); + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.event(Event::Text(value.into())); + h.run_steps(1); + h.key_press(Key::Tab); + h.run_steps(3); +} + +fn fields(h: &Harness<'_, PhotocraftApp>, id: u64) -> Map { + h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone() +} + +#[test] +fn custom_paper_ui_edits_reach_preview_and_pdf_and_restore_when_reopened() { + let (mut app, id) = app(); + let output = std::env::temp_dir().join(format!("pc-custom-paper-{}.pdf", std::process::id())); + let dialog = app.ui.dialog_mut(id).unwrap(); + dialog.fields.insert("output".into(), json!(output.to_string_lossy())); + dialog.fields.insert("dryRun".into(), json!(true)); + let mut h = harness(app); + click(&mut h, "letter"); + assert!(shown(&h, "a4"), "the real preset menu must be open"); + assert!(shown(&h, "Custom"), "the paper menu must offer editable custom dimensions"); + click(&mut h, "Custom"); + assert_eq!(fields(&h, id)["paper"], json!([612.0, 792.0]), "Custom starts at the current preset dimensions"); + click(&mut h, "Centimeters"); + click(&mut h, "Inches"); + enter_length(&mut h, "Paper Width", "7.25"); + enter_length(&mut h, "Paper Height", "9.25"); + let edited = fields(&h, id); + assert_eq!(edited["paper"], json!([522.0, 666.0]), "the engine takes points, not the displayed units"); + click(&mut h, "Custom"); + click(&mut h, "a4"); + assert_eq!(fields(&h, id)["paper"], "a4"); + assert!(!shown(&h, "Paper Width")); + click(&mut h, "a4"); + click(&mut h, "Custom"); + assert_eq!(fields(&h, id)["paper"], edited["paper"], "returning from a preset restores the custom dimensions"); + click(&mut h, "Inches"); + click(&mut h, "Millimeters"); + assert_eq!(fields(&h, id)["paper"], edited["paper"], "changing units must not change the physical paper"); + click(&mut h, "Millimeters"); + click(&mut h, "Inches"); + assert_eq!(fields(&h, id)["paper"], edited["paper"]); + let preview = photocraft_engine::print_cmds::layout(&h.state().session.active().unwrap().doc, &Value::Object(edited.clone()), "file.print").unwrap(); + assert_eq!(preview.paper, (522.0, 666.0)); + click(&mut h, "Portrait"); + click(&mut h, "Landscape"); + let edited = fields(&h, id); + assert_eq!(edited["paper"], json!([522.0, 666.0]), "orientation must not rewrite the unrotated custom dimensions"); + let preview = photocraft_engine::print_cmds::layout(&h.state().session.active().unwrap().doc, &Value::Object(edited), "file.print").unwrap(); + assert_eq!(preview.paper, (666.0, 522.0)); + click(&mut h, "Print"); + let pdf = std::fs::read(&output).unwrap(); + assert!(String::from_utf8_lossy(&pdf).contains("/MediaBox [0 0 666.00 522.00]")); + assert_eq!(h.state().session.file_menu.last_print.as_ref().unwrap()["paper"], json!([522.0, 666.0])); + let id = open_print(h.state_mut()); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("dryRun".into(), json!(true)); + h.run_steps(3); + assert!(shown(&h, "Custom"), "reopening array-valued settings must show Custom"); + assert_eq!(fields(&h, id)["paper"], json!([522.0, 666.0])); + std::fs::remove_file(output).unwrap(); +} + +#[test] +fn remembered_custom_paper_is_shown_without_becoming_a_preset() { + let (mut app, id) = app(); + app.ui.dialog_mut(id).unwrap().fields.insert("paper".into(), json!([432.0, 576.0])); + let h = harness(app); + assert!(shown(&h, "Custom"), "an array paper size must not display as letter"); + assert!(shown(&h, "Paper Width")); + assert!(shown(&h, "Paper Height")); + assert_eq!(fields(&h, id)["paper"], json!([432.0, 576.0])); +} + +#[test] +fn invalid_custom_paper_is_rejected_without_printing_or_changing_the_document() { + let (mut app, id) = app(); + let output = std::env::temp_dir().join(format!("pc-invalid-custom-paper-{}.pdf", std::process::id())); + app.ui.dialog_mut(id).unwrap().fields.insert("output".into(), json!(output.to_string_lossy())); + let before = app.session.active().unwrap().doc.clone(); + let mut h = harness(app); + click(&mut h, "letter"); + click(&mut h, "Custom"); + enter_length(&mut h, "Paper Width", "0"); + let edited = fields(&h, id); + assert!(photocraft_engine::print_cmds::layout(&h.state().session.active().unwrap().doc, &Value::Object(edited), "file.print").is_err()); + click(&mut h, "Print"); + let error = &h.state().ui.status; + assert!(error.contains("paper is too small"), "{error}"); + assert_eq!(h.state().session.active().unwrap().doc, before); + assert!(h.state().session.file_menu.last_print.is_none()); + assert!(!output.exists()); +}