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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 08:28:16 +07:00
parent 25df58e722
commit d527349f5a
32 changed files with 1804 additions and 129 deletions

View File

@ -16,6 +16,7 @@ pub mod pattern;
mod pixels;
mod reach;
pub mod selection;
pub mod setup;
pub mod style_libs;
pub mod swatches;
pub mod text;
@ -50,10 +51,11 @@ pub use node::{ImageMap, ObjectAttributes};
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask};
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
pub use selection::{AnchorRef, Selection};
pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
pub use style_libs::StyleLibrary;
pub use text::{
AreaOptions, CharStyle, FirstBaseline, Justify, ParaStyle, PathEffect, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap,
WrapShape,
AreaOptions, CharPosition, CharStyle, FirstBaseline, Justify, ParaStyle, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject,
TextRun, TextStyleDef, TextWrap, WrapShape,
};
pub use vectorcraft_color as color;
pub use vectorcraft_geom as geom;
@ -467,6 +469,9 @@ pub struct Document {
/// ([`Document::linked_color`]).
#[serde(default = "yes", skip_serializing_if = "skip::is_true")]
pub spot_use_lab: bool,
/// File → Document Setup (bleed, transparency grid, paper, type options).
#[serde(default, skip_serializing_if = "skip::is_default")]
pub setup: DocSetup,
}
fn ppi72() -> f64 {
@ -522,6 +527,7 @@ impl Document {
page_isolate: false,
page_knockout: false,
spot_use_lab: true,
setup: DocSetup::default(),
};
let id = d.alloc_id();
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
@ -919,6 +925,43 @@ mod tests {
assert!(!ids.contains(&a) && !ids.contains(&b));
}
#[test]
fn setup_round_trips_and_old_files_load() {
let (mut d, _, _) = doc_with_rects();
// A document without a setup writes no `setup` key (old readers and files stay unchanged).
assert!(!serde_json::to_string(&d).unwrap().contains("\"setup\""));
d.setup.bleed = [9.0, 9.0, 0.0, 18.0];
d.setup.grid_size = GridSize::Large;
d.setup.typographers_quotes = false;
d.setup.quotes = setup::language_quotes("German").unwrap();
d.setup.flattener_preset = Some("High Resolution".into());
d.setup.background = Background::White;
d.setup.export_text = ExportText::Appearance;
let back: Document = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap();
assert_eq!(back.setup, d.setup);
// Files written before Document Setup existed load with the defaults.
let mut v = serde_json::to_value(&d).unwrap();
v.as_object_mut().unwrap().remove("setup");
let old: Document = serde_json::from_value(v).unwrap();
assert_eq!(old.setup, DocSetup::default());
}
#[test]
fn char_position_round_trips() {
let st = CharStyle {
position: CharPosition::Subscript(ScriptMetrics { size: 50.0, position: 20.0 }),
small_caps: Some(75.0),
..CharStyle::default()
};
let v = serde_json::to_value(&st).unwrap();
assert_eq!(v["position"], serde_json::json!({"kind": "subscript", "size": 50.0, "position": 20.0}));
assert_eq!(serde_json::from_value::<CharStyle>(v).unwrap(), st);
let plain = serde_json::to_value(CharStyle::default()).unwrap();
assert!(plain.get("position").is_none() && plain.get("small_caps").is_none());
let (scale, shift) = CharPosition::Subscript(ScriptMetrics::DEFAULT).scale_shift(10.0);
assert!((scale - 0.583).abs() < 1e-9 && (shift + 3.33).abs() < 1e-9);
}
#[test]
fn units() {
assert_eq!(Unit::Inches.parse("1"), Some(72.0));

318
crates/doc/src/setup.rs Normal file
View File

@ -0,0 +1,318 @@
//! Document Setup (File → Document Setup): the bleed around the artboards, view options
//! (transparency grid, simulated paper, images in outline mode, substitution highlights), output
//! options and the document's typographic defaults. Every field is defaulted, so documents saved
//! before Document Setup existed load unchanged.
use serde::{Deserialize, Serialize};
use vectorcraft_color::Color;
use vectorcraft_geom::Rect;
use crate::text::ScriptMetrics;
/// Transparency grid cell size.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GridSize {
Small,
#[default]
Medium,
Large,
}
impl GridSize {
pub const ALL: [GridSize; 3] = [GridSize::Small, GridSize::Medium, GridSize::Large];
pub fn id(self) -> &'static str {
match self {
GridSize::Small => "small",
GridSize::Medium => "medium",
GridSize::Large => "large",
}
}
pub fn label(self) -> &'static str {
match self {
GridSize::Small => "Small",
GridSize::Medium => "Medium",
GridSize::Large => "Large",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|g| g.id().eq_ignore_ascii_case(s))
}
/// Edge of one grid cell in screen points (the grid does not scale with the zoom).
pub fn cell(self) -> f32 {
match self {
GridSize::Small => 4.0,
GridSize::Medium => 8.0,
GridSize::Large => 16.0,
}
}
}
const fn hex(v: u32) -> Color {
Color::Rgb { r: ((v >> 16) & 0xff) as f32 / 255.0, g: ((v >> 8) & 0xff) as f32 / 255.0, b: (v & 0xff) as f32 / 255.0 }
}
/// Transparency grid colour presets (VectorCraft's own pairs). Any other pair is "Custom".
pub const GRID_COLOR_PRESETS: &[(&str, [Color; 2])] = &[
("Light", [hex(0xffffff), hex(0xcccccc)]),
("Medium", [hex(0xb3b3b3), hex(0x8c8c8c)]),
("Dark", [hex(0x666666), hex(0x4d4d4d)]),
("Red", [hex(0xffffff), hex(0xffc7c7)]),
("Orange", [hex(0xffffff), hex(0xffdcb8)]),
("Green", [hex(0xffffff), hex(0xc6efc6)]),
("Blue", [hex(0xffffff), hex(0xc4dcff)]),
("Purple", [hex(0xffffff), hex(0xe2c9ff)]),
];
/// How text is written to formats without live type (Document Setup → Type → Export).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ExportText {
/// Keep text as text (editable).
#[default]
Editable,
/// Write glyph outlines (exact appearance without the fonts).
Appearance,
}
impl ExportText {
pub fn id(self) -> &'static str {
match self {
ExportText::Editable => "editable",
ExportText::Appearance => "appearance",
}
}
pub fn parse(s: &str) -> Option<Self> {
[ExportText::Editable, ExportText::Appearance].into_iter().find(|e| e.id().eq_ignore_ascii_case(s))
}
}
/// Quote characters (Document Setup → Type): opening and closing double and single quotes.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Quotes {
pub double: [char; 2],
pub single: [char; 2],
}
impl Default for Quotes {
fn default() -> Self {
Self::ENGLISH
}
}
impl Quotes {
pub const ENGLISH: Self = Self { double: ['“', '”'], single: ['‘', '’'] };
/// Double-quote pairs offered by Document Setup.
pub const DOUBLE_CHOICES: &[[char; 2]] = &[['“', '”'], ['„', '“'], ['„', '”'], ['”', '”'], ['«', '»'], ['»', '«'], ['「', '」'], ['"', '"']];
/// Single-quote pairs offered by Document Setup.
pub const SINGLE_CHOICES: &[[char; 2]] = &[['‘', '’'], ['‚', '‘'], ['‚', '’'], ['’', '’'], ['‹', '›'], ['›', '‹'], ['『', '』'], ['\'', '\'']];
/// Is `c` one of these opening quotes?
fn is_open(&self, c: char) -> bool {
c == self.double[0] || c == self.single[0]
}
/// A quote opens after nothing, whitespace, an opening bracket, a dash or another opening quote.
fn opens(&self, prev: Option<char>) -> bool {
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || self.is_open(p))
}
/// `text` with straight quotes (`"` and `'`) replaced by these quotes: opening or closing by
/// context (an apostrophe is a closing single quote). `prev` is the character before `text`,
/// and is updated to its last character, so text can be converted in pieces.
pub fn apply(&self, text: &str, prev: &mut Option<char>) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
let r = match c {
'"' => self.double[usize::from(!self.opens(*prev))],
'\'' => self.single[usize::from(!self.opens(*prev))],
c => c,
};
out.push(r);
*prev = Some(r);
}
out
}
}
/// Document languages and the quote characters each uses (choosing a language in Document Setup
/// also picks its quotes).
pub const LANGUAGES: &[(&str, Quotes)] = &[
("English: USA", Quotes::ENGLISH),
("English: UK", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("German", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
("German: Swiss", Quotes { double: ['«', '»'], single: ['‹', '›'] }),
("French", Quotes { double: ['«', '»'], single: ['‹', '›'] }),
("Italian", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Spanish", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Portuguese", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Dutch", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("Danish", Quotes { double: ['»', '«'], single: ['›', '‹'] }),
("Swedish", Quotes { double: ['”', '”'], single: ['’', '’'] }),
("Finnish", Quotes { double: ['”', '”'], single: ['’', '’'] }),
("Norwegian", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Polish", Quotes { double: ['„', '”'], single: ['‚', '’'] }),
("Czech", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
("Russian", Quotes { double: ['«', '»'], single: ['„', '“'] }),
("Japanese", Quotes { double: ['「', '」'], single: ['『', '』'] }),
("Chinese", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
];
/// The quotes of a [`LANGUAGES`] entry (case-insensitive).
pub fn language_quotes(language: &str) -> Option<Quotes> {
LANGUAGES.iter().find(|(l, _)| l.eq_ignore_ascii_case(language)).map(|(_, q)| *q)
}
/// The transparency flattener preset Document Setup uses unless another is chosen.
pub const DEFAULT_FLATTENER: &str = "Medium Resolution";
/// Background Contents (New Document): what shows behind the art on the artboards, and what
/// raster exports put there.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Background {
#[default]
Transparent,
/// A white artboard background (not an object): raster exports are opaque white there.
White,
}
impl Background {
pub const ALL: [Background; 2] = [Background::Transparent, Background::White];
pub fn id(self) -> &'static str {
match self {
Background::Transparent => "transparent",
Background::White => "white",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|b| b.id().eq_ignore_ascii_case(s))
}
}
/// Largest bleed on any side, in points (1 inch).
pub const MAX_BLEED: f64 = 72.0;
/// File → Document Setup.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DocSetup {
/// Bleed beyond every artboard edge in points: top, bottom, left, right.
pub bleed: [f64; 4],
/// Show Images in Outline Mode.
pub outline_images: bool,
/// Highlight text whose font is missing (drawn in a substitute font).
pub highlight_substituted_fonts: bool,
/// Highlight glyphs the font lacks (drawn from a fallback font).
pub highlight_substituted_glyphs: bool,
/// Transparency grid cell size.
pub grid_size: GridSize,
/// Transparency grid colours; the first is also the simulated paper colour.
pub grid_colors: [Color; 2],
/// Simulate Colored Paper: artboards show the paper colour and art is multiplied onto it.
pub simulate_paper: bool,
/// Transparency flattener preset for output that can't keep transparency (None =
/// [`DEFAULT_FLATTENER`]).
pub flattener_preset: Option<String>,
/// Discard White Overprint in Output.
pub discard_white_overprint: bool,
/// Document language (one of [`LANGUAGES`]).
pub language: String,
pub quotes: Quotes,
/// Use Typographer's Quotes: typed straight quotes become [`DocSetup::quotes`].
pub typographers_quotes: bool,
pub superscript: ScriptMetrics,
pub subscript: ScriptMetrics,
/// Synthesized small capitals, percent of the font size.
pub small_caps_size: f64,
pub export_text: ExportText,
/// Background Contents chosen in New Document.
pub background: Background,
}
impl Default for DocSetup {
fn default() -> Self {
Self {
bleed: [0.0; 4],
outline_images: false,
highlight_substituted_fonts: true,
highlight_substituted_glyphs: false,
grid_size: GridSize::Medium,
grid_colors: GRID_COLOR_PRESETS[0].1,
simulate_paper: false,
flattener_preset: None,
discard_white_overprint: true,
language: LANGUAGES[0].0.to_string(),
quotes: Quotes::ENGLISH,
typographers_quotes: true,
superscript: ScriptMetrics::DEFAULT,
subscript: ScriptMetrics::DEFAULT,
small_caps_size: 70.0,
export_text: ExportText::Editable,
background: Background::Transparent,
}
}
}
impl DocSetup {
/// `artboard` grown by the bleed.
pub fn bleed_rect(&self, artboard: Rect) -> Rect {
let [top, bottom, left, right] = self.bleed;
Rect::new(artboard.x0 - left, artboard.y0 - top, artboard.x1 + right, artboard.y1 + bottom)
}
pub fn has_bleed(&self) -> bool {
self.bleed.iter().any(|b| *b > 0.0)
}
/// The simulated paper colour.
pub fn paper(&self) -> Color {
self.grid_colors[0]
}
/// The name of the grid colour preset in use, or "Custom".
pub fn grid_colors_name(&self) -> &'static str {
GRID_COLOR_PRESETS.iter().find(|(_, c)| *c == self.grid_colors).map_or("Custom", |(n, _)| n)
}
/// The flattener preset in use.
pub fn flattener(&self) -> &str {
self.flattener_preset.as_deref().unwrap_or(DEFAULT_FLATTENER)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quotes_open_and_close_by_context() {
let mut prev = None;
assert_eq!(Quotes::ENGLISH.apply("\"Hi,\" it's 'x'", &mut prev), "“Hi,” it’s ‘x’");
let german = language_quotes("german").unwrap();
let mut prev = Some('(');
assert_eq!(german.apply("\"a\"", &mut prev), "„a“");
assert_eq!(prev, Some('“'));
// Converting in pieces keeps the context.
let mut prev = None;
let a = Quotes::ENGLISH.apply("\"", &mut prev);
let b = Quotes::ENGLISH.apply("\"", &mut prev);
assert_eq!((a.as_str(), b.as_str()), ("“", "“"));
}
#[test]
fn bleed_grows_the_artboard() {
let s = DocSetup { bleed: [9.0, 9.0, 4.0, 2.0], ..Default::default() };
assert_eq!(s.bleed_rect(Rect::new(0.0, 0.0, 100.0, 50.0)), Rect::new(-4.0, -9.0, 102.0, 59.0));
assert!(s.has_bleed() && !DocSetup::default().has_bleed());
}
#[test]
fn presets_and_defaults() {
let d = DocSetup::default();
assert_eq!(d.grid_colors_name(), "Light");
assert_eq!(d.flattener(), "Medium Resolution");
assert_eq!(Background::parse("WHITE"), Some(Background::White));
assert_eq!(GridSize::parse("LARGE"), Some(GridSize::Large));
assert_eq!(ExportText::parse("appearance"), Some(ExportText::Appearance));
// A partial setup fills in the rest.
let s: DocSetup = serde_json::from_str(r#"{"bleed":[1,2,3,4]}"#).unwrap();
assert_eq!(s, DocSetup { bleed: [1.0, 2.0, 3.0, 4.0], ..DocSetup::default() });
}
}

View File

@ -76,6 +76,62 @@ pub struct CharStyle {
pub stroke_miter_limit: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stroke_dash: Option<Dash>,
/// Superscript or subscript (Character panel).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub position: CharPosition,
/// Small Caps (Character panel): lowercase letters drawn as capitals at this percentage of the
/// size (Document Setup → Type → Small Caps); None = off.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub small_caps: Option<f64>,
}
/// Superscript or subscript proportions in percent of the font size (Document Setup → Type).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct ScriptMetrics {
/// Glyph size.
pub size: f64,
/// Baseline offset: up for superscript, down for subscript.
pub position: f64,
}
impl ScriptMetrics {
pub const DEFAULT: Self = Self { size: 58.3, position: 33.3 };
}
impl Default for ScriptMetrics {
fn default() -> Self {
Self::DEFAULT
}
}
/// Character position (Character panel Superscript / Subscript). The proportions are the
/// document's when the position is applied, and follow later Document Setup changes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum CharPosition {
#[default]
Normal,
Superscript(ScriptMetrics),
Subscript(ScriptMetrics),
}
impl CharPosition {
/// Glyph scale and baseline shift (points, positive = up) for text of `size` points.
pub fn scale_shift(self, size: f64) -> (f64, f64) {
match self {
CharPosition::Normal => (1.0, 0.0),
CharPosition::Superscript(m) => (m.size / 100.0, m.position / 100.0 * size),
CharPosition::Subscript(m) => (m.size / 100.0, -m.position / 100.0 * size),
}
}
/// `normal`, `superscript` or `subscript`.
pub fn id(self) -> &'static str {
match self {
CharPosition::Normal => "normal",
CharPosition::Superscript(_) => "superscript",
CharPosition::Subscript(_) => "subscript",
}
}
}
fn ten() -> f64 {
@ -118,6 +174,8 @@ impl Default for CharStyle {
stroke_join: LineJoin::Miter,
stroke_miter_limit: 10.0,
stroke_dash: None,
position: CharPosition::Normal,
small_caps: None,
}
}
}

