From 00e96fe8c6de727742d100f5e79ab39fb0cc48ac Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Tue, 13 Oct 2026 19:11:18 -0300 Subject: [PATCH] feat(whiteboard): wave 2 whiteboard Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 26 ++ crates/whiteboard/Cargo.toml | 5 + crates/whiteboard/src/board.rs | 154 +++++++ crates/whiteboard/src/build.rs | 106 +++++ crates/whiteboard/src/codec.rs | 296 +++++++++++++ crates/whiteboard/src/error.rs | 36 ++ crates/whiteboard/src/geometry.rs | 81 ++++ crates/whiteboard/src/id.rs | 103 +++++ crates/whiteboard/src/lib.rs | 42 +- crates/whiteboard/src/model.rs | 320 ++++++++++++++ crates/whiteboard/src/rough.rs | 392 ++++++++++++++++++ crates/whiteboard/src/serde_impl.rs | 252 +++++++++++ crates/whiteboard/tests/api.rs | 146 +++++++ .../tests/fixtures/all_types.excalidraw.json | 266 ++++++++++++ .../fixtures/bound_arrow.excalidraw.json | 135 ++++++ .../fixtures/image_with_files.excalidraw.json | 72 ++++ .../tests/fixtures/minimal.excalidraw.json | 7 + .../fixtures/text_container.excalidraw.json | 101 +++++ .../tests/fixtures/unknowns.excalidraw.json | 136 ++++++ crates/whiteboard/tests/roundtrip.rs | 227 ++++++++++ crates/whiteboard/tests/shapes.rs | 178 ++++++++ 21 files changed, 3076 insertions(+), 5 deletions(-) create mode 100644 crates/whiteboard/src/board.rs create mode 100644 crates/whiteboard/src/build.rs create mode 100644 crates/whiteboard/src/codec.rs create mode 100644 crates/whiteboard/src/error.rs create mode 100644 crates/whiteboard/src/geometry.rs create mode 100644 crates/whiteboard/src/id.rs create mode 100644 crates/whiteboard/src/model.rs create mode 100644 crates/whiteboard/src/rough.rs create mode 100644 crates/whiteboard/src/serde_impl.rs create mode 100644 crates/whiteboard/tests/api.rs create mode 100644 crates/whiteboard/tests/fixtures/all_types.excalidraw.json create mode 100644 crates/whiteboard/tests/fixtures/bound_arrow.excalidraw.json create mode 100644 crates/whiteboard/tests/fixtures/image_with_files.excalidraw.json create mode 100644 crates/whiteboard/tests/fixtures/minimal.excalidraw.json create mode 100644 crates/whiteboard/tests/fixtures/text_container.excalidraw.json create mode 100644 crates/whiteboard/tests/fixtures/unknowns.excalidraw.json create mode 100644 crates/whiteboard/tests/roundtrip.rs create mode 100644 crates/whiteboard/tests/shapes.rs diff --git a/Cargo.lock b/Cargo.lock index 09202f2..5cabae6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,6 +89,11 @@ version = "0.1.0" [[package]] name = "notescraft-whiteboard" version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror", +] [[package]] name = "proc-macro2" @@ -115,6 +120,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -161,6 +167,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "unicode-ident" version = "1.0.27" diff --git a/crates/whiteboard/Cargo.toml b/crates/whiteboard/Cargo.toml index c475d59..ebcb2c2 100644 --- a/crates/whiteboard/Cargo.toml +++ b/crates/whiteboard/Cargo.toml @@ -10,5 +10,10 @@ publish = false [lib] path = "src/lib.rs" +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + [lints] workspace = true \ No newline at end of file diff --git a/crates/whiteboard/src/board.rs b/crates/whiteboard/src/board.rs new file mode 100644 index 0000000..dc7952d --- /dev/null +++ b/crates/whiteboard/src/board.rs @@ -0,0 +1,154 @@ +//! The `.excalidraw` document: the element list plus the opaque `appState` +//! and `files` objects, with the size and count caps of the reader. + +use serde_json::Value; + +use crate::codec::Fields; +use crate::error::{Error, Result}; +use crate::geometry::element_bounds; +use crate::model::{Bounds, Element, Extra}; + +/// Largest accepted document, in bytes. +pub const MAX_INPUT_BYTES: usize = 32 * 1024 * 1024; + +/// Largest accepted number of elements in one document. +pub const MAX_ELEMENTS: usize = 100_000; + +/// One `.excalidraw` document. +#[derive(Debug, Clone, PartialEq)] +pub struct Whiteboard { + /// Elements in file order, deleted ones included. + pub elements: Vec, + /// The `appState` object, kept opaque. + pub app_state: Value, + /// The `files` object (embedded images), kept opaque. + pub files: Value, + /// Unknown top-level keys, re-emitted as they were read. + pub extra: Extra, +} + +impl Default for Whiteboard { + fn default() -> Self { + Self { elements: Vec::new(), app_state: Value::Object(Extra::new()), files: Value::Object(Extra::new()), extra: Extra::new() } + } +} + +impl Whiteboard { + /// An empty board. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Reads an Excalidraw v2 document. + /// + /// Tolerant: missing optional fields get their defaults and unknown fields + /// are preserved. Returns an error for malformed JSON, for numbers that + /// overflow to infinity, and for documents above [`MAX_INPUT_BYTES`] or + /// [`MAX_ELEMENTS`]. + /// + /// # Errors + /// + /// See [`Error`]. + #[expect( + clippy::should_implement_trait, + reason = "reads an Excalidraw document, not a bare `FromStr`, and the name is what callers of the whiteboard tools expect" + )] + pub fn from_str(text: &str) -> Result { + if text.len() > MAX_INPUT_BYTES { + return Err(Error::TooLarge { max: MAX_INPUT_BYTES }); + } + Self::from_value(serde_json::from_str(text)?) + } + + /// Same as [`Whiteboard::from_str`], for an already parsed JSON value. + /// + /// # Errors + /// + /// See [`Error`]. + pub fn from_value(value: Value) -> Result { + let mut fields = Fields::new(value)?; + let elements = match fields.take_array("elements")? { + Some(list) => { + if list.len() > MAX_ELEMENTS { + return Err(Error::TooManyElements { max: MAX_ELEMENTS }); + } + list.into_iter().map(Element::from_value).collect::>>()? + } + None => Vec::new(), + }; + // `type` and `version` are re-emitted in canonical form, never kept. + fields.take("type"); + fields.take("version"); + Ok(Self { elements, app_state: fields.value_or_object("appState"), files: fields.value_or_object("files"), extra: fields.extra() }) + } + + /// Canonical pretty JSON, always starting with `{"type":"excalidraw","version":2}`. + /// + /// # Errors + /// + /// Only if a value cannot be represented as JSON, which cannot happen for + /// values that came from JSON in the first place. + pub fn to_string_pretty(&self) -> Result { + Ok(serde_json::to_string_pretty(self)?) + } + + /// Appends an element. + pub fn add(&mut self, element: Element) { + self.elements.push(element); + } + + /// Marks the element with this id as deleted, keeping it in the document. + /// + /// Returns the updated element, or `None` when no element has that id. + pub fn delete(&mut self, id: &str) -> Option<&Element> { + let element = self.elements.iter_mut().find(|element| element.id == id)?; + element.is_deleted = true; + Some(element) + } + + /// The element with this id, deleted or not. + #[must_use] + pub fn element(&self, id: &str) -> Option<&Element> { + self.elements.iter().find(|element| element.id == id) + } + + /// Axis-aligned box of the visible elements, or `None` when there are none. + #[must_use] + pub fn bounding_box(&self) -> Option { + self.elements.iter().filter(|element| !element.is_deleted).filter_map(element_bounds).reduce(Bounds::union) + } +} + +#[cfg(test)] +mod tests { + use super::{MAX_ELEMENTS, MAX_INPUT_BYTES, Whiteboard}; + use crate::error::{Error, Result}; + use crate::id::IdGen; + use crate::model::Element; + + #[test] + fn limits_are_the_documented_ones() { + assert_eq!(MAX_INPUT_BYTES, 32 * 1024 * 1024); + assert_eq!(MAX_ELEMENTS, 100_000); + } + + #[test] + fn a_document_round_trips_through_serde() { + let mut ids = IdGen::new(); + let mut board = Whiteboard::new(); + board.add(Element::rectangle(&mut ids, 0.0, 0.0, 10.0, 10.0)); + board.add(Element::text(&mut ids, 0.0, 20.0, "hello")); + let json = board.to_string_pretty().expect("serializes"); + assert_eq!(Whiteboard::from_str(&json).expect("parses"), board); + let value = serde_json::to_value(&board).expect("value"); + assert_eq!(Whiteboard::from_value(value).expect("value parses"), board); + } + + #[test] + fn oversized_documents_are_refused() { + let padded = format!("{{\"elements\":[],\"pad\":\"{}\"}}", "x".repeat(MAX_INPUT_BYTES)); + let err: Result = Whiteboard::from_str(&padded); + assert!(matches!(err, Err(Error::TooLarge { .. }))); + } +} diff --git a/crates/whiteboard/src/build.rs b/crates/whiteboard/src/build.rs new file mode 100644 index 0000000..65027df --- /dev/null +++ b/crates/whiteboard/src/build.rs @@ -0,0 +1,106 @@ +//! Builders: elements with Excalidraw defaults, fresh ids and seeds. +//! +//! Every builder draws two values from the [`IdGen`], one id and one seed, so +//! a board built from a fresh generator is always the same board. + +use crate::id::IdGen; +use crate::model::{Element, ElementKind, Extra, FillStyle, StrokeStyle}; + +impl Element { + /// An element of the given kind with Excalidraw defaults. + /// + /// Colours start unset (`""`) and `"transparent"`: the renderer fills them + /// in from the theme, this crate never hardcodes one. + #[must_use] + pub fn new(id: String, kind: ElementKind) -> Self { + Self { + id, + x: 0.0, + y: 0.0, + width: 0.0, + height: 0.0, + angle: 0.0, + stroke_color: String::new(), + background_color: "transparent".to_owned(), + fill_style: FillStyle::Solid, + stroke_width: 1.0, + stroke_style: StrokeStyle::Solid, + roughness: 1, + opacity: 100, + seed: 1, + version: 1, + version_nonce: 0, + is_deleted: false, + group_ids: Vec::new(), + bound_elements: Vec::new(), + link: None, + locked: false, + updated: 0, + kind, + extra: Extra::new(), + } + } + + /// The Excalidraw `type` of this element. + #[must_use] + pub fn type_name(&self) -> &str { + self.kind.type_name() + } + + fn shaped(id: String, seed: u64, kind: ElementKind, x: f64, y: f64, width: f64, height: f64) -> Self { + Self { x, y, width, height, seed, ..Self::new(id, kind) } + } + + /// A rectangle with a fresh id and seed from `ids`. + #[must_use] + pub fn rectangle(ids: &mut IdGen, x: f64, y: f64, width: f64, height: f64) -> Self { + Self::shaped(ids.next_id(), ids.next_seed(), ElementKind::Rectangle, x, y, width, height) + } + + /// An ellipse with a fresh id and seed from `ids`. + #[must_use] + pub fn ellipse(ids: &mut IdGen, x: f64, y: f64, width: f64, height: f64) -> Self { + Self::shaped(ids.next_id(), ids.next_seed(), ElementKind::Ellipse, x, y, width, height) + } + + /// A diamond with a fresh id and seed from `ids`. + #[must_use] + pub fn diamond(ids: &mut IdGen, x: f64, y: f64, width: f64, height: f64) -> Self { + Self::shaped(ids.next_id(), ids.next_seed(), ElementKind::Diamond, x, y, width, height) + } + + /// A line from `(x, y)` to `(x + width, y + height)`. + #[must_use] + pub fn line(ids: &mut IdGen, x: f64, y: f64, width: f64, height: f64) -> Self { + let points = vec![[0.0, 0.0], [width, height]]; + Self::shaped(ids.next_id(), ids.next_seed(), ElementKind::Line { points }, x, y, width, height) + } + + /// An arrow from `(x, y)` to `(x + width, y + height)` with an arrowhead. + #[must_use] + pub fn arrow(ids: &mut IdGen, x: f64, y: f64, width: f64, height: f64) -> Self { + let points = vec![[0.0, 0.0], [width, height]]; + let kind = ElementKind::Arrow { points, start_binding: None, end_binding: None, start_arrowhead: None, end_arrowhead: Some("arrow".to_owned()) }; + Self::shaped(ids.next_id(), ids.next_seed(), kind, x, y, width, height) + } + + /// A text element with a fresh id and seed from `ids`. + /// + /// The box is estimated from the text length; a renderer refits it anyway. + #[must_use] + pub fn text(ids: &mut IdGen, x: f64, y: f64, text: &str) -> Self { + let font_size = 20.0; + let lines = text.lines().count().max(1); + let width = f64::from(u32::try_from(text.chars().count()).unwrap_or(u32::MAX)) * font_size * 0.6; + let height = f64::from(u32::try_from(lines).unwrap_or(u32::MAX)) * font_size * 1.25; + let kind = ElementKind::Text { + text: text.to_owned(), + font_size, + font_family: "1".to_owned(), + text_align: "left".to_owned(), + vertical_align: "top".to_owned(), + container_id: None, + }; + Self::shaped(ids.next_id(), ids.next_seed(), kind, x, y, width, height) + } +} diff --git a/crates/whiteboard/src/codec.rs b/crates/whiteboard/src/codec.rs new file mode 100644 index 0000000..9f4787c --- /dev/null +++ b/crates/whiteboard/src/codec.rs @@ -0,0 +1,296 @@ +//! JSON field reading: every reader splits a JSON object into known fields and +//! a map of leftovers, so an unknown field is never dropped on the way in. + +use serde_json::{Map, Value}; + +use crate::error::{Error, Result}; +use crate::model::Extra; + +/// Largest integer kept exactly by a `f64` significand. +const EXACT_I64_BITS: f64 = 18_446_744_073_709_551_616.0; + +/// A JSON object being decoded into known fields plus leftovers. +pub(crate) struct Fields { + map: Map, +} + +impl Fields { + /// Wraps a JSON object, rejecting any other JSON shape. + pub(crate) fn new(value: Value) -> Result { + match value { + Value::Object(map) => Ok(Self { map }), + _ => Err(Error::NotAnObject), + } + } + + /// Removes a key and returns whatever was there. + pub(crate) fn take(&mut self, key: &str) -> Option { + self.map.remove(key) + } + + /// Whatever is left once every known key was taken. + pub(crate) fn extra(self) -> Extra { + self.map + } + + /// A number field, clamped by its type. `null` and missing mean `default`. + pub(crate) fn number(&mut self, key: &str, default: T) -> Result { + match self.take(key) { + None | Some(Value::Null) => Ok(default), + Some(value) => T::from_number(value, key), + } + } + + /// A number field that may be absent entirely. + pub(crate) fn optional_number(&mut self, key: &str) -> Result> { + match self.take(key) { + None | Some(Value::Null) => Ok(None), + Some(value) => f64::from_number(value, key).map(Some), + } + } + + /// A string field. `null` and missing mean `default`. + pub(crate) fn text(&mut self, key: &str, default: &str) -> Result { + match self.take(key) { + None | Some(Value::Null) => Ok(default.to_owned()), + Some(Value::String(text)) => Ok(text), + Some(_) => Err(Error::InvalidField { field: key.to_owned() }), + } + } + + /// A field Excalidraw spells as a string in newer files and as a number in + /// older ones (`fontFamily`), read as text either way. + pub(crate) fn text_or_number(&mut self, key: &str, default: &str) -> Result { + match self.take(key) { + None | Some(Value::Null) => Ok(default.to_owned()), + Some(Value::String(text)) => Ok(text), + Some(value @ Value::Number(_)) => Ok(value.to_string()), + Some(_) => Err(Error::InvalidField { field: key.to_owned() }), + } + } + + /// A string field that may be `null`. + pub(crate) fn optional_text(&mut self, key: &str) -> Result> { + match self.take(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(text)) => Ok(Some(text)), + Some(_) => Err(Error::InvalidField { field: key.to_owned() }), + } + } + + /// A boolean field. `null` and missing mean `default`. + pub(crate) fn flag(&mut self, key: &str, default: bool) -> Result { + match self.take(key) { + Some(Value::Bool(value)) => Ok(value), + None | Some(Value::Null) => Ok(default), + Some(_) => Err(Error::InvalidField { field: key.to_owned() }), + } + } + + /// An array field. `null` and missing mean an empty array. + pub(crate) fn take_array(&mut self, key: &str) -> Result>> { + match self.take(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Array(items)) => Ok(Some(items)), + Some(_) => Err(Error::InvalidField { field: key.to_owned() }), + } + } + + /// An array of strings, `null` and missing meaning an empty array. + pub(crate) fn texts(&mut self, key: &str) -> Result> { + let invalid = || Error::InvalidField { field: key.to_owned() }; + match self.take_array(key)? { + None => Ok(Vec::new()), + Some(items) => items.into_iter().map(|item| if let Value::String(text) = item { Ok(text) } else { Err(invalid()) }).collect(), + } + } + + /// An array of numbers, `null` and missing meaning an empty array. + pub(crate) fn numbers(&mut self, key: &str) -> Result> { + match self.take_array(key)? { + None => Ok(Vec::new()), + Some(items) => items.into_iter().map(|item| f64::from_number(item, key)).collect(), + } + } + + /// An array of objects, `null` and missing meaning an empty array. + pub(crate) fn objects(&mut self, key: &str) -> Result> { + match self.take_array(key)? { + None => Ok(Vec::new()), + Some(items) => items + .into_iter() + .map(|item| match item { + Value::Object(_) => Ok(item), + _ => Err(Error::InvalidField { field: key.to_owned() }), + }) + .collect(), + } + } + + /// An array of `[x, y]` pairs, `null` and missing meaning an empty path. + pub(crate) fn points(&mut self, key: &str) -> Result> { + let invalid = || Error::InvalidField { field: key.to_owned() }; + let Some(items) = self.take_array(key)? else { + return Ok(Vec::new()); + }; + let mut points = Vec::with_capacity(items.len()); + for item in items { + let Value::Array(pair) = item else { + return Err(invalid()); + }; + let mut values = pair.into_iter(); + match (values.next(), values.next()) { + (Some(x), Some(y)) => points.push([f64::from_number(x, key)?, f64::from_number(y, key)?]), + _ => return Err(invalid()), + } + } + Ok(points) + } + + /// A single object field, `null` and missing meaning `None`. + pub(crate) fn take_object(&mut self, key: &str) -> Result> { + match self.take(key) { + None | Some(Value::Null) => Ok(None), + Some(value @ Value::Object(_)) => Ok(Some(value)), + Some(_) => Err(Error::InvalidField { field: key.to_owned() }), + } + } + + /// A JSON object field; anything else falls back to an empty object. + pub(crate) fn value_or_object(&mut self, key: &str) -> Value { + match self.take(key) { + Some(value @ Value::Object(_)) => value, + _ => Value::Object(Extra::new()), + } + } +} + +/// A JSON number read into a Rust number. +pub(crate) trait FromNumber: Sized { + /// Reads `value`, or fails with the key it was found under. + fn from_number(value: Value, key: &str) -> Result; +} + +impl FromNumber for f64 { + /// JSON numbers are finite by construction: `serde_json` rejects `NaN` and + /// out-of-range literals while parsing, so anything read here is usable. + fn from_number(value: Value, key: &str) -> Result { + value.as_f64().ok_or_else(|| Error::InvalidField { field: key.to_owned() }) + } +} + +impl FromNumber for u64 { + fn from_number(value: Value, key: &str) -> Result { + match value.as_u64() { + Some(number) => Ok(number), + None => integral(&value).ok_or_else(|| Error::InvalidField { field: key.to_owned() }), + } + } +} + +impl FromNumber for u8 { + fn from_number(value: Value, key: &str) -> Result { + let number = match value.as_u64() { + Some(number) => number, + None => integral(&value).ok_or_else(|| Error::InvalidField { field: key.to_owned() })?, + }; + Ok(u8::try_from(number).unwrap_or(u8::MAX)) + } +} + +/// Exact integer value of a non-negative integral `f64`, read straight from +/// its IEEE-754 bits so no lossy `as` cast is needed. +fn integral(value: &Value) -> Option { + let number = value.as_f64()?; + if !number.is_finite() || number < 0.0 || number >= EXACT_I64_BITS || number.fract() != 0.0 { + return None; + } + // value == (2^52 + mantissa) * 2^(biased_exponent - 1075) + let bits = number.to_bits(); + let exponent = i32::try_from((bits >> 52) & 0x07ff).unwrap_or(0); + let significand = (bits & 0x000f_ffff_ffff_ffff) | (1 << 52); + let shift = exponent - 1075; + let shift = shift.unsigned_abs().min(64); + if shift == 0 { + Some(significand) + } else if exponent > 1075 { + Some(significand.checked_shl(shift).unwrap_or(0)) + } else if shift < 64 { + Some(significand.checked_shr(shift).unwrap_or(0)) + } else { + Some(0) + } +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use super::{Fields, FromNumber, integral}; + use crate::error::Error; + + fn fields(value: Value) -> Fields { + Fields::new(value).expect("object") + } + + #[test] + fn known_fields_are_removed_and_the_rest_is_extra() { + let mut fields = fields(json!({ "x": 1, "known": 2, "other": 3 })); + assert_eq!(fields.number("x", 0.0).expect("number"), 1.0); + assert_eq!(fields.take("known"), Some(json!(2))); + let extra = fields.extra(); + assert_eq!(extra.len(), 1); + assert_eq!(extra.get("other"), Some(&json!(3))); + } + + #[test] + fn only_font_family_survives_a_number() { + let mut numeric = fields(json!({ "fontFamily": 2, "id": 5 })); + assert_eq!(numeric.text_or_number("fontFamily", "1").expect("font family"), "2"); + assert!(matches!(numeric.text("id", ""), Err(Error::InvalidField { .. }))); + } + + #[test] + fn null_and_missing_mean_default() { + let mut fields = fields(json!({ "x": null, "strokeColor": null })); + assert_eq!(fields.number("x", 5.0).expect("number"), 5.0); + assert_eq!(fields.text("strokeColor", "fallback").expect("text"), "fallback"); + assert_eq!(fields.text("absent", "fallback").expect("text"), "fallback"); + assert!(fields.optional_text("absent").expect("optional").is_none()); + assert!(fields.flag("absent", true).expect("flag")); + } + + #[test] + fn numbers_are_read_from_numbers_only() { + assert!(f64::from_number(json!("1"), "x").is_err()); + assert!(u64::from_number(json!(null), "x").is_err()); + assert_eq!(u64::from_number(json!(7), "x").expect("u64"), 7); + assert_eq!(u8::from_number(json!(300), "x").expect("u8"), u8::MAX); + assert!(u8::from_number(Value::from(-4_i64), "x").is_err()); + assert_eq!(u64::from_number(json!(1.0e18), "x").expect("u64"), 1_000_000_000_000_000_000); + assert!(u64::from_number(json!(1.5), "x").is_err()); + } + + #[test] + fn integral_values_come_from_the_significand() { + assert_eq!(integral(&json!(0.0)), Some(0)); + assert_eq!(integral(&json!(1.0e15)), Some(1_000_000_000_000_000)); + assert_eq!(integral(&json!(2.0_f64.powf(53.0))), Some(9_007_199_254_740_992)); + assert_eq!(integral(&json!(-1.0)), None); + assert_eq!(integral(&json!(0.5)), None); + assert_eq!(integral(&json!(1.0e30)), None); + assert_eq!(integral(&json!("x")), None); + } + + #[test] + fn points_are_pairs_of_finite_numbers() { + let mut good = fields(json!({ "points": [[1, 2], [3.5, -4]] })); + assert_eq!(good.points("points").expect("points"), vec![[1.0, 2.0], [3.5, -4.0]]); + let mut short = fields(json!({ "points": [[1]] })); + assert!(matches!(short.points("points"), Err(Error::InvalidField { .. }))); + let mut nested = fields(json!({ "points": [[1, [2]]] })); + assert!(matches!(nested.points("points"), Err(Error::InvalidField { .. }))); + let mut not_numbers = fields(json!({ "points": [["a", "b"]] })); + assert!(matches!(not_numbers.points("points"), Err(Error::InvalidField { .. }))); + } +} diff --git a/crates/whiteboard/src/error.rs b/crates/whiteboard/src/error.rs new file mode 100644 index 0000000..a5539ac --- /dev/null +++ b/crates/whiteboard/src/error.rs @@ -0,0 +1,36 @@ +//! Error type of the crate: one `thiserror` enum plus the `Result` alias. + +use thiserror::Error; + +/// Everything that can go wrong while reading a whiteboard document. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum Error { + /// The text is not valid JSON, or a value has the wrong JSON shape. + #[error("invalid json: {0}")] + Json(#[from] serde_json::Error), + /// The document is larger than [`crate::MAX_INPUT_BYTES`]. + #[error("document larger than {max} bytes")] + TooLarge { + /// The limit that was exceeded. + max: usize, + }, + /// The document holds more elements than [`crate::MAX_ELEMENTS`]. + #[error("more than {max} elements")] + TooManyElements { + /// The limit that was exceeded. + max: usize, + }, + /// A field holds a value of a type the model does not accept. + #[error("field `{field}` has an unsupported value")] + InvalidField { + /// Offending key. + field: String, + }, + /// A JSON object was required where something else was found. + #[error("expected a json object")] + NotAnObject, +} + +/// Result of every fallible operation of this crate. +pub type Result = std::result::Result; diff --git a/crates/whiteboard/src/geometry.rs b/crates/whiteboard/src/geometry.rs new file mode 100644 index 0000000..4edacf0 --- /dev/null +++ b/crates/whiteboard/src/geometry.rs @@ -0,0 +1,81 @@ +//! Element geometry: points in element space, in document space, and the +//! bounding box of an element. +//! +//! Input comes from files, so every coordinate passes through [`sanitize`]: +//! non-finite input becomes `0.0` and absurd magnitudes are clamped. + +use crate::model::{Bounds, Element, ElementKind}; + +/// Coordinate magnitude kept by the geometry helpers. +const MAX_COORDINATE: f64 = 1.0e9; + +/// Axis-aligned box of one element, rotation included. +/// +/// `None` when the element carries no geometry at all. +#[must_use] +pub(crate) fn element_bounds(element: &Element) -> Option { + let mut box_ = Bounds::default(); + let mut seen = false; + for point in local_points(element) { + let world = to_world(element, point); + let bounds = Bounds { x: world[0], y: world[1], width: 0.0, height: 0.0 }; + box_ = if seen { box_.union(bounds) } else { bounds }; + seen = true; + } + seen.then_some(box_) +} + +/// Points of an element in its own coordinate system. +#[must_use] +pub(crate) fn local_points(element: &Element) -> Vec<[f64; 2]> { + let (width, height) = (sanitize(element.width), sanitize(element.height)); + match &element.kind { + ElementKind::Line { points } | ElementKind::Arrow { points, .. } if !points.is_empty() => { + points.iter().map(|p| [sanitize(p[0]), sanitize(p[1])]).collect() + } + ElementKind::Freedraw { points, .. } if !points.is_empty() => points.iter().map(|p| [sanitize(p[0]), sanitize(p[1])]).collect(), + _ => corners(width, height), + } +} + +/// The four corners of an element box. +#[must_use] +pub(crate) fn corners(width: f64, height: f64) -> Vec<[f64; 2]> { + vec![[0.0, 0.0], [width, 0.0], [width, height], [0.0, height]] +} + +/// Maps a point of an element to document coordinates, rotation included. +#[must_use] +pub(crate) fn to_world(element: &Element, point: [f64; 2]) -> [f64; 2] { + let (width, height) = (sanitize(element.width), sanitize(element.height)); + let (cx, cy) = (width / 2.0, height / 2.0); + let (dx, dy) = (sanitize(point[0]) - cx, sanitize(point[1]) - cy); + let (sin, cos) = sanitize(element.angle).sin_cos(); + [sanitize(element.x) + cx + dx * cos - dy * sin, sanitize(element.y) + cy + dx * sin + dy * cos] +} + +/// Replaces non-finite input by `0.0` and keeps magnitudes in range. +#[must_use] +pub(crate) fn sanitize(value: f64) -> f64 { + if value.is_finite() { value.clamp(-MAX_COORDINATE, MAX_COORDINATE) } else { 0.0 } +} + +#[cfg(test)] +mod tests { + use super::{corners, sanitize}; + + #[test] + fn sanitize_kills_nan_and_clamps() { + assert_eq!(sanitize(f64::NAN), 0.0); + assert_eq!(sanitize(f64::INFINITY), 0.0); + assert_eq!(sanitize(f64::NEG_INFINITY), 0.0); + assert_eq!(sanitize(1.0e30), 1.0e9); + assert_eq!(sanitize(-1.0e30), -1.0e9); + assert_eq!(sanitize(2.5), 2.5); + } + + #[test] + fn corners_are_in_order() { + assert_eq!(corners(2.0, 3.0), vec![[0.0, 0.0], [2.0, 0.0], [2.0, 3.0], [0.0, 3.0]]); + } +} diff --git a/crates/whiteboard/src/id.rs b/crates/whiteboard/src/id.rs new file mode 100644 index 0000000..e5f7ac5 --- /dev/null +++ b/crates/whiteboard/src/id.rs @@ -0,0 +1,103 @@ +//! Deterministic id and seed generation: no `rand`, no clock, no state beyond a counter. + +/// Hands out Excalidraw-shaped ids and element seeds from a counter. +/// +/// Two `IdGen`s that consumed the same number of values always produce the +/// same ids, which is what makes generated boards reproducible. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IdGen { + counter: u64, +} + +impl Default for IdGen { + fn default() -> Self { + Self::new() + } +} + +impl IdGen { + /// A generator that has not handed out anything yet. + #[must_use] + pub const fn new() -> Self { + Self { counter: 0 } + } + + /// The next 16-character alphanumeric id. + /// + /// The low 13 base-36 digits are the counter itself (always unique), the + /// high 3 digits are a hash of it, so ids look random but stay ordered. + #[must_use] + pub fn next_id(&mut self) -> String { + self.counter = self.counter.wrapping_add(1); + format!("{}{:0>13}", base36(mix(self.counter), 3), base36(self.counter, 13)) + } + + /// The next element seed. + #[must_use] + pub fn next_seed(&mut self) -> u64 { + self.counter = self.counter.wrapping_add(1); + mix(self.counter) + } +} + +/// splitmix64 finalizer: spreads a counter over all 64 bits. +fn mix(value: u64) -> u64 { + let mut z = value.wrapping_add(0x9e37_79b9_7f4a_7c15); + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^ (z >> 31) +} + +/// `value` as `width` base-36 digits, most significant first, zero padded. +fn base36(mut value: u64, width: usize) -> String { + let mut digits: Vec = Vec::with_capacity(width); + for _ in 0..width { + let digit = u32::try_from(value % 36).unwrap_or(0); + digits.push(char::from_digit(digit, 36).unwrap_or('0')); + value /= 36; + } + digits.reverse(); + digits.into_iter().collect() +} + +#[cfg(test)] +mod tests { + use super::{IdGen, base36}; + + #[test] + fn ids_are_16_chars_and_never_repeat() { + let mut ids = IdGen::new(); + let mut seen = std::collections::HashSet::new(); + for _ in 0..10_000 { + let id = ids.next_id(); + assert_eq!(id.chars().count(), 16, "{id}"); + assert!(id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()), "{id}"); + assert!(seen.insert(id), "ids must be unique"); + } + } + + #[test] + fn generators_are_reproducible() { + let mut a = IdGen::new(); + let mut b = IdGen::new(); + for _ in 0..64 { + assert_eq!(a.next_id(), b.next_id()); + assert_eq!(a.next_seed(), b.next_seed()); + } + } + + #[test] + fn seeds_do_not_repeat_easily() { + let mut ids = IdGen::new(); + let seeds: std::collections::HashSet = (0..1000).map(|_| ids.next_seed()).collect(); + assert_eq!(seeds.len(), 1000); + } + + #[test] + fn base36_pads_and_wraps_nothing() { + assert_eq!(base36(0, 3), "000"); + assert_eq!(base36(35, 3), "00z"); + assert_eq!(base36(36, 3), "010"); + assert_eq!(base36(u64::MAX, 13).chars().count(), 13); + } +} diff --git a/crates/whiteboard/src/lib.rs b/crates/whiteboard/src/lib.rs index 2d3ba7b..b753f4c 100644 --- a/crates/whiteboard/src/lib.rs +++ b/crates/whiteboard/src/lib.rs @@ -1,12 +1,44 @@ -//! Whiteboard: Excalidraw-compatible model, read/write, and a seeded -//! hand-drawn shape generator. +//! Whiteboard: Excalidraw-compatible model, read/write, seeded hand-drawn +//! shape generator. //! -//! Reads/writes Excalidraw v2 element JSON (`type: "excalidraw"`) and -//! preserves unknown fields. +//! Reads and writes Excalidraw v2 element JSON (`type: "excalidraw"`), keeps +//! every unknown field at every level verbatim, and turns elements into +//! polylines with a rough.js look that is deterministic per seed. +//! +//! ``` +//! use notescraft_whiteboard::{IdGen, Whiteboard, rough}; +//! use notescraft_whiteboard::rough::Style; +//! +//! let board = Whiteboard::from_str(r#"{"type":"excalidraw","version":2,"elements":[]}"#) +//! .expect("valid document"); +//! assert!(board.elements.is_empty()); +//! +//! let mut ids = IdGen::new(); +//! let rect = notescraft_whiteboard::Element::rectangle(&mut ids, 0.0, 0.0, 100.0, 50.0); +//! let strokes = rough::outline(&rect, Style::HandDrawn); +//! assert_eq!(strokes.len(), 2); +//! ``` +//! +//! Colours are never invented here: elements carry the values of the file and +//! an empty stroke colour, which the renderer fills in from the theme. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -// TODO(phase 2): Excalidraw-compatible model, read/write, seeded hand-drawn shape generator. +mod board; +mod build; +mod codec; +mod error; +mod geometry; +mod id; +mod model; +mod serde_impl; + +pub mod rough; + +pub use crate::board::{MAX_ELEMENTS, MAX_INPUT_BYTES, Whiteboard}; +pub use crate::error::{Error, Result}; +pub use crate::id::IdGen; +pub use crate::model::{Binding, BoundElement, Bounds, Element, ElementKind, Extra, FillStyle, StrokeStyle}; #[cfg(test)] mod tests { diff --git a/crates/whiteboard/src/model.rs b/crates/whiteboard/src/model.rs new file mode 100644 index 0000000..42750c8 --- /dev/null +++ b/crates/whiteboard/src/model.rs @@ -0,0 +1,320 @@ +//! The Excalidraw v2 element model. +//! +//! Everything the model does not know about is kept verbatim in an [`Extra`] +//! map and written back out, so files from newer Excalidraw versions survive a +//! read/write cycle unchanged in meaning. + +use serde_json::{Map, Value}; + +/// Unknown JSON keys, kept verbatim at any level of the document. +pub type Extra = Map; + +/// One Excalidraw element: common fields plus its kind. +#[derive(Debug, Clone, PartialEq)] +pub struct Element { + /// Unique element id. + pub id: String, + /// Position of the unrotated top-left corner. + pub x: f64, + /// Position of the unrotated top-left corner. + pub y: f64, + /// Box width. + pub width: f64, + /// Box height. + pub height: f64, + /// Rotation around the box center, in radians. + pub angle: f64, + /// Stroke colour; empty means "take the theme default". + pub stroke_color: String, + /// Fill colour, `"transparent"` when the element is not filled. + pub background_color: String, + /// How the fill is painted. + pub fill_style: FillStyle, + /// Stroke width in pixels. + pub stroke_width: f64, + /// Stroke pattern. + pub stroke_style: StrokeStyle, + /// Hand-drawn roughness, `0` (clean) to `3`. + pub roughness: u8, + /// Opacity, `0` to `255`. + pub opacity: u8, + /// Seed of the hand-drawn generator: same seed, same output. + pub seed: u64, + /// Excalidraw version counter of the last edit. + pub version: u64, + /// Excalidraw version nonce of the last edit. + pub version_nonce: u64, + /// Deleted elements stay in the file until Excalidraw prunes them. + pub is_deleted: bool, + /// Ids of the groups this element belongs to. + pub group_ids: Vec, + /// Elements bound to this one (arrows, bound text). + pub bound_elements: Vec, + /// Link target of the element. + pub link: Option, + /// Locked elements cannot be edited in Excalidraw. + pub locked: bool, + /// Last modification time, in milliseconds. + pub updated: u64, + /// What the element draws. + pub kind: ElementKind, + /// Unknown keys of this element, including the ones of its kind, kept + /// verbatim and re-emitted after the known ones. + pub extra: Extra, +} + +/// The shape or content of an [`Element`]. +#[derive(Debug, Clone, PartialEq)] +pub enum ElementKind { + /// A rectangle. + Rectangle, + /// An ellipse. + Ellipse, + /// A diamond. + Diamond, + /// A polyline with optional bindings and arrowheads. + Arrow { + /// Points relative to the element origin. + points: Vec<[f64; 2]>, + /// Element the arrow starts on. + start_binding: Option, + /// Element the arrow ends on. + end_binding: Option, + /// Arrowhead at the start, `"arrow"` or `None`. + start_arrowhead: Option, + /// Arrowhead at the end, `"arrow"` or `None`. + end_arrowhead: Option, + }, + /// A polyline. + Line { + /// Points relative to the element origin. + points: Vec<[f64; 2]>, + }, + /// A freehand stroke. + Freedraw { + /// Points relative to the element origin. + points: Vec<[f64; 2]>, + /// One pressure value per point. + pressures: Vec, + }, + /// A text run, optionally bound to a container. + Text { + /// The text itself. + text: String, + /// Font size in pixels. + font_size: f64, + /// Excalidraw font family: `"1"`, `"2"`, `"3"` or a font name. + font_family: String, + /// `"left"`, `"center"` or `"right"`. + text_align: String, + /// `"top"`, `"middle"` or `"bottom"`. + vertical_align: String, + /// Container element this text is bound to. + container_id: Option, + }, + /// An image, with the payload in the `files` object of the document. + Image { + /// Key into [`crate::Whiteboard::files`]. + file_id: String, + }, + /// A named frame. + Frame { + /// Frame name, `"#1"` for unnamed ones. + name: String, + }, + /// An element type this version does not know, kept as read. + Unknown { + /// The original `type` value. + type_name: String, + }, +} + +/// An entry of the `boundElements` array of an element. +#[derive(Debug, Clone, PartialEq)] +pub struct BoundElement { + /// Id of the bound element. + pub id: String, + /// Type of the bound element. + pub type_name: String, + /// Unknown keys of this entry, kept verbatim. + pub extra: Extra, +} + +/// Where an arrow is attached to another element. +#[derive(Debug, Clone, PartialEq)] +pub struct Binding { + /// Id of the element the arrow sticks to. + pub element_id: String, + /// Focus offset, `-1` to `1`. + pub focus: Option, + /// Gap between the arrow tip and the element, in pixels. + pub gap: Option, + /// Unknown keys of this binding, kept verbatim. + pub extra: Extra, +} + +/// How the interior of a shape is painted. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FillStyle { + /// One flat colour. + #[default] + Solid, + /// Parallel sketchy lines. + Hachure, + /// Crossed sketchy lines. + CrossHatch, + /// Zigzag sketchy lines. + ZigZag, +} + +impl FillStyle { + /// The Excalidraw spelling of this style. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Solid => "solid", + Self::Hachure => "hachure", + Self::CrossHatch => "cross-hatch", + Self::ZigZag => "zigzag", + } + } + + /// Parses the Excalidraw spelling, falling back to [`FillStyle::Solid`]. + #[must_use] + pub fn from_name(name: &str) -> Self { + match name { + "hachure" => Self::Hachure, + "cross-hatch" => Self::CrossHatch, + "zigzag" => Self::ZigZag, + _ => Self::Solid, + } + } +} + +/// How the outline of a shape is stroked. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum StrokeStyle { + /// One continuous line. + #[default] + Solid, + /// Dotted line. + Dotted, + /// Dashed line. + Dashed, +} + +impl StrokeStyle { + /// The Excalidraw spelling of this style. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Solid => "solid", + Self::Dotted => "dotted", + Self::Dashed => "dashed", + } + } + + /// Parses the Excalidraw spelling, falling back to [`StrokeStyle::Solid`]. + #[must_use] + pub fn from_name(name: &str) -> Self { + match name { + "dotted" => Self::Dotted, + "dashed" => Self::Dashed, + _ => Self::Solid, + } + } +} + +/// Axis-aligned bounding box. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Bounds { + /// Left edge. + pub x: f64, + /// Top edge. + pub y: f64, + /// Box width. + pub width: f64, + /// Box height. + pub height: f64, +} + +impl Bounds { + /// The smallest box containing both boxes. + #[must_use] + pub fn union(self, other: Bounds) -> Bounds { + let x = self.x.min(other.x); + let y = self.y.min(other.y); + let right = (self.x + self.width).max(other.x + other.width); + let bottom = (self.y + self.height).max(other.y + other.height); + Bounds { x, y, width: right - x, height: bottom - y } + } +} + +impl ElementKind { + /// The Excalidraw `type` value of this kind. + #[must_use] + pub fn type_name(&self) -> &str { + match self { + Self::Rectangle => "rectangle", + Self::Ellipse => "ellipse", + Self::Diamond => "diamond", + Self::Arrow { .. } => "arrow", + Self::Line { .. } => "line", + Self::Freedraw { .. } => "freedraw", + Self::Text { .. } => "text", + Self::Image { .. } => "image", + Self::Frame { .. } => "frame", + Self::Unknown { type_name } => type_name, + } + } +} + +#[cfg(test)] +mod tests { + use super::{Bounds, Element, ElementKind, FillStyle, StrokeStyle}; + + #[test] + fn defaults_match_excalidraw() { + let element = Element::new("id".to_owned(), ElementKind::Rectangle); + assert_eq!(element.roughness, 1); + assert_eq!(element.opacity, 100); + assert_eq!(element.stroke_width, 1.0); + assert_eq!(element.background_color, "transparent"); + assert_eq!(element.stroke_color, ""); + assert_eq!(element.fill_style, FillStyle::Solid); + assert_eq!(element.stroke_style, StrokeStyle::Solid); + assert!(!element.is_deleted); + assert!(!element.locked); + } + + #[test] + fn style_names_round_trip() { + for style in [FillStyle::Solid, FillStyle::Hachure, FillStyle::CrossHatch, FillStyle::ZigZag] { + assert_eq!(FillStyle::from_name(style.as_str()), style); + } + assert_eq!(FillStyle::from_name("nope"), FillStyle::Solid); + for style in [StrokeStyle::Solid, StrokeStyle::Dotted, StrokeStyle::Dashed] { + assert_eq!(StrokeStyle::from_name(style.as_str()), style); + } + assert_eq!(StrokeStyle::from_name("nope"), StrokeStyle::Solid); + } + + #[test] + fn unknown_type_names_are_kept() { + let element = Element::new("id".to_owned(), ElementKind::Unknown { type_name: "weird".to_owned() }); + assert_eq!(element.type_name(), "weird"); + assert_eq!(ElementKind::Rectangle.type_name(), "rectangle"); + assert_eq!( + ElementKind::Arrow { points: vec![], start_binding: None, end_binding: None, start_arrowhead: None, end_arrowhead: None }.type_name(), + "arrow" + ); + } + + #[test] + fn bounds_union_is_stable() { + let a = Bounds { x: 0.0, y: 0.0, width: 1.0, height: 1.0 }; + assert_eq!(a.union(a), a); + let b = Bounds { x: -1.0, y: 2.0, width: 1.0, height: 1.0 }; + assert_eq!(a.union(b), Bounds { x: -1.0, y: 0.0, width: 2.0, height: 3.0 }); + } +} diff --git a/crates/whiteboard/src/rough.rs b/crates/whiteboard/src/rough.rs new file mode 100644 index 0000000..685d64f --- /dev/null +++ b/crates/whiteboard/src/rough.rs @@ -0,0 +1,392 @@ +//! Deterministic hand-drawn outline generator. +//! +//! Same element, same seed, same output: the jitter comes from a xorshift +//! generator seeded with [`Element::seed`], never from a clock or from `rand`. +//! Every coordinate is sanitized, so degenerate input yields degenerate +//! geometry, never `NaN` and never an infinity. + +use crate::model::{Element, ElementKind}; + +/// One stroke, as a list of `[x, y]` points. +pub type Polyline = Vec<[f32; 2]>; + +/// How shapes are drawn. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Style { + /// Rough.js look: two jittered strokes with overshooting ends. + #[default] + HandDrawn, + /// Exact geometry, one stroke. + Clean, +} + +/// Segments used to approximate an ellipse. +const SEGMENTS: usize = 32; +/// Jitter amplitude per unit of roughness. +const AMPLITUDE: f32 = 1.2; +/// Jitter amplitude ceiling, in pixels. +const MAX_AMPLITUDE: f32 = 3.6; +/// Endpoint overshoot bounds, in pixels. +const OVERSHOOT: (f32, f32) = (0.5, 2.0); +/// Coordinate magnitude the output is clamped to. +const MAX_COORDINATE: f32 = 1.0e6; +/// Arrowhead length, in pixels. +const ARROWHEAD: f32 = 12.0; +/// Arrowhead half spread, in radians. +const ARROWHEAD_SPREAD: f32 = 2.6; +/// Salt mixed into the seed of the second stroke. +const STROKE_SALT: u64 = 0x9e37_79b9_7f4a_7c15; + +/// The strokes of one element. +/// +/// Returns an empty list for elements without a stroked shape (text, image, +/// frame, unknown types). A hand-drawn shape is stroked twice, with jitter +/// scaled by [`Element::roughness`]; `roughness == 0` falls back to +/// [`Style::Clean`], and every result is bit-identical for a given seed. +#[must_use] +pub fn outline(element: &Element, style: Style) -> Vec { + let Some((local, closed)) = local_path(element) else { + return Vec::new(); + }; + + let hand_drawn = style == Style::HandDrawn && element.roughness > 0; + let mut strokes: Vec = Vec::new(); + if hand_drawn { + let amplitude = (f32::from(element.roughness.min(3)) * AMPLITUDE).clamp(0.0, MAX_AMPLITUDE); + for pass in 0..2 { + let seed = if pass == 0 { element.seed } else { element.seed ^ STROKE_SALT }; + let mut rng = Rng::new(seed); + let mut points: Polyline = local.iter().map(|point| jitter(*point, amplitude, &mut rng)).collect(); + if !closed { + overshoot(&mut points, &mut rng); + } + strokes.push(place(element, points)); + } + } else { + let mut points = local; + if let (true, Some(first)) = (closed, points.first().copied()) { + points.push(first); + } + strokes.push(place(element, points)); + } + + strokes.extend(head_legs(element, strokes.last())); + strokes +} + +/// Points of the element in its own coordinate system, and whether the path +/// closes on itself. +fn local_path(element: &Element) -> Option<(Vec<[f32; 2]>, bool)> { + let width = narrow(element.width).abs(); + let height = narrow(element.height).abs(); + match &element.kind { + ElementKind::Rectangle => Some((vec![[0.0, 0.0], [width, 0.0], [width, height], [0.0, height]], true)), + ElementKind::Diamond => Some((vec![[width / 2.0, 0.0], [width, height / 2.0], [width / 2.0, height], [0.0, height / 2.0]], true)), + ElementKind::Ellipse => Some((ellipse(width, height), true)), + ElementKind::Line { points } | ElementKind::Arrow { points, .. } => { + if points.is_empty() { + None + } else { + Some((points.iter().map(|point| [narrow(point[0]), narrow(point[1])]).collect(), false)) + } + } + _ => None, + } +} + +/// The outline of an ellipse centred in the element box, closed on itself. +fn ellipse(width: f32, height: f32) -> Vec<[f32; 2]> { + let segments = f32::from(u16::try_from(SEGMENTS).unwrap_or(32)); + let step = std::f32::consts::TAU / segments; + let mut points = Vec::with_capacity(SEGMENTS + 1); + for index in 0..SEGMENTS { + let angle = step * f32::from(u16::try_from(index).unwrap_or(0)); + let (sin, cos) = angle.sin_cos(); + points.push([width / 2.0 + width / 2.0 * cos, height / 2.0 + height / 2.0 * sin]); + } + if let Some(first) = points.first().copied() { + points.push(first); + } + points +} + +/// One point moved by a random offset. +fn jitter(point: [f32; 2], amplitude: f32, rng: &mut Rng) -> [f32; 2] { + [point[0] + rng.range(-amplitude, amplitude), point[1] + rng.range(-amplitude, amplitude)] +} + +/// Pushes both ends a little past the path, the way a hand does. +fn overshoot(points: &mut Polyline, rng: &mut Rng) { + let len = points.len(); + let ends = (points.first().copied(), points.get(1).copied(), points.last().copied(), points.get(len.wrapping_sub(2)).copied()); + let (Some(start), Some(next), Some(end), Some(previous)) = ends else { + return; + }; + // Both ends are measured before the path grows, so they stay symmetric. + let head = extend(start, next, rng); + let tail = extend(end, previous, rng); + if let Some(point) = head { + points.insert(0, point); + } + if let Some(point) = tail { + points.push(point); + } +} + +/// `tip` pushed further along the `tip - from` direction by a random amount. +fn extend(tip: [f32; 2], from: [f32; 2], rng: &mut Rng) -> Option<[f32; 2]> { + let (dx, dy) = (tip[0] - from[0], tip[1] - from[1]); + let length = (dx * dx + dy * dy).sqrt(); + if !length.is_finite() || length < 1.0e-4 { + return None; + } + let extra = rng.range(OVERSHOOT.0, OVERSHOOT.1); + Some([clamp(tip[0] + dx / length * extra), clamp(tip[1] + dy / length * extra)]) +} + +/// The arrowhead legs of a stroke, when the element has an arrowhead. +fn head_legs(element: &Element, stroke: Option<&Polyline>) -> Vec { + let Some(stroke) = stroke else { + return Vec::new(); + }; + let len = stroke.len(); + if len < 2 { + return Vec::new(); + } + let point = |index: usize| stroke.get(index).copied().unwrap_or([0.0, 0.0]); + let (start_head, end_head) = match &element.kind { + ElementKind::Arrow { start_arrowhead, end_arrowhead, .. } => (start_arrowhead.as_deref(), end_arrowhead.as_deref()), + _ => (None, None), + }; + let mut legs = Vec::new(); + if end_head == Some("arrow") { + legs.extend(arrowhead_legs(point(len - 1), point(len - 2))); + } + if start_head == Some("arrow") { + legs.extend(arrowhead_legs(point(0), point(1))); + } + legs +} + +/// Two legs of length [`ARROWHEAD`] spread around the direction `tip - from`. +fn arrowhead_legs(tip: [f32; 2], from: [f32; 2]) -> Vec { + let (dx, dy) = (tip[0] - from[0], tip[1] - from[1]); + let length = (dx * dx + dy * dy).sqrt(); + if !length.is_finite() || length < 1.0e-4 { + return Vec::new(); + } + let base = dy.atan2(dx); + [-ARROWHEAD_SPREAD, ARROWHEAD_SPREAD] + .map(|spread| { + let angle = base + spread; + let (sin, cos) = angle.sin_cos(); + vec![[tip[0], tip[1]], [clamp(tip[0] + ARROWHEAD * cos), clamp(tip[1] + ARROWHEAD * sin)]] + }) + .into_iter() + .collect() +} + +/// Maps element points to document coordinates: rotation, then translation. +fn place(element: &Element, points: Polyline) -> Polyline { + let (width, height) = (narrow(element.width), narrow(element.height)); + let (cx, cy) = (width / 2.0, height / 2.0); + let (sin, cos) = narrow(element.angle).sin_cos(); + let (x, y) = (narrow(element.x), narrow(element.y)); + points + .into_iter() + .map(|point| { + let (dx, dy) = (point[0] - cx, point[1] - cy); + [clamp(x + cx + dx * cos - dy * sin), clamp(y + cy + dx * sin + dy * cos)] + }) + .collect() +} + +/// xorshift64: three shifts, no state but the seed. +struct Rng(u64); + +impl Rng { + fn new(seed: u64) -> Self { + Self(scramble(seed)) + } + + fn next(&mut self) -> u64 { + let mut state = self.0; + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + self.0 = state; + state + } + + /// A uniform value in `[0, 1)`, built from 16 random bits. + fn unit(&mut self) -> f32 { + f32::from(u16::try_from(self.next() >> 48).unwrap_or(0)) * (1.0 / 65_536.0) + } + + /// A uniform value in `[low, high)`. + fn range(&mut self, low: f32, high: f32) -> f32 { + low + (high - low) * self.unit() + } +} + +/// splitmix64 finalizer: spreads neighbouring seeds over the whole state, so +/// two elements created one after the other do not look alike. +fn scramble(seed: u64) -> u64 { + let mut state = seed.wrapping_add(STROKE_SALT); + state = (state ^ (state >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + state = (state ^ (state >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + (state ^ (state >> 31)) | 1 +} + +/// Narrows `value` to the nearest `f32`, mapping non-finite input to `0.0`. +/// +/// The shortest round-trip decimal of a finite `f64` parses back to the very +/// `f32` a hardware cast would pick, without tripping the lossy-cast lint. +fn narrow(value: f64) -> f32 { + if !value.is_finite() { + return 0.0; + } + value.clamp(-f64::from(MAX_COORDINATE), f64::from(MAX_COORDINATE)).to_string().parse::().unwrap_or(0.0) +} + +/// Keeps a coordinate inside the range the renderer can handle. +fn clamp(value: f32) -> f32 { + if value.is_finite() { value.clamp(-MAX_COORDINATE, MAX_COORDINATE) } else { 0.0 } +} + +#[cfg(test)] +mod tests { + use super::{Polyline, Rng, Style, ellipse, extend, narrow, outline, overshoot}; + use crate::id::IdGen; + use crate::model::{Element, ElementKind}; + + fn bits(lines: &[Polyline]) -> Vec { + lines.iter().flat_map(|line| line.iter().flat_map(|p| [p[0].to_bits(), p[1].to_bits()])).collect() + } + + #[test] + fn rng_is_reproducible_and_in_range() { + let mut a = Rng::new(42); + let mut b = Rng::new(42); + for _ in 0..1000 { + let value = a.unit(); + assert!((0.0..1.0).contains(&value), "{value} out of range"); + assert_eq!(value.to_bits(), b.unit().to_bits()); + } + } + + #[test] + fn rng_never_sticks() { + let mut rng = Rng::new(0); + let first = rng.next(); + let mut different = 0; + for _ in 0..100 { + if rng.next() != first { + different += 1; + } + } + assert!(different > 50, "a stuck generator would be useless"); + } + + #[test] + fn narrow_survives_extreme_input() { + assert_eq!(narrow(f64::NAN), 0.0); + assert_eq!(narrow(f64::INFINITY), 0.0); + assert_eq!(narrow(1.0e300), 1.0e6); + assert_eq!(narrow(-1.0e300), -1.0e6); + assert_eq!(narrow(0.5), 0.5); + assert_eq!(narrow(1.0 / 3.0), 1.0 / 3.0_f32); + } + + #[test] + fn ellipse_is_closed_and_stays_in_the_box() { + let ring = ellipse(100.0, 50.0); + assert_eq!(ring.len(), 33); + assert_eq!(ring.first(), ring.last()); + for point in ring { + assert!(point[0] <= 100.001 && point[1] <= 50.001, "{point:?} escaped the box"); + } + } + + #[test] + fn rotation_moves_the_shape_around_its_centre() { + let mut ids = IdGen::new(); + let mut square = Element::rectangle(&mut ids, 0.0, 0.0, 100.0, 100.0); + let flat = outline(&square, Style::Clean); + square.angle = std::f64::consts::FRAC_PI_2; + let turned = outline(&square, Style::Clean); + assert_eq!(flat[0][0], [0.0, 0.0]); + assert_ne!(flat, turned, "a quarter turn must move the corners"); + for stroke in &turned { + for point in stroke { + assert!(point[0].is_finite() && point[1].is_finite()); + } + } + } + + #[test] + fn hand_drawn_lines_have_overshooting_ends() { + let mut ids = IdGen::new(); + let line = Element::line(&mut ids, 0.0, 0.0, 100.0, 40.0); + let hand = outline(&line, Style::HandDrawn); + assert_eq!(hand.len(), 2); + for stroke in &hand { + assert_eq!(stroke.len(), 4, "two original points plus two overshoots"); + } + } + + #[test] + fn empty_paths_draw_nothing() { + let mut ids = IdGen::new(); + let mut arrow = Element::arrow(&mut ids, 0.0, 0.0, 10.0, 10.0); + if let ElementKind::Arrow { points, .. } = &mut arrow.kind { + points.clear(); + } + assert_eq!(outline(&arrow, Style::Clean), Vec::::new()); + assert_eq!(outline(&arrow, Style::HandDrawn), Vec::::new()); + assert_eq!(bits(&outline(&arrow, Style::Clean)), Vec::::new()); + } + + #[test] + fn overshoot_pushes_both_ends_outwards() { + let mut points = vec![[0.0, 0.0], [50.0, 10.0], [100.0, 0.0]]; + overshoot(&mut points, &mut Rng::new(3)); + assert_eq!(points.len(), 5); + let head = points.first().copied().unwrap_or([0.0, 0.0]); + let tail = points.last().copied().unwrap_or([0.0, 0.0]); + assert!(head[0] < 0.0 && head[1] < 0.0, "the start goes back: {head:?}"); + assert!(tail[0] > 100.0 && tail[1] < 0.0, "the end goes forward: {tail:?}"); + } + + #[test] + fn extend_pushes_an_endpoint_further_along() { + let mut rng = Rng::new(7); + for _ in 0..100 { + let point = extend([10.0, 0.0], [0.0, 0.0], &mut rng).expect("a direction"); + assert!(point[0] > 10.0, "overshoot goes forward: {point:?}"); + assert!(point[0] <= 12.0); + } + assert!(extend([1.0, 1.0], [1.0, 1.0], &mut rng).is_none(), "no direction, no overshoot"); + } + + #[test] + fn neighbouring_seeds_do_not_look_alike() { + let mut first = Rng::new(1); + let mut second = Rng::new(2); + assert_ne!(first.next(), second.next()); + } + + #[test] + fn arrowheads_follow_the_stroke() { + let mut ids = IdGen::new(); + let arrow = Element::arrow(&mut ids, 0.0, 0.0, 100.0, 0.0); + let clean = outline(&arrow, Style::Clean); + assert_eq!(clean.len(), 3); + assert_eq!(clean.first(), Some(&vec![[0.0, 0.0], [100.0, 0.0]])); + for leg in clean.iter().skip(1) { + assert_eq!(leg.first().copied(), Some([100.0, 0.0]), "each leg starts at the tip"); + assert_eq!(leg.len(), 2); + assert!(leg.get(1).is_some_and(|point| point[0] < 100.0), "legs point back at the shaft"); + } + } +} diff --git a/crates/whiteboard/src/serde_impl.rs b/crates/whiteboard/src/serde_impl.rs new file mode 100644 index 0000000..e51da33 --- /dev/null +++ b/crates/whiteboard/src/serde_impl.rs @@ -0,0 +1,252 @@ +//! JSON encoding and decoding of elements and documents. +//! +//! Writers emit the known fields first and the leftovers afterwards, so an +//! unknown field survives a round trip verbatim. + +use serde::ser::SerializeMap; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; + +use crate::board::Whiteboard; +use crate::codec::Fields; +use crate::error::Result; +use crate::model::{Binding, BoundElement, Element, ElementKind, Extra, FillStyle, StrokeStyle}; + +impl Element { + /// Reads one element from a JSON value. + pub(crate) fn from_value(value: Value) -> Result { + let mut fields = Fields::new(value)?; + let mut element = Self { + id: fields.text("id", "")?, + x: fields.number("x", 0.0)?, + y: fields.number("y", 0.0)?, + width: fields.number("width", 0.0)?, + height: fields.number("height", 0.0)?, + angle: fields.number("angle", 0.0)?, + stroke_color: fields.text("strokeColor", "")?, + background_color: fields.text("backgroundColor", "transparent")?, + fill_style: FillStyle::from_name(&fields.text("fillStyle", "solid")?), + stroke_width: fields.number("strokeWidth", 1.0)?, + stroke_style: StrokeStyle::from_name(&fields.text("strokeStyle", "solid")?), + roughness: fields.number("roughness", 1)?, + opacity: fields.number("opacity", 100)?, + seed: fields.number("seed", 1)?, + version: fields.number("version", 1)?, + version_nonce: fields.number("versionNonce", 0)?, + is_deleted: fields.flag("isDeleted", false)?, + group_ids: fields.texts("groupIds")?, + bound_elements: fields.objects("boundElements")?.into_iter().map(BoundElement::from_value).collect::>>()?, + link: fields.optional_text("link")?, + locked: fields.flag("locked", false)?, + updated: fields.number("updated", 0)?, + kind: ElementKind::decode(&mut fields)?, + extra: Extra::new(), + }; + element.extra = fields.extra(); + Ok(element) + } +} + +impl ElementKind { + /// Reads the kind of an element, leaving its keys for `extra`. + fn decode(fields: &mut Fields) -> Result { + let type_name = fields.text("type", "")?; + Ok(match type_name.as_str() { + "rectangle" => Self::Rectangle, + "ellipse" => Self::Ellipse, + "diamond" => Self::Diamond, + "line" => Self::Line { points: fields.points("points")? }, + "arrow" => Self::Arrow { + points: fields.points("points")?, + start_binding: Binding::decode(fields, "startBinding")?, + end_binding: Binding::decode(fields, "endBinding")?, + start_arrowhead: fields.optional_text("startArrowhead")?, + end_arrowhead: fields.optional_text("endArrowhead")?, + }, + "freedraw" => Self::Freedraw { points: fields.points("points")?, pressures: fields.numbers("pressures")? }, + "text" => Self::Text { + text: fields.text("text", "")?, + font_size: fields.number("fontSize", 20.0)?, + font_family: fields.text_or_number("fontFamily", "1")?, + text_align: fields.text("textAlign", "left")?, + vertical_align: fields.text("verticalAlign", "top")?, + container_id: fields.optional_text("containerId")?, + }, + "image" => Self::Image { file_id: fields.text("fileId", "")? }, + "frame" => Self::Frame { name: fields.text("name", "")? }, + _ => Self::Unknown { type_name }, + }) + } + + /// Writes the keys that belong to the kind itself. + pub(crate) fn write_fields(&self, map: &mut M) -> std::result::Result<(), M::Error> { + match self { + Self::Rectangle | Self::Ellipse | Self::Diamond | Self::Unknown { .. } => Ok(()), + Self::Line { points } => map.serialize_entry("points", points), + Self::Arrow { points, start_binding, end_binding, start_arrowhead, end_arrowhead } => { + map.serialize_entry("points", points)?; + map.serialize_entry("startBinding", start_binding)?; + map.serialize_entry("endBinding", end_binding)?; + map.serialize_entry("startArrowhead", start_arrowhead)?; + map.serialize_entry("endArrowhead", end_arrowhead) + } + Self::Freedraw { points, pressures } => { + map.serialize_entry("points", points)?; + map.serialize_entry("pressures", pressures) + } + Self::Text { text, font_size, font_family, text_align, vertical_align, container_id } => { + map.serialize_entry("text", text)?; + map.serialize_entry("fontSize", font_size)?; + map.serialize_entry("fontFamily", font_family)?; + map.serialize_entry("textAlign", text_align)?; + map.serialize_entry("verticalAlign", vertical_align)?; + map.serialize_entry("containerId", container_id) + } + Self::Image { file_id } => map.serialize_entry("fileId", file_id), + Self::Frame { name } => map.serialize_entry("name", name), + } + } +} + +impl Binding { + fn from_value(value: Value) -> Result { + let mut fields = Fields::new(value)?; + let mut binding = Self { + element_id: fields.text("elementId", "")?, + focus: fields.optional_number("focus")?, + gap: fields.optional_number("gap")?, + extra: Extra::new(), + }; + binding.extra = fields.extra(); + Ok(binding) + } + + /// Reads the binding under `key`, if any. + fn decode(fields: &mut Fields, key: &str) -> Result> { + match fields.take_object(key)? { + Some(value) => Self::from_value(value).map(Some), + None => Ok(None), + } + } +} + +impl Serialize for Binding { + fn serialize(&self, serializer: S) -> std::result::Result { + let mut map = serializer.serialize_map(None)?; + map.serialize_entry("elementId", &self.element_id)?; + if let Some(focus) = self.focus { + map.serialize_entry("focus", &focus)?; + } + if let Some(gap) = self.gap { + map.serialize_entry("gap", &gap)?; + } + for (key, value) in &self.extra { + map.serialize_entry(key, value)?; + } + map.end() + } +} + +impl BoundElement { + fn from_value(value: Value) -> Result { + let mut fields = Fields::new(value)?; + let mut bound = Self { id: fields.text("id", "")?, type_name: fields.text("type", "")?, extra: Extra::new() }; + bound.extra = fields.extra(); + Ok(bound) + } +} + +impl Serialize for BoundElement { + fn serialize(&self, serializer: S) -> std::result::Result { + let mut map = serializer.serialize_map(None)?; + map.serialize_entry("id", &self.id)?; + map.serialize_entry("type", &self.type_name)?; + for (key, value) in &self.extra { + map.serialize_entry(key, value)?; + } + map.end() + } +} + +impl Serialize for Element { + fn serialize(&self, serializer: S) -> std::result::Result { + let mut map = serializer.serialize_map(None)?; + map.serialize_entry("id", &self.id)?; + map.serialize_entry("type", self.kind.type_name())?; + map.serialize_entry("x", &self.x)?; + map.serialize_entry("y", &self.y)?; + map.serialize_entry("width", &self.width)?; + map.serialize_entry("height", &self.height)?; + map.serialize_entry("angle", &self.angle)?; + map.serialize_entry("strokeColor", &self.stroke_color)?; + map.serialize_entry("backgroundColor", &self.background_color)?; + map.serialize_entry("fillStyle", self.fill_style.as_str())?; + map.serialize_entry("strokeWidth", &self.stroke_width)?; + map.serialize_entry("strokeStyle", self.stroke_style.as_str())?; + map.serialize_entry("roughness", &self.roughness)?; + map.serialize_entry("opacity", &self.opacity)?; + map.serialize_entry("seed", &self.seed)?; + map.serialize_entry("version", &self.version)?; + map.serialize_entry("versionNonce", &self.version_nonce)?; + map.serialize_entry("isDeleted", &self.is_deleted)?; + map.serialize_entry("groupIds", &self.group_ids)?; + map.serialize_entry("boundElements", &self.bound_elements)?; + map.serialize_entry("link", &self.link)?; + map.serialize_entry("locked", &self.locked)?; + map.serialize_entry("updated", &self.updated)?; + self.kind.write_fields(&mut map)?; + for (key, value) in &self.extra { + map.serialize_entry(key, value)?; + } + map.end() + } +} + +impl<'de> Deserialize<'de> for Element { + fn deserialize>(deserializer: D) -> std::result::Result { + Element::from_value(Value::deserialize(deserializer)?).map_err(serde::de::Error::custom) + } +} + +impl Serialize for Whiteboard { + fn serialize(&self, serializer: S) -> std::result::Result { + let mut map = serializer.serialize_map(None)?; + map.serialize_entry("type", "excalidraw")?; + map.serialize_entry("version", &2_u64)?; + map.serialize_entry("elements", &self.elements)?; + map.serialize_entry("appState", &self.app_state)?; + map.serialize_entry("files", &self.files)?; + for (key, value) in &self.extra { + map.serialize_entry(key, value)?; + } + map.end() + } +} + +impl<'de> Deserialize<'de> for Whiteboard { + fn deserialize>(deserializer: D) -> std::result::Result { + Whiteboard::from_value(Value::deserialize(deserializer)?).map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::Element; + use crate::model::ElementKind; + + #[test] + fn elements_round_trip_through_a_borrowed_value() { + let source = json!({ "id": "a", "type": "line", "points": [[0, 0], [1, 1]], "unknown": 1 }); + let element: Element = serde_json::from_value(source).expect("parses"); + let back = serde_json::to_value(&element).expect("serializes"); + assert_eq!(back.get("unknown"), Some(&json!(1))); + let ElementKind::Line { points } = &element.kind else { + panic!("line"); + }; + assert_eq!(*points, vec![[0.0, 0.0], [1.0, 1.0]]); + let again: Element = serde_json::from_value(back).expect("re-parses"); + assert_eq!(again, element); + } +} diff --git a/crates/whiteboard/tests/api.rs b/crates/whiteboard/tests/api.rs new file mode 100644 index 0000000..726b78b --- /dev/null +++ b/crates/whiteboard/tests/api.rs @@ -0,0 +1,146 @@ +//! Malformed input, hard caps, and the builder API. + +use notescraft_whiteboard::{Element, ElementKind, Error, IdGen, Whiteboard}; + +fn wrap(elements: &str) -> String { + format!(r#"{{"type":"excalidraw","version":2,"elements":[{elements}]}}"#) +} + +#[test] +fn malformed_json_is_rejected() { + for bad in ["", "{", "[]", "null", "{\"elements\":{}}", "{\"elements\":[1,2]}", "{\"elements\":[{\"id\":5}]}"] { + assert!(Whiteboard::from_str(bad).is_err(), "expected an error for `{bad}`"); + } + let err = Whiteboard::from_str("").expect_err("empty string fails"); + assert!(matches!(err, Error::Json(_)), "unexpected error: {err:?}"); +} + +/// JSON has no `NaN`, and a number too large for `f64` is a parse error. +#[test] +fn non_finite_numbers_from_json_are_rejected() { + for literal in ["NaN", "Infinity", "1e400"] { + let bad = wrap(&format!(r#"{{"id":"a","type":"rectangle","x":{literal}}}"#)); + let err = Whiteboard::from_str(&bad).expect_err("out of range numbers must fail"); + assert!(matches!(err, Error::Json(_)), "unexpected error for {literal}: {err:?}"); + } + + let ok = wrap(r#"{"id":"a","type":"rectangle","x":1e300,"width":-5.0}"#); + assert!(Whiteboard::from_str(&ok).is_ok(), "large but finite numbers are accepted"); +} + +#[test] +fn wrong_typed_fields_are_reported() { + let bad = wrap(r#"{"id":"a","type":"rectangle","x":"left"}"#); + let err = Whiteboard::from_str(&bad).expect_err("wrong type must fail"); + assert!(matches!(err, Error::InvalidField { .. }), "unexpected error: {err:?}"); +} + +#[test] +fn element_count_is_capped() { + let many = (0..notescraft_whiteboard::MAX_ELEMENTS).map(|i| format!(r#"{{"id":"e{i}","type":"rectangle"}}"#)).collect::>().join(","); + assert!(Whiteboard::from_str(&wrap(&many)).is_ok(), "exactly the cap is allowed"); + let over = format!(r#"{{"id":"e{}","type":"rectangle"}}"#, 100_000); + let err = Whiteboard::from_str(&wrap(&format!("{many},{over}"))).expect_err("over the cap must fail"); + assert!(matches!(err, Error::TooManyElements { .. }), "unexpected error: {err:?}"); +} + +#[test] +fn input_size_is_capped() { + let filler = "x".repeat(notescraft_whiteboard::MAX_INPUT_BYTES); + let big = format!(r#"{{"type":"excalidraw","version":2,"elements":[],"appState":{{"pad":"{filler}"}}}}"#); + assert!(big.len() > notescraft_whiteboard::MAX_INPUT_BYTES); + let err = Whiteboard::from_str(&big).expect_err("over the size cap must fail"); + assert!(matches!(err, Error::TooLarge { .. }), "unexpected error: {err:?}"); +} + +#[test] +fn builders_produce_excalidraw_shapes_with_fresh_ids() { + let mut ids = IdGen::new(); + let mut made: Vec = vec![ + Element::rectangle(&mut ids, 1.0, 2.0, 3.0, 4.0), + Element::ellipse(&mut ids, 1.0, 2.0, 3.0, 4.0), + Element::diamond(&mut ids, 1.0, 2.0, 3.0, 4.0), + Element::line(&mut ids, 1.0, 2.0, 3.0, 4.0), + Element::arrow(&mut ids, 1.0, 2.0, 3.0, 4.0), + Element::text(&mut ids, 1.0, 2.0, "hello"), + ]; + let ids_seen: Vec<&str> = made.iter().map(|e| e.id.as_str()).collect(); + for id in &ids_seen { + assert_eq!(id.chars().count(), 16, "ids are 16 chars: {id}"); + } + let mut sorted = ids_seen.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), ids_seen.len(), "ids must be unique"); + + let names: Vec<&str> = made.iter().map(Element::type_name).collect(); + assert_eq!(names, ["rectangle", "ellipse", "diamond", "line", "arrow", "text"]); + for element in &made { + assert!(element.roughness > 0); + assert_eq!(element.opacity, 100); + assert!(!element.is_deleted); + } + + let ElementKind::Line { points } = &made[3].kind else { panic!("line") }; + assert_eq!(points.len(), 2); + let ElementKind::Arrow { points, start_binding, end_binding, end_arrowhead, .. } = &made[4].kind else { + panic!("arrow"); + }; + assert_eq!(points.len(), 2); + assert!(start_binding.is_none() && end_binding.is_none()); + assert_eq!(end_arrowhead.as_deref(), Some("arrow")); + let ElementKind::Text { text, .. } = &made[5].kind else { panic!("text") }; + assert_eq!(text, "hello"); + + made[0].id.push('x'); + let mut fresh = IdGen::new(); + assert_eq!(fresh.next_id().chars().count(), 16); + assert_ne!(fresh.next_id(), fresh.next_id(), "the generator keeps counting"); +} + +#[test] +fn delete_keeps_the_element_and_marks_it() { + let mut ids = IdGen::new(); + let mut board = Whiteboard::default(); + let rect = Element::rectangle(&mut ids, 0.0, 0.0, 10.0, 10.0); + board.add(rect.clone()); + board.add(Element::ellipse(&mut ids, 0.0, 0.0, 10.0, 10.0)); + assert!(board.delete("nope").is_none()); + let deleted = board.delete(&rect.id).expect("returns the deleted element"); + assert!(deleted.is_deleted); + assert_eq!(board.elements.len(), 2); + let stored = board.element(&rect.id).expect("still stored"); + assert!(stored.is_deleted); + assert_eq!(board.bounding_box().expect("the ellipse is still visible").width, 10.0); + + let last_id = board.elements[1].id.clone(); + board.delete(&last_id); + assert_eq!(board.bounding_box(), None, "deleted elements do not extend the box"); +} + +#[test] +fn bounding_box_accounts_for_rotation_and_points() { + let mut ids = IdGen::new(); + let mut board = Whiteboard::default(); + board.add(Element::rectangle(&mut ids, 0.0, 0.0, 10.0, 10.0)); + let bounds = board.bounding_box().expect("box"); + assert_eq!((bounds.x, bounds.y, bounds.width, bounds.height), (0.0, 0.0, 10.0, 10.0)); + + let mut rotated = Element::rectangle(&mut ids, 0.0, 0.0, 20.0, 10.0); + rotated.angle = std::f64::consts::FRAC_PI_2; + let mut board = Whiteboard::default(); + board.add(rotated); + let bounds = board.bounding_box().expect("box"); + assert!((bounds.width - 10.0).abs() < 1e-9 && (bounds.height - 20.0).abs() < 1e-9, "the box follows the rotation: {bounds:?}"); + assert!((bounds.x - 5.0).abs() < 1e-9 && (bounds.y + 5.0).abs() < 1e-9, "the box follows the rotation: {bounds:?}"); + + let mut board = Whiteboard::default(); + board.add(Element::line(&mut ids, 10.0, 10.0, 30.0, 20.0)); + let bounds = board.bounding_box().expect("box"); + assert_eq!((bounds.x, bounds.y, bounds.width, bounds.height), (10.0, 10.0, 30.0, 20.0)); +} + +#[test] +fn empty_board_has_no_bounding_box() { + assert_eq!(Whiteboard::default().bounding_box(), None); +} diff --git a/crates/whiteboard/tests/fixtures/all_types.excalidraw.json b/crates/whiteboard/tests/fixtures/all_types.excalidraw.json new file mode 100644 index 0000000..3b7d1a1 --- /dev/null +++ b/crates/whiteboard/tests/fixtures/all_types.excalidraw.json @@ -0,0 +1,266 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw.com", + "elements": [ + { + "id": "rect-0001", + "type": "rectangle", + "x": 100.0, + "y": 50.0, + "width": 200.0, + "height": 120.0, + "angle": 0.0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "seed": 123456, + "version": 42, + "versionNonce": 987654, + "isDeleted": false, + "groupIds": ["grp-1"], + "boundElements": [{ "id": "arrow-0001", "type": "arrow" }], + "link": "note://diagram", + "locked": false, + "updated": 1700000000000, + "roundness": { "type": 3 } + }, + { + "id": "ellipse-0001", + "type": "ellipse", + "x": -40.5, + "y": 12.25, + "width": 80.0, + "height": 60.0, + "angle": 0.5, + "strokeColor": "#e03131", + "backgroundColor": "#ffc9c9", + "fillStyle": "cross-hatch", + "strokeWidth": 1, + "strokeStyle": "dashed", + "roughness": 2, + "opacity": 60, + "seed": 987, + "version": 3, + "versionNonce": 11, + "isDeleted": false, + "groupIds": [], + "boundElements": null, + "link": null, + "locked": true, + "updated": 1700000000001 + }, + { + "id": "diamond-0001", + "type": "diamond", + "x": 400.0, + "y": 50.0, + "width": 120.0, + "height": 120.0, + "angle": 0.0, + "strokeColor": "#2f9e44", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "seed": 55, + "version": 1, + "versionNonce": 2, + "isDeleted": false, + "groupIds": [], + "boundElements": [], + "link": null, + "locked": false, + "updated": 1700000000002 + }, + { + "id": "line-0001", + "type": "line", + "x": 10.0, + "y": 10.0, + "width": 120.0, + "height": 40.0, + "angle": 0.0, + "strokeColor": "#1971c2", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 4, + "strokeStyle": "dotted", + "roughness": 1, + "opacity": 100, + "seed": 7, + "version": 5, + "versionNonce": 8, + "isDeleted": false, + "groupIds": [], + "boundElements": [], + "link": null, + "locked": false, + "updated": 1700000000003, + "points": [[0, 0], [40, 30], [120, 40]], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null + }, + { + "id": "arrow-0001", + "type": "arrow", + "x": 300.0, + "y": 110.0, + "width": -100.0, + "height": -60.0, + "angle": 0.0, + "strokeColor": "#f08c00", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "seed": 424242, + "version": 9, + "versionNonce": 10, + "isDeleted": false, + "groupIds": [], + "boundElements": [], + "link": null, + "locked": false, + "updated": 1700000000004, + "points": [[0, 0], [-50, -20], [-100, -60]], + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "freedraw-0001", + "type": "freedraw", + "x": 0.0, + "y": 300.0, + "width": 64.0, + "height": 32.0, + "angle": 0.0, + "strokeColor": "#0b7285", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "seed": 2024, + "version": 2, + "versionNonce": 4, + "isDeleted": false, + "groupIds": [], + "boundElements": null, + "link": null, + "locked": false, + "updated": 1700000000005, + "points": [[0, 0], [16, 32], [32, 0], [48, 32], [64, 8]], + "pressures": [0.5, 1.0, 0.25, 0.75, 1.0], + "simulatePressure": false + }, + { + "id": "text-0001", + "type": "text", + "x": 20.0, + "y": 400.0, + "width": 130.0, + "height": 25.0, + "angle": 0.0, + "strokeColor": "#212529", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "seed": 31, + "version": 6, + "versionNonce": 7, + "isDeleted": false, + "groupIds": [], + "boundElements": [], + "link": null, + "locked": false, + "updated": 1700000000006, + "fontSize": 20, + "fontFamily": 1, + "text": "Hello whiteboard", + "textAlign": "center", + "verticalAlign": "middle", + "containerId": null, + "originalText": "Hello whiteboard", + "lineHeight": 1.25, + "autoResize": true + }, + { + "id": "image-0001", + "type": "image", + "x": 600.0, + "y": 0.0, + "width": 128.0, + "height": 96.0, + "angle": 0.0, + "strokeColor": "transparent", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "seed": 909, + "version": 12, + "versionNonce": 13, + "isDeleted": false, + "groupIds": [], + "boundElements": null, + "link": null, + "locked": false, + "updated": 1700000000007, + "fileId": "file-abc", + "status": "saved", + "scale": [1, 1] + }, + { + "id": "frame-0001", + "type": "frame", + "x": -10.0, + "y": -10.0, + "width": 800.0, + "height": 600.0, + "angle": 0.0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "seed": 17, + "version": 1, + "versionNonce": 2, + "isDeleted": false, + "groupIds": [], + "boundElements": null, + "link": null, + "locked": false, + "updated": 1700000000008, + "name": "Board 1" + } + ], + "appState": { + "gridSize": null, + "viewBackgroundColor": "#ffffff", + "zoom": { "value": 1.2 }, + "scrollX": 0.0, + "scrollY": 0.0 + }, + "files": {} +} \ No newline at end of file diff --git a/crates/whiteboard/tests/fixtures/bound_arrow.excalidraw.json b/crates/whiteboard/tests/fixtures/bound_arrow.excalidraw.json new file mode 100644 index 0000000..4a6a1b3 --- /dev/null +++ b/crates/whiteboard/tests/fixtures/bound_arrow.excalidraw.json @@ -0,0 +1,135 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw.com", + "elements": [ + { + "id": "src", + "type": "rectangle", + "x": 0.0, + "y": 0.0, + "width": 100.0, + "height": 60.0, + "angle": 0.0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#d0ebff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "seed": 1, + "version": 1, + "versionNonce": 1, + "isDeleted": false, + "groupIds": [], + "boundElements": [ + { "id": "link", "type": "arrow" }, + { "id": "label", "type": "text" } + ], + "link": null, + "locked": false, + "updated": 1700000001000 + }, + { + "id": "dst", + "type": "ellipse", + "x": 300.0, + "y": 0.0, + "width": 100.0, + "height": 60.0, + "angle": 0.0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#d3f9d8", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "seed": 2, + "version": 1, + "versionNonce": 2, + "isDeleted": false, + "groupIds": [], + "boundElements": [{ "id": "link", "type": "arrow" }], + "link": null, + "locked": false, + "updated": 1700000001001 + }, + { + "id": "link", + "type": "arrow", + "x": 100.0, + "y": 30.0, + "width": 200.0, + "height": 0.0, + "angle": 0.0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "seed": 3, + "version": 1, + "versionNonce": 3, + "isDeleted": false, + "groupIds": [], + "boundElements": null, + "link": null, + "locked": false, + "updated": 1700000001002, + "points": [[0, 0], [200, 0]], + "startBinding": { + "elementId": "src", + "focus": 0, + "gap": 4, + "fixedPoint": [0.5, 0] + }, + "endBinding": { + "elementId": "dst", + "focus": -0.2, + "gap": 1, + "fixedPoint": [0.5, 1] + }, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "label", + "type": "text", + "x": 150.0, + "y": 5.0, + "width": 60.0, + "height": 20.0, + "angle": 0.0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "seed": 4, + "version": 1, + "versionNonce": 4, + "isDeleted": false, + "groupIds": [], + "boundElements": null, + "link": null, + "locked": false, + "updated": 1700000001003, + "fontSize": 16, + "fontFamily": 1, + "text": "depends on", + "textAlign": "center", + "verticalAlign": "top", + "containerId": "link", + "originalText": "depends on", + "lineHeight": 1.25 + } + ], + "appState": { "viewBackgroundColor": "#ffffff" }, + "files": {} +} \ No newline at end of file diff --git a/crates/whiteboard/tests/fixtures/image_with_files.excalidraw.json b/crates/whiteboard/tests/fixtures/image_with_files.excalidraw.json new file mode 100644 index 0000000..a1eaeaa --- /dev/null +++ b/crates/whiteboard/tests/fixtures/image_with_files.excalidraw.json @@ -0,0 +1,72 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw.com", + "elements": [ + { + "id": "img-1", + "type": "image", + "x": 5.0, + "y": 6.5, + "width": 320.0, + "height": 240.0, + "angle": 0.0, + "strokeColor": "transparent", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "seed": 7, + "version": 4, + "versionNonce": 8, + "isDeleted": false, + "groupIds": [], + "boundElements": null, + "link": "note://assets", + "locked": false, + "updated": 1700000003000, + "fileId": "6a1b2c3d4e5f60718293a4b5c6d7e8f9", + "status": "saved", + "scale": [1, 1], + "crop": null + }, + { + "id": "img-2", + "type": "image", + "x": 400.0, + "y": 6.5, + "width": 64.0, + "height": 64.0, + "angle": 0.0, + "strokeColor": "transparent", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "seed": 8, + "version": 5, + "versionNonce": 9, + "isDeleted": false, + "groupIds": [], + "boundElements": null, + "link": null, + "locked": false, + "updated": 1700000003001, + "fileId": "missing-file-id", + "status": "error" + } + ], + "appState": { "viewBackgroundColor": "#ffffff" }, + "files": { + "6a1b2c3d4e5f60718293a4b5c6d7e8f9": { + "mimeType": "image/png", + "id": "6a1b2c3d4e5f60718293a4b5c6d7e8f9", + "dataURL": "data:image/png;base64,iVBORw0KGgo=", + "created": 1700000003000 + } + } +} \ No newline at end of file diff --git a/crates/whiteboard/tests/fixtures/minimal.excalidraw.json b/crates/whiteboard/tests/fixtures/minimal.excalidraw.json new file mode 100644 index 0000000..43364e4 --- /dev/null +++ b/crates/whiteboard/tests/fixtures/minimal.excalidraw.json @@ -0,0 +1,7 @@ +{ + "type": "excalidraw", + "version": 2, + "elements": [ + { "id": "bare-rect", "type": "rectangle" } + ] +} \ No newline at end of file diff --git a/crates/whiteboard/tests/fixtures/text_container.excalidraw.json b/crates/whiteboard/tests/fixtures/text_container.excalidraw.json new file mode 100644 index 0000000..6f64dd2 --- /dev/null +++ b/crates/whiteboard/tests/fixtures/text_container.excalidraw.json @@ -0,0 +1,101 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw.com", + "elements": [ + { + "id": "container-1", + "type": "rectangle", + "x": 100.0, + "y": 100.0, + "width": 240.0, + "height": 140.0, + "angle": 0.0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#fff3bf", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "seed": 100, + "version": 7, + "versionNonce": 101, + "isDeleted": false, + "groupIds": [], + "boundElements": [{ "id": "caption-1", "type": "text" }], + "link": null, + "locked": false, + "updated": 1700000002000 + }, + { + "id": "caption-1", + "type": "text", + "x": 112.0, + "y": 150.0, + "width": 216.0, + "height": 50.0, + "angle": 0.0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "seed": 101, + "version": 8, + "versionNonce": 102, + "isDeleted": false, + "groupIds": [], + "boundElements": null, + "link": null, + "locked": false, + "updated": 1700000002001, + "fontSize": 20, + "fontFamily": 2, + "text": "double\nclick to edit", + "originalText": "double\nclick to edit", + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "container-1", + "lineHeight": 1.25, + "autoResize": true + }, + { + "id": "loose-1", + "type": "text", + "x": 10.0, + "y": 10.0, + "width": 40.0, + "height": 25.0, + "angle": 0.0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "seed": 102, + "version": 9, + "versionNonce": 103, + "isDeleted": false, + "groupIds": [], + "boundElements": null, + "link": null, + "locked": false, + "updated": 1700000002002, + "fontSize": 20, + "fontFamily": "Virgil", + "text": "free", + "originalText": "free", + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "lineHeight": 1.25 + } + ], + "appState": { "viewBackgroundColor": "#1e1e1e" }, + "files": {} +} \ No newline at end of file diff --git a/crates/whiteboard/tests/fixtures/unknowns.excalidraw.json b/crates/whiteboard/tests/fixtures/unknowns.excalidraw.json new file mode 100644 index 0000000..ea3e36c --- /dev/null +++ b/crates/whiteboard/tests/fixtures/unknowns.excalidraw.json @@ -0,0 +1,136 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw.com", + "appVersion": "2.15.3", + "exportedFrom": "obsidian-1.5.3", + "elements": [ + { + "id": "custom-1", + "type": "embeddable", + "x": 1.0, + "y": 2.0, + "width": 300.0, + "height": 150.0, + "angle": 0.25, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "seed": 777, + "version": 2, + "versionNonce": 22, + "isDeleted": false, + "groupIds": [], + "boundElements": null, + "link": null, + "locked": false, + "updated": 1700000004000, + "customKind": "iframe", + "url": "https://example.com/embed", + "customData": { "note": "kept verbatim", "n": 3 } + }, + { + "id": "rect-unknowns", + "type": "rectangle", + "x": 0.0, + "y": 0.0, + "width": 10.0, + "height": 10.0, + "angle": 0.0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "seed": 5, + "version": 2, + "versionNonce": 3, + "isDeleted": true, + "groupIds": ["g1", "g2"], + "boundElements": [ + { "id": "arrow-unknowns", "type": "arrow", "extra": [1, 2, 3] } + ], + "link": null, + "locked": false, + "updated": 1700000004001, + "customData": { "keep": true }, + "frameId": "frame-x", + "boundText": "txt-1", + "roundness": { "type": 3, "value": 0.5 } + }, + { + "id": "arrow-unknowns", + "type": "arrow", + "x": 0.0, + "y": 0.0, + "width": 10.0, + "height": 0.0, + "angle": 0.0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "seed": 6, + "version": 3, + "versionNonce": 4, + "isDeleted": false, + "groupIds": [], + "boundElements": null, + "link": null, + "locked": false, + "updated": 1700000004002, + "points": [[0, 0], [10, 0]], + "startBinding": { + "elementId": "rect-unknowns", + "focus": 0, + "gap": 1, + "unknownBindingField": "kept" + }, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false, + "unknownArrowFlag": 42 + }, + { + "id": "freedraw-unknowns", + "type": "freedraw", + "x": 0.0, + "y": 0.0, + "width": 0.0, + "height": 0.0, + "angle": 0.0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "seed": 8, + "version": 2, + "versionNonce": 5, + "isDeleted": false, + "groupIds": [], + "boundElements": null, + "link": null, + "locked": false, + "updated": 1700000004003, + "points": [], + "pressures": [], + "unknownFreedrawField": { "deeply": { "nested": [1, 2, { "x": null }] } } + } + ], + "appState": { "viewBackgroundColor": "#ffffff", "unknownAppStateKey": [1, 2, 3] }, + "files": {}, + "unknownTopLevelKey": { "nested": { "deeper": true } } +} \ No newline at end of file diff --git a/crates/whiteboard/tests/roundtrip.rs b/crates/whiteboard/tests/roundtrip.rs new file mode 100644 index 0000000..3a8b83d --- /dev/null +++ b/crates/whiteboard/tests/roundtrip.rs @@ -0,0 +1,227 @@ +//! Round-trip and unknown-field preservation over hand-written Excalidraw files. + +use notescraft_whiteboard::{Element, ElementKind, Whiteboard}; + +const ALL_TYPES: &str = include_str!("fixtures/all_types.excalidraw.json"); +const BOUND_ARROW: &str = include_str!("fixtures/bound_arrow.excalidraw.json"); +const TEXT_CONTAINER: &str = include_str!("fixtures/text_container.excalidraw.json"); +const IMAGE_FILES: &str = include_str!("fixtures/image_with_files.excalidraw.json"); +const UNKNOWNS: &str = include_str!("fixtures/unknowns.excalidraw.json"); +const MINIMAL: &str = include_str!("fixtures/minimal.excalidraw.json"); + +fn all() -> [&'static str; 6] { + [ALL_TYPES, BOUND_ARROW, TEXT_CONTAINER, IMAGE_FILES, UNKNOWNS, MINIMAL] +} + +/// `read(write(read(x))) == read(x)` and the canonical header is always written. +#[test] +fn round_trip_is_stable() { + for source in all() { + let board = Whiteboard::from_str(source).expect("fixture parses"); + let json = board.to_string_pretty().expect("serializes"); + let again = Whiteboard::from_str(&json).expect("canonical output parses"); + assert_eq!(board, again, "model changed after a round trip"); + let json2 = again.to_string_pretty().expect("serializes again"); + assert_eq!(json, json2, "canonical output is not idempotent"); + } +} + +#[test] +fn canonical_header_is_written() { + for source in all() { + let json = Whiteboard::from_str(source).expect("fixture parses").to_string_pretty().expect("serializes"); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json"); + assert_eq!(parsed.get("type").and_then(serde_json::Value::as_str), Some("excalidraw")); + assert_eq!(parsed.get("version").and_then(serde_json::Value::as_u64), Some(2)); + assert!(parsed.get("elements").and_then(serde_json::Value::as_array).is_some()); + } +} + +/// Every unknown key of the fixture, at every level, survives a round trip. +#[test] +fn unknown_fields_are_preserved() { + let board = Whiteboard::from_str(UNKNOWNS).expect("fixture parses"); + let json = board.to_string_pretty().expect("serializes"); + for key in [ + "appVersion", + "exportedFrom", + "unknownTopLevelKey", + "customKind", + "url", + "customData", + "frameId", + "boundText", + "roundness", + "unknownBindingField", + "elbowed", + "unknownArrowFlag", + "unknownFreedrawField", + "extra", + "unknownAppStateKey", + ] { + assert!(json.contains(key), "unknown key `{key}` was dropped:\n{json}"); + } +} + +#[test] +fn unknown_element_type_keeps_every_field() { + let board = Whiteboard::from_str(UNKNOWNS).expect("fixture parses"); + let element = board.element("custom-1").expect("element present"); + assert_eq!(element.type_name(), "embeddable"); + assert!(matches!(&element.kind, ElementKind::Unknown { type_name } if type_name == "embeddable"), "unknown element type must map to ElementKind::Unknown"); + let json = serde_json::to_value(element).expect("serializes"); + let object = json.as_object().expect("object"); + assert_eq!(object.get("type").and_then(serde_json::Value::as_str), Some("embeddable")); + assert_eq!(object.get("customKind").and_then(serde_json::Value::as_str), Some("iframe")); +} + +/// Common fields and kind payloads survive the trip. +#[test] +fn all_element_types_are_parsed() { + let board = Whiteboard::from_str(ALL_TYPES).expect("fixture parses"); + assert_eq!(board.elements.len(), 9); + assert_eq!( + board.elements.iter().map(Element::type_name).collect::>(), + ["rectangle", "ellipse", "diamond", "line", "arrow", "freedraw", "text", "image", "frame"] + ); + + let arrow = board.element("arrow-0001").expect("arrow present"); + let ElementKind::Arrow { points, end_arrowhead, .. } = &arrow.kind else { + panic!("arrow kind"); + }; + assert_eq!(points.len(), 3); + assert_eq!(points[0], [0.0, 0.0]); + assert_eq!(points[2][0], -100.0); + assert_eq!(end_arrowhead.as_deref(), Some("arrow")); + + let free = board.element("freedraw-0001").expect("freedraw present"); + let ElementKind::Freedraw { points, pressures } = &free.kind else { + panic!("freedraw kind"); + }; + assert_eq!(points.len(), 5); + assert_eq!(pressures.len(), 5); + + let text = board.element("text-0001").expect("text present"); + let ElementKind::Text { text: body, font_family, container_id, .. } = &text.kind else { + panic!("text kind"); + }; + assert_eq!(body, "Hello whiteboard"); + assert_eq!(font_family, "1"); + assert!(container_id.is_none()); + + let image = board.element("image-0001").expect("image present"); + let ElementKind::Image { file_id } = &image.kind else { + panic!("image kind"); + }; + assert_eq!(file_id, "file-abc"); + + let frame = board.element("frame-0001").expect("frame present"); + let ElementKind::Frame { name } = &frame.kind else { + panic!("frame kind"); + }; + assert_eq!(name, "Board 1"); + + let rect = board.element("rect-0001").expect("rect present"); + assert_eq!(rect.group_ids, ["grp-1"]); + assert_eq!(rect.bound_elements.len(), 1); + assert_eq!(rect.bound_elements[0].id, "arrow-0001"); + assert_eq!(rect.bound_elements[0].type_name, "arrow"); + assert_eq!(rect.link.as_deref(), Some("note://diagram")); + assert_eq!(rect.stroke_width, 2.0); +} + +#[test] +fn bound_arrow_keeps_bindings() { + let board = Whiteboard::from_str(BOUND_ARROW).expect("fixture parses"); + let arrow = board.element("link").expect("arrow present"); + let ElementKind::Arrow { start_binding, end_binding, .. } = &arrow.kind else { + panic!("arrow kind"); + }; + let start = start_binding.as_ref().expect("start binding"); + let end = end_binding.as_ref().expect("end binding"); + assert_eq!(start.element_id, "src"); + assert_eq!(start.gap, Some(4.0)); + assert_eq!(end.element_id, "dst"); + assert_eq!(end.focus, Some(-0.2)); + + let json = board.to_string_pretty().expect("serializes"); + assert!(json.contains("\"elementId\""), "binding keys must stay camelCase:\n{json}"); + assert!(json.contains("\"boundElements\""), "bound elements must survive:\n{json}"); +} + +#[test] +fn text_in_container_is_parsed() { + let board = Whiteboard::from_str(TEXT_CONTAINER).expect("fixture parses"); + let caption = board.element("caption-1").expect("text present"); + let ElementKind::Text { container_id, text_align, vertical_align, .. } = &caption.kind else { + panic!("text kind"); + }; + assert_eq!(container_id.as_deref(), Some("container-1")); + assert_eq!(text_align, "center"); + assert_eq!(vertical_align, "middle"); + assert_eq!(caption.bound_elements.len(), 0); +} + +/// `files` is kept as an opaque JSON value, image elements keep their `fileId`. +#[test] +fn files_are_kept_verbatim() { + let board = Whiteboard::from_str(IMAGE_FILES).expect("fixture parses"); + assert_eq!(board.files.get("6a1b2c3d4e5f60718293a4b5c6d7e8f9").and_then(|f| f.get("mimeType")).and_then(serde_json::Value::as_str), Some("image/png")); + let json = board.to_string_pretty().expect("serializes"); + assert!(json.contains("dataURL"), "file payload must survive:\n{json}"); + assert!(json.contains("\"fileId\""), "images keep fileId:\n{json}"); +} + +#[test] +fn missing_optional_fields_get_defaults() { + let board = Whiteboard::from_str(MINIMAL).expect("fixture parses"); + let rect = board.element("bare-rect").expect("element present"); + assert_eq!(rect.x, 0.0); + assert_eq!(rect.width, 0.0); + assert_eq!(rect.roughness, 1); + assert!(!rect.is_deleted); + assert_eq!(rect.group_ids, Vec::::new()); + assert!(board.app_state.as_object().is_some_and(serde_json::Map::is_empty)); + assert!(board.files.as_object().is_some_and(serde_json::Map::is_empty)); + assert!(board.extra.is_empty()); + let json = board.to_string_pretty().expect("serializes"); + assert!(json.contains("\"appState\""), "appState is always emitted:\n{json}"); + assert!(json.contains("\"files\""), "files is always emitted:\n{json}"); +} + +/// Element JSON also goes through plain serde, not only the whiteboard wrapper. +#[test] +fn element_json_is_camel_case() { + let source = r##"{"id":"x","type":"rectangle","isDeleted":true,"strokeColor":"#fff","versionNonce":7,"boundElements":[]}"##; + let element: Element = serde_json::from_str(source).expect("parses"); + let back = serde_json::to_value(&element).expect("serializes"); + let object = back.as_object().expect("object"); + for key in [ + "id", + "type", + "isDeleted", + "strokeColor", + "versionNonce", + "boundElements", + "x", + "y", + "width", + "height", + "angle", + "opacity", + "seed", + "roughness", + "updated", + "fillStyle", + "strokeWidth", + "strokeStyle", + "groupIds", + "locked", + "link", + "version", + "backgroundColor", + ] { + assert!(object.contains_key(key), "missing canonical key `{key}`"); + } + assert_eq!(object.get("isDeleted").and_then(serde_json::Value::as_bool), Some(true)); +} diff --git a/crates/whiteboard/tests/shapes.rs b/crates/whiteboard/tests/shapes.rs new file mode 100644 index 0000000..f4a7d5e --- /dev/null +++ b/crates/whiteboard/tests/shapes.rs @@ -0,0 +1,178 @@ +//! Determinism and geometry robustness of the hand-drawn shape generator. + +use notescraft_whiteboard::rough::{Polyline, Style}; +use notescraft_whiteboard::{Element, ElementKind, IdGen, Whiteboard, rough}; + +fn bits(lines: &[Polyline]) -> Vec { + lines.iter().flat_map(|l| l.iter().flat_map(|p| [p[0].to_bits(), p[1].to_bits()])).collect() +} + +fn assert_finite(label: &str, lines: &[Polyline]) { + for line in lines { + for p in line { + assert!(p[0].is_finite() && p[1].is_finite(), "{label}: non-finite point {p:?}"); + } + } +} + +#[test] +fn same_seed_gives_identical_output() { + let mut ids = IdGen::new(); + let a = Element::rectangle(&mut ids, 10.0, 20.0, 100.0, 60.0); + let mut ids = IdGen::new(); + let b = Element::rectangle(&mut ids, 10.0, 20.0, 100.0, 60.0); + assert_eq!(a, b, "builders must be deterministic for the same generator position"); + + for style in [Style::HandDrawn, Style::Clean] { + let first = rough::outline(&a, style); + let second = rough::outline(&b, style); + assert_eq!(bits(&first), bits(&second), "{style:?} output must be bit-identical"); + let again = rough::outline(&a, style); + assert_eq!(bits(&first), bits(&again), "{style:?} output must be repeatable"); + } +} + +#[test] +fn different_seed_changes_hand_drawn_but_not_clean() { + let mut ids = IdGen::new(); + let a = Element::rectangle(&mut ids, 0.0, 0.0, 100.0, 60.0); + let mut b = a.clone(); + b.seed = a.seed.wrapping_add(1); + assert_ne!(bits(&rough::outline(&a, Style::HandDrawn)), bits(&rough::outline(&b, Style::HandDrawn))); + assert_eq!(bits(&rough::outline(&a, Style::Clean)), bits(&rough::outline(&b, Style::Clean))); +} + +#[test] +fn hand_drawn_is_a_double_stroke_and_perturbs() { + let mut ids = IdGen::new(); + let rect = Element::rectangle(&mut ids, 0.0, 0.0, 100.0, 60.0); + let hand = rough::outline(&rect, Style::HandDrawn); + assert_eq!(hand.len(), 2, "hand drawn shapes are stroked twice"); + assert_ne!(bits(std::slice::from_ref(&hand[0])), bits(std::slice::from_ref(&hand[1])), "the two strokes must differ"); + + let clean = rough::outline(&rect, Style::Clean); + assert_eq!(clean.len(), 1, "clean shapes are a single polyline"); + let corners = clean.first().expect("one polyline"); + assert_eq!(corners.len(), 5, "closed rectangle: 4 corners plus the closing point"); + assert_eq!(corners.first(), corners.last()); + assert_eq!(corners[0], [0.0, 0.0]); + assert_eq!(corners[2], [100.0, 60.0]); + + let nudged: Polyline = hand[0].clone(); + assert!(nudged.iter().any(|p| *p != [0.0, 0.0]), "hand drawn geometry must be perturbed"); +} + +#[test] +fn clean_geometry_is_exact_for_every_supported_shape() { + let mut ids = IdGen::new(); + + let diamond = Element::diamond(&mut ids, 0.0, 0.0, 100.0, 60.0); + let lines = rough::outline(&diamond, Style::Clean); + assert_eq!(lines[0][0], [50.0, 0.0]); + assert_eq!(lines[0][1], [100.0, 30.0]); + assert_eq!(lines[0][2], [50.0, 60.0]); + assert_eq!(lines[0][3], [0.0, 30.0]); + + let ellipse = Element::ellipse(&mut ids, 0.0, 0.0, 100.0, 60.0); + let lines = rough::outline(&ellipse, Style::Clean); + let ring = lines.first().expect("one polyline"); + assert!(ring.len() >= 32, "ellipse needs enough segments"); + assert_eq!(ring.first(), ring.last(), "ellipse ring is closed"); + + let line = Element::line(&mut ids, 5.0, 7.0, 30.0, 40.0); + let lines = rough::outline(&line, Style::Clean); + assert_eq!(lines[0][0], [5.0, 7.0]); + assert_eq!(lines[0][1], [35.0, 47.0]); + + let arrow = Element::arrow(&mut ids, 0.0, 0.0, 100.0, 0.0); + let lines = rough::outline(&arrow, Style::Clean); + assert_eq!(lines[0].len(), 2, "arrow shaft"); + assert_eq!(lines.len(), 3, "shaft plus two arrowhead legs"); + assert_finite("arrow", &lines); +} + +#[test] +fn hand_drawn_lines_overshoot_both_ends() { + let mut ids = IdGen::new(); + let line = Element::line(&mut ids, 0.0, 0.0, 100.0, 0.0); + let clean = rough::outline(&line, Style::Clean); + let hand = rough::outline(&line, Style::HandDrawn); + assert_eq!(clean[0].len(), 2, "clean strokes keep the exact path"); + for stroke in &hand { + assert_eq!(stroke.len(), 4, "two original points plus two overshoots"); + for (drawn, exact) in stroke[1..3].iter().zip(&clean[0]) { + // roughness 1 jitters by at most 1.2 px per axis + assert!((drawn[0] - exact[0]).abs() <= 1.25 && (drawn[1] - exact[1]).abs() <= 1.25, "{drawn:?} drifted from {exact:?}"); + } + } +} + +#[test] +fn unsupported_kinds_have_no_outline() { + let mut ids = IdGen::new(); + let image: Element = serde_json::from_str(r#"{"id":"i","type":"image","fileId":"f"}"#).expect("parses"); + for element in [Element::text(&mut ids, 0.0, 0.0, "hi"), image] { + assert_eq!(rough::outline(&element, Style::Clean), Vec::::new()); + assert_eq!(rough::outline(&element, Style::HandDrawn), Vec::::new()); + } +} + +#[test] +fn degenerate_geometry_never_produces_nan_or_infinity() { + let mut ids = IdGen::new(); + let mut elements = vec![ + Element::rectangle(&mut ids, 0.0, 0.0, 0.0, 0.0), + Element::ellipse(&mut ids, 0.0, 0.0, 0.0, 1e30), + Element::diamond(&mut ids, f64::NAN, f64::INFINITY, -50.0, 1e9), + Element::line(&mut ids, 1e300, 1e300, 0.0, 0.0), + Element::arrow(&mut ids, 0.0, 0.0, 0.0, 0.0), + ]; + elements[4].seed = u64::MAX; + if let ElementKind::Arrow { points, .. } = &mut elements[4].kind { + points.clear(); + } + for element in &elements { + for style in [Style::Clean, Style::HandDrawn] { + let lines = rough::outline(element, style); + assert_finite("degenerate", &lines); + for line in &lines { + for p in line { + assert!(p[0].abs() <= 1.0e6 && p[1].abs() <= 1.0e6, "coordinates must stay clamped: {p:?}"); + } + } + } + } +} + +#[test] +fn roughness_zero_falls_back_to_clean_geometry() { + let mut ids = IdGen::new(); + let mut rect = Element::rectangle(&mut ids, 0.0, 0.0, 100.0, 60.0); + rect.roughness = 0; + assert_eq!(bits(&rough::outline(&rect, Style::HandDrawn)), bits(&rough::outline(&rect, Style::Clean))); +} + +#[test] +fn board_survives_a_geometry_free_editing_session() { + let mut ids = IdGen::new(); + let mut board = Whiteboard::default(); + let rect = Element::rectangle(&mut ids, 0.0, 0.0, 100.0, 50.0); + board.add(rect.clone()); + board.add(Element::arrow(&mut ids, 100.0, 25.0, 60.0, 0.0)); + board.add(Element::text(&mut ids, 10.0, 80.0, "hello")); + assert_eq!(board.elements.len(), 3); + assert!(board.delete(&rect.id).is_some()); + assert!(board.delete("missing").is_none()); + assert_eq!(board.elements.len(), 3, "delete keeps the element"); + assert!(board.elements.iter().all(|e| e.id != rect.id || e.is_deleted)); + + // the arrow sits at (100, 25)-(160, 25), the text below at (10, 80)-(70, 105) + let box_ = board.bounding_box().expect("visible elements"); + assert_eq!(box_.x, 10.0); + assert_eq!(box_.y, 25.0); + assert_eq!(box_.width, 150.0); + assert_eq!(box_.height, 80.0); + + let json = board.to_string_pretty().expect("serializes"); + assert_eq!(Whiteboard::from_str(&json).expect("re-parses"), board); +}