fix(ui): expose custom paper dimensions in Print Settings (#3893)

* fix(ui): expose custom paper dimensions in Print Settings

* fix(i18n): keep custom paper labels away from catalog tails

* Custom print paper: Serbian rows; drop the env-gated capture test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
zenjabba
2026-10-13 01:04:53 -04:00
committed by GitHub
co-authored by zenjabba Brandon Thomas Claude Opus 5.5
parent 8c135457b4
commit a80fefce5c
22 changed files with 275 additions and 6 deletions
+53 -6
View File
@@ -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<String, Value>) {
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<String, Value>) {
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<String, Va
}
});
number(ui, f, "copies", tl!("Copies"), 1.0..=999.0, "", 1.0);
ui.horizontal(|ui| {
let papers: Vec<(&str, &str)> = 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,
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -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: Επάνω:
1 # Greek (el) PhotoCraft interface catalog.
4 # Covers every source label the i18n coverage check lists.
5 # Guide layout dialog
6 # Custom print paper (#3820)
7
8
9
10
11
12
13
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 4 and column 47.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 4 and column 47.
+4
View File
@@ -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
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
13 # letters; the trailing … of dialog commands is kept.
14 # Guide layout dialog
15 # Custom print paper (#3820)
16
17
18
19
20
21
22
+4
View File
@@ -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: 上:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -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 개수
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
8 # Product names and technology names retain their spelling.
9 # Guide layout dialog
10 # Custom print paper (#3820)
11
12
13
14
15
16
17
+4
View File
@@ -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:
Can't render this file because it contains an unexpected character in line 8 and column 34.
+4
View File
@@ -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:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -20,6 +20,10 @@
# Menus
# Guide layout dialog
# Custom print paper (#3820)
Paper Width Ширина бумаги
Paper Height Высота бумаги
Rows Строки
Number Количество
Margin Поля
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -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:
Can't render this file because it contains an unexpected character in line 8 and column 34.
+4
View File
@@ -11,6 +11,10 @@
# Menus
# Guide layout dialog
# Custom print paper (#3820)
Paper Width Ширина паперу
Paper Height Висота паперу
Rows Рядки
Number Кількість
Top: Зверху:
1 # Ukrainian (uk) catalog. UTF-8: context<TAB>English source<TAB>translation.
11 # Guide layout dialog
12 # Custom print paper (#3820)
13
14
15
16
17
18
19
20
+4
View File
@@ -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ả
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -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 数量
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
12 # Product and technology names retain their spelling. See docs/localization-zh-hans.md.
13 # Guide layout dialog
14 # Custom print paper (#3820)
15
16
17
18
19
20
21
+4
View File
@@ -20,6 +20,10 @@
# separators as in the English.
# Guide layout dialog
# Custom print paper (#3820)
Paper Width 紙張寬度
Paper Height 紙張高度
Columns 欄
Rows 列
Number 數量
Can't render this file because it contains an unexpected character in line 6 and column 47.
+142
View File
@@ -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<String, Value> {
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());
}