Skip to main content

appcore_filemaker/
table.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: 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 table contracts and behavior for this crate.
12
13use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16
17use crate::{
18    DataValue, ErrorCode, Expression, ExpressionBudget, FileMakerError, Length, Result, Style,
19};
20#[path = "table_dataset.rs"]
21mod dataset;
22pub use dataset::{BorrowedDataset, DataRow, Dataset, InMemoryDataset, StreamingDataset};
23#[path = "table_spec.rs"]
24mod spec;
25
26/// Table column sizing strategy.
27#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
28#[serde(
29    tag = "mode",
30    content = "value",
31    rename_all = "snake_case",
32    deny_unknown_fields
33)]
34pub enum ColumnWidth {
35    /// Exact width.
36    Fixed(Length),
37    /// Share of remaining width.
38    Flex(u32),
39    /// Width measured from bounded row samples.
40    Auto,
41}
42
43/// Per-column cell insets, resolved against each column and table body.
44#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
45#[serde(default, deny_unknown_fields)]
46pub struct CellPadding {
47    /// Top inset.
48    pub top: Length,
49    /// Right inset.
50    pub right: Length,
51    /// Bottom inset.
52    pub bottom: Length,
53    /// Left inset.
54    pub left: Length,
55}
56
57impl Default for CellPadding {
58    fn default() -> Self {
59        Self {
60            top: Length::Absolute(crate::Unit::ZERO),
61            right: Length::Absolute(crate::Unit::ZERO),
62            bottom: Length::Absolute(crate::Unit::ZERO),
63            left: Length::Absolute(crate::Unit::ZERO),
64        }
65    }
66}
67
68impl CellPadding {
69    fn is_valid(self) -> bool {
70        [self.top, self.right, self.bottom, self.left]
71            .into_iter()
72            .all(|length| match length {
73                Length::Absolute(value) => value >= crate::Unit::ZERO,
74                Length::Logical(value) => value >= 0,
75                Length::Percent(_) | Length::Auto => false,
76            })
77    }
78
79    /// Resolves absolute and caller-logical inset lengths.
80    pub fn resolve(self, logical_unit: crate::Unit) -> Result<crate::Insets> {
81        let resolve = |length| match length {
82            Length::Absolute(value) if value >= crate::Unit::ZERO => Ok(value),
83            Length::Logical(value) if value >= 0 => logical_unit.checked_scale(value),
84            _ => Err(table_error(
85                "cell padding must use nonnegative absolute or logical lengths",
86            )),
87        };
88        Ok(crate::Insets {
89            top: resolve(self.top)?,
90            right: resolve(self.right)?,
91            bottom: resolve(self.bottom)?,
92            left: resolve(self.left)?,
93        })
94    }
95}
96
97/// One first-class table column.
98#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
99#[serde(deny_unknown_fields)]
100pub struct TableColumn {
101    /// Stable field name.
102    pub field: String,
103    /// Header text.
104    pub header: String,
105    /// Sizing strategy.
106    pub width: ColumnWidth,
107    /// Inline alignment applied to every cell in this column.
108    #[serde(default)]
109    pub align_x: crate::Alignment,
110    /// Per-side padding applied to every header, body, and totals cell.
111    #[serde(default)]
112    pub padding: CellPadding,
113}
114
115/// Conditional data-row style evaluated without IO or exporter state.
116#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
117#[serde(deny_unknown_fields)]
118pub struct TableStyleRule {
119    /// Deterministic expression evaluated against the row object.
120    pub when: String,
121    /// Partial style applied when the expression is truthy.
122    pub style: Style,
123    /// Per-side cell padding applied to every cell in a matching row.
124    #[serde(default)]
125    pub padding: Option<CellPadding>,
126    /// First-page cell padding for rows matching this rule.
127    #[serde(default)]
128    pub padding_first_page: Option<CellPadding>,
129    /// Continuation-page cell padding for rows matching this rule.
130    #[serde(default)]
131    pub padding_continuation: Option<CellPadding>,
132    /// Paint-only vertical translation for text in matching row cells.
133    #[serde(default)]
134    pub text_offset_y: Option<Length>,
135    /// First-page paint-only vertical translation for matching row cells.
136    #[serde(default)]
137    pub text_offset_y_first_page: Option<Length>,
138    /// Continuation-page paint-only vertical translation for matching row cells.
139    #[serde(default)]
140    pub text_offset_y_continuation: Option<Length>,
141    /// Minimum measured height for matching rows.
142    #[serde(default)]
143    pub min_height: Option<Length>,
144    /// Minimum row height on the first physical page.
145    #[serde(default)]
146    pub min_height_first_page: Option<Length>,
147    /// Minimum row height on continuation pages.
148    #[serde(default)]
149    pub min_height_continuation: Option<Length>,
150    /// Additional page capacity reserved after a matching anchored row.
151    #[serde(default)]
152    pub reserve_after: Option<Length>,
153}
154
155/// Pagination and grouping contract for a table.
156#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
157pub struct TableSpec {
158    /// Columns in visual order.
159    pub columns: Vec<TableColumn>,
160    /// Repeat header after pagination.
161    pub repeat_header: bool,
162    /// Optional grouping field.
163    pub group_by: Option<String>,
164    /// Optional row field whose contiguous equal values are kept on one page
165    /// when the complete group fits; oversized groups split between rows.
166    #[serde(default)]
167    pub keep_together_by: Option<String>,
168    /// Optional row field whose string values publish named row-bound anchors.
169    #[serde(default)]
170    pub row_anchor_field: Option<String>,
171    /// Fields totaled as exact numeric values.
172    pub total_fields: Vec<String>,
173    /// Conditional row styles in stable declaration order.
174    pub conditional_styles: Vec<TableStyleRule>,
175    /// Per-row expression step budget shared by conditional rules.
176    pub style_expression_steps: usize,
177    /// Maximum rows sampled for automatic sizing.
178    pub auto_sample_rows: usize,
179    /// Maximum rows accepted from the dataset.
180    pub max_rows: u64,
181    /// Maximum fields accepted in one streamed row.
182    pub max_row_fields: usize,
183    /// Maximum displayed bytes accepted in one cell.
184    pub max_cell_bytes: usize,
185}
186
187/// One bounded table page delivered to a sink.
188#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
189pub struct TablePage {
190    /// Zero-based table page index.
191    pub index: usize,
192    /// Whether the header must be rendered on this page.
193    pub header: bool,
194    /// Rows bounded by the computed per-page capacity.
195    pub rows: Vec<DataRow>,
196    /// Measured height corresponding one-to-one with `rows`.
197    pub row_heights: Vec<crate::Unit>,
198    /// Computed conditional style corresponding one-to-one with `rows`.
199    pub row_styles: Vec<Style>,
200    /// Additional per-side padding corresponding one-to-one with `rows`.
201    #[serde(default)]
202    pub row_padding: Vec<CellPadding>,
203    /// Group key when each row begins a new group.
204    pub group_starts: Vec<Option<String>>,
205    /// Group key active at the first row, when configured.
206    pub starting_group: Option<String>,
207    /// Exact totals emitted only on the final table page.
208    pub totals: BTreeMap<String, DataValue>,
209}
210
211/// Streaming page consumer.
212pub trait TablePageSink {
213    /// Accepts one complete bounded page.
214    fn page(&mut self, page: TablePage) -> Result<()>;
215}
216
217/// Fixed-point bounded table pagination engine.
218pub struct TablePaginator {
219    /// Height available on each continuation page.
220    pub available_height: crate::Unit,
221    /// Header height.
222    pub header_height: crate::Unit,
223    /// Fixed row height after external text measurement.
224    pub row_height: crate::Unit,
225    /// Maximum generated pages.
226    pub max_pages: usize,
227}
228
229impl TablePaginator {
230    /// Streams paginated rows without retaining the entire dataset.
231    pub fn paginate(
232        &self,
233        spec: &TableSpec,
234        dataset: &dyn Dataset,
235        sink: &mut dyn TablePageSink,
236    ) -> Result<()> {
237        if self.row_height <= crate::Unit::ZERO {
238            return Err(table_error("fixed table row height must be positive"));
239        }
240        self.paginate_measured(spec, dataset, &mut |_| Ok(self.row_height), sink)
241    }
242
243    /// Streams rows using a deterministic externally measured height per row.
244    pub fn paginate_measured(
245        &self,
246        spec: &TableSpec,
247        dataset: &dyn Dataset,
248        measure: &mut dyn FnMut(&DataRow) -> Result<crate::Unit>,
249        sink: &mut dyn TablePageSink,
250    ) -> Result<()> {
251        self.paginate_measured_with_first_page_height(
252            spec,
253            dataset,
254            measure,
255            sink,
256            self.available_height,
257        )
258    }
259
260    pub(crate) fn paginate_measured_with_first_page_height(
261        &self,
262        spec: &TableSpec,
263        dataset: &dyn Dataset,
264        measure: &mut dyn FnMut(&DataRow) -> Result<crate::Unit>,
265        sink: &mut dyn TablePageSink,
266        first_page_height: crate::Unit,
267    ) -> Result<()> {
268        let mut contextual_measure = |_: usize, row: &DataRow| measure(row);
269        self.paginate_measured_with_page_index(
270            spec,
271            dataset,
272            &mut contextual_measure,
273            sink,
274            first_page_height,
275            false,
276        )
277    }
278
279    pub(crate) fn paginate_measured_with_page_index(
280        &self,
281        spec: &TableSpec,
282        dataset: &dyn Dataset,
283        measure: &mut dyn FnMut(usize, &DataRow) -> Result<crate::Unit>,
284        sink: &mut dyn TablePageSink,
285        first_page_height: crate::Unit,
286        page_sensitive: bool,
287    ) -> Result<()> {
288        if self.available_height <= crate::Unit::ZERO
289            || first_page_height <= crate::Unit::ZERO
290            || self.header_height < crate::Unit::ZERO
291            || self.max_pages == 0
292        {
293            return Err(table_error("table pagination dimensions are invalid"));
294        }
295        crate::table_group_pagination::paginate_measured(
296            self,
297            spec,
298            dataset,
299            measure,
300            sink,
301            first_page_height,
302            page_sensitive,
303        )
304    }
305
306    pub(crate) fn content_height(
307        &self,
308        spec: &TableSpec,
309        page_index: usize,
310        first_page_height: crate::Unit,
311    ) -> Result<crate::Unit> {
312        let available_height = if page_index == 0 {
313            first_page_height
314        } else {
315            self.available_height
316        };
317        if page_index == 0 || spec.repeat_header {
318            available_height.checked_sub(self.header_height)
319        } else {
320            Ok(available_height)
321        }
322    }
323}
324
325#[derive(Default)]
326pub(crate) struct PaginationState {
327    page_index: usize,
328    rows: Vec<DataRow>,
329    row_heights: Vec<crate::Unit>,
330    row_styles: Vec<Style>,
331    row_padding: Vec<CellPadding>,
332    group_starts: Vec<Option<String>>,
333    starting_group: Option<String>,
334    last_group: Option<String>,
335    used_height: crate::Unit,
336    totals: BTreeMap<String, DataValue>,
337}
338
339impl PaginationState {
340    pub(crate) fn push(
341        &mut self,
342        spec: &TableSpec,
343        row: &DataRow,
344        height: crate::Unit,
345        reserve_after: crate::Unit,
346    ) -> Result<()> {
347        let group = spec
348            .group_by
349            .as_ref()
350            .and_then(|field| row.get(field))
351            .map(DataValue::display);
352        if self.rows.is_empty() {
353            self.starting_group.clone_from(&group);
354        }
355        self.group_starts
356            .push((group != self.last_group).then(|| group.clone()).flatten());
357        self.last_group = group;
358        accumulate_totals(&mut self.totals, &spec.total_fields, row)?;
359        self.row_styles.push(spec.style_for(row)?);
360        self.row_padding
361            .push(spec.padding_for(row, self.page_index)?);
362        self.row_heights.push(height);
363        self.rows.push(row.clone());
364        self.used_height = self
365            .used_height
366            .checked_add(height)?
367            .checked_add(reserve_after)?;
368        Ok(())
369    }
370
371    pub(crate) fn flush(
372        &mut self,
373        spec: &TableSpec,
374        sink: &mut dyn TablePageSink,
375        max_pages: usize,
376        final_page: bool,
377    ) -> Result<()> {
378        if self.page_index >= max_pages {
379            return Err(FileMakerError::new(
380                ErrorCode::LimitExceeded,
381                "table page limit exceeded",
382            ));
383        }
384        sink.page(TablePage {
385            index: self.page_index,
386            header: self.page_index == 0 || spec.repeat_header,
387            rows: std::mem::take(&mut self.rows),
388            row_heights: std::mem::take(&mut self.row_heights),
389            row_styles: std::mem::take(&mut self.row_styles),
390            row_padding: std::mem::take(&mut self.row_padding),
391            group_starts: std::mem::take(&mut self.group_starts),
392            starting_group: self.starting_group.take(),
393            totals: if final_page {
394                std::mem::take(&mut self.totals)
395            } else {
396                BTreeMap::new()
397            },
398        })?;
399        self.page_index += 1;
400        self.used_height = crate::Unit::ZERO;
401        Ok(())
402    }
403
404    pub(crate) fn is_empty(&self) -> bool {
405        self.rows.is_empty()
406    }
407
408    pub(crate) fn can_fit(&self, height: crate::Unit, capacity: crate::Unit) -> Result<bool> {
409        Ok(self.used_height.checked_add(height)? <= capacity)
410    }
411
412    pub(crate) fn page_index(&self) -> usize {
413        self.page_index
414    }
415
416    pub(crate) fn skip_empty_page(&mut self) -> Result<()> {
417        if !self.rows.is_empty() {
418            return Err(table_error("cannot defer a non-empty table page"));
419        }
420        self.page_index = self
421            .page_index
422            .checked_add(1)
423            .ok_or_else(|| table_error("table page index overflow"))?;
424        Ok(())
425    }
426}
427
428fn accumulate_totals(
429    totals: &mut BTreeMap<String, DataValue>,
430    fields: &[String],
431    row: &DataRow,
432) -> Result<()> {
433    for field in fields {
434        let Some(value) = row.get(field) else {
435            continue;
436        };
437        if matches!(value, DataValue::Null) {
438            continue;
439        }
440        let next = add_total(totals.get(field), value)?;
441        totals.insert(field.clone(), next);
442    }
443    Ok(())
444}
445
446fn add_total(current: Option<&DataValue>, value: &DataValue) -> Result<DataValue> {
447    match (current, value) {
448        (None, DataValue::Integer(value)) => Ok(DataValue::Integer(*value)),
449        (None, DataValue::Decimal(value)) => Ok(DataValue::Decimal(*value)),
450        (None, DataValue::Currency(value)) => Ok(DataValue::Currency(value.clone())),
451        (Some(DataValue::Integer(left)), DataValue::Integer(right)) => left
452            .checked_add(*right)
453            .map(DataValue::Integer)
454            .ok_or_else(|| table_error("integer table total overflow")),
455        (Some(DataValue::Decimal(left)), DataValue::Decimal(right)) => left
456            .checked_add(*right)
457            .map(DataValue::Decimal)
458            .ok_or_else(|| table_error("decimal table total overflow")),
459        (Some(DataValue::Integer(left)), DataValue::Decimal(right)) => {
460            rust_decimal::Decimal::from(*left)
461                .checked_add(*right)
462                .map(DataValue::Decimal)
463                .ok_or_else(|| table_error("decimal table total overflow"))
464        }
465        (Some(DataValue::Decimal(left)), DataValue::Integer(right)) => left
466            .checked_add(rust_decimal::Decimal::from(*right))
467            .map(DataValue::Decimal)
468            .ok_or_else(|| table_error("decimal table total overflow")),
469        (Some(DataValue::Currency(left)), DataValue::Currency(right))
470            if left.code == right.code =>
471        {
472            left.amount
473                .checked_add(right.amount)
474                .map(|amount| {
475                    DataValue::Currency(crate::CurrencyValue {
476                        code: left.code.clone(),
477                        amount,
478                    })
479                })
480                .ok_or_else(|| table_error("currency table total overflow"))
481        }
482        _ => Err(table_error(
483            "table total field contains incompatible or non-numeric values",
484        )),
485    }
486}
487
488pub(super) fn table_error(message: impl Into<String>) -> FileMakerError {
489    FileMakerError::new(ErrorCode::DataType, message)
490}