Skip to main content

btctax_forms/
wrap.rs

1//! Helvetica-Bold 8pt text measurement + paragraph-aware word-wrap for Form 8275's Part II line 1 +
2//! Part IV ("Explanations (continued from Parts I and/or II)") continuation lines.
3//!
4//! Every continuation field (`p1-t80[0]` on page 1, `p2-t1[0]`..`p2-t27[0]` on page 2) is a
5//! SINGLE-LINE, non-multiline, `DoNotScroll` AcroForm text field with no `/MaxLen` — nothing at the
6//! PDF-data level stops an over-long `/V` from being WRITTEN (see `form8275.rs`'s doc comment for why
7//! `verify_flat` cannot see this), but a viewer honoring the widget's own `/Rect` and `/DA`
8//! (`/HelveticaLTStd-Bold 8.00 Tf`, verified against the bundled Rev. 10-2024 asset) can only DISPLAY
9//! what fits in that box. This module measures at that same font/size so the FILL decides where to
10//! break a line, instead of leaving it to an unpredictable, silent, per-viewer clip.
11//!
12//! ★ **Only Part II's OWN line 1 is ever written** (`form8275.rs`'s module doc explains why: the
13//! bundled PDF's XFA numbers Part II lines 1-6 beside Part I rows 1-6, so spreading one combined
14//! narrative across them misattributes sentence fragments to items they do not explain). Everything
15//! past line 1 goes to Part IV, prefixed on its first line with the IRS-required cross-reference
16//! ([`PART_IV_CROSS_REFERENCE_PREFIX`]). See [`wrap_part_ii`].
17//!
18//! T-f8275-part-ii-overflow (round 2 — the two-lens review that produced findings 1/3/4/5/6/7).
19
20/// Helvetica-Bold glyph widths, ASCII printable range 0x20..=0x7E (`code - 0x20` indexed) — extracted
21/// DIRECTLY from the bundled `f8275.pdf`'s own embedded font (`/DR/Font/HelveticaLTStd-Bold`, a
22/// `/Type1` font with `/Encoding /WinAnsiEncoding`, `/FirstChar 0`/`/LastChar 255`, and a full 256-entry
23/// `/Widths` array covering this whole range directly) — NOT the generic Adobe Core-14 Helvetica-Bold
24/// AFM table. The two differ at two codepoints this asset's own font actually ships: `'` (0x27) is 238
25/// here vs the generic AFM's 278 (this table is narrower — safe, the over-measuring direction), and
26/// `` ` `` (0x60) is 333 here vs the generic AFM's 278 (this table is WIDER — the generic AFM
27/// under-measured backtick, the dangerous direction that can let a line overflow its box). Re-derive
28/// with the one-off dump documented in `design/f8275-part-ii-overflow/BUILD-REPORT.md` if the bundled
29/// asset ever changes.
30const HELV_BOLD_ASCII: [u16; 95] = [
31    278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556,
32    556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, 975, 722, 722, 722, 722, 667,
33    611, 778, 722, 278, 556, 722, 611, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667,
34    667, 611, 333, 278, 333, 584, 556, 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556,
35    278, 889, 611, 611, 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584,
36];
37
38/// A handful of non-ASCII glyphs this codebase's own domain text already uses (`Part1Item::line`
39/// carries an em dash) or a filer's word processor commonly substitutes ("smart" punctuation). Widths
40/// are the SAME bundled asset's `/Widths` entries at each glyph's `/WinAnsiEncoding` byte code (0x91
41/// quoteleft, 0x92 quoteright, 0x93 quotedblleft, 0x94 quotedblright, 0x95 bullet, 0x96 endash, 0x97
42/// emdash, 0x85 ellipsis, 0xA7 section) — all independently confirmed to match the values already used
43/// here (only the two ASCII codepoints above needed correction).
44fn extra_glyph_width(c: char) -> Option<u16> {
45    Some(match c {
46        '\u{2014}' => 1000,             // em dash
47        '\u{2013}' => 556,              // en dash
48        '\u{2018}' | '\u{2019}' => 278, // single quotes / apostrophe
49        '\u{201C}' | '\u{201D}' => 500, // double quotes
50        '\u{00A7}' => 556,              // section sign
51        '\u{2022}' => 350,              // bullet
52        '\u{2026}' => 1000,             // ellipsis
53        _ => return None,
54    })
55}
56
57/// The widest glyph in the whole Helvetica-Bold table above (1000/1000 em — several glyphs share it,
58/// e.g. em dash and ellipsis; confirmed the actual max of the asset's full 256-entry `/Widths` array,
59/// not just the ASCII slice). Assigned to any character neither table above knows about — deliberately
60/// the WIDEST possible, so an unmodeled glyph can only make wrapping MORE conservative (an earlier line
61/// break), never under-measure a line that then overflows its field's physical box.
62const UNKNOWN_GLYPH_WIDTH: u16 = 1000;
63
64fn glyph_width(c: char) -> u16 {
65    if c.is_ascii() {
66        let code = c as u32;
67        if (0x20..=0x7E).contains(&code) {
68            return HELV_BOLD_ASCII[(code - 0x20) as usize];
69        }
70    }
71    extra_glyph_width(c).unwrap_or(UNKNOWN_GLYPH_WIDTH)
72}
73
74/// Width, in PDF points, of `s` set in Helvetica-Bold at `size_pt`.
75pub(crate) fn text_width_pts(s: &str, size_pt: f32) -> f32 {
76    let units: u32 = s.chars().map(|c| glyph_width(c) as u32).sum();
77    units as f32 * size_pt / 1000.0
78}
79
80/// Float slop for a `<=` width comparison — text measurement here is exact arithmetic on integer glyph
81/// units, so this only absorbs f32 rounding, not any real uncertainty.
82const EPS: f32 = 0.01;
83
84/// Split `text` into paragraphs on blank-line boundaries — a run of one or more lines containing only
85/// whitespace ends the current paragraph. Mirrors how `disclosure_8275` joins multiple promoted
86/// tranches' own narratives with `"\n\n"` (`btctax-core/src/tax/form8275.rs`): each tranche's account
87/// is a distinct, independent statement and must never blend into the next mid-line on the filed form.
88/// A paragraph's own internal line breaks are NOT preserved (rewrapped freely); only the boundary
89/// BETWEEN paragraphs is a hard break. Empty/whitespace-only input yields no paragraphs.
90fn split_paragraphs(text: &str) -> Vec<String> {
91    let mut paragraphs = Vec::new();
92    let mut current: Vec<&str> = Vec::new();
93    for line in text.lines() {
94        if line.trim().is_empty() {
95            if !current.is_empty() {
96                paragraphs.push(current.join(" "));
97                current.clear();
98            }
99        } else {
100            current.push(line);
101        }
102    }
103    if !current.is_empty() {
104        paragraphs.push(current.join(" "));
105    }
106    paragraphs
107}
108
109/// Greedily word-wrap `paragraphs` into lines, where the i-th produced line's max width is
110/// `line_widths[i]` (or `line_widths`'s LAST entry, for any line beyond the array — used only so the
111/// overflow diagnostic can keep measuring how many lines a too-long narrative would actually need).
112/// Breaks only on whitespace (never mid-word). A **paragraph boundary is a hard break**: the line being
113/// built is flushed (even if not full) before the next paragraph's first word, so two paragraphs never
114/// share a field.
115fn wrap_paragraphs(paragraphs: &[String], line_widths: &[f32], size_pt: f32) -> Vec<String> {
116    debug_assert!(!line_widths.is_empty());
117    let space_w = text_width_pts(" ", size_pt);
118    let mut lines: Vec<String> = Vec::new();
119    for paragraph in paragraphs {
120        let mut current = String::new();
121        let mut current_w = 0.0_f32;
122        for word in paragraph.split_whitespace() {
123            let word_w = text_width_pts(word, size_pt);
124            let idx = lines.len();
125            let max_w = line_widths
126                .get(idx)
127                .copied()
128                .unwrap_or_else(|| *line_widths.last().unwrap());
129            if current.is_empty() {
130                current = word.to_string();
131                current_w = word_w;
132            } else if current_w + space_w + word_w <= max_w + EPS {
133                current.push(' ');
134                current.push_str(word);
135                current_w += space_w + word_w;
136            } else {
137                lines.push(std::mem::take(&mut current));
138                current = word.to_string();
139                current_w = word_w;
140            }
141        }
142        if !current.is_empty() {
143            lines.push(current);
144        }
145        // Hard break: the next paragraph (if any) always starts a FRESH `current`, above — it never
146        // appends to this paragraph's last line.
147    }
148    lines
149}
150
151/// The cross-reference Form 8275's Rev. 10-2024 Specific Instructions require on Part IV: *"Use Part IV
152/// on page 2 if you need more space for Parts I and/or II. Include the corresponding part and line
153/// number from page 1."* Always refers to "line 1" because Part II now writes ONLY its own line 1 (see
154/// the module doc) — there is never a different Part II line number to cite.
155pub const PART_IV_CROSS_REFERENCE_PREFIX: &str = "Part II, line 1 (continued): ";
156
157/// The wrapped placement of a Part II narrative: Part II's own line 1 (possibly empty, iff the
158/// narrative itself was empty/whitespace-only), then 0 or more Part IV continuation lines — the first
159/// of which, if any, carries [`PART_IV_CROSS_REFERENCE_PREFIX`].
160#[derive(Debug, Clone, Default)]
161pub(crate) struct PartIiWrap {
162    pub part_ii_line1: String,
163    pub part_iv_lines: Vec<String>,
164}
165
166/// Detail for a narrative that will not fit — everything a caller needs to build a helpful refusal
167/// (see `btctax-forms::part_ii_capacity_check`, used by `btctax-cli`'s export pre-flight).
168#[derive(Debug, Clone, Copy)]
169pub struct PartIiOverflow {
170    /// How many single-line fields the narrative actually needs (Part II's line 1 + however many Part
171    /// IV lines) — may UNDER-report for the (pathological, follow-up-tracked) case of a single
172    /// unbreakable run of text wider than any one line's budget, where it is instead inflated to a
173    /// value guaranteed to exceed `capacity` regardless of the true "need."
174    pub rows_needed: usize,
175    /// The total capacity available: 1 (Part II line 1) + the Part IV continuation-line count.
176    pub capacity: usize,
177    /// How many characters of the narrative (NOT counting the inserted Part IV cross-reference prefix)
178    /// would land across the `capacity` lines that ARE available — a real count reconstructed from the
179    /// same wrap the fill itself performs, not a fabricated estimate.
180    pub chars_fit: usize,
181}
182
183/// Wrap `narrative` for Form 8275: `part_ii_width_pts` is Part II line 1's own usable width (already
184/// inset — see `form8275.rs::usable_width`); `part_iv_width_pts` is a Part IV line's usable width;
185/// `part_iv_capacity` is how many Part IV lines are available (27 on the bundled Rev. 10-2024 asset).
186///
187/// Single pass: line 0 (Part II) always gets the full `part_ii_width_pts`. Line 1 (the first Part IV
188/// line, if the wrap ever reaches it) is budgeted `part_iv_width_pts` MINUS
189/// [`PART_IV_CROSS_REFERENCE_PREFIX`]'s own width, reserving room for it — this reservation only ever
190/// matters when a second line is actually produced, which is exactly when the cross-reference is
191/// needed, so there is no need to detect "will we need Part IV?" up front. Lines 2+ get the full
192/// `part_iv_width_pts`.
193pub(crate) fn wrap_part_ii(
194    narrative: &str,
195    part_ii_width_pts: f32,
196    part_iv_width_pts: f32,
197    part_iv_capacity: usize,
198    size_pt: f32,
199) -> Result<PartIiWrap, PartIiOverflow> {
200    let paragraphs = split_paragraphs(narrative);
201    if paragraphs.is_empty() {
202        return Ok(PartIiWrap::default());
203    }
204
205    let capacity = 1 + part_iv_capacity;
206    let prefix_w = text_width_pts(PART_IV_CROSS_REFERENCE_PREFIX, size_pt);
207    let line_widths = [
208        part_ii_width_pts,
209        (part_iv_width_pts - prefix_w).max(0.0),
210        part_iv_width_pts,
211    ];
212    let lines = wrap_paragraphs(&paragraphs, &line_widths, size_pt);
213
214    let too_wide = lines.iter().enumerate().any(|(i, l)| {
215        let budget = line_widths
216            .get(i)
217            .copied()
218            .unwrap_or_else(|| *line_widths.last().unwrap());
219        text_width_pts(l, size_pt) > budget + EPS
220    });
221
222    if too_wide || lines.len() > capacity {
223        let rows_needed = if too_wide {
224            lines.len().max(capacity + 1)
225        } else {
226            lines.len()
227        };
228        let fit_count = capacity.min(lines.len());
229        let mut fit: Vec<String> = lines[..fit_count].to_vec();
230        if let Some(first_iv) = fit.get_mut(1) {
231            if let Some(stripped) = first_iv.strip_prefix(PART_IV_CROSS_REFERENCE_PREFIX) {
232                *first_iv = stripped.to_string();
233            }
234        }
235        let chars_fit = fit.join(" ").chars().count();
236        return Err(PartIiOverflow {
237            rows_needed,
238            capacity,
239            chars_fit,
240        });
241    }
242
243    let part_ii_line1 = lines[0].clone();
244    let mut part_iv_lines: Vec<String> = lines.get(1..).unwrap_or(&[]).to_vec();
245    if let Some(first) = part_iv_lines.first_mut() {
246        *first = format!("{PART_IV_CROSS_REFERENCE_PREFIX}{first}");
247    }
248    Ok(PartIiWrap {
249        part_ii_line1,
250        part_iv_lines,
251    })
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn measures_known_widths() {
260        // "MM" (two 833-unit glyphs) at 8pt = 2 * 833 * 8 / 1000 = 13.328pt.
261        assert!((text_width_pts("MM", 8.0) - 13.328).abs() < 0.01);
262        // A capital-heavy string measures wider than the same length of narrow lowercase.
263        assert!(text_width_pts("WWWW", 8.0) > text_width_pts("iiii", 8.0));
264        assert_eq!(text_width_pts("", 8.0), 0.0);
265    }
266
267    #[test]
268    fn backtick_and_apostrophe_use_the_assets_own_widths_not_the_generic_afm() {
269        // ★ Round-2 finding 7: the generic Adobe Core-14 AFM has backtick at 278 (this asset's font
270        // says 333 — under-measuring, the dangerous direction) and apostrophe at 278 (this asset says
271        // 238 — over-measuring, safe, but still worth pinning so a future edit can't silently drift).
272        assert_eq!(text_width_pts("`", 8.0), 333.0 * 8.0 / 1000.0);
273        assert_eq!(text_width_pts("'", 8.0), 238.0 * 8.0 / 1000.0);
274    }
275
276    #[test]
277    fn unknown_glyphs_use_the_widest_known_width_never_narrower() {
278        // A made-up private-use character must not be measured as narrower than any real glyph in the
279        // table — this is the "never under-measure" guarantee `UNKNOWN_GLYPH_WIDTH` exists for.
280        let widest_known = *HELV_BOLD_ASCII.iter().max().unwrap();
281        assert!(UNKNOWN_GLYPH_WIDTH as u32 >= widest_known as u32);
282        assert_eq!(glyph_width('\u{E000}'), UNKNOWN_GLYPH_WIDTH);
283    }
284
285    #[test]
286    fn split_paragraphs_treats_a_blank_line_as_a_boundary() {
287        let paras = split_paragraphs("first account of events\n\nsecond, unrelated account");
288        assert_eq!(
289            paras,
290            vec![
291                "first account of events".to_string(),
292                "second, unrelated account".to_string(),
293            ]
294        );
295    }
296
297    #[test]
298    fn split_paragraphs_collapses_multiple_blank_lines_to_one_boundary() {
299        let paras = split_paragraphs("a\n\n\n\nb");
300        assert_eq!(paras, vec!["a".to_string(), "b".to_string()]);
301    }
302
303    #[test]
304    fn split_paragraphs_of_empty_text_yields_no_paragraphs() {
305        assert_eq!(split_paragraphs(""), Vec::<String>::new());
306        assert_eq!(split_paragraphs("   \n  \n"), Vec::<String>::new());
307    }
308
309    #[test]
310    fn wrap_paragraphs_never_splits_a_word_and_preserves_every_word_in_order() {
311        let paras = split_paragraphs("one two three four five six seven eight nine ten");
312        let lines = wrap_paragraphs(&paras, &[60.0], 8.0); // narrow: forces several lines
313        assert!(lines.len() > 1, "fixture premise: must actually wrap");
314        let rejoined: Vec<&str> = lines.iter().flat_map(|l| l.split_whitespace()).collect();
315        let original: Vec<&str> = "one two three four five six seven eight nine ten"
316            .split_whitespace()
317            .collect();
318        assert_eq!(
319            rejoined, original,
320            "no word may be dropped, split, or reordered"
321        );
322        for line in &lines {
323            assert!(
324                text_width_pts(line, 8.0) <= 60.0 + EPS,
325                "line {line:?} exceeds the 60pt budget"
326            );
327        }
328    }
329
330    #[test]
331    fn wrap_paragraphs_hard_breaks_between_paragraphs_never_shares_a_line() {
332        // Two short paragraphs that would EASILY fit on one combined line at this width — but the
333        // paragraph boundary must still force a new line.
334        let paras = split_paragraphs("short one\n\nshort two");
335        let lines = wrap_paragraphs(&paras, &[1000.0], 8.0);
336        assert_eq!(
337            lines,
338            vec!["short one".to_string(), "short two".to_string()],
339            "a paragraph boundary must start a new line even when there is room to share one"
340        );
341    }
342
343    #[test]
344    fn wrap_paragraphs_of_no_paragraphs_yields_no_lines() {
345        assert_eq!(wrap_paragraphs(&[], &[518.4], 8.0), Vec::<String>::new());
346    }
347
348    #[test]
349    fn wrap_part_ii_fits_entirely_on_part_ii_line_1_when_short() {
350        let wrapped = wrap_part_ii("a short narrative", 514.4, 536.0, 27, 8.0).unwrap();
351        assert_eq!(wrapped.part_ii_line1, "a short narrative");
352        assert!(wrapped.part_iv_lines.is_empty());
353    }
354
355    #[test]
356    fn wrap_part_ii_of_empty_narrative_writes_nothing() {
357        let wrapped = wrap_part_ii("", 514.4, 536.0, 27, 8.0).unwrap();
358        assert_eq!(wrapped.part_ii_line1, "");
359        assert!(wrapped.part_iv_lines.is_empty());
360    }
361
362    #[test]
363    fn wrap_part_ii_spills_to_part_iv_with_the_cross_reference_prefix() {
364        // A narrow Part II line (forces early overflow) with plenty of Part IV room.
365        let wrapped = wrap_part_ii(
366            "one two three four five six seven eight",
367            60.0,
368            1000.0,
369            27,
370            8.0,
371        )
372        .unwrap();
373        assert!(!wrapped.part_iv_lines.is_empty(), "must have spilled");
374        assert!(
375            wrapped.part_iv_lines[0].starts_with(PART_IV_CROSS_REFERENCE_PREFIX),
376            "the FIRST Part IV line must carry the IRS-required cross-reference: {:?}",
377            wrapped.part_iv_lines[0]
378        );
379        for later in &wrapped.part_iv_lines[1..] {
380            assert!(
381                !later.starts_with(PART_IV_CROSS_REFERENCE_PREFIX),
382                "only the FIRST Part IV line carries the cross-reference: {later:?}"
383            );
384        }
385        // No word lost: Part II line 1 + all Part IV lines (cross-reference stripped) reproduce the
386        // original word sequence in order.
387        let mut got: Vec<&str> = wrapped.part_ii_line1.split_whitespace().collect();
388        for (i, l) in wrapped.part_iv_lines.iter().enumerate() {
389            let text = if i == 0 {
390                l.strip_prefix(PART_IV_CROSS_REFERENCE_PREFIX).unwrap()
391            } else {
392                l.as_str()
393            };
394            got.extend(text.split_whitespace());
395        }
396        assert_eq!(
397            got,
398            "one two three four five six seven eight"
399                .split_whitespace()
400                .collect::<Vec<_>>()
401        );
402    }
403
404    #[test]
405    fn wrap_part_ii_treats_a_paragraph_break_as_a_hard_break_across_the_wrap() {
406        // Two short paragraphs, each far under Part II's own line width — without the hard-break rule
407        // they would share Part II's line 1. With it, the second paragraph must start Part IV.
408        let wrapped =
409            wrap_part_ii("first account\n\nsecond account", 200.0, 1000.0, 27, 8.0).unwrap();
410        assert_eq!(wrapped.part_ii_line1, "first account");
411        assert_eq!(wrapped.part_iv_lines.len(), 1);
412        assert_eq!(
413            wrapped.part_iv_lines[0],
414            format!("{PART_IV_CROSS_REFERENCE_PREFIX}second account")
415        );
416    }
417
418    #[test]
419    fn wrap_part_ii_fails_closed_when_more_lines_are_needed_than_available() {
420        let text = "word ".repeat(900);
421        let err = wrap_part_ii(&text, 514.4, 536.0, 27, 8.0)
422            .expect_err("900 words must not fit in 1 + 27 = 28 lines");
423        assert_eq!(err.capacity, 28);
424        assert!(
425            err.rows_needed > err.capacity,
426            "rows_needed {} must exceed capacity {}",
427            err.rows_needed,
428            err.capacity
429        );
430        assert!(
431            err.chars_fit > 0 && err.chars_fit < text.chars().count(),
432            "chars_fit {} must be a real partial count, not 0 or the whole narrative ({})",
433            err.chars_fit,
434            text.chars().count()
435        );
436    }
437
438    #[test]
439    fn wrap_part_ii_fails_closed_on_a_single_word_wider_than_any_line() {
440        let token = "a".repeat(50);
441        let err = wrap_part_ii(&token, 20.0, 20.0, 27, 8.0)
442            .expect_err("an unbreakable token wider than every line can never fit");
443        assert!(err.rows_needed > err.capacity);
444    }
445}