mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 06:07:06 -06:00
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:
+45
-2
@@ -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));
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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)?;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}}}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user