Merge pull request #69 from storytold/pr/m4-document-setup-new-document

M4: Document Setup depth; New Document with preset categories, details column and More Settings (M4.20, M4.21)
This commit is contained in:
BFlat
2026-10-05 08:41:16 +07:00
committed by GitHub
37 changed files with 3380 additions and 277 deletions
+45 -2
View File
@@ -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::<CharStyle>(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));
+318
View File
@@ -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> {
Self::ALL.into_iter().find(|g| g.id().eq_ignore_ascii_case(s))
}
/// Edge of one grid cell in screen points (the grid does not scale with the zoom).
pub fn cell(self) -> f32 {
match self {
GridSize::Small => 4.0,
GridSize::Medium => 8.0,
GridSize::Large => 16.0,
}
}
}
const fn hex(v: u32) -> Color {
Color::Rgb { r: ((v >> 16) & 0xff) as f32 / 255.0, g: ((v >> 8) & 0xff) as f32 / 255.0, b: (v & 0xff) as f32 / 255.0 }
}
/// Transparency grid colour presets (VectorCraft's own pairs). Any other pair is "Custom".
pub const GRID_COLOR_PRESETS: &[(&str, [Color; 2])] = &[
("Light", [hex(0xffffff), hex(0xcccccc)]),
("Medium", [hex(0xb3b3b3), hex(0x8c8c8c)]),
("Dark", [hex(0x666666), hex(0x4d4d4d)]),
("Red", [hex(0xffffff), hex(0xffc7c7)]),
("Orange", [hex(0xffffff), hex(0xffdcb8)]),
("Green", [hex(0xffffff), hex(0xc6efc6)]),
("Blue", [hex(0xffffff), hex(0xc4dcff)]),
("Purple", [hex(0xffffff), hex(0xe2c9ff)]),
];
/// How text is written to formats without live type (Document Setup → Type → Export).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ExportText {
/// Keep text as text (editable).
#[default]
Editable,
/// Write glyph outlines (exact appearance without the fonts).
Appearance,
}
impl ExportText {
pub fn id(self) -> &'static str {
match self {
ExportText::Editable => "editable",
ExportText::Appearance => "appearance",
}
}
pub fn parse(s: &str) -> Option<Self> {
[ExportText::Editable, ExportText::Appearance].into_iter().find(|e| e.id().eq_ignore_ascii_case(s))
}
}
/// Quote characters (Document Setup → Type): opening and closing double and single quotes.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Quotes {
pub double: [char; 2],
pub single: [char; 2],
}
impl Default for Quotes {
fn default() -> Self {
Self::ENGLISH
}
}
impl Quotes {
pub const ENGLISH: Self = Self { double: ['“', '”'], single: ['‘', '’'] };
/// Double-quote pairs offered by Document Setup.
pub const DOUBLE_CHOICES: &[[char; 2]] = &[['“', '”'], ['„', '“'], ['„', '”'], ['”', '”'], ['«', '»'], ['»', '«'], ['「', '」'], ['"', '"']];
/// Single-quote pairs offered by Document Setup.
pub const SINGLE_CHOICES: &[[char; 2]] = &[['‘', '’'], ['‚', '‘'], ['‚', '’'], ['’', '’'], ['‹', '›'], ['›', '‹'], ['『', '』'], ['\'', '\'']];
/// Is `c` one of these opening quotes?
fn is_open(&self, c: char) -> bool {
c == self.double[0] || c == self.single[0]
}
/// A quote opens after nothing, whitespace, an opening bracket, a dash or another opening quote.
fn opens(&self, prev: Option<char>) -> bool {
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || self.is_open(p))
}
/// `text` with straight quotes (`"` and `'`) replaced by these quotes: opening or closing by
/// context (an apostrophe is a closing single quote). `prev` is the character before `text`,
/// and is updated to its last character, so text can be converted in pieces.
pub fn apply(&self, text: &str, prev: &mut Option<char>) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
let r = match c {
'"' => self.double[usize::from(!self.opens(*prev))],
'\'' => self.single[usize::from(!self.opens(*prev))],
c => c,
};
out.push(r);
*prev = Some(r);
}
out
}
}
/// Document languages and the quote characters each uses (choosing a language in Document Setup
/// also picks its quotes).
pub const LANGUAGES: &[(&str, Quotes)] = &[
("English: USA", Quotes::ENGLISH),
("English: UK", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("German", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
("German: Swiss", Quotes { double: ['«', '»'], single: ['‹', '›'] }),
("French", Quotes { double: ['«', '»'], single: ['‹', '›'] }),
("Italian", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Spanish", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Portuguese", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Dutch", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("Danish", Quotes { double: ['»', '«'], single: ['›', '‹'] }),
("Swedish", Quotes { double: ['”', '”'], single: ['’', '’'] }),
("Finnish", Quotes { double: ['”', '”'], single: ['’', '’'] }),
("Norwegian", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Polish", Quotes { double: ['„', '”'], single: ['‚', '’'] }),
("Czech", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
("Russian", Quotes { double: ['«', '»'], single: ['„', '“'] }),
("Japanese", Quotes { double: ['「', '」'], single: ['『', '』'] }),
("Chinese", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
];
/// The quotes of a [`LANGUAGES`] entry (case-insensitive).
pub fn language_quotes(language: &str) -> Option<Quotes> {
LANGUAGES.iter().find(|(l, _)| l.eq_ignore_ascii_case(language)).map(|(_, q)| *q)
}
/// The transparency flattener preset Document Setup uses unless another is chosen.
pub const DEFAULT_FLATTENER: &str = "Medium Resolution";
/// Background Contents (New Document): what shows behind the art on the artboards, and what
/// raster exports put there.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Background {
#[default]
Transparent,
/// A white artboard background (not an object): raster exports are opaque white there.
White,
}
impl Background {
pub const ALL: [Background; 2] = [Background::Transparent, Background::White];
pub fn id(self) -> &'static str {
match self {
Background::Transparent => "transparent",
Background::White => "white",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|b| b.id().eq_ignore_ascii_case(s))
}
}
/// Largest bleed on any side, in points (1 inch).
pub const MAX_BLEED: f64 = 72.0;
/// File → Document Setup.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DocSetup {
/// Bleed beyond every artboard edge in points: top, bottom, left, right.
pub bleed: [f64; 4],
/// Show Images in Outline Mode.
pub outline_images: bool,
/// Highlight text whose font is missing (drawn in a substitute font).
pub highlight_substituted_fonts: bool,
/// Highlight glyphs the font lacks (drawn from a fallback font).
pub highlight_substituted_glyphs: bool,
/// Transparency grid cell size.
pub grid_size: GridSize,
/// Transparency grid colours; the first is also the simulated paper colour.
pub grid_colors: [Color; 2],
/// Simulate Colored Paper: artboards show the paper colour and art is multiplied onto it.
pub simulate_paper: bool,
/// Transparency flattener preset for output that can't keep transparency (None =
/// [`DEFAULT_FLATTENER`]).
pub flattener_preset: Option<String>,
/// Discard White Overprint in Output.
pub discard_white_overprint: bool,
/// Document language (one of [`LANGUAGES`]).
pub language: String,
pub quotes: Quotes,
/// Use Typographer's Quotes: typed straight quotes become [`DocSetup::quotes`].
pub typographers_quotes: bool,
pub superscript: ScriptMetrics,
pub subscript: ScriptMetrics,
/// Synthesized small capitals, percent of the font size.
pub small_caps_size: f64,
pub export_text: ExportText,
/// Background Contents chosen in New Document.
pub background: Background,
}
impl Default for DocSetup {
fn default() -> Self {
Self {
bleed: [0.0; 4],
outline_images: false,
highlight_substituted_fonts: true,
highlight_substituted_glyphs: false,
grid_size: GridSize::Medium,
grid_colors: GRID_COLOR_PRESETS[0].1,
simulate_paper: false,
flattener_preset: None,
discard_white_overprint: true,
language: LANGUAGES[0].0.to_string(),
quotes: Quotes::ENGLISH,
typographers_quotes: true,
superscript: ScriptMetrics::DEFAULT,
subscript: ScriptMetrics::DEFAULT,
small_caps_size: 70.0,
export_text: ExportText::Editable,
background: Background::Transparent,
}
}
}
impl DocSetup {
/// `artboard` grown by the bleed.
pub fn bleed_rect(&self, artboard: Rect) -> Rect {
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() });
}
}
+58
View File
@@ -76,6 +76,62 @@ pub struct CharStyle {
pub stroke_miter_limit: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stroke_dash: Option<Dash>,
/// 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<f64>,
}
/// 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,
}
}
}
+295
View File
@@ -0,0 +1,295 @@
//! File → Document Setup (`document.setup`): units, bleed, the transparency grid and simulated
//! paper, output options and the document's type options.
use serde_json::{Map, Value, json};
use vectorcraft_doc::setup::{DEFAULT_FLATTENER, GRID_COLOR_PRESETS, LANGUAGES, MAX_BLEED};
use vectorcraft_doc::{Background, CharPosition, CharStyle, DocSetup, Document, ExportText, GridSize, NodeKind, Quotes, ScriptMetrics};
use super::flatten::{FlattenOptions, FlattenerPreset};
use super::*;
const C: &str = "document.setup";
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"document.setup",
"Document Setup",
[],
None,
"{units?: \"Points\"|\"Inches\"|\"Millimeters\"|…, bleed?: pt (all sides)|[top, bottom, left, right]|{top?, bottom?, left?, right?} (0–72 pt), outlineImages?: bool (show images in Outline mode), highlightSubstitutedFonts?: bool, highlightSubstitutedGlyphs?: bool, gridSize?: \"small\"|\"medium\"|\"large\", gridColors?: \"Light\"|\"Medium\"|\"Dark\"|\"Red\"|\"Orange\"|\"Green\"|\"Blue\"|\"Purple\"|[colour, colour] (transparency grid; the first is also the paper colour), simulatePaper?: bool, flattenerPreset?: \"High Resolution\"|\"Medium Resolution\"|\"Low Resolution\"|a saved preset (flattener.presets.list), discardWhiteOverprint?: bool, language?: \"English: USA\"|\"German\"|\"French\"|… (also picks its quotes), quotes?: {double?: \"“”\", single?: \"‘’\"} (beside a language: only if they differ from the current ones), typographersQuotes?: bool (typed straight quotes become the document's quotes), superscript?: {size?: %, position?: %}, subscript?: {size?: %, position?: %}, smallCapsSize?: %, exportText?: \"editable\"|\"appearance\" (SVG text as text or as outlines), backgroundContents?: \"transparent\"|\"white\" (white: raster exports are white behind the art)} change the document setup in one undo step; no params → the current setup",
has_doc,
setup
)]
}
/// The setup as `document.setup` reports it (and accepts it back).
pub(crate) fn setup_json(d: &Document) -> Value {
let s = &d.setup;
let pair = |p: [char; 2]| p.iter().collect::<String>();
json!({
"units": d.units.label(),
"bleed": s.bleed,
"outlineImages": s.outline_images,
"highlightSubstitutedFonts": s.highlight_substituted_fonts,
"highlightSubstitutedGlyphs": s.highlight_substituted_glyphs,
"gridSize": s.grid_size.id(),
"gridColors": s.grid_colors.map(|c| c.to_hex()),
"gridColorsName": s.grid_colors_name(),
"simulatePaper": s.simulate_paper,
"flattenerPreset": s.flattener(),
"discardWhiteOverprint": s.discard_white_overprint,
"language": s.language,
"quotes": {"double": pair(s.quotes.double), "single": pair(s.quotes.single)},
"typographersQuotes": s.typographers_quotes,
"superscript": s.superscript,
"subscript": s.subscript,
"smallCapsSize": s.small_caps_size,
"exportText": s.export_text.id(),
"backgroundContents": s.background.id(),
})
}
fn setup(s: &mut Session, p: &Value) -> Result<Value> {
let empty = Map::new();
let o = match p {
Value::Null => &empty,
Value::Object(o) => o,
_ => return Err(bad(C, "params must be an object")),
};
let st = s.doc()?;
if o.is_empty() {
return Ok(setup_json(&st.doc));
}
let mut new = st.doc.setup.clone();
let mut units = st.doc.units;
// The language goes first: it picks its quotes, which `quotes` may then override.
let current = st.doc.setup.quotes;
for (k, v) in o.iter().filter(|(k, _)| *k == "language").chain(o.iter().filter(|(k, _)| *k != "language")) {
// With a language, unchanged quotes (a report fed back) don't undo the language's quotes.
if k == "quotes" && o.contains_key("language") && quotes_param(v, current)? == current {
continue;
}
apply(&mut new, &mut units, k, v, &s.prefs.flattener_presets)?;
}
let st = s.doc()?;
if new == st.doc.setup && units == st.doc.units {
return Ok(setup_json(&st.doc));
}
s.edit("Document Setup", |d, _| {
let scripts_changed =
(new.superscript, new.subscript, new.small_caps_size) != (d.setup.superscript, d.setup.subscript, d.setup.small_caps_size);
d.setup = new;
d.units = units;
if scripts_changed {
restamp_scripts(d);
}
Ok(())
})?;
Ok(setup_json(&s.doc()?.doc))
}
fn flag(k: &str, v: &Value) -> Result<bool> {
v.as_bool().ok_or_else(|| bad(C, format!("{k} must be true or false")))
}
fn percent(k: &str, v: &Value, min: f64, max: f64) -> Result<f64> {
v.as_f64().filter(|x| (min..=max).contains(x)).ok_or_else(|| bad(C, format!("{k} must be a number from {min} to {max}")))
}
fn apply(s: &mut DocSetup, units: &mut vectorcraft_doc::Unit, k: &str, v: &Value, saved: &[FlattenerPreset]) -> Result<()> {
match k {
"units" => *units = v.as_str().and_then(vectorcraft_doc::Unit::named).ok_or_else(|| bad(C, "unknown units"))?,
"bleed" => s.bleed = bleed_param(v, s.bleed, C)?,
"outlineImages" => s.outline_images = flag(k, v)?,
"highlightSubstitutedFonts" => s.highlight_substituted_fonts = flag(k, v)?,
"highlightSubstitutedGlyphs" => s.highlight_substituted_glyphs = flag(k, v)?,
"gridSize" => s.grid_size = v.as_str().and_then(GridSize::parse).ok_or_else(|| bad(C, "gridSize must be small, medium or large"))?,
"gridColors" => s.grid_colors = grid_colors(v)?,
"simulatePaper" => s.simulate_paper = flag(k, v)?,
"flattenerPreset" => s.flattener_preset = flattener_preset(v, saved)?,
"discardWhiteOverprint" => s.discard_white_overprint = flag(k, v)?,
"language" => {
let l = v.as_str().unwrap_or("");
let (name, quotes) = LANGUAGES.iter().find(|(n, _)| n.eq_ignore_ascii_case(l)).ok_or_else(|| {
bad(C, format!("unknown language `{l}` (one of {})", LANGUAGES.iter().map(|(n, _)| *n).collect::<Vec<_>>().join(", ")))
})?;
s.language = name.to_string();
s.quotes = *quotes;
}
"quotes" => s.quotes = quotes_param(v, s.quotes)?,
"typographersQuotes" => s.typographers_quotes = flag(k, v)?,
"superscript" => s.superscript = script_param(k, v, s.superscript)?,
"subscript" => s.subscript = script_param(k, v, s.subscript)?,
"smallCapsSize" => s.small_caps_size = percent(k, v, 1.0, 200.0)?,
"exportText" => s.export_text = v.as_str().and_then(ExportText::parse).ok_or_else(|| bad(C, "exportText must be editable or appearance"))?,
"backgroundContents" => s.background = background_param(v, C)?,
// Read-only values `document.setup` reports.
"gridColorsName" => {}
_ => return Err(bad(C, format!("unknown setting `{k}`"))),
}
Ok(())
}
/// A built-in flattener preset (by name or id) or a saved one, stored by its display name (None:
/// the default preset).
fn flattener_preset(v: &Value, saved: &[FlattenerPreset]) -> Result<Option<String>> {
let name = v.as_str().unwrap_or("").trim();
let builtin = FlattenOptions::PRESETS
.into_iter()
.filter_map(|id| Some((id, FlattenOptions::preset_label(id)?)))
.find(|(id, label)| id.eq_ignore_ascii_case(name) || label.eq_ignore_ascii_case(name))
.map(|(_, label)| label);
let found = builtin.or_else(|| saved.iter().map(|p| p.name.as_str()).find(|n| n.eq_ignore_ascii_case(name))).ok_or_else(|| {
bad(
C,
format!(
"unknown flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution or a saved one: see flattener.presets.list)"
),
)
})?;
Ok((found != DEFAULT_FLATTENER).then(|| found.to_string()))
}
/// `"transparent"` or `"white"`.
pub(crate) fn background_param(v: &Value, cmd: &str) -> Result<Background> {
v.as_str().and_then(Background::parse).ok_or_else(|| bad(cmd, "backgroundContents must be transparent or white"))
}
/// `pt` (every side), `[top, bottom, left, right]` or `{top?, bottom?, left?, right?}`.
pub(crate) fn bleed_param(v: &Value, current: [f64; 4], cmd: &str) -> Result<[f64; 4]> {
let side = |x: &Value| x.as_f64().filter(|b| (0.0..=MAX_BLEED).contains(b));
let err = || bad(cmd, format!("bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}}"));
match v {
Value::Number(_) => side(v).map(|b| [b; 4]).ok_or_else(err),
Value::Array(a) if a.len() == 4 => {
let mut out = [0.0; 4];
for (o, x) in out.iter_mut().zip(a) {
*o = side(x).ok_or_else(err)?;
}
Ok(out)
}
Value::Object(o) => {
let mut out = current;
for (k, x) in o {
let i = ["top", "bottom", "left", "right"].iter().position(|s| s == k).ok_or_else(err)?;
out[i] = side(x).ok_or_else(err)?;
}
Ok(out)
}
_ => Err(err()),
}
}
fn grid_colors(v: &Value) -> Result<[vectorcraft_color::Color; 2]> {
if let Some(name) = v.as_str() {
return GRID_COLOR_PRESETS.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, c)| *c).ok_or_else(|| {
bad(C, format!("unknown grid colours `{name}` (one of {})", GRID_COLOR_PRESETS.iter().map(|(n, _)| *n).collect::<Vec<_>>().join(", ")))
});
}
match v.as_array().map(|a| a.iter().map(color_value).collect::<Option<Vec<_>>>()) {
Some(Some(c)) if c.len() == 2 => Ok([c[0], c[1]]),
_ => Err(bad(C, "gridColors must be a preset name or two colours")),
}
}
/// A quote pair: a two-character string or two one-character strings.
fn quote_pair(v: &Value) -> Option<[char; 2]> {
let chars: Vec<char> = match v {
Value::String(s) => s.chars().collect(),
Value::Array(a) => a.iter().map(|x| x.as_str().and_then(|s| s.chars().next().filter(|_| s.chars().count() == 1))).collect::<Option<_>>()?,
_ => return None,
};
match chars[..] {
[a, b] if !a.is_control() && !b.is_control() && !a.is_whitespace() && !b.is_whitespace() => Some([a, b]),
_ => None,
}
}
fn quotes_param(v: &Value, mut q: Quotes) -> Result<Quotes> {
let o = v.as_object().ok_or_else(|| bad(C, "quotes must be {double?, single?}"))?;
for (k, x) in o {
let pair = quote_pair(x).ok_or_else(|| bad(C, format!("quotes.{k} must be an opening and a closing quote, e.g. \"“”\"")))?;
match k.as_str() {
"double" => q.double = pair,
"single" => q.single = pair,
_ => return Err(bad(C, format!("unknown quotes key `{k}` (double, single)"))),
}
}
Ok(q)
}
fn script_param(k: &str, v: &Value, mut m: ScriptMetrics) -> Result<ScriptMetrics> {
let o = v.as_object().ok_or_else(|| bad(C, format!("{k} must be {{size?, position?}}")))?;
for (key, x) in o {
match key.as_str() {
"size" => m.size = percent(&format!("{k}.size"), x, 1.0, 500.0)?,
"position" => m.position = percent(&format!("{k}.position"), x, -500.0, 500.0)?,
_ => return Err(bad(C, format!("unknown {k} key `{key}` (size, position)"))),
}
}
Ok(m)
}
/// `position?: "normal"|"superscript"|"subscript"` and `smallCaps?: bool` from a character
/// command's params, in the document's Document Setup proportions.
pub(crate) fn script_params(p: &Value, setup: &DocSetup, cmd: &str) -> Result<(Option<CharPosition>, Option<Option<f64>>)> {
let position = match p.get("position").filter(|v| !v.is_null()) {
None => None,
Some(v) => Some(match v.as_str().map(str::to_ascii_lowercase).as_deref() {
Some("normal") => CharPosition::Normal,
Some("superscript") => CharPosition::Superscript(setup.superscript),
Some("subscript") => CharPosition::Subscript(setup.subscript),
_ => return Err(bad(cmd, "position must be normal, superscript or subscript")),
}),
};
let small_caps = match p.get("smallCaps").filter(|v| !v.is_null()) {
None => None,
Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "smallCaps must be true or false"))?.then_some(setup.small_caps_size)),
};
Ok((position, small_caps))
}
/// A superscript or subscript in `setup`'s proportions.
fn position_in(setup: &DocSetup, p: CharPosition) -> CharPosition {
match p {
CharPosition::Normal => CharPosition::Normal,
CharPosition::Superscript(_) => CharPosition::Superscript(setup.superscript),
CharPosition::Subscript(_) => CharPosition::Subscript(setup.subscript),
}
}
/// After the superscript, subscript or small caps proportions changed: every run (and character
/// style) using them takes the new ones.
fn restamp_scripts(d: &mut Document) {
let uses = |st: &CharStyle| st.position != CharPosition::Normal || st.small_caps.is_some();
let mut ids = vec![];
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind
&& t.runs.iter().any(|r| uses(&r.style))
{
ids.push(n.id);
}
});
let setup = d.setup.clone();
for id in ids {
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
for st in t.runs.iter_mut().map(|r| &mut r.style).filter(|st| uses(st)) {
st.position = position_in(&setup, st.position);
st.small_caps = st.small_caps.map(|_| setup.small_caps_size);
}
super::typecmd::refresh_bounds(t);
}
}
// Character styles store the attributes they set by their serialized names.
for def in &mut d.char_styles {
if let Some(v) = def.attrs.get_mut("position")
&& let Ok(p) = serde_json::from_value::<CharPosition>(v.clone())
{
*v = serde_json::to_value(position_in(&setup, p)).unwrap_or(Value::Null);
}
if let Some(v) = def.attrs.get_mut("small_caps").filter(|v| v.is_number()) {
*v = json!(setup.small_caps_size);
}
}
}
+4 -34
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, Unit};
use vectorcraft_geom::{Affine, Vec2};
use vectorcraft_geom::Affine;
use super::*;
use crate::{EngineError, inspect};
@@ -16,9 +16,9 @@ pub fn specs() -> Vec<CommandSpec> {
"New…",
["File"],
Some("Cmd+N"),
"{width?: pt=612, height?: pt=792, units?: \"Points\"|\"Inches\"|\"Millimeters\"|\"Pixels\"… (default: prefs unitsGeneral), title?, artboards?: n, colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK)}",
"{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both)} → {index, previewMode}; the size is remembered in file.newPresets' Recent",
always,
file_new
super::newdoc::file_new
),
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close),
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
@@ -52,37 +52,6 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
let w = f64_or(p, "width", 612.0);
let h = f64_or(p, "height", 792.0);
if !(w > 0.0 && h > 0.0) || w > 16383.0 * 10.0 || h > 16383.0 * 10.0 {
return Err(bad("file.new", "width/height must be positive and within the canvas"));
}
let mode = match str_param(p, "colorMode") {
Some(m) if m.eq_ignore_ascii_case("cmyk") => vectorcraft_doc::ColorMode::Cmyk,
_ => vectorcraft_doc::ColorMode::Rgb,
};
let mut d = Document::new_with_mode(w, h, mode);
d.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| s.next_untitled());
d.units = match str_param(p, "units") {
Some(u) => Unit::named(u).ok_or_else(|| bad("file.new", format!("unknown units `{u}`")))?,
None => s.default_units(),
};
let n = p.get("artboards").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize;
for i in 1..n {
let r = vectorcraft_geom::Rect::new(0.0, 0.0, w, h) + Vec2::new(i as f64 * (w + 20.0), 0.0);
d.artboards.push(vectorcraft_doc::Artboard {
id: (i + 1) as u32,
name: format!("Artboard {}", i + 1),
rect: r,
show_center_mark: false,
show_cross_hairs: false,
});
}
let i = s.add_document(d, None);
Ok(json!({ "index": i }))
}
fn file_close(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?;
if !s.close_document(i) {
@@ -102,6 +71,7 @@ fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
Ok(serde_json::to_value(n).unwrap_or(Value::Null))
}
/// Document Setup's units (also `document.setup {units}`).
fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
let u = str_param(p, "units").and_then(Unit::named).ok_or_else(|| bad("document.setUnits", "unknown units"))?;
s.set_document_units(u)?;
+3 -1
View File
@@ -176,7 +176,9 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
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(),
+5 -1
View File
@@ -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 <text>",
description: "Fonts: text as glyph outlines (viewable without the fonts) instead of <text>; default: Document Setup → Type → Export (Preserve Text Appearance: true)",
},
FormatOption {
name: "images",
@@ -99,6 +99,10 @@ pub fn options_map(p: &Value) -> Result<Map<String, Value>, 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<Option<usize>>), 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<String, Value> = 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}"))?;
+3 -21
View File
@@ -679,27 +679,9 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Rearrange Artboards", |d, _| {
let rects: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
let Some(first) = rects.first().copied() else { return Ok(()) };
let n = rects.len();
let rows = n.div_ceil(cols);
// Grid cell (row, col) of artboard i.
let cell = |i: usize| if by_col { (i % rows, i / rows) } else { (i / cols, i % cols) };
let mut col_w = vec![0.0f64; cols];
let mut row_h = vec![0.0f64; rows];
for (i, r) in rects.iter().enumerate() {
let (ro, co) = cell(i);
col_w[co] = col_w[co].max(r.width());
row_h[ro] = row_h[ro].max(r.height());
}
let x_of = |c: usize| first.x0 + col_w[..c].iter().map(|w| w + spacing).sum::<f64>();
let y_of = |r: usize| first.y0 + row_h[..r].iter().map(|h| h + spacing).sum::<f64>();
let deltas: Vec<Vec2> = rects
.iter()
.enumerate()
.map(|(i, r)| {
let (ro, co) = cell(i);
Point::new(x_of(co), y_of(ro)) - r.origin()
})
.collect();
let sizes: Vec<(f64, f64)> = rects.iter().map(|r| (r.width(), r.height())).collect();
let origins = super::newdoc::grid_origins(&sizes, first.origin(), cols, spacing, by_col, false);
let deltas: Vec<Vec2> = rects.iter().zip(origins).map(|(r, o)| o - r.origin()).collect();
if move_art {
let tops: Vec<(NodeId, Point)> = d
.layers
+4
View File
@@ -12,6 +12,7 @@ mod create;
pub(crate) mod distortcmds;
mod docinfo;
mod docmenu;
pub(crate) mod docsetup;
mod draw2;
mod edit;
mod effectcmd;
@@ -28,6 +29,7 @@ mod live;
pub(crate) mod maskedit;
pub(crate) mod menucmds;
pub(crate) mod newart;
pub mod newdoc;
mod object;
mod opacitymask;
mod overprint;
@@ -211,6 +213,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(newart::specs());
v.extend(colortheme::specs());
v.extend(fileio::pdf::specs());
v.extend(docsetup::specs());
v.extend(newdoc::specs());
v
})
}
+621
View File
@@ -0,0 +1,621 @@
//! File → New: the settings `file.new` takes, the New Document presets (generated in code, by
//! category), the user's saved presets and the recently used sizes (both kept in the
//! preferences), and the artboard grid layout shared with Rearrange All Artboards.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use vectorcraft_doc::{Artboard, Background, ColorMode, Document, Unit};
use vectorcraft_geom::{Point, Rect};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "file.newPresets",
"New Document Presets",
[],
None,
"{category?: \"Recent\"|\"Saved\"|\"Mobile\"|\"Web\"|\"Print\"|\"Film & Video\"|\"Art & Illustration\"|\"Branding\"|\"Social\"} the New Document presets by category (Recent: the last sizes used; Saved: the user's presets) → {categories: [{name, presets: [{name, size: \"1920 × 1080 px\", width: pt, height: pt, units, orientation, artboards, artboardLayout: {layout, columns, spacing, rightToLeft}, bleed, backgroundContents, colorMode, rasterEffectsPpi, previewMode}]}]}; file.new {preset: name} starts from one",
always,
list
),
cmd!(
"file.newPresets.save",
"Save Preset",
[],
None,
"{name, preset?: start from this preset, …settings as file.new (default: Letter in prefs unitsGeneral)} save New Document settings as a user preset (the Saved category; same name = replace) in the preferences → {name, count}",
always,
save
),
cmd!(
"file.newPresets.delete",
"Delete Preset",
[],
None,
"{name} delete a saved New Document preset → {deleted}",
|s| if s.prefs.new_doc_presets.is_empty() { Err("no saved presets".into()) } else { Ok(()) },
delete
),
]
}
/// How New Document lays out several artboards.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ArtboardLayout {
/// Rows of `columns` artboards, filled left to right then down.
#[default]
GridByRow,
/// `columns` columns, filled top to bottom then across.
GridByColumn,
/// One row.
Row,
/// One column.
Column,
}
impl ArtboardLayout {
pub const ALL: [Self; 4] = [Self::GridByRow, Self::GridByColumn, Self::Row, Self::Column];
pub fn id(self) -> &'static str {
match self {
Self::GridByRow => "gridByRow",
Self::GridByColumn => "gridByColumn",
Self::Row => "row",
Self::Column => "column",
}
}
pub fn label(self) -> &'static str {
match self {
Self::GridByRow => "Grid by Row",
Self::GridByColumn => "Grid by Column",
Self::Row => "Arrange by Row",
Self::Column => "Arrange by Column",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(s) || l.label().eq_ignore_ascii_case(s))
}
}
/// How a new document previews: normally, as pixels (Pixel Preview) or with Overprint Preview.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PreviewMode {
#[default]
Default,
Pixel,
Overprint,
}
impl PreviewMode {
pub const ALL: [Self; 3] = [Self::Default, Self::Pixel, Self::Overprint];
pub fn id(self) -> &'static str {
match self {
Self::Default => "default",
Self::Pixel => "pixel",
Self::Overprint => "overprint",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s))
}
}
/// Everything New Document sets: a preset (built-in, saved or recent) or `file.new`'s params.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DocSettings {
/// The preset's name.
pub name: String,
/// Artboard size in points.
pub width: f64,
pub height: f64,
pub units: Unit,
pub artboards: u32,
pub layout: ArtboardLayout,
/// Columns of a grid layout.
pub columns: u32,
/// Space between artboards, points.
pub spacing: f64,
/// Change to Right-to-Left Layout: the first artboard at the right.
pub right_to_left: bool,
/// Top, bottom, left, right, points.
pub bleed: [f64; 4],
pub background: Background,
pub color_mode: ColorMode,
pub raster_effects_ppi: f64,
pub preview_mode: PreviewMode,
}
impl Default for DocSettings {
fn default() -> Self {
Self {
name: "Letter".into(),
width: 612.0,
height: 792.0,
units: Unit::Points,
artboards: 1,
layout: ArtboardLayout::GridByRow,
columns: 0,
spacing: 20.0,
right_to_left: false,
bleed: [0.0; 4],
background: Background::Transparent,
color_mode: ColorMode::Rgb,
raster_effects_ppi: 72.0,
preview_mode: PreviewMode::Default,
}
}
}
/// Most artboards a new document can have (as the reference app).
pub const MAX_ARTBOARDS: u32 = 1000;
/// Raster effects resolutions New Document offers: (ppi, label).
pub const RASTER_PPI: [(f64, &str); 3] = [(72.0, "Screen (72 ppi)"), (150.0, "Medium (150 ppi)"), (300.0, "High (300 ppi)")];
impl DocSettings {
/// The columns a grid actually uses (0 or more than the artboards: one row).
pub fn grid_columns(&self) -> usize {
let n = self.artboards.max(1) as usize;
match self.layout {
ArtboardLayout::Row => n,
ArtboardLayout::Column => 1,
_ if self.columns == 0 => n,
_ => (self.columns as usize).min(n),
}
}
/// "portrait" or "landscape" (a square is portrait).
pub fn orientation(&self) -> &'static str {
if self.width > self.height { "landscape" } else { "portrait" }
}
/// The size as New Document shows it, e.g. "1920 × 1080 px".
pub fn size_label(&self) -> String {
let u = self.units;
format!("{} × {} {}", u.number(self.width), u.number(self.height), u.suffix())
}
/// As `file.newPresets` lists it (and `file.new` takes it back).
pub fn to_json(&self) -> Value {
json!({
"name": self.name,
"size": self.size_label(),
"width": self.width,
"height": self.height,
"units": self.units.label(),
"orientation": self.orientation(),
"artboards": self.artboards,
"artboardLayout": {"layout": self.layout.id(), "columns": self.grid_columns(), "spacing": self.spacing, "rightToLeft": self.right_to_left},
"bleed": self.bleed,
"backgroundContents": self.background.id(),
"colorMode": color_mode_id(self.color_mode),
"rasterEffectsPpi": self.raster_effects_ppi,
"previewMode": self.preview_mode.id(),
})
}
/// These settings with `p`'s changes (the params of `file.new`) → validated.
pub fn with_params(mut self, p: &Value, cmd: &str) -> Result<Self> {
if let Some(u) = p.get("units").filter(|v| !v.is_null()) {
let u = u.as_str().unwrap_or("");
self.units = Unit::named(u).ok_or_else(|| bad(cmd, format!("unknown units `{u}`")))?;
}
// Numbers are points; strings may carry their unit ("210 mm"), else the document's.
let length = |k: &str, units: Unit| -> Result<Option<f64>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(Value::Number(n)) => Ok(n.as_f64()),
Some(Value::String(s)) => units.parse(s).map(Some).ok_or_else(|| bad(cmd, format!("{k}: can't read `{s}` as a length"))),
Some(_) => Err(bad(cmd, format!("{k} must be a number (pt) or a length such as \"210 mm\""))),
}
};
if let Some(w) = length("width", self.units)? {
self.width = w;
}
if let Some(h) = length("height", self.units)? {
self.height = h;
}
if !(self.width > 0.0 && self.height > 0.0) || self.width > MAX_SIDE || self.height > MAX_SIDE {
return Err(bad(cmd, "width/height must be positive and within the canvas"));
}
match p.get("orientation").and_then(Value::as_str) {
None => {}
Some(o) if o.eq_ignore_ascii_case("portrait") => (self.width, self.height) = (self.width.min(self.height), self.width.max(self.height)),
Some(o) if o.eq_ignore_ascii_case("landscape") => (self.width, self.height) = (self.width.max(self.height), self.width.min(self.height)),
Some(o) => return Err(bad(cmd, format!("orientation must be portrait or landscape, not `{o}`"))),
}
if let Some(n) = p.get("artboards").filter(|v| !v.is_null()) {
let n = n.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad(cmd, "artboards must be a number"))?;
self.artboards = n.round().clamp(1.0, MAX_ARTBOARDS as f64) as u32;
}
if let Some(l) = p.get("artboardLayout").filter(|v| !v.is_null()) {
self.layout_params(l, cmd)?;
}
if let Some(b) = p.get("bleed").filter(|v| !v.is_null()) {
self.bleed = super::docsetup::bleed_param(b, self.bleed, cmd)?;
}
if let Some(b) = p.get("backgroundContents").filter(|v| !v.is_null()) {
self.background = super::docsetup::background_param(b, cmd)?;
}
if let Some(m) = str_param(p, "colorMode") {
self.color_mode = match m.to_ascii_lowercase().as_str() {
"rgb" | "rgb color" => ColorMode::Rgb,
"cmyk" | "cmyk color" => ColorMode::Cmyk,
_ => return Err(bad(cmd, format!("colorMode must be rgb or cmyk, not `{m}`"))),
};
}
if let Some(v) = p.get("rasterEffectsPpi").filter(|v| !v.is_null()) {
self.raster_effects_ppi = v
.as_f64()
.filter(|r| (1.0..=2400.0).contains(r))
.ok_or_else(|| bad(cmd, "rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high)"))?;
}
if let Some(m) = p.get("previewMode").filter(|v| !v.is_null()) {
self.preview_mode = m.as_str().and_then(PreviewMode::parse).ok_or_else(|| bad(cmd, "previewMode must be default, pixel or overprint"))?;
}
let extent = self.boards().iter().fold(Rect::ZERO, |a, r| a.union(*r));
if extent.width().max(extent.height()) + self.bleed.iter().fold(0.0f64, |a, b| a.max(*b)) * 2.0 > crate::MAX_COORD {
return Err(bad(cmd, "the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size"));
}
Ok(self)
}
/// `artboardLayout: {layout?, columns?, spacing?, rightToLeft?}` or just a layout id.
fn layout_params(&mut self, l: &Value, cmd: &str) -> Result<()> {
let bad_layout = || bad(cmd, "artboardLayout.layout must be gridByRow, gridByColumn, row or column");
if let Some(id) = l.as_str() {
self.layout = ArtboardLayout::parse(id).ok_or_else(bad_layout)?;
return Ok(());
}
let o = l.as_object().ok_or_else(|| bad(cmd, "artboardLayout must be {layout?, columns?, spacing?, rightToLeft?}"))?;
for (k, v) in o {
match k.as_str() {
"layout" => self.layout = v.as_str().and_then(ArtboardLayout::parse).ok_or_else(bad_layout)?,
"columns" => {
self.columns = v
.as_f64()
.filter(|c| c.is_finite() && *c >= 1.0)
.ok_or_else(|| bad(cmd, "artboardLayout.columns must be 1 or more"))?
.round()
.min(MAX_ARTBOARDS as f64) as u32
}
"spacing" => {
self.spacing = v
.as_f64()
.filter(|s| (0.0..=MAX_SPACING).contains(s))
.ok_or_else(|| bad(cmd, format!("artboardLayout.spacing must be 0–{MAX_SPACING} pt")))?
}
"rightToLeft" => self.right_to_left = v.as_bool().ok_or_else(|| bad(cmd, "artboardLayout.rightToLeft must be true or false"))?,
_ => return Err(bad(cmd, format!("unknown artboardLayout key `{k}` (layout, columns, spacing, rightToLeft)"))),
}
}
Ok(())
}
/// The artboard rectangles, the first at the origin's side.
pub fn boards(&self) -> Vec<Rect> {
let n = self.artboards.max(1) as usize;
let sizes = vec![(self.width, self.height); n];
let by_column = self.layout == ArtboardLayout::GridByColumn;
grid_origins(&sizes, Point::ZERO, self.grid_columns(), self.spacing, by_column, self.right_to_left)
.into_iter()
.map(|o| Rect::from_origin_size(o, (self.width, self.height)))
.collect()
}
/// The new document (title and swatches set by `file.new`).
pub fn document(&self) -> Document {
let mut d = Document::new_with_mode(self.width, self.height, self.color_mode);
d.units = self.units;
d.raster_effects_ppi = self.raster_effects_ppi;
d.setup.bleed = self.bleed;
d.setup.background = self.background;
d.artboards = self
.boards()
.into_iter()
.enumerate()
.map(|(i, rect)| Artboard {
id: i as u32 + 1,
name: format!("Artboard {}", i + 1),
rect,
show_center_mark: false,
show_cross_hairs: false,
})
.collect();
d
}
}
/// Largest artboard side `file.new` accepts, points.
const MAX_SIDE: f64 = 16383.0 * 10.0;
/// Largest artboard spacing, points.
const MAX_SPACING: f64 = 1.0e5;
pub fn color_mode_id(m: ColorMode) -> &'static str {
match m {
ColorMode::Rgb => "rgb",
ColorMode::Cmyk => "cmyk",
}
}
/// Top-left corners for artboards of `sizes` from `origin`: `columns` per row, `spacing` apart,
/// each column as wide and each row as tall as its largest artboard; `by_column` fills columns
/// top to bottom first, `rtl` puts the first column at the right.
pub(crate) fn grid_origins(sizes: &[(f64, f64)], origin: Point, columns: usize, spacing: f64, by_column: bool, rtl: bool) -> Vec<Point> {
let n = sizes.len();
let cols = columns.clamp(1, n.max(1));
let rows = n.div_ceil(cols).max(1);
// Grid cell (row, column) of artboard i.
let cell = |i: usize| {
let (r, c) = if by_column { (i % rows, i / rows) } else { (i / cols, i % cols) };
(r, if rtl { cols - 1 - c.min(cols - 1) } else { c })
};
let (mut col_w, mut row_h) = (vec![0.0f64; cols], vec![0.0f64; rows]);
for (i, (w, h)) in sizes.iter().enumerate() {
let (r, c) = cell(i);
if let (Some(cw), Some(rh)) = (col_w.get_mut(c), row_h.get_mut(r)) {
*cw = cw.max(*w);
*rh = rh.max(*h);
}
}
let offset = |spans: &[f64], k: usize| spans.iter().take(k).map(|s| s + spacing).sum::<f64>();
(0..n)
.map(|i| {
let (r, c) = cell(i);
Point::new(origin.x + offset(&col_w, c), origin.y + offset(&row_h, r))
})
.collect()
}
/// The built-in presets: (category, presets as (name, width, height, units)), with the category's
/// colour mode and raster effects resolution. Sizes in `units` (converted to points on use).
type Category = (&'static str, ColorMode, f64, &'static [(&'static str, f64, f64, Unit)]);
const PX: Unit = Unit::Pixels;
const PT: Unit = Unit::Points;
const MM: Unit = Unit::Millimeters;
const IN: Unit = Unit::Inches;
pub const CATEGORIES: &[Category] = &[
(
"Mobile",
ColorMode::Rgb,
72.0,
&[
("Phone 390×844", 390.0, 844.0, PX),
("Phone 393×852", 393.0, 852.0, PX),
("Phone 430×932", 430.0, 932.0, PX),
("Phone 360×800", 360.0, 800.0, PX),
("Phone 412×915", 412.0, 915.0, PX),
("Phone 375×667", 375.0, 667.0, PX),
("Tablet 768×1024", 768.0, 1024.0, PX),
("Tablet 820×1180", 820.0, 1180.0, PX),
("Tablet 834×1194", 834.0, 1194.0, PX),
("Tablet 1024×1366", 1024.0, 1366.0, PX),
("Tablet 800×1280", 800.0, 1280.0, PX),
("Watch 184×224", 184.0, 224.0, PX),
("Watch 198×242", 198.0, 242.0, PX),
],
),
(
"Web",
ColorMode::Rgb,
72.0,
&[
("Web 1920×1080", 1920.0, 1080.0, PX),
("Web 1600×900", 1600.0, 900.0, PX),
("Web 1440×900", 1440.0, 900.0, PX),
("Web 1366×768", 1366.0, 768.0, PX),
("Web 1280×800", 1280.0, 800.0, PX),
("Web 1024×768", 1024.0, 768.0, PX),
],
),
(
"Print",
ColorMode::Cmyk,
300.0,
&[
("Letter", 612.0, 792.0, PT),
("Legal", 612.0, 1008.0, PT),
("Tabloid", 792.0, 1224.0, PT),
("A3", 297.0, 420.0, MM),
("A4", 210.0, 297.0, MM),
("A5", 148.0, 210.0, MM),
("B4", 250.0, 353.0, MM),
("B5", 176.0, 250.0, MM),
],
),
(
"Film & Video",
ColorMode::Rgb,
72.0,
&[
("HDTV 1080p", 1920.0, 1080.0, PX),
("HDTV 720p", 1280.0, 720.0, PX),
("4K UHD", 3840.0, 2160.0, PX),
("8K UHD", 7680.0, 4320.0, PX),
("2K Cinema", 2048.0, 1080.0, PX),
("4K Cinema", 4096.0, 2160.0, PX),
],
),
(
"Art & Illustration",
ColorMode::Rgb,
300.0,
&[
("Postcard", 4.0, 6.0, IN),
("Greeting Card", 5.0, 7.0, IN),
("Square 8 × 8 in", 8.0, 8.0, IN),
("Poster 18 × 24 in", 18.0, 24.0, IN),
("Poster 24 × 36 in", 24.0, 36.0, IN),
],
),
(
"Branding",
ColorMode::Cmyk,
300.0,
&[
("Logo 1000×1000", 1000.0, 1000.0, PX),
("Business Card 3.5 × 2 in", 3.5, 2.0, IN),
("Business Card 85 × 55 mm", 85.0, 55.0, MM),
("Letterhead Letter", 8.5, 11.0, IN),
("Letterhead A4", 210.0, 297.0, MM),
("Envelope DL", 220.0, 110.0, MM),
("Envelope #10", 9.5, 4.125, IN),
],
),
(
"Social",
ColorMode::Rgb,
72.0,
&[
("Social Square Post 1080×1080", 1080.0, 1080.0, PX),
("Social Portrait Post 1080×1350", 1080.0, 1350.0, PX),
("Social Landscape Post 1200×630", 1200.0, 630.0, PX),
("Social Story 1080×1920", 1080.0, 1920.0, PX),
("Social Tall Post 1000×1500", 1000.0, 1500.0, PX),
("Profile Cover 1500×500", 1500.0, 500.0, PX),
("Page Cover 1640×624", 1640.0, 624.0, PX),
("Video Banner 2560×1440", 2560.0, 1440.0, PX),
("Video Thumbnail 1280×720", 1280.0, 720.0, PX),
],
),
];
/// The category tabs in order: Recent, Saved, then the built-in categories.
pub fn category_names() -> impl Iterator<Item = &'static str> {
["Recent", "Saved"].into_iter().chain(CATEGORIES.iter().map(|c| c.0))
}
/// The built-in presets of one category: screen sizes in pixels, the others in `general`
/// (Preferences ▸ Units ▸ General).
fn builtin(c: &Category, general: Unit) -> impl Iterator<Item = DocSettings> + '_ {
let (_, color_mode, ppi, presets) = *c;
presets.iter().map(move |&(name, w, h, units)| DocSettings {
name: name.into(),
width: units.to_pt(w),
height: units.to_pt(h),
units: if units == Unit::Pixels { units } else { general },
color_mode,
raster_effects_ppi: ppi,
..DocSettings::default()
})
}
/// The presets of the category `name` (any case), or None for no such category.
pub fn category(s: &Session, name: &str) -> Option<Vec<DocSettings>> {
if name.eq_ignore_ascii_case("Recent") {
return Some(s.prefs.recent_new_docs.clone());
}
if name.eq_ignore_ascii_case("Saved") {
return Some(s.prefs.new_doc_presets.clone());
}
CATEGORIES.iter().find(|c| c.0.eq_ignore_ascii_case(name)).map(|c| builtin(c, s.default_units()).collect())
}
/// A preset by name (any case): a saved one first, then the built-in ones.
pub fn find(s: &Session, name: &str) -> Option<DocSettings> {
let name = name.trim();
s.prefs
.new_doc_presets
.iter()
.find(|p| p.name.eq_ignore_ascii_case(name))
.cloned()
.or_else(|| CATEGORIES.iter().flat_map(|c| builtin(c, s.default_units())).find(|p| p.name.eq_ignore_ascii_case(name)))
}
impl Session {
/// New Document's starting settings: Letter in Preferences ▸ Units ▸ General.
fn default_settings(&self) -> DocSettings {
DocSettings { units: self.default_units(), ..DocSettings::default() }
}
}
/// Recent keeps this many sizes.
const RECENT: usize = 12;
/// `file.new`: a new document from a preset and/or explicit settings.
pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "file.new";
let base = match str_param(p, "preset") {
Some(name) => find(s, name).ok_or_else(|| bad(C, format!("no preset called `{name}` (see file.newPresets)")))?,
None => s.default_settings(),
};
// A preset keeps its name in Recent unless its size changed (turning it doesn't count).
let sorted = |d: &DocSettings| (d.width.min(d.height), d.width.max(d.height), d.units);
let size = str_param(p, "preset").map(|_| sorted(&base));
let mut settings = base.with_params(p, C)?;
if size != Some(sorted(&settings)) {
settings.name = "Custom".into();
}
let mut d = settings.document();
d.title = match str_param(p, "name").or(str_param(p, "title")).map(str::trim).filter(|t| !t.is_empty()) {
// New Document offers the next untitled name: taking it uses it up.
Some(t) if t == s.peek_untitled() => s.next_untitled(),
Some(t) => t.to_string(),
None => s.next_untitled(),
};
let i = s.add_document(d, None);
// Overprint Preview is the engine's (the UI turns Pixel Preview on from `previewMode`). Both
// are views of the whole app, so Default leaves them as they are.
if settings.preview_mode == PreviewMode::Overprint {
s.execute("view.overprintPreview", &json!({ "on": true }))?;
}
remember(s, &settings);
Ok(json!({ "index": i, "previewMode": settings.preview_mode.id() }))
}
/// Put `settings` first in Recent (one entry per size and units).
fn remember(s: &mut Session, settings: &DocSettings) {
let recent = &mut s.prefs.recent_new_docs;
recent.retain(|r| (r.width, r.height, r.units) != (settings.width, settings.height, settings.units));
recent.insert(0, settings.clone());
recent.truncate(RECENT);
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let names: Vec<&str> = match str_param(p, "category") {
Some(c) => vec![
category_names()
.find(|n| n.eq_ignore_ascii_case(c))
.ok_or_else(|| bad("file.newPresets", format!("unknown category `{c}` ({})", category_names().collect::<Vec<_>>().join(", "))))?,
],
None => category_names().collect(),
};
let categories: Vec<Value> = names
.into_iter()
.map(|n| json!({ "name": n, "presets": category(s, n).unwrap_or_default().iter().map(DocSettings::to_json).collect::<Vec<_>>() }))
.collect();
Ok(json!({ "categories": categories }))
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "file.newPresets.save";
let name = str_param(p, "name")
.map(str::trim)
.filter(|n| !n.is_empty() && n.chars().count() <= 64)
.ok_or_else(|| bad(C, "give the preset a name (1–64 characters)"))?;
let base = match str_param(p, "preset") {
Some(from) => find(s, from).ok_or_else(|| bad(C, format!("no preset called `{from}` (see file.newPresets)")))?,
None => s.default_settings(),
};
let mut preset = base.with_params(p, C)?;
preset.name = name.to_string();
let saved = &mut s.prefs.new_doc_presets;
match saved.iter_mut().find(|q| q.name.eq_ignore_ascii_case(name)) {
Some(q) => *q = preset,
None => saved.push(preset),
}
Ok(json!({ "name": name, "count": saved.len() }))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "file.newPresets.delete";
let name = str_param(p, "name").unwrap_or("").trim();
let saved = &mut s.prefs.new_doc_presets;
let i = saved.iter().position(|q| q.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(C, format!("no saved preset called `{name}`")))?;
Ok(json!({ "deleted": saved.remove(i).name }))
}
+10 -1
View File
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
"underline",
"strikethrough",
"allCaps",
"smallCaps",
"position",
"leftIndent",
"rightIndent",
"firstLineIndent",
@@ -103,6 +105,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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") {
+17 -3
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<bool>,
all_caps: Option<bool>,
features: Option<Vec<String>>,
position: Option<vectorcraft_doc::CharPosition>,
small_caps: Option<Option<f64>>,
}
/// `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<Option<Option<f64>>> {
}
impl CharChange {
pub(crate) fn parse(p: &Value, cmd: &str) -> Result<Self> {
/// `setup` gives the superscript, subscript and small caps proportions.
pub(crate) fn parse(p: &Value, cmd: &str, setup: &vectorcraft_doc::DocSetup) -> Result<Self> {
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<Value> {
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"));
}
+14 -35
View File
@@ -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<CommandSpec> {
"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<char>,
}
fn opens(prev: Option<char>) -> bool {
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–' | '“' | '‘'))
}
pub(crate) fn smarten(text: &str, prev: &mut Option<char>, 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<char>, 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<char>, 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<Value> {
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<Value> {
} else {
text_ids(&st.doc, &st.selection.objects)
};
let n = edit_runs::<PunctState>(s, "Smart Punctuation", &ids, |text, st| smarten(text, &mut st.prev, q, d, e))?;
let n = edit_runs::<PunctState>(s, "Smart Punctuation", &ids, |text, st| smarten(text, &mut st.prev, q.as_ref(), d, e))?;
Ok(json!({ "changed": n }))
}
+17
View File
@@ -356,6 +356,13 @@ pub struct Prefs {
/// dialog field, so it has no [`cmd::prefscmds::PREF_SPECS`] row and resetting keeps it.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub color_themes: Vec<cmd::colortheme::ColorTheme>,
/// New Document → Saved: the user's document presets (`file.newPresets.save`). A local
/// library, not a Preferences dialog field: resetting the preferences keeps it.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub new_doc_presets: Vec<cmd::newdoc::DocSettings>,
/// New Document → Recent: the settings of the last documents made (newest first).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub recent_new_docs: Vec<cmd::newdoc::DocSettings>,
}
impl Default for Prefs {
@@ -479,6 +486,8 @@ impl Default for Prefs {
japanese_crop_marks: false,
new_art_basic: true,
color_themes: vec![],
new_doc_presets: vec![],
recent_new_docs: vec![],
}
}
}
@@ -616,6 +625,10 @@ impl Session {
false
}
}
/// The name [`Session::next_untitled`] gives next (New Document's Name field).
pub fn peek_untitled(&self) -> String {
format!("Untitled-{}", self.untitled_counter + 1)
}
pub fn next_untitled(&mut self) -> String {
self.untitled_counter += 1;
format!("Untitled-{}", self.untitled_counter)
@@ -909,6 +922,8 @@ mod tests_dashalign;
#[cfg(test)]
mod tests_distort;
#[cfg(test)]
mod tests_docsetup;
#[cfg(test)]
mod tests_draw2;
#[cfg(test)]
mod tests_editcolors;
@@ -951,6 +966,8 @@ mod tests_menucmds;
#[cfg(test)]
mod tests_newart;
#[cfg(test)]
mod tests_newdoc;
#[cfg(test)]
mod tests_objexpand;
#[cfg(test)]
mod tests_opacitymask;
+266
View File
@@ -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("<text"));
s.execute("document.setup", &json!({"exportText": "appearance"})).unwrap();
assert!(!svg(&mut s, json!({})).contains("<text"));
// An explicit option still wins.
assert!(svg(&mut s, json!({"outlineText": false})).contains("<text"));
}
#[test]
fn discard_white_overprint_keeps_white_overprints_in_overprint_preview() {
let mut s = session();
let rect = |s: &mut Session, color: &str| {
let id = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"ids": [id], "color": color})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [id], "none": true})).unwrap();
id
};
rect(&mut s, "#ff0000");
let white = rect(&mut s, "#ffffff");
s.execute("object.setOverprint", &json!({"fill": true, "ids": [white]})).unwrap();
let centre = |s: &Session| {
let opts = vectorcraft_render::RenderOptions { background: Some([255; 4]), overprint_preview: true, ..Default::default() };
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 40, 40, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(20, 20)
};
// On by default: the white knocks out as it will print.
assert_eq!(centre(&s), [255, 255, 255, 255]);
s.execute("document.setup", &json!({"discardWhiteOverprint": false})).unwrap();
let [r, g, b, _] = centre(&s);
assert!(r > 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);
}
+225
View File
@@ -0,0 +1,225 @@
//! File → New (`file.new`, `file.newPresets*`): artboard layouts, orientation, bleed, colour mode,
//! raster effects resolution, background, presets, Recent and Saved.
use serde_json::{Value, json};
use vectorcraft_doc::{Background, ColorMode, Unit};
use vectorcraft_geom::Rect;
use super::*;
fn new_doc(p: Value) -> Session {
let mut s = Session::new();
s.execute("file.new", &p).unwrap();
s
}
fn boards(s: &Session) -> Vec<Rect> {
s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect()
}
fn presets(s: &mut Session, category: &str) -> Vec<Value> {
let r = s.execute("file.newPresets", &json!({ "category": category })).unwrap();
r["categories"][0]["presets"].as_array().unwrap().clone()
}
#[test]
fn four_artboards_in_two_columns_make_a_two_by_two_grid() {
let s = new_doc(json!({"width": 100, "height": 50, "artboards": 4, "artboardLayout": {"columns": 2, "spacing": 10}}));
let origins: Vec<(f64, f64)> = boards(&s).iter().map(|r| (r.x0, r.y0)).collect();
assert_eq!(origins, [(0.0, 0.0), (110.0, 0.0), (0.0, 60.0), (110.0, 60.0)]);
assert!(boards(&s).iter().all(|r| r.width() == 100.0 && r.height() == 50.0));
let names: Vec<String> = s.doc().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect();
assert_eq!(names, ["Artboard 1", "Artboard 2", "Artboard 3", "Artboard 4"]);
// Grid by Column fills down first; Right-to-Left starts at the right.
let s = new_doc(json!({"width": 100, "height": 50, "artboards": 4, "artboardLayout": {"layout": "gridByColumn", "columns": 2, "spacing": 10}}));
assert_eq!(boards(&s).iter().map(|r| (r.x0, r.y0)).collect::<Vec<_>>(), [(0.0, 0.0), (0.0, 60.0), (110.0, 0.0), (110.0, 60.0)]);
let s = new_doc(json!({"width": 100, "height": 50, "artboards": 3, "artboardLayout": {"layout": "row", "rightToLeft": true}}));
assert_eq!(boards(&s).iter().map(|r| r.x0).collect::<Vec<_>>(), [240.0, 120.0, 0.0]);
let s = new_doc(json!({"width": 100, "height": 50, "artboards": 3, "artboardLayout": "column"}));
assert_eq!(boards(&s).iter().map(|r| r.y0).collect::<Vec<_>>(), [0.0, 70.0, 140.0]);
// Without a layout: one row, 20 pt apart (as before).
let s = new_doc(json!({"width": 100, "height": 50, "artboards": 2}));
assert_eq!(boards(&s)[1].x0, 120.0);
}
#[test]
fn orientation_swaps_width_and_height() {
let s = new_doc(json!({"width": 612, "height": 792, "orientation": "landscape"}));
assert_eq!((boards(&s)[0].width(), boards(&s)[0].height()), (792.0, 612.0));
let s = new_doc(json!({"width": 792, "height": 612, "orientation": "Portrait"}));
assert_eq!((boards(&s)[0].width(), boards(&s)[0].height()), (612.0, 792.0));
let s = new_doc(json!({"preset": "HDTV 1080p", "orientation": "portrait"}));
assert_eq!((boards(&s)[0].width(), boards(&s)[0].height()), (1080.0, 1920.0));
}
#[test]
fn every_setting_reaches_the_document() {
let mut s = Session::new();
let r = s
.execute(
"file.new",
&json!({"name": "Poster", "width": "210 mm", "height": "297mm", "units": "Millimeters", "bleed": [9, 9, 4.5, 4.5],
"backgroundContents": "white", "colorMode": "cmyk", "rasterEffectsPpi": 150}),
)
.unwrap();
assert_eq!(r["previewMode"], "default");
let d = &s.doc().unwrap().doc;
assert_eq!(d.title, "Poster");
assert_eq!(d.units, Unit::Millimeters);
assert!((d.artboards[0].rect.width() - 595.2756).abs() < 1e-3);
assert_eq!(d.setup.bleed, [9.0, 9.0, 4.5, 4.5]);
assert_eq!(d.setup.background, Background::White);
assert_eq!(d.color_mode, ColorMode::Cmyk);
assert_eq!(d.raster_effects_ppi, 150.0);
// Saved and reopened, the settings stay.
let back = vectorcraft_format::load(&vectorcraft_format::save_file(d)).unwrap();
assert_eq!((back.raster_effects_ppi, back.setup.bleed, back.setup.background), (150.0, d.setup.bleed, Background::White));
}
#[test]
fn a_white_background_exports_opaque() {
let alpha = |bg: &str| {
let mut s = new_doc(json!({"width": 20, "height": 20, "backgroundContents": bg}));
let r = s.execute("document.export", &json!({"format": "png"})).unwrap();
let png = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(10, 10).0
};
assert_eq!(alpha("white"), [255, 255, 255, 255]);
assert_eq!(alpha("transparent")[3], 0);
}
#[test]
fn a_preset_starts_the_document_and_params_change_it() {
let s = new_doc(json!({"preset": "a4"}));
let d = &s.doc().unwrap().doc;
assert_eq!((d.units, d.color_mode, d.raster_effects_ppi), (Unit::Points, ColorMode::Cmyk, 300.0), "print sizes in prefs unitsGeneral");
assert!((d.artboards[0].rect.height() - 841.89).abs() < 0.01);
let s = new_doc(json!({"preset": "Web 1920×1080", "colorMode": "cmyk", "artboards": 2}));
let d = &s.doc().unwrap().doc;
assert_eq!((d.units, d.color_mode, d.artboards.len()), (Unit::Pixels, ColorMode::Cmyk, 2));
assert!(Session::new().execute("file.new", &json!({"preset": "Napkin"})).is_err());
// Print presets and plain sizes start in Preferences ▸ Units ▸ General; screen presets in pixels.
let mut s = Session::new();
s.execute("prefs.set", &json!({"key": "unitsGeneral", "value": "millimeters"})).unwrap();
s.execute("file.new", &json!({"preset": "A4"})).unwrap();
assert_eq!(s.doc().unwrap().doc.units, Unit::Millimeters);
assert_eq!(presets(&mut s, "Print")[4]["size"], "210 × 297 mm");
s.execute("file.new", &json!({"width": 100})).unwrap();
assert_eq!(s.doc().unwrap().doc.units, Unit::Millimeters);
s.execute("file.new", &json!({"preset": "HDTV 720p"})).unwrap();
assert_eq!(s.doc().unwrap().doc.units, Unit::Pixels);
}
#[test]
fn ppi_overprint_preview_and_bad_params() {
// Overprint Preview is process-wide.
let _view = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut s = new_doc(json!({"rasterEffectsPpi": 300, "previewMode": "overprint"}));
assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, 300.0);
let overprint = |s: &mut Session| s.execute("view.proofSetup", &json!({})).unwrap()["overprintPreview"].clone();
assert_eq!(overprint(&mut s), true);
s.execute("view.overprintPreview", &json!({"on": false})).unwrap();
let r = s.execute("file.new", &json!({"previewMode": "pixel"})).unwrap();
assert_eq!(r["previewMode"], "pixel", "for the app's Pixel Preview");
assert_eq!(overprint(&mut s), false);
for p in [
json!({"orientation": "sideways"}),
json!({"width": -5}),
json!({"width": "wide"}),
json!({"bleed": 100}),
json!({"backgroundContents": "plaid"}),
json!({"colorMode": "lab"}),
json!({"rasterEffectsPpi": 0}),
json!({"previewMode": "xray"}),
json!({"artboardLayout": {"layout": "spiral"}}),
json!({"artboardLayout": {"columns": 0}}),
json!({"artboardLayout": {"rows": 2}}),
json!({"units": "furlongs"}),
json!({"width": 160000, "artboards": 1000}),
] {
let mut s = Session::new();
assert!(s.execute("file.new", &p).is_err(), "{p}");
assert!(s.active().is_none(), "{p}: no document on an error");
}
}
#[test]
fn categories_list_generated_presets_with_neutral_names() {
let mut s = Session::new();
let r = s.execute("file.newPresets", &json!({})).unwrap();
let names: Vec<&str> = r["categories"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Recent", "Saved", "Mobile", "Web", "Print", "Film & Video", "Art & Illustration", "Branding", "Social"]);
let print = presets(&mut s, "print");
let letter = print.iter().find(|p| p["name"] == "Letter").unwrap();
assert_eq!(
(letter["size"].as_str(), letter["colorMode"].as_str(), letter["orientation"].as_str()),
(Some("612 × 792 pt"), Some("cmyk"), Some("portrait"))
);
assert_eq!(presets(&mut s, "Film & Video").iter().find(|p| p["name"] == "4K UHD").unwrap()["size"], "3840 × 2160 px");
for c in ["Mobile", "Web", "Print", "Film & Video", "Art & Illustration", "Branding", "Social"] {
assert!(!presets(&mut s, c).is_empty(), "{c}");
}
// A listed preset can be passed straight back to file.new.
let story = presets(&mut s, "Social").into_iter().find(|p| p["name"] == "Social Story 1080×1920").unwrap();
s.execute("file.new", &story).unwrap();
assert_eq!(boards(&s)[0], Rect::new(0.0, 0.0, 1080.0, 1920.0));
assert!(s.execute("file.newPresets", &json!({"category": "Furniture"})).is_err());
}
#[test]
fn recent_remembers_the_last_sizes() {
let mut s = Session::new();
assert!(presets(&mut s, "Recent").is_empty());
s.execute("file.new", &json!({"preset": "A4"})).unwrap();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
s.execute("file.new", &json!({"preset": "A4", "orientation": "landscape"})).unwrap();
let recent = presets(&mut s, "Recent");
let names: Vec<&str> = recent.iter().map(|p| p["name"].as_str().unwrap()).collect();
assert_eq!(names, ["A4", "Custom", "A4"], "newest first; a turned preset keeps its name");
// The same size again moves to the front instead of repeating.
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
assert_eq!(presets(&mut s, "Recent").len(), 3);
assert_eq!(presets(&mut s, "Recent")[0]["size"], "300 × 200 pt");
for i in 0..20 {
s.execute("file.new", &json!({"width": 100 + i, "height": 100})).unwrap();
}
assert_eq!(presets(&mut s, "Recent").len(), 12);
}
#[test]
fn saved_presets_live_in_the_preferences() {
let mut s = Session::new();
assert!(s.execute("file.newPresets.delete", &json!({"name": "Mine"})).is_err());
let r = s
.execute(
"file.newPresets.save",
&json!({"name": "Mine", "width": 400, "height": 300, "units": "Pixels", "artboards": 4, "artboardLayout": {"columns": 2}, "bleed": 9}),
)
.unwrap();
assert_eq!(r, json!({"name": "Mine", "count": 1}));
let saved = presets(&mut s, "Saved");
assert_eq!(
(saved[0]["name"].as_str(), saved[0]["size"].as_str(), saved[0]["artboardLayout"]["columns"].as_u64()),
(Some("Mine"), Some("400 × 300 px"), Some(2))
);
// The same name replaces it.
s.execute("file.newPresets.save", &json!({"name": "mine", "width": 500, "height": 300})).unwrap();
assert_eq!(presets(&mut s, "Saved").len(), 1);
s.execute("file.new", &json!({"preset": "Mine"})).unwrap();
assert_eq!(boards(&s)[0].width(), 500.0);
// Kept with the preferences, and by a reset.
let prefs: crate::Prefs = serde_json::from_value(s.prefs.to_json()).unwrap();
assert_eq!(prefs.new_doc_presets, s.prefs.new_doc_presets);
s.execute("prefs.reset", &json!({})).unwrap();
assert_eq!(s.prefs.new_doc_presets.len(), 1);
assert_eq!(s.execute("file.newPresets.delete", &json!({"name": "MINE"})).unwrap(), json!({"deleted": "mine"}));
assert!(presets(&mut s, "Saved").is_empty());
assert!(s.execute("file.newPresets.save", &json!({"name": " "})).is_err());
}
#[test]
fn rearrange_keeps_its_grid() {
let mut s = new_doc(json!({"width": 100, "height": 50, "artboards": 3}));
s.execute("artboard.rearrange", &json!({"columns": 2, "spacing": 5})).unwrap();
assert_eq!(boards(&s).iter().map(|r| (r.x0, r.y0)).collect::<Vec<_>>(), [(0.0, 0.0), (105.0, 0.0), (0.0, 55.0)]);
}
+81 -17
View File
@@ -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<NodeId>,
/// 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<Arc<Pixmap>> {
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<BezPath>,
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<u32>)> =
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;
+22 -7
View File
@@ -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<Node>) {
/// 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<Node>, 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 {
+33
View File
@@ -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]);
}
+2
View File
@@ -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;
+44 -23
View File
@@ -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<FontFace>)> = 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<usize>,
run: usize,
st: &'a CharStyle,
face: Arc<FontFace>,
/// 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<usize>, run: usize, st: &CharStyle, face: &Arc<FontFace>, feats: &OtFeatures, out: &mut Vec<SGlyph>) {
let seg = &text[range.clone()];
let size = st.size.max(0.0);
fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SGlyph>) {
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<usize>, 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<char> = if st.all_caps { c.to_uppercase().collect() } else { vec![c] };
let chars: Vec<char> = 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<usize>, 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,
+50
View File
@@ -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);
}
+10
View File
@@ -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();
+52 -40
View File
@@ -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::<Vec<_>>();
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) {
@@ -1024,31 +1053,14 @@ fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
ui.add_space(28.0);
ui.label(egui::RichText::new("Quickly start a new file").font(theme::semibold(14.0)).color(t.text));
ui.add_space(10.0);
// (name, width, height in points, in pixels); print sizes show in Preferences ▸ Units ▸ General.
let presets: [(&str, f64, f64, bool); 6] = [
("Letter", 612.0, 792.0, false),
("A4", 595.28, 841.89, false),
("Web 1920", 1920.0, 1080.0, true),
("Mobile", 390.0, 844.0, true),
("Postcard", 432.0, 288.0, false),
("Square", 1080.0, 1080.0, true),
];
let print = app.session.default_units();
// A few of New Document's presets (`file.newPresets`), as its cards.
let presets = ["Letter", "A4", "Web 1920×1080", "Phone 390×844", "Postcard", "Social Square Post 1080×1080"];
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(14.0, 14.0);
for (name, w, h, pixels) in presets {
let unit = if pixels { Unit::Pixels } else { print };
let size = format!("{} × {}", unit.number(w), unit.format(h));
let (r, resp) = ui.allocate_exact_size(vec2(120.0, 132.0), Sense::click());
ui.painter().rect_filled(r, CornerRadius::same(8), if resp.hovered() { t.hover } else { t.panel });
let s = (70.0 / w.max(h)) as f32;
let pr = egui::Rect::from_center_size(r.center_top() + vec2(0.0, 50.0), vec2(w as f32 * s, h as f32 * s));
ui.painter().rect_filled(pr.translate(vec2(2.0, 2.0)), 0.0, Color32::from_black_alpha(80));
ui.painter().rect_filled(pr, 0.0, Color32::WHITE);
ui.painter().text(r.center_bottom() - vec2(0.0, 30.0), egui::Align2::CENTER_CENTER, name, theme::semibold(12.5), t.text);
ui.painter().text(r.center_bottom() - vec2(0.0, 14.0), egui::Align2::CENTER_CENTER, size, egui::FontId::proportional(11.0), t.text_dim);
if resp.clicked() {
app.run("file.new", json!({"width": w, "height": h, "units": unit.label()})).ok();
let cards: Vec<_> = presets.iter().filter_map(|name| vectorcraft_engine::cmd::newdoc::find(&app.session, name)).collect();
for s in cards {
if crate::dialogs::preset_card(ui, &s, false).clicked() {
app.run("file.new", json!({ "preset": s.name })).ok();
}
}
});
+249 -8
View File
@@ -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<Value, String> {
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<Value, String> {
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<String> = 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<String> = 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::<String>(), "single": q.single.iter().collect::<String>()}));
}
for (key, label, choices) in [("double", "Double Quotes:", Quotes::DOUBLE_CHOICES), ("single", "Single Quotes:", Quotes::SINGLE_CHOICES)] {
let pairs: Vec<String> = 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());
}
}
+58
View File
@@ -270,3 +270,61 @@ 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));
}
}
/// A small grey caption above a field (New Document's details column).
pub(super) fn caption(ui: &mut egui::Ui, text: &str) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(text).size(11.0).color(t.text_dim));
}
+4 -2
View File
@@ -47,8 +47,9 @@ 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 new_document::{open as open_new_document, preset_card};
pub use save_pdf::open as open_save_pdf;
pub use tools::open_tool_dialog;
@@ -137,7 +138,7 @@ macro_rules! registry {
}
registry! {
NewDocument: ["newDocument"] => new_document::SPEC,
NewDocument: [new_document::KIND] => new_document::SPEC,
Shape: ["rectangle", "roundedRectangle", "ellipse", "polygon", "star", "lineSegment"] => shapes::SPEC,
Transform: ["move", "rotate", "scale", "reflect", "shear"] => transform::SPEC,
PathOp: ["average", "offsetPath", "simplify", "splitIntoGrid"] => path_ops::SPEC,
@@ -176,6 +177,7 @@ registry! {
WidthPoint: [width_point::KIND] => width_point::SPEC,
SavePdf: [save_pdf::KIND] => save_pdf::SPEC,
SvgOptions: [svg_options::KIND] => svg_options::SPEC,
NewDocumentMore: [new_document::MORE] => new_document::MORE_SPEC,
}
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
+642 -67
View File
@@ -1,91 +1,666 @@
//! File → New: presets and the document details (name, size, units, artboards, colour mode).
//! File → New: category tabs (Recent, Saved and the built-in categories of `file.newPresets`)
//! over a grid of preset cards, and a Preset Details column (name and Save Preset, size and
//! units, orientation, artboards, bleed, background contents, Advanced Options, More Settings);
//! Close and Create. More Settings is the classic form of the same fields (artboard layout,
//! spacing and columns, profile and size lists), so both share one set of fields and widgets.
//!
//! Fields: `preset`, `name`, `width` and `height` (points, or strings with a unit), `units` (a unit
//! label; print presets start in Preferences ▸ Units ▸ General), `artboards`, `colorMode`.
//! Fields: `file.new`'s params (`preset`, `name`, `width`, `height` (pt), `units`, `artboards`,
//! `artboardLayout`, `bleed`, `backgroundContents`, `colorMode`, `rasterEffectsPpi`,
//! `previewMode`), plus `category` (the tab, also More Settings' Profile), `advanced` (Advanced
//! Options open), `bleedLinked` and `presetName` (Save Preset's name). Setting `preset` fills the
//! fields from that preset; changing any of them afterwards makes the settings custom.
use serde_json::{Value, json};
use serde_json::{Map, Value, json};
use vectorcraft_doc::Unit;
use vectorcraft_engine::cmd::newdoc::{self, ArtboardLayout, DocSettings, MAX_ARTBOARDS, PreviewMode, RASTER_PPI};
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, widgets};
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "New Document".into(), body, confirm, ok: Some("Create"), min_width: 560.0, ..DialogSpec::FORM };
pub(super) const KIND: &str = "newDocument";
/// More Settings.
pub(super) const MORE: &str = "newDocumentMore";
/// The presets: (name, width, height in points, in pixels).
const PRESETS: [(&str, f64, f64, bool); 8] = [
("Letter", 612.0, 792.0, false),
("Legal", 612.0, 1008.0, false),
("Tabloid", 792.0, 1224.0, false),
("A4", 595.28, 841.89, false),
("A3", 841.89, 1190.55, false),
("Web 1920", 1920.0, 1080.0, true),
("Phone 390×844", 390.0, 844.0, true),
("Square Post", 1080.0, 1080.0, true),
pub(super) const SPEC: DialogSpec = DialogSpec::window(show, confirm);
pub(super) const MORE_SPEC: DialogSpec = DialogSpec::window(show_more, confirm);
/// The fields `file.new` takes.
const PARAMS: [&str; 12] = [
"preset",
"name",
"width",
"height",
"units",
"artboards",
"artboardLayout",
"bleed",
"backgroundContents",
"colorMode",
"rasterEffectsPpi",
"previewMode",
];
/// The preset the fields were last filled from (its fields as `apply` set them).
const APPLIED: &str = "__applied";
/// Save Preset's name field is showing.
const SAVING: &str = "__saving";
/// The units New Document offers (all ten).
const UNITS: [Unit; 10] = [
Unit::Pixels,
Unit::Points,
Unit::Picas,
Unit::Inches,
Unit::Millimeters,
Unit::Centimeters,
Unit::Feet,
Unit::Yards,
Unit::Meters,
Unit::FeetInches,
];
const BACKGROUNDS: [(&str, &str); 2] = [("transparent", "Transparent (Default)"), ("white", "White")];
const COLOR_MODES: [(&str, &str); 2] = [("rgb", "RGB Color"), ("cmyk", "CMYK Color")];
/// Width of the Preset Details column.
const DETAILS: f32 = 292.0;
/// Height of the dialog's content.
const HEIGHT: f32 = 600.0;
/// The units a preset starts in: pixels for screen presets, else the General preference.
fn preset_units(app: &VectorcraftApp, pixels: bool) -> Unit {
if pixels { Unit::Pixels } else { app.session.default_units() }
}
/// Open the dialog on the Letter preset.
/// Open New Document on the most recent settings (or Letter), named after the next untitled
/// document.
pub fn open(app: &mut VectorcraftApp) {
let units = preset_units(app, false).label();
let fields = json!({"preset": "Letter", "width": 612, "height": 792, "units": units, "artboards": 1, "colorMode": "RGB", "name": "Untitled-1"});
app.ui.dialog = Some(Dialog::new("newDocument", fields));
let category = if app.session.prefs.recent_new_docs.is_empty() { "Print" } else { "Recent" };
let first = newdoc::category(&app.session, category).and_then(|v| v.into_iter().next()).unwrap_or_default();
let mut d = Dialog::new(KIND, json!({ "category": category, "name": app.session.peek_untitled(), "advanced": false }));
apply(&mut d, &first);
app.ui.dialog = Some(d);
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.set_width(300.0);
ui.label(egui::RichText::new("Presets").color(t.text_dim));
ui.horizontal_wrapped(|ui| {
for (name, w, h, pixels) in PRESETS {
let sel = d.str("preset") == name;
if ui.selectable_label(sel, name).clicked() {
d.fields.insert("preset".into(), json!(name));
d.fields.insert("width".into(), json!(w));
d.fields.insert("height".into(), json!(h));
d.fields.insert("units".into(), json!(preset_units(app, pixels).label()));
}
}
});
});
ui.separator();
ui.vertical(|ui| {
ui.label(egui::RichText::new("Preset Details").font(theme::semibold(12.5)));
let unit = Unit::named(&d.str("units")).unwrap_or_default();
egui::Grid::new("newdoc").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
form::field(ui, d, "name", "Name:");
form::length_field(ui, d, "width", "Width:", unit);
form::length_field(ui, d, "height", "Height:", unit);
ui.label(egui::RichText::new("Units:").color(t.text_dim));
let labels: Vec<&str> = Unit::ALL.iter().map(|u| u.label()).collect();
if let Some(i) = widgets::dropdown(ui, "newdoc-units", unit.label(), &labels, 132.0) {
d.fields.insert("units".into(), json!(labels[i]));
}
ui.end_row();
form::field(ui, d, "artboards", "Artboards:");
form::field(ui, d, "colorMode", "Color Mode:");
});
});
});
false
/// Fill the fields from a preset.
fn apply(d: &mut Dialog, s: &DocSettings) {
let Value::Object(mut v) = s.to_json() else { return };
for k in ["size", "orientation"] {
v.remove(k);
}
if let Some(name) = v.remove("name") {
v.insert("preset".into(), name);
}
d.fields.insert("bleedLinked".into(), json!(s.bleed.iter().all(|b| *b == s.bleed[0])));
for (k, val) in &v {
d.fields.insert(k.clone(), val.clone());
}
d.fields.insert(APPLIED.into(), Value::Object(v));
}
/// Keep the fields and the chosen preset in step: a new `preset` (also from `ui.dialog.set`) fills
/// the fields; a changed field makes the settings custom.
fn sync(app: &VectorcraftApp, d: &mut Dialog) {
let preset = d.str("preset");
match d.fields.get(APPLIED).and_then(Value::as_object) {
Some(a) if a.get("preset").and_then(Value::as_str) == Some(preset.as_str()) => {
if a.iter().any(|(k, v)| k != "preset" && d.fields.get(k) != Some(v)) {
d.fields.insert("preset".into(), json!(""));
d.fields.remove(APPLIED);
}
}
_ if !preset.is_empty() => match newdoc::find(&app.session, &preset) {
Some(s) => apply(d, &s),
None => {
d.fields.remove(APPLIED);
}
},
_ => {}
}
}
/// Create the document; the dialog stays open when that fails (bad size, …).
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let r = app.run(
"file.new",
json!({"width": d.f64("width", 612.0), "height": d.f64("height", 792.0), "units": d.str("units"), "title": d.str("name"), "artboards": d.f64("artboards", 1.0), "colorMode": d.str("colorMode").to_lowercase()}),
);
let mut p: Map<String, Value> = PARAMS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect();
// Recent entries such as "Custom" aren't presets file.new knows.
if newdoc::find(&app.session, &d.str("preset")).is_none() {
p.remove("preset");
}
let r = app.run("file.new", Value::Object(p));
if r.is_ok() {
app.ui.dialog = None;
}
r
}
/// The units the size fields show.
fn unit(d: &Dialog) -> Unit {
Unit::named(&d.str("units")).unwrap_or_default()
}
/// The artboard layout fields.
fn layout(d: &Dialog) -> (ArtboardLayout, u64, f64, bool) {
let l = d.fields.get("artboardLayout");
let get = |k: &str| l.and_then(|l| l.get(k));
(
get("layout").and_then(Value::as_str).and_then(ArtboardLayout::parse).unwrap_or_default(),
get("columns").and_then(Value::as_u64).unwrap_or(1),
get("spacing").and_then(Value::as_f64).unwrap_or(20.0),
get("rightToLeft").and_then(Value::as_bool).unwrap_or(false),
)
}
fn set_layout(d: &mut Dialog, key: &str, v: Value) {
let mut l = d.fields.get("artboardLayout").cloned().filter(Value::is_object).unwrap_or_else(|| json!({}));
l[key] = v;
d.fields.insert("artboardLayout".into(), l);
}
/// The dimmed backdrop and a centred dialog window in the shared dialog style.
fn window(ctx: &egui::Context, id: &str, margin: i8, add: impl FnOnce(&mut egui::Ui)) {
let t = Tokens::get(ctx);
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
});
egui::Window::new(id)
.id(egui::Id::new(("dialog", id)))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0])
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(margin)))
.show(ctx, add);
}
/// What the buttons of a window asked for.
#[derive(Default)]
struct Buttons {
create: bool,
close: bool,
}
impl Buttons {
/// Act on the buttons (Enter also creates) once the window is drawn.
fn finish(self, app: &mut VectorcraftApp, ctx: &egui::Context, d: Dialog) {
let create = self.create || ctx.input(|i| i.key_pressed(egui::Key::Enter)) && !ctx.memory(|m| m.focused().is_some());
app.ui.dialog = Some(d);
if self.close {
app.ui.dialog = None;
} else if create && let Err(e) = super::confirm(app) {
app.status(e);
}
}
}
fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some(mut d) = app.ui.dialog.clone() else { return };
sync(app, &mut d);
let t = Tokens::get(ctx);
let mut b = Buttons::default();
window(ctx, KIND, 0, |ui| {
ui.horizontal_top(|ui| {
egui::Frame::NONE.inner_margin(egui::Margin { left: 22, right: 14, top: 14, bottom: 18 }).show(ui, |ui| {
ui.vertical(|ui| {
ui.set_width(640.0);
ui.set_min_height(HEIGHT - 32.0);
presets(app, ui, &mut d);
});
});
egui::Frame::NONE.fill(t.panel_darker).inner_margin(egui::Margin::same(18)).show(ui, |ui| {
ui.vertical(|ui| {
ui.set_width(DETAILS);
ui.set_min_height(HEIGHT);
details(app, ui, &mut d, &mut b);
});
});
});
});
b.finish(app, ctx, d);
}
/// The category tabs and the chosen category's preset cards.
fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let t = Tokens::get(ui.ctx());
let names: Vec<&str> = newdoc::category_names().collect();
let cat = names.iter().position(|n| n.eq_ignore_ascii_case(&d.str("category"))).unwrap_or(0);
if let Some(i) = widgets::tab_bar(ui, &names, cat) {
d.fields.insert("category".into(), json!(names[i]));
}
let list = names.get(cat).and_then(|c| newdoc::category(&app.session, c)).unwrap_or_default();
ui.add_space(14.0);
let heading = if cat == 0 {
"RECENT"
} else if cat == 1 {
"SAVED"
} else {
"BLANK DOCUMENT PRESETS"
};
ui.label(egui::RichText::new(format!("{heading} ({})", list.len())).font(theme::semibold(11.0)).color(t.text_dim));
ui.add_space(10.0);
if list.is_empty() {
let hint = if cat == 1 { "Presets you save with the Save Preset button appear here." } else { "Documents you create appear here." };
ui.label(egui::RichText::new(hint).color(t.text_dim));
return;
}
egui::ScrollArea::vertical().id_salt(("newdoc-presets", cat)).max_height(HEIGHT - 110.0).auto_shrink([false, false]).show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(12.0, 12.0);
let (w, h, preset) = (d.f64("width", 0.0), d.f64("height", 0.0), d.str("preset"));
for s in &list {
let selected = d.fields.contains_key(APPLIED) && s.name == preset && s.width == w && s.height == h;
if preset_card(ui, s, selected).clicked() {
apply(d, s);
}
}
});
});
}
/// A preset card: a page-proportioned thumbnail, the name and the size (New Document, the Home
/// screen). Returns the card's response.
pub fn preset_card(ui: &mut egui::Ui, s: &DocSettings, selected: bool) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(egui::vec2(146.0, 150.0), egui::Sense::click());
let p = ui.painter();
p.rect_filled(r, egui::CornerRadius::same(6), if resp.hovered() || selected { t.hover } else { t.panel_darker });
if selected {
p.rect_stroke(r, egui::CornerRadius::same(6), egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
}
let k = (60.0 / s.width.max(s.height).max(1e-9)) as f32;
let page = egui::Rect::from_center_size(
r.center_top() + egui::vec2(0.0, 48.0),
egui::vec2((s.width as f32 * k).max(4.0), (s.height as f32 * k).max(4.0)),
);
p.rect_filled(page.translate(egui::vec2(2.0, 2.0)), 0.0, egui::Color32::from_black_alpha(80));
p.rect_filled(page, 0.0, egui::Color32::WHITE);
// The name on up to two lines, then the size.
let mut job = egui::text::LayoutJob::single_section(
s.name.clone(),
egui::TextFormat { font_id: theme::semibold(11.5), color: t.text_strong, ..Default::default() },
);
job.halign = egui::Align::Center;
job.wrap = egui::text::TextWrapping { max_width: r.width() - 14.0, max_rows: 2, break_anywhere: false, overflow_character: Some('…') };
let name = ui.fonts_mut(|f| f.layout_job(job));
let name_h = name.size().y;
p.galley(egui::pos2(r.center().x, r.top() + 94.0), name, t.text_strong);
p.text(egui::pos2(r.center().x, r.top() + 98.0 + name_h), egui::Align2::CENTER_TOP, s.size_label(), egui::FontId::proportional(11.0), t.text_dim);
resp.on_hover_text(&s.name)
}
/// The Preset Details column.
fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut Buttons) {
let t = Tokens::get(ui.ctx());
ui.spacing_mut().item_spacing = egui::vec2(6.0, 4.0);
let top = ui.cursor().top();
ui.label(egui::RichText::new("PRESET DETAILS").font(theme::semibold(11.0)).color(t.text_dim));
ui.add_space(8.0);
ui.horizontal(|ui| {
form::text(ui, d, "name", DETAILS - 50.0);
if widgets::icon_button(ui, "save", "Save Preset", d.bool(SAVING), 26.0).clicked() {
let saving = !d.bool(SAVING);
d.fields.insert(SAVING.into(), json!(saving));
if saving && d.str("presetName").is_empty() {
d.fields.insert("presetName".into(), json!(format!("Preset {}", app.session.prefs.new_doc_presets.len() + 1)));
}
}
});
if d.bool(SAVING) {
save_row(app, ui, d);
}
ui.add_space(8.0);
let unit = unit(d);
form::caption(ui, "Width");
ui.horizontal(|ui| {
form::length(ui, d, "width", unit, DETAILS - 124.0);
units_dropdown(ui, d, 112.0);
});
ui.add_space(4.0);
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
form::caption(ui, "Height");
form::length(ui, d, "height", unit, 104.0);
});
ui.add_space(6.0);
ui.vertical(|ui| {
form::caption(ui, "Orientation");
orientation(ui, d);
});
ui.add_space(6.0);
ui.vertical(|ui| {
form::caption(ui, "Artboards");
artboards(ui, d, 76.0);
});
});
ui.add_space(8.0);
form::caption(ui, "Bleed");
form::bleed(ui, d, unit, 52.0);
ui.add_space(8.0);
form::caption(ui, "Background Contents");
background(ui, d, DETAILS - 34.0);
ui.add_space(8.0);
advanced(ui, d, 110.0, DETAILS - 116.0);
ui.add_space(12.0);
if widgets::flat_button(ui, "More Settings", DETAILS).clicked() {
d.kind = MORE.into();
}
// Close and Create at the bottom right.
ui.add_space((HEIGHT - (ui.cursor().top() - top) - 28.0).max(12.0));
ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 28.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
b.create = widgets::primary_button(ui, "Create").clicked();
ui.add_space(8.0);
b.close = widgets::secondary_button(ui, "Close").clicked();
});
}
/// Save Preset: the preset's name, Save and Cancel.
fn save_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.add_space(4.0);
form::caption(ui, "Preset Name");
ui.horizontal(|ui| {
let enter = form::text_edit(ui, d, "presetName", DETAILS - 140.0).lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if widgets::flat_button(ui, "Save", 54.0).clicked() || enter {
let mut p: Map<String, Value> = PARAMS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect();
p.insert("name".into(), json!(d.str("presetName")));
p.remove("preset");
match app.run("file.newPresets.save", Value::Object(p)) {
Ok(r) => {
d.fields.insert(SAVING.into(), json!(false));
d.fields.insert("category".into(), json!("Saved"));
d.fields.insert("preset".into(), r["name"].clone());
d.fields.remove(APPLIED);
}
Err(e) => app.status(e),
}
}
if widgets::flat_button(ui, "Cancel", 60.0).clicked() {
d.fields.insert(SAVING.into(), json!(false));
}
});
}
fn units_dropdown(ui: &mut egui::Ui, d: &mut Dialog, width: f32) {
let labels = UNITS.map(Unit::label);
if let Some(u) = widgets::dropdown(ui, "newdoc-units", unit(d).label(), &labels, width).and_then(|i| UNITS.get(i)) {
d.fields.insert("units".into(), json!(u.label()));
}
}
/// Portrait and landscape: choosing the other one swaps width and height.
fn orientation(ui: &mut egui::Ui, d: &mut Dialog) {
let (w, h) = (d.f64("width", 612.0), d.f64("height", 792.0));
let landscape = w > h;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
for (is_landscape, tip) in [(false, "Portrait"), (true, "Landscape")] {
if widgets::orientation_button(ui, is_landscape, landscape == is_landscape, tip) && landscape != is_landscape {
d.fields.insert("width".into(), json!(h));
d.fields.insert("height".into(), json!(w));
}
}
});
}
fn artboards(ui: &mut egui::Ui, d: &mut Dialog, width: f32) {
let n = d.f64("artboards", 1.0);
ui.horizontal(|ui| {
if let Some(v) = widgets::spin_plain(ui, "newdoc-artboards", n, "", 0, width, 1.0, 1.0, &[]) {
d.fields.insert("artboards".into(), json!(v.round().clamp(1.0, MAX_ARTBOARDS as f64) as u64));
}
});
}
/// Background Contents with its chip: white, or white slashed in red for transparent.
fn background(ui: &mut egui::Ui, d: &mut Dialog, width: f32) {
let t = Tokens::get(ui.ctx());
let cur = d.str("backgroundContents");
let shown = BACKGROUNDS.iter().find(|(v, _)| *v == cur).map_or(BACKGROUNDS[0].1, |(_, l)| l);
ui.horizontal(|ui| {
if let Some((v, _)) = widgets::dropdown(ui, "newdoc-background", shown, &BACKGROUNDS.map(|b| b.1), width).and_then(|i| BACKGROUNDS.get(i)) {
d.fields.insert("backgroundContents".into(), json!(v));
}
let (chip, _) = ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::hover());
ui.painter().rect_filled(chip, 2.0, egui::Color32::WHITE);
if cur != "white" {
ui.painter().line_segment([chip.left_bottom(), chip.right_top()], egui::Stroke::new(1.5, t.bleed));
}
ui.painter().rect_stroke(chip, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
});
}
/// A dropdown bound to `d.fields[key]` over (value, label) pairs.
fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, options: &[(Value, String)], width: f32) {
let cur = d.fields.get(key).cloned().unwrap_or_default();
let shown = options
.iter()
.find(|(v, _)| *v == cur || v.as_f64().is_some_and(|x| cur.as_f64() == Some(x)))
.map_or_else(|| cur.to_string(), |(_, l)| l.clone());
let labels: Vec<&str> = options.iter().map(|(_, l)| l.as_str()).collect();
if let Some((v, _)) = widgets::dropdown(ui, ("newdoc", key), &shown, &labels, width).and_then(|i| options.get(i)) {
d.fields.insert(key.into(), v.clone());
}
}
/// The collapsible Advanced Options: Color Mode, Raster Effects and Preview Mode.
fn advanced(ui: &mut egui::Ui, d: &mut Dialog, label: f32, field: f32) {
let t = Tokens::get(ui.ctx());
let open = d.bool("advanced");
let resp = ui
.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 18.0), egui::Sense::hover());
crate::icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, r, t.icon);
ui.label(egui::RichText::new("Advanced Options").color(t.text));
})
.response
.interact(egui::Sense::click());
if resp.clicked() {
d.fields.insert("advanced".into(), json!(!open));
}
if !open {
return;
}
let colors: Vec<(Value, String)> = COLOR_MODES.iter().map(|(v, l)| (json!(v), l.to_string())).collect();
let ppi: Vec<(Value, String)> = RASTER_PPI.iter().map(|(v, l)| (json!(v), l.to_string())).collect();
let previews: Vec<(Value, String)> =
PreviewMode::ALL.iter().map(|m| (json!(m.id()), form::humanize(m.id()).trim_end_matches(':').to_string())).collect();
for (key, name, options) in
[("colorMode", "Color Mode", &colors), ("rasterEffectsPpi", "Raster Effects", &ppi), ("previewMode", "Preview Mode", &previews)]
{
widgets::label_row(ui, name, label, |ui| choice(ui, d, key, options, field));
}
}
/// More Settings: the classic form of the same fields.
fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some(mut d) = app.ui.dialog.clone() else { return };
sync(app, &mut d);
let t = Tokens::get(ctx);
let mut b = Buttons::default();
let mut templates = false;
const L: f32 = 150.0;
window(ctx, MORE, 22, |ui| {
ui.set_width(560.0);
ui.label(egui::RichText::new("More Settings").font(theme::semibold(16.0)).color(t.text));
ui.add_space(12.0);
widgets::label_row(ui, "Name:", L, |ui| {
form::text(ui, &mut d, "name", 300.0);
});
// Profile: the preset categories (the tabs); Size: the profile's presets.
let profiles: Vec<&str> = newdoc::CATEGORIES.iter().map(|c| c.0).collect();
let profile = profiles.iter().find(|p| p.eq_ignore_ascii_case(&d.str("category"))).copied().unwrap_or("[Custom]");
widgets::label_row(ui, "Profile:", L, |ui| {
if let Some(p) = widgets::dropdown(ui, "newdoc-profile", profile, &profiles, 220.0).and_then(|i| profiles.get(i)) {
d.fields.insert("category".into(), json!(p));
if let Some(first) = newdoc::category(&app.session, p).and_then(|v| v.into_iter().next()) {
apply(&mut d, &first);
}
}
});
ui.add_space(6.0);
let (lay, cols, spacing, rtl) = layout(&d);
let n = d.f64("artboards", 1.0);
let several = n > 1.0;
widgets::label_row(ui, "Number of Artboards:", L, |ui| {
artboards(ui, &mut d, 76.0);
ui.add_space(10.0);
for (l, icon) in [
(ArtboardLayout::GridByRow, "dc-grid-view"),
(ArtboardLayout::GridByColumn, "grid-3x3"),
(ArtboardLayout::Row, "arrow-left-right"),
(ArtboardLayout::Column, "arrow-up-down"),
] {
if widgets::icon_button_enabled(ui, icon, l.label(), lay == l, several, 26.0).clicked() {
set_layout(&mut d, "layout", json!(l.id()));
}
}
ui.add_space(6.0);
if widgets::icon_button_enabled(ui, "chevrons-left", "Change to Right-to-Left Layout", rtl, several, 26.0).clicked() {
set_layout(&mut d, "rightToLeft", json!(!rtl));
}
});
ui.add_enabled_ui(several, |ui| {
widgets::label_row(ui, "Spacing:", L, |ui| {
if let Some(v) = widgets::num_field(ui, "newdoc-spacing", Some(spacing), unit(&d), 100.0) {
set_layout(&mut d, "spacing", json!(v.max(0.0)));
}
ui.add_space(20.0);
ui.label(egui::RichText::new("Columns:").color(t.text));
let grid = matches!(lay, ArtboardLayout::GridByRow | ArtboardLayout::GridByColumn);
ui.add_enabled_ui(grid, |ui| {
if let Some(v) = widgets::spin_plain(ui, "newdoc-columns", cols as f64, "", 0, 76.0, 1.0, 1.0, &[]) {
set_layout(&mut d, "columns", json!(v.round().clamp(1.0, n) as u64));
}
});
});
});
ui.add_space(6.0);
let sizes = newdoc::category(&app.session, profile).unwrap_or_default();
let size_names: Vec<&str> = sizes.iter().map(|s| s.name.as_str()).collect();
let preset = d.str("preset");
widgets::label_row(ui, "Size:", L, |ui| {
let shown = if preset.is_empty() { "Custom" } else { preset.as_str() };
if let Some(s) = widgets::dropdown(ui, "newdoc-size", shown, &size_names, 220.0).and_then(|i| sizes.get(i)) {
apply(&mut d, s);
}
});
let unit = unit(&d);
widgets::label_row(ui, "Width:", L, |ui| {
form::length(ui, &mut d, "width", unit, 120.0);
ui.add_space(20.0);
ui.label(egui::RichText::new("Units:").color(t.text));
units_dropdown(ui, &mut d, 120.0);
});
widgets::label_row(ui, "Height:", L, |ui| {
form::length(ui, &mut d, "height", unit, 120.0);
ui.add_space(20.0);
ui.label(egui::RichText::new("Orientation:").color(t.text));
orientation(ui, &mut d);
});
ui.add_space(4.0);
widgets::label_row(ui, "Bleed:", L, |ui| form::bleed(ui, &mut d, unit, 64.0));
ui.add_space(4.0);
widgets::label_row(ui, "Background Contents:", L, |ui| background(ui, &mut d, 220.0));
ui.add_space(8.0);
advanced(ui, &mut d, L, 220.0);
ui.add_space(16.0);
ui.horizontal(|ui| {
templates = widgets::secondary_button(ui, "Templates…").on_hover_text("New from Template").clicked();
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
b.create = widgets::primary_button(ui, "Create Document").clicked();
ui.add_space(8.0);
// Cancel goes back to New Document.
if widgets::secondary_button(ui, "Cancel").clicked() {
d.kind = KIND.into();
}
});
});
});
if templates {
app.ui.dialog = None;
if let Err(e) = app.run("file.newFromTemplate", json!({})) {
app.status(e);
}
return;
}
b.finish(app, ctx, d);
}
#[cfg(test)]
mod tests {
use serde_json::json;
use vectorcraft_engine::Session;
use crate::VectorcraftApp;
fn frame(app: &mut VectorcraftApp) -> egui::FullOutput {
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(1400.0, 1000.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx()));
out.textures_delta.clear();
out
}
fn set(app: &mut VectorcraftApp, k: &str, v: serde_json::Value) {
app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v);
}
fn field(app: &VectorcraftApp, k: &str) -> serde_json::Value {
app.ui.dialog.as_ref().unwrap().fields.get(k).cloned().unwrap_or_default()
}
#[test]
fn a_preset_fills_the_details_and_create_makes_the_document() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.newDialog", json!({})).unwrap();
frame(&mut app);
assert_eq!((field(&app, "category"), field(&app, "preset"), field(&app, "name")), (json!("Print"), json!("Letter"), json!("Untitled-1")));
// Choosing a preset (as `ui.dialog.set` does) fills the fields.
set(&mut app, "category", json!("Film & Video"));
set(&mut app, "preset", json!("4K UHD"));
frame(&mut app);
assert_eq!((field(&app, "width"), field(&app, "units"), field(&app, "colorMode")), (json!(3840.0), json!("Pixels"), json!("rgb")));
set(&mut app, "artboards", json!(4));
set(&mut app, "artboardLayout", json!({"layout": "gridByRow", "columns": 2, "spacing": 10}));
set(&mut app, "bleed", json!([0, 0, 0, 0]));
set(&mut app, "previewMode", json!("pixel"));
set(&mut app, "backgroundContents", json!("white"));
frame(&mut app);
assert_eq!(field(&app, "preset"), json!(""), "editing makes the settings custom");
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let st = app.session.active().unwrap();
let rects: Vec<_> = st.doc.artboards.iter().map(|a| (a.rect.x0, a.rect.y0)).collect();
assert_eq!(rects, [(0.0, 0.0), (3850.0, 0.0), (0.0, 2170.0), (3850.0, 2170.0)]);
assert_eq!(st.doc.setup.background, vectorcraft_doc::Background::White);
assert!(app.ui.view.pixel_preview, "Pixel preview mode");
// Next time Recent opens on it.
app.run("file.newDialog", json!({})).unwrap();
assert_eq!((field(&app, "category"), field(&app, "name")), (json!("Recent"), json!("Untitled-2")));
frame(&mut app);
}
#[test]
fn every_category_and_more_settings_draw_and_save_preset_saves() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.newDialog", json!({})).unwrap();
set(&mut app, "advanced", json!(true));
for c in vectorcraft_engine::cmd::newdoc::category_names() {
set(&mut app, "category", json!(c));
let out = frame(&mut app);
assert!(!out.shapes.is_empty());
}
// More Settings shares the fields; Cancel goes back.
app.ui.dialog.as_mut().unwrap().kind = super::MORE.into();
set(&mut app, "artboards", json!(3));
frame(&mut app);
assert_eq!(field(&app, "artboards"), json!(3));
// Save Preset.
set(&mut app, "category", json!("Print"));
set(&mut app, "preset", json!("A5"));
frame(&mut app);
let p = app.run("file.newPresets.save", json!({"name": "Zine", "preset": "A5"}));
assert!(p.is_ok());
app.ui.dialog.as_mut().unwrap().kind = super::KIND.into();
set(&mut app, "category", json!("Saved"));
set(&mut app, "preset", json!("Zine"));
frame(&mut app);
assert!((field(&app, "width").as_f64().unwrap() - 148.0 * 72.0 / 25.4).abs() < 1e-6, "A5 wide");
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(app.session.prefs.recent_new_docs[0].name, "Zine");
}
}
@@ -72,6 +72,10 @@ fn first_use() -> Map<String, Value> {
/// 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() {
+7 -1
View File
@@ -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)]
@@ -252,9 +254,13 @@ impl VectorcraftApp {
self.sync_views();
match &r {
Err(e) => self.ui.status = e.clone(),
Ok(_) => {
Ok(v) => {
if id == "file.new" {
self.ui.status.clear();
// New Document's Pixel preview mode (Overprint Preview is the engine's).
if v["previewMode"] == "pixel" {
self.ui.view.pixel_preview = true;
}
}
if id == "text.setStyle"
&& let Some(font) = params.get("font").and_then(Value::as_str)
+1 -5
View File
@@ -464,11 +464,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
}
"file.exportForScreens" => 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)
+109
View File
@@ -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<egui::Shape>, Option<Vec<egui::Color32>>, Option<egui::Mesh>) {
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<egui::Shape> = 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<egui::Shape>) {
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<Vec<egui::Pos2>> {
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");
}
+10 -9
View File
@@ -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]
+3
View File
@@ -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,
+50
View File
@@ -1197,3 +1197,53 @@ 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<usize> {
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
}
/// A page-orientation toggle drawn in code: a portrait or landscape sheet with a folded corner,
/// accent-filled when `selected`. Returns clicked.
pub fn orientation_button(ui: &mut Ui, landscape: bool, selected: bool, tip: &str) -> bool {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::click());
if resp.hovered() && !selected {
ui.painter().rect_filled(rect, CornerRadius::same(3), t.hover);
}
let size = if landscape { vec2(16.0, 12.0) } else { vec2(12.0, 16.0) };
let page = Rect::from_center_size(rect.center(), size);
let color = if selected { t.accent } else { t.icon };
let fold = 4.0;
let outline =
vec![page.left_top(), page.right_top() - vec2(fold, 0.0), page.right_top() + vec2(0.0, fold), page.right_bottom(), page.left_bottom()];
let fill = if selected { t.accent } else { Color32::TRANSPARENT };
ui.painter().add(egui::Shape::convex_polygon(outline.clone(), fill, Stroke::new(1.2, color)));
let corner = [page.right_top() - vec2(fold, 0.0), page.right_top() + vec2(-fold, fold), page.right_top() + vec2(0.0, fold)];
ui.painter().add(egui::Shape::line(corner.to_vec(), Stroke::new(1.2, if selected { t.panel } else { color })));
resp.on_hover_text(tip).clicked()
}
+17
View File
@@ -204,3 +204,20 @@ 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.
New Document: File → New… (`file.newDialog`, Cmd+N) opens the `newDocument` dialog. Its fields are `file.new`'s params
(`preset`, `name`, `width` and `height` in pt, `units`, `artboards`, `artboardLayout`, `bleed`, `backgroundContents`,
`colorMode`, `rasterEffectsPpi`, `previewMode`) plus `category` (the tab: Recent, Saved, Mobile, Web, Print, Film &
Video, Art & Illustration, Branding, Social), `advanced` (Advanced Options open), `bleedLinked` and `presetName`
(Save Preset's name). Setting `preset` to a name from `file.newPresets` fills the other fields from that preset;
changing a field afterwards makes the settings custom (`preset` becomes ""). More Settings is the `newDocumentMore`
dialog over the same fields (its Profile is `category`). `ui.dialog.confirm` runs `file.new` with the fields (with
`previewMode: "pixel"` the app turns Pixel Preview on); on an error the dialog stays open.
+27
View File
@@ -779,3 +779,30 @@ 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}}}
```
## New Document
`file.new` takes everything New Document sets: `preset` (a name from `file.newPresets`), `name`, `width`/`height`
(points, or lengths such as `"210 mm"`), `units`, `orientation`, `artboards` with `artboardLayout {layout, columns,
spacing, rightToLeft}`, `bleed`, `backgroundContents`, `colorMode`, `rasterEffectsPpi` and `previewMode`.
`file.newPresets {category?}` lists the categories and presets (Recent: the last sizes used; Saved: the user's presets,
kept in the preferences by `file.newPresets.save` and removed by `file.newPresets.delete`). A listed preset can be
passed straight back to `file.new`. Print presets and sizes without `units` start in `unitsGeneral`; screen presets
(mobile, web, video, social) in Pixels.
```json
{"name":"run_command","arguments":{"command":"file.new","params":{"preset":"A4","orientation":"landscape","artboards":4,"artboardLayout":{"columns":2},"bleed":9}}}
```