diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 5b51fa761..b085c3924 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -16,6 +16,7 @@ pub mod pattern; mod pixels; mod reach; pub mod selection; +pub mod setup; pub mod style_libs; pub mod swatches; pub mod text; @@ -50,10 +51,11 @@ pub use node::{ImageMap, ObjectAttributes}; pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask}; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use selection::{AnchorRef, Selection}; +pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use style_libs::StyleLibrary; pub use text::{ - AreaOptions, CharStyle, FirstBaseline, Justify, ParaStyle, PathEffect, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, - WrapShape, + AreaOptions, CharPosition, CharStyle, FirstBaseline, Justify, ParaStyle, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, + TextRun, TextStyleDef, TextWrap, WrapShape, }; pub use vectorcraft_color as color; pub use vectorcraft_geom as geom; @@ -467,6 +469,9 @@ pub struct Document { /// ([`Document::linked_color`]). #[serde(default = "yes", skip_serializing_if = "skip::is_true")] pub spot_use_lab: bool, + /// File → Document Setup (bleed, transparency grid, paper, type options). + #[serde(default, skip_serializing_if = "skip::is_default")] + pub setup: DocSetup, } fn ppi72() -> f64 { @@ -522,6 +527,7 @@ impl Document { page_isolate: false, page_knockout: false, spot_use_lab: true, + setup: DocSetup::default(), }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); @@ -919,6 +925,43 @@ mod tests { assert!(!ids.contains(&a) && !ids.contains(&b)); } + #[test] + fn setup_round_trips_and_old_files_load() { + let (mut d, _, _) = doc_with_rects(); + // A document without a setup writes no `setup` key (old readers and files stay unchanged). + assert!(!serde_json::to_string(&d).unwrap().contains("\"setup\"")); + d.setup.bleed = [9.0, 9.0, 0.0, 18.0]; + d.setup.grid_size = GridSize::Large; + d.setup.typographers_quotes = false; + d.setup.quotes = setup::language_quotes("German").unwrap(); + d.setup.flattener_preset = Some("High Resolution".into()); + d.setup.background = Background::White; + d.setup.export_text = ExportText::Appearance; + let back: Document = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap(); + assert_eq!(back.setup, d.setup); + // Files written before Document Setup existed load with the defaults. + let mut v = serde_json::to_value(&d).unwrap(); + v.as_object_mut().unwrap().remove("setup"); + let old: Document = serde_json::from_value(v).unwrap(); + assert_eq!(old.setup, DocSetup::default()); + } + + #[test] + fn char_position_round_trips() { + let st = CharStyle { + position: CharPosition::Subscript(ScriptMetrics { size: 50.0, position: 20.0 }), + small_caps: Some(75.0), + ..CharStyle::default() + }; + let v = serde_json::to_value(&st).unwrap(); + assert_eq!(v["position"], serde_json::json!({"kind": "subscript", "size": 50.0, "position": 20.0})); + assert_eq!(serde_json::from_value::(v).unwrap(), st); + let plain = serde_json::to_value(CharStyle::default()).unwrap(); + assert!(plain.get("position").is_none() && plain.get("small_caps").is_none()); + let (scale, shift) = CharPosition::Subscript(ScriptMetrics::DEFAULT).scale_shift(10.0); + assert!((scale - 0.583).abs() < 1e-9 && (shift + 3.33).abs() < 1e-9); + } + #[test] fn units() { assert_eq!(Unit::Inches.parse("1"), Some(72.0)); diff --git a/crates/doc/src/setup.rs b/crates/doc/src/setup.rs new file mode 100644 index 000000000..ee92f7456 --- /dev/null +++ b/crates/doc/src/setup.rs @@ -0,0 +1,318 @@ +//! Document Setup (File → Document Setup): the bleed around the artboards, view options +//! (transparency grid, simulated paper, images in outline mode, substitution highlights), output +//! options and the document's typographic defaults. Every field is defaulted, so documents saved +//! before Document Setup existed load unchanged. + +use serde::{Deserialize, Serialize}; +use vectorcraft_color::Color; +use vectorcraft_geom::Rect; + +use crate::text::ScriptMetrics; + +/// Transparency grid cell size. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GridSize { + Small, + #[default] + Medium, + Large, +} + +impl GridSize { + pub const ALL: [GridSize; 3] = [GridSize::Small, GridSize::Medium, GridSize::Large]; + pub fn id(self) -> &'static str { + match self { + GridSize::Small => "small", + GridSize::Medium => "medium", + GridSize::Large => "large", + } + } + pub fn label(self) -> &'static str { + match self { + GridSize::Small => "Small", + GridSize::Medium => "Medium", + GridSize::Large => "Large", + } + } + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|g| g.id().eq_ignore_ascii_case(s)) + } + /// Edge of one grid cell in screen points (the grid does not scale with the zoom). + pub fn cell(self) -> f32 { + match self { + GridSize::Small => 4.0, + GridSize::Medium => 8.0, + GridSize::Large => 16.0, + } + } +} + +const fn hex(v: u32) -> Color { + Color::Rgb { r: ((v >> 16) & 0xff) as f32 / 255.0, g: ((v >> 8) & 0xff) as f32 / 255.0, b: (v & 0xff) as f32 / 255.0 } +} + +/// Transparency grid colour presets (VectorCraft's own pairs). Any other pair is "Custom". +pub const GRID_COLOR_PRESETS: &[(&str, [Color; 2])] = &[ + ("Light", [hex(0xffffff), hex(0xcccccc)]), + ("Medium", [hex(0xb3b3b3), hex(0x8c8c8c)]), + ("Dark", [hex(0x666666), hex(0x4d4d4d)]), + ("Red", [hex(0xffffff), hex(0xffc7c7)]), + ("Orange", [hex(0xffffff), hex(0xffdcb8)]), + ("Green", [hex(0xffffff), hex(0xc6efc6)]), + ("Blue", [hex(0xffffff), hex(0xc4dcff)]), + ("Purple", [hex(0xffffff), hex(0xe2c9ff)]), +]; + +/// How text is written to formats without live type (Document Setup → Type → Export). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ExportText { + /// Keep text as text (editable). + #[default] + Editable, + /// Write glyph outlines (exact appearance without the fonts). + Appearance, +} + +impl ExportText { + pub fn id(self) -> &'static str { + match self { + ExportText::Editable => "editable", + ExportText::Appearance => "appearance", + } + } + pub fn parse(s: &str) -> Option { + [ExportText::Editable, ExportText::Appearance].into_iter().find(|e| e.id().eq_ignore_ascii_case(s)) + } +} + +/// Quote characters (Document Setup → Type): opening and closing double and single quotes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Quotes { + pub double: [char; 2], + pub single: [char; 2], +} + +impl Default for Quotes { + fn default() -> Self { + Self::ENGLISH + } +} + +impl Quotes { + pub const ENGLISH: Self = Self { double: ['“', '”'], single: ['‘', '’'] }; + /// Double-quote pairs offered by Document Setup. + pub const DOUBLE_CHOICES: &[[char; 2]] = &[['“', '”'], ['„', '“'], ['„', '”'], ['”', '”'], ['«', '»'], ['»', '«'], ['「', '」'], ['"', '"']]; + /// Single-quote pairs offered by Document Setup. + pub const SINGLE_CHOICES: &[[char; 2]] = &[['‘', '’'], ['‚', '‘'], ['‚', '’'], ['’', '’'], ['‹', '›'], ['›', '‹'], ['『', '』'], ['\'', '\'']]; + + /// Is `c` one of these opening quotes? + fn is_open(&self, c: char) -> bool { + c == self.double[0] || c == self.single[0] + } + + /// A quote opens after nothing, whitespace, an opening bracket, a dash or another opening quote. + fn opens(&self, prev: Option) -> bool { + prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || self.is_open(p)) + } + + /// `text` with straight quotes (`"` and `'`) replaced by these quotes: opening or closing by + /// context (an apostrophe is a closing single quote). `prev` is the character before `text`, + /// and is updated to its last character, so text can be converted in pieces. + pub fn apply(&self, text: &str, prev: &mut Option) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + let r = match c { + '"' => self.double[usize::from(!self.opens(*prev))], + '\'' => self.single[usize::from(!self.opens(*prev))], + c => c, + }; + out.push(r); + *prev = Some(r); + } + out + } +} + +/// Document languages and the quote characters each uses (choosing a language in Document Setup +/// also picks its quotes). +pub const LANGUAGES: &[(&str, Quotes)] = &[ + ("English: USA", Quotes::ENGLISH), + ("English: UK", Quotes { double: ['“', '”'], single: ['‘', '’'] }), + ("German", Quotes { double: ['„', '“'], single: ['‚', '‘'] }), + ("German: Swiss", Quotes { double: ['«', '»'], single: ['‹', '›'] }), + ("French", Quotes { double: ['«', '»'], single: ['‹', '›'] }), + ("Italian", Quotes { double: ['«', '»'], single: ['‘', '’'] }), + ("Spanish", Quotes { double: ['«', '»'], single: ['‘', '’'] }), + ("Portuguese", Quotes { double: ['«', '»'], single: ['‘', '’'] }), + ("Dutch", Quotes { double: ['“', '”'], single: ['‘', '’'] }), + ("Danish", Quotes { double: ['»', '«'], single: ['›', '‹'] }), + ("Swedish", Quotes { double: ['”', '”'], single: ['’', '’'] }), + ("Finnish", Quotes { double: ['”', '”'], single: ['’', '’'] }), + ("Norwegian", Quotes { double: ['«', '»'], single: ['‘', '’'] }), + ("Polish", Quotes { double: ['„', '”'], single: ['‚', '’'] }), + ("Czech", Quotes { double: ['„', '“'], single: ['‚', '‘'] }), + ("Russian", Quotes { double: ['«', '»'], single: ['„', '“'] }), + ("Japanese", Quotes { double: ['「', '」'], single: ['『', '』'] }), + ("Chinese", Quotes { double: ['“', '”'], single: ['‘', '’'] }), +]; + +/// The quotes of a [`LANGUAGES`] entry (case-insensitive). +pub fn language_quotes(language: &str) -> Option { + LANGUAGES.iter().find(|(l, _)| l.eq_ignore_ascii_case(language)).map(|(_, q)| *q) +} + +/// The transparency flattener preset Document Setup uses unless another is chosen. +pub const DEFAULT_FLATTENER: &str = "Medium Resolution"; + +/// Background Contents (New Document): what shows behind the art on the artboards, and what +/// raster exports put there. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Background { + #[default] + Transparent, + /// A white artboard background (not an object): raster exports are opaque white there. + White, +} + +impl Background { + pub const ALL: [Background; 2] = [Background::Transparent, Background::White]; + pub fn id(self) -> &'static str { + match self { + Background::Transparent => "transparent", + Background::White => "white", + } + } + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|b| b.id().eq_ignore_ascii_case(s)) + } +} + +/// Largest bleed on any side, in points (1 inch). +pub const MAX_BLEED: f64 = 72.0; + +/// File → Document Setup. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DocSetup { + /// Bleed beyond every artboard edge in points: top, bottom, left, right. + pub bleed: [f64; 4], + /// Show Images in Outline Mode. + pub outline_images: bool, + /// Highlight text whose font is missing (drawn in a substitute font). + pub highlight_substituted_fonts: bool, + /// Highlight glyphs the font lacks (drawn from a fallback font). + pub highlight_substituted_glyphs: bool, + /// Transparency grid cell size. + pub grid_size: GridSize, + /// Transparency grid colours; the first is also the simulated paper colour. + pub grid_colors: [Color; 2], + /// Simulate Colored Paper: artboards show the paper colour and art is multiplied onto it. + pub simulate_paper: bool, + /// Transparency flattener preset for output that can't keep transparency (None = + /// [`DEFAULT_FLATTENER`]). + pub flattener_preset: Option, + /// Discard White Overprint in Output. + pub discard_white_overprint: bool, + /// Document language (one of [`LANGUAGES`]). + pub language: String, + pub quotes: Quotes, + /// Use Typographer's Quotes: typed straight quotes become [`DocSetup::quotes`]. + pub typographers_quotes: bool, + pub superscript: ScriptMetrics, + pub subscript: ScriptMetrics, + /// Synthesized small capitals, percent of the font size. + pub small_caps_size: f64, + pub export_text: ExportText, + /// Background Contents chosen in New Document. + pub background: Background, +} + +impl Default for DocSetup { + fn default() -> Self { + Self { + bleed: [0.0; 4], + outline_images: false, + highlight_substituted_fonts: true, + highlight_substituted_glyphs: false, + grid_size: GridSize::Medium, + grid_colors: GRID_COLOR_PRESETS[0].1, + simulate_paper: false, + flattener_preset: None, + discard_white_overprint: true, + language: LANGUAGES[0].0.to_string(), + quotes: Quotes::ENGLISH, + typographers_quotes: true, + superscript: ScriptMetrics::DEFAULT, + subscript: ScriptMetrics::DEFAULT, + small_caps_size: 70.0, + export_text: ExportText::Editable, + background: Background::Transparent, + } + } +} + +impl DocSetup { + /// `artboard` grown by the bleed. + pub fn bleed_rect(&self, artboard: Rect) -> Rect { + let [top, bottom, left, right] = self.bleed; + Rect::new(artboard.x0 - left, artboard.y0 - top, artboard.x1 + right, artboard.y1 + bottom) + } + pub fn has_bleed(&self) -> bool { + self.bleed.iter().any(|b| *b > 0.0) + } + /// The simulated paper colour. + pub fn paper(&self) -> Color { + self.grid_colors[0] + } + /// The name of the grid colour preset in use, or "Custom". + pub fn grid_colors_name(&self) -> &'static str { + GRID_COLOR_PRESETS.iter().find(|(_, c)| *c == self.grid_colors).map_or("Custom", |(n, _)| n) + } + /// The flattener preset in use. + pub fn flattener(&self) -> &str { + self.flattener_preset.as_deref().unwrap_or(DEFAULT_FLATTENER) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quotes_open_and_close_by_context() { + let mut prev = None; + assert_eq!(Quotes::ENGLISH.apply("\"Hi,\" it's 'x'", &mut prev), "“Hi,” it’s ‘x’"); + let german = language_quotes("german").unwrap(); + let mut prev = Some('('); + assert_eq!(german.apply("\"a\"", &mut prev), "„a“"); + assert_eq!(prev, Some('“')); + // Converting in pieces keeps the context. + let mut prev = None; + let a = Quotes::ENGLISH.apply("\"", &mut prev); + let b = Quotes::ENGLISH.apply("\"", &mut prev); + assert_eq!((a.as_str(), b.as_str()), ("“", "“")); + } + + #[test] + fn bleed_grows_the_artboard() { + let s = DocSetup { bleed: [9.0, 9.0, 4.0, 2.0], ..Default::default() }; + assert_eq!(s.bleed_rect(Rect::new(0.0, 0.0, 100.0, 50.0)), Rect::new(-4.0, -9.0, 102.0, 59.0)); + assert!(s.has_bleed() && !DocSetup::default().has_bleed()); + } + + #[test] + fn presets_and_defaults() { + let d = DocSetup::default(); + assert_eq!(d.grid_colors_name(), "Light"); + assert_eq!(d.flattener(), "Medium Resolution"); + assert_eq!(Background::parse("WHITE"), Some(Background::White)); + assert_eq!(GridSize::parse("LARGE"), Some(GridSize::Large)); + assert_eq!(ExportText::parse("appearance"), Some(ExportText::Appearance)); + // A partial setup fills in the rest. + let s: DocSetup = serde_json::from_str(r#"{"bleed":[1,2,3,4]}"#).unwrap(); + assert_eq!(s, DocSetup { bleed: [1.0, 2.0, 3.0, 4.0], ..DocSetup::default() }); + } +} diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 010c26478..387c14146 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -76,6 +76,62 @@ pub struct CharStyle { pub stroke_miter_limit: f64, #[serde(default, skip_serializing_if = "Option::is_none")] pub stroke_dash: Option, + /// Superscript or subscript (Character panel). + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub position: CharPosition, + /// Small Caps (Character panel): lowercase letters drawn as capitals at this percentage of the + /// size (Document Setup → Type → Small Caps); None = off. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub small_caps: Option, +} + +/// Superscript or subscript proportions in percent of the font size (Document Setup → Type). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct ScriptMetrics { + /// Glyph size. + pub size: f64, + /// Baseline offset: up for superscript, down for subscript. + pub position: f64, +} + +impl ScriptMetrics { + pub const DEFAULT: Self = Self { size: 58.3, position: 33.3 }; +} + +impl Default for ScriptMetrics { + fn default() -> Self { + Self::DEFAULT + } +} + +/// Character position (Character panel Superscript / Subscript). The proportions are the +/// document's when the position is applied, and follow later Document Setup changes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum CharPosition { + #[default] + Normal, + Superscript(ScriptMetrics), + Subscript(ScriptMetrics), +} + +impl CharPosition { + /// Glyph scale and baseline shift (points, positive = up) for text of `size` points. + pub fn scale_shift(self, size: f64) -> (f64, f64) { + match self { + CharPosition::Normal => (1.0, 0.0), + CharPosition::Superscript(m) => (m.size / 100.0, m.position / 100.0 * size), + CharPosition::Subscript(m) => (m.size / 100.0, -m.position / 100.0 * size), + } + } + /// `normal`, `superscript` or `subscript`. + pub fn id(self) -> &'static str { + match self { + CharPosition::Normal => "normal", + CharPosition::Superscript(_) => "superscript", + CharPosition::Subscript(_) => "subscript", + } + } } fn ten() -> f64 { @@ -118,6 +174,8 @@ impl Default for CharStyle { stroke_join: LineJoin::Miter, stroke_miter_limit: 10.0, stroke_dash: None, + position: CharPosition::Normal, + small_caps: None, } } } diff --git a/crates/engine/src/cmd/docsetup.rs b/crates/engine/src/cmd/docsetup.rs new file mode 100644 index 000000000..8244b8c44 --- /dev/null +++ b/crates/engine/src/cmd/docsetup.rs @@ -0,0 +1,295 @@ +//! File → Document Setup (`document.setup`): units, bleed, the transparency grid and simulated +//! paper, output options and the document's type options. + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::setup::{DEFAULT_FLATTENER, GRID_COLOR_PRESETS, LANGUAGES, MAX_BLEED}; +use vectorcraft_doc::{Background, CharPosition, CharStyle, DocSetup, Document, ExportText, GridSize, NodeKind, Quotes, ScriptMetrics}; + +use super::flatten::{FlattenOptions, FlattenerPreset}; + +use super::*; + +const C: &str = "document.setup"; + +pub fn specs() -> Vec { + vec![cmd!( + "document.setup", + "Document Setup", + [], + None, + "{units?: \"Points\"|\"Inches\"|\"Millimeters\"|…, bleed?: pt (all sides)|[top, bottom, left, right]|{top?, bottom?, left?, right?} (0–72 pt), outlineImages?: bool (show images in Outline mode), highlightSubstitutedFonts?: bool, highlightSubstitutedGlyphs?: bool, gridSize?: \"small\"|\"medium\"|\"large\", gridColors?: \"Light\"|\"Medium\"|\"Dark\"|\"Red\"|\"Orange\"|\"Green\"|\"Blue\"|\"Purple\"|[colour, colour] (transparency grid; the first is also the paper colour), simulatePaper?: bool, flattenerPreset?: \"High Resolution\"|\"Medium Resolution\"|\"Low Resolution\"|a saved preset (flattener.presets.list), discardWhiteOverprint?: bool, language?: \"English: USA\"|\"German\"|\"French\"|… (also picks its quotes), quotes?: {double?: \"“”\", single?: \"‘’\"} (beside a language: only if they differ from the current ones), typographersQuotes?: bool (typed straight quotes become the document's quotes), superscript?: {size?: %, position?: %}, subscript?: {size?: %, position?: %}, smallCapsSize?: %, exportText?: \"editable\"|\"appearance\" (SVG text as text or as outlines), backgroundContents?: \"transparent\"|\"white\" (white: raster exports are white behind the art)} change the document setup in one undo step; no params → the current setup", + has_doc, + setup + )] +} + +/// The setup as `document.setup` reports it (and accepts it back). +pub(crate) fn setup_json(d: &Document) -> Value { + let s = &d.setup; + let pair = |p: [char; 2]| p.iter().collect::(); + json!({ + "units": d.units.label(), + "bleed": s.bleed, + "outlineImages": s.outline_images, + "highlightSubstitutedFonts": s.highlight_substituted_fonts, + "highlightSubstitutedGlyphs": s.highlight_substituted_glyphs, + "gridSize": s.grid_size.id(), + "gridColors": s.grid_colors.map(|c| c.to_hex()), + "gridColorsName": s.grid_colors_name(), + "simulatePaper": s.simulate_paper, + "flattenerPreset": s.flattener(), + "discardWhiteOverprint": s.discard_white_overprint, + "language": s.language, + "quotes": {"double": pair(s.quotes.double), "single": pair(s.quotes.single)}, + "typographersQuotes": s.typographers_quotes, + "superscript": s.superscript, + "subscript": s.subscript, + "smallCapsSize": s.small_caps_size, + "exportText": s.export_text.id(), + "backgroundContents": s.background.id(), + }) +} + +fn setup(s: &mut Session, p: &Value) -> Result { + let empty = Map::new(); + let o = match p { + Value::Null => &empty, + Value::Object(o) => o, + _ => return Err(bad(C, "params must be an object")), + }; + let st = s.doc()?; + if o.is_empty() { + return Ok(setup_json(&st.doc)); + } + let mut new = st.doc.setup.clone(); + let mut units = st.doc.units; + // The language goes first: it picks its quotes, which `quotes` may then override. + let current = st.doc.setup.quotes; + for (k, v) in o.iter().filter(|(k, _)| *k == "language").chain(o.iter().filter(|(k, _)| *k != "language")) { + // With a language, unchanged quotes (a report fed back) don't undo the language's quotes. + if k == "quotes" && o.contains_key("language") && quotes_param(v, current)? == current { + continue; + } + apply(&mut new, &mut units, k, v, &s.prefs.flattener_presets)?; + } + let st = s.doc()?; + if new == st.doc.setup && units == st.doc.units { + return Ok(setup_json(&st.doc)); + } + s.edit("Document Setup", |d, _| { + let scripts_changed = + (new.superscript, new.subscript, new.small_caps_size) != (d.setup.superscript, d.setup.subscript, d.setup.small_caps_size); + d.setup = new; + d.units = units; + if scripts_changed { + restamp_scripts(d); + } + Ok(()) + })?; + Ok(setup_json(&s.doc()?.doc)) +} + +fn flag(k: &str, v: &Value) -> Result { + v.as_bool().ok_or_else(|| bad(C, format!("{k} must be true or false"))) +} + +fn percent(k: &str, v: &Value, min: f64, max: f64) -> Result { + v.as_f64().filter(|x| (min..=max).contains(x)).ok_or_else(|| bad(C, format!("{k} must be a number from {min} to {max}"))) +} + +fn apply(s: &mut DocSetup, units: &mut vectorcraft_doc::Unit, k: &str, v: &Value, saved: &[FlattenerPreset]) -> Result<()> { + match k { + "units" => *units = v.as_str().and_then(vectorcraft_doc::Unit::named).ok_or_else(|| bad(C, "unknown units"))?, + "bleed" => s.bleed = bleed_param(v, s.bleed, C)?, + "outlineImages" => s.outline_images = flag(k, v)?, + "highlightSubstitutedFonts" => s.highlight_substituted_fonts = flag(k, v)?, + "highlightSubstitutedGlyphs" => s.highlight_substituted_glyphs = flag(k, v)?, + "gridSize" => s.grid_size = v.as_str().and_then(GridSize::parse).ok_or_else(|| bad(C, "gridSize must be small, medium or large"))?, + "gridColors" => s.grid_colors = grid_colors(v)?, + "simulatePaper" => s.simulate_paper = flag(k, v)?, + "flattenerPreset" => s.flattener_preset = flattener_preset(v, saved)?, + "discardWhiteOverprint" => s.discard_white_overprint = flag(k, v)?, + "language" => { + let l = v.as_str().unwrap_or(""); + let (name, quotes) = LANGUAGES.iter().find(|(n, _)| n.eq_ignore_ascii_case(l)).ok_or_else(|| { + bad(C, format!("unknown language `{l}` (one of {})", LANGUAGES.iter().map(|(n, _)| *n).collect::>().join(", "))) + })?; + s.language = name.to_string(); + s.quotes = *quotes; + } + "quotes" => s.quotes = quotes_param(v, s.quotes)?, + "typographersQuotes" => s.typographers_quotes = flag(k, v)?, + "superscript" => s.superscript = script_param(k, v, s.superscript)?, + "subscript" => s.subscript = script_param(k, v, s.subscript)?, + "smallCapsSize" => s.small_caps_size = percent(k, v, 1.0, 200.0)?, + "exportText" => s.export_text = v.as_str().and_then(ExportText::parse).ok_or_else(|| bad(C, "exportText must be editable or appearance"))?, + "backgroundContents" => s.background = background_param(v, C)?, + // Read-only values `document.setup` reports. + "gridColorsName" => {} + _ => return Err(bad(C, format!("unknown setting `{k}`"))), + } + Ok(()) +} + +/// A built-in flattener preset (by name or id) or a saved one, stored by its display name (None: +/// the default preset). +fn flattener_preset(v: &Value, saved: &[FlattenerPreset]) -> Result> { + let name = v.as_str().unwrap_or("").trim(); + let builtin = FlattenOptions::PRESETS + .into_iter() + .filter_map(|id| Some((id, FlattenOptions::preset_label(id)?))) + .find(|(id, label)| id.eq_ignore_ascii_case(name) || label.eq_ignore_ascii_case(name)) + .map(|(_, label)| label); + let found = builtin.or_else(|| saved.iter().map(|p| p.name.as_str()).find(|n| n.eq_ignore_ascii_case(name))).ok_or_else(|| { + bad( + C, + format!( + "unknown flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution or a saved one: see flattener.presets.list)" + ), + ) + })?; + Ok((found != DEFAULT_FLATTENER).then(|| found.to_string())) +} + +/// `"transparent"` or `"white"`. +pub(crate) fn background_param(v: &Value, cmd: &str) -> Result { + v.as_str().and_then(Background::parse).ok_or_else(|| bad(cmd, "backgroundContents must be transparent or white")) +} + +/// `pt` (every side), `[top, bottom, left, right]` or `{top?, bottom?, left?, right?}`. +pub(crate) fn bleed_param(v: &Value, current: [f64; 4], cmd: &str) -> Result<[f64; 4]> { + let side = |x: &Value| x.as_f64().filter(|b| (0.0..=MAX_BLEED).contains(b)); + let err = || bad(cmd, format!("bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}}")); + match v { + Value::Number(_) => side(v).map(|b| [b; 4]).ok_or_else(err), + Value::Array(a) if a.len() == 4 => { + let mut out = [0.0; 4]; + for (o, x) in out.iter_mut().zip(a) { + *o = side(x).ok_or_else(err)?; + } + Ok(out) + } + Value::Object(o) => { + let mut out = current; + for (k, x) in o { + let i = ["top", "bottom", "left", "right"].iter().position(|s| s == k).ok_or_else(err)?; + out[i] = side(x).ok_or_else(err)?; + } + Ok(out) + } + _ => Err(err()), + } +} + +fn grid_colors(v: &Value) -> Result<[vectorcraft_color::Color; 2]> { + if let Some(name) = v.as_str() { + return GRID_COLOR_PRESETS.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, c)| *c).ok_or_else(|| { + bad(C, format!("unknown grid colours `{name}` (one of {})", GRID_COLOR_PRESETS.iter().map(|(n, _)| *n).collect::>().join(", "))) + }); + } + match v.as_array().map(|a| a.iter().map(color_value).collect::>>()) { + Some(Some(c)) if c.len() == 2 => Ok([c[0], c[1]]), + _ => Err(bad(C, "gridColors must be a preset name or two colours")), + } +} + +/// A quote pair: a two-character string or two one-character strings. +fn quote_pair(v: &Value) -> Option<[char; 2]> { + let chars: Vec = match v { + Value::String(s) => s.chars().collect(), + Value::Array(a) => a.iter().map(|x| x.as_str().and_then(|s| s.chars().next().filter(|_| s.chars().count() == 1))).collect::>()?, + _ => return None, + }; + match chars[..] { + [a, b] if !a.is_control() && !b.is_control() && !a.is_whitespace() && !b.is_whitespace() => Some([a, b]), + _ => None, + } +} + +fn quotes_param(v: &Value, mut q: Quotes) -> Result { + let o = v.as_object().ok_or_else(|| bad(C, "quotes must be {double?, single?}"))?; + for (k, x) in o { + let pair = quote_pair(x).ok_or_else(|| bad(C, format!("quotes.{k} must be an opening and a closing quote, e.g. \"“”\"")))?; + match k.as_str() { + "double" => q.double = pair, + "single" => q.single = pair, + _ => return Err(bad(C, format!("unknown quotes key `{k}` (double, single)"))), + } + } + Ok(q) +} + +fn script_param(k: &str, v: &Value, mut m: ScriptMetrics) -> Result { + let o = v.as_object().ok_or_else(|| bad(C, format!("{k} must be {{size?, position?}}")))?; + for (key, x) in o { + match key.as_str() { + "size" => m.size = percent(&format!("{k}.size"), x, 1.0, 500.0)?, + "position" => m.position = percent(&format!("{k}.position"), x, -500.0, 500.0)?, + _ => return Err(bad(C, format!("unknown {k} key `{key}` (size, position)"))), + } + } + Ok(m) +} + +/// `position?: "normal"|"superscript"|"subscript"` and `smallCaps?: bool` from a character +/// command's params, in the document's Document Setup proportions. +pub(crate) fn script_params(p: &Value, setup: &DocSetup, cmd: &str) -> Result<(Option, Option>)> { + let position = match p.get("position").filter(|v| !v.is_null()) { + None => None, + Some(v) => Some(match v.as_str().map(str::to_ascii_lowercase).as_deref() { + Some("normal") => CharPosition::Normal, + Some("superscript") => CharPosition::Superscript(setup.superscript), + Some("subscript") => CharPosition::Subscript(setup.subscript), + _ => return Err(bad(cmd, "position must be normal, superscript or subscript")), + }), + }; + let small_caps = match p.get("smallCaps").filter(|v| !v.is_null()) { + None => None, + Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "smallCaps must be true or false"))?.then_some(setup.small_caps_size)), + }; + Ok((position, small_caps)) +} + +/// A superscript or subscript in `setup`'s proportions. +fn position_in(setup: &DocSetup, p: CharPosition) -> CharPosition { + match p { + CharPosition::Normal => CharPosition::Normal, + CharPosition::Superscript(_) => CharPosition::Superscript(setup.superscript), + CharPosition::Subscript(_) => CharPosition::Subscript(setup.subscript), + } +} + +/// After the superscript, subscript or small caps proportions changed: every run (and character +/// style) using them takes the new ones. +fn restamp_scripts(d: &mut Document) { + let uses = |st: &CharStyle| st.position != CharPosition::Normal || st.small_caps.is_some(); + let mut ids = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind + && t.runs.iter().any(|r| uses(&r.style)) + { + ids.push(n.id); + } + }); + let setup = d.setup.clone(); + for id in ids { + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + for st in t.runs.iter_mut().map(|r| &mut r.style).filter(|st| uses(st)) { + st.position = position_in(&setup, st.position); + st.small_caps = st.small_caps.map(|_| setup.small_caps_size); + } + super::typecmd::refresh_bounds(t); + } + } + // Character styles store the attributes they set by their serialized names. + for def in &mut d.char_styles { + if let Some(v) = def.attrs.get_mut("position") + && let Ok(p) = serde_json::from_value::(v.clone()) + { + *v = serde_json::to_value(position_in(&setup, p)).unwrap_or(Value::Null); + } + if let Some(v) = def.attrs.get_mut("small_caps").filter(|v| v.is_number()) { + *v = json!(setup.small_caps_size); + } + } +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e3050a087..2d4d18cd5 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::{Document, Node, NodeId, Unit}; -use vectorcraft_geom::{Affine, Vec2}; +use vectorcraft_geom::Affine; use super::*; use crate::{EngineError, inspect}; @@ -16,9 +16,9 @@ pub fn specs() -> Vec { "New…", ["File"], Some("Cmd+N"), - "{width?: pt=612, height?: pt=792, units?: \"Points\"|\"Inches\"|\"Millimeters\"|\"Pixels\"… (default: prefs unitsGeneral), title?, artboards?: n, colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK)}", + "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", always, - file_new + super::newdoc::file_new ), cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close), cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate), @@ -52,37 +52,6 @@ pub fn specs() -> Vec { ] } -fn file_new(s: &mut Session, p: &Value) -> Result { - let w = f64_or(p, "width", 612.0); - let h = f64_or(p, "height", 792.0); - if !(w > 0.0 && h > 0.0) || w > 16383.0 * 10.0 || h > 16383.0 * 10.0 { - return Err(bad("file.new", "width/height must be positive and within the canvas")); - } - let mode = match str_param(p, "colorMode") { - Some(m) if m.eq_ignore_ascii_case("cmyk") => vectorcraft_doc::ColorMode::Cmyk, - _ => vectorcraft_doc::ColorMode::Rgb, - }; - let mut d = Document::new_with_mode(w, h, mode); - d.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| s.next_untitled()); - d.units = match str_param(p, "units") { - Some(u) => Unit::named(u).ok_or_else(|| bad("file.new", format!("unknown units `{u}`")))?, - None => s.default_units(), - }; - let n = p.get("artboards").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize; - for i in 1..n { - let r = vectorcraft_geom::Rect::new(0.0, 0.0, w, h) + Vec2::new(i as f64 * (w + 20.0), 0.0); - d.artboards.push(vectorcraft_doc::Artboard { - id: (i + 1) as u32, - name: format!("Artboard {}", i + 1), - rect: r, - show_center_mark: false, - show_cross_hairs: false, - }); - } - let i = s.add_document(d, None); - Ok(json!({ "index": i })) -} - fn file_close(s: &mut Session, p: &Value) -> Result { let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?; if !s.close_document(i) { @@ -102,6 +71,7 @@ fn doc_node(s: &mut Session, p: &Value) -> Result { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) } +/// Document Setup's units (also `document.setup {units}`). fn set_units(s: &mut Session, p: &Value) -> Result { let u = str_param(p, "units").and_then(Unit::named).ok_or_else(|| bad("document.setUnits", "unknown units"))?; s.set_document_units(u)?; diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index f8c674886..eedd99174 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -176,7 +176,9 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { let scale = o.scale.unwrap_or(1.0).clamp(0.01, 64.0); check_format_size(f, region.width() * scale, region.height() * scale)?; vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?; - let img = vectorcraft_render::Renderer::new().render_region(doc, region, scale, f.id == "jpg"); + // JPEG has no alpha; New Document → Background Contents: White fills the artboard too. + let white = f.id == "jpg" || doc.setup.background == vectorcraft_doc::Background::White; + let img = vectorcraft_render::Renderer::new().render_region(doc, region, scale, white); match f.id { "png" => img.to_png(), "webp" => img.to_webp(), diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index db2032773..56be86ae3 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -31,7 +31,7 @@ pub const OPTIONS: &[FormatOption] = &[ name: "outlineText", ty: "boolean", default: "false", - description: "Fonts: text as glyph outlines (viewable without the fonts) instead of ", + description: "Fonts: text as glyph outlines (viewable without the fonts) instead of ; default: Document Setup → Type → Export (Preserve Text Appearance: true)", }, FormatOption { name: "images", @@ -99,6 +99,10 @@ pub fn options_map(p: &Value) -> Result, String> { /// The writer options and the artboards (`None`: the art bounds) an SVG export of `doc` covers. fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec>), String> { let mut m = options_map(p)?; + // Fonts: unless chosen, Document Setup → Type → Export decides (appearance = outlines). + if !m.contains_key("outlineText") && doc.setup.export_text == vectorcraft_doc::ExportText::Appearance { + m.insert("outlineText".into(), Value::Bool(true)); + } let picks: Map = ARTBOARD_PARAMS.iter().chain(["useArtboards"].iter()).filter_map(|k| m.remove_entry(*k)).collect(); let boards = Boards::deserialize(Value::Object(picks)).map_err(|e| format!("SVG options: {e}"))?; let opts = ExportOptions::deserialize(Value::Object(m)).map_err(|e| format!("SVG options: {e}"))?; diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index d3fc727e1..cd2a21cfd 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -679,27 +679,9 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result { s.edit("Rearrange Artboards", |d, _| { let rects: Vec = d.artboards.iter().map(|a| a.rect).collect(); let Some(first) = rects.first().copied() else { return Ok(()) }; - let n = rects.len(); - let rows = n.div_ceil(cols); - // Grid cell (row, col) of artboard i. - let cell = |i: usize| if by_col { (i % rows, i / rows) } else { (i / cols, i % cols) }; - let mut col_w = vec![0.0f64; cols]; - let mut row_h = vec![0.0f64; rows]; - for (i, r) in rects.iter().enumerate() { - let (ro, co) = cell(i); - col_w[co] = col_w[co].max(r.width()); - row_h[ro] = row_h[ro].max(r.height()); - } - let x_of = |c: usize| first.x0 + col_w[..c].iter().map(|w| w + spacing).sum::(); - let y_of = |r: usize| first.y0 + row_h[..r].iter().map(|h| h + spacing).sum::(); - let deltas: Vec = rects - .iter() - .enumerate() - .map(|(i, r)| { - let (ro, co) = cell(i); - Point::new(x_of(co), y_of(ro)) - r.origin() - }) - .collect(); + let sizes: Vec<(f64, f64)> = rects.iter().map(|r| (r.width(), r.height())).collect(); + let origins = super::newdoc::grid_origins(&sizes, first.origin(), cols, spacing, by_col, false); + let deltas: Vec = rects.iter().zip(origins).map(|(r, o)| o - r.origin()).collect(); if move_art { let tops: Vec<(NodeId, Point)> = d .layers diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 45a2f1597..2657064c6 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -12,6 +12,7 @@ mod create; pub(crate) mod distortcmds; mod docinfo; mod docmenu; +pub(crate) mod docsetup; mod draw2; mod edit; mod effectcmd; @@ -28,6 +29,7 @@ mod live; pub(crate) mod maskedit; pub(crate) mod menucmds; pub(crate) mod newart; +pub mod newdoc; mod object; mod opacitymask; mod overprint; @@ -211,6 +213,8 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(newart::specs()); v.extend(colortheme::specs()); v.extend(fileio::pdf::specs()); + v.extend(docsetup::specs()); + v.extend(newdoc::specs()); v }) } diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs new file mode 100644 index 000000000..1428670a9 --- /dev/null +++ b/crates/engine/src/cmd/newdoc.rs @@ -0,0 +1,621 @@ +//! File → New: the settings `file.new` takes, the New Document presets (generated in code, by +//! category), the user's saved presets and the recently used sizes (both kept in the +//! preferences), and the artboard grid layout shared with Rearrange All Artboards. + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use vectorcraft_doc::{Artboard, Background, ColorMode, Document, Unit}; +use vectorcraft_geom::{Point, Rect}; + +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "file.newPresets", + "New Document Presets", + [], + None, + "{category?: \"Recent\"|\"Saved\"|\"Mobile\"|\"Web\"|\"Print\"|\"Film & Video\"|\"Art & Illustration\"|\"Branding\"|\"Social\"} the New Document presets by category (Recent: the last sizes used; Saved: the user's presets) → {categories: [{name, presets: [{name, size: \"1920 × 1080 px\", width: pt, height: pt, units, orientation, artboards, artboardLayout: {layout, columns, spacing, rightToLeft}, bleed, backgroundContents, colorMode, rasterEffectsPpi, previewMode}]}]}; file.new {preset: name} starts from one", + always, + list + ), + cmd!( + "file.newPresets.save", + "Save Preset", + [], + None, + "{name, preset?: start from this preset, …settings as file.new (default: Letter in prefs unitsGeneral)} save New Document settings as a user preset (the Saved category; same name = replace) in the preferences → {name, count}", + always, + save + ), + cmd!( + "file.newPresets.delete", + "Delete Preset", + [], + None, + "{name} delete a saved New Document preset → {deleted}", + |s| if s.prefs.new_doc_presets.is_empty() { Err("no saved presets".into()) } else { Ok(()) }, + delete + ), + ] +} + +/// How New Document lays out several artboards. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ArtboardLayout { + /// Rows of `columns` artboards, filled left to right then down. + #[default] + GridByRow, + /// `columns` columns, filled top to bottom then across. + GridByColumn, + /// One row. + Row, + /// One column. + Column, +} + +impl ArtboardLayout { + pub const ALL: [Self; 4] = [Self::GridByRow, Self::GridByColumn, Self::Row, Self::Column]; + pub fn id(self) -> &'static str { + match self { + Self::GridByRow => "gridByRow", + Self::GridByColumn => "gridByColumn", + Self::Row => "row", + Self::Column => "column", + } + } + pub fn label(self) -> &'static str { + match self { + Self::GridByRow => "Grid by Row", + Self::GridByColumn => "Grid by Column", + Self::Row => "Arrange by Row", + Self::Column => "Arrange by Column", + } + } + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(s) || l.label().eq_ignore_ascii_case(s)) + } +} + +/// How a new document previews: normally, as pixels (Pixel Preview) or with Overprint Preview. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PreviewMode { + #[default] + Default, + Pixel, + Overprint, +} + +impl PreviewMode { + pub const ALL: [Self; 3] = [Self::Default, Self::Pixel, Self::Overprint]; + pub fn id(self) -> &'static str { + match self { + Self::Default => "default", + Self::Pixel => "pixel", + Self::Overprint => "overprint", + } + } + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s)) + } +} + +/// Everything New Document sets: a preset (built-in, saved or recent) or `file.new`'s params. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DocSettings { + /// The preset's name. + pub name: String, + /// Artboard size in points. + pub width: f64, + pub height: f64, + pub units: Unit, + pub artboards: u32, + pub layout: ArtboardLayout, + /// Columns of a grid layout. + pub columns: u32, + /// Space between artboards, points. + pub spacing: f64, + /// Change to Right-to-Left Layout: the first artboard at the right. + pub right_to_left: bool, + /// Top, bottom, left, right, points. + pub bleed: [f64; 4], + pub background: Background, + pub color_mode: ColorMode, + pub raster_effects_ppi: f64, + pub preview_mode: PreviewMode, +} + +impl Default for DocSettings { + fn default() -> Self { + Self { + name: "Letter".into(), + width: 612.0, + height: 792.0, + units: Unit::Points, + artboards: 1, + layout: ArtboardLayout::GridByRow, + columns: 0, + spacing: 20.0, + right_to_left: false, + bleed: [0.0; 4], + background: Background::Transparent, + color_mode: ColorMode::Rgb, + raster_effects_ppi: 72.0, + preview_mode: PreviewMode::Default, + } + } +} + +/// Most artboards a new document can have (as the reference app). +pub const MAX_ARTBOARDS: u32 = 1000; +/// Raster effects resolutions New Document offers: (ppi, label). +pub const RASTER_PPI: [(f64, &str); 3] = [(72.0, "Screen (72 ppi)"), (150.0, "Medium (150 ppi)"), (300.0, "High (300 ppi)")]; + +impl DocSettings { + /// The columns a grid actually uses (0 or more than the artboards: one row). + pub fn grid_columns(&self) -> usize { + let n = self.artboards.max(1) as usize; + match self.layout { + ArtboardLayout::Row => n, + ArtboardLayout::Column => 1, + _ if self.columns == 0 => n, + _ => (self.columns as usize).min(n), + } + } + + /// "portrait" or "landscape" (a square is portrait). + pub fn orientation(&self) -> &'static str { + if self.width > self.height { "landscape" } else { "portrait" } + } + + /// The size as New Document shows it, e.g. "1920 × 1080 px". + pub fn size_label(&self) -> String { + let u = self.units; + format!("{} × {} {}", u.number(self.width), u.number(self.height), u.suffix()) + } + + /// As `file.newPresets` lists it (and `file.new` takes it back). + pub fn to_json(&self) -> Value { + json!({ + "name": self.name, + "size": self.size_label(), + "width": self.width, + "height": self.height, + "units": self.units.label(), + "orientation": self.orientation(), + "artboards": self.artboards, + "artboardLayout": {"layout": self.layout.id(), "columns": self.grid_columns(), "spacing": self.spacing, "rightToLeft": self.right_to_left}, + "bleed": self.bleed, + "backgroundContents": self.background.id(), + "colorMode": color_mode_id(self.color_mode), + "rasterEffectsPpi": self.raster_effects_ppi, + "previewMode": self.preview_mode.id(), + }) + } + + /// These settings with `p`'s changes (the params of `file.new`) → validated. + pub fn with_params(mut self, p: &Value, cmd: &str) -> Result { + if let Some(u) = p.get("units").filter(|v| !v.is_null()) { + let u = u.as_str().unwrap_or(""); + self.units = Unit::named(u).ok_or_else(|| bad(cmd, format!("unknown units `{u}`")))?; + } + // Numbers are points; strings may carry their unit ("210 mm"), else the document's. + let length = |k: &str, units: Unit| -> Result> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::Number(n)) => Ok(n.as_f64()), + Some(Value::String(s)) => units.parse(s).map(Some).ok_or_else(|| bad(cmd, format!("{k}: can't read `{s}` as a length"))), + Some(_) => Err(bad(cmd, format!("{k} must be a number (pt) or a length such as \"210 mm\""))), + } + }; + if let Some(w) = length("width", self.units)? { + self.width = w; + } + if let Some(h) = length("height", self.units)? { + self.height = h; + } + if !(self.width > 0.0 && self.height > 0.0) || self.width > MAX_SIDE || self.height > MAX_SIDE { + return Err(bad(cmd, "width/height must be positive and within the canvas")); + } + match p.get("orientation").and_then(Value::as_str) { + None => {} + Some(o) if o.eq_ignore_ascii_case("portrait") => (self.width, self.height) = (self.width.min(self.height), self.width.max(self.height)), + Some(o) if o.eq_ignore_ascii_case("landscape") => (self.width, self.height) = (self.width.max(self.height), self.width.min(self.height)), + Some(o) => return Err(bad(cmd, format!("orientation must be portrait or landscape, not `{o}`"))), + } + if let Some(n) = p.get("artboards").filter(|v| !v.is_null()) { + let n = n.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad(cmd, "artboards must be a number"))?; + self.artboards = n.round().clamp(1.0, MAX_ARTBOARDS as f64) as u32; + } + if let Some(l) = p.get("artboardLayout").filter(|v| !v.is_null()) { + self.layout_params(l, cmd)?; + } + if let Some(b) = p.get("bleed").filter(|v| !v.is_null()) { + self.bleed = super::docsetup::bleed_param(b, self.bleed, cmd)?; + } + if let Some(b) = p.get("backgroundContents").filter(|v| !v.is_null()) { + self.background = super::docsetup::background_param(b, cmd)?; + } + if let Some(m) = str_param(p, "colorMode") { + self.color_mode = match m.to_ascii_lowercase().as_str() { + "rgb" | "rgb color" => ColorMode::Rgb, + "cmyk" | "cmyk color" => ColorMode::Cmyk, + _ => return Err(bad(cmd, format!("colorMode must be rgb or cmyk, not `{m}`"))), + }; + } + if let Some(v) = p.get("rasterEffectsPpi").filter(|v| !v.is_null()) { + self.raster_effects_ppi = v + .as_f64() + .filter(|r| (1.0..=2400.0).contains(r)) + .ok_or_else(|| bad(cmd, "rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high)"))?; + } + if let Some(m) = p.get("previewMode").filter(|v| !v.is_null()) { + self.preview_mode = m.as_str().and_then(PreviewMode::parse).ok_or_else(|| bad(cmd, "previewMode must be default, pixel or overprint"))?; + } + let extent = self.boards().iter().fold(Rect::ZERO, |a, r| a.union(*r)); + if extent.width().max(extent.height()) + self.bleed.iter().fold(0.0f64, |a, b| a.max(*b)) * 2.0 > crate::MAX_COORD { + return Err(bad(cmd, "the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size")); + } + Ok(self) + } + + /// `artboardLayout: {layout?, columns?, spacing?, rightToLeft?}` or just a layout id. + fn layout_params(&mut self, l: &Value, cmd: &str) -> Result<()> { + let bad_layout = || bad(cmd, "artboardLayout.layout must be gridByRow, gridByColumn, row or column"); + if let Some(id) = l.as_str() { + self.layout = ArtboardLayout::parse(id).ok_or_else(bad_layout)?; + return Ok(()); + } + let o = l.as_object().ok_or_else(|| bad(cmd, "artboardLayout must be {layout?, columns?, spacing?, rightToLeft?}"))?; + for (k, v) in o { + match k.as_str() { + "layout" => self.layout = v.as_str().and_then(ArtboardLayout::parse).ok_or_else(bad_layout)?, + "columns" => { + self.columns = v + .as_f64() + .filter(|c| c.is_finite() && *c >= 1.0) + .ok_or_else(|| bad(cmd, "artboardLayout.columns must be 1 or more"))? + .round() + .min(MAX_ARTBOARDS as f64) as u32 + } + "spacing" => { + self.spacing = v + .as_f64() + .filter(|s| (0.0..=MAX_SPACING).contains(s)) + .ok_or_else(|| bad(cmd, format!("artboardLayout.spacing must be 0–{MAX_SPACING} pt")))? + } + "rightToLeft" => self.right_to_left = v.as_bool().ok_or_else(|| bad(cmd, "artboardLayout.rightToLeft must be true or false"))?, + _ => return Err(bad(cmd, format!("unknown artboardLayout key `{k}` (layout, columns, spacing, rightToLeft)"))), + } + } + Ok(()) + } + + /// The artboard rectangles, the first at the origin's side. + pub fn boards(&self) -> Vec { + let n = self.artboards.max(1) as usize; + let sizes = vec![(self.width, self.height); n]; + let by_column = self.layout == ArtboardLayout::GridByColumn; + grid_origins(&sizes, Point::ZERO, self.grid_columns(), self.spacing, by_column, self.right_to_left) + .into_iter() + .map(|o| Rect::from_origin_size(o, (self.width, self.height))) + .collect() + } + + /// The new document (title and swatches set by `file.new`). + pub fn document(&self) -> Document { + let mut d = Document::new_with_mode(self.width, self.height, self.color_mode); + d.units = self.units; + d.raster_effects_ppi = self.raster_effects_ppi; + d.setup.bleed = self.bleed; + d.setup.background = self.background; + d.artboards = self + .boards() + .into_iter() + .enumerate() + .map(|(i, rect)| Artboard { + id: i as u32 + 1, + name: format!("Artboard {}", i + 1), + rect, + show_center_mark: false, + show_cross_hairs: false, + }) + .collect(); + d + } +} + +/// Largest artboard side `file.new` accepts, points. +const MAX_SIDE: f64 = 16383.0 * 10.0; +/// Largest artboard spacing, points. +const MAX_SPACING: f64 = 1.0e5; + +pub fn color_mode_id(m: ColorMode) -> &'static str { + match m { + ColorMode::Rgb => "rgb", + ColorMode::Cmyk => "cmyk", + } +} + +/// Top-left corners for artboards of `sizes` from `origin`: `columns` per row, `spacing` apart, +/// each column as wide and each row as tall as its largest artboard; `by_column` fills columns +/// top to bottom first, `rtl` puts the first column at the right. +pub(crate) fn grid_origins(sizes: &[(f64, f64)], origin: Point, columns: usize, spacing: f64, by_column: bool, rtl: bool) -> Vec { + let n = sizes.len(); + let cols = columns.clamp(1, n.max(1)); + let rows = n.div_ceil(cols).max(1); + // Grid cell (row, column) of artboard i. + let cell = |i: usize| { + let (r, c) = if by_column { (i % rows, i / rows) } else { (i / cols, i % cols) }; + (r, if rtl { cols - 1 - c.min(cols - 1) } else { c }) + }; + let (mut col_w, mut row_h) = (vec![0.0f64; cols], vec![0.0f64; rows]); + for (i, (w, h)) in sizes.iter().enumerate() { + let (r, c) = cell(i); + if let (Some(cw), Some(rh)) = (col_w.get_mut(c), row_h.get_mut(r)) { + *cw = cw.max(*w); + *rh = rh.max(*h); + } + } + let offset = |spans: &[f64], k: usize| spans.iter().take(k).map(|s| s + spacing).sum::(); + (0..n) + .map(|i| { + let (r, c) = cell(i); + Point::new(origin.x + offset(&col_w, c), origin.y + offset(&row_h, r)) + }) + .collect() +} + +/// The built-in presets: (category, presets as (name, width, height, units)), with the category's +/// colour mode and raster effects resolution. Sizes in `units` (converted to points on use). +type Category = (&'static str, ColorMode, f64, &'static [(&'static str, f64, f64, Unit)]); + +const PX: Unit = Unit::Pixels; +const PT: Unit = Unit::Points; +const MM: Unit = Unit::Millimeters; +const IN: Unit = Unit::Inches; + +pub const CATEGORIES: &[Category] = &[ + ( + "Mobile", + ColorMode::Rgb, + 72.0, + &[ + ("Phone 390×844", 390.0, 844.0, PX), + ("Phone 393×852", 393.0, 852.0, PX), + ("Phone 430×932", 430.0, 932.0, PX), + ("Phone 360×800", 360.0, 800.0, PX), + ("Phone 412×915", 412.0, 915.0, PX), + ("Phone 375×667", 375.0, 667.0, PX), + ("Tablet 768×1024", 768.0, 1024.0, PX), + ("Tablet 820×1180", 820.0, 1180.0, PX), + ("Tablet 834×1194", 834.0, 1194.0, PX), + ("Tablet 1024×1366", 1024.0, 1366.0, PX), + ("Tablet 800×1280", 800.0, 1280.0, PX), + ("Watch 184×224", 184.0, 224.0, PX), + ("Watch 198×242", 198.0, 242.0, PX), + ], + ), + ( + "Web", + ColorMode::Rgb, + 72.0, + &[ + ("Web 1920×1080", 1920.0, 1080.0, PX), + ("Web 1600×900", 1600.0, 900.0, PX), + ("Web 1440×900", 1440.0, 900.0, PX), + ("Web 1366×768", 1366.0, 768.0, PX), + ("Web 1280×800", 1280.0, 800.0, PX), + ("Web 1024×768", 1024.0, 768.0, PX), + ], + ), + ( + "Print", + ColorMode::Cmyk, + 300.0, + &[ + ("Letter", 612.0, 792.0, PT), + ("Legal", 612.0, 1008.0, PT), + ("Tabloid", 792.0, 1224.0, PT), + ("A3", 297.0, 420.0, MM), + ("A4", 210.0, 297.0, MM), + ("A5", 148.0, 210.0, MM), + ("B4", 250.0, 353.0, MM), + ("B5", 176.0, 250.0, MM), + ], + ), + ( + "Film & Video", + ColorMode::Rgb, + 72.0, + &[ + ("HDTV 1080p", 1920.0, 1080.0, PX), + ("HDTV 720p", 1280.0, 720.0, PX), + ("4K UHD", 3840.0, 2160.0, PX), + ("8K UHD", 7680.0, 4320.0, PX), + ("2K Cinema", 2048.0, 1080.0, PX), + ("4K Cinema", 4096.0, 2160.0, PX), + ], + ), + ( + "Art & Illustration", + ColorMode::Rgb, + 300.0, + &[ + ("Postcard", 4.0, 6.0, IN), + ("Greeting Card", 5.0, 7.0, IN), + ("Square 8 × 8 in", 8.0, 8.0, IN), + ("Poster 18 × 24 in", 18.0, 24.0, IN), + ("Poster 24 × 36 in", 24.0, 36.0, IN), + ], + ), + ( + "Branding", + ColorMode::Cmyk, + 300.0, + &[ + ("Logo 1000×1000", 1000.0, 1000.0, PX), + ("Business Card 3.5 × 2 in", 3.5, 2.0, IN), + ("Business Card 85 × 55 mm", 85.0, 55.0, MM), + ("Letterhead Letter", 8.5, 11.0, IN), + ("Letterhead A4", 210.0, 297.0, MM), + ("Envelope DL", 220.0, 110.0, MM), + ("Envelope #10", 9.5, 4.125, IN), + ], + ), + ( + "Social", + ColorMode::Rgb, + 72.0, + &[ + ("Social Square Post 1080×1080", 1080.0, 1080.0, PX), + ("Social Portrait Post 1080×1350", 1080.0, 1350.0, PX), + ("Social Landscape Post 1200×630", 1200.0, 630.0, PX), + ("Social Story 1080×1920", 1080.0, 1920.0, PX), + ("Social Tall Post 1000×1500", 1000.0, 1500.0, PX), + ("Profile Cover 1500×500", 1500.0, 500.0, PX), + ("Page Cover 1640×624", 1640.0, 624.0, PX), + ("Video Banner 2560×1440", 2560.0, 1440.0, PX), + ("Video Thumbnail 1280×720", 1280.0, 720.0, PX), + ], + ), +]; + +/// The category tabs in order: Recent, Saved, then the built-in categories. +pub fn category_names() -> impl Iterator { + ["Recent", "Saved"].into_iter().chain(CATEGORIES.iter().map(|c| c.0)) +} + +/// The built-in presets of one category: screen sizes in pixels, the others in `general` +/// (Preferences ▸ Units ▸ General). +fn builtin(c: &Category, general: Unit) -> impl Iterator + '_ { + let (_, color_mode, ppi, presets) = *c; + presets.iter().map(move |&(name, w, h, units)| DocSettings { + name: name.into(), + width: units.to_pt(w), + height: units.to_pt(h), + units: if units == Unit::Pixels { units } else { general }, + color_mode, + raster_effects_ppi: ppi, + ..DocSettings::default() + }) +} + +/// The presets of the category `name` (any case), or None for no such category. +pub fn category(s: &Session, name: &str) -> Option> { + if name.eq_ignore_ascii_case("Recent") { + return Some(s.prefs.recent_new_docs.clone()); + } + if name.eq_ignore_ascii_case("Saved") { + return Some(s.prefs.new_doc_presets.clone()); + } + CATEGORIES.iter().find(|c| c.0.eq_ignore_ascii_case(name)).map(|c| builtin(c, s.default_units()).collect()) +} + +/// A preset by name (any case): a saved one first, then the built-in ones. +pub fn find(s: &Session, name: &str) -> Option { + let name = name.trim(); + s.prefs + .new_doc_presets + .iter() + .find(|p| p.name.eq_ignore_ascii_case(name)) + .cloned() + .or_else(|| CATEGORIES.iter().flat_map(|c| builtin(c, s.default_units())).find(|p| p.name.eq_ignore_ascii_case(name))) +} + +impl Session { + /// New Document's starting settings: Letter in Preferences ▸ Units ▸ General. + fn default_settings(&self) -> DocSettings { + DocSettings { units: self.default_units(), ..DocSettings::default() } + } +} + +/// Recent keeps this many sizes. +const RECENT: usize = 12; + +/// `file.new`: a new document from a preset and/or explicit settings. +pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.new"; + let base = match str_param(p, "preset") { + Some(name) => find(s, name).ok_or_else(|| bad(C, format!("no preset called `{name}` (see file.newPresets)")))?, + None => s.default_settings(), + }; + // A preset keeps its name in Recent unless its size changed (turning it doesn't count). + let sorted = |d: &DocSettings| (d.width.min(d.height), d.width.max(d.height), d.units); + let size = str_param(p, "preset").map(|_| sorted(&base)); + let mut settings = base.with_params(p, C)?; + if size != Some(sorted(&settings)) { + settings.name = "Custom".into(); + } + let mut d = settings.document(); + d.title = match str_param(p, "name").or(str_param(p, "title")).map(str::trim).filter(|t| !t.is_empty()) { + // New Document offers the next untitled name: taking it uses it up. + Some(t) if t == s.peek_untitled() => s.next_untitled(), + Some(t) => t.to_string(), + None => s.next_untitled(), + }; + let i = s.add_document(d, None); + // Overprint Preview is the engine's (the UI turns Pixel Preview on from `previewMode`). Both + // are views of the whole app, so Default leaves them as they are. + if settings.preview_mode == PreviewMode::Overprint { + s.execute("view.overprintPreview", &json!({ "on": true }))?; + } + remember(s, &settings); + Ok(json!({ "index": i, "previewMode": settings.preview_mode.id() })) +} + +/// Put `settings` first in Recent (one entry per size and units). +fn remember(s: &mut Session, settings: &DocSettings) { + let recent = &mut s.prefs.recent_new_docs; + recent.retain(|r| (r.width, r.height, r.units) != (settings.width, settings.height, settings.units)); + recent.insert(0, settings.clone()); + recent.truncate(RECENT); +} + +fn list(s: &mut Session, p: &Value) -> Result { + let names: Vec<&str> = match str_param(p, "category") { + Some(c) => vec![ + category_names() + .find(|n| n.eq_ignore_ascii_case(c)) + .ok_or_else(|| bad("file.newPresets", format!("unknown category `{c}` ({})", category_names().collect::>().join(", "))))?, + ], + None => category_names().collect(), + }; + let categories: Vec = names + .into_iter() + .map(|n| json!({ "name": n, "presets": category(s, n).unwrap_or_default().iter().map(DocSettings::to_json).collect::>() })) + .collect(); + Ok(json!({ "categories": categories })) +} + +fn save(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.newPresets.save"; + let name = str_param(p, "name") + .map(str::trim) + .filter(|n| !n.is_empty() && n.chars().count() <= 64) + .ok_or_else(|| bad(C, "give the preset a name (1–64 characters)"))?; + let base = match str_param(p, "preset") { + Some(from) => find(s, from).ok_or_else(|| bad(C, format!("no preset called `{from}` (see file.newPresets)")))?, + None => s.default_settings(), + }; + let mut preset = base.with_params(p, C)?; + preset.name = name.to_string(); + let saved = &mut s.prefs.new_doc_presets; + match saved.iter_mut().find(|q| q.name.eq_ignore_ascii_case(name)) { + Some(q) => *q = preset, + None => saved.push(preset), + } + Ok(json!({ "name": name, "count": saved.len() })) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.newPresets.delete"; + let name = str_param(p, "name").unwrap_or("").trim(); + let saved = &mut s.prefs.new_doc_presets; + let i = saved.iter().position(|q| q.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(C, format!("no saved preset called `{name}`")))?; + Ok(json!({ "deleted": saved.remove(i).name })) +} diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 14d02b9aa..36efb5fdd 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool}", + "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool}", has_doc, set_format ), @@ -93,6 +93,8 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "underline", "strikethrough", "allCaps", + "smallCaps", + "position", "leftIndent", "rightIndent", "firstLineIndent", @@ -103,6 +105,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if !keys.iter().any(|k| p.get(*k).is_some()) { return Err(bad(C, "nothing to change")); } + let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?; s.edit("Character", |d, _| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; @@ -132,6 +135,12 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = flag("allCaps") { st.all_caps = v; } + if let Some(v) = position { + st.position = v; + } + if let Some(v) = small_caps { + st.small_caps = v; + } } let para = &mut t.para; if let Some(v) = num("leftIndent") { diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index e284fb2e8..aa3cd500f 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, features?: [\"dlig\", \"-liga\", …]} style a character range (runs are split at the range ends) → {id, runs}", + "{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, 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\", …]} style a character range (runs are split at the range ends) → {id, runs}", has_doc, set_range_style ), @@ -140,6 +140,8 @@ pub(crate) struct CharChange { strikethrough: Option, all_caps: Option, features: Option>, + position: Option, + small_caps: Option>, } /// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults). @@ -169,8 +171,10 @@ fn auto_or_num(p: &Value, k: &str, cmd: &str) -> Result>> { } impl CharChange { - pub(crate) fn parse(p: &Value, cmd: &str) -> Result { + /// `setup` gives the superscript, subscript and small caps proportions. + pub(crate) fn parse(p: &Value, cmd: &str, setup: &vectorcraft_doc::DocSetup) -> Result { let num = |k: &str| p.get(k).and_then(Value::as_f64); + let (position, small_caps) = super::docsetup::script_params(p, setup, cmd)?; let flag = |k: &str| p.get(k).and_then(Value::as_bool); let mut stroke_opts = match p.get("strokeOptions") { None | Some(Value::Null) => StrokeChange::default(), @@ -198,6 +202,8 @@ impl CharChange { strikethrough: flag("strikethrough"), all_caps: flag("allCaps"), features: features_param(p, cmd)?, + position, + small_caps, }; if c.size.is_some_and(|v| v <= 0.0) { return Err(bad(cmd, "size must be positive")); @@ -223,6 +229,8 @@ impl CharChange { && self.strikethrough.is_none() && self.all_caps.is_none() && self.features.is_none() + && self.position.is_none() + && self.small_caps.is_none() } pub(crate) fn apply(&self, st: &mut CharStyle) { @@ -287,13 +295,19 @@ impl CharChange { if let Some(v) = &self.features { st.features = v.clone(); } + if let Some(v) = self.position { + st.position = v; + } + if let Some(v) = self.small_caps { + st.small_caps = v; + } } } fn set_range_style(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setRangeStyle"; let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; - let change = CharChange::parse(p, C)?; + let change = CharChange::parse(p, C, &s.doc()?.doc.setup)?; if change.is_empty() { return Err(bad(C, "nothing to change")); } diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 571056351..f23324e9c 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -3,7 +3,7 @@ //! commands of the Edit menu (Find and Replace, Find Next, Paste without Formatting). use serde_json::{Value, json}; -use vectorcraft_doc::{CharStyle, Document, Justify, NodeId, NodeKind, TextKind, TextRun}; +use vectorcraft_doc::{CharStyle, Document, Justify, NodeId, NodeKind, Quotes, TextKind, TextRun}; use vectorcraft_geom::{Affine, Rect, shapes}; use super::typecmd::refresh_bounds; @@ -25,7 +25,7 @@ pub fn specs() -> Vec { "Smart Punctuation…", ["Type"], None, - "{quotes?: true (\"\" '' → curly), dashes?: true (-- → en dash, --- → em dash), ellipsis?: true (... → …), scope?: \"selection\"|\"document\"} → {changed}", + "{quotes?: true (straight quotes become the Document Setup quotes), dashes?: true (-- → en dash, --- → em dash), ellipsis?: true (... → …), scope?: \"selection\"|\"document\"} → {changed}", has_doc, smart_punctuation ), @@ -217,11 +217,8 @@ struct PunctState { prev: Option, } -fn opens(prev: Option) -> bool { - prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–' | '“' | '‘')) -} - -pub(crate) fn smarten(text: &str, prev: &mut Option, quotes: bool, dashes: bool, ellipsis: bool) -> String { +/// Dashes (`--` en, `---` em), ellipses and, with `quotes`, typographer's quotes. +pub(crate) fn smarten(text: &str, prev: &mut Option, quotes: Option<&Quotes>, dashes: bool, ellipsis: bool) -> String { let mut s = text.to_string(); if dashes { s = s.replace("---", "—").replace("--", "–"); @@ -229,38 +226,20 @@ pub(crate) fn smarten(text: &str, prev: &mut Option, quotes: bool, dashes: if ellipsis { s = s.replace("...", "…"); } - if !quotes { - *prev = s.chars().last().or(*prev); - return s; + match quotes { + Some(q) => q.apply(&s, prev), + None => { + *prev = s.chars().last().or(*prev); + s + } } - let mut out = String::with_capacity(s.len()); - for c in s.chars() { - let r = match c { - '"' => { - if opens(*prev) { - '“' - } else { - '”' - } - } - '\'' => { - if opens(*prev) { - '‘' - } else { - '’' - } - } - c => c, - }; - out.push(r); - *prev = Some(r); - } - out } fn smart_punctuation(s: &mut Session, p: &Value) -> Result { - let (q, d, e) = (bool_or(p, "quotes", true), bool_or(p, "dashes", true), bool_or(p, "ellipsis", true)); + let (d, e) = (bool_or(p, "dashes", true), bool_or(p, "ellipsis", true)); let st = s.doc()?; + // Quotes in the document's style (Document Setup → Type). + let q = bool_or(p, "quotes", true).then_some(st.doc.setup.quotes); let ids = if str_param(p, "scope") == Some("document") || st.selection.is_empty() { let mut v = vec![]; st.doc.walk(|n| { @@ -272,7 +251,7 @@ fn smart_punctuation(s: &mut Session, p: &Value) -> Result { } else { text_ids(&st.doc, &st.selection.objects) }; - let n = edit_runs::(s, "Smart Punctuation", &ids, |text, st| smarten(text, &mut st.prev, q, d, e))?; + let n = edit_runs::(s, "Smart Punctuation", &ids, |text, st| smarten(text, &mut st.prev, q.as_ref(), d, e))?; Ok(json!({ "changed": n })) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9b9b19796..25a934c3b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -356,6 +356,13 @@ pub struct Prefs { /// dialog field, so it has no [`cmd::prefscmds::PREF_SPECS`] row and resetting keeps it. #[serde(skip_serializing_if = "Vec::is_empty")] pub color_themes: Vec, + /// New Document → Saved: the user's document presets (`file.newPresets.save`). A local + /// library, not a Preferences dialog field: resetting the preferences keeps it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub new_doc_presets: Vec, + /// New Document → Recent: the settings of the last documents made (newest first). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub recent_new_docs: Vec, } impl Default for Prefs { @@ -479,6 +486,8 @@ impl Default for Prefs { japanese_crop_marks: false, new_art_basic: true, color_themes: vec![], + new_doc_presets: vec![], + recent_new_docs: vec![], } } } @@ -616,6 +625,10 @@ impl Session { false } } + /// The name [`Session::next_untitled`] gives next (New Document's Name field). + pub fn peek_untitled(&self) -> String { + format!("Untitled-{}", self.untitled_counter + 1) + } pub fn next_untitled(&mut self) -> String { self.untitled_counter += 1; format!("Untitled-{}", self.untitled_counter) @@ -909,6 +922,8 @@ mod tests_dashalign; #[cfg(test)] mod tests_distort; #[cfg(test)] +mod tests_docsetup; +#[cfg(test)] mod tests_draw2; #[cfg(test)] mod tests_editcolors; @@ -951,6 +966,8 @@ mod tests_menucmds; #[cfg(test)] mod tests_newart; #[cfg(test)] +mod tests_newdoc; +#[cfg(test)] mod tests_objexpand; #[cfg(test)] mod tests_opacitymask; diff --git a/crates/engine/src/tests_docsetup.rs b/crates/engine/src/tests_docsetup.rs new file mode 100644 index 000000000..f937bf402 --- /dev/null +++ b/crates/engine/src/tests_docsetup.rs @@ -0,0 +1,266 @@ +//! File → Document Setup (`document.setup`): validation, one undo step, round-trips, and the +//! options other commands read (typographer's quotes, superscript/subscript, SVG text export). + +use serde_json::{Value, json}; +use vectorcraft_doc::{CharPosition, DocSetup, ExportText, GridSize, NodeId, NodeKind, ScriptMetrics, TextObject, TextStyleDef, Unit}; +use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +fn setup(s: &Session) -> &DocSetup { + &s.doc().unwrap().doc.setup +} + +fn text(s: &Session, id: NodeId) -> TextObject { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +fn create_text(s: &mut Session, t: &str) -> NodeId { + NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": t, "size": 20})).unwrap()["id"].as_u64().unwrap()) +} + +/// Click with the Type tool at (x, y) and type `keys` one at a time; returns the new text object. +fn type_at(s: &mut Session, x: f64, y: f64, keys: &[&str]) -> NodeId { + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, x, y), v).unwrap(); + } + let id = s.doc().unwrap().selection.objects[0]; + for k in keys { + s.tool_text(k, v).unwrap(); + } + s.tool_key(ToolKey::Escape, Default::default(), v).unwrap(); + id +} + +#[test] +fn query_reports_the_defaults_and_accepts_them_back() { + let mut s = session(); + let q = s.execute("document.setup", &json!({})).unwrap(); + assert_eq!(q["units"], "Points"); + assert_eq!(q["bleed"], json!([0.0, 0.0, 0.0, 0.0])); + assert_eq!(q["gridColors"], json!(["#ffffff", "#cccccc"])); + assert_eq!(q["gridColorsName"], "Light"); + assert_eq!(q["flattenerPreset"], "Medium Resolution"); + assert_eq!(q["backgroundContents"], "transparent"); + assert_eq!(q["quotes"], json!({"double": "“”", "single": "‘’"})); + assert_eq!(q["typographersQuotes"], true); + assert_eq!(q["superscript"], json!({"size": 58.3, "position": 33.3})); + // Feeding the report back changes nothing (and records no undo step). + assert_eq!(s.execute("document.setup", &q).unwrap(), q); + assert!(s.doc().unwrap().history.undo.is_empty()); + assert_eq!(*setup(&s), DocSetup::default()); +} + +#[test] +fn many_settings_are_one_undo_step() { + let mut s = session(); + let before = s.doc().unwrap().doc.clone(); + let r = s + .execute( + "document.setup", + &json!({ + "units": "Millimeters", "bleed": {"top": 9, "right": 4.5}, "gridSize": "large", "gridColors": "Blue", + "simulatePaper": true, "flattenerPreset": "high", "discardWhiteOverprint": false, + "outlineImages": true, "highlightSubstitutedGlyphs": true, "exportText": "appearance", + "superscript": {"size": 60}, "subscript": {"position": 20}, "smallCapsSize": 75, + }), + ) + .unwrap(); + assert_eq!(r["gridColorsName"], "Blue"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.units, Unit::Millimeters); + let st = &d.setup; + assert_eq!(st.bleed, [9.0, 0.0, 0.0, 4.5]); + assert_eq!((st.grid_size, st.simulate_paper, st.outline_images, st.highlight_substituted_glyphs), (GridSize::Large, true, true, true)); + assert_eq!((st.flattener(), st.discard_white_overprint, st.export_text), ("High Resolution", false, ExportText::Appearance)); + assert_eq!((st.superscript, st.subscript), (ScriptMetrics { size: 60.0, position: 33.3 }, ScriptMetrics { size: 58.3, position: 20.0 })); + assert_eq!(st.small_caps_size, 75.0); + let undo: Vec<&str> = s.doc().unwrap().history.undo.iter().map(|e| e.label.as_str()).collect(); + assert_eq!(undo, ["Document Setup"]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(*s.doc().unwrap().doc, *before); +} + +#[test] +fn bad_values_are_rejected_and_change_nothing() { + let mut s = session(); + for p in [ + json!({"bleed": 73}), + json!({"bleed": [1, 2, 3]}), + json!({"bleed": {"middle": 2}}), + json!({"bleed": -1}), + json!({"gridSize": "huge"}), + json!({"gridColors": ["#ffffff"]}), + json!({"gridColors": "Plaid"}), + json!({"language": "Klingon"}), + json!({"quotes": {"double": "“"}}), + json!({"quotes": {"triple": "“”"}}), + json!({"superscript": {"size": 0}}), + json!({"exportText": "pdf"}), + json!({"flattenerPreset": "Ultra"}), + json!({"backgroundContents": "grey"}), + json!({"simulatePaper": "yes"}), + json!({"units": "furlongs"}), + json!({"bleed": 9, "nonsense": true}), + json!([1, 2]), + ] { + assert!(s.execute("document.setup", &p).is_err(), "{p}"); + } + assert_eq!(*setup(&s), DocSetup::default()); + assert!(s.doc().unwrap().history.undo.is_empty()); +} + +#[test] +fn set_units_is_an_alias() { + let mut s = session(); + s.execute("document.setUnits", &json!({"units": "inches"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.units, Unit::Inches); + assert_eq!(s.execute("document.setup", &json!({})).unwrap()["units"], "Inches"); + assert!(s.execute("document.setUnits", &json!({"units": "parsecs"})).is_err()); +} + +#[test] +fn language_picks_its_quotes_unless_quotes_are_given() { + let mut s = session(); + s.execute("document.setup", &json!({"language": "german"})).unwrap(); + assert_eq!(setup(&s).language, "German"); + assert_eq!(setup(&s).quotes.double, ['„', '“']); + s.execute("document.setup", &json!({"language": "French", "quotes": {"double": ["“", "”"]}})).unwrap(); + assert_eq!((setup(&s).quotes.double, setup(&s).quotes.single), (['“', '”'], ['‹', '›'])); +} + +#[test] +fn typographers_quotes_setting_changes_typed_quotes() { + let mut s = session(); + let id = type_at(&mut s, 50.0, 50.0, &["\"", "H", "i", "\"", " ", "i", "t", "'", "s"]); + assert_eq!(text(&s, id).plain_text(), "“Hi” it’s"); + // The document's quote style. + s.execute("document.setup", &json!({"language": "German"})).unwrap(); + let id = type_at(&mut s, 50.0, 150.0, &["\"", "a", "\""]); + assert_eq!(text(&s, id).plain_text(), "„a“"); + // Straight quotes with the option off. + s.execute("document.setup", &json!({"typographersQuotes": false})).unwrap(); + let id = type_at(&mut s, 50.0, 250.0, &["\"", "b", "'"]); + assert_eq!(text(&s, id).plain_text(), "\"b'"); +} + +#[test] +fn smart_punctuation_uses_the_document_quotes() { + let mut s = session(); + s.execute("document.setup", &json!({"language": "French"})).unwrap(); + let id = create_text(&mut s, "\"oui\" 'non'"); + s.execute("type.smartPunctuation", &json!({"scope": "document"})).unwrap(); + assert_eq!(text(&s, id).plain_text(), "«oui» ‹non›"); +} + +#[test] +fn superscript_and_small_caps_use_and_follow_the_setup_sizes() { + let mut s = session(); + let id = create_text(&mut s, "E=mc2"); + s.execute("document.setup", &json!({"superscript": {"size": 50, "position": 40}})).unwrap(); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 4, "end": 5, "position": "superscript"})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id.0], "smallCaps": true})).unwrap(); + let t = text(&s, id); + assert_eq!(t.runs.last().unwrap().style.position, CharPosition::Superscript(ScriptMetrics { size: 50.0, position: 40.0 })); + assert!(t.runs.iter().all(|r| r.style.small_caps == Some(70.0))); + let narrow = t.cached_bounds.unwrap(); + // A Document Setup change restyles the text in the same undo step. + let undo = s.doc().unwrap().history.undo.len(); + s.execute("document.setup", &json!({"superscript": {"size": 100}, "smallCapsSize": 100})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + let t = text(&s, id); + assert_eq!(t.runs.last().unwrap().style.position, CharPosition::Superscript(ScriptMetrics { size: 100.0, position: 40.0 })); + assert!(t.runs.iter().all(|r| r.style.small_caps == Some(100.0))); + assert!(t.cached_bounds.unwrap().width() > narrow.width(), "bounds follow the bigger glyphs"); + s.execute("text.setFormat", &json!({"ids": [id.0], "position": "normal", "smallCaps": false})).unwrap(); + assert!(text(&s, id).runs.iter().all(|r| r.style.position == CharPosition::Normal && r.style.small_caps.is_none())); + assert!(s.execute("text.setFormat", &json!({"ids": [id.0], "position": "sideways"})).is_err()); +} + +#[test] +fn character_styles_follow_the_setup_sizes() { + let mut s = session(); + let attrs = json!({"position": {"kind": "subscript", "size": 58.3, "position": 33.3}, "small_caps": 70.0}); + Arc::make_mut(&mut s.doc_mut().unwrap().doc).char_styles.push(TextStyleDef { name: "Note".into(), attrs: attrs.as_object().unwrap().clone() }); + s.execute("document.setup", &json!({"subscript": {"size": 40}, "smallCapsSize": 80})).unwrap(); + let def = &s.doc().unwrap().doc.char_styles[0]; + assert_eq!(def.attrs["position"], json!({"kind": "subscript", "size": 40.0, "position": 33.3})); + assert_eq!(def.attrs["small_caps"], json!(80.0)); +} + +#[test] +fn setup_survives_save_and_load() { + let mut s = session(); + s.execute( + "document.setup", + &json!({"bleed": [9, 9, 18, 18], "gridColors": ["#ff0000", "#00ff00"], "language": "Japanese", "exportText": "appearance"}), + ) + .unwrap(); + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save_file(d)).unwrap(); + assert_eq!(back.setup, d.setup); + assert_eq!(back.setup.grid_colors_name(), "Custom"); +} + +#[test] +fn svg_text_export_follows_the_export_text_setting() { + let mut s = session(); + create_text(&mut s, "Hello"); + let svg = |s: &mut Session, p: Value| { + let mut p = p; + p["format"] = json!("svg"); + let r = s.execute("document.export", &p).unwrap(); + String::from_utf8(vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()).unwrap() + }; + assert!(svg(&mut s, json!({})).contains(" 200 && g < 60 && b < 60, "a white overprint vanishes: {:?}", [r, g, b]); +} + +#[test] +fn saved_flattener_presets_and_background_contents() { + let mut s = session(); + s.execute("flattener.presets.save", &json!({"name": "Proofs", "preset": "high"})).unwrap(); + assert_eq!(s.execute("document.setup", &json!({"flattenerPreset": "proofs"})).unwrap()["flattenerPreset"], "Proofs"); + assert_eq!(s.execute("document.setup", &json!({"flattenerPreset": "Medium Resolution"})).unwrap()["flattenerPreset"], "Medium Resolution"); + assert_eq!(setup(&s).flattener_preset, None, "the default is not stored"); + let r = s.execute("document.setup", &json!({"backgroundContents": "white"})).unwrap(); + assert_eq!(r["backgroundContents"], "white"); + assert_eq!(setup(&s).background, vectorcraft_doc::Background::White); +} diff --git a/crates/engine/src/tests_newdoc.rs b/crates/engine/src/tests_newdoc.rs new file mode 100644 index 000000000..3581caaba --- /dev/null +++ b/crates/engine/src/tests_newdoc.rs @@ -0,0 +1,225 @@ +//! File → New (`file.new`, `file.newPresets*`): artboard layouts, orientation, bleed, colour mode, +//! raster effects resolution, background, presets, Recent and Saved. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Background, ColorMode, Unit}; +use vectorcraft_geom::Rect; + +use super::*; + +fn new_doc(p: Value) -> Session { + let mut s = Session::new(); + s.execute("file.new", &p).unwrap(); + s +} + +fn boards(s: &Session) -> Vec { + s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect() +} + +fn presets(s: &mut Session, category: &str) -> Vec { + let r = s.execute("file.newPresets", &json!({ "category": category })).unwrap(); + r["categories"][0]["presets"].as_array().unwrap().clone() +} + +#[test] +fn four_artboards_in_two_columns_make_a_two_by_two_grid() { + let s = new_doc(json!({"width": 100, "height": 50, "artboards": 4, "artboardLayout": {"columns": 2, "spacing": 10}})); + let origins: Vec<(f64, f64)> = boards(&s).iter().map(|r| (r.x0, r.y0)).collect(); + assert_eq!(origins, [(0.0, 0.0), (110.0, 0.0), (0.0, 60.0), (110.0, 60.0)]); + assert!(boards(&s).iter().all(|r| r.width() == 100.0 && r.height() == 50.0)); + let names: Vec = s.doc().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect(); + assert_eq!(names, ["Artboard 1", "Artboard 2", "Artboard 3", "Artboard 4"]); + // Grid by Column fills down first; Right-to-Left starts at the right. + let s = new_doc(json!({"width": 100, "height": 50, "artboards": 4, "artboardLayout": {"layout": "gridByColumn", "columns": 2, "spacing": 10}})); + assert_eq!(boards(&s).iter().map(|r| (r.x0, r.y0)).collect::>(), [(0.0, 0.0), (0.0, 60.0), (110.0, 0.0), (110.0, 60.0)]); + let s = new_doc(json!({"width": 100, "height": 50, "artboards": 3, "artboardLayout": {"layout": "row", "rightToLeft": true}})); + assert_eq!(boards(&s).iter().map(|r| r.x0).collect::>(), [240.0, 120.0, 0.0]); + let s = new_doc(json!({"width": 100, "height": 50, "artboards": 3, "artboardLayout": "column"})); + assert_eq!(boards(&s).iter().map(|r| r.y0).collect::>(), [0.0, 70.0, 140.0]); + // Without a layout: one row, 20 pt apart (as before). + let s = new_doc(json!({"width": 100, "height": 50, "artboards": 2})); + assert_eq!(boards(&s)[1].x0, 120.0); +} + +#[test] +fn orientation_swaps_width_and_height() { + let s = new_doc(json!({"width": 612, "height": 792, "orientation": "landscape"})); + assert_eq!((boards(&s)[0].width(), boards(&s)[0].height()), (792.0, 612.0)); + let s = new_doc(json!({"width": 792, "height": 612, "orientation": "Portrait"})); + assert_eq!((boards(&s)[0].width(), boards(&s)[0].height()), (612.0, 792.0)); + let s = new_doc(json!({"preset": "HDTV 1080p", "orientation": "portrait"})); + assert_eq!((boards(&s)[0].width(), boards(&s)[0].height()), (1080.0, 1920.0)); +} + +#[test] +fn every_setting_reaches_the_document() { + let mut s = Session::new(); + let r = s + .execute( + "file.new", + &json!({"name": "Poster", "width": "210 mm", "height": "297mm", "units": "Millimeters", "bleed": [9, 9, 4.5, 4.5], + "backgroundContents": "white", "colorMode": "cmyk", "rasterEffectsPpi": 150}), + ) + .unwrap(); + assert_eq!(r["previewMode"], "default"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.title, "Poster"); + assert_eq!(d.units, Unit::Millimeters); + assert!((d.artboards[0].rect.width() - 595.2756).abs() < 1e-3); + assert_eq!(d.setup.bleed, [9.0, 9.0, 4.5, 4.5]); + assert_eq!(d.setup.background, Background::White); + assert_eq!(d.color_mode, ColorMode::Cmyk); + assert_eq!(d.raster_effects_ppi, 150.0); + // Saved and reopened, the settings stay. + let back = vectorcraft_format::load(&vectorcraft_format::save_file(d)).unwrap(); + assert_eq!((back.raster_effects_ppi, back.setup.bleed, back.setup.background), (150.0, d.setup.bleed, Background::White)); +} + +#[test] +fn a_white_background_exports_opaque() { + let alpha = |bg: &str| { + let mut s = new_doc(json!({"width": 20, "height": 20, "backgroundContents": bg})); + let r = s.execute("document.export", &json!({"format": "png"})).unwrap(); + let png = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(10, 10).0 + }; + assert_eq!(alpha("white"), [255, 255, 255, 255]); + assert_eq!(alpha("transparent")[3], 0); +} + +#[test] +fn a_preset_starts_the_document_and_params_change_it() { + let s = new_doc(json!({"preset": "a4"})); + let d = &s.doc().unwrap().doc; + assert_eq!((d.units, d.color_mode, d.raster_effects_ppi), (Unit::Points, ColorMode::Cmyk, 300.0), "print sizes in prefs unitsGeneral"); + assert!((d.artboards[0].rect.height() - 841.89).abs() < 0.01); + let s = new_doc(json!({"preset": "Web 1920×1080", "colorMode": "cmyk", "artboards": 2})); + let d = &s.doc().unwrap().doc; + assert_eq!((d.units, d.color_mode, d.artboards.len()), (Unit::Pixels, ColorMode::Cmyk, 2)); + assert!(Session::new().execute("file.new", &json!({"preset": "Napkin"})).is_err()); + // Print presets and plain sizes start in Preferences ▸ Units ▸ General; screen presets in pixels. + let mut s = Session::new(); + s.execute("prefs.set", &json!({"key": "unitsGeneral", "value": "millimeters"})).unwrap(); + s.execute("file.new", &json!({"preset": "A4"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.units, Unit::Millimeters); + assert_eq!(presets(&mut s, "Print")[4]["size"], "210 × 297 mm"); + s.execute("file.new", &json!({"width": 100})).unwrap(); + assert_eq!(s.doc().unwrap().doc.units, Unit::Millimeters); + s.execute("file.new", &json!({"preset": "HDTV 720p"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.units, Unit::Pixels); +} + +#[test] +fn ppi_overprint_preview_and_bad_params() { + // Overprint Preview is process-wide. + let _view = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = new_doc(json!({"rasterEffectsPpi": 300, "previewMode": "overprint"})); + assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, 300.0); + let overprint = |s: &mut Session| s.execute("view.proofSetup", &json!({})).unwrap()["overprintPreview"].clone(); + assert_eq!(overprint(&mut s), true); + s.execute("view.overprintPreview", &json!({"on": false})).unwrap(); + let r = s.execute("file.new", &json!({"previewMode": "pixel"})).unwrap(); + assert_eq!(r["previewMode"], "pixel", "for the app's Pixel Preview"); + assert_eq!(overprint(&mut s), false); + for p in [ + json!({"orientation": "sideways"}), + json!({"width": -5}), + json!({"width": "wide"}), + json!({"bleed": 100}), + json!({"backgroundContents": "plaid"}), + json!({"colorMode": "lab"}), + json!({"rasterEffectsPpi": 0}), + json!({"previewMode": "xray"}), + json!({"artboardLayout": {"layout": "spiral"}}), + json!({"artboardLayout": {"columns": 0}}), + json!({"artboardLayout": {"rows": 2}}), + json!({"units": "furlongs"}), + json!({"width": 160000, "artboards": 1000}), + ] { + let mut s = Session::new(); + assert!(s.execute("file.new", &p).is_err(), "{p}"); + assert!(s.active().is_none(), "{p}: no document on an error"); + } +} + +#[test] +fn categories_list_generated_presets_with_neutral_names() { + let mut s = Session::new(); + let r = s.execute("file.newPresets", &json!({})).unwrap(); + let names: Vec<&str> = r["categories"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Recent", "Saved", "Mobile", "Web", "Print", "Film & Video", "Art & Illustration", "Branding", "Social"]); + let print = presets(&mut s, "print"); + let letter = print.iter().find(|p| p["name"] == "Letter").unwrap(); + assert_eq!( + (letter["size"].as_str(), letter["colorMode"].as_str(), letter["orientation"].as_str()), + (Some("612 × 792 pt"), Some("cmyk"), Some("portrait")) + ); + assert_eq!(presets(&mut s, "Film & Video").iter().find(|p| p["name"] == "4K UHD").unwrap()["size"], "3840 × 2160 px"); + for c in ["Mobile", "Web", "Print", "Film & Video", "Art & Illustration", "Branding", "Social"] { + assert!(!presets(&mut s, c).is_empty(), "{c}"); + } + // A listed preset can be passed straight back to file.new. + let story = presets(&mut s, "Social").into_iter().find(|p| p["name"] == "Social Story 1080×1920").unwrap(); + s.execute("file.new", &story).unwrap(); + assert_eq!(boards(&s)[0], Rect::new(0.0, 0.0, 1080.0, 1920.0)); + assert!(s.execute("file.newPresets", &json!({"category": "Furniture"})).is_err()); +} + +#[test] +fn recent_remembers_the_last_sizes() { + let mut s = Session::new(); + assert!(presets(&mut s, "Recent").is_empty()); + s.execute("file.new", &json!({"preset": "A4"})).unwrap(); + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + s.execute("file.new", &json!({"preset": "A4", "orientation": "landscape"})).unwrap(); + let recent = presets(&mut s, "Recent"); + let names: Vec<&str> = recent.iter().map(|p| p["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["A4", "Custom", "A4"], "newest first; a turned preset keeps its name"); + // The same size again moves to the front instead of repeating. + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + assert_eq!(presets(&mut s, "Recent").len(), 3); + assert_eq!(presets(&mut s, "Recent")[0]["size"], "300 × 200 pt"); + for i in 0..20 { + s.execute("file.new", &json!({"width": 100 + i, "height": 100})).unwrap(); + } + assert_eq!(presets(&mut s, "Recent").len(), 12); +} + +#[test] +fn saved_presets_live_in_the_preferences() { + let mut s = Session::new(); + assert!(s.execute("file.newPresets.delete", &json!({"name": "Mine"})).is_err()); + let r = s + .execute( + "file.newPresets.save", + &json!({"name": "Mine", "width": 400, "height": 300, "units": "Pixels", "artboards": 4, "artboardLayout": {"columns": 2}, "bleed": 9}), + ) + .unwrap(); + assert_eq!(r, json!({"name": "Mine", "count": 1})); + let saved = presets(&mut s, "Saved"); + assert_eq!( + (saved[0]["name"].as_str(), saved[0]["size"].as_str(), saved[0]["artboardLayout"]["columns"].as_u64()), + (Some("Mine"), Some("400 × 300 px"), Some(2)) + ); + // The same name replaces it. + s.execute("file.newPresets.save", &json!({"name": "mine", "width": 500, "height": 300})).unwrap(); + assert_eq!(presets(&mut s, "Saved").len(), 1); + s.execute("file.new", &json!({"preset": "Mine"})).unwrap(); + assert_eq!(boards(&s)[0].width(), 500.0); + // Kept with the preferences, and by a reset. + let prefs: crate::Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); + assert_eq!(prefs.new_doc_presets, s.prefs.new_doc_presets); + s.execute("prefs.reset", &json!({})).unwrap(); + assert_eq!(s.prefs.new_doc_presets.len(), 1); + assert_eq!(s.execute("file.newPresets.delete", &json!({"name": "MINE"})).unwrap(), json!({"deleted": "mine"})); + assert!(presets(&mut s, "Saved").is_empty()); + assert!(s.execute("file.newPresets.save", &json!({"name": " "})).is_err()); +} + +#[test] +fn rearrange_keeps_its_grid() { + let mut s = new_doc(json!({"width": 100, "height": 50, "artboards": 3})); + s.execute("artboard.rearrange", &json!({"columns": 2, "spacing": 5})).unwrap(); + assert_eq!(boards(&s).iter().map(|r| (r.x0, r.y0)).collect::>(), [(0.0, 0.0), (105.0, 0.0), (0.0, 55.0)]); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3dd41cf53..d9697d6e9 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -87,6 +87,8 @@ pub struct RenderOptions { /// View Opacity Mask (Alt-click the mask thumbnail): instead of the artwork, show this object's /// opacity mask alone as its coverage in greyscale (white = opaque, black = transparent). pub mask_view: Option, + /// Screen view: highlight substituted fonts and glyphs as Document Setup asks. + pub highlight_substitutions: bool, } impl Default for RenderOptions { @@ -103,6 +105,7 @@ impl Default for RenderOptions { skip_templates: false, tile_edge: vectorcraft_doc::LAYER_COLORS[0].1, mask_view: None, + highlight_substitutions: false, } } } @@ -1102,6 +1105,18 @@ impl Renderer { if let Some(all) = &all { self.draw_text_items(ctx, f, n, below, all, tb); } + if f.opts.highlight_substitutions { + let setup = &f.doc.setup; + for (on, path) in [(setup.highlight_substituted_fonts, &g.substituted_fonts), (setup.highlight_substituted_glyphs, &g.substituted_glyphs)] + { + if on && !path.elements().is_empty() { + ctx.set_transform(xf); + ctx.set_fill_rule(peniko::Fill::NonZero); + ctx.set_paint(SUBSTITUTED); + ctx.fill_path(path); + } + } + } for (i, run) in t.runs.iter().enumerate() { let Some(path) = g.runs.get(i) else { continue }; if path.elements().is_empty() { @@ -1159,30 +1174,53 @@ impl Renderer { fn draw_image(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject) { let rect = Rect::new(0.0, 0.0, im.width as f64, im.height as f64); - if f.opts.outline { + // Outline mode draws the image's frame, and with Document Setup → Show Images in Outline + // Mode its pixels in greyscale under the frame. + let outline = f.opts.outline; + let pixels = if outline && !f.doc.setup.outline_images { None } else { self.image_pixmap(f.doc, &im.key, outline) }; + if let Some(pm) = pixels { + let pm = if outline { pm } else { self.ink_image(&im.key, &pm, f.ink) }; + let sx = im.width as f64 / pm.width().max(1) as f64; + let sy = im.height as f64 / pm.height().max(1) as f64; + ctx.set_transform(f.view * im.xf); + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() }); + ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy)); + ctx.fill_rect(&rect); + ctx.reset_paint_transform(); + } + if outline { let mut p = rect.to_path(0.1); p.apply_affine(im.xf); self.hairline(ctx, f, &p, [0, 0, 0, 255]); - return; } - let pm = match self.images.get(&im.key) { + } + + /// The decoded pixels of image blob `key` (cached), or a greyscale copy of them. + fn image_pixmap(&mut self, doc: &Document, key: &str, grey: bool) -> Option> { + let colour = match self.images.get(key) { Some(p) => p.clone(), None => { - let Some(blob) = f.doc.images.get(&im.key) else { return }; - let Some(pm) = paint::decode_pixmap(&blob.bytes) else { return }; - let pm = Arc::new(pm); - self.images.insert(im.key.clone(), pm.clone()); + let pm = Arc::new(paint::decode_pixmap(&doc.images.get(key)?.bytes)?); + self.images.insert(key.to_string(), pm.clone()); pm } }; - let pm = self.ink_image(&im.key, &pm, f.ink); - let sx = im.width as f64 / pm.width().max(1) as f64; - let sy = im.height as f64 / pm.height().max(1) as f64; - ctx.set_transform(f.view * im.xf); - ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() }); - ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy)); - ctx.fill_rect(&rect); - ctx.reset_paint_transform(); + if !grey { + return Some(colour); + } + let grey_key = format!("{key}\u{0}grey"); + if let Some(p) = self.images.get(&grey_key) { + return Some(p.clone()); + } + let mut pm = (*colour).clone(); + for px in pm.data_mut() { + // The luma of premultiplied components is the premultiplied luma. + let l = (0.2126 * px.r as f32 + 0.7152 * px.g as f32 + 0.0722 * px.b as f32 + 0.5) as u8; + (px.r, px.g, px.b) = (l, l, l); + } + let pm = Arc::new(pm); + self.images.insert(grey_key, pm.clone()); + Some(pm) } } @@ -1261,21 +1299,45 @@ struct TextGeom { runs: Vec, all: BezPath, bounds: Rect, + /// The boxes of characters whose font is missing (drawn in a substitute), and of glyphs the + /// font lacks (drawn from a fallback font): Document Setup's substitution highlights. + substituted_fonts: BezPath, + substituted_glyphs: BezPath, } fn text_geom(t: &TextObject) -> TextGeom { - let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let db = vectorcraft_text::FontDb::global(); + let layout = vectorcraft_text::layout(db, t); let mut runs = vec![BezPath::new(); t.runs.len()]; let mut all = BezPath::new(); + // Per run: is its family missing, and the face its glyphs should come from. + let faces: Vec<(bool, Option)> = + t.runs.iter().map(|r| (!db.has_family(&r.style.font_family), db.face(&r.style.font_family, &r.style.font_style).map(|f| f.id()))).collect(); + let (mut substituted_fonts, mut substituted_glyphs) = (BezPath::new(), BezPath::new()); for g in &layout.glyphs { if let Some(r) = runs.get_mut(g.run) { r.extend(g.outline.iter()); } all.extend(g.outline.iter()); + let Some(&(missing, face)) = faces.get(g.run) else { continue }; + let target = if missing { + &mut substituted_fonts + } else if face.is_some_and(|f| f != g.font_id) { + &mut substituted_glyphs + } else { + continue; + }; + let Some(line) = layout.lines.get(g.line) else { continue }; + let mut cell = Rect::new(g.origin.x, g.origin.y - line.ascent, g.origin.x + g.advance, g.origin.y + line.descent).to_path(0.1); + cell.apply_affine(Affine::rotate_about(g.angle, g.origin)); + target.extend(cell.iter()); } - TextGeom { runs, all, bounds: layout.bounds } + TextGeom { runs, all, bounds: layout.bounds, substituted_fonts, substituted_glyphs } } +/// Document Setup's highlight behind substituted fonts and glyphs (screen only). +const SUBSTITUTED: peniko::Color = peniko::Color::from_rgba8(255, 120, 190, 110); + fn rects_overlap(a: Rect, b: Rect) -> bool { a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 } @@ -1341,6 +1403,8 @@ mod tests_knockout; #[cfg(test)] mod tests_objectfx; #[cfg(test)] +mod tests_setup; +#[cfg(test)] mod tests_strokegradient; #[cfg(test)] mod tests_tileedge; diff --git a/crates/render/src/proof.rs b/crates/render/src/proof.rs index 9a9a25154..019218957 100644 --- a/crates/render/src/proof.rs +++ b/crates/render/src/proof.rs @@ -162,21 +162,35 @@ pub(crate) fn overprints(opts: &RenderOptions) -> bool { } /// Draw the overprinting fills and strokes of `a`'s subtree with Multiply (those with a blend -/// mode of their own keep it), copying only the nodes on the way to them. -fn multiply_overprints(a: &mut Arc) { +/// mode of their own keep it), copying only the nodes on the way to them. With `discard_white` +/// (Document Setup → Discard White Overprint) white fills and strokes knock out instead. +fn multiply_overprints(a: &mut Arc, discard_white: bool) { if !a.has_overprint() { return; } let n = Arc::make_mut(a); + let multiplies = + |overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint)); for it in &mut n.appearance.items { match it { - AppearanceItem::Fill(l) if l.overprint && l.blend == BlendMode::Normal => l.blend = BlendMode::Multiply, - AppearanceItem::Stroke(l) if l.overprint && l.blend == BlendMode::Normal => l.blend = BlendMode::Multiply, + AppearanceItem::Fill(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply, + AppearanceItem::Stroke(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply, _ => {} } } for c in n.children_mut().into_iter().flatten() { - multiply_overprints(c); + multiply_overprints(c, discard_white); + } +} + +/// A solid paint of paper white (no ink in any colour model). +fn is_white(p: &Paint) -> bool { + let Paint::Solid { color, .. } = p else { return false }; + match *color { + Color::Rgb { r, g, b } => r.min(g).min(b) >= 1.0, + Color::Cmyk { c, m, y, k } => c.max(m).max(y).max(k) <= 0.0, + Color::Gray { k } => k <= 0.0, + Color::Lab { l, a, b } => l >= 100.0 && a == 0.0 && b == 0.0, } } @@ -223,11 +237,12 @@ pub(crate) fn prepare<'a>(doc: &'a Document, opts: &RenderOptions) -> Cow<'a, Do } let mut d = doc.clone(); if overprint { + let discard_white = doc.setup.discard_white_overprint; for l in &mut d.layers { - multiply_overprints(l); + multiply_overprints(l, discard_white); } for s in &mut d.symbols { - multiply_overprints(&mut s.art); + multiply_overprints(&mut s.art, discard_white); } } if let Some((proof, visible)) = seps { diff --git a/crates/render/src/tests_setup.rs b/crates/render/src/tests_setup.rs new file mode 100644 index 000000000..00b10cd72 --- /dev/null +++ b/crates/render/src/tests_setup.rs @@ -0,0 +1,33 @@ +//! Document Setup options the renderer honours. + +use std::sync::Arc; + +use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; + +use super::*; + +fn doc_with_red_image() -> Document { + let mut d = Document::new(40.0, 40.0); + let mut png = Vec::new(); + image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + d.images.insert("red".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }); + let (id, layer) = (d.alloc_id(), d.layers[0].id); + let im = ImageObject { key: "red".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None }; + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d +} + +#[test] +fn outline_mode_draws_images_in_grey_only_when_the_setup_asks() { + let mut d = doc_with_red_image(); + let outline = RenderOptions { outline: true, ..Default::default() }; + let centre = |d: &Document| Renderer::new().render(d, 40, 40, Affine::IDENTITY, &outline).pixel(20, 20); + assert_eq!(centre(&d)[3], 0, "outline mode draws only the image frame"); + d.setup.outline_images = true; + let [r, g, b, a] = centre(&d); + // Red's luma (0.2126 × 255 ≈ 54) in every channel. + assert_eq!(a, 255); + assert!(r == g && g == b && (50..=58).contains(&r), "{:?}", [r, g, b]); + // Preview mode keeps the colours. + assert_eq!(Renderer::new().render(&d, 40, 40, Affine::IDENTITY, &RenderOptions::default()).pixel(20, 20), [255, 0, 0, 255]); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 64a76205c..c3fb1bdc2 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -330,4 +330,6 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4 #[cfg(test)] mod tests; #[cfg(test)] +mod tests_scripts; +#[cfg(test)] mod tests_typo; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 49c287d4c..ba3b49409 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -114,9 +114,10 @@ pub(crate) fn shape_range( } let Some(primary) = db.face(&st.font_family, &st.font_style) else { continue }; let pmap = primary.skrifa().map(|f| f.charmap()); - // Split into segments by font coverage. - let mut seg_start = a; - let mut seg_face = primary.clone(); + // Synthesized Small Caps shape lowercase letters separately (as smaller capitals). + let small_caps = st.small_caps.is_some() && !st.all_caps; + // Split into segments by font coverage (and case, for Small Caps). + let mut seg = Segment { range: a..a, run: ri, st, face: primary.clone(), small: false }; let mut cache: Vec<(char, Arc)> = Vec::new(); for (i, c) in text[a..b].char_indices() { let i = a + i; @@ -130,47 +131,67 @@ pub(crate) fn shape_range( cache.push((c, f.clone())); f }; + let small = small_caps && c.is_lowercase(); // Combining marks stay with their base. - if face.id() != seg_face.id() && !is_mark(c) { - if i > seg_start { - shape_segment(text, seg_start..i, ri, st, &seg_face, feats, out); + if (face.id() != seg.face.id() || small != seg.small) && !is_mark(c) { + if i > seg.range.start { + seg.range.end = i; + shape_segment(text, &seg, feats, out); } - seg_start = i; - seg_face = face; + seg = Segment { range: i..i, face, small, ..seg }; } } - if b > seg_start { - shape_segment(text, seg_start..b, ri, st, &seg_face, feats, out); + if b > seg.range.start { + seg.range.end = b; + shape_segment(text, &seg, feats, out); } } } +/// A piece of one run shaped in one go: one face and, for Small Caps, one case. +struct Segment<'a> { + range: Range, + run: usize, + st: &'a CharStyle, + face: Arc, + /// Lowercase letters drawn as synthesized small capitals. + small: bool, +} + fn is_mark(c: char) -> bool { matches!(c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F | 0x200D | 0xFE00..=0xFE0F) } -fn shape_segment(text: &str, range: Range, run: usize, st: &CharStyle, face: &Arc, feats: &OtFeatures, out: &mut Vec) { - let seg = &text[range.clone()]; - let size = st.size.max(0.0); +fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec) { + let Segment { range, run, st, face, small } = seg; + let (range, run, small) = (range.clone(), *run, *small); + let text_seg = &text[range.clone()]; + let full = st.size.max(0.0); + // Superscript/subscript and small capitals shrink the glyphs; line metrics keep the full size. + let (pos_scale, pos_shift) = st.position.scale_shift(full); + let small_scale = if small { st.small_caps.unwrap_or(100.0) / 100.0 } else { 1.0 }; + let size = full * pos_scale * small_scale; let k = size / face.upem; + let km = full / face.upem; let hs = st.h_scale / 100.0; let vs = st.v_scale / 100.0; let tracking = st.tracking / 1000.0 * size; let manual_kern = st.kerning.map(|v| v / 1000.0 * size).unwrap_or(0.0); - let ascent = face.ascent * k * vs; - let descent = face.descent * k * vs; + let ascent = face.ascent * km * vs; + let descent = face.descent * km * vs; let leading = st.effective_leading(); - let cap = face.cap_height * k * vs; - let xh = face.x_height * k * vs; + let cap = face.cap_height * km * vs; + let xh = face.x_height * km * vs; + let upper = st.all_caps || small; let first_char = |byte: usize| text[byte..].chars().next().unwrap_or(' '); - let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(seg.len()); // gid, cluster, xadv, xoff, yoff + let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(text_seg.len()); // gid, cluster, xadv, xoff, yoff let shaped = face.hb().map(|hb| { let shaper = face.shaper.shaper(&hb).build(); let mut buf = UnicodeBuffer::new(); - for (i, c) in seg.char_indices() { + for (i, c) in text_seg.char_indices() { let cl = (range.start + i) as u32; - if st.all_caps { + if upper { for u in c.to_uppercase() { buf.add(u, cl); } @@ -191,9 +212,9 @@ fn shape_segment(text: &str, range: Range, run: usize, st: &CharStyle, fa if let Some(f) = face.skrifa() { let cmap = f.charmap(); let gm = f.glyph_metrics(Size::unscaled(), LocationRef::default()); - for (i, c) in seg.char_indices() { + for (i, c) in text_seg.char_indices() { let cl = (range.start + i) as u32; - let chars: Vec = if st.all_caps { c.to_uppercase().collect() } else { vec![c] }; + let chars: Vec = if upper { c.to_uppercase().collect() } else { vec![c] }; for u in chars { let g = cmap.map(u).unwrap_or_default(); let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5); @@ -226,7 +247,7 @@ fn shape_segment(text: &str, range: Range, run: usize, st: &CharStyle, fa dy: -(yo as f64) * k * vs, sx: k * hs, sy: k * vs, - bshift: st.baseline_shift, + bshift: st.baseline_shift + pos_shift, rotation: st.rotation, ascent, descent, diff --git a/crates/text/src/tests_scripts.rs b/crates/text/src/tests_scripts.rs new file mode 100644 index 000000000..1ba84a220 --- /dev/null +++ b/crates/text/src/tests_scripts.rs @@ -0,0 +1,50 @@ +//! Superscript, subscript and synthesized small caps (Character panel, Document Setup → Type). + +use kurbo::Shape; +use vectorcraft_doc::{CharPosition, CharStyle, ScriptMetrics, TextObject}; + +use super::*; + +fn layout_of(text: &str, st: CharStyle) -> TextLayout { + layout(FontDb::global(), &TextObject::point(kurbo::Point::ZERO, text, st)) +} + +fn glyph_box(l: &TextLayout, i: usize) -> kurbo::Rect { + l.glyphs[i].outline.bounding_box() +} + +#[test] +fn superscript_and_subscript_scale_and_shift_glyphs() { + let base = CharStyle { size: 20.0, ..CharStyle::default() }; + let normal = layout_of("H", base.clone()); + let m = ScriptMetrics { size: 50.0, position: 40.0 }; + let sup = layout_of("H", CharStyle { position: CharPosition::Superscript(m), ..base.clone() }); + let sub = layout_of("H", CharStyle { position: CharPosition::Subscript(m), ..base.clone() }); + let (n, p, b) = (glyph_box(&normal, 0), glyph_box(&sup, 0), glyph_box(&sub, 0)); + // Half the height and advance. + assert!((p.height() - n.height() * 0.5).abs() < 0.05, "{p:?} vs {n:?}"); + assert!((sup.glyphs[0].advance - normal.glyphs[0].advance * 0.5).abs() < 1e-6); + // Raised / lowered by 40% of 20 pt (y points down). + assert!((p.y1 - (n.y1 - 8.0)).abs() < 0.05, "{p:?}"); + assert!((b.y1 - (n.y1 + 8.0)).abs() < 0.05, "{b:?}"); + // Line metrics keep the full size. + assert_eq!(sup.lines[0].ascent, normal.lines[0].ascent); +} + +#[test] +fn small_caps_draw_lowercase_as_smaller_capitals() { + let base = CharStyle { size: 20.0, ..CharStyle::default() }; + let cap = layout_of("A", base.clone()); + let cap14 = layout_of("A", CharStyle { size: 14.0, ..base.clone() }); + let small = layout_of("Aa", CharStyle { small_caps: Some(70.0), ..base.clone() }); + // The capital is unchanged; the lowercase letter is a capital A at 70% (14 pt). + assert_eq!(small.glyphs[0].advance, cap.glyphs[0].advance); + assert!((small.glyphs[1].advance - cap14.glyphs[0].advance).abs() < 1e-6); + let (a, s) = (glyph_box(&cap14, 0), glyph_box(&small, 1)); + assert!((s.height() - a.height()).abs() < 0.05, "{s:?} vs {a:?}"); + // Byte offsets still map back to the source characters. + assert_eq!((small.glyphs[1].byte, small.glyphs[1].len), (1, 1)); + // All Caps wins over Small Caps. + let all = layout_of("a", CharStyle { all_caps: true, small_caps: Some(70.0), ..base }); + assert_eq!(all.glyphs[0].advance, cap.glyphs[0].advance); +} diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index c48ca408f..e21e6dfa9 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -354,6 +354,16 @@ impl Tool for TypeTool { if s.is_empty() { return vec![]; } + // Use Typographer's Quotes (Document Setup): a typed straight quote becomes the document's + // opening or closing quote, by the character before it. + let setup = &cx.doc.setup; + let s = if setup.typographers_quotes && matches!(s.as_str(), "\"" | "'") { + let text = self.plain(cx); + let mut prev = text.get(..self.caret.min(self.anchor)).and_then(|b| b.chars().next_back()); + setup.quotes.apply(&s, &mut prev) + } else { + s + }; self.clicks = (None, 0); // Pasting what we copied keeps its formatting. let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8e080e96a..621c0b5f6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -147,20 +147,24 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let doc = st.doc.clone(); let mask_view = st.shown_mask(); - // Pasteboard, artboard shadows and paper. + // Pasteboard, artboard shadows and paper (Document Setup: the transparency grid's look, the + // simulated paper colour; a white Background Contents hides the grid). painter.rect_filled(rect, 0.0, t.pasteboard); if app.ui.view.artboards && !app.ui.view.outline { + let setup = &doc.setup; + let grid = (st.transparency_grid && setup.background == vectorcraft_doc::Background::Transparent).then(|| checker_texture(ui.ctx(), setup)); + let paper = if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE }; for ab in &doc.artboards { - let r = xf.rect_to_screen(ab.rect); let q = xf.quad(ab.rect); // Hard 2 pt drop shadow, right and bottom (measured: #4d4d4d then #565656 on #606060). let shift = |d: f32| q.iter().map(|p| *p + vec2(d, d)).collect::>(); painter.add(Shape::convex_polygon(shift(2.0), Color32::from_black_alpha(26), Stroke::NONE)); painter.add(Shape::convex_polygon(shift(1.0), Color32::from_black_alpha(52), Stroke::NONE)); - if st.transparency_grid && xf.rot == 0.0 { - checker(&painter, r); - } else { - painter.add(Shape::convex_polygon(q, Color32::WHITE, Stroke::NONE)); + match &grid { + Some((tex, cell)) => checker(&painter, &q, ab.rect, xf.zoom, tex.id(), *cell), + None => { + painter.add(Shape::convex_polygon(q, paper, Stroke::NONE)); + } } } } else if app.ui.view.outline { @@ -219,6 +223,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { [r, g, b] }), mask_view, + highlight_substitutions: true, ..opts }; // Light documents render synchronously (no lag vs overlays); heavy ones go to the worker. @@ -254,6 +259,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let r = xf.rect_to_screen(ab.rect); let c = if i == active_ab { Color32::from_gray(0) } else { Color32::from_gray(120) }; painter.add(Shape::closed_line(xf.quad(ab.rect), Stroke::new(if i == active_ab { 1.0 } else { 0.6 }, c))); + // The bleed (Document Setup) as a red outline around the artboard. + if doc.setup.has_bleed() { + painter.add(Shape::closed_line(xf.quad(doc.setup.bleed_rect(ab.rect)), Stroke::new(1.0, t.bleed))); + } if app.session.tool_id() == "artboard" || doc.artboards.len() > 1 { painter.text( r.left_top() - vec2(0.0, 4.0), @@ -534,18 +543,38 @@ pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_e } } -fn checker(p: &egui::Painter, r: egui::Rect) { - p.rect_filled(r, 0.0, Color32::WHITE); - let s = 8.0; - let clip = p.with_clip_rect(r.intersect(p.clip_rect())); - let (nx, ny) = (((r.width() / s).ceil() as i32).min(400), ((r.height() / s).ceil() as i32).min(400)); - for y in 0..ny { - for x in 0..nx { - if (x + y) % 2 == 1 { - clip.rect_filled(egui::Rect::from_min_size(r.min + vec2(x as f32 * s, y as f32 * s), vec2(s, s)), 0.0, Color32::from_gray(204)); - } - } +/// The transparency grid's 2 × 2-cell tile in the document's colours (Document Setup), cached per +/// look, and its cell size in screen points. +fn checker_texture(ctx: &egui::Context, setup: &vectorcraft_doc::DocSetup) -> (egui::TextureHandle, f32) { + let colors = setup.grid_colors.map(|c| crate::panels::c32(&c)); + let id = egui::Id::new("transparency-grid-tile"); + let cached = ctx.data(|d| d.get_temp::<([Color32; 2], egui::TextureHandle)>(id)).filter(|(c, _)| *c == colors); + let tex = cached.map(|(_, t)| t).unwrap_or_else(|| { + let [a, b] = colors; + let img = egui::ColorImage::new([2, 2], vec![a, b, b, a]); + let opts = egui::TextureOptions { wrap_mode: egui::TextureWrapMode::Repeat, ..egui::TextureOptions::NEAREST }; + let t = ctx.load_texture(TRANSPARENCY_GRID, img, opts); + ctx.data_mut(|d| d.insert_temp(id, (colors, t.clone()))); + t + }); + (tex, setup.grid_size.cell()) +} + +/// Name of the transparency grid's texture. +pub(crate) const TRANSPARENCY_GRID: &str = "transparency grid"; + +/// The transparency grid over the artboard `ab` (screen corners `quad`): one textured quad whose +/// cells stay `cell` screen points at every zoom and turn with a rotated view. +fn checker(p: &egui::Painter, quad: &[Pos2], ab: Rect, zoom: f64, tex: egui::TextureId, cell: f32) { + let mut mesh = egui::Mesh::with_texture(tex); + // Two cells per texture repeat. + let per = (2.0 * cell as f64 / zoom).max(1e-9); + let (u, v) = ((ab.width() / per) as f32, (ab.height() / per) as f32); + for (pos, uv) in quad.iter().zip([pos2(0.0, 0.0), pos2(u, 0.0), pos2(u, v), pos2(0.0, v)]) { + mesh.vertices.push(egui::epaint::Vertex { pos: *pos, uv, color: Color32::WHITE }); } + mesh.indices.extend([0, 1, 2, 0, 2, 3]); + p.add(Shape::mesh(mesh)); } fn grid(p: &egui::Painter, xf: &Xf, spacing: f64, subdiv: u32) { @@ -1024,31 +1053,14 @@ fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { ui.add_space(28.0); ui.label(egui::RichText::new("Quickly start a new file").font(theme::semibold(14.0)).color(t.text)); ui.add_space(10.0); - // (name, width, height in points, in pixels); print sizes show in Preferences ▸ Units ▸ General. - let presets: [(&str, f64, f64, bool); 6] = [ - ("Letter", 612.0, 792.0, false), - ("A4", 595.28, 841.89, false), - ("Web 1920", 1920.0, 1080.0, true), - ("Mobile", 390.0, 844.0, true), - ("Postcard", 432.0, 288.0, false), - ("Square", 1080.0, 1080.0, true), - ]; - let print = app.session.default_units(); + // A few of New Document's presets (`file.newPresets`), as its cards. + let presets = ["Letter", "A4", "Web 1920×1080", "Phone 390×844", "Postcard", "Social Square Post 1080×1080"]; ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(14.0, 14.0); - for (name, w, h, pixels) in presets { - let unit = if pixels { Unit::Pixels } else { print }; - let size = format!("{} × {}", unit.number(w), unit.format(h)); - let (r, resp) = ui.allocate_exact_size(vec2(120.0, 132.0), Sense::click()); - ui.painter().rect_filled(r, CornerRadius::same(8), if resp.hovered() { t.hover } else { t.panel }); - let s = (70.0 / w.max(h)) as f32; - let pr = egui::Rect::from_center_size(r.center_top() + vec2(0.0, 50.0), vec2(w as f32 * s, h as f32 * s)); - ui.painter().rect_filled(pr.translate(vec2(2.0, 2.0)), 0.0, Color32::from_black_alpha(80)); - ui.painter().rect_filled(pr, 0.0, Color32::WHITE); - ui.painter().text(r.center_bottom() - vec2(0.0, 30.0), egui::Align2::CENTER_CENTER, name, theme::semibold(12.5), t.text); - ui.painter().text(r.center_bottom() - vec2(0.0, 14.0), egui::Align2::CENTER_CENTER, size, egui::FontId::proportional(11.0), t.text_dim); - if resp.clicked() { - app.run("file.new", json!({"width": w, "height": h, "units": unit.label()})).ok(); + let cards: Vec<_> = presets.iter().filter_map(|name| vectorcraft_engine::cmd::newdoc::find(&app.session, name)).collect(); + for s in cards { + if crate::dialogs::preset_card(ui, &s, false).clicked() { + app.run("file.new", json!({ "preset": s.name })).ok(); } } }); diff --git a/crates/ui-egui/src/dialogs/document_setup.rs b/crates/ui-egui/src/dialogs/document_setup.rs index 273a5bb7f..0319715d5 100644 --- a/crates/ui-egui/src/dialogs/document_setup.rs +++ b/crates/ui-egui/src/dialogs/document_setup.rs @@ -1,11 +1,252 @@ -//! File → Document Setup: the document's ruler units. +//! File → Document Setup: General (units, bleed, Edit Artboards, outline images, substitution +//! highlights), Transparency (grid size and colours, simulated paper, flattener preset, white +//! overprint) and Type (language, quotes, superscript/subscript/small caps, text export) tabs. +//! +//! Fields: what `document.setup` reports (and takes back), plus `tab` and `bleedLinked`. OK runs +//! `document.setup` with them: one undo step. On an error the dialog stays open. -use serde_json::json; +use serde_json::{Value, json}; +use vectorcraft_doc::setup::{GRID_COLOR_PRESETS, LANGUAGES}; +use vectorcraft_doc::{GridSize, Quotes, Unit}; -use super::{DialogSpec, run_and_close}; +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; -pub(super) const SPEC: DialogSpec = DialogSpec { - heading: |_| "Document Setup".into(), - confirm: |app, d| run_and_close(app, "document.setUnits", json!({"units": d.str("units")})), - ..DialogSpec::FORM -}; +pub(super) const KIND: &str = "documentSetup"; +const TABS: [&str; 3] = ["General", "Transparency", "Type"]; +/// Dialog-only fields `document.setup` doesn't take. +const UI_ONLY: [&str; 2] = ["tab", "bleedLinked"]; +const LABEL: f32 = 130.0; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "Document Setup".into(), body, confirm, min_width: 540.0, max_width: Some(540.0), ..DialogSpec::FORM }; + +/// Open Document Setup on the active document's settings. +pub fn open(app: &mut VectorcraftApp) -> Result { + let mut fields = app.session.execute("document.setup", &json!({})).map_err(|e| e.to_string())?; + let linked = fields["bleed"].as_array().is_some_and(|b| b.windows(2).all(|w| w[0] == w[1])); + fields["bleedLinked"] = json!(linked); + fields["tab"] = json!(TABS[0]); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let params = d.fields.iter().filter(|(k, _)| !UI_ONLY.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect(); + let r = app.run("document.setup", Value::Object(params)); + if r.is_ok() { + app.ui.dialog = None; + } + r +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let tab = TABS.iter().position(|t| *t == d.str("tab")).unwrap_or(0); + if let Some(i) = widgets::tab_bar(ui, &TABS, tab) { + d.fields.insert("tab".into(), json!(TABS[i])); + } + ui.add_space(12.0); + ui.vertical(|ui| { + ui.set_min_height(330.0); + match tab { + 0 => general(app, ui, d), + 1 => transparency(app, ui, d), + _ => typography(ui, d), + } + }); + false +} + +/// A dropdown row bound to the string `d.fields[key]`; `options` are (value, label). +fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, options: &[(&str, &str)]) { + let cur = d.str(key); + let shown = options.iter().find(|(v, _)| v.eq_ignore_ascii_case(&cur)).map_or(cur.as_str(), |(_, l)| l); + let labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect(); + widgets::label_row(ui, label, LABEL, |ui| { + if let Some((value, _)) = widgets::dropdown(ui, key, shown, &labels, 220.0).and_then(|i| options.get(i)) { + d.fields.insert(key.into(), json!(value)); + } + }); +} + +/// A checkbox bound to `d.fields[key]`. +fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { + let on = d.bool(key); + if widgets::check(ui, label, on, true) { + d.fields.insert(key.into(), json!(!on)); + } +} + +/// The units the dialog shows lengths in. +fn unit(d: &Dialog) -> Unit { + Unit::named(&d.str("units")).unwrap_or_default() +} + +fn general(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + let units: Vec<(&str, &str)> = Unit::ALL.iter().map(|u| (u.label(), u.label())).collect(); + choice(ui, d, "units", "Units:", &units); + ui.add_space(6.0); + let unit = unit(d); + widgets::label_row(ui, "Bleed:", LABEL, |ui| form::bleed(ui, d, unit, 64.0)); + ui.add_space(6.0); + widgets::label_row(ui, "", LABEL, |ui| { + // Applies the settings, then edits the artboards with the Artboard tool. + if widgets::flat_button(ui, "Edit Artboards", 120.0).clicked() && confirm(app, d).is_ok() { + app.select_tool("artboard"); + } + }); + ui.add_space(12.0); + check(ui, d, "outlineImages", "Show Images in Outline Mode"); + check(ui, d, "highlightSubstitutedFonts", "Highlight Substituted Fonts"); + check(ui, d, "highlightSubstitutedGlyphs", "Highlight Substituted Glyphs"); +} + +fn transparency(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + let t = Tokens::get(ui.ctx()); + widgets::subheader(ui, "Transparency Grid"); + let sizes: Vec<(&str, &str)> = GridSize::ALL.iter().map(|g| (g.id(), g.label())).collect(); + choice(ui, d, "gridSize", "Grid Size:", &sizes); + let colors: Vec = d + .fields + .get("gridColors") + .and_then(Value::as_array) + .map(|a| a.iter().map(|c| c.as_str().unwrap_or("").to_string()).collect()) + .unwrap_or_default(); + let preset = GRID_COLOR_PRESETS + .iter() + .find(|(_, pair)| colors.len() == 2 && colors.iter().zip(pair).all(|(h, c)| h.eq_ignore_ascii_case(&c.to_hex()))) + .map_or("Custom", |(n, _)| n); + let names: Vec<&str> = GRID_COLOR_PRESETS.iter().map(|(n, _)| *n).chain(["Custom"]).collect(); + widgets::label_row(ui, "Grid Colors:", LABEL, |ui| { + if let Some((_, pair)) = widgets::dropdown(ui, "gridColors", preset, &names, 220.0).and_then(|i| GRID_COLOR_PRESETS.get(i)) { + d.fields.insert("gridColors".into(), json!(pair.map(|c| c.to_hex()))); + } + }); + // The two colours (the first is also the paper colour): editing either makes them Custom. + widgets::label_row(ui, "", LABEL, |ui| { + for i in 0..2 { + let hex = colors.get(i).map_or("", |h| h.trim_start_matches('#')); + let (chip, _) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover()); + if let Some(c) = vectorcraft_color::Color::from_hex(hex) { + ui.painter().rect_filled(chip, 2.0, crate::panels::c32(&c)); + } + ui.painter().rect_stroke(chip, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); + if let Some(c) = widgets::hex_field(ui, ("grid-color", i), hex).and_then(|h| vectorcraft_color::Color::from_hex(&h)) { + let mut v = colors.clone(); + v.resize(2, "#ffffff".into()); + v[i] = c.to_hex(); + d.fields.insert("gridColors".into(), json!(v)); + } + ui.add_space(10.0); + } + }); + ui.add_space(4.0); + check(ui, d, "simulatePaper", "Simulate Colored Paper"); + ui.add_space(14.0); + widgets::subheader(ui, "Export and Clipboard Transparency Flattener Settings"); + let presets: Vec = app.session.flattener_presets().into_iter().map(|p| p.name).collect(); + let options: Vec<(&str, &str)> = presets.iter().map(|p| (p.as_str(), p.as_str())).collect(); + choice(ui, d, "flattenerPreset", "Preset:", &options); + check(ui, d, "discardWhiteOverprint", "Discard White Overprint in Output"); +} + +fn typography(ui: &mut egui::Ui, d: &mut Dialog) { + let t = Tokens::get(ui.ctx()); + let langs: Vec<(&str, &str)> = LANGUAGES.iter().map(|(l, _)| (*l, *l)).collect(); + let before = d.str("language"); + choice(ui, d, "language", "Language:", &langs); + // A language brings its quotes. + if d.str("language") != before + && let Some(q) = vectorcraft_doc::setup::language_quotes(&d.str("language")) + { + d.fields.insert("quotes".into(), json!({"double": q.double.iter().collect::(), "single": q.single.iter().collect::()})); + } + for (key, label, choices) in [("double", "Double Quotes:", Quotes::DOUBLE_CHOICES), ("single", "Single Quotes:", Quotes::SINGLE_CHOICES)] { + let pairs: Vec = choices.iter().map(|p| p.iter().collect()).collect(); + let labels: Vec<&str> = pairs.iter().map(String::as_str).collect(); + let cur = d.fields.get("quotes").and_then(|q| q.get(key)).and_then(Value::as_str).unwrap_or("").to_string(); + widgets::label_row(ui, label, LABEL, |ui| { + if let Some(pair) = widgets::dropdown(ui, ("quotes", key), &cur, &labels, 90.0).and_then(|i| pairs.get(i)) { + let mut q = d.fields.get("quotes").cloned().unwrap_or_else(|| json!({})); + q[key] = json!(pair); + d.fields.insert("quotes".into(), q); + } + }); + } + check(ui, d, "typographersQuotes", "Use Typographer's Quotes"); + ui.add_space(14.0); + widgets::subheader(ui, "Options"); + egui::Grid::new("docsetup-scripts").num_columns(3).spacing([16.0, 6.0]).show(ui, |ui| { + ui.label(""); + for h in ["Size", "Position"] { + ui.label(egui::RichText::new(h).size(11.0).color(t.text_dim)); + } + ui.end_row(); + for (key, label) in [("superscript", "Superscript:"), ("subscript", "Subscript:")] { + ui.label(egui::RichText::new(label).color(t.text)); + for part in ["size", "position"] { + let v = d.fields.get(key).and_then(|m| m.get(part)).and_then(Value::as_f64).unwrap_or(0.0); + if let Some(n) = widgets::plain_field(ui, (key, part), v, "%", 1, 70.0) { + let mut m = d.fields.get(key).cloned().unwrap_or_else(|| json!({})); + m[part] = json!(n); + d.fields.insert(key.into(), m); + } + } + ui.end_row(); + } + ui.label(egui::RichText::new("Small Caps:").color(t.text)); + if let Some(n) = widgets::plain_field(ui, "smallCaps", d.f64("smallCapsSize", 70.0), "%", 1, 70.0) { + d.fields.insert("smallCapsSize".into(), json!(n)); + } + ui.end_row(); + }); + ui.add_space(6.0); + choice(ui, d, "exportText", "Export:", &[("editable", "Preserve Text Editability"), ("appearance", "Preserve Text Appearance")]); +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_engine::Session; + + use crate::VectorcraftApp; + + fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + #[test] + fn every_tab_draws_and_ok_is_one_undo_step() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("file.documentSetup", json!({})).unwrap(); + for tab in ["General", "Transparency", "Type"] { + app.ui.dialog.as_mut().unwrap().fields.insert("tab".into(), json!(tab)); + frame(&mut app); + } + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((d.fields["flattenerPreset"].as_str(), d.fields["bleedLinked"].as_bool()), (Some("Medium Resolution"), Some(true))); + for (k, v) in [("bleed", json!([9, 9, 9, 9])), ("gridColors", json!("Blue")), ("language", json!("German")), ("units", json!("Millimeters"))] + { + d.fields.insert(k.into(), v); + } + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.setup.bleed, st.doc.setup.grid_colors_name()), ([9.0; 4], "Blue")); + assert_eq!((st.doc.setup.quotes.double, st.doc.units), (['„', '“'], vectorcraft_doc::Unit::Millimeters)); + assert_eq!(st.history.undo.len(), 1); + // A bad value keeps the dialog open. + app.run("file.documentSetup", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("bleed".into(), json!(500)); + assert!(crate::dialogs::confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + } +} diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 23851e48a..0fb85a48e 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -270,3 +270,61 @@ pub(super) fn commit_preview(app: &mut crate::VectorcraftApp, cmd: &str, params: } r } + +/// The `[top, bottom, left, right]` bleed in `d.fields["bleed"]` (points). +pub(super) fn bleed_values(d: &Dialog) -> [f64; 4] { + let mut out = [0.0; 4]; + if let Some(a) = d.fields.get("bleed").and_then(Value::as_array) { + for (o, v) in out.iter_mut().zip(a) { + *o = v.as_f64().unwrap_or(0.0); + } + } + out +} + +/// Bleed fields (Document Setup, New Document): Top, Bottom, Left and Right shown in `unit`, each +/// `width` wide with a caption above, and a link toggle (`bleedLinked`, on by default) that keeps +/// them equal. Values are clamped to 0–72 pt. +pub(super) fn bleed(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit, width: f32) { + let t = Tokens::get(ui.ctx()); + let mut b = bleed_values(d); + let linked = d.fields.get("bleedLinked").and_then(Value::as_bool).unwrap_or(true); + let mut changed = None; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + for (i, side) in ["Top", "Bottom", "Left", "Right"].into_iter().enumerate() { + ui.vertical(|ui| { + ui.label(egui::RichText::new(side).size(11.0).color(t.text_dim)); + if let Some(v) = crate::widgets::num_field(ui, ("bleed", i), Some(b[i]), unit, width) { + changed = Some((i, v.clamp(0.0, vectorcraft_doc::setup::MAX_BLEED))); + } + }); + } + ui.vertical(|ui| { + ui.add_space(15.0); + let tip = if linked { "Make the bleed values differ" } else { "Make all bleed settings the same" }; + if crate::widgets::icon_button(ui, if linked { "link" } else { "link-2-off" }, tip, linked, 24.0).clicked() { + d.fields.insert("bleedLinked".into(), json!(!linked)); + if !linked { + // Linking makes every side the top's. + changed = Some((0, b[0])); + } + } + }); + }); + if let Some((i, v)) = changed { + // Linked (also just now): every side takes the value. + if d.fields.get("bleedLinked").and_then(Value::as_bool).unwrap_or(true) { + b = [v; 4]; + } else { + b[i] = v; + } + d.fields.insert("bleed".into(), json!(b)); + } +} + +/// A small grey caption above a field (New Document's details column). +pub(super) fn caption(ui: &mut egui::Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(text).size(11.0).color(t.text_dim)); +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 9c0c42350..00a97b5e1 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -47,8 +47,9 @@ pub mod width_point; use serde_json::{Value, json}; pub use color_picker::open as open_color_picker; +pub use document_setup::open as open_document_setup; pub use effect::open as open_effect_dialog; -pub use new_document::open as open_new_document; +pub use new_document::{open as open_new_document, preset_card}; pub use save_pdf::open as open_save_pdf; pub use tools::open_tool_dialog; @@ -137,7 +138,7 @@ macro_rules! registry { } registry! { - NewDocument: ["newDocument"] => new_document::SPEC, + NewDocument: [new_document::KIND] => new_document::SPEC, Shape: ["rectangle", "roundedRectangle", "ellipse", "polygon", "star", "lineSegment"] => shapes::SPEC, Transform: ["move", "rotate", "scale", "reflect", "shear"] => transform::SPEC, PathOp: ["average", "offsetPath", "simplify", "splitIntoGrid"] => path_ops::SPEC, @@ -176,6 +177,7 @@ registry! { WidthPoint: [width_point::KIND] => width_point::SPEC, SavePdf: [save_pdf::KIND] => save_pdf::SPEC, SvgOptions: [svg_options::KIND] => svg_options::SPEC, + NewDocumentMore: [new_document::MORE] => new_document::MORE_SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/new_document.rs b/crates/ui-egui/src/dialogs/new_document.rs index 8be06d8fe..441f24809 100644 --- a/crates/ui-egui/src/dialogs/new_document.rs +++ b/crates/ui-egui/src/dialogs/new_document.rs @@ -1,91 +1,666 @@ -//! File → New: presets and the document details (name, size, units, artboards, colour mode). +//! File → New: category tabs (Recent, Saved and the built-in categories of `file.newPresets`) +//! over a grid of preset cards, and a Preset Details column (name and Save Preset, size and +//! units, orientation, artboards, bleed, background contents, Advanced Options, More Settings); +//! Close and Create. More Settings is the classic form of the same fields (artboard layout, +//! spacing and columns, profile and size lists), so both share one set of fields and widgets. //! -//! Fields: `preset`, `name`, `width` and `height` (points, or strings with a unit), `units` (a unit -//! label; print presets start in Preferences ▸ Units ▸ General), `artboards`, `colorMode`. +//! Fields: `file.new`'s params (`preset`, `name`, `width`, `height` (pt), `units`, `artboards`, +//! `artboardLayout`, `bleed`, `backgroundContents`, `colorMode`, `rasterEffectsPpi`, +//! `previewMode`), plus `category` (the tab, also More Settings' Profile), `advanced` (Advanced +//! Options open), `bleedLinked` and `presetName` (Save Preset's name). Setting `preset` fills the +//! fields from that preset; changing any of them afterwards makes the settings custom. -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; use vectorcraft_doc::Unit; +use vectorcraft_engine::cmd::newdoc::{self, ArtboardLayout, DocSettings, MAX_ARTBOARDS, PreviewMode, RASTER_PPI}; use super::{DialogSpec, form}; use crate::state::Dialog; use crate::theme::{self, Tokens}; use crate::{VectorcraftApp, widgets}; -pub(super) const SPEC: DialogSpec = - DialogSpec { heading: |_| "New Document".into(), body, confirm, ok: Some("Create"), min_width: 560.0, ..DialogSpec::FORM }; +pub(super) const KIND: &str = "newDocument"; +/// More Settings. +pub(super) const MORE: &str = "newDocumentMore"; -/// The presets: (name, width, height in points, in pixels). -const PRESETS: [(&str, f64, f64, bool); 8] = [ - ("Letter", 612.0, 792.0, false), - ("Legal", 612.0, 1008.0, false), - ("Tabloid", 792.0, 1224.0, false), - ("A4", 595.28, 841.89, false), - ("A3", 841.89, 1190.55, false), - ("Web 1920", 1920.0, 1080.0, true), - ("Phone 390×844", 390.0, 844.0, true), - ("Square Post", 1080.0, 1080.0, true), +pub(super) const SPEC: DialogSpec = DialogSpec::window(show, confirm); +pub(super) const MORE_SPEC: DialogSpec = DialogSpec::window(show_more, confirm); + +/// The fields `file.new` takes. +const PARAMS: [&str; 12] = [ + "preset", + "name", + "width", + "height", + "units", + "artboards", + "artboardLayout", + "bleed", + "backgroundContents", + "colorMode", + "rasterEffectsPpi", + "previewMode", ]; +/// The preset the fields were last filled from (its fields as `apply` set them). +const APPLIED: &str = "__applied"; +/// Save Preset's name field is showing. +const SAVING: &str = "__saving"; +/// The units New Document offers (all ten). +const UNITS: [Unit; 10] = [ + Unit::Pixels, + Unit::Points, + Unit::Picas, + Unit::Inches, + Unit::Millimeters, + Unit::Centimeters, + Unit::Feet, + Unit::Yards, + Unit::Meters, + Unit::FeetInches, +]; +const BACKGROUNDS: [(&str, &str); 2] = [("transparent", "Transparent (Default)"), ("white", "White")]; +const COLOR_MODES: [(&str, &str); 2] = [("rgb", "RGB Color"), ("cmyk", "CMYK Color")]; +/// Width of the Preset Details column. +const DETAILS: f32 = 292.0; +/// Height of the dialog's content. +const HEIGHT: f32 = 600.0; -/// The units a preset starts in: pixels for screen presets, else the General preference. -fn preset_units(app: &VectorcraftApp, pixels: bool) -> Unit { - if pixels { Unit::Pixels } else { app.session.default_units() } -} - -/// Open the dialog on the Letter preset. +/// Open New Document on the most recent settings (or Letter), named after the next untitled +/// document. pub fn open(app: &mut VectorcraftApp) { - let units = preset_units(app, false).label(); - let fields = json!({"preset": "Letter", "width": 612, "height": 792, "units": units, "artboards": 1, "colorMode": "RGB", "name": "Untitled-1"}); - app.ui.dialog = Some(Dialog::new("newDocument", fields)); + let category = if app.session.prefs.recent_new_docs.is_empty() { "Print" } else { "Recent" }; + let first = newdoc::category(&app.session, category).and_then(|v| v.into_iter().next()).unwrap_or_default(); + let mut d = Dialog::new(KIND, json!({ "category": category, "name": app.session.peek_untitled(), "advanced": false })); + apply(&mut d, &first); + app.ui.dialog = Some(d); } -fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { - let t = Tokens::get(ui.ctx()); - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.set_width(300.0); - ui.label(egui::RichText::new("Presets").color(t.text_dim)); - ui.horizontal_wrapped(|ui| { - for (name, w, h, pixels) in PRESETS { - let sel = d.str("preset") == name; - if ui.selectable_label(sel, name).clicked() { - d.fields.insert("preset".into(), json!(name)); - d.fields.insert("width".into(), json!(w)); - d.fields.insert("height".into(), json!(h)); - d.fields.insert("units".into(), json!(preset_units(app, pixels).label())); - } - } - }); - }); - ui.separator(); - ui.vertical(|ui| { - ui.label(egui::RichText::new("Preset Details").font(theme::semibold(12.5))); - let unit = Unit::named(&d.str("units")).unwrap_or_default(); - egui::Grid::new("newdoc").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { - form::field(ui, d, "name", "Name:"); - form::length_field(ui, d, "width", "Width:", unit); - form::length_field(ui, d, "height", "Height:", unit); - ui.label(egui::RichText::new("Units:").color(t.text_dim)); - let labels: Vec<&str> = Unit::ALL.iter().map(|u| u.label()).collect(); - if let Some(i) = widgets::dropdown(ui, "newdoc-units", unit.label(), &labels, 132.0) { - d.fields.insert("units".into(), json!(labels[i])); - } - ui.end_row(); - form::field(ui, d, "artboards", "Artboards:"); - form::field(ui, d, "colorMode", "Color Mode:"); - }); - }); - }); - false +/// Fill the fields from a preset. +fn apply(d: &mut Dialog, s: &DocSettings) { + let Value::Object(mut v) = s.to_json() else { return }; + for k in ["size", "orientation"] { + v.remove(k); + } + if let Some(name) = v.remove("name") { + v.insert("preset".into(), name); + } + d.fields.insert("bleedLinked".into(), json!(s.bleed.iter().all(|b| *b == s.bleed[0]))); + for (k, val) in &v { + d.fields.insert(k.clone(), val.clone()); + } + d.fields.insert(APPLIED.into(), Value::Object(v)); +} + +/// Keep the fields and the chosen preset in step: a new `preset` (also from `ui.dialog.set`) fills +/// the fields; a changed field makes the settings custom. +fn sync(app: &VectorcraftApp, d: &mut Dialog) { + let preset = d.str("preset"); + match d.fields.get(APPLIED).and_then(Value::as_object) { + Some(a) if a.get("preset").and_then(Value::as_str) == Some(preset.as_str()) => { + if a.iter().any(|(k, v)| k != "preset" && d.fields.get(k) != Some(v)) { + d.fields.insert("preset".into(), json!("")); + d.fields.remove(APPLIED); + } + } + _ if !preset.is_empty() => match newdoc::find(&app.session, &preset) { + Some(s) => apply(d, &s), + None => { + d.fields.remove(APPLIED); + } + }, + _ => {} + } } /// Create the document; the dialog stays open when that fails (bad size, …). fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - let r = app.run( - "file.new", - json!({"width": d.f64("width", 612.0), "height": d.f64("height", 792.0), "units": d.str("units"), "title": d.str("name"), "artboards": d.f64("artboards", 1.0), "colorMode": d.str("colorMode").to_lowercase()}), - ); + let mut p: Map = PARAMS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect(); + // Recent entries such as "Custom" aren't presets file.new knows. + if newdoc::find(&app.session, &d.str("preset")).is_none() { + p.remove("preset"); + } + let r = app.run("file.new", Value::Object(p)); if r.is_ok() { app.ui.dialog = None; } r } + +/// The units the size fields show. +fn unit(d: &Dialog) -> Unit { + Unit::named(&d.str("units")).unwrap_or_default() +} + +/// The artboard layout fields. +fn layout(d: &Dialog) -> (ArtboardLayout, u64, f64, bool) { + let l = d.fields.get("artboardLayout"); + let get = |k: &str| l.and_then(|l| l.get(k)); + ( + get("layout").and_then(Value::as_str).and_then(ArtboardLayout::parse).unwrap_or_default(), + get("columns").and_then(Value::as_u64).unwrap_or(1), + get("spacing").and_then(Value::as_f64).unwrap_or(20.0), + get("rightToLeft").and_then(Value::as_bool).unwrap_or(false), + ) +} + +fn set_layout(d: &mut Dialog, key: &str, v: Value) { + let mut l = d.fields.get("artboardLayout").cloned().filter(Value::is_object).unwrap_or_else(|| json!({})); + l[key] = v; + d.fields.insert("artboardLayout".into(), l); +} + +/// The dimmed backdrop and a centred dialog window in the shared dialog style. +fn window(ctx: &egui::Context, id: &str, margin: i8, add: impl FnOnce(&mut egui::Ui)) { + let t = Tokens::get(ctx); + egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| { + ui.allocate_rect(ctx.content_rect(), egui::Sense::click()); + }); + egui::Window::new(id) + .id(egui::Id::new(("dialog", id))) + .order(egui::Order::Foreground) + .collapsible(false) + .resizable(false) + .title_bar(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0]) + .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(margin))) + .show(ctx, add); +} + +/// What the buttons of a window asked for. +#[derive(Default)] +struct Buttons { + create: bool, + close: bool, +} + +impl Buttons { + /// Act on the buttons (Enter also creates) once the window is drawn. + fn finish(self, app: &mut VectorcraftApp, ctx: &egui::Context, d: Dialog) { + let create = self.create || ctx.input(|i| i.key_pressed(egui::Key::Enter)) && !ctx.memory(|m| m.focused().is_some()); + app.ui.dialog = Some(d); + if self.close { + app.ui.dialog = None; + } else if create && let Err(e) = super::confirm(app) { + app.status(e); + } + } +} + +fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { + let Some(mut d) = app.ui.dialog.clone() else { return }; + sync(app, &mut d); + let t = Tokens::get(ctx); + let mut b = Buttons::default(); + window(ctx, KIND, 0, |ui| { + ui.horizontal_top(|ui| { + egui::Frame::NONE.inner_margin(egui::Margin { left: 22, right: 14, top: 14, bottom: 18 }).show(ui, |ui| { + ui.vertical(|ui| { + ui.set_width(640.0); + ui.set_min_height(HEIGHT - 32.0); + presets(app, ui, &mut d); + }); + }); + egui::Frame::NONE.fill(t.panel_darker).inner_margin(egui::Margin::same(18)).show(ui, |ui| { + ui.vertical(|ui| { + ui.set_width(DETAILS); + ui.set_min_height(HEIGHT); + details(app, ui, &mut d, &mut b); + }); + }); + }); + }); + b.finish(app, ctx, d); +} + +/// The category tabs and the chosen category's preset cards. +fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + let t = Tokens::get(ui.ctx()); + let names: Vec<&str> = newdoc::category_names().collect(); + let cat = names.iter().position(|n| n.eq_ignore_ascii_case(&d.str("category"))).unwrap_or(0); + if let Some(i) = widgets::tab_bar(ui, &names, cat) { + d.fields.insert("category".into(), json!(names[i])); + } + let list = names.get(cat).and_then(|c| newdoc::category(&app.session, c)).unwrap_or_default(); + ui.add_space(14.0); + let heading = if cat == 0 { + "RECENT" + } else if cat == 1 { + "SAVED" + } else { + "BLANK DOCUMENT PRESETS" + }; + ui.label(egui::RichText::new(format!("{heading} ({})", list.len())).font(theme::semibold(11.0)).color(t.text_dim)); + ui.add_space(10.0); + if list.is_empty() { + let hint = if cat == 1 { "Presets you save with the Save Preset button appear here." } else { "Documents you create appear here." }; + ui.label(egui::RichText::new(hint).color(t.text_dim)); + return; + } + egui::ScrollArea::vertical().id_salt(("newdoc-presets", cat)).max_height(HEIGHT - 110.0).auto_shrink([false, false]).show(ui, |ui| { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(12.0, 12.0); + let (w, h, preset) = (d.f64("width", 0.0), d.f64("height", 0.0), d.str("preset")); + for s in &list { + let selected = d.fields.contains_key(APPLIED) && s.name == preset && s.width == w && s.height == h; + if preset_card(ui, s, selected).clicked() { + apply(d, s); + } + } + }); + }); +} + +/// A preset card: a page-proportioned thumbnail, the name and the size (New Document, the Home +/// screen). Returns the card's response. +pub fn preset_card(ui: &mut egui::Ui, s: &DocSettings, selected: bool) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(egui::vec2(146.0, 150.0), egui::Sense::click()); + let p = ui.painter(); + p.rect_filled(r, egui::CornerRadius::same(6), if resp.hovered() || selected { t.hover } else { t.panel_darker }); + if selected { + p.rect_stroke(r, egui::CornerRadius::same(6), egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + } + let k = (60.0 / s.width.max(s.height).max(1e-9)) as f32; + let page = egui::Rect::from_center_size( + r.center_top() + egui::vec2(0.0, 48.0), + egui::vec2((s.width as f32 * k).max(4.0), (s.height as f32 * k).max(4.0)), + ); + p.rect_filled(page.translate(egui::vec2(2.0, 2.0)), 0.0, egui::Color32::from_black_alpha(80)); + p.rect_filled(page, 0.0, egui::Color32::WHITE); + // The name on up to two lines, then the size. + let mut job = egui::text::LayoutJob::single_section( + s.name.clone(), + egui::TextFormat { font_id: theme::semibold(11.5), color: t.text_strong, ..Default::default() }, + ); + job.halign = egui::Align::Center; + job.wrap = egui::text::TextWrapping { max_width: r.width() - 14.0, max_rows: 2, break_anywhere: false, overflow_character: Some('…') }; + let name = ui.fonts_mut(|f| f.layout_job(job)); + let name_h = name.size().y; + p.galley(egui::pos2(r.center().x, r.top() + 94.0), name, t.text_strong); + p.text(egui::pos2(r.center().x, r.top() + 98.0 + name_h), egui::Align2::CENTER_TOP, s.size_label(), egui::FontId::proportional(11.0), t.text_dim); + resp.on_hover_text(&s.name) +} + +/// The Preset Details column. +fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut Buttons) { + let t = Tokens::get(ui.ctx()); + ui.spacing_mut().item_spacing = egui::vec2(6.0, 4.0); + let top = ui.cursor().top(); + ui.label(egui::RichText::new("PRESET DETAILS").font(theme::semibold(11.0)).color(t.text_dim)); + ui.add_space(8.0); + ui.horizontal(|ui| { + form::text(ui, d, "name", DETAILS - 50.0); + if widgets::icon_button(ui, "save", "Save Preset", d.bool(SAVING), 26.0).clicked() { + let saving = !d.bool(SAVING); + d.fields.insert(SAVING.into(), json!(saving)); + if saving && d.str("presetName").is_empty() { + d.fields.insert("presetName".into(), json!(format!("Preset {}", app.session.prefs.new_doc_presets.len() + 1))); + } + } + }); + if d.bool(SAVING) { + save_row(app, ui, d); + } + ui.add_space(8.0); + let unit = unit(d); + form::caption(ui, "Width"); + ui.horizontal(|ui| { + form::length(ui, d, "width", unit, DETAILS - 124.0); + units_dropdown(ui, d, 112.0); + }); + ui.add_space(4.0); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + form::caption(ui, "Height"); + form::length(ui, d, "height", unit, 104.0); + }); + ui.add_space(6.0); + ui.vertical(|ui| { + form::caption(ui, "Orientation"); + orientation(ui, d); + }); + ui.add_space(6.0); + ui.vertical(|ui| { + form::caption(ui, "Artboards"); + artboards(ui, d, 76.0); + }); + }); + ui.add_space(8.0); + form::caption(ui, "Bleed"); + form::bleed(ui, d, unit, 52.0); + ui.add_space(8.0); + form::caption(ui, "Background Contents"); + background(ui, d, DETAILS - 34.0); + ui.add_space(8.0); + advanced(ui, d, 110.0, DETAILS - 116.0); + ui.add_space(12.0); + if widgets::flat_button(ui, "More Settings", DETAILS).clicked() { + d.kind = MORE.into(); + } + // Close and Create at the bottom right. + ui.add_space((HEIGHT - (ui.cursor().top() - top) - 28.0).max(12.0)); + ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 28.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + b.create = widgets::primary_button(ui, "Create").clicked(); + ui.add_space(8.0); + b.close = widgets::secondary_button(ui, "Close").clicked(); + }); +} + +/// Save Preset: the preset's name, Save and Cancel. +fn save_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + ui.add_space(4.0); + form::caption(ui, "Preset Name"); + ui.horizontal(|ui| { + let enter = form::text_edit(ui, d, "presetName", DETAILS - 140.0).lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if widgets::flat_button(ui, "Save", 54.0).clicked() || enter { + let mut p: Map = PARAMS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect(); + p.insert("name".into(), json!(d.str("presetName"))); + p.remove("preset"); + match app.run("file.newPresets.save", Value::Object(p)) { + Ok(r) => { + d.fields.insert(SAVING.into(), json!(false)); + d.fields.insert("category".into(), json!("Saved")); + d.fields.insert("preset".into(), r["name"].clone()); + d.fields.remove(APPLIED); + } + Err(e) => app.status(e), + } + } + if widgets::flat_button(ui, "Cancel", 60.0).clicked() { + d.fields.insert(SAVING.into(), json!(false)); + } + }); +} + +fn units_dropdown(ui: &mut egui::Ui, d: &mut Dialog, width: f32) { + let labels = UNITS.map(Unit::label); + if let Some(u) = widgets::dropdown(ui, "newdoc-units", unit(d).label(), &labels, width).and_then(|i| UNITS.get(i)) { + d.fields.insert("units".into(), json!(u.label())); + } +} + +/// Portrait and landscape: choosing the other one swaps width and height. +fn orientation(ui: &mut egui::Ui, d: &mut Dialog) { + let (w, h) = (d.f64("width", 612.0), d.f64("height", 792.0)); + let landscape = w > h; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + for (is_landscape, tip) in [(false, "Portrait"), (true, "Landscape")] { + if widgets::orientation_button(ui, is_landscape, landscape == is_landscape, tip) && landscape != is_landscape { + d.fields.insert("width".into(), json!(h)); + d.fields.insert("height".into(), json!(w)); + } + } + }); +} + +fn artboards(ui: &mut egui::Ui, d: &mut Dialog, width: f32) { + let n = d.f64("artboards", 1.0); + ui.horizontal(|ui| { + if let Some(v) = widgets::spin_plain(ui, "newdoc-artboards", n, "", 0, width, 1.0, 1.0, &[]) { + d.fields.insert("artboards".into(), json!(v.round().clamp(1.0, MAX_ARTBOARDS as f64) as u64)); + } + }); +} + +/// Background Contents with its chip: white, or white slashed in red for transparent. +fn background(ui: &mut egui::Ui, d: &mut Dialog, width: f32) { + let t = Tokens::get(ui.ctx()); + let cur = d.str("backgroundContents"); + let shown = BACKGROUNDS.iter().find(|(v, _)| *v == cur).map_or(BACKGROUNDS[0].1, |(_, l)| l); + ui.horizontal(|ui| { + if let Some((v, _)) = widgets::dropdown(ui, "newdoc-background", shown, &BACKGROUNDS.map(|b| b.1), width).and_then(|i| BACKGROUNDS.get(i)) { + d.fields.insert("backgroundContents".into(), json!(v)); + } + let (chip, _) = ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::hover()); + ui.painter().rect_filled(chip, 2.0, egui::Color32::WHITE); + if cur != "white" { + ui.painter().line_segment([chip.left_bottom(), chip.right_top()], egui::Stroke::new(1.5, t.bleed)); + } + ui.painter().rect_stroke(chip, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); + }); +} + +/// A dropdown bound to `d.fields[key]` over (value, label) pairs. +fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, options: &[(Value, String)], width: f32) { + let cur = d.fields.get(key).cloned().unwrap_or_default(); + let shown = options + .iter() + .find(|(v, _)| *v == cur || v.as_f64().is_some_and(|x| cur.as_f64() == Some(x))) + .map_or_else(|| cur.to_string(), |(_, l)| l.clone()); + let labels: Vec<&str> = options.iter().map(|(_, l)| l.as_str()).collect(); + if let Some((v, _)) = widgets::dropdown(ui, ("newdoc", key), &shown, &labels, width).and_then(|i| options.get(i)) { + d.fields.insert(key.into(), v.clone()); + } +} + +/// The collapsible Advanced Options: Color Mode, Raster Effects and Preview Mode. +fn advanced(ui: &mut egui::Ui, d: &mut Dialog, label: f32, field: f32) { + let t = Tokens::get(ui.ctx()); + let open = d.bool("advanced"); + let resp = ui + .horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 18.0), egui::Sense::hover()); + crate::icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, r, t.icon); + ui.label(egui::RichText::new("Advanced Options").color(t.text)); + }) + .response + .interact(egui::Sense::click()); + if resp.clicked() { + d.fields.insert("advanced".into(), json!(!open)); + } + if !open { + return; + } + let colors: Vec<(Value, String)> = COLOR_MODES.iter().map(|(v, l)| (json!(v), l.to_string())).collect(); + let ppi: Vec<(Value, String)> = RASTER_PPI.iter().map(|(v, l)| (json!(v), l.to_string())).collect(); + let previews: Vec<(Value, String)> = + PreviewMode::ALL.iter().map(|m| (json!(m.id()), form::humanize(m.id()).trim_end_matches(':').to_string())).collect(); + for (key, name, options) in + [("colorMode", "Color Mode", &colors), ("rasterEffectsPpi", "Raster Effects", &ppi), ("previewMode", "Preview Mode", &previews)] + { + widgets::label_row(ui, name, label, |ui| choice(ui, d, key, options, field)); + } +} + +/// More Settings: the classic form of the same fields. +fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) { + let Some(mut d) = app.ui.dialog.clone() else { return }; + sync(app, &mut d); + let t = Tokens::get(ctx); + let mut b = Buttons::default(); + let mut templates = false; + const L: f32 = 150.0; + window(ctx, MORE, 22, |ui| { + ui.set_width(560.0); + ui.label(egui::RichText::new("More Settings").font(theme::semibold(16.0)).color(t.text)); + ui.add_space(12.0); + widgets::label_row(ui, "Name:", L, |ui| { + form::text(ui, &mut d, "name", 300.0); + }); + // Profile: the preset categories (the tabs); Size: the profile's presets. + let profiles: Vec<&str> = newdoc::CATEGORIES.iter().map(|c| c.0).collect(); + let profile = profiles.iter().find(|p| p.eq_ignore_ascii_case(&d.str("category"))).copied().unwrap_or("[Custom]"); + widgets::label_row(ui, "Profile:", L, |ui| { + if let Some(p) = widgets::dropdown(ui, "newdoc-profile", profile, &profiles, 220.0).and_then(|i| profiles.get(i)) { + d.fields.insert("category".into(), json!(p)); + if let Some(first) = newdoc::category(&app.session, p).and_then(|v| v.into_iter().next()) { + apply(&mut d, &first); + } + } + }); + ui.add_space(6.0); + let (lay, cols, spacing, rtl) = layout(&d); + let n = d.f64("artboards", 1.0); + let several = n > 1.0; + widgets::label_row(ui, "Number of Artboards:", L, |ui| { + artboards(ui, &mut d, 76.0); + ui.add_space(10.0); + for (l, icon) in [ + (ArtboardLayout::GridByRow, "dc-grid-view"), + (ArtboardLayout::GridByColumn, "grid-3x3"), + (ArtboardLayout::Row, "arrow-left-right"), + (ArtboardLayout::Column, "arrow-up-down"), + ] { + if widgets::icon_button_enabled(ui, icon, l.label(), lay == l, several, 26.0).clicked() { + set_layout(&mut d, "layout", json!(l.id())); + } + } + ui.add_space(6.0); + if widgets::icon_button_enabled(ui, "chevrons-left", "Change to Right-to-Left Layout", rtl, several, 26.0).clicked() { + set_layout(&mut d, "rightToLeft", json!(!rtl)); + } + }); + ui.add_enabled_ui(several, |ui| { + widgets::label_row(ui, "Spacing:", L, |ui| { + if let Some(v) = widgets::num_field(ui, "newdoc-spacing", Some(spacing), unit(&d), 100.0) { + set_layout(&mut d, "spacing", json!(v.max(0.0))); + } + ui.add_space(20.0); + ui.label(egui::RichText::new("Columns:").color(t.text)); + let grid = matches!(lay, ArtboardLayout::GridByRow | ArtboardLayout::GridByColumn); + ui.add_enabled_ui(grid, |ui| { + if let Some(v) = widgets::spin_plain(ui, "newdoc-columns", cols as f64, "", 0, 76.0, 1.0, 1.0, &[]) { + set_layout(&mut d, "columns", json!(v.round().clamp(1.0, n) as u64)); + } + }); + }); + }); + ui.add_space(6.0); + let sizes = newdoc::category(&app.session, profile).unwrap_or_default(); + let size_names: Vec<&str> = sizes.iter().map(|s| s.name.as_str()).collect(); + let preset = d.str("preset"); + widgets::label_row(ui, "Size:", L, |ui| { + let shown = if preset.is_empty() { "Custom" } else { preset.as_str() }; + if let Some(s) = widgets::dropdown(ui, "newdoc-size", shown, &size_names, 220.0).and_then(|i| sizes.get(i)) { + apply(&mut d, s); + } + }); + let unit = unit(&d); + widgets::label_row(ui, "Width:", L, |ui| { + form::length(ui, &mut d, "width", unit, 120.0); + ui.add_space(20.0); + ui.label(egui::RichText::new("Units:").color(t.text)); + units_dropdown(ui, &mut d, 120.0); + }); + widgets::label_row(ui, "Height:", L, |ui| { + form::length(ui, &mut d, "height", unit, 120.0); + ui.add_space(20.0); + ui.label(egui::RichText::new("Orientation:").color(t.text)); + orientation(ui, &mut d); + }); + ui.add_space(4.0); + widgets::label_row(ui, "Bleed:", L, |ui| form::bleed(ui, &mut d, unit, 64.0)); + ui.add_space(4.0); + widgets::label_row(ui, "Background Contents:", L, |ui| background(ui, &mut d, 220.0)); + ui.add_space(8.0); + advanced(ui, &mut d, L, 220.0); + ui.add_space(16.0); + ui.horizontal(|ui| { + templates = widgets::secondary_button(ui, "Templates…").on_hover_text("New from Template").clicked(); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + b.create = widgets::primary_button(ui, "Create Document").clicked(); + ui.add_space(8.0); + // Cancel goes back to New Document. + if widgets::secondary_button(ui, "Cancel").clicked() { + d.kind = KIND.into(); + } + }); + }); + }); + if templates { + app.ui.dialog = None; + if let Err(e) = app.run("file.newFromTemplate", json!({})) { + app.status(e); + } + return; + } + b.finish(app, ctx, d); +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_engine::Session; + + use crate::VectorcraftApp; + + fn frame(app: &mut VectorcraftApp) -> egui::FullOutput { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 1000.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + out + } + + fn set(app: &mut VectorcraftApp, k: &str, v: serde_json::Value) { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + } + + fn field(app: &VectorcraftApp, k: &str) -> serde_json::Value { + app.ui.dialog.as_ref().unwrap().fields.get(k).cloned().unwrap_or_default() + } + + #[test] + fn a_preset_fills_the_details_and_create_makes_the_document() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.newDialog", json!({})).unwrap(); + frame(&mut app); + assert_eq!((field(&app, "category"), field(&app, "preset"), field(&app, "name")), (json!("Print"), json!("Letter"), json!("Untitled-1"))); + // Choosing a preset (as `ui.dialog.set` does) fills the fields. + set(&mut app, "category", json!("Film & Video")); + set(&mut app, "preset", json!("4K UHD")); + frame(&mut app); + assert_eq!((field(&app, "width"), field(&app, "units"), field(&app, "colorMode")), (json!(3840.0), json!("Pixels"), json!("rgb"))); + set(&mut app, "artboards", json!(4)); + set(&mut app, "artboardLayout", json!({"layout": "gridByRow", "columns": 2, "spacing": 10})); + set(&mut app, "bleed", json!([0, 0, 0, 0])); + set(&mut app, "previewMode", json!("pixel")); + set(&mut app, "backgroundContents", json!("white")); + frame(&mut app); + assert_eq!(field(&app, "preset"), json!(""), "editing makes the settings custom"); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let st = app.session.active().unwrap(); + let rects: Vec<_> = st.doc.artboards.iter().map(|a| (a.rect.x0, a.rect.y0)).collect(); + assert_eq!(rects, [(0.0, 0.0), (3850.0, 0.0), (0.0, 2170.0), (3850.0, 2170.0)]); + assert_eq!(st.doc.setup.background, vectorcraft_doc::Background::White); + assert!(app.ui.view.pixel_preview, "Pixel preview mode"); + // Next time Recent opens on it. + app.run("file.newDialog", json!({})).unwrap(); + assert_eq!((field(&app, "category"), field(&app, "name")), (json!("Recent"), json!("Untitled-2"))); + frame(&mut app); + } + + #[test] + fn every_category_and_more_settings_draw_and_save_preset_saves() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.newDialog", json!({})).unwrap(); + set(&mut app, "advanced", json!(true)); + for c in vectorcraft_engine::cmd::newdoc::category_names() { + set(&mut app, "category", json!(c)); + let out = frame(&mut app); + assert!(!out.shapes.is_empty()); + } + // More Settings shares the fields; Cancel goes back. + app.ui.dialog.as_mut().unwrap().kind = super::MORE.into(); + set(&mut app, "artboards", json!(3)); + frame(&mut app); + assert_eq!(field(&app, "artboards"), json!(3)); + // Save Preset. + set(&mut app, "category", json!("Print")); + set(&mut app, "preset", json!("A5")); + frame(&mut app); + let p = app.run("file.newPresets.save", json!({"name": "Zine", "preset": "A5"})); + assert!(p.is_ok()); + app.ui.dialog.as_mut().unwrap().kind = super::KIND.into(); + set(&mut app, "category", json!("Saved")); + set(&mut app, "preset", json!("Zine")); + frame(&mut app); + assert!((field(&app, "width").as_f64().unwrap() - 148.0 * 72.0 / 25.4).abs() < 1e-6, "A5 wide"); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.prefs.recent_new_docs[0].name, "Zine"); + } +} diff --git a/crates/ui-egui/src/dialogs/svg_options.rs b/crates/ui-egui/src/dialogs/svg_options.rs index 91ee5208d..3aaae46ce 100644 --- a/crates/ui-egui/src/dialogs/svg_options.rs +++ b/crates/ui-egui/src/dialogs/svg_options.rs @@ -72,6 +72,10 @@ fn first_use() -> Map { /// the document was last saved with). `path`: where a save goes. pub fn open(app: &mut VectorcraftApp, mode: Mode, path: Option<&str>) { let mut fields = first_use(); + // Fonts start from Document Setup → Type → Export. + if app.session.active().is_some_and(|st| st.doc.setup.export_text == vectorcraft_doc::ExportText::Appearance) { + fields.insert("outlineText".into(), json!(true)); + } let saved = app.session.active().map(|st| st.save_options.clone()).filter(|_| mode != Mode::Export); for last in [&app.ui.svg_options].into_iter().chain(saved.as_ref()) { if let Some(o) = last.as_object() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f994844c8..ee9853dc2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,8 @@ pub mod unsaved; pub mod widgets; pub mod workspaces; +#[cfg(test)] +mod tests_docsetup; #[cfg(test)] mod tests_labels; #[cfg(test)] @@ -252,9 +254,13 @@ impl VectorcraftApp { self.sync_views(); match &r { Err(e) => self.ui.status = e.clone(), - Ok(_) => { + Ok(v) => { if id == "file.new" { self.ui.status.clear(); + // New Document's Pixel preview mode (Overprint Preview is the engine's). + if v["previewMode"] == "pixel" { + self.ui.view.pixel_preview = true; + } } if id == "text.setStyle" && let Some(font) = params.get("font").and_then(Value::as_str) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9e2d04964..78e0a420d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -464,11 +464,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option app.run("document.exportForScreens", p.clone()), "file.export.png" => io::export(app, Some("png"), s("path"), p).map(|p| json!({"path": p})), - "file.documentSetup" => { - let units = app.session.active().map(|d| d.doc.units.label()).unwrap_or("Points"); - app.ui.dialog = Some(crate::state::Dialog::new("documentSetup", json!({"units": units}))); - Ok(Value::Null) - } + "file.documentSetup" => crate::dialogs::open_document_setup(app), "edit.preferences" => { crate::prefs_dialog::open(app, s("category").as_deref()); Ok(Value::Null) diff --git a/crates/ui-egui/src/tests_docsetup.rs b/crates/ui-egui/src/tests_docsetup.rs new file mode 100644 index 000000000..f3f462e8c --- /dev/null +++ b/crates/ui-egui/src/tests_docsetup.rs @@ -0,0 +1,109 @@ +//! The canvas draws Document Setup: the red bleed outline and the transparency grid in the +//! document's colours and cell size, also in a rotated view; a white Background Contents hides +//! the grid and Simulate Colored Paper tints the artboard. + +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::theme::Tokens; +use crate::{VectorcraftApp, canvas}; + +/// One headless canvas frame: its shapes, the transparency grid tile uploaded in it (2 × 2 +/// pixels: the grid colours) and the grid mesh, if any. +fn frame(app: &mut VectorcraftApp) -> (Vec, Option>, Option) { + let ctx = egui::Context::default(); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| canvas::show(app, ui)); + let tile = ctx.tex_manager().read().allocated().find(|(_, m)| m.name == canvas::TRANSPARENCY_GRID).map(|(id, _)| *id); + let pixels = tile.and_then(|id| out.textures_delta.set.iter().find(|(t, _)| **t == id)).and_then(|(_, deltas)| match &deltas.first()?.image { + egui::ImageData::Color(img) => Some(img.pixels.clone()), + }); + out.textures_delta.clear(); + let shapes: Vec = out.shapes.into_iter().map(|c| c.shape).collect(); + let mut all = vec![]; + flatten(&shapes, &mut all); + let mesh = all.into_iter().find_map(|s| match s { + egui::Shape::Mesh(m) if Some(m.texture_id) == tile => Some((*m).clone()), + _ => None, + }); + (shapes, pixels, mesh) +} + +fn flatten(shapes: &[egui::Shape], out: &mut Vec) { + for s in shapes { + match s { + egui::Shape::Vec(v) => flatten(v, out), + s => out.push(s.clone()), + } + } +} + +/// The closed outlines drawn in `color`: their corner points. +fn outlines(shapes: &[egui::Shape], color: egui::Color32) -> Vec> { + let mut all = vec![]; + flatten(shapes, &mut all); + all.into_iter() + .filter_map(|s| match s { + egui::Shape::Path(p) if p.closed && p.stroke.color == egui::epaint::ColorMode::Solid(color) => Some(p.points), + _ => None, + }) + .collect() +} + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app +} + +#[test] +fn the_bleed_draws_as_a_red_outline_around_each_artboard() { + let mut app = app(); + let red = Tokens::for_brightness(Default::default()).bleed; + assert!(outlines(&frame(&mut app).0, red).is_empty(), "no bleed, no outline"); + app.run("document.setup", json!({"bleed": [10, 10, 20, 20]})).unwrap(); + let (shapes, _, _) = frame(&mut app); + let found = outlines(&shapes, red); + assert_eq!(found.len(), 1); + // The artboard is 200 × 100 pt; the bleed grows it by 40 × 20 pt at the fitted zoom. + let ab = outlines(&shapes, egui::Color32::from_gray(0)).into_iter().next().unwrap(); + let span = |q: &[egui::Pos2]| (q[2].x - q[0].x, q[2].y - q[0].y); + let ((bw, bh), (aw, ah)) = (span(&found[0]), span(&ab)); + let zoom = aw / 200.0; + assert!((bw - 240.0 * zoom).abs() < 0.5 && (bh - 120.0 * zoom).abs() < 0.5 && (ah - 100.0 * zoom).abs() < 0.5, "{found:?} {ab:?}"); + // A rotated view turns the outline with the artboard. + app.view_mut().unwrap().rotation = 30.0; + let q = &outlines(&frame(&mut app).0, red)[0]; + assert!((q[1].y - q[0].y).abs() > 10.0, "the top edge is no longer horizontal: {q:?}"); +} + +#[test] +fn the_grid_takes_the_document_colours_also_rotated() { + let mut app = app(); + app.run("view.transparencyGrid", json!({"on": true})).unwrap(); + app.run("document.setup", json!({"gridColors": ["#ff0000", "#0000ff"], "gridSize": "large"})).unwrap(); + let (_, tile, _) = frame(&mut app); + let (red, blue) = (egui::Color32::from_rgb(255, 0, 0), egui::Color32::from_rgb(0, 0, 255)); + assert_eq!(tile, Some(vec![red, blue, blue, red])); + // Rotated: still one textured mesh over the artboard's turned corners. + app.view_mut().unwrap().rotation = 45.0; + let m = frame(&mut app).2.expect("the grid mesh"); + assert!((m.vertices[1].pos.y - m.vertices[0].pos.y).abs() > 10.0); + // Large cells are 16 screen points: the 200 pt artboard spans 200 × zoom / 32 tile repeats. + let zoom = app.view_mut().unwrap().zoom as f32; + assert!((m.vertices[1].uv.x - 200.0 * zoom / 32.0).abs() < 1e-3, "{:?}", m.vertices[1].uv); +} + +#[test] +fn white_background_hides_the_grid_and_paper_tints_the_artboard() { + let mut app = app(); + app.run("view.transparencyGrid", json!({"on": true})).unwrap(); + app.run("document.setup", json!({"backgroundContents": "white"})).unwrap(); + assert!(frame(&mut app).2.is_none()); + app.run("view.transparencyGrid", json!({"on": false})).unwrap(); + app.run("document.setup", json!({"simulatePaper": true, "gridColors": ["#ffeecc", "#cccccc"]})).unwrap(); + let mut all = vec![]; + flatten(&frame(&mut app).0, &mut all); + let paper = egui::Color32::from_rgb(0xff, 0xee, 0xcc); + assert!(all.iter().any(|s| matches!(s, egui::Shape::Path(p) if p.fill == paper)), "the artboard shows the paper colour"); +} diff --git a/crates/ui-egui/src/tests_transparencygrid.rs b/crates/ui-egui/src/tests_transparencygrid.rs index 2307429e4..0dbccaa92 100644 --- a/crates/ui-egui/src/tests_transparencygrid.rs +++ b/crates/ui-egui/src/tests_transparencygrid.rs @@ -6,20 +6,21 @@ use vectorcraft_engine::Session; use crate::{VectorcraftApp, canvas, menus}; -/// Whether one headless canvas frame paints the grid's grey squares. +/// Whether one headless canvas frame paints the grid (one mesh textured with its tile). fn draws_grid(app: &mut VectorcraftApp) -> bool { - fn grey(s: &egui::Shape) -> bool { - match s { - egui::Shape::Rect(r) => r.fill == egui::Color32::from_gray(204), - egui::Shape::Vec(v) => v.iter().any(grey), - _ => false, - } - } let ctx = egui::Context::default(); let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| canvas::show(app, ui)); out.textures_delta.clear(); - out.shapes.iter().any(|c| grey(&c.shape)) + let Some(tile) = ctx.tex_manager().read().allocated().find(|(_, m)| m.name == canvas::TRANSPARENCY_GRID).map(|(id, _)| *id) else { return false }; + fn uses(s: &egui::Shape, tile: egui::TextureId) -> bool { + match s { + egui::Shape::Mesh(m) => m.texture_id == tile, + egui::Shape::Vec(v) => v.iter().any(|s| uses(s, tile)), + _ => false, + } + } + out.shapes.iter().any(|c| uses(&c.shape, tile)) } #[test] diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a57a36e59..4eeb10231 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -83,6 +83,8 @@ pub struct Tokens { pub caption_close_text: Color32, /// What preview images mark (Flattener Preview highlights). pub highlight: Color32, + /// The bleed outline around artboards (Document Setup → Bleed). + pub bleed: Color32, } impl Tokens { @@ -124,6 +126,7 @@ impl Tokens { caption_close: hex(0xc42b1c), caption_close_text: Color32::WHITE, highlight: hex(0xff3030), + bleed: hex(0xf03030), }; match b { Brightness::Dark => base, diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index ad279eb42..54532a4fa 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1197,3 +1197,53 @@ pub fn harmony_wheel(ui: &mut Ui, id: &str, size: f32, colors: &[Color], base: O }); out } + +/// A row of text tabs, the current one underlined in the accent colour (New Document's +/// categories, Document Setup's sections). Returns the clicked tab's index. +pub fn tab_bar(ui: &mut Ui, tabs: &[&str], current: usize) -> Option { + let t = Tokens::get(ui.ctx()); + let mut clicked = None; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 18.0; + for (i, tab) in tabs.iter().enumerate() { + let sel = i == current; + let font = if sel { theme::semibold(13.0) } else { egui::FontId::proportional(13.0) }; + let galley = ui.painter().layout_no_wrap(tab.to_string(), font, t.text); + let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x, 30.0), Sense::click()); + let color = if sel || resp.hovered() { t.text_strong } else { t.text_dim }; + ui.painter().galley(pos2(rect.left(), rect.center().y - galley.size().y / 2.0 - 2.0), galley, color); + if sel { + ui.painter().rect_filled( + Rect::from_min_max(pos2(rect.left(), rect.bottom() - 2.0), rect.right_bottom()), + CornerRadius::same(1), + t.accent, + ); + } + if resp.clicked() { + clicked = Some(i); + } + } + }); + clicked +} + +/// A page-orientation toggle drawn in code: a portrait or landscape sheet with a folded corner, +/// accent-filled when `selected`. Returns clicked. +pub fn orientation_button(ui: &mut Ui, landscape: bool, selected: bool, tip: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::click()); + if resp.hovered() && !selected { + ui.painter().rect_filled(rect, CornerRadius::same(3), t.hover); + } + let size = if landscape { vec2(16.0, 12.0) } else { vec2(12.0, 16.0) }; + let page = Rect::from_center_size(rect.center(), size); + let color = if selected { t.accent } else { t.icon }; + let fold = 4.0; + let outline = + vec![page.left_top(), page.right_top() - vec2(fold, 0.0), page.right_top() + vec2(0.0, fold), page.right_bottom(), page.left_bottom()]; + let fill = if selected { t.accent } else { Color32::TRANSPARENT }; + ui.painter().add(egui::Shape::convex_polygon(outline.clone(), fill, Stroke::new(1.2, color))); + let corner = [page.right_top() - vec2(fold, 0.0), page.right_top() + vec2(-fold, fold), page.right_top() + vec2(0.0, fold)]; + ui.painter().add(egui::Shape::line(corner.to_vec(), Stroke::new(1.2, if selected { t.panel } else { color }))); + resp.on_hover_text(tip).clicked() +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index dd686d33e..0f6d016a5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -204,3 +204,20 @@ length preferences in `preferences`) show in the General unit (the active docume document). Through `ui.dialog.set` a number is in points and a string may carry its unit (`"10 mm"`; a bare number in a string is points). The `preferences` dialog's `unitsGeneral` starts as the active document's units, and OK sets them. `newDocument` has `units` (a unit label; print presets start in `unitsGeneral`, screen presets in Pixels). + +Document Setup: File → Document Setup… (`file.documentSetup`, Cmd+Alt+P) opens the `documentSetup` dialog. Its fields +are what `document.setup` reports (`units`, `bleed` [top, bottom, left, right] in pt, `outlineImages`, +`highlightSubstitutedFonts`, `highlightSubstitutedGlyphs`, `gridSize`, `gridColors`, `simulatePaper`, `flattenerPreset`, +`discardWhiteOverprint`, `language`, `quotes`, `typographersQuotes`, `superscript`, `subscript`, `smallCapsSize`, +`exportText`, `backgroundContents`) plus `tab` (`General`, `Transparency` or `Type`) and `bleedLinked`. +`ui.dialog.confirm` runs `document.setup` with them as one undo step; a bad value answers with an error and the dialog +stays open. + +New Document: File → New… (`file.newDialog`, Cmd+N) opens the `newDocument` dialog. Its fields are `file.new`'s params +(`preset`, `name`, `width` and `height` in pt, `units`, `artboards`, `artboardLayout`, `bleed`, `backgroundContents`, +`colorMode`, `rasterEffectsPpi`, `previewMode`) plus `category` (the tab: Recent, Saved, Mobile, Web, Print, Film & +Video, Art & Illustration, Branding, Social), `advanced` (Advanced Options open), `bleedLinked` and `presetName` +(Save Preset's name). Setting `preset` to a name from `file.newPresets` fills the other fields from that preset; +changing a field afterwards makes the settings custom (`preset` becomes ""). More Settings is the `newDocumentMore` +dialog over the same fields (its Profile is `category`). `ui.dialog.confirm` runs `file.new` with the fields (with +`previewMode: "pixel"` the app turns Pixel Preview on); on an error the dialog stays open. diff --git a/docs/mcp.md b/docs/mcp.md index f1beb4426..896334e44 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -779,3 +779,30 @@ a string with a unit. Unit names: `Points`, `Picas`, `Inches`, `Millimeters`, `C ```json {"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"unitsGeneral","value":"millimeters"}}} ``` + +## Document Setup + +`document.setup` with no params reports the document setup: units, the bleed ([top, bottom, left, right] in pt, drawn +as a red outline around each artboard), the transparency grid (size and two colours; the first is also the simulated +paper colour), the flattener preset and Discard White Overprint (Overprint Preview keeps white overprints visible while +it is on), the type options (language and its quotes, Use Typographer's Quotes for typed quotes, superscript, subscript +and small caps proportions, SVG text export) and the background contents (white: raster exports are white behind the +art). Pass any of these keys to change them in one undo step; `document.setUnits` stays as an alias for `units`. + +```json +{"name":"run_command","arguments":{"command":"document.setup","params":{"gridColors":"Blue","gridSize":"large","language":"German","bleed":9}}} +``` + +## New Document + +`file.new` takes everything New Document sets: `preset` (a name from `file.newPresets`), `name`, `width`/`height` +(points, or lengths such as `"210 mm"`), `units`, `orientation`, `artboards` with `artboardLayout {layout, columns, +spacing, rightToLeft}`, `bleed`, `backgroundContents`, `colorMode`, `rasterEffectsPpi` and `previewMode`. +`file.newPresets {category?}` lists the categories and presets (Recent: the last sizes used; Saved: the user's presets, +kept in the preferences by `file.newPresets.save` and removed by `file.newPresets.delete`). A listed preset can be +passed straight back to `file.new`. Print presets and sizes without `units` start in `unitsGeneral`; screen presets +(mobile, web, video, social) in Pixels. + +```json +{"name":"run_command","arguments":{"command":"file.new","params":{"preset":"A4","orientation":"landscape","artboards":4,"artboardLayout":{"columns":2},"bleed":9}}} +```