Merge branch 'feat/whiteboard' into develop

This commit is contained in:
Marcos Paulo
2026-10-13 19:11:18 -03:00
21 changed files with 3056 additions and 5 deletions
Generated
+6
View File
@@ -237,6 +237,11 @@ dependencies = [
[[package]]
name = "notescraft-whiteboard"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"thiserror",
]
[[package]]
name = "notify"
@@ -323,6 +328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
+5
View File
@@ -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
+154
View File
@@ -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<Element>,
/// 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<Self> {
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<Self> {
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::<Result<Vec<_>>>()?
}
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<String> {
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<Bounds> {
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> = Whiteboard::from_str(&padded);
assert!(matches!(err, Err(Error::TooLarge { .. })));
}
}
+106
View File
@@ -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)
}
}
+296
View File
@@ -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<String, Value>,
}
impl Fields {
/// Wraps a JSON object, rejecting any other JSON shape.
pub(crate) fn new(value: Value) -> Result<Self> {
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<Value> {
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<T: FromNumber>(&mut self, key: &str, default: T) -> Result<T> {
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<Option<f64>> {
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<String> {
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<String> {
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<Option<String>> {
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<bool> {
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<Option<Vec<Value>>> {
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<Vec<String>> {
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<Vec<f64>> {
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<Vec<Value>> {
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<Vec<[f64; 2]>> {
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<Option<Value>> {
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<Self>;
}
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<Self> {
value.as_f64().ok_or_else(|| Error::InvalidField { field: key.to_owned() })
}
}
impl FromNumber for u64 {
fn from_number(value: Value, key: &str) -> Result<Self> {
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<Self> {
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<u64> {
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 { .. })));
}
}
+36
View File
@@ -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<T> = std::result::Result<T, Error>;
+81
View File
@@ -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<Bounds> {
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]]);
}
}
+103
View File
@@ -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<char> = 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<u64> = (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);
}
}
+37 -5
View File
@@ -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 {
+320
View File
@@ -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<String, Value>;
/// 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<String>,
/// Elements bound to this one (arrows, bound text).
pub bound_elements: Vec<BoundElement>,
/// Link target of the element.
pub link: Option<String>,
/// 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<Binding>,
/// Element the arrow ends on.
end_binding: Option<Binding>,
/// Arrowhead at the start, `"arrow"` or `None`.
start_arrowhead: Option<String>,
/// Arrowhead at the end, `"arrow"` or `None`.
end_arrowhead: Option<String>,
},
/// 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<f64>,
},
/// 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<String>,
},
/// 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<f64>,
/// Gap between the arrow tip and the element, in pixels.
pub gap: Option<f64>,
/// 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 });
}
}
+392
View File
@@ -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<Polyline> {
let Some((local, closed)) = local_path(element) else {
return Vec::new();
};
let hand_drawn = style == Style::HandDrawn && element.roughness > 0;
let mut strokes: Vec<Polyline> = 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<Polyline> {
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<Polyline> {
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::<f32>().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<u32> {
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::<Polyline>::new());
assert_eq!(outline(&arrow, Style::HandDrawn), Vec::<Polyline>::new());
assert_eq!(bits(&outline(&arrow, Style::Clean)), Vec::<u32>::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");
}
}
}
+252
View File
@@ -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<Self> {
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::<Result<Vec<_>>>()?,
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<Self> {
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<M: SerializeMap>(&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<Self> {
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<Option<Self>> {
match fields.take_object(key)? {
Some(value) => Self::from_value(value).map(Some),
None => Ok(None),
}
}
}
impl Serialize for Binding {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
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<Self> {
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<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
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<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
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<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
Element::from_value(Value::deserialize(deserializer)?).map_err(serde::de::Error::custom)
}
}
impl Serialize for Whiteboard {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
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<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
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);
}
}
+146
View File
@@ -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::<Vec<_>>().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<Element> = 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);
}
@@ -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": {}
}
@@ -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": {}
}
@@ -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
}
}
}
@@ -0,0 +1,7 @@
{
"type": "excalidraw",
"version": 2,
"elements": [
{ "id": "bare-rect", "type": "rectangle" }
]
}
@@ -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": {}
}
@@ -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 } }
}
+227
View File
@@ -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::<Vec<_>>(),
["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::<String>::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));
}
+178
View File
@@ -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<u32> {
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::<Polyline>::new());
assert_eq!(rough::outline(&element, Style::HandDrawn), Vec::<Polyline>::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);
}