M4.20: Document Setup with bleed, transparency grid, paper and type options

File > Document Setup is a tabbed dialog over one engine command,
document.setup (no params: a query; any keys: one undo step;
document.setUnits stays as the units alias).

- General: units, bleed (four sides with a link toggle), Edit Artboards,
  Show Images in Outline Mode (greyscale pixels under the frame),
  Highlight Substituted Fonts/Glyphs (pink boxes on screen only).
- Transparency: grid size and colour presets or custom colours, Simulate
  Colored Paper, the flattener preset (built-in or saved) and Discard
  White Overprint (Overprint Preview keeps white overprints visible).
- Type: language with its quotes, Use Typographer's Quotes for the Type
  tool and Smart Punctuation, superscript/subscript size and position and
  small caps size (Character panel position/smallCaps; changing the
  setup restyles the text in the same undo step), text export (SVG text
  as text or outlines by default).
- The canvas draws a red bleed outline and the grid in the document's
  colours and cell size as one textured quad, also in rotated views; a
  white Background Contents exports raster formats opaque.

Model: Document.setup (DocSetup, defaulted, skipped when default) and
CharStyle position/small_caps; old files load unchanged.
tests_transparencygrid's helper now finds the grid by its texture (the
grid is one textured mesh instead of per-cell rectangles).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 08:28:16 +07:00
co-authored by Claude Opus 5.5
parent 25df58e722
commit d527349f5a
32 changed files with 1804 additions and 129 deletions
+295
View File
@@ -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<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);
}
}
}