diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index da845ba739..d69da9bae2 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -17,7 +17,7 @@ mod wrap; pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape}; use char_style::yes; -pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, ScriptMetrics}; +pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, ScriptMetrics}; pub use composite::{ COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CharClass, CompositeFont, CompositePart, CompositeParts, diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index fcaa78e494..a880a2a2bc 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -34,6 +34,16 @@ pub struct CharStyle { /// before it whose [`Self::kerning`] could space it. Elsewhere it has no effect. #[serde(default, rename = "kerningBefore", skip_serializing_if = "crate::skip::is_default")] pub kerning_before: f64, + /// Insert Aki Left (Character panel, East Asian options): space added before the character in + /// reading order, in 1/1000 em as [`Self::tracking`], 0 to 1000. Before is on the left in + /// left-to-right type, on the right in right-to-left type and above in vertical type. A + /// cluster takes the value of its first character, and full justification leaves it as it is. + /// + #[serde(default, rename = "akiLeft", skip_serializing_if = "crate::skip::is_default")] + pub aki_left: f64, + /// Insert Aki Right: space added after the character in reading order, as [`Self::aki_left`]. + #[serde(default, rename = "akiRight", skip_serializing_if = "crate::skip::is_default")] + pub aki_right: f64, /// Use glyph shapes for automatic pair spacing; false uses font metrics. #[serde(default, rename = "opticalKerning", skip_serializing_if = "crate::skip::is_default")] pub optical_kerning: bool, @@ -404,6 +414,9 @@ fn hundred() -> f64 { 100.0 } +/// The largest Insert Aki Left or Right ([`CharStyle::aki_left`]), in 1/1000 em: one em. +pub const MAX_AKI: f64 = 1000.0; + pub(super) fn yes() -> bool { true } @@ -420,6 +433,8 @@ impl Default for CharStyle { tracking: 0.0, kerning: None, kerning_before: 0.0, + aki_left: 0.0, + aki_right: 0.0, optical_kerning: false, baseline_shift: 0.0, h_scale: 100.0, @@ -468,7 +483,8 @@ impl CharStyle { /// becomes Auto, and a value past [`crate::MAX_COORD`] (a baseline shift past it either way) /// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a /// baseline shift that is not a number becomes 0. The Tate-chu-yoko Up/Down and Left/Right - /// offsets are kept as the baseline shift is. + /// offsets are kept as the baseline shift is. Insert Aki Left and Right are kept from 0 to + /// [`MAX_AKI`], and one that is not a number becomes 0. pub fn keep_dimensions_in_range(&mut self) { let default = Self::default(); let within = |v: f64, or: f64| if v > 0.0 { v.min(crate::MAX_COORD) } else { or }; @@ -480,6 +496,9 @@ impl CharStyle { self.baseline_shift = shift(self.baseline_shift); self.tcy_up_down = shift(self.tcy_up_down); self.tcy_left_right = shift(self.tcy_left_right); + let aki = |v: f64| if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_AKI) }; + self.aki_left = aki(self.aki_left); + self.aki_right = aki(self.aki_right); } /// Do the characters draw a stroke (a paint and a positive weight)? pub fn has_stroke(&self) -> bool { diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index d46b45b1f7..68f41abd5b 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -490,6 +490,8 @@ impl crate::Document { st.baseline_shift = num("baseline_shift").unwrap_or(st.baseline_shift); st.tcy_up_down = num("tcyUpDown").unwrap_or(st.tcy_up_down); st.tcy_left_right = num("tcyLeftRight").unwrap_or(st.tcy_left_right); + st.aki_left = num("akiLeft").unwrap_or(st.aki_left); + st.aki_right = num("akiRight").unwrap_or(st.aki_right); st.keep_dimensions_in_range(); let kept = [ ("size", Some(st.size)), @@ -498,6 +500,8 @@ impl crate::Document { ("baseline_shift", Some(st.baseline_shift)), ("tcyUpDown", Some(st.tcy_up_down)), ("tcyLeftRight", Some(st.tcy_left_right)), + ("akiLeft", Some(st.aki_left)), + ("akiRight", Some(st.aki_right)), ("leading", st.leading), ]; for (k, v) in kept { diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index e213c252bb..4fcaa8d078 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -357,7 +357,7 @@ impl ParaList { /// The style the marker is set in: `first` (the style of the paragraph's first character) /// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning, - /// tate-chu-yoko, underline and strikethrough. + /// Insert Aki, tate-chu-yoko, underline and strikethrough. pub fn marker_style(first: &CharStyle) -> CharStyle { CharStyle { baseline_shift: 0.0, @@ -366,6 +366,8 @@ impl ParaList { tracking: 0.0, kerning: None, kerning_before: 0.0, + aki_left: 0.0, + aki_right: 0.0, optical_kerning: first.optical_kerning, tate_chu_yoko: false, underline: false, diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index 3b3781f87c..0f474d2ed8 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -92,6 +92,43 @@ fn font_height_reference_is_saved_only_when_not_the_em_box() { assert_eq!(serde_json::from_value::(old).unwrap().font_height_reference, FontHeightReference::EmBox); } +/// Insert Aki Left and Right are 0 by default and saved only when not 0, each side on its own; +/// styles written before them read as 0, and a value out of range from a file is kept from 0 to one +/// em. +#[test] +fn insert_aki_is_saved_only_when_set() { + let st = CharStyle::default(); + assert_eq!((st.aki_left, st.aki_right), (0.0, 0.0)); + let saved = serde_json::to_value(&st).unwrap(); + assert!(saved.get("akiLeft").is_none() && saved.get("akiRight").is_none()); + for set in [ + CharStyle { aki_left: 250.0, ..CharStyle::default() }, + CharStyle { aki_right: 500.0, ..CharStyle::default() }, + CharStyle { aki_left: 125.5, aki_right: 1000.0, ..CharStyle::default() }, + ] { + let saved = serde_json::to_value(&set).unwrap(); + assert_eq!(saved.get("akiLeft").and_then(serde_json::Value::as_f64).unwrap_or(0.0), set.aki_left); + assert_eq!(saved.get("akiRight").and_then(serde_json::Value::as_f64).unwrap_or(0.0), set.aki_right); + assert_eq!(saved.get("akiLeft").is_some(), set.aki_left != 0.0); + assert_eq!(serde_json::from_value::(saved).unwrap(), set); + } + let old = serde_json::to_value(&st).unwrap(); + let read = serde_json::from_value::(old).unwrap(); + assert_eq!((read.aki_left, read.aki_right), (0.0, 0.0)); + let mut damaged = CharStyle { aki_left: -20.0, aki_right: 4000.0, ..CharStyle::default() }; + damaged.keep_dimensions_in_range(); + assert_eq!((damaged.aki_left, damaged.aki_right), (0.0, MAX_AKI)); + let mut damaged = CharStyle { aki_left: f64::NAN, aki_right: f64::INFINITY, ..CharStyle::default() }; + damaged.keep_dimensions_in_range(); + assert_eq!((damaged.aki_left, damaged.aki_right), (0.0, MAX_AKI)); + // A character style from a file is kept in range the same way. + let mut d = crate::Document::new(100.0, 100.0); + let attrs = serde_json::json!({"akiLeft": 5000, "akiRight": -1}); + d.char_styles.push(TextStyleDef { name: "Spaced".into(), attrs: attrs.as_object().cloned().unwrap_or_default() }); + d.keep_type_values_in_range(); + assert_eq!((d.char_styles[0].attrs["akiLeft"].as_f64(), d.char_styles[0].attrs["akiRight"].as_f64()), (Some(MAX_AKI), Some(0.0))); +} + /// Each Indic numeral system draws 0–9 (and the Arabic-Indic sets) as the ten digits of its /// script's block; every system saves under its id, which reads back, and other names are refused. #[test] diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 5b785bee14..602e2d7fe9 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -189,8 +189,8 @@ pub fn effect_catalog() -> Vec { EXTRUDE, "Extrude & Bevel…", &["Effect", "3D and Materials"], - "{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).", - json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0}), + "{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the caps, see threeD.mapGraphic: symbol: name of a document symbol, face: front|back (front; the back reads left to right as seen from behind), x: pt (0; the offset of the graphic's center from the center of the object's bounds, in the face's own plane), y: pt (0), scale: % (1..10000, 100; of the symbol's own size), rotation: degrees (-360..360, 0; counterclockwise); drawn turned, foreshortened and shaded with the cap and clipped to it, on a solid cap only)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).", + json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"graphics":[]}), ), g( INFLATE, @@ -203,8 +203,8 @@ pub fn effect_catalog() -> Vec { ROTATE, "Rotate…", &["Effect", "3D and Materials"], - "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.", - json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0}), + "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the plane, as Extrude & Bevel's, with face front only)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.", + json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"graphics":[]}), ), g( CLASSIC_EXTRUDE, diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index d5f0af894a..89b41df9e7 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -87,15 +87,21 @@ pub use revolve::{ CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in, }; pub use solids::{ - CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, INFLATE, ROTATE, ROTATE_SURFACES, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, - classic_extrude_options, classic_shading, extrude_options, has_solid, inflate_options, is_classic_three_d, is_three_d, solid_art, solid_art_in, - three_d_meshes, three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, three_d_wireframe_in, validate_solid, - validate_solid_in, validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, + CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, GRAPHICS, GraphicFrame, INFLATE, MAX_GRAPHICS, MappedGraphic, ROTATE, ROTATE_SURFACES, + ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, classic_shading, extrude_options, graphic_bounds, + graphic_frame, graphic_symbols, has_solid, inflate_options, is_classic_three_d, is_three_d, mapped_graphics, missing_symbol, + names_graphic_symbol, rename_graphic_symbol, rename_graphic_symbol_in, solid_art, solid_art_in, takes_graphics, three_d_meshes, + three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, three_d_wireframe_in, validate_solid, validate_solid_in, + validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, with_graphic, +}; +pub use solids::{ + GRAPHIC_MAX_OFFSET, GRAPHIC_SCALE_RANGE, appearance_graphic_symbols, graphic_frame_at, graphics_reshaped_after, rename_appearance_graphic_symbol, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; pub use trap::TRAP; pub use vectorcraft_three_d::Mesh; +pub use vectorcraft_three_d::PlanarFace; pub use warp::{WARP_NET, WarpNet, WarpStyle, map_version, warp_point, warp_version}; mod catalog; diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 0006e5362f..6845199b29 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -620,15 +620,17 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let path = geometry(&path, &n.appearance.effects, &ctx); remaining = crate::without_geometry(&remaining); // The object's Transform effects, applied to the path above, transform the items' raster - // effects too. - let mut items: Vec = n.appearance.items.iter().filter(|i| crate::paints(i)).cloned().collect(); + // effects too. Each copy keeps its item beside it, which plug-in effects are told they sit + // on. + let originals: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).collect(); + let mut items: Vec = originals.iter().map(|i| (*i).clone()).collect(); crate::raster::follow_paints(&mut items, &n.appearance.effects); // Every item's shadow goes behind every item's art. let mut shadows = vec![]; - for item in &items { + for (original, item) in originals.into_iter().zip(&items) { if let Some(index) = item.effects().iter().position(|e| e.visible && is_revolve(&e.id)) { let fx = item.effects().get(index)?; - let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); + let p = geometry(&path, item.effects().get(..index)?, &ctx.item(original)); let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); let mut art = object_surface(n, &p, fx, item, expand, &mut copies)?; art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_revolve(&e.id)).cloned().collect(); diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 4ddd453587..e4fb218604 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -6,7 +6,7 @@ use serde_json::Value; use vectorcraft_doc::color::{BlendMode, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData, Rect}; -use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, Mesh, View}; +use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, Mesh, PlanarFace, View}; #[cfg(test)] use vectorcraft_doc::color::Color; @@ -20,6 +20,15 @@ pub use classic::{CLASSIC_EXTRUDE, CLASSIC_ROTATE, ROTATE_SURFACES, SHADING_COLO mod source; pub(crate) use source::{resolve, resolve_for}; +mod graphics; +pub use graphics::{ + GRAPHICS, GraphicFrame, MAX_GRAPHICS, MappedGraphic, graphic_symbols, mapped_graphics, missing_symbol, names_graphic_symbol, + rename_graphic_symbol, rename_graphic_symbol_in, takes_graphics, with_graphic, +}; +pub use graphics::{ + MAX_OFFSET as GRAPHIC_MAX_OFFSET, SCALE_RANGE as GRAPHIC_SCALE_RANGE, appearance_graphic_symbols, rename_appearance_graphic_symbol, +}; + pub(crate) mod shadow; pub(crate) mod wireframe; @@ -70,6 +79,12 @@ pub fn three_d_params_as(from: &str, params: &Value, to: &str) -> Value { } } } + // A plane has only a front, so graphics on the back cap are dropped. + if to == ROTATE + && let Some(Value::Array(list)) = out.get_mut(GRAPHICS) + { + list.retain(|g| g.get("face").and_then(Value::as_str) != Some(PlanarFace::Back.id())); + } Value::Object(out) } @@ -212,6 +227,7 @@ fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> { } crate::lighting::validate(p)?; shadow::validate(p)?; + graphics::validate(id, p)?; if let Some(v) = p.get("bevel") && !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some()) { @@ -241,6 +257,9 @@ pub fn validate_solid_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) /// `item`, whose shadow is cast by that item's solid alone. fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, item: Option) -> Result<(), String> { validate_solid_params(id, p)?; + if let Some(doc) = doc { + graphics::validate_symbols(doc, p)?; + } let label = label_of(id); let n = &*source::resolve_for(doc, n, label, is_plane(id))?; if is_container(n) { @@ -434,6 +453,7 @@ pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> { /// [`validate_three_d`] with the symbols of `doc` ([`validate_solid_in`]). pub fn validate_three_d_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> { + graphics::validate(id, p)?; if crate::revolve::is_revolve(id) { crate::revolve::validate_in(doc, n, id, p) } else { validate_solid_in(doc, n, id, p) } } @@ -449,6 +469,7 @@ pub fn validate_three_d_at(n: &Node, id: &str, p: &Value, at: Option<(usize, usi /// [`validate_three_d_at`] with the symbols of `doc` ([`validate_solid_in`]). A symbol instance, a /// blend and a clipping group take a 3D effect as groups do: on the object itself. pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: Option<(usize, usize)>) -> Result<(), String> { + graphics::validate(id, p)?; match at { Some(_) if is_container(n) || source::resolves(n) => { Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into()) @@ -463,6 +484,7 @@ pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Va /// build the art, for an edit that hides the effect: hidden, it draws nothing, so its object does /// not need to fit. pub fn validate_three_d_params(id: &str, p: &Value) -> Result<(), String> { + graphics::validate(id, p)?; if crate::revolve::is_revolve(id) { crate::revolve::validate_params_of(id, p) } else { validate_solid_params(id, p) } } @@ -712,8 +734,8 @@ type ShadowAndSurfaces = (Vec>, Vec>); /// blending mode, and those of a group it is in, apply to the surfaces of the members it holds /// ([`masked`]). The art comes in two parts: the effect's shadow ([`cast_shadow`], none with it /// off), to be drawn behind every surface of the object, and the surfaces. -fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option { - let art = surfaces_for(n, fx, shapes)?; +fn art_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile]) -> Option { + let art = surfaces_for(doc, n, fx, shapes)?; // A shadow past the limits draws the source path, as a solid past them does; commands report // the error ([`validate_solid_in`]). let shadow = match cast_shadow(&fx.id, &crate::merged_params(&fx.id, &fx.params), shapes).ok()? { @@ -789,8 +811,139 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result = (vectorcraft_three_d::ShadowCaster, vectorcraft_three_d::Shadow, Vec>); -/// [`art_for`] without the shadow: the solids' surfaces. -fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> { +/// The graphics of a 3D effect with parameters `p`, drawn on its faces ([`graphics::face_art`]): +/// for each planar face with a graphic, the art and the face. `caps` are the faces the viewer sees +/// of the ends of the object's solids, each with its end; `bounds` are the bounds the solids were +/// built with; `opacity` is the opacity of the fill or stroke those solids are painted with, which +/// the graphics take too. `elements` is the effect's count of drawn path elements, shared by every +/// call for one effect ([`graphics::face_art`]). +fn graphic_art( + doc: Option<&Document>, + id: NodeId, + (effect, p): (&str, &Value), + bounds: Rect, + caps: &[&Facet], + opacity: f32, + elements: &mut usize, +) -> Vec<(PlanarFace, Arc)> { + let list = graphics::mapped_graphics(p); + if list.is_empty() || !takes_graphics(effect) { + return vec![]; + } + let view = view_options(p); + PlanarFace::ALL + .into_iter() + .filter_map(|face| { + let on: Vec<&Facet> = caps.iter().copied().filter(|f| f.end == Some(face)).collect(); + let map = if effect == ROTATE { + vectorcraft_three_d::plane_face_map(bounds, view) + } else { + vectorcraft_three_d::extrude_face_map(bounds, extrude_options(p), view, face) + }?; + graphics::face_art(doc, id, &list, (face, &map), bounds.center(), &on, elements).map(|mut art| { + art.opacity *= opacity; + (face, Arc::new(art)) + }) + }) + .collect() +} + +/// A solid (a fill's or a stroke's) with ends the viewer sees: the index of its surface, its +/// member, the ends, the opacity and blending mode of its fill or stroke, and whether its surface +/// carries an opacity mask (a gradient's transparency under a colored light, [`surface`]). +struct Filled { + at: usize, + member: usize, + caps: Vec, + opacity: f32, + blend: BlendMode, + masked: bool, +} + +/// Does a surface in `paint` carry an opacity mask ([`surface`])? +fn masks_surface(paint: &Paint, colored: bool) -> bool { + colored && crate::lighting::opaque_paint(paint).1.is_some() +} + +/// The graphics of the solids `filled` ([`graphic_art`]), each with the index of the surface it is +/// drawn right after (the last of those solids holding its face) and its face. With `per_member` +/// (a member's opacity mask or blending mode applies to its surfaces) each member's solids get +/// graphics of their own, clipped to that member's caps, so they stay in that member's scopes. +/// All the groups share one limit on the path elements drawn: once it is reached, later groups +/// draw no graphics. +fn placed_graphics( + doc: Option<&Document>, + id: NodeId, + effect: (&str, &Value), + bounds: Rect, + filled: &[Filled], + per_member: bool, +) -> Vec<(usize, PlanarFace, Arc)> { + let mut groups: Vec> = vec![]; + for f in filled { + // Solids whose fills or strokes differ in opacity or blending mode get graphics of their + // own, each drawn as its fill or stroke is. A solid whose surface carries an opacity mask + // gets its own too, which takes that mask ([`with_graphics`]). + let joins = |first: &Filled| { + (!per_member || first.member == f.member) && first.opacity == f.opacity && first.blend == f.blend && !first.masked && !f.masked + }; + match groups.iter_mut().find(|g| g.first().is_some_and(|first| joins(first))) { + Some(g) => g.push(f), + None => groups.push(vec![f]), + } + } + let mut out = vec![]; + let mut elements = 0usize; + for group in groups { + let caps: Vec<&Facet> = group.iter().flat_map(|f| &f.caps).collect(); + let opacity = group.first().map_or(1.0, |f| f.opacity); + let blend = group.first().map_or(BlendMode::Normal, |f| f.blend); + for (face, mut art) in graphic_art(doc, id, effect, bounds, &caps, opacity, &mut elements) { + if let Some(f) = group.iter().rev().find(|f| f.caps.iter().any(|c| c.end == Some(face))) { + Arc::make_mut(&mut art).blend = blend; + out.push((f.at, face, art)); + } + } + } + out +} + +/// One solid's surface as [`surfaces_for`] builds it: its faces' depths, its art, its member's +/// scopes, and for each face the end of a solid it is, when the viewer sees it. +type BuiltSurface<'s> = (Vec, Node, &'s [Scope], Vec>); + +/// [`with_graphics`] of built surfaces, each drawn whole. +fn whole_surfaces<'s>(surfaces: Vec>, arts: &[(usize, PlanarFace, Arc)]) -> Vec<(&'s [Scope], Arc)> { + with_graphics(surfaces.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), arts) +} + +/// `surfaces` drawn whole, each with its scopes, and the graphics `arts` ([`placed_graphics`]) each +/// right after its surface, with that surface's scopes and its opacity mask (a gradient's +/// transparency), so a graphic is hidden where its cap is. +fn with_graphics<'s>(surfaces: Vec<(&'s [Scope], Arc)>, arts: &[(usize, PlanarFace, Arc)]) -> Vec<(&'s [Scope], Arc)> { + let mut out = Vec::with_capacity(surfaces.len() + arts.len()); + for (k, (scopes, s)) in surfaces.into_iter().enumerate() { + let mask = s.extras().mask.clone(); + out.push((scopes, s)); + out.extend(arts.iter().filter(|(at, ..)| *at == k).map(|(.., art)| { + let mut art = art.clone(); + if let Some(mask) = &mask { + Arc::make_mut(&mut art).extras_mut().mask = Some(mask.clone()); + } + (scopes, art) + })); + } + out +} + +/// The ends of `f` the viewer sees, for a fill's solid or a stroke's: where its graphics go. +fn seen_ends(f: &[Facet]) -> impl Iterator { + f.iter().filter(|f| f.end.is_some() && !f.away) +} + +/// [`art_for`] without the shadow: the solids' surfaces, with the graphics mapped onto their planar +/// faces drawn right after the last of the solids' surfaces that holds that face. +fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> { let b = union_bounds(shapes)?; let p = crate::merged_params(&fx.id, &fx.params); let classic = fx.id == CLASSIC_EXTRUDE || fx.id == CLASSIC_ROTATE; @@ -798,11 +951,14 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option Option = seen_ends(&f).cloned().collect(); + if !caps.is_empty() { + filled.push(Filled { at: out.len(), member, caps, opacity, blend, masked: masks_surface(&paint, colored) }); + } + } let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) }; out.push((s.scopes.as_slice(), Arc::new(art))); } } + let arts = placed_graphics(doc, n.id, (ROTATE, &p), b, &filled, masks); + let out = with_graphics(out, &arts); return Some(if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() }); } let (parts, bounds) = solid_parts(shapes, b); @@ -845,36 +1009,56 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option= 1.0 && blend == BlendMode::Normal }); - let surfaces: Vec<(Vec, Node, &[Scope])> = solids + let arts = if !graphics_on { + vec![] + } else { + let filled: Vec = solids + .iter() + .enumerate() + .filter_map(|(at, (item, member, _, f, shape))| { + let caps: Vec = seen_ends(f).cloned().collect(); + let (paint, opacity, blend) = paint_of(item, *member); + let masked = masks_surface(&paint, colored); + (!caps.is_empty()).then_some(Filled { at, member: *shape, caps, opacity, blend, masked }) + }) + .collect(); + placed_graphics(doc, n.id, (&fx.id, &p), bounds, &filled, masks) + }; + let surfaces: Vec = solids .into_iter() .map(|(item, member, scopes, f, shape)| { let closed = shapes.get(shape).is_some_and(|s| s.path.subpaths.iter().all(|p| p.closed)); let paints = Paints::of(item, closed); let depths: Vec = f.iter().map(|f| Placed::of(f, shape, paints)).collect(); + let ends = f.iter().map(|f| f.end.filter(|_| !f.away)).collect(); let (paint, opacity, blend) = paint_of(item, member); let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) }; - (depths, art, scopes) + (depths, art, scopes, ends) }) .collect(); if masks { - return Some(masked(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect())); + return Some(masked(n.id, whole_surfaces(surfaces, &arts))); } // One order takes a plain group of faces from each solid, one face for each facet. With // colored lights a surface can instead be one clipped composite of all its facets. A composite // has no faces to order, so when any surface is one, every surface of the solid, masked ones // included, draws whole in member order. - let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); - if !opaque || !plain { - return Some(surfaces.into_iter().map(|(_, s, _)| Arc::new(s)).collect()); - } + let plain = + surfaces.iter().all(|(depths, s, _, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); // A surface in a gradient with transparent stops carries an opacity mask: its faces go in runs - // ([`in_runs`]), and each run carries the mask. - let masks: Vec>> = surfaces.iter().map(|(_, s, _)| s.extras().mask.clone()).collect(); - let (mut placed, mut faces) = (vec![], vec![]); - for (surface, (depths, s, _)) in surfaces.into_iter().enumerate() { + // ([`in_runs`]), and each run carries the mask. With graphics as well, the surfaces draw whole, + // each graphic right after its surface. + let masks: Vec>> = surfaces.iter().map(|(_, s, _, _)| s.extras().mask.clone()).collect(); + let masked_faces = masks.iter().any(Option::is_some); + if !opaque || !plain || (masked_faces && !arts.is_empty()) { + return Some(whole_surfaces(surfaces, &arts).into_iter().map(|(_, s)| s).collect()); + } + let (mut placed, mut faces, mut ends) = (vec![], vec![], vec![]); + for (surface, (depths, s, _, e)) in surfaces.into_iter().enumerate() { if let NodeKind::Group { children, .. } = s.kind { placed.extend(depths); faces.extend(children.into_iter().map(|face| Some((surface, face)))); + ends.extend(e); } } let sealed = match fx.id.as_str() { @@ -883,8 +1067,18 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option extrude_options(&p).cap, }; let order = draw_order(&placed, sealed); - let faces: Vec<(usize, Arc)> = order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect(); - let children = if masks.iter().all(Option::is_none) { faces.into_iter().map(|(_, face)| face).collect() } else { in_runs(n.id, faces, &masks) }; + if masked_faces { + let faces: Vec<(usize, Arc)> = order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect(); + return Some(vec![Arc::new(Node::group(n.id, in_runs(n.id, faces, &masks)))]); + } + // Each face's graphics go right after the last of the faces they are drawn on. + let last: Vec> = + arts.iter().map(|(_, face, _)| order.iter().rposition(|k| ends.get(*k).copied().flatten() == Some(*face))).collect(); + let mut children = Vec::with_capacity(order.len() + arts.len()); + for (at, k) in order.into_iter().enumerate() { + children.extend(faces.get_mut(k).and_then(Option::take).map(|(_, face)| face)); + children.extend(last.iter().zip(&arts).filter(|(l, _)| **l == Some(at)).map(|(_, (.., art))| art.clone())); + } Some(vec![Arc::new(Node::group(n.id, children))]) } @@ -969,7 +1163,7 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { } let n = &*resolve(doc, n, "").ok()?; if is_container(n) { - return group_solid_art(n); + return group_solid_art(doc, n); } let (path, rule) = profile(n)?; let ctx = GeomContext::of(n); @@ -978,7 +1172,7 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { let remaining: Vec; if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); - let (shadow, surfaces) = art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?; + let (shadow, surfaces) = art_for(doc, n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?; children.extend(shadow.into_iter().chain(surfaces)); // Later geometry effects act on the projected art as a whole. remaining = past_effect(&n.appearance.effects, index); @@ -994,7 +1188,7 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { for item in &paints { if let Some((index, fx)) = solid(item.effects()) { let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); - let (shadow, surfaces) = art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?; + let (shadow, surfaces) = art_for(doc, n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?; let later: Vec = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect(); let with_later = |mut art: Arc| { Arc::make_mut(&mut art).appearance.effects = later.clone(); @@ -1018,11 +1212,11 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { /// geometry effects before it reshape the members, those after it bend the result as a whole; the /// group's own fills and strokes are not drawn. `None` when the effect is only on one of the /// group's own fills or strokes, or when its members can't become solids. -fn group_solid_art(n: &Node) -> Option { +fn group_solid_art(doc: Option<&Document>, n: &Node) -> Option { let (index, fx) = solid(&n.appearance.effects)?; let mut shapes = members(n, "").ok()?; reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); - let (shadow, surfaces) = art_for(n, fx, &shapes)?; + let (shadow, surfaces) = art_for(doc, n, fx, &shapes)?; let children = shadow.into_iter().chain(surfaces).collect(); let remaining = past_effect(&n.appearance.effects, index); let mut art = n.clone(); @@ -1031,6 +1225,77 @@ fn group_solid_art(n: &Node) -> Option { Some(art) } +/// Where the graphics of the 3D effect of `n` (on the object itself with `item` `None`, else on its +/// fill or stroke `item`) are drawn: the bounds its solids are built with and the maps of their +/// planar faces ([`graphics::GraphicFrame`]). `None` when `n` has no visible Extrude & Bevel or +/// Rotate there, or its art can't be built. +pub fn graphic_frame(doc: Option<&Document>, n: &Node, item: Option) -> Option { + frame_of(doc, n, item, &solid) +} + +/// [`graphic_frame`] of the 3D effect at `index` in that list, which must be a visible Extrude & +/// Bevel or Rotate. +pub fn graphic_frame_at(doc: Option<&Document>, n: &Node, item: Option, index: usize) -> Option { + frame_of(doc, n, item, &|fx: &[Effect]| fx.get(index).filter(|e| e.visible && SOLIDS.contains(&e.id.as_str())).map(|e| (index, e))) +} + +/// Does a visible geometry effect come after the 3D effect at `index` in the effects of `n` (on the +/// object itself with `item` `None`, else on its fill or stroke `item`)? Such an effect moves or +/// bends the drawn art, graphics included, while [`graphic_frame_at`] places the graphics on the +/// art before it. +pub fn graphics_reshaped_after(n: &Node, item: Option, index: usize) -> bool { + n.appearance.effects_at(item).and_then(|fx| fx.get(index + 1..)).is_some_and(crate::has_geometry) +} + +/// Finds a 3D effect in a list of effects, with its index there. +type Pick<'p> = dyn for<'e> Fn(&'e [Effect]) -> Option<(usize, &'e Effect)> + 'p; + +/// [`graphic_frame`] of the 3D effect `pick` finds in the effects of `n` or its `item`. +fn frame_of(doc: Option<&Document>, n: &Node, item: Option, pick: &Pick<'_>) -> Option { + let n = &*resolve(doc, n, "").ok()?; + let (fx, shapes) = if is_container(n) { + let (index, fx) = pick(&n.appearance.effects).filter(|_| item.is_none())?; + let mut shapes = members(n, "").ok()?; + reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); + (fx, shapes) + } else { + let (path, rule) = profile(n)?; + let ctx = GeomContext::of(n); + match item { + None => { + let (index, fx) = pick(&n.appearance.effects)?; + let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); + (fx, vec![Profile { path, rule, items: solid_paints(n), opacity: 1.0, scopes: vec![] }]) + } + Some(k) => { + let it = n.appearance.items.get(k)?; + let (index, fx) = pick(it.effects())?; + let path = geometry(&geometry(&path, &n.appearance.effects, &ctx), it.effects().get(..index)?, &ctx.item(it)); + (fx, vec![Profile { path, rule, items: vec![it.clone()], opacity: 1.0, scopes: vec![] }]) + } + } + }; + if !takes_graphics(&fx.id) { + return None; + } + let p = crate::merged_params(&fx.id, &fx.params); + let b = union_bounds(&shapes)?; + let view = view_options(&p); + if fx.id == ROTATE { + let plane = vectorcraft_three_d::plane_face_map(b, view); + return Some(graphics::GraphicFrame::new(b, plane, None)); + } + let (_, bounds) = solid_parts(&shapes, b); + let options = extrude_options(&p); + let map = |face| vectorcraft_three_d::extrude_face_map(bounds, options, view, face); + Some(graphics::GraphicFrame::new(bounds, map(PlanarFace::Front), map(PlanarFace::Back))) +} + +/// The bounds of the art symbol `name` of `doc` draws as a graphic, in the symbol's coordinates. +pub fn graphic_bounds(doc: &Document, name: &str) -> Option { + graphics::art_bounds(doc, name) +} + /// `effects` without the 3D effect at `index` and the geometry effects before it, which shape the /// art it projects. Each raster effect before it follows the Transform effects among those /// ([`crate::without_geometry`]), unless a Transform effect after it makes copies or moves at diff --git a/crates/effects/src/solids/graphics.rs b/crates/effects/src/solids/graphics.rs new file mode 100644 index 0000000000..6c5d8686a7 --- /dev/null +++ b/crates/effects/src/solids/graphics.rs @@ -0,0 +1,631 @@ +//! Graphics mapped onto a 3D object's planar faces (Window › 3D and Materials › Materials › +//! Graphics): the art of a document symbol drawn on Extrude & Bevel's front or back cap, or on a +//! plane, turned, foreshortened and shaded with that face and clipped to it. +//! +//! The effect keeps its graphics in its `graphics` parameter, each `{symbol, face, x, y, scale, +//! rotation}`: the symbol's name, the face, the offset of the graphic's center from the center of +//! the object's bounds in the face's own plane (points, x to the right and y down as the face is +//! seen from the front of that face), its size as a share of the symbol's own, and its turn +//! (degrees, counterclockwise). On the back cap, x runs to the right as the back is seen from +//! behind, so the graphic reads the right way round there. + +use std::sync::Arc; + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::color::{BlendMode, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol}; +use vectorcraft_geom::kurbo::{CubicBez, ParamCurve, PathEl, Shape}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, SubPath}; +use vectorcraft_three_d::{FaceMap, Facet, PlanarFace}; + +/// The effect parameter that holds the graphics. +pub const GRAPHICS: &str = "graphics"; + +/// The most graphics one 3D effect maps. +pub const MAX_GRAPHICS: usize = 64; + +/// The most fills and stroke outlines the art of one symbol draws as a graphic. +const MAX_PIECES: usize = 4096; + +/// The most path elements the graphics of one 3D effect draw together, after a perspective cuts +/// their curves. +const MAX_ELEMENTS: usize = 400_000; + +/// The pieces each curve of a graphic is cut into under a perspective map, which bends it. +const PERSPECTIVE_PIECES: usize = 8; + +/// How deep symbol instances in a graphic's art may nest. +const MAX_NESTING: usize = 8; + +/// How deep the objects of a graphic's art may be, counting groups and the art of the symbols it +/// places: as deep as an object may be drawn. +const MAX_LEVELS: usize = 4 * vectorcraft_doc::MAX_DEPTH; + +/// The size range of a graphic, as a percentage of its symbol's own size. +pub const SCALE_RANGE: (f64, f64) = (1.0, 10_000.0); + +/// How far a graphic's center may be from the center of its object, in points. +pub const MAX_OFFSET: f64 = 1e6; + +/// One graphic mapped onto a face of a 3D object. +#[derive(Clone, Debug, PartialEq)] +pub struct MappedGraphic { + pub symbol: String, + pub face: PlanarFace, + pub x: f64, + pub y: f64, + /// Percent of the symbol's own size. + pub scale: f64, + /// Degrees, counterclockwise. + pub rotation: f64, +} + +impl MappedGraphic { + pub fn to_value(&self) -> Value { + json!({"symbol": self.symbol, "face": self.face.id(), "x": self.x, "y": self.y, "scale": self.scale, "rotation": self.rotation}) + } + + /// The graphic `v` describes. Only `symbol` is required: `face` defaults to front, the offsets + /// and the rotation to 0 and the scale to 100. + pub fn from_value(v: &Value) -> Result { + let Value::Object(m) = v else { return Err(format!("each of `{GRAPHICS}` must be an object")) }; + if let Some(k) = m.keys().find(|k| !matches!(k.as_str(), "symbol" | "face" | "x" | "y" | "scale" | "rotation")) { + return Err(format!("`{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}`")); + } + let symbol = match m.get("symbol") { + Some(Value::String(s)) if !s.trim().is_empty() => s.clone(), + _ => return Err(format!("each of `{GRAPHICS}` needs `symbol`, the name of a symbol")), + }; + let face = match m.get("face") { + None => PlanarFace::Front, + Some(v) => v.as_str().and_then(PlanarFace::parse).ok_or_else(|| format!("`{GRAPHICS}` face must be front or back"))?, + }; + let number = |key: &str, default: f64, (lo, hi): (f64, f64)| -> Result { + match m.get(key) { + None => Ok(default), + Some(v) => v + .as_f64() + .filter(|n| n.is_finite() && (lo..=hi).contains(n)) + .ok_or_else(|| format!("`{GRAPHICS}` {key} must be a number from {lo} to {hi}")), + } + }; + Ok(Self { + symbol, + face, + x: number("x", 0.0, (-MAX_OFFSET, MAX_OFFSET))?, + y: number("y", 0.0, (-MAX_OFFSET, MAX_OFFSET))?, + scale: number("scale", 100.0, SCALE_RANGE)?, + rotation: number("rotation", 0.0, (-360.0, 360.0))?, + }) + } + + /// Where the graphic's art goes in the face's plane, for symbol art whose bounds are `art` on an + /// object whose bounds have center `center`. + fn placement(&self, art: Rect, center: Point) -> Affine { + let place = Affine::translate((center.x + self.x, center.y + self.y)) + * Affine::rotate(-self.rotation.to_radians()) + * Affine::scale(self.scale / 100.0) + * Affine::translate(-art.center().to_vec2()); + match self.face { + PlanarFace::Front => place, + // Seen from behind, the back's right is the front's left. + PlanarFace::Back => { + Affine::translate(center.to_vec2()) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate(-center.to_vec2()) * place + } + } + } +} + +/// The 3D effects that take graphics: Extrude & Bevel (its caps) and Rotate (the plane), the two +/// object types of the 3D and Materials panel with planar faces. +pub fn takes_graphics(id: &str) -> bool { + id == super::EXTRUDE || id == super::ROTATE +} + +/// The graphics in parameters `p`, leaving out entries that are not valid (a file may hold +/// anything; commands check every entry with [`validate`]). +pub fn mapped_graphics(p: &Value) -> Vec { + match p.get(GRAPHICS) { + Some(Value::Array(list)) => list.iter().take(MAX_GRAPHICS).filter_map(|v| MappedGraphic::from_value(v).ok()).collect(), + _ => vec![], + } +} + +/// Are the graphics in parameters `p` of 3D effect `id` valid? Only Extrude & Bevel and Rotate take +/// graphics, and a plane has only a front. +pub fn validate(id: &str, p: &Value) -> Result<(), String> { + let list = match p.get(GRAPHICS) { + None => return Ok(()), + Some(Value::Array(list)) => list, + Some(_) => return Err(format!("`{GRAPHICS}` must be an array")), + }; + if list.is_empty() { + return Ok(()); + } + if !takes_graphics(id) { + return Err("Graphics map onto planar faces: use the Extrude or Plane object type".into()); + } + if list.len() > MAX_GRAPHICS { + return Err(format!("`{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics")); + } + for v in list { + let g = MappedGraphic::from_value(v)?; + if id == super::ROTATE && g.face == PlanarFace::Back { + return Err("A plane has one face: map its graphics onto the front".into()); + } + } + Ok(()) +} + +/// The first symbol the graphics in `p` name that `doc` does not have, leaving out the symbols the +/// graphics in `old` (the effect's parameters before an edit) already name: a graphic whose symbol +/// was deleted may stay. +pub fn missing_symbol(doc: &Document, p: &Value, old: Option<&Value>) -> Option { + let kept = old.map(mapped_graphics).unwrap_or_default(); + mapped_graphics(p) + .into_iter() + .map(|g| g.symbol) + .find(|name| !doc.symbols.iter().any(|s| s.name == *name) && !kept.iter().any(|g| g.symbol == *name)) +} + +/// Does the art of the symbols the graphics in `p` name fit the limits? A graphic whose symbol +/// `doc` does not have draws nothing and is not counted. With a perspective, each curve counts as +/// the [`PERSPECTIVE_PIECES`] pieces it is cut into; without one, it counts once. +pub fn validate_symbols(doc: &Document, p: &Value) -> Result<(), String> { + let curve = if super::view_options(p).perspective > 0.0 { PERSPECTIVE_PIECES } else { 1 }; + let mut elements = 0usize; + for g in mapped_graphics(p) { + let Some(symbol) = doc.symbols.iter().find(|s| s.name == g.symbol) else { continue }; + let pieces = pieces_of(doc, symbol)?; + elements = elements.saturating_add(pieces.iter().map(|p| drawn_elements(&p.path, curve)).sum()); + if elements > MAX_ELEMENTS { + return Err("The graphics are too complex: simplify the symbols' art".into()); + } + } + Ok(()) +} + +/// The path elements `path` draws when each curve draws `curve` elements, before curves near the +/// eye are split further: one for each straight line, move and close. +fn drawn_elements(path: &PathData, curve: usize) -> usize { + let segments = |s: &SubPath| (0..s.segment_count()).map(|i| if s.segment_is_line(i) { 1 } else { curve }).sum::(); + path.subpaths.iter().map(|s| 1 + usize::from(s.closed) + segments(s)).sum() +} + +/// One area of a graphic's art, drawn with one paint: a fill, or a stroke's outline. +struct Piece { + path: PathData, + rule: FillRule, + paint: Paint, + opacity: f32, + blend: BlendMode, +} + +/// The areas the art of `symbol` paints, back to front, at the symbol's natural size: the fills of +/// its paths, compound paths and type (as outlines), and its strokes' outlines, through the symbol +/// instances it holds (with their changes to dynamic symbols, and through 9-slice guides). Hidden objects, guides, clipping paths and images are left out, and clipping +/// groups draw their art unclipped. +fn pieces_of(doc: &Document, symbol: &Symbol) -> Result, String> { + fn walk(doc: &Document, n: &Node, outer: f32, xf: Affine, (depth, level): (usize, usize), out: &mut Vec) -> Result<(), String> { + if !n.visible || n.is_guide() { + return Ok(()); + } + if level > MAX_LEVELS { + return Err(format!("A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art")); + } + let opacity = outer * n.opacity; + let shape = match &n.kind { + NodeKind::Path { path, rule, clipping: false, .. } => Some((path.clone(), *rule)), + NodeKind::Compound { children, rule } => { + Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule)) + } + NodeKind::Text(_) => { + if let Some(mut outlined) = crate::outline_text(n) { + // The outlines take the type's place, with its opacity. + outlined.visible = true; + outlined.opacity = n.opacity; + walk(doc, &outlined, outer, xf, (depth, level + 1), out)?; + } + None + } + NodeKind::SymbolInstance { symbol, xf: at } => { + if let Some(s) = doc.symbols.iter().find(|s| s.name == *symbol) { + if depth >= MAX_NESTING { + return Err(format!("A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art")); + } + // The instance shows its own changes to a dynamic symbol's art, and a symbol + // with 9-slice guides is placed through them, as the canvas draws it. The + // pieces are not clipped, so paths that cross a guide are cut at it. + let art = vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n); + if doc.symbol_slice_map(symbol, *at).is_some() { + let Some(placed) = doc.place_symbol_art_unclipped(symbol, &art, *at) else { + return Err("A graphic's art holds too many objects: simplify the symbol's art".into()); + }; + walk(doc, &placed, opacity, xf, (depth + 1, level + 1), out)?; + } else { + walk(doc, &art, opacity, xf * *at, (depth + 1, level + 1), out)?; + } + } + None + } + _ => { + for c in n.children().into_iter().flatten() { + walk(doc, c, opacity, xf, (depth, level + 1), out)?; + } + None + } + }; + let Some((path, rule)) = shape else { return Ok(()) }; + // Symbol art keeps its strokes' widths as drawn while its geometry is scaled to the + // symbol's square, so a stroke is outlined after the art takes its size. + let path = path.transformed(xf); + for item in n.appearance.items.iter().filter(|i| i.visible()) { + let (area, rule, paint, item_opacity, blend) = match item { + AppearanceItem::Fill(f) => (path.clone(), rule, f.paint.clone(), f.opacity, f.blend), + AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => { + (crate::stroke::outline_region(&path, rule, s), FillRule::EvenOdd, s.paint.clone(), s.opacity, s.blend) + } + AppearanceItem::Stroke(_) => continue, + }; + if matches!(paint, Paint::None) || area.bounds().is_none() { + continue; + } + if out.len() >= MAX_PIECES { + return Err(format!("A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art")); + } + // A placed gradient or pattern takes the same size and place as its path. + let mut paint = paint; + match &mut paint { + Paint::Gradient(g) => g.transform(xf), + Paint::Pattern { xf: placed, .. } => *placed = xf * *placed, + Paint::None | Paint::Solid { .. } => {} + } + out.push(Piece { path: area, rule, paint, opacity: opacity * item_opacity, blend }); + } + Ok(()) + } + let mut out = vec![]; + walk(doc, &symbol.art, 1.0, doc.symbol_natural_xf(&symbol.name), (0, 1), &mut out)?; + Ok(out) +} + +/// `path` through `map`: exactly for an affine map, otherwise with each curve cut into +/// [`PERSPECTIVE_PIECES`] pieces whose control points go through it (straight lines stay straight +/// under a perspective). Fails when part of it has no page point, or when it takes more than `room` +/// elements. +fn map_path(path: &PathData, map: &FaceMap, affine: Option, room: usize) -> Result { + let bp = path.to_bezpath(); + if let Some(a) = affine { + return if bp.elements().len() <= room { Ok(a * bp) } else { Err(NotMapped::Full) }; + } + let to = |p: Point| map.to_page(p).ok_or(NotMapped::Behind); + let mut out = BezPath::new(); + let mut at = Point::ZERO; + for el in bp.elements() { + if out.elements().len() > room { + return Err(NotMapped::Full); + } + match *el { + PathEl::MoveTo(p) => { + out.move_to(to(p)?); + at = p; + } + PathEl::LineTo(p) => { + out.line_to(to(p)?); + at = p; + } + PathEl::QuadTo(c, p) => { + cubic_to(&mut out, vectorcraft_geom::kurbo::QuadBez::new(at, c, p).raise(), map).ok_or(NotMapped::Behind)?; + at = p; + } + PathEl::CurveTo(c1, c2, p) => { + cubic_to(&mut out, CubicBez::new(at, c1, c2, p), map).ok_or(NotMapped::Behind)?; + at = p; + } + PathEl::ClosePath => out.close_path(), + } + } + if out.elements().len() <= room { Ok(out) } else { Err(NotMapped::Full) } +} + +/// Why [`map_path`] drew nothing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum NotMapped { + /// Part of the path has no page point: it is at or behind the eye. + Behind, + /// The path takes more elements than it has room for. + Full, +} + +/// Curve `c` through `map`, cut into [`PERSPECTIVE_PIECES`] pieces. A piece that lies in front of +/// the eye while a control point does not is split further ([`piece_to`]). `None` when part of a +/// piece has no page point. +fn cubic_to(out: &mut BezPath, c: CubicBez, map: &FaceMap) -> Option<()> { + for k in 0..PERSPECTIVE_PIECES { + let (t0, t1) = (k as f64 / PERSPECTIVE_PIECES as f64, (k + 1) as f64 / PERSPECTIVE_PIECES as f64); + let s = c.subsegment(t0..t1); + if !map.maps_curve([s.p0, s.p1, s.p2, s.p3]) { + return None; + } + piece_to(out, s, map, MAX_SPLITS)?; + } + Some(()) +} + +/// How many times [`piece_to`] halves a piece whose control points are behind the eye. +const MAX_SPLITS: usize = 4; + +/// Piece `s`, which lies in front of the eye, through `map`: halved until the control points of +/// each half have page points, at most `splits` times, and drawn straight past that. +fn piece_to(out: &mut BezPath, s: CubicBez, map: &FaceMap, splits: usize) -> Option<()> { + let end = map.to_page(s.p3)?; + match (map.to_page(s.p1), map.to_page(s.p2)) { + (Some(c1), Some(c2)) => out.curve_to(c1, c2, end), + _ if splits == 0 => out.line_to(end), + _ => { + let (a, b) = s.subdivide(); + piece_to(out, a, map, splits - 1)?; + piece_to(out, b, map, splits - 1)?; + } + } + Some(()) +} + +/// The graphics `graphics` lists for `face`, drawn on caps `caps` (the faces of that end of the +/// object's solids that the viewer sees) through `map`, for an object whose bounds have +/// center `center`: one clipping group of every such graphic, shaded with the first cap's light. +/// Pattern tiles keep their colors: the document's pattern draws them. A graphic whose symbol +/// `doc` does not have draws nothing. `elements` counts the path elements +/// the effect's graphics have drawn so far, after a perspective cuts their curves, and is shared by +/// every call for one effect. `None` when there is nothing to draw or that count passes +/// [`MAX_ELEMENTS`]. +pub(super) fn face_art( + doc: Option<&Document>, + id: NodeId, + graphics: &[MappedGraphic], + (face, map): (PlanarFace, &FaceMap), + center: Point, + caps: &[&Facet], + elements: &mut usize, +) -> Option { + let doc = doc?; + let light = caps.first()?.illumination; + let affine = map.local_affine(center).filter(|_| map.is_affine()); + let mut children = vec![]; + for g in graphics.iter().filter(|g| g.face == face) { + let Some(symbol) = doc.symbols.iter().find(|s| s.name == g.symbol) else { continue }; + // A symbol past the limits is left out; the other graphics still draw. + let Ok(pieces) = pieces_of(doc, symbol) else { continue }; + let Some(bounds) = pieces.iter().filter_map(|p| p.path.bounds()).reduce(|a, b| a.union(b)) else { continue }; + let place = g.placement(bounds, center); + // Gradients follow the face's map as it is near the graphic's center. + let middle = place * bounds.center(); + // When the center is at or behind the eye, they follow it near the middle of the part that + // is in front; a graphic with no part in front is left out. + let local = map.local_affine(middle).or_else(|| { + let part = map.mapped_part(place.transform_rect_bbox(bounds))?; + let sum = part.iter().fold(Point::ZERO, |s, p| s + p.to_vec2()); + map.local_affine(Point::new(sum.x / part.len() as f64, sum.y / part.len() as f64)) + }); + let Some(local) = local else { continue }; + let gradient_map = local * place; + for piece in pieces { + let placed = piece.path.transformed(place); + // Each anchor draws at least one element. A perspective cuts curves into more, and + // straight lines stay one each: the path may take what is left of the limit, and is + // counted as it is drawn. Past the limit, the face draws nothing. + let least: usize = placed.subpaths.iter().map(|s| s.anchors.len()).sum(); + let before = *elements; + if before.saturating_add(least) > MAX_ELEMENTS { + return None; + } + let room = MAX_ELEMENTS - before; + let mut rule = piece.rule; + let mapped = match map_path(&placed, map, affine, room) { + Err(NotMapped::Behind) => { + // Part of it is at or behind the eye: the part in front is drawn. + let Some(part) = placed.bounds().and_then(|b| map.mapped_part(b)) else { continue }; + let Some(&first) = part.first() else { continue }; + let mut front = BezPath::new(); + front.move_to(first); + part.iter().skip(1).for_each(|p| front.line_to(*p)); + front.close_path(); + let cut = vectorcraft_pathops::boolean( + &placed, + piece.rule, + &PathData::from_bezpath(&front), + FillRule::NonZero, + vectorcraft_pathops::BoolOp::Intersect, + ); + rule = FillRule::NonZero; + map_path(&cut, map, affine, room) + } + done => done, + }; + let mapped = match mapped { + Ok(mapped) => mapped, + Err(NotMapped::Full) => return None, + Err(NotMapped::Behind) => continue, + }; + *elements = before.saturating_add(mapped.elements().len()); + let mut paint = piece.paint; + vectorcraft_doc::recolor::map_paint(&mut paint, &|c| crate::lighting::shade(c, light)); + // A pattern's tiles follow the graphic onto the face, as its gradients do below. + if let Paint::Pattern { xf, .. } = &mut paint { + *xf = gradient_map * *xf; + } + let mut fill = FillLayer::new(paint); + fill.opacity = piece.opacity; + fill.blend = piece.blend; + let mut appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Default::default() }; + appearance.transform_gradients(gradient_map); + let mut node = Node::path(id, PathData::from_bezpath(&mapped), appearance); + if let NodeKind::Path { rule: r, .. } = &mut node.kind { + *r = rule; + } + children.push(Arc::new(node)); + } + } + if children.is_empty() { + return None; + } + let mut clip = BezPath::new(); + for cap in caps { + // A cap's holes are contours of the same path wound against its outline, so turning the + // whole path keeps them holes under the nonzero rule. + let mut outline = cap.path().to_bezpath(); + if outline.area() < 0.0 { + outline = outline.reverse_subpaths(); + } + clip.extend(outline.iter()); + } + let clip = Node::path(id, PathData::from_bezpath(&clip), Appearance::default()); + Some(Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(Node::group(id, children))], clip: true })) +} + +/// The bounds of the art symbol `name` of `doc` draws as a graphic, in the symbol's coordinates. +pub(super) fn art_bounds(doc: &Document, name: &str) -> Option { + let symbol = doc.symbols.iter().find(|s| s.name == name)?; + pieces_of(doc, symbol).ok()?.iter().filter_map(|p| p.path.bounds()).reduce(|a, b| a.union(b)) +} + +/// Where a 3D object's graphics are drawn: the bounds its solids are built with (the graphics' +/// offsets are from their center) and the maps of its front and back faces, when it has them. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GraphicFrame { + pub bounds: Rect, + front: Option, + back: Option, +} + +impl GraphicFrame { + pub(super) fn new(bounds: Rect, front: Option, back: Option) -> Self { + Self { bounds, front, back } + } + + pub fn map(&self, face: PlanarFace) -> Option<&FaceMap> { + match face { + PlanarFace::Front => self.front.as_ref(), + PlanarFace::Back => self.back.as_ref(), + } + } + + /// The point of `face`'s plane that offset `(x, y)` names, as [`MappedGraphic`] places a + /// graphic's center. + fn art_point(&self, face: PlanarFace, (x, y): (f64, f64)) -> Point { + let c = self.bounds.center(); + match face { + PlanarFace::Front => Point::new(c.x + x, c.y + y), + PlanarFace::Back => Point::new(c.x - x, c.y + y), + } + } + + /// Where graphic `g`'s center lands on the page, and whether its face is turned toward the + /// viewer. `None` when the object has no such face or the point has no page point. + pub fn handle(&self, g: &MappedGraphic) -> Option<(Point, bool)> { + let map = self.map(g.face)?; + Some((map.to_page(self.art_point(g.face, (g.x, g.y)))?, map.visible)) + } + + /// The offset of a graphic on `face` whose center lands on page point `page`. + pub fn offset_at(&self, face: PlanarFace, page: Point) -> Option<(f64, f64)> { + let art = self.map(face)?.to_art(page)?; + let c = self.bounds.center(); + let (x, y) = match face { + PlanarFace::Front => (art.x - c.x, art.y - c.y), + PlanarFace::Back => (c.x - art.x, art.y - c.y), + }; + (x.abs() <= MAX_OFFSET && y.abs() <= MAX_OFFSET).then_some((x, y)) + } + + /// The scale (percent) at which art with bounds `art` fits inside the object's bounds, at most + /// 100: where a newly mapped graphic starts. + pub fn fitting_scale(&self, art: Rect) -> f64 { + let fit = (self.bounds.width() / art.width().max(1e-9)).min(self.bounds.height() / art.height().max(1e-9)); + (fit * 100.0).clamp(SCALE_RANGE.0, 100.0) + } +} + +/// The symbols the graphics of `n`'s 3D effects name (its own effects and its fills' and strokes'). +pub fn graphic_symbols(n: &Node) -> Vec { + appearance_graphic_symbols(&n.appearance) +} + +/// The symbols the graphics of the 3D effects in `appearance` name, as [`graphic_symbols`]: for a +/// graphic style's appearance. +pub fn appearance_graphic_symbols(appearance: &Appearance) -> Vec { + std::iter::once(&appearance.effects) + .chain(appearance.items.iter().map(AppearanceItem::effects)) + .flatten() + .filter(|e| takes_graphics(&e.id)) + .flat_map(|e| mapped_graphics(&e.params)) + .map(|g| g.symbol) + .collect() +} + +/// Makes the graphics of `n`'s 3D effects that name symbol `from` name `to` instead. Returns +/// whether any changed. +pub fn rename_graphic_symbol(n: &mut Node, from: &str, to: &str) -> bool { + // Checked first, so an appearance that names another symbol is not copied. + graphic_symbols(n).iter().any(|s| s == from) && rename_appearance_graphic_symbol(&mut n.appearance, from, to) +} + +/// [`rename_graphic_symbol`] for the 3D effects in `appearance`: for a graphic style's appearance. +pub fn rename_appearance_graphic_symbol(appearance: &mut Appearance, from: &str, to: &str) -> bool { + if !appearance_graphic_symbols(appearance).iter().any(|s| s == from) { + return false; + } + let mut lists: Vec<&mut Vec> = vec![]; + let (effects, items) = (&mut appearance.effects, &mut appearance.items); + lists.push(effects); + lists.extend(items.iter_mut().map(AppearanceItem::effects_mut)); + for e in lists.into_iter().flatten().filter(|e| takes_graphics(&e.id)) { + if let Some(Value::Array(list)) = e.params.get_mut(GRAPHICS) { + for g in list.iter_mut() { + if g.get("symbol").and_then(Value::as_str) == Some(from) { + g["symbol"] = json!(to); + } + } + } + } + true +} + +/// Does `n` or any object in it, opacity masks included, have a graphic that names symbol `from`? +pub fn names_graphic_symbol(n: &Node, from: &str) -> bool { + let mut found = false; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |c| found |= graphic_symbols(c).iter().any(|s| s == from)); + found +} + +/// [`rename_graphic_symbol`] for `n` and every object in it, at any depth, opacity masks included. +/// Only the parts that name `from` are copied. Returns whether any changed. +pub fn rename_graphic_symbol_in(n: &mut Node, from: &str, to: &str) -> bool { + let names = names_graphic_symbol; + let mut changed = rename_graphic_symbol(n, from, to); + if let Some(mask) = n.extras_if_any().and_then(|x| x.mask.as_deref_mut()).filter(|m| names(&m.art, from)) { + changed |= rename_graphic_symbol_in(Arc::make_mut(&mut mask.art), from, to); + } + if let Some(children) = n.children_mut() { + for c in children.iter_mut().filter(|c| names(c, from)) { + changed |= rename_graphic_symbol_in(Arc::make_mut(c), from, to); + } + } + changed +} + +/// `p` with graphic `g` added at the end of its list. +pub fn with_graphic(p: &Value, g: &MappedGraphic) -> Value { + let mut out = p.as_object().cloned().unwrap_or_else(Map::new); + let mut list = match out.get(GRAPHICS) { + Some(Value::Array(list)) => list.clone(), + _ => vec![], + }; + list.push(g.to_value()); + out.insert(GRAPHICS.into(), Value::Array(list)); + Value::Object(out) +} + +#[cfg(test)] +#[path = "tests_graphics.rs"] +mod tests; diff --git a/crates/effects/src/solids/tests_graphics.rs b/crates/effects/src/solids/tests_graphics.rs new file mode 100644 index 0000000000..64c947a9df --- /dev/null +++ b/crates/effects/src/solids/tests_graphics.rs @@ -0,0 +1,659 @@ +use serde_json::json; +use vectorcraft_doc::color::Color; +use vectorcraft_doc::{StrokeLayer, Symbol}; +use vectorcraft_geom::shapes; + +use super::super::{EXTRUDE, INFLATE, ROTATE, graphic_frame, solid_art_in, three_d_params_as, validate_three_d_in, validate_three_d_params}; +use super::*; + +fn red() -> Color { + Color::rgb(1.0, 0.0, 0.0) +} + +fn square(id: u64, r: Rect, paint: Paint) -> Node { + Node::path(NodeId(id), shapes::rectangle(r), Appearance::basic(paint, Paint::None, 0.0)) +} + +/// A document with symbol "Mark": a red 10 × 10 square. +fn document() -> Document { + let mut d = Document::new(1000.0, 1000.0); + d.symbols.push(Symbol { + dynamic: false, + name: "Mark".into(), + art: Arc::new(square(0, Rect::new(500.0, 500.0, 510.0, 510.0), Paint::solid(red()))), + }); + d +} + +/// A gray 100 × 100 square at (0, 0) with effect `id`, its parameters `params` over a view +/// straight on. +fn object(id: &str, params: Value) -> Node { + let mut p = json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": false}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), params.as_object()) { + p.extend(extra.clone()); + } + let mut n = square(7, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(0.5, 0.5, 0.5))); + n.appearance.effects.push(crate::new_effect(id, &p).unwrap()); + n +} + +/// The graphics clip groups in `art`, in drawing order: each one's mapped paths. +fn graphics_in(art: &Node) -> Vec> { + let mut out = vec![]; + art.walk(&mut |n| { + if let NodeKind::Group { children, clip: true } = &n.kind + && let [_, inner] = children.as_slice() + && let NodeKind::Group { children: paths, clip: false } = &inner.kind + { + let paths: Vec<(PathData, Paint)> = paths + .iter() + .filter_map(|p| { + let fill = p.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) => Some(f.paint.clone()), + AppearanceItem::Stroke(_) => None, + })?; + Some((p.path_data()?.clone(), fill)) + }) + .collect(); + if paths.iter().any(|(_, p)| p.color().is_some_and(|c| c.to_rgb()[1] < 0.1 && c.to_rgb()[0] > 0.3)) { + out.push(paths); + } + } + }); + out +} + +fn bounds_of(paths: &[(PathData, Paint)]) -> Rect { + paths.iter().filter_map(|(p, _)| p.bounds()).reduce(|a, b| a.union(b)).unwrap() +} + +fn close(a: Rect, b: Rect) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 1e-6) +} + +#[test] +fn a_graphic_is_drawn_on_the_front_cap_at_its_offset_size_and_turn() { + let d = document(); + let n = object(EXTRUDE, json!({"graphics": [{"symbol": "Mark", "x": 20.0, "y": -10.0, "scale": 200.0}]})); + let art = solid_art_in(Some(&d), &n).unwrap(); + let found = graphics_in(&art); + assert_eq!(found.len(), 1); + // Straight on, the front cap is the art: a 20 × 20 square centered 20 right of and 10 above (50, 50). + assert!(close(bounds_of(&found[0]), Rect::new(60.0, 30.0, 80.0, 50.0)), "{:?}", bounds_of(&found[0])); + // Turned 45°, the square's corners reach out to its half diagonal. + let n = object(EXTRUDE, json!({"graphics": [{"symbol": "Mark", "rotation": 45.0}]})); + let b = bounds_of(&graphics_in(&solid_art_in(Some(&d), &n).unwrap())[0]); + assert!((b.width() - 10.0 * 2f64.sqrt()).abs() < 1e-6); +} + +#[test] +fn without_the_document_or_its_symbol_nothing_is_drawn() { + let n = object(EXTRUDE, json!({"graphics": [{"symbol": "Mark"}]})); + let art = solid_art_in(None, &n).unwrap(); + assert!(graphics_in(&art).is_empty()); + let n = object(EXTRUDE, json!({"graphics": [{"symbol": "Gone"}]})); + assert!(graphics_in(&solid_art_in(Some(&document()), &n).unwrap()).is_empty()); + // Without graphics, the art is the same as before they existed. + let plain = object(EXTRUDE, json!({})); + assert_eq!(solid_art_in(Some(&document()), &plain), solid_art_in(None, &plain)); +} + +#[test] +fn the_graphic_is_shaded_with_its_face() { + let d = document(); + let n = object(EXTRUDE, json!({"shade": true, "lightIntensity": 0.0, "ambient": 50.0, "graphics": [{"symbol": "Mark"}]})); + let found = graphics_in(&solid_art_in(Some(&d), &n).unwrap()); + let c = found[0][0].1.color().unwrap().to_rgb(); + assert!((c[0] - 0.5).abs() < 1e-3 && c[1].abs() < 1e-6 && c[2].abs() < 1e-6, "{c:?}"); +} + +#[test] +fn the_graphic_goes_right_after_its_cap_and_is_clipped_to_it() { + let d = document(); + let n = object(EXTRUDE, json!({"rotationX": -20.0, "rotationY": 25.0, "graphics": [{"symbol": "Mark", "scale": 2000.0}]})); + let art = solid_art_in(Some(&d), &n).unwrap(); + let NodeKind::Group { children, .. } = &art.kind else { panic!("a group") }; + let NodeKind::Group { children: faces, .. } = &children.last().unwrap().kind else { panic!("the ordered faces") }; + let last = faces.last().unwrap(); + assert!(matches!(&last.kind, NodeKind::Group { clip: true, .. }), "the graphic is drawn last, over the front cap"); + let NodeKind::Group { children: parts, .. } = &last.kind else { panic!() }; + let clip = parts[0].path_data().unwrap().bounds().unwrap(); + let cap = faces[faces.len() - 2].path_data().unwrap().bounds().unwrap(); + assert!(close(clip, cap), "clipped to the cap: {clip:?} against {cap:?}"); +} + +#[test] +fn the_back_cap_reads_the_right_way_round_from_behind() { + let d = document(); + let behind = json!({"rotationY": 180.0, "graphics": [{"symbol": "Mark", "face": "back", "x": 20.0}]}); + let n = object(EXTRUDE, behind); + let found = graphics_in(&solid_art_in(Some(&d), &n).unwrap()); + assert_eq!(found.len(), 1); + let b = bounds_of(&found[0]); + // Seen from behind, x runs to the right on the page too. + assert!((b.center().x - 70.0).abs() < 1e-6, "{b:?}"); + let frame = graphic_frame(Some(&d), &n, None).unwrap(); + let g = MappedGraphic::from_value(&json!({"symbol": "Mark", "face": "back", "x": 20.0})).unwrap(); + let (at, visible) = frame.handle(&g).unwrap(); + assert!(visible && (at.x - 70.0).abs() < 1e-6); + let (x, y) = frame.offset_at(PlanarFace::Back, Point::new(40.0, 55.0)).unwrap(); + assert!((x + 10.0).abs() < 1e-6 && (y - 5.0).abs() < 1e-6); + // From the front the back cap is hidden, and so is its graphic. + let front = object(EXTRUDE, json!({"graphics": [{"symbol": "Mark", "face": "back"}]})); + assert!(graphics_in(&solid_art_in(Some(&d), &front).unwrap()).is_empty()); +} + +#[test] +fn a_perspective_foreshortens_the_graphic_with_its_face() { + let d = document(); + let params = json!({"rotationX": -30.0, "rotationY": 35.0, "perspectiveAngle": 100.0, "graphics": [{"symbol": "Mark", "scale": 400.0}]}); + let n = object(EXTRUDE, params); + let found = graphics_in(&solid_art_in(Some(&d), &n).unwrap()); + let frame = graphic_frame(Some(&d), &n, None).unwrap(); + let map = frame.map(PlanarFace::Front).unwrap(); + // The corners of the 40 × 40 square centered on the object land where the face's map takes them. + let path = &found[0][0].0; + let corners: Vec = [(30.0, 30.0), (70.0, 30.0), (70.0, 70.0), (30.0, 70.0)].map(|(x, y)| map.to_page(Point::new(x, y)).unwrap()).into(); + for c in corners { + let near = path.subpaths.iter().flat_map(|s| &s.anchors).map(|a| a.p.distance(c)).fold(f64::INFINITY, f64::min); + assert!(near < 1e-6, "{c:?} is {near} from the drawn graphic"); + } +} + +#[test] +fn a_graphic_behind_the_eye_leaves_the_others_drawn() { + let d = document(); + let view = json!({"rotationX": -60.0, "rotationY": 0.0, "perspectiveAngle": 160.0}); + let n = object(EXTRUDE, view.clone()); + let frame = graphic_frame(Some(&d), &n, None).unwrap(); + let map = frame.map(PlanarFace::Front).unwrap(); + let c = frame.bounds.center(); + // An offset whose point of the front's plane is at or behind the eye. + let y = [1.0, -1.0] + .into_iter() + .flat_map(|s| (1..60).map(move |k| s * 10f64.powf(f64::from(k) / 10.0))) + .find(|y| map.local_affine(Point::new(c.x, c.y + y)).is_none()) + .expect("an offset behind the eye"); + let mut params = view; + params["graphics"] = json!([{"symbol": "Mark"}, {"symbol": "Mark", "y": y}]); + let found = graphics_in(&solid_art_in(Some(&d), &object(EXTRUDE, params)).unwrap()); + assert_eq!(found.len(), 1, "the graphic that projects is still drawn"); + // A graphic centered there but large enough to reach over the cap draws the part in front. + let mut params = json!({"rotationX": -60.0, "rotationY": 0.0, "perspectiveAngle": 160.0}); + params["graphics"] = json!([{"symbol": "Mark", "y": y, "scale": (20.0 * (y.abs() + 50.0)).clamp(1.0, 10_000.0)}]); + let found = graphics_in(&solid_art_in(Some(&d), &object(EXTRUDE, params)).unwrap()); + assert_eq!(found.len(), 1, "the part in front is drawn (offset {y})"); + assert!(!found[0].is_empty()); +} + +#[test] +fn the_graphic_takes_the_opacity_of_its_fill() { + let d = document(); + for id in [EXTRUDE, ROTATE] { + let mut n = object(id, json!({"graphics": [{"symbol": "Mark"}]})); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.opacity = 0.25; + } + let art = solid_art_in(Some(&d), &n).unwrap(); + let mut opacities = vec![]; + art.walk(&mut |c| { + if matches!(c.kind, NodeKind::Group { clip: true, .. }) { + opacities.push(c.opacity); + } + }); + assert_eq!(opacities, vec![0.25], "{id}"); + } +} + +#[test] +fn the_graphic_takes_the_blending_mode_of_its_fill() { + let d = document(); + for id in [EXTRUDE, ROTATE] { + let mut n = object(id, json!({"graphics": [{"symbol": "Mark"}]})); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.blend = BlendMode::Multiply; + } + let art = solid_art_in(Some(&d), &n).unwrap(); + let mut blends = vec![]; + art.walk(&mut |c| { + if matches!(c.kind, NodeKind::Group { clip: true, .. }) { + blends.push(c.blend); + } + }); + assert_eq!(blends, vec![BlendMode::Multiply], "{id}"); + } +} + +#[test] +fn each_fill_of_a_plane_gives_its_graphic_its_own_opacity() { + let d = document(); + let mut n = object(ROTATE, json!({"graphics": [{"symbol": "Mark"}]})); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.opacity = 0.25; + } + let mut second = FillLayer::new(Paint::solid(Color::rgb(0.2, 0.2, 0.2))); + second.opacity = 0.75; + n.appearance.items.push(AppearanceItem::Fill(second)); + let art = solid_art_in(Some(&d), &n).unwrap(); + let mut opacities = vec![]; + art.walk(&mut |c| { + if matches!(c.kind, NodeKind::Group { clip: true, .. }) { + opacities.push(c.opacity); + } + }); + opacities.sort_by(f32::total_cmp); + assert_eq!(opacities, vec![0.25, 0.75]); +} + +#[test] +fn a_symbols_gradient_takes_the_size_of_its_art() { + use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let mut d = document(); + // Symbol art kept in its 20 × 20 square, drawn 40 × 20. + let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, red()), GradientStop::new(1.0, Color::BLACK)])); + g.geom = Some(GradientGeom { start: Point::new(-10.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }); + let art = square(0, Rect::new(-10.0, -10.0, 10.0, 10.0), Paint::Gradient(Box::new(g))); + d.symbols.push(Symbol { dynamic: false, name: "Ramp".into(), art: Arc::new(art) }); + d.unknown.insert(vectorcraft_doc::SYMBOL_SIZES.into(), json!({"Ramp": [40.0, 20.0]})); + let n = object(EXTRUDE, json!({"graphics": [{"symbol": "Ramp"}]})); + let out = solid_art_in(Some(&d), &n).unwrap(); + let mut found = None; + out.walk(&mut |c| { + if let (Some(p), Some(AppearanceItem::Fill(f))) = (c.path_data(), c.appearance.items.first()) + && let Paint::Gradient(g) = &f.paint + { + found = Some((p.bounds().unwrap(), g.geom.unwrap())); + } + }); + let (bounds, geom) = found.unwrap(); + assert!(close(bounds, Rect::new(30.0, 40.0, 70.0, 60.0)), "{bounds:?}"); + assert!(geom.start.distance(Point::new(30.0, 50.0)) < 1e-6 && geom.end.distance(Point::new(70.0, 50.0)) < 1e-6, "{geom:?}"); +} + +#[test] +fn a_symbols_pattern_follows_the_graphic() { + let mut d = document(); + let tiles = Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }; + d.symbols.push(Symbol { dynamic: false, name: "Tiles".into(), art: Arc::new(square(0, Rect::new(-10.0, -10.0, 10.0, 10.0), tiles)) }); + let n = object(EXTRUDE, json!({"graphics": [{"symbol": "Tiles", "scale": 200.0}]})); + let out = solid_art_in(Some(&d), &n).unwrap(); + let mut found = None; + out.walk(&mut |c| { + if let Some(AppearanceItem::Fill(f)) = c.appearance.items.first() + && let Paint::Pattern { xf, .. } = &f.paint + { + found = Some((c.path_data().unwrap().bounds().unwrap(), *xf)); + } + }); + let (bounds, xf) = found.unwrap(); + // The 20 × 20 art drawn 40 × 40 on the middle of the front: its tiles go with it. + assert!(close(bounds, Rect::new(30.0, 30.0, 70.0, 70.0)), "{bounds:?}"); + assert!((xf * Point::new(-10.0, -10.0)).distance(Point::new(30.0, 30.0)) < 1e-6, "{xf:?}"); + assert!((xf * Point::new(10.0, 10.0)).distance(Point::new(70.0, 70.0)) < 1e-6, "{xf:?}"); +} + +#[test] +fn a_curve_in_front_of_the_eye_maps_though_its_control_points_do_not() { + let bounds = Rect::new(10.0, 20.0, 70.0, 60.0); + let view = vectorcraft_three_d::View { rotation_x: -89.0, rotation_y: 40.0, rotation_z: 10.0, perspective: 160.0, ..Default::default() }; + let map = vectorcraft_three_d::plane_face_map(bounds, view).unwrap(); + // Where the page map stops along the line x = `x`, between the center and a point it does not map. + let edge = |x: f64| { + let inside = Point::new(x, bounds.center().y); + let far = [Point::new(x, -1e6), Point::new(x, 1e6)].into_iter().find(|p| map.to_page(*p).is_none()).unwrap(); + let (mut a, mut b) = (inside, far); + for _ in 0..200 { + let m = a.midpoint(b); + if map.to_page(m).is_some() { a = m } else { b = m } + } + a + }; + let (b1, b2) = (edge(40.0), edge(60.0)); + let u = (b2 - b1).normalize(); + let mut n = vectorcraft_geom::Vec2::new(-u.y, u.x); + if map.to_page(b1 + n * 10.0).is_none() { + n = -n; + } + // Ends 219.17 in front of that line and control points 100 and 42 past it: the curve stays + // about 1 in front, while a control point of one of the pieces it is cut into falls behind. + let at = |s: f64, t: f64| b1 + n * s + u * t; + let mut curve = BezPath::new(); + curve.move_to(at(219.17, 0.0)); + curve.curve_to(at(-100.0, 5.0), at(-42.0, 15.0), at(219.17, 20.0)); + let c = CubicBez::new(at(219.17, 0.0), at(-100.0, 5.0), at(-42.0, 15.0), at(219.17, 20.0)); + assert!((0..=1000).all(|k| map.to_page(c.eval(k as f64 / 1000.0)).is_some()), "the curve itself is in front"); + let pieces = (0..PERSPECTIVE_PIECES).map(|k| c.subsegment(k as f64 / 8.0..(k + 1) as f64 / 8.0)); + assert!(pieces.flat_map(|s| [s.p1, s.p2]).any(|p| map.to_page(p).is_none()), "a piece's control point is behind"); + let mapped = map_path(&PathData::from_bezpath(&curve), &map, None, usize::MAX).expect("the curve maps"); + // The split pieces count toward the room the path is given: without room, the path is full, + // not behind the eye, so it is not clipped. + assert!(mapped.elements().len() > 1 + PERSPECTIVE_PIECES); + assert_eq!(map_path(&PathData::from_bezpath(&curve), &map, None, PERSPECTIVE_PIECES + 1).err(), Some(NotMapped::Full)); + assert!(mapped.elements().iter().all(|e| e.end_point().is_none_or(|p| p.x.is_finite() && p.y.is_finite()))); + // That piece is split until its halves' control points map, so it stays a curve. + assert!(mapped.elements().iter().all(|e| !matches!(e, PathEl::LineTo(_))), "{mapped:?}"); + // With its ends at 214.25 instead, the curve dips about 0.24 behind the line between the ends + // of two pieces, which still map: it has no page point there, and is left to the clipping. + let c = CubicBez::new(at(214.25, 0.0), at(-100.0, 5.0), at(-42.0, 15.0), at(214.25, 20.0)); + assert!((0..=PERSPECTIVE_PIECES).all(|k| map.to_page(c.eval(k as f64 / 8.0)).is_some())); + assert!((0..=1000).any(|k| map.to_page(c.eval(k as f64 / 1000.0)).is_none())); + let mut dips = BezPath::new(); + dips.move_to(c.p0); + dips.curve_to(c.p1, c.p2, c.p3); + assert_eq!(map_path(&PathData::from_bezpath(&dips), &map, None, usize::MAX).err(), Some(NotMapped::Behind)); +} + +#[test] +fn members_with_their_own_blending_get_graphics_of_their_own() { + let d = document(); + let a = square(1, Rect::new(0.0, 0.0, 50.0, 100.0), Paint::solid(Color::rgb(0.5, 0.5, 0.5))); + let mut b = square(2, Rect::new(50.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(0.5, 0.5, 0.5))); + b.blend = BlendMode::Multiply; + let mut group = Node::group(NodeId(3), vec![Arc::new(a), Arc::new(b)]); + let p = json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": false, "graphics": [{"symbol": "Mark", "scale": 1000.0}]}); + group.appearance.effects.push(crate::new_effect(EXTRUDE, &p).unwrap()); + let art = solid_art_in(Some(&d), &group).unwrap(); + let found = graphics_in(&art); + // One graphic clipped to each member's cap: the second stays inside the member's Multiply group. + assert_eq!(found.len(), 2); + let mut blended = 0; + art.walk(&mut |c| { + if c.blend == BlendMode::Multiply { + blended += graphics_in(c).len(); + } + }); + assert_eq!(blended, 1); +} + +/// [`document`] with symbol "Dense" too: a red polygon of 30,000 sides, straight or `curved`. +fn dense_document(curved: bool) -> Document { + let mut d = document(); + let mut poly = BezPath::new(); + let mut last = Point::ZERO; + for k in 0..30_000 { + let a = k as f64 / 30_000.0 * std::f64::consts::TAU; + let p = Point::new(5.0 + 5.0 * a.cos(), 5.0 + 5.0 * a.sin()); + if k == 0 { + poly.move_to(p); + } else if curved { + poly.curve_to(last.lerp(p, 1.0 / 3.0), last.lerp(p, 2.0 / 3.0), p); + } else { + poly.line_to(p); + } + last = p; + } + poly.close_path(); + let art = Node::path(NodeId(0), PathData::from_bezpath(&poly), Appearance::basic(Paint::solid(red()), Paint::None, 0.0)); + d.symbols.push(Symbol { dynamic: false, name: "Dense".into(), art: Arc::new(art) }); + d +} + +/// The graphics drawn on a group of two members, each with its own graphic of symbol "Dense" +/// ([`dense_document`]), seen under a perspective. +fn dense_graphics_drawn(curved: bool) -> usize { + let d = dense_document(curved); + let a = square(1, Rect::new(0.0, 0.0, 50.0, 100.0), Paint::solid(Color::rgb(0.5, 0.5, 0.5))); + let mut b = square(2, Rect::new(50.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(0.5, 0.5, 0.5))); + b.blend = BlendMode::Multiply; + let mut group = Node::group(NodeId(3), vec![Arc::new(a), Arc::new(b)]); + let p = json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0, "perspectiveAngle": 60.0, "shade": false, "graphics": [{"symbol": "Dense", "scale": 1000.0}]}); + group.appearance.effects.push(crate::new_effect(EXTRUDE, &p).unwrap()); + graphics_in(&solid_art_in(Some(&d), &group).unwrap()).len() +} + +#[test] +fn members_with_graphics_of_their_own_share_one_element_limit() { + // A perspective cuts each of the 30,000 curves into 8: one copy fits the limit, two do not. + // The first member's graphic is drawn; the second would pass the limit, so it draws none. + assert_eq!(dense_graphics_drawn(true), 1); +} + +#[test] +fn straight_sides_count_once_under_a_perspective() { + // A perspective keeps each of the 30,000 straight sides one element: both copies fit the limit. + assert_eq!(dense_graphics_drawn(false), 2); +} + +#[test] +fn the_symbol_check_counts_what_the_view_draws() { + // Two graphics of the 30,000-sided polygon under a perspective: straight, they fit the limit; + // curved, they do not. + let two = json!({"perspectiveAngle": 60.0, "graphics": [{"symbol": "Dense"}, {"symbol": "Dense", "face": "back"}]}); + let n = object(EXTRUDE, two.clone()); + assert!(validate_three_d_in(Some(&dense_document(false)), &n, EXTRUDE, &two).is_ok()); + let e = validate_three_d_in(Some(&dense_document(true)), &n, EXTRUDE, &two).unwrap_err(); + assert!(e.contains("too complex"), "{e}"); + // Without a perspective each curve draws once: curved, they fit too. + let flat = json!({"perspectiveAngle": 0.0, "graphics": two["graphics"].clone()}); + assert!(validate_three_d_in(Some(&dense_document(true)), &n, EXTRUDE, &flat).is_ok()); +} + +#[test] +fn a_plane_and_a_hollow_solid() { + let d = document(); + let plane = object(ROTATE, json!({"rotationY": 30.0, "graphics": [{"symbol": "Mark"}]})); + assert_eq!(graphics_in(&solid_art_in(Some(&d), &plane).unwrap()).len(), 1); + let hollow = object(EXTRUDE, json!({"cap": false, "graphics": [{"symbol": "Mark"}]})); + assert!(graphics_in(&solid_art_in(Some(&d), &hollow).unwrap()).is_empty()); +} + +#[test] +fn a_stroked_graphic_draws_its_stroke_as_an_outline() { + let mut d = document(); + let mut ring = square(0, Rect::new(0.0, 0.0, 10.0, 10.0), Paint::None); + ring.appearance = Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(red()), 2.0))], ..Default::default() }.into(); + d.symbols.push(Symbol { dynamic: false, name: "Ring".into(), art: Arc::new(ring) }); + let n = object(EXTRUDE, json!({"graphics": [{"symbol": "Ring"}]})); + let found = graphics_in(&solid_art_in(Some(&d), &n).unwrap()); + // The 10 × 10 square's 2 pt stroke reaches 1 pt outside it. + assert!(close(bounds_of(&found[0]), Rect::new(44.0, 44.0, 56.0, 56.0)), "{:?}", bounds_of(&found[0])); +} + +#[test] +fn graphics_are_checked() { + let n = object(EXTRUDE, json!({})); + let check = |id: &str, graphics: Value| validate_three_d_in(None, &n, id, &json!({"graphics": graphics})); + assert!(check(EXTRUDE, json!([{"symbol": "Mark", "face": "back", "x": 3.0, "scale": 50.0, "rotation": -90.0}])).is_ok()); + assert!(check(EXTRUDE, json!([])).is_ok()); + for bad in [ + json!("Mark"), + json!([{"face": "front"}]), + json!([{"symbol": ""}]), + json!([{"symbol": "Mark", "face": "side"}]), + json!([{"symbol": "Mark", "scale": 0.0}]), + json!([{"symbol": "Mark", "rotation": 400.0}]), + json!([{"symbol": "Mark", "x": "1"}]), + json!([{"symbol": "Mark", "repeat": true}]), + json!(vec![json!({"symbol": "Mark"}); MAX_GRAPHICS + 1]), + ] { + assert!(check(EXTRUDE, bad.clone()).is_err(), "{bad}"); + } + assert!(check(ROTATE, json!([{"symbol": "Mark", "face": "back"}])).is_err()); + assert!(check(INFLATE, json!([{"symbol": "Mark"}])).is_err()); + assert!(validate_three_d_params(crate::REVOLVE, &json!({"graphics": [{"symbol": "Mark"}]})).is_err()); + // A loaded file's invalid entries are left out when drawn. + assert_eq!(mapped_graphics(&json!({"graphics": [{"symbol": "Mark"}, {"face": "back"}, 3]})).len(), 1); +} + +#[test] +fn the_frame_at_an_index_is_that_effects() { + let d = document(); + let mut n = object(EXTRUDE, json!({})); + n.appearance.effects.push(crate::new_effect(ROTATE, &json!({})).unwrap()); + // The first 3D effect is the Extrude & Bevel, with a back cap; the second is a plane, without. + assert!(graphic_frame(Some(&d), &n, None).unwrap().map(PlanarFace::Back).is_some()); + assert!(super::super::graphic_frame_at(Some(&d), &n, None, 0).unwrap().map(PlanarFace::Back).is_some()); + assert!(super::super::graphic_frame_at(Some(&d), &n, None, 1).unwrap().map(PlanarFace::Back).is_none()); + assert!(super::super::graphic_frame_at(Some(&d), &n, None, 2).is_none()); +} + +#[test] +fn a_change_to_a_plane_drops_the_back_caps_graphics() { + let p = json!({"graphics": [{"symbol": "Mark", "face": "front"}, {"symbol": "Mark", "face": "back"}]}); + let plane = three_d_params_as(EXTRUDE, &p, ROTATE); + assert_eq!(plane["graphics"], json!([{"symbol": "Mark", "face": "front"}])); + assert!(validate_three_d_params(ROTATE, &plane).is_ok()); + // Back to Extrude & Bevel, the front's graphic stays. + assert_eq!(three_d_params_as(ROTATE, &plane, EXTRUDE)["graphics"], plane["graphics"]); +} + +#[test] +fn a_stroked_solid_carries_its_graphic_too() { + let d = document(); + let mut n = Node::path( + NodeId(7), + shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), + Appearance::basic(Paint::None, Paint::solid(Color::rgb(0.5, 0.5, 0.5)), 20.0), + ); + let p = json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": false, "graphics": [{"symbol": "Mark", "scale": 2000.0}]}); + n.appearance.effects.push(crate::new_effect(EXTRUDE, &p).unwrap()); + assert_eq!(graphics_in(&solid_art_in(Some(&d), &n).unwrap()).len(), 1); +} + +#[test] +fn art_nested_too_deep_is_refused_and_not_drawn() { + let mut d = document(); + let mut art = square(0, Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(red())); + for k in 0..=MAX_LEVELS { + art = Node::group(NodeId(k as u64 + 1), vec![Arc::new(art)]); + } + d.symbols.push(Symbol { dynamic: false, name: "Deep".into(), art: Arc::new(art) }); + let n = object(EXTRUDE, json!({"graphics": [{"symbol": "Deep"}]})); + let e = validate_three_d_in(Some(&d), &n, EXTRUDE, &json!({"graphics": [{"symbol": "Deep"}]})).unwrap_err(); + assert!(e.contains("nested"), "{e}"); + assert!(graphics_in(&solid_art_in(Some(&d), &n).unwrap()).is_empty()); + // A loaded file draws its other graphics. + let both = object(EXTRUDE, json!({"graphics": [{"symbol": "Deep"}, {"symbol": "Mark"}]})); + assert_eq!(graphics_in(&solid_art_in(Some(&d), &both).unwrap()).len(), 1); +} + +#[test] +fn symbols_nested_too_deep_are_refused_and_not_drawn() { + let mut d = document(); + // Symbol `N{k}` holds an instance of `N{k-1}`, and `N0` is a square. + d.symbols.push(Symbol { dynamic: false, name: "N0".into(), art: Arc::new(square(0, Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(red()))) }); + for k in 1..=MAX_NESTING + 1 { + let instance = Node::new(NodeId(k as u64), NodeKind::SymbolInstance { symbol: format!("N{}", k - 1), xf: Affine::IDENTITY }); + d.symbols.push(Symbol { dynamic: false, name: format!("N{k}"), art: Arc::new(instance) }); + } + let deepest = format!("N{MAX_NESTING}"); + let p = json!({"graphics": [{"symbol": deepest}]}); + assert!(validate_three_d_in(Some(&d), &object(EXTRUDE, p.clone()), EXTRUDE, &p).is_ok()); + assert_eq!(graphics_in(&solid_art_in(Some(&d), &object(EXTRUDE, p)).unwrap()).len(), 1); + let past = format!("N{}", MAX_NESTING + 1); + let p = json!({"graphics": [{"symbol": past}]}); + let e = validate_three_d_in(Some(&d), &object(EXTRUDE, p.clone()), EXTRUDE, &p).unwrap_err(); + assert!(e.contains("nest"), "{e}"); + assert!(graphics_in(&solid_art_in(Some(&d), &object(EXTRUDE, p)).unwrap()).is_empty()); +} + +#[test] +fn a_solid_in_a_transparent_gradient_keeps_its_graphic() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let d = document(); + let mut stop = GradientStop::new(1.0, red()); + stop.opacity = 0.25; + let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, red()), stop])); + // Under a colored light the faces are drawn opaque and masked by the gradient's transparency. + let mut n = object(EXTRUDE, json!({"shade": true, "lights": [{"color": "#ff8080"}], "graphics": [{"symbol": "Mark"}]})); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.paint = Paint::Gradient(Box::new(gradient)); + } + let art = solid_art_in(Some(&d), &n).unwrap(); + let mut masked = false; + art.walk(&mut |c| masked |= c.extras().mask.is_some()); + assert!(masked); + assert_eq!(graphics_in(&art).len(), 1); + // Each graphic clip group in `art`, in drawing order: whether it carries an opacity mask. + let masks = |art: &Node| { + let mut out = vec![]; + art.walk(&mut |c| { + if let NodeKind::Group { children, clip: true } = &c.kind + && let [_, inner] = children.as_slice() + && matches!(inner.kind, NodeKind::Group { clip: false, .. }) + { + out.push(c.extras().mask.is_some()); + } + }); + out + }; + // The graphic carries the cap's mask, so it is hidden where the gradient is transparent. + assert_eq!(masks(&art), vec![true]); + // With an opaque stroke drawn after the fill, the fill's cap keeps a masked graphic of its own + // and the stroke's outline gets an unmasked one. + n.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0))); + assert_eq!(masks(&solid_art_in(Some(&d), &n).unwrap()), vec![true, false]); +} + +#[test] +fn a_cap_with_a_hole_keeps_the_hole_out_of_the_graphic() { + let d = document(); + let outer = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let hole = shapes::rectangle(Rect::new(30.0, 30.0, 70.0, 70.0)).to_bezpath().reverse_subpaths(); + let mut ring = outer; + ring.extend(hole.iter()); + for flip in [false, true] { + let ring = if flip { ring.reverse_subpaths() } else { ring.clone() }; + let mut n = + Node::path(NodeId(7), PathData::from_bezpath(&ring), Appearance::basic(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0)); + let p = json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": false, "graphics": [{"symbol": "Mark", "scale": 2000.0}]}); + n.appearance.effects.push(crate::new_effect(EXTRUDE, &p).unwrap()); + let art = solid_art_in(Some(&d), &n).unwrap(); + let mut clips = vec![]; + art.walk(&mut |c| { + if let NodeKind::Group { children, clip: true } = &c.kind { + clips.push(children[0].path_data().unwrap().to_bezpath()); + } + }); + assert_eq!(clips.len(), 1, "flip {flip}"); + assert_ne!(clips[0].winding(Point::new(10.0, 10.0)), 0, "flip {flip}: the ring is inside the clip"); + assert_eq!(clips[0].winding(Point::new(50.0, 50.0)), 0, "flip {flip}: the hole is outside it"); + } +} + +#[test] +fn a_graphic_starts_inside_its_object() { + let d = document(); + let frame = graphic_frame(Some(&d), &object(EXTRUDE, json!({})), None).unwrap(); + assert_eq!(frame.fitting_scale(Rect::new(0.0, 0.0, 10.0, 10.0)), 100.0); + assert!((frame.fitting_scale(Rect::new(0.0, 0.0, 400.0, 200.0)) - 25.0).abs() < 1e-9); + assert_eq!(super::super::graphic_bounds(&d, "Mark"), Some(Rect::new(500.0, 500.0, 510.0, 510.0))); +} + +#[test] +fn a_symbol_instance_in_a_graphic_draws_as_the_canvas_shows_it() { + let mut d = document(); + let blue = Paint::solid(Color::rgb(0.0, 0.0, 1.0)); + // A dynamic symbol whose instance in "Changed" fills its square blue instead of red. + d.symbols.push(Symbol { dynamic: true, name: "Dynamic".into(), art: Arc::new(square(0, Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(red()))) }); + let mut changed = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "Dynamic".into(), xf: Affine::IDENTITY }); + changed.extras_mut().symbol_overrides = + vec![vectorcraft_doc::symbol_overrides::SymbolOverride { fill: Some(blue.clone()), ..Default::default() }]; + d.symbols.push(Symbol { dynamic: false, name: "Changed".into(), art: Arc::new(changed) }); + let pieces = pieces_of(&d, d.symbols.iter().find(|s| s.name == "Changed").unwrap()).unwrap(); + assert_eq!(pieces.iter().map(|p| p.paint.clone()).collect::>(), [blue]); + // A symbol with 9-slice guides, a 100 × 100 square with a 10 × 10 one in its corner, placed + // three times as wide: the corner keeps its width while the rest stretches. + let corner = square(2, Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(red())); + let whole = square(3, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(red())); + d.symbols.push(Symbol { dynamic: false, name: "Sliced".into(), art: Arc::new(Node::group(NodeId(4), vec![Arc::new(whole), Arc::new(corner)])) }); + d.set_symbol_nine_slice("Sliced", Some(vectorcraft_doc::SymbolNineSlice::default())); + let wide = Node::new(NodeId(5), NodeKind::SymbolInstance { symbol: "Sliced".into(), xf: Affine::scale_non_uniform(3.0, 1.0) }); + d.symbols.push(Symbol { dynamic: false, name: "Wide".into(), art: Arc::new(wide) }); + let pieces = pieces_of(&d, d.symbols.iter().find(|s| s.name == "Wide").unwrap()).unwrap(); + let widths: Vec = pieces.iter().map(|p| p.path.bounds().unwrap().width()).collect(); + assert_eq!(widths.len(), 2); + assert!((widths[1] / widths[0] - 1.0 / 30.0).abs() < 1e-6, "{widths:?}"); + // With a gradient, the square crossing the guides is cut at them: its pieces cover the + // placed 300 × 100 once, not one whole copy for each cell. + use vectorcraft_doc::color::{Gradient, GradientPaint}; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let shaded = square(6, Rect::new(0.0, 0.0, 100.0, 100.0), gradient); + d.symbols.push(Symbol { dynamic: false, name: "Shaded".into(), art: Arc::new(Node::group(NodeId(7), vec![Arc::new(shaded)])) }); + d.set_symbol_nine_slice("Shaded", Some(vectorcraft_doc::SymbolNineSlice::default())); + let wide = Node::new(NodeId(8), NodeKind::SymbolInstance { symbol: "Shaded".into(), xf: Affine::scale_non_uniform(3.0, 1.0) }); + d.symbols.push(Symbol { dynamic: false, name: "WideShaded".into(), art: Arc::new(wide) }); + let pieces = pieces_of(&d, d.symbols.iter().find(|s| s.name == "WideShaded").unwrap()).unwrap(); + let area: f64 = pieces.iter().map(|p| p.path.to_bezpath().area().abs()).sum(); + assert!((area - 30_000.0).abs() < 1e-3, "{area}"); +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 66eb2104d1..eab57e2a91 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -426,8 +426,13 @@ pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { pub(crate) fn f64_or(p: &Value, key: &str, default: f64) -> f64 { p.get(key).and_then(Value::as_f64).unwrap_or(default) } +/// The number `p` gives for `key`. A missing key or null is "missing number `{key}`"; any other +/// value, such as `"40"`, is "`{key}` must be a number, not {v}", as [`number_or`] words it. pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result { - p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`"))) + match p.get(key) { + None | Some(Value::Null) => Err(bad(cmd, format!("missing number `{key}`"))), + Some(v) => v.as_f64().ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))), + } } /// The number `p` gives for `key`, or `default` when `p` leaves it out or gives null. Any other /// value, such as `"40"`, is an error: "`{key}` must be a number, not {v}". diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 10fd432dfc..7d7dcbb197 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -12,7 +12,7 @@ use vectorcraft_doc::{CharStyle, Document, Justify, Node, NodeId, NodeKind, Para use vectorcraft_geom::{Affine, Point, Vec2}; use vectorcraft_text::{FontDb, FontMatch, TextLayout, edit}; -use crate::cmd::fonts::{edit_symbol_texts, symbol_texts}; +use crate::cmd::fonts::{art_texts_of, edit_art_texts_of}; use crate::cmd::textedit::{editing_range, editing_story}; use crate::cmd::typecmd::refresh_bounds; use crate::cmd::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, switch_or, text_param}; @@ -108,7 +108,7 @@ pub fn specs() -> Vec { "Replace Missing Fonts…", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{font?: family (default: the default type font, Source Sans 3; any of its names)} set every character of the document's type (the type in the art of its symbols included) whose font family isn't available in `font`, in the style it has closest to the one each had, as one undo step → {runs (style runs changed), font}", + "{font?: family (default: the default type font, Source Sans 3; any of its names)} set every character of the document's type (the type in its opacity masks and in the art of its symbols and patterns included, hidden or locked type and disabled masks too) whose font family isn't available in `font`, in the style it has closest to the one each had, as one undo step → {runs (style runs changed), font}", has_doc, replace_fonts ), @@ -982,8 +982,9 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result { } }); } - // The type in the symbols' art too, which exports name when its font is missing. - if ids.is_empty() && !symbol_texts(d, None).into_iter().any(|(t, _)| lacks(t)) { + // The type in opacity masks and in the art of symbols and patterns too, hidden or locked type + // included, which exports name when its font is missing. + if ids.is_empty() && !art_texts_of(d).into_iter().any(|(_, t, _)| lacks(t)) { return Err(EngineError::Other("No fonts are missing".into())); } let runs = s.edit("Replace Missing Fonts", |d, _| { @@ -1008,7 +1009,7 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result { replace(t); } } - edit_symbol_texts(d, None, &|t, _| lacks(t), &mut replace); + edit_art_texts_of(d, &lacks, &mut replace); Ok(runs) })?; Ok(json!({ "runs": runs, "font": family })) diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs index 0af24be36a..3769cf3b42 100644 --- a/crates/engine/src/cmd/addons/cleanup/text/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -415,3 +415,53 @@ fn replace_missing_fonts_reaches_the_type_in_symbols() { s.execute("symbol.exitEditing", &json!({})).unwrap(); assert_eq!(crate::cmd::fonts::substitution_warning(&s.doc().unwrap().doc), None); } + +/// Replace Missing Fonts reaches the type in opacity masks (of a hidden object, disabled, in a +/// symbol's art) and in a pattern swatch nothing paints with, and the export warning, the missing +/// fonts list and Find Font name that type's font until it is replaced. +#[test] +fn replace_missing_fonts_reaches_the_type_in_masks_and_patterns() { + let mut s = session(); + let mask = |s: &mut Session, owner: NodeId, art: NodeId| { + s.edit("Mask", |d, _| { + let art = d.remove(art)?; + d.node_mut(owner).unwrap().extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new((*art).clone(), true))); + Ok(()) + }) + .unwrap(); + }; + let lost = |s: &mut Session| text(s, json!({"x": 40, "y": 60, "text": "Lost", "size": 20, "font": "No Such Font Family"})); + let rect = + |s: &mut Session| NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap()); + let (hidden, disabled, in_symbol) = (rect(&mut s), rect(&mut s), rect(&mut s)); + for owner in [hidden, disabled, in_symbol] { + let t = lost(&mut s); + mask(&mut s, owner, t); + } + s.execute("select.set", &json!({"ids": [hidden.0]})).unwrap(); + s.execute("object.hide", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [disabled.0]})).unwrap(); + s.execute("transparency.disableOpacityMask", &json!({})).unwrap(); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [in_symbol.0]})).unwrap(); + let t = lost(&mut s); + s.execute("object.pattern.make", &json!({"ids": [t.0], "name": "Unused", "edit": false})).unwrap(); + select(&mut s, &[t]); + s.execute("edit.clear", &json!({})).unwrap(); + let mut on_layers = 0; + s.doc().unwrap().doc.walk(|n| on_layers += usize::from(matches!(n.kind, NodeKind::Text(_)))); + assert_eq!(on_layers, 0, "the type is only in masks, a symbol and a pattern"); + let missing = |s: &Session| crate::cmd::fontfiles::missing_fonts(&s.doc().unwrap().doc).into_iter().map(|f| f.family).collect::>(); + assert_eq!(missing(&s), ["No Such Font Family"]); + assert!(crate::cmd::fonts::substitution_warning(&s.doc().unwrap().doc).is_some_and(|w| w.contains("No Such Font Family"))); + let listed = s.execute("text.fonts", &json!({})).unwrap(); + assert_eq!((listed[0]["family"].as_str(), listed[0]["objects"].as_u64()), (Some("No Such Font Family"), Some(4)), "{listed}"); + let steps = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = steps(&s); + let r = s.execute("addon.cleanup.replaceFonts", &json!({})).unwrap(); + assert_eq!(r["runs"].as_u64(), Some(4), "{r}"); + assert_eq!(steps(&s), before + 1); + assert!(missing(&s).is_empty()); + assert_eq!(crate::cmd::fonts::substitution_warning(&s.doc().unwrap().doc), None); + assert_eq!(other(s.execute("addon.cleanup.replaceFonts", &json!({}))), "No fonts are missing"); + assert_eq!(steps(&s), before + 1); +} diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 76b4e1a715..347968b2c5 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -208,7 +208,7 @@ pub fn specs() -> Vec { "Symbol Options…", [], None, - "{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static|dynamic} rename a symbol and/or set Symbol Options › Registration, Export Type, 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions 0..1) and Symbol Type (a dynamic symbol's instances may change the appearance of objects in its art and stay linked; making it static drops those changes); default name: current → {name, registration, exportType, nineSlice, type}", + "{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static|dynamic} rename a symbol (its instances and the graphics mapped onto 3D objects follow the new name) and/or set Symbol Options › Registration, Export Type, 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions 0..1) and Symbol Type (a dynamic symbol's instances may change the appearance of objects in its art and stay linked; making it static drops those changes); default name: current → {name, registration, exportType, nineSlice, type}", has_doc, symbol_options ), @@ -326,7 +326,7 @@ pub fn specs() -> Vec { "Select All Unused", [], None, - "{} → {names: [the symbols nothing places, in panel order]} (the Symbols panel selects them): instances in the art (opacity masks included), graph designs, pattern tiles (with the tiles that pattern editing's Cancel puts back) and brush art count, and so do instances in the art of a symbol that is used", + "{} → {names: [the symbols nothing places, in panel order]} (the Symbols panel selects them): instances in the art (opacity masks included), graphics mapped onto 3D objects (threeD.mapGraphic), graph designs, pattern tiles (with the tiles that pattern editing's Cancel puts back) and brush art count, and so do instances in the art of a symbol that is used", has_doc, symbol_unused ), diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index b789f600b4..edd2335f6a 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -115,6 +115,8 @@ fn art_symbols(n: &Node, out: &mut Vec) { if let NodeKind::SymbolInstance { symbol, .. } = &m.kind { out.push(symbol.clone()); } + // A graphic on a 3D object draws its symbol's art too. + out.extend(vectorcraft_render::effects::graphic_symbols(m)); }); } @@ -129,6 +131,8 @@ pub(super) fn symbol_unused(s: &mut Session, _: &Value) -> Result { let patterns = d.patterns.iter().chain(d.pattern_edit.iter().filter_map(|pe| pe.original.as_ref())); let trees = d.layers.iter().map(|l| &**l).chain(d.graph_designs.iter().map(|g| &*g.art)); trees.chain(patterns.flat_map(|p| p.art.iter().map(|n| &**n))).chain(lib.iter().flat_map(|b| b.art())).for_each(|n| art_symbols(n, &mut todo)); + // A graphic in a graphic style draws its symbol's art wherever the style is applied. + todo.extend(d.graphic_styles.iter().flat_map(|g| vectorcraft_render::effects::appearance_graphic_symbols(&g.appearance))); let mut used = std::collections::BTreeSet::new(); while let Some(name) = todo.pop() { if !used.contains(&name) { @@ -290,6 +294,33 @@ pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result { *symbol = n.clone(); } } + // The graphics mapped onto 3D objects follow the new name: in the layers, the art of + // symbols, patterns, graph designs and brushes, their opacity masks, and graphic styles. + let patterns = d.patterns.iter_mut().chain(d.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())); + let trees = d + .layers + .iter_mut() + .chain(d.symbols.iter_mut().map(|sym| &mut sym.art)) + .chain(d.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(patterns.flat_map(|p| p.art.iter_mut())); + for art in trees.filter(|art| vectorcraft_render::effects::names_graphic_symbol(art, &name)) { + vectorcraft_render::effects::rename_graphic_symbol_in(Arc::make_mut(art), &name, n); + } + let styled = |g: &vectorcraft_doc::GraphicStyle| vectorcraft_render::effects::appearance_graphic_symbols(&g.appearance).contains(&name); + if d.graphic_styles.iter().any(styled) { + for style in d.graphic_styles.iter_mut() { + vectorcraft_render::effects::rename_appearance_graphic_symbol(&mut style.appearance, &name, n); + } + } + // The brush library is stored again only when a brush's art changed. + let mut lib = brush::library(d); + let mut brushes = false; + for art in lib.iter_mut().flat_map(|b| b.art_mut()) { + brushes |= vectorcraft_render::effects::rename_graphic_symbol_in(art, &name, n); + } + if brushes { + brush::store(d, &lib); + } rename_natural_size(d, &name, n); d.rename_symbol_registration(&name, n); d.rename_symbol_export_type(&name, n); diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index b1377d2fef..291c221756 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -86,6 +86,10 @@ fn paint_refs(p: &Paint, f: Visit) { } fn appearance_refs(a: &Appearance, f: Visit) { + // The graphics mapped onto the faces of its 3D effects name symbols. + for s in vectorcraft_render::effects::appearance_graphic_symbols(a) { + f(Ref::Name(Res::Symbol, &s)); + } for it in &a.items { paint_refs(it.paint(), f); if let AppearanceItem::Stroke(l) = it @@ -620,6 +624,17 @@ impl Relink { } } } + // A 3D effect's mapped graphics follow their symbols' names in the document. Each name is + // looked up once, so renames that chain (A to B, B to C) do not apply twice. + let items = a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut()); + for e in a.effects.iter_mut().chain(items).filter(|e| vectorcraft_render::effects::takes_graphics(&e.id)) { + let list = e.params.get_mut(vectorcraft_render::effects::GRAPHICS).and_then(Value::as_array_mut); + for g in list.into_iter().flatten() { + if let Some(Value::String(s)) = g.get_mut("symbol") { + self.name(Res::Symbol, s); + } + } + } for it in &mut a.items { self.paint(it.paint_mut()); if let AppearanceItem::Stroke(l) = it @@ -697,15 +712,6 @@ fn same_brush(r: &Relink, ours: &Brush, theirs: &Brush) -> bool { relinked(&Relink::default(), ours) == relinked(r, theirs) } -/// Does brush `b`'s art show symbols or patterns? -fn shows_definitions(b: &Brush) -> bool { - let mut found = false; - for a in b.art() { - node_refs(a, &mut |r| found |= matches!(r, Ref::Name(Res::Symbol | Res::Pattern, _))); - } - found -} - /// A symbol's Symbol Options, which its document keeps by symbol name: copied with the symbol and /// given to it under the name pasting adds it as. #[derive(Clone, Debug, PartialEq)] @@ -739,7 +745,7 @@ impl SymbolOptions { } /// A character or paragraph style, symbol (with the Symbol Options copied with it), pattern or -/// brush (placed with the others only when they are placed in use order) to place +/// brush to place /// ([`Clipboard::import_used`]). #[derive(Clone, Copy)] enum Def<'c> { @@ -1055,49 +1061,40 @@ impl Clipboard { } /// Add the resources the objects use to `d` (inside the paste's undo step) and work out how - /// pasted copies relink to them. An error, with `d` unchanged, when a pasted SVG filter finds no - /// free name ([`Self::filter_links`]). + /// pasted copies relink to them. Each style, symbol, pattern and brush is placed after those it + /// uses, as [`import_text_styles`] places them, so that a style that paints with a pattern added + /// under a free name ("Dots 2") is compared and added with that name. An error, with `d` + /// unchanged, when a pasted SVG filter finds no free name ([`Self::filter_links`]). pub(crate) fn import_into(&self, d: &mut Document, choices: &SwatchChoices, same_doc: bool) -> Result { - self.import_used(d, &self.used(), choices, same_doc, false) + self.import_used(d, &self.used(), choices, same_doc) } - /// [`Self::import_into`] for the resources `used` names. The character and paragraph styles - /// come before the symbols and patterns, whose art may set type in them; with `in_use_order`, - /// each style, symbol, pattern and brush comes after the styles, symbols, patterns and brushes it uses - /// ([`use_order`]), so that it is compared and relinked with their names here, and each loop - /// of definitions that use each other is matched on its own ([`loop_groups`]). - fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Result { + /// [`Self::import_into`] for the resources `used` names. Each style, symbol, pattern and brush + /// comes after the styles, symbols, patterns and brushes it uses ([`use_order`]), so that it is + /// compared and relinked with their names here, and each loop of definitions that use each + /// other is matched on its own ([`loop_groups`]). Pasted back into its own document + /// (`same_doc`), each definition of a loop is the document's of its name. + fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool) -> Result { let mut out = Imported::default(); out.relink.filters = self.filter_links(d, same_doc)?; self.import_images(d, used, same_doc, &mut out); self.import_swatches(d, used, choices, same_doc, &mut out); - // Brushes whose art shows no symbols or patterns, before the symbols and patterns whose art - // they may brush; the others once those are in. With `in_use_order`, the brushes are - // placed in use order with the others. - let mut added_brushes: HashSet = - if in_use_order { HashSet::new() } else { self.import_brushes(d, used, same_doc, false, &mut out).into_iter().collect() }; let mut defs: Vec> = vec![]; for (r, list) in [(Res::CharStyle, &self.char_styles), (Res::ParaStyle, &self.para_styles)] { defs.extend(list.iter().filter(|s| used.has(r, &s.name)).map(|s| Def::Style(r, s))); } defs.extend(self.symbols.iter().filter(|s| used.has(Res::Symbol, &s.name)).map(|s| Def::Symbol(s, self.symbol_options.get(&s.name)))); defs.extend(self.patterns.iter().filter(|p| used.has(Res::Pattern, &p.name)).map(Def::Pattern)); - if in_use_order { - defs.extend(self.brushes.iter().filter(|b| used.has(Res::Brush, &b.name)).map(Def::Brush)); - } + defs.extend(self.brushes.iter().filter(|b| used.has(Res::Brush, &b.name)).map(Def::Brush)); // Groups to place in order; true for a loop of definitions that use each other. - let groups: Vec<(Vec>, bool)> = if in_use_order { - let (order, rest) = use_order(defs); - let loops = loop_groups(&rest).into_iter().map(|(at, looped)| (at.into_iter().filter_map(|i| rest.get(i).copied()).collect(), looped)); - std::iter::once((order, false)).chain(loops).collect() - } else { - vec![(defs, false)] - }; - // Styles (their colors relinked first), symbols and patterns (compared once relinked, - // symbols with their options too). The symbols and patterns added are relinked at the end, - // when every new name and graphic style id is known; with `in_use_order`, so are the - // styles added, which a loop of uses may place before a pattern they paint with. - let (mut added_symbols, mut added_patterns, mut added_styles) = (HashSet::new(), HashSet::new(), vec![]); + let (order, rest) = use_order(defs); + let loops = loop_groups(&rest).into_iter().map(|(at, looped)| (at.into_iter().filter_map(|i| rest.get(i).copied()).collect(), looped)); + let groups: Vec<(Vec>, bool)> = std::iter::once((order, false)).chain(loops).collect(); + // Styles (their colors relinked first), symbols, patterns and brushes (compared once + // relinked, symbols with their options too). The styles, symbols, patterns and brushes + // added are relinked at the end, when every new name and graphic style id is known: a loop + // of uses may place one before a definition it uses. + let (mut added_symbols, mut added_patterns, mut added_brushes, mut added_styles) = (HashSet::new(), HashSet::new(), HashSet::new(), vec![]); // Each definition, whether it is in a loop, and the loop it starts. let steps = groups .iter() @@ -1107,8 +1104,8 @@ impl Clipboard { let mut fresh = false; // The names in the document of the loop being placed, when it has them all at once. let mut matched: BTreeMap<(Res, String), String> = BTreeMap::new(); - // The document's brush library, read the first time a brush is placed in use order and - // stored again after each brush added; nothing else in the loop changes it. + // The document's brush library, read the first time a brush is placed and stored again + // after each brush added; nothing else in the loop changes it. let mut library: Option> = None; for (def, looped, starts) in steps { fresh &= looped; @@ -1116,8 +1113,9 @@ impl Clipboard { matched.clear(); } // A loop of uses that the document has all at once is known under those names before - // its definitions are compared. - if let Some(group) = starts { + // its definitions are compared. In its own document, each definition is the + // document's of its name, as outside a loop. + if let Some(group) = starts.filter(|_| !same_doc) { matched.clear(); match names_of_loop(d, group, &out.relink) { Some(names) => { @@ -1146,7 +1144,7 @@ impl Clipboard { out.relink.attrs(&mut s.attrs); let theirs = if r == Res::CharStyle { &mut d.char_styles } else { &mut d.para_styles }; let p = place(theirs, &s, same_doc, |a, b| !fresh && a == b); - if in_use_order && let Some(name) = p.new_name(&def.name) { + if let Some(name) = p.new_name(&def.name) { added_styles.push((r, name, def)); } out.placed(r, &def.name, &p); @@ -1200,9 +1198,6 @@ impl Clipboard { } } } - if !in_use_order { - added_brushes.extend(self.import_brushes(d, used, same_doc, true, &mut out)); - } self.import_graphic_styles(d, used, same_doc, &mut out); for (r, name, def) in added_styles { let theirs = if r == Res::CharStyle { &mut d.char_styles } else { &mut d.para_styles }; @@ -1323,28 +1318,6 @@ impl Clipboard { } } - /// The brushes whose art shows symbols or patterns (`late`), or the others: each one the - /// document has (its art compared once relinked) is used, the others are added. → the names of - /// those added, whose art is relinked at the end. - fn import_brushes(&self, d: &mut Document, used: &Used, same_doc: bool, late: bool, out: &mut Imported) -> Vec { - let brushes: Vec<&Brush> = self.brushes.iter().filter(|b| used.has(Res::Brush, &b.name) && shows_definitions(b) == late).collect(); - if brushes.is_empty() { - return vec![]; - } - let mut lib = vectorcraft_brush::library(d); - let mut added = vec![]; - for b in brushes { - let r = &out.relink; - let p = place(&mut lib, b, same_doc, |ours, theirs| same_brush(r, ours, theirs)); - added.extend(p.new_name(&b.name)); - out.placed(Res::Brush, &b.name, &p); - } - if !added.is_empty() { - vectorcraft_brush::store(d, &lib); - } - added - } - /// Global and spot colours (a name conflict merges or adds as `choices` say), then the tint /// swatches of the tints used and the gradient swatches. fn import_swatches(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, out: &mut Imported) { @@ -1429,7 +1402,7 @@ pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool, let used = Used::of_text_styles(&Defs::of(src, &library), chars, paras); let clip = Clipboard::with_used(src, &used, &library, vec![]); let before = [&d.char_styles, &d.para_styles].map(|l| l.iter().map(|s| s.name.clone()).collect::>()); - let out = clip.import_used(d, &used, &SwatchChoices::default(), false, true)?; + let out = clip.import_used(d, &used, &SwatchChoices::default(), false)?; let mut placed: Vec = vec![]; for (para, r, list) in [(false, Res::CharStyle, &clip.char_styles), (true, Res::ParaStyle, &clip.para_styles)] { for s in list { diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index ac79d0acc7..dd56ab8404 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts (the type in opacity masks and in the art of symbols and patterns included; with selectionOnly: the selected type's), images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", has_doc, info ), @@ -627,16 +627,22 @@ fn info(s: &mut Session, p: &Value) -> Result { } }); } + // The document's fonts include those of the type in opacity masks and in the art of its + // symbols and patterns, as Find Font lists them. + if !selection_only { + fonts.extend(super::fonts::art_texts_of(d).into_iter().flat_map(|(_, t, _)| t.runs.iter().map(|r| super::fonts::font_of(&r.style)))); + } let font_names: Vec = fonts.iter().map(super::fonts::font_label).collect(); // Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the last of two - // brushes with one name); a brush the panel does not hold draws as a plain stroke and has no type. + // brushes with one name); a brush the panel does not hold draws as a plain stroke and has no type. A row names the type + // as the New Brush dialog does (`Art Brush`), and the panel translates these values when it shows them. let kinds: HashMap<&str, &vectorcraft_brush::BrushKind> = library.iter().map(|b| (b.name.as_str(), &b.kind)).collect(); let brush_entries: Vec = brushes.iter().map(|name| json!({ "name": name, "type": kinds.get(name.as_str()).map(|k| k.type_id()) })).collect(); let brush_rows: Vec<(String, String)> = brushes .iter() .map(|name| { - (name.clone(), kinds.get(name.as_str()).map_or_else(|| "missing from the Brushes panel".to_string(), |k| format!("{} brush", k.label()))) + (name.clone(), kinds.get(name.as_str()).map_or_else(|| "missing from the Brushes panel".to_string(), |k| format!("{} Brush", k.label()))) }) .collect(); let mut out = json!({ @@ -755,6 +761,40 @@ mod tests { assert!(i["objects"]["textObjects"].is_null() && i["objects"]["paths"] == 1); } + /// Document Info's fonts include the type in an opacity mask, in a symbol's art and in a + /// pattern swatch that nothing paints with; with `selectionOnly`, only the selected type's. + #[test] + fn info_fonts_include_masks_symbols_and_patterns() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + let id = |v: Value| NodeId(v["id"].as_u64().unwrap()); + let text = |s: &mut Session, font: &str| id(s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi", "font": font})).unwrap()); + let rect = |s: &mut Session| id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + let (masked, in_symbol) = (rect(&mut s), rect(&mut s)); + for (owner, font) in [(masked, "Mask Family"), (in_symbol, "Symbol Mask Family")] { + let t = text(&mut s, font); + s.edit("Mask", |d, _| { + let art = d.remove(t)?; + d.node_mut(owner).unwrap().extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new((*art).clone(), true))); + Ok(()) + }) + .unwrap(); + } + s.execute("symbol.new", &json!({"name": "Mark", "ids": [in_symbol.0]})).unwrap(); + let t = text(&mut s, "Pattern Family"); + s.execute("object.pattern.make", &json!({"ids": [t.0], "name": "Unused", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let plain = text(&mut s, "Source Sans 3"); + let fonts = |s: &mut Session, p: Value| s.execute("document.info", &p).unwrap()["fonts"].clone(); + assert_eq!( + fonts(&mut s, json!({})), + json!(["Mask Family Regular", "Pattern Family Regular", "Source Sans 3 Regular", "Symbol Mask Family Regular"]) + ); + s.execute("select.set", &json!({"ids": [plain.0]})).unwrap(); + assert_eq!(fonts(&mut s, json!({"selectionOnly": true})), json!(["Source Sans 3 Regular"])); + } + /// Document Info › Objects gives the paths' total length in the document's units (#1958): an /// open line, a closed rectangle's perimeter and a compound path's pieces count, a guide doesn't. #[test] @@ -805,10 +845,10 @@ mod tests { let i = brushes(&mut s, json!({"category": "brushes"})); assert_eq!(i["brushNames"], json!(["Charcoal", "Dots"])); assert_eq!(i["brushes"], json!([{"name": "Charcoal", "type": "art"}, {"name": "Dots", "type": "scatter"}])); - assert_eq!(i["sections"][0]["rows"], json!([["Charcoal", "Art brush"], ["Dots", "Scatter brush"]])); + assert_eq!(i["sections"][0]["rows"], json!([["Charcoal", "Art Brush"], ["Dots", "Scatter Brush"]])); assert!(panel.contains(&json!("3 pt. Round")) && i["panelBrushNames"] == json!(panel)); let text = brushes(&mut s, json!({"category": "brushes", "format": "text"}))["text"].as_str().unwrap().to_string(); - assert!(text.contains("\nBRUSHES\nCharcoal: Art brush\nDots: Scatter brush\n") && !text.contains("3 pt. Round"), "{text}"); + assert!(text.contains("\nBRUSHES\nCharcoal: Art Brush\nDots: Scatter Brush\n") && !text.contains("3 pt. Round"), "{text}"); assert!(!text.contains("OBJECTS"), "{text}"); // Selection Only: the brushes the selected objects' strokes use. let selected = |s: &mut Session| brushes(s, json!({"selectionOnly": true, "category": "brushes"})); @@ -818,10 +858,10 @@ mod tests { let i = selected(&mut s); assert_eq!( (i["brushes"].clone(), i["sections"][0]["rows"].clone()), - (json!([{"name": "Dots", "type": "scatter"}]), json!([["Dots", "Scatter brush"]])) + (json!([{"name": "Dots", "type": "scatter"}]), json!([["Dots", "Scatter Brush"]])) ); let text = brushes(&mut s, json!({"selectionOnly": true, "category": "brushes", "format": "text"}))["text"].as_str().unwrap().to_string(); - assert!(text.contains("\nBRUSHES\nDots: Scatter brush\n") && !text.contains("Charcoal"), "{text}"); + assert!(text.contains("\nBRUSHES\nDots: Scatter Brush\n") && !text.contains("Charcoal"), "{text}"); // A stroke without a brush lists none. let plain = s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); s.execute("select.set", &json!({"ids": [plain]})).unwrap(); diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index b570ce332a..8040b0016c 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -34,8 +34,15 @@ fn svg_filter_name(s: &Session, xml: &str, cmd: &str) -> Result { Ok(name) } -/// The effect commands of the Effect menu and the Appearance panel. +/// The effect commands of the Effect menu and the Appearance panel, and the 3D and Materials +/// panel's Graphics. pub fn specs() -> Vec { + let mut specs = effect_specs(); + specs.extend(graphics::specs()); + specs +} + +fn effect_specs() -> Vec { vec![ cmd!( "effect.apply", @@ -454,6 +461,9 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { let on = at.and_then(|at| at.effects_item(&n.appearance, C).ok()).flatten(); let on = on.and_then(|i| Some((i, n.appearance.items.get(i)?.effects().len()))); effects::validate_three_d_at_in(Some(d), n, id, &effect.params, on).map_err(|e| bad(C, e))?; + if let Some(name) = effects::missing_symbol(d, &effect.params, None) { + return Err(bad(C, format!("no symbol named `{name}`"))); + } if id == effects::REVOLVE && effects::has_revolve(n) { return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead.")); } @@ -758,6 +768,9 @@ fn set_params(s: &mut Session, p: &Value) -> Result { if let (Some(cur), Value::Object(new)) = (prospective.as_object_mut(), effects::upgrade_params(to, ¶ms)) { cur.extend(new); } + if let Some(name) = effects::missing_symbol(d, &prospective, Some(&e.params)) { + return Err(bad(C, format!("no symbol named `{name}`"))); + } if visible != Some(false) { effects::validate_three_d_at_in(Some(d), n, to, &prospective, target.map(|item| (item, index))).map_err(|e| bad(C, e))?; } else { @@ -1201,6 +1214,8 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { Ok(out) } +mod graphics; + #[cfg(test)] mod lighting_presets_tests; diff --git a/crates/engine/src/cmd/effectcmd/graphics.rs b/crates/engine/src/cmd/effectcmd/graphics.rs new file mode 100644 index 0000000000..1801c83492 --- /dev/null +++ b/crates/engine/src/cmd/effectcmd/graphics.rs @@ -0,0 +1,153 @@ +//! Window › 3D and Materials › Materials › Graphics: art kept as a document symbol (Add as Single +//! Graphic) and mapped onto the planar faces of 3D objects (Extrude & Bevel's caps, a plane). The +//! graphics an object shows are the `graphics` parameter of its 3D effect, which `effect.setParams` +//! also edits. + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::{Effect, Node}; +use vectorcraft_render::effects; + +use super::super::appearance::{appearance_targets, index_param, item_target}; +use super::super::brushsym::{normalize, selection_art, store_symbol}; +use super::super::{CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, has_doc, has_selection, str_param}; +use super::{effect_targets, ids_json}; + +pub(super) fn specs() -> Vec { + vec![ + cmd!( + "threeD.addGraphic", + "Add as Single Graphic", + [], + None, + "{name?: string (default Graphic; made unique), ids?|id?} keep the selection's art as a new document symbol, listed among the 3D and Materials panel's Graphics; the selection stays as it is → {name}", + has_selection, + add_graphic + ), + cmd!( + "threeD.mapGraphic", + "Map Graphic", + [], + None, + "{symbol: name of a document symbol, face?: front|back (front; Plane has only a front; the back reads left to right as seen from behind), x?: pt, y?: pt (0, 0: the offset of the graphic's center from the center of the object's bounds, in the face's own plane), scale?: % (1..10000; default 100, or less so the graphic fits inside the object), rotation?: degrees (-360..360, 0; counterclockwise), index?: int (the 3D effect's position; default: each object's first 3D effect, its own before its fills' and strokes'), item?: appearance item index|null (refused without index; as effect.setParams), ids?|id?} add a graphic to the 3D effect of each selected object, drawn on that face turned, foreshortened and shaded with it and clipped to it; every object's 3D effect must be Extrude & Bevel (on a solid cap) or Rotate (the Plane type); one undo step → {ids, graphic}", + has_doc, + map_graphic + ), + ] +} + +/// `threeD.addGraphic`: stores the selection's art as a symbol without replacing it with an +/// instance. +fn add_graphic(s: &mut Session, p: &Value) -> Result { + const C: &str = "threeD.addGraphic"; + if let Some(key) = p.as_object().and_then(|m| m.keys().find(|k| !["name", "ids", "id"].contains(&k.as_str()))) { + return Err(bad(C, format!("unknown parameter `{key}`"))); + } + if p.get("name").is_some_and(|v| v.as_str().is_none_or(|n| n.trim().is_empty())) { + return Err(bad(C, "`name` must be a name that is not empty")); + } + let mut art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?; + crate::cmd::threads::keep_shown_text(&s.doc()?.doc, &mut art); + let (art, size, _) = normalize(art)?; + let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("Graphic")); + s.edit("Add as Single Graphic", |d, _| { + store_symbol(d, &art, &name, size); + Ok(()) + })?; + Ok(json!({ "name": name })) +} + +/// The 3D effect of `n` that a graphic goes on when none is named: its own first, then its fills' +/// and strokes', as the 3D and Materials panel finds it. +fn first_three_d(n: &Node) -> Option<(Option, usize)> { + let at = |fx: &[Effect]| fx.iter().position(|e| effects::is_three_d(&e.id)); + at(&n.appearance.effects) + .map(|i| (None, i)) + .or_else(|| n.appearance.items.iter().enumerate().find_map(|(k, item)| at(item.effects()).map(|i| (Some(k), i)))) +} + +/// `threeD.mapGraphic`: appends a graphic to the 3D effect of each target, after checking that every +/// target takes it. +fn map_graphic(s: &mut Session, p: &Value) -> Result { + const C: &str = "threeD.mapGraphic"; + const KEYS: [&str; 10] = ["symbol", "face", "x", "y", "scale", "rotation", "index", "item", "ids", "id"]; + if let Some(key) = p.as_object().and_then(|m| m.keys().find(|k| !KEYS.contains(&k.as_str()))) { + return Err(bad(C, format!("unknown parameter `{key}`"))); + } + // An item names where the effect is in that item's list, so it takes an index too. + if p.get("item").is_some() && p.get("index").is_none() { + return Err(bad(C, "`item` needs `index`")); + } + // The graphic's own settings, checked as the effect checks them. + let mut given = Map::new(); + for key in ["symbol", "face", "x", "y", "scale", "rotation"] { + if let Some(v) = p.get(key) { + given.insert(key.into(), v.clone()); + } + } + let base = effects::MappedGraphic::from_value(&Value::Object(given)).map_err(|e| bad(C, e))?; + let d = &s.doc()?.doc; + if !d.symbols.iter().any(|sym| sym.name == base.symbol) { + return Err(bad(C, format!("no symbol named `{}`", base.symbol))); + } + let targets: Vec<(NodeId, Option, usize)> = if p.get("index").is_some() { + let index = index_param(p, "index", C)?; + let item = item_target(s, p, C)?; + effect_targets(s, p, item, index, C)? + } else { + let mut out = vec![]; + for id in appearance_targets(s, p)? { + let (item, index) = d.node(id).and_then(first_three_d).ok_or_else(|| bad(C, format!("object {} needs a 3D effect", id.0)))?; + out.push((id, item, index)); + } + out + }; + if targets.is_empty() { + return Err(bad(C, "select an object with a 3D effect")); + } + let mut edits = Vec::with_capacity(targets.len()); + for &(id, item, index) in &targets { + let n = d.node(id).ok_or(EngineError::NoNode(id))?; + let Some(effect) = n.appearance.effects_at(item).and_then(|fx| fx.get(index)).filter(|e| effects::is_three_d(&e.id)) else { + return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0))); + }; + if !effects::takes_graphics(&effect.id) { + return Err(bad(C, format!("object {}: Graphics map onto planar faces: use the Extrude or Plane object type", id.0))); + } + // A hollow Extrude & Bevel keeps the graphics it has, to show when Cap is turned on again, + // but has no cap to show a new one on. + let merged = effects::merged_params(&effect.id, &effect.params); + let extrude = (effect.id == effects::EXTRUDE).then(|| effects::extrude_options(&merged)); + if extrude.is_some_and(|o| o.depth > 0.0 && !o.cap) { + return Err(bad(C, format!("object {}: a hollow Extrude & Bevel has no caps: turn on Cap to map a graphic", id.0))); + } + // With a depth of 0 the art is a plane, which has only a front. + if base.face == effects::PlanarFace::Back && extrude.is_some_and(|o| o.depth <= 0.0) { + return Err(bad(C, format!("object {}: with a depth of 0 the object has one face: map its graphics onto the front", id.0))); + } + let mut graphic = base.clone(); + if p.get("scale").is_none() + && let (Some(frame), Some(art)) = (effects::graphic_frame_at(Some(d), n, item, index), effects::graphic_bounds(d, &graphic.symbol)) + { + graphic.scale = frame.fitting_scale(art); + } + let params = effects::with_graphic(&effects::merged_params(&effect.id, &effect.params), &graphic); + effects::validate_three_d_at_in(Some(d), n, &effect.id, ¶ms, item.map(|i| (i, index))).map_err(|e| bad(C, e))?; + edits.push((id, item, index, graphic)); + } + let first = edits.first().map(|(.., g)| g.to_value()); + let ids = s.edit("Map Graphic", |d, _| { + let mut done = vec![]; + for (id, item, index, graphic) in &edits { + let Some(e) = d.node_mut(*id).and_then(|n| n.appearance.effects_mut(*item)).and_then(|fx| fx.get_mut(*index)) else { continue }; + let current = effects::upgrade_params(&e.id, &e.params); + e.params = effects::with_graphic(¤t, graphic); + done.push(*id); + } + Ok(done) + })?; + Ok(json!({ "ids": ids_json(&ids), "graphic": first })) +} + +#[cfg(test)] +#[path = "graphics_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/effectcmd/graphics_tests.rs b/crates/engine/src/cmd/effectcmd/graphics_tests.rs new file mode 100644 index 0000000000..125ba82514 --- /dev/null +++ b/crates/engine/src/cmd/effectcmd/graphics_tests.rs @@ -0,0 +1,289 @@ +#![allow(clippy::unwrap_used, clippy::panic)] +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; + +use crate::Session; + +/// A 100 × 100 square with `effect`, and a 10 × 20 red rectangle beside it kept as graphic "Mark". +fn setup(effect: &str) -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":400,"height":400})).unwrap(); + let mark = s.execute("path.create", &json!({"d":"M300 300L310 300L310 320L300 320Z"})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"color":"#ff0000"})).unwrap(); + let made = s.execute("threeD.addGraphic", &json!({"name": "Mark", "ids": [mark]})).unwrap(); + assert_eq!(made["name"], "Mark"); + let id = NodeId(s.execute("path.create", &json!({"d":"M100 100L200 100L200 200L100 200Z"})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color":"#338ad6"})).unwrap(); + s.execute("effect.apply", &json!({"effect":effect,"item":null,"params":{"rotationX":0.0,"rotationY":0.0,"rotationZ":0.0}})).unwrap(); + (s, id) +} + +fn graphics(s: &Session, id: NodeId) -> Value { + s.doc().unwrap().doc.node(id).unwrap().appearance.effects[0].params["graphics"].clone() +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn add_as_single_graphic_keeps_the_art_and_makes_a_symbol() { + let (s, _) = setup("threeD.extrude"); + let d = &s.doc().unwrap().doc; + assert!(d.symbols.iter().any(|sym| sym.name == "Mark")); + // The art stays a plain path: no instance replaced it. + let mut instances = 0; + for l in &d.layers { + l.walk(&mut |n| instances += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::SymbolInstance { .. }))); + } + assert_eq!(instances, 0); + // The 10 × 20 rectangle and its 1 pt stroke. + assert_eq!(vectorcraft_render::effects::graphic_bounds(d, "Mark").map(|r| (r.width(), r.height())), Some((11.0, 21.0))); +} + +#[test] +fn map_graphic_adds_to_the_selected_object_in_one_undo_step_and_undoes() { + let (mut s, id) = setup("threeD.extrude"); + let before = s.doc().unwrap().doc.clone(); + let history = undo_len(&s); + let r = s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "x": 5.0, "rotation": 30.0})).unwrap(); + assert_eq!(r["ids"], json!([id.0])); + assert_eq!(graphics(&s, id), json!([{"symbol": "Mark", "face": "front", "x": 5.0, "y": 0.0, "scale": 100.0, "rotation": 30.0}])); + assert_eq!(undo_len(&s), history + 1); + s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "face": "back"})).unwrap(); + assert_eq!(graphics(&s, id).as_array().unwrap().len(), 2); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers, before.layers); +} + +#[test] +fn a_large_graphic_starts_inside_its_object() { + let (mut s, id) = setup("threeD.rotate"); + let big = s.execute("path.create", &json!({"d":"M0 0L400 0L400 200L0 200Z"})).unwrap()["id"].clone(); + s.execute("threeD.addGraphic", &json!({"name": "Big", "ids": [big]})).unwrap(); + s.execute("threeD.mapGraphic", &json!({"symbol": "Big", "ids": [id.0]})).unwrap(); + // 401 pt wide with its stroke, in a 100 pt square. + let scale = graphics(&s, id)[0]["scale"].as_f64().unwrap(); + assert!((scale - 100.0 * 100.0 / 401.0).abs() < 1e-9, "{scale}"); +} + +#[test] +fn map_graphic_refuses_what_cannot_take_it_and_changes_nothing() { + let (mut s, id) = setup("threeD.inflate"); + let history = undo_len(&s); + let e = s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "ids": [id.0]})).unwrap_err().to_string(); + assert!(e.contains("Extrude or Plane"), "{e}"); + let (mut s, id) = setup("threeD.rotate"); + for (p, says) in [ + (json!({"symbol": "Gone"}), "no symbol named"), + (json!({"face": "front"}), "needs `symbol`"), + (json!({"symbol": "Mark", "face": "back"}), "one face"), + (json!({"symbol": "Mark", "scale": 0.0}), "scale"), + (json!({"symbol": "Mark", "tile": true}), "`tile`"), + (json!({"symbol": "Mark", "item": 0}), "`item` needs `index`"), + (json!({"symbol": "Mark", "item": null}), "`item` needs `index`"), + ] { + let mut p = p; + p["ids"] = json!([id.0]); + let e = s.execute("threeD.mapGraphic", &p).unwrap_err().to_string(); + assert!(e.contains(says), "{p}: {e}"); + } + assert_eq!(undo_len(&s), history); + // An object without a 3D effect. + let plain = s.execute("path.create", &json!({"d":"M0 0L10 0L10 10Z"})).unwrap()["id"].clone(); + let e = s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "ids": [plain]})).unwrap_err().to_string(); + assert!(e.contains("3D effect"), "{e}"); + // A hollow Extrude & Bevel has no cap to show a new graphic on. + let (mut s, id) = setup("threeD.extrude"); + s.execute("effect.setParams", &json!({"index": 0, "item": null, "params": {"cap": false}})).unwrap(); + let history = undo_len(&s); + let e = s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "ids": [id.0]})).unwrap_err().to_string(); + assert!(e.contains("turn on Cap"), "{e}"); + assert_eq!(undo_len(&s), history); + // With a depth of 0 there is no back. + s.execute("effect.setParams", &json!({"index": 0, "item": null, "params": {"cap": true, "depth": 0.0}})).unwrap(); + let history = undo_len(&s); + let e = s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "face": "back", "ids": [id.0]})).unwrap_err().to_string(); + assert!(e.contains("one face"), "{e}"); + assert_eq!(undo_len(&s), history); + assert!(s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "ids": [id.0]})).is_ok()); +} + +#[test] +fn set_params_edits_graphics_and_checks_them() { + let (mut s, id) = setup("threeD.extrude"); + s.execute("threeD.mapGraphic", &json!({"symbol": "Mark"})).unwrap(); + let moved = json!([{"symbol": "Mark", "face": "front", "x": -12.0, "y": 8.0, "scale": 150.0, "rotation": 0.0}]); + s.execute("effect.setParams", &json!({"ids": [id.0], "index": 0, "item": null, "params": {"graphics": moved}})).unwrap(); + assert_eq!(graphics(&s, id), moved); + let bad = json!({"ids": [id.0], "index": 0, "item": null, "params": {"graphics": [{"symbol": "Mark", "face": "top"}]}}); + assert!(s.execute("effect.setParams", &bad).is_err()); + let null = json!({"ids": [id.0], "index": 0, "item": null, "params": {"graphics": null}}); + assert!(s.execute("effect.setParams", &null).is_err()); + assert_eq!(graphics(&s, id), moved); + // Extrude to Plane keeps the graphics; to Inflate drops them. + s.execute("effect.setParams", &json!({"ids": [id.0], "index": 0, "item": null, "threeDType": "plane"})).unwrap(); + assert_eq!(graphics(&s, id), moved); + s.execute("effect.setParams", &json!({"ids": [id.0], "index": 0, "item": null, "threeDType": "inflate"})).unwrap(); + assert_eq!(graphics(&s, id), Value::Null); +} + +#[test] +fn effect_commands_refuse_an_unknown_symbol_but_keep_a_deleted_ones_graphic() { + let (mut s, id) = setup("threeD.extrude"); + let unknown = json!([{"symbol": "Nowhere"}]); + let history = undo_len(&s); + assert!(s.execute("effect.setParams", &json!({"ids": [id.0], "index": 0, "item": null, "params": {"graphics": unknown}})).is_err()); + s.execute("path.create", &json!({"d":"M0 0L50 0L50 50L0 50Z"})).unwrap(); + let created = undo_len(&s); + assert!(s.execute("effect.apply", &json!({"effect": "threeD.extrude", "item": null, "params": {"graphics": unknown}})).is_err()); + assert_eq!((history + 1, undo_len(&s)), (created, created)); + // A graphic whose symbol was deleted stays while the effect's other settings change. + s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "ids": [id.0]})).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).symbols.retain(|sym| sym.name != "Mark"); + s.execute("effect.setParams", &json!({"ids": [id.0], "index": 0, "item": null, "params": {"depth": 30.0}})).unwrap(); + assert_eq!(graphics(&s, id)[0]["symbol"], "Mark"); +} + +#[test] +fn a_symbol_a_graphic_uses_is_used_and_follows_a_rename() { + let (mut s, id) = setup("threeD.extrude"); + s.execute("threeD.mapGraphic", &json!({"symbol": "Mark"})).unwrap(); + let unused = s.execute("symbol.unused", &json!({})).unwrap(); + assert!(!unused["names"].as_array().unwrap().iter().any(|n| n == "Mark"), "{unused}"); + // A 3D object with the graphic kept in another symbol's art follows the rename too. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + s.execute("symbol.new", &json!({"name": "Holder"})).unwrap(); + s.execute("symbol.options", &json!({"name": "Mark", "newName": "Label"})).unwrap(); + assert_eq!(graphics(&s, id)[0]["symbol"], "Label"); + let d = &s.doc().unwrap().doc; + let holder = d.symbols.iter().find(|sym| sym.name == "Holder").unwrap(); + let mut held = vec![]; + holder.art.walk(&mut |n| held.extend(vectorcraft_render::effects::graphic_symbols(n))); + assert_eq!(held, vec!["Label".to_string()]); +} + +#[test] +fn a_rename_reaches_graphics_in_graph_designs_and_opacity_masks() { + let (mut s, id) = setup("threeD.extrude"); + s.execute("threeD.mapGraphic", &json!({"symbol": "Mark"})).unwrap(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let held = d.node(id).unwrap().clone(); + d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Bars".into(), art: std::sync::Arc::new(held.clone()) }); + d.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Boxed", (*held.appearance).clone())); + let mut lib = vectorcraft_brush::library(d); + let scatter = lib.iter_mut().find(|b| !b.art().is_empty()).unwrap(); + *scatter.art_mut()[0] = held.clone(); + vectorcraft_brush::store(d, &lib); + d.node_mut(id).unwrap().extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(held, true))); + s.execute("symbol.options", &json!({"name": "Mark", "newName": "Label"})).unwrap(); + let d = &s.doc().unwrap().doc; + let style = d.graphic_styles.iter().find(|g| g.name == "Boxed").unwrap(); + assert_eq!(vectorcraft_render::effects::appearance_graphic_symbols(&style.appearance), vec!["Label".to_string()]); + let brush: Vec = + vectorcraft_brush::library(d).iter().flat_map(|b| b.art()).flat_map(vectorcraft_render::effects::graphic_symbols).collect(); + assert_eq!(brush, vec!["Label".to_string()]); + let names = |n: &vectorcraft_doc::Node| { + let mut out = vec![]; + n.walk(&mut |c| out.extend(vectorcraft_render::effects::graphic_symbols(c))); + out + }; + assert_eq!(names(&d.graph_designs[0].art), vec!["Label".to_string()]); + let mask = d.node(id).unwrap().extras().mask.as_deref().map(|m| names(&m.art)); + assert_eq!(mask, Some(vec!["Label".to_string()])); +} + +#[test] +fn pasted_into_another_document_a_graphic_brings_its_symbol() { + let (mut s, id) = setup("threeD.extrude"); + s.execute("threeD.mapGraphic", &json!({"symbol": "Mark"})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + // The symbol the pasted object's graphic names, and the size of that symbol's art. + let pasted = |s: &mut Session| { + s.execute("edit.paste", &json!({})).unwrap(); + let st = s.doc().unwrap(); + let n = st.doc.node(st.selection.objects[0]).unwrap(); + let name = n.appearance.effects[0].params["graphics"][0]["symbol"].as_str().unwrap().to_string(); + let size = vectorcraft_render::effects::graphic_bounds(&st.doc, &name).map(|r| (r.width(), r.height())); + (name, size) + }; + // A document without the symbol gets it. + s.execute("file.new", &json!({"width":400,"height":400})).unwrap(); + assert_eq!(pasted(&mut s), ("Mark".to_string(), Some((11.0, 21.0)))); + // A document whose symbol "Mark" is other art gets the copied symbol under a free name. + s.execute("file.new", &json!({"width":400,"height":400})).unwrap(); + let other = s.execute("path.create", &json!({"d":"M0 0L50 0L50 50L0 50Z"})).unwrap()["id"].clone(); + s.execute("threeD.addGraphic", &json!({"name": "Mark", "ids": [other]})).unwrap(); + let (name, size) = pasted(&mut s); + assert_ne!(name, "Mark"); + assert_eq!(size, Some((11.0, 21.0))); +} + +#[test] +fn a_symbol_only_a_graphic_style_uses_is_used() { + let (mut s, id) = setup("threeD.extrude"); + s.execute("threeD.mapGraphic", &json!({"symbol": "Mark"})).unwrap(); + let appearance = (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone(); + s.execute("effect.setParams", &json!({"index": 0, "item": null, "params": {"graphics": []}})).unwrap(); + let unused = |s: &mut Session| s.execute("symbol.unused", &json!({})).unwrap()["names"].clone(); + assert!(unused(&mut s).as_array().unwrap().iter().any(|n| n == "Mark")); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Boxed", appearance)); + assert!(!unused(&mut s).as_array().unwrap().iter().any(|n| n == "Mark")); +} + +#[test] +fn add_graphic_refuses_bad_parameters_and_changes_nothing() { + let (mut s, _) = setup("threeD.extrude"); + let art = s.execute("path.create", &json!({"d":"M0 0L10 0L10 10Z"})).unwrap()["id"].clone(); + let (symbols, history) = (s.doc().unwrap().doc.symbols.len(), undo_len(&s)); + for p in [ + json!({"name": 5, "ids": [art]}), + json!({"name": null, "ids": [art]}), + json!({"name": "", "ids": [art]}), + json!({"name": " ", "ids": [art]}), + json!({"tile": true, "ids": [art]}), + ] { + assert!(s.execute("threeD.addGraphic", &p).is_err(), "{p}"); + } + assert_eq!((s.doc().unwrap().doc.symbols.len(), undo_len(&s)), (symbols, history)); +} + +#[test] +fn map_graphic_refuses_a_selection_with_an_object_without_a_3d_effect() { + let (mut s, id) = setup("threeD.extrude"); + let plain = s.execute("path.create", &json!({"d":"M0 0L10 0L10 10Z"})).unwrap()["id"].as_u64().unwrap(); + let history = undo_len(&s); + let e = s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "ids": [id.0, plain]})).unwrap_err().to_string(); + assert!(e.contains(&format!("object {plain} needs a 3D effect")), "{e}"); + assert_eq!(undo_len(&s), history); + assert_eq!(graphics(&s, id).as_array().map_or(0, Vec::len), 0); +} + +#[test] +fn the_graphic_is_drawn_on_the_object() { + let (mut s, id) = setup("threeD.extrude"); + s.execute("threeD.mapGraphic", &json!({"symbol": "Mark"})).unwrap(); + let d = &s.doc().unwrap().doc; + let art = vectorcraft_render::effects::solid_art_in(Some(d), d.node(id).unwrap()).unwrap(); + let mut red = None; + art.walk(&mut |n| { + if let (Some(p), Some(vectorcraft_doc::AppearanceItem::Fill(f))) = (n.path_data(), n.appearance.items.first()) + && f.paint.color().is_some_and(|c| c.to_rgb()[0] > 0.3 && c.to_rgb()[1] < 0.1) + { + red = p.bounds(); + } + }); + // Seen straight on, the 10 × 20 graphic sits at the center of the square. + let r = red.unwrap(); + assert!( + (r.center().x - 150.0).abs() < 1e-6 + && (r.center().y - 150.0).abs() < 1e-6 + && (r.width() - 10.0).abs() < 1e-6 + && (r.height() - 20.0).abs() < 1e-6, + "{r:?}" + ); +} diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 1be80f2c56..31539ff51d 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -408,15 +408,31 @@ pub(crate) fn anti_alias(id: &str) -> std::result::Result { /// The `useArtboards` param of a PDF, AI, DXF or raster export (SVG has its own): `true` writes every /// chosen artboard (default all), `false` the bounds of the visible art instead, absent one -/// artboard (raster, DXF) or the chosen pages (PDF, AI). -fn use_artboards(p: &Value) -> Result> { +/// artboard (raster, DXF) or the chosen pages (PDF, AI). Errors name the command `cmd`. +pub(super) fn use_artboards(cmd: &str, p: &Value) -> Result> { match p.get("useArtboards") { None | Some(Value::Null) => Ok(None), Some(Value::Bool(b)) => Ok(Some(*b)), - Some(v) => Err(bad(C, format!("useArtboards must be true or false, not {v}"))), + Some(v) => Err(bad(cmd, format!("useArtboards must be true or false, not {v}"))), } } +/// With `useArtboards: false` in `p`: `doc` without its opacity-mask or symbol editing layer and +/// with one artboard, the bounds of its visible art, and `p` without the artboard choice. `None` +/// when `useArtboards` is absent, null or true. A document with no visible art is refused. +pub(super) fn art_page(cmd: &str, doc: &Document, p: &Value) -> Result> { + if use_artboards(cmd, p)? != Some(false) { + return Ok(None); + } + let doc = &*doc.without_edit_modes(); + let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(cmd, "nothing to export: the document has no visible art"))?; + let mut q = super::export::without_artboards(p); + if let Some(o) = q.as_object_mut() { + o.remove("useArtboards"); + } + Ok(Some((single_artboard(doc, bounds, "Art"), q))) +} + /// The `rect` param of a raster export: `[x, y, width, height]` in points, y down. `None` when /// absent; an error when it is not four finite numbers with a positive size, when the format isn't /// a raster one, or when it comes with an artboard choice (`rect` is the area, so those would @@ -596,7 +612,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { let doc = &*doc.without_edit_modes(); let n = doc.artboards.len(); // SVG reads its own `useArtboards` (an SVG option). - let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf" | "hpgl") { use_artboards(p)? } else { None }; + let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf" | "hpgl") { use_artboards(C, p)? } else { None }; // `rect`: that area of the page, as if it were the only artboard (the document is not changed). if let Some(rect) = page_rect(f, p)? { let mut q = super::export::without_artboards(p); @@ -606,13 +622,17 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { } return encode_files(&single_artboard(doc, rect, "Rect"), f, &q); } - if use_artboards == Some(false) { - let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?; - let mut q = super::export::without_artboards(p); - if let Some(o) = q.as_object_mut() { - o.remove("useArtboards"); - } - return encode_files(&single_artboard(doc, bounds, "Art"), f, &q); + // A PDF of the art's bounds carries no editing data (it would replace the artboards). + if use_artboards == Some(false) + && f.id == "pdf" + && let Some((bytes, notes)) = super::pdf::encode_art_page(C, doc, p)? + { + return Ok(Encoded::one_with_notes(bytes, notes)); + } + if use_artboards == Some(false) + && let Some((art, q)) = art_page(C, doc, p)? + { + return encode_files(&art, f, &q); } let bytes = match f.id { "vectorcraft" => super::native::encode(C, f, doc, p)?, diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index d5137c8b1a..3492fd6051 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -782,9 +782,9 @@ pub fn open_extensions() -> &'static [&'static str] { /// File › Open's filters: "All readable files" first, then one per readable format, then one per /// importer plug-in (under its name, with the extensions [`open_extensions`] takes from it), then -/// swatch libraries (which open in the library panel), Libraries panel libraries, flattener, PDF, -/// print and export presets (imported), scripts (checked without running them) and plug-ins -/// (installed). +/// swatch and graphic style libraries (which open in their library panels), Libraries panel +/// libraries, flattener, PDF, print, perspective grid and export presets and composite fonts +/// (imported), shortcut sets, scripts (checked without running them) and plug-ins (installed). pub fn open_filters() -> impl Iterator { let (all, importers) = importer_open_part(); std::iter::once(("All readable files", all)).chain(format_filters()).chain(importers).chain(other_open_filters()) @@ -797,14 +797,17 @@ pub fn builtin_open_filters() -> impl Iterator impl Iterator { std::iter::once(("Swatch libraries", crate::cmd::swatchlib::LIBRARY_EXTS)) + .chain(std::iter::once(("Graphic Style Libraries", std::slice::from_ref(&vectorcraft_doc::style_libs::STYLES_EXT)))) .chain(std::iter::once(("Libraries", crate::cmd::library::LIBRARY_EXTS))) .chain(std::iter::once(("Flattener presets", crate::cmd::flatten::PRESET_EXTS))) .chain(std::iter::once(("PDF presets", crate::cmd::pdfcmds::PRESET_EXTS))) .chain(std::iter::once(("Print presets", crate::cmd::printpresets::PRESET_EXTS))) + .chain(std::iter::once(("Perspective grid presets", crate::cmd::perspgrid::PRESET_EXTS))) .chain(std::iter::once(("Export presets", screens::PRESET_FILE_EXTS))) .chain(std::iter::once(("Composite fonts", crate::cmd::compositefonts::EXTS))) .chain(std::iter::once(("Shortcut sets", crate::cmd::shortcutset::EXTS))) @@ -815,13 +818,13 @@ fn other_open_filters() -> impl Iterator impl Iterator { - std::iter::once((library_label, library_exts)).chain(builtin_open_filters()) + std::iter::once((library_label, library_exts)).chain(builtin_open_filters().filter(move |f| *f != (library_label, library_exts))) } /// File → Place dialog filters: "All placeable files", then one per placeable format, then text, diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index b11251ba34..acc22c7876 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: false (true: such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art, as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: false (true: such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), @@ -137,7 +137,7 @@ pub fn specs() -> Vec { "PDF Settings", [], None, - "{preset?, includeDocument?: false, …options of document.exportPdf} → {settings (the preset with the options applied), presets: [name…], changed: [{option: \"compression.compressText\", value}] (what differs from the preset), warnings}; includeDocument also exports the active document in memory and adds its warnings (knockout groups approximated, effects left out…). A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{preset?, includeDocument?: false, …options of document.exportPdf} → {settings (the preset with the options applied), presets: [name…], changed: [{option: \"compression.compressText\", value}] (what differs from the preset), warnings}; includeDocument also exports the active document in memory (one page of the art's bounds with useArtboards: false) and adds its warnings (knockout groups approximated, effects left out…). A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", always, pdf_settings ), @@ -280,6 +280,27 @@ fn options_at(cmd: &str, doc: &Document, p: &Value, now: Option) -> Result< pub const EDITING_NEEDS_EVERY_ARTBOARD: &str = "Preserve editing was left out: it needs a PDF of every artboard, in order (reopened, this one would show the others too)"; +/// Why a PDF of the art's bounds (`useArtboards: false`) carries no editing data. +pub const EDITING_NEEDS_THE_ARTBOARDS: &str = + "Preserve editing was left out: it needs a PDF of the artboards (reopened, this one would replace them with one artboard around the art)"; + +/// With `useArtboards: false` in `p`: the PDF of one page, the bounds of `doc`'s visible art +/// ([`super::encode::art_page`]) → (bytes, notes). It carries no editing data: with Preserve +/// Editing on, the notes end with [`EDITING_NEEDS_THE_ARTBOARDS`]. `None` when `p` asks for the +/// artboards. +pub(super) fn encode_art_page(cmd: &str, doc: &Document, p: &Value) -> Result, Vec)>> { + let Some((art, mut q)) = super::encode::art_page(cmd, doc, p)? else { return Ok(None) }; + let left_out = settings(cmd, &q)?.preserve_editing; + if left_out && let Some(o) = q.as_object_mut() { + o.insert("preserveEditing".into(), json!(false)); + } + let (bytes, mut notes) = encode(cmd, &art, &q)?; + if left_out { + notes.push(Note::setting(EDITING_NEEDS_THE_ARTBOARDS)); + } + Ok(Some((bytes, notes))) +} + /// The warning for a PDF/X file that leaves out the document's bleed /// ([`vectorcraft_pdf::PdfSettings::leaves_out_bleed`]). pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings"; @@ -482,7 +503,12 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { } else { q = json!({ "_displayDocTitle": display_doc_title }); } - let (bytes, notes) = encode(C, titled.as_ref().unwrap_or(&st.doc), &q)?; + // The opacity-mask or symbol editing layer is left out. + let doc = titled.as_ref().unwrap_or(&st.doc).without_edit_modes(); + let (bytes, notes) = match encode_art_page(C, &doc, &q)? { + Some(encoded) => encoded, + None => encode(C, &doc, &q)?, + }; write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": note_texts(¬es) })) } @@ -513,9 +539,18 @@ fn pdf_settings(s: &mut Session, p: &Value) -> Result { const Q: &str = "document.pdfSettings"; let saved = &s.prefs.pdf_presets; let set = settings_with(Q, p, saved)?; + super::encode::use_artboards(Q, p)?; let diff = changes(&set, &preset_settings(Q, p, saved)?); let warnings = match s.active() { - Some(st) if bool_or(p, "includeDocument", false) => note_texts(&encode(Q, &st.doc, &*expand_preset(s, Q, p)?)?.1), + Some(st) if bool_or(p, "includeDocument", false) => { + let q = expand_preset(s, Q, p)?; + let doc = st.doc.without_edit_modes(); + let (_, notes) = match encode_art_page(Q, &doc, &q)? { + Some(encoded) => encoded, + None => encode(Q, &doc, &q)?, + }; + note_texts(¬es) + } _ => set.warnings(), }; let v = serde_json::to_value(&set).map_err(|e| EngineError::Other(e.to_string()))?; diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 5ea57858cc..18bb4853b1 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -390,9 +390,20 @@ fn open_exts_cover_every_readable_format() { assert_eq!(filters[0], ("All readable files", OPEN_EXTS)); assert_eq!( filters.len(), - 11 + FORMATS.iter().filter(|f| f.read).count(), - "and swatch libraries, libraries, flattener, PDF, print and export presets, composite fonts, shortcut sets, scripts, plug-ins" + 13 + FORMATS.iter().filter(|f| f.read).count(), + "and swatch and graphic style libraries, libraries, flattener, PDF, print, perspective grid and export presets, composite \ + fonts, shortcut sets, scripts, plug-ins" ); + let at = |label: &str| filters.iter().position(|f| f.0 == label).unwrap_or_else(|| panic!("File › Open has no {label} filter")); + let styles: &[&str] = &[vectorcraft_doc::style_libs::STYLES_EXT]; + assert_eq!(filters[at("Graphic Style Libraries")].1, styles, "File › Open opens graphic style libraries"); + assert_eq!(at("Graphic Style Libraries"), at("Swatch libraries") + 1, "next to the swatch libraries"); + assert_eq!(filters[at("Perspective grid presets")].1, crate::cmd::perspgrid::PRESET_EXTS, "File › Open imports perspective grid presets"); + assert_eq!(at("Perspective grid presets"), at("Print presets") + 1, "among the presets"); + assert_eq!(at("Export presets"), at("Perspective grid presets") + 1, "among the presets"); + for e in [vectorcraft_doc::style_libs::STYLES_EXT].iter().chain(crate::cmd::perspgrid::PRESET_EXTS) { + assert!(!filters[0].1.contains(e), "All readable files lists documents only, not .{e}"); + } assert!(filters.contains(&("Export presets", SCREEN_EXPORT_PRESET_EXTS)), "File › Open imports export presets"); assert!(filters.contains(&("Scripts", crate::cmd::scripts::EXTS)), "File › Open checks scripts"); assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins"); @@ -404,6 +415,11 @@ fn library_open_filters_list_library_formats_first() { assert_eq!(filters[0], ("Swatch libraries", crate::cmd::swatchlib::LIBRARY_EXTS)); assert!(filters[0].1.contains(&"ase")); assert_eq!(filters[1], ("All readable files", OPEN_EXTS)); + let styles: &'static [&'static str] = std::slice::from_ref(&vectorcraft_doc::style_libs::STYLES_EXT); + for (label, exts) in [("Swatch libraries", crate::cmd::swatchlib::LIBRARY_EXTS), ("Graphic Style Libraries", styles)] { + let filters: Vec<_> = library_open_filters(label, exts).collect(); + assert_eq!(filters.iter().filter(|f| f.0 == label).count(), 1, "{label} is listed once"); + } } /// The extensions usage texts list for export: one per writable format, each picking that format diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index 6f47307290..91fe9701e7 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -56,6 +56,126 @@ fn export_pdf_writes_the_compatibility_header_and_the_range() { assert!(b64(&v).starts_with(b"%PDF-1.6")); } +/// `useArtboards: false` writes one page, the bounds of the visible art, as `document.export` +/// does, whatever artboards are chosen. Art longer than 200 in keeps its size with +/// `longPagesInPoints`. A document with no visible art and a value that isn't a boolean are +/// refused. +#[test] +fn use_artboards_false_writes_one_page_of_the_arts_bounds() { + let mut s = session(3); + let page = |bytes: &[u8]| { + let d = vectorcraft_pdf::import(bytes).unwrap(); + (d.artboards.len(), d.artboards[0].rect.size()) + }; + let through_export = page(&b64(&s.execute("document.export", &as_export(&json!({"useArtboards": false}))).unwrap())); + assert!(through_export.0 == 1 && through_export.1.width < 50.0 && through_export.1.height < 40.0, "{through_export:?}"); + for p in [ + json!({"useArtboards": false}), + json!({"useArtboards": false, "range": "2"}), + json!({"useArtboards": false, "artboards": [0, 1]}), + json!({"useArtboards": false, "artboard": 2}), + ] { + assert_eq!(page(&b64(&export(&mut s, p.clone()))), through_export, "{p}"); + } + assert_eq!(pages(&b64(&export(&mut s, json!({"useArtboards": true})))), 3); + assert_eq!(pages(&b64(&export(&mut s, json!({"useArtboards": null})))), 3); + + // A 10 m drawing on a small artboard: its page is 10 m long, in points. + s.execute("shape.rectangle", &json!({"x": 0, "y": 200, "width": 28_346.46, "height": 100})).unwrap(); + let v = export(&mut s, json!({"useArtboards": false, "longPagesInPoints": true})); + let pdf = b64(&v); + let (n, size) = page(&pdf); + assert!(n == 1 && size.width > 28_346.0 && size.width < 28_350.0, "{size:?}"); + assert!(!String::from_utf8_lossy(&pdf).contains("/UserUnit")); + assert!(warnings(&v).iter().any(|w| w.contains("written without a UserUnit, in points")), "{v}"); + // The Summary's export in memory writes the same page. + let mut long = |p: Value| warnings(&s.execute("document.pdfSettings", &p).unwrap()).iter().any(|w| w.contains("longer than PDF's page limit")); + assert!(long(json!({"includeDocument": true, "useArtboards": false}))); + assert!(!long(json!({"includeDocument": true})), "the artboards are small"); + let e = s.execute("document.pdfSettings", &json!({"useArtboards": "no"})).unwrap_err(); + assert!(matches!(e, crate::EngineError::BadParams { .. }), "without includeDocument too: {e}"); + + s.execute("select.all", &json!({})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + for p in [json!({"useArtboards": false}), json!({"useArtboards": "no"})] { + let e = s.execute("document.exportPdf", &p).unwrap_err(); + assert!(matches!(e, crate::EngineError::BadParams { .. }), "{p}: {e}"); + } +} + +/// A PDF of the art's bounds carries no Preserve Editing data, through `document.exportPdf`, +/// `document.export` and the Summary's export in memory, and says so in a warning: reopened, its +/// editing data would replace the document's artboards with the one around the art. A PDF of the +/// artboards keeps the data, and it reopens with every artboard. With Preserve Editing off, there +/// is no warning. +#[test] +fn a_pdf_of_the_arts_bounds_carries_no_editing_data() { + let mut s = session(3); + let left_out = |v: &Value| warnings(v).iter().any(|w| w == super::pdf::EDITING_NEEDS_THE_ARTBOARDS); + let art = json!({"useArtboards": false}); + for v in [export(&mut s, art.clone()), s.execute("document.export", &as_export(&art)).unwrap()] { + let pdf = b64(&v); + assert!(vectorcraft_pdf::editing(&pdf).is_none(), "{:?}", warnings(&v)); + assert!(left_out(&v), "{:?}", warnings(&v)); + assert_eq!(pages(&pdf), 1); + } + let summary = s.execute("document.pdfSettings", &json!({"includeDocument": true, "useArtboards": false})).unwrap(); + assert!(left_out(&summary), "{summary}"); + let quiet = export(&mut s, json!({"useArtboards": false, "preserveEditing": false})); + assert!(!left_out(&quiet), "{:?}", warnings(&quiet)); + + for v in [export(&mut s, json!({})), export(&mut s, json!({"useArtboards": true}))] { + let editing = vectorcraft_pdf::editing(&b64(&v)).expect("editing data"); + let reopened = vectorcraft_format::load(&editing.data).unwrap(); + assert_eq!(reopened.artboards.len(), 3); + assert!(!left_out(&v), "{:?}", warnings(&v)); + } +} + +/// While an opacity mask is edited, a PDF of the art's bounds measures the art without the +/// editing layer, as `document.export` does: a mask shape drawn past the masked object doesn't +/// widen the page. +#[test] +fn a_pdf_of_the_art_leaves_out_the_mask_editing_layer() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + let obj = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap()["id"].clone(); + let mask = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"color": "#ffffff", "ids": [mask]})).unwrap(); + s.execute("select.set", &json!({"ids": [obj, mask]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + s.execute("transparency.editOpacityMask", &json!({})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 250, "y": 250, "width": 40, "height": 40})).unwrap(); + assert!(s.doc().unwrap().doc.mask_edit.is_some()); + let width = |bytes: &[u8]| vectorcraft_pdf::import(bytes).unwrap().artboards[0].rect.width(); + let through_export = width(&b64(&s.execute("document.export", &as_export(&json!({"useArtboards": false}))).unwrap())); + assert!(through_export < 250.0, "{through_export}"); + assert_eq!(width(&b64(&export(&mut s, json!({"useArtboards": false})))), through_export); +} + +/// While an opacity mask is edited, a PDF of the artboards leaves out the editing layer too, as +/// `document.export` does: from the drawn page and from the Preserve Editing data. +#[test] +fn a_pdf_of_the_artboards_leaves_out_the_mask_editing_layer() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + let obj = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap()["id"].clone(); + let mask = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"color": "#ffffff", "ids": [mask]})).unwrap(); + s.execute("select.set", &json!({"ids": [obj, mask]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + s.execute("transparency.editOpacityMask", &json!({})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 250, "y": 250, "width": 40, "height": 40})).unwrap(); + assert!(s.doc().unwrap().doc.mask_edit.is_some()); + let nodes = |bytes: &[u8]| vectorcraft_pdf::import(bytes).unwrap().node_count(); + let plain = json!({"preserveEditing": false}); + let through_export = nodes(&b64(&s.execute("document.export", &as_export(&plain)).unwrap())); + assert_eq!(nodes(&b64(&export(&mut s, plain))), through_export, "the drawn page"); + let layers = |v: &Value| vectorcraft_format::load(&vectorcraft_pdf::editing(&b64(v)).unwrap().data).unwrap().layers; + let through_export = layers(&s.execute("document.export", &as_export(&json!({}))).unwrap()); + assert_eq!(layers(&export(&mut s, json!({}))), through_export, "the Preserve Editing data"); +} + #[test] fn bad_options_are_refused() { let mut s = session(2); diff --git a/crates/engine/src/cmd/fontfiles.rs b/crates/engine/src/cmd/fontfiles.rs index 0ccf1ab94a..fde85dd92d 100644 --- a/crates/engine/src/cmd/fontfiles.rs +++ b/crates/engine/src/cmd/fontfiles.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Missing Fonts", [], None, - "{} → {fonts: [{family, style, status: missing|substitute, resolved: {family, style}}] (sorted by name; at most 1000), count, fontsNextToDocument?} the fonts the active document's type uses, in its layers and its symbols (for type in a composite font, the fonts of the parts its characters are drawn in), that aren't available as named: missing (the family is unknown and the fallback family stands in) or substitute (the family is there but not the style; resolved names the stand-in), as text.fonts says. CSS generic families such as sans-serif and names longer than 256 bytes are left out. fontsNextToDocument: the Fonts folder next to the saved document (File › Package writes it), when there is one and a search may start there (not in another app's folders, such as a mail app's attachments)", + "{} → {fonts: [{family, style, status: missing|substitute, resolved: {family, style}}] (sorted by name; at most 1000), count, fontsNextToDocument?} the fonts the active document's type uses, in its layers, its symbols and its patterns, opacity masks included (for type in a composite font, the fonts of the parts its characters are drawn in), that aren't available as named: missing (the family is unknown and the fallback family stands in) or substitute (the family is there but not the style; resolved names the stand-in), as text.fonts says. CSS generic families such as sans-serif and names longer than 256 bytes are left out. fontsNextToDocument: the Fonts folder next to the saved document (File › Package writes it), when there is one and a search may start there (not in another app's folders, such as a mail app's attachments)", has_doc, missing ), @@ -103,7 +103,7 @@ pub struct MissingFont { pub resolved: Option<(String, String)>, } -/// The fonts the type of `d` uses, in its layers and its symbols (a composite font's parts, those +/// The fonts the type of `d` uses, in its layers, symbols and patterns, opacity masks included (a composite font's parts, those /// its characters are drawn in, in place of its name), that aren't available as named (sorted by family and style, each once whatever version its type names; at most 1000). CSS generic families such as `sans-serif` /// ([`GENERIC_FAMILIES`]) and names longer than 256 bytes are left out. pub fn missing_fonts(d: &Document) -> Vec { diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 2a6d1cb0df..74cece5af3 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fonts in Document", [], None, - "{selectionOnly?} (the type in the art of the symbols in scope included: all of the document's, or those the selected instances show) → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font). For a composite font, status is the worst among the parts its characters are drawn in, and missingGlyphs counts the characters their part's font lacks", + "{selectionOnly?} (the type in the opacity masks of the objects in scope and in the art of the symbols and patterns in scope included: all of the document's, a pattern swatch that nothing paints included, or those the selected objects show through their instances and paints; disabled masks too) → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font). For a composite font, status is the worst among the parts its characters are drawn in, and missingGlyphs counts the characters their part's font lacks", has_doc, fonts ), @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Replace Font", [], None, - "{from: {family, style?}, to: {family (any of its names; stored as the font's own family name, a composite font's as its own name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); the type in the art of the symbols in scope changes too (all of the document's, or those the selected instances show, so every instance of them follows); text that is hidden or locked, in a hidden or locked group or layer (or group of a symbol's art), or on a locked artboard keeps its font; when no run changes (text already set in the new font, or none to change), the document and its undo history stay as they are → {runs: the runs changed, skipped?: the text objects left in the font, warning?: text (some text was left in its font, or the style set isn't available: its type is drawn in the family's closest style)}", + "{from: {family, style?}, to: {family (any of its names; stored as the font's own family name, a composite font's as its own name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); the type in the opacity masks of the objects in scope (disabled masks too) and in the art of the symbols and patterns in scope changes too (all of the document's, or those the selected objects show through their instances and paints, so every instance of a symbol and every object a pattern paints follows); text that is hidden or locked, in a hidden or locked group or layer (or group of a symbol's, pattern's or mask's art), in the mask of a hidden or locked object, or on a locked artboard keeps its font; when no run changes (text already set in the new font, or none to change), the document and its undo history stay as they are → {runs: the runs changed, skipped?: the text objects left in the font, warning?: text (some text was left in its font, or the style set isn't available: its type is drawn in the family's closest style)}", has_doc, replace ), @@ -39,7 +39,7 @@ pub fn specs() -> Vec { "Select Text by Font", [], None, - "{family, style?, scope?: \"document\" (default) | {artboards: [index, …] (0-based)} | {layers: [id, …] (layers or sublayers)}} select the text objects using the font, and the instances of the symbols whose art uses it, that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; with scope, only those on one of the artboards (as All on Active Artboard takes them: their bounds overlap it or their center is on it) or in one of the layers → {count}", + "{family, style?, scope?: \"document\" (default) | {artboards: [index, …] (0-based)} | {layers: [id, …] (layers or sublayers)}} select the text objects using the font, the instances of the symbols whose art uses it, the objects painted with a pattern whose art uses it and the objects whose opacity mask uses it (in type that is not hidden or locked in that art), that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; a pattern swatch that nothing paints, or a layer's mask, selects nothing; with scope, only those on one of the artboards (as All on Active Artboard takes them: their bounds overlap it or their center is on it) or in one of the layers → {count}", has_doc, select_font ), @@ -64,15 +64,23 @@ pub fn specs() -> Vec { ] } -/// Text objects in scope: the selection's (and their descendants') or the whole document's. -fn scope(s: &Session, selection_only: bool, selected: Option<&[NodeId]>) -> Result> { +/// Text objects in scope: the selection's (and their descendants') or the whole document's, and +/// the objects among them that have an opacity mask (not the object whose mask is being edited: +/// its art is on the editing layer). +fn scope(s: &Session, selection_only: bool, selected: Option<&[NodeId]>) -> Result<(Vec, Vec)> { let st = s.doc()?; - let mut v = vec![]; + let editing = st.doc.mask_edit.map(|m| m.object); + let (mut texts, mut masked) = (vec![], vec![]); let mut seen = HashSet::new(); let mut add = |n: &vectorcraft_doc::Node| { n.walk(&mut |c| { - if matches!(c.kind, NodeKind::Text(_)) && seen.insert(c.id) { - v.push(c.id); + if seen.insert(c.id) { + if matches!(c.kind, NodeKind::Text(_)) { + texts.push(c.id); + } + if c.extras().mask.is_some() && Some(c.id) != editing { + masked.push(c.id); + } } }) }; @@ -87,7 +95,7 @@ fn scope(s: &Session, selection_only: bool, selected: Option<&[NodeId]>) -> Resu add(l); } } - Ok(v) + Ok((texts, masked)) } /// Whether `name` is the symbol being edited, whose art is on the editing layer. @@ -95,106 +103,269 @@ fn being_edited(d: &Document, name: &str) -> bool { d.symbol_edit.as_ref().is_some_and(|e| e.symbol == name) } -/// The symbols that the selected objects of `s` show: those of the instances among them and their -/// descendants, and of the instances in those symbols' art that are not hidden or locked, nor in a -/// hidden or locked group of the art. Returned as (all of them, those shown by instances the -/// Selection tool can reach: not hidden or locked, as `Document::editable` says). -fn selected_symbols(s: &Session, selected: Option<&[NodeId]>) -> Result<(BTreeSet, BTreeSet)> { - fn open_instances(n: &Node, out: &mut BTreeSet) { +/// Whether `name` is the pattern being edited, whose tile art is on the editing layer. +fn pattern_being_edited(d: &Document, name: &str) -> bool { + d.pattern_edit.as_ref().is_some_and(|e| e.pattern == name) +} + +/// The patterns that the fills and strokes `n` draws name (of a text object, also those of its +/// characters): visible ones, and strokes wider than 0. +fn paint_patterns(n: &Node) -> impl Iterator { + let runs = match &n.kind { + NodeKind::Text(t) => t.runs.as_slice(), + _ => &[], + }; + n.appearance + .items + .iter() + .filter(|i| i.visible() && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0)) + .map(AppearanceItem::paint) + .chain(runs.iter().flat_map(|r| [Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)]).flatten()) + .filter_map(|p| match p { + Paint::Pattern { pattern, .. } => Some(pattern.as_str()), + _ => None, + }) +} + +/// Symbols and patterns, by name: those whose art is in scope. +#[derive(Clone, Debug, Default)] +struct Resources { + symbols: BTreeSet, + patterns: BTreeSet, +} + +impl Resources { + /// Adds what `n` itself shows: the symbol of an instance, the patterns its paints name, and + /// what the art of its opacity mask shows (not hidden or locked objects of that art). + fn add_own(&mut self, n: &Node) { + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { + self.symbols.insert(symbol.clone()); + } + self.patterns.extend(paint_patterns(n).map(str::to_string)); + if let Some(m) = n.extras().mask.as_deref() { + self.add(&m.art); + } + } + + /// Adds what `n` and its descendants show, leaving out hidden or locked objects and what they + /// hold. + fn add(&mut self, n: &Node) { if !n.visible || n.locked { return; } - if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { - out.insert(symbol.clone()); - } + self.add_own(n); for c in n.children().map(Vec::as_slice).unwrap_or_default() { - open_instances(c, out); + self.add(c); } } - let expand = |names: &mut BTreeSet, d: &Document| loop { - let mut more = BTreeSet::new(); - for sym in d.symbols.iter().filter(|sym| names.contains(&sym.name)) { - open_instances(&sym.art, &mut more); + + /// Adds what the art of the symbols and patterns named shows, until nothing more is added; each + /// symbol and pattern is read once, so art that shows itself ends. + fn expand(&mut self, d: &Document) { + let mut symbols: Vec = self.symbols.iter().cloned().collect(); + let mut patterns: Vec = self.patterns.iter().cloned().collect(); + while !symbols.is_empty() || !patterns.is_empty() { + let mut more = Resources::default(); + for name in symbols.drain(..) { + if let Some(sym) = d.symbols.iter().find(|sym| sym.name == name) { + more.add(&sym.art); + } + } + for name in patterns.drain(..) { + for art in d.pattern(&name).map(|p| p.art.as_slice()).unwrap_or_default() { + more.add(art); + } + } + symbols.extend(more.symbols.into_iter().filter(|name| self.symbols.insert(name.clone()))); + patterns.extend(more.patterns.into_iter().filter(|name| self.patterns.insert(name.clone()))); } - let before = names.len(); - names.extend(more); - if names.len() == before { - break; + } + + /// The symbols and patterns of `self` that are not in `other`. + fn difference(&self, other: &Self) -> Self { + Self { + symbols: self.symbols.difference(&other.symbols).cloned().collect(), + patterns: self.patterns.difference(&other.patterns).cloned().collect(), } - }; + } +} + +/// The symbols and patterns that the selected objects of `s` show: those of the instances among +/// them and their descendants and the patterns their paints name (also in the art of their +/// opacity masks), and those that the art of these symbols and patterns shows through objects that +/// are not hidden or locked, nor in a hidden or locked group of the art. Returned as (all of them, +/// those shown by objects the Selection tool can reach: not hidden or locked, as +/// `Document::editable` says). +fn selected_art(s: &Session, selected: Option<&[NodeId]>) -> Result<(Resources, Resources)> { let st = s.doc()?; - let (mut all, mut open) = (BTreeSet::new(), BTreeSet::new()); + let (mut all, mut open) = (Resources::default(), Resources::default()); let editable = st.doc.editable(); for id in selected.unwrap_or(&st.selection.objects) { if let Some(n) = st.doc.node(*id) { n.walk(&mut |c| { - if let NodeKind::SymbolInstance { symbol, .. } = &c.kind { - all.insert(symbol.clone()); - if editable(c.id) { - open.insert(symbol.clone()); - } + all.add_own(c); + if editable(c.id) { + open.add_own(c); } }); } } drop(editable); - expand(&mut all, &st.doc); - expand(&mut open, &st.doc); + all.expand(&st.doc); + open.expand(&st.doc); Ok((all, open)) } -/// The text objects in the art of `d`'s symbols (with `only`, of those it names), each with -/// whether it is open: neither it nor a group around it in the art is hidden or locked. The symbol -/// being edited is left out: its art is on the editing layer. -pub(super) fn symbol_texts<'a>(d: &'a Document, only: Option<&BTreeSet>) -> Vec<(&'a TextObject, bool)> { - fn visit<'a>(n: &'a Node, open: bool, out: &mut Vec<(&'a TextObject, bool)>) { - let open = open && n.visible && !n.locked; - if let NodeKind::Text(t) = &n.kind { - out.push((&**t, open)); - } - for c in n.children().map(Vec::as_slice).unwrap_or_default() { - visit(c, open, out); - } +/// A text object found in art: its id, the text and whether it is open (neither it nor a group +/// around it, nor an object whose opacity mask holds it, is hidden or locked). +pub(super) type ArtText<'a> = (NodeId, &'a TextObject, bool); + +/// Adds the text objects of `n` and its descendants, the art of their opacity masks included, to +/// `out`; each is open when `open` holds and [`ArtText`] says so. +fn art_texts<'a>(n: &'a Node, open: bool, out: &mut Vec>) { + let open = open && n.visible && !n.locked; + if let NodeKind::Text(t) = &n.kind { + out.push((n.id, &**t, open)); } + if let Some(m) = n.extras().mask.as_deref() { + art_texts(&m.art, open, out); + } + for c in n.children().map(Vec::as_slice).unwrap_or_default() { + art_texts(c, open, out); + } +} + +/// The text objects in the art of the opacity masks of the objects `masked` (each with whether +/// the object is open: when it isn't, none of its mask's text is). +fn mask_texts<'a>(d: &'a Document, masked: &[(NodeId, bool)]) -> Vec> { let mut out = vec![]; - for s in d.symbols.iter().filter(|s| !being_edited(d, &s.name) && only.is_none_or(|o| o.contains(&s.name))) { - visit(&s.art, true, &mut out); + for (id, open) in masked { + if let Some(m) = d.node(*id).and_then(|n| n.extras().mask.as_deref()) { + art_texts(&m.art, *open, &mut out); + } } out } -/// Calls `f` on each text object in the art of `d`'s symbols (with `only`, of those it names) for -/// which `wants(text, open)` holds (open as in [`symbol_texts`]), the symbol being edited left out. -/// Art without such text is left as it is. -pub(super) fn edit_symbol_texts( - d: &mut Document, - only: Option<&BTreeSet>, - wants: &impl Fn(&TextObject, bool) -> bool, - f: &mut impl FnMut(&mut TextObject), -) { - fn wanted(n: &Node, open: bool, wants: &impl Fn(&TextObject, bool) -> bool) -> bool { - let open = open && n.visible && !n.locked; - matches!(&n.kind, NodeKind::Text(t) if wants(t, open)) || n.children().is_some_and(|ch| ch.iter().any(|c| wanted(c, open, wants))) +/// The text objects in the art of `d`'s symbols and patterns (with `only`, of those it names, a +/// pattern's art read once however many objects it paints), opacity masks in that art included. +/// The symbol and the pattern being edited are left out: their art is on the editing layer. +fn resource_texts<'a>(d: &'a Document, only: Option<&Resources>) -> Vec> { + let mut out = vec![]; + for s in d.symbols.iter().filter(|s| !being_edited(d, &s.name) && only.is_none_or(|o| o.symbols.contains(&s.name))) { + art_texts(&s.art, true, &mut out); } - fn visit(n: &mut Node, open: bool, wants: &impl Fn(&TextObject, bool) -> bool, f: &mut impl FnMut(&mut TextObject)) { - let open = open && n.visible && !n.locked; - if let NodeKind::Text(t) = &mut n.kind - && wants(t, open) - { - f(t); + for p in d.patterns.iter().filter(|p| !pattern_being_edited(d, &p.name) && only.is_none_or(|o| o.patterns.contains(&p.name))) { + for art in &p.art { + art_texts(art, true, &mut out); } - for c in n.children_mut().into_iter().flatten() { - if wanted(c, open, wants) { - visit(Arc::make_mut(c), open, wants, f); + } + out +} + +/// The text objects in the art of `d`'s symbols (with `only`, of those it names), as +/// [`resource_texts`] finds them, each with whether it is open. +#[cfg(test)] +pub(super) fn symbol_texts<'a>(d: &'a Document, only: Option<&BTreeSet>) -> Vec<(&'a TextObject, bool)> { + let mut out = vec![]; + for s in d.symbols.iter().filter(|s| !being_edited(d, &s.name) && only.is_none_or(|o| o.contains(&s.name))) { + art_texts(&s.art, true, &mut out); + } + out.into_iter().map(|(_, t, open)| (t, open)).collect() +} + +/// Whether `n` or a descendant (the art of their opacity masks included) holds a text object for +/// which `wants(text, open)` holds, open as [`ArtText`] says. +fn wanted(n: &Node, open: bool, wants: &impl Fn(&TextObject, bool) -> bool) -> bool { + let open = open && n.visible && !n.locked; + matches!(&n.kind, NodeKind::Text(t) if wants(t, open)) + || n.extras().mask.as_deref().is_some_and(|m| wanted(&m.art, open, wants)) + || n.children().is_some_and(|ch| ch.iter().any(|c| wanted(c, open, wants))) +} + +/// Calls `f` on each text object of `n` and its descendants (the art of their opacity masks +/// included) for which `wants(text, open)` holds; only the nodes on the way to such text are +/// copied. +fn edit_art(n: &mut Node, open: bool, wants: &impl Fn(&TextObject, bool) -> bool, f: &mut impl FnMut(&mut TextObject)) { + let open = open && n.visible && !n.locked; + if let NodeKind::Text(t) = &mut n.kind + && wants(t, open) + { + f(t); + } + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) + && wanted(&m.art, open, wants) + { + edit_art(Arc::make_mut(&mut m.art), open, wants, f); + } + for c in n.children_mut().into_iter().flatten() { + if wanted(c, open, wants) { + edit_art(Arc::make_mut(c), open, wants, f); + } + } +} + +/// Calls `f` on each text object in the opacity masks of the objects `masked` (as [`mask_texts`] +/// finds them) for which `wants(text, open)` holds. Masks without such text are left as they are. +fn edit_mask_texts(d: &mut Document, masked: &[(NodeId, bool)], wants: &impl Fn(&TextObject, bool) -> bool, f: &mut impl FnMut(&mut TextObject)) { + for (id, open) in masked { + if d.node(*id).and_then(|n| n.extras().mask.as_deref()).is_some_and(|m| wanted(&m.art, *open, wants)) + && let Some(m) = d.node_mut(*id).and_then(|n| n.extras_if_any()).and_then(|e| e.mask.as_mut()) + { + edit_art(Arc::make_mut(&mut m.art), *open, wants, f); + } + } +} + +/// Calls `f` on each text object in the art of `d`'s symbols and patterns (as [`resource_texts`] +/// finds them) for which `wants(text, open)` holds. Art without such text is left as it is. +fn edit_resource_texts(d: &mut Document, only: Option<&Resources>, wants: &impl Fn(&TextObject, bool) -> bool, f: &mut impl FnMut(&mut TextObject)) { + let symbol = d.symbol_edit.as_ref().map(|e| e.symbol.clone()); + let pattern = d.pattern_edit.as_ref().map(|e| e.pattern.clone()); + for s in &mut d.symbols { + if symbol.as_ref() != Some(&s.name) && only.is_none_or(|o| o.symbols.contains(&s.name)) && wanted(&s.art, true, wants) { + edit_art(Arc::make_mut(&mut s.art), true, wants, f); + } + } + for p in &mut d.patterns { + if pattern.as_ref() != Some(&p.name) && only.is_none_or(|o| o.patterns.contains(&p.name)) { + for art in &mut p.art { + if wanted(art, true, wants) { + edit_art(Arc::make_mut(art), true, wants, f); + } } } } - let edited = d.symbol_edit.as_ref().map(|e| e.symbol.clone()); - for s in &mut d.symbols { - if edited.as_ref() != Some(&s.name) && only.is_none_or(|o| o.contains(&s.name)) && wanted(&s.art, true, wants) { - visit(Arc::make_mut(&mut s.art), true, wants, f); +} + +/// Every text object of `d` outside the objects of its layers: in the opacity masks of the +/// objects on its layers, and in the art of its symbols and patterns (as [`mask_texts`] and +/// [`resource_texts`] find them, each mask's text open). +pub(super) fn art_texts_of(d: &Document) -> Vec> { + let masked = masked_objects(d); + let mut out = mask_texts(d, &masked); + out.extend(resource_texts(d, None)); + out +} + +/// Calls `f` on each text object of [`art_texts_of`] for which `wants(text)` holds. +pub(super) fn edit_art_texts_of(d: &mut Document, wants: &impl Fn(&TextObject) -> bool, f: &mut impl FnMut(&mut TextObject)) { + let masked = masked_objects(d); + edit_mask_texts(d, &masked, &|t, _| wants(t), f); + edit_resource_texts(d, None, &|t, _| wants(t), f); +} + +/// The objects on the layers of `d` that have an opacity mask (not the object whose mask is being +/// edited), each as open. +fn masked_objects(d: &Document) -> Vec<(NodeId, bool)> { + let editing = d.mask_edit.map(|m| m.object); + let mut out = vec![]; + d.walk(|n| { + if n.extras().mask.is_some() && Some(n.id) != editing { + out.push((n.id, true)); } - } + }); + out } /// A font as type names it: family, style and, when it names one, the installed version. @@ -218,14 +389,16 @@ pub(super) fn run_fonts(r: &TextRun) -> Vec { } } -/// The fonts (family, style, version) the type in the layers and symbols of `d` is drawn in, a -/// composite font's parts in place of its name ([`run_fonts`]). +/// The fonts (family, style, version) the type in the layers, symbols and patterns of `d` is drawn +/// in, the type in opacity masks included, a composite font's parts in place of its name +/// ([`run_fonts`]). pub(super) fn used_fonts(d: &Document) -> BTreeSet { art_fonts(d, &[]) } -/// The fonts (family, style, version) of the type in the layers and symbols of `d` and in `art` -/// (art read from a file, its resources in `d`). +/// The fonts (family, style, version) of the type in the layers, symbols and patterns of `d` and +/// in `art` (art read from a file, its resources in `d`), the type in opacity masks included. The +/// pattern being edited is read from the editing layer only. pub(super) fn art_fonts(d: &Document, art: &[Node]) -> BTreeSet { let mut fonts = BTreeSet::new(); let mut add = |n: &Node| { @@ -233,12 +406,9 @@ pub(super) fn art_fonts(d: &Document, art: &[Node]) -> BTreeSet { fonts.extend(t.runs.iter().flat_map(run_fonts)); } }; - d.walk(&mut add); - for s in &d.symbols { - s.art.walk(&mut add); - } - for n in art { - n.walk(&mut add); + let patterns = d.patterns.iter().filter(|p| !pattern_being_edited(d, &p.name)).flat_map(|p| p.art.iter().map(|a| &**a)); + for n in d.layers.iter().map(|l| &**l).chain(d.symbols.iter().map(|s| &*s.art)).chain(patterns).chain(art) { + vectorcraft_doc::swatches::walk_with_masks(n, &mut add); } fonts } @@ -513,50 +683,78 @@ impl Session { pub fn skipped_font_ids(&self, params: &Value, selected: Option<&[NodeId]>) -> Result> { let (family, style) = font_param(params, "from", "text.replaceFont")?; let selection_only = bool_or(params, "selectionOnly", false); - let selected_symbols = selection_only.then(|| selected_symbols(self, selected)).transpose()?; - let d = &self.doc()?.doc; - let other: Vec = { - let editable = d.editable(); - scope(self, selection_only, selected)?.into_iter().filter(|id| !editable(*id)).collect() - }; + let in_scope = ReplaceScope::read(self, selection_only, selected)?; let uses = |t: &TextObject| { t.runs.iter().any(|r| { r.style.font_family.eq_ignore_ascii_case(&family) && style.as_ref().is_none_or(|name| r.style.font_style.eq_ignore_ascii_case(name)) }) }; - let mut ids = other.into_iter().filter(|id| text(d, *id).is_some_and(uses)).collect::>(); - fn visit(n: &Node, open: bool, closed: bool, uses: &impl Fn(&TextObject) -> bool, ids: &mut Vec) { - let open = open && n.visible && !n.locked; - if let NodeKind::Text(t) = &n.kind - && (!open || closed) - && uses(t) - { - ids.push(n.id); - } - for child in n.children().map(Vec::as_slice).unwrap_or_default() { - visit(child, open, closed, uses, ids); - } + Ok(in_scope.skipped(&self.doc()?.doc, &uses)) + } +} + +/// What Replace Font looks at: the text objects in scope that the Selection tool can reach and +/// those it can't, the objects with an opacity mask (each with whether it can reach them), and the +/// symbols and patterns whose art is in scope (`None`: all of the document's) with those shown +/// only by hidden or locked objects of the selection. +struct ReplaceScope { + ids: Vec, + other: Vec, + masked: Vec<(NodeId, bool)>, + resources: Option, + closed: Resources, +} + +impl ReplaceScope { + fn read(s: &Session, selection_only: bool, selected: Option<&[NodeId]>) -> Result { + // With selectionOnly: the symbols and patterns the selection shows; those shown only by + // hidden or locked objects keep their type, counted in `skipped`. + let (resources, closed) = if selection_only { + let (all, open) = selected_art(s, selected)?; + let closed = all.difference(&open); + (Some(open), closed) + } else { + (None, Resources::default()) + }; + let d = &s.doc()?.doc; + let editable = d.editable(); + let (texts, masked) = scope(s, selection_only, selected)?; + let (ids, other): (Vec, Vec) = texts.into_iter().partition(|id| editable(*id)); + let masked = masked.into_iter().map(|id| (id, editable(id))).collect(); + Ok(Self { ids, other, masked, resources, closed }) + } + + /// The text objects in the art of opacity masks, symbols and patterns in scope. + fn art<'a>(&self, d: &'a Document) -> Vec> { + let mut texts = mask_texts(d, &self.masked); + texts.extend(resource_texts(d, self.resources.as_ref())); + texts + } + + /// The text objects in scope set in a font `uses` matches that Replace Font leaves as they are: + /// those the Selection tool can't reach, hidden or locked text in art, and the text of the + /// symbols and patterns shown only by hidden or locked objects of the selection. + fn skipped(&self, d: &Document, uses: &impl Fn(&TextObject) -> bool) -> Vec { + let mut ids: Vec = self.other.iter().copied().filter(|id| text(d, *id).is_some_and(uses)).collect(); + ids.extend(self.art(d).into_iter().filter(|(_, t, open)| !open && uses(t)).map(|(id, ..)| id)); + if !self.closed.symbols.is_empty() || !self.closed.patterns.is_empty() { + ids.extend(resource_texts(d, Some(&self.closed)).into_iter().filter(|(_, t, _)| uses(t)).map(|(id, ..)| id)); } - for symbol in d - .symbols - .iter() - .filter(|symbol| !being_edited(d, &symbol.name) && selected_symbols.as_ref().is_none_or(|(all, _)| all.contains(&symbol.name))) - { - let closed = selected_symbols.as_ref().is_some_and(|(_, open)| !open.contains(&symbol.name)); - visit(&symbol.art, true, closed, &uses, &mut ids); - } - Ok(ids) + ids } } fn font_inventory(s: &Session, selected: Option<&[NodeId]>) -> Result { - let ids = scope(s, selected.is_some(), selected)?; + let (ids, masked) = scope(s, selected.is_some(), selected)?; let d = &s.doc()?.doc; let db = vectorcraft_text::FontDb::global(); - // The type in the art of the symbols in scope too: all of them, or those the selection shows. - let symbols = if selected.is_some() { Some(selected_symbols(s, selected)?.0) } else { None }; + // The type in the art of the opacity masks of the objects in scope, and of the symbols and + // patterns in scope: all of them, or those the selection shows. + let resources = if selected.is_some() { Some(selected_art(s, selected)?.0) } else { None }; let mut texts: Vec<&TextObject> = ids.iter().filter_map(|id| text(d, *id)).collect(); - texts.extend(symbol_texts(d, symbols.as_ref()).into_iter().map(|(t, _)| t)); + let masked: Vec<(NodeId, bool)> = masked.into_iter().map(|id| (id, true)).collect(); + texts.extend(mask_texts(d, &masked).into_iter().map(|(_, t, _)| t)); + texts.extend(resource_texts(d, resources.as_ref()).into_iter().map(|(_, t, _)| t)); // Each font's runs, objects and the text of its runs. let mut found: BTreeMap)> = BTreeMap::new(); for t in texts { @@ -617,21 +815,6 @@ fn font_param(p: &Value, k: &str, c: &str) -> Result<(String, Option)> { Ok((family.to_string(), v.get("style").and_then(Value::as_str).map(str::to_string))) } -fn skipped_font_texts( - d: &Document, - other: &[NodeId], - symbols: &[(&TextObject, bool)], - closed: &BTreeSet, - uses: &impl Fn(&TextObject) -> bool, -) -> usize { - let mut skipped = other.iter().filter(|id| text(d, **id).is_some_and(uses)).count(); - skipped += symbols.iter().filter(|(t, open)| !open && uses(t)).count(); - if !closed.is_empty() { - skipped += symbol_texts(d, Some(closed)).into_iter().filter(|(t, _)| uses(t)).count(); - } - skipped -} - fn replace(s: &mut Session, p: &Value) -> Result { const C: &str = "text.replaceFont"; let (ff, fs) = font_param(p, "from", C)?; @@ -660,35 +843,25 @@ fn replace(s: &mut Session, p: &Value) -> Result { }; // Only text the Selection tool can reach changes: not hidden or locked, in a hidden or locked // group or layer, or on a locked artboard. The text left in the font is counted. The type in - // the art of the symbols in scope (all of them, or those the selection shows) changes too, - // unless it or a group around it in the art is hidden or locked. + // the opacity masks of the objects in scope, and in the art of the symbols and patterns in + // scope (all of them, or those the selection shows), changes too, unless it, a group around it + // in the art or the object whose mask holds it is hidden or locked. let selection_only = bool_or(p, "selectionOnly", false); - // With selectionOnly: the symbols the selection shows; those shown only by hidden or locked - // instances keep their type, counted in `skipped`. - let (symbols, closed) = if selection_only { - let (all, open) = selected_symbols(s, None)?; - let closed: BTreeSet = all.difference(&open).cloned().collect(); - (Some(open), closed) - } else { - (None, BTreeSet::new()) - }; let uses = |t: &TextObject| t.runs.iter().any(|r| matches(&r.style)); // Whether a run of `t` changes. let changes = |t: &TextObject| t.runs.iter().any(|r| new_style(&r.style).is_some()); - let (ids, skipped, in_symbols) = { + let in_scope = ReplaceScope::read(s, selection_only, None)?; + let (ids, skipped, in_art) = { let d = &s.doc()?.doc; - let editable = d.editable(); - let (ids, other): (Vec, Vec) = scope(s, selection_only, None)?.into_iter().partition(|id| editable(*id)); - let texts = symbol_texts(d, symbols.as_ref()); - let skipped = skipped_font_texts(d, &other, &texts, &closed, &uses); + let skipped = in_scope.skipped(d, &uses).len(); // The text that changes: when none does, the document and its undo history stay as they are. - let ids: Vec = ids.into_iter().filter(|id| text(d, *id).is_some_and(changes)).collect(); - let in_symbols = texts.iter().any(|(t, open)| *open && changes(t)); - (ids, skipped, in_symbols) + let ids: Vec = in_scope.ids.iter().copied().filter(|id| text(d, *id).is_some_and(changes)).collect(); + let in_art = in_scope.art(d).iter().any(|(_, t, open)| *open && changes(t)); + (ids, skipped, in_art) }; // The fonts the replaced runs are set in, for the warning. let mut set = BTreeSet::new(); - let n = if ids.is_empty() && !in_symbols { + let n = if ids.is_empty() && !in_art { 0 } else { s.edit("Replace Font", |d, _| { @@ -717,7 +890,9 @@ fn replace(s: &mut Session, p: &Value) -> Result { replace_in(t); } } - edit_symbol_texts(d, symbols.as_ref(), &|t, open| open && changes(t), &mut replace_in); + let wants = |t: &TextObject, open: bool| open && changes(t); + edit_mask_texts(d, &in_scope.masked, &wants, &mut replace_in); + edit_resource_texts(d, in_scope.resources.as_ref(), &wants, &mut replace_in); Ok(n) })? }; @@ -734,13 +909,26 @@ fn replace(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// Whether `n` or a descendant that is not hidden or locked, nor in a hidden or locked group, +/// shows type in a font `uses` matches: text set in it, an instance of a symbol in `found`, a paint +/// naming a pattern in `found`, or such type in the art of its opacity mask. +fn reaches(n: &Node, uses: &impl Fn(&TextObject) -> bool, found: &Resources) -> bool { + n.visible + && !n.locked + && (matches!(&n.kind, NodeKind::Text(t) if uses(t)) + || matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if found.symbols.contains(symbol)) + || paint_patterns(n).any(|p| found.patterns.contains(p)) + || n.extras().mask.as_deref().is_some_and(|m| reaches(&m.art, uses, found)) + || n.children().is_some_and(|ch| ch.iter().any(|c| reaches(c, uses, found)))) +} + /// What Replace Font says when it left text in the font that it can't reach. const REPLACE_SKIPPED: &str = "some text is hidden or locked, so its font wasn't replaced"; fn select_font(s: &mut Session, p: &Value) -> Result { let family = str_param(p, "family").ok_or_else(|| bad("select.font", "missing `family`"))?.to_string(); let style = str_param(p, "style").map(str::to_string); - let ids = scope(s, false, None)?; + let (ids, masked) = scope(s, false, None)?; let d = &s.doc()?.doc; let within = FontScope::read(d, p)?; // Only text the Selection tool can reach: not hidden or locked, in a hidden or locked group @@ -752,37 +940,40 @@ fn select_font(s: &mut Session, p: &Value) -> Result { .any(|r| r.style.font_family.eq_ignore_ascii_case(&family) && style.as_ref().is_none_or(|x| r.style.font_style.eq_ignore_ascii_case(x))) }; let mut hits: Vec = ids.into_iter().filter(|id| editable(*id) && text(d, *id).is_some_and(uses)).collect(); - // The symbols whose art uses the font in type that is not hidden or locked, nor in a hidden or - // locked group of the art, also through the instances of other symbols it holds. - fn reaches(n: &Node, uses: &impl Fn(&TextObject) -> bool, symbols: &BTreeSet<&str>) -> bool { - n.visible - && !n.locked - && match &n.kind { - NodeKind::Text(t) => uses(t), - NodeKind::SymbolInstance { symbol, .. } => symbols.contains(symbol.as_str()), - _ => n.children().is_some_and(|ch| ch.iter().any(|c| reaches(c, uses, symbols))), - } - } - let mut symbols: BTreeSet<&str> = BTreeSet::new(); + // The symbols and patterns whose art uses the font in type that is not hidden or locked, nor in + // a hidden or locked group of the art, also through the instances, patterns and opacity masks + // it holds. The pattern being edited is left out: its art is on the editing layer. + let mut found = Resources::default(); loop { - let found: Vec<&str> = d + let symbols: Vec = d .symbols .iter() - .filter(|sym| !symbols.contains(sym.name.as_str()) && reaches(&sym.art, &uses, &symbols)) - .map(|sym| sym.name.as_str()) + .filter(|sym| !found.symbols.contains(&sym.name) && reaches(&sym.art, &uses, &found)) + .map(|sym| sym.name.clone()) .collect(); - if found.is_empty() { + let patterns: Vec = d + .patterns + .iter() + .filter(|p| !pattern_being_edited(d, &p.name) && !found.patterns.contains(&p.name) && p.art.iter().any(|a| reaches(a, &uses, &found))) + .map(|p| p.name.clone()) + .collect(); + if symbols.is_empty() && patterns.is_empty() { break; } - symbols.extend(found); + found.symbols.extend(symbols); + found.patterns.extend(patterns); } - // Their instances the Selection tool can reach. - if !symbols.is_empty() { + // The objects the Selection tool can reach that show such type: the instances of those + // symbols, the objects that those patterns paint, and the objects whose opacity mask holds it + // (not layers, which the Selection tool doesn't select). + let masked: HashSet = masked.into_iter().collect(); + if !found.symbols.is_empty() || !found.patterns.is_empty() || !masked.is_empty() { + let texts: HashSet = hits.iter().copied().collect(); d.walk(&mut |n: &Node| { - if let NodeKind::SymbolInstance { symbol, .. } = &n.kind - && symbols.contains(symbol.as_str()) - && editable(n.id) - { + let shows = matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if found.symbols.contains(symbol)) + || paint_patterns(n).any(|p| found.patterns.contains(p)) + || (masked.contains(&n.id) && n.extras().mask.as_deref().is_some_and(|m| reaches(&m.art, &uses, &found))); + if shows && !n.is_layer() && !texts.contains(&n.id) && editable(n.id) { hits.push(n.id); } }); @@ -1311,6 +1502,255 @@ mod tests { assert_eq!(s.execute("text.replaceFont", &serif_to_sans).unwrap(), json!({"runs": 0})); } + /// Moves object `art` of the layers into the opacity mask of `owner` (a layer or an object). + fn mask_with(s: &mut Session, owner: u64, art: u64) { + s.edit("Mask", |d, _| { + let art = d.remove(NodeId(art))?; + d.node_mut(NodeId(owner)).unwrap().extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new((*art).clone(), true))); + Ok(()) + }) + .unwrap(); + } + + /// The font families of the first runs of the text in the opacity mask of `owner`. + fn mask_families(s: &Session, owner: u64) -> Vec { + let d = &s.doc().unwrap().doc; + mask_texts(d, &[(NodeId(owner), true)]).iter().map(|(_, t, _)| t.runs[0].style.font_family.clone()).collect() + } + + /// The font families of the first runs of the text in the art of pattern `name`. + fn pattern_families(s: &Session, name: &str) -> Vec { + let d = &s.doc().unwrap().doc; + let mut out = vec![]; + for art in &d.pattern(name).unwrap().art { + art_texts(art, true, &mut out); + } + out.iter().map(|(_, t, _)| t.runs[0].style.font_family.clone()).collect() + } + + /// The rows of `text.fonts` with `p`: family, runs and objects. + fn font_rows(s: &mut Session, p: Value) -> Vec<(String, u64, u64)> { + let fonts = s.execute("text.fonts", &p).unwrap(); + fonts + .as_array() + .unwrap() + .iter() + .map(|f| (f["family"].as_str().unwrap().to_string(), f["runs"].as_u64().unwrap(), f["objects"].as_u64().unwrap())) + .collect() + } + + /// Find Font lists, selects and changes the type in opacity masks: on objects, on a layer, + /// disabled and inside the art of another mask. The type in the mask of a locked object is + /// listed and left in its font; Find selects the objects whose mask holds the type. + #[test] + fn find_font_reaches_the_type_in_opacity_masks() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let text = |s: &mut Session, y: i32| { + s.execute("text.create", &json!({"x": 10, "y": y, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap() + }; + let rect = |s: &mut Session, y: i32| { + s.execute("shape.rectangle", &json!({"x": 0, "y": y, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap() + }; + let mut owners = vec![]; + for y in [20, 50, 80] { + let owner = rect(&mut s, y); + let t = text(&mut s, y); + mask_with(&mut s, owner, t); + owners.push(owner); + } + let (open, locked, disabled) = (owners[0], owners[1], owners[2]); + s.execute("select.set", &json!({"ids": [locked]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [disabled]})).unwrap(); + s.execute("transparency.disableOpacityMask", &json!({})).unwrap(); + // A mask whose art is an object with a mask of its own, and a layer's mask. + let nested = rect(&mut s, 110); + let inner = rect(&mut s, 110); + let t = text(&mut s, 110); + mask_with(&mut s, inner, t); + mask_with(&mut s, nested, inner); + let layer = s.doc().unwrap().doc.layers[0].id.0; + let t = text(&mut s, 140); + mask_with(&mut s, layer, t); + let mut on_layers = 0; + s.doc().unwrap().doc.walk(|n| on_layers += usize::from(matches!(n.kind, NodeKind::Text(_)))); + assert_eq!(on_layers, 0, "the type is only in masks"); + assert_eq!(font_rows(&mut s, json!({})), [("Source Serif 4".to_string(), 5, 5)]); + s.execute("select.set", &json!({"ids": [open]})).unwrap(); + assert_eq!(font_rows(&mut s, json!({"selectionOnly": true})), [("Source Serif 4".to_string(), 1, 1)]); + // Find selects the objects whose mask holds the type, not the locked one or the layer. + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 3); + let mut selected = s.doc().unwrap().selection.objects.clone(); + selected.sort(); + assert_eq!(selected, [NodeId(open), NodeId(disabled), NodeId(nested)]); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"artboards": [0]}})).unwrap()["count"], 3); + // Change All: one undo step, the locked object's mask left in its font and counted. + let steps = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = steps(&s); + let serif_to_sans = json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Source Sans 3"}}); + let r = s.execute("text.replaceFont", &serif_to_sans).unwrap(); + assert_eq!((r["runs"].as_u64(), r["skipped"].as_u64()), (Some(4), Some(1)), "{r}"); + assert_eq!(steps(&s), before + 1); + for owner in [open, disabled, nested, layer] { + assert_eq!(mask_families(&s, owner), ["Source Sans 3"], "the mask of {owner}"); + } + assert_eq!(mask_families(&s, locked), ["Source Serif 4"]); + let r = s.execute("text.replaceFont", &serif_to_sans).unwrap(); + assert_eq!(r, json!({"runs": 0, "skipped": 1, "warning": REPLACE_SKIPPED})); + assert_eq!(steps(&s), before + 1); + s.execute("edit.undo", &json!({})).unwrap(); + for owner in [open, disabled, nested, layer] { + assert_eq!(mask_families(&s, owner), ["Source Serif 4"], "the mask of {owner} after Undo"); + } + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(mask_families(&s, nested), ["Source Sans 3"]); + // While a mask is edited, its type on the editing layer is counted and changed once. + s.execute("transparency.editOpacityMask", &json!({"id": open})).unwrap(); + assert_eq!(font_rows(&mut s, json!({})), [("Source Sans 3".to_string(), 4, 4), ("Source Serif 4".to_string(), 1, 1)]); + let r = s.execute("text.replaceFont", &json!({"from": {"family": "Source Sans 3"}, "to": {"family": "Inter"}})).unwrap(); + assert_eq!(r["runs"].as_u64(), Some(4), "{r}"); + let families: Vec = used_fonts(&s.doc().unwrap().doc).into_iter().map(|(family, ..)| family).collect(); + assert_eq!(families, ["Inter", "Source Serif 4"], "the edited mask is written back after each edit"); + s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); + assert_eq!(mask_families(&s, open), ["Inter"]); + } + + /// Find Font lists the type in pattern swatches once, also when nothing paints with them; + /// Find selects the objects the pattern paints, and Change changes the pattern's art once for + /// every object it paints. While a pattern is edited, its type is taken from the editing layer. + #[test] + fn find_font_reaches_the_type_in_patterns() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let pattern = |s: &mut Session, name: &str, font: &str| { + let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": font})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.pattern.make", &json!({"ids": [t], "name": name, "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + }; + pattern(&mut s, "Dots", "Source Serif 4"); + pattern(&mut s, "Unused", "Inter"); + let mut on_layers = 0; + s.doc().unwrap().doc.walk(|n| on_layers += usize::from(matches!(n.kind, NodeKind::Text(_)))); + assert_eq!(on_layers, 0, "the type is only in patterns"); + let all = [("Inter".to_string(), 1, 1), ("Source Serif 4".to_string(), 1, 1)]; + assert_eq!(font_rows(&mut s, json!({})), all); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 0); + let mut painted = vec![]; + for y in [20, 60] { + painted.push(s.execute("shape.rectangle", &json!({"x": 0, "y": y, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap()); + } + s.execute("paint.setFill", &json!({"swatch": "Dots", "ids": painted})).unwrap(); + assert_eq!(font_rows(&mut s, json!({})), all, "a pattern's type counts once"); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 2); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(painted[0]), NodeId(painted[1])]); + assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 0); + // Only the selection: the patterns the selected objects paint with. + s.execute("select.set", &json!({"ids": [painted[0]]})).unwrap(); + assert_eq!(font_rows(&mut s, json!({"selectionOnly": true})), [("Source Serif 4".to_string(), 1, 1)]); + let steps = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = steps(&s); + let r = s + .execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Source Sans 3"}, "selectionOnly": true})) + .unwrap(); + assert_eq!(r, json!({"runs": 1})); + assert_eq!(steps(&s), before + 1); + assert_eq!(pattern_families(&s, "Dots"), ["Source Sans 3"]); + assert_eq!(pattern_families(&s, "Unused"), ["Inter"]); + // No run changes: no undo step. + let r = s.execute("text.replaceFont", &json!({"from": {"family": "Source Sans 3"}, "to": {"family": "Source Sans 3"}})).unwrap(); + assert_eq!(r, json!({"runs": 0})); + assert_eq!(steps(&s), before + 1); + // Change All reaches the swatch nothing paints with. + let r = s.execute("text.replaceFont", &json!({"from": {"family": "Inter"}, "to": {"family": "Source Serif 4"}})).unwrap(); + assert_eq!(r, json!({"runs": 1})); + assert_eq!(pattern_families(&s, "Unused"), ["Source Serif 4"]); + // A pattern in a hidden fill doesn't show: Find doesn't select the object for its type. + let hidden = s.execute("shape.rectangle", &json!({"x": 100, "y": 20, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Unused", "ids": [hidden]})).unwrap(); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1); + s.edit("Hide the fill", |d, _| { + for it in d.node_mut(NodeId(hidden)).unwrap().appearance.items.iter_mut() { + if let AppearanceItem::Fill(f) = it { + f.visible = false; + } + } + Ok(()) + }) + .unwrap(); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 0); + // Nor does a pattern on a stroke of no width. + s.execute("paint.setStroke", &json!({"swatch": "Unused", "ids": [hidden]})).unwrap(); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1); + s.edit("Thin the stroke", |d, _| { + for it in d.node_mut(NodeId(hidden)).unwrap().appearance.items.iter_mut() { + if let AppearanceItem::Stroke(st) = it { + st.width = 0.0; + } + } + Ok(()) + }) + .unwrap(); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 0); + // A locked painted object: its pattern's type is listed and left in its font. + s.execute("select.set", &json!({"ids": [painted[1]]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [painted[1]]})).unwrap(); + let r = + s.execute("text.replaceFont", &json!({"from": {"family": "Source Sans 3"}, "to": {"family": "Inter"}, "selectionOnly": true})).unwrap(); + assert_eq!((r["runs"].as_u64(), r["skipped"].as_u64()), (Some(0), Some(1)), "{r}"); + assert_eq!(s.execute("select.font", &json!({"family": "Source Sans 3"})).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(painted[0])]); + // While the pattern is edited, its type on the editing layer is counted and changed once. + s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap(); + let rows = font_rows(&mut s, json!({})); + assert_eq!(rows, [("Source Sans 3".to_string(), 1, 1), ("Source Serif 4".to_string(), 1, 1)]); + let r = s.execute("text.replaceFont", &json!({"from": {"family": "Source Sans 3"}, "to": {"family": "Inter"}})).unwrap(); + assert_eq!(r, json!({"runs": 1})); + let families: Vec = used_fonts(&s.doc().unwrap().doc).into_iter().map(|(family, ..)| family).collect(); + assert_eq!(families, ["Inter", "Source Serif 4"], "the pattern's stored art is left out while it is edited"); + s.execute("object.pattern.done", &json!({})).unwrap(); + assert_eq!(pattern_families(&s, "Dots"), ["Inter"]); + } + + /// A pattern whose art holds a symbol instance, painting objects inside and outside a symbol: + /// Find Font lists each pattern's and symbol's type once and reaches the symbol's type through + /// the pattern. + #[test] + fn find_font_reaches_symbols_through_patterns() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let inter = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "In", "font": "Inter"})).unwrap()["id"].clone(); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [inter]})).unwrap(); + let instance = s.doc().unwrap().selection.objects[0].0; + let serif = s.execute("text.create", &json!({"x": 10, "y": 60, "text": "Se", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap(); + let tile = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 80})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.pattern.make", &json!({"ids": [tile, serif, instance], "name": "Loop", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [tile, serif, instance]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + // Another object paints with the pattern too: its type is still read once. + let other = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Loop", "ids": [other]})).unwrap(); + s.execute("symbol.new", &json!({"name": "Outer", "ids": [other]})).unwrap(); + let outer = s.doc().unwrap().selection.objects[0]; + let painted = s.execute("shape.rectangle", &json!({"x": 0, "y": 100, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Loop", "ids": [painted]})).unwrap(); + let both = [("Inter".to_string(), 1, 1), ("Source Serif 4".to_string(), 1, 1)]; + assert_eq!(font_rows(&mut s, json!({})), both); + s.execute("select.set", &json!({"ids": [painted]})).unwrap(); + assert_eq!(font_rows(&mut s, json!({"selectionOnly": true})), both); + // Find reaches the symbol's type through the pattern, and through a symbol whose art paints + // with the pattern. + assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 2); + assert_eq!(s.doc().unwrap().selection.objects, [outer, NodeId(painted)]); + s.execute("select.set", &json!({"ids": [painted]})).unwrap(); + let r = + s.execute("text.replaceFont", &json!({"from": {"family": "Inter"}, "to": {"family": "Source Sans 3"}, "selectionOnly": true})).unwrap(); + assert_eq!(r, json!({"runs": 1})); + assert_eq!(symbol_texts(&s.doc().unwrap().doc, None)[0].0.runs[0].style.font_family, "Source Sans 3"); + } + /// The font family of text object `id`'s first run. fn text_family(s: &Session, id: u64) -> String { text(&s.doc().unwrap().doc, NodeId(id)).unwrap().runs[0].style.font_family.clone() diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 9b37bfaa6f..1ab5707d58 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", has_doc, set_format ), @@ -571,6 +571,8 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "tateChuYoko", "tcyUpDown", "tcyLeftRight", + "akiLeft", + "akiRight", "standardVerticalRoman", "noBreak", "digits", @@ -613,6 +615,8 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let font_height_reference = super::textedit::font_height_reference_param(p, C)?; let tcy_up_down = super::textedit::tcy_offset_param(p, "tcyUpDown", C)?; let tcy_left_right = super::textedit::tcy_offset_param(p, "tcyLeftRight", C)?; + let aki_left = super::textedit::aki_param(p, "akiLeft", C)?; + let aki_right = super::textedit::aki_param(p, "akiRight", C)?; let mojikumi = match p.get("mojikumi") { None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { @@ -773,6 +777,12 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = tcy.1 { st.tcy_left_right = v; } + if let Some(v) = aki_left { + st.aki_left = v; + } + if let Some(v) = aki_right { + st.aki_right = v; + } if let Some(v) = flag("standardVerticalRoman") { st.standard_vertical_roman = v; } diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index fff7289352..0f5ada29fe 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -627,6 +627,8 @@ pub(super) const ALLOWED: &[&str] = &[ "text.thread.releaseSelection", "text.thread.remove", "text.touchType", + "threeD.addGraphic", + "threeD.mapGraphic", "transparency.disableOpacityMask", "transparency.editOpacityMask", "transparency.enableOpacityMask", diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index eae92d87af..6fc8b12edc 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -459,6 +459,9 @@ pub(crate) struct CharChange { /// Tate-chu-yoko Settings › Up/Down and Left/Right, in points. tcy_up_down: Option, tcy_left_right: Option, + /// Insert Aki Left and Right, in 1/1000 em. + aki_left: Option, + aki_right: Option, standard_vertical_roman: Option, no_break: Option, digits: Option, @@ -492,6 +495,15 @@ pub(crate) fn tcy_offset_param(p: &Value, key: &str, cmd: &str) -> Result