//! Native proof that the module produces a PDF with the text as embedded //! glyphs from the font it was given, not as paths. //! //! The assertions grep the PDF's bytes rather than parsing it, so they //! tolerate both spellings of a dictionary entry: pdf-writer 0.12 (which //! svg2pdf used) wrote `/Type /Page`, and 0.15 (which krilla uses) writes //! `/Type/Page`. The object is the property under test; the space is not. use std::io::Read; const FONT: &[u8] = include_bytes!("fonts/LiberationSerif-Regular.ttf"); const SVG: &str = r##" Hello slides "##; fn frames(items: &[&[u8]]) -> Vec { let mut out = Vec::new(); for it in items { out.extend_from_slice(&(it.len() as u32).to_be_bytes()); out.extend_from_slice(it); } out } /// Inflates every FlateDecode stream in the PDF and returns the PDF with /// those streams replaced by their plain contents, so text operators and /// font dictionaries can be grepped for. Streams are located by their /// dictionary's direct `/Length` (pdf-writer always writes one). fn inflate_streams(pdf: &[u8]) -> Vec { let mut out = Vec::new(); let mut pos = 0; while let Some(i) = find(&pdf[pos..], b">>\nstream\n") { let dict_end = pos + i; let start = dict_end + b">>\nstream\n".len(); // The stream's own dictionary starts at the enclosing "N 0 obj" // header, not at the last "<<" (that could be a nested dict). let dict_start = pdf[..dict_end] .windows(6) .rposition(|w| w == b" 0 obj") .unwrap_or(0); let dict = &pdf[dict_start..dict_end]; let len = find(dict, b"/Length ") .map(|j| { let digits: String = dict[j + 8..] .iter() .take_while(|b| b.is_ascii_digit()) .map(|&b| b as char) .collect(); digits.parse::().expect("/Length") }) .expect("stream dictionary without /Length"); let end = start + len; out.extend_from_slice(&pdf[pos..start]); let raw = &pdf[start..end]; if find(dict, b"FlateDecode").is_some() { let mut z = flate2::read::ZlibDecoder::new(raw); let mut plain = Vec::new(); match z.read_to_end(&mut plain) { Ok(_) => out.extend_from_slice(&plain), Err(_) => out.extend_from_slice(raw), } } else { out.extend_from_slice(raw); } pos = end; } out.extend_from_slice(&pdf[pos..]); out } fn find(hay: &[u8], needle: &[u8]) -> Option { hay.windows(needle.len()).position(|w| w == needle) } fn count(hay: &[u8], needle: &[u8]) -> usize { hay.windows(needle.len()).filter(|w| *w == needle).count() } /// Both spellings of a `/Key /Value` entry. See the module comment. fn count_either(hay: &[u8], spaced: &[u8], tight: &[u8]) -> usize { count(hay, spaced) + count(hay, tight) } fn find_either(hay: &[u8], spaced: &[u8], tight: &[u8]) -> bool { find(hay, spaced).is_some() || find(hay, tight).is_some() } /// Page objects, less the one page-tree node whose `/Type /Pages` also /// matches the `/Type /Page` prefix. fn page_count(pdf: &[u8]) -> usize { count_either(pdf, b"/Type /Page", b"/Type/Page") - count_either(pdf, b"/Type /Pages", b"/Type/Pages") } #[test] fn renders_text_as_embedded_glyphs() { let pdf = slides_pdf::render(&[SVG.as_bytes()], &[FONT]).expect("render"); assert!(pdf.starts_with(b"%PDF-"), "no PDF header"); // Page count: /Type /Page objects less the /Type /Pages tree node. let pages = page_count(&pdf); assert_eq!(pages, 1, "expected one page"); assert!(find(&pdf, b"/Count 1").is_some()); assert!( find_either( &pdf, b"/MediaBox [0 0 1920 1080]", b"/MediaBox[0 0 1920 1080]" ), "page is 1920x1080 pt" ); // An embedded font program (TrueType -> FontFile2; CFF -> FontFile3). assert!( find(&pdf, b"/FontFile2").is_some() || find(&pdf, b"/FontFile3").is_some(), "no embedded font program" ); assert!( find_either(&pdf, b"/Type /Font", b"/Type/Font"), "no /Font dictionary" ); assert!( find(&pdf, b"LiberationSerif").is_some(), "font name not Liberation Serif" ); // Text drawn with a text-showing operator, not as filled paths. let plain = inflate_streams(&pdf); let has_show = find(&plain, b"Tj").is_some() || find(&plain, b"TJ").is_some(); assert!(has_show, "no Tj/TJ operator in any content stream"); assert!( find(&plain, b"BT").is_some() && find(&plain, b"Tf").is_some(), "no BT/Tf" ); // ToUnicode so the text extracts as "Hello slides". assert!(find(&pdf, b"/ToUnicode").is_some(), "no ToUnicode CMap"); std::fs::create_dir_all("target").ok(); std::fs::write("target/spike.pdf", &pdf).ok(); eprintln!("PDF {} bytes, {} pages", pdf.len(), pages); } #[test] fn two_svgs_make_two_pages() { let pdf = slides_pdf::render(&[SVG.as_bytes(), SVG.as_bytes()], &[FONT]).expect("render"); let pages = page_count(&pdf); assert_eq!(pages, 2); assert!(find(&pdf, b"/Count 2").is_some()); } #[test] fn abi_frames_round_trip() { let svgs = frames(&[SVG.as_bytes()]); let fonts = frames(&[FONT]); assert_eq!(slides_pdf::abi::render_frames(&svgs, &fonts), 0); assert!(slides_pdf::abi::slides_pdf_out_len() > 0); assert_eq!(slides_pdf::abi::slides_pdf_err_len(), 0); // A truncated frame is a refusal with a message, not a panic. let bad = &svgs[..svgs.len() - 3]; assert_eq!( slides_pdf::abi::render_frames(bad, &fonts), slides_pdf::abi::ERR_RENDER ); assert_eq!(slides_pdf::abi::slides_pdf_out_len(), 0); assert!(slides_pdf::abi::slides_pdf_err_len() > 0); // No pages is a refusal too. assert_eq!( slides_pdf::abi::render_frames(&[], &fonts), slides_pdf::abi::ERR_RENDER ); } #[test] fn a_null_zero_length_pair_is_no_frames_not_a_dereference() { // The host sends (0, 0) for "no frames". Natively there is no linear // memory to bound-check against, so this exercises exactly the path // that must not build a slice over a null pointer: no pages is a // refusal with a message, and the fonts side reads as empty. // SAFETY: (0, 0) is the documented "nothing" pair; nothing is read. let rc = unsafe { slides_pdf::abi::slides_pdf_render(0, 0, 0, 0) }; assert_eq!(rc, slides_pdf::abi::ERR_RENDER); assert!(slides_pdf::abi::slides_pdf_err_len() > 0); assert_eq!(slides_pdf::abi::slides_pdf_out_len(), 0); // Reading an empty result into an empty buffer is 0, not a copy. // SAFETY: a zero-length destination is never written. assert_eq!(unsafe { slides_pdf::abi::slides_pdf_out_read(0, 0) }, 0); } #[test] fn text_with_no_fonts_at_all_still_renders_the_shapes() { // A face the host did not send: usvg drops the text with a log // warning, never substitutes and never errors. The shapes are still // a page. let pdf = slides_pdf::render(&[SVG.as_bytes()], &[]).expect("render"); assert_eq!(page_count(&pdf), 1); assert!(find(&pdf, b"/FontFile2").is_none()); } #[test] fn raster_image_is_embedded() { // A 2x2 opaque red PNG, so the `image` feature is exercised end to end. let png = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR4nGP4z8AARAwQCgAf7gP9i18U1AAAAABJRU5ErkJggg=="; let svg = format!( r##" "## ); let pdf = slides_pdf::render(&[svg.as_bytes()], &[FONT]).expect("render"); assert!( find_either(&pdf, b"/Subtype /Image", b"/Subtype/Image"), "no image XObject in the PDF" ); }