M10.5: add cut-and-stack page order for single-sided printing (#197)

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Ayan
2026-10-07 21:38:21 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent 9e0fcc1380
commit 2f872f7e7d
10 changed files with 233 additions and 12 deletions
+2 -1
View File
@@ -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).
+7
View File
@@ -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,
+2 -2
View File
@@ -368,7 +368,7 @@ pub fn tools() -> Vec<ToolDef> {
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<ToolDef> {
"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"] },
+57
View File
@@ -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<usize> = 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);
}
+8
View File
@@ -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.
+41 -7
View File
@@ -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<Vec<Sheet>, 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<Vec<Sheet>, 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<Vec<Sheet>, 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);
+64
View File
@@ -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<usize> = (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<usize>> = 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<usize> = 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(_))));
}
}
+9
View File
@@ -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<print::Settings, String> {
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| {
+41
View File
@@ -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);
}
+2 -2
View File
@@ -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"