diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 9a0af4a054..29de3e57f0 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -61,6 +61,27 @@ pub struct Marks { pub corner_crop: bool, pub center_crop: bool, pub registration: bool, + /// Photoshop's Functions › Bleed, in points: corner crop marks sit this far inside the image + /// edge, so the trim cuts through the image and leaves no paper-white sliver. 0 = on the edge. + pub bleed: f64, +} + +/// Gap between the image edge and a crop mark, and the mark's length (pt). +const MARK_GAP: f64 = 6.0; +const MARK_LEN: f64 = 18.0; + +/// Corner crop marks around `(x, y, w, h)` as eight `(from, to)` segments, a horizontal then a +/// vertical one per corner. Each starts `MARK_GAP` outside the image (so it never prints on it) +/// and lies on the trim line, `bleed` inside the edge. The inset is symmetric, so this holds for +/// both bottom-up (PDF) and top-down (Windows) coordinates. +fn corner_crop_segments(bleed: f64, (x, y, w, h): (f64, f64, f64, f64)) -> [((f64, f64), (f64, f64)); 8] { + let mut out = [((0.0, 0.0), (0.0, 0.0)); 8]; + for (i, (cx, cy, dx, dy)) in [(x, y, -1.0, -1.0), (x + w, y, 1.0, -1.0), (x, y + h, -1.0, 1.0), (x + w, y + h, 1.0, 1.0)].into_iter().enumerate() { + let (tx, ty) = (cx - dx * bleed, cy - dy * bleed); + out[2 * i] = ((cx + dx * MARK_GAP, ty), (cx + dx * (MARK_GAP + MARK_LEN), ty)); + out[2 * i + 1] = ((tx, cy + dy * MARK_GAP), (tx, cy + dy * (MARK_GAP + MARK_LEN))); + } + out } /// One page: paper (pt), image box (pt, origin bottom-left), marks and texts. @@ -102,12 +123,11 @@ fn deflate(data: &[u8]) -> Vec { /// Vector marks around `(x, y, w, h)`. fn marks_ops(m: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> String { let mut s = String::from("q 0 G 0.3 w\n"); - let (gap, len) = (6.0, 18.0); + let (gap, len) = (MARK_GAP, MARK_LEN); let line = |s: &mut String, x0: f64, y0: f64, x1: f64, y1: f64| s.push_str(&format!("{x0:.2} {y0:.2} m {x1:.2} {y1:.2} l S\n")); if m.corner_crop { - for (cx, cy, dx, dy) in [(x, y, -1.0, -1.0), (x + w, y, 1.0, -1.0), (x, y + h, -1.0, 1.0), (x + w, y + h, 1.0, 1.0)] { - line(&mut s, cx + dx * gap, cy, cx + dx * (gap + len), cy); - line(&mut s, cx, cy + dy * gap, cx, cy + dy * (gap + len)); + for ((x0, y0), (x1, y1)) in corner_crop_segments(m.bleed, (x, y, w, h)) { + line(&mut s, x0, y0, x1, y1); } } if m.center_crop { @@ -303,10 +323,16 @@ pub fn layout(doc: &Document, p: &Value, cmd: &str) -> Result { let (pw, ph) = paper(p, cmd)?; let dpi = f64::from(doc.resolution_dpi.max(1.0)); let (iw, ih) = (f64::from(doc.size.width) * 72.0 / dpi, f64::from(doc.size.height) * 72.0 / dpi); - let marks = Marks { + // Bleed in inches, like Photoshop's Position fields; the marks need it in points. + let bleed = match p.get("bleed") { + None | Some(Value::Null) => 0.0, + Some(v) => v.as_f64().filter(|b| b.is_finite() && *b >= 0.0).ok_or_else(|| bad(cmd, "bleed is a width in inches, 0 or more"))? * 72.0, + }; + let mut marks = Marks { corner_crop: p.get("cornerCropMarks").and_then(Value::as_bool).unwrap_or(false), center_crop: p.get("centerCropMarks").and_then(Value::as_bool).unwrap_or(false), registration: p.get("registrationMarks").and_then(Value::as_bool).unwrap_or(false), + bleed, }; let margin = if marks.corner_crop || marks.center_crop || marks.registration { 36.0 } else { 18.0 }; let fit = p.get("scaleToFit").and_then(Value::as_bool).unwrap_or(false); @@ -316,6 +342,14 @@ pub fn layout(doc: &Document, p: &Value, cmd: &str) -> Result { p.get("scale").and_then(Value::as_f64).filter(|v| *v > 0.0).unwrap_or(100.0) / 100.0 }; let (w, h) = (iw * scale, ih * scale); + // The trim box must keep some area, or the marks of opposite corners would cross. + if 2.0 * marks.bleed >= w.min(h) { + return Err(bad(cmd, format!("bleed {:.3} in leaves no image inside the trim ({:.2} × {:.2} in printed)", marks.bleed / 72.0, w / 72.0, h / 72.0))); + } + // Bleed only moves crop marks; without them it has nothing to change. + if !marks.corner_crop { + marks.bleed = 0.0; + } let center = p.get("center").and_then(Value::as_bool).unwrap_or(true); let (x, y) = if center { ((pw - w) / 2.0, (ph - h) / 2.0) @@ -398,7 +432,7 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { require_printing_enabled()?; let printed = print_windows(&page, p, &doc.name, copies)?; return Ok(json!({ - "pdf": Value::Null, "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, + "pdf": Value::Null, "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "bleed": marks.bleed / 72.0, "scale": scale * 100.0, "copies": copies, "command": Value::Null, "sent": true, "spooler": printed, "color": color, })); } @@ -431,7 +465,7 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { sent = true; } Ok( - json!({"pdf": pdf_path, "bytes": pdf.len(), "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, "copies": copies, "command": command, "sent": sent, "spooler": spool, "color": color}), + json!({"pdf": pdf_path, "bytes": pdf.len(), "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "bleed": marks.bleed / 72.0, "scale": scale * 100.0, "copies": copies, "command": command, "sent": sent, "spooler": spool, "color": color}), ) } @@ -750,7 +784,7 @@ pub fn specs() -> Vec { CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run } }; } - const PRINT_PARAMS: &str = r##"{"printerSettings":hex? (Windows driver settings),"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"dryRun":bool=false (render the PDF, report the backend command, don't spool; write a PDF only at an explicit output path)} → {pdf:path|null (null without explicit output), imageRect, command:diagnostic array|null, sent}"##; + const PRINT_PARAMS: &str = r##"{"printerSettings":hex? (Windows driver settings),"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"bleed":in=0 (Functions › Bleed: corner crop marks this far inside the image edge, on the trim line),"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"dryRun":bool=false (render the PDF, report the backend command, don't spool; write a PDF only at an explicit output path)} → {pdf:path|null (null without explicit output), imageRect, command:diagnostic array|null, sent}"##; vec![ spec!("file.print", "Print…", &["File"], Some("Cmd+P"), PRINT_PARAMS, native_doc, print), diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index d012f6de41..f3d1fc3fe3 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -434,3 +434,74 @@ fn printers_lists_the_spooler_destinations_without_a_document() { None => assert!(list.iter().all(|p| p["default"] == false)), } } + +/// A dry-run print to `name`.pdf with `extra` params; returns the result and the PDF content stream. +fn print_with(s: &mut Session, dir: &str, name: &str, extra: Value) -> (Value, Vec) { + let mut p = json!({"output": format!("{dir}/{name}.pdf"), "dryRun": true, "paper": "a4", "center": false, "left": 1.0, "top": 1.0}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + let r = s.execute("file.print", p).unwrap(); + let pdf = std::fs::read(r["pdf"].as_str().unwrap()).unwrap(); + (r, pdf) +} + +#[test] +fn zero_bleed_prints_exactly_as_before() { + let mut s = session("rgb", 8); + let dir = tmp("bleed-zero"); + let (plain, a) = print_with(&mut s, &dir, "plain", json!({"cornerCropMarks": true})); + let (zero, b) = print_with(&mut s, &dir, "zero", json!({"cornerCropMarks": true, "bleed": 0.0})); + assert_eq!(a, b, "bleed 0 must not change a single byte"); + assert_eq!(plain["bleed"], 0.0); + assert_eq!(zero["bleed"], 0.0); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn bleed_puts_corner_crop_marks_on_the_trim_line_outside_the_image() { + let mut s = session("rgb", 8); + let dir = tmp("bleed"); + let (r, pdf) = print_with(&mut s, &dir, "bleed", json!({"cornerCropMarks": true, "centerCropMarks": true, "bleed": 0.125})); + assert_eq!(r["bleed"], 0.125); + let rect: Vec = r["imageRect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + let (x, y, w, h) = (rect[0], rect[1], rect[2], rect[3]); + let c = pdf_content(&pdf); + // Bottom-left corner (PDF is bottom-up): the horizontal mark runs left from 6 pt outside the + // image, 9 pt (0.125 in) above the image's bottom edge; the vertical one 9 pt right of its left edge. + assert!(c.contains(&format!("{:.2} {:.2} m {:.2} {:.2} l S", x - 6.0, y + 9.0, x - 24.0, y + 9.0)), "{c}"); + assert!(c.contains(&format!("{:.2} {:.2} m {:.2} {:.2} l S", x + 9.0, y - 6.0, x + 9.0, y - 24.0)), "{c}"); + // Top-right corner, mirrored. + assert!(c.contains(&format!("{:.2} {:.2} m {:.2} {:.2} l S", x + w + 6.0, y + h - 9.0, x + w + 24.0, y + h - 9.0)), "{c}"); + assert!(c.contains(&format!("{:.2} {:.2} m {:.2} {:.2} l S", x + w - 9.0, y + h + 6.0, x + w - 9.0, y + h + 24.0)), "{c}"); + // Centre crop marks are on the midlines, which a symmetric bleed does not move. + assert!(c.contains(&format!("{:.2} {:.2} m {:.2} {:.2} l S", x + w / 2.0, y - 6.0, x + w / 2.0, y - 24.0)), "{c}"); + // Print One Copy repeats the last print, bleed included. + let again = s.execute("file.printOneCopy", json!({"output": format!("{dir}/again.pdf"), "dryRun": true})).unwrap(); + assert_eq!(again["bleed"], 0.125); + // The image box itself is unchanged: bleed moves marks, not pixels. + let (plain, _) = print_with(&mut s, &dir, "plain", json!({"cornerCropMarks": true})); + assert_eq!(plain["imageRect"], r["imageRect"]); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn bleed_without_corner_crop_marks_changes_nothing() { + let mut s = session("rgb", 8); + let dir = tmp("bleed-nomarks"); + let (_, a) = print_with(&mut s, &dir, "plain", json!({"registrationMarks": true})); + let (r, b) = print_with(&mut s, &dir, "bleed", json!({"registrationMarks": true, "bleed": 0.1})); + assert_eq!(a, b); + assert_eq!(r["bleed"], 0.0); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn bad_bleed_is_an_error_not_a_panic() { + let mut s = session("rgb", 8); + // The test document prints 300 × 150 pt, so 1.05 in (75.6 pt) leaves no trim box. + for bleed in [json!(-0.1), json!("0.1"), json!(true), json!([0.1]), json!(1.05), json!(1e308)] { + let r = s.execute("file.print", json!({"dryRun": true, "send": false, "output": "", "cornerCropMarks": true, "bleed": bleed})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "bleed {bleed}: {r:?}"); + } +} diff --git a/crates/engine/src/print_cmds/windows_marks.rs b/crates/engine/src/print_cmds/windows_marks.rs index 857e6335a9..b78d4234b9 100644 --- a/crates/engine/src/print_cmds/windows_marks.rs +++ b/crates/engine/src/print_cmds/windows_marks.rs @@ -1,5 +1,5 @@ //! App-owned print-mark composition in points from the sheet's top-left. -use super::Marks; +use super::{MARK_GAP, MARK_LEN, Marks, corner_crop_segments}; use craft_print::rgb::{Primitive, Shape}; pub(super) fn primitives(marks: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> Vec { @@ -7,11 +7,10 @@ pub(super) fn primitives(marks: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> Ve let line = |out: &mut Vec, x0, y0, x1, y1| { out.push(Primitive { shape: Shape::Line { from: (x0, y0), to: (x1, y1) }, width: 0.3, color: [0; 3] }); }; - let (gap, len) = (6.0, 18.0); + let (gap, len) = (MARK_GAP, MARK_LEN); if marks.corner_crop { - for (cx, cy, dx, dy) in [(x, y, -1.0, -1.0), (x + w, y, 1.0, -1.0), (x, y + h, -1.0, 1.0), (x + w, y + h, 1.0, 1.0)] { - line(&mut out, cx + dx * gap, cy, cx + dx * (gap + len), cy); - line(&mut out, cx, cy + dy * gap, cx, cy + dy * (gap + len)); + for ((x0, y0), (x1, y1)) in corner_crop_segments(marks.bleed, (x, y, w, h)) { + line(&mut out, x0, y0, x1, y1); } } if marks.center_crop { @@ -37,11 +36,24 @@ mod tests { use super::*; #[test] fn marks_surround_the_final_image_box_in_physical_points() { - let out = primitives(Marks { corner_crop: true, center_crop: true, registration: true }, (100.0, 200.0, 144.0, 72.0)); + let out = primitives(Marks { corner_crop: true, center_crop: true, registration: true, bleed: 0.0 }, (100.0, 200.0, 144.0, 72.0)); assert_eq!(out.len(), 24); assert_eq!(out[0].shape, Shape::Line { from: (94.0, 200.0), to: (76.0, 200.0) }); assert_eq!(out[12].shape, Shape::Ellipse { bounds: (167.0, 180.0, 10.0, 10.0) }); assert!(out.iter().all(|p| p.width == 0.3 && p.color == [0; 3])); assert!(primitives(Marks::default(), (0.0, 0.0, 10.0, 10.0)).is_empty()); } + + #[test] + fn bleed_moves_corner_crop_marks_onto_the_trim_line_in_top_down_coordinates() { + let rect = (100.0, 200.0, 144.0, 72.0); + let out = primitives(Marks { corner_crop: true, bleed: 9.0, ..Marks::default() }, rect); + assert_eq!(out.len(), 8); + // Top-left corner: still outside the image, but on the trim line 9 pt in (y down here). + assert_eq!(out[0].shape, Shape::Line { from: (94.0, 209.0), to: (76.0, 209.0) }); + assert_eq!(out[1].shape, Shape::Line { from: (109.0, 194.0), to: (109.0, 176.0) }); + // Bottom-right corner, mirrored. + assert_eq!(out[6].shape, Shape::Line { from: (250.0, 263.0), to: (268.0, 263.0) }); + assert_eq!(out[7].shape, Shape::Line { from: (235.0, 278.0), to: (235.0, 296.0) }); + } } diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 3f87f6339c..aebd7ca18a 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -921,11 +921,13 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (Print › Functions › Bleed through `file.print`; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -286,7 +286,7 @@ just presence. | Transform, warp, puppet, perspective, liquify | ~88% | No Face-Aware Liquify (needs a landmark model) | | Automation: CLI, MCP, headless server, control channel | ~95% | Ahead of Photoshop here (see below) | | Image modes (RGB/Gray/CMYK/Lab/Indexed/Duotone/Multichannel) | ~85% | Multichannel display-only; no ink-plate model | -| Print / export / Save for Web / slices / Generator | ~85% | No Separations/bleed; droplets basic | +| Print / export / Save for Web / slices / Generator | ~85% | No Separations; Bleed only through `file.print` (no Print dialog field yet); droplets basic | | Computational photo (Photomerge, HDR, Lens, Camera Raw, Wide Angle) | ~70% | Classical implementations; generic lens profiles only | | Data-driven graphics (Variables, Data Sets) | ~0% | Not started (5 menu items) | | Video / Timeline / animation | ~0% | Not started (13 menu items) — a whole subsystem | @@ -565,6 +565,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Print: Functions › Bleed (`file.print` / `file.printOneCopy` `bleed`, corner crop marks on the trim line, PDF and Windows paths); the Print dialog field is still missing; percentages unchanged | | 2026-10-13 | minor | Vector shapes: on-canvas corner radius widgets on live rectangles (TOOL-214-12); estimates unchanged | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the Pen flyout) are live toolbar tools and Frame landed, so Art History Brush is the only tool still missing; percentages unchanged | | 2026-10-12 | minor | Free Transform options bar H/V skew fields; percentages unchanged |