vectorcraft/crates/doc/src/setup.rs

318 lines
12 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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> {
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<Self> {
[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<char>) -> 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<char>) -> 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<Quotes> {
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> {
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<String>,
/// 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() });
}
}