Skip to main content

appcore_filemaker/
source_table.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: source_table.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 source table contracts and behavior for this crate.
12
13use serde::{Deserialize, Serialize};
14
15use crate::source::StyleSource;
16use crate::source_style::convert_style;
17use crate::{
18    ErrorCode, FileMakerError, Length, ResourceLimits, Result, TableColumn, TableIr, TableSpec,
19    TableStyleRule,
20};
21
22/// Conditional table-row style in frontend color/length syntax.
23#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct TableStyleRuleSource {
26    /// Deterministic expression evaluated against one row.
27    pub when: String,
28    /// Partial data-rule style.
29    #[serde(default)]
30    pub style: StyleSource,
31    /// Per-side cell padding applied to every cell in a matching row.
32    #[serde(default)]
33    pub padding: Option<crate::CellPadding>,
34    /// First-page cell padding for rows matching this rule.
35    #[serde(default)]
36    pub padding_first_page: Option<crate::CellPadding>,
37    /// Continuation-page cell padding for rows matching this rule.
38    #[serde(default)]
39    pub padding_continuation: Option<crate::CellPadding>,
40    /// Paint-only vertical translation for text in matching row cells.
41    #[serde(default)]
42    pub text_offset_y: Option<Length>,
43    /// First-page paint-only vertical translation for matching row cells.
44    #[serde(default)]
45    pub text_offset_y_first_page: Option<Length>,
46    /// Continuation-page paint-only vertical translation for matching row cells.
47    #[serde(default)]
48    pub text_offset_y_continuation: Option<Length>,
49    /// Minimum measured height for rows matching this rule.
50    #[serde(default)]
51    pub min_height: Option<Length>,
52    /// First-page minimum height for matching rows.
53    #[serde(default)]
54    pub min_height_first_page: Option<Length>,
55    /// Continuation-page minimum height for matching rows.
56    #[serde(default)]
57    pub min_height_continuation: Option<Length>,
58    /// Additional page capacity reserved after a matching anchored row.
59    #[serde(default)]
60    pub reserve_after: Option<Length>,
61}
62
63/// Declarative first-class table options.
64#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct TableSource {
67    /// Columns in stable visual order.
68    pub columns: Vec<TableColumn>,
69    /// Repeat the header on continuation pages.
70    #[serde(default = "default_true")]
71    pub repeat_header: bool,
72    /// Optional grouping field.
73    #[serde(default)]
74    pub group_by: Option<String>,
75    /// Optional row field that keeps contiguous equal values together when
76    /// they fit one page; oversized groups split between complete rows.
77    #[serde(default)]
78    pub keep_together_by: Option<String>,
79    /// Optional data field whose string values publish named row anchors.
80    #[serde(default)]
81    pub row_anchor_field: Option<String>,
82    /// Exact numeric total fields.
83    #[serde(default)]
84    pub total_fields: Vec<String>,
85    /// Ordered conditional row styles.
86    #[serde(default)]
87    pub conditional_styles: Vec<TableStyleRuleSource>,
88    /// Maximum rows sampled for auto columns.
89    #[serde(default = "default_auto_samples")]
90    pub auto_sample_rows: usize,
91    /// Optional stricter row limit.
92    #[serde(default)]
93    pub max_rows: Option<u64>,
94    /// Optional stricter field-count limit per row.
95    #[serde(default)]
96    pub max_row_fields: Option<usize>,
97    /// Optional stricter displayed byte limit per cell.
98    #[serde(default)]
99    pub max_cell_bytes: Option<usize>,
100    /// Header height.
101    #[serde(default = "default_header_height")]
102    pub header_height: Length,
103    /// Fixed row height or `auto` for measured rows.
104    #[serde(default)]
105    pub row_height: Option<Length>,
106    /// Optional first-page and continuation body regions inside the table bounds.
107    #[serde(default)]
108    pub page_bodies: Option<crate::TablePageBodies>,
109}
110
111pub(crate) fn convert_table(source: &TableSource, limits: &ResourceLimits) -> Result<TableIr> {
112    if matches!(source.header_height, Length::Auto) {
113        return Err(table_source_error("table header height cannot be auto"));
114    }
115    if source.page_bodies.as_ref().is_some_and(|regions| {
116        [
117            &regions.first.offset_y,
118            &regions.first.height,
119            &regions.continuation.offset_y,
120            &regions.continuation.height,
121        ]
122        .into_iter()
123        .any(|length| matches!(length, Length::Auto))
124    }) {
125        return Err(table_source_error(
126            "table page body offsets and heights cannot be auto",
127        ));
128    }
129    reject_raised_limit(source.max_rows, limits.max_rows, "table row")?;
130    reject_raised_limit(
131        source.max_row_fields,
132        limits.max_elements,
133        "table row field",
134    )?;
135    reject_raised_limit(
136        source.max_cell_bytes,
137        limits.max_text_bytes,
138        "table cell byte",
139    )?;
140    let spec = TableSpec {
141        columns: source.columns.clone(),
142        repeat_header: source.repeat_header,
143        group_by: source.group_by.clone(),
144        keep_together_by: source.keep_together_by.clone(),
145        row_anchor_field: source.row_anchor_field.clone(),
146        total_fields: source.total_fields.clone(),
147        conditional_styles: source
148            .conditional_styles
149            .iter()
150            .map(|rule| {
151                Ok(TableStyleRule {
152                    when: rule.when.clone(),
153                    style: convert_style(&rule.style)?,
154                    padding: rule.padding,
155                    padding_first_page: rule.padding_first_page,
156                    padding_continuation: rule.padding_continuation,
157                    text_offset_y: rule.text_offset_y,
158                    text_offset_y_first_page: rule.text_offset_y_first_page,
159                    text_offset_y_continuation: rule.text_offset_y_continuation,
160                    min_height: rule.min_height,
161                    min_height_first_page: rule.min_height_first_page,
162                    min_height_continuation: rule.min_height_continuation,
163                    reserve_after: rule.reserve_after,
164                })
165            })
166            .collect::<Result<Vec<_>>>()?,
167        style_expression_steps: limits.max_expression_steps,
168        auto_sample_rows: source.auto_sample_rows,
169        max_rows: source.max_rows.unwrap_or(limits.max_rows),
170        max_row_fields: source.max_row_fields.unwrap_or(limits.max_elements),
171        max_cell_bytes: source.max_cell_bytes.unwrap_or(limits.max_text_bytes),
172    };
173    spec.validate()?;
174    Ok(TableIr {
175        spec,
176        header_height: source.header_height,
177        row_height: source.row_height,
178        page_bodies: source.page_bodies.clone(),
179        rows: Vec::new(),
180    })
181}
182
183const fn default_true() -> bool {
184    true
185}
186
187const fn default_auto_samples() -> usize {
188    16
189}
190
191fn default_header_height() -> Length {
192    Length::Absolute(crate::Unit::from_raw(18_000_000))
193}
194
195fn table_source_error(message: impl Into<String>) -> FileMakerError {
196    FileMakerError::new(ErrorCode::SchemaField, message)
197}
198
199fn reject_raised_limit<T>(requested: Option<T>, global: T, label: &str) -> Result<()>
200where
201    T: Copy + PartialOrd,
202{
203    if requested.is_some_and(|value| value > global) {
204        return Err(FileMakerError::new(
205            ErrorCode::LimitExceeded,
206            format!("{label} limit cannot exceed the compiler limit"),
207        ));
208    }
209    Ok(())
210}