View File

@ -0,0 +1,295 @@
//! File → Document Setup (`document.setup`): units, bleed, the transparency grid and simulated
//! paper, output options and the document's type options.
use serde_json::{Map, Value, json};
use vectorcraft_doc::setup::{DEFAULT_FLATTENER, GRID_COLOR_PRESETS, LANGUAGES, MAX_BLEED};
use vectorcraft_doc::{Background, CharPosition, CharStyle, DocSetup, Document, ExportText, GridSize, NodeKind, Quotes, ScriptMetrics};
use super::flatten::{FlattenOptions, FlattenerPreset};
use super::*;
const C: &str = "document.setup";
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"document.setup",
"Document Setup",
[],
None,
"{units?: \"Points\"|\"Inches\"|\"Millimeters\"|…, bleed?: pt (all sides)|[top, bottom, left, right]|{top?, bottom?, left?, right?} (0–72 pt), outlineImages?: bool (show images in Outline mode), highlightSubstitutedFonts?: bool, highlightSubstitutedGlyphs?: bool, gridSize?: \"small\"|\"medium\"|\"large\", gridColors?: \"Light\"|\"Medium\"|\"Dark\"|\"Red\"|\"Orange\"|\"Green\"|\"Blue\"|\"Purple\"|[colour, colour] (transparency grid; the first is also the paper colour), simulatePaper?: bool, flattenerPreset?: \"High Resolution\"|\"Medium Resolution\"|\"Low Resolution\"|a saved preset (flattener.presets.list), discardWhiteOverprint?: bool, language?: \"English: USA\"|\"German\"|\"French\"|… (also picks its quotes), quotes?: {double?: \"“”\", single?: \"‘’\"} (beside a language: only if they differ from the current ones), typographersQuotes?: bool (typed straight quotes become the document's quotes), superscript?: {size?: %, position?: %}, subscript?: {size?: %, position?: %}, smallCapsSize?: %, exportText?: \"editable\"|\"appearance\" (SVG text as text or as outlines), backgroundContents?: \"transparent\"|\"white\" (white: raster exports are white behind the art)} change the document setup in one undo step; no params → the current setup",
has_doc,
setup
)]
}
/// The setup as `document.setup` reports it (and accepts it back).
pub(crate) fn setup_json(d: &Document) -> Value {
let s = &d.setup;
let pair = |p: [char; 2]| p.iter().collect::<String>();
json!({
"units": d.units.label(),
"bleed": s.bleed,
"outlineImages": s.outline_images,
"highlightSubstitutedFonts": s.highlight_substituted_fonts,
"highlightSubstitutedGlyphs": s.highlight_substituted_glyphs,
"gridSize": s.grid_size.id(),
"gridColors": s.grid_colors.map(|c| c.to_hex()),
"gridColorsName": s.grid_colors_name(),
"simulatePaper": s.simulate_paper,
"flattenerPreset": s.flattener(),
"discardWhiteOverprint": s.discard_white_overprint,
"language": s.language,
"quotes": {"double": pair(s.quotes.double), "single": pair(s.quotes.single)},
"typographersQuotes": s.typographers_quotes,
"superscript": s.superscript,
"subscript": s.subscript,
"smallCapsSize": s.small_caps_size,
"exportText": s.export_text.id(),
"backgroundContents": s.background.id(),
})
}
fn setup(s: &mut Session, p: &Value) -> Result<Value> {
let empty = Map::new();
let o = match p {
Value::Null => &empty,
Value::Object(o) => o,
_ => return Err(bad(C, "params must be an object")),
};
let st = s.doc()?;
if o.is_empty() {
return Ok(setup_json(&st.doc));
}
let mut new = st.doc.setup.clone();
let mut units = st.doc.units;
// The language goes first: it picks its quotes, which `quotes` may then override.
let current = st.doc.setup.quotes;
for (k, v) in o.iter().filter(|(k, _)| *k == "language").chain(o.iter().filter(|(k, _)| *k != "language")) {
// With a language, unchanged quotes (a report fed back) don't undo the language's quotes.
if k == "quotes" && o.contains_key("language") && quotes_param(v, current)? == current {
continue;
}
apply(&mut new, &mut units, k, v, &s.prefs.flattener_presets)?;
}
let st = s.doc()?;
if new == st.doc.setup && units == st.doc.units {
return Ok(setup_json(&st.doc));
}
s.edit("Document Setup", |d, _| {
let scripts_changed =
(new.superscript, new.subscript, new.small_caps_size) != (d.setup.superscript, d.setup.subscript, d.setup.small_caps_size);
d.setup = new;
d.units = units;
if scripts_changed {
restamp_scripts(d);
}
Ok(())
})?;
Ok(setup_json(&s.doc()?.doc))
}
fn flag(k: &str, v: &Value) -> Result<bool> {
v.as_bool().ok_or_else(|| bad(C, format!("{k} must be true or false")))
}
fn percent(k: &str, v: &Value, min: f64, max: f64) -> Result<f64> {
v.as_f64().filter(|x| (min..=max).contains(x)).ok_or_else(|| bad(C, format!("{k} must be a number from {min} to {max}")))
}
fn apply(s: &mut DocSetup, units: &mut vectorcraft_doc::Unit, k: &str, v: &Value, saved: &[FlattenerPreset]) -> Result<()> {
match k {
"units" => *units = v.as_str().and_then(vectorcraft_doc::Unit::named).ok_or_else(|| bad(C, "unknown units"))?,
"bleed" => s.bleed = bleed_param(v, s.bleed, C)?,
"outlineImages" => s.outline_images = flag(k, v)?,
"highlightSubstitutedFonts" => s.highlight_substituted_fonts = flag(k, v)?,
"highlightSubstitutedGlyphs" => s.highlight_substituted_glyphs = flag(k, v)?,
"gridSize" => s.grid_size = v.as_str().and_then(GridSize::parse).ok_or_else(|| bad(C, "gridSize must be small, medium or large"))?,
"gridColors" => s.grid_colors = grid_colors(v)?,
"simulatePaper" => s.simulate_paper = flag(k, v)?,
"flattenerPreset" => s.flattener_preset = flattener_preset(v, saved)?,
"discardWhiteOverprint" => s.discard_white_overprint = flag(k, v)?,
"language" => {
let l = v.as_str().unwrap_or("");
let (name, quotes) = LANGUAGES.iter().find(|(n, _)| n.eq_ignore_ascii_case(l)).ok_or_else(|| {
bad(C, format!("unknown language `{l}` (one of {})", LANGUAGES.iter().map(|(n, _)| *n).collect::<Vec<_>>().join(", ")))
})?;
s.language = name.to_string();
s.quotes = *quotes;
}
"quotes" => s.quotes = quotes_param(v, s.quotes)?,
"typographersQuotes" => s.typographers_quotes = flag(k, v)?,
"superscript" => s.superscript = script_param(k, v, s.superscript)?,
"subscript" => s.subscript = script_param(k, v, s.subscript)?,
"smallCapsSize" => s.small_caps_size = percent(k, v, 1.0, 200.0)?,
"exportText" => s.export_text = v.as_str().and_then(ExportText::parse).ok_or_else(|| bad(C, "exportText must be editable or appearance"))?,
"backgroundContents" => s.background = background_param(v, C)?,
// Read-only values `document.setup` reports.
"gridColorsName" => {}
_ => return Err(bad(C, format!("unknown setting `{k}`"))),
}
Ok(())
}
/// A built-in flattener preset (by name or id) or a saved one, stored by its display name (None:
/// the default preset).
fn flattener_preset(v: &Value, saved: &[FlattenerPreset]) -> Result<Option<String>> {
let name = v.as_str().unwrap_or("").trim();
let builtin = FlattenOptions::PRESETS
.into_iter()
.filter_map(|id| Some((id, FlattenOptions::preset_label(id)?)))
.find(|(id, label)| id.eq_ignore_ascii_case(name) || label.eq_ignore_ascii_case(name))
.map(|(_, label)| label);
let found = builtin.or_else(|| saved.iter().map(|p| p.name.as_str()).find(|n| n.eq_ignore_ascii_case(name))).ok_or_else(|| {
bad(
C,
format!(
"unknown flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution or a saved one: see flattener.presets.list)"
),
)
})?;
Ok((found != DEFAULT_FLATTENER).then(|| found.to_string()))
}
/// `"transparent"` or `"white"`.
pub(crate) fn background_param(v: &Value, cmd: &str) -> Result<Background> {
v.as_str().and_then(Background::parse).ok_or_else(|| bad(cmd, "backgroundContents must be transparent or white"))
}
/// `pt` (every side), `[top, bottom, left, right]` or `{top?, bottom?, left?, right?}`.
pub(crate) fn bleed_param(v: &Value, current: [f64; 4], cmd: &str) -> Result<[f64; 4]> {
let side = |x: &Value| x.as_f64().filter(|b| (0.0..=MAX_BLEED).contains(b));
let err = || bad(cmd, format!("bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}}"));
match v {
Value::Number(_) => side(v).map(|b| [b; 4]).ok_or_else(err),
Value::Array(a) if a.len() == 4 => {
let mut out = [0.0; 4];
for (o, x) in out.iter_mut().zip(a) {
*o = side(x).ok_or_else(err)?;
}
Ok(out)
}
Value::Object(o) => {
let mut out = current;
for (k, x) in o {
let i = ["top", "bottom", "left", "right"].iter().position(|s| s == k).ok_or_else(err)?;
out[i] = side(x).ok_or_else(err)?;
}
Ok(out)
}
_ => Err(err()),
}
}
fn grid_colors(v: &Value) -> Result<[vectorcraft_color::Color; 2]> {
if let Some(name) = v.as_str() {
return GRID_COLOR_PRESETS.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, c)| *c).ok_or_else(|| {
bad(C, format!("unknown grid colours `{name}` (one of {})", GRID_COLOR_PRESETS.iter().map(|(n, _)| *n).collect::<Vec<_>>().join(", ")))
});
}
match v.as_array().map(|a| a.iter().map(color_value).collect::<Option<Vec<_>>>()) {
Some(Some(c)) if c.len() == 2 => Ok([c[0], c[1]]),
_ => Err(bad(C, "gridColors must be a preset name or two colours")),
}
}
/// A quote pair: a two-character string or two one-character strings.
fn quote_pair(v: &Value) -> Option<[char; 2]> {
let chars: Vec<char> = match v {
Value::String(s) => s.chars().collect(),
Value::Array(a) => a.iter().map(|x| x.as_str().and_then(|s| s.chars().next().filter(|_| s.chars().count() == 1))).collect::<Option<_>>()?,
_ => return None,
};
match chars[..] {
[a, b] if !a.is_control() && !b.is_control() && !a.is_whitespace() && !b.is_whitespace() => Some([a, b]),
_ => None,
}
}
fn quotes_param(v: &Value, mut q: Quotes) -> Result<Quotes> {
let o = v.as_object().ok_or_else(|| bad(C, "quotes must be {double?, single?}"))?;
for (k, x) in o {
let pair = quote_pair(x).ok_or_else(|| bad(C, format!("quotes.{k} must be an opening and a closing quote, e.g. \"“”\"")))?;
match k.as_str() {
"double" => q.double = pair,
"single" => q.single = pair,
_ => return Err(bad(C, format!("unknown quotes key `{k}` (double, single)"))),
}
}
Ok(q)
}
fn script_param(k: &str, v: &Value, mut m: ScriptMetrics) -> Result<ScriptMetrics> {
let o = v.as_object().ok_or_else(|| bad(C, format!("{k} must be {{size?, position?}}")))?;
for (key, x) in o {
match key.as_str() {
"size" => m.size = percent(&format!("{k}.size"), x, 1.0, 500.0)?,
"position" => m.position = percent(&format!("{k}.position"), x, -500.0, 500.0)?,
_ => return Err(bad(C, format!("unknown {k} key `{key}` (size, position)"))),
}
}
Ok(m)
}
/// `position?: "normal"|"superscript"|"subscript"` and `smallCaps?: bool` from a character
/// command's params, in the document's Document Setup proportions.
pub(crate) fn script_params(p: &Value, setup: &DocSetup, cmd: &str) -> Result<(Option<CharPosition>, Option<Option<f64>>)> {
let position = match p.get("position").filter(|v| !v.is_null()) {
None => None,
Some(v) => Some(match v.as_str().map(str::to_ascii_lowercase).as_deref() {
Some("normal") => CharPosition::Normal,
Some("superscript") => CharPosition::Superscript(setup.superscript),
Some("subscript") => CharPosition::Subscript(setup.subscript),
_ => return Err(bad(cmd, "position must be normal, superscript or subscript")),
}),
};
let small_caps = match p.get("smallCaps").filter(|v| !v.is_null()) {
None => None,
Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "smallCaps must be true or false"))?.then_some(setup.small_caps_size)),
};
Ok((position, small_caps))
}
/// A superscript or subscript in `setup`'s proportions.
fn position_in(setup: &DocSetup, p: CharPosition) -> CharPosition {
match p {
CharPosition::Normal => CharPosition::Normal,
CharPosition::Superscript(_) => CharPosition::Superscript(setup.superscript),
CharPosition::Subscript(_) => CharPosition::Subscript(setup.subscript),
}
}
/// After the superscript, subscript or small caps proportions changed: every run (and character
/// style) using them takes the new ones.
fn restamp_scripts(d: &mut Document) {
let uses = |st: &CharStyle| st.position != CharPosition::Normal || st.small_caps.is_some();
let mut ids = vec![];
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind
&& t.runs.iter().any(|r| uses(&r.style))
{
ids.push(n.id);
}
});
let setup = d.setup.clone();
for id in ids {
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
for st in t.runs.iter_mut().map(|r| &mut r.style).filter(|st| uses(st)) {
st.position = position_in(&setup, st.position);
st.small_caps = st.small_caps.map(|_| setup.small_caps_size);
}
super::typecmd::refresh_bounds(t);
}
}
// Character styles store the attributes they set by their serialized names.
for def in &mut d.char_styles {
if let Some(v) = def.attrs.get_mut("position")
&& let Ok(p) = serde_json::from_value::<CharPosition>(v.clone())
{
*v = serde_json::to_value(position_in(&setup, p)).unwrap_or(Value::Null);
}
if let Some(v) = def.attrs.get_mut("small_caps").filter(|v| v.is_number()) {
*v = json!(setup.small_caps_size);
}
}
}

