diff --git a/Cargo.lock b/Cargo.lock index 591389407..798c159d6 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=c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e#c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" dependencies = [ "chrono", "dunce", @@ -6511,6 +6511,7 @@ name = "vectorcraft-eps" version = "0.21.0" dependencies = [ "craft-pdf", + "encoding_rs", "fax", "flate2", "hayro-ccitt", @@ -6651,6 +6652,8 @@ dependencies = [ name = "vectorcraft-plugins" version = "0.21.0" dependencies = [ + "craft-vectorcraft-format", + "image", "log", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index fd8527fd9..9de669345 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 = "c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } @@ -99,6 +99,9 @@ ruzstd = { version = "0.9.1", default-features = false, features = ["std"] } hayro-ccitt = "0.3" # Substring search for the EPS reader's PostScript `search`. memchr = "2" +# The WHATWG Encoding Standard's legacy encodings: names in legacy .ai files read in the code page +# the user picks (Mac OS Roman, Korean, Japanese, Chinese). +encoding_rs = "0.8.42" jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] } gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] } # CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 8fc2661dc..acb665f49 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -5,7 +5,7 @@ //! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]... //! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2] [--ppi 300] //! vectorcraft-cli commands -//! vectorcraft-cli convert IN OUT [--scale 2] [--ppi 300] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] +//! vectorcraft-cli convert IN OUT [--scale 2] [--ppi 300] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--legacy-name-encoding ENC] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] //! vectorcraft-cli info FILE //! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5] //! vectorcraft-cli perf [--paths 50000] [--deep] @@ -101,7 +101,7 @@ USAGE: vectorcraft-cli commands Print the command catalogue as JSON. - vectorcraft-cli convert IN OUT [--scale N] [--ppi N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] + vectorcraft-cli convert IN OUT [--scale N] [--ppi N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--legacy-name-encoding ENC] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] Open IN (any readable format) and export OUT in the format its extension picks (see Writable formats). Raster resolution: --ppi, else 72 times --scale, else the document's raster effects resolution; other formats leave them out. --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard @@ -114,7 +114,10 @@ USAGE: IN left things out (hidden text, art or layers it could not read; the notes say which) unless --replace-lossy is given. --editing-data opens a .ai or an EPS with editing data from its editing copy even where that draws differently from its PDF part or page (which can be older), as long as - it can be read. A PDF OUT omits the embedded native document unless --preserve-editing is + it can be read. --legacy-name-encoding reads the names in a .ai's or an EPS's editing data + that aren't UTF-8 (layers, objects, artboards, colors, gradients, patterns, graphic styles, + swatches, brushes, symbols, variables and data sets, and data sets' text values) in + windows-1252 (the default), macintosh, windows-949, shift_jis, gbk or big5. A PDF OUT omits the embedded native document unless --preserve-editing is given (the app's Save As PDF still defaults to keeping it). With --preserve-editing, when the attachment is written the result's warnings say so (it is left out, with its own warning, for --artboard or a --range without every artboard). @@ -302,8 +305,20 @@ fn commands() -> Result<(), String> { } fn convert(args: &[String]) -> Result<(), String> { - let valued = - ["--scale", "-s", "--ppi", "--artboard", "-a", "--range", "-r", "--decimals", "--ai-version", "--hidden-objects", "--transparent-images"]; + let valued = [ + "--scale", + "-s", + "--ppi", + "--artboard", + "-a", + "--range", + "-r", + "--decimals", + "--ai-version", + "--hidden-objects", + "--transparent-images", + "--legacy-name-encoding", + ]; if help_requested(args, &valued) { return help(); } @@ -312,6 +327,9 @@ fn convert(args: &[String]) -> Result<(), String> { (None::, None::, None::, false, false, false, false); let mut ppi = None::; let mut decimals = None::; + // The encoding of a legacy `.ai`'s names that aren't UTF-8, passed on as `document.open`'s + // `legacyNameEncoding`; the engine checks it. + let mut legacy_names = None::; // Legacy `.ai` options, passed on as the export's own (`aiVersion`, `hiddenObjects`, // `transparentImages`); the engine checks their values. let mut legacy: Vec<(&str, String)> = vec![]; @@ -334,6 +352,9 @@ fn convert(args: &[String]) -> Result<(), String> { "--replace-lossy" => replace_lossy = true, "--editing-data" => editing_data = true, "--preserve-editing" => preserve_editing = true, + "--legacy-name-encoding" => { + legacy_names = Some(it.next().cloned().ok_or("--legacy-name-encoding needs an encoding such as windows-949")?); + } "--ai-version" => legacy.push(("aiVersion", it.next().cloned().ok_or("--ai-version needs 7 or pdf")?)), "--hidden-objects" => legacy.push(("hiddenObjects", it.next().cloned().ok_or("--hidden-objects needs layer, omit or visible")?)), "--transparent-images" => legacy.push(("transparentImages", it.next().cloned().ok_or("--transparent-images needs flatten or omit")?)), @@ -346,8 +367,14 @@ fn convert(args: &[String]) -> Result<(), String> { return Err("a .ait template holds every artboard: --artboard and --range don't apply".into()); } let mut h = Headless::new(); - let opened = if editing_data { - let params = json!({"path": input, "editingData": "always"}); + let opened = if editing_data || legacy_names.is_some() { + let mut params = json!({"path": input}); + if editing_data { + params["editingData"] = json!("always"); + } + if let Some(e) = &legacy_names { + params["legacyNameEncoding"] = json!(e); + } h.call("engine.execute", json!({"command": "document.open", "params": params})).map_err(|e| format!("open {input}: {e}"))? } else { h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))? diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs index e81dd7815..6e61cbe31 100644 --- a/apps/vectorcraft/src/tests.rs +++ b/apps/vectorcraft/src/tests.rs @@ -193,7 +193,8 @@ fn the_system_title_bar_preference_keeps_the_window_decorations() { /// Preferences › User Interface › VectorCraft Title Bar on Every Desktop gives the app frame where /// the probe chose the desktop's (KDE, X11, a window opened through XWayland); without it the probe -/// decides, and `VECTORCRAFT_TITLEBAR` takes precedence over both. +/// decides, and `VECTORCRAFT_TITLEBAR` set to `app`, `system` or `desktop` takes precedence over +/// both. #[test] fn the_app_title_bar_preference_gives_the_app_frame_on_desktop_frames() { assert!(!super::opted_into_app_title_bar(None), "no preferences: the default"); diff --git a/apps/vectorcraft/src/titlebar.rs b/apps/vectorcraft/src/titlebar.rs index 17c847040..fc6e39592 100644 --- a/apps/vectorcraft/src/titlebar.rs +++ b/apps/vectorcraft/src/titlebar.rs @@ -42,7 +42,7 @@ 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 unless -/// `VECTORCRAFT_TITLEBAR` is set ([`system_title_bar_applies`]). +/// `VECTORCRAFT_TITLEBAR` is set to `app`, `system` or `desktop` ([`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) } @@ -56,8 +56,8 @@ pub(crate) fn opted_into_app_title_bar(saved: Option<&vectorcraft_ui_egui::UiSta /// Whether the app draws the frame on this start, before the System Title Bar preference is /// applied ([`custom_titlebar_from`]). `probed_app` is the `craft-window` probe's answer and -/// `overridden` its `VECTORCRAFT_TITLEBAR` override. In order: the override decides when it is -/// set; VectorCraft Title Bar on Every Desktop (`opted_into_app`) gives the app frame; a window +/// `overridden` its `VECTORCRAFT_TITLEBAR` override. In order: the override decides when the +/// variable is set to `app`, `system` or `desktop`;VectorCraft Title Bar on Every Desktop (`opted_into_app`) gives the app frame; a window /// that opens through XWayland (`forced_to_x11`) gets the desktop frame; otherwise the probe /// decides. pub(crate) fn app_draws_frame(probed_app: bool, overridden: Option, forced_to_x11: bool, opted_into_app: bool) -> bool { @@ -89,13 +89,14 @@ fn probed_choice() -> &'static craft_window::Choice { /// Whether `VECTORCRAFT_TITLEBAR` chose this window's frame (Linux and the BSDs), so Preferences › /// 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. +/// it is set to `app`, `system` or `desktop`. 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. +/// is set to `app`, `system` or `desktop` (`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 } @@ -107,10 +108,10 @@ pub(crate) fn system_title_bar_applies(opted_into_system: bool, from_env: bool) /// ([`craft_window::Frame::Desktop`]) on macOS, Windows, X11 and any Wayland compositor with /// server-side decorations (KDE, Sway, Hyprland, COSMIC); the app draws it /// ([`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 +/// or when `VECTORCRAFT_TITLEBAR` set to `app`, `system` or `desktop` 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 forces the native frame back on unless `VECTORCRAFT_TITLEBAR` is set -/// ([`system_title_bar_applies`]). +/// to one of those values ([`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 @@ -132,13 +133,14 @@ 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, which does not apply while the variable is set. Call once at startup, +/// Title Bar preference, which does not apply while the variable is set to `app`, `system` or +/// `desktop`. 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 /// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native -/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins, +/// frame, the same as a real X11 session. A recognized `VECTORCRAFT_TITLEBAR` override still wins, /// 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(); diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 5de064d6b..03e7dc10e 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -143,38 +143,11 @@ impl Color { } /// HSB with hue in degrees 0..360, saturation and brightness 0..1. pub fn to_hsb(&self) -> [f32; 3] { - let [r, g, b] = self.to_rgb(); - let max = r.max(g).max(b); - let min = r.min(g).min(b); - let d = max - min; - let h = if d <= 0.0 { - 0.0 - } else if max == r { - 60.0 * (((g - b) / d).rem_euclid(6.0)) - } else if max == g { - 60.0 * ((b - r) / d + 2.0) - } else { - 60.0 * ((r - g) / d + 4.0) - }; - // f32 rounding in rem_euclid can yield exactly 360; wrap back to 0. - let h = if h >= 360.0 { 0.0 } else { h }; - let s = if max <= 0.0 { 0.0 } else { d / max }; - [h, s, max] + rgb_to_hsb(self.to_rgb()) } pub fn from_hsb(h: f32, s: f32, v: f32) -> Self { - let h = h.rem_euclid(360.0) / 60.0; - let c = v * s; - let x = c * (1.0 - (h % 2.0 - 1.0).abs()); - let (r, g, b) = match h as u32 { - 0 => (c, x, 0.0), - 1 => (x, c, 0.0), - 2 => (0.0, c, x), - 3 => (0.0, x, c), - 4 => (x, 0.0, c), - _ => (c, 0.0, x), - }; - let m = v - c; - Color::Rgb { r: r + m, g: g + m, b: b + m } + let [r, g, b] = hsb_to_rgb([h, s, v]); + Color::Rgb { r, g, b } } /// `#rrggbb` (display RGB). pub fn to_hex(&self) -> String { @@ -327,6 +300,45 @@ impl Color { } } +/// HSB (hue in degrees 0..360, saturation and brightness 0..1) of RGB numbers 0..1, with no color +/// conversion ([`Color::to_hsb`] reads the display color first). +pub fn rgb_to_hsb([r, g, b]: [f32; 3]) -> [f32; 3] { + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let d = max - min; + let h = if d <= 0.0 { + 0.0 + } else if max == r { + 60.0 * (((g - b) / d).rem_euclid(6.0)) + } else if max == g { + 60.0 * ((b - r) / d + 2.0) + } else { + 60.0 * ((r - g) / d + 4.0) + }; + // f32 rounding in rem_euclid can yield exactly 360; wrap back to 0. + let h = if h >= 360.0 { 0.0 } else { h }; + let s = if max <= 0.0 { 0.0 } else { d / max }; + [h, s, max] +} + +/// RGB numbers 0..1 of HSB `[h, s, v]` (hue in degrees, saturation and brightness 0..1): the inverse +/// of [`rgb_to_hsb`]. +pub fn hsb_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] { + let h = h.rem_euclid(360.0) / 60.0; + let c = v * s; + let x = c * (1.0 - (h % 2.0 - 1.0).abs()); + let (r, g, b) = match h as u32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let m = v - c; + [r + m, g + m, b + m] +} + /// `new` expressed in the colour model of `orig` ([`Color::in_model`]): results of colour operations /// (harmonies, blends, inversions, recolouring) keep the model of the colour they came from. pub fn keep_model(orig: Color, new: Color) -> Color { diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index f099b5f95..14bda9a00 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -128,11 +128,14 @@ fn live_inputs() -> u64 { } /// A live object evaluated by [`live_art`] on this thread: the object, its document's symbols (by -/// name and art) and patterns and the [`LiveInputs`] as they were, the art and its [`art_size`]. +/// name and art), patterns, swatches and color groups and the [`LiveInputs`] as they were, the art +/// and its [`art_size`]. struct EvaluatedLive { node: Node, symbols: Vec<(String, Arc)>, patterns: Vec, + swatches: crate::Shared>, + swatch_groups: crate::Shared>, inputs: u64, art: Rc>, size: usize, @@ -146,6 +149,8 @@ impl EvaluatedLive { && self.symbols.iter().zip(&doc.symbols).all(|((name, art), s)| *name == s.name && Arc::ptr_eq(art, &s.art)) && self.node == *n && self.patterns == doc.patterns + && self.swatches == doc.swatches + && self.swatch_groups == doc.swatch_groups } } @@ -184,8 +189,8 @@ thread_local! { /// The art live object `n` of `doc` draws ([`set_live_art`]); [`crate::live::expand_live`] while /// nothing is registered. The art is evaluated again when `n`, a symbol of `doc`, a pattern of -/// `doc` or the [`LiveInputs`] have changed since, and on every click while it or the document's -/// symbols or patterns hold a placed document ([`holds_placed`]). +/// `doc`, its swatches or color groups, or the [`LiveInputs`] have changed since, and on every +/// click while it or the document's symbols or patterns hold a placed document ([`holds_placed`]). fn live_art(doc: &Document, n: &Node) -> Rc> { let inputs = live_inputs(); let kept = EVALUATED.with_borrow_mut(|kept| { @@ -206,7 +211,16 @@ fn live_art(doc: &Document, n: &Node) -> Rc> { return art; } let symbols = doc.symbols.iter().map(|s| (s.name.clone(), s.art.clone())).collect(); - let e = EvaluatedLive { node: n.clone(), symbols, patterns: doc.patterns.clone(), inputs, art: art.clone(), size }; + let e = EvaluatedLive { + node: n.clone(), + symbols, + patterns: doc.patterns.clone(), + swatches: doc.swatches.clone(), + swatch_groups: doc.swatch_groups.clone(), + inputs, + art: art.clone(), + size, + }; EVALUATED.with_borrow_mut(|kept| { kept.insert(0, e); kept.truncate(LIVE_KEPT); diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index d6f4cafe7..cb5b19d1a 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -605,11 +605,41 @@ fn evaluated_art_is_not_kept_while_a_placed_document_may_show_in_it() { fn a_kept_evaluation_is_stale_after_the_live_inputs_change() { let d = Document::new(100.0, 100.0); let n = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - let e = EvaluatedLive { node: n.clone(), symbols: vec![], patterns: vec![], inputs: 7, art: std::rc::Rc::new(vec![]), size: 0 }; + let e = kept_evaluation(&d, &n); assert!(e.is_of(&d, &n, 7)); assert!(!e.is_of(&d, &n, 8)); } +/// An evaluation of `n` in `d`, with inputs 7, holding no symbols, patterns or art. +fn kept_evaluation(d: &Document, n: &Node) -> EvaluatedLive { + EvaluatedLive { + node: n.clone(), + symbols: vec![], + patterns: vec![], + swatches: d.swatches.clone(), + swatch_groups: d.swatch_groups.clone(), + inputs: 7, + art: std::rc::Rc::new(vec![]), + size: 0, + } +} + +/// A kept evaluation is stale once a swatch changes, in a color group too: a painted live effect's +/// pieces name swatches. +#[test] +fn a_kept_evaluation_is_stale_after_a_swatch_changes() { + let mut d = Document::new(100.0, 100.0); + let n = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let e = kept_evaluation(&d, &n); + assert!(e.is_of(&d, &n, 7)); + d.swatches.first_mut().unwrap().name = "Renamed".into(); + assert!(!e.is_of(&d, &n, 7)); + let mut d = Document::new(100.0, 100.0); + let e = kept_evaluation(&d, &n); + d.swatch_groups.push(vectorcraft_color::SwatchGroup { name: "Added".into(), swatches: vec![] }); + assert!(!e.is_of(&d, &n, 7)); +} + /// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a /// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the /// clipping path and the art. diff --git a/crates/engine/src/cmd/buildcmds/shape_builder.rs b/crates/engine/src/cmd/buildcmds/shape_builder.rs index 5cbcdea97..fea79a0e6 100644 --- a/crates/engine/src/cmd/buildcmds/shape_builder.rs +++ b/crates/engine/src/cmd/buildcmds/shape_builder.rs @@ -1,5 +1,5 @@ use super::{ - Beside, BuilderMap, EngineError, FillRule, Node, NodeId, PathData, Point, Rect, Result, Session, SubPath, Touched, Value, b, bad, check_numbers, + Arc, Beside, BuilderMap, EngineError, FillRule, Node, NodeId, PathData, Point, Rect, Result, Session, Touched, Value, b, bad, check_numbers, f64_or, fill_param, finite, gap_param, ids_json, json, marquee_param, po, points_param, remnant, replace_leaves, replace_with_pieces, root_ids, shape_node, switch_or, }; @@ -41,6 +41,7 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { let gap = gap_param(p); // The map the Shape Builder tool already has, which a click or drag of it is made over. let shared = s.tool.shape_builder_map().filter(|(_, g)| *g == gap); + let prepared = s.tool.shape_builder_merge(); let label = if erase { "Shape Builder Delete" } else { "Shape Builder" }; let (ids, merged, touched, split) = s.edit(label, |d, sel| { let (shapes, leaves) = b::shape_builder_shapes(d, &roots, open_filled_as_closed); @@ -89,16 +90,28 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { touched.add(map.hit(q, Some(tol))); return Ok((ids, None, touched, Some(split))); } - let mut touched = Touched { regions: marquee.map(|(a, b)| map.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() }; - touched.extend(&map.builder_hits_along(&pts, piece_tol)); - touched.edges = map.erased_edges(&touched); + let touched = map.merge_touched(&pts, marquee, piece_tol); if touched.is_empty() { return Err(EngineError::Other("Shape Builder: no region under the pointer".into())); } - let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect(); + let inputs = b::merge_inputs(&leaves.iter().collect::>(), open_filled_as_closed); + // The tool computed this merge on another thread when it would come out the same. + let ready = prepared.as_ref().filter(|m| { + shared.as_ref().is_some_and(|(s, _)| Arc::ptr_eq(s, &m.map) && std::ptr::eq(s.as_ref(), map)) + && m.gap == gap + && m.touched == touched + && m.inputs == inputs + }); + let computed; + let geometry = match ready { + Some(m) => &m.geometry, + None => { + computed = b::merge_geometry(map, &touched, gap, &inputs); + &computed + } + }; let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect(); - let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p)); - let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) }; + let union = &geometry.union; // Pick Color From Artwork: the merged shape takes the look of the front-most object under the // drag's first point, else under its last point, else of the front-most object. Color // Swatches: the current fill, with the rest of the look of the front-most object over the @@ -115,7 +128,7 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { // What goes in place of each object the map was made from (none: it stays as it is). let mut outs: Vec>> = vec![]; let mut merged_id = None; - for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() { + for (i, leaf) in leaves.iter().take(map.shapes.len()).enumerate() { let mut out = None; let pieces: Vec = map.lines.iter().enumerate().filter(|(_, (l, _))| l.key == i as u64).map(|(k, _)| k).collect(); if !pieces.is_empty() { @@ -131,41 +144,15 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { } out = Some(kept); } - } else { + } else if let Some(Some(rest)) = geometry.rests.get(i) { // A shape loses the regions deleted or merged, then the edges deleted (each one on - // its outline, shared edges from every shape along them). - let in_union = picked.iter().any(|r| r.sources.contains(&i)); - let mut rest = if in_union { - // What is left of it: its faces not merged or deleted, united. They share their - // edges with the merged faces exactly, where subtracting the merged shape left - // slivers of the outline along them (#569). A sliver goes in only where the - // shape covers it and the merged shape does not take it in. - let kept: Vec<&po::Region> = map - .regions - .iter() - .enumerate() - .filter(|(k, (r, ..))| r.sources.contains(&i) && !touched.regions.contains(k)) - .map(|(_, (r, ..))| r) - .collect(); - if kept.is_empty() { PathData::default() } else { faces.merge_rest(i, &kept, &picked) } + // its outline, shared edges from every shape along them). What is left of its own + // outline keeps its fill rule; united faces fill with non-zero. + out = Some(if rest.path.is_empty() { + vec![] } else { - b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0) - }; - let fill_closes = open_filled_as_closed && !leaf.appearance.fill_paint().is_none(); - // Edges come back from the planar map within a few hundredths of the outlines they - // follow, whatever else is selected. - let cut_tol = b::outline_snap(&sh.path); - let mut cut = false; - for piece in &cuts { - if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) { - rest = r; - cut = true; - } - } - if in_union || cut { - // What is left of its own outline keeps its fill rule; united faces fill with non-zero. - out = Some(if rest.is_empty() { vec![] } else { vec![remnant(d, leaf, rest, in_union.then_some(FillRule::NonZero))] }); - } + vec![remnant(d, leaf, rest.path.clone(), rest.united.then_some(FillRule::NonZero))] + }); } // The merged shape goes in place of the object it takes its look from, so it stays in // that object's group (a clip group too). diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index d11df03be..b913720f1 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -93,16 +93,17 @@ pub use save::{ remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder, }; +pub(crate) use screens::{ + PARAM_KEYS as SCREEN_PARAM_KEYS, ROW_KEYS as SCREEN_ROW_KEYS, UNWRITTEN_FORMATS as SCREEN_UNWRITTEN_FORMATS, + check_settings as check_screen_settings, export as export_screens, safe_file_name, store_settings as store_screen_settings, + with_export_preset as with_screen_export_preset, +}; pub use screens::{ PRESET_FILE_EXTS as SCREEN_EXPORT_PRESET_EXTS, PRESET_FILE_FORMAT as SCREEN_EXPORT_PRESET_FORMAT, PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, SHARED_KEYS as SCREEN_SETTINGS_KEYS, ScreenExport, ScreenExportPreset, ScreenSize, builtin_presets as screen_export_builtin_presets, first_file as screen_first_file, preset_list as screen_export_preset_list, preset_rows as screen_preset_rows, }; -pub(crate) use screens::{ - check_settings as check_screen_settings, export as export_screens, safe_file_name, store_settings as store_screen_settings, - with_export_preset as with_screen_export_preset, -}; pub use svg::options_map as svg_options; /// Atomic file writes (a temporary file renamed over the target: a failed write never damages the /// file it replaces), for the apps' own writers too. diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 52478ac13..ce7a82d2b 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", always, load::open ), @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, ,