mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 01:47:08 -06:00
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:
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
@@ -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, ¶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<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, ¶ms(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, ¶ms(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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user