View File

@ -176,7 +176,9 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
let scale = o.scale.unwrap_or(1.0).clamp(0.01, 64.0);
check_format_size(f, region.width() * scale, region.height() * scale)?;
vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?;
let img = vectorcraft_render::Renderer::new().render_region(doc, region, scale, f.id == "jpg");
// JPEG has no alpha; New Document → Background Contents: White fills the artboard too.
let white = f.id == "jpg" || doc.setup.background == vectorcraft_doc::Background::White;
let img = vectorcraft_render::Renderer::new().render_region(doc, region, scale, white);
match f.id {
"png" => img.to_png(),
"webp" => img.to_webp(),

View File

@ -31,7 +31,7 @@ pub const OPTIONS: &[FormatOption] = &[
name: "outlineText",
ty: "boolean",
default: "false",
description: "Fonts: text as glyph outlines (viewable without the fonts) instead of <text>",
description: "Fonts: text as glyph outlines (viewable without the fonts) instead of <text>; default: Document Setup → Type → Export (Preserve Text Appearance: true)",
},
FormatOption {
name: "images",
@ -99,6 +99,10 @@ pub fn options_map(p: &Value) -> Result<Map<String, Value>, String> {
/// The writer options and the artboards (`None`: the art bounds) an SVG export of `doc` covers.
fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>), String> {
let mut m = options_map(p)?;
// Fonts: unless chosen, Document Setup → Type → Export decides (appearance = outlines).
if !m.contains_key("outlineText") && doc.setup.export_text == vectorcraft_doc::ExportText::Appearance {
m.insert("outlineText".into(), Value::Bool(true));
}
let picks: Map<String, Value> = ARTBOARD_PARAMS.iter().chain(["useArtboards"].iter()).filter_map(|k| m.remove_entry(*k)).collect();
let boards = Boards::deserialize(Value::Object(picks)).map_err(|e| format!("SVG options: {e}"))?;
let opts = ExportOptions::deserialize(Value::Object(m)).map_err(|e| format!("SVG options: {e}"))?;

View File

@ -12,6 +12,7 @@ mod create;
pub(crate) mod distortcmds;
mod docinfo;
mod docmenu;
pub(crate) mod docsetup;
mod draw2;
mod edit;
mod effectcmd;
@ -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
})
}

View File

