Skip to main content

appcore_filemaker/
ir.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: ir.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 ir contracts and behavior for this crate.
12
13use std::collections::{BTreeMap, BTreeSet};
14
15use serde::{Deserialize, Serialize};
16
17use crate::source::ModelKind;
18use crate::{
19    CollisionPolicy, DataSchema, ErrorCode, FileMakerError, ImageOptions, Length, Orientation,
20    PageTemplate, Result, Size, Style,
21};
22
23/// Validated stable element identifier.
24#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Hash, Serialize)]
25#[serde(transparent)]
26pub struct ElementId(String);
27
28impl ElementId {
29    /// Validates and creates an identifier.
30    pub fn new(value: impl Into<String>) -> Result<Self> {
31        let value = value.into();
32        if value.is_empty()
33            || value.len() > 128
34            || !value.bytes().all(|byte| {
35                byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b'/')
36            })
37        {
38            return Err(FileMakerError::new(
39                ErrorCode::SchemaField,
40                "element ID must be 1..128 safe ASCII characters",
41            ));
42        }
43        Ok(Self(value))
44    }
45
46    /// Returns the validated ID.
47    #[must_use]
48    pub fn as_str(&self) -> &str {
49        &self.0
50    }
51}
52
53impl<'de> Deserialize<'de> for ElementId {
54    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
55    where
56        D: serde::Deserializer<'de>,
57    {
58        let value = String::deserialize(deserializer)?;
59        Self::new(value).map_err(serde::de::Error::custom)
60    }
61}
62
63/// Format-neutral element kind.
64#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum ElementKind {
67    /// Text.
68    Text,
69    /// Image.
70    Image,
71    /// Line.
72    Line,
73    /// Rectangle.
74    Rect,
75    /// Circle.
76    Circle,
77    /// Ellipse.
78    Ellipse,
79    /// Polygon.
80    Polygon,
81    /// Vector path.
82    Path,
83    /// Group.
84    Group,
85    /// First-class table.
86    Table,
87    /// Reserved chart node.
88    Chart,
89    /// Reserved QR node.
90    Qr,
91    /// Reserved barcode node.
92    Barcode,
93}
94
95impl ElementKind {
96    /// Parses the exact schema spelling.
97    pub fn parse(value: &str) -> Result<Self> {
98        match value {
99            "text" => Ok(Self::Text),
100            "image" => Ok(Self::Image),
101            "line" => Ok(Self::Line),
102            "rect" => Ok(Self::Rect),
103            "circle" => Ok(Self::Circle),
104            "ellipse" => Ok(Self::Ellipse),
105            "polygon" => Ok(Self::Polygon),
106            "path" => Ok(Self::Path),
107            "group" => Ok(Self::Group),
108            "table" => Ok(Self::Table),
109            "chart" => Ok(Self::Chart),
110            "qr" => Ok(Self::Qr),
111            "barcode" => Ok(Self::Barcode),
112            _ => Err(FileMakerError::new(
113                ErrorCode::SchemaField,
114                format!("unsupported element type `{value}`"),
115            )),
116        }
117    }
118}
119
120/// Source-independent geometry intent retained before measurement.
121#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
122pub struct GeometryIr {
123    /// Optional horizontal position.
124    pub x: Option<Length>,
125    /// Optional vertical position.
126    pub y: Option<Length>,
127    /// Optional/automatic width.
128    pub width: Option<Length>,
129    /// Optional/automatic height.
130    pub height: Option<Length>,
131    /// Minimum, preferred, maximum, and aspect-ratio size intent.
132    pub constraints: crate::LayoutConstraints,
133    /// Optional horizontal alignment inside the resolved container.
134    pub align_x: Option<crate::Alignment>,
135    /// Optional vertical alignment inside the resolved container.
136    pub align_y: Option<crate::Alignment>,
137    /// Named containing region.
138    pub region: Option<String>,
139    /// Named anchor expressions.
140    pub anchors: BTreeMap<String, String>,
141}
142
143/// Source-independent transform intent retained until page geometry is known.
144#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
145pub struct TransformIr {
146    /// Horizontal translation.
147    pub translate_x: Length,
148    /// Vertical translation.
149    pub translate_y: Length,
150    /// Clockwise integer-degree rotation.
151    pub rotate: i32,
152    /// Horizontal fixed-point scale.
153    pub scale_x: i64,
154    /// Vertical fixed-point scale.
155    pub scale_y: i64,
156    /// Horizontal transform origin.
157    pub origin_x: Length,
158    /// Vertical transform origin.
159    pub origin_y: Length,
160}
161
162/// Format-neutral text layout intent retained until font measurement.
163#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
164pub struct TextIr {
165    /// Overflow behavior.
166    pub overflow: crate::TextOverflow,
167    /// Optional maximum line count.
168    pub max_lines: Option<usize>,
169    /// Optional explicit minimum font size.
170    pub min_font_size: Option<Length>,
171    /// Line-height multiplier in millionths.
172    pub line_height: u32,
173    /// Horizontal lines or top-to-bottom right-to-left vertical columns.
174    pub writing_mode: crate::WritingMode,
175}
176
177/// Bound table intent retained until row/column measurement and pagination.
178#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
179pub struct TableIr {
180    /// Validated planning contract.
181    pub spec: crate::TableSpec,
182    /// Header height in source-relative units.
183    pub header_height: Length,
184    /// Fixed row height or auto measurement.
185    pub row_height: Option<Length>,
186    /// Bound bounded rows; empty before data binding.
187    pub rows: Vec<crate::DataRow>,
188}
189
190/// Source-relative vector path command retained until layout resolves its units.
191#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
192#[serde(tag = "command", rename_all = "snake_case")]
193pub enum PathCommandIr {
194    /// Starts a contour.
195    Move { x: Length, y: Length },
196    /// Adds a straight segment.
197    Line { x: Length, y: Length },
198    /// Adds a cubic Bézier segment.
199    Curve {
200        x1: Length,
201        y1: Length,
202        x2: Length,
203        y2: Length,
204        x: Length,
205        y: Length,
206    },
207    /// Closes the current contour.
208    Close,
209}
210
211/// Named region retained in source-relative units until page layout.
212#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
213pub struct RegionIr {
214    /// Horizontal position.
215    pub x: Length,
216    /// Vertical position.
217    pub y: Length,
218    /// Width.
219    pub width: Length,
220    /// Height.
221    pub height: Length,
222    /// Region collision policy inherited after document and page policies.
223    pub collision: Option<crate::CollisionPolicy>,
224}
225
226/// Page-relative non-painted geometry retained until layout.
227#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
228pub struct ExclusionIr {
229    /// Horizontal coordinate.
230    pub x: Length,
231    /// Vertical coordinate.
232    pub y: Length,
233    /// Width.
234    pub width: Length,
235    /// Height.
236    pub height: Length,
237    /// Collision group exposed by the exclusion.
238    pub group: String,
239    /// Candidate groups blocked by this exclusion; empty means every group.
240    pub collides_with: BTreeSet<String>,
241}
242
243/// Layout strategy for a node and its children.
244#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
245#[serde(rename_all = "snake_case")]
246pub enum LayoutMode {
247    /// Coordinates are resolved independently.
248    #[default]
249    Absolute,
250    /// Children flow from top to bottom.
251    FlowVertical,
252    /// Children flow from left to right.
253    FlowHorizontal,
254}
255
256/// Provenance retained through binding and layout.
257#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
258pub struct Provenance {
259    /// Logical source path.
260    pub source: String,
261    /// Component expansion chain.
262    pub components: Vec<String>,
263    /// Applied style names in cascade order.
264    pub styles: Vec<String>,
265    /// Runtime patch sequence numbers.
266    pub patches: Vec<u64>,
267}
268
269/// Author-supplied edit policy carried for optional external tool bridges.
270#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
271pub struct AiPolicy {
272    /// Compact document purpose; the deterministic core does not interpret it.
273    pub purpose: String,
274    /// Bounded textual rules; the deterministic core does not interpret them.
275    pub rules: Vec<String>,
276    /// IDs an external bridge may edit; empty delegates to bridge defaults.
277    pub editable: BTreeSet<String>,
278    /// IDs an external bridge must never edit.
279    pub locked: BTreeSet<String>,
280}
281
282/// Expanded format-neutral element.
283#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
284pub struct ElementIr {
285    /// Stable ID.
286    pub id: ElementId,
287    /// Element kind.
288    pub kind: ElementKind,
289    /// Geometry intent.
290    pub geometry: GeometryIr,
291    /// Transform intent resolved only after layout proposes a box.
292    pub transform: TransformIr,
293    /// Literal or bound text.
294    pub text: Option<String>,
295    /// Text measurement and overflow intent.
296    pub text_options: TextIr,
297    /// First-class table planning intent and bound rows.
298    pub table: Option<TableIr>,
299    /// Explicit asset reference.
300    pub asset: Option<String>,
301    /// Vector commands in element-local source coordinates.
302    pub path: Vec<PathCommandIr>,
303    /// Image crop and fit intent.
304    pub image: ImageOptions,
305    /// Typed inline/cascaded style.
306    pub style: Style,
307    /// Ordered conditional style layers evaluated during data binding.
308    #[serde(default)]
309    pub style_rules: Vec<crate::ElementStyleRule>,
310    /// Layout strategy.
311    pub layout: LayoutMode,
312    /// Distribution of children on a flow's primary axis.
313    pub distribute: crate::Distribution,
314    /// Gap between flow children.
315    pub gap: Length,
316    /// Optional policy overriding the inherited collision policy.
317    pub collision: Option<CollisionPolicy>,
318    /// Child elements in deterministic source order.
319    pub children: Vec<ElementIr>,
320    /// Immutable after compile unless a privileged caller builds a new IR.
321    pub locked: bool,
322    /// Visibility after binding rules.
323    pub hidden: bool,
324    /// Visual layer independent of collision.
325    pub layer: String,
326    /// Visual order within layer.
327    pub z_index: i32,
328    /// Data binding expression.
329    pub binding: Option<String>,
330    /// Conditional expression.
331    pub when: Option<String>,
332    /// Repeat expression.
333    pub repeat: Option<String>,
334    /// Provenance.
335    pub provenance: Provenance,
336    /// Root-only master/role page placement; children inherit their root.
337    pub page_placement: Option<crate::PagePlacement>,
338}
339
340/// Expanded reusable template IR.
341#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
342pub struct TemplateIr {
343    /// Template identity.
344    pub id: String,
345    /// Model.
346    pub model: ModelKind,
347    /// Optional page size.
348    pub page_size: Option<Size>,
349    /// Source orientation.
350    pub orientation: Orientation,
351    /// Resolved trim, margin, bleed, safe-area, and crop metadata.
352    pub page_template: Option<PageTemplate>,
353    /// Document-wide collision policy.
354    pub collision: Option<crate::CollisionPolicy>,
355    /// Page collision policy inherited after the document policy.
356    pub page_collision: Option<crate::CollisionPolicy>,
357    /// Named guides.
358    pub guides: BTreeMap<String, Length>,
359    /// Named regions.
360    pub regions: BTreeMap<String, RegionIr>,
361    /// Named page-relative exclusions.
362    pub exclusions: BTreeMap<String, ExclusionIr>,
363    /// Optional typed data contract.
364    pub data_schema: DataSchema,
365    /// Optional external-tool edit policy, retained but never executed by core.
366    pub ai_policy: AiPolicy,
367    /// Expanded root nodes.
368    pub elements: Vec<ElementIr>,
369}
370
371/// Bound instance ready for measurement and layout.
372#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
373pub struct DocumentIr {
374    /// Template identity.
375    pub template_id: String,
376    /// Model.
377    pub model: ModelKind,
378    /// Optional page size.
379    pub page_size: Option<Size>,
380    /// Resolved page metadata.
381    pub page_template: Option<PageTemplate>,
382    /// Bound document-wide collision policy.
383    pub collision: Option<crate::CollisionPolicy>,
384    /// Bound page collision policy inherited after the document policy.
385    pub page_collision: Option<crate::CollisionPolicy>,
386    /// Named guides.
387    pub guides: BTreeMap<String, Length>,
388    /// Named regions.
389    pub regions: BTreeMap<String, RegionIr>,
390    /// Named page-relative exclusions.
391    pub exclusions: BTreeMap<String, ExclusionIr>,
392    /// Optional external-tool edit policy, retained but never executed by core.
393    pub ai_policy: AiPolicy,
394    /// Bound root nodes.
395    pub elements: Vec<ElementIr>,
396}
397
398impl TemplateIr {
399    /// Verifies global ID uniqueness and a caller-supplied element bound.
400    pub fn validate(&self, max_elements: usize) -> Result<()> {
401        let mut ids = BTreeSet::new();
402        let mut count = self.exclusions.len();
403        if count > max_elements {
404            return Err(FileMakerError::new(
405                ErrorCode::LimitExceeded,
406                format!("element and exclusion count exceeds {max_elements}"),
407            ));
408        }
409        for (name, exclusion) in &self.exclusions {
410            validate_exclusion(name, exclusion)?;
411        }
412        let mut stack: Vec<&ElementIr> = self.elements.iter().rev().collect();
413        while let Some(element) = stack.pop() {
414            count = count.saturating_add(1);
415            if count > max_elements {
416                return Err(FileMakerError::new(
417                    ErrorCode::LimitExceeded,
418                    format!("element count exceeds {max_elements}"),
419                ));
420            }
421            if !ids.insert(element.id.as_str()) {
422                return Err(FileMakerError::new(
423                    ErrorCode::SchemaField,
424                    format!("duplicate element ID `{}`", element.id.as_str()),
425                ));
426            }
427            stack.extend(element.children.iter().rev());
428        }
429        Ok(())
430    }
431}
432
433fn validate_exclusion(name: &str, exclusion: &ExclusionIr) -> Result<()> {
434    validate_exclusion_name("exclusion", name, 118)?;
435    validate_exclusion_name("exclusion group", &exclusion.group, 128)?;
436    if exclusion.collides_with.len() > 64 {
437        return Err(FileMakerError::new(
438            ErrorCode::LimitExceeded,
439            "exclusion collision-group list exceeds 64",
440        ));
441    }
442    for group in &exclusion.collides_with {
443        validate_exclusion_name("exclusion collision group", group, 128)?;
444    }
445    if [exclusion.x, exclusion.y, exclusion.width, exclusion.height].contains(&Length::Auto) {
446        return Err(FileMakerError::new(
447            ErrorCode::SchemaField,
448            "exclusion geometry cannot be auto",
449        ));
450    }
451    Ok(())
452}
453
454fn validate_exclusion_name(label: &str, value: &str, max_bytes: usize) -> Result<()> {
455    if value.is_empty()
456        || value.len() > max_bytes
457        || !value
458            .bytes()
459            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'))
460    {
461        return Err(FileMakerError::new(
462            ErrorCode::SchemaField,
463            format!("{label} name is invalid"),
464        ));
465    }
466    Ok(())
467}