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 Form 8275, Rev. 10-2024 (official IRS fillable PDF, US-gov public domain). ★ Form 8275
32/// is REVISION-versioned, not tax-year-versioned: this ONE asset is aliased to EVERY `SUPPORTED_YEAR`
33/// by [`f8275_pdf`] — there is no separate `F8275_PDF_2017` / `F8275_PDF_2025`.
34pub const F8275_PDF_2024: &[u8] = include_bytes!("../forms/2024/f8275.pdf");
35/// The bundled TY2024 Form 1040 (official IRS fillable PDF, US-gov public domain).
36pub const F1040_PDF_2024: &[u8] = include_bytes!("../forms/2024/f1040.pdf");
37/// The bundled TY2024 Form 8959, Additional Medicare Tax (official IRS fillable PDF, public domain).
38pub const F8959_PDF_2024: &[u8] = include_bytes!("../forms/2024/f8959.pdf");
39/// The bundled TY2024 Form 8960, Net Investment Income Tax (official IRS fillable PDF, public domain).
40pub const F8960_PDF_2024: &[u8] = include_bytes!("../forms/2024/f8960.pdf");
41/// The bundled TY2024 Form 8995, QBI deduction — simplified (official IRS fillable PDF, public domain).
42pub const F8995_PDF_2024: &[u8] = include_bytes!("../forms/2024/f8995.pdf");
43/// Form 8995-A — the FULL §199A form, required above the §199A(e)(2) threshold where the simplified
44/// Form 8995 no longer applies (§G-28/B1).
45pub const F8995A_PDF_2024: &[u8] = include_bytes!("../forms/2024/f8995a.pdf");
46/// §G-6 — the bundled TY2024 Form 6251.
47static F6251_PDF_2024: &[u8] = include_bytes!("../forms/2024/f6251.pdf");
48/// The bundled TY2024 Schedule 2, Additional Taxes (official IRS fillable PDF, public domain).
49pub const SCHEDULE_2_PDF_2024: &[u8] = include_bytes!("../forms/2024/f1040s2.pdf");
50/// The bundled TY2024 Schedule 3, Additional Credits and Payments (official IRS fillable PDF, public domain).
51pub const SCHEDULE_3_PDF_2024: &[u8] = include_bytes!("../forms/2024/f1040s3.pdf");
52/// The bundled TY2024 Schedule A, Itemized Deductions (official IRS fillable PDF, public domain).
53pub const SCHEDULE_A_PDF_2024: &[u8] = include_bytes!("../forms/2024/f1040sa.pdf");
54/// The bundled TY2024 Schedule 1, Additional Income and Adjustments (official IRS fillable PDF, public domain).
55pub const SCHEDULE_1_PDF_2024: &[u8] = include_bytes!("../forms/2024/f1040s1.pdf");
56/// The bundled TY2024 Schedule C, Profit or Loss From Business (official IRS fillable PDF, public domain).
57pub const SCHEDULE_C_PDF_2024: &[u8] = include_bytes!("../forms/2024/f1040sc.pdf");
58/// The bundled TY2024 Schedule B, Interest and Ordinary Dividends (official IRS fillable PDF, public domain).
59pub const SCHEDULE_B_PDF_2024: &[u8] = include_bytes!("../forms/2024/f1040sb.pdf");
60
61/// The bundled TY2017 Form 8949 (official IRS fillable PDF, US-gov public domain).
62pub const F8949_PDF_2017: &[u8] = include_bytes!("../forms/2017/f8949.pdf");
63/// The bundled TY2017 Schedule D (official IRS fillable PDF, US-gov public domain).
64pub const SCHEDULE_D_PDF_2017: &[u8] = include_bytes!("../forms/2017/schedule_d.pdf");
65/// The bundled TY2017 Schedule SE (official IRS fillable PDF, US-gov public domain).
66pub const SCHEDULE_SE_PDF_2017: &[u8] = include_bytes!("../forms/2017/schedule_se.pdf");
67/// The bundled Form 8283, Rev. 12-2014 (TY2017; official IRS fillable PDF, US-gov public domain).
68pub const F8283_PDF_2017: &[u8] = include_bytes!("../forms/2017/f8283.pdf");
69/// The bundled TY2017 Form 1040 (official IRS fillable PDF, US-gov public domain).
70pub const F1040_PDF_2017: &[u8] = include_bytes!("../forms/2017/f1040.pdf");
71
72/// The bundled Form 8949 PDF bytes for a supported tax year (the asset bound to the year's map).
73pub fn f8949_pdf(year: i32) -> Result<&'static [u8], FormsError> {
74    match year {
75        2017 => Ok(F8949_PDF_2017),
76        2024 => Ok(F8949_PDF_2024),
77        2025 => Ok(F8949_PDF_2025),
78        _ => Err(FormsError::UnsupportedYear(year)),
79    }
80}
81
82/// The bundled Schedule D PDF bytes for a supported tax year.
83pub fn schedule_d_pdf(year: i32) -> Result<&'static [u8], FormsError> {
84    match year {
85        2017 => Ok(SCHEDULE_D_PDF_2017),
86        2024 => Ok(SCHEDULE_D_PDF_2024),
87        2025 => Ok(SCHEDULE_D_PDF_2025),
88        _ => Err(FormsError::UnsupportedYear(year)),
89    }
90}
91
92/// The bundled Schedule SE PDF bytes for a supported tax year.
93pub fn schedule_se_pdf(year: i32) -> Result<&'static [u8], FormsError> {
94    match year {
95        2017 => Ok(SCHEDULE_SE_PDF_2017),
96        2024 => Ok(SCHEDULE_SE_PDF_2024),
97        2025 => Ok(SCHEDULE_SE_PDF_2025),
98        _ => Err(FormsError::UnsupportedYear(year)),
99    }
100}
101
102/// The bundled Form 8959 PDF bytes for a supported tax year. Full-return v1 is TY2024-only.
103pub fn f8959_pdf(year: i32) -> Result<&'static [u8], FormsError> {
104    match year {
105        2024 => Ok(F8959_PDF_2024),
106        _ => Err(FormsError::UnsupportedYear(year)),
107    }
108}
109
110/// The bundled Form 8960 PDF bytes for a supported tax year. Full-return v1 is TY2024-only.
111pub fn f8960_pdf(year: i32) -> Result<&'static [u8], FormsError> {
112    match year {
113        2024 => Ok(F8960_PDF_2024),
114        _ => Err(FormsError::UnsupportedYear(year)),
115    }
116}
117
118/// The bundled Form 8995 PDF bytes for a supported tax year. Full-return v1 is TY2024-only.
119pub fn f8995_pdf(year: i32) -> Result<&'static [u8], FormsError> {
120    match year {
121        2024 => Ok(F8995_PDF_2024),
122        _ => Err(FormsError::UnsupportedYear(year)),
123    }
124}
125
126/// The bundled Form 8995-A PDF bytes. Full-return v1 is TY2024-only.
127/// §G-6 — the bundled Form 6251 PDF bytes. Full-return v1 is TY2024-only.
128pub fn f6251_pdf(year: i32) -> Result<&'static [u8], FormsError> {
129    match year {
130        2024 => Ok(F6251_PDF_2024),
131        _ => Err(FormsError::UnsupportedYear(year)),
132    }
133}
134
135pub fn f8995a_pdf(year: i32) -> Result<&'static [u8], FormsError> {
136    match year {
137        2024 => Ok(F8995A_PDF_2024),
138        _ => Err(FormsError::UnsupportedYear(year)),
139    }
140}
141
142/// The bundled Schedule 2 PDF bytes for a supported tax year. Full-return v1 is TY2024-only.
143pub fn schedule_2_pdf(year: i32) -> Result<&'static [u8], FormsError> {
144    match year {
145        2024 => Ok(SCHEDULE_2_PDF_2024),
146        _ => Err(FormsError::UnsupportedYear(year)),
147    }
148}
149
150/// The bundled Schedule 3 PDF bytes for a supported tax year. Full-return v1 is TY2024-only.
151pub fn schedule_3_pdf(year: i32) -> Result<&'static [u8], FormsError> {
152    match year {
153        2024 => Ok(SCHEDULE_3_PDF_2024),
154        _ => Err(FormsError::UnsupportedYear(year)),
155    }
156}
157
158/// The bundled Schedule B PDF bytes for a supported tax year. Full-return v1 is TY2024-only.
159pub fn schedule_b_pdf(year: i32) -> Result<&'static [u8], FormsError> {
160    match year {
161        2024 => Ok(SCHEDULE_B_PDF_2024),
162        _ => Err(FormsError::UnsupportedYear(year)),
163    }
164}
165
166/// The bundled Schedule C PDF bytes for a supported tax year. Full-return v1 is TY2024-only.
167pub fn schedule_c_pdf(year: i32) -> Result<&'static [u8], FormsError> {
168    match year {
169        2024 => Ok(SCHEDULE_C_PDF_2024),
170        _ => Err(FormsError::UnsupportedYear(year)),
171    }
172}
173
174/// The bundled Schedule 1 PDF bytes for a supported tax year. Full-return v1 is TY2024-only.
175pub fn schedule_1_pdf(year: i32) -> Result<&'static [u8], FormsError> {
176    match year {
177        2024 => Ok(SCHEDULE_1_PDF_2024),
178        _ => Err(FormsError::UnsupportedYear(year)),
179    }
180}
181
182/// The bundled Schedule A PDF bytes for a supported tax year. Full-return v1 is TY2024-only.
183pub fn schedule_a_pdf(year: i32) -> Result<&'static [u8], FormsError> {
184    match year {
185        2024 => Ok(SCHEDULE_A_PDF_2024),
186        _ => Err(FormsError::UnsupportedYear(year)),
187    }
188}
189
190/// The bundled Form 8283 PDF bytes for a supported tax year (bound by filing-year → revision).
191pub fn f8283_pdf(year: i32) -> Result<&'static [u8], FormsError> {
192    match year {
193        2017 => Ok(F8283_PDF_2017),
194        2024 => Ok(F8283_PDF_2024),
195        2025 => Ok(F8283_PDF_2025),
196        _ => Err(FormsError::UnsupportedYear(year)),
197    }
198}
199
200/// The bundled Form 8275 PDF bytes for a supported tax year. ★ Form 8275 is REVISION-versioned, not
201/// tax-year-versioned: the single bundled Rev. 10-2024 asset is returned for EVERY `SUPPORTED_YEAR`
202/// (2017/2024/2025) — never `UnsupportedYear` for those three, unlike every other form here that
203/// bundles a distinct PDF per year. This is what lets a promoted 2025 (or 2017) disposal attach a real
204/// Form 8275 rather than being permanently refused for want of a "2025 revision" that does not exist.
205pub fn f8275_pdf(year: i32) -> Result<&'static [u8], FormsError> {
206    match year {
207        2017 | 2024 | 2025 => Ok(F8275_PDF_2024),
208        _ => Err(FormsError::UnsupportedYear(year)),
209    }
210}
211
212/// The bundled Form 1040 PDF bytes for a supported tax year.
213pub fn f1040_pdf(year: i32) -> Result<&'static [u8], FormsError> {
214    match year {
215        2017 => Ok(F1040_PDF_2017),
216        2024 => Ok(F1040_PDF_2024),
217        2025 => Ok(F1040_PDF_2025),
218        _ => Err(FormsError::UnsupportedYear(year)),
219    }
220}
221
222/// One terminal (leaf) AcroForm field: its object id, fully-qualified name, widget rectangle, and
223/// whether it is a checkbox (`/FT /Btn`).
224#[derive(Debug, Clone)]
225pub struct Field {
226    /// lopdf object id of the field dictionary.
227    pub id: ObjectId,
228    /// Fully-qualified, bracketed name (`topmostSubform[0].Page1[0]…f1_03[0]`).
229    pub fqn: String,
230    /// Widget rectangle `[x0, y0, x1, y1]` in PDF user space, if present.
231    pub rect: Option<[f32; 4]>,
232    /// `true` iff `/FT` is `/Btn` (a checkbox/radio).
233    pub is_button: bool,
234    /// `/MaxLen` — the cell's character capacity, when the form declares one (inheritable, like `/FT`).
235    ///
236    /// The IRS forms set this on their **comb** cells (the SSN boxes are `/MaxLen 9`, comb-flagged), and
237    /// it is the PRIMARY SOURCE for how a value must be formatted: nine characters means nine bare
238    /// digits, not a hyphenated `123-45-6789`, which is eleven and would be silently truncated by the
239    /// viewer. [`crate::verify::verify_flat`] enforces it on read-back, so an over-long write fails
240    /// closed instead of being quietly mangled.
241    pub max_len: Option<usize>,
242}
243
244impl Field {
245    /// Horizontal center of the widget rectangle.
246    pub fn cx(&self) -> Option<f32> {
247        self.rect.map(|r| (r[0] + r[2]) / 2.0)
248    }
249    /// Vertical center of the widget rectangle.
250    pub fn cy(&self) -> Option<f32> {
251        self.rect.map(|r| (r[1] + r[3]) / 2.0)
252    }
253}
254
255/// What to write into a field.
256#[derive(Debug, Clone)]
257pub enum FieldValue {
258    /// A text value (`/Tx`).
259    Text(String),
260    /// Turn a checkbox on to the given on-state name (without the leading `/`).
261    Check {
262        /// The on-state PDF name, e.g. `"6"` for Box I.
263        on: String,
264    },
265}
266
267/// Parse a bundled PDF into a mutable document.
268pub fn load(bytes: &[u8]) -> Result<Document, FormsError> {
269    Ok(Document::load_mem(bytes)?)
270}
271
272fn number(o: &Object) -> Option<f32> {
273    match o {
274        Object::Integer(i) => Some(*i as f32),
275        Object::Real(r) => Some(*r),
276        _ => None,
277    }
278}
279
280fn rect_of(dict: &lopdf::Dictionary) -> Option<[f32; 4]> {
281    let arr = dict.get(b"Rect").ok()?.as_array().ok()?;
282    if arr.len() != 4 {
283        return None;
284    }
285    Some([
286        number(&arr[0])?,
287        number(&arr[1])?,
288        number(&arr[2])?,
289        number(&arr[3])?,
290    ])
291}
292
293/// The AcroForm dictionary's object id (it must be an indirect reference).
294fn acroform_id(doc: &Document) -> Result<ObjectId, FormsError> {
295    match doc.catalog()?.get(b"AcroForm") {
296        Ok(Object::Reference(id)) => Ok(*id),
297        Ok(_) => Err(FormsError::Structure(
298            "AcroForm is not an indirect reference".into(),
299        )),
300        Err(_) => Err(FormsError::Structure("catalog has no AcroForm".into())),
301    }
302}
303
304/// Remove `/XFA` from the AcroForm and set `/NeedAppearances true` (viewers regenerate the visible
305/// appearance from `/V`). Must run before saving.
306pub fn drop_xfa_and_set_needappearances(doc: &mut Document) -> Result<(), FormsError> {
307    let id = acroform_id(doc)?;
308    let acro = doc.get_dictionary_mut(id)?;
309    acro.remove(b"XFA");
310    acro.set("NeedAppearances", Object::Boolean(true));
311    Ok(())
312}
313
314/// Walk the AcroForm `/Fields` tree and collect every terminal (leaf) field.
315pub fn collect_fields(doc: &Document) -> Result<Vec<Field>, FormsError> {
316    let acro = doc.get_dictionary(acroform_id(doc)?)?;
317    let mut out = Vec::new();
318    let fields = acro
319        .get(b"Fields")
320        .and_then(|o| o.as_array())
321        .map_err(|_| FormsError::Structure("AcroForm has no /Fields array".into()))?;
322    for f in fields {
323        if let Ok(id) = f.as_reference() {
324            walk(doc, id, "", None, None, &mut out)?;
325        }
326    }
327    Ok(out)
328}
329
330/// Decode a PDF text string: UTF-16BE if it carries the `FEFF` BOM (Adobe LiveCycle exports field
331/// names this way), else PDFDocEncoding (treated as Latin-1, which is exact for the ASCII names).
332pub(crate) fn decode_pdf_text(b: &[u8]) -> String {
333    if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
334        let units: Vec<u16> = b[2..]
335            .chunks(2)
336            .map(|c| ((c[0] as u16) << 8) | *c.get(1).unwrap_or(&0) as u16)
337            .collect();
338        String::from_utf16_lossy(&units)
339    } else {
340        b.iter().map(|&c| c as char).collect()
341    }
342}
343
344fn field_component_name(dict: &lopdf::Dictionary) -> Option<String> {
345    dict.get(b"T")
346        .ok()
347        .and_then(|o| o.as_str().ok())
348        .map(decode_pdf_text)
349}
350
351fn walk(
352    doc: &Document,
353    id: ObjectId,
354    parent_fqn: &str,
355    inherited_ft: Option<String>,
356    inherited_max_len: Option<usize>,
357    out: &mut Vec<Field>,
358) -> Result<(), FormsError> {
359    let dict = match doc.get_dictionary(id) {
360        Ok(d) => d,
361        Err(_) => return Ok(()), // dangling ref — skip
362    };
363    let name = field_component_name(dict);
364    let fqn = match &name {
365        Some(t) if parent_fqn.is_empty() => t.clone(),
366        Some(t) => format!("{parent_fqn}.{t}"),
367        None => parent_fqn.to_string(),
368    };
369    let ft = dict
370        .get(b"FT")
371        .ok()
372        .and_then(|o| o.as_name().ok())
373        .map(|b| String::from_utf8_lossy(b).into_owned())
374        .or(inherited_ft);
375
376    // /MaxLen is inheritable down the field tree, exactly like /FT.
377    let max_len = dict
378        .get(b"MaxLen")
379        .ok()
380        .and_then(|o| o.as_i64().ok())
381        .and_then(|n| usize::try_from(n).ok())
382        .or(inherited_max_len);
383
384    // A branch node carries /Kids of further named fields; a leaf is a terminal field.
385    let kids: Option<Vec<ObjectId>> = dict
386        .get(b"Kids")
387        .ok()
388        .and_then(|o| o.as_array().ok())
389        .map(|arr| arr.iter().filter_map(|k| k.as_reference().ok()).collect());
390    match kids {
391        Some(kids) if !kids.is_empty() => {
392            for k in kids {
393                walk(doc, k, &fqn, ft.clone(), max_len, out)?;
394            }
395        }
396        _ => {
397            out.push(Field {
398                id,
399                fqn,
400                rect: rect_of(dict),
401                is_button: ft.as_deref() == Some("Btn"),
402                max_len,
403            });
404        }
405    }
406    Ok(())
407}
408
409/// Index the collected leaf fields by fully-qualified name.
410pub fn index(fields: &[Field]) -> HashMap<String, Field> {
411    fields.iter().map(|f| (f.fqn.clone(), f.clone())).collect()
412}
413
414/// Encode a text value for a PDF string object. Pure ASCII is written as literal bytes (identical to
415/// PDFDocEncoding for that range — every existing byte-golden is plain ASCII, so this preserves them
416/// exactly). Any other content is written as **UTF-16BE with the `FEFF` BOM** — Adobe's own convention
417/// for a Unicode string, and precisely what [`decode_pdf_text`] decodes back on read-back. Writing raw
418/// UTF-8 bytes instead (the naive choice) is neither valid PDFDocEncoding nor valid UTF-16: a viewer
419/// (and our own read-back) would render it as mojibake — silently wrong text on a FILED disclosure is
420/// exactly the defect class this crate refuses to ship (surfaced by Form 8275's `Part1Item.line`, the
421/// first domain string in this crate to carry a non-ASCII character, an em dash).
422fn encode_pdf_text(s: &str) -> Vec<u8> {
423    if s.is_ascii() {
424        return s.as_bytes().to_vec();
425    }
426    let mut out = Vec::with_capacity(2 + s.len() * 2);
427    out.extend_from_slice(&[0xFE, 0xFF]);
428    for unit in s.encode_utf16() {
429        out.push((unit >> 8) as u8);
430        out.push((unit & 0xFF) as u8);
431    }
432    out
433}
434
435/// Apply a batch of writes. Errors (fails closed) if any field name is absent from the PDF.
436pub fn apply_writes(
437    doc: &mut Document,
438    index: &HashMap<String, Field>,
439    writes: &[(String, FieldValue)],
440) -> Result<(), FormsError> {
441    for (fqn, value) in writes {
442        let field = index
443            .get(fqn)
444            .ok_or_else(|| FormsError::MapFieldMissing(fqn.clone()))?;
445        match value {
446            FieldValue::Text(s) => {
447                doc.get_dictionary_mut(field.id)?.set(
448                    "V",
449                    Object::String(encode_pdf_text(s), StringFormat::Literal),
450                );
451            }
452            FieldValue::Check { on } => {
453                // ★★ THE ON-STATE MUST BE ONE THE WIDGET ITSELF DECLARES. A checkbox renders from its
454                // `/AP` `/N` appearance dictionary, so writing an `/AS` with no matching key draws
455                // NOTHING: the box comes out BLANK on every filed copy while `/V` and `/AS` read back
456                // as the value we asked for. That is the worst shape a forms defect can take — the
457                // second row of CLAUDE.md's provenance table ("nothing ever populated it") wearing the
458                // costume of the first ("the inputs say so"), invisible on the page and invisible to
459                // any test that reads the field value back.
460                //
461                // It is reachable from one transposition: a map whose `yes`/`no` FIELD names are
462                // swapped while the on-states stay put. Found as G-19b in the 2026-07-30 review, where
463                // the Schedule D line-17 KAT stayed GREEN under exactly that mutation.
464                //
465                // Checked here rather than per-form because the map is what we distrust, and this is
466                // the ONE chokepoint every checkbox on every form passes through. Widgets with no
467                // `/AP` `/N` at all are left alone: `button_on_states` returns empty for them, and a
468                // form whose appearances are generated at render time is not this bug.
469                let states = button_on_states(doc, field.id);
470                if !states.is_empty() && !states.iter().any(|s| s == on) {
471                    return Err(FormsError::Geometry(format!(
472                        "{fqn}: on-state {on:?} is not one this widget declares ({states:?}) — \
473                         writing it would render the box BLANK on the filed form while reading back \
474                         as checked. This is what a swapped Yes/No map looks like."
475                    )));
476                }
477                let dict = doc.get_dictionary_mut(field.id)?;
478                dict.set("V", Object::Name(on.clone().into_bytes()));
479                dict.set("AS", Object::Name(on.clone().into_bytes()));
480            }
481        }
482    }
483    Ok(())
484}
485
486/// Strip clock/RNG-derived bytes so `(data, form) → byte-identical` output: drop the document
487/// `/Info` timestamps and the trailer `/ID`. No other source of nondeterminism exists (lopdf writes
488/// objects in stable id order; no float structure; miniz_oxide deflate is deterministic).
489pub fn strip_nondeterminism(doc: &mut Document) {
490    if let Ok(info) = doc.trailer.get(b"Info").and_then(|o| o.as_reference()) {
491        if let Ok(d) = doc.get_dictionary_mut(info) {
492            d.remove(b"CreationDate");
493            d.remove(b"ModDate");
494        }
495    }
496    doc.trailer.remove(b"ID");
497}
498
499/// Serialize the document to bytes.
500pub fn save(doc: &mut Document) -> Result<Vec<u8>, FormsError> {
501    let mut buf = Vec::new();
502    doc.save_to(&mut buf)?;
503    Ok(buf)
504}
505
506/// Read back a leaf field's `/V` as a string (text value) — used by tests and the no-unmapped scan.
507pub fn text_value(doc: &Document, id: ObjectId) -> Option<String> {
508    let v = doc.get_dictionary(id).ok()?.get(b"V").ok()?;
509    match v {
510        Object::String(b, _) => Some(decode_pdf_text(b)),
511        _ => None,
512    }
513}
514
515/// Read back a checkbox's `/AS` on-state (None if `/Off` or absent).
516pub fn checkbox_on(doc: &Document, id: ObjectId) -> Option<String> {
517    let as_ = doc.get_dictionary(id).ok()?.get(b"AS").ok()?;
518    match as_ {
519        Object::Name(b) if b != b"Off" => Some(String::from_utf8_lossy(b).into_owned()),
520        _ => None,
521    }
522}
523
524/// The possible ON-state name(s) of a button widget — the `/AP` `/N` appearance keys other than
525/// `/Off` (without the leading `/`). Read straight from the bundled PDF, so the SP2 same-y `/Btn`
526/// pair oracle (the 1040 Digital-Asset Yes/No question) is map-INDEPENDENT. Empty when the widget
527/// carries no `/AP`/`/N`.
528pub fn button_on_states(doc: &Document, id: ObjectId) -> Vec<String> {
529    let mut out = Vec::new();
530    let Ok(dict) = doc.get_dictionary(id) else {
531        return out;
532    };
533    let ap = match dict.get(b"AP") {
534        Ok(Object::Reference(r)) => doc.get_dictionary(*r).ok(),
535        Ok(Object::Dictionary(d)) => Some(d),
536        _ => None,
537    };
538    let n = ap.and_then(|ap| match ap.get(b"N") {
539        Ok(Object::Reference(r)) => doc.get_dictionary(*r).ok(),
540        Ok(Object::Dictionary(d)) => Some(d),
541        _ => None,
542    });
543    if let Some(n) = n {
544        for (k, _) in n.iter() {
545            if k != b"Off" {
546                out.push(String::from_utf8_lossy(k).into_owned());
547            }
548        }
549    }
550    out.sort();
551    out
552}