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..8ccd76deb 100644 --- a/crates/engine/src/tests/print.rs +++ b/crates/engine/src/tests/print.rs @@ -144,6 +144,68 @@ 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 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(); @@ -154,6 +216,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.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/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/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 7f9b939f9..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 @@ -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,62 @@ 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(); + // 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 { + 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(); + } } + 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); + 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..3d404abab 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)) { @@ -232,13 +246,15 @@ 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 base = |view, window, place, area, trim, (page_trim, page_bleed), tile| Layout { + 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, size: page_size, @@ -252,24 +268,52 @@ 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() + }); + // 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; } // Tiles across then down over the printed area, overlapping by `overlap`. @@ -312,7 +356,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])); + // 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] +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 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 { 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)) }; + 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. 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 1a8c4c353..e4caf74f5 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3463,6 +3463,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 bebdb3ac0..0444d5cb3 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2714,6 +2714,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/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 429738169..43a134d27 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2502,6 +2502,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/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0fbf2d153..2e2267941 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3495,6 +3495,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 b3d38e345..91a929c21 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3887,6 +3887,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 e60255872..8df0a3953 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3551,6 +3551,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 42b5e5e99..6a980373a 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2658,6 +2658,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 8ba60f345..72f1b84cd 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3542,6 +3542,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 16513f7dc..2797d4cf5 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4225,6 +4225,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/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 054938af1..54e894885 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2502,6 +2502,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/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8283b4448..50324a528 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3587,6 +3587,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 50d979fad..4abe7747f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3279,6 +3279,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 fe60a07b1..4647c4f86 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3268,6 +3268,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/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index f9c6e6377..81320e62c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2516,6 +2516,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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3595a6a99..4107219d4 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2627,6 +2627,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 ab2e91ea1..699ea20f8 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2916,6 +2916,7 @@ 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) 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 9579b73d5..0f0a2ed78 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3582,6 +3582,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/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index c1a55bd2c..90fae3ceb 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3473,6 +3473,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 d74783d02..83761eb95 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3580,6 +3580,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 7939ac2fe..bc29dc0c1 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3927,6 +3927,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 c821b3cdc..9b9dc822d 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 c8f7bd567..402e562f7 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4476,6 +4476,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 796e67b8d..9b373aea3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5177,6 +5177,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, @@ -5473,10 +5479,11 @@ its pixels are decoded. `file.place {path}` links the file like any image. Vecto ## 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 324102e09..e9a15af0a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1405,6 +1405,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`).