Files
effectcraft/crates/pdf/src/tests.rs
T

1023 lines
50 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.
//! Test files are generated here (no third-party samples).
use super::*;
use crate::write::pdf;
const CONTENT: &str = "/OC /MC0 BDC\n0 1 0 rg 0 0 200 100 re f\nEMC\n\
/OC /MC1 BDC\nq 10 10 80 80 re W n\n1 0 0 rg 0 0 50 100 re f\nQ\n\
/Pattern cs /P0 scn 100 0 100 50 re f\n\
0 0 1 RG 4 w 120 80 m 180 80 l S\n\
BT /F1 12 Tf (ignored) Tj ET\nEMC\n";
pub(crate) fn sample_pdf(object_stream: bool) -> Vec<u8> {
let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Properties << /MC0 5 0 R /MC1 6 0 R >> /Pattern << /P0 7 0 R >> >> /Contents 4 0 R >>";
let pattern = "<< /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [100 0 200 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> /Extend [true true] >> >>";
let mut objs = vec![
(1, "<< /Type /Catalog /Pages 2 0 R >>".to_string(), None),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), None),
(3, page.to_string(), None),
(4, "<< >>".to_string(), Some(CONTENT.as_bytes().to_vec())),
(7, pattern.to_string(), None),
];
let ocg5 = "<< /Type /OCG /Name (Background) >>";
let ocg6 = "<< /Type /OCG /Name <FEFF00410072007400> >>"; // "Art" in UTF-16BE
if object_stream {
let body = format!("{ocg5} {ocg6}");
let head = format!("5 0 6 {} ", ocg5.len() + 1);
let data = format!("{head}{body}");
objs.push((8, format!("<< /Type /ObjStm /N 2 /First {} >>", head.len()), Some(data.into_bytes())));
} else {
objs.push((5, ocg5.to_string(), None));
objs.push((6, ocg6.to_string(), None));
}
pdf(&objs, 1)
}
fn px(doc: &Doc, x: i64, y: i64) -> [f32; 4] {
let (w, h) = doc.pixel_size();
let img = effectcraft_svg::rasterize(doc, w, h, 1.0);
img.get(x, y)
}
#[test]
fn pdf_paths_clip_gradient_and_layers() {
for objstm in [false, true] {
let bytes = sample_pdf(objstm);
assert_eq!(sniff(&bytes), Some(Format::Pdf));
assert_eq!(page_count(&bytes), 1);
let doc = parse(&bytes).unwrap();
assert_eq!((doc.width, doc.height), (200.0, 100.0));
assert_eq!(layer_names(&doc), vec!["Background", "Art"], "object stream {objstm}");
assert!(doc.skipped.contains(&"text (no font)".to_string()), "{:?}", doc.skipped);
let (w, h) = doc.pixel_size();
let img = effectcraft_svg::rasterize(&doc, w, h, 1.0);
let at = |x: i64, y: i64| img.get(x, y);
// Red rectangle clipped to x ≥ 10, over the green background.
assert!(at(30, 50)[0] > 0.99 && at(30, 50)[1] < 0.01, "{:?}", at(30, 50));
assert!(at(5, 50)[1] > 0.99 && at(5, 50)[0] < 0.01, "clipped out: {:?}", at(5, 50));
assert!(at(70, 50)[1] > 0.99, "{:?}", at(70, 50));
// Axial shading, red → blue left to right (in the lower half: PDF y is up).
let (l, r) = (at(110, 75), at(190, 75));
assert!(l[0] > 0.8 && l[2] < 0.2 && r[2] > 0.8 && r[0] < 0.2, "{l:?} {r:?}");
assert!(at(150, 25)[1] > 0.99, "the gradient stays in its rectangle: {:?}", at(150, 25));
// Stroked line (PDF y = 80 → 20 px from the top).
assert!(at(150, 20)[2] > 0.99 && at(150, 20)[1] < 0.01, "{:?}", at(150, 20));
// One layer at a time.
let bg = layer_doc(&doc, 0);
assert!(px(&bg, 30, 50)[1] > 0.99);
let art = layer_doc(&doc, 1);
assert_eq!(px(&art, 5, 50)[3], 0.0);
// Continuous rasterisation: twice the size stays sharp at the clip edge.
let big = effectcraft_svg::rasterize(&doc, 400, 200, 2.0);
assert!(big.get(21, 100)[0] > 0.99 && big.get(18, 100)[1] > 0.99);
}
}
#[test]
fn pdf_forms_rotation_cmyk_and_alpha() {
let content = "q 2 0 0 2 0 0 cm /Fm0 Do Q\n/GS0 gs 0 0 0 1 k 0 0 10 10 re f\n";
let form = "0 1 1 0 k 0 0 20 20 re f";
let objs = vec![
(1, "<< /Type /Catalog /Pages 2 0 R >>".to_string(), None),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 /Rotate 90 >>".to_string(), None),
(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Resources << /XObject << /Fm0 5 0 R >> /ExtGState << /GS0 << /ca 0.5 >> >> >> /Contents 4 0 R >>"
.to_string(),
None,
),
(4, "<< >>".to_string(), Some(content.as_bytes().to_vec())),
(5, "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] >>".to_string(), Some(form.as_bytes().to_vec())),
];
let doc = parse(&pdf(&objs, 1)).unwrap();
// Rotated a quarter turn: 50 × 100.
assert_eq!((doc.width, doc.height), (50.0, 100.0));
let (w, h) = doc.pixel_size();
let img = effectcraft_svg::rasterize(&doc, w, h, 1.0);
// The form (CMYK red, clipped to its 10×10 box, scaled ×2) covers PDF (0..20, 0..20); the
// half-transparent black square covers PDF (0..10, 0..10). /Rotate 90: PDF (x, y) → (y, x).
let red = img.get(15, 15);
assert!(red[0] > 0.99 && red[1] < 0.01 && red[3] > 0.99, "{red:?}");
let dark = img.get(5, 5);
assert!((dark[0] - 0.5).abs() < 0.02 && dark[3] > 0.99, "{dark:?}");
assert_eq!(img.get(30, 30)[3], 0.0, "form clipped to its bounding box");
}
const EPS: &str = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n\
/m {moveto} bind def /l {lineto} bind def\n\
/cm { 6 array astore concat } bind def\n\
/box { 4 dict begin /h exch def /w exch def /y exch def /x exch def x y m w 0 rlineto 0 h rlineto w neg 0 rlineto closepath end } bind def\n\
1 0 0 setrgbcolor\n10 10 30 30 box fill\n\
gsave 0 0 1 setrgbcolor 70 50 20 0 360 arc fill grestore\n\
gsave 1 0 0 1 50 0 cm 0 1 0 setrgbcolor 0 0 10 10 box fill grestore\n\
0 setgray 2 setlinewidth 0 90 m 100 90 l stroke\n\
/Helvetica findfont 12 scalefont setfont 5 5 moveto (text) show\n\
showpage\n%%EOF\n";
#[test]
fn eps_postscript_subset() {
let doc = parse(EPS.as_bytes()).unwrap();
assert_eq!((doc.width, doc.height), (100.0, 100.0));
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
assert!(shape_names(&doc).contains(&"Text: text".to_string()), "EPS text is drawn: {:?}", shape_names(&doc));
let at = |x, y| px(&doc, x, y);
assert!(at(25, 75)[0] > 0.99 && at(25, 75)[3] > 0.99, "red box {:?}", at(25, 75));
assert!(at(70, 50)[2] > 0.99, "blue disc {:?}", at(70, 50));
assert!(at(55, 95)[1] > 0.99, "translated green box {:?}", at(55, 95));
let line = at(50, 10);
assert!(line[3] > 0.99 && line[0] < 0.01, "black line {line:?}");
assert_eq!(at(95, 70)[3], 0.0);
// DOS EPS binary header (with a fake TIFF preview after the PostScript).
let ps = EPS.as_bytes();
let mut dos = vec![0xC5, 0xD0, 0xD3, 0xC6];
dos.extend_from_slice(&30u32.to_le_bytes());
dos.extend_from_slice(&(ps.len() as u32).to_le_bytes());
dos.extend_from_slice(&[0; 18]);
dos.extend_from_slice(ps);
dos.extend_from_slice(b"II*\0 not a real preview");
let d2 = parse(&dos).unwrap();
assert_eq!(d2.root.children.len(), doc.root.children.len());
assert_eq!(codec("art.eps", &dos), Some("EPS"));
assert_eq!(codec("art.ai", &sample_pdf(false)), Some("AI"));
assert_eq!(parse(b"hello"), Err(Error::NotVector));
}
#[test]
fn filters_decode() {
assert_eq!(object::ascii85(b"<~87cURD]i,\"Ebo80~>"), b"Hello World!".to_vec());
let z = miniz_oxide::deflate::compress_to_vec_zlib(b"abc", 6);
assert_eq!(object::inflate(&z, 1 << 20).unwrap(), b"abc");
}
#[test]
fn flate_bomb_is_bounded() {
// A decompression bomb in miniature: 16 MiB of zeros deflate to about 16 KB.
let zeros = vec![0u8; 16 << 20];
for bomb in [miniz_oxide::deflate::compress_to_vec_zlib(&zeros, 6), miniz_oxide::deflate::compress_to_vec(&zeros, 6)] {
assert!(bomb.len() < 64 << 10);
// Past the limit, as zlib and as raw deflate, it is undecodable: not inflated in full
// and not salvaged as a truncated stream.
assert_eq!(object::inflate(&bomb, 1 << 20).map(|v| v.len()), None);
assert_eq!(object::inflate(&bomb, 32 << 20).map(|v| v.len()), Some(16 << 20));
}
}
#[test]
fn truncated_flate_keeps_what_inflates() {
let mut x = 1u32;
let data: Vec<u8> = (0..20_000)
.map(|_| {
x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345);
(x >> 16) as u8
})
.collect();
let z = miniz_oxide::deflate::compress_to_vec_zlib(&data, 6);
let part = object::inflate(&z[..z.len() / 2], 1 << 20).unwrap();
assert!(!part.is_empty() && part.len() < data.len());
assert_eq!(part, data[..part.len()]);
}
#[test]
fn salvage_buffer_is_bounded_by_the_limit() {
// Small inputs keep eight times their size and 1 KiB.
assert_eq!(object::salvage_len(0, 1 << 20), 1024);
assert_eq!(object::salvage_len(10_000, 1 << 20), 81_024);
// Large ones stop at the limit (`1 << 28` fits a 32-bit `usize` too), without overflowing.
assert_eq!(object::salvage_len(1 << 28, 1 << 20), 1 << 20);
assert_eq!(object::salvage_len(usize::MAX, 1 << 20), 1 << 20);
}
#[test]
fn nested_bomb_in_the_second_flate_filter_is_refused() {
// `/Filter [/FlateDecode /FlateDecode]`: the first filter inflates, within the limit, to a zlib
// bomb (padded to just under the limit) that the second would inflate to 16 MiB. The second
// stops at the limit, so the stream is undecodable.
const MAX: usize = 1 << 20;
let flate = object::Obj::Name("FlateDecode".into());
let chain: object::Dict = [("Filter".to_string(), object::Obj::Array(vec![flate.clone(), flate]))].into_iter().collect();
let file = object::File::parse(b"");
let mut nested = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 16 << 20], 6);
nested.resize(MAX - 1, 0);
let raw = miniz_oxide::deflate::compress_to_vec_zlib(&nested, 6);
assert_eq!(object::inflate(&raw, MAX).map(|v| v.len()), Some(MAX - 1));
assert_eq!(object::decode_stream_within(&file, &chain, &raw, MAX).map(|v| v.len()), None);
}
fn bounded_filter(dict: &str, raw: &[u8], max: usize) -> Option<Vec<u8>> {
let object::Obj::Dict(d) = object::Lexer::new(dict.as_bytes(), 0).next().unwrap() else { panic!("not a dictionary") };
object::decode_stream_within(&object::File::parse(b""), &d, raw, max)
}
#[test]
fn predictor_huge_columns_decode_to_nothing() {
// A row of a petabyte used to be allocated before looking at the data (an allocation
// failure aborts the process, so this test took the whole test binary down).
let raw = miniz_oxide::deflate::compress_to_vec_zlib(&[0, 42], 6);
let dict = "<< /Filter /FlateDecode /DecodeParms << /Predictor 12 /Columns 1000000000000000 >> >>";
assert_eq!(bounded_filter(dict, &raw, 16), Some(vec![]));
}
#[test]
fn predictor_overflow_and_empty_rows_decode_to_nothing() {
let raw = miniz_oxide::deflate::compress_to_vec_zlib(&[0, 42], 6);
for parms in [
format!("/Columns {} /Colors 2 /BitsPerComponent 8", usize::MAX),
format!("/Columns 1 /Colors {} /BitsPerComponent 8", usize::MAX),
"/Columns 0".into(),
// A row is two bytes plus its tag byte: longer than the two bytes of data.
"/Columns 2".into(),
] {
assert_eq!(bounded_filter(&format!("<< /Filter /FlateDecode /DecodeParms << /Predictor 12 {parms} >> >>"), &raw, 16), Some(vec![]));
}
}
#[test]
fn run_length_chain_is_bounded() {
// Each stage expands repetitions of 0x81, which is itself an RL repeat code.
let raw = [0x81, 0x81, 128];
let chain = "<< /Filter [/RunLengthDecode /RL /RunLengthDecode] >>";
assert_eq!(bounded_filter(chain, &raw, 128).map(|v| v.len()), None);
assert_eq!(bounded_filter("<< /Filter /RL >>", &raw, 128), Some(vec![0x81; 128]));
// An over-limit intermediate stage must fail even if the next stage shrinks it.
assert_eq!(bounded_filter("<< /Filter [/RL /AHx] >>", &raw, 127), None);
}
fn lzw_codes(codes: impl IntoIterator<Item = u16>, early: bool) -> Vec<u8> {
let mut w = crate::ccitt::tests::W::default();
let (mut bits, mut entries, mut prev) = (9, 258, false);
for code in codes {
w.put(code, bits);
match code {
256 => (bits, entries, prev) = (9, 258, false),
257 => break,
_ => {
if prev && entries < 4096 {
entries += 1;
}
prev = true;
if entries + usize::from(early) >= (1 << bits) && bits < 12 {
bits += 1;
}
}
}
}
w.bytes
}
#[test]
fn lzw_long_run_is_bounded_and_full_table_decodes() {
// Grow strings of A until the 12-bit table is full, then send literal A codes.
// Both old and new implementations use well under 100 MB for this fixture.
for early in [false, true] {
let raw = lzw_codes([256, 65].into_iter().chain(258..=4095).chain(std::iter::repeat_n(65, 62_500)).chain([257]), early);
assert!((95_000..105_000).contains(&raw.len()));
let dict = format!("<< /Filter /LZWDecode /DecodeParms << /EarlyChange {} >> >>", usize::from(early));
assert_eq!(bounded_filter(&dict, &raw, 1 << 20).map(|v| v.len()), None);
let expected = 3839 * 3840 / 2 + 62_500;
assert_eq!(bounded_filter(&dict, &raw, expected), Some(vec![b'A'; expected]));
// Clear restores the initial dictionary and code width after saturation.
let cleared = lzw_codes([256, 65].into_iter().chain(258..=4095).chain([256, 66, 257]), early);
let mut expected = vec![b'A'; 3839 * 3840 / 2];
expected.push(b'B');
assert_eq!(bounded_filter(&dict, &cleared, expected.len()), Some(expected));
}
}
#[test]
fn ccitt_declared_and_unknown_rows_are_bounded() {
// Eight white Group 4 rows. The old decoder pads declared missing rows to white.
assert_eq!(bounded_filter("<< /Filter /CCF /DecodeParms << /K -1 /Columns 64 /Rows 1024 >> >>", &[0xFF], 16), None);
assert_eq!(bounded_filter("<< /Filter /CCF /DecodeParms << /K -1 /Columns 64 >> >>", &[0xFF], 16), None);
assert_eq!(bounded_filter("<< /Filter /CCF /DecodeParms << /K -1 /Columns 8 /Rows 8 >> >>", &[0xFF], 8), Some(vec![0xFF; 8]));
// Padding below the limit remains unchanged.
assert_eq!(bounded_filter("<< /Filter /CCF /DecodeParms << /K -1 /Columns 8 /Rows 16 >> >>", &[0xFF], 16), Some(vec![0xFF; 16]));
// Without `/Rows` the size is never computed up front: 2^20 rows of 4096 bytes overflow a
// 32-bit `usize`, but eight rows of this width decode on every target.
assert_eq!(bounded_filter("<< /Filter /CCF /DecodeParms << /K -1 /Columns 32768 >> >>", &[0xFF], 32768), Some(vec![0xFF; 32768]));
}
#[test]
fn ascii85_and_hex_outputs_are_bounded() {
assert_eq!(bounded_filter("<< /Filter /A85 >>", b"zz", 7), None);
assert_eq!(bounded_filter("<< /Filter /A85 >>", b"zz", 8), Some(vec![0; 8]));
assert_eq!(bounded_filter("<< /Filter /A85 >>", b"87cURD]i,\"Ebo80", 12), Some(b"Hello World!".to_vec()));
assert_eq!(bounded_filter("<< /Filter /A85 >>", b"87cURD]i,\"Ebo80", 11), None);
assert_eq!(bounded_filter("<< /Filter /AHx >>", b"4142434>", 3), None);
assert_eq!(bounded_filter("<< /Filter /AHx >>", b"4142434>", 4), Some(b"ABC@".to_vec()));
}
#[test]
fn predictors_and_normal_lzw_decode_within_limit() {
for predictor in [1, 2, 12] {
let (data, expected) = if predictor == 12 { (vec![0, 10, 20, 2, 1, 2], vec![10, 20, 11, 22]) } else { (vec![10, 20], vec![10, 20]) };
let raw = miniz_oxide::deflate::compress_to_vec_zlib(&data, 6);
let dict = format!("<< /Filter /FlateDecode /DecodeParms << /Predictor {predictor} /Columns 2 >> >>");
assert_eq!(bounded_filter(&dict, &raw, data.len()), Some(expected));
}
for early in [false, true] {
let raw = lzw_codes([256, 65, 66, 258, 257], early);
assert_eq!(bounded_filter("<< /Filter /LZW >>", &raw, 4), Some(b"ABAB".to_vec()));
assert_eq!(bounded_filter("<< /Filter /LZW >>", &raw, 3), None);
}
assert_eq!(bounded_filter("<< /Filter /RL >>", &[2, b'a', b'b', b'c', 254, b'd', 128], 6), Some(b"abcddd".to_vec()));
assert_eq!(bounded_filter("<< /Filter /RL >>", &[2, b'a', b'b', b'c', 128], 2), None);
assert_eq!(bounded_filter("<< /Filter /RL >>", &[128], 0), Some(vec![]));
}
// ------------------------------------------------------------------ M13.6: text, images,
// patterns, soft masks, blend modes, pages
pub(crate) type Objs = Vec<(u32, String, Option<Vec<u8>>)>;
/// A one-page 200×100 PDF: `resources` is the page's resource dictionary body, `extra` more
/// objects (numbered from 10).
pub(crate) fn page_pdf(content: &str, resources: &str, extra: Objs) -> Vec<u8> {
let mut objs: Objs = vec![
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
(3, format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << {resources} >> /Contents 4 0 R >>"), None),
(4, "<< >>".into(), Some(content.as_bytes().to_vec())),
];
objs.extend(extra);
pdf(&objs, 1)
}
pub(crate) fn render(doc: &Doc) -> effectcraft_raster::Image {
let (w, h) = doc.pixel_size();
effectcraft_svg::rasterize(doc, w, h, 1.0)
}
pub(crate) fn coverage(img: &effectcraft_raster::Image, x0: i64, y0: i64, x1: i64, y1: i64) -> f32 {
let mut s = 0.0;
for y in y0..y1 {
for x in x0..x1 {
s += img.get(x, y)[3];
}
}
s
}
pub(crate) fn shape_names(doc: &Doc) -> Vec<String> {
fn walk(g: &effectcraft_svg::Group, out: &mut Vec<String>) {
for c in &g.children {
match c {
Node::Group(s) => walk(s, out),
Node::Shape(s) => out.push(s.name.clone()),
Node::Image(i) => out.push(i.name.clone()),
}
}
}
let mut out = vec![];
walk(&doc.root, &mut out);
out
}
/// Type 2 charstring operands.
fn t2(v: &[i32]) -> Vec<u8> {
let mut o = vec![];
for &x in v {
if (-107..=107).contains(&x) {
o.push((x + 139) as u8);
} else {
o.push(28);
o.extend_from_slice(&(x as i16).to_be_bytes());
}
}
o
}
/// A CFF font with a 500-unit square glyph `square` (x 50..550, y 0..500, width 600).
fn square_cff() -> Vec<u8> {
let mut sq = t2(&[600, 50, 0]);
sq.push(21);
sq.extend(t2(&[500, 500, -500]));
sq.push(6);
sq.push(14);
crate::cff::write_test_cff(&[(".notdef", vec![14]), ("square", sq)])
}
const SQUARE_FONT: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Square /FirstChar 65 /LastChar 65 /Widths [600] /Encoding << /Differences [65 /square] >> /FontDescriptor 11 0 R >>";
pub(crate) fn square_font_objs() -> Objs {
vec![
(10, SQUARE_FONT.into(), None),
(11, "<< /Type /FontDescriptor /FontName /Square /Flags 32 /FontFile3 12 0 R >>".into(), None),
(12, "<< /Subtype /Type1C >>".into(), Some(square_cff())),
]
}
#[test]
fn text_with_an_embedded_cff_font_spacing_and_tj() {
// Squares 10 pt per 100 units: A at 0..60 (square 5..55), Tc 10 and a TJ kern of −400
// (+40) put the second at 110 (square 115..165).
let bytes = page_pdf("BT /F1 100 Tf 0 0 Td 10 Tc [(A) -400 (A)] TJ ET", "/Font << /F1 10 0 R >>", square_font_objs());
let doc = parse(&bytes).unwrap();
assert!(!doc.skipped.iter().any(|s| s.contains("text")), "{:?}", doc.skipped);
assert!(shape_names(&doc).iter().any(|n| n.starts_with("Text")), "{:?}", shape_names(&doc));
let img = render(&doc);
for (x, on) in [(30, true), (60, false), (105, false), (140, true), (170, false)] {
assert_eq!(img.get(x, 75)[3] > 0.99, on, "x = {x}: {:?}", img.get(x, 75));
}
// Squares are 50 pt tall: rows 50..100.
assert_eq!(img.get(30, 45)[3], 0.0);
}
#[test]
fn text_render_mode_clip() {
let bytes = page_pdf("BT /F1 100 Tf 7 Tr 0 0 Td (A) Tj ET 1 0 0 rg 0 0 200 100 re f", "/Font << /F1 10 0 R >>", square_font_objs());
let img = render(&parse(&bytes).unwrap());
assert!(img.get(30, 75)[0] > 0.99 && img.get(30, 75)[3] > 0.99);
assert_eq!(img.get(100, 75)[3], 0.0, "clipped to the glyph");
assert_eq!(img.get(30, 25)[3], 0.0);
}
#[test]
fn text_with_an_embedded_type1_font() {
// 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 font = crate::type1::write_test_type1(&[(".notdef", vec![139, 139, 13, 14]), ("box", cs)], &[(66, "box")]);
let extra: Objs = vec![
(10, "<< /Type /Font /Subtype /Type1 /BaseFont /Box /FirstChar 66 /LastChar 66 /Widths [600] /FontDescriptor 11 0 R >>".into(), None),
(11, "<< /Type /FontDescriptor /FontName /Box /Flags 4 /FontFile 12 0 R >>".into(), None),
(12, "<< /Length1 0 /Length2 0 /Length3 0 >>".into(), Some(font)),
];
// The font's built-in encoding maps B (66) to `box`.
let img = render(&parse(&page_pdf("BT /F1 100 Tf 0 0 Td (BB) Tj ET", "/Font << /F1 10 0 R >>", extra)).unwrap());
for (x, on) in [(30, true), (60, false), (90, true), (120, false)] {
assert_eq!(img.get(x, 75)[3] > 0.99, on, "x = {x}");
}
}
#[test]
fn text_with_a_type3_font() {
let extra: Objs = vec![
(
10,
"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 100 100] /FontMatrix [0.01 0 0 0.01 0 0] /CharProcs << /sq 11 0 R >> /Encoding << /Type /Encoding /Differences [65 /sq] >> /FirstChar 65 /LastChar 65 /Widths [100] >>".into(),
None,
),
(11, "<< >>".into(), Some(b"100 0 d0 0 0 100 100 re f".to_vec())),
];
let img = render(&parse(&page_pdf("BT 0 0 1 rg /F1 20 Tf 10 10 Td (AA) Tj ET", "/Font << /F1 10 0 R >>", extra)).unwrap());
// 20 pt squares from x = 10, advancing 20: user 10..30 and 30..50, y 10..30 (rows 70..90).
assert!(img.get(20, 80)[2] > 0.99 && img.get(20, 80)[3] > 0.99, "{:?}", img.get(20, 80));
assert!(img.get(40, 80)[3] > 0.99);
assert_eq!(img.get(55, 80)[3], 0.0);
assert_eq!(img.get(20, 60)[3], 0.0);
}
#[test]
fn text_with_standard_fonts_embedded_truetype_and_cid_fonts() {
use skrifa::MetadataProvider;
let inter = effectcraft_text::fonts::INTER_REGULAR.to_vec();
// Not embedded: Helvetica drawn with the bundled sans serif.
let std14 = page_pdf("BT /F1 60 Tf 10 30 Td (Hi) Tj ET", "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>", vec![]);
let doc = parse(&std14).unwrap();
assert!(shape_names(&doc).contains(&"Text: Hi".to_string()), "{:?}", shape_names(&doc));
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
let a = render(&doc);
let ink = coverage(&a, 0, 0, 200, 100);
assert!(ink > 300.0, "{ink}");
// Ink only on the text line: baseline at row 70, cap height ≈ 44 px above it.
assert!(coverage(&a, 0, 72, 200, 100) < 1.0 && coverage(&a, 0, 0, 200, 20) < 1.0);
// The same face embedded (a TrueType font program, WinAnsiEncoding): the same outlines.
let tt = page_pdf(
"BT /F1 60 Tf 10 30 Td (Hi) Tj ET",
"/Font << /F1 10 0 R >>",
vec![
(10, "<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+Inter /Encoding /WinAnsiEncoding /FontDescriptor 11 0 R >>".into(), None),
(11, "<< /Type /FontDescriptor /Flags 32 /FontFile2 12 0 R >>".into(), None),
(12, "<< >>".into(), Some(inter.clone())),
],
);
let b = render(&parse(&tt).unwrap());
assert!((coverage(&b, 0, 0, 200, 100) - ink).abs() < 1.0, "{} vs {ink}", coverage(&b, 0, 0, 200, 100));
// A composite font: Identity-H codes are glyph ids of a CIDFontType2.
let font = skrifa::FontRef::new(&inter).unwrap();
let gid = |c: char| font.charmap().map(c).unwrap().to_u32() as u16;
let (h, i) = (gid('H'), gid('i'));
let loc = skrifa::instance::LocationRef::default();
let upem = font.metrics(skrifa::instance::Size::unscaled(), loc).units_per_em as f32;
let w = |g: u16| font.glyph_metrics(skrifa::instance::Size::unscaled(), loc).advance_width(skrifa::GlyphId::new(g as u32)).unwrap() / upem * 1000.0;
let cid = page_pdf(
&format!("BT /F1 60 Tf 10 30 Td <{h:04X}{i:04X}> Tj ET"),
"/Font << /F1 10 0 R >>",
vec![
(10, "<< /Type /Font /Subtype /Type0 /BaseFont /Inter /Encoding /Identity-H /DescendantFonts [13 0 R] >>".into(), None),
(
13,
format!(
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Inter /CIDToGIDMap /Identity /W [{h} [{}] {i} [{}]] /FontDescriptor 11 0 R >>",
w(h),
w(i)
),
None,
),
(11, "<< /Type /FontDescriptor /Flags 32 /FontFile2 12 0 R >>".into(), None),
(12, "<< >>".into(), Some(inter)),
],
);
let c = render(&parse(&cid).unwrap());
assert!((coverage(&c, 0, 0, 200, 100) - ink).abs() < 1.0, "{} vs {ink}", coverage(&c, 0, 0, 200, 100));
}
#[test]
fn images_flate_smask_indexed_stencil_inline_and_dct() {
let objs = |im: &str, data: Vec<u8>, extra: Objs| -> Vec<u8> {
let mut e: Objs = vec![(10, im.to_string(), Some(data))];
e.extend(extra);
page_pdf("q 100 0 0 50 0 0 cm /Im1 Do Q", "/XObject << /Im1 10 0 R >>", e)
};
// RGB with a soft mask: red opaque, green transparent.
let doc = parse(&objs(
"<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 11 0 R >>",
vec![255, 0, 0, 0, 255, 0],
vec![(11, "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 >>".into(), Some(vec![255, 0]))],
))
.unwrap();
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
let img = render(&doc);
assert!(img.get(25, 75)[0] > 0.95 && img.get(25, 75)[3] > 0.95, "{:?}", img.get(25, 75));
assert!(img.get(75, 75)[3] < 0.05, "{:?}", img.get(75, 75));
assert_eq!(img.get(25, 25)[3], 0.0, "the image is the unit square of user space");
// Indexed (palette in a string), 8 bits.
let img = render(
&parse(&objs(
"<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace [/Indexed /DeviceRGB 1 <0000FFFFFF00>] /BitsPerComponent 8 >>",
vec![1, 0],
vec![],
))
.unwrap(),
);
let (l, r) = (img.get(25, 75), img.get(75, 75));
assert!(l[0] > 0.95 && l[1] > 0.95 && l[2] < 0.05 && r[2] > 0.95 && r[0] < 0.05, "{l:?} {r:?}");
// ICCBased with an /Alternate space, 1-bit samples.
let img = render(
&parse(&objs(
"<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace [/ICCBased 11 0 R] /BitsPerComponent 1 >>",
vec![0b0100_0000],
vec![(11, "<< /N 1 /Alternate /DeviceGray >>".into(), Some(vec![0; 16]))],
))
.unwrap(),
);
assert!(img.get(25, 75)[0] < 0.05 && img.get(75, 75)[0] > 0.95);
// Stencil mask painted in the fill colour (sample 0 paints).
let stencil = page_pdf(
"0 1 0 rg q 100 0 0 50 0 0 cm /Im1 Do Q",
"/XObject << /Im1 10 0 R >>",
vec![(10, "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ImageMask true >>".into(), Some(vec![0b0100_0000]))],
);
let img = render(&parse(&stencil).unwrap());
assert!(img.get(25, 75)[1] > 0.95 && img.get(25, 75)[3] > 0.95);
assert!(img.get(75, 75)[3] < 0.05);
// Inline image (abbreviated keys, ASCIIHex) in the top half.
let inline = page_pdf("q 100 0 0 50 0 50 cm BI /W 2 /H 1 /CS /RGB /BPC 8 /F /AHx ID 00FF00FF0000> EI Q 0 0 1 rg 150 0 50 50 re f", "", vec![]);
let doc = parse(&inline).unwrap();
let img = render(&doc);
assert!(img.get(25, 25)[1] > 0.95 && img.get(75, 25)[0] > 0.95, "{:?} {:?}", img.get(25, 25), img.get(75, 25));
assert!(img.get(175, 75)[2] > 0.95, "content after EI still runs");
// DCT (JPEG): left half red, right half blue.
let mut jpg = vec![];
let mut rgb = ::image::RgbImage::new(16, 16);
for (x, _, p) in rgb.enumerate_pixels_mut() {
*p = if x < 8 { ::image::Rgb([255, 0, 0]) } else { ::image::Rgb([0, 0, 255]) };
}
::image::DynamicImage::ImageRgb8(rgb).write_to(&mut std::io::Cursor::new(&mut jpg), ::image::ImageFormat::Jpeg).unwrap();
let doc =
parse(&objs("<< /Type /XObject /Subtype /Image /Width 16 /Height 16 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode >>", jpg, vec![]))
.unwrap();
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
let img = render(&doc);
let (l, r) = (img.get(20, 75), img.get(80, 75));
assert!(l[0] > 0.85 && l[2] < 0.15 && r[2] > 0.85 && r[0] < 0.15, "{l:?} {r:?}");
}
#[test]
fn soft_masks_and_blend_modes() {
// Luminosity: a form that is white on the left half, transparent (→ black backdrop) elsewhere.
let lum = page_pdf(
"q /GS1 gs 1 0 0 rg 0 0 200 100 re f Q 0 0 1 rg 0 0 20 20 re f",
"/ExtGState << /GS1 << /SMask << /S /Luminosity /G 10 0 R >> >> >>",
vec![(
10,
"<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] /Group << /S /Transparency /CS /DeviceGray >> >>".into(),
Some(b"1 g 0 0 100 100 re f".to_vec()),
)],
);
let doc = parse(&lum).unwrap();
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
let img = render(&doc);
assert!(img.get(50, 50)[0] > 0.99 && img.get(50, 50)[3] > 0.99, "{:?}", img.get(50, 50));
assert!(img.get(150, 50)[3] < 0.01, "{:?}", img.get(150, 50));
assert!(img.get(10, 90)[2] > 0.99, "the mask ends with Q");
// Alpha: the mask form's coverage.
let alpha = page_pdf(
"/GS1 gs 1 0 0 rg 0 0 200 100 re f",
"/ExtGState << /GS1 << /SMask << /S /Alpha /G 10 0 R >> >> >>",
vec![(10, "<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] >>".into(), Some(b"0 0 0 rg 100 0 100 100 re f".to_vec()))],
);
let img = render(&parse(&alpha).unwrap());
assert!(img.get(50, 50)[3] < 0.01 && img.get(150, 50)[0] > 0.99);
// Multiply over a light blue backdrop; Screen in the right half.
let blend = page_pdf(
"0.5 0.5 1 rg 0 0 200 100 re f q /M gs 1 1 0 rg 0 0 100 100 re f Q q /S gs 0.5 0 0 rg 100 0 100 100 re f Q",
"/ExtGState << /M << /BM /Multiply >> /S << /BM [/Screen] >> >>",
vec![],
);
let img = render(&parse(&blend).unwrap());
let m = img.get(50, 50);
assert!((m[0] - 0.5).abs() < 0.01 && (m[1] - 0.5).abs() < 0.01 && m[2] < 0.01, "multiply {m:?}");
let s = img.get(150, 50);
assert!((s[0] - 0.75).abs() < 0.01 && (s[1] - 0.5).abs() < 0.01 && (s[2] - 1.0).abs() < 0.01, "screen {s:?}");
}
#[test]
fn tiling_patterns_coloured_and_uncoloured() {
let cell = b"1 0 0 rg 0 0 10 10 re f 0 0 1 rg 10 10 10 10 re f".to_vec();
let bytes = page_pdf(
"/Pattern cs /P1 scn 0 0 100 100 re f /Cs1 cs 0 1 0 /P2 scn 100 0 100 100 re f",
"/Pattern << /P1 10 0 R /P2 11 0 R >> /ColorSpace << /Cs1 [/Pattern /DeviceRGB] >>",
vec![
(10, "<< /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 /Resources << >> >>".into(), Some(cell)),
(
11,
"<< /Type /Pattern /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 >>".into(),
Some(b"1 0 0 rg 0 0 10 10 re f".to_vec()),
),
],
);
let doc = parse(&bytes).unwrap();
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
let img = render(&doc);
// PDF (x, y) → pixel (x, 100 − y).
let at = |x: i64, y: i64| img.get(x, 100 - y);
assert!(at(5, 5)[0] > 0.99 && at(25, 5)[0] > 0.99 && at(85, 45)[0] > 0.99, "{:?}", at(5, 5));
assert!(at(15, 15)[2] > 0.99 && at(55, 35)[2] > 0.99);
assert_eq!(at(15, 5)[3], 0.0);
// Uncoloured: the cell in the colour given with the pattern (green), its own colour ignored.
assert!(at(105, 5)[1] > 0.99 && at(105, 5)[0] < 0.01, "{:?}", at(105, 5));
assert_eq!(at(115, 15)[3], 0.0);
}
/// Object 1 of a file holding just `objs`, read as a function.
fn read_function(objs: Objs) -> color::Func {
color::Func::read(&object::File::parse(&pdf(&objs, 1)), &object::Obj::Ref(1, 0))
}
/// A sampled (type 0) function, read from a file holding just its stream.
fn read_sampled_function(dict: &str, data: Vec<u8>) -> color::Func {
read_function(vec![(1, dict.into(), Some(data))])
}
/// Object 1 of a file holding just `objs`, read as a colour space.
fn read_color_space(objs: Objs) -> color::Cs {
color::color_space(&object::File::parse(&pdf(&objs, 1)), &object::Obj::Ref(1, 0), None)
}
#[test]
fn sampled_function_over_the_sample_cap_is_unsupported() {
// 4 Mi entries, as many as `/Size` allows; every `/Range` pair adds an output, 4 Mi more samples.
let dict = |outputs: usize| format!("<< /FunctionType 0 /Domain [0 1] /Size [4194304] /Range [{}] /BitsPerSample 1 >>", "0 1 ".repeat(outputs));
// Five outputs ask for 20 Mi samples, past the per-table cap (16 Mi); refused by the sample
// count before any decode, so this is cheap and no lost cap reaches the large table below.
assert!(matches!(read_sampled_function(&dict(5), vec![]), color::Func::Unsupported));
// 32 outputs of an empty stream ask for 128 Mi zeros; also past the cap. Through parse() the
// refused tint transform takes the Separation fallback: tint 0.25 paints grey 0.75, not black.
let bytes =
page_pdf("/Tint cs 0.25 scn 0 0 200 100 re f", "/ColorSpace << /Tint [/Separation /Ink /DeviceRGB 10 0 R] >>", vec![(10, dict(32), Some(vec![]))]);
let doc = parse(&bytes).unwrap();
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
assert_eq!(px(&doc, 50, 50), [0.75, 0.75, 0.75, 1.0]);
}
#[test]
fn sampled_function_one_input_two_outputs() {
let func =
read_sampled_function("<< /FunctionType 0 /Domain [0 1] /Size [4] /Range [0 1 -1 1] /BitsPerSample 8 >>", vec![0, 255, 85, 170, 170, 85, 255, 0]);
assert!(matches!(func, color::Func::Sampled { outputs: 2, .. }));
// 0.5 → coordinate 1.5, halfway between samples 1 and 2; the second output decodes to [-1, 1].
assert_eq!(func.eval(&[0.5]), vec![0.5, 0.0]);
// Inputs outside the domain take the end samples.
assert_eq!(func.eval(&[-1.0]), vec![0.0, 1.0]);
assert_eq!(func.eval(&[2.0]), vec![1.0, -1.0]);
}
#[test]
fn sampled_function_two_inputs() {
let func = read_sampled_function(
"<< /FunctionType 0 /Domain [0 1 0 1] /Size [3 3] /Range [0 1 0 1] /BitsPerSample 8 >>",
vec![0, 0, 85, 0, 170, 0, 0, 85, 85, 85, 170, 85, 0, 170, 85, 170, 170, 170],
);
assert!(matches!(func, color::Func::SampledN { outputs: 2, .. }));
// Entry (i, j), the first input varying fastest, holds (i / 3, j / 3); (0.25, 0.75) → (0.5, 1.5).
assert_eq!(func.eval(&[0.25, 0.75]), vec![1.0 / 6.0, 0.5]);
assert_eq!(func.eval(&[0.0, 0.0]), vec![0.0, 0.0]);
assert_eq!(func.eval(&[1.0, 1.0]), vec![2.0 / 3.0, 2.0 / 3.0]);
}
#[test]
fn sampled_functions_have_at_most_32_inputs_and_outputs() {
// Each evaluation allocates and loops over every output, and shadings evaluate millions of
// times; no colour space has more than 32 components.
let outputs = |n: usize| format!("<< /FunctionType 0 /Domain [0 1] /Size [2] /Range [{}] /BitsPerSample 8 >>", "0 1 ".repeat(n));
assert!(matches!(read_sampled_function(&outputs(32), vec![255; 64]), color::Func::Sampled { outputs: 32, .. }));
assert_eq!(read_sampled_function(&outputs(33), vec![255; 66]), color::Func::Unsupported);
let inputs = |m: usize| format!("<< /FunctionType 0 /Domain [{}] /Size [{}] /Range [0 1] /BitsPerSample 8 >>", "0 1 ".repeat(m), "1 ".repeat(m));
let f = read_sampled_function(&inputs(32), vec![255]);
assert!(matches!(f, color::Func::SampledN { .. }), "{f:?}");
assert_eq!(f.eval(&[0.5; 32]), vec![1.0]);
assert_eq!(read_sampled_function(&inputs(33), vec![255]), color::Func::Unsupported);
}
/// Object 10: a sampled function with one input, one output and samples 0 and 1.
fn ramp() -> (u32, String, Option<Vec<u8>>) {
(10, "<< /FunctionType 0 /Domain [0 1] /Size [2] /Range [0 1] /BitsPerSample 8 >>".into(), Some(vec![0, 255]))
}
/// A stitching function of `n` equal pieces, each function object 10.
fn stitching_of_10(n: usize) -> String {
let bounds: Vec<String> = (1..n).map(|i| (i as f64 / n as f64).to_string()).collect();
format!("<< /FunctionType 3 /Domain [0 1] /Functions [{}] /Bounds [{}] /Encode [{}] >>", "10 0 R ".repeat(n), bounds.join(" "), "0 1 ".repeat(n))
}
#[test]
fn function_arrays_hold_at_most_32_functions() {
// An array of one-output functions gives an output per function: 32 at most, as for a single
// function. (A stitching function's pieces are NOT capped this way; see the 100-piece test.)
let array = |n: usize| format!("[{}]", "10 0 R ".repeat(n));
assert!(matches!(read_function(vec![(1, array(32), None), ramp()]), color::Func::Array(fs) if fs.len() == 32));
assert_eq!(read_function(vec![(1, array(33), None), ramp()]), color::Func::Unsupported);
}
#[test]
fn stitching_function_with_100_pieces_builds_and_evaluates() {
// A repeating gradient (Cairo, Inkscape) lists one sub-function once per repeat, often far
// more than 32 times; such a stitching function is valid and must build. Here object 10 (a
// 0 → 1 ramp) is listed 100 times, read once and shared.
let f = read_function(vec![(1, stitching_of_10(100), None), ramp()]);
let color::Func::Stitch { funcs, .. } = &f else { panic!("{f:?}") };
assert_eq!(funcs.len(), 100);
let table = |g: &color::Func| match g {
color::Func::Sampled { samples, .. } => Some(samples.as_ptr()),
_ => None,
};
assert!(table(&funcs[0]).is_some());
assert!(funcs.iter().all(|g| g == &funcs[0] && table(g) == table(&funcs[0])));
// The sawtooth's ends: piece 0 at t = 0 and piece 99 at t = 1 both map through the ramp.
assert_eq!(f.eval1(0.0), vec![0.0]);
assert_eq!(f.eval1(1.0), vec![1.0]);
}
#[test]
fn a_function_repeated_in_an_array_is_read_once() {
// Read once per reference, 32 references to one 32 MiB table made 1 GiB. Now the object is
// read once: the copies are equal and share its table.
let table = |f: &color::Func| match f {
color::Func::Sampled { samples, .. } => samples.as_ptr(),
_ => std::ptr::null(),
};
let array = read_function(vec![(1, format!("[{}]", "10 0 R ".repeat(32)), None), ramp()]);
let stitch = read_function(vec![(1, stitching_of_10(32), None), ramp()]);
for f in [&array, &stitch] {
let (color::Func::Array(fs) | color::Func::Stitch { funcs: fs, .. }) = f else { panic!("{f:?}") };
assert_eq!(fs.len(), 32);
assert!(!table(&fs[0]).is_null());
assert!(fs.iter().all(|g| g == &fs[0] && table(g) == table(&fs[0])));
}
assert_eq!(array.eval1(0.5), vec![0.5; 32]);
}
#[test]
fn saved_graphics_states_share_a_sampled_tint() {
// `q` saves a copy of the graphics state, colour spaces included: the copies share the tint
// transform's table.
let tint = "<< /FunctionType 0 /Domain [0 1] /Size [4096] /Range [0 1 0 1 0 1] /BitsPerSample 8 >>";
let data: Vec<u8> = (0..4096u32).flat_map(|i| [(i >> 4) as u8, 0, 255 - (i >> 4) as u8]).collect();
let cs = read_color_space(vec![(1, "[/Separation /Ink /DeviceRGB 10 0 R]".into(), None), (10, tint.into(), Some(data.clone()))]);
let table = |cs: &color::Cs| match cs {
color::Cs::Tint { func: color::Func::Sampled { samples, .. }, .. } => samples.as_ptr(),
_ => std::ptr::null(),
};
assert!(!table(&cs).is_null());
assert_eq!(table(&cs.clone()), table(&cs));
// A thousand nested `q` around a fill in that space: tint 1 is the last entry, red.
let content = format!("/Tint cs 1 scn {}0 0 200 100 re f {}", "q ".repeat(1000), "Q ".repeat(1000));
let doc = parse(&page_pdf(&content, "/ColorSpace << /Tint [/Separation /Ink /DeviceRGB 10 0 R] >>", vec![(10, tint.into(), Some(data))])).unwrap();
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
assert_eq!(px(&doc, 100, 50), [1.0, 0.0, 0.0, 1.0]);
}
/// A one-page PDF drawing image object 10 over the whole 200 × 100 page; `extra` holds more objects.
fn image_page(dict: &str, data: Vec<u8>, extra: Objs) -> Vec<u8> {
let mut objs: Objs = vec![(10, dict.into(), Some(data))];
objs.extend(extra);
page_pdf("q 200 0 0 100 0 0 cm /Im1 Do Q", "/XObject << /Im1 10 0 R >>", objs)
}
/// Whether two colours match within rendering error.
fn near(a: [f32; 4], b: [f32; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.02)
}
#[test]
fn indexed_space_reads_at_most_256_entries() {
// hival 10^12 asked the image palette for 10^12 colours (24 TB); an indexed palette holds at
// most 256 entries, so hival is 0 to 255.
let im = "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace [/Indexed /DeviceRGB 1000000000000 <00>] /BitsPerComponent 8 >>";
let doc = parse(&image_page(im, vec![200], vec![])).unwrap();
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
// Entry 200 is past the one-byte lookup string: black.
assert!(near(px(&doc, 100, 50), [0.0, 0.0, 0.0, 1.0]), "{:?}", px(&doc, 100, 50));
let cs = read_color_space(vec![(1, "[/Indexed /DeviceRGB 1000000000000 <00>]".into(), None)]);
assert!(matches!(cs, color::Cs::Indexed { hival: 255, .. }), "{cs:?}");
}
#[test]
fn indexed_images_with_4_and_256_entries() {
let im = |cs: &str, w: usize| format!("<< /Type /XObject /Subtype /Image /Width {w} /Height 1 /ColorSpace {cs} /BitsPerComponent 8 >>");
let doc = parse(&image_page(&im("[/Indexed /DeviceRGB 3 <FF000000FF000000FFFFFFFF>]", 4), vec![0, 1, 2, 3], vec![])).unwrap();
for (x, c) in [(25, [1.0, 0.0, 0.0, 1.0]), (75, [0.0, 1.0, 0.0, 1.0]), (125, [0.0, 0.0, 1.0, 1.0]), (175, [1.0; 4])] {
assert!(near(px(&doc, x, 50), c), "x = {x}: {:?}", px(&doc, x, 50));
}
// 256 entries, the lookup table in a stream: entry i is (i, 0, 255 − i).
let lookup: Vec<u8> = (0..=255u8).flat_map(|i| [i, 0, 255 - i]).collect();
let doc = parse(&image_page(&im("[/Indexed /DeviceRGB 255 11 0 R]", 2), vec![255, 0], vec![(11, "<< >>".into(), Some(lookup))])).unwrap();
assert!(near(px(&doc, 50, 50), [1.0, 0.0, 0.0, 1.0]), "{:?}", px(&doc, 50, 50));
assert!(near(px(&doc, 150, 50), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&doc, 150, 50));
}
/// Functions nested down the first element of arrays and stitching functions, outermost first.
fn nested_functions(f: &color::Func) -> Vec<&color::Func> {
let mut out = vec![f];
let mut f = f;
while let color::Func::Array(fs) | color::Func::Stitch { funcs: fs, .. } = f
&& let Some(first) = fs.first()
{
out.push(first);
f = first;
}
out
}
/// Colour spaces nested down Indexed bases and Separation or DeviceN alternates, outermost first.
fn nested_spaces(cs: &color::Cs) -> Vec<&color::Cs> {
let mut out = vec![cs];
let mut cs = cs;
while let color::Cs::Indexed { base: next, .. } | color::Cs::Tint { alt: next, .. } = cs {
cs = next;
out.push(cs);
}
out
}
#[test]
fn function_array_containing_itself_stops_at_depth_8() {
// `[1 0 R]` as object 1: the reader recursed until the stack overflowed (an abort).
let f = read_function(vec![(1, "[1 0 R]".into(), None)]);
let chain = nested_functions(&f);
assert_eq!(chain.len(), 9);
assert!(chain[..8].iter().all(|f| matches!(f, color::Func::Array(_))));
assert_eq!(chain[8], &color::Func::Unsupported);
// As a tint transform: the innermost function's 0.5, in DeviceGray.
let tint = "/ColorSpace << /Tint [/Separation /Ink /DeviceGray 10 0 R] >>";
let doc = parse(&page_pdf("/Tint cs 1 scn 0 0 200 100 re f", tint, vec![(10, "[10 0 R]".into(), None)])).unwrap();
assert_eq!(px(&doc, 100, 50), [0.5, 0.5, 0.5, 1.0]);
}
#[test]
fn stitching_function_containing_itself_stops_at_depth_8() {
let stitch = |me: u32| format!("<< /FunctionType 3 /Domain [0 1] /Functions [{me} 0 R] /Bounds [] /Encode [0 1] >>");
let f = read_function(vec![(1, stitch(1), None)]);
let chain = nested_functions(&f);
assert_eq!(chain.len(), 9);
assert!(chain[..8].iter().all(|f| matches!(f, color::Func::Stitch { .. })));
assert_eq!(chain[8], &color::Func::Unsupported);
let tint = "/ColorSpace << /Tint [/Separation /Ink /DeviceGray 10 0 R] >>";
let doc = parse(&page_pdf("/Tint cs 1 scn 0 0 200 100 re f", tint, vec![(10, stitch(10), None)])).unwrap();
assert_eq!(px(&doc, 100, 50), [0.5, 0.5, 0.5, 1.0]);
}
#[test]
fn colour_spaces_based_on_themselves_stop_at_depth_8() {
// Object 1 as its own Indexed base, Separation alternate or ICCBased alternate: the reader
// recursed until the stack overflowed (an abort).
let indexed = read_color_space(vec![(1, "[/Indexed 1 0 R 1 <00>]".into(), None)]);
let separation = read_color_space(vec![(1, "[/Separation /Ink 1 0 R << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>]".into(), None)]);
for cs in [&indexed, &separation] {
let chain = nested_spaces(cs);
assert_eq!(chain.len(), 9);
assert!(chain[..8].iter().all(|c| matches!(c, color::Cs::Indexed { .. } | color::Cs::Tint { .. })));
assert_eq!(chain[8], &color::Cs::Gray);
}
// An ICCBased space reads as its alternate: DeviceGray once the depth runs out.
let icc = read_color_space(vec![(1, "[/ICCBased 2 0 R]".into(), None), (2, "<< /N 3 /Alternate 1 0 R >>".into(), Some(vec![0; 16]))]);
assert_eq!(icc, color::Cs::Gray);
// A fill in the Indexed one paints black.
let doc = parse(&page_pdf("/CS0 cs 0 scn 0 0 200 100 re f", "/ColorSpace << /CS0 10 0 R >>", vec![(10, "[/Indexed 10 0 R 1 <00>]".into(), None)])).unwrap();
assert_eq!(px(&doc, 100, 50), [0.0, 0.0, 0.0, 1.0]);
}
#[test]
fn nesting_deeper_than_8_is_refused() {
// Chains of 100 distinct objects, each nesting the next (real files nest two or three
// levels): read down to depth 7, refused at depth 8.
let functions = |n: u32| -> Objs {
let stitch = |next: u32| format!("<< /FunctionType 3 /Domain [0 1] /Functions [{next} 0 R] /Bounds [] /Encode [0 1] >>");
(1..n).map(|i| (i, stitch(i + 1), None)).chain([(n, "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>".into(), None)]).collect()
};
let f = read_function(functions(100));
let chain = nested_functions(&f);
assert_eq!(chain.len(), 9);
assert!(chain[..8].iter().all(|f| matches!(f, color::Func::Stitch { .. })));
assert_eq!(chain[8], &color::Func::Unsupported);
// Three levels are read to the end.
let f = read_function(functions(3));
assert_eq!(nested_functions(&f).len(), 3);
assert_eq!(f.eval1(0.25), vec![0.25]);
// Indexed bases.
let spaces: Objs =
(1..100).map(|i| (i, format!("[/Indexed {} 0 R 0 <00>]", i + 1), None)).chain([(100, "[/Indexed /DeviceRGB 0 <FF0000>]".into(), None)]).collect();
let cs = read_color_space(spaces);
let chain = nested_spaces(&cs);
assert_eq!(chain.len(), 9);
assert!(chain[..8].iter().all(|c| matches!(c, color::Cs::Indexed { .. })));
assert_eq!(chain[8], &color::Cs::Gray);
}
#[test]
fn a_fanning_out_reference_graph_is_refused_within_the_budget() {
// Eight objects, each an array of 32 references to the next, with a function at the bottom:
// depth 8 alone would still expand 32^7 copies. Each object is read once per array but its
// nodes are charged per copy, so the per-read node budget is spent after a few levels and the
// whole function is refused — fast and with bounded memory, not 32^7 nodes.
let objs: Objs = (1..=7u32)
.map(|i| (i, format!("[{}]", format!("{} 0 R ", i + 1).repeat(32)), None))
.chain([(8, "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>".into(), None)])
.collect();
assert_eq!(read_function(objs), color::Func::Unsupported);
}
#[test]
fn colour_space_chain_of_depth_3() {
// Indexed → Separation → ICCBased → DeviceRGB: entry 0 is tint 1 (blue), entry 1 tint 0 (white).
let doc = parse(&image_page(
"<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace [/Indexed 11 0 R 1 <FF00>] /BitsPerComponent 8 >>",
vec![0, 1],
vec![
(11, "[/Separation /Ink 12 0 R 13 0 R]".into(), None),
(12, "[/ICCBased 14 0 R]".into(), None),
(13, "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 0 1] /N 1 >>".into(), None),
(14, "<< /N 3 /Alternate /DeviceRGB >>".into(), Some(vec![0; 16])),
],
))
.unwrap();
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
assert!(near(px(&doc, 50, 50), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&doc, 50, 50));
assert!(near(px(&doc, 150, 50), [1.0; 4]), "{:?}", px(&doc, 150, 50));
}
#[test]
fn stitching_function_with_a_reversed_domain() {
// `f64::clamp` panics on bounds in the wrong order, as `/Domain [1 0]` gives them.
let exp = "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>";
let f = read_function(vec![(1, format!("<< /FunctionType 3 /Domain [1 0] /Functions [{exp}] /Bounds [] /Encode [0 1] >>"), None)]);
assert_eq!(f.eval(&[0.25]), vec![0.0]);
}
#[test]
fn sampled_function_with_a_reversed_domain() {
let f = read_sampled_function("<< /FunctionType 0 /Domain [1 0] /Size [2] /Range [0 1] /BitsPerSample 8 >>", vec![0, 255]);
assert_eq!(f.eval(&[0.25]), vec![0.0]);
}
#[test]
fn calculator_function_with_a_reversed_range() {
let f = read_function(vec![(1, "<< /FunctionType 4 /Domain [0 1] /Range [1 0] >>".into(), Some(b"{ 0.5 add }".to_vec()))]);
assert_eq!(f.eval(&[0.25]), vec![0.75]);
}
#[test]
fn multiple_pages_and_calculator_shadings() {
let objs: Objs = vec![
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
(2, "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>".into(), None),
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R >>".into(), None),
(4, "<< >>".into(), Some(b"1 0 0 rg 0 0 200 100 re f".to_vec())),
(5, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Resources << /Shading << /Sh 7 0 R >> >> /Contents 6 0 R >>".into(), None),
(6, "<< >>".into(), Some(b"/Sh sh".to_vec())),
(7, "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function 8 0 R >>".into(), None),
(8, "<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] >>".into(), Some(b"{ 0 0 }".to_vec())),
];
let bytes = pdf(&objs, 1);
assert_eq!(page_count(&bytes), 2);
let p1 = parse_page(&bytes, 0).unwrap();
assert_eq!((p1.width, p1.height), (200.0, 100.0));
let p2 = parse_page(&bytes, 1).unwrap();
assert_eq!((p2.width, p2.height), (100.0, 50.0));
assert!(p2.skipped.is_empty(), "{:?}", p2.skipped);
let img = render(&p2);
assert!(img.get(5, 25)[0] < 0.1 && img.get(95, 25)[0] > 0.9, "{:?} {:?}", img.get(5, 25), img.get(95, 25));
assert_eq!(parse_page(&bytes, 2), Err(Error::PageOutOfRange(2)));
}