Skip to main content

appcore_filemaker/
source_layout.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: source_layout.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/30 05:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/30 05:00:00 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Defines bounded source layout contracts and behavior for this crate.
12
13use std::collections::{BTreeMap, BTreeSet};
14
15use serde::{Deserialize, Serialize};
16
17use crate::{
18    Distribution, ElementSource, ErrorCode, FileMakerError, LayoutMode, Length, Result, Unit,
19};
20
21/// Named non-painted geometry inserted into every page collision index.
22#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct ExclusionSource {
25    /// Page-relative horizontal coordinate.
26    pub x: Length,
27    /// Page-relative vertical coordinate.
28    pub y: Length,
29    /// Page-relative width.
30    pub width: Length,
31    /// Page-relative height.
32    pub height: Length,
33    /// Collision group exposed by the exclusion.
34    #[serde(default = "default_exclusion_group")]
35    pub group: String,
36    /// Candidate groups blocked by this exclusion; empty means every group.
37    #[serde(default)]
38    pub collides_with: Vec<String>,
39}
40
41pub(crate) fn validate_exclusions(
42    exclusions: &BTreeMap<String, ExclusionSource>,
43    max_exclusions: usize,
44) -> Result<()> {
45    if exclusions.len() > max_exclusions {
46        return Err(limit_error(
47            "exclusion count exceeds configured element limit",
48        ));
49    }
50    for (name, exclusion) in exclusions {
51        validate_name("exclusion", name, 118)?;
52        validate_name("exclusion group", &exclusion.group, 128)?;
53        if exclusion.collides_with.len() > 64 {
54            return Err(limit_error("exclusion collision-group list exceeds 64"));
55        }
56        for group in &exclusion.collides_with {
57            validate_name("exclusion collision group", group, 128)?;
58        }
59        if [exclusion.x, exclusion.y, exclusion.width, exclusion.height].contains(&Length::Auto) {
60            return Err(schema_error("exclusion geometry cannot be auto"));
61        }
62    }
63    Ok(())
64}
65
66pub(crate) fn convert_exclusions(
67    exclusions: &BTreeMap<String, ExclusionSource>,
68) -> BTreeMap<String, crate::ExclusionIr> {
69    exclusions
70        .iter()
71        .map(|(name, source)| {
72            (
73                name.clone(),
74                crate::ExclusionIr {
75                    x: source.x,
76                    y: source.y,
77                    width: source.width,
78                    height: source.height,
79                    group: source.group.clone(),
80                    collides_with: source
81                        .collides_with
82                        .iter()
83                        .cloned()
84                        .collect::<BTreeSet<_>>(),
85                },
86            )
87        })
88        .collect()
89}
90
91fn default_exclusion_group() -> String {
92    "exclusion".to_owned()
93}
94
95fn validate_name(label: &str, value: &str, max_bytes: usize) -> Result<()> {
96    if value.is_empty()
97        || value.len() > max_bytes
98        || !value
99            .bytes()
100            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'))
101    {
102        return Err(schema_error(format!("{label} name is invalid")));
103    }
104    Ok(())
105}
106
107fn schema_error(message: impl Into<String>) -> FileMakerError {
108    FileMakerError::new(ErrorCode::SchemaField, message)
109}
110
111fn limit_error(message: impl Into<String>) -> FileMakerError {
112    FileMakerError::new(ErrorCode::LimitExceeded, message)
113}
114
115pub(crate) fn default_gap() -> Length {
116    Length::Absolute(Unit::ZERO)
117}
118
119pub(crate) fn validate_layout_source(element: &ElementSource) -> Result<()> {
120    element.constraints.validate().map_err(|error| {
121        FileMakerError::new(ErrorCode::SchemaField, error.message()).at(element.id.clone())
122    })?;
123    if element.align_x.is_some() && element.x.is_some()
124        || element.align_y.is_some() && element.y.is_some()
125    {
126        return Err(FileMakerError::new(
127            ErrorCode::SchemaField,
128            "aligned axes cannot also declare an explicit coordinate",
129        )
130        .at(element.id.clone()));
131    }
132    if element.distribute != Distribution::Start && element.layout == LayoutMode::Absolute {
133        return Err(FileMakerError::new(
134            ErrorCode::SchemaField,
135            "distribution requires flow_vertical or flow_horizontal layout",
136        )
137        .at(element.id.clone()));
138    }
139    Ok(())
140}