diff --git a/ROADMAP.md b/ROADMAP.md index c9fdfdf8f..0903614c8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -99,7 +99,7 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 17% | 210–420 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 66% | 35–65 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize. Missing: certificate security, redaction codes and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 55% | 70–125 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL. Done since: macOS Keychain signing, certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, Windows store, PKCS #11 | -| M10 | OCR, create, export, print | 200–350 | 33% | 130–235 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, CUPS spooler, print-ready PDF). Done since: export all images; OCR (searchable image for pages, ranges and multiple files). Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, Windows/web printing | +| M10 | OCR, create, export, print | 200–350 | 33% | 130–235 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, CUPS spooler, print-ready PDF). Done since: export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print. Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, Windows/web printing | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 165–290 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 20% | 160–305 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, measure, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 33–66 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools`, UI control channel with drag. Missing: Action Wizard, AI providers | @@ -127,6 +127,7 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run Newest first. One line per session: the date, what moved, and the new overall percentage. +- **2026-10-08 (community):** Cut and stack in Print > Multiple: single-sided n-up sheets can be cut into cell piles and restacked in selected page order, with aligned gutter marks and blanks kept in their cells. Available through doc_print; duplex is refused. Grid-size overflow returns an error. No change to the parity count or the overall estimate (about 30–35%). - **2026-10-07 (community, #143):** Windows MSI gets a full-UI welcome dialog with a "Create a desktop shortcut" checkbox (on by default; `INSTALLDESKTOPSHORTCUT=0` for unattended installs), progress, files in use, repair/remove, and a "Setup completed successfully" screen with Finish (plus distinct failed/cancelled screens). Start Menu and desktop shortcuts are now plain links to `pdfcraft.exe` instead of advertised MSI shortcuts. Packaging checks the compiled MSI's shortcut, checkbox and dialog tables; the ARM64 smoke test checks both links' targets, their removal on uninstall, and the opt-out. Installer polish only; feature counts unchanged. ≈ 30–35%. - **2026-10-07 (session 16):** PrintCraft is renamed PdfCraft: repo `storytold/pdfcraft`, crates and binaries `pdfcraft*`, bundle id `ai.storyteller.pdfcraft`. On first launch the app moves the old PrintCraft settings and crash-recovery folders to the new name, so upgrades keep recent files, preferences and unsaved work. No feature change. ≈ 30–35%. - **2026-10-07 (community, #163):** Fill & Sign shows saved signature/initials previews with add, change and remove controls in the quick picker and left panel. The editor loads the saved typed text or drawn strokes; Apply replaces future placements and Cancel preserves the saved value. Existing annotations stay unchanged. Long typed names shrink to the placement width and include glyph overhangs in preview/PDF bounds. Regression coverage includes persistence, removal/recreation, long names and the opt-in UI control channel. Parity counts and effort estimate unchanged (50.2% shipped, ≈ 30–35% effort). diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 34fd799b8..a21fff049 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -40,6 +40,7 @@ impl Automation { "horizontal-reversed" => PageOrder::HorizontalReversed, "vertical" => PageOrder::Vertical, "vertical-reversed" => PageOrder::VerticalReversed, + "cut-stack" => PageOrder::CutStack, o => return Err(bad(format!("unknown order {o:?}"))), }; match Layout::multiple(per) { @@ -69,6 +70,12 @@ impl Automation { }, l => return Err(bad(format!("unknown layout {l:?}"))), }; + if matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }) && a.opt_str("duplex")?.unwrap_or("off") != "off" { + return Err(bad("cut-and-stack printing needs duplex off (single-sided sheets)")); + } + if a.opt_str("order")? == Some("cut-stack") && !matches!(layout, Layout::Multiple { .. }) { + return Err(bad("cut-stack order needs layout multiple")); + } let orientation = match a.opt_str("orientation")?.unwrap_or("auto") { "auto" => Orientation::Auto, "portrait" => Orientation::Portrait, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index e6a793595..94670c954 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -368,7 +368,7 @@ pub fn tools() -> Vec { t( "doc_print", "Print", - "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even; reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate), booklet (booklet_subset both/front/back, binding left/right), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid/A3/A4/A5. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale.", + "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even; reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid/A3/A4/A5. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale.", ) .with(schema( json!({ @@ -379,7 +379,7 @@ pub fn tools() -> Vec { "layout": { "type": "string", "enum": ["fit", "actual", "shrink", "custom", "multiple", "booklet", "poster"] }, "scale": { "type": "number", "exclusiveMinimum": 0 }, "per_sheet": { "type": "integer", "minimum": 1, "maximum": 256 }, - "order": { "type": "string", "enum": ["horizontal", "horizontal-reversed", "vertical", "vertical-reversed"] }, + "order": { "type": "string", "enum": ["horizontal", "horizontal-reversed", "vertical", "vertical-reversed", "cut-stack"] }, "border": { "type": "boolean" }, "auto_rotate": { "type": "boolean" }, "booklet_subset": { "type": "string", "enum": ["both", "front", "back"] }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index f92a14dac..018de77e9 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1796,3 +1796,60 @@ fn exporting_to_word_html_and_rtf() { assert!(std::fs::read_to_string(dir.join("a.rtf")).unwrap().contains("Page 2")); assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err()); } + +#[test] +fn cut_stack_printing_through_tools() { + let dir = workdir("cut-stack"); + std::fs::write(dir.join("numbered.pdf"), fixture(10)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "numbered.pdf"}))["doc"].as_u64().unwrap(); + let before = page_text(&mut a, doc); + for reverse in [false, true] { + let r = ok( + &mut a, + "doc_print", + json!({ + "doc": doc, "layout": "multiple", "order": "cut-stack", "per_sheet": 4, + "orientation": "portrait", "auto_rotate": false, "reverse": reverse, "path": "cut.pdf" + }), + ); + assert_eq!(r["sheets"], 3); + let printed = ok(&mut a, "doc_open", json!({"path": "cut.pdf"}))["doc"].as_u64().unwrap(); + let expected = + if reverse { vec![vec![10, 7, 4, 1], vec![9, 6, 3], vec![8, 5, 2]] } else { vec![vec![1, 4, 7, 10], vec![2, 5, 8], vec![3, 6, 9]] }; + for (text, expected) in page_text(&mut a, printed).iter().zip(&expected) { + let actual: Vec = text.split_whitespace().filter_map(|t| t.parse().ok()).collect(); + assert_eq!(&actual, expected, "saved PDF must contain the imposed order: {text}"); + } + ok(&mut a, "doc_close", json!({"doc": printed})); + } + let r = ok( + &mut a, + "doc_print", + json!({ + "doc": doc, "pages": "2-10", "subset": "odd", "reverse": true, + "layout": "multiple", "order": "cut-stack", "per_sheet": 2, "auto_rotate": false, + "orientation": "landscape", "path": "range.pdf" + }), + ); + assert_eq!(r["pages"], 5); + let printed = ok(&mut a, "doc_open", json!({"path": "range.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(page_text(&mut a, printed), ["Page 10\nPage 4", "Page 8\nPage 2", "Page 6"]); + assert_eq!(page_text(&mut a, doc), before); + assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["document"]["dirty"], false); + for duplex in ["long-edge", "short-edge"] { + assert!(matches!( + a.call( + "doc_print", + &json!({ + "doc": doc, "layout": "multiple", "order": "cut-stack", "duplex": duplex, "path": "refused.pdf" + }) + ), + Err(ToolError::InvalidArgs(_)) + )); + } + assert!(matches!(a.call("doc_print", &json!({"doc": doc, "order": "cut-stack", "path": "refused.pdf"})), Err(ToolError::InvalidArgs(_)))); + assert!(!dir.join("refused.pdf").exists()); + assert!(a.call("doc_print", &json!({"doc": doc, "layout": "multiple", "order": "cut-stack", "path": "../escaped.pdf"})).is_err()); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/print/README.md b/crates/print/README.md index 074e05a5a..62ac388a8 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -14,6 +14,14 @@ spool::submit(&pdf, &spool::Job { printer: None, copies: 2, ..Default::default() centred; auto orientation turns the sheet for landscape pages. - **Multiple**: 2/4/6/9/16 (or any n) pages per sheet, horizontal/vertical (reversed) order, page borders, auto-rotation of pages that don't match the cell. +- **Cut and stack** (Multiple's page order): consecutive pages in each cell's pile, + with aligned cut marks in the gutters. For example, 10 pages at 4 per sheet give + `[1, 4, 7, 10]`, `[2, 5, 8, blank]`, `[3, 6, 9, blank]`. Print single-sided, + keep sheets in output order, cut at the marks, then stack the cell piles from + left to right, top to bottom. Blank cells can be discarded. Page ranges and + Reverse pages are applied before imposition. Duplex imposition is not supported. + Headless: `doc_print` with `layout: "multiple"`, `order: "cut-stack"` and + `per_sheet: 4` (or any supported grid size), plus `path` or `printer`. - **Booklet**: saddle-stitch imposition padded to a multiple of 4; both sides, front or back only; left or right binding. - **Poster**: tile scale, overlap shared by neighbouring tiles, cut marks. diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 31edd8744..25b6873e4 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -64,6 +64,8 @@ pub enum PageOrder { HorizontalReversed, Vertical, VerticalReversed, + /// Single-sided sheets: cut into cell piles, then stack them in row-major order. + CutStack, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] @@ -247,11 +249,12 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P } } Layout::Multiple { cols, rows, order, border, auto_rotate } => { - if cols == 0 || rows == 0 || cols * rows > 256 { + let cells = cols.checked_mul(rows).filter(|&n| n > 0 && n <= 256); + let Some(cells) = cells else { return Err(PrintError::Invalid("invalid number of pages per sheet".into())); - } + }; // A grid with more columns than rows wants a landscape sheet, and the reverse. - let first = sizes[pages[0]]; + let first = pages.first().and_then(|&p| sizes.get(p)).copied().ok_or(PrintError::NoPages)?; let page_landscape = first.0 > first.1; let (gc, gr, sheet_landscape) = if cols == rows { (cols, rows, page_landscape) @@ -265,11 +268,36 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P let gap = 6.0; let (aw, ah) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); let (cw, ch) = ((aw - gap * (gc - 1) as f64) / gc as f64, (ah - gap * (gr - 1) as f64) / gr as f64); - for chunk in pages.chunks(gc * gr) { + if cw <= 0.0 || ch <= 0.0 { + return Err(PrintError::Invalid("the page grid does not fit on the paper".into())); + } + let sheet_count = pages.len().div_ceil(cells); + for sheet_index in 0..sheet_count { let mut sheet = Sheet { size, ..Sheet::default() }; - for (k, &p) in chunk.iter().enumerate() { + if order == PageOrder::CutStack { + // Marks align across every sheet, including unoccupied cells. Cut in the + // middle of each gutter; marks stay in the printable area, clear of cells. + for col in 1..gc { + let x = MARGIN + col as f64 * (cw + gap) - gap / 2.0; + sheet.lines.extend([[x, MARGIN, x, MARGIN + 8.0], [x, size.1 - MARGIN - 8.0, x, size.1 - MARGIN]]); + } + for row in 1..gr { + let y = size.1 - MARGIN - row as f64 * (ch + gap) + gap / 2.0; + sheet.lines.extend([[MARGIN, y, MARGIN + 8.0, y], [size.0 - MARGIN - 8.0, y, size.0 - MARGIN, y]]); + } + } + for k in 0..cells { + // A cell's pile gets consecutive pages. Missing pages leave blank cells, + // rather than shifting later piles left on the final sheets. + let index = if order == PageOrder::CutStack { + k.checked_mul(sheet_count).and_then(|n| n.checked_add(sheet_index)) + } else { + sheet_index.checked_mul(cells).and_then(|n| n.checked_add(k)) + }; + let Some(p) = index.and_then(|i| pages.get(i)).copied() else { continue }; + let (col, row) = match order { - PageOrder::Horizontal => (k % gc, k / gc), + PageOrder::Horizontal | PageOrder::CutStack => (k % gc, k / gc), PageOrder::HorizontalReversed => (gc - 1 - k % gc, k / gc), PageOrder::Vertical => (k / gr, k % gr), PageOrder::VerticalReversed => (gc - 1 - k / gr, k % gr), @@ -277,9 +305,15 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P let x0 = MARGIN + col as f64 * (cw + gap); let y1 = size.1 - MARGIN - row as f64 * (ch + gap); let cell = [x0, y1 - ch, x0 + cw, y1]; - let d = sizes[p]; + let d = sizes.get(p).copied().ok_or_else(|| PrintError::Invalid("page size is missing".into()))?; + if !(d.0.is_finite() && d.1.is_finite() && d.0 > 0.0 && d.1 > 0.0) { + return Err(PrintError::Invalid("invalid page size".into())); + } let rotate = auto_rotate && (d.0 > d.1) != (cw > ch) && (d.0 - d.1).abs() > 1.0; let s = fit_scale(if rotate { (d.1, d.0) } else { d }, (cw, ch)); + if !s.is_finite() || s <= 0.0 { + return Err(PrintError::Invalid("page size cannot be scaled to the grid".into())); + } let pl = place_in(p, d, cell, s, rotate); if border { let b = pl.matrix.bbox(pl.clip); diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index ee4a4a6fb..9e14fe1c2 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -204,3 +204,67 @@ fn lpstat_output_is_untranslated() { } assert!(envs.iter().any(|(k, v)| k == "SOFTWARE" && v.as_deref().is_some_and(|v| !v.is_empty())), "SOFTWARE missing: {envs:?}"); } + +fn cut_stack(cols: usize, rows: usize) -> Layout { + Layout::Multiple { cols, rows, order: PageOrder::CutStack, border: false, auto_rotate: false } +} + +#[test] +fn cut_stack_keeps_piles_in_order_and_blanks_in_place() { + let sizes = vec![(200.0, 300.0); 10]; + let sheets = layout(&sizes, &settings((0..10).collect(), cut_stack(2, 2))).unwrap(); + assert_eq!(sheet_pages(&sheets), [vec![0, 3, 6, 9], vec![1, 4, 7], vec![2, 5, 8]]); + for s in &sheets { + assert_eq!(s.lines, sheets[0].lines, "identical cuts even with blank cells"); + assert_eq!(s.lines.len(), 4); + for (pl, first) in s.placed.iter().zip(&sheets[0].placed) { + assert_eq!(pl.matrix, first.matrix, "every pile stays in its cell"); + } + } + assert!(sheets[0].placed[1].matrix.0[4] > sheets[0].placed[0].matrix.0[4]); + assert!(sheets[0].placed[2].matrix.0[5] < sheets[0].placed[0].matrix.0[5]); + // Selection and reverse order are preserved, including repeated source pages. + let selected = vec![9, 5, 5, 2, 0]; + let sheets = layout(&sizes, &settings(selected, cut_stack(2, 2))).unwrap(); + assert_eq!(sheet_pages(&sheets), [vec![9, 5, 0], vec![5, 2]]); + assert_eq!(sheets[1].placed[1].matrix, sheets[0].placed[1].matrix); +} + +#[test] +fn cutting_and_stacking_recovers_every_selected_page() { + // Simulate the actual operation using cell positions, not the imposition formula. + for (cols, rows) in [(1, 1), (1, 2), (2, 2), (2, 3), (3, 3), (4, 4)] { + for orientation in [Orientation::Auto, Orientation::Portrait, Orientation::Landscape] { + for n in 1..=37 { + let sizes = vec![(200.0, 300.0); n]; + let selected: Vec = (0..n).rev().collect(); + let s = Settings { orientation, ..settings(selected.clone(), cut_stack(cols, rows)) }; + let sheets = layout(&sizes, &s).unwrap(); + let mut piles: std::collections::BTreeMap<(i64, i64), Vec> = Default::default(); + for sheet in &sheets { + for pl in &sheet.placed { + // Equal source sizes, so the page origins identify row/column. + let [_, _, _, _, x, y] = pl.matrix.0; + piles.entry((-(y * 100.0).round() as i64, (x * 100.0).round() as i64)).or_default().push(pl.page); + } + } + let restacked: Vec = piles.into_values().flatten().collect(); + assert_eq!(restacked, selected, "{cols}x{rows}, {n} pages, {orientation:?}"); + } + } + } +} + +#[test] +fn multiple_rejects_hostile_grids_without_panicking() { + let sizes = [(200.0, 300.0)]; + for (cols, rows) in [(usize::MAX, 2), (2, usize::MAX), (0, 2), (1, 0), (257, 1)] { + let mode = Layout::Multiple { cols, rows, order: PageOrder::Horizontal, border: false, auto_rotate: false }; + assert!(matches!(layout(&sizes, &settings(vec![0], mode)), Err(PrintError::Invalid(_)))); + } + let tiny = Settings { paper: (72.0, 72.0), ..settings(vec![0], cut_stack(16, 16)) }; + assert!(matches!(layout(&sizes, &tiny), Err(PrintError::Invalid(_)))); + for size in [(0.0, 300.0), (200.0, f64::NAN), (f64::INFINITY, 300.0), (f64::from_bits(1), f64::from_bits(1))] { + assert!(matches!(layout(&[size], &settings(vec![0], cut_stack(2, 2))), Err(PrintError::Invalid(_)))); + } +} diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index abea25716..30ea45860 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -94,6 +94,9 @@ impl Default for PrintDraft { impl PrintDraft { /// The engine settings for this draft (page count and labels from the document). pub fn settings(&self, count: usize, labels: &[String]) -> Result { + if self.handling == Handling::Multiple && self.order == PageOrder::CutStack && self.duplex != spool::Duplex::Off { + return Err("Cut and stack needs Two-sided: Off. Print single-sided sheets.".into()); + } let range = match self.which { Which::All => None, Which::Current => Some((self.current_page + 1).to_string()), @@ -338,6 +341,7 @@ pub(crate) fn body( (PageOrder::HorizontalReversed, "Horizontal reversed"), (PageOrder::Vertical, "Vertical"), (PageOrder::VerticalReversed, "Vertical reversed"), + (PageOrder::CutStack, "Cut and stack"), ], 150.0, ); @@ -346,6 +350,11 @@ pub(crate) fn body( ui.checkbox(&mut d.border, "Print page border"); ui.checkbox(&mut d.auto_rotate, "Auto-rotate pages"); }); + if d.order == PageOrder::CutStack { + ui.label( + "Print single-sided. Keep the sheets in order, cut at the marks, then stack the piles left to right, top to bottom.", + ); + } } Handling::Booklet => { ui.horizontal(|ui| { diff --git a/crates/ui-egui/tests/print.rs b/crates/ui-egui/tests/print.rs index 31d881027..b2f07acba 100644 --- a/crates/ui-egui/tests/print.rs +++ b/crates/ui-egui/tests/print.rs @@ -50,3 +50,44 @@ fn invalid_ranges_are_explained_in_the_preview() { h.run_steps(2); h.get_by_label_contains("out of range"); } + +#[test] +fn cut_stack_dialog_previews_saves_and_refuses_duplex() { + use pdfcraft_engine::print::{PageOrder, spool::Duplex}; + let mut h = harness(); + // Ten source pages, generated using the headless engine. + let bytes = h.state().session.create_blank(200.0, 300.0, 10).unwrap(); + h.state_mut().open_bytes("numbered.pdf", None, bytes.as_ref().clone()).unwrap(); + h.state_mut().execute("print.dialog"); + h.run_steps(3); + h.get_by_label("Multiple").click(); + h.run_steps(2); + // Pick the new order through the actual widgets. + h.get_by_value("Horizontal").click(); + h.run_steps(2); + h.get_by_label("Cut and stack").click(); + h.run_steps(2); + assert_eq!(h.state().print_draft.order, PageOrder::CutStack); + h.state_mut().print_draft.per_sheet = 4; + h.run_steps(2); + h.get_by_label("Sheet 1 of 3"); + h.get_by_label_contains("Keep the sheets in order"); + h.get_by_label("›").click(); + h.run_steps(2); + h.get_by_label("Sheet 2 of 3"); + h.state_mut().print_draft.duplex = Duplex::LongEdge; + h.run_steps(2); + h.get_by_label_contains("Cut and stack needs Two-sided: Off"); + assert!(!h.state_mut().print_now()); + h.state_mut().print_draft.duplex = Duplex::Off; + h.state_mut().print_draft.printer = None; + let out = std::env::temp_dir().join(format!("pdfcraft-cut-stack-ui-{}.pdf", std::process::id())); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.run_steps(2); + h.get_by_label("Save as PDF").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + let printed = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(&out).unwrap())).unwrap(); + assert_eq!(pdfcraft_model::pages(&printed).len(), 3); + let _ = std::fs::remove_file(out); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cef5d3ec9..435b53751 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7902,8 +7902,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::multiple_pages_per_sheet", "crates/automation/tests/automation.rs::printing_through_tools"] -notes = "2/4/6/9/16, four orders, borders, auto-rotate" +evidence = ["crates/print/src/tests.rs::multiple_pages_per_sheet", "crates/automation/tests/automation.rs::printing_through_tools", "crates/print/src/tests.rs::cut_stack_keeps_piles_in_order_and_blanks_in_place", "crates/print/src/tests.rs::cutting_and_stacking_recovers_every_selected_page", "crates/automation/tests/automation.rs::cut_stack_printing_through_tools", "crates/ui-egui/tests/print.rs::cut_stack_dialog_previews_saves_and_refuses_duplex"] +notes = "2/4/6/9/16, four sequential orders, borders, auto-rotate; also single-sided cut-and-stack order with aligned gutter cut marks, blank padding and range/reverse support (an extension, not an Acrobat parity claim). Duplex cut-and-stack is refused." [[feature]] id = "print.booklet"