work on rendering
authorBen Pfaff <blp@cs.stanford.edu>
Sun, 5 Jan 2025 19:54:10 +0000 (11:54 -0800)
committerBen Pfaff <blp@cs.stanford.edu>
Sun, 5 Jan 2025 19:54:10 +0000 (11:54 -0800)
rust/pspp/src/output/mod.rs
rust/pspp/src/output/pivot/mod.rs
rust/pspp/src/output/render.rs [new file with mode: 0644]
rust/pspp/src/output/table.rs

index efcddc8169e86d3345d12400a1f905374d626a63..edf93bfd82055cfe719b072365fa62937f86170b 100644 (file)
@@ -11,6 +11,7 @@ pub mod csv;
 pub mod driver;
 pub mod page;
 pub mod pivot;
+pub mod render;
 pub mod table;
 
 /// A single output item.
index 03d4331d6310711df18efd6727794499ad77c294..fa55ae330f5e5a724311bd45ed9446656a2fb050 100644 (file)
@@ -59,7 +59,7 @@ use std::{
     collections::HashMap,
     fmt::{Display, Write},
     iter::{once, repeat},
-    ops::{Index, Not, Range},
+    ops::{Index, IndexMut, Not, Range, RangeInclusive},
     str::from_utf8,
     sync::{Arc, OnceLock, Weak},
 };
