Skip to main content

btctax_forms/
pdf.rs

1//! The low-level lopdf fill primitive: parse a bundled IRS PDF, walk its AcroForm field tree,
2//! drop the `/XFA` layer, set `/V` (+ checkbox `/AS`), pin determinism, and serialize.
3//!
4//! These forms are **static XFA hybrids**: the `/AcroForm` carries both a live `/XFA` XML layer
5//! (which Acrobat/Reader PREFER — a `/V`-only fill opens BLANK) AND a complete classic AcroForm.
6//! Removing `/XFA` makes the classic `/V`/`/AS` values authoritative and render everywhere.
7
8use crate::error::FormsError;
9use lopdf::{Document, Object, ObjectId, StringFormat};
10use std::collections::HashMap;
11
12/// The bundled TY2025 Form 8949 (official IRS fillable PDF, US-gov public domain).
13pub const F8949_PDF_2025: &[u8] = include_bytes!("../forms/2025/f8949.pdf");
14/// The bundled TY2025 Schedule D (official IRS fillable PDF, US-gov public domain).
15pub const SCHEDULE_D_PDF_2025: &[u8] = include_bytes!("../forms/2025/schedule_d.pdf");
16/// The bundled TY2025 Schedule SE (official IRS fillable PDF, US-gov public domain).
17pub const SCHEDULE_SE_PDF_2025: &[u8] = include_bytes!("../forms/2025/schedule_se.pdf");
18/// The bundled Form 8283, Rev. 12-2025 (official IRS fillable PDF, US-gov public domain).
19pub const F8283_PDF_2025: &[u8] = include_bytes!("../forms/2025/f8283.pdf");
20/// The bundled TY2025 Form 1040 (official IRS fillable PDF, US-gov public domain).
21pub const F1040_PDF_2025: &[u8] = include_bytes!("../forms/2025/f1040.pdf");
22
23/// The bundled TY2024 Form 8949 (official IRS fillable PDF, US-gov public domain).
24pub const F8949_PDF_2024: &[u8] = include_bytes!("../forms/2024/f8949.pdf");
25/// The bundled TY2024 Schedule D (official IRS fillable PDF, US-gov public domain).
26pub const SCHEDULE_D_PDF_2024: &[u8] = include_bytes!("../forms/2024/schedule_d.pdf");
27/// The bundled TY2024 Schedule SE (official IRS fillable PDF, US-gov public domain).
28pub const SCHEDULE_SE_PDF_2024: &[u8] = include_bytes!("../forms/2024/schedule_se.pdf");
29/// The bundled Form 8283, Rev. 12-2023 (TY2024; official IRS fillable PDF, US-gov public domain).
30pub const F8283_PDF_2024: &[u8] = include_bytes!("../forms/2024/f8283.pdf");
31/// The bundled TY2024 Form 1040 (official IRS fillable PDF, US-gov public domain).
32pub const F1040_PDF_2024: &[u8] = include_bytes!("../forms/2024/f1040.pdf");
33/// The bundled TY2024 Form 8959, Additional Medicare Tax (official IRS fillable PDF, public domain).
34pub const F8959_PDF_2024: &[u8] = include_bytes!("../forms/2024/f8959.pdf");
35/// The bundled TY2024 Form 8960, Net Investment Income Tax (official IRS fillable PDF, public domain).
36pub const F8960_PDF_2024: &[u8] = include_bytes!("../forms/2024/f8960.pdf");
37/// The bundled TY2024 Form 8995, QBI deduction — simplified (official IRS fillable PDF, public domain).
38pub const F8995_PDF_2024: &[u8] = include_bytes!("../forms/2024/f8995.pdf");
39/// The bundled TY2024 Schedule 2, Additional Taxes (official IRS fillable PDF, public domain).
40pub const SCHEDULE_2_PDF_2024: &[u8] = include_bytes!("../forms/2024/f1040s2.pdf");
41/// The bundled TY2024 Schedule 3, Additional Credits and Payments (official IRS fillable PDF, public domain).
42pub const SCHEDULE_3_PDF_2024: &[u8] = include_bytes!("../forms/2024/f1040s3.pdf");
43/// The bundled TY2024 Schedule A, Itemized Deductions (official IRS fillable PDF, public domain).
44pub const SCHEDULE_A_PDF_2024: &[u8] = include_bytes!("../forms/2024/f1040sa.pdf");
45/// The bundled TY2024 Schedule 1, Additional Income and Adjustments (official IRS fillable PDF, public domain).
46pub const SCHEDULE_1_PDF_2024: &[u8] = include_bytes!("../forms/2024/f1040s1.pdf");
47/// The bundled TY2024 Schedule C, Profit or Loss From Business (official IRS fillable PDF, public domain).
48pub const SCHEDULE_C_PDF_2024: &[u8] = include_bytes!("../forms/2024/f1040sc.pdf");
49/// The bundled TY2024 Schedule B, Interest and Ordinary Dividends (official IRS fillable PDF, public domain).
50pub const SCHEDULE_B_PDF_2024: &[u8] = include_bytes!("../forms/2024/f1040sb.pdf");
51
52/// The bundled TY2017 Form 8949 (official IRS fillable PDF, US-gov public domain).
53pub const F8949_PDF_2017: &[u8] = include_bytes!("../forms/2017/f8949.pdf");
54/// The bundled TY2017 Schedule D (official IRS fillable PDF, US-gov public domain).
55pub const SCHEDULE_D_PDF_2017: &[u8] = include_bytes!("../forms/2017/schedule_d.pdf");
56/// The bundled TY2017 Schedule SE (official IRS fillable PDF, US-gov public domain).
57pub const SCHEDULE_SE_PDF_2017: &[u8] = include_bytes!("../forms/2017/schedule_se.pdf");
58/// The bundled Form 8283, Rev. 12-2014 (TY2017; official IRS fillable PDF, US-gov public domain).
59pub const F8283_PDF_2017: &[u8] = include_bytes!("../forms/2017/f8283.pdf");
60/// The bundled TY2017 Form 1040 (official IRS fillable PDF, US-gov public domain).
61pub const F1040_PDF_2017: &[u8] = include_bytes!("../forms/2017/f1040.pdf");
62
63/// The bundled Form 8949 PDF bytes for a supported tax year (the asset bound to the year's map).
64pub fn f8949_pdf(year: i32) -> Result<&'static [u8], FormsError> {
65    match year {
66        2017 => Ok(F8949_PDF_2017),
67        2024 => Ok(F8949_PDF_2024),
68        2025 => Ok(F8949_PDF_2025),
69        _ => Err(FormsError::UnsupportedYear(year)),
70    }
71}
72
73/// The bundled Schedule D PDF bytes for a supported tax year.
74pub fn schedule_d_pdf(year: i32) -> Result<&'static [u8], FormsError> {
75    match year {
76        2017 => Ok(SCHEDULE_D_PDF_2017),
77        2024 => Ok(SCHEDULE_D_PDF_2024),
78        2025 => Ok(SCHEDULE_D_PDF_2025),
79        _ => Err(FormsError::UnsupportedYear(year)),
80    }
81}
82
83/// The bundled Schedule SE PDF bytes for a supported tax year.
84pub fn schedule_se_pdf(year: i32) -> Result<&'static [u8], FormsError> {
85    match year {
86        2017 => Ok(SCHEDULE_SE_PDF_2017),
87        2024 => Ok(SCHEDULE_SE_PDF_2024),
88        2025 => Ok(SCHEDULE_SE_PDF_2025),
89        _ => Err(FormsError::UnsupportedYear(year)),
90    }
91}
92
93/// The bundled Form 8959 PDF bytes for a supported tax year. Full-return v1 is TY2024-only.
94pub fn f8959_pdf(year: i32) -> Result<&'static [u8], FormsError> {
95    match year {
96        2024 => Ok(F8959_PDF_2024),
97        _ => Err(FormsError::UnsupportedYear(year)),
98    }
99}
100
101/// The bundled Form 8960 PDF bytes for a supported tax year. Full-return v1 is TY2024-only.
102pub fn f8960_pdf(year: i32) -> Result<&'static [u8], FormsError> {
103    match year {
104        2024 => Ok(F8960_PDF_2024),
105        _ => Err(FormsError::UnsupportedYear(year)),
106    }
107}
108
109/// The bundled Form 8995 PDF bytes for a supported tax year. Full-return v1 is TY2024-only.
110pub fn f8995_pdf(year: i32) -> Result<&'static [u8], FormsError> {
111    match year {
112        2024 => Ok(F8995_PDF_2024),
113        _ => Err(FormsError::UnsupportedYear(year)),
114    }
115}
116
117/// The bundled Schedule 2 PDF bytes for a supported tax year. Full-return v1 is TY2024-only.
118pub fn schedule_2_pdf(year: i32) -> Result<&'static [u8], FormsError> {
119    match year {
120        2024 => Ok(SCHEDULE_2_PDF_2024),
121        _ => Err(FormsError::UnsupportedYear(year)),
122    }
123}
124
125/// The bundled Schedule 3 PDF bytes for a supported tax year. Full-return v1 is TY2024-only.
126pub fn schedule_3_pdf(year: i32) -> Result<&'static [u8], FormsError> {
127    match year {
128        2024 => Ok(SCHEDULE_3_PDF_2024),
129        _ => Err(FormsError::UnsupportedYear(year)),
130    }
131}
132
133/// The bundled Schedule B PDF bytes for a supported tax year. Full-return v1 is TY2024-only.
134pub fn schedule_b_pdf(year: i32) -> Result<&'static [u8], FormsError> {
135    match year {
136        2024 => Ok(SCHEDULE_B_PDF_2024),
137        _ => Err(FormsError::UnsupportedYear(year)),
138    }
139}
140
141/// The bundled Schedule C PDF bytes for a supported tax year. Full-return v1 is TY2024-only.
142pub fn schedule_c_pdf(year: i32) -> Result<&'static [u8], FormsError> {
143    match year {
144        2024 => Ok(SCHEDULE_C_PDF_2024),
145        _ => Err(FormsError::UnsupportedYear(year)),
146    }
147}
148
149/// The bundled Schedule 1 PDF bytes for a supported tax year. Full-return v1 is TY2024-only.
150pub fn schedule_1_pdf(year: i32) -> Result<&'static [u8], FormsError> {
151    match year {
152        2024 => Ok(SCHEDULE_1_PDF_2024),
153        _ => Err(FormsError::UnsupportedYear(year)),
154    }
155}
156
157/// The bundled Schedule A PDF bytes for a supported tax year. Full-return v1 is TY2024-only.
158pub fn schedule_a_pdf(year: i32) -> Result<&'static [u8], FormsError> {
159    match year {
160        2024 => Ok(SCHEDULE_A_PDF_2024),
161        _ => Err(FormsError::UnsupportedYear(year)),
162    }
163}
164
165/// The bundled Form 8283 PDF bytes for a supported tax year (bound by filing-year → revision).
166pub fn f8283_pdf(year: i32) -> Result<&'static [u8], FormsError> {
167    match year {
168        2017 => Ok(F8283_PDF_2017),
169        2024 => Ok(F8283_PDF_2024),
170        2025 => Ok(F8283_PDF_2025),
171        _ => Err(FormsError::UnsupportedYear(year)),
172    }
173}
174
175/// The bundled Form 1040 PDF bytes for a supported tax year.
176pub fn f1040_pdf(year: i32) -> Result<&'static [u8], FormsError> {
177    match year {
178        2017 => Ok(F1040_PDF_2017),
179        2024 => Ok(F1040_PDF_2024),
180        2025 => Ok(F1040_PDF_2025),
181        _ => Err(FormsError::UnsupportedYear(year)),
182    }
183}
184
185/// One terminal (leaf) AcroForm field: its object id, fully-qualified name, widget rectangle, and
186/// whether it is a checkbox (`/FT /Btn`).
187#[derive(Debug, Clone)]
188pub struct Field {
189    /// lopdf object id of the field dictionary.
190    pub id: ObjectId,
191    /// Fully-qualified, bracketed name (`topmostSubform[0].Page1[0]…f1_03[0]`).
192    pub fqn: String,
193    /// Widget rectangle `[x0, y0, x1, y1]` in PDF user space, if present.
194    pub rect: Option<[f32; 4]>,
195    /// `true` iff `/FT` is `/Btn` (a checkbox/radio).
196    pub is_button: bool,
197    /// `/MaxLen` — the cell's character capacity, when the form declares one (inheritable, like `/FT`).
198    ///
199    /// The IRS forms set this on their **comb** cells (the SSN boxes are `/MaxLen 9`, comb-flagged), and
200    /// it is the PRIMARY SOURCE for how a value must be formatted: nine characters means nine bare
201    /// digits, not a hyphenated `123-45-6789`, which is eleven and would be silently truncated by the
202    /// viewer. [`crate::verify::verify_flat`] enforces it on read-back, so an over-long write fails
203    /// closed instead of being quietly mangled.
204    pub max_len: Option<usize>,
205}
206
207impl Field {
208    /// Horizontal center of the widget rectangle.
209    pub fn cx(&self) -> Option<f32> {
210        self.rect.map(|r| (r[0] + r[2]) / 2.0)
211    }
212    /// Vertical center of the widget rectangle.
213    pub fn cy(&self) -> Option<f32> {
214        self.rect.map(|r| (r[1] + r[3]) / 2.0)
215    }
216}
217
218/// What to write into a field.
219#[derive(Debug, Clone)]
220pub enum FieldValue {
221    /// A text value (`/Tx`).
222    Text(String),
223    /// Turn a checkbox on to the given on-state name (without the leading `/`).
224    Check {
225        /// The on-state PDF name, e.g. `"6"` for Box I.
226        on: String,
227    },
228}
229
230/// Parse a bundled PDF into a mutable document.
231pub fn load(bytes: &[u8]) -> Result<Document, FormsError> {
232    Ok(Document::load_mem(bytes)?)
233}
234
235fn number(o: &Object) -> Option<f32> {
236    match o {
237        Object::Integer(i) => Some(*i as f32),
238        Object::Real(r) => Some(*r),
239        _ => None,
240    }
241}
242
243fn rect_of(dict: &lopdf::Dictionary) -> Option<[f32; 4]> {
244    let arr = dict.get(b"Rect").ok()?.as_array().ok()?;
245    if arr.len() != 4 {
246        return None;
247    }
248    Some([
249        number(&arr[0])?,
250        number(&arr[1])?,
251        number(&arr[2])?,
252        number(&arr[3])?,
253    ])
254}
255
256/// The AcroForm dictionary's object id (it must be an indirect reference).
257fn acroform_id(doc: &Document) -> Result<ObjectId, FormsError> {
258    match doc.catalog()?.get(b"AcroForm") {
259        Ok(Object::Reference(id)) => Ok(*id),
260        Ok(_) => Err(FormsError::Structure(
261            "AcroForm is not an indirect reference".into(),
262        )),
263        Err(_) => Err(FormsError::Structure("catalog has no AcroForm".into())),
264    }
265}
266
267/// Remove `/XFA` from the AcroForm and set `/NeedAppearances true` (viewers regenerate the visible
268/// appearance from `/V`). Must run before saving.
269pub fn drop_xfa_and_set_needappearances(doc: &mut Document) -> Result<(), FormsError> {
270    let id = acroform_id(doc)?;
271    let acro = doc.get_dictionary_mut(id)?;
272    acro.remove(b"XFA");
273    acro.set("NeedAppearances", Object::Boolean(true));
274    Ok(())
275}
276
277/// Walk the AcroForm `/Fields` tree and collect every terminal (leaf) field.
278pub fn collect_fields(doc: &Document) -> Result<Vec<Field>, FormsError> {
279    let acro = doc.get_dictionary(acroform_id(doc)?)?;
280    let mut out = Vec::new();
281    let fields = acro
282        .get(b"Fields")
283        .and_then(|o| o.as_array())
284        .map_err(|_| FormsError::Structure("AcroForm has no /Fields array".into()))?;
285    for f in fields {
286        if let Ok(id) = f.as_reference() {
287            walk(doc, id, "", None, None, &mut out)?;
288        }
289    }
290    Ok(out)
291}
292
293/// Decode a PDF text string: UTF-16BE if it carries the `FEFF` BOM (Adobe LiveCycle exports field
294/// names this way), else PDFDocEncoding (treated as Latin-1, which is exact for the ASCII names).
295pub(crate) fn decode_pdf_text(b: &[u8]) -> String {
296    if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
297        let units: Vec<u16> = b[2..]
298            .chunks(2)
299            .map(|c| ((c[0] as u16) << 8) | *c.get(1).unwrap_or(&0) as u16)
300            .collect();
301        String::from_utf16_lossy(&units)
302    } else {
303        b.iter().map(|&c| c as char).collect()
304    }
305}
306
307fn field_component_name(dict: &lopdf::Dictionary) -> Option<String> {
308    dict.get(b"T")
309        .ok()
310        .and_then(|o| o.as_str().ok())
311        .map(decode_pdf_text)
312}
313
314fn walk(
315    doc: &Document,
316    id: ObjectId,
317    parent_fqn: &str,
318    inherited_ft: Option<String>,
319    inherited_max_len: Option<usize>,
320    out: &mut Vec<Field>,
321) -> Result<(), FormsError> {
322    let dict = match doc.get_dictionary(id) {
323        Ok(d) => d,
324        Err(_) => return Ok(()), // dangling ref — skip
325    };
326    let name = field_component_name(dict);
327    let fqn = match &name {
328        Some(t) if parent_fqn.is_empty() => t.clone(),
329        Some(t) => format!("{parent_fqn}.{t}"),
330        None => parent_fqn.to_string(),
331    };
332    let ft = dict
333        .get(b"FT")
334        .ok()
335        .and_then(|o| o.as_name().ok())
336        .map(|b| String::from_utf8_lossy(b).into_owned())
337        .or(inherited_ft);
338
339    // /MaxLen is inheritable down the field tree, exactly like /FT.
340    let max_len = dict
341        .get(b"MaxLen")
342        .ok()
343        .and_then(|o| o.as_i64().ok())
344        .and_then(|n| usize::try_from(n).ok())
345        .or(inherited_max_len);
346
347    // A branch node carries /Kids of further named fields; a leaf is a terminal field.
348    let kids: Option<Vec<ObjectId>> = dict
349        .get(b"Kids")
350        .ok()
351        .and_then(|o| o.as_array().ok())
352        .map(|arr| arr.iter().filter_map(|k| k.as_reference().ok()).collect());
353    match kids {
354        Some(kids) if !kids.is_empty() => {
355            for k in kids {
356                walk(doc, k, &fqn, ft.clone(), max_len, out)?;
357            }
358        }
359        _ => {
360            out.push(Field {
361                id,
362                fqn,
363                rect: rect_of(dict),
364                is_button: ft.as_deref() == Some("Btn"),
365                max_len,
366            });
367        }
368    }
369    Ok(())
370}
371
372/// Index the collected leaf fields by fully-qualified name.
373pub fn index(fields: &[Field]) -> HashMap<String, Field> {
374    fields.iter().map(|f| (f.fqn.clone(), f.clone())).collect()
375}
376
377/// Apply a batch of writes. Errors (fails closed) if any field name is absent from the PDF.
378pub fn apply_writes(
379    doc: &mut Document,
380    index: &HashMap<String, Field>,
381    writes: &[(String, FieldValue)],
382) -> Result<(), FormsError> {
383    for (fqn, value) in writes {
384        let field = index
385            .get(fqn)
386            .ok_or_else(|| FormsError::MapFieldMissing(fqn.clone()))?;
387        let dict = doc.get_dictionary_mut(field.id)?;
388        match value {
389            FieldValue::Text(s) => {
390                dict.set(
391                    "V",
392                    Object::String(s.clone().into_bytes(), StringFormat::Literal),
393                );
394            }
395            FieldValue::Check { on } => {
396                dict.set("V", Object::Name(on.clone().into_bytes()));
397                dict.set("AS", Object::Name(on.clone().into_bytes()));
398            }
399        }
400    }
401    Ok(())
402}
403
404/// Strip clock/RNG-derived bytes so `(data, form) → byte-identical` output: drop the document
405/// `/Info` timestamps and the trailer `/ID`. No other source of nondeterminism exists (lopdf writes
406/// objects in stable id order; no float structure; miniz_oxide deflate is deterministic).
407pub fn strip_nondeterminism(doc: &mut Document) {
408    if let Ok(info) = doc.trailer.get(b"Info").and_then(|o| o.as_reference()) {
409        if let Ok(d) = doc.get_dictionary_mut(info) {
410            d.remove(b"CreationDate");
411            d.remove(b"ModDate");
412        }
413    }
414    doc.trailer.remove(b"ID");
415}
416
417/// Serialize the document to bytes.
418pub fn save(doc: &mut Document) -> Result<Vec<u8>, FormsError> {
419    let mut buf = Vec::new();
420    doc.save_to(&mut buf)?;
421    Ok(buf)
422}
423
424/// Read back a leaf field's `/V` as a string (text value) — used by tests and the no-unmapped scan.
425pub fn text_value(doc: &Document, id: ObjectId) -> Option<String> {
426    let v = doc.get_dictionary(id).ok()?.get(b"V").ok()?;
427    match v {
428        Object::String(b, _) => Some(decode_pdf_text(b)),
429        _ => None,
430    }
431}
432
433/// Read back a checkbox's `/AS` on-state (None if `/Off` or absent).
434pub fn checkbox_on(doc: &Document, id: ObjectId) -> Option<String> {
435    let as_ = doc.get_dictionary(id).ok()?.get(b"AS").ok()?;
436    match as_ {
437        Object::Name(b) if b != b"Off" => Some(String::from_utf8_lossy(b).into_owned()),
438        _ => None,
439    }
440}
441
442/// The possible ON-state name(s) of a button widget — the `/AP` `/N` appearance keys other than
443/// `/Off` (without the leading `/`). Read straight from the bundled PDF, so the SP2 same-y `/Btn`
444/// pair oracle (the 1040 Digital-Asset Yes/No question) is map-INDEPENDENT. Empty when the widget
445/// carries no `/AP`/`/N`.
446pub fn button_on_states(doc: &Document, id: ObjectId) -> Vec<String> {
447    let mut out = Vec::new();
448    let Ok(dict) = doc.get_dictionary(id) else {
449        return out;
450    };
451    let ap = match dict.get(b"AP") {
452        Ok(Object::Reference(r)) => doc.get_dictionary(*r).ok(),
453        Ok(Object::Dictionary(d)) => Some(d),
454        _ => None,
455    };
456    let n = ap.and_then(|ap| match ap.get(b"N") {
457        Ok(Object::Reference(r)) => doc.get_dictionary(*r).ok(),
458        Ok(Object::Dictionary(d)) => Some(d),
459        _ => None,
460    });
461    if let Some(n) = n {
462        for (k, _) in n.iter() {
463            if k != b"Off" {
464                out.push(String::from_utf8_lossy(k).into_owned());
465            }
466        }
467    }
468    out.sort();
469    out
470}