Merge branch 'feat/whiteboard' into develop
This commit is contained in:
Generated
+6
@@ -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]]
|
||||
|
||||
@@ -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
|
||||
@@ -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 { .. })));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 { .. })));
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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]]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user