Skip to main content

hwpforge_core/table/
grid.rs

1//! Format-neutral logical grid derivation for tables.
2//!
3//! A table's cells carry only spans (`col_span`/`row_span`); their absolute
4//! grid positions are implicit in row/cell order. This module derives the
5//! **pre-merge logical grid** — the same coordinate system used by document
6//! formats on the wire — from Core data alone, using a row-major greedy
7//! placement scan (cursor per row, skipping positions occupied by spans from
8//! previous rows).
9//!
10//! Two surfaces share one placement algorithm:
11//!
12//! - [`TableGrid::from_table`] — **strict**: fails on any tiling violation
13//!   (overlap, hole, bottom overhang, oversized grid). Addressing surfaces
14//!   (export, cell editing) use this so addresses are only ever derived from
15//!   well-formed grids.
16//! - [`grid_placements`] — **lenient**: mirrors the historical encoder
17//!   behaviour exactly, performing no validation. Format encoders use this so
18//!   byte output for existing documents (including malformed tables) never
19//!   changes.
20//!
21//! The grid validates only what Core can see. Wire-level addresses that a
22//! source format may have carried are not compared here (decoders normalize
23//! into Core before this module runs).
24
25use super::Table;
26
27/// Maximum number of logical grid positions (`rows × cols`) a grid may have.
28///
29/// Real-world documents are far below this (the largest table observed in a
30/// 3,999-file government corpus has 414 logical positions); the cap exists to
31/// stop pathological spans (e.g. `65535×65535`) from exhausting memory.
32pub const MAX_GRID_POSITIONS: u64 = 1_048_576;
33
34/// Absolute position on the pre-merge logical grid, zero-based.
35#[derive(
36    Debug,
37    Clone,
38    Copy,
39    PartialEq,
40    Eq,
41    PartialOrd,
42    Ord,
43    Hash,
44    serde::Serialize,
45    serde::Deserialize,
46    schemars::JsonSchema,
47)]
48pub struct GridCoord {
49    /// Zero-based logical row.
50    pub row: u32,
51    /// Zero-based logical column.
52    pub col: u32,
53}
54
55impl GridCoord {
56    /// Creates a coordinate from a row and column.
57    #[must_use]
58    pub const fn new(row: u32, col: u32) -> Self {
59        Self { row, col }
60    }
61}
62
63/// An anchor cell placed on the logical grid.
64///
65/// Only merge anchors (the top-left cell of a merged region) exist as cells;
66/// positions covered by a span resolve back to their anchor.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct GridCell {
69    /// Logical position of the anchor (top-left of the merged region).
70    pub anchor: GridCoord,
71    /// Index into [`Table::rows`] where this cell lives.
72    pub row_idx: usize,
73    /// Index into the row's `cells` where this cell lives.
74    pub cell_idx: usize,
75    /// Number of logical rows this cell covers (≥ 1).
76    pub row_span: u16,
77    /// Number of logical columns this cell covers (≥ 1).
78    pub col_span: u16,
79}
80
81/// Why a table's cells do not tile a well-formed logical grid.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83#[non_exhaustive]
84pub enum GridError {
85    /// The grid would exceed [`MAX_GRID_POSITIONS`].
86    TooLarge {
87        /// Derived (or partially derived) row count.
88        rows: u64,
89        /// Derived (or partially derived) column count.
90        cols: u64,
91    },
92    /// A cell's span covers a position already covered by another cell.
93    Overlap {
94        /// First doubly-covered position encountered.
95        at: GridCoord,
96    },
97    /// A cell's `row_span` extends past the table's last row.
98    OverhangsBottom {
99        /// First covered position outside the grid.
100        at: GridCoord,
101    },
102    /// A position inside the grid rectangle is covered by no cell.
103    NotTiled {
104        /// First uncovered position (row-major scan order).
105        at: GridCoord,
106    },
107}
108
109impl core::fmt::Display for GridError {
110    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
111        match self {
112            Self::TooLarge { rows, cols } => write!(
113                f,
114                "table grid {rows}x{cols} exceeds the {MAX_GRID_POSITIONS}-position limit"
115            ),
116            Self::Overlap { at } => {
117                write!(f, "cell spans overlap at logical position ({}, {})", at.row, at.col)
118            }
119            Self::OverhangsBottom { at } => write!(
120                f,
121                "cell row span extends past the last table row at ({}, {})",
122                at.row, at.col
123            ),
124            Self::NotTiled { at } => {
125                write!(f, "no cell covers logical position ({}, {})", at.row, at.col)
126            }
127        }
128    }
129}
130
131impl std::error::Error for GridError {}
132
133/// One placement produced by the lenient scan: where a cell landed.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub struct PlacedCell {
136    /// Logical position the cursor assigned to this cell.
137    pub at: GridCoord,
138    /// Index into [`Table::rows`].
139    pub row_idx: usize,
140    /// Index into the row's `cells`.
141    pub cell_idx: usize,
142}
143
144/// Result of the lenient placement scan (see [`grid_placements`]).
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct GridPlacements {
147    /// Every cell's assigned position, in row-major table order.
148    pub cells: Vec<PlacedCell>,
149    /// Column count as the historical encoder derives it: the maximum
150    /// row-cursor end position (equal to the true grid width for well-formed
151    /// tables; may differ for malformed ones).
152    pub cols: u32,
153}
154
155/// Places every cell on the logical grid without validating the result.
156///
157/// This mirrors the historical HWPX encoder scan exactly — per-row cursor,
158/// skip positions occupied by earlier spans, mark this cell's span occupied —
159/// including its silent tolerance of overlaps and ragged rows. Encoders and
160/// analysis passes use this to stay byte/behaviour-identical for existing
161/// documents; addressing surfaces must use [`TableGrid::from_table`] instead.
162#[must_use]
163pub fn grid_placements(table: &Table) -> GridPlacements {
164    let mut occupied = std::collections::HashSet::<(u32, u32)>::new();
165    let mut cells = Vec::new();
166    let mut cols: u32 = 0;
167
168    for (row_idx, row) in table.rows.iter().enumerate() {
169        let mut cursor: u32 = 0;
170        for (cell_idx, cell) in row.cells.iter().enumerate() {
171            while occupied.contains(&(row_idx as u32, cursor)) {
172                cursor += 1;
173            }
174            cells.push(PlacedCell {
175                at: GridCoord::new(row_idx as u32, cursor),
176                row_idx,
177                cell_idx,
178            });
179            let col_span = u32::from(cell.col_span).max(1);
180            let row_span = u32::from(cell.row_span).max(1);
181            for dr in 0..row_span {
182                for dc in 0..col_span {
183                    occupied.insert((row_idx as u32 + dr, cursor + dc));
184                }
185            }
186            cursor += col_span;
187        }
188        cols = cols.max(cursor);
189    }
190
191    GridPlacements { cells, cols }
192}
193
194/// Interval of covered columns within one logical row: `[start, end)` maps to
195/// `anchors[idx]`.
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197struct RowInterval {
198    start: u32,
199    end: u32,
200    idx: usize,
201}
202
203/// The derived logical grid of a table (strict; see module docs).
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct TableGrid {
206    rows: u32,
207    cols: u32,
208    anchors: Vec<GridCell>,
209    /// Per logical row, covered column intervals sorted by `start`.
210    coverage: Vec<Vec<RowInterval>>,
211}
212
213impl TableGrid {
214    /// Derives the logical grid, failing on any tiling violation.
215    ///
216    /// # Errors
217    ///
218    /// [`GridError::TooLarge`] when the grid would exceed
219    /// [`MAX_GRID_POSITIONS`]; [`GridError::Overlap`] /
220    /// [`GridError::OverhangsBottom`] / [`GridError::NotTiled`] when the
221    /// cells do not tile the grid rectangle exactly.
222    pub fn from_table(table: &Table) -> Result<Self, GridError> {
223        let rows = table.rows.len() as u32;
224
225        // Pre-check total covered area before any per-position allocation so
226        // pathological spans cannot exhaust memory while scanning.
227        let mut covered_area: u64 = 0;
228        for row in &table.rows {
229            for cell in &row.cells {
230                let area = u64::from(cell.col_span.max(1)) * u64::from(cell.row_span.max(1));
231                covered_area = covered_area.saturating_add(area);
232            }
233        }
234        if covered_area > MAX_GRID_POSITIONS {
235            return Err(GridError::TooLarge { rows: u64::from(rows), cols: covered_area });
236        }
237
238        let mut anchors: Vec<GridCell> = Vec::new();
239        let mut coverage: Vec<Vec<RowInterval>> = vec![Vec::new(); table.rows.len()];
240        let mut cols: u32 = 0;
241
242        for (row_idx, row) in table.rows.iter().enumerate() {
243            let mut cursor: u32 = 0;
244            for (cell_idx, cell) in row.cells.iter().enumerate() {
245                while covered(&coverage[row_idx], cursor) {
246                    cursor += 1;
247                }
248                let col_span = cell.col_span.max(1);
249                let row_span = cell.row_span.max(1);
250                let idx = anchors.len();
251                let anchor = GridCoord::new(row_idx as u32, cursor);
252
253                let end_row = row_idx as u64 + u64::from(row_span);
254                if end_row > u64::from(rows) {
255                    return Err(GridError::OverhangsBottom {
256                        at: GridCoord::new(rows, anchor.col),
257                    });
258                }
259                let end_col = u64::from(cursor) + u64::from(col_span);
260                if u64::from(rows) * end_col > MAX_GRID_POSITIONS {
261                    return Err(GridError::TooLarge { rows: u64::from(rows), cols: end_col });
262                }
263
264                for dr in 0..u32::from(row_span) {
265                    let target = &mut coverage[row_idx + dr as usize];
266                    let start = cursor;
267                    let end = cursor + u32::from(col_span);
268                    if let Some(col) = first_covered_in(target, start, end) {
269                        return Err(GridError::Overlap {
270                            at: GridCoord::new(row_idx as u32 + dr, col),
271                        });
272                    }
273                    let pos = target.partition_point(|iv| iv.start < start);
274                    target.insert(pos, RowInterval { start, end, idx });
275                }
276
277                anchors.push(GridCell { anchor, row_idx, cell_idx, row_span, col_span });
278                cols = cols.max(cursor + u32::from(col_span));
279                cursor += u32::from(col_span);
280            }
281        }
282
283        // Every position inside rows × cols must be covered exactly once.
284        // Overlaps were rejected above, so contiguity per row is sufficient.
285        for (row_idx, intervals) in coverage.iter().enumerate() {
286            let mut expected: u32 = 0;
287            for iv in intervals {
288                if iv.start != expected {
289                    return Err(GridError::NotTiled {
290                        at: GridCoord::new(row_idx as u32, expected),
291                    });
292                }
293                expected = iv.end;
294            }
295            if expected != cols {
296                return Err(GridError::NotTiled { at: GridCoord::new(row_idx as u32, expected) });
297            }
298        }
299
300        Ok(Self { rows, cols, anchors, coverage })
301    }
302
303    /// Grid dimensions as `(rows, cols)` in logical positions.
304    #[must_use]
305    pub fn dimensions(&self) -> (u32, u32) {
306        (self.rows, self.cols)
307    }
308
309    /// Resolves a logical position to the cell covering it.
310    ///
311    /// Positions inside a merged region resolve to the region's anchor.
312    /// Returns `None` when the position lies outside the grid.
313    #[must_use]
314    pub fn resolve(&self, at: GridCoord) -> Option<&GridCell> {
315        let intervals = self.coverage.get(at.row as usize)?;
316        let idx = interval_at(intervals, at.col)?;
317        Some(&self.anchors[idx])
318    }
319
320    /// Iterates over anchor cells in row-major placement order.
321    pub fn iter_anchors(&self) -> impl Iterator<Item = &GridCell> {
322        self.anchors.iter()
323    }
324}
325
326/// Whether `col` is covered by any interval in a row.
327fn covered(intervals: &[RowInterval], col: u32) -> bool {
328    interval_at(intervals, col).is_some()
329}
330
331/// Index of the anchor covering `col`, if any.
332fn interval_at(intervals: &[RowInterval], col: u32) -> Option<usize> {
333    let pos = intervals.partition_point(|iv| iv.end <= col);
334    let iv = intervals.get(pos)?;
335    (iv.start <= col).then_some(iv.idx)
336}
337
338/// First covered column in `[start, end)`, if any.
339fn first_covered_in(intervals: &[RowInterval], start: u32, end: u32) -> Option<u32> {
340    let pos = intervals.partition_point(|iv| iv.end <= start);
341    let iv = intervals.get(pos)?;
342    (iv.start < end).then_some(iv.start.max(start))
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use crate::paragraph::Paragraph;
349    use crate::table::{TableCell, TableRow};
350    use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
351
352    fn cell(row_span: u16, col_span: u16) -> TableCell {
353        TableCell::with_span(
354            vec![Paragraph::new(ParaShapeIndex::new(0))],
355            HwpUnit::from_mm(10.0).unwrap(),
356            col_span,
357            row_span,
358        )
359    }
360
361    fn table(rows: Vec<Vec<TableCell>>) -> Table {
362        Table::new(rows.into_iter().map(TableRow::new).collect())
363    }
364
365    // === Edge cases first (TDD) ===
366
367    #[test]
368    fn empty_table_yields_zero_dimensions() {
369        let grid = TableGrid::from_table(&table(vec![])).unwrap();
370        assert_eq!(grid.dimensions(), (0, 0));
371        assert_eq!(grid.iter_anchors().count(), 0);
372        assert_eq!(grid.resolve(GridCoord::new(0, 0)), None);
373    }
374
375    #[test]
376    fn all_empty_rows_yield_degenerate_zero_width_grid() {
377        // A table with rows but no cells derives as rows×0 — vacuously tiled.
378        // Callers deciding document validity must check width themselves
379        // (Core validation keeps rejecting such tables).
380        let grid = TableGrid::from_table(&table(vec![vec![], vec![]])).unwrap();
381        assert_eq!(grid.dimensions(), (2, 0));
382        assert_eq!(grid.iter_anchors().count(), 0);
383    }
384
385    #[test]
386    fn pathological_span_rejected_as_too_large() {
387        let t = table(vec![vec![cell(u16::MAX, u16::MAX)]]);
388        assert!(matches!(TableGrid::from_table(&t), Err(GridError::TooLarge { .. })));
389    }
390
391    #[test]
392    fn zero_span_normalized_to_one() {
393        let t = table(vec![vec![cell(0, 0)]]);
394        let grid = TableGrid::from_table(&t).unwrap();
395        assert_eq!(grid.dimensions(), (1, 1));
396    }
397
398    #[test]
399    fn row_span_overhang_rejected() {
400        let t = table(vec![vec![cell(2, 1)]]);
401        assert_eq!(
402            TableGrid::from_table(&t),
403            Err(GridError::OverhangsBottom { at: GridCoord::new(1, 0) })
404        );
405    }
406
407    #[test]
408    fn ragged_rows_rejected_as_not_tiled() {
409        let t = table(vec![vec![cell(1, 1), cell(1, 1)], vec![cell(1, 1)]]);
410        assert_eq!(
411            TableGrid::from_table(&t),
412            Err(GridError::NotTiled { at: GridCoord::new(1, 1) })
413        );
414    }
415
416    #[test]
417    fn uncovered_empty_row_rejected_as_not_tiled() {
418        let t = table(vec![vec![cell(1, 1)], vec![]]);
419        assert_eq!(
420            TableGrid::from_table(&t),
421            Err(GridError::NotTiled { at: GridCoord::new(1, 0) })
422        );
423    }
424
425    #[test]
426    fn overlapping_spans_rejected() {
427        // Row 0: A(rs2), B, C(rs2) → 3 cols. Row 1: X(cs2) placed at col 1,
428        // covering (1,1)+(1,2) — (1,2) is already covered by C.
429        let t = table(vec![vec![cell(2, 1), cell(1, 1), cell(2, 1)], vec![cell(1, 2)]]);
430        assert_eq!(TableGrid::from_table(&t), Err(GridError::Overlap { at: GridCoord::new(1, 2) }));
431    }
432
433    // === Well-formed grids ===
434
435    #[test]
436    fn fully_covered_empty_row_accepted() {
437        let t = table(vec![vec![cell(2, 1)], vec![]]);
438        let grid = TableGrid::from_table(&t).unwrap();
439        assert_eq!(grid.dimensions(), (2, 1));
440        let anchor = grid.resolve(GridCoord::new(1, 0)).unwrap();
441        assert_eq!(anchor.anchor, GridCoord::new(0, 0));
442        assert_eq!((anchor.row_idx, anchor.cell_idx), (0, 0));
443    }
444
445    #[test]
446    fn hpc_form_layout_resolves_covered_positions_to_anchors() {
447        // Real layout from a native government form (blank-HPC table #11):
448        // 4×3 grid, 8 anchors, 4 covered positions.
449        //   row 0: (0,0,rs2) (0,1,cs2)
450        //   row 1: cells land at col 1, 2 (col 0 covered from above)
451        //   row 2: three 1×1 cells
452        //   row 3: (3,0,cs3)
453        let t = table(vec![
454            vec![cell(2, 1), cell(1, 2)],
455            vec![cell(1, 1), cell(1, 1)],
456            vec![cell(1, 1), cell(1, 1), cell(1, 1)],
457            vec![cell(1, 3)],
458        ]);
459        let grid = TableGrid::from_table(&t).unwrap();
460        assert_eq!(grid.dimensions(), (4, 3));
461        assert_eq!(grid.iter_anchors().count(), 8);
462
463        // Covered → anchor.
464        let a = grid.resolve(GridCoord::new(1, 0)).unwrap();
465        assert_eq!(a.anchor, GridCoord::new(0, 0));
466        let b = grid.resolve(GridCoord::new(0, 2)).unwrap();
467        assert_eq!(b.anchor, GridCoord::new(0, 1));
468        let c = grid.resolve(GridCoord::new(3, 2)).unwrap();
469        assert_eq!(c.anchor, GridCoord::new(3, 0));
470        assert_eq!((c.row_idx, c.cell_idx), (3, 0));
471
472        // Exact anchors resolve to themselves; row-1 cells landed at col 1, 2.
473        let x = grid.resolve(GridCoord::new(1, 1)).unwrap();
474        assert_eq!((x.row_idx, x.cell_idx), (1, 0));
475        assert_eq!(x.anchor, GridCoord::new(1, 1));
476
477        // Out of bounds.
478        assert_eq!(grid.resolve(GridCoord::new(4, 0)), None);
479        assert_eq!(grid.resolve(GridCoord::new(0, 3)), None);
480    }
481
482    // === Lenient placement mirrors the historical encoder ===
483
484    #[test]
485    fn lenient_placement_matches_strict_for_well_formed_tables() {
486        let t = table(vec![
487            vec![cell(2, 1), cell(1, 2)],
488            vec![cell(1, 1), cell(1, 1)],
489            vec![cell(1, 1), cell(1, 1), cell(1, 1)],
490            vec![cell(1, 3)],
491        ]);
492        let placements = grid_placements(&t);
493        let grid = TableGrid::from_table(&t).unwrap();
494        assert_eq!(placements.cols, grid.dimensions().1);
495        let strict: Vec<_> =
496            grid.iter_anchors().map(|a| (a.anchor, a.row_idx, a.cell_idx)).collect();
497        let lenient: Vec<_> =
498            placements.cells.iter().map(|p| (p.at, p.row_idx, p.cell_idx)).collect();
499        assert_eq!(strict, lenient);
500    }
501
502    #[test]
503    fn lenient_placement_tolerates_malformed_tables() {
504        // Overlap case from `overlapping_spans_rejected` — lenient must not
505        // fail and must keep the historical cursor result.
506        let t = table(vec![vec![cell(2, 1), cell(1, 1), cell(2, 1)], vec![cell(1, 2)]]);
507        let placements = grid_placements(&t);
508        assert_eq!(placements.cells.len(), 4);
509        assert_eq!(placements.cells[3].at, GridCoord::new(1, 1));
510        assert_eq!(placements.cols, 3);
511    }
512}