mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Merge pull request #120 from storytold/pr/m4-print-tiling
M4.80–M4.81: Print Tiling and advanced print options
This commit is contained in:
+7
-7
@@ -33,7 +33,7 @@ since 2026-10-01.
|
||||
|
||||
| Dimension | Estimate | Evidence and what's missing |
|
||||
|---|---|---|
|
||||
| **Breadth:** menus, tools and panels exist | ~90% | 18 menu items still stubbed; every tool implemented except Touch Type, vertical type ×3 and Print Tiling; 51 panel modules |
|
||||
| **Breadth:** menus, tools and panels exist | ~90% | 18 menu items still stubbed; every tool implemented except Touch Type and vertical type ×3; 51 panel modules |
|
||||
| **Depth:** each feature behaves like Illustrator | ~68–74% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~35%), brushes and symbols (in progress), raster effects (~20%) |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, vertical/CJK type, Variables (data merge), scripting |
|
||||
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
|
||||
@@ -64,7 +64,7 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
|
||||
5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries
|
||||
generated in code (never Adobe's); dynamic symbols; Start Global Edit. 14–22 h.
|
||||
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
|
||||
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, the Print Tiling tool, video
|
||||
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video
|
||||
rulers. 15–25 h.
|
||||
8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files, idle-machine perf budgets, Windows, Linux and
|
||||
browser QA, accessibility, packaging. 50–80 h.
|
||||
@@ -128,7 +128,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash, background save and export.
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts).
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export.
|
||||
- Place and the Links panel, Package, File Info, Print with print presets and PostScript output, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours.
|
||||
- Place and the Links panel, Package, 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.
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
|
||||
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
|
||||
|
||||
@@ -147,7 +147,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
| M11 | Artboards & views (artboard panel/tool done, Trim View, middle-button pan; print tiling, multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 |
|
||||
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux and content-sized dialogs and menus done; accessibility, Windows/Linux packaging pending | 18–28 |
|
||||
@@ -183,11 +183,11 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 75% | symbol libraries (original), dynamic symbols, perspective edge cases | 8–12 |
|
||||
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish | 6–10 |
|
||||
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 |
|
||||
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, print tiling, Snap to Pixel/Glyph | 10–15 |
|
||||
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth | 4–8 |
|
||||
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X and presets are done | 6–10 |
|
||||
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
|
||||
| Print, colour management, separations, flattener | 3 | 70% | Print Tiling tool and Show Print Tiling, flattener presets in print/PDF, print preview fidelity (Print dialog, presets, PostScript, marks and separations are done) | 5–8 |
|
||||
| Print, colour management, separations, flattener | 3 | 78% | flattener presets in PDF export, print preview fidelity, `/OP` in composite print (Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 2–4 |
|
||||
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
|
||||
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows (Links, Asset Export, CSS Properties, Attributes are done) | 15–25 |
|
||||
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 |
|
||||
@@ -201,7 +201,7 @@ With 4–6 agents working on disjoint crates (as the layering allows) the wall-c
|
||||
**~85–150 h** to full parity.
|
||||
|
||||
_Inventories (2026-10-05):_ 18 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
|
||||
implemented except five (Touch Type, vertical type ×3, Print Tiling); 51 panel modules; Illustrator-style live
|
||||
implemented except four (Touch Type, vertical type ×3); 51 panel modules; Illustrator-style live
|
||||
effects ~44/54, Photoshop-style raster effects ~1/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust.
|
||||
|
||||
_Where we already beat Illustrator:_ exact curve booleans, off-thread multithreaded rendering, undo that never runs out,
|
||||
|
||||
@@ -149,6 +149,33 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any fill or stroke of `n`'s subtree overprints, characters' too.
|
||||
fn overprints_anywhere(n: &Node) -> bool {
|
||||
n.appearance.items.iter().any(|i| i.overprint())
|
||||
|| matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| r.style.overprint_fill || r.style.overprint_stroke))
|
||||
|| n.children().is_some_and(|c| c.iter().any(|c| overprints_anywhere(c)))
|
||||
}
|
||||
|
||||
/// Stop every fill and stroke of `a`'s subtree overprinting (characters' too), copying only the
|
||||
/// nodes on the way to them.
|
||||
pub fn clear_overprints(a: &mut Arc<Node>) {
|
||||
if !overprints_anywhere(a) {
|
||||
return;
|
||||
}
|
||||
let n = Arc::make_mut(a);
|
||||
for it in &mut n.appearance.items {
|
||||
*it.overprint_mut() = false;
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
for r in &mut t.runs {
|
||||
(r.style.overprint_fill, r.style.overprint_stroke) = (false, false);
|
||||
}
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
clear_overprints(c);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -47,7 +47,9 @@ pub mod pdfcmds;
|
||||
mod place;
|
||||
pub mod prefscmds;
|
||||
pub mod print;
|
||||
pub(crate) mod printadvanced;
|
||||
pub mod printpresets;
|
||||
pub mod printtiling;
|
||||
pub mod rasterfx;
|
||||
mod recolor;
|
||||
pub mod recovery;
|
||||
@@ -241,6 +243,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(assets::specs());
|
||||
v.extend(css::specs());
|
||||
v.extend(printpresets::specs());
|
||||
v.extend(printtiling::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ use vectorcraft_pdf::{PrintOptions, PrintSettings};
|
||||
|
||||
use super::fileio::pdf::{merge, pdf_error};
|
||||
use super::fileio::{extension, write_or_return};
|
||||
use super::flatten::{FlattenOptions, flatten_document};
|
||||
use super::*;
|
||||
|
||||
const SETUP: &str = "print.setup";
|
||||
@@ -22,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print Setup",
|
||||
[],
|
||||
None,
|
||||
"{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|b4|b5|custom, 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 in separations)}}} store the print settings with the document, over the ones it has (null keeps a value), as one undo step; no settings → {settings} (the current ones, defaults if never set up)",
|
||||
"{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|b4|b5|custom, 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; 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 (null keeps a value), as one undo step; no settings → {settings} (the current ones, defaults if never set up)",
|
||||
has_doc,
|
||||
setup
|
||||
),
|
||||
@@ -53,7 +52,7 @@ fn saved(doc: &Document) -> Value {
|
||||
}
|
||||
|
||||
/// The settings of `doc` with `p.settings` over them, checked.
|
||||
fn settings(cmd: &str, doc: &Document, p: &Value) -> Result<PrintSettings> {
|
||||
pub(crate) fn settings(cmd: &str, doc: &Document, p: &Value) -> Result<PrintSettings> {
|
||||
settings_over(cmd, saved(doc), p)
|
||||
}
|
||||
|
||||
@@ -69,7 +68,7 @@ pub(crate) fn settings_over(cmd: &str, mut v: Value, p: &Value) -> Result<PrintS
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
fn to_json(set: &PrintSettings) -> Result<Value> {
|
||||
pub(crate) fn to_json(set: &PrintSettings) -> Result<Value> {
|
||||
serde_json::to_value(set).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
@@ -90,6 +89,7 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "print.preview";
|
||||
let doc = &s.doc()?.doc;
|
||||
let set = settings(C, doc, p)?;
|
||||
super::printadvanced::flattener(C, &set, &s.prefs.flattener_presets)?;
|
||||
let pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?;
|
||||
let mut v = serde_json::to_value(pv).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
v["settings"] = to_json(&set)?;
|
||||
@@ -110,25 +110,25 @@ fn to_postscript(p: &Value) -> Result<bool> {
|
||||
|
||||
fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let postscript = to_postscript(p)?;
|
||||
let flattener = if postscript {
|
||||
let preset = str_param(p, "flattenerPreset").unwrap_or("medium");
|
||||
Some(
|
||||
FlattenOptions::find_preset(preset, &s.prefs.flattener_presets)
|
||||
.ok_or_else(|| bad(PRINT, format!("flattenerPreset `{preset}`: high, medium, low or a saved one (see flattener.presets.list)")))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let doc = &s.doc()?.doc;
|
||||
let set = settings(PRINT, doc, p)?;
|
||||
let mut set = settings(PRINT, doc, p)?;
|
||||
if postscript {
|
||||
// PostScript has no transparency: `flattenerPreset`, else the Advanced preset, else medium.
|
||||
let given = str_param(p, "flattenerPreset").filter(|n| !n.trim().is_empty());
|
||||
let preset = given.or(Some(set.advanced.flattener_preset.as_str()).filter(|n| !n.trim().is_empty())).unwrap_or("medium");
|
||||
set.advanced.flattener_preset = preset.to_string();
|
||||
}
|
||||
// Raster effects print as images at the document's raster effects resolution.
|
||||
let flat = super::rasterfx::flatten_raster_effects(doc);
|
||||
let doc = flat.as_ref().unwrap_or(doc);
|
||||
// Print as Bitmap and the flattener preset (Advanced) render and flatten it first.
|
||||
let prepared = super::printadvanced::prepare(PRINT, doc, &set, &s.prefs.flattener_presets)?;
|
||||
let path = str_param(p, "path");
|
||||
let Some(flattener) = flattener else {
|
||||
let r = vectorcraft_pdf::print(doc, &PrintOptions { settings: set, ..Default::default() }).map_err(|e| pdf_error(PRINT, e))?;
|
||||
if !postscript {
|
||||
let r = vectorcraft_pdf::print(prepared.as_ref().unwrap_or(doc), &PrintOptions { settings: set, ..Default::default() })
|
||||
.map_err(|e| pdf_error(PRINT, e))?;
|
||||
return write_or_return(path, &r.bytes, json!({ "pages": r.pages, "format": "pdf", "warnings": r.warnings }));
|
||||
};
|
||||
}
|
||||
let level = match p.get("level").filter(|v| !v.is_null()) {
|
||||
Some(v) => {
|
||||
let t = super::fileio::dxf::text(v);
|
||||
@@ -136,14 +136,16 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => Level::default(),
|
||||
};
|
||||
// PostScript has no transparency.
|
||||
let flat = flatten_document(doc, &flattener)?;
|
||||
let mut warnings = vec![];
|
||||
if flat.is_some() && doc.layers.iter().any(|l| l.shows_transparency()) {
|
||||
let bitmap = set.advanced.print_as_bitmap && set.output.mode == vectorcraft_pdf::OutputMode::Composite;
|
||||
if !bitmap && doc.layers.iter().any(|l| l.shows_transparency()) {
|
||||
warnings.push("transparency is flattened into opaque art and images (PostScript has none): see flattenerPreset".to_string());
|
||||
}
|
||||
if !set.color.profile.trim().is_empty() && set.output.mode == vectorcraft_pdf::OutputMode::Composite {
|
||||
warnings.push("the printer profile converts the colours of PDF output: PostScript prints the document's colours".to_string());
|
||||
}
|
||||
let flatness = (!set.graphics.auto_flatness).then_some(set.graphics.flatness);
|
||||
let plan = vectorcraft_pdf::plan(flat.as_ref().unwrap_or(doc), &PrintOptions { settings: set, postscript: true, ..Default::default() })
|
||||
let plan = vectorcraft_pdf::plan(prepared.as_ref().unwrap_or(doc), &PrintOptions { settings: set, postscript: true, ..Default::default() })
|
||||
.map_err(|e| pdf_error(PRINT, e))?;
|
||||
let pages: Vec<PrintPage> = plan
|
||||
.sheets
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! File → Print › Advanced, what needs the renderer and the flattener (the PDF writer does the
|
||||
//! rest): Print as Bitmap renders each printed region as one image, and a flattener preset
|
||||
//! flattens transparency, both on the document as it prints (only the layers that print,
|
||||
//! overprints discarded or simulated in composite output) before it is laid out on paper.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::marks::outset;
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, LayerColor, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
use vectorcraft_pdf::{OutputMode, PrintOverprints, PrintSettings};
|
||||
use vectorcraft_render::{AntiAlias, RenderOptions};
|
||||
|
||||
use super::fileio::pdf::pdf_error;
|
||||
use super::flatten::{FlattenOptions, FlattenerPreset, flatten_document};
|
||||
use super::menucmds::{MAX_PIXELS, unique_key};
|
||||
use super::*;
|
||||
|
||||
/// The flattener options of `set`'s preset (`None`: transparency prints live); an error naming
|
||||
/// `cmd` for a preset that is neither built in nor in `saved`.
|
||||
pub(crate) fn flattener(cmd: &str, set: &PrintSettings, saved: &[FlattenerPreset]) -> Result<Option<FlattenOptions>> {
|
||||
let name = set.advanced.flattener_preset.trim();
|
||||
if name.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let o = FlattenOptions::find_preset(name, saved)
|
||||
.ok_or_else(|| bad(cmd, format!("flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list)")))?;
|
||||
Ok(Some(o))
|
||||
}
|
||||
|
||||
/// `doc` as Print as Bitmap and the flattener preset of `set` print it (from `saved` presets), or
|
||||
/// `None` when neither applies. Print as Bitmap is for composite output.
|
||||
pub(crate) fn prepare(cmd: &str, doc: &Document, set: &PrintSettings, saved: &[FlattenerPreset]) -> Result<Option<Document>> {
|
||||
let composite = set.output.mode == OutputMode::Composite;
|
||||
let bitmap = set.advanced.print_as_bitmap && composite;
|
||||
let flattener = flattener(cmd, set, saved)?;
|
||||
if !bitmap && flattener.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let printed = vectorcraft_pdf::printed_document(doc, set);
|
||||
if bitmap {
|
||||
return bitmap_document(cmd, &printed, set).map(Some);
|
||||
}
|
||||
let Some(mut o) = flattener else { return Ok(None) };
|
||||
// Composite overprints that were discarded or simulated aren't preserved either.
|
||||
o.preserve_overprints &= !composite || set.advanced.overprints == PrintOverprints::Preserve;
|
||||
Ok(Some(flatten_document(&printed, &o)?.unwrap_or_else(|| printed.into_owned())))
|
||||
}
|
||||
|
||||
/// Leave guide paths out of `nodes` (the renderer draws them; they never print).
|
||||
fn drop_guides(nodes: &mut Vec<Arc<Node>>) {
|
||||
fn has_guides(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Path { guide: true, .. }) || n.children().is_some_and(|c| c.iter().any(|c| has_guides(c)))
|
||||
}
|
||||
nodes.retain(|n| !matches!(n.kind, NodeKind::Path { guide: true, .. }));
|
||||
for n in nodes.iter_mut().filter(|n| has_guides(n)) {
|
||||
if let Some(children) = Arc::make_mut(n).children_mut() {
|
||||
drop_guides(children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `doc` (as it prints) with its art replaced by one image of each printed region (an artboard
|
||||
/// with its bleed, or all the art), at the document's raster effects resolution; regions where
|
||||
/// nothing is drawn get none.
|
||||
fn bitmap_document(cmd: &str, doc: &Document, set: &PrintSettings) -> Result<Document> {
|
||||
let regions = vectorcraft_pdf::print_regions(doc, set).map_err(|e| pdf_error(cmd, e))?;
|
||||
let bleed = set.bleed.of(doc);
|
||||
let mut art = doc.clone();
|
||||
drop_guides(&mut art.layers);
|
||||
let anti_alias = if doc.raster_effects.anti_alias { AntiAlias::Art } else { AntiAlias::None };
|
||||
let opts = RenderOptions { skip_templates: true, anti_alias, ..Default::default() };
|
||||
let mut out = doc.clone();
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
let mut images = vec![];
|
||||
for (_, r) in regions {
|
||||
let area = outset(r, bleed);
|
||||
// At most MAX_PIXELS, and 65535 pixels a side.
|
||||
let side = area.width().max(area.height()).max(1.0);
|
||||
let scale = (doc.raster_effects_ppi / 72.0).min((MAX_PIXELS / (area.width() * area.height()).max(1.0)).sqrt()).min(65_535.0 / side);
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
continue;
|
||||
}
|
||||
// Whole pixels.
|
||||
let region = Rect::new(
|
||||
area.x0,
|
||||
area.y0,
|
||||
area.x0 + (area.width() * scale).ceil().max(1.0) / scale,
|
||||
area.y0 + (area.height() * scale).ceil().max(1.0) / scale,
|
||||
);
|
||||
let img = renderer.render_region_with(&art, region, scale, &opts);
|
||||
if img.pixels.as_chunks::<4>().0.iter().all(|px| px[3] == 0) {
|
||||
continue;
|
||||
}
|
||||
let key = unique_key(&out, "print-bitmap");
|
||||
out.images.insert(key.clone(), ImageBlob::new("image/png", img.to_png().map_err(EngineError::Other)?));
|
||||
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
||||
let image = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() };
|
||||
images.push(Arc::new(Node::new(out.alloc_id(), NodeKind::Image(image))));
|
||||
}
|
||||
let mut layer = Node::layer(out.alloc_id(), "Bitmap", LayerColor::Preset(0));
|
||||
if let Some(children) = layer.children_mut() {
|
||||
*children = images;
|
||||
}
|
||||
out.layers = vec![Arc::new(layer)];
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! View → Show Print Tiling (`view.printTiling`) and the Print Tiling tool (`print.tiling.set`):
|
||||
//! the pages of the document's print settings drawn on the canvas, from `print.preview`'s layout
|
||||
//! (`print.tiling`), and where the tool put them ([`vectorcraft_pdf::TileOrigin`], saved with
|
||||
//! the print settings).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_pdf::{TileOrigin, TilingPage};
|
||||
|
||||
use super::fileio::pdf::pdf_error;
|
||||
use super::print::{settings, to_json};
|
||||
use super::*;
|
||||
|
||||
const SET: &str = "print.tiling.set";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "view.printTiling",
|
||||
"Show Print Tiling",
|
||||
["View"],
|
||||
None,
|
||||
"{on?: bool (default: toggle)} show the pages of the document's print settings on the canvas (each document has its own setting; the Print Tiling tool shows them too) → {on}",
|
||||
has_doc,
|
||||
show
|
||||
),
|
||||
cmd!(
|
||||
"print.tiling.set",
|
||||
"Print Tiling",
|
||||
[],
|
||||
None,
|
||||
"{origin: [x, y] (document point: the top-left corner of the first page's imageable area), artboard? (0-based: the artboard it is measured from; default the printed artboard containing origin, else the first; ignored with artboards: ignore)} | {reset: true} place the printed pages as the Print Tiling tool does, saved with the print settings (tileOrigin: {placed, x, y}, from the artboard's top-left corner; it replaces the placement, and tiles start there), as one undo step; reset → the placement places them again → {tileOrigin}",
|
||||
has_doc,
|
||||
set_origin
|
||||
),
|
||||
cmd!(
|
||||
query "print.tiling",
|
||||
"Print Tiling Pages",
|
||||
[],
|
||||
None,
|
||||
"{settings?: {…print.setup settings} (over the document's)} the pages as View → Show Print Tiling draws them, from print.preview's layout → {tileOrigin, pages: [{artboard (0-based; null with artboards ignored), number (1-based tile across then down, or page), printed (tiles outside tileRange don't), page: [x0, y0, x1, y1] (the paper's edge), imageable: [x0, y0, x1, y1] (inside the unprintable margin)}] (document space; every tile when tiling, else each page once)}",
|
||||
has_doc,
|
||||
tiling
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn show(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc_mut()?;
|
||||
st.print_tiling = p.get("on").and_then(Value::as_bool).unwrap_or(!st.print_tiling);
|
||||
Ok(json!({ "on": st.print_tiling }))
|
||||
}
|
||||
|
||||
/// The pages of `doc`'s print settings in document space (what View → Show Print Tiling draws);
|
||||
/// an error when they can't print.
|
||||
pub fn pages(doc: &Document) -> Result<Vec<TilingPage>> {
|
||||
pages_with(doc, &json!({}))
|
||||
}
|
||||
|
||||
fn pages_with(doc: &Document, p: &Value) -> Result<Vec<TilingPage>> {
|
||||
const C: &str = "print.tiling";
|
||||
let set = settings(C, doc, p)?;
|
||||
let pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?;
|
||||
Ok(vectorcraft_pdf::tiling(&pv, &set))
|
||||
}
|
||||
|
||||
fn tiling(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = &s.doc()?.doc;
|
||||
let origin = settings("print.tiling", doc, p)?.tile_origin;
|
||||
Ok(json!({ "tileOrigin": origin, "pages": pages_with(doc, p)? }))
|
||||
}
|
||||
|
||||
/// `[x, y]` at `key` of `p`.
|
||||
fn point(p: &Value, key: &str) -> Option<Point> {
|
||||
let a = p.get(key)?.as_array()?;
|
||||
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
|
||||
}
|
||||
|
||||
fn set_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = &s.doc()?.doc;
|
||||
let current = settings(SET, doc, &json!({}))?;
|
||||
let mut set = current.clone();
|
||||
set.tile_origin = if bool_or(p, "reset", false) {
|
||||
TileOrigin::default()
|
||||
} else {
|
||||
let at = point(p, "origin").filter(|q| q.x.is_finite() && q.y.is_finite());
|
||||
let at = at.ok_or_else(|| bad(SET, "give origin: [x, y], or reset: true"))?;
|
||||
let regions = vectorcraft_pdf::print_regions(doc, &set).map_err(|e| pdf_error(SET, e))?;
|
||||
let picked = match p.get("artboard").filter(|v| !v.is_null()) {
|
||||
Some(v) => {
|
||||
let i = v.as_u64().ok_or_else(|| bad(SET, "artboard must be a 0-based index"))? as usize;
|
||||
regions.iter().find(|(a, _)| a.is_none() || *a == Some(i)).ok_or_else(|| bad(SET, format!("artboard {i} doesn't print")))?
|
||||
}
|
||||
None => regions.iter().find(|(_, r)| r.contains(at)).or(regions.first()).ok_or_else(|| bad(SET, "nothing prints"))?,
|
||||
};
|
||||
let corner = picked.1.origin();
|
||||
TileOrigin { placed: true, x: at.x - corner.x, y: at.y - corner.y }
|
||||
};
|
||||
set.check().map_err(|e| pdf_error(SET, e))?;
|
||||
if set != current {
|
||||
let stored = to_json(&set)?;
|
||||
s.edit("Print Tiling", |d, _| {
|
||||
d.print_setup = Some(stored);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "tileOrigin": set.tile_origin }))
|
||||
}
|
||||
@@ -129,6 +129,8 @@ pub struct DocState {
|
||||
pub recovered: bool,
|
||||
/// The document's Data Recovery copy, once one was written ([`cmd::recovery`]).
|
||||
pub recovery: Option<cmd::recovery::RecoveryCopy>,
|
||||
/// View → Show Print Tiling, per document (view state: not saved, not undoable).
|
||||
pub print_tiling: bool,
|
||||
}
|
||||
|
||||
static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -160,6 +162,7 @@ impl DocState {
|
||||
view,
|
||||
recovered: false,
|
||||
recovery: None,
|
||||
print_tiling: false,
|
||||
}
|
||||
}
|
||||
/// Unsaved changes: the document differs from the saved one (selection changes don't count).
|
||||
@@ -1215,12 +1218,16 @@ mod tests_previewbounds;
|
||||
#[cfg(test)]
|
||||
mod tests_print;
|
||||
#[cfg(test)]
|
||||
mod tests_printadvanced;
|
||||
#[cfg(test)]
|
||||
mod tests_printpresets;
|
||||
#[cfg(test)]
|
||||
mod tests_printpreview;
|
||||
#[cfg(test)]
|
||||
mod tests_printps;
|
||||
#[cfg(test)]
|
||||
mod tests_printtiling;
|
||||
#[cfg(test)]
|
||||
mod tests_proxyitems;
|
||||
#[cfg(test)]
|
||||
mod tests_rastersettings;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! File → Print › Advanced: simulated overprints print what Overprint Preview shows, Print as
|
||||
//! Bitmap prints images of the art, and a flattener preset flattens transparency first.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_pdf::PrintSettings;
|
||||
use vectorcraft_render::{RenderOptions, Renderer};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A 200 × 100 CMYK document: a cyan square under a yellow one whose fill overprints, and a
|
||||
/// half-transparent magenta one; their ids.
|
||||
fn session() -> (Session, [u64; 3]) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "colorMode": "cmyk"})).unwrap();
|
||||
let mut ids = [0; 3];
|
||||
for (i, (x, color)) in [(10, "#00ffff"), (40, "#ffff00"), (120, "#ff00ff")].into_iter().enumerate() {
|
||||
let r = s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
ids[i] = r["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [ids[i]], "color": color})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [ids[i]], "none": true})).unwrap();
|
||||
}
|
||||
s.execute("object.setOverprint", &json!({"ids": [ids[1]], "fill": true})).unwrap();
|
||||
s.execute("transparency.set", &json!({"ids": [ids[2]], "opacity": 50, "item": null})).unwrap();
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
fn settings(v: Value) -> PrintSettings {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
|
||||
fn doc(s: &Session) -> &Document {
|
||||
&s.doc().unwrap().doc
|
||||
}
|
||||
|
||||
/// `doc` rendered over its artboard at 2 px/pt on white.
|
||||
fn pixels(doc: &Document, opts: RenderOptions) -> Vec<u8> {
|
||||
let opts = RenderOptions { background: Some([255; 4]), skip_templates: true, ..opts };
|
||||
Renderer::new().render_region_with(doc, Rect::new(0.0, 0.0, 200.0, 100.0), 2.0, &opts).pixels
|
||||
}
|
||||
|
||||
/// The pixel of the page `pdf` prints at document point `(x, y)` of the first sheet.
|
||||
fn printed_pixel(s: &mut Session, settings: Value, (x, y): (f64, f64)) -> [u8; 4] {
|
||||
let v = s.execute("file.print", &json!({ "settings": settings })).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let pv = s.execute("print.preview", &json!({ "settings": settings })).unwrap();
|
||||
let t: Vec<f64> = pv["sheets"][0]["transform"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
let back = vectorcraft_pdf::import(&bytes).unwrap();
|
||||
let page = back.artboards[0].rect;
|
||||
let (px, py) = (page.x0 + t[0] * x + t[2] * y + t[4], page.y0 + t[1] * x + t[3] * y + t[5]);
|
||||
let img = Renderer::new().render_region(&back, Rect::new(px - 0.5, py - 0.5, px + 0.5, py + 0.5), 1.0, true);
|
||||
img.pixels[..4].try_into().unwrap()
|
||||
}
|
||||
|
||||
fn near(a: [u8; 4], b: [u8; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 4)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simulated_overprints_print_what_overprint_preview_shows() {
|
||||
let (mut s, _) = session();
|
||||
let preview = pixels(doc(&s), RenderOptions { overprint_preview: true, ..Default::default() });
|
||||
let simulated = vectorcraft_pdf::printed_document(doc(&s), &settings(json!({"advanced": {"overprints": "simulate"}})));
|
||||
assert_eq!(pixels(&simulated, RenderOptions::default()), preview, "the same pixels as Overprint Preview");
|
||||
assert_ne!(pixels(doc(&s), RenderOptions::default()), preview, "overprinting shows");
|
||||
// On the printed page too: where yellow overprints cyan it prints green; discarded, yellow.
|
||||
let at = (55.0, 30.0);
|
||||
let i = ((30.0 * 2.0) as usize * 400 + (55.0 * 2.0) as usize) * 4;
|
||||
let shown: [u8; 4] = preview[i..i + 4].try_into().unwrap();
|
||||
let sim = printed_pixel(&mut s, json!({"advanced": {"overprints": "simulate"}}), at);
|
||||
assert!(near(sim, shown), "{sim:?} vs {shown:?}");
|
||||
let discarded = printed_pixel(&mut s, json!({"advanced": {"overprints": "discard"}}), at);
|
||||
assert!(!near(discarded, shown) && discarded[2] < 64 && discarded[0] > 192, "yellow knocks out: {discarded:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flattener_preset_flattens_transparency_before_printing() {
|
||||
let (mut s, _) = session();
|
||||
let saved = s.prefs.flattener_presets.clone();
|
||||
let transparent = |d: &Document| {
|
||||
let mut any = false;
|
||||
d.walk(|n| any |= n.opacity < 1.0);
|
||||
any
|
||||
};
|
||||
assert!(transparent(doc(&s)));
|
||||
let none = super::cmd::printadvanced::prepare("file.print", doc(&s), &settings(json!({})), &saved).unwrap();
|
||||
assert!(none.is_none(), "transparency prints live without a preset");
|
||||
for name in ["High Resolution", "low"] {
|
||||
let set = settings(json!({"advanced": {"flattenerPreset": name}}));
|
||||
let flat = super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved).unwrap().unwrap();
|
||||
assert!(!transparent(&flat), "{name}: flattened");
|
||||
}
|
||||
// A saved preset by name; an unknown one is refused by preview and print alike.
|
||||
s.execute("flattener.presets.save", &json!({"name": "Proofs", "preset": "low"})).unwrap();
|
||||
let ok = json!({"settings": {"advanced": {"flattenerPreset": "Proofs"}}});
|
||||
assert!(s.execute("print.preview", &ok).is_ok() && s.execute("file.print", &ok).is_ok());
|
||||
let bad = json!({"settings": {"advanced": {"flattenerPreset": "Nope"}}});
|
||||
assert!(s.execute("print.preview", &bad).is_err() && s.execute("file.print", &bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_as_bitmap_prints_an_image_of_each_artboard() {
|
||||
let (mut s, _) = session();
|
||||
s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 100})).unwrap();
|
||||
let saved = s.prefs.flattener_presets.clone();
|
||||
let set = settings(json!({"advanced": {"printAsBitmap": true}}));
|
||||
let bitmap = super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved).unwrap().unwrap();
|
||||
let mut kinds = vec![];
|
||||
bitmap.walk(|n| kinds.push(matches!(n.kind, NodeKind::Image(_))));
|
||||
assert_eq!(kinds.iter().filter(|k| **k).count(), 1, "one image: the empty artboard gets none");
|
||||
// The image looks like the art, at the raster effects resolution.
|
||||
assert_eq!(pixels(&bitmap, RenderOptions::default()).len(), pixels(doc(&s), RenderOptions::default()).len());
|
||||
let v = s.execute("file.print", &json!({"settings": {"advanced": {"printAsBitmap": true}, "skipBlank": true}})).unwrap();
|
||||
assert_eq!(v["pages"], 1, "the blank artboard is skipped");
|
||||
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let back = vectorcraft_pdf::import(&bytes).unwrap();
|
||||
let (mut images, mut paths) = (0, 0);
|
||||
back.walk(|n| match n.kind {
|
||||
NodeKind::Image(_) => images += 1,
|
||||
NodeKind::Path { .. } => paths += 1,
|
||||
_ => {}
|
||||
});
|
||||
assert_eq!((images, paths), (1, 0), "no vector art is left");
|
||||
// Separations print the art as it is.
|
||||
let seps = json!({"settings": {"advanced": {"printAsBitmap": true}, "output": {"mode": "separations"}}});
|
||||
let pv = s.execute("print.preview", &seps).unwrap();
|
||||
assert!(pv["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("Print as Bitmap")));
|
||||
let set = settings(seps["settings"].clone());
|
||||
assert!(super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved).unwrap().is_none());
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! View → Show Print Tiling (`view.printTiling`, `print.tiling`) and the Print Tiling tool
|
||||
//! (`print.tiling.set`): the pages drawn are print.preview's, and where the tool puts them is
|
||||
//! saved with the print settings.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
use crate::tooling::ViewInfo;
|
||||
|
||||
/// A 300 × 200 document with a rectangle.
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn rect(v: &Value) -> [f64; 4] {
|
||||
let a: Vec<f64> = v.as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
|
||||
[a[0], a[1], a[2], a[3]]
|
||||
}
|
||||
|
||||
fn close(a: [f64; 4], b: [f64; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
|
||||
}
|
||||
|
||||
/// `t` (a print.preview transform) applied to `(x, y)`.
|
||||
fn apply(t: &Value, (x, y): (f64, f64)) -> (f64, f64) {
|
||||
let c: Vec<f64> = t.as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
(c[0] * x + c[2] * y + c[4], c[1] * x + c[3] * y + c[5])
|
||||
}
|
||||
|
||||
fn tiling(s: &mut Session, settings: Value) -> Value {
|
||||
s.execute("print.tiling", &json!({ "settings": settings })).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_print_tiling_is_a_per_document_view_toggle() {
|
||||
let mut s = session();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(s.execute("view.printTiling", &json!({})).unwrap()["on"], true);
|
||||
assert!(s.doc().unwrap().print_tiling);
|
||||
assert_eq!(s.execute("view.printTiling", &json!({})).unwrap()["on"], false);
|
||||
assert_eq!(s.execute("view.printTiling", &json!({"on": true})).unwrap()["on"], true);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "a view toggle is no undo step");
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
assert!(!s.doc().unwrap().print_tiling, "each document has its own");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiling_pages_are_print_preview_tiles() {
|
||||
let mut s = session();
|
||||
let set = json!({"scaling": "tileImageable", "scale": {"width": 600, "height": 600}, "margin": 18, "overlap": 9, "tileRange": "1-2, 5"});
|
||||
let pv = s.execute("print.preview", &json!({ "settings": set })).unwrap();
|
||||
let grid = &pv["tiles"][0];
|
||||
let tiles = grid["tiles"].as_array().unwrap();
|
||||
let pages = tiling(&mut s, set.clone())["pages"].as_array().unwrap().clone();
|
||||
assert!(tiles.len() > 4);
|
||||
assert_eq!(pages.len(), tiles.len(), "one page per tile");
|
||||
for (i, (p, t)) in pages.iter().zip(tiles).enumerate() {
|
||||
assert_eq!(p["number"], i + 1);
|
||||
assert!(close(rect(&p["imageable"]), rect(t)), "tiles are imageable areas");
|
||||
let printed = grid["printed"].as_array().unwrap().contains(&json!(i + 1));
|
||||
assert_eq!(p["printed"], printed);
|
||||
// The paper reaches the margin (18 pt on paper, 3 pt of the document at 600%) further.
|
||||
let (pg, im) = (rect(&p["page"]), rect(&p["imageable"]));
|
||||
assert!((im[0] - pg[0] - 3.0).abs() < 1e-6 && (pg[3] - im[3] - 3.0).abs() < 1e-6);
|
||||
}
|
||||
// Full pages: the tiles are the paper, the imageable area inside it.
|
||||
let full = json!({"scaling": "tileFull", "scale": {"width": 400, "height": 400}, "margin": 18});
|
||||
let pv = s.execute("print.preview", &json!({ "settings": full })).unwrap();
|
||||
let pages = tiling(&mut s, full)["pages"].as_array().unwrap().clone();
|
||||
assert_eq!(pages.len(), pv["tiles"][0]["tiles"].as_array().unwrap().len());
|
||||
assert!(close(rect(&pages[0]["page"]), rect(&pv["tiles"][0]["tiles"][0])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untiled_pages_come_from_the_sheets_once_per_page() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({"x": 400, "y": 0, "width": 300, "height": 200})).unwrap();
|
||||
// Letter turned landscape for the wide artboards, centred on each.
|
||||
let set = json!({"output": {"mode": "separations"}, "margin": 36});
|
||||
let pv = s.execute("print.preview", &json!({ "settings": set })).unwrap();
|
||||
assert!(pv["sheets"].as_array().unwrap().len() >= 8, "a page per ink");
|
||||
let pages = tiling(&mut s, set)["pages"].as_array().unwrap().clone();
|
||||
assert_eq!(pages.len(), 2, "each page once, not once per ink");
|
||||
for (p, cx) in pages.iter().zip([150.0, 550.0]) {
|
||||
let pg = rect(&p["page"]);
|
||||
assert!(close(pg, [cx - 396.0, 100.0 - 306.0, cx + 396.0, 100.0 + 306.0]), "{pg:?}");
|
||||
assert!(close(rect(&p["imageable"]), [pg[0] + 36.0, pg[1] + 36.0, pg[2] - 36.0, pg[3] - 36.0]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tool_puts_the_first_page_where_it_is_dragged_as_one_undo_step() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("printTiling", v).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 50.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 45.0, 40.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 40.0, 30.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 40.0, 30.0), v).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.history.undo.len(), undo + 1);
|
||||
assert_eq!(st.history.undo.last().unwrap().label, "Print Tiling");
|
||||
let set = s.execute("print.setup", &json!({})).unwrap()["settings"].clone();
|
||||
assert_eq!(set["tileOrigin"], json!({"placed": true, "x": 40.0, "y": 30.0}));
|
||||
// The page's imageable corner is the point dragged to, on the paper and on the canvas.
|
||||
let pv = s.execute("print.preview", &json!({"settings": {"margin": 12}})).unwrap();
|
||||
let (x, y) = apply(&pv["sheets"][0]["transform"], (40.0, 30.0));
|
||||
assert!((x - 12.0).abs() < 1e-6 && (y - 12.0).abs() < 1e-6, "({x}, {y})");
|
||||
let page = &tiling(&mut s, json!({"margin": 12}))["pages"][0];
|
||||
assert!((rect(&page["imageable"])[0] - 40.0).abs() < 1e-6 && (rect(&page["imageable"])[1] - 30.0).abs() < 1e-6);
|
||||
// Saved in the native file.
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap().print_setup, doc.print_setup);
|
||||
// A double click puts the pages back where the placement puts them.
|
||||
s.pointer(&PointerEvent::new(PointerKind::DoubleClick, 10.0, 10.0), v).unwrap();
|
||||
assert_eq!(s.execute("print.setup", &json!({})).unwrap()["settings"]["tileOrigin"]["placed"], false);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("print.setup", &json!({})).unwrap()["settings"]["tileOrigin"]["placed"], true);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.print_setup.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiles_start_at_the_tile_origin_and_cover_the_art() {
|
||||
let mut s = session();
|
||||
let tiles = json!({"scaling": "tileImageable", "scale": {"width": 400, "height": 400}});
|
||||
s.execute("print.setup", &json!({ "settings": tiles })).unwrap();
|
||||
let before = tiling(&mut s, json!({}))["pages"].as_array().unwrap().len();
|
||||
s.execute("print.tiling.set", &json!({"origin": [60, 35]})).unwrap();
|
||||
let pages = tiling(&mut s, json!({}))["pages"].as_array().unwrap().clone();
|
||||
let pv = s.execute("print.preview", &json!({})).unwrap();
|
||||
assert_eq!(pages.len(), pv["tiles"][0]["tiles"].as_array().unwrap().len(), "tile count equals print.preview");
|
||||
assert_eq!(pages.len(), pv["sheets"].as_array().unwrap().len());
|
||||
assert!(pages.len() > before, "the grid moved off the corner takes more tiles");
|
||||
let rects: Vec<[f64; 4]> = pages.iter().map(|p| rect(&p["imageable"])).collect();
|
||||
assert!(rects.iter().any(|r| (r[0] - 60.0).abs() < 1e-6 && (r[1] - 35.0).abs() < 1e-6), "a tile starts at the origin");
|
||||
let all = rects.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |a, r| [a[0].min(r[0]), a[1].min(r[1]), a[2].max(r[2]), a[3].max(r[3])]);
|
||||
assert!(all[0] <= 0.0 && all[1] <= 0.0 && all[2] >= 300.0 && all[3] >= 200.0, "the tiles cover the artboard: {all:?}");
|
||||
// Bad input is refused.
|
||||
assert!(s.execute("print.tiling.set", &json!({})).is_err());
|
||||
assert!(s.execute("print.tiling.set", &json!({"origin": [1, 2], "artboard": 7})).is_err());
|
||||
assert!(s.execute("print.tiling.set", &json!({"origin": [1e9, 2]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_saved_before_the_tile_origin_still_load() {
|
||||
let mut s = session();
|
||||
let mut old = serde_json::to_value(vectorcraft_pdf::PrintSettings::default()).unwrap();
|
||||
old.as_object_mut().unwrap().remove("tileOrigin");
|
||||
old["copies"] = json!(2);
|
||||
let mut doc = (*s.doc().unwrap().doc).clone();
|
||||
doc.print_setup = Some(old);
|
||||
s.doc_mut().unwrap().doc = std::sync::Arc::new(doc);
|
||||
let set = s.execute("print.setup", &json!({})).unwrap()["settings"].clone();
|
||||
assert_eq!((set["copies"].clone(), set["tileOrigin"]["placed"].clone()), (json!(2), json!(false)));
|
||||
assert_eq!(tiling(&mut s, json!({}))["pages"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
@@ -227,6 +227,8 @@ mod tests_presets;
|
||||
#[cfg(test)]
|
||||
mod tests_print;
|
||||
#[cfg(test)]
|
||||
mod tests_printadvanced;
|
||||
#[cfg(test)]
|
||||
mod tests_realtext;
|
||||
#[cfg(test)]
|
||||
mod tests_settings;
|
||||
|
||||
@@ -102,7 +102,7 @@ fn art_bounds(doc: &Document) -> Option<Rect> {
|
||||
}
|
||||
|
||||
/// The regions that print: `(artboard, rect)`.
|
||||
fn regions(doc: &Document, set: &PrintSettings) -> Result<Vec<(Option<usize>, Rect)>, PdfError> {
|
||||
pub(crate) fn regions(doc: &Document, set: &PrintSettings) -> Result<Vec<(Option<usize>, Rect)>, PdfError> {
|
||||
let count = doc.artboards.len();
|
||||
let picked: Vec<usize> = match set.artboards {
|
||||
PrintArtboards::Ignore => {
|
||||
@@ -128,6 +128,22 @@ fn scaled(sides: [f64; 4], (sx, sy): (f64, f64)) -> [f64; 4] {
|
||||
[sides[0] * sy, sides[1] * sy, sides[2] * sx, sides[3] * sx]
|
||||
}
|
||||
|
||||
/// The tiles along one axis of a printed area `extent` long, `tile` long each and `step` apart:
|
||||
/// the first (tile `k` starts at `start + k × step`) and how many. Tiles showing no more of the area
|
||||
/// than the `overlap` are left out. Without a `start`, the first tile starts at the area's edge.
|
||||
fn span(start: Option<f64>, extent: f64, tile: f64, step: f64, overlap: f64) -> (f64, f64) {
|
||||
// Tiles that end (or start) exactly on an edge show none of the area beyond it.
|
||||
const EPS: f64 = 1e-6;
|
||||
match start {
|
||||
None => (0.0, ((extent - overlap) / step).ceil().max(1.0)),
|
||||
Some(g) => {
|
||||
let first = ((overlap - tile - g) / step + EPS).floor() + 1.0;
|
||||
let last = ((extent - overlap - g) / step - EPS).ceil() - 1.0;
|
||||
(first, (last - first + 1.0).max(1.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)) {
|
||||
@@ -151,6 +167,7 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec<Str
|
||||
let marks = set.marks.printer_marks();
|
||||
let bleed = set.bleed.of(doc);
|
||||
let (pw, ph) = set.paper();
|
||||
let origin = set.tile_origin.placed.then_some((set.tile_origin.x, set.tile_origin.y));
|
||||
let (mut pages, mut grids) = (vec![], vec![]);
|
||||
for (artboard, r) in regions(doc, set)? {
|
||||
let what = artboard.map_or_else(|| "The art".to_string(), |i| format!("Artboard {}", i + 1));
|
||||
@@ -225,9 +242,17 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec<Str
|
||||
page_bleed,
|
||||
};
|
||||
if !set.scaling.tiles() {
|
||||
let (fx, fy) = set.placement.origin.factors();
|
||||
let x = imageable.x0 + (imageable.width() - extent.0) * fx + set.placement.x;
|
||||
let y = imageable.y0 + (imageable.height() - extent.1) * fy + set.placement.y;
|
||||
// The printed area'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,
|
||||
)
|
||||
}
|
||||
};
|
||||
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)) {
|
||||
warnings.push(format!("{what} with its bleed and marks is larger than the imageable area: part of it doesn't print"));
|
||||
@@ -242,7 +267,13 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec<Str
|
||||
return Err(PdfError::BadSetting(format!("overlap: {o} pt is half a tile or more")));
|
||||
}
|
||||
let step = (tile.0 - o, tile.1 - o);
|
||||
let (cols, rows) = (((extent.0 - o) / step.0).ceil().max(1.0), ((extent.1 - o) / step.1).ceil().max(1.0));
|
||||
// The tile origin's page starts the grid: its imageable area's corner is the origin's point
|
||||
// (a full page's imageable area is inside the margin).
|
||||
let inside = if set.scaling == PrintScaling::TileFull { m } else { 0.0 };
|
||||
let start = origin.map(|(ox, oy)| (most[2] + ox * scale.0 - inside, most[0] + oy * scale.1 - inside));
|
||||
let (first_x, cols) = span(start.map(|s| s.0), extent.0, tile.0, step.0, o);
|
||||
let (first_y, rows) = span(start.map(|s| s.1), extent.1, tile.1, step.1, o);
|
||||
let grid = (start.map_or(0.0, |s| s.0) + first_x * step.0, start.map_or(0.0, |s| s.1) + first_y * step.1);
|
||||
if cols * rows > MAX_TILES as f64 {
|
||||
return Err(PdfError::BadSetting(format!("{what} would print on more than {MAX_TILES} tiles: scale it down")));
|
||||
}
|
||||
@@ -259,7 +290,8 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec<Str
|
||||
let at = if set.scaling == PrintScaling::TileFull { (0.0, 0.0) } else { (imageable.x0, imageable.y0) };
|
||||
let window = |t: usize| {
|
||||
let (c, rw) = ((t % cols) as f64, (t / cols) as f64);
|
||||
Rect::new(c * step.0, rw * step.1, c * step.0 + tile.0, rw * step.1 + tile.1)
|
||||
let (x, y) = (grid.0 + c * step.0, grid.1 + rw * step.1);
|
||||
Rect::new(x, y, x + tile.0, y + tile.1)
|
||||
};
|
||||
for &t in &pick {
|
||||
let w = window(t);
|
||||
|
||||
+83
-11
@@ -4,39 +4,49 @@
|
||||
//! - General: copies (collated or not), reverse order, the artboards (all, a range or ignored:
|
||||
//! all the art as one page), blank artboards skipped, the paper and its orientation (or turned
|
||||
//! to each artboard's), transverse, which layers print (template layers never do), the
|
||||
//! placement on the imageable area, and the scale: none, fit, custom, or tiles of the paper or
|
||||
//! of its imageable area, overlapping, a range of them.
|
||||
//! 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.
|
||||
//! - 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
|
||||
//! grey of its coverage (overprints honoured, spot colours as process if asked), emulsion down
|
||||
//! (mirrored), negative (inverted).
|
||||
//! - Colour management: the rendering intent colours are separated with, and whether CMYK
|
||||
//! colours keep their numbers.
|
||||
//! - Colour management: the printer profile composite colours are converted to (and
|
||||
//! separations separate with), the rendering intent, and whether CMYK colours keep their
|
||||
//! numbers.
|
||||
//! - Advanced: overprints in composite output preserved, discarded or simulated (as Overprint
|
||||
//! Preview shows them). Print as Bitmap and the flattener preset need the renderer and the
|
||||
//! flattener: the app applies them to the document before printing it.
|
||||
//!
|
||||
//! Halftone screens and flatness are left to the output device (warnings say so). [`preview`]
|
||||
//! lists the pages, tiles and inks without writing the file; [`plan`] lays the job out for other
|
||||
//! writers (PostScript), which can write screens and flatness themselves.
|
||||
//! writers (PostScript), which can write screens and flatness themselves; [`tiling`] turns the
|
||||
//! layout into the pages in document space (View → Show Print Tiling).
|
||||
|
||||
mod layout;
|
||||
mod plates;
|
||||
mod settings;
|
||||
mod tiling;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{Affine, Rect};
|
||||
use serde::Serialize;
|
||||
use vectorcraft_color::cms::{self, ProfileKind};
|
||||
use vectorcraft_doc::marks::PrinterMarks;
|
||||
use vectorcraft_doc::overprint::{clear_overprints, multiply_overprints};
|
||||
use vectorcraft_doc::{ColorMode, Document, Node, NodeKind};
|
||||
|
||||
pub use layout::{MAX_TILES, TileGrid};
|
||||
pub use plates::print_inks;
|
||||
pub use settings::*;
|
||||
pub use tiling::{TilingPage, tiling};
|
||||
|
||||
use crate::export::{Exporter, Sheet, Writer};
|
||||
use crate::marks::PageBoxes;
|
||||
use crate::{CompressionSettings, PdfError, PdfSettings};
|
||||
use crate::{AdvancedSettings, ColorConversion, CompressionSettings, OutputSettings, Overprint, PdfError, PdfSettings};
|
||||
use layout::Layout;
|
||||
use plates::Separator;
|
||||
|
||||
@@ -145,12 +155,61 @@ pub fn keep_layers(nodes: &mut Vec<Arc<Node>>, which: PrintLayers) {
|
||||
});
|
||||
}
|
||||
|
||||
/// `doc` as `set` prints it: live effects applied (raster effects are rendered by the app first),
|
||||
/// only the layers that print, overprints in composite output as [`PrintAdvanced::overprints`]
|
||||
/// says.
|
||||
pub fn printed_document<'a>(doc: &'a Document, set: &PrintSettings) -> Cow<'a, Document> {
|
||||
let mut doc = vectorcraft_effects::bake_document(doc).map_or(Cow::Borrowed(doc), Cow::Owned);
|
||||
let d = doc.to_mut();
|
||||
keep_layers(&mut d.layers, set.print_layers);
|
||||
composite_overprints(d, set);
|
||||
doc
|
||||
}
|
||||
|
||||
/// Overprinting fills and strokes in composite output: discarded (they knock out) or simulated
|
||||
/// (drawn with Multiply, as Overprint Preview shows them), after which nothing overprints any
|
||||
/// more; or preserved. Separations always honour them.
|
||||
fn composite_overprints(doc: &mut Document, set: &PrintSettings) {
|
||||
let how = set.advanced.overprints;
|
||||
if set.output.mode == OutputMode::Separations || how == PrintOverprints::Preserve {
|
||||
return;
|
||||
}
|
||||
let discard_white = doc.setup.discard_white_overprint;
|
||||
let symbols = doc.symbols.iter_mut().map(|s| &mut s.art);
|
||||
let nodes = doc.layers.iter_mut().chain(symbols).chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut()));
|
||||
for n in nodes {
|
||||
if how == PrintOverprints::Simulate {
|
||||
multiply_overprints(n, discard_white);
|
||||
}
|
||||
clear_overprints(n);
|
||||
}
|
||||
}
|
||||
|
||||
/// How composite output writes colours: converted to the printer profile (CMYK colours keep
|
||||
/// their numbers with Preserve CMYK Numbers), overprints kept unless discarded or simulated.
|
||||
fn composite_pdf(set: &PrintSettings) -> (OutputSettings, AdvancedSettings) {
|
||||
let profile = set.color.profile.trim();
|
||||
let output = if profile.is_empty() {
|
||||
OutputSettings::default()
|
||||
} else {
|
||||
let conversion = if set.color.preserve_numbers { ColorConversion::PreserveNumbers } else { ColorConversion::Destination };
|
||||
OutputSettings { conversion, destination: profile.to_string(), ..Default::default() }
|
||||
};
|
||||
let overprint = if set.advanced.overprints == PrintOverprints::Preserve { Overprint::Preserve } else { Overprint::Discard };
|
||||
(output, AdvancedSettings { overprint, ..Default::default() })
|
||||
}
|
||||
|
||||
/// What of `doc` prints on pages of its own: each artboard printed (its rect), or all the art
|
||||
/// (`None`) with artboards ignored.
|
||||
pub fn print_regions(doc: &Document, set: &PrintSettings) -> Result<Vec<(Option<usize>, kurbo::Rect)>, PdfError> {
|
||||
set.check()?;
|
||||
layout::regions(&printed_document(doc, set), set)
|
||||
}
|
||||
|
||||
impl<'a> Job<'a> {
|
||||
fn new(doc: &'a Document, set: &PrintSettings, postscript: bool) -> Result<Self, PdfError> {
|
||||
set.check()?;
|
||||
// Live effects print as their result (raster effects are rendered by the app first).
|
||||
let mut doc = vectorcraft_effects::bake_document(doc).map_or(Cow::Borrowed(doc), Cow::Owned);
|
||||
keep_layers(&mut doc.to_mut().layers, set.print_layers);
|
||||
let doc = printed_document(doc, set);
|
||||
let mut warnings = vec![];
|
||||
let (layouts, tiles) = layout::layout(&doc, set, &mut warnings)?;
|
||||
let separations = set.output.mode == OutputMode::Separations;
|
||||
@@ -172,7 +231,15 @@ impl<'a> Job<'a> {
|
||||
warnings.push("ink frequencies and angles are not written as halftone screens yet: the output device's screens apply".into());
|
||||
}
|
||||
if !separations && doc.layers.iter().any(|l| l.has_overprint()) {
|
||||
warnings.push("overprinting fills and strokes print as knockouts in composite output (separations honour them)".into());
|
||||
warnings.push(
|
||||
"overprinting fills and strokes print as knockouts in composite output (separations honour them; Simulate Overprints prints them as Overprint Preview shows them)".into(),
|
||||
);
|
||||
}
|
||||
if separations && set.advanced.print_as_bitmap {
|
||||
warnings.push("Print as Bitmap is for composite output: separations print the art as it is".into());
|
||||
}
|
||||
if separations && cms::profile(set.color.profile.trim()).is_some_and(|p| p.kind != ProfileKind::Cmyk) {
|
||||
warnings.push("separations separate with a CMYK profile: the RGB printer profile is left out".into());
|
||||
}
|
||||
if !postscript && !set.graphics.auto_flatness {
|
||||
warnings.push("a fixed flatness is not written yet: the output device flattens curves".into());
|
||||
@@ -330,8 +397,11 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result<PrintPlan<'a>,
|
||||
pub fn print(doc: &Document, opts: &PrintOptions) -> Result<PrintReport, PdfError> {
|
||||
let plan = plan(doc, opts)?;
|
||||
let doc = &*plan.doc;
|
||||
let pdf = PdfSettings { compression: CompressionSettings { compress_text: !opts.uncompressed, ..Default::default() }, ..Default::default() };
|
||||
let separations = opts.settings.output.mode == OutputMode::Separations;
|
||||
// Plates are greys already: only composite colours go to the printer profile.
|
||||
let (output, advanced) = if separations { Default::default() } else { composite_pdf(&opts.settings) };
|
||||
let compression = CompressionSettings { compress_text: !opts.uncompressed, ..Default::default() };
|
||||
let pdf = PdfSettings { compression, output, advanced, ..Default::default() };
|
||||
let mut w = Writer::new(doc, &pdf, &plan.title, plan.created, !separations && doc.color_mode == ColorMode::Cmyk)?;
|
||||
// One exporter per ink (one for composite), each drawing its own document.
|
||||
let mut exporters: Vec<Exporter> =
|
||||
@@ -339,6 +409,8 @@ pub fn print(doc: &Document, opts: &PrintOptions) -> Result<PrintReport, PdfErro
|
||||
for ex in &mut exporters {
|
||||
ex.non_printing = true;
|
||||
ex.intent = opts.settings.color.intent;
|
||||
// The writer's colour output: the printer profile's conversion.
|
||||
ex.out = w.out.clone();
|
||||
}
|
||||
for &i in &plan.order {
|
||||
let Some(s) = plan.sheets.get(i) else { continue };
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_color::cms::{Cms, Model};
|
||||
use vectorcraft_color::cms::{self, Cms, Model, ProfileKind};
|
||||
use vectorcraft_color::swatch::REGISTRATION;
|
||||
use vectorcraft_doc::inks::{Link, inks, plates, visit_node_colors};
|
||||
use vectorcraft_doc::overprint::multiply_overprints;
|
||||
@@ -42,6 +42,19 @@ pub fn print_inks(doc: &Document, set: &PrintSettings) -> (Vec<PrintInk>, Vec<St
|
||||
(list, warnings)
|
||||
}
|
||||
|
||||
/// The colour settings separations convert with: the active ones, with the printer profile as
|
||||
/// their CMYK profile when it is a CMYK one.
|
||||
fn separation_cms(set: &PrintSettings) -> Arc<Cms> {
|
||||
let active = cms::active();
|
||||
match cms::profile(set.color.profile.trim()) {
|
||||
Some(p) if p.kind == ProfileKind::Cmyk && cms::canonical_name(&active.settings().cmyk) != p.name => {
|
||||
let settings = cms::ColorSettings { cmyk: p.name, ..active.settings().clone() };
|
||||
Cms::new(&settings).map_or(active, Arc::new)
|
||||
}
|
||||
_ => active,
|
||||
}
|
||||
}
|
||||
|
||||
/// Separates colours into inks as the job's colour options say.
|
||||
pub(crate) struct Separator<'a> {
|
||||
pub doc: &'a Document,
|
||||
@@ -51,7 +64,7 @@ pub(crate) struct Separator<'a> {
|
||||
|
||||
impl<'a> Separator<'a> {
|
||||
pub fn new(doc: &'a Document, set: &'a PrintSettings) -> Self {
|
||||
Self { doc, cms: vectorcraft_color::cms::active(), set }
|
||||
Self { doc, cms: separation_cms(set), set }
|
||||
}
|
||||
|
||||
/// How much of ink `plate` colour `c` (with swatch link `link`) prints, 0..1.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! separation ([`PrintInk`]).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::cms::Intent;
|
||||
use vectorcraft_color::cms::{self, Intent, ProfileKind};
|
||||
|
||||
use crate::settings::{choice, within};
|
||||
use crate::{BleedSettings, MarkSettings, PdfError};
|
||||
@@ -214,6 +214,10 @@ pub struct PrintSettings {
|
||||
pub output: PrintOutput,
|
||||
pub graphics: PrintGraphics,
|
||||
pub color: PrintColor,
|
||||
/// Where the Print Tiling tool put the pages.
|
||||
pub tile_origin: TileOrigin,
|
||||
// Advanced.
|
||||
pub advanced: PrintAdvanced,
|
||||
}
|
||||
|
||||
impl Default for PrintSettings {
|
||||
@@ -244,6 +248,8 @@ impl Default for PrintSettings {
|
||||
output: PrintOutput::default(),
|
||||
graphics: PrintGraphics::default(),
|
||||
color: PrintColor::default(),
|
||||
tile_origin: TileOrigin::default(),
|
||||
advanced: PrintAdvanced::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,14 +365,57 @@ pub struct PrintColor {
|
||||
pub intent: Intent,
|
||||
/// CMYK colours print with their own values; off, they go through the colour settings too.
|
||||
pub preserve_numbers: bool,
|
||||
/// The printer profile (an RGB or CMYK profile by name): composite output converts colours to
|
||||
/// it with the intent (CMYK colours keep their numbers with `preserve_numbers`), and
|
||||
/// separations separate with it when it is a CMYK one. Empty: colours print as they are.
|
||||
pub profile: String,
|
||||
}
|
||||
|
||||
impl Default for PrintColor {
|
||||
fn default() -> Self {
|
||||
Self { intent: Intent::RelativeColorimetric, preserve_numbers: true }
|
||||
Self { intent: Intent::RelativeColorimetric, preserve_numbers: true, profile: String::new() }
|
||||
}
|
||||
}
|
||||
|
||||
choice! {
|
||||
/// What composite output does with overprinting fills and strokes (separations always
|
||||
/// honour them).
|
||||
PrintOverprints {
|
||||
Preserve = "preserve", "Preserve";
|
||||
/// They knock out.
|
||||
Discard = "discard", "Discard";
|
||||
/// Composited as Overprint Preview shows them.
|
||||
Simulate = "simulate", "Simulate";
|
||||
} default Preserve
|
||||
}
|
||||
|
||||
/// The Advanced section (with [`PrintSettings::margin`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PrintAdvanced {
|
||||
/// Composite pages print as one image each of the art, at the document's raster effects
|
||||
/// resolution (the app renders them before printing).
|
||||
pub print_as_bitmap: bool,
|
||||
pub overprints: PrintOverprints,
|
||||
/// The transparency flattener preset transparency is flattened with (a built-in or saved
|
||||
/// one, by name; the app flattens before printing). Empty: transparency prints live.
|
||||
pub flattener_preset: String,
|
||||
}
|
||||
|
||||
/// Where the Print Tiling tool put the pages (View → Show Print Tiling).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TileOrigin {
|
||||
/// The tool placed the pages: the top-left corner of the first page's imageable area is at
|
||||
/// (`x`, `y`) from the top-left corner of the art that prints (the artboard, or all the art
|
||||
/// with artboards ignored), in document points. It replaces the placement, and tiles start
|
||||
/// there (and go on to the left and up as far as the art does). Off: the placement places the
|
||||
/// art and tiles start at the top-left corner of the art with its bleed and marks.
|
||||
pub placed: bool,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
/// Most copies of a job.
|
||||
pub const MAX_COPIES: u32 = 999;
|
||||
|
||||
@@ -385,9 +434,15 @@ impl PrintSettings {
|
||||
within("scale.height", self.scale.height, 1.0, 1000.0, "%")?;
|
||||
within("overlap", self.overlap, 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")?;
|
||||
self.marks.check()?;
|
||||
self.bleed.check()?;
|
||||
within("graphics.flatness", self.graphics.flatness, 0.2, 100.0, "")?;
|
||||
let profile = self.color.profile.trim();
|
||||
if !profile.is_empty() && cms::profile(profile).is_none_or(|p| p.kind == ProfileKind::Gray) {
|
||||
return Err(PdfError::BadSetting(format!("color.profile: no RGB or CMYK profile is called “{profile}”")));
|
||||
}
|
||||
for ink in &self.output.inks {
|
||||
if let Some(f) = ink.frequency {
|
||||
within(&format!("output.inks[{}].frequency", ink.name), f, 1.0, 1000.0, " lpi")?;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//! View → Show Print Tiling: the pages of a job in document space, as [`super::preview`] lays
|
||||
//! them out: the layout [`super::plan`] gives the PDF and PostScript writers, so the canvas shows
|
||||
//! what prints (without separating the plates, which the overlay doesn't need).
|
||||
|
||||
use kurbo::{Affine, Rect};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{PrintPreview, PrintScaling, PrintSettings};
|
||||
|
||||
/// One page of the print tiling.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TilingPage {
|
||||
/// 0-based artboard (`None`: artboards ignored).
|
||||
pub artboard: Option<usize>,
|
||||
/// 1-based: the tile (across then down), or the page of the job.
|
||||
pub number: usize,
|
||||
/// It prints (tiles outside the tile range don't).
|
||||
pub printed: bool,
|
||||
/// The paper's edge, `[x0, y0, x1, y1]` in document space.
|
||||
pub page: [f64; 4],
|
||||
/// Its imageable area (inside the unprintable margin).
|
||||
pub imageable: [f64; 4],
|
||||
}
|
||||
|
||||
fn arr(r: Rect) -> [f64; 4] {
|
||||
[r.x0, r.y0, r.x1, r.y1]
|
||||
}
|
||||
|
||||
/// The pages of the job `pv` previews (printed with `set`), in document space: every tile of each
|
||||
/// artboard when tiling, else each page once (not once per ink).
|
||||
pub fn tiling(pv: &PrintPreview, set: &PrintSettings) -> Vec<TilingPage> {
|
||||
let m = set.margin;
|
||||
if set.scaling.tiles() {
|
||||
// Tiles are the paper (full pages) or its imageable area, at the print scale.
|
||||
let (sx, sy) = (set.scale.width / 100.0, set.scale.height / 100.0);
|
||||
let inset = (m / sx, m / sy);
|
||||
return pv
|
||||
.tiles
|
||||
.iter()
|
||||
.flat_map(|g| {
|
||||
g.tiles.iter().enumerate().map(move |(i, t)| {
|
||||
let tile = Rect::new(t[0], t[1], t[2], t[3]);
|
||||
let (page, imageable) = if set.scaling == PrintScaling::TileFull {
|
||||
(tile, tile.inflate(-inset.0, -inset.1))
|
||||
} else {
|
||||
(tile.inflate(inset.0, inset.1), tile)
|
||||
};
|
||||
TilingPage {
|
||||
artboard: g.artboard,
|
||||
number: i + 1,
|
||||
printed: g.printed.contains(&(i + 1)),
|
||||
page: arr(page),
|
||||
imageable: arr(imageable),
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let mut out: Vec<TilingPage> = vec![];
|
||||
let mut last = None;
|
||||
for s in &pv.sheets {
|
||||
// Separations repeat each page once per ink, one after the other.
|
||||
if last == Some((s.artboard, s.tile)) {
|
||||
continue;
|
||||
}
|
||||
last = Some((s.artboard, s.tile));
|
||||
let to_page = Affine::new(s.transform);
|
||||
let det = to_page.determinant();
|
||||
if !det.is_finite() || det.abs() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let back = to_page.inverse();
|
||||
let paper = Rect::new(0.0, 0.0, s.width, s.height);
|
||||
let page = back.transform_rect_bbox(paper);
|
||||
let imageable = back.transform_rect_bbox(paper.inflate(-m, -m));
|
||||
out.push(TilingPage { artboard: s.artboard, number: out.len() + 1, printed: true, page: arr(page), imageable: arr(imageable) });
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! File → Print › Advanced and Color Management: overprints preserved, discarded or simulated in
|
||||
//! composite output, and colours converted to the printer profile with the rendering intent.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use hayro_syntax::Pdf;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::cms::{self, Cms, ColorSettings, DEVICE_CMYK, GENERIC_CMYK, Intent};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// A 200 × 100 document with a rectangle filled with each `(colour, overprints)`, overlapping
|
||||
/// one another left to right, in paint order.
|
||||
fn doc(fills: &[(Color, bool)]) -> Document {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let l = d.layers[0].id;
|
||||
for (i, (c, over)) in fills.iter().enumerate() {
|
||||
let x = 10.0 + 30.0 * i as f64;
|
||||
let mut a = Appearance::basic(Paint::solid(*c), Paint::None, 0.0);
|
||||
for it in &mut a.items {
|
||||
*it.overprint_mut() = *over;
|
||||
}
|
||||
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 10.0, x + 50.0, 60.0)), a);
|
||||
d.insert(Some(l), i, n).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Cyan under a yellow that overprints.
|
||||
fn overprinted() -> Document {
|
||||
doc(&[(Color::cmyk(1.0, 0.0, 0.0, 0.0), false), (Color::cmyk(0.0, 0.0, 1.0, 0.0), true)])
|
||||
}
|
||||
|
||||
fn settings(v: Value) -> PrintSettings {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
|
||||
/// The job's bytes, content streams left readable.
|
||||
fn job(d: &Document, v: Value) -> PrintReport {
|
||||
let opts = PrintOptions { settings: settings(v), created: Some(1_791_200_000), uncompressed: true, ..Default::default() };
|
||||
print(d, &opts).unwrap()
|
||||
}
|
||||
|
||||
/// The fill colours page `page` sets: (operator, operands).
|
||||
fn colours(pdf: &[u8], page: usize) -> Vec<(String, Vec<f32>)> {
|
||||
let file = Pdf::new(Arc::new(pdf.to_vec())).unwrap();
|
||||
let pages = file.pages();
|
||||
let content = String::from_utf8_lossy(pages.get(page).unwrap().page_stream().unwrap()).into_owned();
|
||||
let (mut nums, mut out) = (vec![], vec![]);
|
||||
for t in content.split_whitespace() {
|
||||
if let Ok(v) = t.parse::<f32>() {
|
||||
nums.push(v);
|
||||
continue;
|
||||
}
|
||||
if ["rg", "k", "g", "scn"].contains(&t) {
|
||||
out.push((t.to_string(), nums.clone()));
|
||||
}
|
||||
nums.clear();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `v` as the writer stores it (8 bits a component).
|
||||
fn q8(v: &[f32]) -> Vec<f32> {
|
||||
v.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() / 255.0).collect()
|
||||
}
|
||||
|
||||
fn close(a: &[f32], b: &[f32]) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3)
|
||||
}
|
||||
|
||||
/// Does any fill or stroke of `d` overprint?
|
||||
fn overprints(d: &Document) -> bool {
|
||||
d.layers.iter().any(|l| l.has_overprint())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_saved_before_the_advanced_options_load_with_their_defaults() {
|
||||
let s = settings(json!({"copies": 2, "color": {"intent": "perceptual"}}));
|
||||
assert_eq!((s.copies, s.color.intent, s.color.preserve_numbers), (2, Intent::Perceptual, true));
|
||||
assert_eq!(s.advanced, PrintAdvanced::default());
|
||||
assert_eq!((s.advanced.print_as_bitmap, s.advanced.overprints, s.advanced.flattener_preset.as_str()), (false, PrintOverprints::Preserve, ""));
|
||||
assert_eq!(s.color.profile, "");
|
||||
let v = serde_json::to_value(&s).unwrap();
|
||||
assert_eq!(v["advanced"], json!({"printAsBitmap": false, "overprints": "preserve", "flattenerPreset": ""}));
|
||||
// A printer profile must be one there is.
|
||||
assert!(settings(json!({"color": {"profile": GENERIC_CMYK}})).check().is_ok());
|
||||
assert!(matches!(settings(json!({"color": {"profile": "No Such Profile"}})).check(), Err(PdfError::BadSetting(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discarded_and_simulated_overprints_write_no_overprint() {
|
||||
let d = overprinted();
|
||||
let preserve = job(&d, json!({}));
|
||||
assert!(preserve.warnings.iter().any(|w| w.contains("knockouts")), "{:?}", preserve.warnings);
|
||||
assert!(overprints(&printed_document(&d, &settings(json!({})))), "preserved");
|
||||
for (how, multiplies) in [("discard", false), ("simulate", true)] {
|
||||
let set = json!({"advanced": {"overprints": how}});
|
||||
assert!(!overprints(&printed_document(&d, &settings(set.clone()))), "{how}: nothing overprints");
|
||||
let r = job(&d, set);
|
||||
let text = String::from_utf8_lossy(&r.bytes);
|
||||
assert!(!text.contains("/OP"), "{how}: no overprint in the file");
|
||||
assert_eq!(text.contains("/Multiply"), multiplies, "{how}");
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("knockouts")), "{how}: {:?}", r.warnings);
|
||||
}
|
||||
// Separations honour overprints whatever the option says.
|
||||
let seps = settings(json!({"output": {"mode": "separations"}, "advanced": {"overprints": "discard"}}));
|
||||
assert!(overprints(&printed_document(&d, &seps)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cmyk_printer_profile_writes_device_cmyk_with_the_intent() {
|
||||
let d = doc(&[(Color::rgb(1.0, 0.0, 0.0), false)]);
|
||||
assert_eq!(colours(&job(&d, json!({})).bytes, 0), [("rg".to_string(), vec![1.0, 0.0, 0.0])], "as it is without a profile");
|
||||
let to_cmyk = Cms::new(&ColorSettings { cmyk: GENERIC_CMYK.into(), ..cms::active_settings() }).unwrap();
|
||||
for intent in [Intent::Perceptual, Intent::Saturation] {
|
||||
let c = colours(&job(&d, json!({"color": {"profile": GENERIC_CMYK, "intent": intent}})).bytes, 0);
|
||||
let ink = q8(&to_cmyk.srgb_to_cmyk([1.0, 0.0, 0.0], intent));
|
||||
assert!(c.len() == 1 && c[0].0 == "k" && close(&c[0].1, &ink), "{intent:?}: {c:?} vs {ink:?}");
|
||||
}
|
||||
// CMYK colours keep their numbers with Preserve CMYK Numbers.
|
||||
let d = doc(&[(Color::cmyk(0.1, 0.2, 0.3, 0.4), false)]);
|
||||
let c = colours(&job(&d, json!({"color": {"profile": DEVICE_CMYK, "preserveNumbers": true}})).bytes, 0);
|
||||
assert!(c[0].0 == "k" && close(&c[0].1, &q8(&[0.1, 0.2, 0.3, 0.4])), "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separations_separate_with_a_cmyk_printer_profile() {
|
||||
// A mid grey: naive device CMYK puts it all on the black plate; a press profile doesn't.
|
||||
let d = doc(&[(Color::rgb(0.5, 0.5, 0.5), false)]);
|
||||
let cyan = |profile: &str| {
|
||||
let r = job(&d, json!({"output": {"mode": "separations"}, "color": {"profile": profile}}));
|
||||
colours(&r.bytes, 0)
|
||||
};
|
||||
let device = cyan(DEVICE_CMYK);
|
||||
let press = cyan(GENERIC_CMYK);
|
||||
assert_ne!(device, press, "the profile separates");
|
||||
// An RGB printer profile doesn't separate.
|
||||
let r = job(&d, json!({"output": {"mode": "separations"}, "color": {"profile": cms::SRGB}}));
|
||||
assert!(r.warnings.iter().any(|w| w.contains("RGB printer profile")), "{:?}", r.warnings);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ pub mod meshblend;
|
||||
pub mod params;
|
||||
pub mod pen;
|
||||
pub mod place;
|
||||
pub mod printtiling;
|
||||
pub mod select;
|
||||
pub mod shape;
|
||||
pub mod slice;
|
||||
@@ -357,6 +358,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
|
||||
.or_else(|| distort::create(other))
|
||||
.or_else(|| extra::create(other))
|
||||
.or_else(|| slice::create(other))
|
||||
.or_else(|| printtiling::create(other))
|
||||
.unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! The Print Tiling tool: drag to put the printed pages where they print. The pointer is the
|
||||
//! top-left corner of the first page's imageable area (snapping to the artboard's edges); the
|
||||
//! drag previews `print.tiling.set` and commits it as one undo step. A double click puts the pages
|
||||
//! back where the placement puts them (`print.tiling.set {reset}`). The canvas shows the pages
|
||||
//! while the tool is active (as View → Show Print Tiling does).
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
/// Create the Print Tiling tool by id (None = not ours).
|
||||
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
||||
(id == "printTiling").then(|| Box::new(PrintTilingTool::default()) as Box<dyn Tool>)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PrintTilingTool {
|
||||
/// The artboard the drag started on (index and rect), while dragging.
|
||||
drag: Option<(Option<usize>, Option<Rect>)>,
|
||||
began: bool,
|
||||
last: Point,
|
||||
}
|
||||
|
||||
/// `v` snapped to the nearest of `edges` within `tol`.
|
||||
fn snap1(v: f64, edges: [f64; 2], tol: f64) -> f64 {
|
||||
edges.into_iter().filter(|e| (e - v).abs() <= tol).min_by(|a, b| (a - v).abs().total_cmp(&(b - v).abs())).unwrap_or(v)
|
||||
}
|
||||
|
||||
impl Tool for PrintTilingTool {
|
||||
fn id(&self) -> &'static str {
|
||||
"printTiling"
|
||||
}
|
||||
|
||||
fn busy(&self) -> bool {
|
||||
self.drag.is_some()
|
||||
}
|
||||
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
match ev.kind {
|
||||
PointerKind::Down => {
|
||||
let hit = cx.doc.artboards.iter().position(|a| a.rect.contains(ev.pos));
|
||||
self.drag = Some((hit, hit.and_then(|i| cx.doc.artboards.get(i)).map(|a| a.rect)));
|
||||
(self.began, self.last) = (false, ev.pos);
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
let Some((artboard, rect)) = self.drag else { return vec![] };
|
||||
let tol = cx.tol(5.0);
|
||||
let p = match rect {
|
||||
Some(r) => Point::new(snap1(ev.pos.x, [r.x0, r.x1], tol), snap1(ev.pos.y, [r.y0, r.y1], tol)),
|
||||
None => ev.pos,
|
||||
};
|
||||
self.last = p;
|
||||
let mut out = vec![];
|
||||
if !self.began {
|
||||
self.began = true;
|
||||
out.push(Action::Begin("Print Tiling".into()));
|
||||
}
|
||||
out.push(Action::Preview("print.tiling.set".into(), json!({ "origin": [p.x, p.y], "artboard": artboard })));
|
||||
out
|
||||
}
|
||||
PointerKind::Up => {
|
||||
let began = std::mem::take(&mut self.began);
|
||||
if self.drag.take().is_some() && began { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
PointerKind::DoubleClick => {
|
||||
self.drag = None;
|
||||
vec![Action::Exec("print.tiling.set".into(), json!({ "reset": true }))]
|
||||
}
|
||||
PointerKind::Move => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if key == ToolKey::Escape { self.deactivate(cx) } else { vec![] }
|
||||
}
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
match self.drag {
|
||||
// How far the corner is from the artboard's.
|
||||
Some((_, Some(r))) if self.began => {
|
||||
vec![Overlay::Measure { p: self.last, text: cx.offset_label(self.last.x - r.x0, self.last.y - r.y0) }]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
}
|
||||
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
self.drag = None;
|
||||
if std::mem::take(&mut self.began) { vec![Action::Cancel] } else { vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::Selection;
|
||||
|
||||
use super::*;
|
||||
use crate::testutil::{cx, doc_with_rect, paint};
|
||||
|
||||
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||||
PointerEvent::new(kind, x, y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_previews_the_origin_and_commits_once() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let (s, pd) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &pd);
|
||||
let mut t = PrintTilingTool::default();
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Down, 50.0, 60.0)).is_empty());
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, 40.0, 30.0));
|
||||
assert_eq!(a.first(), Some(&Action::Begin("Print Tiling".into())));
|
||||
assert_eq!(a.get(1), Some(&Action::Preview("print.tiling.set".into(), json!({"origin": [40.0, 30.0], "artboard": 0}))));
|
||||
// Near the artboard's edge the corner snaps to it.
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, 2.0, 497.0));
|
||||
assert_eq!(a, vec![Action::Preview("print.tiling.set".into(), json!({"origin": [0.0, 500.0], "artboard": 0}))]);
|
||||
assert!(matches!(t.overlays(&c).as_slice(), [Overlay::Measure { .. }]));
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 2.0, 497.0)), vec![Action::Commit]);
|
||||
assert!(!t.busy());
|
||||
// A click alone changes nothing; a double click resets.
|
||||
t.pointer(&c, &ev(PointerKind::Down, 10.0, 10.0));
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Up, 10.0, 10.0)).is_empty());
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::DoubleClick, 10.0, 10.0)), vec![Action::Exec("print.tiling.set".into(), json!({"reset": true}))]);
|
||||
// Escape mid-drag cancels.
|
||||
t.pointer(&c, &ev(PointerKind::Down, 10.0, 10.0));
|
||||
t.pointer(&c, &ev(PointerKind::Drag, 20.0, 20.0));
|
||||
assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
|
||||
assert_eq!(crate::create("printTiling").id(), "printTiling");
|
||||
}
|
||||
}
|
||||
@@ -289,6 +289,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if !app.session.slices_hidden() {
|
||||
slice_overlay(app, &painter, &xf);
|
||||
}
|
||||
if app.session.active().is_some_and(|d| d.print_tiling) || app.session.tool_id() == "printTiling" {
|
||||
print_tiling_overlay(app, &painter, &xf);
|
||||
}
|
||||
|
||||
// Selection visuals and tool overlays.
|
||||
if app.ui.view.edges {
|
||||
@@ -955,6 +958,40 @@ fn slice_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The print tiling (View → Show Print Tiling, or the Print Tiling tool): each page of the
|
||||
/// document's print settings as `print.preview` lays it out, its paper edge and its imageable
|
||||
/// area dashed, numbered at its top left; tiles outside the tile range dimmer. Cached per revision.
|
||||
fn print_tiling_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let key = (st.uid, st.revision);
|
||||
let pages = match &app.canvas.print_tiling {
|
||||
Some((k, pages)) if *k == key => pages.clone(),
|
||||
_ => {
|
||||
// Settings that can't print have no pages to show.
|
||||
let pages = std::sync::Arc::new(vectorcraft_engine::cmd::printtiling::pages(&st.doc).unwrap_or_default());
|
||||
app.canvas.print_tiling = Some((key, pages.clone()));
|
||||
pages
|
||||
}
|
||||
};
|
||||
let font = egui::FontId::proportional(10.0);
|
||||
let ring = |r: [f64; 4]| {
|
||||
let q = xf.quad(Rect::new(r[0], r[1], r[2], r[3]));
|
||||
q.iter().chain(q.first()).copied().collect::<Vec<Pos2>>()
|
||||
};
|
||||
for page in pages.iter() {
|
||||
let color = if page.printed { PRINT_TILING } else { PRINT_TILING.gamma_multiply(0.45) };
|
||||
p.add(Shape::line(ring(page.page), Stroke::new(0.75, color)));
|
||||
let inner = ring(page.imageable);
|
||||
p.extend(Shape::dashed_line(&inner, Stroke::new(1.0, color), 4.0, 3.0));
|
||||
if let Some(corner) = inner.iter().copied().reduce(|a, b| pos2(a.x.min(b.x), a.y.min(b.y))) {
|
||||
p.text(corner + vec2(3.0, 2.0), egui::Align2::LEFT_TOP, page.number.to_string(), font.clone(), color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The print tiling's lines: a neutral grey that reads on the paper and on the pasteboard.
|
||||
const PRINT_TILING: Color32 = Color32::from_gray(96);
|
||||
|
||||
fn selection_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let tool = app.session.tool_id();
|
||||
|
||||
@@ -378,3 +378,6 @@ mod tests_print;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_print_presets;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_print_advanced;
|
||||
|
||||
@@ -22,7 +22,8 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_color::cms::Intent;
|
||||
use vectorcraft_geom::Rect as DRect;
|
||||
use vectorcraft_pdf::{
|
||||
Choice, Emulsion, FontDownload, Media, Orientation, Origin, OutputMode, PrintArtboards, PrintImage, PrintLayers, PrintScaling, PrintSettings,
|
||||
Choice, Emulsion, FontDownload, Media, Orientation, Origin, OutputMode, PrintArtboards, PrintImage, PrintLayers, PrintOverprints, PrintScaling,
|
||||
PrintSettings,
|
||||
};
|
||||
|
||||
use super::save_pdf::{TOP_LABEL_WIDTH, choice, current_section, flag, get, heading, length, note, number, pick, row, row_with, set, text};
|
||||
@@ -65,6 +66,9 @@ pub(super) fn defaults(kind: &str) -> Option<&'static Value> {
|
||||
(kind == KIND || kind == PRESET_KIND).then(|| &*DEFAULTS)
|
||||
}
|
||||
|
||||
/// Set when the Print Tiling tool placed the pages (`tileOrigin`): the placement is ignored.
|
||||
const PLACED: &str = "tileOrigin.placed";
|
||||
|
||||
/// The fields that aren't print settings.
|
||||
const NOT_SETTINGS: [&str; 5] = ["printer", "toFile", "discard", "preset", "name"];
|
||||
|
||||
@@ -507,8 +511,17 @@ pub(super) fn move_placement(d: &mut Dialog, sheet: &Value, (dx, dy): (f64, f64)
|
||||
}
|
||||
// The transform's linear part is the page's turn times the scale, and the turn's inverse is
|
||||
// its transpose: the columns over the scale.
|
||||
let x = get(d, "placement.x").as_f64().unwrap_or(0.0) + (a * dx + b * dy) / sx;
|
||||
let y = get(d, "placement.y").as_f64().unwrap_or(0.0) + (c * dx + e * dy) / sy;
|
||||
let (px, py) = ((a * dx + b * dy) / sx, (c * dx + e * dy) / sy);
|
||||
if get(d, PLACED).as_bool() == Some(true) {
|
||||
// Pages the Print Tiling tool placed: their corner moves the other way over the art.
|
||||
let x = get(d, "tileOrigin.x").as_f64().unwrap_or(0.0) - px / sx;
|
||||
let y = get(d, "tileOrigin.y").as_f64().unwrap_or(0.0) - py / sy;
|
||||
set(d, "tileOrigin.x", json!(round2(x)));
|
||||
set(d, "tileOrigin.y", json!(round2(y)));
|
||||
return;
|
||||
}
|
||||
let x = get(d, "placement.x").as_f64().unwrap_or(0.0) + px;
|
||||
let y = get(d, "placement.y").as_f64().unwrap_or(0.0) + py;
|
||||
set(d, "placement.x", json!(round2(x)));
|
||||
set(d, "placement.y", json!(round2(y)));
|
||||
}
|
||||
@@ -606,8 +619,9 @@ fn general(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
});
|
||||
let scaling = choice::<PrintScaling>(d, "scaling").unwrap_or(PrintScaling::None);
|
||||
let tiles = scaling.tiles();
|
||||
let placed = get(d, PLACED).as_bool() == Some(true);
|
||||
row(ui, "Placement:", |ui| {
|
||||
ui.add_enabled_ui(!tiles, |ui| {
|
||||
ui.add_enabled_ui(!tiles && !placed, |ui| {
|
||||
let origin = choice::<Origin>(d, "placement.origin").unwrap_or_default();
|
||||
let current = Origin::ALL.iter().position(|o| *o == origin).unwrap_or(4);
|
||||
if let Some(o) = widgets::reference_point(ui, current).and_then(|i| Origin::ALL.get(i)) {
|
||||
@@ -619,6 +633,14 @@ fn general(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
length(ui, d, "placement.y", unit, true);
|
||||
});
|
||||
});
|
||||
if placed {
|
||||
row(ui, "", |ui| {
|
||||
note(ui, "The Print Tiling tool placed the pages.");
|
||||
if widgets::flat_button(ui, "Reset", 56.0).on_hover_text("Place the pages with the placement again").clicked() {
|
||||
set(d, PLACED, json!(false));
|
||||
}
|
||||
});
|
||||
}
|
||||
row(ui, "Scaling:", |ui| {
|
||||
pick::<PrintScaling>(ui, d, "scaling", 180.0, |_| true);
|
||||
});
|
||||
@@ -709,6 +731,10 @@ fn graphics(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
|
||||
fn color(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let profiles = vectorcraft_color::cms::profiles();
|
||||
let names: Vec<&str> = std::iter::once(SAME_AS_SOURCE).chain(profiles.iter().map(|p| p.name.as_str())).collect();
|
||||
row(ui, "Printer profile:", |ui| super::save_pdf::profile_pick(ui, d, "color.profile", &names, true));
|
||||
note(ui, "Composite colours are converted to it; separations separate with it when it is a CMYK profile.");
|
||||
row(ui, "Rendering intent:", |ui| {
|
||||
let current = serde_json::from_value::<Intent>(get(d, "color.intent").clone()).unwrap_or_default();
|
||||
let labels: Vec<&str> = Intent::ALL.iter().map(Intent::label).collect();
|
||||
@@ -725,8 +751,27 @@ fn advanced(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
heading(ui, "Printer");
|
||||
row(ui, "Unprintable margin:", |ui| length(ui, d, "margin", app.session.general_unit(), true));
|
||||
note(ui, "The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed.");
|
||||
let composite = choice::<OutputMode>(d, "output.mode") != Some(OutputMode::Separations);
|
||||
flag(ui, d, "advanced.printAsBitmap", "Print as Bitmap", composite);
|
||||
note(ui, "Each page prints as one image, at the document's raster effects resolution.");
|
||||
heading(ui, "Overprint and Transparency Flattener Options");
|
||||
row(ui, "Overprints:", |ui| {
|
||||
ui.add_enabled_ui(composite, |ui| pick::<PrintOverprints>(ui, d, "advanced.overprints", 160.0, |_| true));
|
||||
});
|
||||
let presets = app.session.flattener_presets();
|
||||
let names: Vec<&str> = std::iter::once(KEEP_TRANSPARENCY).chain(presets.iter().map(|p| p.name.as_str())).collect();
|
||||
row(ui, "Preset:", |ui| super::save_pdf::profile_pick(ui, d, "advanced.flattenerPreset", &names, true));
|
||||
note(
|
||||
ui,
|
||||
"Simulate prints overprints as Overprint Preview shows them; separations always keep them. A preset flattens transparency before printing (PostScript files always flatten it).",
|
||||
);
|
||||
}
|
||||
|
||||
/// The Printer Profile entry for no conversion.
|
||||
const SAME_AS_SOURCE: &str = "Same As Source";
|
||||
/// The flattener Preset entry for transparency printed as it is.
|
||||
const KEEP_TRANSPARENCY: &str = "None (keep transparency)";
|
||||
|
||||
/// The section an option is set in, and whether its first key is the section's own object
|
||||
/// (`output.mode` → Output › Mode).
|
||||
pub(super) fn section_of(option: &str) -> (usize, bool) {
|
||||
@@ -736,6 +781,7 @@ pub(super) fn section_of(option: &str) -> (usize, bool) {
|
||||
"graphics" => (3, true),
|
||||
"color" => (4, true),
|
||||
"margin" => (5, false),
|
||||
"advanced" => (5, true),
|
||||
_ => (0, false),
|
||||
}
|
||||
}
|
||||
@@ -767,6 +813,7 @@ pub(super) fn labelled(changed: &mut [Value]) {
|
||||
"output.emulsion" => label::<Emulsion>(id),
|
||||
"output.image" => label::<PrintImage>(id),
|
||||
"graphics.fonts" => label::<FontDownload>(id),
|
||||
"advanced.overprints" => label::<PrintOverprints>(id),
|
||||
"color.intent" => serde_json::from_value::<Intent>(c["value"].clone()).ok().map(|i| i.label()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -552,9 +552,9 @@ pub(super) fn marks_and_bleeds(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut
|
||||
});
|
||||
}
|
||||
|
||||
/// A dropdown of the colour profiles (`names`, the first one standing for "" at `path`), showing
|
||||
/// the profile at `path` (a name that isn't one of them too).
|
||||
fn profile_pick(ui: &mut egui::Ui, d: &mut Dialog, path: &str, names: &[&str], enabled: bool) {
|
||||
/// A dropdown of names such as colour profiles or flattener presets (`names`, the first one
|
||||
/// standing for "" at `path`), showing the name at `path` (one that isn't among them too).
|
||||
pub(super) fn profile_pick(ui: &mut egui::Ui, d: &mut Dialog, path: &str, names: &[&str], enabled: bool) {
|
||||
let blank = names.first().copied().unwrap_or_default();
|
||||
let current = get(d, path).as_str().filter(|s| !s.is_empty()).unwrap_or(blank).to_string();
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! The Print dialog's Advanced and Color Management options and the Print Tiling tool's placement
|
||||
//! in it: they draw, label, save with the document and move with the preview.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::print::KIND;
|
||||
use crate::state::Dialog;
|
||||
use crate::{VectorcraftApp, theme};
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 120, "height": 90})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn set(app: &mut VectorcraftApp, field: &str, value: Value) {
|
||||
app.ui.dialog.as_mut().expect("a dialog is open").fields.insert(field.into(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_options_draw_label_and_save_with_the_document() {
|
||||
let mut app = app();
|
||||
app.run("print.tiling.set", json!({"origin": [-20, -10]})).unwrap();
|
||||
app.run("file.print", json!({})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["tileOrigin"]["placed"], true, "the tool's placement comes in");
|
||||
for section in ["General", "Color Management", "Advanced", "Summary"] {
|
||||
for mode in ["composite", "separations"] {
|
||||
set(&mut app, "__section", json!(section));
|
||||
set(&mut app, "output", json!({"mode": mode}));
|
||||
set(&mut app, "advanced", json!({"printAsBitmap": true, "overprints": "simulate", "flattenerPreset": "High Resolution"}));
|
||||
set(&mut app, "color", json!({"profile": vectorcraft_color::cms::GENERIC_CMYK}));
|
||||
frame(&mut app);
|
||||
assert!(app.ui.dialog.is_some(), "{section} ({mode}) closed the dialog");
|
||||
}
|
||||
}
|
||||
let mut changed = vec![json!({"option": "advanced.overprints", "value": "simulate"})];
|
||||
super::print::labelled(&mut changed);
|
||||
assert_eq!(changed[0]["value"], "Simulate");
|
||||
assert_eq!(super::print::option_label("advanced.printAsBitmap"), "Advanced › Print As Bitmap");
|
||||
// Done keeps them with the document.
|
||||
set(&mut app, "output", json!({"mode": "composite"}));
|
||||
set(&mut app, "discard", json!(true));
|
||||
super::confirm(&mut app).unwrap();
|
||||
let saved = app.session.execute("print.setup", &json!({})).unwrap()["settings"].clone();
|
||||
assert_eq!(saved["advanced"], json!({"printAsBitmap": true, "overprints": "simulate", "flattenerPreset": "High Resolution"}));
|
||||
assert_eq!(saved["color"]["profile"], vectorcraft_color::cms::GENERIC_CMYK);
|
||||
assert_eq!(saved["tileOrigin"]["placed"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_preview_moves_the_print_tiling_tools_pages() {
|
||||
let app = &mut app();
|
||||
for settings in [json!({}), json!({"transverse": true, "scaling": "custom", "scale": {"width": 50, "height": 200}})] {
|
||||
let mut settings = settings;
|
||||
settings["tileOrigin"] = json!({"placed": true, "x": -20, "y": -10});
|
||||
let before = app.session.execute("print.preview", &json!({ "settings": settings })).unwrap();
|
||||
let mut d = Dialog::new(KIND, settings.clone());
|
||||
super::print::move_placement(&mut d, &before["sheets"][0], (10.0, 5.0));
|
||||
let moved = super::print::settings(&d);
|
||||
assert_eq!(moved["placement"], Value::Null, "the placement stays");
|
||||
let after = app.session.execute("print.preview", &json!({ "settings": moved })).unwrap();
|
||||
let (t0, t1) = (&before["sheets"][0]["trim"], &after["sheets"][0]["trim"]);
|
||||
let delta = (t1[0].as_f64().unwrap() - t0[0].as_f64().unwrap(), t1[1].as_f64().unwrap() - t0[1].as_f64().unwrap());
|
||||
assert!((delta.0 - 10.0).abs() < 0.05 && (delta.1 - 5.0).abs() < 0.05, "{settings}: moved {delta:?}");
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,8 @@ mod tests_place;
|
||||
#[cfg(test)]
|
||||
mod tests_printps;
|
||||
#[cfg(test)]
|
||||
mod tests_printtiling;
|
||||
#[cfg(test)]
|
||||
mod tests_recolor;
|
||||
#[cfg(test)]
|
||||
mod tests_recovery;
|
||||
@@ -178,11 +180,16 @@ pub struct CanvasCache {
|
||||
pub worker_started: bool,
|
||||
/// The slices as laid out for (document uid, revision): the canvas draws them every frame.
|
||||
pub slices: Option<SliceCache>,
|
||||
/// The print tiling's pages for (document uid, revision) (View → Show Print Tiling).
|
||||
pub print_tiling: Option<PrintTilingCache>,
|
||||
}
|
||||
|
||||
/// [`CanvasCache::slices`]: the layout of the slices of (document uid, revision).
|
||||
pub type SliceCache = ((u64, u64), std::sync::Arc<Vec<vectorcraft_doc::SliceArea>>);
|
||||
|
||||
/// [`CanvasCache::print_tiling`]: the print tiling of (document uid, revision).
|
||||
pub type PrintTilingCache = ((u64, u64), std::sync::Arc<Vec<vectorcraft_pdf::TilingPage>>);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CacheKey {
|
||||
pub doc: usize,
|
||||
@@ -284,6 +291,7 @@ impl VectorcraftApp {
|
||||
worker: None,
|
||||
worker_started: false,
|
||||
slices: None,
|
||||
print_tiling: None,
|
||||
},
|
||||
perf: Perf::default(),
|
||||
integrated_titlebar: false,
|
||||
|
||||
@@ -226,7 +226,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"tool.options",
|
||||
"Tool Options…",
|
||||
"",
|
||||
"{tool: id} what double-clicking a tool button opens: gradient → the Gradient panel (window.panel), eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions)",
|
||||
"{tool: id} what double-clicking a tool button opens: gradient → the Gradient panel (window.panel), eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true})",
|
||||
),
|
||||
(
|
||||
"ui.colorGuideOptions",
|
||||
@@ -1027,6 +1027,7 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String {
|
||||
"view.grid" => if v.grid { "Hide Grid" } else { "Show Grid" }.into(),
|
||||
"view.guides.lock" => if app.session.guides_locked() { "Unlock Guides" } else { "Lock Guides" }.into(),
|
||||
"view.slices.hide" => if app.session.slices_hidden() { "Show Slices" } else { "Hide Slices" }.into(),
|
||||
"view.printTiling" => if app.session.active().is_some_and(|d| d.print_tiling) { "Hide Print Tiling" } else { "Show Print Tiling" }.into(),
|
||||
id if id.starts_with("file.openRecent") => recent_slot(app, id)
|
||||
.map(|p| std::path::Path::new(p).file_name().map_or(p.clone(), |f| f.to_string_lossy().to_string()))
|
||||
.unwrap_or_else(|| "—".into()),
|
||||
@@ -1698,7 +1699,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
||||
Sep,
|
||||
c("Hide Edges", "view.edges"),
|
||||
c("Hide Artboards", "view.artboards"),
|
||||
todo("Show Print Tiling"),
|
||||
c("Show Print Tiling", "view.printTiling"),
|
||||
c("Hide Slices", "view.slices.hide"),
|
||||
c("Lock Slices", "view.slices.lock"),
|
||||
Sep,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! View → Show Print Tiling and the Print Tiling tool on the canvas and in the menus.
|
||||
|
||||
use egui::{Pos2, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
/// One headless canvas frame on an 800 × 600 window; the texts it painted.
|
||||
fn frame_texts(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<String> {
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| crate::canvas::show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
out.shapes
|
||||
.iter()
|
||||
.filter_map(|c| match &c.shape {
|
||||
egui::Shape::Text(t) => Some(t.galley.text().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_canvas_numbers_the_print_tiles_of_print_preview() {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
assert!(!frame_texts(&mut app, &ctx).iter().any(|t| t == "1"), "hidden by default");
|
||||
assert_eq!(crate::menus::dynamic_label(&app, "view.printTiling", "Show Print Tiling"), "Show Print Tiling");
|
||||
app.run("view.printTiling", json!({})).unwrap();
|
||||
assert_eq!(crate::menus::dynamic_label(&app, "view.printTiling", "Show Print Tiling"), "Hide Print Tiling");
|
||||
assert!(frame_texts(&mut app, &ctx).iter().any(|t| t == "1"), "the page is numbered");
|
||||
// Tiled at 400%: every tile print.preview lays out is drawn and numbered.
|
||||
app.run("print.setup", json!({"settings": {"scaling": "tileImageable", "scale": {"width": 400, "height": 400}}})).unwrap();
|
||||
let texts = frame_texts(&mut app, &ctx);
|
||||
let pv = app.session.execute("print.preview", &json!({})).unwrap();
|
||||
let count = pv["tiles"][0]["tiles"].as_array().unwrap().len();
|
||||
assert!(count > 1);
|
||||
assert_eq!(app.canvas.print_tiling.as_ref().map(|(_, p)| p.len()), Some(count));
|
||||
for n in 1..=count {
|
||||
assert!(texts.contains(&n.to_string()), "tile {n} numbered: {texts:?}");
|
||||
}
|
||||
// The Print Tiling tool shows the tiling too; double-clicking its button resets it.
|
||||
app.run("view.printTiling", json!({})).unwrap();
|
||||
app.run("print.tiling.set", json!({"origin": [10, 10]})).unwrap();
|
||||
app.select_tool("printTiling");
|
||||
assert!(frame_texts(&mut app, &ctx).iter().any(|t| t == "1"));
|
||||
app.run("tool.options", json!({"tool": "printTiling"})).unwrap();
|
||||
assert_eq!(app.session.execute("print.setup", &json!({})).unwrap()["settings"]["tileOrigin"]["placed"], false);
|
||||
}
|
||||
@@ -182,7 +182,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
|
||||
/// Open a tool's options (`tool.options`, a double-click on its button): the Gradient tool's are
|
||||
/// the Gradient panel, the Eyedropper's the Eyedropper Options dialog.
|
||||
/// the Gradient panel, the Eyedropper's the Eyedropper Options dialog; the Print Tiling tool's
|
||||
/// resets the print tiling.
|
||||
pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::Value, String> {
|
||||
match tool {
|
||||
"gradient" if app.ui.open_panel.as_deref() == Some("gradient") => Ok(json!({ "open": "gradient" })),
|
||||
@@ -191,6 +192,8 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
|
||||
crate::dialogs::eyedropper::open(app);
|
||||
Ok(json!({ "dialog": crate::dialogs::eyedropper::KIND }))
|
||||
}
|
||||
// A double click on the Print Tiling tool puts the pages back where the placement puts them.
|
||||
"printTiling" => app.run("print.tiling.set", json!({ "reset": true })),
|
||||
_ if vectorcraft_tools::tool_info(tool).is_none() => Err(format!("unknown tool `{tool}`")),
|
||||
_ => Err(format!("the {tool} tool has no options")),
|
||||
}
|
||||
|
||||
+40
@@ -1584,3 +1584,43 @@ claims the standard.
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"document.exportPdf","params":{"path":"/tmp/press.pdf","preset":"PDF/X-1a:2001","output":{"outputIntent":"VectorCraft Generic CMYK (SWOP-like)","outputCondition":"Coated","trapped":false}}}}
|
||||
```
|
||||
|
||||
## Print tiling
|
||||
|
||||
View → Show Print Tiling (`view.printTiling {on?}`, per document, not undoable) draws the pages of the document's print
|
||||
settings on the canvas: each page's paper edge, its imageable area dashed, numbered (tiles across then down; tiles
|
||||
outside `tileRange` dimmer). The Print Tiling tool (`printTiling`) shows them too. `print.tiling {settings?}` answers
|
||||
what is drawn, from the layout `print.preview` and both writers (PDF and PostScript) use, so the canvas and the
|
||||
printed job agree: `{tileOrigin, pages: [{artboard, number, printed, page, imageable}]}` (`[x0, y0, x1, y1]` in document space; every tile when tiling, else
|
||||
each page once, not once per ink).
|
||||
|
||||
Dragging with the tool puts the top-left corner of the first page's imageable area where the pointer is (snapping to
|
||||
the artboard's edges): `print.tiling.set {origin: [x, y], artboard?}` saves it with the print settings as
|
||||
`tileOrigin: {placed: true, x, y}`, measured from the artboard's top-left corner (one undo step). It replaces the
|
||||
placement, and tiles start there (going on left and up as far as the art does). A double click on the canvas, or on
|
||||
the tool's button, runs `print.tiling.set {reset: true}`: the placement places the pages again. In the Print dialog
|
||||
the placement then shows as placed by the tool, with a Reset button; dragging the preview moves the origin.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"view.printTiling","params":{"on":true}}}
|
||||
{"name":"run_command","arguments":{"command":"print.tiling.set","params":{"origin":[-36,-36]}}}
|
||||
{"name":"run_command","arguments":{"command":"print.tiling","params":{"settings":{"scaling":"tileImageable","scale":{"width":300,"height":300}}}}}
|
||||
```
|
||||
|
||||
## Print advanced options
|
||||
|
||||
The Print dialog's Advanced section and the printer profile are print settings too (`print.setup`, `print.preview`,
|
||||
`file.print`): `advanced: {printAsBitmap, overprints: preserve|discard|simulate, flattenerPreset}` and `color:
|
||||
{profile}`. In composite output, `discard` makes overprinting fills and strokes knock out and `simulate` prints them
|
||||
as Overprint Preview shows them (multiplied); either way the file has no overprint left. Separations always keep
|
||||
overprints. `flattenerPreset` (High Resolution, Medium Resolution, Low Resolution or a saved preset,
|
||||
`flattener.presets.list`) flattens transparency before printing, as Object → Flatten Transparency does; empty, a
|
||||
PDF keeps it live (PostScript, which has none, uses `file.print`'s `flattenerPreset`, else this one, else medium).
|
||||
`printAsBitmap` prints each composite page as one image of the art at the document's raster effects resolution.
|
||||
`color.profile` names an RGB or CMYK profile: composite PDF colours are converted to it with `color.intent` (CMYK
|
||||
colours keep their numbers with `preserveNumbers`), so a CMYK profile writes device CMYK; separations separate with a
|
||||
CMYK one. Settings saved before these options load with their defaults.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"file.print","params":{"path":"/tmp/proof.pdf","settings":{"advanced":{"overprints":"simulate","flattenerPreset":"High Resolution"},"color":{"profile":"VectorCraft Generic CMYK (SWOP-like)","intent":"perceptual"}}}}}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user