Skip to main content

appcore_filemaker/
layout.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: layout.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 layout contracts and behavior for this crate.
12
13use std::sync::Arc;
14
15use serde::{Deserialize, Serialize};
16
17use crate::layout_context::LayoutContext;
18use crate::layout_geometry::{
19    layout_error, propose_rect, resolve_layout_rect, resolve_transform, select_collision_bounds,
20    shape_for, validate_anchor_graph, visual_bounds,
21};
22use crate::layout_measure::{measure_content, resolve_image};
23
24use crate::{
25    AssetResolver, BoundsSet, CollisionPolicy, DocumentFingerprint, DocumentIr, ElementIr,
26    FontManager, LayoutMode, OperationControl, ProgressPhase, Rect, ResolvedElement, ResolvedScene,
27    ResourceLimits, Result, SceneCache, TextOverflow, Transform, Unit, ENGINE_VERSION,
28};
29
30/// Explicit layout and reflow policy.
31#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
32pub struct LayoutOptions {
33    /// Default inherited collision policy.
34    pub collision: CollisionPolicy,
35    /// Logical unit used by `lu` lengths.
36    pub logical_unit: Unit,
37    /// Smallest dimension accepted by collision shrinking.
38    pub minimum_size: Unit,
39    /// Gap introduced by push collision resolution.
40    pub collision_gap: Unit,
41}
42
43impl Default for LayoutOptions {
44    fn default() -> Self {
45        Self {
46            collision: CollisionPolicy::default(),
47            logical_unit: Unit::from_raw(Unit::PER_POINT),
48            minimum_size: Unit::from_raw(Unit::PER_POINT),
49            collision_gap: Unit::ZERO,
50        }
51    }
52}
53
54/// Deterministic measure → propose → query → resolve → commit engine.
55pub struct LayoutEngine<'a> {
56    pub(crate) limits: &'a ResourceLimits,
57    fonts: &'a FontManager,
58    pub(crate) options: LayoutOptions,
59    control: OperationControl,
60    assets: Option<&'a dyn AssetResolver>,
61}
62
63#[derive(Clone)]
64struct ElementPlacement {
65    initial_page: usize,
66    page_index: usize,
67    proposed: Rect,
68    rect: Rect,
69    policy: CollisionPolicy,
70    transform: Transform,
71}
72
73impl<'a> LayoutEngine<'a> {
74    /// Creates an engine over explicit limits and fonts.
75    pub fn new(
76        limits: &'a ResourceLimits,
77        fonts: &'a FontManager,
78        options: LayoutOptions,
79    ) -> Result<Self> {
80        Self::new_controlled(limits, fonts, options, OperationControl::default())
81    }
82
83    /// Creates an engine with cooperative cancellation and progress controls.
84    pub fn new_controlled(
85        limits: &'a ResourceLimits,
86        fonts: &'a FontManager,
87        options: LayoutOptions,
88        control: OperationControl,
89    ) -> Result<Self> {
90        limits.validate()?;
91        if options.logical_unit <= Unit::ZERO
92            || options.minimum_size <= Unit::ZERO
93            || options.collision_gap < Unit::ZERO
94        {
95            return Err(layout_error("layout options contain invalid dimensions"));
96        }
97        Ok(Self {
98            limits,
99            fonts,
100            options,
101            control,
102            assets: None,
103        })
104    }
105
106    /// Supplies the explicit resolver needed to resolve image paint geometry.
107    #[must_use]
108    pub fn with_assets(mut self, assets: &'a dyn AssetResolver) -> Self {
109        self.assets = Some(assets);
110        self
111    }
112
113    /// Resolves all geometry before any exporter is selected.
114    pub fn resolve(&self, document: &DocumentIr) -> Result<ResolvedScene> {
115        self.control.checkpoint(ProgressPhase::Layout, 0, None)?;
116        let page_size = document
117            .page_size
118            .ok_or_else(|| layout_error("document/canvas requires an explicit page size"))?;
119        validate_anchor_graph(&document.elements)?;
120        let exclusions = crate::layout_exclusion::resolve_exclusions(
121            document,
122            page_size,
123            self.options.logical_unit,
124        )?;
125        let mut context = LayoutContext::new(
126            page_size,
127            document.page_template.clone(),
128            exclusions,
129            self.limits.max_pages,
130            self.limits.max_elements,
131        );
132        let page_rect = document.page_template.as_ref().map_or_else(
133            || Rect::new(Unit::ZERO, Unit::ZERO, page_size.width, page_size.height),
134            crate::PageTemplate::content_bounds,
135        )?;
136        let document_collision = document
137            .collision
138            .as_ref()
139            .unwrap_or(&self.options.collision);
140        let page_collision = document
141            .page_collision
142            .as_ref()
143            .unwrap_or(document_collision);
144        let regions =
145            crate::layout_region::resolve_regions(document, page_rect, self.options.logical_unit)?;
146        context.regions = regions;
147        self.layout_list(
148            &document.elements,
149            document,
150            page_rect,
151            0,
152            LayoutMode::Absolute,
153            crate::Distribution::Start,
154            Unit::ZERO,
155            page_collision,
156            Transform::IDENTITY,
157            &mut context,
158        )?;
159        crate::layout_page::resolve_page_layers(self, document, page_collision, &mut context)?;
160        for page in &mut context.pages {
161            page.elements.sort_by(|left, right| {
162                (&left.layer, left.z_index, left.sequence).cmp(&(
163                    &right.layer,
164                    right.z_index,
165                    right.sequence,
166                ))
167            });
168        }
169        self.control.checkpoint(
170            ProgressPhase::Layout,
171            u64::try_from(context.sequence).unwrap_or(u64::MAX),
172            Some(u64::try_from(self.limits.max_elements).unwrap_or(u64::MAX)),
173        )?;
174        Ok(ResolvedScene {
175            template_id: document.template_id.clone(),
176            pages: context.pages,
177            engine_version: ENGINE_VERSION.to_owned(),
178        })
179    }
180
181    /// Resolves only on a fingerprint miss and returns an immutable shared scene.
182    ///
183    /// The fingerprint must be computed from the same template, data, patches,
184    /// assets, and fonts used to produce `document` and this engine.
185    pub fn resolve_cached(
186        &self,
187        document: &DocumentIr,
188        fingerprint: DocumentFingerprint,
189        cache: &mut SceneCache,
190    ) -> Result<Arc<ResolvedScene>> {
191        cache.get_or_try_insert_with(fingerprint, self.limits, || self.resolve(document))
192    }
193
194    #[allow(clippy::too_many_arguments)]
195    pub(crate) fn layout_list(
196        &self,
197        elements: &[ElementIr],
198        document: &DocumentIr,
199        container: Rect,
200        mut page_index: usize,
201        parent_layout: LayoutMode,
202        distribution: crate::Distribution,
203        gap: Unit,
204        inherited_collision: &CollisionPolicy,
205        parent_transform: Transform,
206        context: &mut LayoutContext,
207    ) -> Result<()> {
208        let flow = crate::layout_flow::plan_flow(
209            elements,
210            container,
211            parent_layout,
212            distribution,
213            gap,
214            self.options.logical_unit,
215        )?;
216        let mut flow_x = flow.x;
217        let mut flow_y = flow.y;
218        let effective_gap = flow.gap;
219        for element in elements
220            .iter()
221            .filter(|element| element.page_placement.is_none())
222        {
223            context.checkpoint(&self.control, self.limits.max_elements)?;
224            if element.hidden {
225                continue;
226            }
227            let placement = self.place_element(
228                element,
229                document,
230                container,
231                page_index,
232                parent_layout,
233                crate::Point {
234                    x: flow_x,
235                    y: flow_y,
236                },
237                inherited_collision,
238                parent_transform,
239                context,
240            )?;
241            if element.kind == crate::ElementKind::Table {
242                let first = placement.clone();
243                let last = self.commit_table_fragments(
244                    element,
245                    document,
246                    container,
247                    parent_layout,
248                    crate::Point {
249                        x: flow_x,
250                        y: flow_y,
251                    },
252                    inherited_collision,
253                    parent_transform,
254                    placement,
255                    context,
256                )?;
257                page_index = last.page_index;
258                context.positions.insert(
259                    element.id.as_str().to_owned(),
260                    (first.page_index, first.rect),
261                );
262                match parent_layout {
263                    LayoutMode::FlowVertical => {
264                        flow_y = last.rect.bottom()?.checked_add(effective_gap)?;
265                    }
266                    LayoutMode::FlowHorizontal => {
267                        flow_x = last.rect.right()?.checked_add(effective_gap)?;
268                    }
269                    LayoutMode::Absolute => {}
270                }
271                continue;
272            }
273            page_index = placement.page_index;
274            let resolved = self.build_resolved_element(element, &placement, context)?;
275            context.commit(placement.page_index, resolved, placement.policy.clone())?;
276            context.positions.insert(
277                element.id.as_str().to_owned(),
278                (placement.page_index, placement.rect),
279            );
280            match parent_layout {
281                LayoutMode::FlowVertical => {
282                    flow_y = placement.rect.bottom()?.checked_add(effective_gap)?;
283                }
284                LayoutMode::FlowHorizontal => {
285                    flow_x = placement.rect.right()?.checked_add(effective_gap)?;
286                }
287                LayoutMode::Absolute => {}
288            }
289            let child_gap = element
290                .gap
291                .resolve(placement.rect.size.width, self.options.logical_unit)?
292                .unwrap_or(Unit::ZERO);
293            self.layout_list(
294                &element.children,
295                document,
296                placement.rect,
297                placement.page_index,
298                element.layout,
299                element.distribute,
300                child_gap,
301                &placement.policy,
302                placement.transform,
303                context,
304            )?;
305        }
306        Ok(())
307    }
308
309    #[allow(clippy::too_many_arguments)]
310    fn place_element(
311        &self,
312        element: &ElementIr,
313        document: &DocumentIr,
314        container: Rect,
315        page_index: usize,
316        parent_layout: LayoutMode,
317        flow_origin: crate::Point,
318        inherited_collision: &CollisionPolicy,
319        parent_transform: Transform,
320        context: &mut LayoutContext,
321    ) -> Result<ElementPlacement> {
322        let initial_page = page_index;
323        let effective_container = crate::layout_region::resolve_region(
324            element,
325            document,
326            container,
327            self.options.logical_unit,
328        )?;
329        let mut proposed = propose_rect(
330            element,
331            effective_container,
332            parent_layout,
333            flow_origin,
334            &context.positions,
335            &document.guides,
336            self.options.logical_unit,
337        )?;
338        let policy = crate::layout_policy::effective_collision_policy(
339            element,
340            document,
341            inherited_collision,
342        );
343        crate::layout_policy::validate_shrink_policy(&policy)?;
344        let (mut initial_style, initial_text_layout, mut initial_intrinsic) =
345            measure_content(element, proposed, self.fonts)?;
346        if element.kind == crate::ElementKind::Text
347            && element.text_options.overflow == TextOverflow::Expand
348        {
349            let measured = initial_text_layout
350                .as_ref()
351                .ok_or_else(|| layout_error("expanded text has no measurement"))?
352                .measured;
353            proposed.size.width = proposed.size.width.max(measured.width);
354            proposed.size.height = proposed.size.height.max(measured.height);
355            (initial_style, _, initial_intrinsic) = measure_content(element, proposed, self.fonts)?;
356        }
357        let proposed_layout = proposed;
358        let proposed_transform = resolve_transform(element, proposed, self.options.logical_unit)?
359            .then(parent_transform)?;
360        crate::layout_policy::validate_shrink_transform(&policy, proposed_transform)?;
361        let initial_visual =
362            proposed_transform.bounds(visual_bounds(proposed, initial_style.stroke_width)?)?;
363        let collision_candidate = select_collision_bounds(
364            policy.bounds,
365            proposed_transform.bounds(proposed)?,
366            proposed_transform.bounds(initial_intrinsic)?,
367            initial_visual,
368        );
369        let (page_index, resolved_collision) = crate::layout_collision::resolve_candidate(
370            element,
371            page_index,
372            collision_candidate,
373            &policy,
374            context,
375            self.limits,
376            &self.options,
377            &self.control,
378        )?;
379        let rect = resolve_layout_rect(
380            policy.bounds,
381            proposed,
382            collision_candidate,
383            resolved_collision,
384            parent_transform,
385        )?;
386        let transform =
387            resolve_transform(element, rect, self.options.logical_unit)?.then(parent_transform)?;
388        Ok(ElementPlacement {
389            initial_page,
390            page_index,
391            proposed: proposed_layout,
392            rect,
393            policy,
394            transform,
395        })
396    }
397
398    fn build_resolved_element(
399        &self,
400        element: &ElementIr,
401        placement: &ElementPlacement,
402        context: &mut LayoutContext,
403    ) -> Result<ResolvedElement> {
404        let (style, text_layout, intrinsic) = measure_content(element, placement.rect, self.fonts)?;
405        let image_placement = resolve_image(element, placement.rect, self.assets, self.limits)?;
406        let transformed_layout = placement.transform.bounds(placement.rect)?;
407        let transformed_intrinsic = placement.transform.bounds(intrinsic)?;
408        let visual = placement
409            .transform
410            .bounds(visual_bounds(placement.rect, style.stroke_width)?)?;
411        let collision = select_collision_bounds(
412            placement.policy.bounds,
413            transformed_layout,
414            transformed_intrinsic,
415            visual,
416        );
417        let clip = text_layout.as_ref().and_then(|layout| {
418            layout
419                .diagnostics
420                .contains(&crate::TextDiagnostic::Clipped)
421                .then_some(placement.rect)
422        });
423        Ok(ResolvedElement {
424            id: element.id.clone(),
425            kind: element.kind,
426            bounds: BoundsSet {
427                intrinsic,
428                layout: placement.rect,
429                collision,
430                visual,
431                clip,
432            },
433            collidable: placement.policy.enabled,
434            shape: shape_for(element, placement.rect, self.options.logical_unit)?,
435            transform: placement.transform,
436            style,
437            text: element.text.clone(),
438            text_layout,
439            asset: element.asset.clone(),
440            image_placement,
441            table: None,
442            layer: element.layer.clone(),
443            z_index: element.z_index,
444            sequence: context.next_sequence(),
445            provenance: element.provenance.clone(),
446            layout_trace: crate::LayoutTrace {
447                geometry: element.geometry.clone(),
448                proposed: placement.proposed,
449                collision_policy: placement.policy.clone(),
450                initial_page: placement.initial_page,
451                reflowed: placement.initial_page != placement.page_index
452                    || placement.proposed != placement.rect,
453            },
454        })
455    }
456
457    #[allow(clippy::too_many_arguments)]
458    fn commit_table_fragments(
459        &self,
460        element: &ElementIr,
461        document: &DocumentIr,
462        container: Rect,
463        parent_layout: LayoutMode,
464        flow_origin: crate::Point,
465        inherited_collision: &CollisionPolicy,
466        parent_transform: Transform,
467        first: ElementPlacement,
468        context: &mut LayoutContext,
469    ) -> Result<ElementPlacement> {
470        let fragments = crate::layout_table::resolve_table_fragments(
471            element,
472            first.rect,
473            self.fonts,
474            self.limits,
475            self.options.logical_unit,
476        )?;
477        let mut placement = first.clone();
478        for (index, mut fragment) in fragments.into_iter().enumerate() {
479            if index > 0 {
480                placement = self.place_element(
481                    element,
482                    document,
483                    container,
484                    placement
485                        .page_index
486                        .checked_add(1)
487                        .ok_or_else(|| layout_error("table continuation page index overflow"))?,
488                    parent_layout,
489                    flow_origin,
490                    inherited_collision,
491                    parent_transform,
492                    context,
493                )?;
494            }
495            crate::layout_table::translate_table_fragment(
496                &mut fragment,
497                first.rect.origin,
498                placement.rect.origin,
499            )?;
500            let mut resolved = self.build_resolved_element(element, &placement, context)?;
501            resolved.table = Some(fragment);
502            context.commit(placement.page_index, resolved, placement.policy.clone())?;
503        }
504        Ok(placement)
505    }
506}