//! The FigCraft document model. //! //! A [`Document`] holds pages; each page is the root of a tree of [`Node`]s (frames, groups, shapes, //! text, components, instances…). Nodes are stored by id in a map of `Arc`s, so cloning a document //! (an undo snapshot) only copies pointers, and editing one node copies only that node. //! //! Everything here is pure data plus queries and structural edits; commands, history and rendering //! live in higher crates. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod board; pub mod colors; pub mod comments; pub mod facilitation; pub mod geometry; pub mod handoff; pub mod ids; pub mod library; pub mod node; pub mod props; pub mod proto; pub mod styles; pub mod text; pub mod vector; pub mod vector_faces; pub mod vector_regions; pub use board::*; pub use colors::*; pub use comments::*; pub use facilitation::*; pub use figcraft_color as color; pub use figcraft_geom as geom; pub use handoff::*; pub use library::*; pub use node::*; pub use props::*; pub use styles::*; pub use text::*; pub use vector::*; pub use vector_faces::Face; use figcraft_color::{Color, ColorProfile}; use figcraft_geom::{Affine, Point, Rect}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::sync::Arc; /// Deepest descendant [`Document::world_render_bounds`] follows: as deep as the renderer draws /// (`figcraft_render::MAX_EXPORT_DEPTH`), so a crafted chain can't overflow the stack (#1062). const MAX_RENDER_DEPTH: usize = 256; /// Stable node identifier, unique within a document. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct NodeId(pub u64); impl std::fmt::Display for NodeId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } impl std::str::FromStr for NodeId { type Err = std::num::ParseIntError; fn from_str(s: &str) -> Result { // Accept "12" and Figma-style "0:12". let t = s.trim(); let t = t.rsplit(':').next().unwrap_or(t); if t.starts_with('+') { // `u64::parse` accepts a leading '+'; ids are digits only. return "-".parse::().map(NodeId); } t.parse::().map(NodeId) } } #[derive(Debug, thiserror::Error, PartialEq)] pub enum DocError { #[error("no such node {0}")] NoNode(NodeId), #[error("{0} can't contain children")] NotContainer(NodeId), #[error("moving {0} into {1} would create a cycle")] Cycle(NodeId, NodeId), #[error("{0}")] Invalid(String), } pub type DocResult = Result; /// The set of touched node ids, whether any layer is a mask (hit testing skips its mask checks /// without one) and the largest background blur radius (renders keep that much overlap between /// their bands), both worked out when first asked after a change. Never part of equality or /// serialisation. #[derive(Clone, Debug, Default)] pub struct Dirty(pub std::collections::BTreeSet, std::sync::OnceLock, std::sync::OnceLock); impl Dirty { /// Record node `id` as touched (edited, added or removed). fn touch(&mut self, id: NodeId) { self.0.insert(id); self.1 = std::sync::OnceLock::new(); self.2 = std::sync::OnceLock::new(); } } impl PartialEq for Dirty { fn eq(&self, _: &Self) -> bool { true } } /// Hands out node ids. A single-user document counts up through the whole id range; in a live /// collaboration session each participant allocates in its own space, `client << 32 | counter`, so /// ids made at the same time on different machines never collide. Saved as the file's `next_id`; /// the client id is never saved. Never part of equality: two documents with the same content are /// equal however far their counters went. #[derive(Clone, Copy, Debug, Serialize, Deserialize)] #[serde(transparent)] struct IdAlloc { /// The next id to hand out. next: u64, /// The client whose space ids come from (0: the whole range, as in a single-user file). #[serde(skip)] client: u32, } impl PartialEq for IdAlloc { fn eq(&self, _: &Self) -> bool { true } } impl IdAlloc { /// Whether `id` lies in the space this allocator hands out (any id without a client). fn owns(&self, id: NodeId) -> bool { self.client == 0 || id.0 >> 32 == u64::from(self.client) } /// Never hand out `id` (or anything below it in this space) again. fn reserve(&mut self, id: NodeId) { if self.owns(id) && id.0 >= self.next { self.next = id.0.saturating_add(1); } } fn alloc(&mut self) -> NodeId { let id = NodeId(self.next); let next = self.next.saturating_add(1); // A client space holds 2^32 ids; a full one keeps returning its last id (an insert then // fails as a duplicate) rather than spilling into another client's space. if self.owns(NodeId(next)) { self.next = next; } id } } /// Raw image bytes (encoded PNG/JPEG/…) kept by content hash. #[derive(Clone, Debug, PartialEq)] pub struct ImageData { pub bytes: Arc>, pub width: u32, pub height: u32, } impl ImageData { /// Largest encoded image a document takes from outside: an image member of a `.fig` or /// `.sketch` file, an image file a REST import reads, an image entry from a live session. pub const MAX_BYTES: u64 = 256 * 1024 * 1024; /// Whether `key` claims to be a content hash: 64 hex digits, the shape of the sha256 hex /// `figcraft_format::image_hash` makes. Bytes stored under such a key must hash to it. pub fn claims_content_hash(key: &str) -> bool { key.len() == 64 && key.bytes().all(|b| b.is_ascii_hexdigit()) } /// Whether `key` can name an image: 1–128 ASCII letters, digits, `-` and `_` (content hashes, /// the svg importer's FNV keys, the REST importer's `rest-image-missing` placeholder). A key /// becomes an archive member name (`images/`) when the file is saved, so only these are /// read back from a file or taken from a collaborator: never `/`, `.` or other path text. pub fn is_valid_key(key: &str) -> bool { (1..=128).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') } } /// A whole design file. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Document { pub name: String, /// A design file or a board (files from before boards are design files). #[serde(default, skip_serializing_if = "FileKind::is_design")] pub kind: FileKind, /// Page ids in order. pub pages: Vec, nodes: imbl::OrdMap>, /// Nodes touched since the last [`take_dirty`](Self::take_dirty) (edited, added or removed). #[serde(skip)] dirty: Dirty, next_id: IdAlloc, #[serde(default)] pub styles: Vec