M4.20: Document Setup with bleed, transparency grid, paper and type options
File > Document Setup is a tabbed dialog over one engine command, document.setup (no params: a query; any keys: one undo step; document.setUnits stays as the units alias). - General: units, bleed (four sides with a link toggle), Edit Artboards, Show Images in Outline Mode (greyscale pixels under the frame), Highlight Substituted Fonts/Glyphs (pink boxes on screen only). - Transparency: grid size and colour presets or custom colours, Simulate Colored Paper, the flattener preset (built-in or saved) and Discard White Overprint (Overprint Preview keeps white overprints visible). - Type: language with its quotes, Use Typographer's Quotes for the Type tool and Smart Punctuation, superscript/subscript size and position and small caps size (Character panel position/smallCaps; changing the setup restyles the text in the same undo step), text export (SVG text as text or outlines by default). - The canvas draws a red bleed outline and the grid in the document's colours and cell size as one textured quad, also in rotated views; a white Background Contents exports raster formats opaque. Model: Document.setup (DocSetup, defaulted, skipped when default) and CharStyle position/small_caps; old files load unchanged. tests_transparencygrid's helper now finds the grid by its texture (the grid is one textured mesh instead of per-cell rectangles). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
25df58e722
commit
d527349f5a
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"))?;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ mod create;
|
|||
pub(crate) mod distortcmds;
|
||||
mod docinfo;
|
||||
mod docmenu;
|
||||
pub(crate) mod docsetup;
|
||||
mod draw2;
|
||||
mod edit;
|
||||
mod effectcmd;
|
||||
|
|
@ -211,6 +212,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(newart::specs());
|
||||
v.extend(colortheme::specs());
|
||||
v.extend(fileio::pdf::specs());
|
||||
v.extend(docsetup::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -909,6 +909,8 @@ mod tests_dashalign;
|
|||
#[cfg(test)]
|
||||
mod tests_distort;
|
||||
#[cfg(test)]
|
||||
mod tests_docsetup;
|
||||
#[cfg(test)]
|
||||
mod tests_draw2;
|
||||
#[cfg(test)]
|
||||
mod tests_editcolors;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,55 @@ pub(super) fn commit_preview(app: &mut crate::VectorcraftApp, cmd: &str, params:
|
|||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// The `[top, bottom, left, right]` bleed in `d.fields["bleed"]` (points).
|
||||
pub(super) fn bleed_values(d: &Dialog) -> [f64; 4] {
|
||||
let mut out = [0.0; 4];
|
||||
if let Some(a) = d.fields.get("bleed").and_then(Value::as_array) {
|
||||
for (o, v) in out.iter_mut().zip(a) {
|
||||
*o = v.as_f64().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bleed fields (Document Setup, New Document): Top, Bottom, Left and Right shown in `unit`, each
|
||||
/// `width` wide with a caption above, and a link toggle (`bleedLinked`, on by default) that keeps
|
||||
/// them equal. Values are clamped to 0–72 pt.
|
||||
pub(super) fn bleed(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit, width: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut b = bleed_values(d);
|
||||
let linked = d.fields.get("bleedLinked").and_then(Value::as_bool).unwrap_or(true);
|
||||
let mut changed = None;
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
for (i, side) in ["Top", "Bottom", "Left", "Right"].into_iter().enumerate() {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(side).size(11.0).color(t.text_dim));
|
||||
if let Some(v) = crate::widgets::num_field(ui, ("bleed", i), Some(b[i]), unit, width) {
|
||||
changed = Some((i, v.clamp(0.0, vectorcraft_doc::setup::MAX_BLEED)));
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(15.0);
|
||||
let tip = if linked { "Make the bleed values differ" } else { "Make all bleed settings the same" };
|
||||
if crate::widgets::icon_button(ui, if linked { "link" } else { "link-2-off" }, tip, linked, 24.0).clicked() {
|
||||
d.fields.insert("bleedLinked".into(), json!(!linked));
|
||||
if !linked {
|
||||
// Linking makes every side the top's.
|
||||
changed = Some((0, b[0]));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some((i, v)) = changed {
|
||||
// Linked (also just now): every side takes the value.
|
||||
if d.fields.get("bleedLinked").and_then(Value::as_bool).unwrap_or(true) {
|
||||
b = [v; 4];
|
||||
} else {
|
||||
b[i] = v;
|
||||
}
|
||||
d.fields.insert("bleed".into(), json!(b));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ pub mod width_point;
|
|||
use serde_json::{Value, json};
|
||||
|
||||
pub use color_picker::open as open_color_picker;
|
||||
pub use document_setup::open as open_document_setup;
|
||||
pub use effect::open as open_effect_dialog;
|
||||
pub use new_document::open as open_new_document;
|
||||
pub use save_pdf::open as open_save_pdf;
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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,32 @@ pub fn harmony_wheel(ui: &mut Ui, id: &str, size: f32, colors: &[Color], base: O
|
|||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// A row of text tabs, the current one underlined in the accent colour (New Document's
|
||||
/// categories, Document Setup's sections). Returns the clicked tab's index.
|
||||
pub fn tab_bar(ui: &mut Ui, tabs: &[&str], current: usize) -> Option<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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -204,3 +204,11 @@ length preferences in `preferences`) show in the General unit (the active docume
|
|||
document). Through `ui.dialog.set` a number is in points and a string may carry its unit (`"10 mm"`; a bare number in a
|
||||
string is points). The `preferences` dialog's `unitsGeneral` starts as the active document's units, and OK sets them.
|
||||
`newDocument` has `units` (a unit label; print presets start in `unitsGeneral`, screen presets in Pixels).
|
||||
|
||||
Document Setup: File → Document Setup… (`file.documentSetup`, Cmd+Alt+P) opens the `documentSetup` dialog. Its fields
|
||||
are what `document.setup` reports (`units`, `bleed` [top, bottom, left, right] in pt, `outlineImages`,
|
||||
`highlightSubstitutedFonts`, `highlightSubstitutedGlyphs`, `gridSize`, `gridColors`, `simulatePaper`, `flattenerPreset`,
|
||||
`discardWhiteOverprint`, `language`, `quotes`, `typographersQuotes`, `superscript`, `subscript`, `smallCapsSize`,
|
||||
`exportText`, `backgroundContents`) plus `tab` (`General`, `Transparency` or `Type`) and `bleedLinked`.
|
||||
`ui.dialog.confirm` runs `document.setup` with them as one undo step; a bad value answers with an error and the dialog
|
||||
stays open.
|
||||
|
|
|
|||
13
docs/mcp.md
13
docs/mcp.md
|
|
@ -779,3 +779,16 @@ a string with a unit. Unit names: `Points`, `Picas`, `Inches`, `Millimeters`, `C
|
|||
```json
|
||||
{"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"unitsGeneral","value":"millimeters"}}}
|
||||
```
|
||||
|
||||
## Document Setup
|
||||
|
||||
`document.setup` with no params reports the document setup: units, the bleed ([top, bottom, left, right] in pt, drawn
|
||||
as a red outline around each artboard), the transparency grid (size and two colours; the first is also the simulated
|
||||
paper colour), the flattener preset and Discard White Overprint (Overprint Preview keeps white overprints visible while
|
||||
it is on), the type options (language and its quotes, Use Typographer's Quotes for typed quotes, superscript, subscript
|
||||
and small caps proportions, SVG text export) and the background contents (white: raster exports are white behind the
|
||||
art). Pass any of these keys to change them in one undo step; `document.setUnits` stays as an alias for `units`.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"document.setup","params":{"gridColors":"Blue","gridSize":"large","language":"German","bleed":9}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue