mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 05:28:09 -06:00
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:
+2
-1
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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(_))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user