1use 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#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Hash, Serialize)]
25#[serde(transparent)]
26pub struct ElementId(String);
27
28impl ElementId {
29 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 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum ElementKind {
67 Text,
69 Image,
71 Line,
73 Rect,
75 Circle,
77 Ellipse,
79 Polygon,
81 Path,
83 Group,
85 Table,
87 Chart,
89 Qr,
91 Barcode,
93}
94
95impl ElementKind {
96 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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
122pub struct GeometryIr {
123 pub x: Option<Length>,
125 pub y: Option<Length>,
127 pub width: Option<Length>,
129 pub height: Option<Length>,
131 pub constraints: crate::LayoutConstraints,
133 pub align_x: Option<crate::Alignment>,
135 pub align_y: Option<crate::Alignment>,
137 pub region: Option<String>,
139 pub anchors: BTreeMap<String, String>,
141}
142
143#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
145pub struct TransformIr {
146 pub translate_x: Length,
148 pub translate_y: Length,
150 pub rotate: i32,
152 pub scale_x: i64,
154 pub scale_y: i64,
156 pub origin_x: Length,
158 pub origin_y: Length,
160}
161
162#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
164pub struct TextIr {
165 pub overflow: crate::TextOverflow,
167 pub max_lines: Option<usize>,
169 pub min_font_size: Option<Length>,
171 pub line_height: u32,
173 pub writing_mode: crate::WritingMode,
175}
176
177#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
179pub struct TableIr {
180 pub spec: crate::TableSpec,
182 pub header_height: Length,
184 pub row_height: Option<Length>,
186 pub rows: Vec<crate::DataRow>,
188}
189
190#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
192#[serde(tag = "command", rename_all = "snake_case")]
193pub enum PathCommandIr {
194 Move { x: Length, y: Length },
196 Line { x: Length, y: Length },
198 Curve {
200 x1: Length,
201 y1: Length,
202 x2: Length,
203 y2: Length,
204 x: Length,
205 y: Length,
206 },
207 Close,
209}
210
211#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
213pub struct RegionIr {
214 pub x: Length,
216 pub y: Length,
218 pub width: Length,
220 pub height: Length,
222 pub collision: Option<crate::CollisionPolicy>,
224}
225
226#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
228pub struct ExclusionIr {
229 pub x: Length,
231 pub y: Length,
233 pub width: Length,
235 pub height: Length,
237 pub group: String,
239 pub collides_with: BTreeSet<String>,
241}
242
243#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
245#[serde(rename_all = "snake_case")]
246pub enum LayoutMode {
247 #[default]
249 Absolute,
250 FlowVertical,
252 FlowHorizontal,
254}
255
256#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
258pub struct Provenance {
259 pub source: String,
261 pub components: Vec<String>,
263 pub styles: Vec<String>,
265 pub patches: Vec<u64>,
267}
268
269#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
271pub struct AiPolicy {
272 pub purpose: String,
274 pub rules: Vec<String>,
276 pub editable: BTreeSet<String>,
278 pub locked: BTreeSet<String>,
280}
281
282#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
284pub struct ElementIr {
285 pub id: ElementId,
287 pub kind: ElementKind,
289 pub geometry: GeometryIr,
291 pub transform: TransformIr,
293 pub text: Option<String>,
295 pub text_options: TextIr,
297 pub table: Option<TableIr>,
299 pub asset: Option<String>,
301 pub path: Vec<PathCommandIr>,
303 pub image: ImageOptions,
305 pub style: Style,
307 #[serde(default)]
309 pub style_rules: Vec<crate::ElementStyleRule>,
310 pub layout: LayoutMode,
312 pub distribute: crate::Distribution,
314 pub gap: Length,
316 pub collision: Option<CollisionPolicy>,
318 pub children: Vec<ElementIr>,
320 pub locked: bool,
322 pub hidden: bool,
324 pub layer: String,
326 pub z_index: i32,
328 pub binding: Option<String>,
330 pub when: Option<String>,
332 pub repeat: Option<String>,
334 pub provenance: Provenance,
336 pub page_placement: Option<crate::PagePlacement>,
338}
339
340#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
342pub struct TemplateIr {
343 pub id: String,
345 pub model: ModelKind,
347 pub page_size: Option<Size>,
349 pub orientation: Orientation,
351 pub page_template: Option<PageTemplate>,
353 pub collision: Option<crate::CollisionPolicy>,
355 pub page_collision: Option<crate::CollisionPolicy>,
357 pub guides: BTreeMap<String, Length>,
359 pub regions: BTreeMap<String, RegionIr>,
361 pub exclusions: BTreeMap<String, ExclusionIr>,
363 pub data_schema: DataSchema,
365 pub ai_policy: AiPolicy,
367 pub elements: Vec<ElementIr>,
369}
370
371#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
373pub struct DocumentIr {
374 pub template_id: String,
376 pub model: ModelKind,
378 pub page_size: Option<Size>,
380 pub page_template: Option<PageTemplate>,
382 pub collision: Option<crate::CollisionPolicy>,
384 pub page_collision: Option<crate::CollisionPolicy>,
386 pub guides: BTreeMap<String, Length>,
388 pub regions: BTreeMap<String, RegionIr>,
390 pub exclusions: BTreeMap<String, ExclusionIr>,
392 pub ai_policy: AiPolicy,
394 pub elements: Vec<ElementIr>,
396}
397
398impl TemplateIr {
399 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}