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