From d527349f5a02c1ddc6cf7f4d294e054e24a3a790 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 08:28:16 +0700 Subject: [PATCH] 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 --- crates/doc/src/lib.rs | 47 ++- crates/doc/src/setup.rs | 318 +++++++++++++++++++ crates/doc/src/text.rs | 58 ++++ crates/engine/src/cmd/docsetup.rs | 295 +++++++++++++++++ crates/engine/src/cmd/fileio/encode.rs | 4 +- crates/engine/src/cmd/fileio/svg.rs | 6 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/panelcmds.rs | 11 +- crates/engine/src/cmd/textedit.rs | 20 +- crates/engine/src/cmd/typemenu.rs | 49 +-- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_docsetup.rs | 266 ++++++++++++++++ crates/render/src/lib.rs | 98 +++++- crates/render/src/proof.rs | 29 +- crates/render/src/tests_setup.rs | 33 ++ crates/text/src/lib.rs | 2 + crates/text/src/shape.rs | 67 ++-- crates/text/src/tests_scripts.rs | 50 +++ crates/tools/src/text.rs | 10 + crates/ui-egui/src/canvas.rs | 63 +++- crates/ui-egui/src/dialogs/document_setup.rs | 257 ++++++++++++++- crates/ui-egui/src/dialogs/form.rs | 52 +++ crates/ui-egui/src/dialogs/mod.rs | 1 + crates/ui-egui/src/dialogs/svg_options.rs | 4 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 6 +- crates/ui-egui/src/tests_docsetup.rs | 109 +++++++ crates/ui-egui/src/tests_transparencygrid.rs | 19 +- crates/ui-egui/src/theme.rs | 3 + crates/ui-egui/src/widgets.rs | 29 ++ docs/control-protocol.md | 8 + docs/mcp.md | 13 + 32 files changed, 1804 insertions(+), 129 deletions(-) create mode 100644 crates/doc/src/setup.rs create mode 100644 crates/engine/src/cmd/docsetup.rs create mode 100644 crates/engine/src/tests_docsetup.rs create mode 100644 crates/render/src/tests_setup.rs create mode 100644 crates/text/src/tests_scripts.rs create mode 100644 crates/ui-egui/src/tests_docsetup.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 5b51fa76..b085c392 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 00000000..ee92f745 --- /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 010c2647..387c1414 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 00000000..8244b8c4 --- /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/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index f8c67488..eedd9917 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 db203277..56be86ae 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/mod.rs b/crates/engine/src/cmd/mod.rs index 45a2f159..3c2cb376 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; @@ -211,6 +212,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(newart::specs()); v.extend(colortheme::specs()); v.extend(fileio::pdf::specs()); + v.extend(docsetup::specs()); v }) } diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 14d02b9a..36efb5fd 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 e284fb2e..aa3cd500 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 57105635..f23324e9 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 9b9b1979..02063d0d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -909,6 +909,8 @@ mod tests_dashalign; #[cfg(test)] mod tests_distort; #[cfg(test)] +mod tests_docsetup; +#[cfg(test)] mod tests_draw2; #[cfg(test)] mod tests_editcolors; diff --git a/crates/engine/src/tests_docsetup.rs b/crates/engine/src/tests_docsetup.rs new file mode 100644 index 00000000..f937bf40 --- /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/render/src/lib.rs b/crates/render/src/lib.rs index 3dd41cf5..d9697d6e 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 9a9a2515..01921895 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 00000000..00b10cd7 --- /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 64a76205..c3fb1bdc 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 49c287d4..ba3b4940 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 00000000..1ba84a22 --- /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 c48ca408..e21e6dfa 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 8e080e96..8f6574e5 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) { diff --git a/crates/ui-egui/src/dialogs/document_setup.rs b/crates/ui-egui/src/dialogs/document_setup.rs index 273a5bb7..0319715d 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 23851e48..c4bba538 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -270,3 +270,55 @@ 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)); + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 9c0c4235..d53cfe34 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -47,6 +47,7 @@ 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 save_pdf::open as open_save_pdf; diff --git a/crates/ui-egui/src/dialogs/svg_options.rs b/crates/ui-egui/src/dialogs/svg_options.rs index 91ee5208..3aaae46c 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 f994844c..9ebdafab 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)] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9e2d0496..78e0a420 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 00000000..f3f462e8 --- /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 2307429e..0dbccaa9 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 a57a36e5..4eeb1023 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 ad279eb4..3e092f9d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1197,3 +1197,32 @@ 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 +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index dd686d33..020145b1 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -204,3 +204,11 @@ 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. diff --git a/docs/mcp.md b/docs/mcp.md index f1beb442..d7cab7a5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -779,3 +779,16 @@ 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}}} +```