Files
effectcraft/crates/pdf/src/tests_import.rs
T
Brandon ThomasandClaude Opus 5.5 e910858445 Never crash: PostScript radix numbers outside 2..=36
In EPS/PostScript, `0#7` asked i64::from_str_radix for radix 0, which panics (found by
fuzzing). Radixes outside 2..=36 are not numbers now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:21:14 -04:00

316 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! M13.12: CCITT images in PDF pages, EPS text and parser robustness over truncated and
//! corrupt inputs (generated fixtures).
use super::*;
use crate::tests::{coverage, page_pdf, render, shape_names};
fn eps(body: &[u8]) -> Doc {
let mut b = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n".to_vec();
b.extend_from_slice(body);
let doc = parse(&b).unwrap();
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
doc
}
/// The ink columns `(x0, x1)` of a render (alpha > 0.5).
fn ink_x(img: &effectcraft_raster::Image) -> (i64, i64) {
let (mut x0, mut x1) = (i64::MAX, i64::MIN);
for y in 0..img.height as i64 {
for x in 0..img.width as i64 {
if img.get(x, y)[3] > 0.5 {
(x0, x1) = (x0.min(x), x1.max(x));
}
}
}
(x0, x1)
}
#[test]
fn eps_text_with_bundled_fonts_and_show_variants() {
// Not embedded: drawn with the bundled stand-in, like the same text in a PDF.
let doc = eps(b"/Helvetica findfont 40 scalefont setfont 10 30 moveto (Hi) show\n");
assert_eq!(shape_names(&doc), vec!["Text: Hi"]);
let pdf = parse(&page_pdf("BT /F1 40 Tf 10 30 Td (Hi) Tj ET", "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>", vec![])).unwrap();
let (a, b) = (render(&doc), render(&pdf));
let (ia, ib) = (coverage(&a, 0, 0, 200, 100), coverage(&b, 0, 0, 200, 100));
assert!(ia > 100.0 && (ia - ib).abs() < 1.0, "eps {ia} vs pdf {ib}");
assert_eq!(ink_x(&a), ink_x(&b));
// ashow spreads the letters; widthshow widens spaces only; selectfont; stringwidth centres.
let plain = ink_x(&render(&eps(b"/Helvetica 30 selectfont 10 30 moveto (HHH) show\n")));
let spread = ink_x(&render(&eps(b"/Helvetica 30 selectfont 10 30 moveto 10 0 (HHH) ashow\n")));
assert_eq!(spread.0, plain.0);
assert!(((spread.1 - plain.1) - 20).abs() <= 1, "{plain:?} {spread:?}");
let ws = ink_x(&render(&eps(b"/Helvetica 30 selectfont 10 30 moveto 15 0 72 (HHH) widthshow\n")));
assert!(((ws.1 - plain.1) - 30).abs() <= 1, "{plain:?} {ws:?}");
let centred = ink_x(&render(&eps(b"/Helvetica 30 selectfont 100 (HHH) stringwidth pop 2 div sub 30 moveto (HHH) show\n")));
let mid = (centred.0 + centred.1) as f64 / 2.0;
assert!((mid - 100.0).abs() < 3.0, "{centred:?}");
// xshow: explicit advances.
let xs = ink_x(&render(&eps(b"/Helvetica 30 selectfont 10 30 moveto (HH) [50 0] xshow\n")));
let one = ink_x(&render(&eps(b"/Helvetica 30 selectfont 10 30 moveto (H) show\n")));
assert!(((xs.1 - one.1) - 50).abs() <= 1, "{one:?} {xs:?}");
// makefont mirrors; charpath clips; colour applies.
let doc = eps(b"1 0 0 setrgbcolor /Helvetica findfont [40 0 0 40 0 0] makefont setfont 10 30 moveto (I) false charpath clip 0 0 200 100 rectfill\n");
let img = render(&doc);
assert!(coverage(&img, 0, 0, 200, 100) > 50.0);
assert!(coverage(&img, 60, 0, 200, 100) < 1.0, "only the glyph is painted");
}
#[test]
fn eps_text_with_an_embedded_type1_font_and_reencoding() {
// hsbw 50 600; 0 0 rmoveto; 500 hlineto 500 vlineto -500 hlineto closepath endchar
let cs = vec![50 + 139, 248, 236, 13, 139, 139, 21, 248, 136, 6, 248, 136, 7, 252, 136, 6, 9, 14];
let raw = crate::type1::write_test_type1(&[(".notdef", vec![139, 139, 13, 14]), ("box", cs)], &[(66, "box")]);
// Give the program a name.
let mut font = vec![];
let nl = raw.iter().position(|c| *c == b'\n').unwrap() + 1;
font.extend_from_slice(&raw[..nl]);
font.extend_from_slice(b"12 dict begin /FontName /BoxFont def /FontType 1 def\n");
font.extend_from_slice(&raw[nl..]);
let mut body = font.clone();
body.extend_from_slice(b"\n/BoxFont findfont 100 scalefont setfont 0 0 moveto (BB) show\n");
let doc = eps(&body);
let img = render(&doc);
// Boxes 50..550 / 650..1150 font units at size 100: x 5..55 and 65..115, y 0..50.
for (x, on) in [(30, true), (60, false), (90, true), (120, false)] {
assert_eq!(img.get(x, 75)[3] > 0.99, on, "x = {x}");
}
// Re-encoded copy (`forall` / `definefont`): code 65 → box.
let mut body = font;
body.extend_from_slice(
b"\n/BoxFont findfont dup length dict begin { 1 index /FID ne { def } { pop pop } ifelse } forall \
/Encoding 256 array def 0 1 255 { Encoding exch /.notdef put } for Encoding 65 /box put currentdict end \
/BoxA exch definefont pop /BoxA findfont 100 scalefont setfont 0 0 moveto (AB) show\n",
);
let img = render(&eps(&body));
assert!(img.get(30, 75)[3] > 0.99, "A is the box now");
assert!(img.get(90, 75)[3] < 0.01, "B is unencoded");
}
/// An image XObject of `img` (black = true) CCITT-encoded with `k`, drawn over the page.
fn ccitt_page(img: &[Vec<bool>], k: i64, mask: bool) -> Doc {
let (w, h) = (img[0].len(), img.len());
let data = crate::ccitt::tests::encode(img, k);
let kind = if mask { "/ImageMask true" } else { "/ColorSpace /DeviceGray /BitsPerComponent 1" };
let bytes = page_pdf(
"0 0 1 rg q 200 0 0 100 0 0 cm /Im Do Q",
"/XObject << /Im 10 0 R >>",
vec![(
10,
format!(
"<< /Type /XObject /Subtype /Image /Width {w} /Height {h} {kind} /Filter /CCITTFaxDecode /DecodeParms << /K {k} /Columns {w} /Rows {h} /EndOfLine {} >> >>",
k >= 0
),
Some(data),
)],
);
let doc = parse(&bytes).unwrap();
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
doc
}
#[test]
fn ccitt_images_and_stencil_masks() {
// Left half black, a white bar on rows 4..6 (20×10 pixels → 10 PDF units per pixel).
let img: Vec<Vec<bool>> = (0..10).map(|y| (0..20).map(|x| x < 10 && !(4..6).contains(&y)).collect()).collect();
for k in [-1, 0, 2] {
let doc = ccitt_page(&img, k, false);
let r = render(&doc);
let px = |x: usize, y: usize| r.get(x as i64 * 10 + 5, y as i64 * 10 + 5);
assert!(px(2, 2)[0] < 0.01 && px(2, 2)[3] > 0.99, "K {k}: black {:?}", px(2, 2));
assert!(px(15, 2)[0] > 0.99, "K {k}: white {:?}", px(15, 2));
assert!(px(2, 4)[0] > 0.99, "K {k}: the white bar {:?}", px(2, 4));
// Stencil mask: black (sample 0) pixels paint the fill colour, the rest is clear.
let doc = ccitt_page(&img, k, true);
let r = render(&doc);
let px = |x: usize, y: usize| r.get(x as i64 * 10 + 5, y as i64 * 10 + 5);
assert!(px(2, 2)[2] > 0.99 && px(2, 2)[3] > 0.99, "K {k}: mask {:?}", px(2, 2));
assert_eq!(px(15, 2)[3], 0.0, "K {k}");
}
}
/// Byte-level mutations of `bytes`: truncations, flipped bytes, deleted and duplicated
/// spans (deterministic).
fn mutations(bytes: &[u8]) -> Vec<Vec<u8>> {
let mut out = vec![];
let n = bytes.len();
for k in 1..24 {
out.push(bytes[..n * k / 24].to_vec());
}
let mut s = 0x9E37_79B9u32;
let mut rnd = move || {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
s
};
for _ in 0..60 {
let mut b = bytes.to_vec();
for _ in 0..1 + rnd() % 6 {
let i = rnd() as usize % n;
b[i] = match rnd() % 4 {
0 => b[i] ^ 0xFF,
1 => b'0' + (rnd() % 10) as u8,
2 => b"[]<>()/{}% \n"[(rnd() % 12) as usize],
_ => rnd() as u8,
};
}
out.push(b);
}
for _ in 0..30 {
let mut b = bytes.to_vec();
let i = rnd() as usize % n;
let len = (rnd() as usize % 64).min(n - i);
if rnd() % 2 == 0 {
b.drain(i..i + len);
} else {
let span = b[i..i + len].to_vec();
b.splice(i..i, span);
}
out.push(b);
}
out
}
/// Parse and render every page of every mutation: errors are fine, panics are not.
fn survive(name: &str, bytes: &[u8]) {
for (i, m) in mutations(bytes).into_iter().enumerate() {
let r = std::panic::catch_unwind(|| {
let pages = page_count(&m);
for p in 0..pages.clamp(1, 3) {
if let Ok(doc) = parse_page(&m, p) {
let (w, h) = doc.pixel_size();
let s = 64.0 / (w.max(h).max(1) as f64);
let _ = effectcraft_svg::rasterize(&doc, 64, 64, s);
let _ = layer_names(&doc);
}
}
});
assert!(r.is_ok(), "{name}: mutation {i} panicked");
}
}
#[test]
fn truncated_and_corrupt_pdfs_never_panic() {
survive("sample", &crate::tests::sample_pdf(false));
survive("object streams", &crate::tests::sample_pdf(true));
let mut fonts = crate::tests::square_font_objs();
fonts.push((13, "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 >>".into(), Some(vec![255; 12])));
survive(
"text and images",
&page_pdf("BT /F1 40 Tf 10 10 Td (AAA) Tj ET q 50 0 0 50 0 0 cm /Im Do Q", "/Font << /F1 10 0 R >> /XObject << /Im 13 0 R >>", fonts),
);
let img: Vec<Vec<bool>> = (0..8).map(|y| (0..16).map(|x| (x + y) % 3 == 0).collect()).collect();
let data = crate::ccitt::tests::encode(&img, -1);
survive(
"ccitt",
&page_pdf(
"q 100 0 0 50 0 0 cm /Im Do Q",
"/XObject << /Im 10 0 R >>",
vec![(
10,
"<< /Type /XObject /Subtype /Image /Width 16 /Height 8 /ImageMask true /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 16 >> >>".into(),
Some(data),
)],
),
);
}
#[test]
fn hostile_structures_return_errors_or_draw_nothing() {
// Not a PDF / EPS at all, empty, and headers alone.
for b in [&b""[..], b"%PDF-", b"%PDF-1.7\n%%EOF", b"%!PS-Adobe-3.0 EPSF-3.0\n", b"\xC5\xD0\xD3\xC6", b"hello"] {
let _ = parse(b);
let _ = page_count(b);
}
assert_eq!(parse(b"hello"), Err(Error::NotVector));
assert_eq!(parse(b"%PDF-1.7\n%%EOF"), Err(Error::NoPages));
// A page tree that refers to itself, a content stream that is a reference loop, deep
// form recursion and absurd numbers.
let objs: crate::tests::Objs = vec![
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
(2, "<< /Type /Pages /Kids [2 0 R 3 0 R] /Count 1 >>".into(), None),
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 1e300 -1e300] /Contents 5 0 R /Resources << /XObject << /F 4 0 R >> >> >>".into(), None),
(
4,
"<< /Type /XObject /Subtype /Form /BBox [0 0 1 1] /Resources << /XObject << /F 4 0 R >> >> >>".into(),
Some(b"/F Do 1e308 1e308 m 1e308 0 l f".to_vec()),
),
(5, "<< >>".into(), Some(b"/F Do q q q q Q Q Q Q Q Q 99999999999 w [1 0 0 1 0 0] cm".to_vec())),
];
let bytes = crate::write::pdf(&objs, 1);
if let Ok(doc) = parse(&bytes) {
let _ = effectcraft_svg::rasterize(&doc, 32, 32, 1e-300);
}
// Images with absurd sizes and filters.
let bytes = page_pdf(
"/A Do /B Do /C Do /D Do",
"/XObject << /A 10 0 R /B 11 0 R /C 12 0 R /D 13 0 R >>",
vec![
(10, "<< /Subtype /Image /Width 100000 /Height 100000 /BitsPerComponent 8 /ColorSpace /DeviceRGB >>".into(), Some(vec![0; 4])),
(11, "<< /Subtype /Image /Width 4 /Height 4 /BitsPerComponent 7 /ColorSpace /DeviceRGB >>".into(), Some(vec![0; 4])),
(
12,
"<< /Subtype /Image /Width 4 /Height 4 /BitsPerComponent 1 /ImageMask true /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 0 >> >>"
.into(),
Some(vec![0xFF; 4]),
),
(13, "<< /Subtype /Image /Width 4 /Height 4 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 300 <00>] >>".into(), Some(vec![9; 16])),
],
);
let doc = parse(&bytes).unwrap();
let _ = render(&doc);
assert!(!doc.skipped.is_empty());
}
#[test]
fn truncated_and_corrupt_eps_never_panic() {
let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n\
/sq { newpath 0 0 moveto 10 0 lineto 10 10 lineto closepath } bind def\n\
1 0 0 setrgbcolor 0 1 9 { gsave dup 10 mul 0 translate sq fill grestore } for\n\
/Helvetica findfont 12 scalefont setfont 10 50 moveto (Hello) show\n\
[1 2] 0 setdash 0 0 moveto 100 100 lineto stroke\n\
1 1 1 { pop } repeat { exit } loop 0 0 50 0 360 arc closepath eofill\n%%EOF\n";
survive("eps", eps);
// Stack underflow, unbalanced procedures, runaway loops and huge repeats.
for prog in [
&b"pop pop pop add mul def"[..],
b"{ { { { {",
b"} } ] >> )",
b"{ } loop",
b"0 1 1e9 { pop } for",
b"1e9 { 1 } repeat",
b"/a { a } def a",
b"(unterminated",
b"<abc",
b"100 100 scale 0 0 moveto 1e308 1e308 lineto stroke",
] {
let mut b = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n".to_vec();
b.extend_from_slice(prog);
let r = std::panic::catch_unwind(|| {
if let Ok(doc) = parse(&b) {
let _ = effectcraft_svg::rasterize(&doc, 10, 10, 1.0);
}
});
assert!(r.is_ok(), "{}", String::from_utf8_lossy(prog));
}
}
#[test]
fn closing_an_empty_path_is_a_no_op() {
// "s", "b" and "b*" closed the path without a current point (kurbo asserts on that).
for ops in ["s", "b", "b*", "0 0 m 50 0 l 50 50 l b s b*", "f s 10 10 m 20 20 l s"] {
let doc = parse(&page_pdf(ops, "", vec![])).unwrap();
let _ = render(&doc);
}
}
#[test]
fn postscript_radix_numbers_outside_2_to_36_are_not_numbers() {
// `0#7` asked `i64::from_str_radix` for radix 0, which panics (found by fuzzing).
for n in ["0#7", "1#1", "37#1", "99999999999#1", "16#FF"] {
let src = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n{n} pop 0 0 moveto 10 10 lineto stroke\n");
let _ = parse(src.as_bytes());
}
}