From 6d7e887efe70ece1464f70ee16969a872418b30b Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:04:52 -0400 Subject: [PATCH] M6: a paste into a full field keeps the longest part that fits, across a change of font --- crates/forms/src/appearance.rs | 40 +++++++++++++++++++++++++++++----- crates/forms/src/tests.rs | 28 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 9d5fb43c7..e283c5ee3 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -156,13 +156,41 @@ impl TextBox { if self.fits(after) { return (after.to_string(), p + inserted.len()); } - // The most of the insertion that fits: text only grows as it gets longer, so halve. - let (mut lo, mut hi) = (0, inserted.len()); - while lo < hi { - let mid = lo + (hi - lo).div_ceil(2); - if self.fits(&with(mid)) { lo = mid } else { hi = mid - 1 } + // The longest of the insertion's first `from..=to` characters that fits, halving: in one + // font a longer text is never narrower. `None` when even `from` doesn't fit. + let longest = |from: usize, to: usize| -> Option { + if from > to || !self.fits(&with(from)) { + return None; + } + let (mut lo, mut hi) = (from, to); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + if self.fits(&with(mid)) { lo = mid } else { hi = mid - 1 } + } + Some(lo) + }; + // The font can change once, from the form's own to Helvetica, at the first inserted + // character the form's font can't show: each side of that point is searched on its own. + let n = inserted.len(); + let kept = match self.font_switch(prefix, inserted, suffix) { + Some(t) => longest(0, t.saturating_sub(1)).into_iter().chain(longest(t, n)).max(), + None => longest(0, n), } - (with(lo), p + lo) + .unwrap_or(0); + (with(kept), p + kept) + } + + /// Where an insertion changes the font [`Self::fits`] measures in: how much of `inserted` + /// (between `prefix` and `suffix`) first includes a character the form's own font can't show, + /// when everything around it can be shown in that font. `None` when the font never changes. + fn font_switch(&self, prefix: &[char], inserted: &[char], suffix: &[char]) -> Option { + let enc = self.font.as_ref().filter(|_| !self.password)?; + // As `fits` decides: a multi-line value is checked line by line, its breaks aside. + let shows = |c: &char| (self.multiline && matches!(c, '\n' | '\r')) || enc.can_encode(&c.to_string()); + if !prefix.iter().chain(suffix).all(shows) { + return None; + } + inserted.iter().position(|c| !shows(c)).map(|i| i + 1) } } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 32eea6631..317f23c78 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -472,6 +472,34 @@ fn an_edit_to_a_full_field_keeps_what_fits() { assert!(kept.starts_with("SW") && kept.ends_with('t') && tb.fits(&kept), "{kept}"); } +/// When the field's own font can't show a character of a paste, the text from there on is drawn +/// in Helvetica, which can be narrower: the longest part that fits may come after that point, and +/// is still the one kept. +#[test] +fn an_edit_keeps_the_longest_part_that_fits_across_a_change_of_font() { + // A subset font holding only A, 1 em wide: 12 pt an A at 12 pt, against Helvetica's 8 pt. + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Wide 12 Tf 0 g) /DR << /Font << /Wide 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Wide /FirstChar 65 /LastChar 65 /Widths [1000] /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (one) /DA (/Wide 12 Tf 0 g) /Rect [50 760 150 780] /P 3 0 R >>".into(), + ]; + let doc = document(&objs); + let all = fields(&doc); + let f = field(&all, "one"); + let tb = appearance::text_box(&doc, f, &f.widgets[0]); + // 96 pt inside: 8 A's in the subset font, or 11 characters (8.004 pt each) once a B brings + // Helvetica in. + assert!(tb.fits(&"A".repeat(8)) && !tb.fits(&"A".repeat(9))); + let pasted = format!("{}B{}", "A".repeat(10), "A".repeat(30)); + let (kept, caret) = tb.fitted_edit("", &pasted); + assert_eq!(kept, format!("{}B", "A".repeat(10)), "the 11 characters that fit in Helvetica, not the 8 before the B"); + assert!(!tb.fits(&format!("{}BA", "A".repeat(10))), "and no more"); + assert_eq!(caret, 11); +} + /// Issue #1323: a text field without a format keeps a whitespace-only value as typed; only an /// empty value clears it. #[test]