Skip to main content

acdc_parser/model/
tables.rs

1//! Table types for `AsciiDoc` documents.
2
3use serde::Serialize;
4
5use super::{
6    AttributeName, AttributeValue, Block, BlockMetadata, DocumentAttributeAssignment,
7    location::Location,
8};
9
10/// The outer border applied to a table.
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12#[non_exhaustive]
13pub enum TableFrame {
14    /// Draw all four outer edges.
15    #[default]
16    All,
17    /// Draw only the top and bottom edges.
18    Ends,
19    /// Draw only the left and right edges.
20    Sides,
21    /// Draw no outer edge.
22    None,
23}
24
25/// The rules drawn between table cells.
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27#[non_exhaustive]
28pub enum TableGrid {
29    /// Draw rules between rows and columns.
30    #[default]
31    All,
32    /// Draw rules only between rows.
33    Rows,
34    /// Draw rules only between columns.
35    Columns,
36    /// Draw no rules between cells.
37    None,
38}
39
40/// The body rows that receive a background fill.
41#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
42#[non_exhaustive]
43pub enum TableStripes {
44    /// Do not fill body rows.
45    #[default]
46    None,
47    /// Fill every body row.
48    All,
49    /// Fill odd body rows.
50    Odd,
51    /// Fill even body rows.
52    Even,
53    /// Fill the body row under the pointer in interactive output.
54    Hover,
55}
56
57/// Table decoration resolved from the attributes at the table's source position.
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
59#[non_exhaustive]
60pub struct TablePresentation {
61    frame: TableFrame,
62    grid: TableGrid,
63    stripes: TableStripes,
64}
65
66impl TablePresentation {
67    /// Resolve local table decoration and the document defaults in effect.
68    #[must_use]
69    pub fn from_attributes<'value>(
70        metadata: &'value BlockMetadata<'_>,
71        mut lookup: impl FnMut(&str) -> Option<&'value crate::DocumentAttributeValue<'value>>,
72    ) -> Self {
73        Self {
74            frame: resolve_table_attribute(metadata, &mut lookup, "frame", "table-frame").map_or(
75                TableFrame::All,
76                |value| match value {
77                    "all" => TableFrame::All,
78                    "ends" | "topbot" => TableFrame::Ends,
79                    "sides" => TableFrame::Sides,
80                    _ => TableFrame::None,
81                },
82            ),
83            grid: resolve_table_attribute(metadata, &mut lookup, "grid", "table-grid").map_or(
84                TableGrid::All,
85                |value| match value {
86                    "all" => TableGrid::All,
87                    "rows" => TableGrid::Rows,
88                    "cols" => TableGrid::Columns,
89                    _ => TableGrid::None,
90                },
91            ),
92            stripes: resolve_table_attribute(metadata, &mut lookup, "stripes", "table-stripes")
93                .map_or(TableStripes::None, |value| match value {
94                    "all" => TableStripes::All,
95                    "odd" => TableStripes::Odd,
96                    "even" => TableStripes::Even,
97                    "hover" => TableStripes::Hover,
98                    _ => TableStripes::None,
99                }),
100        }
101    }
102
103    /// The outer border applied to the table.
104    #[must_use]
105    pub const fn frame(self) -> TableFrame {
106        self.frame
107    }
108
109    /// The rules drawn between table cells.
110    #[must_use]
111    pub const fn grid(self) -> TableGrid {
112        self.grid
113    }
114
115    /// The body rows that receive a background fill.
116    #[must_use]
117    pub const fn stripes(self) -> TableStripes {
118        self.stripes
119    }
120}
121
122fn resolve_table_attribute<'value>(
123    metadata: &'value BlockMetadata<'_>,
124    lookup: &mut impl FnMut(&str) -> Option<&'value crate::DocumentAttributeValue<'value>>,
125    local_name: &str,
126    document_name: &str,
127) -> Option<&'value str> {
128    if let Some(value) = metadata.attributes.get(local_name) {
129        return match value {
130            AttributeValue::String(value) => Some(value),
131            AttributeValue::Bool(true) => Some(""),
132            AttributeValue::Bool(false) | AttributeValue::None => None,
133        };
134    }
135    let value = lookup(document_name)?;
136    if value.is_presence() {
137        Some("")
138    } else {
139        value.text()
140    }
141}
142
143/// Horizontal alignment for table cells
144#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)]
145#[serde(rename_all = "lowercase")]
146pub enum HorizontalAlignment {
147    #[default]
148    Left,
149    Center,
150    Right,
151}
152
153/// Vertical alignment for table cells
154#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)]
155#[serde(rename_all = "lowercase")]
156pub enum VerticalAlignment {
157    #[default]
158    Top,
159    Middle,
160    Bottom,
161}
162
163/// Column width specification
164#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
165#[serde(rename_all = "lowercase")]
166#[non_exhaustive]
167pub enum ColumnWidth {
168    /// Proportional width (e.g., 1, 2, 3 - relative to other columns)
169    Proportional(u32),
170    /// Percentage width (e.g., 15%, 30%, 55%)
171    Percentage(u32),
172    /// Auto-width - content determines width (~)
173    Auto,
174}
175
176impl Default for ColumnWidth {
177    fn default() -> Self {
178        ColumnWidth::Proportional(1)
179    }
180}
181
182/// Column content style
183#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)]
184#[serde(rename_all = "lowercase")]
185#[non_exhaustive]
186pub enum ColumnStyle {
187    /// `AsciiDoc` block content (a) - supports lists, blocks, macros
188    #[serde(rename = "asciidoc")]
189    AsciiDoc,
190    /// Default paragraph-level markup (d)
191    #[default]
192    Default,
193    /// Emphasis/italic (e)
194    Emphasis,
195    /// Header styling (h)
196    Header,
197    /// Literal block text (l)
198    Literal,
199    /// Monospace font (m)
200    Monospace,
201    /// Strong/bold (s)
202    Strong,
203}
204
205/// Column format specification for table formatting
206#[derive(Clone, Debug, Default, PartialEq, Serialize)]
207#[non_exhaustive]
208pub struct ColumnFormat {
209    #[serde(default, skip_serializing_if = "is_default_halign")]
210    pub halign: HorizontalAlignment,
211    #[serde(default, skip_serializing_if = "is_default_valign")]
212    pub valign: VerticalAlignment,
213    #[serde(default, skip_serializing_if = "is_default_width")]
214    pub width: ColumnWidth,
215    #[serde(default, skip_serializing_if = "is_default_style")]
216    pub style: ColumnStyle,
217}
218
219impl ColumnFormat {
220    /// Create a new column format with default values.
221    #[must_use]
222    pub fn new() -> Self {
223        Self::default()
224    }
225
226    /// Set the horizontal alignment.
227    #[must_use]
228    pub fn with_halign(mut self, halign: HorizontalAlignment) -> Self {
229        self.halign = halign;
230        self
231    }
232
233    /// Set the vertical alignment.
234    #[must_use]
235    pub fn with_valign(mut self, valign: VerticalAlignment) -> Self {
236        self.valign = valign;
237        self
238    }
239
240    /// Set the column width.
241    #[must_use]
242    pub fn with_width(mut self, width: ColumnWidth) -> Self {
243        self.width = width;
244        self
245    }
246
247    /// Set the column style.
248    #[must_use]
249    pub fn with_style(mut self, style: ColumnStyle) -> Self {
250        self.style = style;
251        self
252    }
253}
254
255#[allow(clippy::trivially_copy_pass_by_ref)]
256fn is_default_halign(h: &HorizontalAlignment) -> bool {
257    *h == HorizontalAlignment::default()
258}
259
260#[allow(clippy::trivially_copy_pass_by_ref)]
261fn is_default_valign(v: &VerticalAlignment) -> bool {
262    *v == VerticalAlignment::default()
263}
264
265#[allow(clippy::trivially_copy_pass_by_ref)]
266fn is_default_width(w: &ColumnWidth) -> bool {
267    *w == ColumnWidth::default()
268}
269
270#[allow(clippy::trivially_copy_pass_by_ref)]
271fn is_default_style(s: &ColumnStyle) -> bool {
272    *s == ColumnStyle::default()
273}
274
275pub(crate) fn are_all_columns_default(specs: &[ColumnFormat]) -> bool {
276    specs.iter().all(|s| *s == ColumnFormat::default())
277}
278
279/// A `Table` represents a table in a document.
280#[derive(Clone, Debug, PartialEq, Serialize)]
281#[non_exhaustive]
282pub struct Table<'a> {
283    pub header: Option<TableRow<'a>>,
284    pub footer: Option<TableRow<'a>>,
285    pub rows: Vec<TableRow<'a>>,
286    /// Column format specification for each column (alignment, width, style)
287    /// Skipped if all columns have default format
288    #[serde(default, skip_serializing_if = "are_all_columns_default")]
289    pub columns: Vec<ColumnFormat>,
290    #[serde(skip)]
291    presentation: Option<TablePresentation>,
292    pub location: Location,
293}
294
295impl<'a> Table<'a> {
296    /// Create a new table with the given rows and location.
297    #[must_use]
298    pub fn new(rows: Vec<TableRow<'a>>, location: Location) -> Self {
299        Self {
300            header: None,
301            footer: None,
302            rows,
303            columns: Vec::new(),
304            presentation: None,
305            location,
306        }
307    }
308
309    /// Set the header row.
310    #[must_use]
311    pub fn with_header(mut self, header: Option<TableRow<'a>>) -> Self {
312        self.header = header;
313        self
314    }
315
316    /// Set the footer row.
317    #[must_use]
318    pub fn with_footer(mut self, footer: Option<TableRow<'a>>) -> Self {
319        self.footer = footer;
320        self
321    }
322
323    /// Set the column format specifications.
324    #[must_use]
325    pub fn with_columns(mut self, columns: Vec<ColumnFormat>) -> Self {
326        self.columns = columns;
327        self
328    }
329
330    #[must_use]
331    pub(crate) fn with_presentation(mut self, presentation: TablePresentation) -> Self {
332        self.presentation = Some(presentation);
333        self
334    }
335
336    /// Table decoration that overrides converter defaults, if present.
337    #[must_use]
338    pub const fn presentation(&self) -> Option<TablePresentation> {
339        self.presentation
340    }
341}
342
343/// A row in a table, containing one or more columns (cells).
344///
345/// # Note on Field Name
346///
347/// The field is named `columns` (not `cells`) to align with the column-oriented
348/// table model. Each `TableColumn` represents one cell in this row.
349///
350/// ```
351/// # use acdc_parser::{TableRow, TableColumn};
352/// fn count_cells(row: &TableRow) -> usize {
353///     row.columns.len()  // Access cells via .columns
354/// }
355/// ```
356#[derive(Clone, Debug, PartialEq, Serialize)]
357#[non_exhaustive]
358pub struct TableRow<'a> {
359    /// The cells in this row (one per table column).
360    pub columns: Vec<TableColumn<'a>>,
361}
362
363impl<'a> TableRow<'a> {
364    /// Create a new table row with the given columns.
365    #[must_use]
366    pub fn new(columns: Vec<TableColumn<'a>>) -> Self {
367        Self { columns }
368    }
369}
370
371/// A `TableColumn` represents a column/cell in a table row.
372#[derive(Clone, Debug, PartialEq, Serialize)]
373#[non_exhaustive]
374pub struct TableColumn<'a> {
375    #[serde(skip)]
376    pub(crate) initial_attributes: Vec<(AttributeName<'a>, DocumentAttributeAssignment<'a>)>,
377    pub content: Vec<Block<'a>>,
378    /// Number of columns this cell spans (default 1).
379    /// Specified in `AsciiDoc` with `n+|` syntax (e.g., `2+|` for colspan=2).
380    #[serde(skip_serializing_if = "is_default_span")]
381    pub colspan: usize,
382    /// Number of rows this cell spans (default 1).
383    /// Specified in `AsciiDoc` with `.n+|` syntax (e.g., `.2+|` for rowspan=2).
384    #[serde(skip_serializing_if = "is_default_span")]
385    pub rowspan: usize,
386    /// Cell-level horizontal alignment override.
387    /// Specified with `<`, `^`, or `>` in cell specifier (e.g., `^|` for center).
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub halign: Option<HorizontalAlignment>,
390    /// Cell-level vertical alignment override.
391    /// Specified with `.<`, `.^`, or `.>` in cell specifier (e.g., `.>|` for bottom).
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub valign: Option<VerticalAlignment>,
394    /// Cell-level style override.
395    /// Specified with style letter after operator (e.g., `s|` for strong/bold).
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub style: Option<ColumnStyle>,
398}
399
400#[allow(clippy::trivially_copy_pass_by_ref)]
401const fn is_default_span(span: &usize) -> bool {
402    *span == 1
403}
404
405impl<'a> TableColumn<'a> {
406    /// Attribute assignments applied before this `AsciiDoc` cell's content.
407    ///
408    /// These initialize the nested scope; they do not change the parent document.
409    pub fn initial_attributes(
410        &self,
411    ) -> impl Iterator<Item = (&str, &DocumentAttributeAssignment<'a>)> {
412        self.initial_attributes
413            .iter()
414            .map(|(name, assignment)| (name.as_ref(), assignment))
415    }
416
417    /// Create a new table column with full cell specifier options.
418    #[must_use]
419    pub(crate) fn with_format(
420        content: Vec<Block<'a>>,
421        colspan: usize,
422        rowspan: usize,
423        halign: Option<HorizontalAlignment>,
424        valign: Option<VerticalAlignment>,
425        style: Option<ColumnStyle>,
426    ) -> Self {
427        Self {
428            initial_attributes: Vec::new(),
429            content,
430            colspan,
431            rowspan,
432            halign,
433            valign,
434            style,
435        }
436    }
437}