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)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
This commit is contained in:
mtlcl410
2026-11-01 19:05:11 +08:00
co-authored by Claude Opus 5.5
parent ff886283f8
commit 2c02ca3a56
3 changed files with 107 additions and 4 deletions
+6 -2
View File
@@ -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::<f64>().ok());
let num = |i: usize| toks.get(i).and_then(|t| t.parse::<f64>().ok()).filter(|v| v.is_finite());
for (i, t) in toks.iter().enumerate() {
match *t {
"rg" if i >= 3 => {
+39
View File
@@ -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}");
}
}
+62 -2
View File
@@ -65,13 +65,20 @@ pub fn dr_face(doc: &Document, d: &Dict) -> Option<Std14> {
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<String> {
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] {