@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character / Paragraph",
[],
None,
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool}",
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool}",
has_doc,
set_format
),
@ -93,6 +93,8 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
"underline",
"strikethrough",
"allCaps",
"smallCaps",
"position",
"leftIndent",
"rightIndent",
"firstLineIndent",
@ -103,6 +105,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if !keys.iter().any(|k| p.get(*k).is_some()) {
return Err(bad(C, "nothing to change"));
}
let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?;
s.edit("Character", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
@ -132,6 +135,12 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = flag("allCaps") {
st.all_caps = v;
}
if let Some(v) = position {
st.position = v;
}
if let Some(v) = small_caps {
st.small_caps = v;
}
}
let para = &mut t.para;
if let Some(v) = num("leftIndent") {

View File

@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, features?: [\"dlig\", \"-liga\", …]} style a character range (runs are split at the range ends) → {id, runs}",
"{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …]} style a character range (runs are split at the range ends) → {id, runs}",
has_doc,
set_range_style
),
@ -140,6 +140,8 @@ pub(crate) struct CharChange {
strikethrough: Option<bool>,
all_caps: Option<bool>,
features: Option<Vec<String>>,
position: Option<vectorcraft_doc::CharPosition>,
small_caps: Option<Option<f64>>,
}
/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults).
@ -169,8 +171,10 @@ fn auto_or_num(p: &Value, k: &str, cmd: &str) -> Result<Option<Option<f64>>> {
}
impl CharChange {
pub(crate) fn parse(p: &Value, cmd: &str) -> Result<Self> {
/// `setup` gives the superscript, subscript and small caps proportions.
pub(crate) fn parse(p: &Value, cmd: &str, setup: &vectorcraft_doc::DocSetup) -> Result<Self> {
let num = |k: &str| p.get(k).and_then(Value::as_f64);
let (position, small_caps) = super::docsetup::script_params(p, setup, cmd)?;
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
let mut stroke_opts = match p.get("strokeOptions") {
None | Some(Value::Null) => StrokeChange::default(),
@ -198,6 +202,8 @@ impl CharChange {
strikethrough: flag("strikethrough"),
all_caps: flag("allCaps"),
features: features_param(p, cmd)?,
position,
small_caps,
};
if c.size.is_some_and(|v| v <= 0.0) {
return Err(bad(cmd, "size must be positive"));
@ -223,6 +229,8 @@ impl CharChange {
&& self.strikethrough.is_none()
&& self.all_caps.is_none()
&& self.features.is_none()
&& self.position.is_none()
&& self.small_caps.is_none()
}
pub(crate) fn apply(&self, st: &mut CharStyle) {
@ -287,13 +295,19 @@ impl CharChange {
if let Some(v) = &self.features {
st.features = v.clone();
}
if let Some(v) = self.position {
st.position = v;
}
if let Some(v) = self.small_caps {
st.small_caps = v;
}
}
}
fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setRangeStyle";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let change = CharChange::parse(p, C)?;
let change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
if change.is_empty() {
return Err(bad(C, "nothing to change"));
}

View File

@ -3,7 +3,7 @@
//! commands of the Edit menu (Find and Replace, Find Next, Paste without Formatting).
use serde_json::{Value, json};
use vectorcraft_doc::{CharStyle, Document, Justify, NodeId, NodeKind, TextKind, TextRun};
use vectorcraft_doc::{CharStyle, Document, Justify, NodeId, NodeKind, Quotes, TextKind, TextRun};
use vectorcraft_geom::{Affine, Rect, shapes};
use super::typecmd::refresh_bounds;
@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Smart Punctuation…",
["Type"],
None,
"{quotes?: true (\"\" '' → curly), dashes?: true (-- → en dash, --- → em dash), ellipsis?: true (... → …), scope?: \"selection\"|\"document\"} → {changed}",
"{quotes?: true (straight quotes become the Document Setup quotes), dashes?: true (-- → en dash, --- → em dash), ellipsis?: true (... → …), scope?: \"selection\"|\"document\"} → {changed}",
has_doc,
smart_punctuation
),
@ -217,11 +217,8 @@ struct PunctState {
prev: Option<char>,
}
fn opens(prev: Option<char>) -> bool {
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–' | '“' | '‘'))
}
pub(crate) fn smarten(text: &str, prev: &mut Option<char>, quotes: bool, dashes: bool, ellipsis: bool) -> String {
/// Dashes (`--` en, `---` em), ellipses and, with `quotes`, typographer's quotes.
pub(crate) fn smarten(text: &str, prev: &mut Option<char>, quotes: Option<&Quotes>, dashes: bool, ellipsis: bool) -> String {
let mut s = text.to_string();
if dashes {
s = s.replace("---", "—").replace("--", "–");
@ -229,38 +226,20 @@ pub(crate) fn smarten(text: &str, prev: &mut Option<char>, quotes: bool, dashes:
if ellipsis {
s = s.replace("...", "…");
}
if !quotes {
*prev = s.chars().last().or(*prev);
return s;
match quotes {
Some(q) => q.apply(&s, prev),
None => {
*prev = s.chars().last().or(*prev);
s
}
}
let mut out = String::with_capacity(s.len());
for c in s.chars() {
let r = match c {
'"' => {
if opens(*prev) {
'“'
} else {
'”'
}
}
'\'' => {
if opens(*prev) {
'‘'
} else {
'’'
}
}
c => c,
};
out.push(r);
*prev = Some(r);
}
out
}
fn smart_punctuation(s: &mut Session, p: &Value) -> Result<Value> {
let (q, d, e) = (bool_or(p, "quotes", true), bool_or(p, "dashes", true), bool_or(p, "ellipsis", true));
let (d, e) = (bool_or(p, "dashes", true), bool_or(p, "ellipsis", true));
let st = s.doc()?;
// Quotes in the document's style (Document Setup → Type).
let q = bool_or(p, "quotes", true).then_some(st.doc.setup.quotes);
let ids = if str_param(p, "scope") == Some("document") || st.selection.is_empty() {
let mut v = vec![];
st.doc.walk(|n| {
@ -272,7 +251,7 @@ fn smart_punctuation(s: &mut Session, p: &Value) -> Result<Value> {
} else {
text_ids(&st.doc, &st.selection.objects)
};
let n = edit_runs::<PunctState>(s, "Smart Punctuation", &ids, |text, st| smarten(text, &mut st.prev, q, d, e))?;
let n = edit_runs::<PunctState>(s, "Smart Punctuation", &ids, |text, st| smarten(text, &mut st.prev, q.as_ref(), d, e))?;
Ok(json!({ "changed": n }))
}

View File

@ -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;

View File

@ -0,0 +1,266 @@
//! File → Document Setup (`document.setup`): validation, one undo step, round-trips, and the
//! options other commands read (typographer's quotes, superscript/subscript, SVG text export).
use serde_json::{Value, json};
use vectorcraft_doc::{CharPosition, DocSetup, ExportText, GridSize, NodeId, NodeKind, ScriptMetrics, TextObject, TextStyleDef, Unit};
use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
fn setup(s: &Session) -> &DocSetup {
&s.doc().unwrap().doc.setup
}
fn text(s: &Session, id: NodeId) -> TextObject {
match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("not text"),
}
}
fn create_text(s: &mut Session, t: &str) -> NodeId {
NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": t, "size": 20})).unwrap()["id"].as_u64().unwrap())
}
/// Click with the Type tool at (x, y) and type `keys` one at a time; returns the new text object.
fn type_at(s: &mut Session, x: f64, y: f64, keys: &[&str]) -> NodeId {
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
}
let id = s.doc().unwrap().selection.objects[0];
for k in keys {
s.tool_text(k, v).unwrap();
}
s.tool_key(ToolKey::Escape, Default::default(), v).unwrap();
id
}
#[test]
fn query_reports_the_defaults_and_accepts_them_back() {
let mut s = session();
let q = s.execute("document.setup", &json!({})).unwrap();
assert_eq!(q["units"], "Points");
assert_eq!(q["bleed"], json!([0.0, 0.0, 0.0, 0.0]));
assert_eq!(q["gridColors"], json!(["#ffffff", "#cccccc"]));
assert_eq!(q["gridColorsName"], "Light");
assert_eq!(q["flattenerPreset"], "Medium Resolution");
assert_eq!(q["backgroundContents"], "transparent");
assert_eq!(q["quotes"], json!({"double": "“”", "single": "‘’"}));
assert_eq!(q["typographersQuotes"], true);
assert_eq!(q["superscript"], json!({"size": 58.3, "position": 33.3}));
// Feeding the report back changes nothing (and records no undo step).
assert_eq!(s.execute("document.setup", &q).unwrap(), q);
assert!(s.doc().unwrap().history.undo.is_empty());
assert_eq!(*setup(&s), DocSetup::default());
}
#[test]
fn many_settings_are_one_undo_step() {
let mut s = session();
let before = s.doc().unwrap().doc.clone();
let r = s
.execute(
"document.setup",
&json!({
"units": "Millimeters", "bleed": {"top": 9, "right": 4.5}, "gridSize": "large", "gridColors": "Blue",
"simulatePaper": true, "flattenerPreset": "high", "discardWhiteOverprint": false,
"outlineImages": true, "highlightSubstitutedGlyphs": true, "exportText": "appearance",
"superscript": {"size": 60}, "subscript": {"position": 20}, "smallCapsSize": 75,
}),
)
.unwrap();
assert_eq!(r["gridColorsName"], "Blue");
let d = &s.doc().unwrap().doc;
assert_eq!(d.units, Unit::Millimeters);
let st = &d.setup;
assert_eq!(st.bleed, [9.0, 0.0, 0.0, 4.5]);
assert_eq!((st.grid_size, st.simulate_paper, st.outline_images, st.highlight_substituted_glyphs), (GridSize::Large, true, true, true));
assert_eq!((st.flattener(), st.discard_white_overprint, st.export_text), ("High Resolution", false, ExportText::Appearance));
assert_eq!((st.superscript, st.subscript), (ScriptMetrics { size: 60.0, position: 33.3 }, ScriptMetrics { size: 58.3, position: 20.0 }));
assert_eq!(st.small_caps_size, 75.0);
let undo: Vec<&str> = s.doc().unwrap().history.undo.iter().map(|e| e.label.as_str()).collect();
assert_eq!(undo, ["Document Setup"]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(*s.doc().unwrap().doc, *before);
}
#[test]
fn bad_values_are_rejected_and_change_nothing() {
let mut s = session();
for p in [
json!({"bleed": 73}),
json!({"bleed": [1, 2, 3]}),
json!({"bleed": {"middle": 2}}),
json!({"bleed": -1}),
json!({"gridSize": "huge"}),
json!({"gridColors": ["#ffffff"]}),
json!({"gridColors": "Plaid"}),
json!({"language": "Klingon"}),
json!({"quotes": {"double": "“"}}),
json!({"quotes": {"triple": "“”"}}),
json!({"superscript": {"size": 0}}),
json!({"exportText": "pdf"}),
json!({"flattenerPreset": "Ultra"}),
json!({"backgroundContents": "grey"}),
json!({"simulatePaper": "yes"}),
json!({"units": "furlongs"}),
json!({"bleed": 9, "nonsense": true}),
json!([1, 2]),
] {
assert!(s.execute("document.setup", &p).is_err(), "{p}");
}
assert_eq!(*setup(&s), DocSetup::default());
assert!(s.doc().unwrap().history.undo.is_empty());
}
#[test]
fn set_units_is_an_alias() {
let mut s = session();
s.execute("document.setUnits", &json!({"units": "inches"})).unwrap();
assert_eq!(s.doc().unwrap().doc.units, Unit::Inches);
assert_eq!(s.execute("document.setup", &json!({})).unwrap()["units"], "Inches");
assert!(s.execute("document.setUnits", &json!({"units": "parsecs"})).is_err());
}
#[test]
fn language_picks_its_quotes_unless_quotes_are_given() {
let mut s = session();
s.execute("document.setup", &json!({"language": "german"})).unwrap();
assert_eq!(setup(&s).language, "German");
assert_eq!(setup(&s).quotes.double, ['„', '“']);
s.execute("document.setup", &json!({"language": "French", "quotes": {"double": ["“", "”"]}})).unwrap();
assert_eq!((setup(&s).quotes.double, setup(&s).quotes.single), (['“', '”'], ['‹', '›']));
}
#[test]
fn typographers_quotes_setting_changes_typed_quotes() {
let mut s = session();
let id = type_at(&mut s, 50.0, 50.0, &["\"", "H", "i", "\"", " ", "i", "t", "'", "s"]);
assert_eq!(text(&s, id).plain_text(), "“Hi” it’s");
// The document's quote style.
s.execute("document.setup", &json!({"language": "German"})).unwrap();
let id = type_at(&mut s, 50.0, 150.0, &["\"", "a", "\""]);
assert_eq!(text(&s, id).plain_text(), "„a“");
// Straight quotes with the option off.
s.execute("document.setup", &json!({"typographersQuotes": false})).unwrap();
let id = type_at(&mut s, 50.0, 250.0, &["\"", "b", "'"]);
assert_eq!(text(&s, id).plain_text(), "\"b'");
}
#[test]
fn smart_punctuation_uses_the_document_quotes() {
let mut s = session();
s.execute("document.setup", &json!({"language": "French"})).unwrap();
let id = create_text(&mut s, "\"oui\" 'non'");
s.execute("type.smartPunctuation", &json!({"scope": "document"})).unwrap();
assert_eq!(text(&s, id).plain_text(), "«oui» ‹non›");
}
#[test]
fn superscript_and_small_caps_use_and_follow_the_setup_sizes() {
let mut s = session();
let id = create_text(&mut s, "E=mc2");
s.execute("document.setup", &json!({"superscript": {"size": 50, "position": 40}})).unwrap();
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 4, "end": 5, "position": "superscript"})).unwrap();
s.execute("text.setFormat", &json!({"ids": [id.0], "smallCaps": true})).unwrap();
let t = text(&s, id);
assert_eq!(t.runs.last().unwrap().style.position, CharPosition::Superscript(ScriptMetrics { size: 50.0, position: 40.0 }));
assert!(t.runs.iter().all(|r| r.style.small_caps == Some(70.0)));
let narrow = t.cached_bounds.unwrap();
// A Document Setup change restyles the text in the same undo step.
let undo = s.doc().unwrap().history.undo.len();
s.execute("document.setup", &json!({"superscript": {"size": 100}, "smallCapsSize": 100})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
let t = text(&s, id);
assert_eq!(t.runs.last().unwrap().style.position, CharPosition::Superscript(ScriptMetrics { size: 100.0, position: 40.0 }));
assert!(t.runs.iter().all(|r| r.style.small_caps == Some(100.0)));
assert!(t.cached_bounds.unwrap().width() > narrow.width(), "bounds follow the bigger glyphs");
s.execute("text.setFormat", &json!({"ids": [id.0], "position": "normal", "smallCaps": false})).unwrap();
assert!(text(&s, id).runs.iter().all(|r| r.style.position == CharPosition::Normal && r.style.small_caps.is_none()));
assert!(s.execute("text.setFormat", &json!({"ids": [id.0], "position": "sideways"})).is_err());
}
#[test]
fn character_styles_follow_the_setup_sizes() {
let mut s = session();
let attrs = json!({"position": {"kind": "subscript", "size": 58.3, "position": 33.3}, "small_caps": 70.0});
Arc::make_mut(&mut s.doc_mut().unwrap().doc).char_styles.push(TextStyleDef { name: "Note".into(), attrs: attrs.as_object().unwrap().clone() });
s.execute("document.setup", &json!({"subscript": {"size": 40}, "smallCapsSize": 80})).unwrap();
let def = &s.doc().unwrap().doc.char_styles[0];
assert_eq!(def.attrs["position"], json!({"kind": "subscript", "size": 40.0, "position": 33.3}));
assert_eq!(def.attrs["small_caps"], json!(80.0));
}
#[test]
fn setup_survives_save_and_load() {
let mut s = session();
s.execute(
"document.setup",
&json!({"bleed": [9, 9, 18, 18], "gridColors": ["#ff0000", "#00ff00"], "language": "Japanese", "exportText": "appearance"}),
)
.unwrap();
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save_file(d)).unwrap();
assert_eq!(back.setup, d.setup);
assert_eq!(back.setup.grid_colors_name(), "Custom");
}
#[test]
fn svg_text_export_follows_the_export_text_setting() {
let mut s = session();
create_text(&mut s, "Hello");
let svg = |s: &mut Session, p: Value| {
let mut p = p;
p["format"] = json!("svg");
let r = s.execute("document.export", &p).unwrap();
String::from_utf8(vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()).unwrap()
};
assert!(svg(&mut s, json!({})).contains("<text"));
s.execute("document.setup", &json!({"exportText": "appearance"})).unwrap();
assert!(!svg(&mut s, json!({})).contains("<text"));
// An explicit option still wins.
assert!(svg(&mut s, json!({"outlineText": false})).contains("<text"));
}
#[test]
fn discard_white_overprint_keeps_white_overprints_in_overprint_preview() {
let mut s = session();
let rect = |s: &mut Session, color: &str| {
let id = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"ids": [id], "color": color})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [id], "none": true})).unwrap();
id
};
rect(&mut s, "#ff0000");
let white = rect(&mut s, "#ffffff");
s.execute("object.setOverprint", &json!({"fill": true, "ids": [white]})).unwrap();
let centre = |s: &Session| {
let opts = vectorcraft_render::RenderOptions { background: Some([255; 4]), overprint_preview: true, ..Default::default() };
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 40, 40, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(20, 20)
};
// On by default: the white knocks out as it will print.
assert_eq!(centre(&s), [255, 255, 255, 255]);
s.execute("document.setup", &json!({"discardWhiteOverprint": false})).unwrap();
let [r, g, b, _] = centre(&s);
assert!(r > 200 && g < 60 && b < 60, "a white overprint vanishes: {:?}", [r, g, b]);
}
#[test]
fn saved_flattener_presets_and_background_contents() {
let mut s = session();
s.execute("flattener.presets.save", &json!({"name": "Proofs", "preset": "high"})).unwrap();
assert_eq!(s.execute("document.setup", &json!({"flattenerPreset": "proofs"})).unwrap()["flattenerPreset"], "Proofs");
assert_eq!(s.execute("document.setup", &json!({"flattenerPreset": "Medium Resolution"})).unwrap()["flattenerPreset"], "Medium Resolution");
assert_eq!(setup(&s).flattener_preset, None, "the default is not stored");
let r = s.execute("document.setup", &json!({"backgroundContents": "white"})).unwrap();
assert_eq!(r["backgroundContents"], "white");
assert_eq!(setup(&s).background, vectorcraft_doc::Background::White);
}

View File

@ -87,6 +87,8 @@ pub struct RenderOptions {
/// View Opacity Mask (Alt-click the mask thumbnail): instead of the artwork, show this object's
/// opacity mask alone as its coverage in greyscale (white = opaque, black = transparent).
pub mask_view: Option<NodeId>,
/// Screen view: highlight substituted fonts and glyphs as Document Setup asks.
pub highlight_substitutions: bool,
}
impl Default for RenderOptions {
@ -103,6 +105,7 @@ impl Default for RenderOptions {
skip_templates: false,
tile_edge: vectorcraft_doc::LAYER_COLORS[0].1,
mask_view: None,
highlight_substitutions: false,
}
}
}
@ -1102,6 +1105,18 @@ impl Renderer {
if let Some(all) = &all {
self.draw_text_items(ctx, f, n, below, all, tb);
}
if f.opts.highlight_substitutions {
let setup = &f.doc.setup;
for (on, path) in [(setup.highlight_substituted_fonts, &g.substituted_fonts), (setup.highlight_substituted_glyphs, &g.substituted_glyphs)]
{
if on && !path.elements().is_empty() {
ctx.set_transform(xf);
ctx.set_fill_rule(peniko::Fill::NonZero);
ctx.set_paint(SUBSTITUTED);
ctx.fill_path(path);
}
}
}
for (i, run) in t.runs.iter().enumerate() {
let Some(path) = g.runs.get(i) else { continue };
if path.elements().is_empty() {
@ -1159,30 +1174,53 @@ impl Renderer {
fn draw_image(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject) {
let rect = Rect::new(0.0, 0.0, im.width as f64, im.height as f64);
if f.opts.outline {
// Outline mode draws the image's frame, and with Document Setup → Show Images in Outline
// Mode its pixels in greyscale under the frame.
let outline = f.opts.outline;
let pixels = if outline && !f.doc.setup.outline_images { None } else { self.image_pixmap(f.doc, &im.key, outline) };
if let Some(pm) = pixels {
let pm = if outline { pm } else { self.ink_image(&im.key, &pm, f.ink) };
let sx = im.width as f64 / pm.width().max(1) as f64;
let sy = im.height as f64 / pm.height().max(1) as f64;
ctx.set_transform(f.view * im.xf);
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() });
ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy));
ctx.fill_rect(&rect);
ctx.reset_paint_transform();
}
if outline {
let mut p = rect.to_path(0.1);
p.apply_affine(im.xf);
self.hairline(ctx, f, &p, [0, 0, 0, 255]);
return;
}
let pm = match self.images.get(&im.key) {
}
/// The decoded pixels of image blob `key` (cached), or a greyscale copy of them.
fn image_pixmap(&mut self, doc: &Document, key: &str, grey: bool) -> Option<Arc<Pixmap>> {
let colour = match self.images.get(key) {
Some(p) => p.clone(),
None => {
let Some(blob) = f.doc.images.get(&im.key) else { return };
let Some(pm) = paint::decode_pixmap(&blob.bytes) else { return };
let pm = Arc::new(pm);
self.images.insert(im.key.clone(), pm.clone());
let pm = Arc::new(paint::decode_pixmap(&doc.images.get(key)?.bytes)?);
self.images.insert(key.to_string(), pm.clone());
pm
}
};
let pm = self.ink_image(&im.key, &pm, f.ink);
let sx = im.width as f64 / pm.width().max(1) as f64;
let sy = im.height as f64 / pm.height().max(1) as f64;
ctx.set_transform(f.view * im.xf);
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() });
ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy));
ctx.fill_rect(&rect);
ctx.reset_paint_transform();
if !grey {
return Some(colour);
}
let grey_key = format!("{key}\u{0}grey");
if let Some(p) = self.images.get(&grey_key) {
return Some(p.clone());
}
let mut pm = (*colour).clone();
for px in pm.data_mut() {
// The luma of premultiplied components is the premultiplied luma.
let l = (0.2126 * px.r as f32 + 0.7152 * px.g as f32 + 0.0722 * px.b as f32 + 0.5) as u8;
(px.r, px.g, px.b) = (l, l, l);
}
let pm = Arc::new(pm);
self.images.insert(grey_key, pm.clone());
Some(pm)
}
}
@ -1261,21 +1299,45 @@ struct TextGeom {
runs: Vec<BezPath>,
all: BezPath,
bounds: Rect,
/// The boxes of characters whose font is missing (drawn in a substitute), and of glyphs the
/// font lacks (drawn from a fallback font): Document Setup's substitution highlights.
substituted_fonts: BezPath,
substituted_glyphs: BezPath,
}
fn text_geom(t: &TextObject) -> TextGeom {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let db = vectorcraft_text::FontDb::global();
let layout = vectorcraft_text::layout(db, t);
let mut runs = vec![BezPath::new(); t.runs.len()];
let mut all = BezPath::new();
// Per run: is its family missing, and the face its glyphs should come from.
let faces: Vec<(bool, Option<u32>)> =
t.runs.iter().map(|r| (!db.has_family(&r.style.font_family), db.face(&r.style.font_family, &r.style.font_style).map(|f| f.id()))).collect();
let (mut substituted_fonts, mut substituted_glyphs) = (BezPath::new(), BezPath::new());
for g in &layout.glyphs {
if let Some(r) = runs.get_mut(g.run) {
r.extend(g.outline.iter());
}
all.extend(g.outline.iter());
let Some(&(missing, face)) = faces.get(g.run) else { continue };
let target = if missing {
&mut substituted_fonts
} else if face.is_some_and(|f| f != g.font_id) {
&mut substituted_glyphs
} else {
continue;
};
let Some(line) = layout.lines.get(g.line) else { continue };
let mut cell = Rect::new(g.origin.x, g.origin.y - line.ascent, g.origin.x + g.advance, g.origin.y + line.descent).to_path(0.1);
cell.apply_affine(Affine::rotate_about(g.angle, g.origin));
target.extend(cell.iter());
}
TextGeom { runs, all, bounds: layout.bounds }
TextGeom { runs, all, bounds: layout.bounds, substituted_fonts, substituted_glyphs }
}
/// Document Setup's highlight behind substituted fonts and glyphs (screen only).
const SUBSTITUTED: peniko::Color = peniko::Color::from_rgba8(255, 120, 190, 110);
fn rects_overlap(a: Rect, b: Rect) -> bool {
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
}
@ -1341,6 +1403,8 @@ mod tests_knockout;
#[cfg(test)]
mod tests_objectfx;
#[cfg(test)]
mod tests_setup;
#[cfg(test)]
mod tests_strokegradient;
#[cfg(test)]
mod tests_tileedge;

