feat(print): add interactive preview positioning

Drag the visible image in the print preview using paper-scaled egui points.
Switch Center to manual without moving the grab point, allow signed Top/Left
offsets, and retain the shared preview/PDF layout and mirrored behavior.

Add regression coverage for negative PDF placement, native HiDPI dragging,
centering, clipping, scaling, fit, mirroring and invalid geometry.

Validation: 25 engine print tests, 19 file-dialog unit tests, four print-dialog
integration tests and the adversarial-command test pass. Formatting,
all-target clippy, desktop check/build and layer checks pass.
Three existing socket-binding tests are sandbox-blocked and excluded from
the successful print-suite rerun. GUI capture and wasm checks remain limited
by the environment.
This commit is contained in:
FartmanAndBurps
2026-10-21 12:31:08 +01:00
parent 04d0212c7a
commit 755ca9e2a0
5 changed files with 307 additions and 19 deletions
+20 -2
View File
@@ -490,6 +490,9 @@ pub struct PrintLayout {
pub emulsion_down: bool,
}
/// Symmetric safety bound for manual Top/Left offsets, shared with the dialog (inches).
pub const MAX_POSITION_INCHES: f64 = 10000.0;
/// The print layout for `doc` with Print parameters `p` (used by the dialog's preview too).
pub fn layout(doc: &Document, p: &Value, cmd: &str) -> Result<PrintLayout> {
let (pw, ph) = paper(p, cmd)?;
@@ -513,10 +516,25 @@ pub fn layout(doc: &Document, p: &Value, cmd: &str) -> Result<PrintLayout> {
((pw - w) / 2.0, (ph - h) / 2.0)
} else {
// Top / left in inches from the paper's top-left corner (Photoshop's Position fields).
let top = p.get("top").and_then(Value::as_f64).unwrap_or(0.0) * 72.0;
let left = p.get("left").and_then(Value::as_f64).unwrap_or(0.0) * 72.0;
let position = |key: &str| -> Result<f64> {
let value = match p.get(key) {
None => 0.0,
Some(v) => v.as_f64().ok_or_else(|| bad(cmd, "print positions must be finite numbers in inches"))?,
};
if !value.is_finite() || value.abs() > MAX_POSITION_INCHES {
return Err(bad(cmd, "print positions must be within -10000 to 10000 inches"));
}
Ok(value * 72.0)
};
let top = position("top")?;
let left = position("left")?;
(left, ph - top - h)
};
// Guard geometry before converting to screen points or writing PDF operators. Even a
// finite JSON scale can overflow when multiplied by document dimensions.
if ![pw, ph, x, y, w, h, scale].into_iter().all(|v| v.is_finite() && v.abs() <= 1.0e9) || w <= 0.0 || h <= 0.0 {
return Err(bad(cmd, "print layout must have finite geometry within 1000000000 points and positive image dimensions"));
}
let emulsion_down = p.get("emulsionDown").and_then(Value::as_bool).unwrap_or(false);
Ok(PrintLayout { paper: (pw, ph), rect: (x, y, w, h), scale, marks, emulsion_down })
}
+58
View File
@@ -1,6 +1,64 @@
use super::*;
use photocraft_doc::{Knot, NamedPath, Path, Subpath};
#[test]
fn negative_print_positions_match_pdf_without_changing_the_document() {
let dir = tmp("negative-position");
for (scale, fit, landscape, mirrored) in
[(100.0, false, false, false), (175.0, false, true, false), (50.0, true, false, false), (100.0, false, false, true)]
{
let mut s = session("rgb", 8);
let before = s.active().unwrap().doc.clone();
let revision = s.active().unwrap().revision;
let path = format!("{dir}/{scale}-{fit}-{landscape}-{mirrored}.pdf");
let p = json!({"paper": "a4", "center": false, "left": -0.5, "top": -0.25,
"scale": scale, "scaleToFit": fit, "orientation": if landscape {"landscape"} else {"portrait"},
"emulsionDown": mirrored, "output": path, "send": false, "dryRun": true});
let preview = layout(&before, &p, "file.print").unwrap();
let (x, y, w, h) = preview.rect;
assert_eq!(x, -36.0);
assert!((preview.paper.1 - y - h + 18.0).abs() < 1e-9);
assert!((w / h - 2.0).abs() < 1e-9);
if !fit {
assert!((w - 300.0 * scale / 100.0).abs() < 1e-9);
} else {
assert!((w - (preview.paper.0 - 36.0)).abs() < 1e-9);
}
let result = s.execute("file.print", p).unwrap();
assert_eq!(result["sent"], false);
assert_eq!(result["imageRect"], json!([x, y, w, h]));
let pdf = std::fs::read(path).unwrap();
let content = pdf_content(&pdf);
assert!(content.contains(&format!("q {w:.3} 0 0 {h:.3} {x:.3} {y:.3} cm /Im0 Do Q")));
assert_eq!(content.contains(&format!("q -1 0 0 1 {:.2} 0 cm", preview.paper.0)), mirrored);
let after = s.active().unwrap();
assert_eq!(after.revision, revision);
assert_eq!(after.doc.size, before.size);
assert_eq!(after.doc.resolution_dpi, before.resolution_dpi);
assert_eq!(photocraft_compose::thumbnail(&before, 32).pixels, photocraft_compose::thumbnail(&after.doc, 32).pixels);
}
}
#[test]
fn print_position_and_geometry_bounds_reject_malformed_inputs() {
let s = session("rgb", 8);
let doc = &s.active().unwrap().doc;
for key in ["top", "left"] {
for value in [json!(-10000.0), json!(10000.0), json!(-0.125)] {
let mut p = json!({"center": false});
p[key] = value;
assert!(layout(doc, &p, "file.print").is_ok());
}
for value in [json!(1e308), json!(-10001.0), json!("NaN"), Value::Null] {
let mut p = json!({"center": false});
p[key] = value;
assert!(layout(doc, &p, "file.print").is_err());
}
}
assert!(layout(doc, &json!({"scale": 1e308}), "file.print").is_err());
assert!(layout(doc, &json!({"paper": [1e308, 792]}), "file.print").is_err());
}
fn tmp(name: &str) -> String {
let d = std::env::temp_dir().join(format!("photocraft-print-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
+123 -13
View File
@@ -841,6 +841,10 @@ fn length_field(ui: &mut egui::Ui, label: &str, pt: &mut f64, units: &str) -> bo
}
fn length_field_prec(ui: &mut egui::Ui, label: &str, pt: &mut f64, units: &str, decimals: u32) -> bool {
length_field_range(ui, label, pt, units, decimals, 0.0..=10000.0)
}
fn length_field_range(ui: &mut egui::Ui, label: &str, pt: &mut f64, units: &str, decimals: u32, range: std::ops::RangeInclusive<f32>) -> bool {
let t = Tokens::get(ui.ctx());
let k = unit_pt(units);
let mut v = (*pt / k) as f32;
@@ -848,7 +852,7 @@ fn length_field_prec(ui: &mut egui::Ui, label: &str, pt: &mut f64, units: &str,
ui.horizontal(|ui| {
ui.label(egui::RichText::new(label).color(t.text_dim));
let before = v;
crate::widgets::value_field_prec(ui, &mut v, 0.0..=10000.0, units, 70.0, decimals);
crate::widgets::value_field_prec(ui, &mut v, range, units, 70.0, decimals);
if (v - before).abs() > 1e-4 && v.is_finite() {
*pt = f64::from(v) * k;
changed = true;
@@ -919,6 +923,32 @@ fn browse_pdf(app: &mut PhotocraftApp, id: u64, suggested: &str) {
}
}
/// Both the pointer and the painter use egui points, including on Retina displays.
/// Applying pixels_per_point here would count the display scale a second time.
fn drag_print_position(f: &mut Map<String, Value>, start: photocraft_engine::print_cmds::PrintLayout, delta: egui::Vec2, points_per_print_point: f32) -> bool {
if !points_per_print_point.is_finite() || points_per_print_point <= 0.0 || !delta.is_finite() {
return false;
}
let (x, y, _, h) = start.rect;
let direction = if start.emulsion_down { -1.0 } else { 1.0 };
let left = (x + direction * f64::from(delta.x) / f64::from(points_per_print_point)) / 72.0;
let top = (start.paper.1 - y - h + f64::from(delta.y) / f64::from(points_per_print_point)) / 72.0;
if !left.is_finite() || !top.is_finite() {
return false;
}
let limit = photocraft_engine::print_cmds::MAX_POSITION_INCHES;
f.insert("center".into(), json!(false));
f.insert("left".into(), json!(left.clamp(-limit, limit)));
f.insert("top".into(), json!(top.clamp(-limit, limit)));
true
}
fn print_preview_image(l: photocraft_engine::print_cmds::PrintLayout, page: egui::Rect, k: f32) -> egui::Rect {
let (x, y, w, h) = l.rect;
let x = if l.emulsion_down { l.paper.0 - x - w } else { x };
egui::Rect::from_min_size(egui::pos2(page.left() + x as f32 * k, page.bottom() - (y + h) as f32 * k), egui::vec2(w as f32 * k, h as f32 * k))
}
fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Value>, id: u64) {
let t = Tokens::get(ui.ctx());
let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return };
@@ -946,19 +976,39 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Va
let (area, _) = ui.allocate_exact_size(egui::vec2(330.0, 400.0), egui::Sense::hover());
ui.painter().rect_filled(area, 0.0, t.canvas);
match photocraft_engine::print_cmds::layout(&doc, &p, "file.print") {
Ok(l) => {
Ok(mut l) => {
let (pw, ph) = l.paper;
let k = ((area.width() - 20.0) / pw as f32).min((area.height() - 20.0) / ph as f32);
let page = egui::Rect::from_center_size(area.center(), egui::vec2(pw as f32 * k, ph as f32 * k));
ui.painter().rect_filled(page, 0.0, egui::Color32::WHITE);
ui.painter().rect_stroke(page, 0.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Outside);
let mut img = print_preview_image(l, page, k);
let drag_id = ui.id().with(("print-position", id));
let visible = img.intersect(page).intersect(ui.clip_rect());
let response = ui.interact(visible, drag_id, egui::Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, true, tl!("Print preview image")));
// Keep a fixed layout and press origin for the whole gesture: rounding and
// the transition from Center must never accumulate or move the grab point.
if response.drag_started_by(egui::PointerButton::Primary)
&& let Some(origin) = ui.input(|i| i.pointer.press_origin())
{
ui.data_mut(|d| d.insert_temp(drag_id, (l, origin, k)));
}
if (response.dragged_by(egui::PointerButton::Primary) || response.drag_stopped_by(egui::PointerButton::Primary))
&& let Some((start, origin, scale)) = ui.data(|d| d.get_temp::<(photocraft_engine::print_cmds::PrintLayout, egui::Pos2, f32)>(drag_id))
&& let Some(pointer) = response.interact_pointer_pos()
&& drag_print_position(f, start, pointer - origin, scale)
{
if let Ok(updated) = photocraft_engine::print_cmds::layout(&doc, &params(f), "file.print") {
l = updated;
img = print_preview_image(l, page, k);
}
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
if !ui.input(|i| i.pointer.primary_down()) {
ui.data_mut(|d| d.remove::<(photocraft_engine::print_cmds::PrintLayout, egui::Pos2, f32)>(drag_id));
}
let (x, y, w, h) = l.rect;
// Emulsion Down shows the page as it prints: mirrored.
let x = if l.emulsion_down { pw - x - w } else { x };
let img = egui::Rect::from_min_size(
egui::pos2(page.left() + x as f32 * k, page.bottom() - (y + h) as f32 * k),
egui::vec2(w as f32 * k, h as f32 * k),
);
let key = egui::Id::new(("print-thumb", doc.id.0, app.session.active().map_or(0, |d| d.revision)));
let tex: Option<Arc<egui::TextureHandle>> = ui.data(|d| d.get_temp(key));
let tex = tex.unwrap_or_else(|| {
@@ -1138,17 +1188,24 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Va
dropdown_str(ui, "print-units", f, "units", &[("cm", tl!("Centimeters")), ("mm", tl!("Millimeters")), ("in", tl!("Inches"))], 130.0);
let units = s(f, "units", "cm");
check(ui, f, "center", tl!("Center"), true);
if !b(f, "center", true) {
let centered = b(f, "center", true);
let centered_layout = centered.then(|| photocraft_engine::print_cmds::layout(&doc, &params(f), "file.print").ok()).flatten();
// Revealing this row during a drag resizes the centered modal and moves the paper.
// Keep it present, showing the resolved position while Center owns the offsets.
ui.add_enabled_ui(!centered, |ui| {
ui.horizontal(|ui| {
// Kept in inches (Photoshop's Position fields), shown in the chosen units.
for (key, label) in [("top", tl!("Top")), ("left", tl!("Left"))] {
let mut pt = n(f, key, 0.0) * 72.0;
if length_field(ui, label, &mut pt, &units) {
f.insert(key.into(), json!(pt / 72.0));
let mut pt =
centered_layout.map_or_else(|| n(f, key, 0.0) * 72.0, |l| if key == "top" { l.paper.1 - l.rect.1 - l.rect.3 } else { l.rect.0 });
let limit = (photocraft_engine::print_cmds::MAX_POSITION_INCHES * 72.0 / unit_pt(&units)) as f32;
if length_field_range(ui, label, &mut pt, &units, 2, -limit..=limit) {
let limit = photocraft_engine::print_cmds::MAX_POSITION_INCHES;
f.insert(key.into(), json!((pt / 72.0).clamp(-limit, limit)));
}
}
});
}
});
check(ui, f, "scaleToFit", tl!("Scale to Fit Media"), false);
// Scaled Print Size: Scale, Height and Width move together; Print Resolution follows.
if let Ok(l) = photocraft_engine::print_cmds::layout(&doc, &params(f), "file.print") {
@@ -1241,6 +1298,59 @@ pub(crate) fn print_status(app: &mut PhotocraftApp, r: &Value) {
mod tests {
use super::*;
#[test]
fn print_drag_uses_the_resolved_layout_and_screen_points_without_accumulation() {
let (app, _) = app();
let doc = &app.session.active().unwrap().doc;
for fit in [false, true] {
for mirror in [false, true] {
for orientation in ["portrait", "landscape"] {
let mut f = json!({"center": true, "scale": 175, "scaleToFit": fit,
"emulsionDown": mirror, "orientation": orientation})
.as_object()
.unwrap()
.clone();
let start = photocraft_engine::print_cmds::layout(doc, &Value::Object(f.clone()), "file.print").unwrap();
let page = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(start.paper.0 as f32, start.paper.1 as f32));
assert!(drag_print_position(&mut f, start, egui::Vec2::ZERO, 1.0));
let manual = photocraft_engine::print_cmds::layout(doc, &Value::Object(f.clone()), "file.print").unwrap();
assert!((manual.rect.0 - start.rect.0).abs() < 1e-9);
assert!((manual.rect.1 - start.rect.1).abs() < 1e-9);
assert_eq!(manual.scale, start.scale);
for k in [0.25, 0.5, 1.0, 2.0] {
let delta = egui::vec2(-90.0, 45.0) * k;
assert!(drag_print_position(&mut f, start, delta, k));
let moved = photocraft_engine::print_cmds::layout(doc, &Value::Object(f.clone()), "file.print").unwrap();
let old_img = print_preview_image(start, page, k);
let new_img = print_preview_image(moved, page, k);
assert!((new_img.min - old_img.min - delta).length() < 0.001);
assert_eq!((moved.rect.2, moved.rect.3, moved.scale), (start.rect.2, start.rect.3, start.scale));
let once = f.clone();
assert!(drag_print_position(&mut f, start, delta, k));
assert_eq!(f, once, "total drag displacement must not accumulate");
}
}
}
}
}
#[test]
fn print_drag_rejects_nonfinite_input_and_bounds_offsets() {
let (app, _) = app();
let doc = &app.session.active().unwrap().doc;
let start = photocraft_engine::print_cmds::layout(doc, &json!({}), "file.print").unwrap();
let mut f = Map::new();
for k in [0.0, -1.0, f32::NAN, f32::INFINITY] {
assert!(!drag_print_position(&mut f, start, egui::vec2(10.0, 10.0), k));
assert!(f.is_empty());
}
assert!(!drag_print_position(&mut f, start, egui::vec2(f32::NAN, 0.0), 1.0));
assert!(f.is_empty());
assert!(drag_print_position(&mut f, start, egui::vec2(-f32::MAX, f32::MAX), 1.0));
assert_eq!(f["left"], -photocraft_engine::print_cmds::MAX_POSITION_INCHES);
assert_eq!(f["top"], photocraft_engine::print_cmds::MAX_POSITION_INCHES);
}
fn app() -> (PhotocraftApp, egui::Context) {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 120, "height": 80, "name": "web"})).unwrap();
+90 -4
View File
@@ -1,6 +1,9 @@
//! #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 egui_kittest::{
Harness,
kittest::{NodeT, Queryable},
};
use photocraft_ui_egui::{PhotocraftApp, file_ui::open_print};
use serde_json::{Map, Value, json};
@@ -22,7 +25,11 @@ fn show(ui: &mut egui::Ui, app: &mut PhotocraftApp) {
}
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);
harness_at_scale(app, 1.0)
}
fn harness_at_scale(app: PhotocraftApp, pixels_per_point: f32) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(1000.0, 900.0)).with_pixels_per_point(pixels_per_point).with_max_steps(64).build_ui_state(show, app);
PhotocraftApp::setup_context(&h.ctx, Default::default());
h.run_steps(4);
h
@@ -42,8 +49,8 @@ fn shown(h: &Harness<'_, PhotocraftApp>, text: &str) -> bool {
}
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);
// Use the editor's actual accessibility bounds rather than a fixed offset from its label.
let pos = h.get_by_label(label).parent().unwrap().get_by_role(egui::accesskit::Role::SpinButton).rect().center();
h.event(Event::PointerMoved(pos));
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
@@ -60,6 +67,85 @@ fn fields(h: &Harness<'_, PhotocraftApp>, id: u64) -> Map<String, Value> {
h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone()
}
fn painted_image(h: &Harness<'_, PhotocraftApp>) -> egui::Rect {
let active = h.state().session.active().unwrap();
let key = egui::Id::new(("print-thumb", active.doc.id.0, active.revision));
let tex = h.ctx.data(|d| d.get_temp::<std::sync::Arc<egui::TextureHandle>>(key)).unwrap();
h.output()
.shapes
.iter()
.find_map(|s| match &s.shape {
egui::Shape::Mesh(mesh) if mesh.texture_id == tex.id() => Some(mesh.calc_bounds()),
_ => None,
})
.expect("the print image must be painted")
}
#[test]
fn preview_drag_and_negative_fields_work_at_standard_and_retina_scale() {
for pixels_per_point in [1.0, 2.0] {
let (mut app, id) = app();
app.ui
.dialog_mut(id)
.unwrap()
.fields
.extend(json!({"paper": "a4", "center": true, "scale": 100, "scaleToFit": false, "units": "cm"}).as_object().unwrap().clone());
let mut h = harness_at_scale(app, pixels_per_point);
let start = photocraft_engine::print_cmds::layout(&h.state().session.active().unwrap().doc, &Value::Object(fields(&h, id)), "file.print").unwrap();
let start_image = painted_image(&h);
let from = start_image.center();
let delta = vec2(-24.0, 18.0);
h.event(Event::PointerMoved(from));
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for step in 1..=3 {
h.event(Event::PointerMoved(from + delta * (step as f32 / 3.0)));
h.run_steps(1);
}
let during = fields(&h, id);
assert_eq!(during["center"], false, "Center must switch off during the drag");
// The hit target was registered before this frame's pointer handling. Inspect the
// actual painted mesh to verify the image moves immediately, without a frame of lag.
let moved_image = painted_image(&h);
assert!(
(moved_image.min - start_image.min - delta).length() < 0.1,
"the visible image must stay under the pointer when Center turns off: {start_image:?} -> {moved_image:?}, delta {delta:?}"
);
let k = (310.0 / start.paper.0).min(380.0 / start.paper.1);
assert!((during["left"].as_f64().unwrap() * 72.0 - start.rect.0 - f64::from(delta.x) / k).abs() < 0.001);
assert!((during["top"].as_f64().unwrap() * 72.0 - (start.paper.1 - start.rect.1 - start.rect.3) - f64::from(delta.y) / k).abs() < 0.001);
h.event(Event::PointerButton { pos: from + delta, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
click(&mut h, "Centimeters");
click(&mut h, "Inches");
enter_length(&mut h, "Top", "-0.25");
enter_length(&mut h, "Left", "-0.5");
let negative = fields(&h, id);
assert_eq!(negative["top"], -0.25);
assert_eq!(negative["left"], -0.5);
let visible = h.get_by_label("Print preview image").rect();
assert!(visible.is_positive(), "a partly off-page image must retain a visible drag target");
let from = visible.center();
h.event(Event::PointerMoved(from));
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
// Keep capture even when both the pointer and image leave the sheet entirely.
h.event(Event::PointerMoved(from - vec2(400.0, 0.0)));
h.run_steps(2);
assert!(fields(&h, id)["left"].as_f64().unwrap() < -1.0);
h.event(Event::PointerMoved(from + vec2(12.0, 12.0)));
h.run_steps(2);
assert!(fields(&h, id)["left"].as_f64().unwrap() > -0.5);
h.event(Event::PointerButton { pos: from + vec2(12.0, 12.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(1);
click(&mut h, "Center");
let centered = photocraft_engine::print_cmds::layout(&h.state().session.active().unwrap().doc, &Value::Object(fields(&h, id)), "file.print").unwrap();
assert_eq!(fields(&h, id)["center"], true);
assert!((centered.rect.0 - (centered.paper.0 - centered.rect.2) / 2.0).abs() < 1e-9);
assert!((centered.rect.1 - (centered.paper.1 - centered.rect.3) / 2.0).abs() < 1e-9);
}
}
#[test]
fn custom_paper_ui_edits_reach_preview_and_pdf_and_restore_when_reopened() {
let (mut app, id) = app();
+16
View File
@@ -5,6 +5,22 @@ position and scale, crop and registration marks, labels, colour handling) and ha
to a printer. The rendering is `print_cmds.rs` in `photocraft-engine`, shared by the dialog, the
batch commands and automation (`file.print`, `file.printOneCopy`); this page is about the hand-off.
## Positioning the preview
Drag the visible image on the paper to change Top and Left. Starting a drag switches Center
off using the currently resolved layout, including Scale to Fit Media, so the image stays at
the grab point. Top/Left stay visible but disabled while Center is on, keeping the dialog and
paper stationary when a drag enables them. Position fields accept negative values in inches, centimetres or millimetres;
the stored command parameters are inches from the sheet's top-left. Manual positions are
bounded to ±10000 inches. Height, width and paper dimensions retain their positive fields.
The preview converts egui point displacement to PDF points using its paper display scale.
No extra Retina pixel factor is needed: painting and pointer input both use egui points.
Emulsion Down reverses the horizontal position change to match the mirrored output. The image
is clipped to the paper; any visible portion remains a drag target. A completely off-page image
can be recovered with Top/Left or Center. These controls affect print parameters only, and
the preview and generated PDF continue to share `print_cmds::layout`.
## Routes to the printer
| Route | When | What the user sees |