From 2c02ca3a5619f03c80eada5f7c187a898287f9bd Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:05:11 +0800 Subject: [PATCH] M5: a text box's /DA without a usable size, with a non-finite operand or naming no listed font draws at the defaults (ISO 32000-2 12.7.4.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A /DA string comes from the file. Reviewing how the PR reads it: - An operand that is not a finite number was taken as one: Rust parses "NaN" and "inf", so "NaN NaN NaN rg" reached the appearance stream as "NaN NaN NaN rg" (clamping keeps NaN), and "/Helv inf Tf" drew at the 400 pt cap. parse_da now ignores such an operand with its operator. - A Tf without its size ("/TiRo Tf") lost its font: da_font_name read the name two tokens before Tf only. The name is now read whether or not a size sits between it and Tf; the size falls back to 12 pt. - A zero or negative size keeps the default 12 pt (zero is auto-size only for form fields, §12.7.4.3; a text box has none), now stated in parse_da's comment. Tests (each fails with its fix reverted): a text_style unit test over missing, zero, negative, NaN, infinite, huge and non-numeric sizes, missing Tf, non-finite and out-of-range colours, garbage and non-UTF-8 bytes; and an appearance test that draws boxes from such /DA strings, including a font in /DR and one no /DR lists, and checks the stream's font, size and colour and that no NaN or inf reaches it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/annot/src/appearance.rs | 8 +++-- crates/annot/src/tests.rs | 39 +++++++++++++++++++++ crates/annot/src/text_style.rs | 64 ++++++++++++++++++++++++++++++++-- 3 files changed, 107 insertions(+), 4 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 99536fe023..ad8b5b847f 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -233,13 +233,17 @@ fn rg_stroke(c: Rgb) -> String { format!("{} {} {} RG\n", n(c[0]), n(c[1]), n(c[2])) } -/// `/DA` of a text box: (text colour, font size). Defaults to black 12 pt. +/// `/DA` of a text box: (text colour, font size). Defaults to black 12 pt: a `Tf` without a +/// usable size (missing, zero, negative or not a number) keeps 12 pt, and sizes past 400 pt are +/// capped. A text box has no automatic size, so a zero size, which means auto-size in a form +/// field's `/DA` (ISO 32000-2 §12.7.4.3), is the default here. An operand that isn't a finite +/// number (`NaN`, `inf`, which Rust's parser would otherwise accept) is ignored with its operator. pub fn parse_da(d: &Dict) -> (Rgb, f64) { let da = d.get(b"DA").and_then(|o| o.as_string()).map(|s| String::from_utf8_lossy(&s.bytes).into_owned()).unwrap_or_default(); let toks: Vec<&str> = da.split_whitespace().collect(); let mut col = [0.0; 3]; let mut size = 12.0; - let num = |i: usize| toks.get(i).and_then(|t| t.parse::().ok()); + let num = |i: usize| toks.get(i).and_then(|t| t.parse::().ok()).filter(|v| v.is_finite()); for (i, t) in toks.iter().enumerate() { match *t { "rg" if i >= 3 => { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index f8bc881fd0..220e8b070b 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2471,3 +2471,42 @@ fn a_colour_or_size_change_keeps_the_drawn_face() { set_style(&mut d, 0, 0, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); assert!(ap_content(&d, &list(&d, 0)[0]).contains("/Cour 12 Tf")); } + +/// A text box without an appearance is drawn from its `/DA` however the file wrote it: a `Tf` +/// without a size, a zero size, a font name the form's `/DR` doesn't list, and hostile operands. +/// The stream names the face the box is drawn in at a usable size, and no number in it is `NaN` +/// or infinite. +#[test] +fn text_boxes_draw_from_any_da_string() { + let doc = |da: &str| { + let annot = format!("<< /Type /Annot /Subtype /FreeText /Rect [45 500 300 700] /Contents (Hi) /DA ({da}) >>"); + document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /DR << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >> >> >> >> >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + annot.as_bytes(), + ]) + }; + for (da, tf, colour) in [ + ("0 g /Cour Tf", "/Cour 12 Tf", "0 0 0 rg"), + ("0 g /Cour 0 Tf", "/Cour 12 Tf", "0 0 0 rg"), + ("0 g /Cour -5 Tf", "/Cour 12 Tf", "0 0 0 rg"), + ("NaN NaN NaN rg /Cour NaN Tf", "/Cour 12 Tf", "0 0 0 rg"), + ("inf g /Cour inf Tf", "/Cour 12 Tf", "0 0 0 rg"), + ("1 0 0 rg /Cour 1e300 Tf", "/Cour 400 Tf", "1 0 0 rg"), + // In `/DR`: Times Bold. Not in `/DR` and no standard name: Helvetica. + ("0 g /F1 Tf", "/TiBo 12 Tf", "0 0 0 rg"), + ("0 g /F9 11 Tf", "/Helv 11 Tf", "0 0 0 rg"), + // No Tf at all, or garbage. + ("0 0 1 rg", "/Helv 12 Tf", "0 0 1 rg"), + ("] [ << >> Tf", "/Helv 12 Tf", "0 0 0 rg"), + ] { + let mut d = doc(da); + set_appearance(&mut d, ObjRef::new(4, 0)).unwrap(); + let content = ap_content(&d, &list(&d, 0)[0]); + assert!(content.contains(tf), "{da:?}: {content}"); + assert!(content.contains(colour), "{da:?}: {content}"); + let lower = content.to_ascii_lowercase(); + assert!(!lower.contains("nan") && !lower.contains("inf"), "{da:?}: {content}"); + } +} diff --git a/crates/annot/src/text_style.rs b/crates/annot/src/text_style.rs index fc3ad87f86..dfa4c8c1fa 100644 --- a/crates/annot/src/text_style.rs +++ b/crates/annot/src/text_style.rs @@ -65,13 +65,20 @@ pub fn dr_face(doc: &Document, d: &Dict) -> Option { Some(base.as_deref().and_then(base_font_face).unwrap_or(Std14::Helvetica)) } -/// The font resource name of the last `Tf` in `/DA` (without the slash). +/// The font resource name of the last `Tf` in `/DA` (without the slash). `Tf` takes a name and +/// a size; a name given without its size (`/TiRo Tf`) is still read, and the size then falls back +/// as [`parse_da`] says. pub fn da_font_name(d: &Dict) -> Option { let da = d.get(b"DA").and_then(Object::as_string)?; let da = String::from_utf8_lossy(&da.bytes); let toks: Vec<&str> = da.split_whitespace().collect(); let tf = toks.iter().rposition(|t| *t == "Tf")?; - let name = toks.get(tf.checked_sub(2)?)?.strip_prefix('/')?; + let name = match toks.get(..tf)? { + [.., name, size] if !size.starts_with('/') => name, + [.., name] => name, + [] => return None, + }; + let name = name.strip_prefix('/')?; (!name.is_empty()).then(|| name.to_string()) } @@ -409,6 +416,59 @@ mod tests { assert_eq!(text_style(&dict("/Helv 9 Tf", Some("font-size: 1e300pt")), None).size, MAX_SIZE); } + /// `/DA` strings as files give them: a `Tf` without a size, a zero size (auto-size in a form + /// field, which a text box doesn't have), and hostile operands. None panics, each keeps what it + /// can read, and nothing that isn't a finite number reaches the style. + #[test] + fn hostile_da_strings_keep_what_they_can() { + let style = |da: &str| text_style(&dict(da, None), None); + let black12 = |face| TextStyle { face, size: 12.0, color: [0.0; 3] }; + for (da, want) in [ + // A missing size keeps the named face at the default size. + ("/TiRo Tf", black12(Std14::TimesRoman)), + ("0 g /Cour Tf", black12(Std14::Courier)), + // Zero, negative, NaN, infinite or not a number: the default size. + ("/Cour 0 Tf", black12(Std14::Courier)), + ("/Cour -0 Tf", black12(Std14::Courier)), + ("/Cour -12 Tf", black12(Std14::Courier)), + ("/Cour NaN Tf", black12(Std14::Courier)), + ("/Cour inf Tf", black12(Std14::Courier)), + ("/Cour -inf Tf", black12(Std14::Courier)), + ("/Cour 1e999 Tf", black12(Std14::Courier)), + ("/Cour twelve Tf", black12(Std14::Courier)), + // No Tf at all, or one with nothing before it. + ("1 0 0 rg", TextStyle { color: [1.0, 0.0, 0.0], ..black12(Std14::Helvetica) }), + ("Tf", black12(Std14::Helvetica)), + ("Tf Tf Tf", black12(Std14::Helvetica)), + ("", black12(Std14::Helvetica)), + // Colour operands that aren't finite numbers leave the colour black. + ("NaN NaN NaN rg /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }), + ("NaN g /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }), + ("inf 0 0 rg /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }), + ("rg g /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }), + // Out-of-range colour components are clamped. + ("2 -1 0.5 rg /Helv 9 Tf", TextStyle { size: 9.0, color: [1.0, 0.0, 0.5], ..black12(Std14::Helvetica) }), + // Huge sizes are capped. + ("/Helv 1e300 Tf", TextStyle { size: MAX_SIZE, ..black12(Std14::Helvetica) }), + ("/Helv 100000 Tf", TextStyle { size: MAX_SIZE, ..black12(Std14::Helvetica) }), + // Garbage around a valid Tf: the last Tf is read. + ("\u{0} ] [ << /Helv 9 Tf >> /Cour 14 Tf )", TextStyle { size: 14.0, ..black12(Std14::Courier) }), + ] { + assert_eq!(style(da), want, "{da:?}"); + let s = style(da); + assert!(s.size.is_finite() && s.size > 0.0 && s.color.iter().all(|c| (0.0..=1.0).contains(c)), "{da:?}: {s:?}"); + } + // A font name that is no standard face and that no `/DR` lists keeps Helvetica. + assert_eq!(style("0 g /F9 11 Tf"), TextStyle { size: 11.0, ..black12(Std14::Helvetica) }); + // Binary bytes that aren't UTF-8 don't panic. + let mut d = Dict::new(); + d.set(b"DA".to_vec(), PdfString::literal(vec![0xFF, 0xFE, b' ', b'/', 0xC3, b' ', b'9', b' ', b'T', b'f'])); + assert_eq!(text_style(&d, None).size, 9.0); + assert_eq!(da_font_name(&dict("/TiRo Tf", None)).as_deref(), Some("TiRo")); + assert_eq!(da_font_name(&dict("12 Tf", None)), None); + assert_eq!(da_font_name(&dict("0 g Tf", None)), None); + } + #[test] fn css_round_trips_through_the_parser() { for face in [Std14::Helvetica, Std14::TimesBold, Std14::CourierOblique, Std14::HelveticaBoldOblique, Std14::TimesBoldItalic] {