vectorcraft/crates/engine/src/cmd/docsetup.rs

296 lines
14 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.

//! 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<CommandSpec> {
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::<String>();
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<Value> {
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<bool> {
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<f64> {
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::<Vec<_>>().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<Option<String>> {
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<Background> {
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::<Vec<_>>().join(", ")))
});
}
match v.as_array().map(|a| a.iter().map(color_value).collect::<Option<Vec<_>>>()) {
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<char> = 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::<Option<_>>()?,
_ => 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<Quotes> {
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<ScriptMetrics> {
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<CharPosition>, Option<Option<f64>>)> {
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::<CharPosition>(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);
}
}
}