Skip to main content

appcore_filemaker/
resolved.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: resolved.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 resolved contracts and behavior for this crate.
12
13use 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/// Geometry and collision inputs retained for deterministic layout explanation.
22#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
23pub struct LayoutTrace {
24    /// Position, size, constraints, region, and anchors from the bound IR.
25    pub geometry: GeometryIr,
26    /// Proposed layout rectangle before collision or page reflow.
27    pub proposed: Rect,
28    /// Effective inherited collision policy.
29    pub collision_policy: CollisionPolicy,
30    /// Page proposed before pagination or collision reflow.
31    pub initial_page: usize,
32    /// Whether collision or pagination changed the proposed placement.
33    pub reflowed: bool,
34}
35
36/// Named region resolved into page coordinates for inspection and overlays.
37#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
38pub struct ResolvedRegion {
39    /// Stable region name.
40    pub name: String,
41    /// Final page-local bounds.
42    pub bounds: Rect,
43}
44
45/// Named non-painted page geometry retained for inspection and collision masks.
46#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
47pub struct ResolvedExclusion {
48    /// Stable source name.
49    pub name: String,
50    /// Final page-local bounds.
51    pub bounds: crate::Rect,
52    /// Collision group exposed by this geometry.
53    pub group: String,
54    /// Candidate groups blocked by this exclusion; empty means every group.
55    pub collides_with: std::collections::BTreeSet<String>,
56}
57
58/// Fully resolved exporter-facing element.
59#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
60pub struct ResolvedElement {
61    /// Stable source ID.
62    pub id: ElementId,
63    /// Element kind.
64    pub kind: ElementKind,
65    /// Distinct resolved geometry boxes.
66    pub bounds: BoundsSet,
67    /// Whether this element participates in collision masks and preflight.
68    pub collidable: bool,
69    /// Collision/vector shape.
70    pub shape: Shape,
71    /// Transform already accounted for in collision bounds.
72    pub transform: Transform,
73    /// Computed style.
74    pub style: ComputedStyle,
75    /// Literal/bound text.
76    pub text: Option<String>,
77    /// Shaped glyph layout when this is text.
78    pub text_layout: Option<TextLayout>,
79    /// Explicit asset name.
80    pub asset: Option<String>,
81    /// Exporter-ready image crop and destination geometry.
82    pub image_placement: Option<ImagePlacement>,
83    /// Exporter-ready table fragment geometry and shaped cell text.
84    pub table: Option<ResolvedTableFragment>,
85    /// Visual layer.
86    pub layer: String,
87    /// Visual z index.
88    pub z_index: i32,
89    /// Stable source order.
90    pub sequence: usize,
91    /// Source and mutation provenance.
92    pub provenance: Provenance,
93    /// Bound source geometry and placement decisions.
94    pub layout_trace: LayoutTrace,
95}
96
97/// One resolved page/canvas.
98#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
99pub struct ResolvedPage {
100    /// Zero-based page index.
101    pub index: usize,
102    /// First, continuation, or last physical-page role.
103    pub role: crate::PageRole,
104    /// Resolved page size.
105    pub size: Size,
106    /// Trim, margin, bleed, safe-area, and crop metadata.
107    pub page_template: Option<PageTemplate>,
108    /// Non-painted geometry seeded into this page's collision index.
109    pub exclusions: Vec<ResolvedExclusion>,
110    /// Named page geometry retained for debug overlays and inspection.
111    pub regions: Vec<ResolvedRegion>,
112    /// Elements sorted by layer, z index, and source sequence for painting.
113    pub elements: Vec<ResolvedElement>,
114}
115
116/// Complete immutable scene consumed by exporters and inspection.
117#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
118pub struct ResolvedScene {
119    /// Template identity.
120    pub template_id: String,
121    /// Pages/canvases in order.
122    pub pages: Vec<ResolvedPage>,
123    /// Engine version that resolved the scene.
124    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}