From b1236c469b0915852edddadbc52e421d533ad413 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 12:09:36 -0700 Subject: [PATCH 1/8] Print: Copies per Sheet prints as many copies of each artboard as fit the sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Print printed each artboard on a sheet of its own. A 2 × 3.5 in card on Letter printed once in the middle of the page, and Print had no option to fill the sheet with copies. The Print dialog's General section has Copies per Sheet (off by default) and a Gutter. With it on, a sheet holds as many copies of the artboard as fit the imageable area, each with its bleed and marks, across then down, gutter pt apart. The card prints 4 × 3 on Letter, and 3 × 3 with an 18 pt gutter. The copies are not turned, and the placement places the whole grid as it places one copy. An artboard that does not fit prints one copy per sheet with a warning, and more than 1,000 copies on a sheet is an error. Tiling leaves the option out. The PDF and PostScript writers draw the art and the marks once per copy, the PDF TrimBox holds every copy, the preview draws each copy, and print.preview lists them as `copies`. With the option off, the layout lists no copies and the writers draw the page as before. Docs: docs/mcp.md (Print, Printing), docs/target-app-parity.md. --- crates/engine/src/cmd/print.rs | 7 +- crates/engine/src/tests/print.rs | 36 +++++++++ crates/eps/src/print.rs | 49 +++++++++---- crates/eps/src/print/tests.rs | 27 +++++++ crates/pdf/src/export/entry.rs | 1 + crates/pdf/src/export/writer.rs | 77 ++++++++++++-------- crates/pdf/src/print.rs | 22 +++++- crates/pdf/src/print/layout.rs | 60 ++++++++++++--- crates/pdf/src/print/settings.rs | 8 ++ crates/pdf/src/tests/print.rs | 89 +++++++++++++++++++++++ crates/ui-egui/src/dialogs/print.rs | 35 ++++++--- crates/ui-egui/src/dialogs/tests_print.rs | 20 +++++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + docs/mcp.md | 15 +++- docs/target-app-parity.md | 1 + 33 files changed, 403 insertions(+), 74 deletions(-) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index f85c7b2c2..44de123fa 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Print Setup", [], None, - "{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)", + "{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)", has_doc, setup ), @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Print Preview", [], None, - "{preset?: name (as print.setup's: its settings instead of the document's), settings?: {…settings of print.setup} (over the document's or the preset's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, type that doesn't fit its frame, options not applied yet…), settings} without printing", + "{preset?: name (as print.setup's: its settings instead of the document's), settings?: {…settings of print.setup} (over the document's or the preset's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page; with copies per sheet, the box around every copy's), copies?: [[x0, y0, x1, y1]…] (copies per sheet: each copy's artboard on the page, across then down)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, type that doesn't fit its frame, options not applied yet…), settings} without printing", has_doc, preview ), @@ -41,7 +41,7 @@ pub fn specs() -> Vec { "Print", [], Some("Cmd+P"), - "{preset?: name (as print.setup's: its settings instead of the document's; the saved settings stay), settings?: {…settings of print.setup} (over the document's or the preset's), includeArtboardFill?: true (each artboard's background color prints behind its art; false leaves it out), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out), forPrinter?: bool (the PDF goes to a print queue: a page whose paper is turned to landscape is written upright, the art turned a quarter anticlockwise, as a printer driver turns a landscape job; macOS prints a landscape page as it is, cut off on upright paper; paper wide in Portrait and transverse pages stay as they are)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device", + "{preset?: name (as print.setup's: its settings instead of the document's; the saved settings stay), settings?: {…settings of print.setup} (over the document's or the preset's), includeArtboardFill?: true (each artboard's background color prints behind its art; false leaves it out), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out), forPrinter?: bool (the PDF goes to a print queue: a page whose paper is turned to landscape is written upright, the art turned a quarter anticlockwise, as a printer driver turns a landscape job; macOS prints a landscape page as it is, cut off on upright paper; paper wide in Portrait and transverse pages stay as they are)} print the document, one page per sheet of paper (per tile, per ink, per copy; with copiesPerSheet, a sheet holds as many copies of an artboard as fit) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device", has_doc, print ), @@ -228,6 +228,7 @@ fn print(s: &mut Session, p: &Value) -> Result { view: sh.view, window: sh.window, place: sh.place, + copies: &sh.copies, area: sh.area, marks: sh.marks.as_ref(), negative: sh.negative, diff --git a/crates/engine/src/tests/print.rs b/crates/engine/src/tests/print.rs index f685e76af..a0beb2662 100644 --- a/crates/engine/src/tests/print.rs +++ b/crates/engine/src/tests/print.rs @@ -144,6 +144,41 @@ fn preview_lists_pages_tiles_inks_and_warnings() { assert_eq!(v["sheets"][3]["tile"], 4); } +#[test] +fn copies_per_sheet_print_a_grid_of_the_artboard() { + // A 2 × 3.5 in card filled by a rectangle. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 144, "height": 252})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 144, "height": 252})).unwrap(); + let v = s.execute("print.setup", &json!({})).unwrap(); + assert_eq!((v["settings"]["copiesPerSheet"].clone(), v["settings"]["gutter"].clone()), (json!(false), json!(0.0))); + // With an 18 pt gutter, Letter holds 3 × 3 copies; the first, at the top left, spans x 72 to 216. + let set = json!({"settings": {"copiesPerSheet": true, "gutter": 18}}); + let v = s.execute("print.preview", &set).unwrap(); + let copies = v["sheets"][0]["copies"].as_array().unwrap(); + assert_eq!((v["pages"].clone(), copies.len(), copies[0].clone()), (json!(1), 9, json!([72.0, 0.0, 216.0, 252.0]))); + // The PDF draws the card's paths nine times. + let cards = |bytes: &[u8]| { + let mut n = 0; + vectorcraft_pdf::import(bytes).unwrap().walk(|node| { + n += usize::from(node.path_data().and_then(|p| p.bounds()).is_some_and(|b| (b.width().round(), b.height().round()) == (144.0, 252.0))) + }); + n + }; + let (bytes, v) = print(&mut s, set); + assert_eq!(v["pages"], 1); + let one = cards(&print(&mut s, json!({})).0); + assert!(one > 0 && cards(&bytes) == 9 * one, "{one}"); + // PostScript places the art once per copy. + let mut ps = |p: Value| { + let v = s.execute("file.print", &p).unwrap(); + String::from_utf8(vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap() + }; + let one = ps(json!({"format": "postscript"})).matches(" cm\n").count(); + let grid = ps(json!({"format": "postscript", "settings": {"copiesPerSheet": true, "gutter": 18}})).matches(" cm\n").count(); + assert_eq!(grid, one + 8); +} + #[test] fn bad_settings_are_bad_params() { let (mut s, _) = session(); @@ -154,6 +189,7 @@ fn bad_settings_are_bad_params() { json!({"settings": {"media": "quarto"}}), json!({"settings": {"artboards": "range", "range": "2"}}), json!({"settings": {"scaling": "tileFull", "overlap": 500}}), + json!({"settings": {"gutter": 800}}), ] { for c in ["file.print", "print.preview"] { assert!(matches!(s.execute(c, &p), Err(EngineError::BadParams { .. })), "{c} {p}"); diff --git a/crates/eps/src/print.rs b/crates/eps/src/print.rs index f9df227dc..99dffd822 100644 --- a/crates/eps/src/print.rs +++ b/crates/eps/src/print.rs @@ -4,7 +4,7 @@ use std::fmt::Write; -use kurbo::Shape; +use kurbo::{Shape, Vec2}; use vectorcraft_doc::{Document, Node}; use vectorcraft_geom::{Affine, Rect}; @@ -24,6 +24,9 @@ pub struct PrintPage<'a> { pub window: Option, /// Document space → drawing space. pub place: Affine, + /// Copies on the sheet: each one's move in drawing space from where [`Self::place`] puts the + /// art. The art and the marks are drawn once per copy. Empty: one copy, not moved. + pub copies: &'a [Vec2], /// The art drawn, in document space: what lies outside it is left out. pub area: Rect, /// Printer's marks, in drawing space. @@ -59,7 +62,10 @@ fn sheet(p: &PrintPage, job: &PrintJob) -> Result<(String, Vec), String> { if !(w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0 && [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite())) { return Err(format!("page size {w} × {h}")); } - if !finite(p.view) || !finite(p.place) { + // `moves` holds each copy's move in drawing space; without copies, it holds one zero move. + let moves = if p.copies.is_empty() { &[Vec2::ZERO][..] } else { p.copies }; + let place_at = |m: Vec2| if m == Vec2::ZERO { p.place } else { Affine::translate(m) * p.place }; + if !finite(p.view) || !moves.iter().all(|m| finite(place_at(*m))) { return Err("a page's placement is not finite".into()); } let opts = |region: Rect| EpsOptions { level: job.level, region, ..EpsOptions::default() }; @@ -69,6 +75,9 @@ fn sheet(p: &PrintPage, job: &PrintJob) -> Result<(String, Vec), String> { let paper = Rect::new(0.0, 0.0, w, h); let shown = if p.view.determinant().abs() > 1e-12 { p.view.inverse().transform_rect_bbox(paper) } else { paper }; let shown = p.window.map_or(shown, |t| t.intersect(shown)); + // The marks body is drawn for every copy, so it is generated for the union of the regions + // the copies show. + let shown = moves.iter().map(|m| shown - *m).reduce(|a, b| a.union(b)).unwrap_or(shown); let marks_opts = opts(shown); let marks = p.marks.map(|m| Scene::new(p.doc, &marks_opts).run_node(m)); let mut s = String::with_capacity(art.body.len() + art.setup.len() + 1024); @@ -99,19 +108,33 @@ fn sheet(p: &PrintPage, job: &PrintJob) -> Result<(String, Vec), String> { ps::push_path(&mut s, &t.to_path(0.1)); s.push_str("W\n"); } - s.push_str("q\n"); - let _ = writeln!(s, "{} cm", ps::matrix(p.place)); - ps::push_path(&mut s, &r.to_path(0.1)); - s.push_str("W\n"); - s.push_str(&art.setup); - s.push_str(&art.body); - s.push_str("Q\n"); + for (i, m) in moves.iter().enumerate() { + s.push_str("q\n"); + let _ = writeln!(s, "{} cm", ps::matrix(place_at(*m))); + ps::push_path(&mut s, &r.to_path(0.1)); + s.push_str("W\n"); + // The setup defines the spot color spaces once; each copy draws the same body. + if i == 0 { + s.push_str(&art.setup); + } + s.push_str(&art.body); + s.push_str("Q\n"); + } let mut found = vec![art]; - if let Some(m) = marks { + if let Some(marks) = marks { // The marks' spot colour spaces are defined after the art is drawn: names may be reused. - s.push_str(&m.setup); - s.push_str(&m.body); - found.push(m); + s.push_str(&marks.setup); + for m in moves { + let moved = *m != Vec2::ZERO; + if moved { + let _ = writeln!(s, "q\n{} cm", ps::matrix(Affine::translate(*m))); + } + s.push_str(&marks.body); + if moved { + s.push_str("Q\n"); + } + } + found.push(marks); } s.push_str("Q\nend\nshowpage\n%%PageTrailer\n"); Ok((s, found)) diff --git a/crates/eps/src/print/tests.rs b/crates/eps/src/print/tests.rs index 171bb7700..29e7ca053 100644 --- a/crates/eps/src/print/tests.rs +++ b/crates/eps/src/print/tests.rs @@ -25,6 +25,7 @@ fn page<'a>(d: &'a Document, marks: Option<&'a Node>) -> PrintPage<'a> { view: Affine::IDENTITY, window: None, place: Affine::translate((36.0, 36.0)), + copies: &[], area: d.artboards[0].rect, marks, negative: false, @@ -67,6 +68,32 @@ fn pages_follow_dsc_and_repeat_for_copies() { assert!(back.warnings.iter().any(|w| w.contains("first of the file's 4 pages")), "{:?}", back.warnings); } +#[test] +fn copies_on_a_sheet_draw_the_art_and_marks_once_each() { + let d = doc(); + let mark = + Node::path(NodeId(9999), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::registration(), 0.25)); + let moves = [kurbo::Vec2::ZERO, kurbo::Vec2::new(218.0, 0.0), kurbo::Vec2::new(0.0, 118.0)]; + let sheet = PrintPage { copies: &moves, ..page(&d, Some(&mark)) }; + let ps = text(&print(&[sheet], &[0], &PrintJob::default()).unwrap()); + // The art is placed three times, the second 218 pt right and the third 118 pt down, and its + // spot color space and the marks' are defined once. + assert_eq!(ps.matches("[1 0 0 1 36 36] cm\n").count(), 1, "{ps}"); + assert!(ps.contains("[1 0 0 1 254 36] cm\n") && ps.contains("[1 0 0 1 36 154] cm\n"), "{ps}"); + assert_eq!(ps.matches("/All /DeviceCMYK").count(), 1, "{ps}"); + assert!(ps.contains("q\n[1 0 0 1 218 0] cm\n") && ps.contains("q\n[1 0 0 1 0 118] cm\n"), "{ps}"); + // A mark off the page around the first copy still prints around a later copy that has it on the page. + let look = Appearance::basic(Paint::None, Paint::registration(), 0.25); + let off = Node::path(NodeId(9998), shapes::rectangle(Rect::new(-100.0, -100.0, -90.0, -90.0)), look); + let later = [kurbo::Vec2::ZERO, kurbo::Vec2::new(200.0, 200.0)]; + let ps = text(&print(&[PrintPage { copies: &later, ..page(&d, Some(&off)) }], &[0], &PrintJob::default()).unwrap()); + assert_eq!(ps.matches("/All /DeviceCMYK").count(), 1, "{ps}"); + // One copy unmoved is the page without copies. + let one = [kurbo::Vec2::ZERO]; + let single = text(&print(&[PrintPage { copies: &one, ..page(&d, Some(&mark)) }], &[0], &PrintJob::default()).unwrap()); + assert_eq!(single, text(&print(&[page(&d, Some(&mark))], &[0], &PrintJob::default()).unwrap())); +} + #[test] fn separations_screens_flatness_negatives_and_tiles() { let d = doc(); diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index c64d4a6f5..d131ece8f 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -114,6 +114,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha view, window: None, place: Affine::IDENTITY, + copies: &[], area: r, // Art reaches as far as the bleed: the marks lie outside it. clip: m != r, diff --git a/crates/pdf/src/export/writer.rs b/crates/pdf/src/export/writer.rs index 7f9b939f9..f4a6e7f93 100644 --- a/crates/pdf/src/export/writer.rs +++ b/crates/pdf/src/export/writer.rs @@ -43,6 +43,9 @@ pub(crate) struct Sheet<'a> { pub window: Option, /// Document space → drawing space. pub place: Affine, + /// Copies on the page: each one's move in drawing space from where [`Self::place`] puts the + /// art. The art and the marks are drawn once per copy. Empty: one copy, not moved. + pub copies: &'a [Vec2], /// The art drawn, in document space (the bleed box): what lies outside it is left out. pub area: Rect, /// Clip the art to [`Self::area`]. @@ -127,22 +130,17 @@ impl Writer { if sheet.negative { cover(&mut s, paper, 255, 1.0); } + // `moves` holds each copy's move in drawing space; without copies, it holds one zero move. + let moves = if sheet.copies.is_empty() { &[Vec2::ZERO][..] } else { sheet.copies }; + let place_at = |m: Vec2| if m == Vec2::ZERO { sheet.place } else { Affine::translate(m) * sheet.place }; // A page whose art or marks would be placed beyond [`MAX_COORD`] is left blank. - let fits = writable(&s, sheet.view, Rect::ZERO) && writable(&s, sheet.view * sheet.place, Rect::ZERO); + let fits = writable(&s, sheet.view, Rect::ZERO) && moves.iter().all(|m| writable(&s, sheet.view * place_at(*m), Rect::ZERO)); s.push_transform(&xf(sheet.view)); let window = sheet.window.and_then(|r| to_path(&s, &r.to_path(0.1))); if let Some(clip) = &window { s.push_clip_path(clip, &krilla::paint::FillRule::NonZero); } - let placed = sheet.place != Affine::IDENTITY; - if placed { - s.push_transform(&xf(sheet.place)); - } let r = sheet.area; - let clip = sheet.clip.then(|| to_path(&s, &r.to_path(0.1))).flatten(); - if let Some(clip) = &clip { - s.push_clip_path(clip, &krilla::paint::FillRule::NonZero); - } // Page Isolated Blending / Page Knockout Group: the page content is one group (the PDF // writer has no page group attributes; a flat PDF 1.3 file has none). In CMYK, transparency // at the top of a page is put in a group of its own, whose blending space `cmyk_blending` @@ -151,35 +149,56 @@ impl Writer { let doc = ex.doc; let page_group = fits && !self.pdf13 && (doc.page_isolate || doc.page_knockout); let cmyk_page_group = fits && self.cmyk && !page_group && ex.shows_transparency(); - if page_group || (cmyk_page_group && !Node::children_blend(&doc.layers, &mut |c| c.blends_through())) { - s.push_isolated(); - } else if cmyk_page_group { - let all = constant_mask(&mut s, r, 1.0); - s.push_mask(all); - } - if fits { - let shown = (sheet.view * sheet.place).inverse().transform_rect_bbox(paper); - ex.paper = if [shown.x0, shown.y0, shown.x1, shown.y1].iter().all(|v| v.is_finite()) { shown.union(r) } else { r }; - ex.children(&mut s, &doc.layers, r); - } - if page_group || cmyk_page_group { - s.pop(); - } - if clip.is_some() { - s.pop(); - } - if placed { - s.pop(); + for m in moves { + let place = place_at(*m); + let placed = place != Affine::IDENTITY; + if placed { + s.push_transform(&xf(place)); + } + let clip = sheet.clip.then(|| to_path(&s, &r.to_path(0.1))).flatten(); + if let Some(clip) = &clip { + s.push_clip_path(clip, &krilla::paint::FillRule::NonZero); + } + if page_group || (cmyk_page_group && !Node::children_blend(&doc.layers, &mut |c| c.blends_through())) { + s.push_isolated(); + } else if cmyk_page_group { + let all = constant_mask(&mut s, r, 1.0); + s.push_mask(all); + } + if fits { + let shown = (sheet.view * place).inverse().transform_rect_bbox(paper); + ex.paper = if [shown.x0, shown.y0, shown.x1, shown.y1].iter().all(|v| v.is_finite()) { shown.union(r) } else { r }; + ex.children(&mut s, &doc.layers, r); + } + if page_group || cmyk_page_group { + s.pop(); + } + if clip.is_some() { + s.pop(); + } + if placed { + s.pop(); + } } if fits && let Some(art) = sheet.marks { // The drawing space the page shows (the marks' area). let shown = sheet.view.inverse().transform_rect_bbox(paper); + let shown = sheet.window.map_or(shown, |r| r.intersect(shown)); let knockout = std::mem::replace(&mut ex.knockout, false); // Marks keep their inks: they aren't converted to the destination. The page // information is drawn as glyph shapes, like the other marks, not as text. let convert = std::mem::replace(&mut ex.convert, false); let outline = std::mem::replace(&mut ex.outline_text, true); - ex.node(&mut s, art, sheet.window.map_or(shown, |r| r.intersect(shown)), true); + for m in moves { + let moved = *m != Vec2::ZERO; + if moved { + s.push_transform(&xf(Affine::translate(*m))); + } + ex.node(&mut s, art, shown - *m, true); + if moved { + s.pop(); + } + } (ex.knockout, ex.convert, ex.outline_text) = (knockout, convert, outline); } if window.is_some() { diff --git a/crates/pdf/src/print.rs b/crates/pdf/src/print.rs index 559fc6147..b11a764be 100644 --- a/crates/pdf/src/print.rs +++ b/crates/pdf/src/print.rs @@ -6,7 +6,9 @@ //! to each artboard's), transverse, which layers print (template layers never do), the //! placement on the imageable area (or where the Print Tiling tool put the pages, //! [`TileOrigin`]), and the scale: none, fit, custom, or tiles of the paper or of its imageable -//! area, overlapping, a range of them. +//! area, overlapping, a range of them. Without tiles, copies per sheet repeats the artboard +//! (with its bleed and marks) across then down the imageable area, as many times as fit, +//! `gutter` apart, and places the grid as it places one copy. //! - Marks and bleed as PDF export draws them ([`crate::MarkSettings`], [`crate::BleedSettings`]), //! around the artboard on the paper, at the paper's scale. //! - Output: composite, or separations, one page per ink that prints ([`print_inks`]) in the @@ -33,14 +35,14 @@ use std::borrow::Cow; use std::collections::HashSet; use std::sync::Arc; -use kurbo::{Affine, Rect}; +use kurbo::{Affine, Rect, Vec2}; use serde::Serialize; use vectorcraft_color::cms::{self, ProfileKind}; use vectorcraft_doc::marks::PrinterMarks; use vectorcraft_doc::overprint::{clear_overprints, darken_overprints}; use vectorcraft_doc::{ColorMode, Document, Node, NodeKind}; -pub use layout::{MAX_TILES, TileGrid}; +pub use layout::{MAX_COPIES_PER_SHEET, MAX_TILES, TileGrid}; pub use plates::print_inks; pub use settings::*; pub use tiling::{TilingPage, tiling}; @@ -106,8 +108,13 @@ pub struct PageSummary { /// The part of the document this page prints (`[x0, y0, x1, y1]`, document space): the /// artboard with its bleed, or what of it a tile shows. pub area: [f64; 4], - /// The artboard (trim box) on the page, `[x0, y0, x1, y1]`. + /// The artboard (trim box) on the page, `[x0, y0, x1, y1]`; with copies per sheet, the box + /// around every copy's. pub trim: [f64; 4], + /// With copies per sheet, each copy's artboard on the page, `[x0, y0, x1, y1]`, across then + /// down (the first is where `transform` puts it). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub copies: Vec<[f64; 4]>, } /// What a job prints, without printing it. @@ -292,6 +299,7 @@ pub fn preview(doc: &Document, set: &PrintSettings) -> Result, /// Document space → drawing space. pub place: Affine, + /// With copies per sheet, each copy's move in drawing space from where [`Self::place`] puts + /// the art, across then down (the first is zero): the art and the marks are drawn once per + /// copy. Empty: one copy. + pub copies: Vec, /// The art drawn, in document space (the bleed box): what lies outside it is left out. pub area: Rect, /// Printer's marks, in drawing space (separated for the sheet's ink). @@ -334,6 +346,7 @@ impl PlannedSheet { view: self.view, window: self.window, place: self.place, + copies: &self.copies, area: self.area, clip: true, marks: self.marks.as_ref(), @@ -389,6 +402,7 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result, view: l.view, window: l.window, place: l.place, + copies: l.copies.clone(), area: l.area, marks: art, negative: set.output.image == PrintImage::Negative, diff --git a/crates/pdf/src/print/layout.rs b/crates/pdf/src/print/layout.rs index 21462e6b9..9002ceb80 100644 --- a/crates/pdf/src/print/layout.rs +++ b/crates/pdf/src/print/layout.rs @@ -1,12 +1,13 @@ //! Where each printed page's art goes: the artboards (or all the art) that print, the paper's -//! orientation, the scale, the placement on the imageable area or the tiles, and the turns and -//! mirroring of the page (flipped orientation, emulsion down, transverse). +//! orientation, the scale, the placement on the imageable area (of one copy, or of a grid of +//! copies per sheet) or the tiles, and the turns and mirroring of the page (flipped orientation, +//! emulsion down, transverse). //! //! The art is placed in a drawing space: the paper (y down from its top-left corner), or, when //! tiling, the whole printed area (the artboard grown by its bleed and marks) from its top-left //! corner, of which each page shows one tile. -use kurbo::{Affine, Rect}; +use kurbo::{Affine, Rect, Vec2}; use serde::Serialize; use vectorcraft_doc::marks::outset; use vectorcraft_doc::range::parse_range; @@ -18,6 +19,9 @@ use crate::PdfError; /// Most tiles of one artboard. pub const MAX_TILES: usize = 1000; +/// Most copies of one artboard on a sheet ([`PrintSettings::copies_per_sheet`]). +pub const MAX_COPIES_PER_SHEET: usize = 1000; + /// One page of the job, before copies and inks. #[derive(Clone, Debug)] pub(crate) struct Layout { @@ -43,9 +47,12 @@ pub(crate) struct Layout { /// the marks go. pub trim: Rect, pub bleed: [f64; 4], - /// The TrimBox and BleedBox in page space. + /// The TrimBox and BleedBox in page space (around every copy with copies per sheet). pub page_trim: Rect, pub page_bleed: Rect, + /// With copies per sheet, where each copy goes: its move in drawing space from where + /// [`Self::place`] puts the art, across then down (the first is zero). Empty: one copy. + pub copies: Vec, } /// The tiles of one artboard (`print.preview`). @@ -148,6 +155,13 @@ fn span(start: Option, extent: f64, tile: f64, step: f64, overlap: f64) -> } } +/// How many printed areas `extent` long fit in `room`, `gutter` apart (whole copies only). +fn fitting(room: f64, extent: f64, gutter: f64) -> f64 { + // Copies that fill the room exactly fit. + const EPS: f64 = 1e-6; + ((room + gutter) / (extent + gutter) + EPS).floor().max(0.0) +} + /// The page turns of a `w` × `h` paper (flipped, emulsion down, transverse) as one transform from /// the paper onto the page, and the page's size. fn turns(set: &PrintSettings, (w, h): (f64, f64), flipped: bool) -> (Affine, (f64, f64)) { @@ -238,7 +252,7 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec 0.0 { (t, b) } else { (page, page) } }; - let base = |view, window, place, area, trim, (page_trim, page_bleed), tile| Layout { + let base = |view, window, place, area, trim, (page_trim, page_bleed), tile, copies| Layout { artboard, tile, size: page_size, @@ -252,24 +266,48 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec { + warnings.push(format!("{what} with its bleed and marks doesn't fit the imageable area: it prints one copy per sheet")); + None + } + Some((cols, rows)) if cols * rows > MAX_COPIES_PER_SHEET as f64 => { + return Err(PdfError::BadSetting(format!("{what} would print more than {MAX_COPIES_PER_SHEET} copies on a sheet: scale it up"))); + } + fit => fit.map(|(cols, rows)| (cols as usize, rows as usize)), + }; + let step = (extent.0 + gutter, extent.1 + gutter); + let all = grid.map_or(extent, |(cols, rows)| (cols as f64 * step.0 - gutter, rows as f64 * step.1 - gutter)); + // The printed area's (or the grid's) top-left corner: where the tile origin puts it, + // else the placement. let (x, y) = match origin { Some((ox, oy)) => (imageable.x0 - ox * scale.0 - most[2], imageable.y0 - oy * scale.1 - most[0]), None => { let (fx, fy) = set.placement.origin.factors(); ( - imageable.x0 + (imageable.width() - extent.0) * fx + set.placement.x, - imageable.y0 + (imageable.height() - extent.1) * fy + set.placement.y, + imageable.x0 + (imageable.width() - all.0) * fx + set.placement.x, + imageable.y0 + (imageable.height() - all.1) * fy + set.placement.y, ) } }; let trim = Rect::new(x + most[2], y + most[0], x + most[2] + size.0, y + most[0] + size.1); - if !imageable.inflate(0.01, 0.01).contains_rect(Rect::new(x, y, x + extent.0, y + extent.1)) { + if !imageable.inflate(0.01, 0.01).contains_rect(Rect::new(x, y, x + all.0, y + all.1)) { warnings.push(format!("{what} with its bleed and marks is larger than the imageable area: part of it doesn't print")); } - pages.push(base(turn, None, place_at(trim), bleed_box, trim, boxes(turn, trim), None)); + let copies: Vec = grid.map_or_else(Vec::new, |(cols, rows)| { + (0..rows).flat_map(|j| (0..cols).map(move |i| Vec2::new(i as f64 * step.0, j as f64 * step.1))).collect() + }); + let page_boxes = + copies.iter().map(|m| boxes(turn, trim + *m)).reduce(|a, b| (a.0.union(b.0), a.1.union(b.1))).unwrap_or_else(|| boxes(turn, trim)); + pages.push(base(turn, None, place_at(trim), bleed_box, trim, page_boxes, None, copies)); continue; } // Tiles across then down over the printed area, overlapping by `overlap`. @@ -312,7 +350,7 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec Vec<(f64, f64)> { + pv.sheets[0].copies.iter().map(|c| (c[0], c[1])).collect() +} + +/// Every `(x, y)` of `xs` and `ys`, across then down. +fn grid(xs: &[f64], ys: &[f64]) -> Vec<(f64, f64)> { + ys.iter().flat_map(|y| xs.iter().map(move |x| (*x, *y))).collect() +} + +#[test] +fn copies_per_sheet_fill_the_page_in_a_grid() { + // A 2 × 3.5 in card (144 × 252 pt) on Letter (612 × 792 pt): 4 across and 3 down, the + // 576 × 756 pt grid centered. + let d = doc(144.0, 252.0, &[(Rect::new(0.0, 0.0, 144.0, 252.0), rgb(1.0, 0.0, 0.0))]); + let pv = preview(&d, &settings(json!({"copiesPerSheet": true}))).unwrap(); + assert_eq!((pv.pages, pv.sheets.len(), pv.sheets[0].orientation), (1, 1, Orientation::Portrait)); + assert_eq!(corners(&pv), grid(&[18.0, 162.0, 306.0, 450.0], &[18.0, 270.0, 522.0])); + assert!(pv.sheets[0].copies.iter().all(|c| (c[2] - c[0], c[3] - c[1]) == (144.0, 252.0)), "{:?}", pv.sheets[0].copies); + assert_eq!(pv.sheets[0].trim, [18.0, 18.0, 594.0, 774.0], "the TrimBox holds every copy"); + assert!(pv.warnings.is_empty(), "{:?}", pv.warnings); + // The printed page holds the twelve cards there. + let p = pages(&job(&d, json!({"copiesPerSheet": true})).bytes); + assert_eq!(p.len(), 1); + let mut printed: Vec<(f64, f64)> = p[0].1.iter().map(|(r, _)| (r.x0.round(), r.y0.round())).collect(); + printed.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.total_cmp(&b.0))); + assert_eq!(printed, grid(&[18.0, 162.0, 306.0, 450.0], &[18.0, 270.0, 522.0]), "{:?}", p[0].1); + assert!(p[0].1.iter().all(|(r, paint)| rgb8(paint) == [255, 0, 0] && near(*r, Rect::new(r.x0, r.y0, r.x0 + 144.0, r.y0 + 252.0)))); + // An 18 pt gutter: 3 across and 3 down, the 468 × 792 pt grid centered. + let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "gutter": 18}))).unwrap(); + assert_eq!(corners(&pv), grid(&[72.0, 234.0, 396.0], &[0.0, 270.0, 540.0])); + // The placement places the grid, inside the margin. + let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "margin": 9, "placement": {"origin": "topLeft"}}))).unwrap(); + assert_eq!(corners(&pv), grid(&[9.0, 153.0, 297.0, 441.0], &[9.0, 261.0, 513.0])); +} + +#[test] +fn copies_per_sheet_is_off_by_default_and_off_prints_as_before() { + let s = PrintSettings::default(); + assert_eq!((s.copies_per_sheet, s.gutter), (false, 0.0)); + let v = serde_json::to_value(&s).unwrap(); + assert_eq!((&v["copiesPerSheet"], &v["gutter"]), (&json!(false), &json!(0.0))); + assert!(settings(json!({"gutter": -1})).check().is_err() && settings(json!({"gutter": 721})).check().is_err()); + // Off, the job is the same file whatever the gutter, and its pages list no copies. + let d = doc(144.0, 252.0, &[(Rect::new(0.0, 0.0, 144.0, 252.0), rgb(1.0, 0.0, 0.0))]); + assert_eq!(job(&d, json!({})).bytes, job(&d, json!({"copiesPerSheet": false, "gutter": 36})).bytes); + let pv = serde_json::to_value(preview(&d, &settings(json!({}))).unwrap()).unwrap(); + assert!(pv["sheets"][0].get("copies").is_none(), "{pv}"); + // Tiles print one artboard across pages: copies per sheet is left out. + let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "scaling": "tileFull"}))).unwrap(); + assert!(pv.sheets[0].copies.is_empty() && pv.sheets[0].tile.is_some()); +} + +#[test] +fn an_artboard_that_does_not_fit_prints_one_copy_per_sheet() { + // 700 × 900 pt is larger than Letter either way round. + let d = doc(700.0, 900.0, &[(Rect::new(0.0, 0.0, 700.0, 900.0), rgb(0.0, 0.0, 1.0))]); + let r = job(&d, json!({"copiesPerSheet": true})); + assert_eq!(r.pages, 1); + assert!( + r.warnings.iter().any(|w| w == "Artboard 1 with its bleed and marks doesn't fit the imageable area: it prints one copy per sheet"), + "{:?}", + r.warnings + ); + assert!(r.warnings.iter().any(|w| w.contains("larger than the imageable area")), "{:?}", r.warnings); + assert_eq!(r.bytes, job(&d, json!({})).bytes, "printed as without copies per sheet"); + assert!(preview(&d, &settings(json!({"copiesPerSheet": true}))).unwrap().sheets[0].copies.is_empty()); + // When one copy fits, it prints alone, centered, whatever the gutter. + let d = doc(400.0, 500.0, &[]); + let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "gutter": 300}))).unwrap(); + assert_eq!(corners(&pv), [(106.0, 146.0)]); + // More copies than a sheet takes are refused. + let tiny = doc(1.0, 1.0, &[]); + assert!(preview(&tiny, &settings(json!({"copiesPerSheet": true}))).is_err_and(|e| e.to_string().contains("more than 1000 copies"))); +} + +#[test] +fn each_copy_prints_with_its_marks() { + let d = doc(100.0, 100.0, &[(Rect::new(0.0, 0.0, 100.0, 100.0), rgb(1.0, 0.0, 0.0))]); + let set = json!({"copiesPerSheet": true, "marks": {"trim": true}}); + let pv = preview(&d, &settings(set.clone())).unwrap(); + let copies = pv.sheets[0].copies.len(); + assert!(copies > 1, "{pv:?}"); + let back = import(&job(&d, set).bytes).unwrap(); + let mut marks = 0; + back.walk(|n| marks += usize::from(n.appearance.stroke().is_some_and(|s| s.paint.is_registration()))); + assert_eq!(marks, 8 * copies, "8 trim marks around each copy"); +} + #[test] fn tiles_cover_the_artboard_with_overlap() { // 1000 × 700 on Letter tiles (612 × 792), 36 pt overlap: 2 × 1. diff --git a/crates/ui-egui/src/dialogs/print.rs b/crates/ui-egui/src/dialogs/print.rs index 523c42d06..807beed25 100644 --- a/crates/ui-egui/src/dialogs/print.rs +++ b/crates/ui-egui/src/dialogs/print.rs @@ -427,22 +427,32 @@ fn page_preview(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, pv: } let coeffs: Vec = sheet["transform"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); let clip = ui.painter().with_clip_rect(page); + // With copies per sheet, `copies` holds each copy's artboard on the page, and `moves` holds + // each copy's move on screen from the first. + let copies: Vec = sheet["copies"].as_array().map(|a| a.iter().filter_map(rect_of).collect()).unwrap_or_default(); + let moves: Vec = match copies.first() { + Some(first) => copies.iter().map(|r| egui::vec2((r.x0 - first.x0) as f32, (r.y0 - first.y0) as f32) * fit).collect(), + None => vec![egui::Vec2::ZERO], + }; if let ([a, b, c, dd, e, f], Some(area)) = (coeffs.as_slice(), rect_of(&sheet["area"])) { let px = (f64::from(page.width().max(page.height()) * ui.ctx().pixels_per_point())).min(1024.0); let layers = choice::(d, "printLayers").unwrap_or(PrintLayers::VisiblePrintable); if let Some(tex) = art_texture(app, ui.ctx(), layers, area, px) { let at = |x: f64, y: f64| to_screen(a * x + c * y + e, b * x + dd * y + f) + offset; let mut mesh = egui::Mesh::with_texture(tex.id()); - for (p, uv) in [ - ((area.x0, area.y0), (0.0, 0.0)), - ((area.x1, area.y0), (1.0, 0.0)), - ((area.x1, area.y1), (1.0, 1.0)), - ((area.x0, area.y1), (0.0, 1.0)), - ] { - mesh.vertices.push(egui::epaint::Vertex { pos: at(p.0, p.1), uv: egui::pos2(uv.0, uv.1), color: egui::Color32::WHITE }); + for m in &moves { + let first = mesh.vertices.len() as u32; + for (p, uv) in [ + ((area.x0, area.y0), (0.0, 0.0)), + ((area.x1, area.y0), (1.0, 0.0)), + ((area.x1, area.y1), (1.0, 1.0)), + ((area.x0, area.y1), (0.0, 1.0)), + ] { + mesh.vertices.push(egui::epaint::Vertex { pos: at(p.0, p.1) + *m, uv: egui::pos2(uv.0, uv.1), color: egui::Color32::WHITE }); + } + mesh.add_triangle(first, first + 1, first + 2); + mesh.add_triangle(first, first + 2, first + 3); } - mesh.add_triangle(0, 1, 2); - mesh.add_triangle(0, 2, 3); clip.add(mesh); } } @@ -460,7 +470,9 @@ fn page_preview(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, pv: let corners = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; clip.extend(egui::Shape::dashed_line(&corners, stroke, 3.0, 2.0)); } - if let Some(trim) = rect_of(&sheet["trim"]) { + // The preview strokes each copy's artboard edge, or the trim box without copies per sheet. + let trims = if copies.is_empty() { rect_of(&sheet["trim"]).into_iter().collect() } else { copies }; + for trim in trims { let r = egui::Rect::from_min_max(to_screen(trim.x0, trim.y0), to_screen(trim.x1, trim.y1)).translate(offset); clip.rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Middle); } @@ -660,6 +672,9 @@ fn general(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { if tiles { note(ui, tl!("Tiles are numbered across, then down; leave the range empty for every tile.")); } + flag(ui, d, "copiesPerSheet", tl!("Copies per Sheet (as many as fit, in a grid)"), !tiles); + let per_sheet = get(d, "copiesPerSheet").as_bool() == Some(true); + row(ui, tl!("Gutter:"), |ui| length(ui, d, "gutter", unit, !tiles && per_sheet)); } fn output(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result) { diff --git a/crates/ui-egui/src/dialogs/tests_print.rs b/crates/ui-egui/src/dialogs/tests_print.rs index bf213c789..3bf847d0e 100644 --- a/crates/ui-egui/src/dialogs/tests_print.rs +++ b/crates/ui-egui/src/dialogs/tests_print.rs @@ -216,6 +216,24 @@ fn without_a_print_service_print_saves_a_pdf() { assert_eq!(app.run("print.printers", json!({})).unwrap(), json!({"printers": [], "service": false, "setup": false})); } +#[test] +fn copies_per_sheet_prints_a_grid_of_each_artboard() { + let (mut app, jobs) = app(); + app.run("file.print", json!({})).unwrap(); + set(&mut app, "copiesPerSheet", json!(true)); + set(&mut app, "gutter", json!(9)); + frame(&mut app); + let r = super::confirm(&mut app).unwrap(); + assert_eq!(r["pages"], 3, "a sheet per artboard"); + assert_eq!(pages(&jobs.borrow()[0].2), 3); + let saved = app.session.active().unwrap().doc.print_setup.clone().unwrap(); + assert_eq!((saved["copiesPerSheet"].clone(), saved["gutter"].clone()), (json!(true), json!(9.0))); + // Each 120 × 90 artboard fills landscape Letter 6 across and 6 down, 9 pt apart. + let pv = app.session.execute("print.preview", &json!({})).unwrap(); + assert_eq!(pv["sheets"][0]["copies"].as_array().map(Vec::len), Some(36)); + assert_eq!(pv["sheets"][0]["copies"][1], json!([142.5, 13.5, 262.5, 103.5])); +} + #[test] fn dragging_the_preview_moves_the_placement_on_the_paper() { let (mut app, _) = app(); @@ -225,6 +243,8 @@ fn dragging_the_preview_moves_the_placement_on_the_paper() { (json!({"autoRotate": false, "orientation": "portraitFlipped"}), (10.0, 5.0)), (json!({"output": {"emulsion": "down"}, "transverse": true}), (-4.0, 7.0)), (json!({"scaling": "custom", "scale": {"width": 50, "height": 200}}), (6.0, -8.0)), + // Copies per sheet: the drag moves the whole grid. + (json!({"copiesPerSheet": true, "gutter": 9}), (10.0, 5.0)), ] { let before = app.session.execute("print.preview", &json!({ "settings": settings })).unwrap(); let sheet = &before["sheets"][0]; diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index a849c4abc..fc41498e6 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3459,6 +3459,7 @@ digits Malayalam Малаялам Tile Type: Тип плочка: tile {number} плочка {number} Tiles are numbered across, then down; leave the range empty for every tile. Плочките се номерират напречно, след това надолу; оставете диапазона празен за всяка плочка. + Copies per Sheet (as many as fit, in a grid) Копия на лист (колкото се побират, в мрежа) Tint Оттенък Tip extends past the end point Връхът продължава след крайната точка Tip on the end point Връх върху крайната точка diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index cdab9e994..56271636d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2710,6 +2710,7 @@ digits Malayalam Malajálamské Tile Range: Rozsah dlaždic: tile {number} dlaždice {number} Tiles are numbered across, then down; leave the range empty for every tile. Dlaždice se číslují po řádcích, pak dolů; prázdný rozsah znamená všechny dlaždice. + Copies per Sheet (as many as fit, in a grid) Kopie na list (kolik se vejde, v mřížce) Transverse (the page a quarter turn on the paper) Napříč (stránka otočená na papíře o čtvrt otáčky) Unprintable margin: Netisknutelný okraj: {pages} in all, {count} per copy celkem {pages}, na kopii {count} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 9e1ac89a6..c19acf1fb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3491,6 +3491,8 @@ effect Points Punkte Tile Type: Kacheltyp: tile {number} Kachel {number} Tiles are numbered across, then down; leave the range empty for every tile. Kacheln werden erst quer, dann nach unten nummeriert; ein leerer Bereich steht für alle Kacheln. + Copies per Sheet (as many as fit, in a grid) Kopien pro Blatt (so viele wie passen, im Raster) + Gutter: Zwischenraum: Tint Tonwert Tip extends past the end point Spitze ragt über den Endpunkt hinaus Tip on the end point Spitze auf dem Endpunkt diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7d3ddc4c5..30f2da1ef 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3883,6 +3883,7 @@ digits Malayalam Μαλαγιάλαμ Tile Type: Τύπος πλακιδίου: tile {number} πλακίδιο {number} Tiles are numbered across, then down; leave the range empty for every tile. Τα πλακίδια αριθμούνται πρώτα οριζόντια και έπειτα κατακόρυφα· αφήστε κενό το εύρος για όλα τα πλακίδια. + Copies per Sheet (as many as fit, in a grid) Αντίγραφα ανά φύλλο (όσα χωρούν, σε πλέγμα) Tip extends past the end point Η αιχμή προεξέχει από το τελικό σημείο Tip on the end point Η αιχμή βρίσκεται στο τελικό σημείο Title Case Κεφαλαίο σε κάθε λέξη diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index bde3d5ea6..98f7f02c4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3547,6 +3547,8 @@ digits Malayalam Malayálam Tile Type: Tipo de mosaico: tile {number} mosaico {number} Tiles are numbered across, then down; leave the range empty for every tile. Los mosaicos se numeran de izquierda a derecha y luego hacia abajo; deja el rango vacío para todos. + Copies per Sheet (as many as fit, in a grid) Copias por hoja (tantas como quepan, en cuadrícula) + Gutter: Medianil: Tint Matiz Tip extends past the end point La punta se extiende más allá del punto final Tip on the end point Punta en el punto final diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 66c944a2e..458a07d94 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2654,6 +2654,7 @@ digits Malayalam Malajalam Tile Range: Ruutualue: tile {number} ruutu {number} Tiles are numbered across, then down; leave the range empty for every tile. Ruudut numeroidaan vaakasuunnassa ja sitten alaspäin; jätä alue tyhjäksi, niin kaikki ruudut tulostetaan. + Copies per Sheet (as many as fit, in a grid) Kopioita arkille (niin monta kuin mahtuu, ruudukkoon) Transverse (the page a quarter turn on the paper) Poikittainen (sivu neljänneskierroksen käännettynä paperilla) Unprintable margin: Tulostumaton reunus: {pages} in all, {count} per copy {pages} yhteensä, {count} kopiota kohden diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f899e59bd..888572ed4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3538,6 +3538,7 @@ digits Malayalam Malayalam Tile Range: Plage de carreaux : Tile Type: Type de carreau : tile {number} carreau {number} + Copies per Sheet (as many as fit, in a grid) Copies par feuille (autant qu’il en tient, en grille) Tiles are numbered across, then down; leave the range empty for every tile. Les carreaux sont numérotés de gauche à droite, puis de haut en bas ; laissez la plage vide pour tous les carreaux. Tint Teinte Tip extends past the end point La pointe dépasse le point d’extrémité diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index f059e26eb..9b90d2214 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4221,6 +4221,7 @@ mark-style Japanese Japán tile {number} csempe {number} Tiles are numbered across, then down; leave the range empty for every tile. A csempék számozása vízszintesen, majd lefelé halad; az összes csempéhez hagyd üresen a tartományt. Tilt Dőlés + Copies per Sheet (as many as fit, in a grid) Példány laponként (ahány elfér, rácsban) Tint Tónus Tints Tónusok Tints and Shades Tónusok és árnyalatok diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index aba5095e4..fa2126d0d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3583,6 +3583,8 @@ digits Malayalam Malayalam Tile Type: Tipo di tassello: tile {number} riquadro {number} Tiles are numbered across, then down; leave the range empty for every tile. I riquadri sono numerati da sinistra a destra, poi dall'alto in basso; lascia vuoto l'intervallo per stamparli tutti. + Copies per Sheet (as many as fit, in a grid) Copie per foglio (quante ne entrano, in griglia) + Gutter: Spazio intermedio: Tint Tinta Tip extends past the end point La punta si estende oltre il punto finale Tip on the end point Punta sul punto finale diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8f642c98b..6596dc515 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3275,6 +3275,8 @@ mark-style Japanese 日本式 Tile Range: 分割範囲: Tile Type: タイルの種類: Tiles are numbered across, then down; leave the range empty for every tile. タイルは左から右、上から下の順に番号が付きます。範囲を空にするとすべてのタイルが対象です。 + Copies per Sheet (as many as fit, in a grid) 用紙ごとに複数コピー (収まるだけグリッド状に配置) + Gutter: 間隔: Toggle Fill/Stroke Focus 塗り/線の切り替え Toggle Selection 選択を切り替え Toggle single/double column 1 列/2 列を切り替え diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 35627c507..d40d786f6 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3264,6 +3264,8 @@ mark-style Japanese 일본식 Tile Range: 타일 범위: Tile Type: 타일 유형: Tiles are numbered across, then down; leave the range empty for every tile. 타일은 가로로 먼저, 이어서 세로로 번호가 매겨집니다. 모든 타일을 지정하려면 범위를 비워 두십시오. + Copies per Sheet (as many as fit, in a grid) 용지당 여러 매 (들어가는 만큼 격자로 배치) + Gutter: 간격: Toggle Fill/Stroke Focus 칠/선 포커스 전환 Toggle Selection 선택 전환 Toggle single/double column 한 줄/두 줄 전환 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index cd5355d48..feb7ccc1d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2623,6 +2623,8 @@ digits Malayalam Malajalam Tile Range: Zakres kafelków: tile {number} kafelek {number} Tiles are numbered across, then down; leave the range empty for every tile. Kafelki są numerowane poziomo, a potem w dół; pozostaw zakres pusty dla wszystkich kafelków. + Copies per Sheet (as many as fit, in a grid) Kopie na arkusz (tyle, ile się zmieści, w siatce) + Gutter: Odstęp: Transverse (the page a quarter turn on the paper) Poprzecznie (strona obrócona o ćwierć obrotu na papierze) Unprintable margin: Margines niedrukowalny: {pages} in all, {count} per copy Razem: {pages}, na kopię: {count} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 7ea0725c0..ae7303f24 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2916,6 +2916,8 @@ digits Malayalam Malaiala Tile Range: Intervalo de Blocos: Tile Type: Tipo de Bloco: Tiles are numbered across, then down; leave the range empty for every tile. Os blocos são numerados horizontalmente, depois para baixo; deixe o intervalo vazio para cada bloco. + Copies per Sheet (as many as fit, in a grid) Cópias por Folha (quantas couberem, em grade) + Gutter: Medianiz: Title Case Tipo de Título Toggle Fill/Stroke Focus Alternar Foco de Preenchimento/Traçado Toggle Selection Alternar Seleção diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ed2ab3ae4..b4b6cad19 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3578,6 +3578,8 @@ digits Malayalam Малаялам Tile Type: Тип плитки: tile {number} плитка {number} Tiles are numbered across, then down; leave the range empty for every tile. Плитки нумеруются слева направо, затем вниз; оставьте диапазон пустым для всех плиток. + Copies per Sheet (as many as fit, in a grid) Копий на лист (сколько поместится, сеткой) + Gutter: Промежуток: Tint Оттенок Tip extends past the end point Кончик выходит за конечную точку Tip on the end point Кончик на конечной точке diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 494641000..48f235d7a 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3469,6 +3469,7 @@ digits Malayalam Malayalam Tile Type: Ruttyp: tile {number} ruta {number} Tiles are numbered across, then down; leave the range empty for every tile. Rutorna numreras från vänster till höger och sedan nedåt. Lämna omfånget tomt för att få med alla rutor. + Copies per Sheet (as many as fit, in a grid) Kopior per ark (så många som får plats, i ett rutnät) Tint Färgton Tip extends past the end point Spetsen sträcker sig förbi slutpunkten Tip on the end point Spetsen på slutpunkten diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 91e88343f..8e553ea23 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3576,6 +3576,8 @@ effect Points Точки Tile Type: Тип плитки: tile {number} плитка {number} Tiles are numbered across, then down; leave the range empty for every tile. Плитки нумеруються зліва направо, потім униз; Залишіть діапазон порожнім для всіх плиток. + Copies per Sheet (as many as fit, in a grid) Копій на аркуш (скільки поміститься, сіткою) + Gutter: Проміжок: Tint Відтінок Tip extends past the end point Кінчик виходить за кінцеву точку Tip on the end point Кінчик на кінцевій точці diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 2f37a6e87..4ec44a54f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3923,6 +3923,8 @@ mark-style Japanese Kiểu Nhật tile {number} ô {number} Tiles are numbered across, then down; leave the range empty for every tile. Các ô được đánh số từ ngang sang dọc; để trống phạm vi để in tất cả ô. Tilt Độ nghiêng + Copies per Sheet (as many as fit, in a grid) Nhiều bản sao trên một tờ (xếp vừa bao nhiêu in bấy nhiêu, theo lưới) + Gutter: Khoảng cách: Tint Tông màu Tints Sắc pha Tints and Shades Sắc pha và bóng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9e470c0bd..6a3af5217 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3414,6 +3414,7 @@ digits Malayalam 马拉雅拉姆数字 Tile Type: 拼贴类型: tile {number} 拼贴 {number} Tiles are numbered across, then down; leave the range empty for every tile. 拼贴先由左至右再由上至下编号;范围留空表示全部拼贴。 + Copies per Sheet (as many as fit, in a grid) 每张纸印多份 (尽量排满,按网格排列) Tint 色调 Tip extends past the end point 箭头超出端点 Tip on the end point 箭头位于端点 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 7b5fa5e50..201289d2c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4461,6 +4461,8 @@ digits Malayalam 馬來亞拉姆數字 Tile Type: 拼貼類型: tile {number} 拼貼 {number} Tiles are numbered across, then down; leave the range empty for every tile. 拼貼先由左至右再由上至下編號;範圍留空表示全部拼貼。 + Copies per Sheet (as many as fit, in a grid) 每張紙印多份 (盡量排滿,以格點排列) + Gutter: 間距: Tint 色調 Tip extends past the end point 尖端延伸超過終點 Tip on the end point 尖端位於終點 diff --git a/docs/mcp.md b/docs/mcp.md index b4ca733f8..a880c6f3a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5155,6 +5155,12 @@ documents every field: visiblePrintable|visible|all` (template layers never print); `placement: {origin, x, y}` places the printed area (artboard, bleed and marks) on the imageable area (the paper inside `margin`); `scaling: none|fit|custom|tileFull| tileImageable` with `scale: {width, height}` (%), `overlap` and `tileRange` (tiles numbered across then down). +- Copies per sheet: with `copiesPerSheet: true` (off by default; not with tiling) each sheet holds as many copies of + the artboard as fit the imageable area, each with its bleed and marks, across then down, `gutter` pt apart (0–720, + default 0). The copies are not turned, and the placement places the whole grid. An artboard that doesn't fit prints + one copy per sheet, with a warning; more than 1,000 copies on a sheet is an error. `print.preview` lists each + copy's artboard on the page as `copies: [[x0, y0, x1, y1]…]`, across then down, and `trim` (the TrimBox) holds + them all. - `marks` and `bleed` as `document.exportPdf` takes them (the bleed is the document's unless `useDocument: false`), drawn around the artboard at the paper's scale; the page information adds the tile and the ink with its screen. - `output: {mode: composite|separations, emulsion: up|down, image: positive|negative, spotsToProcess, inks: [{name, @@ -5444,10 +5450,11 @@ again when it changes); Multichannel files are refused. ## Printing `print.setup {settings?}` keeps print settings with the document (one undo step; without `settings` it answers the -current ones): the copies, artboards, paper, orientation, placement, scaling and tiling, marks and bleed, composite or -separations output, graphics and colour management options of File › Print. `print.preview {settings?}` lays the job -out without printing: `pages`, then one copy's `sheets`, each with its page size, scale, the `transform` from the -document onto the page (pt, y down), the document `area` it prints and its `trim` box on the page, plus `tiles`, the +current ones): the copies, artboards, paper, orientation, placement, scaling and tiling, copies per sheet, marks and +bleed, composite or separations output, graphics and colour management options of File › Print. `print.preview +{settings?}` lays the job out without printing: `pages`, then one copy's `sheets`, each with its page size, scale, the +`transform` from the document onto the page (pt, y down), the document `area` it prints, its `trim` box on the page +and, with copies per sheet, each copy's artboard on the page (`copies`), plus `tiles`, the `inks` of a separation and `warnings`. `file.print {settings?, path?}` writes the job as a print-ready PDF (one page per sheet, tile, ink and copy); headless it answers `dataBase64`. In the app, `file.print` with no params opens the Print dialog, and with params it prints through the system (`printer`: a name from `print.printers`, default the system's diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f95308865..6c41419d9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1400,6 +1400,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - PDF export and PDF-compatible `.ai` and `.ait` saves write File Info's copyright notice, URL and status to the XMP packet (`dc:rights`, `xmpRights:WebStatement`, `xmpRights:Marked`), so a PDF or `.ai` opened from its pages keeps them (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). - Opening a PDF or PDF-compatible `.ai` whose XMP packet nests elements more than 256 deep no longer overflows the stack: File Info leaves the packet out and takes the document information dictionary's values (`crates/pdf/src/tests/file_info.rs`). - `file.print`, `print.preview` and `print.setup` take a print preset by name (`preset`: `[Default]` or a saved preset, in any case): its settings are used instead of the document's, with `settings` over them, `print.setup` saves them with the document, and an unknown name is refused with the list of presets. + - Print's Copies per Sheet (`copiesPerSheet`, `gutter`; off by default) prints as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, as one grid placed like a single copy; the preview shows them, and an artboard that does not fit prints one copy per sheet with a warning (#1283). - Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). - File › Place reads RTF files as area type with their formatting: each run's font, size, bold and italic, underline, strikethrough, caps, superscript or subscript, baseline shift and color, and each paragraph's alignment, indents, space before and after and direction. Table cells are separated by tabs; hidden text, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests/textimport.rs`). - File › Place reads Word `.docx` documents as area type with the same formatting, taken from the document defaults, the paragraph and character styles (their toggle properties reversing each other) and direct formatting, the theme's fonts included. Table rows are paragraphs with their cells separated by tabs; hidden and deleted text, field instructions, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests/textimport.rs`). From b9f735dd9a587857cf4e7b7e4c3e1b7bfb7848f5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 02:23:16 -0700 Subject: [PATCH 2/8] i18n: Dutch, Danish and Indonesian rows for Copies per Sheet main now lists nl, da and id as complete-menu catalogs, so the Print dialog's Copies per Sheet label needs a row in each. Gutter: already has one. --- crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + 3 files changed, 3 insertions(+) diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 8174bad84..2a6be7c3a 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2498,6 +2498,7 @@ digits Malayalam Malayalam Tile Range: Fliseinterval: tile {number} flise {number} Tiles are numbered across, then down; leave the range empty for every tile. Fliser nummereres på tværs og derefter nedad; lad intervallet stå tomt for alle fliser. + Copies per Sheet (as many as fit, in a grid) Kopier pr. ark (så mange der er plads til, i et gitter) Transverse (the page a quarter turn on the paper) På tværs (siden drejes en kvart omgang på papiret) Unprintable margin: Ikke-udskrivbar margen: {pages} in all, {count} per copy {pages} i alt, {count} pr. kopi diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e0d50d0e1..88247c6f8 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2498,6 +2498,7 @@ digits Malayalam Malayalam Tile Range: Rentang ubin: tile {number} ubin {number} Tiles are numbered across, then down; leave the range empty for every tile. Ubin dinomori menyamping lalu ke bawah; biarkan rentang kosong untuk semua ubin. + Copies per Sheet (as many as fit, in a grid) Salinan per lembar (sebanyak yang muat, dalam kisi) Transverse (the page a quarter turn on the paper) Melintang (halaman diputar seperempat putaran pada kertas) Unprintable margin: Margin yang tidak dapat dicetak: {pages} in all, {count} per copy {pages} total, {count} per salinan diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b7e18b42c..b78c94e83 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2512,6 +2512,7 @@ digits Malayalam Malayalam Tile Range: Tegelbereik: tile {number} tegel {number} Tiles are numbered across, then down; leave the range empty for every tile. Tegels worden van links naar rechts en vervolgens van boven naar beneden genummerd; laat het bereik leeg voor alle tegels. + Copies per Sheet (as many as fit, in a grid) Kopieën per vel (zoveel als er passen, in een raster) Transverse (the page a quarter turn on the paper) Dwars (de pagina een kwartslag op het papier) Unprintable margin: Niet afdrukbare marge: {pages} in all, {count} per copy {pages} in totaal, {count} per exemplaar From 967d77919f2de04edd9a42236353bd08740cabf2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 18:56:39 -0700 Subject: [PATCH 3/8] Print: every copy on a sheet draws the same symbol instances Each copy on a sheet draws within an equal share of the symbol objects the export has left (MAX_SYMBOL_NODES), so a document near the limit leaves out the same instances in every copy instead of dropping them from the later copies. The limit stays export-wide; one copy per page draws as before. --- crates/pdf/src/export.rs | 3 ++ crates/pdf/src/export/exporter.rs | 1 + crates/pdf/src/export/nodes.rs | 29 ++++++++++-------- crates/pdf/src/export/writer.rs | 6 ++++ crates/pdf/src/tests/print.rs | 50 +++++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+), 13 deletions(-) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 8728b29b2..47184d520 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -118,6 +118,9 @@ pub(crate) struct Exporter<'a> { /// Objects the symbol instances written held, together /// ([`vectorcraft_doc::MAX_SYMBOL_NODES`]). symbol_nodes: u64, + /// The most [`Self::symbol_nodes`] may reach: [`vectorcraft_doc::MAX_SYMBOL_NODES`], or less + /// while one of the copies on a sheet is drawn ([`Writer::page`]). + pub(crate) symbol_limit: u64, } #[cfg(test)] diff --git a/crates/pdf/src/export/exporter.rs b/crates/pdf/src/export/exporter.rs index ed5f984c0..eb312021e 100644 --- a/crates/pdf/src/export/exporter.rs +++ b/crates/pdf/src/export/exporter.rs @@ -47,6 +47,7 @@ impl<'a> Exporter<'a> { in_layer: 0, paper: Rect::ZERO, symbol_nodes: 0, + symbol_limit: vectorcraft_doc::MAX_SYMBOL_NODES, overprint: false, overprinted: false, invisible: false, diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index e8057236c..dbf4b0d87 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -272,20 +272,23 @@ impl Exporter<'_> { let count = art.count() as u64; let map = doc.symbol_slice_map(symbol, *xf); let most = map.map_or(count, |m| m.most_placed(art, doc)); - let placed = vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || { - let art = match map { - Some(m) => m.place(art, doc), - None => { - let mut a = art.clone(); - a.transform(*xf, false); - a - } - }; - // As `most_placed`, placed art counts its opacity-mask art. - let count = if map.is_some() { art.count_with_masks() } else { art.count() } as u64; - (art, count) + let within = self.symbol_nodes.saturating_add(most) <= self.symbol_limit; + let placed = within.then(|| { + vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || { + let art = match map { + Some(m) => m.place(art, doc), + None => { + let mut a = art.clone(); + a.transform(*xf, false); + a + } + }; + // As `most_placed`, placed art counts its opacity-mask art. + let count = if map.is_some() { art.count_with_masks() } else { art.count() } as u64; + (art, count) + }) }); - if let Some(art) = placed { + if let Some(art) = placed.flatten() { self.node(s, &art, page, true); } } diff --git a/crates/pdf/src/export/writer.rs b/crates/pdf/src/export/writer.rs index f4a6e7f93..87b2105f3 100644 --- a/crates/pdf/src/export/writer.rs +++ b/crates/pdf/src/export/writer.rs @@ -149,7 +149,12 @@ impl Writer { let doc = ex.doc; let page_group = fits && !self.pdf13 && (doc.page_isolate || doc.page_knockout); let cmyk_page_group = fits && self.cmyk && !page_group && ex.shows_transparency(); + // Each copy draws within an equal share of the symbol objects the export has left + // ([`vectorcraft_doc::MAX_SYMBOL_NODES`]), so every copy leaves out the same instances. + let limit = ex.symbol_limit; + let share = limit.saturating_sub(ex.symbol_nodes) / moves.len() as u64; for m in moves { + ex.symbol_limit = ex.symbol_nodes.saturating_add(share); let place = place_at(*m); let placed = place != Affine::IDENTITY; if placed { @@ -180,6 +185,7 @@ impl Writer { s.pop(); } } + ex.symbol_limit = limit; if fits && let Some(art) = sheet.marks { // The drawing space the page shows (the marks' area). let shown = sheet.view.inverse().transform_rect_bbox(paper); diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index 8c154601b..c29a63722 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -301,6 +301,56 @@ fn each_copy_prints_with_its_marks() { assert_eq!(marks, 8 * copies, "8 trim marks around each copy"); } +#[test] +fn every_copy_on_a_sheet_draws_the_same_symbol_instances() { + // A symbol of three squares (4 objects with its group), placed twice. + let mut d = doc(100.0, 100.0, &[]); + let squares = (0..3) + .map(|i| { + let r = Rect::new(f64::from(i) * 10.0, 0.0, f64::from(i) * 10.0 + 5.0, 5.0); + Arc::new(Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(rgb(1.0, 0.0, 0.0), Paint::None, 0.0))) + }) + .collect(); + let art = Node::group(d.alloc_id(), squares); + d.symbols.push(vectorcraft_doc::Symbol { name: "Squares".into(), art: Arc::new(art) }); + let layer = d.default_layer().unwrap(); + for y in [10.0, 50.0] { + let instance = NodeKind::SymbolInstance { symbol: "Squares".into(), xf: vectorcraft_geom::Affine::translate((10.0, y)) }; + let id = d.alloc_id(); + d.insert(Some(layer), 0, Node::new(id, instance)).unwrap(); + } + let set = PdfSettings { compression: CompressionSettings { compress_text: false, ..Default::default() }, ..Default::default() }; + let mut w = crate::export::Writer::new(&d, &set, "Copies", None, false).unwrap(); + let mut ex = crate::export::Exporter::new(&d, &set); + // Room for 10 symbol objects: the first copy could write both instances (8) and leave the + // second copy none. + ex.symbol_limit = 10; + let copies = [vectorcraft_geom::Vec2::ZERO, vectorcraft_geom::Vec2::new(200.0, 0.0)]; + let page = Rect::new(0.0, 0.0, 300.0, 100.0); + let sheet = crate::export::Sheet { + size: (300.0, 100.0), + unit: 1.0, + trim: page, + bleed: page, + view: vectorcraft_geom::Affine::IDENTITY, + window: None, + place: vectorcraft_geom::Affine::IDENTITY, + copies: &copies, + area: Rect::new(0.0, 0.0, 100.0, 100.0), + clip: false, + marks: None, + negative: false, + links: false, + }; + w.page(&mut ex, &sheet).unwrap(); + assert_eq!(ex.symbol_limit, 10, "the limit is the export's again after the sheet"); + w.absorb(ex); + let (bytes, _) = w.finish(&mut std::collections::HashSet::new(), false).unwrap(); + let p = pages(&bytes); + let left = p[0].1.iter().filter(|(r, _)| r.x0 < 200.0).count(); + assert_eq!((left, p[0].1.len() - left), (3, 3), "one instance in each copy: {:?}", p[0].1); +} + #[test] fn tiles_cover_the_artboard_with_overlap() { // 1000 × 700 on Letter tiles (612 × 792), 36 pt overlap: 2 × 1. From 134520402bc3f9ecbd18cdb36ccf6abc5568b2de Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 16:56:36 -0700 Subject: [PATCH 4/8] PDF/print: import Vec2 in the export writer after merging main --- crates/pdf/src/export/writer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/pdf/src/export/writer.rs b/crates/pdf/src/export/writer.rs index 87b2105f3..d7e652f2e 100644 --- a/crates/pdf/src/export/writer.rs +++ b/crates/pdf/src/export/writer.rs @@ -2,7 +2,7 @@ use super::entry::RgbInCmyk; use super::exporter::{constant_mask, cover, to_path, writable, xf}; use super::{ Affine, Arc, Archival, ColorOut, Compatibility, Document, Exporter, HashSet, Image, Metadata, Node, PageSettings, PdfError, PdfVersion, Rect, - Shape, Size, Standard, find, krilla, rfind, write, + Shape, Size, Standard, Vec2, find, krilla, rfind, write, }; /// A PDF being written, shared by export and print: the file's configuration and metadata, then From 359cd3a4b8d8de4b2f26d8ffe1c8b38635ba9f9b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 08:29:11 -0800 Subject: [PATCH 5/8] Print: set the symbol's dynamic flag in the copies test after merging main --- crates/pdf/src/tests/print.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index c29a63722..694430ad6 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -312,7 +312,7 @@ fn every_copy_on_a_sheet_draws_the_same_symbol_instances() { }) .collect(); let art = Node::group(d.alloc_id(), squares); - d.symbols.push(vectorcraft_doc::Symbol { name: "Squares".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Squares".into(), art: Arc::new(art) }); let layer = d.default_layer().unwrap(); for y in [10.0, 50.0] { let instance = NodeKind::SymbolInstance { symbol: "Squares".into(), xf: vectorcraft_geom::Affine::translate((10.0, y)) }; From 62fd5964da1bab20799a27019ebb98eef9f16a8d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 08:56:15 -0800 Subject: [PATCH 6/8] i18n: drop the Gutter: rows main now carries in ru and pt-br --- crates/ui-egui/src/i18n/pt-br.tsv | 1 - crates/ui-egui/src/i18n/ru.tsv | 1 - 2 files changed, 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ae7303f24..7ea3251a1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2917,7 +2917,6 @@ digits Malayalam Malaiala Tile Type: Tipo de Bloco: Tiles are numbered across, then down; leave the range empty for every tile. Os blocos são numerados horizontalmente, depois para baixo; deixe o intervalo vazio para cada bloco. Copies per Sheet (as many as fit, in a grid) Cópias por Folha (quantas couberem, em grade) - Gutter: Medianiz: Title Case Tipo de Título Toggle Fill/Stroke Focus Alternar Foco de Preenchimento/Traçado Toggle Selection Alternar Seleção diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b4b6cad19..46c87d337 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3579,7 +3579,6 @@ digits Malayalam Малаялам tile {number} плитка {number} Tiles are numbered across, then down; leave the range empty for every tile. Плитки нумеруются слева направо, затем вниз; оставьте диапазон пустым для всех плиток. Copies per Sheet (as many as fit, in a grid) Копий на лист (сколько поместится, сеткой) - Gutter: Промежуток: Tint Оттенок Tip extends past the end point Кончик выходит за конечную точку Tip on the end point Кончик на конечной точке From 9f2fb7a9155104b7d0825bb19add59e0b9e97caf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 08:56:15 -0800 Subject: [PATCH 7/8] Print: test that print presets keep Copies per Sheet and older presets leave it off --- crates/engine/src/tests/print.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/engine/src/tests/print.rs b/crates/engine/src/tests/print.rs index a0beb2662..8ccd76deb 100644 --- a/crates/engine/src/tests/print.rs +++ b/crates/engine/src/tests/print.rs @@ -179,6 +179,33 @@ fn copies_per_sheet_print_a_grid_of_the_artboard() { assert_eq!(grid, one + 8); } +#[test] +fn print_presets_keep_copies_per_sheet_and_older_presets_leave_it_off() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 144, "height": 252})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 144, "height": 252})).unwrap(); + // [Default] prints one copy per sheet. + let v = s.execute("print.presets.list", &json!({})).unwrap(); + assert_eq!((v["presets"][0]["settings"]["copiesPerSheet"].clone(), v["presets"][0]["settings"]["gutter"].clone()), (json!(false), json!(0.0))); + // A saved preset keeps the option and the gutter, lists them as changed, and prints with them. + s.execute("print.presets.save", &json!({"name": "Cards", "settings": {"copiesPerSheet": true, "gutter": 18}})).unwrap(); + let v = s.execute("print.presets.list", &json!({})).unwrap(); + let changed: Vec<&str> = v["presets"][1]["changed"].as_array().unwrap().iter().filter_map(|c| c["option"].as_str()).collect(); + assert_eq!(changed, ["copiesPerSheet", "gutter"]); + let v = s.execute("print.preview", &json!({"preset": "Cards"})).unwrap(); + assert_eq!(v["sheets"][0]["copies"].as_array().map(Vec::len), Some(9)); + // The option survives export and import. + let text = s.execute("print.presets.export", &json!({"names": ["Cards"]})).unwrap()["data"].as_str().unwrap().to_string(); + let mut t = Session::new(); + t.execute("print.presets.import", &json!({"data": text})).unwrap(); + assert_eq!((t.prefs.print_presets[0].settings.copies_per_sheet, t.prefs.print_presets[0].settings.gutter), (true, 18.0)); + // A presets file written without the option imports with it off and no gutter. + let old = r#"{"format": "vcprintpresets", "presets": [{"name": "Old", "settings": {"copies": 2}}]}"#; + t.execute("print.presets.import", &json!({"data": old})).unwrap(); + let q = &t.prefs.print_presets[1].settings; + assert_eq!((q.copies, q.copies_per_sheet, q.gutter), (2, false, 0.0)); +} + #[test] fn bad_settings_are_bad_params() { let (mut s, _) = session(); From 000daef5a6eb27dea31e8d9425d322b402e9bb0c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 09:17:08 -0800 Subject: [PATCH 8/8] Print: the page boxes of a Copies per Sheet page hold only the copies on the paper When the placement moved some copies off the paper, each of those copies counted as the whole page, so the TrimBox and BleedBox became the paper. They now hold the copies that print, and the whole page only when no copy is on it. --- crates/pdf/src/print/layout.rs | 16 +++++++++++----- crates/pdf/src/tests/print.rs | 3 +++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/pdf/src/print/layout.rs b/crates/pdf/src/print/layout.rs index 9002ceb80..3d404abab 100644 --- a/crates/pdf/src/print/layout.rs +++ b/crates/pdf/src/print/layout.rs @@ -246,12 +246,14 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec 0.0 { (t, b) } else { (page, page) } + (t.area() > 0.0).then_some((t, b)) }; + let boxes = |view: Affine, trim: Rect| visible(view, trim).unwrap_or((page, page)); let base = |view, window, place, area, trim, (page_trim, page_bleed), tile, copies| Layout { artboard, tile, @@ -305,8 +307,12 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec = grid.map_or_else(Vec::new, |(cols, rows)| { (0..rows).flat_map(|j| (0..cols).map(move |i| Vec2::new(i as f64 * step.0, j as f64 * step.1))).collect() }); - let page_boxes = - copies.iter().map(|m| boxes(turn, trim + *m)).reduce(|a, b| (a.0.union(b.0), a.1.union(b.1))).unwrap_or_else(|| boxes(turn, trim)); + // The boxes hold the copies on the page; copies off it are left out. + let page_boxes = copies + .iter() + .filter_map(|m| visible(turn, trim + *m)) + .reduce(|a, b| (a.0.union(b.0), a.1.union(b.1))) + .unwrap_or_else(|| boxes(turn, trim)); pages.push(base(turn, None, place_at(trim), bleed_box, trim, page_boxes, None, copies)); continue; } diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index 694430ad6..daf2bab07 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -246,6 +246,9 @@ fn copies_per_sheet_fill_the_page_in_a_grid() { // The placement places the grid, inside the margin. let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "margin": 9, "placement": {"origin": "topLeft"}}))).unwrap(); assert_eq!(corners(&pv), grid(&[9.0, 153.0, 297.0, 441.0], &[9.0, 261.0, 513.0])); + // Moved 400 pt right, two columns leave the paper: the TrimBox holds the copies still on it. + let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "placement": {"origin": "topLeft", "x": 400}}))).unwrap(); + assert_eq!(pv.sheets[0].trim, [400.0, 0.0, 612.0, 756.0]); } #[test]