View File

@ -162,21 +162,35 @@ pub(crate) fn overprints(opts: &RenderOptions) -> bool {
}
/// Draw the overprinting fills and strokes of `a`'s subtree with Multiply (those with a blend
/// mode of their own keep it), copying only the nodes on the way to them.
fn multiply_overprints(a: &mut Arc<Node>) {
/// mode of their own keep it), copying only the nodes on the way to them. With `discard_white`
/// (Document Setup → Discard White Overprint) white fills and strokes knock out instead.
fn multiply_overprints(a: &mut Arc<Node>, discard_white: bool) {
if !a.has_overprint() {
return;
}
let n = Arc::make_mut(a);
let multiplies =
|overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint));
for it in &mut n.appearance.items {
match it {
AppearanceItem::Fill(l) if l.overprint && l.blend == BlendMode::Normal => l.blend = BlendMode::Multiply,
AppearanceItem::Stroke(l) if l.overprint && l.blend == BlendMode::Normal => l.blend = BlendMode::Multiply,
AppearanceItem::Fill(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
AppearanceItem::Stroke(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
_ => {}
}
}
for c in n.children_mut().into_iter().flatten() {
multiply_overprints(c);
multiply_overprints(c, discard_white);
}
}
/// A solid paint of paper white (no ink in any colour model).
fn is_white(p: &Paint) -> bool {
let Paint::Solid { color, .. } = p else { return false };
match *color {
Color::Rgb { r, g, b } => r.min(g).min(b) >= 1.0,
Color::Cmyk { c, m, y, k } => c.max(m).max(y).max(k) <= 0.0,
Color::Gray { k } => k <= 0.0,
Color::Lab { l, a, b } => l >= 100.0 && a == 0.0 && b == 0.0,
}
}
@ -223,11 +237,12 @@ pub(crate) fn prepare<'a>(doc: &'a Document, opts: &RenderOptions) -> Cow<'a, Do
}
let mut d = doc.clone();
if overprint {
let discard_white = doc.setup.discard_white_overprint;
for l in &mut d.layers {
multiply_overprints(l);
multiply_overprints(l, discard_white);
}
for s in &mut d.symbols {
multiply_overprints(&mut s.art);
multiply_overprints(&mut s.art, discard_white);
}
}
if let Some((proof, visible)) = seps {

View File

@ -0,0 +1,33 @@
//! Document Setup options the renderer honours.
use std::sync::Arc;
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use super::*;
fn doc_with_red_image() -> Document {
let mut d = Document::new(40.0, 40.0);
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("red".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });
let (id, layer) = (d.alloc_id(), d.layers[0].id);
let im = ImageObject { key: "red".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None };
d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap();
d
}
#[test]
fn outline_mode_draws_images_in_grey_only_when_the_setup_asks() {
let mut d = doc_with_red_image();
let outline = RenderOptions { outline: true, ..Default::default() };
let centre = |d: &Document| Renderer::new().render(d, 40, 40, Affine::IDENTITY, &outline).pixel(20, 20);
assert_eq!(centre(&d)[3], 0, "outline mode draws only the image frame");
d.setup.outline_images = true;
let [r, g, b, a] = centre(&d);
// Red's luma (0.2126 × 255 ≈ 54) in every channel.
assert_eq!(a, 255);
assert!(r == g && g == b && (50..=58).contains(&r), "{:?}", [r, g, b]);
// Preview mode keeps the colours.
assert_eq!(Renderer::new().render(&d, 40, 40, Affine::IDENTITY, &RenderOptions::default()).pixel(20, 20), [255, 0, 0, 255]);
}

