use crate::error::FormsError;
use crate::pdf::{button_on_states, checkbox_on, text_value, Field};
use lopdf::Document;
use std::collections::{HashMap, HashSet};
const EPS: f32 = 1.0;
#[derive(Debug, Clone)]
pub enum Geo {
Data {
row: usize,
col: usize,
},
Total {
col: usize,
},
Check,
}
#[derive(Debug, Clone)]
pub struct Placement {
pub fqn: String,
pub geo: Geo,
}
fn page_of(fqn: &str) -> usize {
if fqn.contains("Page2") {
1
} else {
0
}
}
fn row_key(fqn: &str, table_token: &str) -> Option<String> {
let after = fqn.split_once(table_token)?.1; let mut it = after.split('.');
it.next()?; let key = it.next()?; it.next()?; Some(key.to_string())
}
struct GridBands {
col_x: Vec<(f32, f32)>,
row_y: Vec<(f32, f32)>,
min_row_y0: f32,
}
fn derive_bands(fields: &[Field], page: usize, table_token: &str) -> Result<GridBands, FormsError> {
let mut rows: HashMap<String, Vec<&Field>> = HashMap::new();
for f in fields {
if page_of(&f.fqn) == page && f.fqn.contains(table_token) && f.rect.is_some() {
if let Some(k) = row_key(&f.fqn, table_token) {
rows.entry(k).or_default().push(f);
}
}
}
if rows.is_empty() {
return Err(FormsError::Structure(format!(
"page {page}: no data-grid widgets found for band derivation"
)));
}
let mut ordered: Vec<(String, Vec<&Field>)> = rows.into_iter().collect();
let row_cy =
|v: &[&Field]| -> f32 { v.iter().filter_map(|f| f.cy()).sum::<f32>() / (v.len() as f32) };
ordered.sort_by(|a, b| row_cy(&b.1).partial_cmp(&row_cy(&a.1)).unwrap());
let ncols = ordered[0].1.len();
let mut col_x: Vec<(f32, f32)> = vec![(f32::INFINITY, f32::NEG_INFINITY); ncols];
let mut row_y: Vec<(f32, f32)> = Vec::with_capacity(ordered.len());
let mut min_row_y0 = f32::INFINITY;
for (_n, mut widgets) in ordered {
if widgets.len() != ncols {
return Err(FormsError::Structure(format!(
"page {page}: inconsistent column count ({} vs {ncols})",
widgets.len()
)));
}
widgets.sort_by(|a, b| a.rect.unwrap()[0].partial_cmp(&b.rect.unwrap()[0]).unwrap());
let mut y0 = f32::INFINITY;
let mut y1 = f32::NEG_INFINITY;
for (c, w) in widgets.iter().enumerate() {
let r = w.rect.unwrap();
col_x[c].0 = col_x[c].0.min(r[0]);
col_x[c].1 = col_x[c].1.max(r[2]);
y0 = y0.min(r[1]);
y1 = y1.max(r[3]);
}
min_row_y0 = min_row_y0.min(y0);
row_y.push((y0, y1));
}
Ok(GridBands {
col_x,
row_y,
min_row_y0,
})
}
pub fn in_band(v: f32, band: (f32, f32)) -> bool {
v >= band.0 - EPS && v <= band.1 + EPS
}
pub fn column_x_bands(
fields: &[Field],
page: usize,
table_token: &str,
) -> Result<Vec<(f32, f32)>, FormsError> {
Ok(derive_bands(fields, page, table_token)?.col_x)
}
pub fn verify_8949(
doc: &Document,
fields: &[Field],
placements: &[Placement],
table_token: &str,
) -> Result<(), FormsError> {
let index: HashMap<&str, &Field> = fields.iter().map(|f| (f.fqn.as_str(), f)).collect();
let mut pages: Vec<usize> = placements
.iter()
.filter(|p| matches!(p.geo, Geo::Data { .. } | Geo::Total { .. }))
.map(|p| page_of(&p.fqn))
.collect();
pages.sort_unstable();
pages.dedup();
let mut bands: HashMap<usize, GridBands> = HashMap::new();
for page in pages {
bands.insert(page, derive_bands(fields, page, table_token)?);
}
for p in placements {
let field = index
.get(p.fqn.as_str())
.ok_or_else(|| FormsError::MapFieldMissing(p.fqn.clone()))?;
match &p.geo {
Geo::Check => {} Geo::Data { row, col } => {
let page = page_of(&p.fqn);
let b = &bands[&page];
let cx = field.cx().ok_or_else(|| miss_rect(&p.fqn))?;
let cy = field.cy().ok_or_else(|| miss_rect(&p.fqn))?;
let colb = *b.col_x.get(*col).ok_or_else(|| {
FormsError::Geometry(format!("column {col} out of range on page {page}"))
})?;
let rowb = *b.row_y.get(*row).ok_or_else(|| {
FormsError::Geometry(format!("row {row} out of range on page {page}"))
})?;
if !in_band(cx, colb) {
return Err(FormsError::Geometry(format!(
"{}: x-center {cx:.1} not in column {col} band {colb:?} (mis-mapped column)",
p.fqn
)));
}
if !in_band(cy, rowb) {
return Err(FormsError::Geometry(format!(
"{}: y-center {cy:.1} not in row {row} band {rowb:?} (mis-mapped row)",
p.fqn
)));
}
}
Geo::Total { col } => {
let page = page_of(&p.fqn);
let b = &bands[&page];
let cx = field.cx().ok_or_else(|| miss_rect(&p.fqn))?;
let cy = field.cy().ok_or_else(|| miss_rect(&p.fqn))?;
let colb = *b.col_x.get(*col).ok_or_else(|| {
FormsError::Geometry(format!("total column {col} out of range on page {page}"))
})?;
if !in_band(cx, colb) {
return Err(FormsError::Geometry(format!(
"{}: total x-center {cx:.1} not in column {col} band {colb:?}",
p.fqn
)));
}
if cy >= b.min_row_y0 {
return Err(FormsError::Geometry(format!(
"{}: total y-center {cy:.1} is not below the data grid (>= {:.1})",
p.fqn, b.min_row_y0
)));
}
}
}
}
no_unmapped_filled(doc, fields, placements)
}
fn miss_rect(fqn: &str) -> FormsError {
FormsError::Geometry(format!("{fqn}: field has no /Rect to verify"))
}
pub fn no_unmapped_filled(
doc: &Document,
fields: &[Field],
placements: &[Placement],
) -> Result<(), FormsError> {
let allowed: HashSet<&str> = placements.iter().map(|p| p.fqn.as_str()).collect();
assert_only_filled(doc, fields, &allowed)
}
pub fn assert_only_filled(
doc: &Document,
fields: &[Field],
allowed: &HashSet<&str>,
) -> Result<(), FormsError> {
for f in fields {
let filled = if f.is_button {
checkbox_on(doc, f.id).is_some()
} else {
text_value(doc, f.id).is_some_and(|s| !s.is_empty())
};
if filled && !allowed.contains(f.fqn.as_str()) {
return Err(FormsError::UnmappedField(f.fqn.clone()));
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct FlatPlacement {
pub fqn: String,
pub page: usize,
pub col: Option<usize>,
pub descent: Option<(u32, u32)>,
pub check: bool,
}
impl FlatPlacement {
pub fn cell(fqn: impl Into<String>, page: usize, col: usize, grp: u32, ord: u32) -> Self {
Self {
fqn: fqn.into(),
page,
col: Some(col),
descent: Some((grp, ord)),
check: false,
}
}
pub fn col_only(fqn: impl Into<String>, page: usize, col: usize) -> Self {
Self {
fqn: fqn.into(),
page,
col: Some(col),
descent: None,
check: false,
}
}
pub fn free(fqn: impl Into<String>, page: usize) -> Self {
Self {
fqn: fqn.into(),
page,
col: None,
descent: None,
check: false,
}
}
pub fn free_ordered(fqn: impl Into<String>, page: usize, grp: u32, ord: u32) -> Self {
Self {
fqn: fqn.into(),
page,
col: None,
descent: Some((grp, ord)),
check: false,
}
}
pub fn check(fqn: impl Into<String>, page: usize) -> Self {
Self {
fqn: fqn.into(),
page,
col: None,
descent: None,
check: true,
}
}
}
pub fn verify_flat(
doc: &Document,
fields: &[Field],
placements: &[FlatPlacement],
clusters: &[(f32, f32)],
) -> Result<(), FormsError> {
let index: HashMap<&str, &Field> = fields.iter().map(|f| (f.fqn.as_str(), f)).collect();
for p in placements {
let field = index
.get(p.fqn.as_str())
.ok_or_else(|| FormsError::MapFieldMissing(p.fqn.clone()))?;
if page_of(&p.fqn) != p.page {
return Err(FormsError::Geometry(format!(
"{}: field is on page {} but placement expected page {}",
p.fqn,
page_of(&p.fqn),
p.page
)));
}
if let Some(col) = p.col {
let cx = field.cx().ok_or_else(|| miss_rect(&p.fqn))?;
let cluster = *clusters.get(col).ok_or_else(|| {
FormsError::Geometry(format!(
"column {col} out of range (clusters={})",
clusters.len()
))
})?;
if !in_band(cx, cluster) {
return Err(FormsError::Geometry(format!(
"{}: x-center {cx:.1} not in column {col} cluster {cluster:?} (mis-mapped column)",
p.fqn
)));
}
}
}
let mut groups: HashMap<u32, Vec<(u32, f32, &str)>> = HashMap::new();
for p in placements {
if let Some((grp, ord)) = p.descent {
let cy = index[p.fqn.as_str()]
.cy()
.ok_or_else(|| miss_rect(&p.fqn))?;
groups
.entry(grp)
.or_default()
.push((ord, cy, p.fqn.as_str()));
}
}
for seq in groups.values_mut() {
seq.sort_by_key(|(ord, _, _)| *ord);
for w in seq.windows(2) {
if w[0].1 <= w[1].1 + EPS {
return Err(FormsError::Geometry(format!(
"ordinal-y descent broken: {} (y {:.1}) is not strictly above {} (y {:.1}) — mis-mapped row/line",
w[0].2, w[0].1, w[1].2, w[1].1
)));
}
}
}
for p in placements {
let field = index[p.fqn.as_str()];
let (Some(max_len), Some(v)) = (field.max_len, text_value(doc, field.id)) else {
continue;
};
let len = v.chars().count();
if len > max_len {
return Err(FormsError::CellOverflow {
fqn: p.fqn.clone(),
max_len,
len,
});
}
}
let allowed: HashSet<&str> = placements.iter().map(|p| p.fqn.as_str()).collect();
assert_only_filled(doc, fields, &allowed)
}
const DA_PAIR_MAX_DX: f32 = 80.0;
pub fn topmost_yes_no_pair(
doc: &Document,
fields: &[Field],
page: usize,
) -> Result<(String, String), FormsError> {
let mut by_y: HashMap<i32, Vec<(&Field, Vec<String>)>> = HashMap::new();
for f in fields {
if !f.is_button || page_of(&f.fqn) != page {
continue;
}
let Some(cy) = f.cy() else { continue };
let states = button_on_states(doc, f.id);
if states.is_empty() {
continue;
}
by_y.entry(cy.round() as i32).or_default().push((f, states));
}
let mut candidates: Vec<(f32, &Field, &Field)> = Vec::new();
for members in by_y.values() {
if members.len() != 2 {
continue;
}
let mut states: Vec<&str> = members
.iter()
.flat_map(|(_, s)| s.iter().map(|x| x.as_str()))
.collect();
states.sort_unstable();
if states != ["1", "2"] {
continue;
}
let (a, b) = (members[0].0, members[1].0);
if (a.cx().unwrap() - b.cx().unwrap()).abs() > DA_PAIR_MAX_DX {
continue; }
let cy = a.cy().unwrap();
candidates.push((cy, a, b));
}
candidates.sort_by(|x, y| y.0.partial_cmp(&x.0).unwrap());
let (_, a, b) = candidates.first().ok_or_else(|| {
FormsError::Geometry(format!(
"no adjacent same-y {{/1,/2}} /Btn pair found on page {page}"
))
})?;
if a.cx().unwrap() <= b.cx().unwrap() {
Ok((a.fqn.clone(), b.fqn.clone()))
} else {
Ok((b.fqn.clone(), a.fqn.clone()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pdf;
#[test]
fn a_value_over_its_maxlen_comb_cell_fails_closed() {
const SSN_CELL: &str = "topmostSubform[0].Page1[0].f1_06[0]";
let fill = |value: &str| -> Result<(), FormsError> {
let mut doc = pdf::load(pdf::F1040_PDF_2024).unwrap();
let index = pdf::index(&pdf::collect_fields(&doc).unwrap());
pdf::apply_writes(
&mut doc,
&index,
&[(SSN_CELL.to_string(), pdf::FieldValue::Text(value.into()))],
)
.unwrap();
let bytes = pdf::save(&mut doc).unwrap();
let check = pdf::load(&bytes).unwrap();
let fields = pdf::collect_fields(&check).unwrap();
verify_flat(
&check,
&fields,
&[FlatPlacement::free(SSN_CELL, 0)],
&[(0.0, 612.0)],
)
};
assert!(fill("123456789").is_ok());
let err = fill("123-45-6789").expect_err("an over-long value must fail closed");
assert!(
matches!(&err, FormsError::CellOverflow { fqn, max_len, len }
if fqn == SSN_CELL && *max_len == 9 && *len == 11),
"expected CellOverflow, got {err:?}"
);
}
}