winprint: draw crop and registration marks on GDI prints

Printer marks (corner/centre crop, registration) were silently dropped on
Windows GDI prints -- only the PDF export path drew them, as content-stream
vector operators. A workshop test print surfaced the gap.

Adds a Marks type to photocraft-winprint and draws the same geometry GDI's
way (MoveToEx/LineTo for lines, Ellipse for registration circles) once per
page between StretchDIBits and EndPage. The JSON field marksSkipped becomes
textSkipped: description and label text are still PDF-only (no font layout
in the GDI path yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
moiztermindandClaude Sonnet 5 committed 2026-10-11 23:19:28 +01:00
1 parent ce8d8d950b
commit f9b3c6bd85
4 files changed
+109 -12

No files matched your search

+7 -4
View File
@@ -444,17 +444,20 @@ pub fn page_rect_top_left(page: &PrintPage) -> (f64, f64, f64, f64) {
(x, ph - y - h, w, h)
}
/// Prints `page` on a Windows printer. Printer marks, description and labels are PDF-only.
/// Prints `page` on a Windows printer. Corner/centre crop and registration marks are drawn with
/// GDI vector calls, matching the PDF path's geometry; description and label text are still
/// PDF-only (no font/text layout in the GDI path yet).
fn print_windows(page: &PrintPage, p: &Value, title: &str, copies: i64) -> Result<Value> {
let printer = printer_name(p)?;
let settings = printer_settings(p)?;
let rgb = page_rgb(page)?;
let image = photocraft_winprint::RgbImage { width: page.image.width, height: page.image.height, data: &rgb };
let job = photocraft_winprint::Job { printer: &printer, settings: settings.as_ref(), title, output_file: None, copies: copies.clamp(1, 999) as u32 };
let marks = photocraft_winprint::Marks { corner_crop: page.marks.corner_crop, center_crop: page.marks.center_crop, registration: page.marks.registration };
let job = photocraft_winprint::Job { printer: &printer, settings: settings.as_ref(), title, output_file: None, copies: copies.clamp(1, 999) as u32, marks };
let done = photocraft_winprint::print(&job, &image, page_rect_top_left(page)).map_err(other)?;
let marks = page.marks.corner_crop || page.marks.center_crop || page.marks.registration || page.description.is_some() || page.label.is_some();
let text_skipped = page.description.is_some() || page.label.is_some();
Ok(
json!({"printer": printer, "dpi": [done.dpi.0, done.dpi.1], "deviceRect": [done.device_rect.0, done.device_rect.1, done.device_rect.2, done.device_rect.3], "marksSkipped": marks}),
json!({"printer": printer, "dpi": [done.dpi.0, done.dpi.1], "deviceRect": [done.device_rect.0, done.device_rect.1, done.device_rect.2, done.device_rect.3], "textSkipped": text_skipped}),
)
}
+6 -2
View File
@@ -923,8 +923,12 @@ fn print_confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Valu
}
let r = app.run("file.print", p)?;
app.ui.status = if let Some(printer) = r["spooler"]["printer"].as_str() {
let skipped = if r["spooler"]["marksSkipped"] == json!(true) { " (printer marks and labels are only in Save as PDF)" } else { "" };
format!("Sent to {printer}{skipped}")
let template = if r["spooler"]["textSkipped"] == json!(true) {
"Sent to {printer} (description and label text are only in Save as PDF)"
} else {
"Sent to {printer}"
};
crate::i18n::fmt(tl!(template), &[("printer", printer)])
} else if r["sent"] == json!(true) {
let template = "Sent to the printer ({spooler})";
crate::i18n::fmt(tl!(template), &[("spooler", r["spooler"].as_str().unwrap_or(""))])
+22 -1
View File
@@ -2,6 +2,9 @@
//! dialog (paper, paper type, quality, the driver's colour correction and mirror), the page
//! geometry it implies, and printing an 8-bit RGB image through GDI with ICM off, so colours
//! converted with a printer profile ("PhotoCraft Manages Colors") reach the driver unchanged.
//! Corner/centre crop marks and registration marks are drawn around the image with GDI vector
//! calls (`MoveToEx`/`LineTo`/`Ellipse`), the same geometry as the PDF path's content stream.
//! Description and label text stay PDF-only (no font/text layout here yet).
//!
//! The driver settings travel as an opaque DEVMODE blob ([`DevMode`]), validated before use.
//! Everything here is a safe API; the Win32 calls live in `windows.rs`. Other platforms get
@@ -107,6 +110,23 @@ pub struct RgbImage<'a> {
pub data: &'a [u8],
}
/// Printer marks drawn around the image box: corner and centre crop marks, and registration
/// marks. Matches [`photocraft_engine`]'s PDF `Marks`, mirrored here so this crate stays
/// independent of the engine; GDI draws the same geometry with vector line/ellipse calls instead
/// of PDF content stream operators.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Marks {
pub corner_crop: bool,
pub center_crop: bool,
pub registration: bool,
}
impl Marks {
pub(crate) fn any(self) -> bool {
self.corner_crop || self.center_crop || self.registration
}
}
/// One print job.
pub struct Job<'a> {
pub printer: &'a str,
@@ -116,6 +136,7 @@ pub struct Job<'a> {
/// Print to this file instead of the device (drivers that write files, and tests).
pub output_file: Option<&'a str>,
pub copies: u32,
pub marks: Marks,
}
/// Where a job went: the image box in device pixels and the device resolution.
@@ -254,7 +275,7 @@ mod tests {
#[test]
fn print_rejects_empty_or_short_images_before_any_system_call() {
let job = Job { printer: "x", settings: None, title: "t", output_file: None, copies: 1 };
let job = Job { printer: "x", settings: None, title: "t", output_file: None, copies: 1, marks: Marks::default() };
let short = RgbImage { width: 2, height: 2, data: &[0; 11] };
assert!(matches!(print(&job, &short, (0.0, 0.0, 10.0, 10.0)), Err(PrintError::Invalid(_))));
let ok = RgbImage { width: 2, height: 2, data: &[0; 12] };
+74 -5
View File
@@ -6,9 +6,9 @@ use std::ptr::{null, null_mut};
use windows_sys::Win32::Foundation::HWND;
use windows_sys::Win32::Graphics::Gdi::{
BI_RGB, BITMAPINFO, BITMAPINFOHEADER, CreateDCW, DEVMODEW, DIB_RGB_COLORS, DM_IN_BUFFER, DM_IN_PROMPT, DM_OUT_BUFFER, DeleteDC, GetDeviceCaps, HALFTONE,
HDC, HORZRES, LOGPIXELSX, LOGPIXELSY, PHYSICALHEIGHT, PHYSICALOFFSETX, PHYSICALOFFSETY, PHYSICALWIDTH, SRCCOPY, SetBrushOrgEx, SetStretchBltMode,
StretchDIBits, VERTRES,
BI_RGB, BITMAPINFO, BITMAPINFOHEADER, CreateDCW, CreatePen, DEVMODEW, DIB_RGB_COLORS, DM_IN_BUFFER, DM_IN_PROMPT, DM_OUT_BUFFER, DeleteDC, DeleteObject,
Ellipse, GetDeviceCaps, GetStockObject, HALFTONE, HDC, HOLLOW_BRUSH, HORZRES, LOGPIXELSX, LOGPIXELSY, LineTo, MoveToEx, PHYSICALHEIGHT, PHYSICALOFFSETX,
PHYSICALOFFSETY, PHYSICALWIDTH, PS_SOLID, SRCCOPY, SelectObject, SetBrushOrgEx, SetStretchBltMode, StretchDIBits, VERTRES,
};
use windows_sys::Win32::Graphics::Printing::{
ClosePrinter, DocumentPropertiesW, EnumPrintersW, GetDefaultPrinterW, OpenPrinterW, PRINTER_ENUM_CONNECTIONS, PRINTER_ENUM_LOCAL, PRINTER_HANDLE,
@@ -17,7 +17,7 @@ use windows_sys::Win32::Graphics::Printing::{
use windows_sys::Win32::Storage::Xps::{AbortDoc, DOCINFOW, EndDoc, EndPage, StartDocW, StartPage};
use windows_sys::Win32::UI::ColorSystem::{ICM_OFF, SetICMMode};
use crate::{DevMode, Job, Page, PrintError, Printed, Printer, Result, RgbImage};
use crate::{DevMode, Job, Marks, Page, PrintError, Printed, Printer, Result, RgbImage};
const IDOK: i32 = 1;
const IDCANCEL: i32 = 2;
@@ -264,6 +264,61 @@ fn dib(image: &RgbImage<'_>) -> Result<(BITMAPINFO, Vec<u8>)> {
Ok((BITMAPINFO { bmiHeader: header, bmiColors: [Default::default()] }, bits))
}
/// Vector marks around the image box `(x, y, w, h)` (device pixels, origin top-left, as GDI
/// draws). Same geometry as `photocraft_engine::print_cmds::marks_ops`'s PDF content stream, with
/// the vertical sense flipped (PDF's origin is bottom-left; GDI's is top-left), scaled from points
/// to device pixels with `dpi` instead of PDF's user-space points.
///
/// # Safety
/// `dc` is a valid, selected device context for the current page.
unsafe fn draw_marks(dc: HDC, marks: Marks, (x, y, w, h): (i32, i32, i32, i32), dpi: (u32, u32)) -> Result<()> {
let (sx, sy) = (f64::from(dpi.0) / 72.0, f64::from(dpi.1) / 72.0);
let px = |v: f64| v.round() as i32;
let (gap_x, gap_y) = (px(6.0 * sx), px(6.0 * sy));
let (len_x, len_y) = (px(18.0 * sx), px(18.0 * sy));
let pen_width = px(0.3 * sx).max(1);
// SAFETY: `dc` is valid; the pen and brush are deleted/restored before returning.
let pen = unsafe { CreatePen(PS_SOLID, pen_width, 0x0000_0000) };
let hollow = unsafe { GetStockObject(HOLLOW_BRUSH) };
let old_pen = unsafe { SelectObject(dc, pen) };
let old_brush = unsafe { SelectObject(dc, hollow) };
let ok = std::cell::Cell::new(true);
let line = |x0: i32, y0: i32, x1: i32, y1: i32| {
// SAFETY: `dc` is valid for the duration of this call.
ok.set(ok.get() && unsafe { MoveToEx(dc, x0, y0, null_mut()) != 0 && LineTo(dc, x1, y1) != 0 });
};
if marks.corner_crop {
for (cx, cy, dx, dy) in [(x, y, -1, -1), (x + w, y, 1, -1), (x, y + h, -1, 1), (x + w, y + h, 1, 1)] {
line(cx + dx * gap_x, cy, cx + dx * (gap_x + len_x), cy);
line(cx, cy + dy * gap_y, cx, cy + dy * (gap_y + len_y));
}
}
if marks.center_crop {
let (mx, my) = (x + w / 2, y + h / 2);
line(mx, y - gap_y, mx, y - gap_y - len_y);
line(mx, y + h + gap_y, mx, y + h + gap_y + len_y);
line(x - gap_x, my, x - gap_x - len_x, my);
line(x + w + gap_x, my, x + w + gap_x + len_x, my);
}
if marks.registration {
let (rx, ry) = (px(5.0 * sx), px(5.0 * sy));
let (off_x, off_y) = (gap_x + len_x / 2, gap_y + len_y / 2);
for (cx, cy) in [(x + w / 2, y - off_y), (x + w / 2, y + h + off_y), (x - off_x, y + h / 2), (x + w + off_x, y + h / 2)] {
// SAFETY: `dc` is valid for the duration of this call.
ok.set(ok.get() && unsafe { Ellipse(dc, cx - rx, cy - ry, cx + rx, cy + ry) != 0 });
line(cx - (rx as f64 * 1.6) as i32, cy, cx + (rx as f64 * 1.6) as i32, cy);
line(cx, cy - (ry as f64 * 1.6) as i32, cx, cy + (ry as f64 * 1.6) as i32);
}
}
// SAFETY: restores the DC's previous pen/brush, then frees the ones created above.
unsafe {
SelectObject(dc, old_pen);
SelectObject(dc, old_brush);
DeleteObject(pen);
}
if ok.get() { Ok(()) } else { Err(last_error("drawing printer marks")) }
}
pub fn print(job: &Job<'_>, image: &RgbImage<'_>, rect: (f64, f64, f64, f64)) -> Result<Printed> {
if image.width > i32::MAX as u32 || image.height > i32::MAX as u32 {
return Err(PrintError::Invalid("image too large".into()));
@@ -311,6 +366,12 @@ pub fn print(job: &Job<'_>, image: &RgbImage<'_>, rect: (f64, f64, f64, f64)) ->
if lines <= 0 {
return Err(fail("sending the image to the printer"));
}
if job.marks.any() {
// SAFETY: `dc.0` is the valid, started page's device context.
if let Err(e) = unsafe { draw_marks(dc.0, job.marks, (x, y, w, h), page.dpi) } {
return Err(fail(&e.to_string()));
}
}
// SAFETY: closes the page started above.
if unsafe { EndPage(dc.0) } <= 0 {
return Err(fail("finishing a page"));
@@ -385,12 +446,20 @@ mod tests {
let (w, h) = (300u32, 200u32);
let data: Vec<u8> = (0..w * h).flat_map(|i| if (i % w) < w / 2 { [220, 30, 30] } else { [30, 30, 220] }).collect();
let out_s = out.to_string_lossy().into_owned();
let job = Job { printer: "Microsoft Print to PDF", settings: Some(&settings), title: "PhotoCraft test", output_file: Some(&out_s), copies: 2 };
let marks = crate::Marks { corner_crop: true, center_crop: true, registration: true };
let job = Job { printer: "Microsoft Print to PDF", settings: Some(&settings), title: "PhotoCraft test", output_file: Some(&out_s), copies: 2, marks };
let done = crate::print(&job, &RgbImage { width: w, height: h, data: &data }, (72.0, 72.0, 216.0, 144.0)).unwrap();
assert_eq!(done.device_rect.2, done.dpi.0 as i32 * 3, "3 inches wide");
let pdf = std::fs::read(&out).unwrap();
assert!(pdf.starts_with(b"%PDF"), "the driver wrote a PDF");
assert!(pdf.len() > 1000, "the page has content");
// The driver vectorises our GDI line/ellipse calls as real PDF content: a job with every
// mark on is meaningfully bigger than the same image with marks off.
let no_marks_job = Job { marks: crate::Marks::default(), ..job };
let done2 = crate::print(&no_marks_job, &RgbImage { width: w, height: h, data: &data }, (72.0, 72.0, 216.0, 144.0)).unwrap();
let _ = done2;
let pdf_no_marks = std::fs::read(&out).unwrap();
assert!(pdf.len() > pdf_no_marks.len() + 100, "marks add vector content: {} vs {}", pdf.len(), pdf_no_marks.len());
let _ = std::fs::remove_file(&out);
}