View File

@ -330,4 +330,6 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_scripts;
#[cfg(test)]
mod tests_typo;

View File

@ -114,9 +114,10 @@ pub(crate) fn shape_range(
}
let Some(primary) = db.face(&st.font_family, &st.font_style) else { continue };
let pmap = primary.skrifa().map(|f| f.charmap());
// Split into segments by font coverage.
let mut seg_start = a;
let mut seg_face = primary.clone();
// Synthesized Small Caps shape lowercase letters separately (as smaller capitals).
let small_caps = st.small_caps.is_some() && !st.all_caps;
// Split into segments by font coverage (and case, for Small Caps).
let mut seg = Segment { range: a..a, run: ri, st, face: primary.clone(), small: false };
let mut cache: Vec<(char, Arc<FontFace>)> = Vec::new();
for (i, c) in text[a..b].char_indices() {
let i = a + i;
@ -130,47 +131,67 @@ pub(crate) fn shape_range(
cache.push((c, f.clone()));
f
};
let small = small_caps && c.is_lowercase();
// Combining marks stay with their base.
if face.id() != seg_face.id() && !is_mark(c) {
if i > seg_start {
shape_segment(text, seg_start..i, ri, st, &seg_face, feats, out);
if (face.id() != seg.face.id() || small != seg.small) && !is_mark(c) {
if i > seg.range.start {
seg.range.end = i;
shape_segment(text, &seg, feats, out);
}
seg_start = i;
seg_face = face;
seg = Segment { range: i..i, face, small, ..seg };
}
}
if b > seg_start {
shape_segment(text, seg_start..b, ri, st, &seg_face, feats, out);
if b > seg.range.start {
seg.range.end = b;
shape_segment(text, &seg, feats, out);
}
}
}
/// A piece of one run shaped in one go: one face and, for Small Caps, one case.
struct Segment<'a> {
range: Range<usize>,
run: usize,
st: &'a CharStyle,
face: Arc<FontFace>,
/// Lowercase letters drawn as synthesized small capitals.
small: bool,
}
fn is_mark(c: char) -> bool {
matches!(c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F | 0x200D | 0xFE00..=0xFE0F)
}
fn shape_segment(text: &str, range: Range<usize>, run: usize, st: &CharStyle, face: &Arc<FontFace>, feats: &OtFeatures, out: &mut Vec<SGlyph>) {
let seg = &text[range.clone()];
let size = st.size.max(0.0);
fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SGlyph>) {
let Segment { range, run, st, face, small } = seg;
let (range, run, small) = (range.clone(), *run, *small);
let text_seg = &text[range.clone()];
let full = st.size.max(0.0);
// Superscript/subscript and small capitals shrink the glyphs; line metrics keep the full size.
let (pos_scale, pos_shift) = st.position.scale_shift(full);
let small_scale = if small { st.small_caps.unwrap_or(100.0) / 100.0 } else { 1.0 };
let size = full * pos_scale * small_scale;
let k = size / face.upem;
let km = full / face.upem;
let hs = st.h_scale / 100.0;
let vs = st.v_scale / 100.0;
let tracking = st.tracking / 1000.0 * size;
let manual_kern = st.kerning.map(|v| v / 1000.0 * size).unwrap_or(0.0);
let ascent = face.ascent * k * vs;
let descent = face.descent * k * vs;
let ascent = face.ascent * km * vs;
let descent = face.descent * km * vs;
let leading = st.effective_leading();
let cap = face.cap_height * k * vs;
let xh = face.x_height * k * vs;
let cap = face.cap_height * km * vs;
let xh = face.x_height * km * vs;
let upper = st.all_caps || small;
let first_char = |byte: usize| text[byte..].chars().next().unwrap_or(' ');
let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(seg.len()); // gid, cluster, xadv, xoff, yoff
let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(text_seg.len()); // gid, cluster, xadv, xoff, yoff
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).build();
let mut buf = UnicodeBuffer::new();
for (i, c) in seg.char_indices() {
for (i, c) in text_seg.char_indices() {
let cl = (range.start + i) as u32;
if st.all_caps {
if upper {
for u in c.to_uppercase() {
buf.add(u, cl);
}
@ -191,9 +212,9 @@ fn shape_segment(text: &str, range: Range<usize>, run: usize, st: &CharStyle, fa
if let Some(f) = face.skrifa() {
let cmap = f.charmap();
let gm = f.glyph_metrics(Size::unscaled(), LocationRef::default());
for (i, c) in seg.char_indices() {
for (i, c) in text_seg.char_indices() {
let cl = (range.start + i) as u32;
let chars: Vec<char> = if st.all_caps { c.to_uppercase().collect() } else { vec![c] };
let chars: Vec<char> = if upper { c.to_uppercase().collect() } else { vec![c] };
for u in chars {
let g = cmap.map(u).unwrap_or_default();
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
@ -226,7 +247,7 @@ fn shape_segment(text: &str, range: Range<usize>, run: usize, st: &CharStyle, fa
dy: -(yo as f64) * k * vs,
sx: k * hs,
sy: k * vs,
bshift: st.baseline_shift,
bshift: st.baseline_shift + pos_shift,
rotation: st.rotation,
ascent,
descent,

View File

@ -0,0 +1,50 @@
//! Superscript, subscript and synthesized small caps (Character panel, Document Setup → Type).
use kurbo::Shape;
use vectorcraft_doc::{CharPosition, CharStyle, ScriptMetrics, TextObject};
use super::*;
fn layout_of(text: &str, st: CharStyle) -> TextLayout {
layout(FontDb::global(), &TextObject::point(kurbo::Point::ZERO, text, st))
}
fn glyph_box(l: &TextLayout, i: usize) -> kurbo::Rect {
l.glyphs[i].outline.bounding_box()
}
#[test]
fn superscript_and_subscript_scale_and_shift_glyphs() {
let base = CharStyle { size: 20.0, ..CharStyle::default() };
let normal = layout_of("H", base.clone());
let m = ScriptMetrics { size: 50.0, position: 40.0 };
let sup = layout_of("H", CharStyle { position: CharPosition::Superscript(m), ..base.clone() });
let sub = layout_of("H", CharStyle { position: CharPosition::Subscript(m), ..base.clone() });
let (n, p, b) = (glyph_box(&normal, 0), glyph_box(&sup, 0), glyph_box(&sub, 0));
// Half the height and advance.
assert!((p.height() - n.height() * 0.5).abs() < 0.05, "{p:?} vs {n:?}");
assert!((sup.glyphs[0].advance - normal.glyphs[0].advance * 0.5).abs() < 1e-6);
// Raised / lowered by 40% of 20 pt (y points down).
assert!((p.y1 - (n.y1 - 8.0)).abs() < 0.05, "{p:?}");
assert!((b.y1 - (n.y1 + 8.0)).abs() < 0.05, "{b:?}");
// Line metrics keep the full size.
assert_eq!(sup.lines[0].ascent, normal.lines[0].ascent);
}
#[test]
fn small_caps_draw_lowercase_as_smaller_capitals() {
let base = CharStyle { size: 20.0, ..CharStyle::default() };
let cap = layout_of("A", base.clone());
let cap14 = layout_of("A", CharStyle { size: 14.0, ..base.clone() });
let small = layout_of("Aa", CharStyle { small_caps: Some(70.0), ..base.clone() });
// The capital is unchanged; the lowercase letter is a capital A at 70% (14 pt).
assert_eq!(small.glyphs[0].advance, cap.glyphs[0].advance);
assert!((small.glyphs[1].advance - cap14.glyphs[0].advance).abs() < 1e-6);
let (a, s) = (glyph_box(&cap14, 0), glyph_box(&small, 1));
assert!((s.height() - a.height()).abs() < 0.05, "{s:?} vs {a:?}");
// Byte offsets still map back to the source characters.
assert_eq!((small.glyphs[1].byte, small.glyphs[1].len), (1, 1));
// All Caps wins over Small Caps.
let all = layout_of("a", CharStyle { all_caps: true, small_caps: Some(70.0), ..base });
assert_eq!(all.glyphs[0].advance, cap.glyphs[0].advance);
}

View File

@ -354,6 +354,16 @@ impl Tool for TypeTool {
if s.is_empty() {
return vec![];
}
// Use Typographer's Quotes (Document Setup): a typed straight quote becomes the document's
// opening or closing quote, by the character before it.
let setup = &cx.doc.setup;
let s = if setup.typographers_quotes && matches!(s.as_str(), "\"" | "'") {
let text = self.plain(cx);
let mut prev = text.get(..self.caret.min(self.anchor)).and_then(|b| b.chars().next_back());
setup.quotes.apply(&s, &mut prev)
} else {
s
};
self.clicks = (None, 0);
// Pasting what we copied keeps its formatting.
let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect();

View File

@ -147,20 +147,24 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let doc = st.doc.clone();
let mask_view = st.shown_mask();
// Pasteboard, artboard shadows and paper.
// Pasteboard, artboard shadows and paper (Document Setup: the transparency grid's look, the
// simulated paper colour; a white Background Contents hides the grid).
painter.rect_filled(rect, 0.0, t.pasteboard);
if app.ui.view.artboards && !app.ui.view.outline {
let setup = &doc.setup;
let grid = (st.transparency_grid && setup.background == vectorcraft_doc::Background::Transparent).then(|| checker_texture(ui.ctx(), setup));
let paper = if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE };
for ab in &doc.artboards {
let r = xf.rect_to_screen(ab.rect);
let q = xf.quad(ab.rect);
// Hard 2 pt drop shadow, right and bottom (measured: #4d4d4d then #565656 on #606060).
let shift = |d: f32| q.iter().map(|p| *p + vec2(d, d)).collect::<Vec<_>>();
painter.add(Shape::convex_polygon(shift(2.0), Color32::from_black_alpha(26), Stroke::NONE));
painter.add(Shape::convex_polygon(shift(1.0), Color32::from_black_alpha(52), Stroke::NONE));
if st.transparency_grid && xf.rot == 0.0 {
checker(&painter, r);
} else {
painter.add(Shape::convex_polygon(q, Color32::WHITE, Stroke::NONE));
match &grid {
Some((tex, cell)) => checker(&painter, &q, ab.rect, xf.zoom, tex.id(), *cell),
None => {
painter.add(Shape::convex_polygon(q, paper, Stroke::NONE));
}
}
}
} else if app.ui.view.outline {
@ -219,6 +223,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
[r, g, b]
}),
mask_view,
highlight_substitutions: true,
..opts
};
// Light documents render synchronously (no lag vs overlays); heavy ones go to the worker.
@ -254,6 +259,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let r = xf.rect_to_screen(ab.rect);
let c = if i == active_ab { Color32::from_gray(0) } else { Color32::from_gray(120) };
painter.add(Shape::closed_line(xf.quad(ab.rect), Stroke::new(if i == active_ab { 1.0 } else { 0.6 }, c)));
// The bleed (Document Setup) as a red outline around the artboard.
if doc.setup.has_bleed() {
painter.add(Shape::closed_line(xf.quad(doc.setup.bleed_rect(ab.rect)), Stroke::new(1.0, t.bleed)));
}
if app.session.tool_id() == "artboard" || doc.artboards.len() > 1 {
painter.text(
r.left_top() - vec2(0.0, 4.0),
@ -534,18 +543,38 @@ pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_e
}
}
fn checker(p: &egui::Painter, r: egui::Rect) {
p.rect_filled(r, 0.0, Color32::WHITE);
let s = 8.0;
let clip = p.with_clip_rect(r.intersect(p.clip_rect()));
let (nx, ny) = (((r.width() / s).ceil() as i32).min(400), ((r.height() / s).ceil() as i32).min(400));
for y in 0..ny {
for x in 0..nx {
if (x + y) % 2 == 1 {
clip.rect_filled(egui::Rect::from_min_size(r.min + vec2(x as f32 * s, y as f32 * s), vec2(s, s)), 0.0, Color32::from_gray(204));
}
}
/// The transparency grid's 2 × 2-cell tile in the document's colours (Document Setup), cached per
/// look, and its cell size in screen points.
fn checker_texture(ctx: &egui::Context, setup: &vectorcraft_doc::DocSetup) -> (egui::TextureHandle, f32) {
let colors = setup.grid_colors.map(|c| crate::panels::c32(&c));
let id = egui::Id::new("transparency-grid-tile");
let cached = ctx.data(|d| d.get_temp::<([Color32; 2], egui::TextureHandle)>(id)).filter(|(c, _)| *c == colors);
let tex = cached.map(|(_, t)| t).unwrap_or_else(|| {
let [a, b] = colors;
let img = egui::ColorImage::new([2, 2], vec![a, b, b, a]);
let opts = egui::TextureOptions { wrap_mode: egui::TextureWrapMode::Repeat, ..egui::TextureOptions::NEAREST };
let t = ctx.load_texture(TRANSPARENCY_GRID, img, opts);
ctx.data_mut(|d| d.insert_temp(id, (colors, t.clone())));
t
});
(tex, setup.grid_size.cell())
}
/// Name of the transparency grid's texture.
pub(crate) const TRANSPARENCY_GRID: &str = "transparency grid";
/// The transparency grid over the artboard `ab` (screen corners `quad`): one textured quad whose
/// cells stay `cell` screen points at every zoom and turn with a rotated view.
fn checker(p: &egui::Painter, quad: &[Pos2], ab: Rect, zoom: f64, tex: egui::TextureId, cell: f32) {
let mut mesh = egui::Mesh::with_texture(tex);
// Two cells per texture repeat.
let per = (2.0 * cell as f64 / zoom).max(1e-9);
let (u, v) = ((ab.width() / per) as f32, (ab.height() / per) as f32);
for (pos, uv) in quad.iter().zip([pos2(0.0, 0.0), pos2(u, 0.0), pos2(u, v), pos2(0.0, v)]) {
mesh.vertices.push(egui::epaint::Vertex { pos: *pos, uv, color: Color32::WHITE });
}
mesh.indices.extend([0, 1, 2, 0, 2, 3]);
p.add(Shape::mesh(mesh));
}
fn grid(p: &egui::Painter, xf: &Xf, spacing: f64, subdiv: u32) {

View File

@ -1,11 +1,252 @@
//! File → Document Setup: the document's ruler units.
//! File → Document Setup: General (units, bleed, Edit Artboards, outline images, substitution
//! highlights), Transparency (grid size and colours, simulated paper, flattener preset, white
//! overprint) and Type (language, quotes, superscript/subscript/small caps, text export) tabs.
//!
//! Fields: what `document.setup` reports (and takes back), plus `tab` and `bleedLinked`. OK runs
//! `document.setup` with them: one undo step. On an error the dialog stays open.
use serde_json::json;
use serde_json::{Value, json};
use vectorcraft_doc::setup::{GRID_COLOR_PRESETS, LANGUAGES};
use vectorcraft_doc::{GridSize, Quotes, Unit};
use super::{DialogSpec, run_and_close};
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| "Document Setup".into(),
confirm: |app, d| run_and_close(app, "document.setUnits", json!({"units": d.str("units")})),
..DialogSpec::FORM
};
pub(super) const KIND: &str = "documentSetup";
const TABS: [&str; 3] = ["General", "Transparency", "Type"];
/// Dialog-only fields `document.setup` doesn't take.
const UI_ONLY: [&str; 2] = ["tab", "bleedLinked"];
const LABEL: f32 = 130.0;
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Document Setup".into(), body, confirm, min_width: 540.0, max_width: Some(540.0), ..DialogSpec::FORM };
/// Open Document Setup on the active document's settings.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let mut fields = app.session.execute("document.setup", &json!({})).map_err(|e| e.to_string())?;
let linked = fields["bleed"].as_array().is_some_and(|b| b.windows(2).all(|w| w[0] == w[1]));
fields["bleedLinked"] = json!(linked);
fields["tab"] = json!(TABS[0]);
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let params = d.fields.iter().filter(|(k, _)| !UI_ONLY.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect();
let r = app.run("document.setup", Value::Object(params));
if r.is_ok() {
app.ui.dialog = None;
}
r
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let tab = TABS.iter().position(|t| *t == d.str("tab")).unwrap_or(0);
if let Some(i) = widgets::tab_bar(ui, &TABS, tab) {
d.fields.insert("tab".into(), json!(TABS[i]));
}
ui.add_space(12.0);
ui.vertical(|ui| {
ui.set_min_height(330.0);
match tab {
0 => general(app, ui, d),
1 => transparency(app, ui, d),
_ => typography(ui, d),
}
});
false
}
/// A dropdown row bound to the string `d.fields[key]`; `options` are (value, label).
fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, options: &[(&str, &str)]) {
let cur = d.str(key);
let shown = options.iter().find(|(v, _)| v.eq_ignore_ascii_case(&cur)).map_or(cur.as_str(), |(_, l)| l);
let labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect();
widgets::label_row(ui, label, LABEL, |ui| {
if let Some((value, _)) = widgets::dropdown(ui, key, shown, &labels, 220.0).and_then(|i| options.get(i)) {
d.fields.insert(key.into(), json!(value));
}
});
}
/// A checkbox bound to `d.fields[key]`.
fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
let on = d.bool(key);
if widgets::check(ui, label, on, true) {
d.fields.insert(key.into(), json!(!on));
}
}
/// The units the dialog shows lengths in.
fn unit(d: &Dialog) -> Unit {
Unit::named(&d.str("units")).unwrap_or_default()
}
fn general(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let units: Vec<(&str, &str)> = Unit::ALL.iter().map(|u| (u.label(), u.label())).collect();
choice(ui, d, "units", "Units:", &units);
ui.add_space(6.0);
let unit = unit(d);
widgets::label_row(ui, "Bleed:", LABEL, |ui| form::bleed(ui, d, unit, 64.0));
ui.add_space(6.0);
widgets::label_row(ui, "", LABEL, |ui| {
// Applies the settings, then edits the artboards with the Artboard tool.
if widgets::flat_button(ui, "Edit Artboards", 120.0).clicked() && confirm(app, d).is_ok() {
app.select_tool("artboard");
}
});
ui.add_space(12.0);
check(ui, d, "outlineImages", "Show Images in Outline Mode");
check(ui, d, "highlightSubstitutedFonts", "Highlight Substituted Fonts");
check(ui, d, "highlightSubstitutedGlyphs", "Highlight Substituted Glyphs");
}
fn transparency(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let t = Tokens::get(ui.ctx());
widgets::subheader(ui, "Transparency Grid");
let sizes: Vec<(&str, &str)> = GridSize::ALL.iter().map(|g| (g.id(), g.label())).collect();
choice(ui, d, "gridSize", "Grid Size:", &sizes);
let colors: Vec<String> = d
.fields
.get("gridColors")
.and_then(Value::as_array)
.map(|a| a.iter().map(|c| c.as_str().unwrap_or("").to_string()).collect())
.unwrap_or_default();
let preset = GRID_COLOR_PRESETS
.iter()
.find(|(_, pair)| colors.len() == 2 && colors.iter().zip(pair).all(|(h, c)| h.eq_ignore_ascii_case(&c.to_hex())))
.map_or("Custom", |(n, _)| n);
let names: Vec<&str> = GRID_COLOR_PRESETS.iter().map(|(n, _)| *n).chain(["Custom"]).collect();
widgets::label_row(ui, "Grid Colors:", LABEL, |ui| {
if let Some((_, pair)) = widgets::dropdown(ui, "gridColors", preset, &names, 220.0).and_then(|i| GRID_COLOR_PRESETS.get(i)) {
d.fields.insert("gridColors".into(), json!(pair.map(|c| c.to_hex())));
}
});
// The two colours (the first is also the paper colour): editing either makes them Custom.
widgets::label_row(ui, "", LABEL, |ui| {
for i in 0..2 {
let hex = colors.get(i).map_or("", |h| h.trim_start_matches('#'));
let (chip, _) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover());
if let Some(c) = vectorcraft_color::Color::from_hex(hex) {
ui.painter().rect_filled(chip, 2.0, crate::panels::c32(&c));
}
ui.painter().rect_stroke(chip, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
if let Some(c) = widgets::hex_field(ui, ("grid-color", i), hex).and_then(|h| vectorcraft_color::Color::from_hex(&h)) {
let mut v = colors.clone();
v.resize(2, "#ffffff".into());
v[i] = c.to_hex();
d.fields.insert("gridColors".into(), json!(v));
}
ui.add_space(10.0);
}
});
ui.add_space(4.0);
check(ui, d, "simulatePaper", "Simulate Colored Paper");
ui.add_space(14.0);
widgets::subheader(ui, "Export and Clipboard Transparency Flattener Settings");
let presets: Vec<String> = app.session.flattener_presets().into_iter().map(|p| p.name).collect();
let options: Vec<(&str, &str)> = presets.iter().map(|p| (p.as_str(), p.as_str())).collect();
choice(ui, d, "flattenerPreset", "Preset:", &options);
check(ui, d, "discardWhiteOverprint", "Discard White Overprint in Output");
}
fn typography(ui: &mut egui::Ui, d: &mut Dialog) {
let t = Tokens::get(ui.ctx());
let langs: Vec<(&str, &str)> = LANGUAGES.iter().map(|(l, _)| (*l, *l)).collect();
let before = d.str("language");
choice(ui, d, "language", "Language:", &langs);
// A language brings its quotes.
if d.str("language") != before
&& let Some(q) = vectorcraft_doc::setup::language_quotes(&d.str("language"))
{
d.fields.insert("quotes".into(), json!({"double": q.double.iter().collect::<String>(), "single": q.single.iter().collect::<String>()}));
}
for (key, label, choices) in [("double", "Double Quotes:", Quotes::DOUBLE_CHOICES), ("single", "Single Quotes:", Quotes::SINGLE_CHOICES)] {
let pairs: Vec<String> = choices.iter().map(|p| p.iter().collect()).collect();
let labels: Vec<&str> = pairs.iter().map(String::as_str).collect();
let cur = d.fields.get("quotes").and_then(|q| q.get(key)).and_then(Value::as_str).unwrap_or("").to_string();
widgets::label_row(ui, label, LABEL, |ui| {
if let Some(pair) = widgets::dropdown(ui, ("quotes", key), &cur, &labels, 90.0).and_then(|i| pairs.get(i)) {
let mut q = d.fields.get("quotes").cloned().unwrap_or_else(|| json!({}));
q[key] = json!(pair);
d.fields.insert("quotes".into(), q);
}
});
}
check(ui, d, "typographersQuotes", "Use Typographer's Quotes");
ui.add_space(14.0);
widgets::subheader(ui, "Options");
egui::Grid::new("docsetup-scripts").num_columns(3).spacing([16.0, 6.0]).show(ui, |ui| {
ui.label("");
for h in ["Size", "Position"] {
ui.label(egui::RichText::new(h).size(11.0).color(t.text_dim));
}
ui.end_row();
for (key, label) in [("superscript", "Superscript:"), ("subscript", "Subscript:")] {
ui.label(egui::RichText::new(label).color(t.text));
for part in ["size", "position"] {
let v = d.fields.get(key).and_then(|m| m.get(part)).and_then(Value::as_f64).unwrap_or(0.0);
if let Some(n) = widgets::plain_field(ui, (key, part), v, "%", 1, 70.0) {
let mut m = d.fields.get(key).cloned().unwrap_or_else(|| json!({}));
m[part] = json!(n);
d.fields.insert(key.into(), m);
}
}
ui.end_row();
}
ui.label(egui::RichText::new("Small Caps:").color(t.text));
if let Some(n) = widgets::plain_field(ui, "smallCaps", d.f64("smallCapsSize", 70.0), "%", 1, 70.0) {
d.fields.insert("smallCapsSize".into(), json!(n));
}
ui.end_row();
});
ui.add_space(6.0);
choice(ui, d, "exportText", "Export:", &[("editable", "Preserve Text Editability"), ("appearance", "Preserve Text Appearance")]);
}
#[cfg(test)]
mod tests {
use serde_json::json;
use vectorcraft_engine::Session;
use crate::VectorcraftApp;
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, Default::default());
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 900.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx()));
out.textures_delta.clear();
}
#[test]
fn every_tab_draws_and_ok_is_one_undo_step() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("file.documentSetup", json!({})).unwrap();
for tab in ["General", "Transparency", "Type"] {
app.ui.dialog.as_mut().unwrap().fields.insert("tab".into(), json!(tab));
frame(&mut app);
}
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!((d.fields["flattenerPreset"].as_str(), d.fields["bleedLinked"].as_bool()), (Some("Medium Resolution"), Some(true)));
for (k, v) in [("bleed", json!([9, 9, 9, 9])), ("gridColors", json!("Blue")), ("language", json!("German")), ("units", json!("Millimeters"))]
{
d.fields.insert(k.into(), v);
}
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let st = app.session.active().unwrap();
assert_eq!((st.doc.setup.bleed, st.doc.setup.grid_colors_name()), ([9.0; 4], "Blue"));
assert_eq!((st.doc.setup.quotes.double, st.doc.units), (['„', '“'], vectorcraft_doc::Unit::Millimeters));
assert_eq!(st.history.undo.len(), 1);
// A bad value keeps the dialog open.
app.run("file.documentSetup", json!({})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("bleed".into(), json!(500));
assert!(crate::dialogs::confirm(&mut app).is_err());
assert!(app.ui.dialog.is_some());
}
}

View File

@ -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));
}
}

View File

@ -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;

View File

@ -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() {

View File

@ -32,6 +32,8 @@ pub mod unsaved;
pub mod widgets;
pub mod workspaces;
#[cfg(test)]
mod tests_docsetup;
#[cfg(test)]
mod tests_labels;
#[cfg(test)]

View File

@ -464,11 +464,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
}
"file.exportForScreens" => app.run("document.exportForScreens", p.clone()),
"file.export.png" => io::export(app, Some("png"), s("path"), p).map(|p| json!({"path": p})),
"file.documentSetup" => {
let units = app.session.active().map(|d| d.doc.units.label()).unwrap_or("Points");
app.ui.dialog = Some(crate::state::Dialog::new("documentSetup", json!({"units": units})));
Ok(Value::Null)
}
"file.documentSetup" => crate::dialogs::open_document_setup(app),
"edit.preferences" => {
crate::prefs_dialog::open(app, s("category").as_deref());
Ok(Value::Null)

View File

@ -0,0 +1,109 @@
//! The canvas draws Document Setup: the red bleed outline and the transparency grid in the
//! document's colours and cell size, also in a rotated view; a white Background Contents hides
//! the grid and Simulate Colored Paper tints the artboard.
use serde_json::json;
use vectorcraft_engine::Session;
use crate::theme::Tokens;
use crate::{VectorcraftApp, canvas};
/// One headless canvas frame: its shapes, the transparency grid tile uploaded in it (2 × 2
/// pixels: the grid colours) and the grid mesh, if any.
fn frame(app: &mut VectorcraftApp) -> (Vec<egui::Shape>, Option<Vec<egui::Color32>>, Option<egui::Mesh>) {
let ctx = egui::Context::default();
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| canvas::show(app, ui));
let tile = ctx.tex_manager().read().allocated().find(|(_, m)| m.name == canvas::TRANSPARENCY_GRID).map(|(id, _)| *id);
let pixels = tile.and_then(|id| out.textures_delta.set.iter().find(|(t, _)| **t == id)).and_then(|(_, deltas)| match &deltas.first()?.image {
egui::ImageData::Color(img) => Some(img.pixels.clone()),
});
out.textures_delta.clear();
let shapes: Vec<egui::Shape> = out.shapes.into_iter().map(|c| c.shape).collect();
let mut all = vec![];
flatten(&shapes, &mut all);
let mesh = all.into_iter().find_map(|s| match s {
egui::Shape::Mesh(m) if Some(m.texture_id) == tile => Some((*m).clone()),
_ => None,
});
(shapes, pixels, mesh)
}
fn flatten(shapes: &[egui::Shape], out: &mut Vec<egui::Shape>) {
for s in shapes {
match s {
egui::Shape::Vec(v) => flatten(v, out),
s => out.push(s.clone()),
}
}
}
/// The closed outlines drawn in `color`: their corner points.
fn outlines(shapes: &[egui::Shape], color: egui::Color32) -> Vec<Vec<egui::Pos2>> {
let mut all = vec![];
flatten(shapes, &mut all);
all.into_iter()
.filter_map(|s| match s {
egui::Shape::Path(p) if p.closed && p.stroke.color == egui::epaint::ColorMode::Solid(color) => Some(p.points),
_ => None,
})
.collect()
}
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app
}
#[test]
fn the_bleed_draws_as_a_red_outline_around_each_artboard() {
let mut app = app();
let red = Tokens::for_brightness(Default::default()).bleed;
assert!(outlines(&frame(&mut app).0, red).is_empty(), "no bleed, no outline");
app.run("document.setup", json!({"bleed": [10, 10, 20, 20]})).unwrap();
let (shapes, _, _) = frame(&mut app);
let found = outlines(&shapes, red);
assert_eq!(found.len(), 1);
// The artboard is 200 × 100 pt; the bleed grows it by 40 × 20 pt at the fitted zoom.
let ab = outlines(&shapes, egui::Color32::from_gray(0)).into_iter().next().unwrap();
let span = |q: &[egui::Pos2]| (q[2].x - q[0].x, q[2].y - q[0].y);
let ((bw, bh), (aw, ah)) = (span(&found[0]), span(&ab));
let zoom = aw / 200.0;
assert!((bw - 240.0 * zoom).abs() < 0.5 && (bh - 120.0 * zoom).abs() < 0.5 && (ah - 100.0 * zoom).abs() < 0.5, "{found:?} {ab:?}");
// A rotated view turns the outline with the artboard.
app.view_mut().unwrap().rotation = 30.0;
let q = &outlines(&frame(&mut app).0, red)[0];
assert!((q[1].y - q[0].y).abs() > 10.0, "the top edge is no longer horizontal: {q:?}");
}
#[test]
fn the_grid_takes_the_document_colours_also_rotated() {
let mut app = app();
app.run("view.transparencyGrid", json!({"on": true})).unwrap();
app.run("document.setup", json!({"gridColors": ["#ff0000", "#0000ff"], "gridSize": "large"})).unwrap();
let (_, tile, _) = frame(&mut app);
let (red, blue) = (egui::Color32::from_rgb(255, 0, 0), egui::Color32::from_rgb(0, 0, 255));
assert_eq!(tile, Some(vec![red, blue, blue, red]));
// Rotated: still one textured mesh over the artboard's turned corners.
app.view_mut().unwrap().rotation = 45.0;
let m = frame(&mut app).2.expect("the grid mesh");
assert!((m.vertices[1].pos.y - m.vertices[0].pos.y).abs() > 10.0);
// Large cells are 16 screen points: the 200 pt artboard spans 200 × zoom / 32 tile repeats.
let zoom = app.view_mut().unwrap().zoom as f32;
assert!((m.vertices[1].uv.x - 200.0 * zoom / 32.0).abs() < 1e-3, "{:?}", m.vertices[1].uv);
}
#[test]
fn white_background_hides_the_grid_and_paper_tints_the_artboard() {
let mut app = app();
app.run("view.transparencyGrid", json!({"on": true})).unwrap();
app.run("document.setup", json!({"backgroundContents": "white"})).unwrap();
assert!(frame(&mut app).2.is_none());
app.run("view.transparencyGrid", json!({"on": false})).unwrap();
app.run("document.setup", json!({"simulatePaper": true, "gridColors": ["#ffeecc", "#cccccc"]})).unwrap();
let mut all = vec![];
flatten(&frame(&mut app).0, &mut all);
let paper = egui::Color32::from_rgb(0xff, 0xee, 0xcc);
assert!(all.iter().any(|s| matches!(s, egui::Shape::Path(p) if p.fill == paper)), "the artboard shows the paper colour");
}