@@ -502,45 +502,40 @@ impl CategoryTrait for Category {
 /// arbitrary.  The ultimate reason for the division is simply because that's
 /// how SPSS documentation and file formats do it.
 #[derive(Clone, Debug)]
-struct Look {
-    name: Option<String>,
+pub struct Look {
+    pub name: Option<String>,
 
-    omit_empty: bool,
-    row_labels_in_corner: bool,
+    pub omit_empty: bool,
+    pub row_labels_in_corner: bool,
 
-    /// Range of column widths for columns in the row headings and corner , in 1/96"
-    /// units.
-    row_heading_widths: Range<usize>,
-
-    /// Range of column widths for columns in the column headings , in 1/96"
-    /// units.
-    col_heading_widths: Range<usize>,
+    /// Ranges of column widths in the two heading regions, in 1/96" units.
+     pub heading_widths: EnumMap<HeadingRegion, RangeInclusive<usize>>,
 
     /// Kind of markers to use for footnotes.
-    footnote_marker_type: FootnoteMarkerType,
+    pub footnote_marker_type: FootnoteMarkerType,
 
     /// Where to put the footnote markers.
-    footnote_marker_position: FootnoteMarkerPosition,
+    pub footnote_marker_position: FootnoteMarkerPosition,
 
     /// Styles for areas of the pivot table.
-    areas: EnumMap<Area, AreaStyle>,
+    pub areas: EnumMap<Area, AreaStyle>,
 
     /// Styles for borders in the pivot table.
-    borders: EnumMap<Border, BorderStyle>,
+    pub borders: EnumMap<Border, BorderStyle>,
 
-    print_all_layers: bool,
+    pub print_all_layers: bool,
 
-    paginate_layers: bool,
+    pub paginate_layers: bool,
 
-    shrink_to_fit: EnumMap<Axis2, bool>,
+    pub shrink_to_fit: EnumMap<Axis2, bool>,
 
-    top_continuation: bool,
+    pub top_continuation: bool,
 
-    bottom_continuation: bool,
+    pub bottom_continuation: bool,
 
-    continuation: Option<String>,
+    pub continuation: Option<String>,
 
-    n_orphan_lines: usize,
+    pub n_orphan_lines: usize,
 }
 
 impl Default for Look {
@@ -549,8 +544,10 @@ impl Default for Look {
             name: None,
             omit_empty: true,
             row_labels_in_corner: true,
-            row_heading_widths: 36..72,
-            col_heading_widths: 36..120,
+            heading_widths: EnumMap::from_fn(|region| match region {
+                HeadingRegion::RowHeadings => 36..=72,
+                HeadingRegion::ColumnHeadings => 36..=120,
+            }),
             footnote_marker_type: FootnoteMarkerType::default(),
             footnote_marker_position: FootnoteMarkerPosition::default(),
             areas: EnumMap::from_fn(Area::default_area_style),
@@ -573,6 +570,25 @@ impl Look {
     }
 }
 
+/// The heading region of a rendered pivot table:
+///
+/// ```text
+/// +------------------+-------------------------------------------------+
+/// |                  |                  column headings                |
+/// |                  +-------------------------------------------------+
+/// |      corner      |                                                 |
+/// |       and        |                                                 |
+/// |   row headings   |                      data                       |
+/// |                  |                                                 |
+/// |                  |                                                 |
+/// +------------------+-------------------------------------------------+
+/// ```
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Enum)]
+pub enum HeadingRegion {
+    RowHeadings,
+    ColumnHeadings,
+}
+
 #[derive(Clone, Debug)]
 pub struct AreaStyle {
     cell_style: CellStyle,
@@ -667,11 +683,11 @@ impl Color {
 
 #[derive(Copy, Clone, Debug)]
 pub struct BorderStyle {
-    stroke: Stroke,
-    color: Color,
+    pub stroke: Stroke,
+    pub color: Color,
 }
 
-#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Enum)]
 pub enum Stroke {
     None,
     Solid,
@@ -721,6 +737,13 @@ impl Coord2 {
         })
     }
 
+    pub fn for_axis((a, az): (Axis2, usize), bz: usize) -> Self {
+        let mut coord = Self::default();
+        coord[a] = az;
+        coord[!a] = bz;
+        coord
+    }
+
     pub fn x(&self) -> usize {
         self.0[Axis2::X]
     }
@@ -748,6 +771,12 @@ impl Index<Axis2> for Coord2 {
     }
 }
 
+impl IndexMut<Axis2> for Coord2 {
+    fn index_mut(&mut self, index: Axis2) -> &mut Self::Output {
+        &mut self.0[index]
+    }
+}
+
 #[derive(Clone, Debug, Default)]
 pub struct Rect2(pub EnumMap<Axis2, Range<usize>>);
 
@@ -833,7 +862,7 @@ pub struct PivotTable {
     /// `axes[Axis3::Z].dimensions[i].data_leaves[]`, except that a dimension
     /// can have zero leaves, in which case `current_layer[i]` is zero and
     /// there's no corresponding leaf.
-    current_layer: Vec<usize>,
+    pub current_layer: Vec<usize>,
 
     /// Column and row sizing and page breaks.
     sizing: EnumMap<Axis2, Sizing>,
diff --git a/rust/pspp/src/output/render.rs b/rust/pspp/src/output/render.rs
new file mode 100644 (file)
index 0000000..d69b5d9
--- /dev/null
@@ -0,0 +1,473 @@
+use std::collections::HashMap;
+use std::ops::RangeInclusive;
+use std::sync::Arc;
+
+use enum_map::EnumMap;
+use smallvec::SmallVec;
+
+use super::pivot::{Axis2, BorderStyle, Coord2, HeadingRegion, Look, PivotTable, Rect2, Stroke};
+use super::table::{Cell, CellInner, Table};
+
+/// Parameters for rendering a table_item to a device.
+///
+///
+/// # Coordinate system
+///
+/// The rendering code assumes that larger `x` is to the right and larger `y`
+/// toward the bottom of the page.
+///
+/// The rendering code assumes that the table being rendered has its upper left
+/// corner at (0,0) in device coordinates.  This is usually not the case from
+/// the driver's perspective, so the driver should expect to apply its own
+/// offset to coordinates passed to callback functions.
+pub struct Params {
+    /// Page size to try to fit the rendering into.  Some tables will, of
+    /// course, overflow this size.
+    size: Coord2,
+
+    /// Nominal size of a character in the most common font:
+    /// `font_size[Axis2::X]` is the em width.
+    /// `font_size[Axis2::Y]` is the line leading.
+    font_size: EnumMap<Axis2, usize>,
+
+    /// Width of different kinds of lines.
+    line_widths: EnumMap<Stroke, usize>,
+
+    /// 1/96" of an inch (1px) in the rendering unit.  Currently used only for
+    /// column width ranges, as in `width_ranges` in
+    /// [crate::output::pivot::Look].  Set to `None` to disable this feature.
+    px_size: Option<usize>,
+
+    /// Minimum cell width or height before allowing the cell to be broken
+    /// across two pages.  (Joined cells may always be broken at join
+    /// points.)
+    min_break: EnumMap<Axis2, usize>,
+
+    /// True if the driver supports cell margins.  (If false, the rendering
+    /// engine will insert a small space betweeen adjacent cells that don't have
+    /// an intervening rule.)
+    supports_margins: bool,
+
+    /// True if the local language has a right-to-left direction, otherwise
+    /// false.
+    rtl: bool,
+
+    /// True if the table is being rendered for printing (as opposed to
+    /// on-screen display).
+    printing: bool,
+
+    /// Whether [RenderOps::adjust_break] is implemented.
+    can_adjust_break: bool,
+
+    /// Whether [RenderOps::scale] is implemented.
+    can_scale: bool,
+}
+
+pub trait Device {
+    fn params(&self) -> &Params;
+
+    /// Measures `cell`'s width.  Returns an arary `[min_width, max_width]`,
+    /// where `min_width` is the minimum width required to avoid splitting a
+    /// single word across multiple lines (normally, this is the width of the
+    /// longest word in the cell) and `max_width` is the minimum width required
+    /// to avoid line breaks other than at new-lines.
+    fn measure_cell_width(&self, cell: &CellInner) -> [usize; 2];
+
+    /// Returns the height required to render `cell` given a width of `width`.
+    fn measure_cell_height(&self, cell: &CellInner, width: usize) -> usize;
+
+    /// Given that there is space measuring `width` by `height` to render
+    /// `cell`, where `height` is insufficient to render the entire height of
+    /// the cell, returns the largest height less than `height` at which it is
+    /// appropriate to break the cell.  For example, if breaking at the
+    /// specified `height` would break in the middle of a line of text, the
+    /// return value would be just sufficiently less that the breakpoint would
+    /// be between lines of text.
+    ///
+    /// Optional.  If [RenderParams::can_adjust_break] is false, the rendering
+    /// engine assumes that all breakpoints are acceptable.
+    fn adjust_break(&self, cell: &Cell, size: Coord2) -> usize;
+
+    /// Draws a generalized intersection of lines in `bb`.
+    ///
+    /// `styles` is interpreted this way:
+    ///
+    /// `styles[Axis2::X][0]`: style of line from top of `bb` to its center.
+    /// `styles[Axis2::X][1]`: style of line from bottom of `bb` to its center.
+    /// `styles[Axis2::Y][0]`: style of line from left of `bb` to its center.
+    /// `styles[Axis2::Y][1]`: style of line from right of `bb` to its center.
+    fn draw_line(&mut self, bb: Rect2, styles: EnumMap<Axis2, [BorderStyle; 2]>);
+
+    /// Draws `cell` within bounding box `bb`.  `clip` is the same as `bb` (the
+    /// common case) or a subregion enclosed by `bb`.  In the latter case only
+    /// the part of the cell that lies within `clip` should actually be drawn,
+    /// although `bb` should used to determine the layout of the cell.
+    ///
+    /// The text in the cell needs to be vertically offset `valign_offset` units
+    /// from the top of the bounding box.  This handles vertical alignment with
+    /// the cell.  (The caller doesn't just reduce the bounding box size because
+    /// that would prevent the implementation from filling the entire cell with
+    /// the background color.)  The implementation must handle horizontal
+    /// alignment itself.
+    fn draw_cell(
+        &mut self,
+        cell: &Cell,
+        color_idx: usize,
+        bb: &Rect2,
+        valign_offset: usize,
+        spill: EnumMap<Axis2, [usize; 2]>,
+        clip: &Rect2,
+    );
+
+    /// Scales all output by `factor`, e.g. a `factor` of 0.5 would cause
+    /// everything subsequent to be drawn half-size.  `factor` will be greater
+    /// than 0 and less than or equal to 1.
+    ///
+    /// Optional.  If [RenderParams::can_scale] is false, the rendering engine
+    /// won't try to scale output.
+    fn scale(&mut self, factor: f64);
+}
+
+/// A layout for rendering a specific table on a specific device.
+///
+/// May represent the layout of an entire table presented to [Pager::new], or a
+/// rectangular subregion of a table broken out using [Break::next] to allow a
+/// table to be broken across multiple pages.
+///
+/// A page's size is not limited to the size passed in as part of [Params].
+/// [Pager] breaks a [Page] into smaller [page]s that will fit in the available
+/// space.
+struct Page {
+    params: Arc<Params>,
+    table: Arc<Table>,
+
+    /// Region of `table` to render.
+    ///
+    /// The horizontal cells rendered are the leftmost h[H][0], then
+    /// r[H][0] through r[H][1], exclusive, then the rightmost h[H][1].
+    ///
+    /// The vertical cells rendered are the topmost h[V][0], then r[V][0]
+    /// through r[V][1], exclusive, then the bottommost h[V][1].
+    ///
+    /// n[H] = h[H][0] + (r[H][1] - r[H][0]) + h[H][1]
+    /// n[V] = h[V][0] + (r[V][1] - r[V][0]) + h[V][1]
+    n: Coord2,
+    /*
+    int h[TABLE_N_AXES][2];
+    int r[TABLE_N_AXES][2];
+    int n[TABLE_N_AXES];*/
+    /// "Cell positions".
+    ///
+    /// cp[H] represents x positions within the table.
+    /// cp[H][0] = 0.
+    /// cp[H][1] = the width of the leftmost vertical rule.
+    /// cp[H][2] = cp[H][1] + the width of the leftmost column.
+    /// cp[H][3] = cp[H][2] + the width of the second-from-left vertical rule.
+    /// and so on:
+    /// cp[H][2 * n[H]] = x position of the rightmost vertical rule.
+    /// cp[H][2 * n[H] + 1] = total table width including all rules.
+    ///
+    /// Similarly, cp[V] represents y positions within the table.
+    /// cp[V][0] = 0.
+    /// cp[V][1] = the height of the topmost horizontal rule.
+    /// cp[V][2] = cp[V][1] + the height of the topmost row.
+    /// cp[V][3] = cp[V][2] + the height of the second-from-top horizontal rule.
+    /// and so on:
+    /// cp[V][2 * n[V]] = y position of the bottommost horizontal rule.
+    /// cp[V][2 * n[V] + 1] = total table height including all rules.
+    ///
+    /// Rules and columns can have width or height 0, in which case consecutive
+    /// values in this array are equal.
+    cp: EnumMap<Axis2, Vec<usize>>,
+
+    /// [Break::next] can break a table such that some cells are not fully
+    /// contained within a render_page.  This will happen if a cell is too wide
+    /// or two tall to fit on a single page, or if a cell spans multiple rows
+    /// or columns and the page only includes some of those rows or columns.
+    ///
+    /// This hash table contains represents each such cell that doesn't
+    /// completely fit on this page.
+    ///
+    /// Each overflow cell borders at least one header edge of the table and may
+    /// border more.  (A single table cell that is so large that it fills the
+    /// entire page can overflow on all four sides!)
+    ///
+    /// # Interpretation
+    ///
+    /// overflow[H][0]: space trimmed off its left side.
+    /// overflow[H][1]: space trimmed off its right side.
+    /// overflow[V][0]: space trimmed off its top.
+    /// overflow[V][1]: space trimmed off its bottom.
+    ///
+    /// During rendering, this information is used to position the rendered
+    /// portion of the cell within the available space.
+    ///
+    /// When a cell is rendered, sometimes it is permitted to spill over into
+    /// space that is ordinarily reserved for rules.  Either way, this space is
+    /// still included in overflow values.
+    ///
+    /// Suppose, for example, that a cell that joins 2 columns has a width of 60
+    /// pixels and content "abcdef", that the 2 columns that it joins have
+    /// widths of 20 and 30 pixels, respectively, and that therefore the rule
+    /// between the two joined columns has a width of 10 (20 + 10 + 30 = 60).
+    /// It might render like this, if each character is 10x10, and showing a few
+    /// extra table cells for context:
+    ///
+    /// ```text
+    /// +------+
+    /// |abcdef|
+    /// +--+---+
+    /// |gh|ijk|
+    /// +--+---+
+    /// ```
+    ///
+    /// If this render_page is broken at the rule that separates "gh" from
+    /// "ijk", then the page that contains the left side of the "abcdef" cell
+    /// will have overflow[H][1] of 10 + 30 = 40 for its portion of the cell,
+    /// and the page that contains the right side of the cell will have
+    /// overflow[H][0] of 20 + 10 = 30.  The two resulting pages would look like
+    /// this:
+    ///
+    /// ```text
+    /// +---
+    /// |abc
+    /// +--+
+    /// |gh|
+    /// +--+
+    /// ```
+    ///
+    /// and:
+    ///
+    /// ```text
+    /// ----+
+    /// cdef|
+    /// +---+
+    /// |ijk|
+    /// +---+
+    /// ```
+    /// Each entry maps from a cell that overflows to the space that has been
+    /// trimmed off the cell:
+    overflows: HashMap<Coord2, EnumMap<Axis2, [usize; 2]>>,
+
+    /// If a single column (or row) is too wide (or tall) to fit on a page
+    /// reasonably, then render_break_next() will split a single row or column
+    /// across multiple render_pages.  This member indicates when this has
+    /// happened:
+    ///
+    /// is_edge_cutoff[H][0] is true if pixels have been cut off the left side
+    /// of the leftmost column in this page, and false otherwise.
+    ///
+    /// is_edge_cutoff[H][1] is true if pixels have been cut off the right side
+    /// of the rightmost column in this page, and false otherwise.
+    ///
+    /// is_edge_cutoff[V][0] and is_edge_cutoff[V][1] are similar for the top
+    /// and bottom of the table.
+    ///
+    /// The effect of is_edge_cutoff is to prevent rules along the edge in
+    /// question from being rendered.
+    ///
+    /// When is_edge_cutoff is true for a given edge, the 'overflows' hmap will
+    /// contain a node for each cell along that edge.
+    is_edge_cutoff: EnumMap<Axis2, [bool; 2]>,
+
+    /// If part of a joined cell would be cut off by breaking a table along
+    /// 'axis' at the rule with offset 'z' (where 0 <= z <= n[axis]), then
+    /// join_crossing[axis][z] is the thickness of the rule that would be cut
+    /// off.
+    ///
+    /// This is used to know to allocate extra space for breaking at such a
+    /// position, so that part of the cell's content is not lost.
+    ///
+    /// This affects breaking a table only when headers are present.  When
+    /// headers are not present, the rule's thickness is used for cell content,
+    /// so no part of the cell's content is lost (and in fact it is duplicated
+    /// across both pages).
+    join_crossing: EnumMap<Axis2, usize>,
+
+    /// Minimum and maximum widths of columns based on their headings.
+    width_ranges: EnumMap<HeadingRegion, RangeInclusive<usize>>,
+}
+
+impl Page {
+    /// Creates and returns a new [Page] for rendering `table` with the given
+    /// `look` on `device`.
+    ///
+    /// The new [Page] will be suitable for rendering on a device whose page
+    /// size is `params.size`, but the caller is responsible for actually
+    /// breaking it up to fit on such a device, using the [Break] abstraction.
+    fn new(table: &Table, device: &dyn Device, min_width: usize, look: &Look) -> Self {
+        // Figure out rule widths.
+        let rules = EnumMap::from_fn(|axis| {
+            let n = table.n[axis];
+            (0..n)
+                .map(|z| measure_rule(table, device, axis, z))
+                .collect::<Vec<_>>()
+        });
+
+        let px_size = device.params().px_size.unwrap_or_default();
+        let heading_widths = look
+            .heading_widths
+            .clone()
+            .map(|region, range| [*range.start() * px_size, *range.end() * px_size]);
+
+        // Calculate minimum and maximum widths of cells that do not span
+        // multiple columns.
+        let mut columns = [vec![0; table.n.x()], vec![0; table.n.x()]];
+        for y in 0..table.n[Axis2::Y] {
+            for x in table.iter_x(y) {
+                let coord = Coord2::new(x, y);
+                let contents = table.get(coord);
+                if !contents.is_top_left(coord) || contents.col_span() != 1 {
+                    continue;
+                }
+
+                let mut w = device.measure_cell_width(contents.inner());
+                if let Some(px_size) = device.params().px_size {
+                    if let Some(region) = table.heading_region(coord) {
+                        let wr = &heading_widths[region];
+                        if w[0] < wr[0] {
+                            w[0] = wr[0];
+                            if w[0] > w[1] {
+                                w[1] = w[0];
+                            }
+                        } else if w[1] > wr[1] {
+                            w[1] = wr[1];
+                            if w[1] < w[0] {
+                                w[0] = w[1];
+                            }
+                        }
+                    }
+                }
+
+                for i in 0..2 {
+                    if columns[i][x] < w[i] {
+                        columns[i][x] = w[i];
+                    }
+                }
+            }
+        }
+
+        // Distribute widths of spanned columns.
+        let columns = columns.map(|widths| {
+            widths
+                .into_iter()
+                .map(|width| Row {
+                    unspanned: width,
+                    width,
+                })
+                .collect::<Vec<_>>()
+        });
+        for y in 0..table.n[Axis2::Y] {
+            for x in table.iter_x(y) {
+                let coord = Coord2::new(x, y);
+                let contents = table.get(coord);
+                if !contents.is_top_left(coord) || contents.col_span() == 1 {
+                    continue;
+                }
+
+                let w = device.measure_cell_width(contents.inner());
+                for i in 0..2 {
+                    //distribute_spanned_width(w[i],
+                }
+            }
+        }
+
+        todo!()
+    }
+}
+
+/// Row or column dimension.
+#[derive(Clone, Default)]
+struct Row {
+    /// Width without considering rows (or columns) that span more than one row
+    /// (or column).
+    unspanned: usize,
+
+    /// Width take spanned rows (or columns) into consideration.
+    width: usize,
+}
+
+/// Returns the width of the rule in `table` that is at offset `z` along axis
+/// `a`, if rendered on `device`.
+fn measure_rule(table: &Table, device: &dyn Device, a: Axis2, z: usize) -> usize {
+    let b = !a;
+
+    // Determine the types of rules that are present.
+    let mut rules = EnumMap::default();
+    for w in 0..table.n[b] {
+        let stroke = table.get_rule(a, Coord2::for_axis((a, z), w)).stroke;
+        rules[stroke] = true;
+    }
+
+    // Turn off [Stroke::None] because it has width 0 and we needn't bother.
+    // However, if the device doesn't support margins, make sure that there is
+    // at least a small gap between cells (but we don't need any at the left or
+    // right edge of the table).
+    if rules[Stroke::None] {
+        rules[Stroke::None] = false;
+        if z > 0 && z < table.n[a] && !device.params().supports_margins && a == Axis2::X {
+            rules[Stroke::Solid] = true;
+        }
+    }
+
+    // Calculate maximum width of rules that are present.
+    let line_widths = &device.params().line_widths;
+    rules
+        .into_iter()
+        .map(
+            |(rule, present)| {
+                if present {
+                    line_widths[rule]
+                } else {
+                    0
+                }
+            },
+        )
+        .max()
+        .unwrap_or(0)
+}
+
+struct Break {
+    page: Arc<Page>,
+
+    /// Axis along which `page` is being broken.
+    axis: Axis2,
+
+    /// Next cell along `axis`.
+    z: usize,
+
+    /// Pixel offset within cell `z` (usually 0).
+    pixel: usize,
+
+    /// Width of headers of `page` along `axis`.
+    hw: usize,
+}
+
+pub struct Pager {
+    device: Box<dyn Device>,
+    scale: f64,
+
+    /// [Page]s to be rendered, in order, vertically.  There may be up to 5
+    /// pages, for the pivot table's title, layers, body, captions, and
+    /// footnotes.
+    pages: SmallVec<[Box<Page>; 5]>,
+
+    cur_page: usize,
+    x_break: Break,
+    y_break: Break,
+}
+
+impl Pager {
+    pub fn new(
+        device: Box<dyn Device>,
+        pivot_table: &PivotTable,
+        layer_indexes: Option<&[usize]>,
+    ) -> Self {
+        let output = pivot_table.output(
+            layer_indexes.unwrap_or(&pivot_table.current_layer),
+            device.params().printing,
+        );
+
+        todo!()
+    }
+}
index ef194a83d298daa28a86408382ecf90142847a42..ac5c65307f27121a8dbe8577b0ac1c5e4ce8421a 100644 (file)
@@ -17,7 +17,7 @@ use enum_map::{enum_map, EnumMap};
 use crate::output::pivot::Coord2;
 
 use super::pivot::{
-    Area, AreaStyle, Axis2, Border, BorderStyle, CellStyle, FontStyle, Rect2, Value,
+    Area, AreaStyle, Axis2, Border, BorderStyle, CellStyle, FontStyle, HeadingRegion, Rect2, Value,
 };
 
 #[derive(Clone)]
@@ -66,6 +66,13 @@ impl Content {
     pub fn is_top_left(&self, coord: Coord2) -> bool {
         self.region().map_or(true, |r| coord == r.top_left())
     }
+
+    pub fn col_span(&self) -> usize {
+        self.region().map_or(1, |r| {
+            let range = &r.0[Axis2::X];
+            range.end - range.start
+        })
+    }
 }
 
 #[derive(Clone)]
@@ -158,6 +165,18 @@ impl Table {
         &self.contents[self.offset(pos)]
     }
 
+    pub fn get_rule(&self, axis: Axis2, pos: Coord2) -> BorderStyle {
+        debug_assert!(pos.x() < self.n[Axis2::X] + (axis == Axis2::X) as usize);
+        debug_assert!(pos.y() < self.n[Axis2::Y] + (axis == Axis2::Y) as usize);
+
+        let border = if axis == Axis2::Y {
+            self.rules[axis][pos.x() + self.n.x() * pos.y()]
+        } else {
+            self.rules[axis][pos.x() + (self.n.x() + 1) * pos.y()]
+        };
+        self.borders[border]
+    }
+
     pub fn put(&mut self, region: Rect2, inner: CellInner) {
         use Axis2::*;
         if region[X].len() == 1 && region[Y].len() == 1 {
@@ -216,13 +235,25 @@ impl Table {
     pub fn visit_cells(&self, mut f: impl FnMut(&CellInner)) {
         for y in 0..self.n.y() {
             for x in self.iter_x(y) {
-                let content = self.get(Coord2::new(x, y));
-                if !content.is_empty() {
+                let coord = Coord2::new(x, y);
+                let content = self.get(coord);
+                if !content.is_empty() && content.is_top_left(coord) {
                     f(content.inner());
                 }
             }
         }
     }
+
+    /// The heading region that `pos` is part of, if any.
+    pub fn heading_region(&self, pos: Coord2) -> Option<HeadingRegion> {
+        if pos.x() < self.headers.x() {
+            Some(HeadingRegion::RowHeadings)
+        } else if pos.y() < self.headers.y() {
+            Some(HeadingRegion::ColumnHeadings)
+        } else {
+            None
+        }
+    }
 }
 
 pub struct XIter<'a> {