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(¶graphs, &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(¶s, &[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(¶s, &[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}