//! 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##""##;
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"
);
}