//! 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 { crate::marks::outset(artboard, self.bleed) } 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() }); } }