use pdf_render::pdf_interpret::InterpreterSettings;
use pdf_render::pdf_syntax::Pdf;
use pdf_render::{GlyphOutlineCache, RenderSettings, render, render_with_glyph_cache};
fn text_pdf(pages: &[&[&str]]) -> Vec<u8> {
use lopdf::{Document, Object, Stream, dictionary};
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let font_id = doc.new_object_id();
doc.objects.insert(
font_id,
Object::Dictionary(dictionary! {
"Type" => Object::Name(b"Font".to_vec()),
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
}),
);
let mut kids = Vec::with_capacity(pages.len());
for lines in pages {
let page_id = doc.new_object_id();
let mut content = b"BT /F1 12 Tf 10 80 Td ".to_vec();
for (i, line) in lines.iter().enumerate() {
if i > 0 {
content.extend_from_slice(b"0 -14 Td ");
}
content.extend_from_slice(b"(");
content.extend_from_slice(line.as_bytes());
content.extend_from_slice(b") Tj ");
}
content.extend_from_slice(b"ET");
let content_id = doc.add_object(Stream::new(dictionary! {}, content));
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => Object::Name(b"Page".to_vec()),
"Parent" => Object::Reference(pages_id),
"MediaBox" => Object::Array(vec![
Object::Integer(0), Object::Integer(0),
Object::Integer(300), Object::Integer(100),
]),
"Contents" => Object::Reference(content_id),
"Resources" => Object::Dictionary(dictionary! {
"Font" => Object::Dictionary(dictionary! {
"F1" => Object::Reference(font_id),
}),
}),
}),
);
kids.push(Object::Reference(page_id));
}
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => Object::Name(b"Pages".to_vec()),
"Kids" => Object::Array(kids),
"Count" => Object::Integer(pages.len() as i64),
}),
);
let catalog_id = doc.new_object_id();
doc.objects.insert(
catalog_id,
Object::Dictionary(dictionary! {
"Type" => Object::Name(b"Catalog".to_vec()),
"Pages" => Object::Reference(pages_id),
}),
);
doc.trailer.set("Root", Object::Reference(catalog_id));
let mut bytes = Vec::new();
doc.save_to(&mut bytes).expect("lopdf save should succeed");
bytes
}
fn render_settings() -> RenderSettings {
RenderSettings {
x_scale: 2.0,
y_scale: 2.0,
..Default::default()
}
}
#[test]
fn cache_on_off_and_default_renders_are_byte_identical() {
let text = format!("abcdefghijklmnopqrstuvwxyz{}", "a".repeat(200));
let bytes = text_pdf(&[&[text.as_str()]]);
let pdf = Pdf::new(bytes).expect("fixture PDF should load");
let pages = pdf.pages();
let interp = InterpreterSettings::default();
let settings = render_settings();
let plain = render(&pages[0], &interp, &settings);
let mut enabled = GlyphOutlineCache::new();
let cached = render_with_glyph_cache(&pages[0], &interp, &settings, &mut enabled);
let mut disabled = GlyphOutlineCache::new();
disabled.set_enabled(false);
let uncached = render_with_glyph_cache(&pages[0], &interp, &settings, &mut disabled);
for (name, pixmap) in [("enabled", &cached), ("disabled", &uncached)] {
assert_eq!(
(pixmap.width(), pixmap.height()),
(plain.width(), plain.height()),
"{name} render dimensions must match the default path"
);
assert_eq!(
pixmap.data_as_u8_slice(),
plain.data_as_u8_slice(),
"{name} render bytes must match the default path"
);
}
assert!(
enabled.hits() > 0,
"repeated glyphs must hit the enabled cache"
);
assert_eq!(disabled.hits(), 0, "disabled cache must serve no hits");
assert!(
disabled.misses() > 0,
"disabled cache must count every build as a miss"
);
assert_eq!(disabled.len(), 0, "disabled cache must store nothing");
}
#[test]
fn repeated_glyphs_are_served_from_cache() {
let text = format!("abcdefghijklmnopqrstuvwxyz{}", "a".repeat(200));
let bytes = text_pdf(&[&[text.as_str()]]);
let pdf = Pdf::new(bytes).expect("fixture PDF should load");
let pages = pdf.pages();
let mut cache = GlyphOutlineCache::new();
render_with_glyph_cache(
&pages[0],
&InterpreterSettings::default(),
&render_settings(),
&mut cache,
);
assert!(
(1..=26).contains(&cache.misses()),
"each distinct glyph is built once; misses = {}",
cache.misses()
);
assert!(
cache.hits() >= 100,
"the 200 repeated draws must hit; hits = {}",
cache.hits()
);
}
#[test]
fn second_page_reuses_first_page_glyphs() {
let bytes = text_pdf(&[&["abcdefgh"], &["abcdefgh"]]);
let pdf = Pdf::new(bytes).expect("fixture PDF should load");
let pages = pdf.pages();
assert_eq!(pages.len(), 2);
let interp = InterpreterSettings::default();
let settings = render_settings();
let mut cache = GlyphOutlineCache::new();
render_with_glyph_cache(&pages[0], &interp, &settings, &mut cache);
let (hits_after_first, misses_after_first) = (cache.hits(), cache.misses());
assert!(
(1..=8).contains(&misses_after_first),
"first page builds each distinct glyph once; misses = {misses_after_first}"
);
render_with_glyph_cache(&pages[1], &interp, &settings, &mut cache);
assert_eq!(
cache.misses(),
misses_after_first,
"second page must add no outline builds"
);
assert!(
cache.hits() > hits_after_first,
"second page must hit the first page's entries"
);
}
#[test]
fn tiny_cache_stays_bounded_and_byte_identical() {
let bytes = text_pdf(&[&["abcdefgh"]]);
let pdf = Pdf::new(bytes).expect("fixture PDF should load");
let pages = pdf.pages();
let interp = InterpreterSettings::default();
let settings = render_settings();
let mut tiny = GlyphOutlineCache::with_capacity(4);
let evicted = render_with_glyph_cache(&pages[0], &interp, &settings, &mut tiny);
assert!(
tiny.len() <= 4,
"cache must stay within capacity; len = {}",
tiny.len()
);
let mut full = GlyphOutlineCache::new();
let reference = render_with_glyph_cache(&pages[0], &interp, &settings, &mut full);
assert_eq!(
(evicted.width(), evicted.height()),
(reference.width(), reference.height())
);
assert_eq!(
evicted.data_as_u8_slice(),
reference.data_as_u8_slice(),
"eviction must not change a single pixel"
);
}