diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 9b264f0611..e747f7db6f 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -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 { let (pw, ph) = paper(p, cmd)?; @@ -513,10 +516,25 @@ pub fn layout(doc: &Document, p: &Value, cmd: &str) -> Result { ((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 { + 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 }) } diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 38567ffb6d..f96f5e0654 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -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); diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 32f97bc4e8..65bc4fa499 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -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) -> 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, 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, 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 { + 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, ¶ms(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> = 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 (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(); diff --git a/crates/ui-egui/tests/custom_print_paper.rs b/crates/ui-egui/tests/custom_print_paper.rs index d0d81dea69..6363464ef2 100644 --- a/crates/ui-egui/tests/custom_print_paper.rs +++ b/crates/ui-egui/tests/custom_print_paper.rs @@ -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 { 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::>(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(); diff --git a/docs/printing.md b/docs/printing.md index 399944d57d..7b5d2a93fe 100644 --- a/docs/printing.md +++ b/docs/printing.md @@ -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 |