diff --git a/Cargo.lock b/Cargo.lock index 404abacf8..591389407 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1059,7 +1059,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e#c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e" dependencies = [ "chrono", "dunce", @@ -8221,7 +8221,6 @@ dependencies = [ "nix", "typed-path", "windows-sys 0.61.2", - "zopfli", ] [[package]] @@ -8236,18 +8235,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" -[[package]] -name = "zopfli" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] - [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 6dbf4a4d0..fd8527fd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63 # The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON. craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 2dfc4e6f4..f47b10e68 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -92,6 +92,9 @@ impl eframe::App for App { } fn on_exit(&mut self) { file_access::unconfined(|| save_prefs(&self.app)); + // A ready update installs once the process has exited. The macOS exit below skips + // dropping the app, so the helper is started here. + self.app.start_pending_update(); end_before_teardown(self.frames); } } diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index b3a1fd06b..fd062e504 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -97,7 +97,7 @@ use titlebar::opted_into_system_title_bar; #[cfg(windows)] use titlebar::set_taskbar_icon; #[cfg(all(test, feature = "wgpu"))] -use titlebar::{app_draws_frame, opted_into_app_title_bar, start_frame}; +use titlebar::{app_draws_frame, opted_into_app_title_bar, start_frame, system_title_bar_applies}; use titlebar::{custom_titlebar, native_options, titlebar_from_env}; struct App { diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs index 555d829e0..e81dd7815 100644 --- a/apps/vectorcraft/src/tests.rs +++ b/apps/vectorcraft/src/tests.rs @@ -245,6 +245,79 @@ fn the_start_frame_applies_the_app_title_bar_preference() { assert!(!super::custom_titlebar_from(&both, true), "System Title Bar takes precedence over the app title bar preference"); } +/// `VECTORCRAFT_TITLEBAR` chooses the frame for the run it is set in, with System Title Bar off and +/// on. The saved preference still decides while the variable is unset. +#[test] +fn the_titlebar_variable_takes_precedence_over_system_title_bar() { + use craft_window::Frame; + assert!(super::system_title_bar_applies(true, false)); + assert!(!super::system_title_bar_applies(true, true)); + assert!(!super::system_title_bar_applies(false, false)); + assert!(!super::system_title_bar_applies(false, true)); + // VECTORCRAFT_TITLEBAR=app with System Title Bar on: VectorCraft draws its own title bar. + let frame = super::start_frame(Frame::Desktop, Some(true), false, false); + assert_eq!(super::custom_titlebar_from(&frame, super::system_title_bar_applies(true, true)), !cfg!(target_os = "macos")); + // VECTORCRAFT_TITLEBAR=desktop with System Title Bar off keeps the desktop's frame. + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + let frame = super::start_frame(Frame::App(craft_window::Layout::default()), Some(false), false, false); + assert!(!super::custom_titlebar_from(&frame, super::system_title_bar_applies(false, true))); + } +} + +/// `VECTORCRAFT_TITLEBAR` read from the environment at startup, with System Title Bar off and on. +/// The variable is process-wide and read once, so each value runs in a separate test process. +#[test] +fn the_titlebar_variable_decides_the_frame_before_the_window_opens() { + const CHILD: &str = "VECTORCRAFT_TEST_TITLEBAR_CHILD"; + if std::env::var_os(CHILD).is_none() { + for value in ["app", "system", "desktop", "unknown"] { + // Linux and the BSDs: hold `spawning` while the child starts, so it does not keep a + // copy of a GPU start test's locked start file (see `CHILD_SPAWN`). + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + let spawning = super::spawning(); + let child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "tests::the_titlebar_variable_decides_the_frame_before_the_window_opens", "--nocapture"]) + .env(CHILD, "1") + .env("VECTORCRAFT_TITLEBAR", value) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn(); + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + drop(spawning); + let output = child.unwrap().wait_with_output().unwrap(); + assert!( + output.status.success(), + "VECTORCRAFT_TITLEBAR={value}\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + return; + } + let value = std::env::var("VECTORCRAFT_TITLEBAR").unwrap(); + let not_read = cfg!(any(target_os = "macos", target_os = "windows")); + assert_eq!(super::titlebar_from_env(), !not_read && value != "unknown", "VECTORCRAFT_TITLEBAR={value}"); + let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false})); + let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true})); + for (saved, opted_into_system) in [(None, false), (Some(&off), false), (Some(&on), true)] { + let custom = super::custom_titlebar(saved); + let expected = match value.as_str() { + _ if not_read => !cfg!(target_os = "macos") && !opted_into_system, + "app" => true, + "system" | "desktop" => false, + // Not a value the variable takes: System Title Bar decides when it is on. Otherwise the + // `craft-window` probe decides, the same with System Title Bar off as with no saved + // preferences. + _ if opted_into_system => false, + _ => super::custom_titlebar(None), + }; + assert_eq!(custom, expected, "{value}, preference {opted_into_system}"); + assert_eq!(super::native_options(custom).viewport.decorations, Some(!custom), "{value}, preference {opted_into_system}"); + } +} + /// The file extensions the macOS bundle declares: its document types and its own exported type. fn plist_extensions(plist: &str) -> Vec<&str> { ["CFBundleTypeExtensions", "public.filename-extension"] diff --git a/apps/vectorcraft/src/titlebar.rs b/apps/vectorcraft/src/titlebar.rs index 6e7451ed9..17c847040 100644 --- a/apps/vectorcraft/src/titlebar.rs +++ b/apps/vectorcraft/src/titlebar.rs @@ -41,7 +41,8 @@ pub(crate) const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); /// Whether the user asked for the system title bar in Preferences › User Interface › System Title /// Bar. Read from the saved preferences before the window opens; a missing or unreadable value is -/// `false` (the default). This preference forces the desktop/native frame back on. +/// `false` (the default). This preference forces the desktop/native frame back on unless +/// `VECTORCRAFT_TITLEBAR` is set ([`system_title_bar_applies`]). pub(crate) fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false) } @@ -87,12 +88,18 @@ fn probed_choice() -> &'static craft_window::Choice { } /// Whether `VECTORCRAFT_TITLEBAR` chose this window's frame (Linux and the BSDs), so Preferences › -/// User Interface › VectorCraft Title Bar on Every Desktop does not apply while it is set. Always -/// `false` on macOS and Windows. +/// User Interface › System Title Bar and VectorCraft Title Bar on Every Desktop do not apply while +/// it is set. Always `false` on macOS and Windows. pub(crate) fn titlebar_from_env() -> bool { cfg!(not(any(target_os = "macos", target_os = "windows"))) && probed_choice().overridden().is_some() } +/// Whether System Title Bar decides this start: `opted_into_system` unless `VECTORCRAFT_TITLEBAR` +/// is set (`from_env`), which chooses the frame for the run it is set in. +pub(crate) fn system_title_bar_applies(opted_into_system: bool, from_env: bool) -> bool { + opted_into_system && !from_env +} + /// Whether this start draws its own title bar, given the frame the shared `craft-window` crate /// decided and the user's System Title Bar preference. /// @@ -102,7 +109,8 @@ pub(crate) fn titlebar_from_env() -> bool { /// ([`craft_window::Frame::App`]) only where the compositor leaves it to the app (GNOME's Mutter), /// or when `VECTORCRAFT_TITLEBAR` overrides the probe. VectorCraft draws its own title bar exactly /// when `craft-window` says the app owns the frame **and** the user has not opted into the system -/// title bar; the preference always forces the native frame back on. +/// title bar; the preference forces the native frame back on unless `VECTORCRAFT_TITLEBAR` is set +/// ([`system_title_bar_applies`]). /// /// macOS and Windows keep today's behavior and are decided by [`CUSTOM_TITLEBAR`] rather than the /// probe: `craft-window` returns `Frame::Desktop` for both (the OS always draws a frame), but @@ -124,7 +132,8 @@ pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_syst /// Whether this start draws its own title bar. Probes the shared `craft-window` crate once (its /// override variable is `VECTORCRAFT_TITLEBAR`) and combines its decision with the saved System -/// Title Bar preference. Call once at startup, before [`native_options`]. +/// Title Bar preference, which does not apply while the variable is set. Call once at startup, +/// before [`native_options`]. /// /// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through /// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs @@ -133,7 +142,7 @@ pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_syst /// and so does VectorCraft Title Bar on Every Desktop ([`app_draws_frame`]). pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { let choice = probed_choice(); - let opted_into_system = opted_into_system_title_bar(saved); + let opted_into_system = system_title_bar_applies(opted_into_system_title_bar(saved), titlebar_from_env()); // macOS and Windows never read VectorCraft Title Bar on Every Desktop (`custom_titlebar_from` // ignores the frame there). let opted_into_app = cfg!(not(any(target_os = "macos", target_os = "windows"))) && opted_into_app_title_bar(saved); diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index f85c7b2c2..44de123fa 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Print Setup", [], None, - "{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)", + "{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)", has_doc, setup ), @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Print Preview", [], None, - "{preset?: name (as print.setup's: its settings instead of the document's), settings?: {…settings of print.setup} (over the document's or the preset's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, type that doesn't fit its frame, options not applied yet…), settings} without printing", + "{preset?: name (as print.setup's: its settings instead of the document's), settings?: {…settings of print.setup} (over the document's or the preset's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page; with copies per sheet, the box around every copy's), copies?: [[x0, y0, x1, y1]…] (copies per sheet: each copy's artboard on the page, across then down)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, type that doesn't fit its frame, options not applied yet…), settings} without printing", has_doc, preview ), @@ -41,7 +41,7 @@ pub fn specs() -> Vec { "Print", [], Some("Cmd+P"), - "{preset?: name (as print.setup's: its settings instead of the document's; the saved settings stay), settings?: {…settings of print.setup} (over the document's or the preset's), includeArtboardFill?: true (each artboard's background color prints behind its art; false leaves it out), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out), forPrinter?: bool (the PDF goes to a print queue: a page whose paper is turned to landscape is written upright, the art turned a quarter anticlockwise, as a printer driver turns a landscape job; macOS prints a landscape page as it is, cut off on upright paper; paper wide in Portrait and transverse pages stay as they are)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device", + "{preset?: name (as print.setup's: its settings instead of the document's; the saved settings stay), settings?: {…settings of print.setup} (over the document's or the preset's), includeArtboardFill?: true (each artboard's background color prints behind its art; false leaves it out), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out), forPrinter?: bool (the PDF goes to a print queue: a page whose paper is turned to landscape is written upright, the art turned a quarter anticlockwise, as a printer driver turns a landscape job; macOS prints a landscape page as it is, cut off on upright paper; paper wide in Portrait and transverse pages stay as they are)} print the document, one page per sheet of paper (per tile, per ink, per copy; with copiesPerSheet, a sheet holds as many copies of an artboard as fit) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device", has_doc, print ), @@ -228,6 +228,7 @@ fn print(s: &mut Session, p: &Value) -> Result { view: sh.view, window: sh.window, place: sh.place, + copies: &sh.copies, area: sh.area, marks: sh.marks.as_ref(), negative: sh.negative, diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 92a6507de..7be385967 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -32,7 +32,8 @@ use std::collections::{BTreeMap, BTreeSet}; use std::io::Read; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::SystemTime; use vectorcraft_pdf::{PdfPreset, builtin_preset}; use vectorcraft_plugins::safe_mode; @@ -81,9 +82,9 @@ pub(crate) const MAX_CACHE_SCAN: usize = storage::MAX_CACHE_ENTRIES * 2; /// Held while a plug-in's settings or cache change, so two runs can't pass a cap together or lose /// each other's settings. static PLUGIN_DATA_LOCK: Mutex<()> = Mutex::new(()); -/// The closed flag of each plug-in folder's storage, shared by every storage made for the folder -/// until the plug-in is removed, which sets it and drops it. -static PLUGIN_DATA_OPEN: Mutex>> = Mutex::new(BTreeMap::new()); +/// The state of each plug-in folder's storage, shared by every storage made for the folder until +/// the plug-in is removed, which closes it and drops it. +static PLUGIN_DATA_OPEN: Mutex>> = Mutex::new(BTreeMap::new()); /// The file name (without extension) a stored item called `name` gets: its name made safe for any /// file system, then a hash of the name, so that names that differ only in characters a file name @@ -529,9 +530,9 @@ impl Session { /// Delete the settings and cache of plug-in `id` from the store, and stop keeping any for it. /// The folder is first renamed out of the way (`.removed`), and that fails, leaving it /// as it was, when the rename does; what the deletion then leaves stays under that name, with a - /// log record, and is deleted at the plug-in's next removal. `forget` runs once the folder is out - /// of the way, before anything is deleted: when it returns `false`, the folder is renamed back - /// and this fails. + /// log record. A later call for the id tries to delete it again. `forget` runs once the folder is + /// out of the way, before anything is deleted: when it returns `false`, the folder is renamed + /// back and this fails. pub(crate) fn remove_plugin_storage(&self, id: &str, forget: impl FnOnce() -> bool) -> Result<(), StorageError> { let not_forgotten = || StorageError::Io(GRANT_NOT_REMOVED.to_string()); vectorcraft_plugins::registry::set_storage(id, None); @@ -543,8 +544,8 @@ impl Session { // Every storage handed out for the folder, including those runs still hold, is closed once // the folder is out of the way; until then they keep working. let close = || { - if let Some(closed) = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).remove(&path) { - closed.store(true, Ordering::Release); + if let Some(data) = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).remove(&path) { + data.closed.store(true, Ordering::Release); } }; // A `storage` folder that is a link leads out of the store: nothing there is deleted. A link @@ -614,22 +615,67 @@ pub(crate) fn plugin_data_dir(dir: &Path, id: &str) -> PathBuf { fn plugin_storage(dir: Option<&Path>, id: &str) -> Option> { let dir = dir?; let data = plugin_data_dir(dir, id); - let closed = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).entry(data.clone()).or_default().clone(); - Some(Arc::new(PluginStorage { root: dir.to_path_buf(), dir: data, closed })) + let state = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).entry(data.clone()).or_default().clone(); + Some(Arc::new(PluginStorage { root: dir.to_path_buf(), dir: data, state })) +} + +/// A file's size and modified time (`None`: there is no file). +type FileStamp = Option<(u64, SystemTime)>; + +/// The stamp of the file at `path`. +fn file_stamp(path: &Path) -> Result { + match std::fs::metadata(path) { + Ok(m) => Ok(Some((m.len(), m.modified().map_err(|e| io(&e))?))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(io(&e)), + } +} + +/// The modified time of the folder at `dir` (`None`: it can't be read, or there is no folder). +fn folder_stamp(dir: &Path) -> Option { + std::fs::symlink_metadata(dir).ok()?.modified().ok() +} + +/// What every storage made for one plug-in folder shares ([`PLUGIN_DATA_OPEN`]). +#[derive(Default)] +struct PluginData { + /// Set, under [`PLUGIN_DATA_LOCK`], once the plug-in is removed. + closed: AtomicBool, + kept: Mutex, +} + +/// What the folder's files last held in this process. +#[derive(Default)] +struct Kept { + settings: Option, + cache: Option, +} + +/// The settings by key, and the settings file's stamp when they were read or written. +struct KeptSettings { + stamp: FileStamp, + values: BTreeMap>, +} + +/// The bytes of the cache entries, and the cache folder's stamp when they were added up. +#[derive(Clone, Copy)] +struct KeptCache { + stamp: Option, + bytes: u64, } /// The settings and cache of one plug-in, in its folder of the plug-in store (see the module docs). /// Keys and entry names are [`storage::valid_name`]s; an entry's file name is its name as /// [`case_safe`] writes it, so that names differing only in case get different files. A link /// anywhere in the folder's path below the store is refused, and so is a cache file that is not a -/// plain file. +/// plain file. The settings stay in memory ([`Kept`]) while the settings file keeps its size and +/// modified time, and every change is written to the file before the call returns. The total +/// `cache_bytes` reports stays in memory while the cache folder keeps its modified time. pub(crate) struct PluginStorage { /// The plug-in store, and the plug-in's folder in it. root: PathBuf, dir: PathBuf, - /// Set, under [`PLUGIN_DATA_LOCK`], once the plug-in is removed; shared by every storage of the - /// folder ([`PLUGIN_DATA_OPEN`]). - closed: Arc, + state: Arc, } /// The sizes of `files` added up, at most `u64::MAX`: the sizes come from the file system. @@ -703,17 +749,29 @@ impl PluginStorage { Ok(()) } - /// The settings, by key. A file that is too large or doesn't parse is moved aside - /// ([`BAD_SETTINGS_FILE`]) with a log record, and the settings are empty. `meter` is charged the - /// file's size before it is read. - fn read_settings(&self, meter: &mut Meter) -> Result>, StorageError> { + fn kept(&self) -> MutexGuard<'_, Kept> { + self.state.kept.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// The settings: those in `kept` when the settings file still has their stamp, else the file's, + /// which then replace them. + fn read_settings<'a>(&self, kept: &'a mut Option, meter: &mut Meter) -> Result<&'a mut KeptSettings, StorageError> { let path = self.settings_path(); self.no_links(&path)?; - let size = match std::fs::metadata(&path) { - Ok(m) => m.len(), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()), - Err(e) => return Err(io(&e)), + let stamp = file_stamp(&path)?; + let held = match kept.take() { + Some(held) if held.stamp == stamp => held, + _ => self.load_settings(stamp, meter)?, }; + Ok(kept.insert(held)) + } + + /// Reads the settings file, whose stamp is `stamp`. A file that is too large or doesn't parse is + /// moved aside ([`BAD_SETTINGS_FILE`]) with a log record, and the settings are empty. `meter` is + /// charged the file's size before it is read. + fn load_settings(&self, stamp: FileStamp, meter: &mut Meter) -> Result { + let path = self.settings_path(); + let Some((size, _)) = stamp else { return Ok(KeptSettings { stamp, values: BTreeMap::new() }) }; let readable = size <= storage::MAX_SETTINGS_BYTES as u64; if readable { meter.take(size)?; @@ -729,12 +787,30 @@ impl PluginStorage { settings.filter(|s| s.len() <= storage::MAX_SETTINGS && s.values().all(|v| v.len() <= storage::MAX_SETTING_BYTES)) }); match parsed { - Some(settings) => Ok(settings), + Some(values) => Ok(KeptSettings { stamp, values }), None => { let aside = self.dir.join(BAD_SETTINGS_FILE); log::warn!("the plug-in settings {} don't read: moved to {} and started empty", path.display(), aside.display()); std::fs::rename(&path, &aside).map_err(|e| io(&e))?; - Ok(BTreeMap::new()) + Ok(KeptSettings { stamp: None, values: BTreeMap::new() }) + } + } + } + + /// Writes the settings in `kept` to the file and records the file's new stamp. When that fails, + /// or settings were written and the file is gone, `kept` is emptied, so the next call reads the + /// file again. + fn save_settings(&self, kept: &mut Option, meter: &mut Meter) -> Result<(), StorageError> { + let Some(held) = kept.as_mut() else { return Ok(()) }; + let written = self.write_settings(&held.values, meter); + match (written, file_stamp(&self.settings_path())) { + (Ok(()), Ok(stamp)) if stamp.is_some() || held.values.is_empty() => { + held.stamp = stamp; + Ok(()) + } + (written, _) => { + *kept = None; + written } } } @@ -762,7 +838,7 @@ impl PluginStorage { /// Fails with [`StorageError::Unavailable`] once the storage is closed. fn open(&self) -> Result<(), StorageError> { - if self.closed.load(Ordering::Acquire) { Err(StorageError::Unavailable) } else { Ok(()) } + if self.state.closed.load(Ordering::Acquire) { Err(StorageError::Unavailable) } else { Ok(()) } } /// The entries of the cache folder (plain files named as [`Self::entry_path`] names them, which @@ -799,28 +875,33 @@ impl Storage for PluginStorage { fn setting(&self, key: &str, meter: &mut Meter) -> Result>, StorageError> { let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); self.open()?; - Ok(self.read_settings(meter)?.remove(key)) + let mut kept = self.kept(); + Ok(self.read_settings(&mut kept.settings, meter)?.values.get(key).cloned()) } fn set_setting(&self, key: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> { let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); self.open()?; - let mut settings = self.read_settings(meter)?; + let mut kept = self.kept(); + let settings = &mut self.read_settings(&mut kept.settings, meter)?.values; if value.len() > storage::MAX_SETTING_BYTES || (!settings.contains_key(key) && settings.len() >= storage::MAX_SETTINGS) { return Err(StorageError::Full); } + if settings.get(key).is_some_and(|old| old == value) { + return Ok(()); + } settings.insert(key.to_string(), value.to_vec()); - self.write_settings(&settings, meter) + self.save_settings(&mut kept.settings, meter) } fn delete_setting(&self, key: &str, meter: &mut Meter) -> Result { let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); self.open()?; - let mut settings = self.read_settings(meter)?; - if settings.remove(key).is_none() { + let mut kept = self.kept(); + if self.read_settings(&mut kept.settings, meter)?.values.remove(key).is_none() { return Ok(false); } - self.write_settings(&settings, meter).map(|()| true) + self.save_settings(&mut kept.settings, meter).map(|()| true) } fn cache_entry(&self, name: &str, meter: &mut Meter) -> Result>, StorageError> { @@ -854,7 +935,10 @@ impl Storage for PluginStorage { meter.take(value.len() as u64)?; std::fs::create_dir_all(self.cache_dir()).map_err(|e| io(&e))?; self.no_links(&path)?; - write_atomic(&path, value).map_err(|e| io(&e)) + write_atomic(&path, value).map_err(|e| io(&e))?; + let stamp = folder_stamp(&self.cache_dir()); + self.kept().cache = stamp.map(|_| KeptCache { stamp, bytes: total }); + Ok(()) } fn remove_cache_entry(&self, name: &str) -> Result { @@ -863,7 +947,10 @@ impl Storage for PluginStorage { let path = self.entry_path(name); self.no_links(&path)?; match std::fs::remove_file(&path) { - Ok(()) => Ok(true), + Ok(()) => { + self.kept().cache = None; + Ok(true) + } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), Err(e) => Err(io(&e)), } @@ -891,6 +978,7 @@ impl Storage for PluginStorage { freed = freed.saturating_add(m.len()); } } + self.kept().cache = Some(KeptCache { stamp: folder_stamp(&dir), bytes: 0 }); Ok(freed) } @@ -898,7 +986,14 @@ impl Storage for PluginStorage { if self.open().is_err() { return 0; } - self.cache_files().map(|(files, _)| sum_sizes(&files)).unwrap_or(0) + let stamp = folder_stamp(&self.cache_dir()); + if let Some(kept) = self.kept().cache.filter(|k| k.stamp == stamp) { + return kept.bytes; + } + let Ok((files, _)) = self.cache_files() else { return 0 }; + let bytes = sum_sizes(&files); + self.kept().cache = Some(KeptCache { stamp, bytes }); + bytes } } diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 34f3be01c..afd7535e7 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -2727,9 +2727,14 @@ fn plugin_storage_writes_are_atomic(st: &dyn vectorcraft_plugins::storage::Stora std::fs::remove_file(&entry_copy).unwrap(); } +/// Gives the file at `path` the modified time `when`, whatever the file system's clock says. +fn set_modified(path: &std::path::Path, when: std::time::SystemTime) { + std::fs::File::options().write(true).open(path).unwrap().set_modified(when).unwrap(); +} + /// Each storage call is charged the bytes it reads and writes, before it does: a settings call the -/// whole settings file it reads and the whole file it writes, a cache call the entry it reads or -/// writes. A call that would pass what its meter has left fails and changes nothing. +/// whole settings file when it reads the file and the whole file when it writes it, a cache call the +/// entry it reads or writes. A call that would pass what its meter has left fails and changes nothing. fn plugin_storage_calls_are_charged_the_files_they_read_and_write() { use vectorcraft_plugins::storage::{Meter, StorageError}; let folder = crate::tests_links::Folder::new("plugin-storage-meter"); @@ -2743,22 +2748,25 @@ fn plugin_storage_calls_are_charged_the_files_they_read_and_write() { st.set_setting("big", &[7; 60_000], &mut Meter::unlimited()).unwrap(); st.set_setting("small", b"x", &mut Meter::unlimited()).unwrap(); - // Reading a one-byte setting reads the whole file. + // A file another window changed is read whole, even for a one-byte setting. + let touch = || set_modified(&file, std::fs::metadata(&file).unwrap().modified().unwrap() + std::time::Duration::from_secs(1)); + touch(); let before = size(); let mut m = Meter::unlimited(); assert_eq!(st.setting("small", &mut m).unwrap(), Some(b"x".to_vec())); assert_eq!(m.used(), before); - // Setting it reads the file and writes it again, and so does deleting one. + // Setting it writes the file again, and so does deleting one. let mut m = Meter::unlimited(); st.set_setting("small", b"yz", &mut m).unwrap(); - assert_eq!(m.used(), before + size()); - let (before, mut m) = (size(), Meter::unlimited()); + assert_eq!(m.used(), size()); + let mut m = Meter::unlimited(); assert!(st.delete_setting("small", &mut m).unwrap()); - assert_eq!(m.used(), before + size()); + assert_eq!(m.used(), size()); st.set_setting("small", b"y", &mut Meter::unlimited()).unwrap(); // Less left than the file: nothing is read. Enough to read it but not to write it again: // nothing changes. + touch(); let mut m = Meter::new(size() - 1); assert_eq!(st.setting("small", &mut m), Err(StorageError::Budget)); assert_eq!(m.used(), 0); @@ -2778,6 +2786,122 @@ fn plugin_storage_calls_are_charged_the_files_they_read_and_write() { run(&mut s, "plugin.remove", json!({"id": id})); } +/// A session with the plug-in `id` installed in a new store → its storage and the path of its +/// settings file. +fn storage_in( + s: &mut Session, + folder: &crate::tests_links::Folder, + id: &str, +) -> (std::sync::Arc, std::path::PathBuf) { + s.attach_plugin_store(folder.0.clone()); + install_storing(s, id, ""); + (vectorcraft_plugins::registry::storage(id).unwrap(), crate::store::plugin_data_dir(&folder.0, id).join("settings.json")) +} + +/// The settings stay in memory while the file keeps its size and modified time: a read doesn't open +/// the file, a set to the value already there doesn't write it, a real change is in the file when +/// the call returns, and a file with another time is read again. +#[test] +fn plugin_settings_are_read_from_memory_until_the_file_changes() { + use vectorcraft_plugins::storage::{Meter, StorageError}; + let folder = crate::tests_links::Folder::new("plugin-storage-kept"); + let id = "test.engine.storagekept"; + let mut s = session(); + let (st, file) = storage_in(&mut s, &folder, id); + st.set_setting("zoom", b"hello", &mut Meter::unlimited()).unwrap(); + let written = std::fs::metadata(&file).unwrap().modified().unwrap(); + + // Other bytes of the same size and the same time: the storage can't tell, so what it answers + // shows that it did not read the file. + let other = + std::fs::read_to_string(&file).unwrap().replace(&vectorcraft_format::base64_encode(b"hello"), &vectorcraft_format::base64_encode(b"world")); + std::fs::write(&file, &other).unwrap(); + set_modified(&file, written); + let mut m = Meter::unlimited(); + assert_eq!(st.setting("zoom", &mut m).unwrap(), Some(b"hello".to_vec())); + assert_eq!(m.used(), 0, "answered from memory"); + + // Setting the value it has writes nothing: the file still holds the other bytes. + let mut m = Meter::unlimited(); + st.set_setting("zoom", b"hello", &mut m).unwrap(); + assert_eq!(m.used(), 0, "nothing to write"); + assert_eq!(std::fs::read_to_string(&file).unwrap(), other); + + // Another time: read again. + set_modified(&file, written + std::time::Duration::from_secs(1)); + let mut m = Meter::unlimited(); + assert_eq!(st.setting("zoom", &mut m).unwrap(), Some(b"world".to_vec())); + assert_eq!(m.used(), other.len() as u64); + + // A change reaches the file before the call returns, and a failed one is not kept. + st.set_setting("tile", b"x", &mut Meter::unlimited()).unwrap(); + assert!(std::fs::read_to_string(&file).unwrap().contains("\"tile\"")); + assert_eq!(st.set_setting("tile", b"y", &mut Meter::new(0)), Err(StorageError::Budget)); + assert_eq!(st.setting("tile", &mut Meter::unlimited()).unwrap(), Some(b"x".to_vec())); + assert!(st.delete_setting("tile", &mut Meter::unlimited()).unwrap()); + assert!(!std::fs::read_to_string(&file).unwrap().contains("\"tile\"")); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// `plugin.remove` drops what was kept: a file put back with the removed settings' size and time is +/// read from disk by the storage of the id installed again. +#[test] +fn removing_a_plugin_drops_its_settings_from_memory() { + use vectorcraft_plugins::storage::Meter; + let folder = crate::tests_links::Folder::new("plugin-storage-kept-removed"); + let id = "test.engine.storagekeptremoved"; + let mut s = session(); + let (st, file) = storage_in(&mut s, &folder, id); + st.set_setting("zoom", b"hello", &mut Meter::unlimited()).unwrap(); + let (bytes, written) = (std::fs::read(&file).unwrap(), std::fs::metadata(&file).unwrap().modified().unwrap()); + run(&mut s, "plugin.remove", json!({"id": id})); + + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(&file, &bytes).unwrap(); + set_modified(&file, written); + s.attach_plugin_storage(id); + let st = vectorcraft_plugins::registry::storage(id).unwrap(); + let mut m = Meter::unlimited(); + assert_eq!(st.setting("zoom", &mut m).unwrap(), Some(b"hello".to_vec())); + assert_eq!(m.used(), bytes.len() as u64, "read from the file"); + s.remove_plugin_storage(id).unwrap(); +} + +/// The cache's total stays in memory while the cache folder keeps its time; a write and a clear +/// update it, and removing an entry drops it. A file whose length changes behind the storage leaves +/// the folder's time alone, so the total it reports shows that it did not list the folder. +#[test] +fn plugin_cache_total_is_kept_and_follows_writes_removals_and_clears() { + use vectorcraft_plugins::storage::Meter; + let folder = crate::tests_links::Folder::new("plugin-storage-kept-cache"); + let id = "test.engine.storagekeptcache"; + let mut s = session(); + let (st, file) = storage_in(&mut s, &folder, id); + let cache = file.parent().unwrap().join("cache"); + let m = &mut Meter::unlimited(); + let grow = |name: &str, len: u64| std::fs::File::options().write(true).open(cache.join(name)).unwrap().set_len(len).unwrap(); + + st.set_cache_entry("tile", b"abc", m).unwrap(); + grow("tile.bin", 10); + assert_eq!(st.cache_bytes(), 3, "the write set the total"); + // A write lists the folder for its caps, and starts from what it finds. + st.set_cache_entry("more", b"12345", m).unwrap(); + grow("more.bin", 5); + assert_eq!(st.cache_bytes(), 15); + assert!(st.remove_cache_entry("more").unwrap()); + assert_eq!(st.cache_bytes(), 10, "after a removal the total comes from the folder"); + grow("tile.bin", 20); + assert_eq!(st.cache_bytes(), 10, "and is kept again"); + assert_eq!(st.clear_cache().unwrap(), 20); + assert_eq!(st.cache_bytes(), 0); + + // A folder deleted behind the storage is noticed. + st.set_cache_entry("tile", b"abc", m).unwrap(); + std::fs::remove_dir_all(&cache).unwrap(); + assert_eq!(st.cache_bytes(), 0); + run(&mut s, "plugin.remove", json!({"id": id})); +} + /// A module with other bytes than the plug-in installed with its id, or, with none installed, one /// whose id has settings and a cache in the plug-in store, installs only with `replace: true`, and /// then keeps them; the same module installs again without it, and `replace` must be true or false. diff --git a/crates/engine/src/tests/print.rs b/crates/engine/src/tests/print.rs index f685e76af..8ccd76deb 100644 --- a/crates/engine/src/tests/print.rs +++ b/crates/engine/src/tests/print.rs @@ -144,6 +144,68 @@ fn preview_lists_pages_tiles_inks_and_warnings() { assert_eq!(v["sheets"][3]["tile"], 4); } +#[test] +fn copies_per_sheet_print_a_grid_of_the_artboard() { + // A 2 × 3.5 in card filled by a rectangle. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 144, "height": 252})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 144, "height": 252})).unwrap(); + let v = s.execute("print.setup", &json!({})).unwrap(); + assert_eq!((v["settings"]["copiesPerSheet"].clone(), v["settings"]["gutter"].clone()), (json!(false), json!(0.0))); + // With an 18 pt gutter, Letter holds 3 × 3 copies; the first, at the top left, spans x 72 to 216. + let set = json!({"settings": {"copiesPerSheet": true, "gutter": 18}}); + let v = s.execute("print.preview", &set).unwrap(); + let copies = v["sheets"][0]["copies"].as_array().unwrap(); + assert_eq!((v["pages"].clone(), copies.len(), copies[0].clone()), (json!(1), 9, json!([72.0, 0.0, 216.0, 252.0]))); + // The PDF draws the card's paths nine times. + let cards = |bytes: &[u8]| { + let mut n = 0; + vectorcraft_pdf::import(bytes).unwrap().walk(|node| { + n += usize::from(node.path_data().and_then(|p| p.bounds()).is_some_and(|b| (b.width().round(), b.height().round()) == (144.0, 252.0))) + }); + n + }; + let (bytes, v) = print(&mut s, set); + assert_eq!(v["pages"], 1); + let one = cards(&print(&mut s, json!({})).0); + assert!(one > 0 && cards(&bytes) == 9 * one, "{one}"); + // PostScript places the art once per copy. + let mut ps = |p: Value| { + let v = s.execute("file.print", &p).unwrap(); + String::from_utf8(vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap() + }; + let one = ps(json!({"format": "postscript"})).matches(" cm\n").count(); + let grid = ps(json!({"format": "postscript", "settings": {"copiesPerSheet": true, "gutter": 18}})).matches(" cm\n").count(); + assert_eq!(grid, one + 8); +} + +#[test] +fn print_presets_keep_copies_per_sheet_and_older_presets_leave_it_off() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 144, "height": 252})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 144, "height": 252})).unwrap(); + // [Default] prints one copy per sheet. + let v = s.execute("print.presets.list", &json!({})).unwrap(); + assert_eq!((v["presets"][0]["settings"]["copiesPerSheet"].clone(), v["presets"][0]["settings"]["gutter"].clone()), (json!(false), json!(0.0))); + // A saved preset keeps the option and the gutter, lists them as changed, and prints with them. + s.execute("print.presets.save", &json!({"name": "Cards", "settings": {"copiesPerSheet": true, "gutter": 18}})).unwrap(); + let v = s.execute("print.presets.list", &json!({})).unwrap(); + let changed: Vec<&str> = v["presets"][1]["changed"].as_array().unwrap().iter().filter_map(|c| c["option"].as_str()).collect(); + assert_eq!(changed, ["copiesPerSheet", "gutter"]); + let v = s.execute("print.preview", &json!({"preset": "Cards"})).unwrap(); + assert_eq!(v["sheets"][0]["copies"].as_array().map(Vec::len), Some(9)); + // The option survives export and import. + let text = s.execute("print.presets.export", &json!({"names": ["Cards"]})).unwrap()["data"].as_str().unwrap().to_string(); + let mut t = Session::new(); + t.execute("print.presets.import", &json!({"data": text})).unwrap(); + assert_eq!((t.prefs.print_presets[0].settings.copies_per_sheet, t.prefs.print_presets[0].settings.gutter), (true, 18.0)); + // A presets file written without the option imports with it off and no gutter. + let old = r#"{"format": "vcprintpresets", "presets": [{"name": "Old", "settings": {"copies": 2}}]}"#; + t.execute("print.presets.import", &json!({"data": old})).unwrap(); + let q = &t.prefs.print_presets[1].settings; + assert_eq!((q.copies, q.copies_per_sheet, q.gutter), (2, false, 0.0)); +} + #[test] fn bad_settings_are_bad_params() { let (mut s, _) = session(); @@ -154,6 +216,7 @@ fn bad_settings_are_bad_params() { json!({"settings": {"media": "quarto"}}), json!({"settings": {"artboards": "range", "range": "2"}}), json!({"settings": {"scaling": "tileFull", "overlap": 500}}), + json!({"settings": {"gutter": 800}}), ] { for c in ["file.print", "print.preview"] { assert!(matches!(s.execute(c, &p), Err(EngineError::BadParams { .. })), "{c} {p}"); diff --git a/crates/eps/src/print.rs b/crates/eps/src/print.rs index f9df227dc..99dffd822 100644 --- a/crates/eps/src/print.rs +++ b/crates/eps/src/print.rs @@ -4,7 +4,7 @@ use std::fmt::Write; -use kurbo::Shape; +use kurbo::{Shape, Vec2}; use vectorcraft_doc::{Document, Node}; use vectorcraft_geom::{Affine, Rect}; @@ -24,6 +24,9 @@ pub struct PrintPage<'a> { pub window: Option, /// Document space → drawing space. pub place: Affine, + /// Copies on the sheet: each one's move in drawing space from where [`Self::place`] puts the + /// art. The art and the marks are drawn once per copy. Empty: one copy, not moved. + pub copies: &'a [Vec2], /// The art drawn, in document space: what lies outside it is left out. pub area: Rect, /// Printer's marks, in drawing space. @@ -59,7 +62,10 @@ fn sheet(p: &PrintPage, job: &PrintJob) -> Result<(String, Vec), String> { if !(w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0 && [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite())) { return Err(format!("page size {w} × {h}")); } - if !finite(p.view) || !finite(p.place) { + // `moves` holds each copy's move in drawing space; without copies, it holds one zero move. + let moves = if p.copies.is_empty() { &[Vec2::ZERO][..] } else { p.copies }; + let place_at = |m: Vec2| if m == Vec2::ZERO { p.place } else { Affine::translate(m) * p.place }; + if !finite(p.view) || !moves.iter().all(|m| finite(place_at(*m))) { return Err("a page's placement is not finite".into()); } let opts = |region: Rect| EpsOptions { level: job.level, region, ..EpsOptions::default() }; @@ -69,6 +75,9 @@ fn sheet(p: &PrintPage, job: &PrintJob) -> Result<(String, Vec), String> { let paper = Rect::new(0.0, 0.0, w, h); let shown = if p.view.determinant().abs() > 1e-12 { p.view.inverse().transform_rect_bbox(paper) } else { paper }; let shown = p.window.map_or(shown, |t| t.intersect(shown)); + // The marks body is drawn for every copy, so it is generated for the union of the regions + // the copies show. + let shown = moves.iter().map(|m| shown - *m).reduce(|a, b| a.union(b)).unwrap_or(shown); let marks_opts = opts(shown); let marks = p.marks.map(|m| Scene::new(p.doc, &marks_opts).run_node(m)); let mut s = String::with_capacity(art.body.len() + art.setup.len() + 1024); @@ -99,19 +108,33 @@ fn sheet(p: &PrintPage, job: &PrintJob) -> Result<(String, Vec), String> { ps::push_path(&mut s, &t.to_path(0.1)); s.push_str("W\n"); } - s.push_str("q\n"); - let _ = writeln!(s, "{} cm", ps::matrix(p.place)); - ps::push_path(&mut s, &r.to_path(0.1)); - s.push_str("W\n"); - s.push_str(&art.setup); - s.push_str(&art.body); - s.push_str("Q\n"); + for (i, m) in moves.iter().enumerate() { + s.push_str("q\n"); + let _ = writeln!(s, "{} cm", ps::matrix(place_at(*m))); + ps::push_path(&mut s, &r.to_path(0.1)); + s.push_str("W\n"); + // The setup defines the spot color spaces once; each copy draws the same body. + if i == 0 { + s.push_str(&art.setup); + } + s.push_str(&art.body); + s.push_str("Q\n"); + } let mut found = vec![art]; - if let Some(m) = marks { + if let Some(marks) = marks { // The marks' spot colour spaces are defined after the art is drawn: names may be reused. - s.push_str(&m.setup); - s.push_str(&m.body); - found.push(m); + s.push_str(&marks.setup); + for m in moves { + let moved = *m != Vec2::ZERO; + if moved { + let _ = writeln!(s, "q\n{} cm", ps::matrix(Affine::translate(*m))); + } + s.push_str(&marks.body); + if moved { + s.push_str("Q\n"); + } + } + found.push(marks); } s.push_str("Q\nend\nshowpage\n%%PageTrailer\n"); Ok((s, found)) diff --git a/crates/eps/src/print/tests.rs b/crates/eps/src/print/tests.rs index 171bb7700..29e7ca053 100644 --- a/crates/eps/src/print/tests.rs +++ b/crates/eps/src/print/tests.rs @@ -25,6 +25,7 @@ fn page<'a>(d: &'a Document, marks: Option<&'a Node>) -> PrintPage<'a> { view: Affine::IDENTITY, window: None, place: Affine::translate((36.0, 36.0)), + copies: &[], area: d.artboards[0].rect, marks, negative: false, @@ -67,6 +68,32 @@ fn pages_follow_dsc_and_repeat_for_copies() { assert!(back.warnings.iter().any(|w| w.contains("first of the file's 4 pages")), "{:?}", back.warnings); } +#[test] +fn copies_on_a_sheet_draw_the_art_and_marks_once_each() { + let d = doc(); + let mark = + Node::path(NodeId(9999), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::registration(), 0.25)); + let moves = [kurbo::Vec2::ZERO, kurbo::Vec2::new(218.0, 0.0), kurbo::Vec2::new(0.0, 118.0)]; + let sheet = PrintPage { copies: &moves, ..page(&d, Some(&mark)) }; + let ps = text(&print(&[sheet], &[0], &PrintJob::default()).unwrap()); + // The art is placed three times, the second 218 pt right and the third 118 pt down, and its + // spot color space and the marks' are defined once. + assert_eq!(ps.matches("[1 0 0 1 36 36] cm\n").count(), 1, "{ps}"); + assert!(ps.contains("[1 0 0 1 254 36] cm\n") && ps.contains("[1 0 0 1 36 154] cm\n"), "{ps}"); + assert_eq!(ps.matches("/All /DeviceCMYK").count(), 1, "{ps}"); + assert!(ps.contains("q\n[1 0 0 1 218 0] cm\n") && ps.contains("q\n[1 0 0 1 0 118] cm\n"), "{ps}"); + // A mark off the page around the first copy still prints around a later copy that has it on the page. + let look = Appearance::basic(Paint::None, Paint::registration(), 0.25); + let off = Node::path(NodeId(9998), shapes::rectangle(Rect::new(-100.0, -100.0, -90.0, -90.0)), look); + let later = [kurbo::Vec2::ZERO, kurbo::Vec2::new(200.0, 200.0)]; + let ps = text(&print(&[PrintPage { copies: &later, ..page(&d, Some(&off)) }], &[0], &PrintJob::default()).unwrap()); + assert_eq!(ps.matches("/All /DeviceCMYK").count(), 1, "{ps}"); + // One copy unmoved is the page without copies. + let one = [kurbo::Vec2::ZERO]; + let single = text(&print(&[PrintPage { copies: &one, ..page(&d, Some(&mark)) }], &[0], &PrintJob::default()).unwrap()); + assert_eq!(single, text(&print(&[page(&d, Some(&mark))], &[0], &PrintJob::default()).unwrap())); +} + #[test] fn separations_screens_flatness_negatives_and_tiles() { let d = doc(); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 8728b29b2..47184d520 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -118,6 +118,9 @@ pub(crate) struct Exporter<'a> { /// Objects the symbol instances written held, together /// ([`vectorcraft_doc::MAX_SYMBOL_NODES`]). symbol_nodes: u64, + /// The most [`Self::symbol_nodes`] may reach: [`vectorcraft_doc::MAX_SYMBOL_NODES`], or less + /// while one of the copies on a sheet is drawn ([`Writer::page`]). + pub(crate) symbol_limit: u64, } #[cfg(test)] diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index c64d4a6f5..d131ece8f 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -114,6 +114,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha view, window: None, place: Affine::IDENTITY, + copies: &[], area: r, // Art reaches as far as the bleed: the marks lie outside it. clip: m != r, diff --git a/crates/pdf/src/export/exporter.rs b/crates/pdf/src/export/exporter.rs index ed5f984c0..eb312021e 100644 --- a/crates/pdf/src/export/exporter.rs +++ b/crates/pdf/src/export/exporter.rs @@ -47,6 +47,7 @@ impl<'a> Exporter<'a> { in_layer: 0, paper: Rect::ZERO, symbol_nodes: 0, + symbol_limit: vectorcraft_doc::MAX_SYMBOL_NODES, overprint: false, overprinted: false, invisible: false, diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index e8057236c..dbf4b0d87 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -272,20 +272,23 @@ impl Exporter<'_> { let count = art.count() as u64; let map = doc.symbol_slice_map(symbol, *xf); let most = map.map_or(count, |m| m.most_placed(art, doc)); - let placed = vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || { - let art = match map { - Some(m) => m.place(art, doc), - None => { - let mut a = art.clone(); - a.transform(*xf, false); - a - } - }; - // As `most_placed`, placed art counts its opacity-mask art. - let count = if map.is_some() { art.count_with_masks() } else { art.count() } as u64; - (art, count) + let within = self.symbol_nodes.saturating_add(most) <= self.symbol_limit; + let placed = within.then(|| { + vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || { + let art = match map { + Some(m) => m.place(art, doc), + None => { + let mut a = art.clone(); + a.transform(*xf, false); + a + } + }; + // As `most_placed`, placed art counts its opacity-mask art. + let count = if map.is_some() { art.count_with_masks() } else { art.count() } as u64; + (art, count) + }) }); - if let Some(art) = placed { + if let Some(art) = placed.flatten() { self.node(s, &art, page, true); } } diff --git a/crates/pdf/src/export/writer.rs b/crates/pdf/src/export/writer.rs index 7f9b939f9..d7e652f2e 100644 --- a/crates/pdf/src/export/writer.rs +++ b/crates/pdf/src/export/writer.rs @@ -2,7 +2,7 @@ use super::entry::RgbInCmyk; use super::exporter::{constant_mask, cover, to_path, writable, xf}; use super::{ Affine, Arc, Archival, ColorOut, Compatibility, Document, Exporter, HashSet, Image, Metadata, Node, PageSettings, PdfError, PdfVersion, Rect, - Shape, Size, Standard, find, krilla, rfind, write, + Shape, Size, Standard, Vec2, find, krilla, rfind, write, }; /// A PDF being written, shared by export and print: the file's configuration and metadata, then @@ -43,6 +43,9 @@ pub(crate) struct Sheet<'a> { pub window: Option, /// Document space → drawing space. pub place: Affine, + /// Copies on the page: each one's move in drawing space from where [`Self::place`] puts the + /// art. The art and the marks are drawn once per copy. Empty: one copy, not moved. + pub copies: &'a [Vec2], /// The art drawn, in document space (the bleed box): what lies outside it is left out. pub area: Rect, /// Clip the art to [`Self::area`]. @@ -127,22 +130,17 @@ impl Writer { if sheet.negative { cover(&mut s, paper, 255, 1.0); } + // `moves` holds each copy's move in drawing space; without copies, it holds one zero move. + let moves = if sheet.copies.is_empty() { &[Vec2::ZERO][..] } else { sheet.copies }; + let place_at = |m: Vec2| if m == Vec2::ZERO { sheet.place } else { Affine::translate(m) * sheet.place }; // A page whose art or marks would be placed beyond [`MAX_COORD`] is left blank. - let fits = writable(&s, sheet.view, Rect::ZERO) && writable(&s, sheet.view * sheet.place, Rect::ZERO); + let fits = writable(&s, sheet.view, Rect::ZERO) && moves.iter().all(|m| writable(&s, sheet.view * place_at(*m), Rect::ZERO)); s.push_transform(&xf(sheet.view)); let window = sheet.window.and_then(|r| to_path(&s, &r.to_path(0.1))); if let Some(clip) = &window { s.push_clip_path(clip, &krilla::paint::FillRule::NonZero); } - let placed = sheet.place != Affine::IDENTITY; - if placed { - s.push_transform(&xf(sheet.place)); - } let r = sheet.area; - let clip = sheet.clip.then(|| to_path(&s, &r.to_path(0.1))).flatten(); - if let Some(clip) = &clip { - s.push_clip_path(clip, &krilla::paint::FillRule::NonZero); - } // Page Isolated Blending / Page Knockout Group: the page content is one group (the PDF // writer has no page group attributes; a flat PDF 1.3 file has none). In CMYK, transparency // at the top of a page is put in a group of its own, whose blending space `cmyk_blending` @@ -151,35 +149,62 @@ impl Writer { let doc = ex.doc; let page_group = fits && !self.pdf13 && (doc.page_isolate || doc.page_knockout); let cmyk_page_group = fits && self.cmyk && !page_group && ex.shows_transparency(); - if page_group || (cmyk_page_group && !Node::children_blend(&doc.layers, &mut |c| c.blends_through())) { - s.push_isolated(); - } else if cmyk_page_group { - let all = constant_mask(&mut s, r, 1.0); - s.push_mask(all); - } - if fits { - let shown = (sheet.view * sheet.place).inverse().transform_rect_bbox(paper); - ex.paper = if [shown.x0, shown.y0, shown.x1, shown.y1].iter().all(|v| v.is_finite()) { shown.union(r) } else { r }; - ex.children(&mut s, &doc.layers, r); - } - if page_group || cmyk_page_group { - s.pop(); - } - if clip.is_some() { - s.pop(); - } - if placed { - s.pop(); + // Each copy draws within an equal share of the symbol objects the export has left + // ([`vectorcraft_doc::MAX_SYMBOL_NODES`]), so every copy leaves out the same instances. + let limit = ex.symbol_limit; + let share = limit.saturating_sub(ex.symbol_nodes) / moves.len() as u64; + for m in moves { + ex.symbol_limit = ex.symbol_nodes.saturating_add(share); + let place = place_at(*m); + let placed = place != Affine::IDENTITY; + if placed { + s.push_transform(&xf(place)); + } + let clip = sheet.clip.then(|| to_path(&s, &r.to_path(0.1))).flatten(); + if let Some(clip) = &clip { + s.push_clip_path(clip, &krilla::paint::FillRule::NonZero); + } + if page_group || (cmyk_page_group && !Node::children_blend(&doc.layers, &mut |c| c.blends_through())) { + s.push_isolated(); + } else if cmyk_page_group { + let all = constant_mask(&mut s, r, 1.0); + s.push_mask(all); + } + if fits { + let shown = (sheet.view * place).inverse().transform_rect_bbox(paper); + ex.paper = if [shown.x0, shown.y0, shown.x1, shown.y1].iter().all(|v| v.is_finite()) { shown.union(r) } else { r }; + ex.children(&mut s, &doc.layers, r); + } + if page_group || cmyk_page_group { + s.pop(); + } + if clip.is_some() { + s.pop(); + } + if placed { + s.pop(); + } } + ex.symbol_limit = limit; if fits && let Some(art) = sheet.marks { // The drawing space the page shows (the marks' area). let shown = sheet.view.inverse().transform_rect_bbox(paper); + let shown = sheet.window.map_or(shown, |r| r.intersect(shown)); let knockout = std::mem::replace(&mut ex.knockout, false); // Marks keep their inks: they aren't converted to the destination. The page // information is drawn as glyph shapes, like the other marks, not as text. let convert = std::mem::replace(&mut ex.convert, false); let outline = std::mem::replace(&mut ex.outline_text, true); - ex.node(&mut s, art, sheet.window.map_or(shown, |r| r.intersect(shown)), true); + for m in moves { + let moved = *m != Vec2::ZERO; + if moved { + s.push_transform(&xf(Affine::translate(*m))); + } + ex.node(&mut s, art, shown - *m, true); + if moved { + s.pop(); + } + } (ex.knockout, ex.convert, ex.outline_text) = (knockout, convert, outline); } if window.is_some() { diff --git a/crates/pdf/src/print.rs b/crates/pdf/src/print.rs index 559fc6147..b11a764be 100644 --- a/crates/pdf/src/print.rs +++ b/crates/pdf/src/print.rs @@ -6,7 +6,9 @@ //! to each artboard's), transverse, which layers print (template layers never do), the //! placement on the imageable area (or where the Print Tiling tool put the pages, //! [`TileOrigin`]), and the scale: none, fit, custom, or tiles of the paper or of its imageable -//! area, overlapping, a range of them. +//! area, overlapping, a range of them. Without tiles, copies per sheet repeats the artboard +//! (with its bleed and marks) across then down the imageable area, as many times as fit, +//! `gutter` apart, and places the grid as it places one copy. //! - Marks and bleed as PDF export draws them ([`crate::MarkSettings`], [`crate::BleedSettings`]), //! around the artboard on the paper, at the paper's scale. //! - Output: composite, or separations, one page per ink that prints ([`print_inks`]) in the @@ -33,14 +35,14 @@ use std::borrow::Cow; use std::collections::HashSet; use std::sync::Arc; -use kurbo::{Affine, Rect}; +use kurbo::{Affine, Rect, Vec2}; use serde::Serialize; use vectorcraft_color::cms::{self, ProfileKind}; use vectorcraft_doc::marks::PrinterMarks; use vectorcraft_doc::overprint::{clear_overprints, darken_overprints}; use vectorcraft_doc::{ColorMode, Document, Node, NodeKind}; -pub use layout::{MAX_TILES, TileGrid}; +pub use layout::{MAX_COPIES_PER_SHEET, MAX_TILES, TileGrid}; pub use plates::print_inks; pub use settings::*; pub use tiling::{TilingPage, tiling}; @@ -106,8 +108,13 @@ pub struct PageSummary { /// The part of the document this page prints (`[x0, y0, x1, y1]`, document space): the /// artboard with its bleed, or what of it a tile shows. pub area: [f64; 4], - /// The artboard (trim box) on the page, `[x0, y0, x1, y1]`. + /// The artboard (trim box) on the page, `[x0, y0, x1, y1]`; with copies per sheet, the box + /// around every copy's. pub trim: [f64; 4], + /// With copies per sheet, each copy's artboard on the page, `[x0, y0, x1, y1]`, across then + /// down (the first is where `transform` puts it). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub copies: Vec<[f64; 4]>, } /// What a job prints, without printing it. @@ -292,6 +299,7 @@ pub fn preview(doc: &Document, set: &PrintSettings) -> Result, /// Document space → drawing space. pub place: Affine, + /// With copies per sheet, each copy's move in drawing space from where [`Self::place`] puts + /// the art, across then down (the first is zero): the art and the marks are drawn once per + /// copy. Empty: one copy. + pub copies: Vec, /// The art drawn, in document space (the bleed box): what lies outside it is left out. pub area: Rect, /// Printer's marks, in drawing space (separated for the sheet's ink). @@ -334,6 +346,7 @@ impl PlannedSheet { view: self.view, window: self.window, place: self.place, + copies: &self.copies, area: self.area, clip: true, marks: self.marks.as_ref(), @@ -389,6 +402,7 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result, view: l.view, window: l.window, place: l.place, + copies: l.copies.clone(), area: l.area, marks: art, negative: set.output.image == PrintImage::Negative, diff --git a/crates/pdf/src/print/layout.rs b/crates/pdf/src/print/layout.rs index 21462e6b9..3d404abab 100644 --- a/crates/pdf/src/print/layout.rs +++ b/crates/pdf/src/print/layout.rs @@ -1,12 +1,13 @@ //! Where each printed page's art goes: the artboards (or all the art) that print, the paper's -//! orientation, the scale, the placement on the imageable area or the tiles, and the turns and -//! mirroring of the page (flipped orientation, emulsion down, transverse). +//! orientation, the scale, the placement on the imageable area (of one copy, or of a grid of +//! copies per sheet) or the tiles, and the turns and mirroring of the page (flipped orientation, +//! emulsion down, transverse). //! //! The art is placed in a drawing space: the paper (y down from its top-left corner), or, when //! tiling, the whole printed area (the artboard grown by its bleed and marks) from its top-left //! corner, of which each page shows one tile. -use kurbo::{Affine, Rect}; +use kurbo::{Affine, Rect, Vec2}; use serde::Serialize; use vectorcraft_doc::marks::outset; use vectorcraft_doc::range::parse_range; @@ -18,6 +19,9 @@ use crate::PdfError; /// Most tiles of one artboard. pub const MAX_TILES: usize = 1000; +/// Most copies of one artboard on a sheet ([`PrintSettings::copies_per_sheet`]). +pub const MAX_COPIES_PER_SHEET: usize = 1000; + /// One page of the job, before copies and inks. #[derive(Clone, Debug)] pub(crate) struct Layout { @@ -43,9 +47,12 @@ pub(crate) struct Layout { /// the marks go. pub trim: Rect, pub bleed: [f64; 4], - /// The TrimBox and BleedBox in page space. + /// The TrimBox and BleedBox in page space (around every copy with copies per sheet). pub page_trim: Rect, pub page_bleed: Rect, + /// With copies per sheet, where each copy goes: its move in drawing space from where + /// [`Self::place`] puts the art, across then down (the first is zero). Empty: one copy. + pub copies: Vec, } /// The tiles of one artboard (`print.preview`). @@ -148,6 +155,13 @@ fn span(start: Option, extent: f64, tile: f64, step: f64, overlap: f64) -> } } +/// How many printed areas `extent` long fit in `room`, `gutter` apart (whole copies only). +fn fitting(room: f64, extent: f64, gutter: f64) -> f64 { + // Copies that fill the room exactly fit. + const EPS: f64 = 1e-6; + ((room + gutter) / (extent + gutter) + EPS).floor().max(0.0) +} + /// The page turns of a `w` × `h` paper (flipped, emulsion down, transverse) as one transform from /// the paper onto the page, and the page's size. fn turns(set: &PrintSettings, (w, h): (f64, f64), flipped: bool) -> (Affine, (f64, f64)) { @@ -232,13 +246,15 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec 0.0 { (t, b) } else { (page, page) } + (t.area() > 0.0).then_some((t, b)) }; - let base = |view, window, place, area, trim, (page_trim, page_bleed), tile| Layout { + let boxes = |view: Affine, trim: Rect| visible(view, trim).unwrap_or((page, page)); + let base = |view, window, place, area, trim, (page_trim, page_bleed), tile, copies| Layout { artboard, tile, size: page_size, @@ -252,24 +268,52 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec { + warnings.push(format!("{what} with its bleed and marks doesn't fit the imageable area: it prints one copy per sheet")); + None + } + Some((cols, rows)) if cols * rows > MAX_COPIES_PER_SHEET as f64 => { + return Err(PdfError::BadSetting(format!("{what} would print more than {MAX_COPIES_PER_SHEET} copies on a sheet: scale it up"))); + } + fit => fit.map(|(cols, rows)| (cols as usize, rows as usize)), + }; + let step = (extent.0 + gutter, extent.1 + gutter); + let all = grid.map_or(extent, |(cols, rows)| (cols as f64 * step.0 - gutter, rows as f64 * step.1 - gutter)); + // The printed area's (or the grid's) top-left corner: where the tile origin puts it, + // else the placement. let (x, y) = match origin { Some((ox, oy)) => (imageable.x0 - ox * scale.0 - most[2], imageable.y0 - oy * scale.1 - most[0]), None => { let (fx, fy) = set.placement.origin.factors(); ( - imageable.x0 + (imageable.width() - extent.0) * fx + set.placement.x, - imageable.y0 + (imageable.height() - extent.1) * fy + set.placement.y, + imageable.x0 + (imageable.width() - all.0) * fx + set.placement.x, + imageable.y0 + (imageable.height() - all.1) * fy + set.placement.y, ) } }; let trim = Rect::new(x + most[2], y + most[0], x + most[2] + size.0, y + most[0] + size.1); - if !imageable.inflate(0.01, 0.01).contains_rect(Rect::new(x, y, x + extent.0, y + extent.1)) { + if !imageable.inflate(0.01, 0.01).contains_rect(Rect::new(x, y, x + all.0, y + all.1)) { warnings.push(format!("{what} with its bleed and marks is larger than the imageable area: part of it doesn't print")); } - pages.push(base(turn, None, place_at(trim), bleed_box, trim, boxes(turn, trim), None)); + let copies: Vec = grid.map_or_else(Vec::new, |(cols, rows)| { + (0..rows).flat_map(|j| (0..cols).map(move |i| Vec2::new(i as f64 * step.0, j as f64 * step.1))).collect() + }); + // The boxes hold the copies on the page; copies off it are left out. + let page_boxes = copies + .iter() + .filter_map(|m| visible(turn, trim + *m)) + .reduce(|a, b| (a.0.union(b.0), a.1.union(b.1))) + .unwrap_or_else(|| boxes(turn, trim)); + pages.push(base(turn, None, place_at(trim), bleed_box, trim, page_boxes, None, copies)); continue; } // Tiles across then down over the printed area, overlapping by `overlap`. @@ -312,7 +356,7 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec Vec<(f64, f64)> { + pv.sheets[0].copies.iter().map(|c| (c[0], c[1])).collect() +} + +/// Every `(x, y)` of `xs` and `ys`, across then down. +fn grid(xs: &[f64], ys: &[f64]) -> Vec<(f64, f64)> { + ys.iter().flat_map(|y| xs.iter().map(move |x| (*x, *y))).collect() +} + +#[test] +fn copies_per_sheet_fill_the_page_in_a_grid() { + // A 2 × 3.5 in card (144 × 252 pt) on Letter (612 × 792 pt): 4 across and 3 down, the + // 576 × 756 pt grid centered. + let d = doc(144.0, 252.0, &[(Rect::new(0.0, 0.0, 144.0, 252.0), rgb(1.0, 0.0, 0.0))]); + let pv = preview(&d, &settings(json!({"copiesPerSheet": true}))).unwrap(); + assert_eq!((pv.pages, pv.sheets.len(), pv.sheets[0].orientation), (1, 1, Orientation::Portrait)); + assert_eq!(corners(&pv), grid(&[18.0, 162.0, 306.0, 450.0], &[18.0, 270.0, 522.0])); + assert!(pv.sheets[0].copies.iter().all(|c| (c[2] - c[0], c[3] - c[1]) == (144.0, 252.0)), "{:?}", pv.sheets[0].copies); + assert_eq!(pv.sheets[0].trim, [18.0, 18.0, 594.0, 774.0], "the TrimBox holds every copy"); + assert!(pv.warnings.is_empty(), "{:?}", pv.warnings); + // The printed page holds the twelve cards there. + let p = pages(&job(&d, json!({"copiesPerSheet": true})).bytes); + assert_eq!(p.len(), 1); + let mut printed: Vec<(f64, f64)> = p[0].1.iter().map(|(r, _)| (r.x0.round(), r.y0.round())).collect(); + printed.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.total_cmp(&b.0))); + assert_eq!(printed, grid(&[18.0, 162.0, 306.0, 450.0], &[18.0, 270.0, 522.0]), "{:?}", p[0].1); + assert!(p[0].1.iter().all(|(r, paint)| rgb8(paint) == [255, 0, 0] && near(*r, Rect::new(r.x0, r.y0, r.x0 + 144.0, r.y0 + 252.0)))); + // An 18 pt gutter: 3 across and 3 down, the 468 × 792 pt grid centered. + let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "gutter": 18}))).unwrap(); + assert_eq!(corners(&pv), grid(&[72.0, 234.0, 396.0], &[0.0, 270.0, 540.0])); + // The placement places the grid, inside the margin. + let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "margin": 9, "placement": {"origin": "topLeft"}}))).unwrap(); + assert_eq!(corners(&pv), grid(&[9.0, 153.0, 297.0, 441.0], &[9.0, 261.0, 513.0])); + // Moved 400 pt right, two columns leave the paper: the TrimBox holds the copies still on it. + let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "placement": {"origin": "topLeft", "x": 400}}))).unwrap(); + assert_eq!(pv.sheets[0].trim, [400.0, 0.0, 612.0, 756.0]); +} + +#[test] +fn copies_per_sheet_is_off_by_default_and_off_prints_as_before() { + let s = PrintSettings::default(); + assert_eq!((s.copies_per_sheet, s.gutter), (false, 0.0)); + let v = serde_json::to_value(&s).unwrap(); + assert_eq!((&v["copiesPerSheet"], &v["gutter"]), (&json!(false), &json!(0.0))); + assert!(settings(json!({"gutter": -1})).check().is_err() && settings(json!({"gutter": 721})).check().is_err()); + // Off, the job is the same file whatever the gutter, and its pages list no copies. + let d = doc(144.0, 252.0, &[(Rect::new(0.0, 0.0, 144.0, 252.0), rgb(1.0, 0.0, 0.0))]); + assert_eq!(job(&d, json!({})).bytes, job(&d, json!({"copiesPerSheet": false, "gutter": 36})).bytes); + let pv = serde_json::to_value(preview(&d, &settings(json!({}))).unwrap()).unwrap(); + assert!(pv["sheets"][0].get("copies").is_none(), "{pv}"); + // Tiles print one artboard across pages: copies per sheet is left out. + let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "scaling": "tileFull"}))).unwrap(); + assert!(pv.sheets[0].copies.is_empty() && pv.sheets[0].tile.is_some()); +} + +#[test] +fn an_artboard_that_does_not_fit_prints_one_copy_per_sheet() { + // 700 × 900 pt is larger than Letter either way round. + let d = doc(700.0, 900.0, &[(Rect::new(0.0, 0.0, 700.0, 900.0), rgb(0.0, 0.0, 1.0))]); + let r = job(&d, json!({"copiesPerSheet": true})); + assert_eq!(r.pages, 1); + assert!( + r.warnings.iter().any(|w| w == "Artboard 1 with its bleed and marks doesn't fit the imageable area: it prints one copy per sheet"), + "{:?}", + r.warnings + ); + assert!(r.warnings.iter().any(|w| w.contains("larger than the imageable area")), "{:?}", r.warnings); + assert_eq!(r.bytes, job(&d, json!({})).bytes, "printed as without copies per sheet"); + assert!(preview(&d, &settings(json!({"copiesPerSheet": true}))).unwrap().sheets[0].copies.is_empty()); + // When one copy fits, it prints alone, centered, whatever the gutter. + let d = doc(400.0, 500.0, &[]); + let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "gutter": 300}))).unwrap(); + assert_eq!(corners(&pv), [(106.0, 146.0)]); + // More copies than a sheet takes are refused. + let tiny = doc(1.0, 1.0, &[]); + assert!(preview(&tiny, &settings(json!({"copiesPerSheet": true}))).is_err_and(|e| e.to_string().contains("more than 1000 copies"))); +} + +#[test] +fn each_copy_prints_with_its_marks() { + let d = doc(100.0, 100.0, &[(Rect::new(0.0, 0.0, 100.0, 100.0), rgb(1.0, 0.0, 0.0))]); + let set = json!({"copiesPerSheet": true, "marks": {"trim": true}}); + let pv = preview(&d, &settings(set.clone())).unwrap(); + let copies = pv.sheets[0].copies.len(); + assert!(copies > 1, "{pv:?}"); + let back = import(&job(&d, set).bytes).unwrap(); + let mut marks = 0; + back.walk(|n| marks += usize::from(n.appearance.stroke().is_some_and(|s| s.paint.is_registration()))); + assert_eq!(marks, 8 * copies, "8 trim marks around each copy"); +} + +#[test] +fn every_copy_on_a_sheet_draws_the_same_symbol_instances() { + // A symbol of three squares (4 objects with its group), placed twice. + let mut d = doc(100.0, 100.0, &[]); + let squares = (0..3) + .map(|i| { + let r = Rect::new(f64::from(i) * 10.0, 0.0, f64::from(i) * 10.0 + 5.0, 5.0); + Arc::new(Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(rgb(1.0, 0.0, 0.0), Paint::None, 0.0))) + }) + .collect(); + let art = Node::group(d.alloc_id(), squares); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Squares".into(), art: Arc::new(art) }); + let layer = d.default_layer().unwrap(); + for y in [10.0, 50.0] { + let instance = NodeKind::SymbolInstance { symbol: "Squares".into(), xf: vectorcraft_geom::Affine::translate((10.0, y)) }; + let id = d.alloc_id(); + d.insert(Some(layer), 0, Node::new(id, instance)).unwrap(); + } + let set = PdfSettings { compression: CompressionSettings { compress_text: false, ..Default::default() }, ..Default::default() }; + let mut w = crate::export::Writer::new(&d, &set, "Copies", None, false).unwrap(); + let mut ex = crate::export::Exporter::new(&d, &set); + // Room for 10 symbol objects: the first copy could write both instances (8) and leave the + // second copy none. + ex.symbol_limit = 10; + let copies = [vectorcraft_geom::Vec2::ZERO, vectorcraft_geom::Vec2::new(200.0, 0.0)]; + let page = Rect::new(0.0, 0.0, 300.0, 100.0); + let sheet = crate::export::Sheet { + size: (300.0, 100.0), + unit: 1.0, + trim: page, + bleed: page, + view: vectorcraft_geom::Affine::IDENTITY, + window: None, + place: vectorcraft_geom::Affine::IDENTITY, + copies: &copies, + area: Rect::new(0.0, 0.0, 100.0, 100.0), + clip: false, + marks: None, + negative: false, + links: false, + }; + w.page(&mut ex, &sheet).unwrap(); + assert_eq!(ex.symbol_limit, 10, "the limit is the export's again after the sheet"); + w.absorb(ex); + let (bytes, _) = w.finish(&mut std::collections::HashSet::new(), false).unwrap(); + let p = pages(&bytes); + let left = p[0].1.iter().filter(|(r, _)| r.x0 < 200.0).count(); + assert_eq!((left, p[0].1.len() - left), (3, 3), "one instance in each copy: {:?}", p[0].1); +} + #[test] fn tiles_cover_the_artboard_with_overlap() { // 1000 × 700 on Letter tiles (612 × 792), 36 pt overlap: 2 × 1. diff --git a/crates/plugins/src/host.rs b/crates/plugins/src/host.rs index 017df5a80..f8235a4fb 100644 --- a/crates/plugins/src/host.rs +++ b/crates/plugins/src/host.rs @@ -91,7 +91,7 @@ pub const STORAGE_BUDGET: i32 = -7; pub const MAX_STORAGE_CALLS: usize = 10_000; /// Most bytes one run's storage calls read and write, all together, counting the whole settings -/// file each settings call reads and writes and each cache entry read or written; a call that would +/// file each time it is read or written and each cache entry read or written; a call that would /// pass it returns [`STORAGE_BUDGET`]. pub const MAX_STORAGE_BYTES: usize = 1 << 30; diff --git a/crates/plugins/src/storage.rs b/crates/plugins/src/storage.rs index f3ef2e899..b7de23c45 100644 --- a/crates/plugins/src/storage.rs +++ b/crates/plugins/src/storage.rs @@ -93,8 +93,9 @@ impl std::fmt::Display for StorageError { /// Where one plug-in's settings and cache are kept. Every key and name the host passes is a /// [`valid_name`], and every value is within its cap ([`MAX_SETTING_BYTES`], [`MAX_CACHE_ENTRY`]); /// the storage enforces the totals. The calls that read or write a file charge `meter` the bytes -/// they read and write ([`Meter::take`]) before they do: a settings call the whole settings file it -/// reads and the whole file it writes, a cache call the entry it reads or writes. +/// they read and write ([`Meter::take`]) before they do: a settings call the whole settings file +/// when it reads the file and the whole file when it writes it, a cache call the entry it reads or +/// writes. A settings call that the storage answers from memory charges nothing. pub trait Storage: Send + Sync { /// The value of setting `key`, if it has one. fn setting(&self, key: &str, meter: &mut Meter) -> Result>, StorageError>; diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 83aacda3c..652213940 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -607,6 +607,17 @@ fn is_cjk_gap(glyph: usize, held: &[usize], after: &[Option], index: usiz }) } +// May a pushed-in line close a CJK gap after `g`? Not after a list marker or an inline graphic, +// which the push-in's room does not count either. +fn may_squeeze(g: &SGlyph) -> bool { + g.len > 0 && g.inline.is_none() +} + +// How much a pushed-in line's `squeeze` adds to the CJK gap after `g`. +fn squeeze_after(g: &SGlyph, squeeze: f64, vertical: bool) -> f64 { + if may_squeeze(g) { squeeze * squeeze_em(g, vertical) } else { 0.0 } +} + // What justification may change of glyph `j`: nothing of a `held` glyph, and no glyph // scaling of a `hung` one. fn flow_line_flex(glyph: usize, flex: &[Flex], held: &[usize], hung: &[usize]) -> Flex { @@ -672,8 +683,10 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin // the same share of the em of the glyph before it, at most [`PUSH_IN_SQUEEZE`]. let pushed_in = line.position.pushed_in && paragraph.regions.is_some() && cjk_gaps > 0 && width > room + EPS; if pushed_in { - let ems: f64 = - (line.index..line.body).filter(|&j| is_cjk_gap(j, held, &after, line.index, sg)).map(|j| squeeze_em(&sg[j], cx.vertical)).sum(); + let ems: f64 = (line.index..line.body) + .filter(|&j| is_cjk_gap(j, held, &after, line.index, sg) && may_squeeze(&sg[j])) + .map(|j| squeeze_em(&sg[j], cx.vertical)) + .sum(); if ems > EPS { squeeze = ((room - width) / ems).max(-PUSH_IN_SQUEEZE); } @@ -778,7 +791,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData advance += justification.per_space; } else if justification.per_cjk != 0.0 || justification.squeeze != 0.0 { if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) { - advance += justification.per_cjk + justification.squeeze * squeeze_em(glyph, cx.vertical); + advance += justification.per_cjk + squeeze_after(glyph, justification.squeeze, cx.vertical); } } else if !line.held.contains(&glyph_index) && drawn_after(index, glyph_index, &justification.after).and_then(|next| sg.get(next)).is_some_and(|next| !next.continues_tcy()) diff --git a/crates/text/src/tests/inline.rs b/crates/text/src/tests/inline.rs index d492608cc..76c90e65b 100644 --- a/crates/text/src/tests/inline.rs +++ b/crates/text/src/tests/inline.rs @@ -347,6 +347,25 @@ fn top_to_top_leading_spaces_inline_art_by_its_run() { assert!((small0 - tall0).abs() < 1e-6 && (small1 - tall1).abs() < 1e-6, "{small0} {small1} / {tall0} {tall1}"); } +/// A pushed-in line closes the gaps next to its CJK characters and leaves the gap after inline art +/// open. Needs a font with full-width Japanese (skipped without one). +#[test] +fn a_pushed_in_line_leaves_the_gap_after_inline_art() { + let mut t = TextObject::point(Point::ZERO, "一二", style(20.0)); + t.xf = Affine::IDENTITY; + t.runs.push(TextRun::inline(art(0.5), style(20.0))); + t.runs.push(TextRun::new("四五六七八九十一二三四五」六", style(20.0))); + t.para.kinsoku_type = vectorcraft_doc::KinsokuShoriType::PushInFirst; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 300.0, 200.0).to_path(0.1)) }; + let l = layout(FontDb::global(), &t); + if (l.glyphs[0].advance - 20.0).abs() > 2.0 { + return; // no font with full-width Japanese here + } + assert!(l.glyphs[1].advance < 20.0 - 0.5, "the other gaps close up: {}", l.glyphs[1].advance); + let art = inline_glyph(&l); + assert!((art.advance - 20.0).abs() < 1e-6, "the art keeps its advance: {}", art.advance); +} + #[test] fn inline_art_moves_with_vertically_aligned_lines() { use vectorcraft_doc::text::{AreaOptions, VerticalAlign}; diff --git a/crates/ui-egui/src/dialogs/print.rs b/crates/ui-egui/src/dialogs/print.rs index 523c42d06..807beed25 100644 --- a/crates/ui-egui/src/dialogs/print.rs +++ b/crates/ui-egui/src/dialogs/print.rs @@ -427,22 +427,32 @@ fn page_preview(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, pv: } let coeffs: Vec = sheet["transform"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); let clip = ui.painter().with_clip_rect(page); + // With copies per sheet, `copies` holds each copy's artboard on the page, and `moves` holds + // each copy's move on screen from the first. + let copies: Vec = sheet["copies"].as_array().map(|a| a.iter().filter_map(rect_of).collect()).unwrap_or_default(); + let moves: Vec = match copies.first() { + Some(first) => copies.iter().map(|r| egui::vec2((r.x0 - first.x0) as f32, (r.y0 - first.y0) as f32) * fit).collect(), + None => vec![egui::Vec2::ZERO], + }; if let ([a, b, c, dd, e, f], Some(area)) = (coeffs.as_slice(), rect_of(&sheet["area"])) { let px = (f64::from(page.width().max(page.height()) * ui.ctx().pixels_per_point())).min(1024.0); let layers = choice::(d, "printLayers").unwrap_or(PrintLayers::VisiblePrintable); if let Some(tex) = art_texture(app, ui.ctx(), layers, area, px) { let at = |x: f64, y: f64| to_screen(a * x + c * y + e, b * x + dd * y + f) + offset; let mut mesh = egui::Mesh::with_texture(tex.id()); - for (p, uv) in [ - ((area.x0, area.y0), (0.0, 0.0)), - ((area.x1, area.y0), (1.0, 0.0)), - ((area.x1, area.y1), (1.0, 1.0)), - ((area.x0, area.y1), (0.0, 1.0)), - ] { - mesh.vertices.push(egui::epaint::Vertex { pos: at(p.0, p.1), uv: egui::pos2(uv.0, uv.1), color: egui::Color32::WHITE }); + for m in &moves { + let first = mesh.vertices.len() as u32; + for (p, uv) in [ + ((area.x0, area.y0), (0.0, 0.0)), + ((area.x1, area.y0), (1.0, 0.0)), + ((area.x1, area.y1), (1.0, 1.0)), + ((area.x0, area.y1), (0.0, 1.0)), + ] { + mesh.vertices.push(egui::epaint::Vertex { pos: at(p.0, p.1) + *m, uv: egui::pos2(uv.0, uv.1), color: egui::Color32::WHITE }); + } + mesh.add_triangle(first, first + 1, first + 2); + mesh.add_triangle(first, first + 2, first + 3); } - mesh.add_triangle(0, 1, 2); - mesh.add_triangle(0, 2, 3); clip.add(mesh); } } @@ -460,7 +470,9 @@ fn page_preview(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, pv: let corners = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; clip.extend(egui::Shape::dashed_line(&corners, stroke, 3.0, 2.0)); } - if let Some(trim) = rect_of(&sheet["trim"]) { + // The preview strokes each copy's artboard edge, or the trim box without copies per sheet. + let trims = if copies.is_empty() { rect_of(&sheet["trim"]).into_iter().collect() } else { copies }; + for trim in trims { let r = egui::Rect::from_min_max(to_screen(trim.x0, trim.y0), to_screen(trim.x1, trim.y1)).translate(offset); clip.rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Middle); } @@ -660,6 +672,9 @@ fn general(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { if tiles { note(ui, tl!("Tiles are numbered across, then down; leave the range empty for every tile.")); } + flag(ui, d, "copiesPerSheet", tl!("Copies per Sheet (as many as fit, in a grid)"), !tiles); + let per_sheet = get(d, "copiesPerSheet").as_bool() == Some(true); + row(ui, tl!("Gutter:"), |ui| length(ui, d, "gutter", unit, !tiles && per_sheet)); } fn output(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result) { diff --git a/crates/ui-egui/src/dialogs/tests_print.rs b/crates/ui-egui/src/dialogs/tests_print.rs index bf213c789..3bf847d0e 100644 --- a/crates/ui-egui/src/dialogs/tests_print.rs +++ b/crates/ui-egui/src/dialogs/tests_print.rs @@ -216,6 +216,24 @@ fn without_a_print_service_print_saves_a_pdf() { assert_eq!(app.run("print.printers", json!({})).unwrap(), json!({"printers": [], "service": false, "setup": false})); } +#[test] +fn copies_per_sheet_prints_a_grid_of_each_artboard() { + let (mut app, jobs) = app(); + app.run("file.print", json!({})).unwrap(); + set(&mut app, "copiesPerSheet", json!(true)); + set(&mut app, "gutter", json!(9)); + frame(&mut app); + let r = super::confirm(&mut app).unwrap(); + assert_eq!(r["pages"], 3, "a sheet per artboard"); + assert_eq!(pages(&jobs.borrow()[0].2), 3); + let saved = app.session.active().unwrap().doc.print_setup.clone().unwrap(); + assert_eq!((saved["copiesPerSheet"].clone(), saved["gutter"].clone()), (json!(true), json!(9.0))); + // Each 120 × 90 artboard fills landscape Letter 6 across and 6 down, 9 pt apart. + let pv = app.session.execute("print.preview", &json!({})).unwrap(); + assert_eq!(pv["sheets"][0]["copies"].as_array().map(Vec::len), Some(36)); + assert_eq!(pv["sheets"][0]["copies"][1], json!([142.5, 13.5, 262.5, 103.5])); +} + #[test] fn dragging_the_preview_moves_the_placement_on_the_paper() { let (mut app, _) = app(); @@ -225,6 +243,8 @@ fn dragging_the_preview_moves_the_placement_on_the_paper() { (json!({"autoRotate": false, "orientation": "portraitFlipped"}), (10.0, 5.0)), (json!({"output": {"emulsion": "down"}, "transverse": true}), (-4.0, 7.0)), (json!({"scaling": "custom", "scale": {"width": 50, "height": 200}}), (6.0, -8.0)), + // Copies per sheet: the drag moves the whole grid. + (json!({"copiesPerSheet": true, "gutter": 9}), (10.0, 5.0)), ] { let before = app.session.execute("print.preview", &json!({ "settings": settings })).unwrap(); let sheet = &before["sheets"][0]; diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1a8c4c353..e4caf74f5 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3463,6 +3463,7 @@ digits Malayalam Малаялам Tile Type: Тип плочка: tile {number} плочка {number} Tiles are numbered across, then down; leave the range empty for every tile. Плочките се номерират напречно, след това надолу; оставете диапазона празен за всяка плочка. + Copies per Sheet (as many as fit, in a grid) Копия на лист (колкото се побират, в мрежа) Tint Оттенък Tip extends past the end point Връхът продължава след крайната точка Tip on the end point Връх върху крайната точка diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index bebdb3ac0..0444d5cb3 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2714,6 +2714,7 @@ digits Malayalam Malajálamské Tile Range: Rozsah dlaždic: tile {number} dlaždice {number} Tiles are numbered across, then down; leave the range empty for every tile. Dlaždice se číslují po řádcích, pak dolů; prázdný rozsah znamená všechny dlaždice. + Copies per Sheet (as many as fit, in a grid) Kopie na list (kolik se vejde, v mřížce) Transverse (the page a quarter turn on the paper) Napříč (stránka otočená na papíře o čtvrt otáčky) Unprintable margin: Netisknutelný okraj: {pages} in all, {count} per copy celkem {pages}, na kopii {count} diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 429738169..43a134d27 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2502,6 +2502,7 @@ digits Malayalam Malayalam Tile Range: Fliseinterval: tile {number} flise {number} Tiles are numbered across, then down; leave the range empty for every tile. Fliser nummereres på tværs og derefter nedad; lad intervallet stå tomt for alle fliser. + Copies per Sheet (as many as fit, in a grid) Kopier pr. ark (så mange der er plads til, i et gitter) Transverse (the page a quarter turn on the paper) På tværs (siden drejes en kvart omgang på papiret) Unprintable margin: Ikke-udskrivbar margen: {pages} in all, {count} per copy {pages} i alt, {count} pr. kopi diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0fbf2d153..2e2267941 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3495,6 +3495,8 @@ effect Points Punkte Tile Type: Kacheltyp: tile {number} Kachel {number} Tiles are numbered across, then down; leave the range empty for every tile. Kacheln werden erst quer, dann nach unten nummeriert; ein leerer Bereich steht für alle Kacheln. + Copies per Sheet (as many as fit, in a grid) Kopien pro Blatt (so viele wie passen, im Raster) + Gutter: Zwischenraum: Tint Tonwert Tip extends past the end point Spitze ragt über den Endpunkt hinaus Tip on the end point Spitze auf dem Endpunkt diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b3d38e345..91a929c21 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3887,6 +3887,7 @@ digits Malayalam Μαλαγιάλαμ Tile Type: Τύπος πλακιδίου: tile {number} πλακίδιο {number} Tiles are numbered across, then down; leave the range empty for every tile. Τα πλακίδια αριθμούνται πρώτα οριζόντια και έπειτα κατακόρυφα· αφήστε κενό το εύρος για όλα τα πλακίδια. + Copies per Sheet (as many as fit, in a grid) Αντίγραφα ανά φύλλο (όσα χωρούν, σε πλέγμα) Tip extends past the end point Η αιχμή προεξέχει από το τελικό σημείο Tip on the end point Η αιχμή βρίσκεται στο τελικό σημείο Title Case Κεφαλαίο σε κάθε λέξη diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e60255872..8df0a3953 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3551,6 +3551,8 @@ digits Malayalam Malayálam Tile Type: Tipo de mosaico: tile {number} mosaico {number} Tiles are numbered across, then down; leave the range empty for every tile. Los mosaicos se numeran de izquierda a derecha y luego hacia abajo; deja el rango vacío para todos. + Copies per Sheet (as many as fit, in a grid) Copias por hoja (tantas como quepan, en cuadrícula) + Gutter: Medianil: Tint Matiz Tip extends past the end point La punta se extiende más allá del punto final Tip on the end point Punta en el punto final diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 42b5e5e99..6a980373a 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2658,6 +2658,7 @@ digits Malayalam Malajalam Tile Range: Ruutualue: tile {number} ruutu {number} Tiles are numbered across, then down; leave the range empty for every tile. Ruudut numeroidaan vaakasuunnassa ja sitten alaspäin; jätä alue tyhjäksi, niin kaikki ruudut tulostetaan. + Copies per Sheet (as many as fit, in a grid) Kopioita arkille (niin monta kuin mahtuu, ruudukkoon) Transverse (the page a quarter turn on the paper) Poikittainen (sivu neljänneskierroksen käännettynä paperilla) Unprintable margin: Tulostumaton reunus: {pages} in all, {count} per copy {pages} yhteensä, {count} kopiota kohden diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 8ba60f345..72f1b84cd 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3542,6 +3542,7 @@ digits Malayalam Malayalam Tile Range: Plage de carreaux : Tile Type: Type de carreau : tile {number} carreau {number} + Copies per Sheet (as many as fit, in a grid) Copies par feuille (autant qu’il en tient, en grille) Tiles are numbered across, then down; leave the range empty for every tile. Les carreaux sont numérotés de gauche à droite, puis de haut en bas ; laissez la plage vide pour tous les carreaux. Tint Teinte Tip extends past the end point La pointe dépasse le point d’extrémité diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 16513f7dc..2797d4cf5 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4225,6 +4225,7 @@ mark-style Japanese Japán tile {number} csempe {number} Tiles are numbered across, then down; leave the range empty for every tile. A csempék számozása vízszintesen, majd lefelé halad; az összes csempéhez hagyd üresen a tartományt. Tilt Dőlés + Copies per Sheet (as many as fit, in a grid) Példány laponként (ahány elfér, rácsban) Tint Tónus Tints Tónusok Tints and Shades Tónusok és árnyalatok diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 054938af1..54e894885 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2502,6 +2502,7 @@ digits Malayalam Malayalam Tile Range: Rentang ubin: tile {number} ubin {number} Tiles are numbered across, then down; leave the range empty for every tile. Ubin dinomori menyamping lalu ke bawah; biarkan rentang kosong untuk semua ubin. + Copies per Sheet (as many as fit, in a grid) Salinan per lembar (sebanyak yang muat, dalam kisi) Transverse (the page a quarter turn on the paper) Melintang (halaman diputar seperempat putaran pada kertas) Unprintable margin: Margin yang tidak dapat dicetak: {pages} in all, {count} per copy {pages} total, {count} per salinan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8283b4448..50324a528 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3587,6 +3587,8 @@ digits Malayalam Malayalam Tile Type: Tipo di tassello: tile {number} riquadro {number} Tiles are numbered across, then down; leave the range empty for every tile. I riquadri sono numerati da sinistra a destra, poi dall'alto in basso; lascia vuoto l'intervallo per stamparli tutti. + Copies per Sheet (as many as fit, in a grid) Copie per foglio (quante ne entrano, in griglia) + Gutter: Spazio intermedio: Tint Tinta Tip extends past the end point La punta si estende oltre il punto finale Tip on the end point Punta sul punto finale diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 50d979fad..4abe7747f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3279,6 +3279,8 @@ mark-style Japanese 日本式 Tile Range: 分割範囲: Tile Type: タイルの種類: Tiles are numbered across, then down; leave the range empty for every tile. タイルは左から右、上から下の順に番号が付きます。範囲を空にするとすべてのタイルが対象です。 + Copies per Sheet (as many as fit, in a grid) 用紙ごとに複数コピー (収まるだけグリッド状に配置) + Gutter: 間隔: Toggle Fill/Stroke Focus 塗り/線の切り替え Toggle Selection 選択を切り替え Toggle single/double column 1 列/2 列を切り替え diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index fe60a07b1..4647c4f86 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3268,6 +3268,8 @@ mark-style Japanese 일본식 Tile Range: 타일 범위: Tile Type: 타일 유형: Tiles are numbered across, then down; leave the range empty for every tile. 타일은 가로로 먼저, 이어서 세로로 번호가 매겨집니다. 모든 타일을 지정하려면 범위를 비워 두십시오. + Copies per Sheet (as many as fit, in a grid) 용지당 여러 매 (들어가는 만큼 격자로 배치) + Gutter: 간격: Toggle Fill/Stroke Focus 칠/선 포커스 전환 Toggle Selection 선택 전환 Toggle single/double column 한 줄/두 줄 전환 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index f9c6e6377..81320e62c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2516,6 +2516,7 @@ digits Malayalam Malayalam Tile Range: Tegelbereik: tile {number} tegel {number} Tiles are numbered across, then down; leave the range empty for every tile. Tegels worden van links naar rechts en vervolgens van boven naar beneden genummerd; laat het bereik leeg voor alle tegels. + Copies per Sheet (as many as fit, in a grid) Kopieën per vel (zoveel als er passen, in een raster) Transverse (the page a quarter turn on the paper) Dwars (de pagina een kwartslag op het papier) Unprintable margin: Niet afdrukbare marge: {pages} in all, {count} per copy {pages} in totaal, {count} per exemplaar diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3595a6a99..4107219d4 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2627,6 +2627,8 @@ digits Malayalam Malajalam Tile Range: Zakres kafelków: tile {number} kafelek {number} Tiles are numbered across, then down; leave the range empty for every tile. Kafelki są numerowane poziomo, a potem w dół; pozostaw zakres pusty dla wszystkich kafelków. + Copies per Sheet (as many as fit, in a grid) Kopie na arkusz (tyle, ile się zmieści, w siatce) + Gutter: Odstęp: Transverse (the page a quarter turn on the paper) Poprzecznie (strona obrócona o ćwierć obrotu na papierze) Unprintable margin: Margines niedrukowalny: {pages} in all, {count} per copy Razem: {pages}, na kopię: {count} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ab2e91ea1..699ea20f8 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2916,6 +2916,7 @@ digits Malayalam Malaiala Tile Range: Intervalo de Blocos: Tile Type: Tipo de Bloco: Tiles are numbered across, then down; leave the range empty for every tile. Os blocos são numerados horizontalmente, depois para baixo; deixe o intervalo vazio para cada bloco. + Copies per Sheet (as many as fit, in a grid) Cópias por Folha (quantas couberem, em grade) Title Case Tipo de Título Toggle Fill/Stroke Focus Alternar Foco de Preenchimento/Traçado Toggle Selection Alternar Seleção diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9579b73d5..0f0a2ed78 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3582,6 +3582,7 @@ digits Malayalam Малаялам Tile Type: Тип плитки: tile {number} плитка {number} Tiles are numbered across, then down; leave the range empty for every tile. Плитки нумеруются слева направо, затем вниз; оставьте диапазон пустым для всех плиток. + Copies per Sheet (as many as fit, in a grid) Копий на лист (сколько поместится, сеткой) Tint Оттенок Tip extends past the end point Кончик выходит за конечную точку Tip on the end point Кончик на конечной точке diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index c1a55bd2c..90fae3ceb 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3473,6 +3473,7 @@ digits Malayalam Malayalam Tile Type: Ruttyp: tile {number} ruta {number} Tiles are numbered across, then down; leave the range empty for every tile. Rutorna numreras från vänster till höger och sedan nedåt. Lämna omfånget tomt för att få med alla rutor. + Copies per Sheet (as many as fit, in a grid) Kopior per ark (så många som får plats, i ett rutnät) Tint Färgton Tip extends past the end point Spetsen sträcker sig förbi slutpunkten Tip on the end point Spetsen på slutpunkten diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d74783d02..83761eb95 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3580,6 +3580,8 @@ effect Points Точки Tile Type: Тип плитки: tile {number} плитка {number} Tiles are numbered across, then down; leave the range empty for every tile. Плитки нумеруються зліва направо, потім униз; Залишіть діапазон порожнім для всіх плиток. + Copies per Sheet (as many as fit, in a grid) Копій на аркуш (скільки поміститься, сіткою) + Gutter: Проміжок: Tint Відтінок Tip extends past the end point Кінчик виходить за кінцеву точку Tip on the end point Кінчик на кінцевій точці diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7939ac2fe..bc29dc0c1 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3927,6 +3927,8 @@ mark-style Japanese Kiểu Nhật tile {number} ô {number} Tiles are numbered across, then down; leave the range empty for every tile. Các ô được đánh số từ ngang sang dọc; để trống phạm vi để in tất cả ô. Tilt Độ nghiêng + Copies per Sheet (as many as fit, in a grid) Nhiều bản sao trên một tờ (xếp vừa bao nhiêu in bấy nhiêu, theo lưới) + Gutter: Khoảng cách: Tint Tông màu Tints Sắc pha Tints and Shades Sắc pha và bóng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c821b3cdc..9b9dc822d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3414,6 +3414,7 @@ digits Malayalam 马拉雅拉姆数字 Tile Type: 拼贴类型: tile {number} 拼贴 {number} Tiles are numbered across, then down; leave the range empty for every tile. 拼贴先由左至右再由上至下编号;范围留空表示全部拼贴。 + Copies per Sheet (as many as fit, in a grid) 每张纸印多份 (尽量排满,按网格排列) Tint 色调 Tip extends past the end point 箭头超出端点 Tip on the end point 箭头位于端点 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c8f7bd567..402e562f7 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4476,6 +4476,8 @@ digits Malayalam 馬來亞拉姆數字 Tile Type: 拼貼類型: tile {number} 拼貼 {number} Tiles are numbered across, then down; leave the range empty for every tile. 拼貼先由左至右再由上至下編號;範圍留空表示全部拼貼。 + Copies per Sheet (as many as fit, in a grid) 每張紙印多份 (盡量排滿,以格點排列) + Gutter: 間距: Tint 色調 Tip extends past the end point 尖端延伸超過終點 Tip on the end point 尖端位於終點 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4b931aaef..802bb695d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -196,8 +196,8 @@ pub struct VectorcraftApp { /// A URL to open through egui next frame (when the host has no `open_url` service). pending_url: Option, /// `VECTORCRAFT_TITLEBAR` chose this window's frame (Linux and the BSDs), so Preferences › User - /// Interface › VectorCraft Title Bar on Every Desktop does not apply while it is set. `false` - /// unless the host sets it. + /// Interface › System Title Bar and VectorCraft Title Bar on Every Desktop do not apply while + /// it is set. `false` unless the host sets it. pub titlebar_from_env: bool, /// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag, /// double-click to maximize, caption buttons) and invisible edge zones resize the window. diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index 733aaf200..d5f391433 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -36,8 +36,8 @@ pub fn open(app: &mut VectorcraftApp, category: Option<&str>) { fields.insert("__smartGuides".into(), json!(app.ui.view.smart_guides)); fields.insert("__snapToGrid".into(), json!(app.ui.view.snap_to_grid)); fields.insert("__taskBar".into(), json!(app.ui.task_bar)); - // Read-only: whether VECTORCRAFT_TITLEBAR chose this window's frame (VectorCraft Title Bar on - // Every Desktop's note). + // Read-only: whether VECTORCRAFT_TITLEBAR chose this window's frame (System Title Bar's dimmed + // row and VectorCraft Title Bar on Every Desktop's note). fields.insert("__titlebarFromEnv".into(), json!(app.titlebar_from_env)); fields.insert("__search".into(), json!("")); fields.insert("__focusSearch".into(), json!(true)); @@ -483,7 +483,8 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, // Windows and Linux only: macOS always has the system's title bar, the web has none. PrefKind::Bool if sp.key == "systemTitleBar" => { if cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) { - bool_row(ui, d, sp.key, sp.label); + // VECTORCRAFT_TITLEBAR chooses the frame while it is set: the checkbox is dimmed. + ui.add_enabled_ui(!d.bool("__titlebarFromEnv"), |ui| bool_row(ui, d, sp.key, sp.label)); // The window's decorations are chosen once, when the app starts. On Linux and the // BSDs the note goes under the next row, which also applies at the next start. if !APP_TITLE_BAR_SHOWN { @@ -499,7 +500,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, let from_env = d.bool("__titlebarFromEnv"); let system = d.bool("systemTitleBar"); ui.add_enabled_ui(!from_env && !system, |ui| bool_row(ui, d, sp.key, sp.label)); - if system { + if system && !from_env { let note = tl!("System Title Bar takes precedence over this option while it is on."); ui.label(egui::RichText::new(note).color(t.text_dim).size(11.0)); } diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index 11fd0f418..9ef8f3ed7 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -336,7 +336,8 @@ fn app_title_bar_preference_shows_and_persists() { /// VectorCraft Title Bar on Every Desktop does not take clicks while System Title Bar is on or /// VECTORCRAFT_TITLEBAR is set. With the variable set its note names the variable in place of the -/// next-launch note, and with System Title Bar on a note says that System Title Bar takes precedence. +/// next-launch note, and with System Title Bar on and the variable unset a note says that System +/// Title Bar takes precedence. #[test] fn app_title_bar_is_dimmed_while_something_takes_precedence() { fn texts(s: &egui::Shape, out: &mut Vec) { @@ -364,7 +365,7 @@ fn app_title_bar_is_dimmed_while_something_takes_precedence() { let mut shown = vec![]; out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); assert_eq!(shown.iter().any(|t| t == note), supported && from_env, "{shown:?}"); - assert_eq!(shown.iter().any(|t| t == system_note), supported && system, "{shown:?}"); + assert_eq!(shown.iter().any(|t| t == system_note), supported && system && !from_env, "{shown:?}"); // The next-launch note stays under this row on Linux and the BSDs, where it also covers // System Title Bar, and under System Title Bar on Windows. let restart_shown = if supported { !from_env } else { cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) }; @@ -392,6 +393,42 @@ fn app_title_bar_is_dimmed_while_something_takes_precedence() { } } +/// While VECTORCRAFT_TITLEBAR chooses the frame, System Title Bar does not take clicks. +#[test] +fn system_title_bar_is_dimmed_while_the_environment_variable_chooses_the_frame() { + let supported = cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))); + for from_env in [false, true] { + let mut a = app(); + a.titlebar_from_env = from_env; + open(&mut a, Some("User Interface")); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let label = out.shapes.iter().find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == "System Title Bar" => Some(t.visual_bounding_rect()), + _ => None, + }); + assert_eq!(label.is_some(), supported); + if let Some(r) = label { + for pressed in [true, false] { + let input = egui::RawInput { + events: vec![ + egui::Event::PointerMoved(r.center()), + egui::Event::PointerButton { pos: r.center(), button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }, + ], + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + } + assert_eq!(a.ui.dialog.as_ref().unwrap().bool("systemTitleBar"), !from_env, "clicked with the variable {from_env}"); + } + } +} + /// Performance › Linux Display Server (#2028): on Linux it shows the drawing-tablet note, and /// X11 (XWayland) When a Pen Is Attached is applied by OK and saved under the key the desktop /// app reads before the window opens. diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index a62a221c5..c8dade12b 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -5,7 +5,7 @@ use serde_json::Value; use crate::VectorcraftApp; #[cfg(not(target_arch = "wasm32"))] -use craft_update::updater::{Action, Updater}; +use craft_update::updater::{Action, Status, Updater}; #[cfg(not(target_arch = "wasm32"))] use craft_update::{CheckResultDialog, CheckResultResponse}; @@ -20,10 +20,10 @@ pub(crate) struct Runtime { /// The context of the last frame, to wake the UI when a check is queued from a menu callback. #[cfg(not(target_arch = "wasm32"))] repaint: Option, - /// Whether the result of Help ▸ Check for Updates is in view. - pub(crate) manual_window_open: bool, /// The web build has no updater; its window only says so. #[cfg(target_arch = "wasm32")] + pub(crate) manual_window_open: bool, + #[cfg(target_arch = "wasm32")] pub(crate) status_message: Option, } @@ -38,6 +38,7 @@ impl Default for Runtime { check_requested: false, #[cfg(not(target_arch = "wasm32"))] repaint: None, + #[cfg(target_arch = "wasm32")] manual_window_open: false, #[cfg(target_arch = "wasm32")] status_message: None, @@ -51,13 +52,20 @@ impl VectorcraftApp { pub fn set_startup_update_check(&mut self, on: bool) { self.updates_rt.startup_check = on; } + + /// Starts the install helper of an update that is ready, so it installs once the process has + /// exited (does nothing when no update is ready). Call it in `on_exit` when the process ends + /// there, since the app is then never dropped. + #[cfg(not(target_arch = "wasm32"))] + pub fn start_pending_update(&mut self) { + self.updates_rt.updater.start_pending_update(); + } } pub(crate) fn check(app: &mut VectorcraftApp) -> Result { #[cfg(not(target_arch = "wasm32"))] { app.updates_rt.check_requested = true; - app.updates_rt.manual_window_open = true; if let Some(ctx) = &app.updates_rt.repaint { ctx.request_repaint(); } @@ -87,10 +95,18 @@ pub(crate) fn tick(app: &mut VectorcraftApp, ctx: &egui::Context) { let _ = (app, ctx); } +/// Whether the check result window shows: for every status but idle (a requested check, and the +/// first start after an update, which reports that it installed or why it failed), and never +/// over the startup prompt. +#[cfg(not(target_arch = "wasm32"))] +fn result_window_shows(status: &Status, prompt_open: bool) -> bool { + !prompt_open && !matches!(status, Status::Idle) +} + pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { #[cfg(not(target_arch = "wasm32"))] { - // The restart helper is scheduled before the app closes, so no update starts over unsaved work. + // The close for a ready update waits until no document holds unsaved work. let clean = !crate::unsaved::any_dirty(app); match app.updates_rt.updater.show_if(ctx, clean) { Action::OpenUrl(url) => app.open_url(&url), @@ -99,20 +115,15 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } // An available update goes to the prompt above, which replaces the result dialog. let rt = &mut app.updates_rt; - if rt.manual_window_open && rt.updater.prompt_open() { - rt.manual_window_open = false; - } - if rt.manual_window_open { + if result_window_shows(rt.updater.status(), rt.updater.prompt_open()) { match CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).show(ctx, rt.updater.status()) { CheckResultResponse::None => {} - CheckResultResponse::Close => rt.manual_window_open = false, + // Closing the dialog dismisses the outcome it showed. + CheckResultResponse::Close => rt.updater.dismiss_status(), CheckResultResponse::CheckAgain => rt.updater.check_now(ctx), + CheckResultResponse::RestartToUpdate => rt.updater.restart_to_update(), } } - // Closing the dialog dismisses the outcome it showed. - if !rt.manual_window_open { - rt.updater.dismiss_status(); - } } #[cfg(target_arch = "wasm32")] @@ -142,13 +153,12 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { #[cfg(test)] mod tests { use super::*; - #[cfg(not(target_arch = "wasm32"))] - use craft_update::updater::Status; use vectorcraft_engine::Session; #[test] fn default_runtime_is_idle() { let rt = Runtime::default(); + #[cfg(target_arch = "wasm32")] assert!(!rt.manual_window_open); #[cfg(not(target_arch = "wasm32"))] { @@ -158,6 +168,26 @@ mod tests { } } + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn result_window_shows_every_status_but_idle() { + let shown = [ + Status::Checking, + Status::UpToDate, + Status::Failed("offline".into()), + Status::Installing, + Status::Ready, + Status::Managed, + Status::Updated("99.0.0".into()), + Status::InstallFailed("locked".into()), + ]; + for status in &shown { + assert!(result_window_shows(status, false), "{status:?} shows"); + assert!(!result_window_shows(status, true), "{status:?} waits for the prompt"); + } + assert!(!result_window_shows(&Status::Idle, false)); + } + #[test] #[cfg(not(target_arch = "wasm32"))] fn startup_prompt_shows_and_closes() { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 290619b2a..66d90cc33 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -338,7 +338,9 @@ when the app starts, off by default) turns off the desktop's frame and uses the sessions where the desktop would draw the frame: X11 sessions, Wayland compositors that draw server-side decorations such as KDE Plasma, and a window opened through XWayland. System Title Bar and `VECTORCRAFT_TITLEBAR` (`app`, or `system` or `desktop`) take precedence over it. The checkbox is dimmed while either applies, and a note under it names -each one that applies. +each one that applies. `VECTORCRAFT_TITLEBAR` also takes precedence over System Title Bar for the run it is set in: +`app` keeps the app bar as the title bar with System Title Bar on. While the variable is set, the Preferences dialog dims +System Title Bar and its `__titlebarFromEnv` field is `true`. Fill/Stroke chips and panel shortcuts: the Control bar's and Properties' Fill and Stroke chips bring their proxy forward (`paint.toggleActive {fill}`) and open a popover with the Swatches panel (Shift-click: the Color panel's mixer); diff --git a/docs/development.md b/docs/development.md index 3204c445b..a4077abc6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -118,7 +118,7 @@ A file opened from the file manager (double-click, Open With, a drop on the exec ## Linux: Wayland and X11 -On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** and restart to use winit's font-free Adwaita window controls (`wayland-csd-adwaita-notitle`); the existing custom title bar remains the Linux default. On X11, on a Wayland compositor that draws server-side decorations such as KDE Plasma, and in a window opened through XWayland, enable **Preferences › User Interface › VectorCraft Title Bar on Every Desktop** and restart to use the app bar as the title bar instead of the desktop's frame. `VECTORCRAFT_TITLEBAR=app`, `=system` or `=desktop` chooses the frame for one run and takes precedence over VectorCraft Title Bar on Every Desktop. +On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** and restart to use winit's font-free Adwaita window controls (`wayland-csd-adwaita-notitle`); the existing custom title bar remains the Linux default. On X11, on a Wayland compositor that draws server-side decorations such as KDE Plasma, and in a window opened through XWayland, enable **Preferences › User Interface › VectorCraft Title Bar on Every Desktop** and restart to use the app bar as the title bar instead of the desktop's frame. `VECTORCRAFT_TITLEBAR=app`, `=system` or `=desktop` chooses the frame for one run and takes precedence over System Title Bar and VectorCraft Title Bar on Every Desktop. The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature: diff --git a/docs/mcp.md b/docs/mcp.md index 0f759abac..b033bebc2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5177,6 +5177,12 @@ documents every field: visiblePrintable|visible|all` (template layers never print); `placement: {origin, x, y}` places the printed area (artboard, bleed and marks) on the imageable area (the paper inside `margin`); `scaling: none|fit|custom|tileFull| tileImageable` with `scale: {width, height}` (%), `overlap` and `tileRange` (tiles numbered across then down). +- Copies per sheet: with `copiesPerSheet: true` (off by default; not with tiling) each sheet holds as many copies of + the artboard as fit the imageable area, each with its bleed and marks, across then down, `gutter` pt apart (0–720, + default 0). The copies are not turned, and the placement places the whole grid. An artboard that doesn't fit prints + one copy per sheet, with a warning; more than 1,000 copies on a sheet is an error. `print.preview` lists each + copy's artboard on the page as `copies: [[x0, y0, x1, y1]…]`, across then down, and `trim` (the TrimBox) holds + them all. - `marks` and `bleed` as `document.exportPdf` takes them (the bleed is the document's unless `useDocument: false`), drawn around the artboard at the paper's scale; the page information adds the tile and the ink with its screen. - `output: {mode: composite|separations, emulsion: up|down, image: positive|negative, spotsToProcess, inks: [{name, @@ -5473,10 +5479,11 @@ its pixels are decoded. `file.place {path}` links the file like any image. Vecto ## Printing `print.setup {settings?}` keeps print settings with the document (one undo step; without `settings` it answers the -current ones): the copies, artboards, paper, orientation, placement, scaling and tiling, marks and bleed, composite or -separations output, graphics and colour management options of File › Print. `print.preview {settings?}` lays the job -out without printing: `pages`, then one copy's `sheets`, each with its page size, scale, the `transform` from the -document onto the page (pt, y down), the document `area` it prints and its `trim` box on the page, plus `tiles`, the +current ones): the copies, artboards, paper, orientation, placement, scaling and tiling, copies per sheet, marks and +bleed, composite or separations output, graphics and colour management options of File › Print. `print.preview +{settings?}` lays the job out without printing: `pages`, then one copy's `sheets`, each with its page size, scale, the +`transform` from the document onto the page (pt, y down), the document `area` it prints, its `trim` box on the page +and, with copies per sheet, each copy's artboard on the page (`copies`), plus `tiles`, the `inks` of a separation and `warnings`. `file.print {settings?, path?}` writes the job as a print-ready PDF (one page per sheet, tile, ink and copy); headless it answers `dataBase64`. In the app, `file.print` with no params opens the Print dialog, and with params it prints through the system (`printer`: a name from `print.printers`, default the system's @@ -6677,7 +6684,9 @@ under its id. One that lists `settings` or `cache` keeps settings or a cache of size as `cacheBytes`, `plugin.clearCache {id?}` empties the cache of one plug-in or of all of them and returns `{cleared: [id], bytesFreed}`, replacing a plug-in keeps both, and `plugin.remove` deletes both (also for an id that isn't installed but still has them). Files it can't delete stay in the plug-in's folder renamed with `.removed`, where -no plug-in reaches them, until a plug-in with that id is removed again ([plugins.md](plugins.md#settings-and-cache)). A module with other bytes than the plug-in installed with its id, or, with none +no plug-in reaches them. A later `plugin.remove` tries again to delete them when a plug-in with that id is installed or +a storage folder for the id exists, and that deletion can fail too; for an id with only a `.removed` folder it does +not ([plugins.md](plugins.md#settings-and-cache)). A module with other bytes than the plug-in installed with its id, or, with none installed, one whose id has settings or a cache in the `Plug-ins` folder (`needsReplace: true` in its `plugin.inspect` entry), installs only with `replace: true`: ask the user first, since it takes that plug-in's place and gets its settings and cache. Without it, `plugin.install` returns diff --git a/docs/plugins.md b/docs/plugins.md index 7e96e8aea..328a1d85e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -325,10 +325,18 @@ the key as `(key, key_len)`, the UTF-8 bytes at `key` in its memory: A call that fails returns `-1` when the key has no value, `-2` when a key, value or buffer doesn't lie inside the module's memory, `-3` when the key is not a valid key, `-4` when the write would pass a cap (nothing changes), `-5` when there is nowhere to keep anything, `-6` when reading or writing the file failed, and `-7` when the run has used -its 10,000 storage calls or its 1 GiB of storage reads and writes. Each call counts the bytes it reads and writes: a -settings call the whole settings file it reads, and a set or delete also the whole file it writes; a cache call the -entry it reads or writes. A read counts them even when the value doesn't fit and nothing is copied. A call that would -pass the 1 GiB returns `-7` before it reads or writes anything. +its 10,000 storage calls or its 1 GiB of storage reads and writes. Each call counts the bytes it reads and writes. A +settings call counts the whole settings file when it reads the file, and a set or delete also counts the whole file +when it writes the file; a cache call counts the entry it reads or writes. A read counts them even when the value +doesn't fit and nothing is copied. A call that would pass the 1 GiB returns `-7` before it reads or writes anything. + +The app keeps the settings in memory after the first call and reads `settings.json` again only when the file's size +or modified time differs from the last read or write. A get answered from memory reads no file and counts no bytes. A +set to the value the key already has writes nothing, and neither does a delete of a key that has no value. A delete +of the last setting removes the file and counts no written bytes. Every other successful set or delete writes the +file before the call returns. A cache call reads or writes the whole entry in memory in one step, up to 64 MiB, and +the run waits for it. +A live effect runs while the art draws, so keep the entries it uses small. - A key is 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-`, and is not a Windows device name (`CON`, `PRN`, `AUX`, `NUL`, `COM0` to `COM9`, `LPT0` to `LPT9`, in any case). Keys differ in case: `tile` and `Tile` are two. @@ -347,7 +355,9 @@ pass the 1 GiB returns `-7` before it reads or writes anything. (`plugin.install` with `replace: true`, after the user agreed) keeps the storage; `plugin.remove` deletes it, and a run of the plug-in still going gets `-5` from every storage call after that. `plugin.remove` first renames the folder (adding `.removed` to its name). When that fails, `plugin.remove` fails and the plug-in stays installed with its storage as it was. Files that can't be deleted after the rename stay in the - `.removed` folder, where no plug-in reaches them, and the next removal of a plug-in with that id deletes them. + `.removed` folder, where no plug-in reaches them. A later `plugin.remove` tries again to delete them when a plug-in + with that id is installed or a storage folder for the id exists, and that deletion can fail too. For an id that + isn't installed and has only a `.removed` folder, `plugin.remove` does not delete the folder and fails as not found. `plugin.remove` with the id of a plug-in that isn't installed deletes the storage kept for that id, when there is any. `plugin.clearCache` empties the cache and leaves the settings, and `plugin.list` and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 64eb2045c..ab2ba97a1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -821,7 +821,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Shortcut sets other apps save as text (`.kys`) open in Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts › Import… in the desktop app, or from a drop on the window. It matches each entry to a tool, command or panel by name and decodes the file's key and modifier codes where VectorCraft's default shortcuts agree on them; the user answers the rest, and Apply fills the Keyboard Shortcuts dialog's working set. The answers are kept with the preferences for the next import (`shortcutSet.read`, `importMatches.*`). - Edit › Keyboard Shortcuts › Export Text… saves the set shown in the dialog as a UTF-8 text file, as the reference app's [Export keyboard shortcuts list as a text document](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/export-keyboard-shortcuts-list.html) page describes (`shortcuts.exportText {path?}`). The layout is VectorCraft's own: the set's name, then the Tools and Menu Commands rows in the dialog's order, each with its name, a tab and its shortcuts as the dialog shows them, and nothing after the tab for a row with no shortcut. Tests `shortcut_editor::tests::export_text_*` and `tests::picks::keyboard_shortcuts_export_text_writes_the_working_set_as_text`. - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. - - On Linux and the BSDs, Preferences › User Interface › VectorCraft Title Bar on Every Desktop (off by default) uses the app bar as the title bar from the next start in the sessions where the desktop would draw the window's frame (X11, Wayland compositors with server-side decorations such as KDE Plasma, a window opened through XWayland). System Title Bar and `VECTORCRAFT_TITLEBAR` take precedence over it. + - On Linux and the BSDs, Preferences › User Interface › VectorCraft Title Bar on Every Desktop (off by default) uses the app bar as the title bar from the next start in the sessions where the desktop would draw the window's frame (X11, Wayland compositors with server-side decorations such as KDE Plasma, a window opened through XWayland). System Title Bar and `VECTORCRAFT_TITLEBAR` take precedence over it, and `VECTORCRAFT_TITLEBAR` also takes precedence over System Title Bar for the run it is set in. - Edit › Keyboard Shortcuts lists items of the menu bar and panel menus, as the reference app's [Customize keyboard shortcuts](https://web.archive.org/web/20221205131209id_/https://helpx.adobe.com/illustrator/using/customizing-keyboard-shortcuts.html) page describes for its Menu Commands. A menu command with no default shortcut that needs a value a key press does not give, such as File › Document Setup › Units (`document.setUnits {units}`), is listed only while the user has changed its shortcut. A command stays listed when a menu bar item runs it without a value or when it opens a dialog run without one, as the Variables panel's commands do. - A shortcut on the key left of 1 runs whatever character that key types, and Edit › Keyboard Shortcuts, the menus in the window, the command palette and tool tips show it as that character once the key has been pressed: `"` on a Turkish Q layout, `~` on a US layout as before. Shortcuts saved as `~` keep working (#4559). - On Windows a maximized window stays inside the work area with the taskbar on any edge (#3242, #3723). @@ -1405,6 +1405,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - PDF export and PDF-compatible `.ai` and `.ait` saves write File Info's copyright notice, URL and status to the XMP packet (`dc:rights`, `xmpRights:WebStatement`, `xmpRights:Marked`), so a PDF or `.ai` opened from its pages keeps them (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). - Opening a PDF or PDF-compatible `.ai` whose XMP packet nests elements more than 256 deep no longer overflows the stack: File Info leaves the packet out and takes the document information dictionary's values (`crates/pdf/src/tests/file_info.rs`). - `file.print`, `print.preview` and `print.setup` take a print preset by name (`preset`: `[Default]` or a saved preset, in any case): its settings are used instead of the document's, with `settings` over them, `print.setup` saves them with the document, and an unknown name is refused with the list of presets. + - Print's Copies per Sheet (`copiesPerSheet`, `gutter`; off by default) prints as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, as one grid placed like a single copy; the preview shows them, and an artboard that does not fit prints one copy per sheet with a warning (#1283). - Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). - File › Place reads RTF files as area type with their formatting: each run's font, size, bold and italic, underline, strikethrough, caps, superscript or subscript, baseline shift and color, and each paragraph's alignment, indents, space before and after and direction. Table cells are separated by tabs; hidden text, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests/textimport.rs`). - File › Place reads Word `.docx` documents as area type with the same formatting, taken from the document defaults, the paragraph and character styles (their toggle properties reversing each other) and direct formatting, the theme's fonts included. Table rows are paragraphs with their cells separated by tabs; hidden and deleted text, field instructions, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests/textimport.rs`). @@ -1568,13 +1569,14 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode ` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged. - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. -- **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. -- **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module with other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; for a plug-in that lists permissions, `plugin.install` needs `grant` and folders install it only when it is granted. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module that lists permissions and has other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A module with a missing or empty permissions list needs no grant; without the list, it gets its kind's implied permissions. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **A plug-in with no permissions ends its id's grant (G17):** installing a module that lists no permissions under a granted id forgets the grant, in the process and in `grants.json`, so the formerly approved bytes need a new approval. Attaching a `Plug-ins` folder whose module for that id lists no permissions forgets it too, and plug-in folders forget the process's grant. A refused install leaves the grant. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin..` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. +- **Plug-in settings and cache totals in memory (#6228):** the settings stay in memory while `settings.json` keeps its size and modified time, a set to the same value writes nothing, every other change is written to the file before the call returns, and `cacheBytes` comes from a total kept while the `cache` folder keeps its modified time. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one.