View File

@ -6,20 +6,21 @@ use vectorcraft_engine::Session;
use crate::{VectorcraftApp, canvas, menus};
/// Whether one headless canvas frame paints the grid's grey squares.
/// Whether one headless canvas frame paints the grid (one mesh textured with its tile).
fn draws_grid(app: &mut VectorcraftApp) -> bool {
fn grey(s: &egui::Shape) -> bool {
match s {
egui::Shape::Rect(r) => r.fill == egui::Color32::from_gray(204),
egui::Shape::Vec(v) => v.iter().any(grey),
_ => false,
}
}
let ctx = egui::Context::default();
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| canvas::show(app, ui));
out.textures_delta.clear();
out.shapes.iter().any(|c| grey(&c.shape))
let Some(tile) = ctx.tex_manager().read().allocated().find(|(_, m)| m.name == canvas::TRANSPARENCY_GRID).map(|(id, _)| *id) else { return false };
fn uses(s: &egui::Shape, tile: egui::TextureId) -> bool {
match s {
egui::Shape::Mesh(m) => m.texture_id == tile,
egui::Shape::Vec(v) => v.iter().any(|s| uses(s, tile)),
_ => false,
}
}
out.shapes.iter().any(|c| uses(&c.shape, tile))
}
#[test]

View File

@ -83,6 +83,8 @@ pub struct Tokens {
pub caption_close_text: Color32,
/// What preview images mark (Flattener Preview highlights).
pub highlight: Color32,
/// The bleed outline around artboards (Document Setup → Bleed).
pub bleed: Color32,
}
impl Tokens {
@ -124,6 +126,7 @@ impl Tokens {
caption_close: hex(0xc42b1c),
caption_close_text: Color32::WHITE,
highlight: hex(0xff3030),
bleed: hex(0xf03030),
};
match b {
Brightness::Dark => base,

View File

@ -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
}

View File

@ -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.

View File

@ -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}}}
```