1709 lines
72 KiB
Rust
1709 lines
72 KiB
Rust
//! VectorCraft document model: artboards, layers and objects, appearance, swatches (pure data + serde).
|
||
//!
|
||
//! Documents are persistent trees: children are `Arc<Node>`, 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 clipnest;
|
||
pub mod cmyk;
|
||
pub mod corners;
|
||
pub mod graph;
|
||
pub mod hit;
|
||
mod id_index;
|
||
pub mod inks;
|
||
mod inline;
|
||
pub mod shared;
|
||
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
|
||
pub use shared::Shared;
|
||
pub mod links;
|
||
pub mod live;
|
||
pub mod marks;
|
||
mod mem;
|
||
pub mod metadata;
|
||
pub mod node;
|
||
pub mod orient;
|
||
pub mod overprint;
|
||
pub mod pattern;
|
||
pub mod perspective;
|
||
mod pixels;
|
||
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<T: Default + PartialEq>(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<T>(v: &[T]) -> bool {
|
||
v.is_empty()
|
||
}
|
||
pub fn is_empty_map(v: &serde_json::Map<String, serde_json::Value>) -> 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 graph::{
|
||
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::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||
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, ObjectAttributes};
|
||
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, InlineArt, Justify, Kinsoku, LeadingModel,
|
||
Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, 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};
|
||
|
||
#[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 STEPS: [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];
|
||
STEPS.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.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_step(zoom) / 10.0 * 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.
|
||
pub fn parse(self, s: &str) -> Option<f64> {
|
||
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<Unit> {
|
||
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());
|
||
Unit::ALL.into_iter().find(|u| letters(u.label()).eq(given()) || 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<f64> {
|
||
let s = s.trim();
|
||
// Simple arithmetic on the right (Illustrator fields accept "10+5", "100/2", "3in*2").
|
||
for op in ['+', '-', '*', '/'] {
|
||
// Past the first character, so a leading sign isn't an operator.
|
||
let skip = s.chars().next().map_or(0, char::len_utf8);
|
||
if let Some(i) = s[skip..].rfind(op).map(|i| i + skip) {
|
||
let (l, r) = (&s[..i], &s[i + 1..]);
|
||
if l.trim().is_empty() {
|
||
continue;
|
||
}
|
||
return match op {
|
||
'+' => Some(parse_measure(l, default)? + parse_measure(r, default)?),
|
||
'-' => Some(parse_measure(l, default)? - parse_measure(r, default)?),
|
||
_ => {
|
||
// A factor or divisor is a plain number; a unit after it is the expression's.
|
||
let (k, unit) = 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::<f64>().is_ok()
|
||
&& (pt.is_empty() || pt.trim().parse::<f64>().is_ok())
|
||
&& !s.ends_with("pt")
|
||
&& !s.ends_with("px")
|
||
{
|
||
return Some(p.trim().parse::<f64>().ok()? * 12.0 + pt.trim().parse::<f64>().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<Unit>)> {
|
||
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 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<f64> {
|
||
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,
|
||
#[serde(default)]
|
||
pub show_center_mark: bool,
|
||
#[serde(default)]
|
||
pub show_cross_hairs: bool,
|
||
}
|
||
|
||
/// 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<NodeId>,
|
||
}
|
||
|
||
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<String> {
|
||
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<u32>,
|
||
/// 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<NodeId>,
|
||
}
|
||
|
||
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<String>, 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<String>, 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<Node>,
|
||
}
|
||
|
||
/// 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<Vec<u8>>,
|
||
/// 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<Arc<Vec<u8>>>,
|
||
}
|
||
|
||
#[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,
|
||
#[serde(default)]
|
||
pub color_mode: ColorMode,
|
||
pub artboards: Vec<Artboard>,
|
||
/// Top-level layers, bottom first.
|
||
pub layers: Vec<Arc<Node>>,
|
||
// 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<Vec<Swatch>>,
|
||
#[serde(default)]
|
||
pub swatch_groups: Shared<Vec<SwatchGroup>>,
|
||
#[serde(default)]
|
||
pub graphic_styles: Shared<Vec<GraphicStyle>>,
|
||
/// 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<Vec<TextStyleDef>>,
|
||
/// Paragraph styles (besides the built-in [Normal Paragraph Style]).
|
||
#[serde(default, skip_serializing_if = "skip::is_empty_vec")]
|
||
pub para_styles: Shared<Vec<TextStyleDef>>,
|
||
/// Threaded text: area-type frames one story flows through, in order.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub text_threads: Vec<Vec<NodeId>>,
|
||
#[serde(default)]
|
||
pub symbols: Vec<Symbol>,
|
||
/// Object › Graph › Design…: art graphs can draw as markers.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub graph_designs: Vec<GraphDesign>,
|
||
#[serde(default)]
|
||
pub guides: Vec<Guide>,
|
||
/// View → New View… (up to 25, like Illustrator).
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub views: Vec<SavedView>,
|
||
/// Select → Save Selection… (at most [`SavedSelection::MAX`]); saved with the document.
|
||
#[serde(default, skip_serializing_if = "skip::is_empty_vec")]
|
||
pub saved_selections: Shared<Vec<SavedSelection>>,
|
||
/// Variables (data merge): named values bound to objects, applied per dataset.
|
||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||
pub variables: Shared<Variables>,
|
||
#[serde(default)]
|
||
pub grid: GridPrefs,
|
||
#[serde(default = "ppi72")]
|
||
pub raster_effects_ppi: f64,
|
||
#[serde(default)]
|
||
pub images: BTreeMap<String, ImageBlob>,
|
||
/// Pattern swatch definitions (Object → Pattern).
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub patterns: Vec<PatternDef>,
|
||
/// Pattern editing mode, while active.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub pattern_edit: Option<PatternEdit>,
|
||
/// 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<MaskEdit>,
|
||
next_id: u64,
|
||
/// Foreign data preserved on round-trip.
|
||
#[serde(default, skip_serializing_if = "skip::is_empty_map")]
|
||
pub unknown: Shared<serde_json::Map<String, serde_json::Value>>,
|
||
/// 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<SavedView>,
|
||
/// 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<Vec<NodeId>>,
|
||
/// 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<String, serde_json::Value>,
|
||
/// 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<String, serde_json::Value>,
|
||
/// User slices (Slice tool, Object → Slice); object slices are [`Node::slice`].
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub slices: Vec<Slice>,
|
||
/// 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<serde_json::Value>,
|
||
/// 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<ExportAsset>,
|
||
/// Puppet Warp pins on the selected artwork while the tool edits it ([`PuppetPins`]): editing
|
||
/// state, never saved.
|
||
#[serde(skip)]
|
||
pub puppet: Option<Arc<PuppetPins>>,
|
||
/// Where each node is in the tree, for lookups by id ([`id_index`]): a cache, never saved.
|
||
#[serde(skip)]
|
||
index: id_index::IdIndex,
|
||
}
|
||
|
||
fn ppi72() -> f64 {
|
||
72.0
|
||
}
|
||
|
||
fn yes() -> bool {
|
||
true
|
||
}
|
||
|
||
/// Listed nodes by parent: the parent's index path (empty for the top-level layers), then the index
|
||
/// of each listed child in it, in order, with the child's place in the list.
|
||
type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
|
||
|
||
pub const FORMAT_VERSION: u32 = 1;
|
||
|
||
impl Document {
|
||
/// A new RGB document with one artboard of `size` and one layer ("Layer 1").
|
||
pub fn new(width: f64, height: f64) -> Self {
|
||
Self::new_with_mode(width, height, ColorMode::Rgb)
|
||
}
|
||
|
||
/// [`Document::new`] in colour `mode`, with that mode's default swatches.
|
||
pub fn new_with_mode(width: f64, height: f64, mode: ColorMode) -> Self {
|
||
let (swatches, swatch_groups) = vectorcraft_color::default_swatches(mode.model());
|
||
let mut d = Self {
|
||
version: FORMAT_VERSION,
|
||
title: "Untitled-1".into(),
|
||
template: false,
|
||
units: Unit::Points,
|
||
ruler_origin: Point::ZERO,
|
||
color_mode: mode,
|
||
artboards: vec![Artboard {
|
||
id: 1,
|
||
name: "Artboard 1".into(),
|
||
rect: Rect::new(0.0, 0.0, width, height),
|
||
show_center_mark: false,
|
||
show_cross_hairs: false,
|
||
}],
|
||
layers: vec![],
|
||
swatches: swatches.into(),
|
||
swatch_groups: swatch_groups.into(),
|
||
graphic_styles: default_graphic_styles().into(),
|
||
char_styles: Default::default(),
|
||
para_styles: Default::default(),
|
||
text_threads: vec![],
|
||
symbols: vec![],
|
||
graph_designs: vec![],
|
||
guides: vec![],
|
||
views: vec![],
|
||
saved_selections: Default::default(),
|
||
variables: Default::default(),
|
||
grid: GridPrefs::default(),
|
||
raster_effects_ppi: 72.0,
|
||
images: BTreeMap::new(),
|
||
patterns: vec![],
|
||
pattern_edit: None,
|
||
mask_edit: None,
|
||
next_id: 1,
|
||
unknown: Default::default(),
|
||
page_isolate: false,
|
||
page_knockout: false,
|
||
spot_use_lab: true,
|
||
setup: DocSetup::default(),
|
||
paste_remembers_layers: false,
|
||
metadata: DocMetadata::default(),
|
||
raster_effects: RasterEffectsSettings::default(),
|
||
last_view: None,
|
||
layers_open: None,
|
||
color_profiles: ColorProfiles::default(),
|
||
export_settings: Default::default(),
|
||
extra: Default::default(),
|
||
slices: vec![],
|
||
slices_clip_to_artboard: true,
|
||
print_setup: None,
|
||
assets: vec![],
|
||
puppet: None,
|
||
index: Default::default(),
|
||
};
|
||
let id = d.alloc_id();
|
||
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
|
||
d
|
||
}
|
||
|
||
/// Allocate a fresh node id.
|
||
pub fn alloc_id(&mut self) -> NodeId {
|
||
let id = NodeId(self.next_id);
|
||
self.next_id += 1;
|
||
id
|
||
}
|
||
pub fn peek_next_id(&self) -> u64 {
|
||
self.next_id
|
||
}
|
||
/// Ensure `next_id` is above every id in the tree (after deserializing foreign data).
|
||
pub fn fix_next_id(&mut self) {
|
||
let mut max = 0;
|
||
for l in &self.layers {
|
||
l.walk(&mut |n| max = max.max(n.id.0));
|
||
}
|
||
max = self.slices.iter().fold(max, |m, s| m.max(s.id.0));
|
||
max = self.assets.iter().fold(max, |m, a| m.max(a.id));
|
||
// An id of u64::MAX (a damaged file) can't overflow: ids after it are reused at worst.
|
||
self.next_id = self.next_id.max(max.saturating_add(1));
|
||
}
|
||
|
||
/// Find a node anywhere in the tree.
|
||
pub fn node(&self, id: NodeId) -> Option<&Node> {
|
||
self.index_path(id).and_then(|p| self.node_at(&p))
|
||
}
|
||
|
||
/// [`Document::node`] without building the lookup cache when there is none: for a look at a
|
||
/// document kept in the undo history ([`Document::drop_lookup_cache`]).
|
||
pub fn node_uncached(&self, id: NodeId) -> Option<&Node> {
|
||
match self.index.is_built() {
|
||
true => self.node(id),
|
||
false => self.walk_index_path(id).and_then(|p| self.node_at(&p)),
|
||
}
|
||
}
|
||
|
||
/// Let go of the lookup cache ([`id_index`]), as a document kept in the undo history does: a
|
||
/// table per step would add up. Lookups still work; the first one builds the cache again.
|
||
pub fn drop_lookup_cache(&self) {
|
||
self.index.clear();
|
||
}
|
||
|
||
/// Whether the lookup cache ([`id_index`]) is built (see [`Document::drop_lookup_cache`]).
|
||
pub fn has_lookup_cache(&self) -> bool {
|
||
self.index.is_built()
|
||
}
|
||
|
||
/// Index path from the top-level layer list to `id` (e.g. `[0, 3, 1]`): the first place in
|
||
/// tree order where `id` appears.
|
||
pub fn index_path(&self, id: NodeId) -> Option<Vec<usize>> {
|
||
// The cached path, while the node there still has the id ([`id_index`]).
|
||
let cached = self.index.get();
|
||
match cached.as_ref().and_then(|m| m.get(&id)) {
|
||
Some(Some(p)) if self.node_at(&p).is_some_and(|n| n.id == id) => return Some(p),
|
||
Some(None) => return self.walk_index_path(id),
|
||
_ => {}
|
||
}
|
||
// A stale path (the tree changed), an id the cache doesn't have (deleted or added since),
|
||
// or no cache yet: walk, and rebuild now and then.
|
||
let found = self.walk_index_path(id);
|
||
let stale = cached.as_ref().is_some_and(|m| m.contains_key(&id));
|
||
if cached.is_none() || ((stale || found.is_some()) && self.index.missed()) {
|
||
self.index.rebuild(&self.layers);
|
||
}
|
||
found
|
||
}
|
||
|
||
/// [`Document::index_path`] by walking the tree.
|
||
fn walk_index_path(&self, id: NodeId) -> Option<Vec<usize>> {
|
||
fn find(nodes: &[Arc<Node>], id: NodeId, path: &mut Vec<usize>) -> bool {
|
||
for (i, n) in nodes.iter().enumerate() {
|
||
path.push(i);
|
||
if n.id == id {
|
||
return true;
|
||
}
|
||
if let Some(ch) = n.children()
|
||
&& find(ch, id, path)
|
||
{
|
||
return true;
|
||
}
|
||
path.pop();
|
||
}
|
||
false
|
||
}
|
||
let mut p = vec![];
|
||
find(&self.layers, id, &mut p).then_some(p)
|
||
}
|
||
|
||
/// [`Document::index_path`] of every id in `ids` that is in the tree, in one walk of it: an
|
||
/// edit of many objects looks them all up for the cost of one search instead of one each. Like
|
||
/// `index_path`, an id that appears twice maps to its first place in tree order.
|
||
pub fn index_paths(&self, ids: impl IntoIterator<Item = NodeId>) -> std::collections::HashMap<NodeId, Vec<usize>> {
|
||
use std::collections::{HashMap, HashSet};
|
||
fn walk(nodes: &[Arc<Node>], want: &HashSet<NodeId>, path: &mut Vec<usize>, out: &mut HashMap<NodeId, Vec<usize>>) -> bool {
|
||
for (i, n) in nodes.iter().enumerate() {
|
||
path.push(i);
|
||
if want.contains(&n.id) && !out.contains_key(&n.id) {
|
||
out.insert(n.id, path.clone());
|
||
if out.len() == want.len() {
|
||
return true;
|
||
}
|
||
}
|
||
if let Some(ch) = n.children()
|
||
&& walk(ch, want, path, out)
|
||
{
|
||
return true;
|
||
}
|
||
path.pop();
|
||
}
|
||
false
|
||
}
|
||
let mut want: HashSet<NodeId> = ids.into_iter().collect();
|
||
let mut out = HashMap::with_capacity(want.len());
|
||
// Cached paths that still check out ([`id_index`]); the rest take one walk. For a good
|
||
// share of the document, one walk is quicker than checking each cached path.
|
||
if let Some(cache) = self.index.get().filter(|c| want.len().saturating_mul(8) < c.len()) {
|
||
want.retain(|id| match cache.get(id) {
|
||
Some(Some(p)) if self.node_at(&p).is_some_and(|n| n.id == *id) => {
|
||
out.insert(*id, p);
|
||
false
|
||
}
|
||
_ => true,
|
||
});
|
||
}
|
||
if !want.is_empty() {
|
||
let mut rest = HashMap::with_capacity(want.len());
|
||
walk(&self.layers, &want, &mut Vec::new(), &mut rest);
|
||
out.extend(rest);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The node at index path `path` (as [`Document::index_path`] gives it).
|
||
pub fn node_at(&self, path: &[usize]) -> Option<&Node> {
|
||
let (first, rest) = path.split_first()?;
|
||
let mut cur: &Node = self.layers.get(*first)?;
|
||
for &i in rest {
|
||
cur = cur.children()?.get(i)?;
|
||
}
|
||
Some(cur)
|
||
}
|
||
|
||
/// Mutable access to the node at index path `path`; clones the Arc path (copy-on-write), as
|
||
/// [`Document::node_mut`] does.
|
||
pub fn node_mut_at(&mut self, path: &[usize]) -> Option<&mut Node> {
|
||
let (first, rest) = path.split_first()?;
|
||
let mut cur: &mut Arc<Node> = self.layers.get_mut(*first)?;
|
||
for &i in rest {
|
||
cur = Arc::make_mut(cur).children_mut()?.get_mut(i)?;
|
||
}
|
||
Some(Arc::make_mut(cur))
|
||
}
|
||
|
||
/// [`Document::ancestry`] of the node at index path `path`.
|
||
pub fn ancestry_at(&self, path: &[usize]) -> Option<Vec<NodeId>> {
|
||
let mut out = Vec::with_capacity(path.len());
|
||
let mut nodes = &self.layers;
|
||
for &i in path {
|
||
let n = nodes.get(i)?;
|
||
out.push(n.id);
|
||
if let Some(ch) = n.children() {
|
||
nodes = ch;
|
||
}
|
||
}
|
||
Some(out)
|
||
}
|
||
|
||
/// Ids from the top-level layer down to `id` inclusive.
|
||
pub fn ancestry(&self, id: NodeId) -> Option<Vec<NodeId>> {
|
||
let path = self.index_path(id)?;
|
||
let mut out = Vec::with_capacity(path.len());
|
||
let mut nodes = &self.layers;
|
||
for &i in &path {
|
||
let n = &nodes[i];
|
||
out.push(n.id);
|
||
if let Some(ch) = n.children() {
|
||
nodes = ch;
|
||
}
|
||
}
|
||
Some(out)
|
||
}
|
||
|
||
pub fn parent_of(&self, id: NodeId) -> Option<NodeId> {
|
||
let a = self.ancestry(id)?;
|
||
(a.len() >= 2).then(|| a[a.len() - 2])
|
||
}
|
||
|
||
/// The top-level layer that contains `id`.
|
||
pub fn layer_of(&self, id: NodeId) -> Option<NodeId> {
|
||
self.ancestry(id).and_then(|a| a.first().copied())
|
||
}
|
||
|
||
/// Mutable access to a node; clones the Arc path (copy-on-write).
|
||
pub fn node_mut(&mut self, id: NodeId) -> Option<&mut Node> {
|
||
let path = self.index_path(id)?;
|
||
let mut cur: &mut Arc<Node> = &mut self.layers[path[0]];
|
||
for &i in &path[1..] {
|
||
cur = &mut Arc::make_mut(cur).children_mut()?[i];
|
||
}
|
||
Some(Arc::make_mut(cur))
|
||
}
|
||
|
||
/// Children list of a container (or the top-level layer list for `None`).
|
||
pub fn children_mut(&mut self, parent: Option<NodeId>) -> Result<&mut Vec<Arc<Node>>, DocError> {
|
||
match parent {
|
||
None => Ok(&mut self.layers),
|
||
Some(p) => self.node_mut(p).ok_or(DocError::NoNode(p))?.children_mut().ok_or(DocError::NotContainer(p)),
|
||
}
|
||
}
|
||
pub fn children(&self, parent: Option<NodeId>) -> Option<&Vec<Arc<Node>>> {
|
||
match parent {
|
||
None => Some(&self.layers),
|
||
Some(p) => self.node(p)?.children(),
|
||
}
|
||
}
|
||
|
||
/// Insert `node` into `parent` at `index` (clamped; `usize::MAX` = top).
|
||
pub fn insert(&mut self, parent: Option<NodeId>, index: usize, node: Node) -> Result<NodeId, DocError> {
|
||
let id = node.id;
|
||
let ch = self.children_mut(parent)?;
|
||
let i = index.min(ch.len());
|
||
ch.insert(i, Arc::new(node));
|
||
Ok(id)
|
||
}
|
||
|
||
/// Remove a node and return it.
|
||
pub fn remove(&mut self, id: NodeId) -> Result<Arc<Node>, DocError> {
|
||
let parent = self.parent_of(id);
|
||
let path = self.index_path(id).ok_or(DocError::NoNode(id))?;
|
||
let idx = *path.last().ok_or(DocError::NoNode(id))?;
|
||
let ch = self.children_mut(parent)?;
|
||
if idx >= ch.len() {
|
||
return Err(DocError::NoNode(id));
|
||
}
|
||
Ok(ch.remove(idx))
|
||
}
|
||
|
||
/// Move a node to `parent` at `index` (index measured after removal).
|
||
pub fn move_node(&mut self, id: NodeId, parent: Option<NodeId>, index: usize) -> Result<(), DocError> {
|
||
if let Some(p) = parent
|
||
&& self.ancestry(p).is_some_and(|a| a.contains(&id))
|
||
{
|
||
return Err(DocError::Invalid("cannot move an object into itself".into()));
|
||
}
|
||
let n = self.remove(id)?;
|
||
let ch = self.children_mut(parent)?;
|
||
let i = index.min(ch.len());
|
||
ch.insert(i, n);
|
||
Ok(())
|
||
}
|
||
|
||
/// Position of `id` within its parent: (parent, index, sibling count).
|
||
pub fn position(&self, id: NodeId) -> Option<(Option<NodeId>, usize, usize)> {
|
||
let parent = self.parent_of(id);
|
||
let idx = *self.index_path(id)?.last()?;
|
||
let n = self.children(parent)?.len();
|
||
Some((parent, idx, n))
|
||
}
|
||
|
||
/// Remove the nodes `ids` names and return them in the order of `ids`, in one pass over the
|
||
/// children of each parent. Each id is listed once and none lies inside another listed node, as
|
||
/// in a list of top-level objects. For an id that is not in the tree, is listed twice or lies
|
||
/// inside another listed node, it fails with `NoNode` and leaves the document as it was.
|
||
pub fn remove_many(&mut self, ids: &[NodeId]) -> Result<Vec<Arc<Node>>, DocError> {
|
||
let places = self.places(ids)?;
|
||
let removed = self.take(places, ids.len())?;
|
||
self.index.clear();
|
||
Ok(removed)
|
||
}
|
||
|
||
/// Remove the nodes `ids` names, as [`Document::remove_many`] does, and put the node that `make`
|
||
/// builds from them (in the order of `ids`) in the parent of the last of them, at that node's
|
||
/// index less the number of listed nodes below it there. Returns the new node's id.
|
||
pub fn wrap(&mut self, ids: &[NodeId], make: impl FnOnce(Vec<Arc<Node>>) -> Node) -> Result<NodeId, DocError> {
|
||
let places = self.places(ids)?;
|
||
let last = ids.len().checked_sub(1);
|
||
let found = places.iter().find_map(|(up, kids)| kids.iter().find(|(_, k)| Some(*k) == last).map(|(i, _)| (up.clone(), *i)));
|
||
let Some((up, i)) = found else { return Err(DocError::Invalid("nothing to wrap".into())) };
|
||
// The parent and each node it is in move down by the listed nodes before them.
|
||
let below = |parent: &[usize], index: usize| places.get(parent).map_or(0, |kids| kids.partition_point(|(j, _)| *j < index));
|
||
let to: Vec<usize> = up.iter().enumerate().map(|(l, &j)| j.saturating_sub(below(&up[..l], j))).collect();
|
||
let at = i.saturating_sub(below(&up, i));
|
||
let node = make(self.take(places, ids.len())?);
|
||
let id = node.id;
|
||
let children = self.children_at(&to).ok_or_else(|| DocError::Invalid("the container of the listed objects is gone".into()))?;
|
||
let at = at.min(children.len());
|
||
children.insert(at, Arc::new(node));
|
||
self.index.clear();
|
||
Ok(id)
|
||
}
|
||
|
||
/// Put each list of nodes in `items` directly above its anchor (`replace`: in its place), in
|
||
/// list order, in one pass over the children of each parent. Each anchor is listed once and none
|
||
/// lies inside another. For an anchor that is not in the tree, is listed twice or lies inside
|
||
/// another listed anchor, it fails with `NoNode` and leaves the document as it was.
|
||
pub fn insert_beside(&mut self, items: Vec<(NodeId, Vec<Node>)>, replace: bool) -> Result<(), DocError> {
|
||
let anchors: Vec<NodeId> = items.iter().map(|(id, _)| *id).collect();
|
||
let places = self.places(&anchors)?;
|
||
let mut lists: Vec<Vec<Node>> = items.into_iter().map(|(_, nodes)| nodes).collect();
|
||
self.edit_children(places, |k, anchor, children| {
|
||
if !replace {
|
||
children.push(anchor);
|
||
}
|
||
let nodes = lists.get_mut(k).map(std::mem::take).unwrap_or_default();
|
||
children.extend(nodes.into_iter().map(Arc::new));
|
||
})?;
|
||
self.index.clear();
|
||
Ok(())
|
||
}
|
||
|
||
/// Where each of `ids` is ([`Places`]). Fails with `NoNode` for an id that is not in the tree,
|
||
/// is listed twice or lies inside another listed node.
|
||
fn places(&self, ids: &[NodeId]) -> Result<Places, DocError> {
|
||
let paths = self.index_paths(ids.iter().copied());
|
||
let mut places = Places::new();
|
||
for (k, id) in ids.iter().enumerate() {
|
||
let (&i, up) = paths.get(id).and_then(|p| p.split_last()).ok_or(DocError::NoNode(*id))?;
|
||
match places.get_mut(up) {
|
||
Some(kids) => kids.push((i, k)),
|
||
None => {
|
||
places.insert(up.to_vec(), vec![(i, k)]);
|
||
}
|
||
}
|
||
}
|
||
for kids in places.values_mut() {
|
||
kids.sort_unstable();
|
||
}
|
||
// An id listed twice has one path, so its parent lists the same child twice. A listed node
|
||
// lies inside another when its parent's path, or a part of it from the top, is the path of a
|
||
// listed node.
|
||
let twice = places.values().flat_map(|kids| kids.windows(2)).find(|w| w[0].0 == w[1].0).map(|w| w[1].1);
|
||
let listed = |path: &[usize]| {
|
||
path.split_last().is_some_and(|(i, up)| places.get(up).is_some_and(|kids| kids.binary_search_by_key(i, |(j, _)| *j).is_ok()))
|
||
};
|
||
let nested = places.iter().find(|(up, _)| (1..=up.len()).any(|l| listed(&up[..l])));
|
||
let inner = nested.and_then(|(_, kids)| kids.first()).map(|(_, k)| *k);
|
||
if let Some(id) = twice.or(inner).and_then(|k| ids.get(k)) {
|
||
return Err(DocError::NoNode(*id));
|
||
}
|
||
Ok(places)
|
||
}
|
||
|
||
/// Take the nodes `places` lists out of their parents and return them in the order of the list,
|
||
/// which is `len` long.
|
||
fn take(&mut self, places: Places, len: usize) -> Result<Vec<Arc<Node>>, DocError> {
|
||
let mut taken = vec![None; len];
|
||
self.edit_children(places, |k, node, _| {
|
||
if let Some(t) = taken.get_mut(k) {
|
||
*t = Some(node);
|
||
}
|
||
})?;
|
||
Ok(taken.into_iter().flatten().collect())
|
||
}
|
||
|
||
/// Rebuild the children of each parent in `places` once, the parents later in tree order
|
||
/// first. `each` gets each listed child, its place in the list and the parent's new children,
|
||
/// onto which it pushes what takes the child's place.
|
||
fn edit_children(&mut self, places: Places, mut each: impl FnMut(usize, Arc<Node>, &mut Vec<Arc<Node>>)) -> Result<(), DocError> {
|
||
for (up, kids) in places.into_iter().rev() {
|
||
let children = self.children_at(&up).ok_or_else(|| DocError::Invalid("a container of the listed objects is gone".into()))?;
|
||
let old = std::mem::take(children);
|
||
children.reserve(old.len());
|
||
let mut kids = kids.into_iter().peekable();
|
||
for (i, node) in old.into_iter().enumerate() {
|
||
match kids.next_if(|(j, _)| *j == i) {
|
||
Some((_, k)) => each(k, node, children),
|
||
None => children.push(node),
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The children of the node at index path `path`, or the top-level layers for an empty path.
|
||
fn children_at(&mut self, path: &[usize]) -> Option<&mut Vec<Arc<Node>>> {
|
||
match path {
|
||
[] => Some(&mut self.layers),
|
||
_ => self.node_mut_at(path)?.children_mut(),
|
||
}
|
||
}
|
||
|
||
/// Default target layer: the topmost visible, unlocked layer.
|
||
pub fn default_layer(&self) -> Option<NodeId> {
|
||
self.layers.iter().rev().find(|l| l.visible && !l.locked).or(self.layers.last()).map(|l| l.id)
|
||
}
|
||
|
||
/// Add a new top-level layer above all others.
|
||
pub fn add_layer(&mut self, name: Option<&str>) -> NodeId {
|
||
let id = self.alloc_id();
|
||
let n = self.layers.len();
|
||
let name = name.map(str::to_string).unwrap_or_else(|| self.next_layer_name());
|
||
self.layers.push(Arc::new(Node::layer(id, &name, LayerColor::Preset((n % LAYER_COLORS.len()) as u8))));
|
||
id
|
||
}
|
||
/// The default name of a new layer or sublayer, "Layer N". Layers and sublayers share the
|
||
/// numbering: it starts after the number of layers there are and skips any layer's name.
|
||
pub fn next_layer_name(&self) -> String {
|
||
let (mut count, mut taken) = (0, std::collections::HashSet::new());
|
||
self.walk(|n| {
|
||
if n.is_layer() {
|
||
count += 1;
|
||
taken.extend(n.name.as_deref());
|
||
}
|
||
});
|
||
(count + 1..).map(|i| format!("Layer {i}")).find(|name| !taken.contains(name.as_str())).unwrap_or_default()
|
||
}
|
||
|
||
/// Visit every node depth first in paint order (bottom to top).
|
||
pub fn walk<'a>(&'a self, mut f: impl FnMut(&'a Node)) {
|
||
for l in &self.layers {
|
||
l.walk(&mut f);
|
||
}
|
||
}
|
||
pub fn node_count(&self) -> usize {
|
||
self.layers.iter().map(|l| l.count()).sum()
|
||
}
|
||
/// Is the node (and every ancestor) visible and unlocked?
|
||
pub fn is_editable(&self, id: NodeId) -> bool {
|
||
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible && !n.locked)))
|
||
}
|
||
pub fn is_visible(&self, id: NodeId) -> bool {
|
||
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible)))
|
||
}
|
||
/// The innermost layer or sublayer containing `id` (`id` itself when it is a layer).
|
||
pub fn layer_containing(&self, id: NodeId) -> Option<NodeId> {
|
||
let a = self.ancestry(id)?;
|
||
a.iter().rev().copied().find(|i| self.node(*i).is_some_and(Node::is_layer))
|
||
}
|
||
/// Every art object the Selection tool can reach (Select All): the visible, unlocked objects
|
||
/// of the visible, unlocked, non-template layers, looking through sublayers (bottom first).
|
||
pub fn selectable_art(&self) -> Vec<NodeId> {
|
||
self.layers.iter().filter(|l| l.visible && !l.locked && !l.is_template()).flat_map(|l| l.layer_art(true)).collect()
|
||
}
|
||
/// Colour of the innermost layer or sublayer containing `id` (selection highlight colour).
|
||
pub fn layer_color(&self, id: NodeId) -> [u8; 3] {
|
||
let l = self.layer_containing(id).and_then(|l| self.node(l));
|
||
match l.map(|n| &n.kind) {
|
||
Some(NodeKind::Layer { color, .. }) => color.rgb(),
|
||
_ => LAYER_COLORS[0].1,
|
||
}
|
||
}
|
||
/// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol
|
||
/// instances measured by their art ([`Node::geometric_bounds_with`]).
|
||
pub fn bounds_of(&self, ids: &[NodeId], visual: bool) -> Option<Rect> {
|
||
let symbols = self.symbol_boxes();
|
||
ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| {
|
||
vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds_with(&symbols) } else { n.geometric_bounds_with(&symbols) })
|
||
})
|
||
}
|
||
/// Visual bounds of all art, with symbol instances measured by their art.
|
||
pub fn art_bounds(&self) -> Option<Rect> {
|
||
let symbols = self.symbol_boxes();
|
||
self.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, l.visual_bounds_with(&symbols)))
|
||
}
|
||
/// Artboard index containing point `p` (topmost = last).
|
||
pub fn artboard_at(&self, p: Point) -> Option<usize> {
|
||
self.artboards.iter().rposition(|a| a.rect.contains(p))
|
||
}
|
||
/// The art that moves with an artboard at `rect`: top-level objects (children of layers and
|
||
/// sublayers) lying entirely inside it, with symbol instances measured by their art. Locked and
|
||
/// hidden objects and layers stay put unless `locked_and_hidden` (Selection & Anchor Display ›
|
||
/// Move Locked and Hidden Artwork with Artboard).
|
||
pub fn art_on_artboard(&self, rect: Rect, locked_and_hidden: bool) -> Vec<NodeId> {
|
||
// Art drawn flush with the artboard can end up a rounding error outside it (a background
|
||
// 3e-14 pt past the top edge, #1295): a millionth of a point is far below anything visible.
|
||
let rect = rect.inflate(1e-6, 1e-6);
|
||
fn collect(n: &Node, s: &SymbolBoxes, rect: Rect, all: bool, out: &mut Vec<NodeId>) {
|
||
for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) {
|
||
if c.is_layer() {
|
||
collect(c, s, rect, all, out);
|
||
} else if let Some(b) = c.geometric_bounds_with(s)
|
||
// Inside it, edges included: art flush with the right or bottom edge is on it too
|
||
// (`Rect::contains` leaves those out, #890).
|
||
&& b.x0 >= rect.x0
|
||
&& b.y0 >= rect.y0
|
||
&& b.x1 <= rect.x1
|
||
&& b.y1 <= rect.y1
|
||
{
|
||
out.push(c.id);
|
||
}
|
||
}
|
||
}
|
||
let symbols = self.symbol_boxes();
|
||
let mut art = vec![];
|
||
for l in self.layers.iter().filter(|l| l.rides_with_artboard(locked_and_hidden)) {
|
||
collect(l, &symbols, rect, locked_and_hidden, &mut art);
|
||
}
|
||
art
|
||
}
|
||
/// Where ruler guide `g` runs along its line (the y range of a vertical guide): across its
|
||
/// artboard for an artboard guide, None (the whole canvas) for a canvas guide or one whose
|
||
/// artboard is gone.
|
||
pub fn guide_span(&self, g: &Guide) -> Option<(f64, f64)> {
|
||
let r = self.artboards.iter().find(|a| Some(a.id) == g.artboard)?.rect;
|
||
Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) })
|
||
}
|
||
/// The layer (or sublayer) ruler guide `g` is on, if it is on one this document has.
|
||
fn guide_layer(&self, g: &Guide) -> Option<NodeId> {
|
||
g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer))
|
||
}
|
||
/// Is ruler guide `g` shown: on no layer, or on a layer that (with the layers around it) is
|
||
/// visible? A guide whose layer is gone shows as one on no layer.
|
||
pub fn guide_shown(&self, g: &Guide) -> bool {
|
||
self.guide_layer(g).is_none_or(|l| self.is_visible(l))
|
||
}
|
||
/// Can ruler guide `g` be picked, moved and deleted: shown, and its layer unlocked?
|
||
pub fn guide_editable(&self, g: &Guide) -> bool {
|
||
self.guide_layer(g).is_none_or(|l| self.is_editable(l))
|
||
}
|
||
/// Put the ruler guides on no layer this document has (from files saved before guides had
|
||
/// layers, or of layers merged into `layer`) on layer `layer`. Returns how many.
|
||
pub fn rehome_guides(&mut self, layer: NodeId) -> usize {
|
||
let lost: Vec<bool> = self.guides.iter().map(|g| self.guide_layer(g).is_none()).collect();
|
||
let mut n = 0;
|
||
for (g, lost) in self.guides.iter_mut().zip(lost) {
|
||
if lost {
|
||
g.layer = Some(layer);
|
||
n += 1;
|
||
}
|
||
}
|
||
n
|
||
}
|
||
/// Put the ruler guides on no layer (from files saved before guides had layers), or on one that
|
||
/// is gone, on the top visible, unlocked layer ([`Self::default_layer`]). Returns how many.
|
||
pub fn adopt_guides(&mut self) -> usize {
|
||
self.default_layer().map_or(0, |home| self.rehome_guides(home))
|
||
}
|
||
/// Delete the ruler guides whose layer was deleted (they go with it), keeping `sel`'s guides in
|
||
/// step. Guides on no layer stay. Returns how many went.
|
||
pub fn drop_guides_of_deleted_layers(&mut self, sel: &mut Selection) -> usize {
|
||
let gone: Vec<bool> = self.guides.iter().map(|g| g.layer.is_some() && self.guide_layer(g).is_none()).collect();
|
||
self.retain_guides(sel, |i, _| !gone.get(i).copied().unwrap_or(false))
|
||
}
|
||
/// Copy the ruler guides of layer `from` and of its sublayers onto their copies in `copy` (made
|
||
/// from `from` by [`Self::reid`]: the same tree with fresh ids), as duplicating a layer copies
|
||
/// the objects on it. Returns how many.
|
||
pub fn copy_layer_guides(&mut self, from: &Node, copy: &Node) -> usize {
|
||
let layers = |n: &Node| {
|
||
let mut out = vec![];
|
||
n.walk(&mut |c| {
|
||
if c.is_layer() {
|
||
out.push(c.id);
|
||
}
|
||
});
|
||
out
|
||
};
|
||
let pairs: Vec<(NodeId, NodeId)> = layers(from).into_iter().zip(layers(copy)).collect();
|
||
let copies: Vec<Guide> = self
|
||
.guides
|
||
.iter()
|
||
.filter_map(|g| {
|
||
let to = pairs.iter().find(|(a, _)| Some(*a) == g.layer)?.1;
|
||
Some(Guide { layer: Some(to), ..g.clone() })
|
||
})
|
||
.collect();
|
||
let n = copies.len();
|
||
self.guides.extend(copies);
|
||
n
|
||
}
|
||
/// The ruler guides on layer `layer` (not its sublayers'), by index.
|
||
pub fn guides_on(&self, layer: NodeId) -> impl Iterator<Item = (usize, &Guide)> + '_ {
|
||
self.guides.iter().enumerate().filter(move |(_, g)| g.layer == Some(layer))
|
||
}
|
||
/// Does ruler guide `g` run past `p` (up to `tol` beyond its ends)?
|
||
pub fn guide_passes(&self, g: &Guide, p: Point, tol: f64) -> bool {
|
||
let along = if g.vertical { p.y } else { p.x };
|
||
self.guide_span(g).is_none_or(|(a, b)| along >= a - tol && along <= b + tol)
|
||
}
|
||
/// Keep the ruler guides `keep` accepts (by index), the selected ones left keeping their
|
||
/// place among the rest (the selected art stays selected). Returns how many went.
|
||
pub fn retain_guides(&mut self, sel: &mut Selection, keep: impl Fn(usize, &Guide) -> bool) -> usize {
|
||
let kept: Vec<bool> = self.guides.iter().enumerate().map(|(i, g)| keep(i, g)).collect();
|
||
// Where each kept guide ends up.
|
||
let (mut to, mut n) = (Vec::with_capacity(kept.len()), 0);
|
||
for k in &kept {
|
||
to.push(k.then_some(n));
|
||
n += usize::from(*k);
|
||
}
|
||
sel.guides = sel.guides.iter().filter_map(|i| to.get(*i).copied().flatten()).collect();
|
||
let before = self.guides.len();
|
||
let mut flags = kept.into_iter();
|
||
self.guides.retain(|_| flags.next().unwrap_or(true));
|
||
before - self.guides.len()
|
||
}
|
||
/// Move the guides of artboard `id` by `d` (along with their artboard).
|
||
pub fn move_artboard_guides(&mut self, id: u32, d: Vec2) {
|
||
for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) {
|
||
*g = g.moved(d);
|
||
}
|
||
}
|
||
/// Map the guides of artboard `id` through `xf`, an axis-aligned scale and move (Scale Artwork
|
||
/// with Artboard).
|
||
pub fn map_artboard_guides(&mut self, id: u32, xf: Affine) {
|
||
for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) {
|
||
g.pos = if g.vertical { (xf * Point::new(g.pos, 0.0)).x } else { (xf * Point::new(0.0, g.pos)).y };
|
||
}
|
||
}
|
||
/// Copy the guides of artboard `from` onto artboard `to`, `d` away (with a copy of their
|
||
/// artboard).
|
||
pub fn copy_artboard_guides(&mut self, from: u32, to: u32, d: Vec2) {
|
||
let copies: Vec<Guide> =
|
||
self.guides.iter().filter(|g| g.artboard == Some(from)).map(|g| Guide { artboard: Some(to), ..g.moved(d) }).collect();
|
||
self.guides.extend(copies);
|
||
}
|
||
pub fn next_artboard_id(&self) -> u32 {
|
||
self.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1
|
||
}
|
||
pub fn pattern(&self, name: &str) -> Option<&PatternDef> {
|
||
self.patterns.iter().find(|p| p.name == name)
|
||
}
|
||
pub fn pattern_mut(&mut self, name: &str) -> Option<&mut PatternDef> {
|
||
self.patterns.iter_mut().find(|p| p.name == name)
|
||
}
|
||
/// Deep-clone `node` with fresh ids for it and all descendants.
|
||
pub fn reid(&mut self, node: &Node) -> Node {
|
||
let mut n = node.clone();
|
||
n.id = self.alloc_id();
|
||
if let Some(ch) = n.children_mut() {
|
||
let old: Vec<Arc<Node>> = std::mem::take(ch);
|
||
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
|
||
}
|
||
n
|
||
}
|
||
}
|
||
|
||
impl Document {
|
||
/// Saved selections as a file gives them, made safe to list and recall: at most
|
||
/// [`SavedSelection::MAX`], each with a clean, unique name and naming objects this document
|
||
/// has, each once. (An id it doesn't have would select whatever object takes that id later.)
|
||
pub fn tidy_saved_selections(&mut self) {
|
||
if self.saved_selections.is_empty() {
|
||
return;
|
||
}
|
||
let mut ids = std::collections::HashSet::new();
|
||
self.walk(|n| {
|
||
ids.insert(n.id);
|
||
});
|
||
let mut kept: Vec<SavedSelection> = vec![];
|
||
for s in std::mem::take(&mut self.saved_selections) {
|
||
if kept.len() >= SavedSelection::MAX {
|
||
break;
|
||
}
|
||
let Some(name) = SavedSelection::clean_name(&s.name) else { continue };
|
||
if kept.iter().any(|k| k.name == name) {
|
||
continue;
|
||
}
|
||
let mut seen = std::collections::HashSet::new();
|
||
let objects = s.objects.into_iter().filter(|id| ids.contains(id) && seen.insert(*id)).collect();
|
||
kept.push(SavedSelection { name, objects });
|
||
}
|
||
self.saved_selections = kept.into();
|
||
}
|
||
|
||
/// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the
|
||
/// mask already holds (the engine syncs it after every edit).
|
||
pub fn drop_edit_modes(&mut self) {
|
||
if let Some(me) = self.mask_edit.take() {
|
||
self.layers.retain(|l| l.id != me.layer);
|
||
}
|
||
}
|
||
|
||
/// The document as saved and exported: without the opacity-mask editing layer. Borrowed when
|
||
/// no mask is being edited. (Pattern editing is saved: its tile layer holds unapplied edits.)
|
||
pub fn without_edit_modes(&self) -> std::borrow::Cow<'_, Document> {
|
||
if self.mask_edit.is_none() {
|
||
return std::borrow::Cow::Borrowed(self);
|
||
}
|
||
let mut d = self.clone();
|
||
d.drop_edit_modes();
|
||
std::borrow::Cow::Owned(d)
|
||
}
|
||
}
|
||
|
||
/// Graphic styles.
|
||
impl Document {
|
||
/// Index of the graphic style named `name`.
|
||
pub fn graphic_style_index(&self, name: &str) -> Option<usize> {
|
||
self.graphic_styles.iter().position(|g| g.name == name)
|
||
}
|
||
/// The graphic style named `name`.
|
||
pub fn graphic_style(&self, name: &str) -> Option<&GraphicStyle> {
|
||
self.graphic_styles.iter().find(|g| g.name == name)
|
||
}
|
||
/// The graphic style with id `id` (0 never matches).
|
||
pub fn graphic_style_by_id(&self, id: u32) -> Option<&GraphicStyle> {
|
||
self.graphic_styles.iter().find(|g| g.id != 0 && g.id == id)
|
||
}
|
||
/// An id no graphic style has.
|
||
pub fn next_graphic_style_id(&self) -> u32 {
|
||
self.graphic_styles.iter().map(|g| g.id).max().unwrap_or(0) + 1
|
||
}
|
||
/// The id of graphic style `index`, assigning one if it has none yet.
|
||
pub fn graphic_style_id(&mut self, index: usize) -> u32 {
|
||
if self.graphic_styles[index].id == 0 {
|
||
self.graphic_styles[index].id = self.next_graphic_style_id();
|
||
}
|
||
self.graphic_styles[index].id
|
||
}
|
||
/// The default name of a new graphic style: the first free "Graphic Style N", N counting on
|
||
/// from the number of styles.
|
||
pub fn new_graphic_style_name(&self) -> String {
|
||
(self.graphic_styles.len() + 1..).map(|i| format!("Graphic Style {i}")).find(|n| self.graphic_style(n).is_none()).unwrap_or_default()
|
||
}
|
||
/// `base`, or the first free "`base` N" (N from 2) when a symbol already has that name.
|
||
pub fn unique_symbol_name(&self, base: &str) -> String {
|
||
let taken = |n: &str| self.symbols.iter().any(|s| s.name == n);
|
||
if !taken(base) {
|
||
return base.to_string();
|
||
}
|
||
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
|
||
}
|
||
}
|
||
|
||
fn default_graphic_styles() -> Vec<GraphicStyle> {
|
||
use vectorcraft_color::{Color, Paint};
|
||
let solid = |hex| Paint::solid(Color::from_hex(hex).unwrap_or(Color::BLACK));
|
||
[
|
||
GraphicStyle::new(DEFAULT_GRAPHIC_STYLE, Appearance::default_art()),
|
||
GraphicStyle::new("Black Outline", Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)),
|
||
GraphicStyle::new("Heavy Ink", Appearance::basic(solid("#1b1464"), Paint::solid(Color::BLACK), 4.0)),
|
||
GraphicStyle::new("Sunshine", Appearance::basic(solid("#fbb03b"), solid("#f15a24"), 2.0)),
|
||
]
|
||
.into_iter()
|
||
.zip(1..)
|
||
.map(|(g, id)| GraphicStyle { id, ..g })
|
||
.collect()
|
||
}
|
||
|
||
impl Document {
|
||
/// Make the next id allocated at least `next`: ids handed out outside the layer tree (such
|
||
/// as an importer's opacity mask and pattern art), which [`Document::fix_next_id`] doesn't see.
|
||
pub fn reserve_ids(&mut self, next: u64) {
|
||
self.next_id = self.next_id.max(next);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use vectorcraft_geom::shapes;
|
||
|
||
fn doc_with_rects() -> (Document, NodeId, NodeId) {
|
||
let mut d = Document::new(612.0, 792.0);
|
||
let layer = d.layers[0].id;
|
||
let a = d.alloc_id();
|
||
d.insert(Some(layer), usize::MAX, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap();
|
||
let b = d.alloc_id();
|
||
d.insert(Some(layer), usize::MAX, Node::path(b, shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 10.0)), Appearance::default_art())).unwrap();
|
||
(d, a, b)
|
||
}
|
||
|
||
#[test]
|
||
fn new_document_has_layer_and_artboard() {
|
||
let d = Document::new(612.0, 792.0);
|
||
assert_eq!(d.layers.len(), 1);
|
||
assert_eq!(d.layers[0].display_name(), "Layer 1");
|
||
assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 612.0, 792.0));
|
||
}
|
||
|
||
#[test]
|
||
fn art_on_artboard_takes_unlocked_objects_wholly_inside() {
|
||
let (mut d, a, b) = doc_with_rects();
|
||
let wide = Rect::new(-1.0, -1.0, 40.0, 15.0);
|
||
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 15.0, 15.0), false), vec![a]);
|
||
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 25.0, 15.0), false), vec![a], "b only half inside");
|
||
// Flush with the artboard's edges counts as inside: the right and bottom ones too (#890).
|
||
for board in [Rect::new(0.0, 0.0, 10.0, 10.0), Rect::new(-5.0, 0.0, 10.0, 15.0), Rect::new(0.0, -5.0, 15.0, 10.0)] {
|
||
assert_eq!(d.art_on_artboard(board, false), vec![a], "{board:?}");
|
||
}
|
||
d.node_mut(a).unwrap().locked = true;
|
||
assert_eq!(d.art_on_artboard(wide, false), vec![b]);
|
||
// Move Locked and Hidden Artwork with Artboard (#394): hidden art stays too, unless on.
|
||
d.node_mut(b).unwrap().visible = false;
|
||
assert!(d.art_on_artboard(wide, false).is_empty());
|
||
assert_eq!(d.art_on_artboard(wide, true), vec![a, b]);
|
||
Arc::make_mut(&mut d.layers[0]).locked = true;
|
||
assert!(d.art_on_artboard(wide, false).is_empty());
|
||
assert_eq!(d.art_on_artboard(wide, true), vec![a, b], "a locked layer's art too");
|
||
}
|
||
|
||
#[test]
|
||
fn art_on_artboard_tolerates_roundoff_at_each_edge() {
|
||
let (d, a, _) = doc_with_rects();
|
||
let roundoff = 1e-12;
|
||
for board in [
|
||
Rect::new(roundoff, 0.0, 10.0, 10.0),
|
||
Rect::new(0.0, roundoff, 10.0, 10.0),
|
||
Rect::new(0.0, 0.0, 10.0 - roundoff, 10.0),
|
||
Rect::new(0.0, 0.0, 10.0, 10.0 - roundoff),
|
||
] {
|
||
assert_eq!(d.art_on_artboard(board, false), vec![a], "roundoff at {board:?}");
|
||
}
|
||
// A real overhang remains outside.
|
||
for board in
|
||
[Rect::new(0.001, 0.0, 10.0, 10.0), Rect::new(0.0, 0.001, 10.0, 10.0), Rect::new(0.0, 0.0, 9.999, 10.0), Rect::new(0.0, 0.0, 10.0, 9.999)]
|
||
{
|
||
assert!(d.art_on_artboard(board, false).is_empty(), "overhang at {board:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn art_on_artboard_measures_a_symbol_instance_by_its_art() {
|
||
let mut d = Document::new(612.0, 792.0);
|
||
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||
d.symbols.push(Symbol { name: "Box".into(), art: Arc::new(art) });
|
||
let (layer, id) = (d.layers[0].id, d.alloc_id());
|
||
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((2.0, 2.0)) })).unwrap();
|
||
// The art, (2, 2) to (12, 12), lies inside; the 20 × 20 square, (-8, -8) to (12, 12), doesn't.
|
||
assert_eq!(d.art_on_artboard(Rect::new(0.0, 0.0, 15.0, 15.0), false), vec![id]);
|
||
}
|
||
|
||
#[test]
|
||
fn find_and_paths() {
|
||
let (d, a, b) = doc_with_rects();
|
||
assert_eq!(d.index_path(b), Some(vec![0, 1]));
|
||
assert_eq!(d.parent_of(a), Some(d.layers[0].id));
|
||
assert_eq!(d.layer_of(b), Some(d.layers[0].id));
|
||
assert!(d.node(NodeId(999)).is_none());
|
||
assert_eq!(d.node_count(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn copy_on_write_shares_untouched() {
|
||
let (d, a, b) = doc_with_rects();
|
||
let mut e = d.clone();
|
||
e.node_mut(a).unwrap().name = Some("A".into());
|
||
// b's Arc is shared between both versions; a's is not.
|
||
let lb = &d.layers[0].children().unwrap()[1];
|
||
let eb = &e.layers[0].children().unwrap()[1];
|
||
assert!(Arc::ptr_eq(lb, eb));
|
||
assert_eq!(d.node(a).unwrap().name, None);
|
||
assert_eq!(e.node(a).unwrap().name.as_deref(), Some("A"));
|
||
let _ = b;
|
||
}
|
||
|
||
#[test]
|
||
fn move_and_remove() {
|
||
let (mut d, a, b) = doc_with_rects();
|
||
let layer = d.layers[0].id;
|
||
d.move_node(a, Some(layer), usize::MAX).unwrap();
|
||
assert_eq!(d.index_path(a), Some(vec![0, 1]));
|
||
assert_eq!(d.index_path(b), Some(vec![0, 0]));
|
||
let r = d.remove(a).unwrap();
|
||
assert_eq!(r.id, a);
|
||
assert!(d.node(a).is_none());
|
||
assert!(d.move_node(layer, Some(layer), 0).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn layers_and_names() {
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let l2 = d.add_layer(None);
|
||
assert_eq!(d.node(l2).unwrap().display_name(), "Layer 2");
|
||
assert_eq!(d.default_layer(), Some(l2));
|
||
assert_eq!(d.layer_color(l2), LAYER_COLORS[1].1);
|
||
}
|
||
|
||
#[test]
|
||
fn serde_roundtrip() {
|
||
let (d, _, _) = doc_with_rects();
|
||
let s = serde_json::to_string(&d).unwrap();
|
||
let back: Document = serde_json::from_str(&s).unwrap();
|
||
assert_eq!(back.node_count(), d.node_count());
|
||
assert_eq!(back.peek_next_id(), d.peek_next_id());
|
||
}
|
||
|
||
#[test]
|
||
fn reid_gives_fresh_ids() {
|
||
let (mut d, a, b) = doc_with_rects();
|
||
let g = d.alloc_id();
|
||
let na = (*d.remove(a).unwrap()).clone();
|
||
let nb = (*d.remove(b).unwrap()).clone();
|
||
let group = Node::group(g, vec![Arc::new(na), Arc::new(nb)]);
|
||
let copy = d.reid(&group);
|
||
assert_ne!(copy.id, g);
|
||
let ids: Vec<NodeId> = copy.children().unwrap().iter().map(|c| c.id).collect();
|
||
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));
|
||
assert_eq!(Unit::Points.parse("1in"), Some(72.0));
|
||
assert_eq!(Unit::Points.parse("10 mm").map(|v| (v * 1000.0).round()), Some(28346.0));
|
||
assert_eq!(Unit::Points.parse("2p6"), Some(30.0));
|
||
assert_eq!(Unit::Points.parse("10+5"), Some(15.0));
|
||
assert_eq!(Unit::Points.parse("100/4"), Some(25.0));
|
||
assert_eq!(Unit::Points.parse("-5"), Some(-5.0));
|
||
assert_eq!(Unit::Points.parse("abc"), None);
|
||
assert_eq!(Unit::Points.format(12.5), "12.5 pt");
|
||
assert_eq!(Unit::Inches.format(144.0), "2 in");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests_lookups;
|
||
#[cfg(test)]
|
||
mod tests_slices;
|
||
#[cfg(test)]
|
||
mod tests_units;
|