Skip to main content

rustyfi_backend/
tabular.rs

1//! The `tabular` grid solver: row/column metrics, `MultiCell` span
2//! bookkeeping, and cell content fitting. A faithful port of v0.0.6's
3//! `src/backend/tabular.ml` (`main`/`determine_row_metrics`/
4//! `determine_column_width`/`normalize_tabular`/`transpose_tabular`/
5//! `solidify_tabular`, cited by name below), adapted two ways:
6//!
7//! - **Depth sign.** `tabular.ml` threads *negative* depths (more negative =
8//!   deeper) through `Length.min`/`Length.negate`. This port's
9//!   [`natural_metrics`] returns a
10//!   non-negative "how far below the baseline" magnitude (see `hbox.rs`), so
11//!   every upstream `min`/`negate` pair becomes a plain `max`/`+` here.
12//! - **Row/column indexing.** `normalize_tabular` always produces a
13//!   rectangular grid, so a positional transpose (by column index) replaces
14//!   upstream's recursive `chop_column`/`transpose_tabular`.
15//!
16//! **Malformed grids degrade, they don't panic**: where upstream asserts
17//! false on a cell that should have been an `EmptyCell` continuing a pending
18//! span (or a span declared with `numrow`/`numcol` < 1), this port drops the
19//! bogus pending state / clamps to 1 and keeps going.
20
21use crate::graphics::GraphicsElem;
22use crate::hbox::{HorzBox, PureHorzBox};
23use crate::length::Length;
24use crate::linebreak::{fit_cell, natural_metrics};
25
26/// `paddings` (horzBox.ml's `paddingL/R/T/B`).
27#[derive(Clone, Copy, Debug, PartialEq)]
28pub struct Paddings {
29    pub l: Length,
30    pub r: Length,
31    pub t: Length,
32    pub b: Length,
33}
34
35/// `cell` (horzBox.ml:447). Content is already-measured pure boxes, so the
36/// solver never threads a `Context`.
37#[derive(Clone, Debug, PartialEq)]
38pub enum Cell {
39    /// `NormalCell(pads, hblst)`.
40    Normal(Paddings, Vec<HorzBox>),
41    /// `EmptyCell` — a blank slot, and also how a `MultiCell`'s spanned
42    /// (non-anchor) slots MUST be filled in, both across columns and down
43    /// rows.
44    Empty,
45    /// `MultiCell(numrow, numcol, pads, hblst)`.
46    Multi(usize, usize, Paddings, Vec<HorzBox>),
47}
48
49/// One placed cell inside a solved [`TabularBox`]: its box-local anchor
50/// (`x` = left edge, `baseline_y` = content baseline, both y-**up** from the
51/// box's own baseline-left origin) and its content already fitted to the
52/// cell's (or, for a span, combined) column width. `EmptyCell`s produce no
53/// entry at all.
54#[derive(Clone, Debug, PartialEq, syan::visit::Ast)]
55#[subast(crate::hbox::PureHorzBox)]
56pub struct TabularCellBox {
57    pub x: Length,
58    pub baseline_y: Length,
59    pub contents: Vec<(Length, PureHorzBox)>,
60}
61
62/// The solved `PHGFixedTabular` payload (horzBox.ml:279), minus `rules`
63/// (filled in lang-side once the rule callback runs, `primitives.rs`'s
64/// `prim_tabular`).
65#[derive(Clone, Debug, PartialEq, syan::visit::Ast)]
66#[subast(crate::tabular::TabularCellBox, crate::graphics::GraphicsElem)]
67pub struct TabularBox {
68    pub width: Length,
69    pub height: Length,
70    /// Always `Length::ZERO` (upstream `dpttotal`, tabular.ml:340).
71    pub depth: Length,
72    pub cells: Vec<TabularCellBox>,
73    pub rules: Vec<GraphicsElem>,
74}
75
76/// `Tabular.main`'s result (tabular.ml:309). `xs` ascends from `0` (column
77/// boundaries); `ys` **descends** from `height` (row *tops*,
78/// `handlePdf.ml:214-220`) down to `0`.
79#[derive(Clone, Debug, PartialEq)]
80pub struct Solved {
81    pub width: Length,
82    pub height: Length,
83    pub cells: Vec<TabularCellBox>,
84    pub xs: Vec<Length>,
85    pub ys: Vec<Length>,
86}
87
88/// Per-column pending multi-**row** span state, threaded top-to-bottom
89/// (`rest_row` in tabular.ml): `Some((rows_remaining, extra_len_needed))` at
90/// column `i` means an earlier row's `MultiCell` still owns this column for
91/// `rows_remaining` more rows.
92type RestRow = Vec<Option<(usize, Length)>>;
93
94/// Per-row pending multi-**column** span state, threaded column-to-column
95/// left-to-right (`rest_column` in tabular.ml).
96type RestCol = Vec<Option<(usize, Length)>>;
97
98/// `normalize_tabular` (tabular.ml): pad every row to the widest row's
99/// length with trailing `EmptyCell`s. A short row is filled on the *right*,
100/// never in the middle — a mid-row gap under a span needs an explicit
101/// `EmptyCell` from the table author.
102fn normalize_tabular(rows: Vec<Vec<Cell>>) -> (usize, Vec<Vec<Cell>>) {
103    let ncols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
104    let htabular = rows
105        .into_iter()
106        .map(|mut row| {
107            while row.len() < ncols {
108                row.push(Cell::Empty);
109            }
110            row
111        })
112        .collect();
113    (ncols, htabular)
114}
115
116/// Column-major view of a (rectangular, post-`normalize_tabular`) grid —
117/// replaces `transpose_tabular`'s recursive `chop_column`.
118fn transpose(rows: &[Vec<Cell>], ncols: usize) -> Vec<Vec<&Cell>> {
119    (0..ncols)
120        .map(|c| rows.iter().map(|row| &row[c]).collect())
121        .collect()
122    // `row[c]` never panics: every row has exactly `ncols` entries here.
123}
124
125/// `determine_row_metrics` (tabular.ml:10): one row's `(height, depth
126/// magnitude)`, plus the updated `rest_row` for the next row down.
127fn determine_row_metrics(restprev: &RestRow, row: &[Cell]) -> (RestRow, Length, Length) {
128    let mut restacc: RestRow = Vec::with_capacity(row.len());
129    let mut hgt_max = Length::ZERO;
130    let mut dpt_mag_max = Length::ZERO;
131    for (slot, cell) in restprev.iter().zip(row.iter()) {
132        match (slot, cell) {
133            (None, Cell::Normal(pads, content)) => {
134                let (_, hgt, dpt) = natural_metrics(content);
135                hgt_max = hgt_max.max(hgt + pads.t);
136                dpt_mag_max = dpt_mag_max.max(dpt + pads.b);
137                restacc.push(None);
138            }
139            (None, Cell::Empty) => restacc.push(None),
140            // A span-anchor `MultiCell` does not affect `hgt_max`/
141            // `dpt_mag_max` itself, only `len` for a *continuing* span
142            // (tabular.ml:34-42 passes `hgtmax dptmin` through unchanged).
143            (None, Cell::Multi(nr, _nc, pads, content)) => {
144                let (_, hgt, dpt) = natural_metrics(content);
145                let len = (hgt + pads.t) + (dpt + pads.b);
146                let nr = (*nr).max(1);
147                let restelem = if nr == 1 { None } else { Some((nr, len)) };
148                restacc.push(restelem);
149            }
150            // A continuing multi-row span: the slot must be `EmptyCell`.
151            (Some((numrow, len)), Cell::Empty) => {
152                restacc.push(Some((*numrow, *len)));
153            }
154            // Malformed grid (upstream `assert false`, tabular.ml:54) — a
155            // real cell where a span's continuation was expected. Degrade:
156            // drop the stale pending span, treat the slot as `None`.
157            (Some(_), Cell::Normal(pads, content)) => {
158                let (_, hgt, dpt) = natural_metrics(content);
159                hgt_max = hgt_max.max(hgt + pads.t);
160                dpt_mag_max = dpt_mag_max.max(dpt + pads.b);
161                restacc.push(None);
162            }
163            (Some(_), Cell::Multi(nr, _nc, pads, content)) => {
164                let (_, hgt, dpt) = natural_metrics(content);
165                let len = (hgt + pads.t) + (dpt + pads.b);
166                let nr = (*nr).max(1);
167                let restelem = if nr == 1 { None } else { Some((nr, len)) };
168                restacc.push(restelem);
169            }
170        }
171    }
172    let rest = restacc
173        .into_iter()
174        .map(|slot| match slot {
175            None => None,
176            Some((1, _)) => None,
177            Some((numrow, len)) => Some((numrow - 1, len - hgt_max - dpt_mag_max)),
178        })
179        .collect();
180    (rest, hgt_max, dpt_mag_max)
181}
182
183/// `determine_column_width` (tabular.ml:83): one column's width, plus the
184/// updated `rest_column` for the next column right.
185fn determine_column_width(restprev: &RestCol, col: &[&Cell]) -> (RestCol, Length) {
186    let mut restacc: RestCol = Vec::with_capacity(col.len());
187    let mut wid_max = Length::ZERO;
188    for (slot, cell) in restprev.iter().zip(col.iter()) {
189        match (slot, cell) {
190            (None, Cell::Normal(pads, content)) => {
191                let (wid, _, _) = natural_metrics(content);
192                wid_max = wid_max.max(pads.l + wid + pads.r);
193                restacc.push(None);
194            }
195            (None, Cell::Empty) => restacc.push(None),
196            (None, Cell::Multi(_nr, nc, pads, content)) => {
197                let (widraw, _, _) = natural_metrics(content);
198                let wid = pads.l + widraw + pads.r;
199                let nc = (*nc).max(1);
200                if nc == 1 {
201                    wid_max = wid_max.max(wid);
202                }
203                restacc.push(Some((nc, wid)));
204            }
205            (Some((numcol, widrest)), Cell::Empty) => {
206                let numcol = *numcol;
207                if numcol == 1 {
208                    wid_max = wid_max.max(*widrest);
209                }
210                restacc.push(Some((numcol, *widrest)));
211            }
212            // Malformed grid (upstream `assert false`, tabular.ml:119) —
213            // degrade like `determine_row_metrics` above.
214            (Some(_), Cell::Normal(pads, content)) => {
215                let (wid, _, _) = natural_metrics(content);
216                wid_max = wid_max.max(pads.l + wid + pads.r);
217                restacc.push(None);
218            }
219            (Some(_), Cell::Multi(_nr, nc, pads, content)) => {
220                let (widraw, _, _) = natural_metrics(content);
221                let wid = pads.l + widraw + pads.r;
222                let nc = (*nc).max(1);
223                if nc == 1 {
224                    wid_max = wid_max.max(wid);
225                }
226                restacc.push(Some((nc, wid)));
227            }
228        }
229    }
230    let rest = restacc
231        .into_iter()
232        .map(|slot| match slot {
233            None => None,
234            Some((1, _)) => None,
235            Some((numcol, wid)) => Some((numcol - 1, wid - wid_max)),
236        })
237        .collect();
238    (rest, wid_max)
239}
240
241/// `multi_cell_width` (tabular.ml:207): the combined width of `nc` columns
242/// starting at `index_c`, clamped to the grid's actual column count (a span
243/// overrunning the grid degrades to "the rest of the grid", not a panic).
244fn multi_cell_width(widlst: &[Length], index_c: usize, nc: usize) -> Length {
245    if widlst.is_empty() {
246        return Length::ZERO;
247    }
248    let end = (index_c + nc).saturating_sub(1).min(widlst.len() - 1);
249    widlst[index_c.min(end)..=end]
250        .iter()
251        .fold(Length::ZERO, |acc, w| acc + *w)
252}
253
254/// `multi_cell_vertical` (tabular.ml:220): the combined `height + depth
255/// magnitude` of `nr` rows starting at `index_r`, clamped like
256/// `multi_cell_width` above.
257fn multi_cell_vertical(vmetrlst: &[(Length, Length)], index_r: usize, nr: usize) -> Length {
258    if vmetrlst.is_empty() {
259        return Length::ZERO;
260    }
261    let end = (index_r + nr).saturating_sub(1).min(vmetrlst.len() - 1);
262    vmetrlst[index_r.min(end)..=end]
263        .iter()
264        .fold(Length::ZERO, |acc, (hgt, dpt)| acc + *hgt + *dpt)
265}
266
267/// Wrap a cell's content with its left/right padding (tabular.ml:263-268's
268/// `hblstwithpads`). Top/bottom padding never enters the horizontal box
269/// list; it only affects `determine_row_metrics`'s row-height arithmetic.
270fn pad_content(pads: Paddings, content: Vec<HorzBox>) -> Vec<HorzBox> {
271    let mut out = Vec::with_capacity(content.len() + 2);
272    out.push(HorzBox::Pure(PureHorzBox::FixedEmpty { width: pads.l }));
273    out.extend(content);
274    out.push(HorzBox::Pure(PureHorzBox::FixedEmpty { width: pads.r }));
275    out
276}
277
278/// `solidify_tabular` (tabular.ml:229): fit every non-`Empty` cell's content
279/// to its (possibly combined) column width and place it at its box-local
280/// anchor.
281fn solidify_tabular(
282    vmetrlst: &[(Length, Length)],
283    widlst: &[Length],
284    xs: &[Length],
285    ys: &[Length],
286    htabular: Vec<Vec<Cell>>,
287) -> Vec<TabularCellBox> {
288    let mut cells = Vec::new();
289    for (index_r, row) in htabular.into_iter().enumerate() {
290        // Only the row's *height* is ever used for placement; upstream's own
291        // `dpt` only feeds its `warn_ratios` diagnostic.
292        let hgt_row = vmetrlst
293            .get(index_r)
294            .map(|(h, _)| *h)
295            .unwrap_or(Length::ZERO);
296        let row_top = ys.get(index_r).copied().unwrap_or(Length::ZERO);
297        for (index_c, cell) in row.into_iter().enumerate() {
298            let x = xs.get(index_c).copied().unwrap_or(Length::ZERO);
299            match cell {
300                Cell::Empty => {}
301                Cell::Normal(pads, content) => {
302                    let wid = widlst.get(index_c).copied().unwrap_or(Length::ZERO);
303                    let padded = pad_content(pads, content);
304                    // Discard `fit_cell`'s own (height, depth): a
305                    // `NormalCell` is placed at the *row's* shared metrics
306                    // (tabular.ml:271's `ImNormalCell(ratios, (wid,
307                    // hgtnmlcell, dptnmlcell), imhbs)`).
308                    let (contents, _fit_hgt, _fit_dpt) = fit_cell(padded, wid);
309                    let baseline_y = row_top - hgt_row;
310                    cells.push(TabularCellBox {
311                        x,
312                        baseline_y,
313                        contents,
314                    });
315                }
316                Cell::Multi(nr, nc, pads, content) => {
317                    let nr = nr.max(1);
318                    let nc = nc.max(1);
319                    let wid = multi_cell_width(widlst, index_c, nc);
320                    let padded = pad_content(pads, content);
321                    let (contents, fit_hgt, fit_dpt) = fit_cell(padded, wid);
322                    // A single-row span places like `NormalCell` (the row's
323                    // own metrics); a multi-row span instead centers the
324                    // *fitted content's own* extent within the combined
325                    // span's vertical space (tabular.ml:288-297). Sign:
326                    // upstream's `(hgt +% lenspace, dpt -% lenspace)` on a
327                    // *negative* dpt is `(hgt + lenspace, dpt_mag +
328                    // lenspace)` on our non-negative magnitude.
329                    let hgt_cell = if nr == 1 {
330                        hgt_row
331                    } else {
332                        let vlen_cell = multi_cell_vertical(vmetrlst, index_r, nr);
333                        let vlen_content = fit_hgt + fit_dpt;
334                        let lenspace = (vlen_cell - vlen_content) * 0.5;
335                        fit_hgt + lenspace
336                    };
337                    let baseline_y = row_top - hgt_cell;
338                    cells.push(TabularCellBox {
339                        x,
340                        baseline_y,
341                        contents,
342                    });
343                }
344            }
345        }
346    }
347    cells
348}
349
350/// `Tabular.main` (tabular.ml:309): solve the whole grid.
351pub fn main(rows: Vec<Vec<Cell>>) -> Solved {
352    let nrows = rows.len();
353    let (ncols, htabular) = normalize_tabular(rows);
354
355    // Row metrics, top-to-bottom.
356    let mut restrow: RestRow = vec![None; ncols];
357    let mut vmetrlst: Vec<(Length, Length)> = Vec::with_capacity(nrows);
358    for row in &htabular {
359        let (rest, hgt, dpt) = determine_row_metrics(&restrow, row);
360        restrow = rest;
361        vmetrlst.push((hgt, dpt));
362    }
363
364    // Column widths, left-to-right.
365    let vtabular = transpose(&htabular, ncols);
366    let mut restcol: RestCol = vec![None; nrows];
367    let mut widlst: Vec<Length> = Vec::with_capacity(ncols);
368    for col in &vtabular {
369        let (rest, wid) = determine_column_width(&restcol, col);
370        restcol = rest;
371        widlst.push(wid);
372    }
373
374    let width = widlst.iter().fold(Length::ZERO, |acc, w| acc + *w);
375    let height = vmetrlst
376        .iter()
377        .fold(Length::ZERO, |acc, (h, d)| acc + *h + *d);
378
379    // Grid-line coordinates for the rule callback (handlePdf.ml's
380    // `ops_of_evaled_tabular`): `xs` ascending from 0 (`ncols + 1` entries),
381    // `ys` descending from `height` (row *tops*) to 0 (`nrows + 1` entries).
382    let mut xs = Vec::with_capacity(ncols + 1);
383    xs.push(Length::ZERO);
384    let mut x = Length::ZERO;
385    for w in &widlst {
386        x = x + *w;
387        xs.push(x);
388    }
389    let mut ys = Vec::with_capacity(nrows + 1);
390    ys.push(height);
391    let mut y = height;
392    for (hgt, dpt) in &vmetrlst {
393        y = y - (*hgt + *dpt);
394        ys.push(y);
395    }
396
397    let cells = solidify_tabular(&vmetrlst, &widlst, &xs, &ys, htabular);
398
399    Solved {
400        width,
401        height,
402        cells,
403        xs,
404        ys,
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    /// A cell whose `natural_metrics` are exactly `(w, h, d)`: a `Graphics`
413    /// box with no elements, so the geometry is deterministic without real
414    /// glyph metrics.
415    fn probe(w: f64, h: f64, d: f64) -> Vec<HorzBox> {
416        vec![HorzBox::Pure(PureHorzBox::Graphics {
417            width: Length::pt(w),
418            height: Length::pt(h),
419            depth: Length::pt(d),
420            elems: Vec::new(),
421            origin_independent: false,
422        })]
423    }
424
425    fn zero_pads() -> Paddings {
426        Paddings {
427            l: Length::ZERO,
428            r: Length::ZERO,
429            t: Length::ZERO,
430            b: Length::ZERO,
431        }
432    }
433
434    #[test]
435    fn two_by_two_normal_grid_geometry() {
436        // col0 widths 30/25 -> 30; col1 widths 20/15 -> 20.
437        // row0 (12,3)/(8,2) -> hgt 12, dpt 3, vlen 15.
438        // row1 (10,4)/(6,1) -> hgt 10, dpt 4, vlen 14.
439        let rows = vec![
440            vec![
441                Cell::Normal(zero_pads(), probe(30.0, 12.0, 3.0)),
442                Cell::Normal(zero_pads(), probe(20.0, 8.0, 2.0)),
443            ],
444            vec![
445                Cell::Normal(zero_pads(), probe(25.0, 10.0, 4.0)),
446                Cell::Normal(zero_pads(), probe(15.0, 6.0, 1.0)),
447            ],
448        ];
449        let solved = main(rows);
450
451        assert_eq!(solved.width, Length::pt(50.0));
452        assert_eq!(solved.height, Length::pt(29.0));
453        assert_eq!(
454            solved.xs,
455            vec![Length::pt(0.0), Length::pt(30.0), Length::pt(50.0)]
456        );
457        assert_eq!(
458            solved.ys,
459            vec![Length::pt(29.0), Length::pt(14.0), Length::pt(0.0)]
460        );
461        assert_eq!(solved.cells.len(), 4);
462
463        // row0 baseline = 29 - 12 = 17; row1 baseline = 14 - 10 = 4.
464        assert_eq!(solved.cells[0].x, Length::pt(0.0));
465        assert_eq!(solved.cells[0].baseline_y, Length::pt(17.0));
466        assert_eq!(solved.cells[1].x, Length::pt(30.0));
467        assert_eq!(solved.cells[1].baseline_y, Length::pt(17.0));
468        assert_eq!(solved.cells[2].x, Length::pt(0.0));
469        assert_eq!(solved.cells[2].baseline_y, Length::pt(4.0));
470        assert_eq!(solved.cells[3].x, Length::pt(30.0));
471        assert_eq!(solved.cells[3].baseline_y, Length::pt(4.0));
472    }
473
474    #[test]
475    fn empty_cell_produces_no_box() {
476        let rows = vec![vec![
477            Cell::Normal(zero_pads(), probe(10.0, 5.0, 1.0)),
478            Cell::Empty,
479        ]];
480        let solved = main(rows);
481        assert_eq!(solved.cells.len(), 1);
482        assert_eq!(solved.xs.len(), 3);
483    }
484
485    #[test]
486    fn multi_column_span_absorbs_following_empty() {
487        // row0: Multi(1,2, w=50) | Empty
488        // row1: Normal(w=20)     | Normal(w=25)
489        // col0 width is forced to 20 by row1; col1 must then absorb the
490        // multi-cell's remaining 50 - 20 = 30 (tabular.ml:119's `rest`).
491        let rows = vec![
492            vec![
493                Cell::Multi(1, 2, zero_pads(), probe(50.0, 10.0, 2.0)),
494                Cell::Empty,
495            ],
496            vec![
497                Cell::Normal(zero_pads(), probe(20.0, 5.0, 1.0)),
498                Cell::Normal(zero_pads(), probe(25.0, 6.0, 1.0)),
499            ],
500        ];
501        let solved = main(rows);
502
503        assert_eq!(
504            solved.xs,
505            vec![Length::pt(0.0), Length::pt(20.0), Length::pt(50.0)]
506        );
507        // 3 boxes: the Multi cell + the two row1 Normals; the row0 Empty
508        // (the span's reserved slot) produces none.
509        assert_eq!(solved.cells.len(), 3);
510        assert_eq!(solved.cells[0].x, Length::pt(0.0));
511    }
512
513    /// A multi-ROW span (what easytable's `merge` leans on). Pins the two
514    /// things a column-span test cannot reach: a `MultiCell` with `nr > 1`
515    /// contributes NOTHING to its own row's height (tabular.ml:34-42), and
516    /// its content is CENTERED in the combined vertical extent
517    /// (tabular.ml:288-297).
518    #[test]
519    fn multi_row_span_centers_content_across_the_rows_it_spans() {
520        // col0: Normal(h10,d2) | Multi(2,1, h6,d1) | Empty
521        // col1: Normal(h8,d1)  | Normal(h9,d3)     | Normal(h7,d2)
522        let rows = vec![
523            vec![
524                Cell::Normal(zero_pads(), probe(20.0, 10.0, 2.0)),
525                Cell::Normal(zero_pads(), probe(15.0, 8.0, 1.0)),
526            ],
527            vec![
528                Cell::Multi(2, 1, zero_pads(), probe(12.0, 6.0, 1.0)),
529                Cell::Normal(zero_pads(), probe(15.0, 9.0, 3.0)),
530            ],
531            vec![
532                Cell::Empty,
533                Cell::Normal(zero_pads(), probe(15.0, 7.0, 2.0)),
534            ],
535        ];
536        let solved = main(rows);
537
538        // Row 1's height/depth come from its col1 `Normal` ALONE (9, 3): the
539        // span contributes only pending `rest_row` state. Rows are 12/12/9.
540        assert_eq!(solved.height, Length::pt(33.0));
541        assert_eq!(
542            solved.ys,
543            vec![
544                Length::pt(33.0),
545                Length::pt(21.0),
546                Length::pt(9.0),
547                Length::pt(0.0)
548            ]
549        );
550        // A single-COLUMN span sets its column's width (nc == 1), but loses
551        // to the wider ordinary cell above it.
552        assert_eq!(
553            solved.xs,
554            vec![Length::pt(0.0), Length::pt(20.0), Length::pt(35.0)]
555        );
556
557        // r0c0, r0c1, r1c0(span), r1c1, r2c1 — the r2c0 `Empty` the span
558        // reserves produces none.
559        assert_eq!(solved.cells.len(), 5);
560        assert_eq!(solved.cells[0].baseline_y, Length::pt(23.0)); // 33 - 10
561        assert_eq!(solved.cells[1].baseline_y, Length::pt(23.0));
562        // The span: combined extent 12 + 9 = 21, content 6 + 1 = 7, so
563        // lenspace = 7 and the content sits 6 + 7 = 13 below the row top
564        // (21) => baseline 8, centered, not on row 1's own baseline (12).
565        assert_eq!(solved.cells[2].x, Length::pt(0.0));
566        assert_eq!(solved.cells[2].baseline_y, Length::pt(8.0));
567        assert_eq!(solved.cells[3].baseline_y, Length::pt(12.0)); // 21 - 9
568        assert_eq!(solved.cells[4].baseline_y, Length::pt(2.0)); // 9 - 7
569    }
570
571    #[test]
572    fn tabular_box_measures_as_a_single_leaf() {
573        let rows = vec![vec![Cell::Normal(zero_pads(), probe(30.0, 12.0, 3.0))]];
574        let solved = main(rows);
575        let tab = TabularBox {
576            width: solved.width,
577            height: solved.height,
578            depth: Length::ZERO,
579            cells: solved.cells,
580            rules: Vec::new(),
581        };
582        let bx = HorzBox::Pure(PureHorzBox::Tabular(tab.clone()));
583        assert_eq!(
584            crate::linebreak::natural_metrics(std::slice::from_ref(&bx)),
585            (tab.width, tab.height, Length::ZERO)
586        );
587        let HorzBox::Pure(p) = &bx;
588        assert!(!p.is_glue());
589        assert_eq!(p.natural_width(), tab.width);
590    }
591}