mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 02:27:00 -06:00
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>
316 lines
14 KiB
Rust
316 lines
14 KiB
Rust
//! 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());
|
||
}
|
||
}
|