1use serde::{Deserialize, Serialize};
14
15use crate::{
16 BoundsSet, CollisionPolicy, ComputedStyle, ElementId, ElementKind, ErrorCode, FileMakerError,
17 GeometryIr, ImagePlacement, PageTemplate, Provenance, Rect, ResolvedTableFragment,
18 ResourceLimits, Result, Shape, Size, TextLayout, Transform,
19};
20
21#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
23pub struct LayoutTrace {
24 pub geometry: GeometryIr,
26 pub proposed: Rect,
28 pub collision_policy: CollisionPolicy,
30 pub initial_page: usize,
32 pub reflowed: bool,
34}
35
36#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
38pub struct ResolvedRegion {
39 pub name: String,
41 pub bounds: Rect,
43}
44
45#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
47pub struct ResolvedExclusion {
48 pub name: String,
50 pub bounds: crate::Rect,
52 pub group: String,
54 pub collides_with: std::collections::BTreeSet<String>,
56}
57
58#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
60pub struct ResolvedElement {
61 pub id: ElementId,
63 pub kind: ElementKind,
65 pub bounds: BoundsSet,
67 pub collidable: bool,
69 pub shape: Shape,
71 pub transform: Transform,
73 pub style: ComputedStyle,
75 pub text: Option<String>,
77 pub text_layout: Option<TextLayout>,
79 pub asset: Option<String>,
81 pub image_placement: Option<ImagePlacement>,
83 pub table: Option<ResolvedTableFragment>,
85 pub layer: String,
87 pub z_index: i32,
89 pub sequence: usize,
91 pub provenance: Provenance,
93 pub layout_trace: LayoutTrace,
95}
96
97#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
99pub struct ResolvedPage {
100 pub index: usize,
102 pub role: crate::PageRole,
104 pub size: Size,
106 pub page_template: Option<PageTemplate>,
108 pub exclusions: Vec<ResolvedExclusion>,
110 pub regions: Vec<ResolvedRegion>,
112 pub elements: Vec<ResolvedElement>,
114}
115
116#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
118pub struct ResolvedScene {
119 pub template_id: String,
121 pub pages: Vec<ResolvedPage>,
123 pub engine_version: String,
125}
126
127pub(crate) fn validate_scene_contract(
128 scene: &ResolvedScene,
129 limits: &ResourceLimits,
130) -> Result<()> {
131 limits.validate()?;
132 if scene.engine_version != crate::ENGINE_VERSION {
133 return Err(FileMakerError::new(
134 ErrorCode::Validation,
135 "resolved scene engine version does not match the active engine",
136 ));
137 }
138 if scene.pages.len() > limits.max_pages {
139 return Err(limit_error("resolved scene exceeds the page budget"));
140 }
141 if scene.template_id.is_empty() || scene.template_id.len() > limits.max_text_bytes {
142 return Err(contract_error(
143 "resolved scene template identity is invalid",
144 ));
145 }
146 let mut counts = SceneCounts::default();
147 for (expected_page, page) in scene.pages.iter().enumerate() {
148 validate_page(page, expected_page, scene.pages.len(), limits, &mut counts)?;
149 }
150 Ok(())
151}
152
153#[derive(Default)]
154struct SceneCounts {
155 elements: usize,
156 paths: usize,
157 rows: u64,
158}
159
160fn validate_page(
161 page: &ResolvedPage,
162 expected_page: usize,
163 total_pages: usize,
164 limits: &ResourceLimits,
165 counts: &mut SceneCounts,
166) -> Result<()> {
167 if page.index != expected_page
168 || page.role != expected_page_role(expected_page, total_pages)
169 || page.size.width <= crate::Unit::ZERO
170 || page.size.height <= crate::Unit::ZERO
171 {
172 return Err(contract_error("resolved page order or size is invalid"));
173 }
174 if let Some(template) = &page.page_template {
175 if template.role != page.role
176 || template.size != page.size
177 || template.name.is_empty()
178 || template.name.len() > limits.max_text_bytes
179 {
180 return Err(contract_error(
181 "resolved page template metadata is inconsistent",
182 ));
183 }
184 template.content_bounds()?;
185 template.safe_bounds()?;
186 }
187 counts.elements = counts
188 .elements
189 .checked_add(page.elements.len())
190 .ok_or_else(|| limit_error("resolved element count overflow"))?;
191 if counts.elements > limits.max_elements
192 || page.exclusions.len() > limits.max_elements
193 || page.regions.len() > limits.max_elements
194 {
195 return Err(limit_error("resolved scene exceeds a geometry budget"));
196 }
197 for exclusion in &page.exclusions {
198 validate_rect(exclusion.bounds)?;
199 }
200 for region in &page.regions {
201 validate_rect(region.bounds)?;
202 }
203 let mut ids = std::collections::BTreeSet::new();
204 for element in &page.elements {
205 if !ids.insert(element.id.as_str()) || element.layer.len() > 128 {
206 return Err(contract_error(
207 "resolved element IDs must be unique and layers bounded",
208 ));
209 }
210 validate_bounds(element.bounds)?;
211 element.transform.bounds(element.bounds.layout)?;
212 element.style.validate()?;
213 validate_text(
214 element.text.as_deref(),
215 element.text_layout.as_ref(),
216 limits,
217 )?;
218 let points = validate_shape(&element.shape)?;
219 counts.paths = counts
220 .paths
221 .checked_add(points)
222 .ok_or_else(|| limit_error("resolved path count overflow"))?;
223 if counts.paths > limits.max_path_commands {
224 return Err(limit_error("resolved scene exceeds the path budget"));
225 }
226 if let Some(table) = &element.table {
227 validate_table(table, limits, &mut counts.rows)?;
228 }
229 if let Some(placement) = element.image_placement {
230 validate_image(placement)?;
231 }
232 }
233 Ok(())
234}
235
236fn validate_shape(shape: &Shape) -> Result<usize> {
237 let points = match shape {
238 Shape::Path { bounds, commands } => {
239 validate_rect(*bounds)?;
240 if commands.is_empty() {
241 return Err(contract_error("resolved path has no commands"));
242 }
243 commands.len()
244 }
245 Shape::Polygon { points } => {
246 if points.len() < 3 {
247 return Err(contract_error(
248 "resolved polygon requires at least three points",
249 ));
250 }
251 points.len()
252 }
253 Shape::Rect { bounds } | Shape::Ellipse { bounds } => {
254 validate_rect(*bounds)?;
255 0
256 }
257 };
258 validate_rect(shape.bounds()?)?;
259 Ok(points)
260}
261
262fn validate_table(
263 table: &ResolvedTableFragment,
264 limits: &ResourceLimits,
265 rows: &mut u64,
266) -> Result<()> {
267 const MAX_COLUMNS: usize = 1_024;
268 *rows = rows
269 .checked_add(
270 u64::try_from(table.rows.len())
271 .map_err(|_| limit_error("resolved table row count overflow"))?,
272 )
273 .ok_or_else(|| limit_error("resolved table row count overflow"))?;
274 if *rows > limits.max_rows
275 || table.columns.len() > MAX_COLUMNS
276 || table.header.len() > MAX_COLUMNS
277 || table.totals.len() > MAX_COLUMNS
278 || table.rows.iter().any(|row| row.cells.len() > MAX_COLUMNS)
279 {
280 return Err(limit_error(
281 "resolved table exceeds its row or column budget",
282 ));
283 }
284 if table.columns.iter().any(|column| {
285 column.width <= crate::Unit::ZERO
286 || column.field.is_empty()
287 || column.field.len() > limits.max_text_bytes
288 || column.header.len() > limits.max_text_bytes
289 }) {
290 return Err(contract_error("resolved table column is invalid"));
291 }
292 for cell in table
293 .header
294 .iter()
295 .chain(table.rows.iter().flat_map(|row| &row.cells))
296 .chain(&table.totals)
297 {
298 validate_rect(cell.bounds)?;
299 cell.style.validate()?;
300 validate_text(Some(&cell.text), Some(&cell.text_layout), limits)?;
301 }
302 for row in &table.rows {
303 validate_rect(row.bounds)?;
304 row.style.validate()?;
305 }
306 Ok(())
307}
308
309fn validate_bounds(bounds: BoundsSet) -> Result<()> {
310 for bounds in [
311 bounds.intrinsic,
312 bounds.layout,
313 bounds.collision,
314 bounds.visual,
315 ] {
316 validate_rect(bounds)?;
317 }
318 if let Some(clip) = bounds.clip {
319 validate_rect(clip)?;
320 }
321 Ok(())
322}
323
324fn validate_rect(rect: Rect) -> Result<()> {
325 if rect.size.width < crate::Unit::ZERO || rect.size.height < crate::Unit::ZERO {
326 return Err(contract_error("resolved rectangle has a negative size"));
327 }
328 rect.right()?;
329 rect.bottom()?;
330 Ok(())
331}
332
333fn validate_image(placement: ImagePlacement) -> Result<()> {
334 validate_rect(placement.destination)?;
335 validate_rect(placement.clip)?;
336 let right = placement
337 .source
338 .x
339 .checked_add(placement.source.width)
340 .ok_or_else(|| contract_error("resolved image source width overflow"))?;
341 let bottom = placement
342 .source
343 .y
344 .checked_add(placement.source.height)
345 .ok_or_else(|| contract_error("resolved image source height overflow"))?;
346 if placement.source.width == 0
347 || placement.source.height == 0
348 || placement.intrinsic_width == 0
349 || placement.intrinsic_height == 0
350 || right > placement.intrinsic_width
351 || bottom > placement.intrinsic_height
352 {
353 return Err(contract_error("resolved image placement is invalid"));
354 }
355 Ok(())
356}
357
358fn validate_text(
359 text: Option<&str>,
360 layout: Option<&TextLayout>,
361 limits: &ResourceLimits,
362) -> Result<()> {
363 if text.is_some_and(|value| value.len() > limits.max_text_bytes) {
364 return Err(limit_error("resolved text exceeds the byte budget"));
365 }
366 let Some(layout) = layout else {
367 return Ok(());
368 };
369 if layout.measured.width < crate::Unit::ZERO
370 || layout.measured.height < crate::Unit::ZERO
371 || layout.font_size <= crate::Unit::ZERO
372 || layout.lines.len() > limits.max_text_bytes
373 || layout.diagnostics.len() > limits.max_text_bytes
374 {
375 return Err(contract_error("resolved text layout geometry is invalid"));
376 }
377 let mut bytes = 0_usize;
378 let mut glyphs = 0_usize;
379 let mut runs = 0_usize;
380 for line in &layout.lines {
381 if line.width < crate::Unit::ZERO || line.height <= crate::Unit::ZERO {
382 return Err(contract_error("resolved text line geometry is invalid"));
383 }
384 runs = runs
385 .checked_add(line.runs.len())
386 .ok_or_else(|| limit_error("resolved text run count overflow"))?;
387 for run in &line.runs {
388 if run.font.is_empty()
389 || run.font.len() > 128
390 || run.width < crate::Unit::ZERO
391 || run.glyphs.iter().any(|glyph| {
392 usize::try_from(glyph.cluster).map_or(true, |index| index > run.text.len())
393 })
394 {
395 return Err(contract_error("resolved glyph run is invalid"));
396 }
397 bytes = bytes
398 .checked_add(run.text.len())
399 .ok_or_else(|| limit_error("resolved text byte count overflow"))?;
400 glyphs = glyphs
401 .checked_add(run.glyphs.len())
402 .ok_or_else(|| limit_error("resolved glyph count overflow"))?;
403 }
404 }
405 if bytes > limits.max_text_bytes
406 || glyphs > limits.max_text_bytes
407 || runs > limits.max_text_bytes
408 {
409 return Err(limit_error("resolved text layout exceeds its budget"));
410 }
411 Ok(())
412}
413
414fn expected_page_role(index: usize, total: usize) -> crate::PageRole {
415 if index == 0 {
416 crate::PageRole::First
417 } else if index + 1 == total {
418 crate::PageRole::Last
419 } else {
420 crate::PageRole::Continuation
421 }
422}
423
424fn limit_error(message: impl Into<String>) -> FileMakerError {
425 FileMakerError::new(ErrorCode::LimitExceeded, message)
426}
427
428fn contract_error(message: impl Into<String>) -> FileMakerError {
429 FileMakerError::new(ErrorCode::Validation, message)
430}