From a3163d157492e635511787173cfb22af43e2d5fe Mon Sep 17 00:00:00 2001 From: Ben Pfaff Date: Sun, 5 Jan 2025 11:54:10 -0800 Subject: [PATCH] work on rendering --- rust/pspp/src/output/mod.rs | 1 + rust/pspp/src/output/pivot/mod.rs | 87 ++++-- rust/pspp/src/output/render.rs | 473 ++++++++++++++++++++++++++++++ rust/pspp/src/output/table.rs | 37 ++- 4 files changed, 566 insertions(+), 32 deletions(-) create mode 100644 rust/pspp/src/output/render.rs diff --git a/rust/pspp/src/output/mod.rs b/rust/pspp/src/output/mod.rs index efcddc8169..edf93bfd82 100644 --- a/rust/pspp/src/output/mod.rs +++ b/rust/pspp/src/output/mod.rs @@ -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. diff --git a/rust/pspp/src/output/pivot/mod.rs b/rust/pspp/src/output/pivot/mod.rs index 03d4331d63..fa55ae330f 100644 --- a/rust/pspp/src/output/pivot/mod.rs +++ b/rust/pspp/src/output/pivot/mod.rs @@ -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, +pub struct Look { + pub name: Option, - 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, - - /// Range of column widths for columns in the column headings , in 1/96" - /// units. - col_heading_widths: Range, + /// Ranges of column widths in the two heading regions, in 1/96" units. + pub heading_widths: EnumMap>, /// 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, + pub areas: EnumMap, /// Styles for borders in the pivot table. - borders: EnumMap, + pub borders: EnumMap, - print_all_layers: bool, + pub print_all_layers: bool, - paginate_layers: bool, + pub paginate_layers: bool, - shrink_to_fit: EnumMap, + pub shrink_to_fit: EnumMap, - top_continuation: bool, + pub top_continuation: bool, - bottom_continuation: bool, + pub bottom_continuation: bool, - continuation: Option, + pub continuation: Option, - 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 for Coord2 { } } +impl IndexMut 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>); @@ -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, + pub current_layer: Vec, /// Column and row sizing and page breaks. sizing: EnumMap, diff --git a/rust/pspp/src/output/render.rs b/rust/pspp/src/output/render.rs new file mode 100644 index 0000000000..d69b5d9013 --- /dev/null +++ b/rust/pspp/src/output/render.rs @@ -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, + + /// Width of different kinds of lines. + line_widths: EnumMap, + + /// 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, + + /// 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, + + /// 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); + + /// 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, + 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, + table: Arc, + + /// 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>, + + /// [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>, + + /// 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, + + /// 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, + + /// Minimum and maximum widths of columns based on their headings. + width_ranges: EnumMap>, +} + +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::>() + }); + + 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::>() + }); + 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, + + /// 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, + 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; 5]>, + + cur_page: usize, + x_break: Break, + y_break: Break, +} + +impl Pager { + pub fn new( + device: Box, + 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!() + } +} diff --git a/rust/pspp/src/output/table.rs b/rust/pspp/src/output/table.rs index ef194a83d2..ac5c65307f 100644 --- a/rust/pspp/src/output/table.rs +++ b/rust/pspp/src/output/table.rs @@ -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 { + 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> { -- 2.30.2