Skip to main content

btctax_forms/
verify.rs

1//! **Geometric, map-INDEPENDENT read-back.** The naive "re-read each value through the same map"
2//! is circular — a swapped-column map would pass. Instead we re-derive the column-x / row-y bands
3//! straight from the bundled PDF's widget `/Rect`s (structural grouping by `Row{n}` subform, column
4//! order by x-position, row order by y-position) and assert that every value we wrote landed in the
5//! band its LOGICAL cell demands. A mis-mapped cell puts the value in the wrong band → we fail
6//! closed. We also assert NO field outside the authorized set carries a value. **The map is what we
7//! distrust; the PDF's geometry is the oracle.**
8
9use crate::error::FormsError;
10use crate::pdf::{button_on_states, checkbox_on, text_value, Field};
11use lopdf::Document;
12use std::collections::{HashMap, HashSet};
13
14/// The separation two money cells must have before one counts as ABOVE the other. Two cells within a
15/// point of each other are the same printed row, not an ordering.
16///
17/// ★ Shared with `form1040_full.rs`'s refund/owe guard so the crate expresses "strictly above" ONE
18/// way. They used to differ — this leg demanded a 1.0-point margin while that guard used a bare `>`,
19/// which accepts a 0.0001-point gap (r7 Nit).
20pub(crate) const EPS: f32 = 1.0;
21
22/// Where a written value is supposed to land.
23#[derive(Debug, Clone)]
24pub enum Geo {
25    /// A data-grid cell: 0-based row (top→bottom) and column (a=0 … h=7).
26    Data {
27        /// 0-based data row (Row1 = 0, the topmost).
28        row: usize,
29        /// 0-based column (a=0, b=1, … h=7).
30        col: usize,
31    },
32    /// A per-part totals-row cell in column `col`, which must sit BELOW the data grid.
33    Total {
34        /// 0-based column (d=3, e=4, g=6, h=7).
35        col: usize,
36    },
37    /// A checkbox / non-grid value — excluded from the column geometry, still in the no-unmapped set.
38    Check,
39}
40
41/// One authorized write: the field we set, and the logical cell it must occupy.
42#[derive(Debug, Clone)]
43pub struct Placement {
44    /// Fully-qualified field name that was written.
45    pub fqn: String,
46    /// The logical cell the value must land in.
47    pub geo: Geo,
48}
49
50fn page_of(fqn: &str) -> usize {
51    if fqn.contains("Page2") {
52        1
53    } else {
54        0
55    }
56}
57
58/// The row-group key of a data-grid cell: the subform component **immediately after the table token**
59/// (`Row1[0]` on Form 8949, `Line3[0]`/`Line1b[0]` on the 2017 Schedule D whose grid rows are named
60/// `Line{n}`, not `Row{n}`). Rows are later ordered by geometry (widget y-center), so the key need
61/// only be unique per row, not numeric. Returns `None` for a non-grid field.
62fn row_key(fqn: &str, table_token: &str) -> Option<String> {
63    let after = fqn.split_once(table_token)?.1; // e.g. "[0].Row1[0].f1_3[0]"
64    let mut it = after.split('.');
65    it.next()?; // the table token's own "[0]"
66    let key = it.next()?; // "Row1[0]" / "Line3[0]"
67    it.next()?; // require a leaf beyond the row subform (so the table node itself is skipped)
68    Some(key.to_string())
69}
70
71/// Column-x and row-y bands re-derived from one page's data grid — the geometry oracle.
72struct GridBands {
73    /// Per-column-index (0..=7) x-interval `(min_x0, max_x1)`, ordered left→right by geometry.
74    col_x: Vec<(f32, f32)>,
75    /// Per-row-index (0.., top→bottom by geometry) y-interval `(y0, y1)`.
76    row_y: Vec<(f32, f32)>,
77    /// The lowest data-row bottom edge — the totals row must sit below this.
78    min_row_y0: f32,
79}
80
81fn derive_bands(fields: &[Field], page: usize, table_token: &str) -> Result<GridBands, FormsError> {
82    // Group this page's data-grid widgets by structural row subform (independent of the map).
83    let mut rows: HashMap<String, Vec<&Field>> = HashMap::new();
84    for f in fields {
85        if page_of(&f.fqn) == page && f.fqn.contains(table_token) && f.rect.is_some() {
86            if let Some(k) = row_key(&f.fqn, table_token) {
87                rows.entry(k).or_default().push(f);
88            }
89        }
90    }
91    if rows.is_empty() {
92        return Err(FormsError::Structure(format!(
93            "page {page}: no data-grid widgets found for band derivation"
94        )));
95    }
96    // Order rows top→bottom by widget y-center (geometry, NOT the row subform label).
97    let mut ordered: Vec<(String, Vec<&Field>)> = rows.into_iter().collect();
98    let row_cy =
99        |v: &[&Field]| -> f32 { v.iter().filter_map(|f| f.cy()).sum::<f32>() / (v.len() as f32) };
100    ordered.sort_by(|a, b| row_cy(&b.1).partial_cmp(&row_cy(&a.1)).unwrap());
101
102    let ncols = ordered[0].1.len();
103    let mut col_x: Vec<(f32, f32)> = vec![(f32::INFINITY, f32::NEG_INFINITY); ncols];
104    let mut row_y: Vec<(f32, f32)> = Vec::with_capacity(ordered.len());
105    let mut min_row_y0 = f32::INFINITY;
106
107    for (_n, mut widgets) in ordered {
108        if widgets.len() != ncols {
109            return Err(FormsError::Structure(format!(
110                "page {page}: inconsistent column count ({} vs {ncols})",
111                widgets.len()
112            )));
113        }
114        // Column order is defined by x-position, purely from geometry.
115        widgets.sort_by(|a, b| a.rect.unwrap()[0].partial_cmp(&b.rect.unwrap()[0]).unwrap());
116        let mut y0 = f32::INFINITY;
117        let mut y1 = f32::NEG_INFINITY;
118        for (c, w) in widgets.iter().enumerate() {
119            let r = w.rect.unwrap();
120            col_x[c].0 = col_x[c].0.min(r[0]);
121            col_x[c].1 = col_x[c].1.max(r[2]);
122            y0 = y0.min(r[1]);
123            y1 = y1.max(r[3]);
124        }
125        min_row_y0 = min_row_y0.min(y0);
126        row_y.push((y0, y1));
127    }
128    Ok(GridBands {
129        col_x,
130        row_y,
131        min_row_y0,
132    })
133}
134
135/// Whether a coordinate lies within a band (with float tolerance).
136pub fn in_band(v: f32, band: (f32, f32)) -> bool {
137    v >= band.0 - EPS && v <= band.1 + EPS
138}
139
140/// Left→right x-bands of a table's amount columns, re-derived from the PDF geometry (independent of
141/// any map). Used by the Schedule D read-back to catch a mis-mapped d/e/g/h column.
142pub fn column_x_bands(
143    fields: &[Field],
144    page: usize,
145    table_token: &str,
146) -> Result<Vec<(f32, f32)>, FormsError> {
147    Ok(derive_bands(fields, page, table_token)?.col_x)
148}
149
150/// Verify a Form 8949 fill: (1) every written value lands in the geometrically-expected column/row
151/// band, and (2) no unmapped field carries a value. Fails closed. `table_token` is the per-year
152/// data-grid subform token (`Table_Line1` for 2024, `Table_Line1_Part` for 2025).
153pub fn verify_8949(
154    doc: &Document,
155    fields: &[Field],
156    placements: &[Placement],
157    table_token: &str,
158) -> Result<(), FormsError> {
159    let index: HashMap<&str, &Field> = fields.iter().map(|f| (f.fqn.as_str(), f)).collect();
160    // Independently derive the geometry oracle for every page a placement touches (0 = Part I,
161    // 1 = Part II) — up front, so a mis-mapped cell cannot dodge the check.
162    let mut pages: Vec<usize> = placements
163        .iter()
164        .filter(|p| matches!(p.geo, Geo::Data { .. } | Geo::Total { .. }))
165        .map(|p| page_of(&p.fqn))
166        .collect();
167    pages.sort_unstable();
168    pages.dedup();
169    let mut bands: HashMap<usize, GridBands> = HashMap::new();
170    for page in pages {
171        bands.insert(page, derive_bands(fields, page, table_token)?);
172    }
173
174    for p in placements {
175        let field = index
176            .get(p.fqn.as_str())
177            .ok_or_else(|| FormsError::MapFieldMissing(p.fqn.clone()))?;
178        match &p.geo {
179            Geo::Check => {} // geometry N/A; only participates in the no-unmapped scan below
180            Geo::Data { row, col } => {
181                let page = page_of(&p.fqn);
182                let b = &bands[&page];
183                let cx = field.cx().ok_or_else(|| miss_rect(&p.fqn))?;
184                let cy = field.cy().ok_or_else(|| miss_rect(&p.fqn))?;
185                let colb = *b.col_x.get(*col).ok_or_else(|| {
186                    FormsError::Geometry(format!("column {col} out of range on page {page}"))
187                })?;
188                let rowb = *b.row_y.get(*row).ok_or_else(|| {
189                    FormsError::Geometry(format!("row {row} out of range on page {page}"))
190                })?;
191                if !in_band(cx, colb) {
192                    return Err(FormsError::Geometry(format!(
193                        "{}: x-center {cx:.1} not in column {col} band {colb:?} (mis-mapped column)",
194                        p.fqn
195                    )));
196                }
197                if !in_band(cy, rowb) {
198                    return Err(FormsError::Geometry(format!(
199                        "{}: y-center {cy:.1} not in row {row} band {rowb:?} (mis-mapped row)",
200                        p.fqn
201                    )));
202                }
203            }
204            Geo::Total { col } => {
205                let page = page_of(&p.fqn);
206                let b = &bands[&page];
207                let cx = field.cx().ok_or_else(|| miss_rect(&p.fqn))?;
208                let cy = field.cy().ok_or_else(|| miss_rect(&p.fqn))?;
209                let colb = *b.col_x.get(*col).ok_or_else(|| {
210                    FormsError::Geometry(format!("total column {col} out of range on page {page}"))
211                })?;
212                if !in_band(cx, colb) {
213                    return Err(FormsError::Geometry(format!(
214                        "{}: total x-center {cx:.1} not in column {col} band {colb:?}",
215                        p.fqn
216                    )));
217                }
218                if cy >= b.min_row_y0 {
219                    return Err(FormsError::Geometry(format!(
220                        "{}: total y-center {cy:.1} is not below the data grid (>= {:.1})",
221                        p.fqn, b.min_row_y0
222                    )));
223                }
224            }
225        }
226    }
227    no_unmapped_filled(doc, fields, placements)
228}
229
230fn miss_rect(fqn: &str) -> FormsError {
231    FormsError::Geometry(format!("{fqn}: field has no /Rect to verify"))
232}
233
234/// Assert that every field carrying a value is in the authorized (placement) set.
235pub fn no_unmapped_filled(
236    doc: &Document,
237    fields: &[Field],
238    placements: &[Placement],
239) -> Result<(), FormsError> {
240    let allowed: HashSet<&str> = placements.iter().map(|p| p.fqn.as_str()).collect();
241    assert_only_filled(doc, fields, &allowed)
242}
243
244/// The form-agnostic core of the no-unmapped guard: every filled field must be in `allowed`. Shared by
245/// the SP1 [`no_unmapped_filled`] and the SP2 flat-form verifier.
246pub fn assert_only_filled(
247    doc: &Document,
248    fields: &[Field],
249    allowed: &HashSet<&str>,
250) -> Result<(), FormsError> {
251    for f in fields {
252        let filled = if f.is_button {
253            checkbox_on(doc, f.id).is_some()
254        } else {
255            text_value(doc, f.id).is_some_and(|s| !s.is_empty())
256        };
257        if filled && !allowed.contains(f.fqn.as_str()) {
258            return Err(FormsError::UnmappedField(f.fqn.clone()));
259        }
260    }
261    Ok(())
262}
263
264// ── [★ R0-C3] SP2 per-form geometric oracle for the FLAT (non-grid) forms ─────────────────────────
265//
266// Schedule SE / Form 8283 / Form 1040 have no `Row{n}` data-grid subform, so the SP1 grid oracle does
267// not fit. Instead this oracle asserts, map-INDEPENDENTLY, that each written value's widget landed:
268//   (1) in its logical column's hand-pinned x-cluster (measured from the blank PDF; catches a
269//       cross-column swap, e.g. SE line 12 (amount) ↔ line 13 (mid));
270//   (2) in strictly-descending center-y within a logically ordered sequence (catches a same-column
271//       swap, e.g. SE 10 ↔ 11) — asserted PER descent GROUP so 8283's two-table columns [R0-M1] each
272//       descend within their own field set;
273//   (3) for the 1040 Digital-Asset question, that the "Yes" value is the LEFT member of the top-most
274//       same-y `/Btn` pair whose on-states are exactly {`/1`,`/2`} (a [`topmost_yes_no_pair`] check);
275//   (4) on the expected page; 1-pt preprinted-constant spacer fields are never map targets.
276// The map is what we distrust — a mis-mapped cell lands in the wrong cluster / breaks monotonicity and
277// FAILS CLOSED. `assert_only_filled` still guards against any stray write.
278
279/// One authorized SP2 write. `col` indexes a hand-pinned column-x cluster (`None` = geometry-exempt,
280/// e.g. a wide free-text identity field); `descent` = `(group, ordinal)` for per-group strictly-
281/// descending-y ordering (`None` = not in any ordered sequence); `check` marks a checkbox (only in the
282/// no-unmapped scan + any same-y-pair predicate).
283#[derive(Debug, Clone)]
284pub struct FlatPlacement {
285    /// Fully-qualified field name that was written.
286    pub fqn: String,
287    /// 0-based page the write must land on.
288    pub page: usize,
289    /// Logical column index into the per-form hand-pinned x-cluster table (`None` = not column-checked).
290    pub col: Option<usize>,
291    /// `(descent_group, ordinal)` — within a group, center-y must strictly decrease as ordinal rises.
292    pub descent: Option<(u32, u32)>,
293    /// `true` iff this is a checkbox (geometry-exempt; still in the no-unmapped set).
294    pub check: bool,
295}
296
297impl FlatPlacement {
298    /// A column-checked, descent-participating money/text cell.
299    pub fn cell(fqn: impl Into<String>, page: usize, col: usize, grp: u32, ord: u32) -> Self {
300        Self {
301            fqn: fqn.into(),
302            page,
303            col: Some(col),
304            descent: Some((grp, ord)),
305            check: false,
306        }
307    }
308    /// A column-checked cell that does NOT participate in any descent sequence.
309    pub fn col_only(fqn: impl Into<String>, page: usize, col: usize) -> Self {
310        Self {
311            fqn: fqn.into(),
312            page,
313            col: Some(col),
314            descent: None,
315            check: false,
316        }
317    }
318    /// A geometry-exempt write (wide free-text identity field): page-checked + no-unmapped only.
319    pub fn free(fqn: impl Into<String>, page: usize) -> Self {
320        Self {
321            fqn: fqn.into(),
322            page,
323            col: None,
324            descent: None,
325            check: false,
326        }
327    }
328    /// A geometry-exempt write that ALSO participates in a per-group strictly-descending-y ordinal
329    /// sequence — for a free-text field SEQUENCE with no column geometry to check but a real physical
330    /// top-to-bottom order the fill assumes (e.g. Form 8275's Part IV continuation lines, written in
331    /// `part_iv_continuation` array order): a map that reordered the array, or a bundled asset whose
332    /// lines were not actually laid out top-to-bottom in that order, fails closed here instead of
333    /// silently landing text out of sequence.
334    pub fn free_ordered(fqn: impl Into<String>, page: usize, grp: u32, ord: u32) -> Self {
335        Self {
336            fqn: fqn.into(),
337            page,
338            col: None,
339            descent: Some((grp, ord)),
340            check: false,
341        }
342    }
343    /// A checkbox: no-unmapped only (+ any same-y-pair predicate the caller runs).
344    pub fn check(fqn: impl Into<String>, page: usize) -> Self {
345        Self {
346            fqn: fqn.into(),
347            page,
348            col: None,
349            descent: None,
350            check: true,
351        }
352    }
353}
354
355/// Verify a flat-form fill: page membership + hand-pinned column-x membership + per-group ordinal-y
356/// descent + the no-unmapped scan. `clusters` is the per-form hand-pinned logical-column → `(min_x0,
357/// max_x1)` table (measured from the blank PDF). Fails closed.
358pub fn verify_flat(
359    doc: &Document,
360    fields: &[Field],
361    placements: &[FlatPlacement],
362    clusters: &[(f32, f32)],
363) -> Result<(), FormsError> {
364    let index: HashMap<&str, &Field> = fields.iter().map(|f| (f.fqn.as_str(), f)).collect();
365
366    // (4)+(1) page membership + column-x membership.
367    for p in placements {
368        let field = index
369            .get(p.fqn.as_str())
370            .ok_or_else(|| FormsError::MapFieldMissing(p.fqn.clone()))?;
371        if page_of(&p.fqn) != p.page {
372            return Err(FormsError::Geometry(format!(
373                "{}: field is on page {} but placement expected page {}",
374                p.fqn,
375                page_of(&p.fqn),
376                p.page
377            )));
378        }
379        if let Some(col) = p.col {
380            let cx = field.cx().ok_or_else(|| miss_rect(&p.fqn))?;
381            let cluster = *clusters.get(col).ok_or_else(|| {
382                FormsError::Geometry(format!(
383                    "column {col} out of range (clusters={})",
384                    clusters.len()
385                ))
386            })?;
387            if !in_band(cx, cluster) {
388                return Err(FormsError::Geometry(format!(
389                    "{}: x-center {cx:.1} not in column {col} cluster {cluster:?} (mis-mapped column)",
390                    p.fqn
391                )));
392            }
393        }
394    }
395
396    // (2) ordinal-y descent, per group.
397    let mut groups: HashMap<u32, Vec<(u32, f32, &str)>> = HashMap::new();
398    for p in placements {
399        if let Some((grp, ord)) = p.descent {
400            let cy = index[p.fqn.as_str()]
401                .cy()
402                .ok_or_else(|| miss_rect(&p.fqn))?;
403            groups
404                .entry(grp)
405                .or_default()
406                .push((ord, cy, p.fqn.as_str()));
407        }
408    }
409    for seq in groups.values_mut() {
410        seq.sort_by_key(|(ord, _, _)| *ord);
411        for w in seq.windows(2) {
412            // Earlier ordinal must sit strictly ABOVE (higher center-y) the next.
413            if w[0].1 <= w[1].1 + EPS {
414                return Err(FormsError::Geometry(format!(
415                    "ordinal-y descent broken: {} (y {:.1}) is not strictly above {} (y {:.1}) — mis-mapped row/line",
416                    w[0].2, w[0].1, w[1].2, w[1].1
417                )));
418            }
419        }
420    }
421
422    // (5) /MaxLen — a value the cell physically cannot hold. Checked on the READ-BACK (the serialized
423    // bytes), like every other leg of the oracle, and against the PDF's OWN declared capacity rather
424    // than anything the map asserts. A viewer would silently truncate an over-long value; we refuse.
425    for p in placements {
426        let field = index[p.fqn.as_str()];
427        let (Some(max_len), Some(v)) = (field.max_len, text_value(doc, field.id)) else {
428            continue;
429        };
430        // Count CHARACTERS, not bytes — /MaxLen is in characters, and a name can be non-ASCII.
431        let len = v.chars().count();
432        if len > max_len {
433            return Err(FormsError::CellOverflow {
434                fqn: p.fqn.clone(),
435                max_len,
436                len,
437            });
438        }
439    }
440
441    // (3) no unmapped write.
442    let allowed: HashSet<&str> = placements.iter().map(|p| p.fqn.as_str()).collect();
443    assert_only_filled(doc, fields, &allowed)
444}
445
446/// Maximum horizontal gap (widget-center to widget-center, PDF points) between the two boxes of the
447/// Digital-Asset Yes/No pair for them to count as **adjacent**. The real DA "Yes"/"No" boxes sit ~36pt
448/// apart on both the 2024 and 2025 1040; the 2024 **filing-status** `{/1,/2}` row (Single vs MFJ) is a
449/// same-y pair too but its boxes are ~266pt apart — the trap the top-most-y rule fell into. 80pt
450/// brackets the real gap with margin while excluding that non-adjacent row.
451const DA_PAIR_MAX_DX: f32 = 80.0;
452
453/// Map-INDEPENDENT oracle for the 1040 Digital-Asset Yes/No question: the **top-most horizontally
454/// ADJACENT** page-`page` `/Btn` pair (exactly two widgets sharing a center-y, boxes ≤ [`DA_PAIR_MAX_DX`]
455/// apart) whose on-states are exactly {`/1`,`/2`}. Returns `(yes_fqn, no_fqn)` = (LEFT member, right
456/// member). Derived from the blank PDF's widget geometry + appearance states, never the map.
457///
458/// **[R0-C2]** Selecting by adjacency (not merely top-most-y) is what keeps the 2024 fill off the
459/// FILING-STATUS `{/1,/2}` row (Single @ x≈107 vs MFJ @ x≈373, ~266pt apart) that sits ABOVE the DA
460/// pair; the DA "Yes"/"No" boxes are ~36pt apart. Re-verified against 2025 (its DA pair is the top-most
461/// `{/1,/2}` 2-widget row AND adjacent, so no regression).
462pub fn topmost_yes_no_pair(
463    doc: &Document,
464    fields: &[Field],
465    page: usize,
466) -> Result<(String, String), FormsError> {
467    // Group page buttons (that carry a rect + on-states) by rounded center-y.
468    let mut by_y: HashMap<i32, Vec<(&Field, Vec<String>)>> = HashMap::new();
469    for f in fields {
470        if !f.is_button || page_of(&f.fqn) != page {
471            continue;
472        }
473        let Some(cy) = f.cy() else { continue };
474        let states = button_on_states(doc, f.id);
475        if states.is_empty() {
476            continue;
477        }
478        by_y.entry(cy.round() as i32).or_default().push((f, states));
479    }
480    // A qualifying row: EXACTLY two widgets whose combined on-states are exactly {"1","2"} AND whose
481    // boxes are horizontally ADJACENT (≤ DA_PAIR_MAX_DX apart).
482    let mut candidates: Vec<(f32, &Field, &Field)> = Vec::new();
483    for members in by_y.values() {
484        if members.len() != 2 {
485            continue;
486        }
487        let mut states: Vec<&str> = members
488            .iter()
489            .flat_map(|(_, s)| s.iter().map(|x| x.as_str()))
490            .collect();
491        states.sort_unstable();
492        if states != ["1", "2"] {
493            continue;
494        }
495        let (a, b) = (members[0].0, members[1].0);
496        if (a.cx().unwrap() - b.cx().unwrap()).abs() > DA_PAIR_MAX_DX {
497            continue; // non-adjacent (e.g. the 2024 filing-status row) — not the DA pair.
498        }
499        let cy = a.cy().unwrap();
500        candidates.push((cy, a, b));
501    }
502    // Top-most (largest center-y) AMONG the adjacent pairs.
503    candidates.sort_by(|x, y| y.0.partial_cmp(&x.0).unwrap());
504    let (_, a, b) = candidates.first().ok_or_else(|| {
505        FormsError::Geometry(format!(
506            "no adjacent same-y {{/1,/2}} /Btn pair found on page {page}"
507        ))
508    })?;
509    // Left member = the Yes box.
510    if a.cx().unwrap() <= b.cx().unwrap() {
511        Ok((a.fqn.clone(), b.fqn.clone()))
512    } else {
513        Ok((b.fqn.clone(), a.fqn.clone()))
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use crate::pdf;
521
522    /// ★ A value too long for its `/MaxLen` comb cell FAILS CLOSED. This is the guard that makes the
523    /// hyphens-vs-digits question un-losable: the 1040's SSN cells are 9-character combs, so a
524    /// formatted `123-45-6789` (ELEVEN characters) is not a formatting preference — it is a value the
525    /// cell cannot hold, which a PDF viewer would silently truncate or splay across the wrong teeth.
526    /// Silent truncation on a filed return is exactly the class of defect this crate refuses to ship.
527    #[test]
528    fn a_value_over_its_maxlen_comb_cell_fails_closed() {
529        const SSN_CELL: &str = "topmostSubform[0].Page1[0].f1_06[0]";
530        let fill = |value: &str| -> Result<(), FormsError> {
531            let mut doc = pdf::load(pdf::F1040_PDF_2024).unwrap();
532            let index = pdf::index(&pdf::collect_fields(&doc).unwrap());
533            pdf::apply_writes(
534                &mut doc,
535                &index,
536                &[(SSN_CELL.to_string(), pdf::FieldValue::Text(value.into()))],
537            )
538            .unwrap();
539            let bytes = pdf::save(&mut doc).unwrap();
540            let check = pdf::load(&bytes).unwrap();
541            let fields = pdf::collect_fields(&check).unwrap();
542            verify_flat(
543                &check,
544                &fields,
545                &[FlatPlacement::free(SSN_CELL, 0)],
546                &[(0.0, 612.0)],
547            )
548        };
549
550        // Nine bare digits fit exactly.
551        assert!(fill("123456789").is_ok());
552
553        // The hyphenated form is eleven characters — it does NOT fit, and must not be written.
554        let err = fill("123-45-6789").expect_err("an over-long value must fail closed");
555        assert!(
556            matches!(&err, FormsError::CellOverflow { fqn, max_len, len }
557                if fqn == SSN_CELL && *max_len == 9 && *len == 11),
558            "expected CellOverflow, got {err:?}"
559        );
560    }
561}