Merge pull request #2552 from dotCooCoo/feat/print-copies-per-sheet

Print: Copies per Sheet prints as many copies of each artboard as fit the sheet
This commit is contained in:
BFlat
2026-11-09 11:17:48 +08:00
committed by GitHub
39 changed files with 520 additions and 91 deletions
+4 -3
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"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<CommandSpec> {
"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<Value> {
view: sh.view,
window: sh.window,
place: sh.place,
copies: &sh.copies,
area: sh.area,
marks: sh.marks.as_ref(),
negative: sh.negative,
+63
View File
@@ -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}");
+36 -13
View File
@@ -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<Rect>,
/// 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<Page>), 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<Page>), 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<Page>), 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))
+27
View File
@@ -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();
+3
View File
@@ -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)]
+1
View File
@@ -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,
+1
View File
@@ -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,
+16 -13
View File
@@ -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);
}
}
+55 -30
View File
@@ -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<Rect>,
/// 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() {
+18 -4
View File
@@ -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<PrintPreview, PdfE
transform: (l.view * l.place).as_coeffs(),
area: [l.area.x0, l.area.y0, l.area.x1, l.area.y1],
trim: [l.page_trim.x0, l.page_trim.y0, l.page_trim.x1, l.page_trim.y1],
copies: l.copies.iter().map(|m| l.view.transform_rect_bbox(l.trim + *m)).map(|r| [r.x0, r.y0, r.x1, r.y1]).collect(),
})
})
.collect();
@@ -312,6 +320,10 @@ pub struct PlannedSheet {
pub window: Option<Rect>,
/// 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<Vec2>,
/// 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<PrintPlan<'a>,
view: l.view,
window: l.window,
place: l.place,
copies: l.copies.clone(),
area: l.area,
marks: art,
negative: set.output.image == PrintImage::Negative,
+58 -14
View File
@@ -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<Vec2>,
}
/// The tiles of one artboard (`print.preview`).
@@ -148,6 +155,13 @@ fn span(start: Option<f64>, 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<Str
let place_at = |trim: Rect| {
Affine::translate(trim.origin().to_vec2()) * Affine::scale_non_uniform(scale.0, scale.1) * Affine::translate(-r.origin().to_vec2())
};
// The page's boxes: the trim box within the bleed box within the page.
let boxes = |view: Affine, trim: Rect| {
// The page's boxes: the trim box within the bleed box within the page; `None` when none of
// the trim box is on the page.
let visible = |view: Affine, trim: Rect| {
let b = view.transform_rect_bbox(outset(trim, bleed_s)).intersect(page);
let t = view.transform_rect_bbox(trim).intersect(b);
if t.area() > 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<Str
bleed: bleed_s,
page_trim,
page_bleed,
copies,
};
if !set.scaling.tiles() {
// The printed area's top-left corner: where the tile origin puts it, else the placement.
// With copies per sheet, the grid holds as many printed areas as fit the imageable
// area, `gutter` apart, and is placed as one; when not even one fits, the artboard
// prints alone.
let gutter = set.gutter;
let fit = set.copies_per_sheet.then(|| (fitting(imageable.width(), extent.0, gutter), fitting(imageable.height(), extent.1, gutter)));
let grid = match fit {
Some((cols, rows)) if cols < 1.0 || rows < 1.0 => {
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<Vec2> = 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<Str
let w = window(t);
let view = turn * Affine::translate((at.0 - w.x0, at.1 - w.y0));
let area = from_doc.transform_rect_bbox(w).intersect(bleed_box);
pages.push(base(view, Some(w), place, area, trim, boxes(view, trim), Some((t, count))));
pages.push(base(view, Some(w), place, area, trim, boxes(view, trim), Some((t, count)), vec![]));
}
let tiles = (0..count).map(|t| from_doc.transform_rect_bbox(window(t))).map(|b| [b.x0, b.y0, b.x1, b.y1]).collect();
grids.push(TileGrid { artboard, columns: cols, rows, printed: pick.iter().map(|t| t + 1).collect(), tiles });
+8
View File
@@ -349,6 +349,11 @@ pub struct PrintSettings {
pub overlap: f64,
/// 1-based tiles to print (`"1-3, 5"`, numbered across then down); empty: every tile.
pub tile_range: String,
/// Print as many copies of the artboard as fit the imageable area on each sheet, in a grid
/// (not when tiling).
pub copies_per_sheet: bool,
/// The space between copies on a sheet (pt).
pub gutter: f64,
/// The device's unprintable margin around the paper (pt): the imageable area is inside it.
pub margin: f64,
// Marks and Bleed.
@@ -384,6 +389,8 @@ impl Default for PrintSettings {
scale: PrintScale::default(),
overlap: 0.0,
tile_range: String::new(),
copies_per_sheet: false,
gutter: 0.0,
margin: 0.0,
marks: MarkSettings::default(),
// The document's bleed (Document Setup) unless other values are given.
@@ -576,6 +583,7 @@ impl PrintSettings {
within("scale.width", self.scale.width, 1.0, 1000.0, "%")?;
within("scale.height", self.scale.height, 1.0, 1000.0, "%")?;
within("overlap", self.overlap, 0.0, 720.0, " pt")?;
within("gutter", self.gutter, 0.0, 720.0, " pt")?;
within("margin", self.margin, 0.0, 144.0, " pt")?;
within("tileOrigin.x", self.tile_origin.x, -14_400.0, 14_400.0, " pt")?;
within("tileOrigin.y", self.tile_origin.y, -14_400.0, 14_400.0, " pt")?;
+142
View File
@@ -212,6 +212,148 @@ fn placement_and_custom_scale() {
assert!(job(&big, json!({"autoRotate": false})).warnings.iter().any(|w| w.contains("larger than the imageable area")));
}
/// The top-left corners of the copies on the first page of `pv`.
fn corners(pv: &PrintPreview) -> 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.
+25 -10
View File
@@ -427,22 +427,32 @@ fn page_preview(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, pv:
}
let coeffs: Vec<f64> = 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<DRect> = sheet["copies"].as_array().map(|a| a.iter().filter_map(rect_of).collect()).unwrap_or_default();
let moves: Vec<egui::Vec2> = 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::<PrintLayers>(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<Value, String>) {
+20
View File
@@ -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];
+1
View File
@@ -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 Връх върху крайната точка
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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 Κεφαλαίο σε κάθε λέξη
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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é
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2301 and column 5.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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 列を切り替え
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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 한 줄/두 줄 전환
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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 Кончик на конечной точке
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -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 Кінчик на кінцевій точці
Can't render this file because it contains an unexpected character in line 182 and column 5.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 204 and column 5.
+1
View File
@@ -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 箭头位于端点
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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 尖端位於終點
Can't render this file because it contains an unexpected character in line 6 and column 47.
+11 -4
View File
@@ -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
+1
View File
@@ -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`).