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