1use std::collections::BTreeMap;
14
15use crate::source::*;
16use crate::source_layout::{convert_exclusions, validate_exclusions, validate_layout_source};
17use crate::source_page_build::{
18 append_page_layer_elements, resolve_page, validate_page, validate_page_layer_elements,
19};
20use crate::source_style::convert_style;
21use crate::source_table::convert_table;
22use crate::source_text::{convert_text_options, validate_text_options};
23use crate::source_transform::{convert_transform, validate_transform};
24use crate::{
25 AiPolicy, CollisionPolicy, CollisionResolution, DataField, DataSchema, DataType, ElementId,
26 ElementIr, ElementKind, ErrorCode, FileMakerError, GeometryIr, PathCommandIr, PresetRegistry,
27 Provenance, RegionIr, ResourceLimits, Result, TemplateIr, TextOverflow, FILEMAKER_SCHEMA_V1,
28};
29
30impl TemplateSourceV1 {
31 pub fn parse_yaml(bytes: &[u8], limits: &ResourceLimits) -> Result<Self> {
33 limits.validate()?;
34 ResourceLimits::check("template bytes", bytes.len(), limits.max_template_bytes)?;
35 let source: Self = serde_yaml::from_slice(bytes).map_err(|error| {
36 FileMakerError::new(ErrorCode::SchemaSyntax, format!("invalid YAML: {error}"))
37 })?;
38 source.validate(limits)?;
39 Ok(source)
40 }
41
42 pub fn validate(&self, limits: &ResourceLimits) -> Result<()> {
44 if self.filemaker != FILEMAKER_SCHEMA_V1 {
45 return Err(FileMakerError::new(
46 ErrorCode::SchemaVersion,
47 format!(
48 "unsupported filemaker schema `{}`; expected `{FILEMAKER_SCHEMA_V1}`",
49 self.filemaker
50 ),
51 ));
52 }
53 validate_safe_name("template ID", &self.id)?;
54 if self.includes.len() > limits.max_include_depth {
55 return Err(FileMakerError::new(
56 ErrorCode::LimitExceeded,
57 "top-level include count exceeds include depth budget",
58 ));
59 }
60 let mut count = self.exclusions.len();
61 let mut path_commands = 0_usize;
62 validate_elements(&self.elements, limits, &mut count, &mut path_commands)?;
63 if let Some(page) = &self.page {
64 if page.has_layer_elements() && self.model != ModelKind::Document {
65 return Err(FileMakerError::new(
66 ErrorCode::SchemaField,
67 "page layers are valid only for the document model",
68 ));
69 }
70 for elements in page.element_lists() {
71 validate_elements(elements, limits, &mut count, &mut path_commands)?;
72 validate_page_layer_elements(elements)?;
73 }
74 }
75 for component in self.components.values() {
76 validate_elements(&component.elements, limits, &mut count, &mut path_commands)?;
77 for slot in component.slots.values() {
78 validate_elements(slot, limits, &mut count, &mut path_commands)?;
79 }
80 }
81 validate_page(self.page.as_ref())?;
82 validate_exclusions(&self.exclusions, limits.max_elements)?;
83 validate_ai_policy(self.ai.as_ref())?;
84 Ok(())
85 }
86
87 pub fn to_ir(&self, presets: &PresetRegistry, limits: &ResourceLimits) -> Result<TemplateIr> {
92 self.validate(limits)?;
93 if !self.includes.is_empty() {
94 return Err(FileMakerError::new(
95 ErrorCode::SchemaField,
96 "direct IR conversion requires includes to be expanded",
97 ));
98 }
99 let (page_size, orientation, page_template) =
100 resolve_page(&self.id, self.page.as_ref(), presets)?;
101 let mut elements = Vec::with_capacity(self.elements.len());
102 for element in &self.elements {
103 elements.push(element_to_ir(element, "template", limits)?);
104 }
105 if let Some(page) = &self.page {
106 append_page_layer_elements(page, limits, &mut elements, element_to_ir)?;
107 }
108 let ir = TemplateIr {
109 id: self.id.clone(),
110 model: self.model,
111 page_size,
112 orientation,
113 page_template,
114 collision: self.collision.as_ref().map(convert_collision).transpose()?,
115 page_collision: self
116 .page
117 .as_ref()
118 .and_then(|page| page.collision.as_ref())
119 .map(convert_collision)
120 .transpose()?,
121 guides: self.guides.clone(),
122 regions: convert_regions(&self.regions)?,
123 exclusions: convert_exclusions(&self.exclusions),
124 data_schema: convert_data_schema(&self.data_schema),
125 ai_policy: convert_ai_policy(self.ai.as_ref()),
126 elements,
127 };
128 ir.validate(limits.max_elements)?;
129 Ok(ir)
130 }
131}
132
133pub(crate) fn self_contained_element_to_ir(
134 source: &ElementSource,
135 limits: &ResourceLimits,
136) -> Result<ElementIr> {
137 limits.validate()?;
138 let mut count = 0;
139 let mut path_commands = 0;
140 validate_elements(
141 std::slice::from_ref(source),
142 limits,
143 &mut count,
144 &mut path_commands,
145 )?;
146 element_to_ir(source, "element", limits)
147}
148
149fn validate_ai_policy(policy: Option<&AiSourcePolicy>) -> Result<()> {
150 let Some(policy) = policy else {
151 return Ok(());
152 };
153 if policy.purpose.len() > 1_024
154 || policy.rules.len() > 64
155 || policy.rules.iter().any(|rule| rule.len() > 1_024)
156 || policy.editable.len() > 10_000
157 || policy.locked.len() > 10_000
158 {
159 return Err(FileMakerError::new(
160 ErrorCode::SchemaField,
161 "AI policy exceeds its bounded text or ID limits",
162 ));
163 }
164 for id in policy.editable.iter().chain(&policy.locked) {
165 ElementId::new(id)?;
166 }
167 Ok(())
168}
169
170fn convert_ai_policy(policy: Option<&AiSourcePolicy>) -> AiPolicy {
171 policy.map_or_else(AiPolicy::default, |policy| AiPolicy {
172 purpose: policy.purpose.clone(),
173 rules: policy.rules.clone(),
174 editable: policy.editable.iter().cloned().collect(),
175 locked: policy.locked.iter().cloned().collect(),
176 })
177}
178
179fn validate_elements(
180 elements: &[ElementSource],
181 limits: &ResourceLimits,
182 count: &mut usize,
183 path_commands: &mut usize,
184) -> Result<()> {
185 for element in elements {
186 *count = count.saturating_add(1);
187 if *count > limits.max_elements {
188 return Err(FileMakerError::new(
189 ErrorCode::LimitExceeded,
190 "source element count exceeds configured limit",
191 ));
192 }
193 ElementId::new(element.id.clone())?;
194 let kind = if element.element_type != "slot" {
195 Some(ElementKind::parse(&element.element_type)?)
196 } else {
197 None
198 };
199 *path_commands = path_commands.saturating_add(element.path.len());
200 if *path_commands > limits.max_path_commands {
201 return Err(FileMakerError::new(
202 ErrorCode::LimitExceeded,
203 "source path command count exceeds configured limit",
204 ));
205 }
206 if matches!(kind, Some(ElementKind::Path | ElementKind::Polygon)) && element.path.is_empty()
207 {
208 return Err(FileMakerError::new(
209 ErrorCode::SchemaField,
210 "path and polygon elements require path commands",
211 )
212 .at(element.id.clone()));
213 }
214 if !element.path.is_empty()
215 && !matches!(
216 kind,
217 Some(ElementKind::Path | ElementKind::Polygon | ElementKind::Line)
218 )
219 {
220 return Err(FileMakerError::new(
221 ErrorCode::SchemaField,
222 "path commands are only valid on line, path, or polygon elements",
223 )
224 .at(element.id.clone()));
225 }
226 if let Some(text) = &element.text {
227 ResourceLimits::check("text bytes", text.len(), limits.max_text_bytes)?;
228 }
229 element.image.validate()?;
230 validate_transform(&element.transform)?;
231 validate_text_options(&element.text_options)?;
232 validate_layout_source(element)?;
233 validate_style_rules(element)?;
234 if element.text_options != TextSourceOptions::default() {
235 match kind {
236 Some(ElementKind::Text) => {}
237 Some(ElementKind::Table)
238 if element.text_options.overflow == TextOverflow::Wrap
239 && element.text_options.max_lines.is_none() => {}
240 Some(ElementKind::Table) => {
241 return Err(FileMakerError::new(
242 ErrorCode::SchemaField,
243 "table text_options support min_font_size, line_height, and writing_mode",
244 )
245 .at(element.id.clone()))
246 }
247 _ => {
248 return Err(FileMakerError::new(
249 ErrorCode::SchemaField,
250 "text_options are only valid on text and table elements",
251 )
252 .at(element.id.clone()))
253 }
254 }
255 }
256 if (kind == Some(ElementKind::Table)) != element.table.is_some() {
257 return Err(FileMakerError::new(
258 ErrorCode::SchemaField,
259 "type table requires `table`, which is invalid on every other element type",
260 )
261 .at(element.id.clone()));
262 }
263 if kind == Some(ElementKind::Table) && element.binding.is_none() {
264 return Err(FileMakerError::new(
265 ErrorCode::SchemaField,
266 "table elements require an array binding",
267 )
268 .at(element.id.clone()));
269 }
270 if kind == Some(ElementKind::Table)
271 && (!element.children.is_empty() || !element.slots.is_empty())
272 {
273 return Err(FileMakerError::new(
274 ErrorCode::SchemaField,
275 "table elements cannot contain children or slots",
276 )
277 .at(element.id.clone()));
278 }
279 validate_elements(&element.children, limits, count, path_commands)?;
280 for slot in element.slots.values() {
281 validate_elements(slot, limits, count, path_commands)?;
282 }
283 }
284 Ok(())
285}
286
287fn validate_style_rules(element: &ElementSource) -> Result<()> {
288 if element.style_rules.len() > 64 {
289 return Err(FileMakerError::new(
290 ErrorCode::LimitExceeded,
291 "element conditional style count exceeds 64",
292 )
293 .at(element.id.clone()));
294 }
295 for rule in &element.style_rules {
296 if rule.when.is_empty() {
297 return Err(FileMakerError::new(
298 ErrorCode::SchemaField,
299 "conditional style expression cannot be empty",
300 )
301 .at(element.id.clone()));
302 }
303 crate::Expression::parse(&rule.when).map_err(|error| error.at(element.id.clone()))?;
304 }
305 Ok(())
306}
307
308fn element_to_ir(
309 source: &ElementSource,
310 logical_source: &str,
311 limits: &ResourceLimits,
312) -> Result<ElementIr> {
313 if source.component.is_some() {
314 return Err(FileMakerError::new(
315 ErrorCode::SchemaField,
316 "direct IR conversion requires components to be expanded",
317 )
318 .at(source.id.clone()));
319 }
320 let mut children = Vec::with_capacity(source.children.len());
321 for child in &source.children {
322 children.push(element_to_ir(child, logical_source, limits)?);
323 }
324 if let Some(text) = &source.text {
325 ResourceLimits::check("text bytes", text.len(), limits.max_text_bytes)?;
326 }
327 Ok(ElementIr {
328 id: ElementId::new(source.id.clone())?,
329 kind: ElementKind::parse(&source.element_type)?,
330 geometry: GeometryIr {
331 x: source.x,
332 y: source.y,
333 width: source.width,
334 height: source.height,
335 constraints: source.constraints,
336 align_x: source.align_x,
337 align_y: source.align_y,
338 region: source.region.clone(),
339 anchors: source.anchors.clone(),
340 },
341 transform: convert_transform(source.transform)?,
342 text: source.text.clone(),
343 text_options: convert_text_options(source.text_options),
344 table: source
345 .table
346 .as_ref()
347 .map(|table| convert_table(table, limits))
348 .transpose()?,
349 asset: source.asset.clone(),
350 path: source.path.iter().map(convert_path_command).collect(),
351 image: source.image,
352 style: convert_style(&source.style)?,
353 style_rules: source
354 .style_rules
355 .iter()
356 .map(|rule| {
357 Ok(crate::ElementStyleRule {
358 when: rule.when.clone(),
359 style: convert_style(&rule.style)?,
360 })
361 })
362 .collect::<Result<Vec<_>>>()?,
363 layout: source.layout,
364 distribute: source.distribute,
365 gap: source.gap,
366 collision: source
367 .collision
368 .as_ref()
369 .map(convert_collision)
370 .transpose()?,
371 children,
372 locked: source.locked,
373 hidden: source.hidden,
374 layer: source.layer.clone(),
375 z_index: source.z_index,
376 binding: source.binding.clone(),
377 when: source.when.clone(),
378 repeat: source.repeat.clone(),
379 provenance: Provenance {
380 source: source
381 .provenance_source
382 .clone()
383 .unwrap_or_else(|| logical_source.to_owned()),
384 components: source.provenance_components.clone(),
385 styles: source.styles.clone(),
386 patches: Vec::new(),
387 },
388 page_placement: None,
389 })
390}
391
392fn convert_path_command(source: &PathCommandSource) -> PathCommandIr {
393 match *source {
394 PathCommandSource::Move { x, y } => PathCommandIr::Move { x, y },
395 PathCommandSource::Line { x, y } => PathCommandIr::Line { x, y },
396 PathCommandSource::Curve {
397 x1,
398 y1,
399 x2,
400 y2,
401 x,
402 y,
403 } => PathCommandIr::Curve {
404 x1,
405 y1,
406 x2,
407 y2,
408 x,
409 y,
410 },
411 PathCommandSource::Close => PathCommandIr::Close,
412 }
413}
414
415fn convert_regions(source: &BTreeMap<String, RegionSource>) -> Result<BTreeMap<String, RegionIr>> {
416 source
417 .iter()
418 .map(|(name, region)| {
419 Ok((
420 name.clone(),
421 RegionIr {
422 x: region.x,
423 y: region.y,
424 width: region.width,
425 height: region.height,
426 collision: region
427 .collision
428 .as_ref()
429 .map(convert_collision)
430 .transpose()?,
431 },
432 ))
433 })
434 .collect()
435}
436
437fn convert_data_schema(source: &BTreeMap<String, DataFieldSource>) -> DataSchema {
438 source
439 .iter()
440 .map(|(name, field)| {
441 let data_type = match field.data_type {
442 DataTypeSource::String => DataType::String,
443 DataTypeSource::Integer => DataType::Integer,
444 DataTypeSource::Decimal => DataType::Decimal,
445 DataTypeSource::Boolean => DataType::Boolean,
446 DataTypeSource::Date => DataType::Date,
447 DataTypeSource::DateTime => DataType::DateTime,
448 DataTypeSource::Duration => DataType::Duration,
449 DataTypeSource::Currency => DataType::Currency,
450 DataTypeSource::Array => DataType::Array,
451 DataTypeSource::Object => DataType::Object,
452 DataTypeSource::Null => DataType::Null,
453 };
454 (
455 name.clone(),
456 DataField {
457 data_type,
458 nullable: field.nullable,
459 computed: field.computed.clone(),
460 },
461 )
462 })
463 .collect()
464}
465
466fn convert_collision(source: &CollisionSource) -> Result<CollisionPolicy> {
467 let advanced = match source {
468 CollisionSource::Enabled(enabled) => {
469 return Ok(CollisionPolicy {
470 enabled: *enabled,
471 ..CollisionPolicy::default()
472 });
473 }
474 CollisionSource::Advanced(advanced) => advanced,
475 };
476 let resolution = match advanced.policy.as_str() {
477 "push" => CollisionResolution::Push,
478 "error" => CollisionResolution::Error,
479 "overlay" => CollisionResolution::Overlay,
480 "next_page" => CollisionResolution::NextPage,
481 "shrink" => CollisionResolution::Shrink,
482 _ => {
483 return Err(FileMakerError::new(
484 ErrorCode::SchemaField,
485 format!("unknown collision policy `{}`", advanced.policy),
486 ))
487 }
488 };
489 Ok(CollisionPolicy {
490 enabled: advanced.enabled,
491 group: advanced.group.clone(),
492 collides_with: advanced.collides_with.iter().cloned().collect(),
493 ignore: advanced.ignore.iter().cloned().collect(),
494 priority: advanced.priority,
495 movable: advanced.movable,
496 bounds: advanced.bounds,
497 resolution,
498 })
499}
500
501fn validate_safe_name(label: &str, value: &str) -> Result<()> {
502 if value.is_empty()
503 || value.len() > 128
504 || !value
505 .bytes()
506 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'))
507 {
508 return Err(FileMakerError::new(
509 ErrorCode::SchemaField,
510 format!("{label} is invalid"),
511 ));
512 }
513 Ok(())
514}