Something went wrong. Try again.
inkfinite (an infinite canvas/whiteboard) monorepo ink.stormlightlabs.org
svelte rust tauri svg canvas perfect-freehand
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299use super::{BTreeSet, ContainerLayout, Document, EngineError, ShapeId, ShapeParent, ShapeRecord};
/// Validates normalized document ownership, references, geometry, and layout.////// # Errors////// Returns [`EngineError::Invariant`] or [`EngineError::Schema`] with the first/// invalid ownership, reference, geometry, or layout condition.pub fn validate_document(document: &Document) -> Result<(), EngineError> { let _span = tracing::info_span!( "document.validate", pages = document.pages.len(), layers = document.layers.len(), shapes = document.shapes.len(), bindings = document.bindings.len(), ) .entered(); if document.pages.is_empty() || document.page_ids.is_empty() { return Err(EngineError::Invariant("document must contain at least one page".into())); } ensure_unique_and_complete(&document.page_ids, document.pages.keys().cloned(), "page")?; let mut listed_layers = BTreeSet::new(); for page in document.pages.values() { if page.name.trim().is_empty() || page.layer_ids.is_empty() { return Err(EngineError::Invariant(format!( "page {} needs a name and at least one layer", page.id ))); } for layer_id in &page.layer_ids { if !listed_layers.insert(layer_id.clone()) { return Err(EngineError::Invariant(format!( "layer {layer_id} is listed more than once" ))); } let layer = document.layers.get(layer_id).ok_or_else(|| { EngineError::Invariant(format!("page {} refers to missing layer {layer_id}", page.id)) })?; if layer.page_id != page.id { return Err(EngineError::Invariant(format!( "layer {layer_id} has inconsistent page ownership" ))); } } } if listed_layers.len() != document.layers.len() { return Err(EngineError::Invariant("one or more layers are unlisted".into())); } let mut listed_shapes = BTreeSet::new(); for layer in document.layers.values() { if layer.name.trim().is_empty() { return Err(EngineError::Invariant(format!("layer {} has an empty name", layer.id))); } for shape_id in &layer.shape_ids { validate_child( document, &mut listed_shapes, shape_id, &ShapeParent::Layer(layer.id.clone()), )?; } } for shape in document.shapes.values() { validate_shape_schema(shape)?; if shape.kind.as_str() == crate::IMAGE_KIND { let asset_id = shape .properties .get("assetId") .or_else(|| shape.properties.get("asset_id")) .and_then(|value| value.as_str()) .ok_or_else(|| EngineError::Schema(format!("image shape {} has no asset ID", shape.id)))?; if !document.assets.contains_key(&crate::AssetId::from(asset_id)) { return Err(EngineError::Invariant(format!( "image shape {} references missing asset {}", shape.id, asset_id ))); } } if shape.kind.as_str() == crate::REFERENCE_KIND && let Ok(reference) = crate::reference_properties_from_properties(&shape.properties) && matches!(reference.reference_type, crate::ReferenceKind::Page) && !document .pages .contains_key(&crate::PageId::from(reference.value.clone())) { return Err(EngineError::Schema(format!( "reference shape {} points to missing page {}", shape.id, reference.value ))); } for child_id in &shape.child_ids { validate_child( document, &mut listed_shapes, child_id, &ShapeParent::Shape(shape.id.clone()), )?; } ensure_acyclic(document, &shape.id)?; } if listed_shapes.len() != document.shapes.len() { return Err(EngineError::Invariant("one or more shapes are unlisted".into())); } for binding in document.bindings.values() { ensure_relationship_reference(document, binding)?; ensure_binding_endpoints(document, binding)?; } Ok(())}
pub fn validate_shape_schema(shape: &ShapeRecord) -> Result<(), EngineError> { crate::validate_shape_properties(shape.kind.as_str(), &shape.properties) .map_err(|error| EngineError::Schema(format!("shape {}: {error}", shape.id)))?; if shape.kind.as_str() != crate::CONTAINER_KIND && (!shape.child_ids.is_empty() || shape.layout.is_some()) { return Err(EngineError::Schema(format!( "non-container shape {} owns children or layout", shape.id ))); } let transform = shape.transform; if ![ transform.translation.x, transform.translation.y, transform.rotation, transform.scale_x, transform.scale_y, ] .into_iter() .all(f64::is_finite) || transform.scale_x == 0.0 || transform.scale_y == 0.0 { return Err(EngineError::Schema(format!( "shape {} has an invalid transform", shape.id ))); } if let Some(layout) = &shape.layout { match layout { ContainerLayout::Free => {} ContainerLayout::Stack { gap, padding, .. } => { validate_layout_numbers(shape, *gap, padding)?; } ContainerLayout::Grid { columns, column_gap, row_gap, padding, .. } => { if *columns == 0 { return Err(EngineError::Schema(format!("shape {} grid has no columns", shape.id))); } validate_layout_numbers(shape, *column_gap, padding)?; if !row_gap.is_finite() || *row_gap < 0.0 { return Err(EngineError::Schema(format!("shape {} has invalid row gap", shape.id))); } } } } Ok(())}
pub fn validate_layout_numbers(shape: &ShapeRecord, gap: f64, padding: &crate::Insets) -> Result<(), EngineError> { if ![gap, padding.top, padding.right, padding.bottom, padding.left] .into_iter() .all(|value| value.is_finite() && value >= 0.0) { return Err(EngineError::Schema(format!( "shape {} has invalid layout spacing", shape.id ))); } Ok(())}
/// Repairs merge-created hierarchy damage using stable IDs and sorted order.////// # Errors////// Returns an error when the document has no page or when deterministic repair/// cannot produce a valid normalized document.#[allow(clippy::too_many_lines)]pub fn ensure_unique_and_complete<'a, I>(listed: &[I::Item], keys: I, name: &str) -> Result<(), EngineError>where I: Iterator, I::Item: Ord + Clone + std::fmt::Display + 'a,{ let listed_set: BTreeSet<_> = listed.iter().cloned().collect(); if listed_set.len() != listed.len() { return Err(EngineError::Invariant(format!("duplicate {name} ordering entry"))); } let keys_set: BTreeSet<_> = keys.collect(); if listed_set != keys_set { return Err(EngineError::Invariant(format!( "{name} ordering does not match records" ))); } Ok(())}
pub fn validate_child( document: &Document, seen: &mut BTreeSet<ShapeId>, child_id: &ShapeId, expected_parent: &ShapeParent,) -> Result<(), EngineError> { if !seen.insert(child_id.clone()) { return Err(EngineError::Invariant(format!( "shape {child_id} is listed more than once" ))); } let child = document .shapes .get(child_id) .ok_or_else(|| EngineError::Invariant(format!("missing child shape {child_id}")))?; if &child.parent != expected_parent { return Err(EngineError::Invariant(format!( "shape {child_id} has inconsistent parent" ))); } Ok(())}
pub fn ensure_acyclic(document: &Document, start: &ShapeId) -> Result<(), EngineError> { let mut seen = BTreeSet::new(); let mut current = start.clone(); while let Some(shape) = document.shapes.get(¤t) { if !seen.insert(current.clone()) { return Err(EngineError::Invariant(format!( "shape hierarchy contains a cycle at {current}" ))); } match &shape.parent { ShapeParent::Shape(parent) => current = parent.clone(), ShapeParent::Layer(_) => return Ok(()), } } Ok(())}
/// Validates the optional semantic relationship attached to a binding.////// Relationship references use the same stable shape IDs as visual bindings,/// but their type is checked independently so a semantic connection does not/// rely on routing fields or coordinates.////// # Errors////// Returns an error when a relationship type is empty or one of its shape/// references is missing.pub fn ensure_relationship_reference(document: &Document, binding: &crate::BindingRecord) -> Result<(), EngineError> { if binding.relation_type.is_none() && binding.kind.as_str() != "relation" { return Ok(()); } let relation_type = binding.relation_type.as_deref(); if relation_type.is_some_and(|value| value.trim().is_empty()) { return Err(EngineError::Schema(format!( "binding {} has an empty relationship type", binding.id ))); } if !document.shapes.contains_key(&binding.source_shape_id) { return Err(EngineError::Invariant(format!( "relationship {} references missing source shape {}", binding.id, binding.source_shape_id ))); } if !document.shapes.contains_key(&binding.target_shape_id) { return Err(EngineError::Invariant(format!( "relationship {} references missing target shape {}", binding.id, binding.target_shape_id ))); } Ok(())}
/// Validates the shape references used by visual binding routing.pub fn ensure_binding_endpoints(document: &Document, binding: &crate::BindingRecord) -> Result<(), EngineError> { if !document.shapes.contains_key(&binding.source_shape_id) || !document.shapes.contains_key(&binding.target_shape_id) { return Err(EngineError::Invariant(format!( "binding {} has a missing endpoint", binding.id ))); } Ok(())}
#[cfg(test)]mod tests { use super::*; use crate::PageId;
#[test] fn a_page_without_a_layer_is_invalid() { let mut document = crate::engine::tests::document(); document .pages .get_mut(&PageId::from("page:one")) .unwrap() .layer_ids .clear(); assert!(matches!(validate_document(&document), Err(EngineError::Invariant(_)))); }}