//! VectorCraft document model: artboards, layers and objects, appearance, swatches (pure data + serde). //! //! Documents are persistent trees: children are `Arc`, and edits go through //! [`Document::node_mut`], which clones only the nodes on the path from the root to the edited node. //! Keeping the previous `Document` value around is therefore a cheap undo snapshot. #![forbid(unsafe_code)] pub mod appearance; pub mod assets; pub mod blend; pub mod cancel; pub mod clipnest; pub mod cmyk; pub mod corners; pub mod depth; pub mod dimension; pub mod graph; pub mod hit; mod id_index; pub mod inks; mod inline; pub mod intertwine; pub mod shared; mod symbol_export_type; mod symbol_nine_slice; mod symbol_registration; pub use inline::{SYMBOL_HALF, SYMBOL_SIZES}; pub use shared::Shared; pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType}; pub use symbol_nine_slice::{SYMBOL_NINE_SLICE, SymbolNineSlice}; pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration}; pub mod links; pub mod live; pub mod live_paint; pub mod marks; mod mem; pub mod metadata; pub mod node; pub mod objects_on_path; pub mod orient; pub mod overprint; pub mod pattern; pub mod perspective; mod pixels; pub use pixels::MAX_RASTER_ALLOC; pub mod placed_document; pub mod profiles; pub mod puppet; pub mod range; pub mod rastersettings; mod reach; pub mod recolor; pub mod selection; pub mod setup; pub mod shaper; pub mod slices; pub mod style_libs; pub mod swatches; mod symbols; pub use symbols::SymbolBoxes; pub mod text; pub mod trace; pub mod variables; pub mod xml_id; use std::collections::BTreeMap; use std::sync::Arc; /// `skip_serializing_if` predicates: fields equal to their serde default are not written. pub(crate) mod skip { pub fn is_default(v: &T) -> bool { *v == T::default() } pub fn is_true(v: &bool) -> bool { *v } pub fn is_one(v: &f32) -> bool { *v == 1.0 } pub fn is_empty_vec(v: &[T]) -> bool { v.is_empty() } pub fn is_empty_map(v: &serde_json::Map) -> bool { v.is_empty() } } pub use appearance::StrokeGradientMode; pub use appearance::{ Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset, SavedProfile, StrokeAlign, StrokeLayer, WidthProfile, }; pub use assets::ExportAsset; pub use corners::LiveCorners; pub use depth::MAX_DEPTH; pub use graph::{ ColumnDesign, ColumnType, Fractions, GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition, PieSort, SeriesPaint, TickLength, ValueAxisSide, }; pub use hit::{Hit, HitKind}; pub use links::{LinkInfo, PlacementOptions}; pub use live::{BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint}; pub use live_paint::{GapOptions, GapSize}; pub use metadata::{CopyrightStatus, DocMetadata}; pub use node::Knockout; pub use node::Scaling; pub use node::{Extras, ExtrasMut, ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeExtras, NodeId, NodeKind, OpacityMask}; pub use node::{ImageMap, MAX_SVG_SCRIPTS, ObjectAttributes, SVG_EVENTS}; pub use orient::OrientedBox; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use perspective::PerspectiveAttachment; pub use placed_document::PlacedDocument; pub use profiles::ColorProfiles; pub use puppet::{PuppetPin, PuppetPins}; pub use rastersettings::{RasterColorModel, RasterEffectsSettings}; pub use selection::{AnchorRef, SegmentRef, Selection}; pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource}; pub use style_libs::StyleLibrary; pub use text::{ AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition, RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape, }; pub use trace::TraceView; pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables}; pub use vectorcraft_color as color; pub use vectorcraft_geom as geom; use serde::{Deserialize, Serialize}; use vectorcraft_color::{Swatch, SwatchGroup}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; /// Coordinates beyond this many points exceed the supported document range. pub const MAX_COORD: f64 = 4.0e6; #[derive(Debug, thiserror::Error, PartialEq)] pub enum DocError { #[error("no such object {0}")] NoNode(NodeId), #[error("object {0} cannot have children")] NotContainer(NodeId), #[error("{0}")] Invalid(String), } /// Measurement units (display only; the model is always in points). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Unit { #[default] Points, Picas, Inches, Millimeters, Centimeters, Pixels, FeetInches, Meters, Yards, Feet, } impl Unit { pub const ALL: [Unit; 10] = [ Unit::Points, Unit::Picas, Unit::Inches, Unit::Millimeters, Unit::Centimeters, Unit::Pixels, Unit::FeetInches, Unit::Meters, Unit::Yards, Unit::Feet, ]; /// Points per unit. pub fn points(self) -> f64 { match self { Unit::Points | Unit::Pixels => 1.0, Unit::Picas => 12.0, Unit::Inches => 72.0, Unit::Millimeters => 72.0 / 25.4, Unit::Centimeters => 72.0 / 2.54, Unit::Meters => 72.0 / 0.0254, Unit::Yards => 72.0 * 36.0, Unit::Feet | Unit::FeetInches => 72.0 * 12.0, } } pub fn suffix(self) -> &'static str { match self { Unit::Points => "pt", Unit::Picas => "p", Unit::Inches => "in", Unit::Millimeters => "mm", Unit::Centimeters => "cm", Unit::Pixels => "px", Unit::FeetInches | Unit::Feet => "ft", Unit::Meters => "m", Unit::Yards => "yd", } } pub fn label(self) -> &'static str { match self { Unit::Points => "Points", Unit::Picas => "Picas", Unit::Inches => "Inches", Unit::Millimeters => "Millimeters", Unit::Centimeters => "Centimeters", Unit::Pixels => "Pixels", Unit::FeetInches => "Feet & Inches", Unit::Meters => "Meters", Unit::Yards => "Yards", Unit::Feet => "Feet", } } /// The rulers' label step at `zoom` (screen pixels per point), in this unit: the first of /// 1, 2, 5 × 10ⁿ that puts labels at least 50 pixels apart. Ticks mark every tenth of it. pub fn ruler_step(self, zoom: f64) -> f64 { const DECIMAL: [f64; 19] = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0]; // An inch is divided in half, not in ten: a tenth of an inch is not a mark an inch ruler // carries, so below an inch the steps are binary fractions (#2827). Labelling stops at a // sixteenth; zooming in past that leaves the labels where they are and only the ticks // between them grow denser, down to a hundred and twenty-eighth // ([`Self::ruler_minor`]). A label is at most six characters that way ("0.0625"), which // the left ruler has room for when it spells one down the ruler a character at a time. // From an inch up the ladder is the shared one. const BINARY: [f64; 15] = [1.0 / 16.0, 0.125, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 10000.0]; let steps: &[f64] = if self.ruler_halves() { &BINARY } else { &DECIMAL }; steps.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.0) } /// Does this unit's ruler divide a step in half rather than in ten? Inches do; the metric /// units, points, pixels and picas are read in tenths. fn ruler_halves(self) -> bool { matches!(self, Unit::Inches) } /// The unlabelled ticks between two labels, `step` apart ([`Self::ruler_step`]): an eighth of /// the step on an inch ruler, so every tick is a binary fraction of an inch and they stay about /// as dense as the tenths elsewhere; a tenth of it otherwise. pub fn ruler_minor(self, step: f64) -> f64 { if self.ruler_halves() { step / 8.0 } else { step / 10.0 } } /// `v` (points) on the nearest ruler tick at `zoom` ([`Self::ruler_step`]). pub fn snap_to_ruler_tick(self, v: f64, zoom: f64) -> f64 { let tick = self.ruler_minor(self.ruler_step(zoom)) * self.points(); (v / tick).round() * tick } pub fn from_pt(self, v: f64) -> f64 { v / self.points() } pub fn to_pt(self, v: f64) -> f64 { v * self.points() } /// Format a point value in this unit the way fields show it (e.g. `12.5 pt`, `3 in`). pub fn format(self, pt: f64) -> String { format!("{} {}", self.number(pt), self.suffix()) } /// [`Unit::format`] without the suffix (`12.5`), for narrow fields. pub fn number(self, pt: f64) -> String { // Three decimals for the small units (`595.276 pt`), four for the large ones (`8.2677 in`, // `35.2778 mm`, the `0.0078 in` stroke preset); trailing zeros are trimmed (`1 pt` is `1`). let decimals = if matches!(self, Unit::Points | Unit::Pixels | Unit::Picas) { 3 } else { 4 }; let s = format!("{:.*}", decimals, self.from_pt(pt)); let s = s.trim_end_matches('0').trim_end_matches('.'); if s == "-0" { "0".into() } else { s.into() } } /// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic. /// /// A unit after a `*` or `/` operand measures the whole expression (`1080/2 px` is 540 px), so /// math typed before a field's unit suffix works. After `*` or `/`, a percentage scales the term /// before it (`3cm*50%` is 1.5 cm, `3cm/50%` is 6 cm). After `+` or `-`, it adds or takes away /// that share of everything before it (`50pt+25%` is 62.5 pt, `100pt+20pt-50%` is 60 pt). A /// percentage on its own (`50%`) reads as nothing. pub fn parse(self, s: &str) -> Option { parse_measure(s, self).filter(|v| v.is_finite()) } /// The value naming this unit in the Units preferences (`points`, `millimeters`, /// `feetInches`). pub fn key(self) -> &'static str { match self { Unit::Points => "points", Unit::Picas => "picas", Unit::Inches => "inches", Unit::Millimeters => "millimeters", Unit::Centimeters => "centimeters", Unit::Pixels => "pixels", Unit::FeetInches => "feetInches", Unit::Meters => "meters", Unit::Yards => "yards", Unit::Feet => "feet", } } /// The unit `name` names: its label, suffix or [`Unit::key`] in any case, ignoring spaces and /// punctuation (`Millimeters`, `mm`, `Feet & Inches`, `feetInches`). pub fn named(name: &str) -> Option { let letters = |s: &'static str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()); let given = || name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()); // Feet and Feet & Inches share the suffix `ft`: it names the plain unit, so Feet & Inches // is left to its label and key. Unit::ALL .into_iter() .find(|u| letters(u.label()).eq(given())) .or_else(|| Unit::ALL.into_iter().filter(|u| *u != Unit::FeetInches).find(|u| u.suffix().chars().eq(given()))) } /// A point value as the canvas measurement labels show it: two decimals and the suffix /// (`12.50 mm`). pub fn readout(self, pt: f64) -> String { format!("{:.2} {}", self.from_pt(pt), self.suffix()) } } fn parse_measure(s: &str, default: Unit) -> Option { let s = s.trim(); // Simple arithmetic on the right (Illustrator fields accept "10+5", "100/2", "3in*2"). // `+` and `-` share one precedence level, as do `*` and `/`, so each level splits at the last // of its operators (left to right). for ops in [&['+', '-'][..], &['*', '/'][..]] { // Past the first character, so a leading sign isn't an operator. let skip = s.chars().next().map_or(0, char::len_utf8); // A `+` or `-` right after another operator is the sign of its operand (`10*-2`, `5--3`). let is_sign = |i: usize| { s.get(i..).and_then(|r| r.chars().next()).is_some_and(|c| matches!(c, '+' | '-')) && s.get(..i).and_then(|l| l.trim_end().chars().last()).is_some_and(|c| "+-*/".contains(c)) }; let found = s.char_indices().rev().find(|&(i, c)| i >= skip && ops.contains(&c) && !is_sign(i)); if let Some(i) = found.map(|(i, _)| i) { let (l, r) = (&s[..i], &s[i + 1..]); if l.trim().is_empty() { continue; } let Some(op) = s[i..].chars().next() else { continue }; return match op { '+' | '-' => { let a = parse_measure(l, default)?; // A percentage added or taken away is a share of everything before it: // `50pt+25%` is 62.5 pt, `100pt+20%-10pt` is 110 pt. let b = match percent(r) { Some(k) => a * k, None => parse_measure(r, default)?, }; Some(if op == '+' { a + b } else { a - b }) } _ => { // A factor or divisor is a plain number or a percentage (`3cm*50%` is 1.5 cm); // a unit after it is the expression's. let (k, unit) = match percent(r) { Some(k) => (k, None), None => number_unit(r)?, }; let a = parse_measure(l, unit.unwrap_or(default))?; if op == '*' { Some(a * k) } else if k == 0.0 { None } else { Some(a / k) } } }; } } if let Some((p, pt)) = s.split_once('p') && !p.is_empty() && p.trim().parse::().is_ok() && (pt.is_empty() || pt.trim().parse::().is_ok()) && !s.ends_with("pt") && !s.ends_with("px") { // The sign belongs to the whole value: `-2p6` is -(2 picas + 6 points). let sign = if p.trim_start().starts_with('-') { -1.0 } else { 1.0 }; let picas = p.trim().parse::().ok()?.abs(); return Some(sign * (picas * 12.0 + pt.trim().parse::().unwrap_or(0.0))); } let (v, unit) = number_unit(s)?; Some(unit.unwrap_or(default).to_pt(v)) } /// A number with an optional unit suffix (`12`, `3 mm`, `2in`) → (the number, its unit). fn number_unit(s: &str) -> Option<(f64, Option)> { let s = s.trim(); let num_end = s.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).unwrap_or(s.len()); let v: f64 = s.get(..num_end)?.trim().parse().ok()?; let unit = match s.get(num_end..)?.trim() { "" => None, "pt" => Some(Unit::Points), "px" => Some(Unit::Pixels), "in" | "\"" => Some(Unit::Inches), "mm" => Some(Unit::Millimeters), "cm" => Some(Unit::Centimeters), "m" => Some(Unit::Meters), "ft" | "'" => Some(Unit::Feet), "yd" => Some(Unit::Yards), "p" | "pc" => Some(Unit::Picas), _ => return None, }; Some((v, unit)) } /// A number followed by `%` (`50%`, `12.5 %`) → its fraction (`0.5`, `0.125`). fn percent(s: &str) -> Option { match number_unit(s.trim().strip_suffix('%')?)? { (p, None) => Some(p / 100.0), _ => None, } } /// A unitless field value (percent, degrees, counts) with the same `+ - * /` arithmetic as /// [`Unit::parse`] (`45*2`, `100/3`); text other than digits, `.` and operators reads as nothing. pub fn parse_number(s: &str) -> Option { if !s.chars().all(|c| c.is_ascii_digit() || " .+-*/".contains(c)) { return None; } // A leading `+` is a sign (`+5`), as a plain number read it. let s = s.trim(); Unit::Points.parse(s.strip_prefix('+').unwrap_or(s)) } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum ColorMode { #[default] Rgb, Cmyk, } impl ColorMode { /// The colour model new colours take in a document of this mode. pub fn model(self) -> vectorcraft_color::cms::Model { match self { ColorMode::Rgb => vectorcraft_color::cms::Model::Rgb, ColorMode::Cmyk => vectorcraft_color::cms::Model::Cmyk, } } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Artboard { pub id: u32, pub name: String, /// Artboard rectangle in document points. pub rect: Rect, /// Locked (the Artboards panel's lock column): the Artboard tool and the artboard commands /// don't move, resize or delete it, Rearrange leaves it where it is, and the art on it can't be /// selected or edited ([`Document::on_locked_artboard`]), except new art while it stays /// selected ([`Document::new_art`]). #[serde(default, skip_serializing_if = "skip::is_default")] pub locked: bool, #[serde(default)] pub show_center_mark: bool, #[serde(default)] pub show_cross_hairs: bool, /// Background color (Properties › Artboards › Fill, Artboard Options). The canvas draws the /// artboard's paper in it, multiplied with the Simulate Colored Paper color while that is on; /// without one the paper is white, or the Simulate Colored Paper color. Exports and print leave /// it out. #[serde(default, skip_serializing_if = "Option::is_none")] pub color: Option, } /// A document's locked artboards ([`Document::locked_artboards`]). pub struct LockedArtboards<'a> { rects: Vec, symbols: SymbolBoxes<'a>, /// The new art, which isn't on them ([`Document::new_art`]). new_art: Arc>, /// The objects of pattern editing mode's layer ([`Document::pattern_edit`]), which are on no /// artboard. pattern_art: std::collections::HashSet, /// What [`LockedArtboards::overlaps`] found for each object it measured: the members of a /// group are each answered with one measurement of the group. held: std::cell::RefCell>, } impl LockedArtboards<'_> { /// No artboard is locked. pub fn is_empty(&self) -> bool { self.rects.is_empty() } /// Is `n`, an object of a layer or sublayer, on a locked artboard: shown, not a guide, and its /// visual bounds (with its strokes, a symbol instance by its art) overlapping the artboard? An /// object counts for every artboard it overlaps, as Fit to Artwork Bounds counts it. A layer or /// sublayer is on none, nor is the art of pattern editing mode ([`Document::pattern_edit`]) or /// new art while it stays selected ([`Document::new_art`]). pub fn holds(&self, n: &Node) -> bool { !self.new_art.contains(&n.id) && self.overlaps(n) } /// [`LockedArtboards::holds`] for new art too: is `n` on a locked artboard, whether or not it /// is new art? pub fn overlaps(&self, n: &Node) -> bool { if self.rects.is_empty() || !n.visible || n.is_layer() || matches!(n.kind, NodeKind::Path { guide: true, .. }) || self.pattern_art.contains(&n.id) { return false; } if let Some(held) = self.held.borrow().get(&n.id) { return *held; } let held = n.visual_bounds_with(&self.symbols).is_some_and(|b| self.rects.iter().any(|r| b.x0 < r.x1 && b.x1 > r.x0 && b.y0 < r.y1 && b.y1 > r.y0)); self.held.borrow_mut().insert(n.id, held); held } /// Is node `id` on a locked artboard: is the object of a layer or sublayer that holds it (`id` /// itself when it is one, a group counting as one object) on one ([`LockedArtboards::holds`])? pub fn holds_id(&self, id: NodeId) -> bool { !self.rects.is_empty() && self.symbols.document().layer_object(id).is_some_and(|n| self.holds(n)) } } /// New art on a locked artboard ([`Document::new_art`]). Setting it changes the document in /// place, so a selection change that sets it doesn't replace the document. #[derive(Default)] pub struct NewArtIds(std::sync::RwLock>>); impl NewArtIds { /// The objects. pub fn get(&self) -> Arc> { self.0.read().map(|g| g.clone()).unwrap_or_default() } /// Make `ids` the objects. pub fn set(&self, ids: Arc>) { if let Ok(mut g) = self.0.write() { *g = ids; } } } impl Clone for NewArtIds { fn clone(&self) -> Self { NewArtIds(std::sync::RwLock::new(self.get())) } } /// Editing state, not document content: two documents are equal whatever new art they name. impl PartialEq for NewArtIds { fn eq(&self, _: &Self) -> bool { true } } impl std::fmt::Debug for NewArtIds { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_tuple("NewArtIds").field(&self.get()).finish() } } /// Opacity-mask editing mode: `object`'s mask art lives on the temporary `layer` while editing. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct MaskEdit { pub object: NodeId, pub layer: NodeId, } /// A saved view (View → New View…): zoom, centre and rotation, listed at the bottom of the View menu. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct SavedView { pub name: String, pub center: Point, pub zoom: f64, #[serde(default)] pub rotation: f64, } /// A saved selection (Select → Save Selection…): the objects that were selected, listed by name at /// the bottom of the Select menu. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct SavedSelection { pub name: String, pub objects: Vec, } impl SavedSelection { /// Most saved selections a document keeps (the Select menu lists every one). pub const MAX: usize = 25; /// Longest name, in characters. pub const MAX_NAME: usize = 255; /// `name` trimmed and cut to [`Self::MAX_NAME`] characters (`None` when blank). pub fn clean_name(name: &str) -> Option { let name: String = name.trim().chars().take(Self::MAX_NAME).collect(); (!name.is_empty()).then_some(name) } /// The first "Selection N" none of `saved` is named. pub fn default_name(saved: &[SavedSelection]) -> String { (1..=saved.len() + 1).map(|i| format!("Selection {i}")).find(|n| !saved.iter().any(|x| &x.name == n)).unwrap_or_default() } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Guide { /// true = vertical guide at `pos` (x), false = horizontal at `pos` (y). pub vertical: bool, pub pos: f64, /// An artboard guide: the [`Artboard::id`] it belongs to. It runs across that artboard only /// and moves, is copied and is deleted with it. None: a canvas guide, across the whole canvas. #[serde(default, skip_serializing_if = "Option::is_none")] pub artboard: Option, /// The layer (or sublayer) it is on: it shows, hides and locks with that layer and is listed /// in it in the Layers panel, and goes when the layer is deleted. None: on no layer (guides /// from before guides had layers, and from files that don't say), always there. #[serde(default, skip_serializing_if = "Option::is_none")] pub layer: Option, } impl Guide { /// A canvas guide. pub fn new(vertical: bool, pos: f64) -> Self { Self { vertical, pos, artboard: None, layer: None } } /// Moved by `d` (a vertical guide across, a horizontal one down). pub fn moved(&self, d: Vec2) -> Self { Self { pos: self.pos + if self.vertical { d.x } else { d.y }, ..self.clone() } } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct GridPrefs { pub spacing: f64, pub subdivisions: u32, } impl Default for GridPrefs { fn default() -> Self { Self { spacing: 72.0, subdivisions: 8 } } } /// A named graphic style (Graphic Styles panel): an appearance plus the object's transparency. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct GraphicStyle { pub name: String, pub appearance: Appearance, /// Stable id linked objects refer to ([`Node::graphic_style`]); 0 until first needed (styles /// from older files get one when an object is first linked to them). #[serde(default, skip_serializing_if = "skip::is_default")] pub id: u32, #[serde(default = "one", skip_serializing_if = "skip::is_one")] pub opacity: f32, #[serde(default, skip_serializing_if = "skip::is_default")] pub blend: vectorcraft_color::BlendMode, #[serde(default, skip_serializing_if = "skip::is_default")] pub isolate: bool, #[serde(default, skip_serializing_if = "skip::is_default")] pub knockout: Knockout, /// Placed gradients are stored relative to the unit box (0, 0)–(1, 1), so applying the style /// places them on each object's own bounds. Styles saved before this kept document /// coordinates (false). #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub unit_box: bool, } fn one() -> f32 { 1.0 } /// The name of the style new documents list first (Sort by Name keeps it first). pub const DEFAULT_GRAPHIC_STYLE: &str = "Default Graphic Style"; impl GraphicStyle { /// The box a style's placed gradients are stored relative to. pub const UNIT_BOX: Rect = Rect::new(0.0, 0.0, 1.0, 1.0); /// A style with `appearance` (its placed gradients, if any, in unit-box space) and default /// transparency (no id yet). pub fn new(name: impl Into, appearance: Appearance) -> Self { Self { name: name.into(), appearance, id: 0, opacity: 1.0, blend: Default::default(), isolate: false, knockout: Knockout::Neutral, unit_box: true, } } /// A style capturing `n`'s transparency and `appearance` (in unit-box space). pub fn of(name: impl Into, appearance: Appearance, n: &Node) -> Self { Self { opacity: n.opacity, blend: n.blend, isolate: n.isolate, knockout: n.knockout, ..Self::new(name, appearance) } } /// The style's appearance as object `n` takes it: placed gradients stored in unit-box space /// land at the same place relative to `n`'s geometric bounds. pub fn appearance_on(&self, n: &Node) -> std::borrow::Cow<'_, Appearance> { let placed = self.unit_box && self.appearance.has_placed_gradient(); match n.geometric_bounds().filter(|_| placed) { Some(b) => { let mut ap = self.appearance.clone(); ap.rebase_gradients(Self::UNIT_BOX, b); std::borrow::Cow::Owned(ap) } None => std::borrow::Cow::Borrowed(&self.appearance), } } /// Give `n` this style's transparency. pub fn apply_transparency(&self, n: &mut Node) { (n.opacity, n.blend, n.isolate, n.knockout) = (self.opacity, self.blend, self.isolate, self.knockout); } /// Does `n` have this style's transparency? pub fn transparency_matches(&self, n: &Node) -> bool { (n.opacity, n.blend, n.isolate, n.knockout) == (self.opacity, self.blend, self.isolate, self.knockout) } } /// A symbol definition. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Symbol { pub name: String, pub art: Arc, } /// Encoded image bytes (PNG/JPEG/WebP…) shared by image objects. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ImageBlob { pub mime: String, #[serde(skip)] pub bytes: Arc>, /// A linked image's low-resolution preview (PNG, see [`links`]): what a save writes when only /// linked images show this blob, and what `bytes` hold while the linked file can't be read. #[serde(skip)] pub proxy: Option>>, } /// What the rulers measure from (View › Rulers): the active artboard's origin, the default of a /// new document, or one origin for the whole document. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum RulerMode { #[default] Artboard, Global, } impl RulerMode { /// The mode of a document saved before the choice existed: global, as its rulers measured. pub fn legacy() -> Self { Self::Global } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Document { pub version: u32, #[serde(default)] pub title: String, /// Saved with File → Save as Template: opening it starts a new untitled document. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub template: bool, #[serde(default)] pub units: Unit, /// The rulers' zero point in document coordinates. Moving it changes measurements, never art. #[serde(default, skip_serializing_if = "skip::is_default")] pub ruler_origin: Point, /// View › Rulers › Change to Global / Artboard Rulers: whether the rulers and position fields /// measure from [`Self::ruler_origin`] (global) or from the active artboard's own origin. /// Documents saved before the choice existed measured globally and open that way. #[serde(default = "RulerMode::legacy")] pub rulers: RulerMode, /// Artboard rulers' zero point of each artboard (by [`Artboard::id`]), as an offset from its /// top-left corner; an artboard not listed measures from that corner. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub artboard_ruler_origins: BTreeMap, #[serde(default)] pub color_mode: ColorMode, pub artboards: Vec, /// Top-level layers, bottom first. pub layers: Vec>, // The document-wide resources below are [`Shared`]: an edit copies the document (copy-on-write) // and the undo history keeps every version, so what an edit doesn't change stays one copy. // Reads, writes and the saved form are those of the value itself. #[serde(default)] pub swatches: Shared>, #[serde(default)] pub swatch_groups: Shared>, #[serde(default)] pub graphic_styles: Shared>, /// Character styles (besides the built-in [Normal Character Style], which may be redefined here). #[serde(default, skip_serializing_if = "skip::is_empty_vec")] pub char_styles: Shared>, /// Paragraph styles (besides the built-in [Normal Paragraph Style]). #[serde(default, skip_serializing_if = "skip::is_empty_vec")] pub para_styles: Shared>, /// Threaded text: area-type frames one story flows through, in order. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub text_threads: Vec>, #[serde(default)] pub symbols: Vec, /// Object › Graph › Design…: art graphs can draw as markers. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub graph_designs: Vec, #[serde(default)] pub guides: Vec, /// View → New View… (up to 25, like Illustrator). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub views: Vec, /// Select → Save Selection… (at most [`SavedSelection::MAX`]); saved with the document. #[serde(default, skip_serializing_if = "skip::is_empty_vec")] pub saved_selections: Shared>, /// Variables (data merge): named values bound to objects, applied per dataset. #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub variables: Shared, #[serde(default)] pub grid: GridPrefs, #[serde(default = "ppi72")] pub raster_effects_ppi: f64, #[serde(default)] pub images: BTreeMap, /// Pattern swatch definitions (Object → Pattern). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub patterns: Vec, /// Pattern editing mode, while active. #[serde(default, skip_serializing_if = "Option::is_none")] pub pattern_edit: Option, /// Opacity-mask editing mode, while active. Never saved (see [`Document::without_edit_modes`]); /// still read so files saved mid-edit by older versions load without the editing layer. #[serde(default, skip_serializing)] pub mask_edit: Option, next_id: u64, /// Foreign data preserved on round-trip. #[serde(default, skip_serializing_if = "skip::is_empty_map")] pub unknown: Shared>, /// Transparency panel → Page Isolated Blending: the page is an isolated transparency group, /// so top-level blend modes don't blend with what lies under the page. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub page_isolate: bool, /// Transparency panel → Page Knockout Group: the page's elements (its layers; neutral layers /// pass their contents through) knock each other out. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub page_knockout: bool, /// Swatches panel → Spot Colors: spot colours defined in Lab show and separate from their Lab /// values (on, the default) or from their working-CMYK equivalents (off) /// ([`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, /// Layers panel → Paste Remembers Layers: pasted objects go back into the layers (by name) /// they were copied from instead of the current layer. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub paste_remembers_layers: bool, /// File → File Info (the title is [`Document::title`]). #[serde(default, skip_serializing_if = "skip::is_default")] pub metadata: DocMetadata, /// Effect → Document Raster Effects Settings besides the resolution /// ([`Document::raster_effects_ppi`]). #[serde(default, skip_serializing_if = "skip::is_default")] pub raster_effects: RasterEffectsSettings, /// The view (zoom, centre, rotation) the document was saved with; it reopens there. Written at /// save time only, so changing the view never marks the document modified. #[serde(default, skip_serializing_if = "Option::is_none")] pub last_view: Option, /// View → Show Transparency Grid when the document was saved. Written at save time only, /// like [`Document::last_view`]; older files open with the grid hidden. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub transparency_grid: bool, /// The layers, sublayers and groups open in the Layers panel when the document was saved; they /// reopen that way. `None`: the default, only the top-level layers open. Written at save time /// only, like [`Document::last_view`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub layers_open: Option>, /// Edit → Assign Profile: the profiles the document is tagged with (files before format v3 /// kept them in `unknown`, see [`Document::migrate_color_profiles`]). #[serde(default, skip_serializing_if = "ColorProfiles::is_empty")] pub color_profiles: ColorProfiles, /// File → Export for Screens: the settings it last exported with (`document.exportForScreens` /// params), so the dialog reopens on them. #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] pub export_settings: serde_json::Map, /// Top-level keys this version doesn't know (written by a newer one), kept so saving doesn't /// lose them. Separate from [`Document::unknown`], which holds foreign data by design. #[serde(flatten)] pub extra: serde_json::Map, /// User slices (Slice tool, Object → Slice); object slices are [`Node::slice`]. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub slices: Vec, /// Object → Slice → Clip to Artboard: slices are clipped to the artboards and auto slices fill /// them (on, the default); off, auto slices cover the art and the slices. #[serde(default = "yes", skip_serializing_if = "skip::is_true")] pub slices_clip_to_artboard: bool, /// File → Print: the print settings saved with the document (`vectorcraft_pdf::PrintSettings` /// as JSON: the print engine sits above this crate); `None` until they are set up. #[serde(default, skip_serializing_if = "Option::is_none")] pub print_setup: Option, /// Window → Asset Export: art collected for export ([`ExportAsset`]), in panel order. Their /// export settings are Export for Screens' ([`Document::export_settings`]). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub assets: Vec, /// Window › SVG Interactivity › Link JavaScript Files: the JavaScript files (paths or URLs, as /// given) SVG export links with `