Skip to main content

btctax_forms/
form8275.rs

1//! Form 8275 (Disclosure Statement, Rev. 10-2024) fill: Part I disclosed positions (one row per T13
2//! `Part1Item` — a promoted Form 8949 disposal leg's estimated basis) + Part II's filer-authored
3//! narrative + the FILER's identity, read back through the shared SP2 flat oracle (`verify_flat`).
4//!
5//! **FREE-TEXT, not a money grid ([R0-C3] scope, T15).** Unlike `form8283.rs`'s property-table rows
6//! (column-x-clustered `push_cell`), every Form 8275 write here is `push_free`
7//! (`FlatPlacement::free`) — geometry-exempt but still page-checked, `/MaxLen`-checked, and inside the
8//! no-unmapped set. Column (a) "Rev. Rul., Rev. Proc., etc." and column (e) "Line No." (a genuine
9//! 3-character comb cell) are never written — see `Form8275Row`'s doc comment (`map.rs`) for why.
10//!
11//! **★ Year coverage is MANDATORY, not conditional (arch r1 I-6 / tax r1 M-7).** Form 8275 is
12//! REVISION-versioned, not tax-year-versioned: `Form8275Map::for_year` and `pdf::f8275_pdf` both alias
13//! the single bundled Rev. 10-2024 asset to EVERY `SUPPORTED_YEAR` (2017/2024/2025), so a promoted
14//! disposal filed in any supported year gets a real fillable disclosure — this is what keeps T16's
15//! re-pointed BG-D8 gate from PERMANENTLY refusing a promoted 2025 (or 2017) export, the dominant
16//! current-year flow.
17//!
18//! **★ Part II is WRAPPED, never truncated (T-f8275-part-ii-overflow).** The filer's Part II narrative
19//! is the §1.6662-4(f) adequate-disclosure text — the whole of Approach B's estimated-basis defense
20//! rests on it. It used to be written whole to the ONE `part_ii_narrative` field, which is a
21//! single-line, `DoNotScroll`, no-`/MaxLen` widget: the write always "succeeded" (nothing at the
22//! PDF-data level stops an over-long `/V`), but a viewer honoring the widget's own box could only
23//! DISPLAY the first ~137 characters at 8pt — a fifth of a disclosure is not a disclosure, and neither
24//! `/MaxLen` (not declared on this field) nor `verify_flat`'s geometry leg (skipped for free
25//! placements) could see it.
26//!
27//! **★ Only Part II's OWN line 1 (`part_ii_narrative`, `p1-t80[0]`) is ever written — round 2.** The
28//! bundled PDF's XFA template draws printed numerals `"1 "`..`"6 "` beside `p1-t80[0]`..`p1-t85[0]`
29//! (`Line1PartII`..`Line6PartII`, confirmed by decompressing the asset's own `template` XFA packet),
30//! numbered to match Part I's 6 rows — Part II line *n* is meant to explain Part I row *n*. Spreading
31//! one COMBINED narrative (multiple promoted tranches' accounts, joined by
32//! `btctax-core/src/tax/form8275.rs`) across those numbered lines would attribute sentence fragments to
33//! Part I items they do not explain — an early build of this fix did exactly that and its own golden
34//! showed line 2's text sitting beside Part I's SECOND row while actually continuing line 1's sentence.
35//! Per-item numbering (one explanation per numbered line, matched to its own Part I row) is the more
36//! faithful long-term shape, but it is a bigger change (needs `Part1Item`-to-narrative correlation this
37//! crate does not have today) — filed as a follow-up, not built here
38//! (`design/f8275-part-ii-overflow/FOLLOWUPS.md`). For now: everything past Part II's line 1 spills to
39//! Part IV instead, which has no per-line numbering to misclaim.
40//!
41//! **★ Part IV carries the IRS-required cross-reference.** Rev. 10-2024's Specific Instructions for
42//! Part IV: *"Use Part IV on page 2 if you need more space for Parts I and/or II. Include the
43//! corresponding part and line number from page 1."* Page 2 is captioned "Explanations (continued
44//! from Parts I **and/or** II)" — an examiner reading it cold cannot tell which without the label. The
45//! FIRST Part IV line used therefore always starts with
46//! [`crate::wrap::PART_IV_CROSS_REFERENCE_PREFIX`] ("Part II, line 1 (continued): "), budgeted into the
47//! wrap so it never itself causes an overflow.
48//!
49//! **★ The wrap budgets the renderer's text inset, not the raw widget box.** AcroForm variable text is
50//! inset from its `/Rect` edges by (at minimum) border width + 1 (PDF 32000-1 §12.7.4.3); measured
51//! against this asset, poppler starts ~2.16pt in from the left edge and pdf.js is more conservative
52//! still (≈4pt total) — budgeting the RAW rect width let 3 lines of the fix's own >1500-char KAT
53//! fixture measure over their box once inset was accounted for (see
54//! `design/f8275-part-ii-overflow/BUILD-REPORT.md`). [`usable_width`] subtracts
55//! `2 * TEXT_INSET_PTS` before `crate::wrap` ever sees a width.
56//!
57//! `crate::wrap` measures at the SAME Helvetica-Bold 8pt the PDF's own `/DA` declares (using the
58//! bundled asset's OWN embedded font `/Widths`, not a generic AFM table — see `wrap.rs`'s doc comment)
59//! and wraps the narrative, respecting paragraph breaks as hard breaks (`wrap::split_paragraphs`) so
60//! two promoted tranches' independent accounts never blend into one filed sentence. If it still will
61//! not fit across Part II's line 1 + all of Part IV, the fill fails closed with [`FormsError::Overflow`]
62//! (mirroring the Part I >6-row refusal below) rather than clip. [`part_ii_capacity_check`] runs the
63//! SAME wrap without filling anything, so `btctax-cli`'s export paths can refuse before writing any
64//! packet file at all.
65
66use crate::error::FormsError;
67use crate::map::Form8275Map;
68use crate::verify::{verify_flat, FlatPlacement};
69use crate::wrap::{wrap_part_ii, PartIiOverflow};
70use crate::{fmt_money, pdf};
71use btctax_core::tax::packet::ReturnHeader;
72use btctax_core::tax::printed::Printed8275;
73
74/// The font size Form 8275's Part II ("Detailed Explanation") and Part IV ("Explanations (continued
75/// from Parts I and/or II)") continuation lines declare in their own `/DA`
76/// (`/HelveticaLTStd-Bold 8.00 Tf`, verified against the bundled Rev. 10-2024 asset) — the size
77/// `crate::wrap` measures the narrative at.
78const PART_II_FONT_SIZE_PT: f32 = 8.0;
79
80/// Minimum text inset (PDF points) a `/Rect`'s raw width is reduced by, PER SIDE, before it is treated
81/// as usable — see the module doc's "the wrap budgets the renderer's text inset" note. `2.0` matches
82/// pdf.js's observed ≈4pt total inset on this asset (the more conservative of the renderers measured),
83/// while still comfortably covering poppler's smaller ~2.16pt.
84const TEXT_INSET_PTS: f32 = 2.0;
85
86/// The usable text width of field `fqn` (its `/Rect` width minus `2 * TEXT_INSET_PTS`) — the SAME
87/// figure both the real fill and [`part_ii_capacity_check`] budget the wrap against, so the two can
88/// never disagree about what fits.
89fn usable_width(fields: &[pdf::Field], fqn: &str) -> Result<f32, FormsError> {
90    let field = fields
91        .iter()
92        .find(|f| f.fqn == fqn)
93        .ok_or_else(|| FormsError::MapFieldMissing(fqn.to_string()))?;
94    let rect = field
95        .rect
96        .ok_or_else(|| FormsError::Structure(format!("{fqn}: field has no /Rect")))?;
97    Ok((rect[2] - rect[0]) - 2.0 * TEXT_INSET_PTS)
98}
99
100/// Part II's own line-1 usable width + a Part IV line's usable width, for `map`'s bundled asset —
101/// shared by the real fill and [`part_ii_capacity_check`].
102fn part_ii_iv_widths(
103    blank_fields: &[pdf::Field],
104    map: &Form8275Map,
105) -> Result<(f32, f32), FormsError> {
106    let part_ii_width = usable_width(blank_fields, &map.part_ii_narrative)?;
107    let part_iv_width = match map.part_iv_continuation.first() {
108        Some(fqn) => usable_width(blank_fields, fqn)?,
109        // No Part IV lines mapped at all — degrade to Part II's own (proven-safe) width; unreachable on
110        // the bundled asset (27 lines mapped), defensive only.
111        None => part_ii_width,
112    };
113    Ok((part_ii_width, part_iv_width))
114}
115
116/// A free-text cell (geometry-exempt, page-derived): written + authorized only when non-empty. Mirrors
117/// `form8283.rs::push_free` exactly — Form 8275 has no money-grid columns, so every cell here uses it.
118fn push_free(
119    w: &mut Vec<(String, pdf::FieldValue)>,
120    p: &mut Vec<FlatPlacement>,
121    fqn: &str,
122    value: &str,
123) {
124    if value.is_empty() {
125        return;
126    }
127    w.push((fqn.to_string(), pdf::FieldValue::Text(value.to_string())));
128    p.push(FlatPlacement::free(
129        fqn.to_string(),
130        crate::cells::page_of(fqn),
131    ));
132}
133
134/// Like [`push_free`], but the placement also joins a per-group strictly-descending-y ordinal sequence
135/// (`FlatPlacement::free_ordered`) — used for Part IV's continuation lines, which (unlike every other
136/// free-text write in this form) really are a physically ORDERED sequence the fill assumes is
137/// top-to-bottom (round 2 finding 6).
138fn push_free_ordered(
139    w: &mut Vec<(String, pdf::FieldValue)>,
140    p: &mut Vec<FlatPlacement>,
141    fqn: &str,
142    value: &str,
143    group: u32,
144    ordinal: u32,
145) {
146    if value.is_empty() {
147        return;
148    }
149    w.push((fqn.to_string(), pdf::FieldValue::Text(value.to_string())));
150    p.push(FlatPlacement::free_ordered(
151        fqn.to_string(),
152        crate::cells::page_of(fqn),
153        group,
154        ordinal,
155    ));
156}
157
158/// Fill Form 8275 for `year` from the T13 printed disclosure + the FULL-RETURN filer's identity.
159///
160/// `Ok(None)` when `printed.part_i` is empty — there is no position to disclose (T13's
161/// `disclosure_8275` already returns `None` upstream for a year with no promoted disposal leg, but a
162/// defensive re-check here means this fill never emits a content-less "blank" 8275).
163///
164/// Refuses ([`FormsError::Overflow`]) when there are more Part I items than this revision's 6 rows —
165/// v1 does not paginate Form 8275 (unlike Form 8283's `overflow::merge_copies`); a promoted year with
166/// more than 6 disposal legs is a real but rare case, tracked as a follow-up rather than built here.
167pub fn fill_form_8275(
168    printed: &Printed8275,
169    header: &ReturnHeader,
170    year: i32,
171) -> Result<Option<Vec<u8>>, FormsError> {
172    let map = Form8275Map::for_year(year)?;
173    fill_form_8275_inner(printed, Some(header), &map)
174}
175
176/// Fill Form 8275 for `year` for the **crypto-slice** `export-irs-pdf` path (Task 16) — NO filer
177/// identity: mirrors `form8283::fill_form_8283`, which writes its property rows the same way (the
178/// crypto slice never wrote an identity block; its 8275 rides beside a return btctax did not produce).
179/// `Ok(None)` when `printed.part_i` is empty (no promoted disposal leg filed in `year`).
180pub fn fill_form_8275_slice(
181    printed: &Printed8275,
182    year: i32,
183) -> Result<Option<Vec<u8>>, FormsError> {
184    let map = Form8275Map::for_year(year)?;
185    fill_form_8275_inner(printed, None, &map)
186}
187
188/// The map-parametrized fill (exposed via `testonly` for fault-injection KATs — mirrors
189/// `fill_schedule_se_with_map` / `fill_1040_with_map`). Kept identity-REQUIRED (unlike
190/// `fill_form_8283`'s `Option`) at this call surface — every existing caller (the full-return packet,
191/// `sp4.rs`'s KATs) has one; the crypto-slice caller goes through [`fill_form_8275_slice`] instead.
192pub fn fill_form_8275_with_map(
193    printed: &Printed8275,
194    header: &ReturnHeader,
195    map: &Form8275Map,
196) -> Result<Option<Vec<u8>>, FormsError> {
197    fill_form_8275_inner(printed, Some(header), map)
198}
199
200/// The shared fill: `filer: None` (crypto-slice) skips the identity cells entirely, exactly as
201/// `form8283::fill_form_8283_inner` does for its `filer: None` case.
202fn fill_form_8275_inner(
203    printed: &Printed8275,
204    filer: Option<&ReturnHeader>,
205    map: &Form8275Map,
206) -> Result<Option<Vec<u8>>, FormsError> {
207    if printed.part_i.is_empty() {
208        return Ok(None);
209    }
210    if printed.part_i.len() > map.rows.len() {
211        return Err(FormsError::Overflow {
212            part: "Part I",
213            rows: printed.part_i.len(),
214            capacity: map.rows.len(),
215        });
216    }
217
218    // The BLANK PDF's own field set — needed for (a) each Part II/IV continuation line's `/Rect` width
219    // (the wrap measurement below) and (b) the identity SSN cell's `/MaxLen` when `filer` is `Some`.
220    // One load covers both; loaded unconditionally because the wrap needs it even on the crypto-slice
221    // path (Task 16), which has no identity to write.
222    let blank_fields = pdf::collect_fields(&pdf::load(pdf::f8275_pdf(map.year)?)?)?;
223
224    let mut w: Vec<(String, pdf::FieldValue)> = Vec::new();
225    let mut p: Vec<FlatPlacement> = Vec::new();
226
227    for (row_map, item) in map.rows.iter().zip(printed.part_i.iter()) {
228        // (b) Item or Group of Items ← the position's form-location descriptor.
229        push_free(&mut w, &mut p, &row_map.item, &item.line);
230        // (c) Detailed Description of Items ← the Cohan-estimate explanation.
231        push_free(&mut w, &mut p, &row_map.desc, &item.description);
232        // (d) Form or Schedule ← the filed form (e.g. "8949").
233        push_free(
234            &mut w,
235            &mut p,
236            &row_map.form_schedule,
237            &format!("Form {}", item.form),
238        );
239        // (f) Amount.
240        push_free(&mut w, &mut p, &row_map.amount, &fmt_money(item.amount));
241    }
242    // Part II — the filer's combined narrative, WRAPPED (never truncated) onto Part II's own line 1
243    // (`part_ii_narrative`) then, if it does not fit there alone, Part IV's continuation lines
244    // (`part_iv_continuation`, with the IRS-required cross-reference on the first one used) — see the
245    // module doc for why Part II's numbered lines 2-6 are deliberately left unclaimed. Fails closed
246    // ([`FormsError::Overflow`], mirroring the Part I row refusal above) rather than silently clipping
247    // past a field boundary — the shipped defect this fix exists to end.
248    let (part_ii_width, part_iv_width) = part_ii_iv_widths(&blank_fields, map)?;
249    let wrapped = wrap_part_ii(
250        &printed.part_ii,
251        part_ii_width,
252        part_iv_width,
253        map.part_iv_continuation.len(),
254        PART_II_FONT_SIZE_PT,
255    )
256    .map_err(|overflow: PartIiOverflow| FormsError::Overflow {
257        part: "Part II",
258        rows: overflow.rows_needed,
259        capacity: overflow.capacity,
260    })?;
261    const PART_II_GROUP: u32 = 0;
262    const PART_IV_GROUP: u32 = 1;
263    push_free_ordered(
264        &mut w,
265        &mut p,
266        &map.part_ii_narrative,
267        &wrapped.part_ii_line1,
268        PART_II_GROUP,
269        0,
270    );
271    for (i, (fqn, line)) in map
272        .part_iv_continuation
273        .iter()
274        .zip(wrapped.part_iv_lines.iter())
275        .enumerate()
276    {
277        push_free_ordered(&mut w, &mut p, fqn, line, PART_IV_GROUP, i as u32);
278    }
279
280    // The FILER's identity — "Name(s) shown on return" + identifying number. `None` on the crypto-slice
281    // path (Task 16): the disclosure still rides the export even with no `ReturnInputs` on file.
282    if let Some(header) = filer {
283        crate::cells::push_identity(
284            &mut w,
285            &mut p,
286            &map.identity,
287            &header.name_line,
288            &header.taxpayer.ssn,
289            &blank_fields,
290        )?;
291    }
292
293    let writes = w;
294    let placements = p;
295
296    let mut doc = pdf::load(pdf::f8275_pdf(map.year)?)?;
297    let index = pdf::index(&pdf::collect_fields(&doc)?);
298    pdf::drop_xfa_and_set_needappearances(&mut doc)?;
299    pdf::apply_writes(&mut doc, &index, &writes)?;
300    pdf::strip_nondeterminism(&mut doc);
301    let bytes = pdf::save(&mut doc)?;
302
303    // Read back the SERIALIZED output. No column clusters (free-text form; see module doc).
304    let check = pdf::load(&bytes)?;
305    let fields = pdf::collect_fields(&check)?;
306    verify_flat(&check, &fields, &placements, &[])?;
307    Ok(Some(bytes))
308}
309
310/// Whether `narrative` fits, WITHOUT filling anything — the SAME wrap the real fill runs (via
311/// [`part_ii_iv_widths`]/[`wrap_part_ii`], so the two can never disagree). `Ok(PartIiCapacity::Fits)`
312/// covers an empty narrative too. Overflow is `Ok(PartIiCapacity::Overflow(_))`, not `Err` — it is an
313/// expected, real outcome the caller builds ITS OWN refusal message from (naming the year, a remedy,
314/// `--part-ii-file`); `Err` is reserved for an actual engine failure (an unsupported year, a bundled
315/// map/PDF that fails to parse) the caller must still surface as such.
316///
317/// Exists so `btctax-cli`'s export paths (`crates/btctax-cli/src/cmd/admin.rs`,
318/// `export_irs_pdf_from_session` + `export_full_return`) can refuse BEFORE `mkdir_out`/writing any
319/// packet file — round 2 finding 2: without this, the crypto-slice path left an estimated-basis 8949
320/// on disk with no 8275 PDF behind it when the narrative overflowed, because the overflow was only
321/// discovered mid-write, inside `fill_form_8275_slice`, well after `basis_methodology.txt` and
322/// `form_8275.txt` were already written.
323pub fn part_ii_capacity_check(narrative: &str, year: i32) -> Result<PartIiCapacity, FormsError> {
324    let map = Form8275Map::for_year(year)?;
325    let blank_fields = pdf::collect_fields(&pdf::load(pdf::f8275_pdf(map.year)?)?)?;
326    let (part_ii_width, part_iv_width) = part_ii_iv_widths(&blank_fields, &map)?;
327    match wrap_part_ii(
328        narrative,
329        part_ii_width,
330        part_iv_width,
331        map.part_iv_continuation.len(),
332        PART_II_FONT_SIZE_PT,
333    ) {
334        Ok(_) => Ok(PartIiCapacity::Fits),
335        Err(overflow) => Ok(PartIiCapacity::Overflow(overflow)),
336    }
337}
338
339/// The verdict [`part_ii_capacity_check`] returns.
340#[derive(Debug, Clone, Copy)]
341pub enum PartIiCapacity {
342    /// The narrative fits (including an empty one).
343    Fits,
344    /// The narrative does not fit — detail for building a refusal message.
345    Overflow(PartIiOverflow),
346}