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/// Sums the grid area covered by every cell's span (`col_span × row_span`,
195/// each floored at 1), saturating at `u64::MAX`.
196///
197/// This is the O(cells) pre-check that strict [`TableGrid::from_table`]
198/// performs before scanning; lenient call sites compare the result against
199/// [`MAX_GRID_POSITIONS`] to refuse or degrade **before** [`grid_placements`]
200/// allocates per-position state. For overlapping spans the sum over-counts
201/// actual coverage, which is the conservative direction for a cap guard.
202#[must_use]
203pub fn covered_area(table: &Table) -> u64 {
204    let mut covered: u64 = 0;
205    for row in &table.rows {
206        for cell in &row.cells {
207            let area = u64::from(cell.col_span.max(1)) * u64::from(cell.row_span.max(1));
208            covered = covered.saturating_add(area);
209        }
210    }
211    covered
212}
213
214/// Interval of covered columns within one logical row: `[start, end)` maps to
215/// `anchors[idx]`.
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217struct RowInterval {
218    start: u32,
219    end: u32,
220    idx: usize,
221}
222
223/// The derived logical grid of a table (strict; see module docs).
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct TableGrid {
226    rows: u32,
227    cols: u32,
228    anchors: Vec<GridCell>,
229    /// Per logical row, covered column intervals sorted by `start`.
230    coverage: Vec<Vec<RowInterval>>,
231}
232
233impl TableGrid {
234    /// Derives the logical grid, failing on any tiling violation.
235    ///
236    /// # Errors
237    ///
238    /// [`GridError::TooLarge`] when the grid would exceed
239    /// [`MAX_GRID_POSITIONS`]; [`GridError::Overlap`] /
240    /// [`GridError::OverhangsBottom`] / [`GridError::NotTiled`] when the
241    /// cells do not tile the grid rectangle exactly.
242    pub fn from_table(table: &Table) -> Result<Self, GridError> {
243        let rows = table.rows.len() as u32;
244
245        // Pre-check total covered area before any per-position allocation so
246        // pathological spans cannot exhaust memory while scanning.
247        let area = covered_area(table);
248        if area > MAX_GRID_POSITIONS {
249            return Err(GridError::TooLarge { rows: u64::from(rows), cols: area });
250        }
251
252        let mut anchors: Vec<GridCell> = Vec::new();
253        let mut coverage: Vec<Vec<RowInterval>> = vec![Vec::new(); table.rows.len()];
254        let mut cols: u32 = 0;
255
256        for (row_idx, row) in table.rows.iter().enumerate() {
257            let mut cursor: u32 = 0;
258            for (cell_idx, cell) in row.cells.iter().enumerate() {
259                while covered(&coverage[row_idx], cursor) {
260                    cursor += 1;
261                }
262                let col_span = cell.col_span.max(1);
263                let row_span = cell.row_span.max(1);
264                let idx = anchors.len();
265                let anchor = GridCoord::new(row_idx as u32, cursor);
266
267                let end_row = row_idx as u64 + u64::from(row_span);
268                if end_row > u64::from(rows) {
269                    return Err(GridError::OverhangsBottom {
270                        at: GridCoord::new(rows, anchor.col),
271                    });
272                }
273                let end_col = u64::from(cursor) + u64::from(col_span);
274                if u64::from(rows) * end_col > MAX_GRID_POSITIONS {
275                    return Err(GridError::TooLarge { rows: u64::from(rows), cols: end_col });
276                }
277
278                for dr in 0..u32::from(row_span) {
279                    let target = &mut coverage[row_idx + dr as usize];
280                    let start = cursor;
281                    let end = cursor + u32::from(col_span);
282                    if let Some(col) = first_covered_in(target, start, end) {
283                        return Err(GridError::Overlap {
284                            at: GridCoord::new(row_idx as u32 + dr, col),
285                        });
286                    }
287                    let pos = target.partition_point(|iv| iv.start < start);
288                    target.insert(pos, RowInterval { start, end, idx });
289                }
290
291                anchors.push(GridCell { anchor, row_idx, cell_idx, row_span, col_span });
292                cols = cols.max(cursor + u32::from(col_span));
293                cursor += u32::from(col_span);
294            }
295        }
296
297        // Every position inside rows × cols must be covered exactly once.
298        // Overlaps were rejected above, so contiguity per row is sufficient.
299        for (row_idx, intervals) in coverage.iter().enumerate() {
300            let mut expected: u32 = 0;
301            for iv in intervals {
302                if iv.start != expected {
303                    return Err(GridError::NotTiled {
304                        at: GridCoord::new(row_idx as u32, expected),
305                    });
306                }
307                expected = iv.end;
308            }
309            if expected != cols {
310                return Err(GridError::NotTiled { at: GridCoord::new(row_idx as u32, expected) });
311            }
312        }
313
314        Ok(Self { rows, cols, anchors, coverage })
315    }
316
317    /// Grid dimensions as `(rows, cols)` in logical positions.
318    #[must_use]
319    pub fn dimensions(&self) -> (u32, u32) {
320        (self.rows, self.cols)
321    }
322
323    /// Resolves a logical position to the cell covering it.
324    ///
325    /// Positions inside a merged region resolve to the region's anchor.
326    /// Returns `None` when the position lies outside the grid.
327    #[must_use]
328    pub fn resolve(&self, at: GridCoord) -> Option<&GridCell> {
329        let intervals = self.coverage.get(at.row as usize)?;
330        let idx = interval_at(intervals, at.col)?;
331        Some(&self.anchors[idx])
332    }
333
334    /// Iterates over anchor cells in row-major placement order.
335    pub fn iter_anchors(&self) -> impl Iterator<Item = &GridCell> {
336        self.anchors.iter()
337    }
338}
339
340/// Whether `col` is covered by any interval in a row.
341fn covered(intervals: &[RowInterval], col: u32) -> bool {
342    interval_at(intervals, col).is_some()
343}
344
345/// Index of the anchor covering `col`, if any.
346fn interval_at(intervals: &[RowInterval], col: u32) -> Option<usize> {
347    let pos = intervals.partition_point(|iv| iv.end <= col);
348    let iv = intervals.get(pos)?;
349    (iv.start <= col).then_some(iv.idx)
350}
351
352/// First covered column in `[start, end)`, if any.
353fn first_covered_in(intervals: &[RowInterval], start: u32, end: u32) -> Option<u32> {
354    let pos = intervals.partition_point(|iv| iv.end <= start);
355    let iv = intervals.get(pos)?;
356    (iv.start < end).then_some(iv.start.max(start))
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use crate::paragraph::Paragraph;
363    use crate::table::{TableCell, TableRow};
364    use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
365
366    fn cell(row_span: u16, col_span: u16) -> TableCell {
367        TableCell::with_span(
368            vec![Paragraph::new(ParaShapeIndex::new(0))],
369            HwpUnit::from_mm(10.0).unwrap(),
370            col_span,
371            row_span,
372        )
373    }
374
375    fn table(rows: Vec<Vec<TableCell>>) -> Table {
376        Table::new(rows.into_iter().map(TableRow::new).collect())
377    }
378
379    // === Edge cases first (TDD) ===
380
381    #[test]
382    fn empty_table_yields_zero_dimensions() {
383        let grid = TableGrid::from_table(&table(vec![])).unwrap();
384        assert_eq!(grid.dimensions(), (0, 0));
385        assert_eq!(grid.iter_anchors().count(), 0);
386        assert_eq!(grid.resolve(GridCoord::new(0, 0)), None);
387    }
388
389    #[test]
390    fn all_empty_rows_yield_degenerate_zero_width_grid() {
391        // A table with rows but no cells derives as rows×0 — vacuously tiled.
392        // Callers deciding document validity must check width themselves
393        // (Core validation keeps rejecting such tables).
394        let grid = TableGrid::from_table(&table(vec![vec![], vec![]])).unwrap();
395        assert_eq!(grid.dimensions(), (2, 0));
396        assert_eq!(grid.iter_anchors().count(), 0);
397    }
398
399    #[test]
400    fn pathological_span_rejected_as_too_large() {
401        let t = table(vec![vec![cell(u16::MAX, u16::MAX)]]);
402        assert!(matches!(TableGrid::from_table(&t), Err(GridError::TooLarge { .. })));
403    }
404
405    #[test]
406    fn zero_span_normalized_to_one() {
407        let t = table(vec![vec![cell(0, 0)]]);
408        let grid = TableGrid::from_table(&t).unwrap();
409        assert_eq!(grid.dimensions(), (1, 1));
410    }
411
412    #[test]
413    fn row_span_overhang_rejected() {
414        let t = table(vec![vec![cell(2, 1)]]);
415        assert_eq!(
416            TableGrid::from_table(&t),
417            Err(GridError::OverhangsBottom { at: GridCoord::new(1, 0) })
418        );
419    }
420
421    #[test]
422    fn ragged_rows_rejected_as_not_tiled() {
423        let t = table(vec![vec![cell(1, 1), cell(1, 1)], vec![cell(1, 1)]]);
424        assert_eq!(
425            TableGrid::from_table(&t),
426            Err(GridError::NotTiled { at: GridCoord::new(1, 1) })
427        );
428    }
429
430    #[test]
431    fn uncovered_empty_row_rejected_as_not_tiled() {
432        let t = table(vec![vec![cell(1, 1)], vec![]]);
433        assert_eq!(
434            TableGrid::from_table(&t),
435            Err(GridError::NotTiled { at: GridCoord::new(1, 0) })
436        );
437    }
438
439    #[test]
440    fn overlapping_spans_rejected() {
441        // Row 0: A(rs2), B, C(rs2) → 3 cols. Row 1: X(cs2) placed at col 1,
442        // covering (1,1)+(1,2) — (1,2) is already covered by C.
443        let t = table(vec![vec![cell(2, 1), cell(1, 1), cell(2, 1)], vec![cell(1, 2)]]);
444        assert_eq!(TableGrid::from_table(&t), Err(GridError::Overlap { at: GridCoord::new(1, 2) }));
445    }
446
447    // === Well-formed grids ===
448
449    #[test]
450    fn fully_covered_empty_row_accepted() {
451        let t = table(vec![vec![cell(2, 1)], vec![]]);
452        let grid = TableGrid::from_table(&t).unwrap();
453        assert_eq!(grid.dimensions(), (2, 1));
454        let anchor = grid.resolve(GridCoord::new(1, 0)).unwrap();
455        assert_eq!(anchor.anchor, GridCoord::new(0, 0));
456        assert_eq!((anchor.row_idx, anchor.cell_idx), (0, 0));
457    }
458
459    #[test]
460    fn hpc_form_layout_resolves_covered_positions_to_anchors() {
461        // Real layout from a native government form (blank-HPC table #11):
462        // 4×3 grid, 8 anchors, 4 covered positions.
463        //   row 0: (0,0,rs2) (0,1,cs2)
464        //   row 1: cells land at col 1, 2 (col 0 covered from above)
465        //   row 2: three 1×1 cells
466        //   row 3: (3,0,cs3)
467        let t = table(vec![
468            vec![cell(2, 1), cell(1, 2)],
469            vec![cell(1, 1), cell(1, 1)],
470            vec![cell(1, 1), cell(1, 1), cell(1, 1)],
471            vec![cell(1, 3)],
472        ]);
473        let grid = TableGrid::from_table(&t).unwrap();
474        assert_eq!(grid.dimensions(), (4, 3));
475        assert_eq!(grid.iter_anchors().count(), 8);
476
477        // Covered → anchor.
478        let a = grid.resolve(GridCoord::new(1, 0)).unwrap();
479        assert_eq!(a.anchor, GridCoord::new(0, 0));
480        let b = grid.resolve(GridCoord::new(0, 2)).unwrap();
481        assert_eq!(b.anchor, GridCoord::new(0, 1));
482        let c = grid.resolve(GridCoord::new(3, 2)).unwrap();
483        assert_eq!(c.anchor, GridCoord::new(3, 0));
484        assert_eq!((c.row_idx, c.cell_idx), (3, 0));
485
486        // Exact anchors resolve to themselves; row-1 cells landed at col 1, 2.
487        let x = grid.resolve(GridCoord::new(1, 1)).unwrap();
488        assert_eq!((x.row_idx, x.cell_idx), (1, 0));
489        assert_eq!(x.anchor, GridCoord::new(1, 1));
490
491        // Out of bounds.
492        assert_eq!(grid.resolve(GridCoord::new(4, 0)), None);
493        assert_eq!(grid.resolve(GridCoord::new(0, 3)), None);
494    }
495
496    // === Lenient placement mirrors the historical encoder ===
497
498    #[test]
499    fn lenient_placement_matches_strict_for_well_formed_tables() {
500        let t = table(vec![
501            vec![cell(2, 1), cell(1, 2)],
502            vec![cell(1, 1), cell(1, 1)],
503            vec![cell(1, 1), cell(1, 1), cell(1, 1)],
504            vec![cell(1, 3)],
505        ]);
506        let placements = grid_placements(&t);
507        let grid = TableGrid::from_table(&t).unwrap();
508        assert_eq!(placements.cols, grid.dimensions().1);
509        let strict: Vec<_> =
510            grid.iter_anchors().map(|a| (a.anchor, a.row_idx, a.cell_idx)).collect();
511        let lenient: Vec<_> =
512            placements.cells.iter().map(|p| (p.at, p.row_idx, p.cell_idx)).collect();
513        assert_eq!(strict, lenient);
514    }
515
516    #[test]
517    fn lenient_placement_tolerates_malformed_tables() {
518        // Overlap case from `overlapping_spans_rejected` — lenient must not
519        // fail and must keep the historical cursor result.
520        let t = table(vec![vec![cell(2, 1), cell(1, 1), cell(2, 1)], vec![cell(1, 2)]]);
521        let placements = grid_placements(&t);
522        assert_eq!(placements.cells.len(), 4);
523        assert_eq!(placements.cells[3].at, GridCoord::new(1, 1));
524        assert_eq!(placements.cols, 3);
525    }
526
527    // === covered_area pre-check primitive ===
528
529    #[test]
530    fn covered_area_sums_span_areas() {
531        // rs2×cs1 + rs1×cs2 + two 1×1 = 2 + 2 + 1 + 1 = 6.
532        let t = table(vec![vec![cell(2, 1), cell(1, 2)], vec![cell(1, 1), cell(1, 1)]]);
533        assert_eq!(covered_area(&t), 6);
534        // Empty table covers nothing.
535        assert_eq!(covered_area(&table(vec![])), 0);
536    }
537
538    #[test]
539    fn covered_area_floors_zero_spans_at_one() {
540        // Mirrors the placement scan's `.max(1)` so the guard and the scan
541        // agree on what a degenerate span occupies.
542        let t = table(vec![vec![cell(0, 0), cell(0, 3)]]);
543        assert_eq!(covered_area(&t), 1 + 3);
544    }
545
546    #[test]
547    fn covered_area_boundary_sits_exactly_at_cap() {
548        // 1024×1024 = MAX_GRID_POSITIONS exactly; guards use strict `>` so
549        // this table is still allowed.
550        let t = table(vec![vec![cell(1024, 1024)]]);
551        assert_eq!(covered_area(&t), MAX_GRID_POSITIONS);
552    }
553
554    #[test]
555    fn covered_area_saturates_instead_of_overflowing() {
556        let row: Vec<TableCell> = (0..8).map(|_| cell(u16::MAX, u16::MAX)).collect();
557        let t = table(vec![row]);
558        assert_eq!(covered_area(&t), 8 * 4_294_836_225u64);
559        assert!(covered_area(&t) > MAX_GRID_POSITIONS);
560    }
561}