use printpdf::*;
static ROBOTO_TTF: &[u8] = include_bytes!("../examples/assets/fonts/RobotoMedium.ttf");
fn save(doc: &PdfDocument) -> Vec<u8> {
doc.save(&PdfSaveOptions::default(), &mut Vec::new())
}
fn parse(bytes: &[u8]) -> PdfDocument {
PdfDocument::parse(bytes, &PdfParseOptions::default(), &mut Vec::new())
.expect("parse failed")
}
fn parse_with_warnings(bytes: &[u8]) -> (PdfDocument, Vec<PdfWarnMsg>) {
let mut w = Vec::new();
let doc = PdfDocument::parse(bytes, &PdfParseOptions::default(), &mut w).expect("parse failed");
(doc, w)
}
fn all_text(doc: &PdfDocument) -> String {
doc.extract_text()
.iter()
.flatten()
.flat_map(|c| c.split_whitespace())
.collect::<Vec<_>>()
.join(" ")
}
fn text_page(items: Vec<TextItem>, font: PdfFontHandle) -> PdfPage {
let ops = vec![
Op::StartTextSection,
Op::SetTextCursor {
pos: Point::new(Mm(20.0), Mm(270.0)),
},
Op::SetFont {
font,
size: Pt(12.0),
},
Op::ShowText { items },
Op::EndTextSection,
];
PdfPage::new(Mm(210.0), Mm(297.0), ops)
}
#[test]
fn builtin_font_even_length_text_roundtrips() {
let mut doc = PdfDocument::new("even");
doc.pages.push(text_page(
vec![TextItem::Text("Hi".to_string())],
PdfFontHandle::Builtin(BuiltinFont::Helvetica),
));
let parsed = parse(&save(&doc));
assert_eq!(all_text(&parsed), "Hi");
let has_text_item = parsed.pages[0].ops.iter().any(|op| {
matches!(op, Op::ShowText { items } if items.iter().any(|i| matches!(i, TextItem::Text(t) if t == "Hi")))
});
assert!(has_text_item, "expected TextItem::Text(\"Hi\"), got: {:?}",
parsed.pages[0].ops.iter().filter(|o| matches!(o, Op::ShowText { .. })).collect::<Vec<_>>());
}
#[test]
fn builtin_font_odd_length_text_roundtrips_bytewise() {
let mut doc = PdfDocument::new("odd");
doc.pages.push(text_page(
vec![TextItem::Text("Hello Helvetica".to_string())],
PdfFontHandle::Builtin(BuiltinFont::Helvetica),
));
let bytes1 = save(&doc);
let parsed1 = parse(&bytes1);
assert_eq!(all_text(&parsed1), "Hello Helvetica");
let bytes2 = save(&parsed1);
let parsed2 = parse(&bytes2);
assert_eq!(all_text(&parsed2), "Hello Helvetica");
assert!(!all_text(&parsed2).contains('\u{0}'));
assert!(!all_text(&parsed2).contains('\u{FFFD}'));
}
#[test]
fn builtin_font_winansi_specials_roundtrip() {
let text = "Caf\u{E9} \u{2014} \u{20AC}5 \u{201E}quote\u{201C}";
let mut doc = PdfDocument::new("winansi");
doc.pages.push(text_page(
vec![TextItem::Text(text.to_string())],
PdfFontHandle::Builtin(BuiltinFont::TimesRoman),
));
let parsed = parse(&save(&doc));
assert_eq!(all_text(&parsed), text.split_whitespace().collect::<Vec<_>>().join(" "));
let parsed2 = parse(&save(&parsed));
assert_eq!(all_text(&parsed2), all_text(&parsed));
}
#[test]
fn builtin_font_tj_with_kerning_roundtrips() {
let mut doc = PdfDocument::new("kerning");
doc.pages.push(text_page(
vec![
TextItem::Text("AW".to_string()),
TextItem::Offset(-120.0),
TextItem::Text("AY".to_string()),
],
PdfFontHandle::Builtin(BuiltinFont::Helvetica),
));
let parsed = parse(&save(&doc));
assert_eq!(all_text(&parsed), "AW AY");
}
#[test]
fn external_font_text_roundtrips_twice() {
let roboto = ParsedFont::from_bytes(ROBOTO_TTF, 0, &mut Vec::new()).unwrap();
let mut doc = PdfDocument::new("external");
let font_id = doc.add_font(&roboto);
doc.pages.push(text_page(
vec![TextItem::Text("External font text".to_string())],
PdfFontHandle::External(font_id),
));
let bytes1 = save(&doc);
let parsed1 = parse(&bytes1);
assert_eq!(all_text(&parsed1), "External font text");
let cid_ok = parsed1.pages[0].ops.iter().any(|op| match op {
Op::ShowText { items } => items.iter().any(|i| match i {
TextItem::GlyphIds(glyphs) => {
!glyphs.is_empty() && glyphs.iter().all(|g| g.cid.is_some())
}
_ => false,
}),
_ => false,
});
assert!(cid_ok, "expected GlyphIds with cid (unicode) populated");
let bytes2 = save(&parsed1);
let parsed2 = parse(&bytes2);
assert_eq!(all_text(&parsed2), "External font text");
}
fn doc_with_embedded_font_named_f1() -> Vec<u8> {
let roboto = ParsedFont::from_bytes(ROBOTO_TTF, 0, &mut Vec::new()).unwrap();
let mut doc = PdfDocument::new("f1-collision");
let fid = FontId("F1".to_string());
doc.resources
.fonts
.map
.insert(fid.clone(), printpdf::font::PdfFont::new(roboto));
doc.pages.push(text_page(
vec![TextItem::Text("Definitely not Times".to_string())],
PdfFontHandle::External(fid),
));
save(&doc)
}
#[test]
fn embedded_font_named_f1_is_not_misdetected_as_builtin() {
let bytes = doc_with_embedded_font_named_f1();
let parsed = parse(&bytes);
assert!(
parsed.resources.fonts.map.contains_key(&FontId("F1".to_string())),
"external font F1 missing from parsed resources: {:?}",
parsed.resources.fonts.map.keys().collect::<Vec<_>>()
);
let set_font_external = parsed.pages[0].ops.iter().any(|op| {
matches!(op, Op::SetFont { font: PdfFontHandle::External(f), .. } if f.0 == "F1")
});
assert!(set_font_external, "SetFont must resolve F1 to the embedded font");
}
#[test]
fn embedded_font_named_f1_text_survives_roundtrip() {
let bytes = doc_with_embedded_font_named_f1();
let parsed = parse(&bytes);
assert_eq!(all_text(&parsed), "Definitely not Times");
let parsed2 = parse(&save(&parsed));
assert_eq!(all_text(&parsed2), "Definitely not Times");
}
#[test]
fn apostrophe_operator_text_survives_roundtrip() {
use lopdf::{dictionary, Object, Stream};
let mut doc = lopdf::Document::with_version("1.4");
let font_dict_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
"Encoding" => "WinAnsiEncoding",
});
let content = b"BT /F5 12 Tf 14 TL 50 700 Td (first line) Tj (quoted line) ' ET".to_vec();
let content_id = doc.add_object(Stream::new(dictionary! {}, content));
let font_res = dictionary! { "F5" => Object::Reference(font_dict_id) };
let resources = dictionary! { "Font" => Object::Dictionary(font_res) };
let pages_id = doc.new_object_id();
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
"Resources" => Object::Dictionary(resources),
"Contents" => Object::Reference(content_id),
});
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Count" => 1,
"Kids" => vec![Object::Reference(page_id)],
}),
);
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"Pages" => Object::Reference(pages_id),
});
doc.trailer.set("Root", Object::Reference(catalog_id));
let mut bytes = Vec::new();
doc.save_to(&mut bytes).unwrap();
let parsed = parse(&bytes);
let text = all_text(&parsed);
assert!(text.contains("first line"), "missing Tj text: {text:?}");
assert!(text.contains("quoted line"), "missing ' text: {text:?}");
let parsed2 = parse(&save(&parsed));
let text2 = all_text(&parsed2);
assert!(text2.contains("quoted line"), "' text lost on round trip: {text2:?}");
}
#[test]
fn cid_font_without_tounicode_gets_synthesized_mapping() {
let roboto = ParsedFont::from_bytes(ROBOTO_TTF, 0, &mut Vec::new()).unwrap();
let gid_h = roboto.lookup_glyph_index('H' as u32).expect("Roboto has H");
let gid_i = roboto.lookup_glyph_index('i' as u32).expect("Roboto has i");
use lopdf::{dictionary, Object, Stream};
let mut doc = lopdf::Document::with_version("1.4");
let font_file_id = doc.add_object(Stream::new(dictionary! {}, ROBOTO_TTF.to_vec()));
let descriptor_id = doc.add_object(dictionary! {
"Type" => "FontDescriptor",
"FontName" => "Roboto",
"Flags" => 4,
"FontFile2" => Object::Reference(font_file_id),
});
let descendant_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "CIDFontType2",
"BaseFont" => "Roboto",
"CIDSystemInfo" => dictionary! {
"Registry" => Object::String(b"Adobe".to_vec(), lopdf::StringFormat::Literal),
"Ordering" => Object::String(b"Identity".to_vec(), lopdf::StringFormat::Literal),
"Supplement" => 0,
},
"FontDescriptor" => Object::Reference(descriptor_id),
"CIDToGIDMap" => "Identity",
});
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type0",
"BaseFont" => "Roboto",
"Encoding" => "Identity-H",
"DescendantFonts" => vec![Object::Reference(descendant_id)],
});
let mut text_bytes = Vec::new();
text_bytes.extend_from_slice(&gid_h.to_be_bytes());
text_bytes.extend_from_slice(&gid_i.to_be_bytes());
let mut content = b"BT /FX 12 Tf 50 700 Td <".to_vec();
for b in &text_bytes {
content.extend_from_slice(format!("{b:02X}").as_bytes());
}
content.extend_from_slice(b"> Tj ET");
let content_id = doc.add_object(Stream::new(dictionary! {}, content));
let pages_id = doc.new_object_id();
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
"Resources" => Object::Dictionary(dictionary! {
"Font" => Object::Dictionary(dictionary! { "FX" => Object::Reference(font_id) }),
}),
"Contents" => Object::Reference(content_id),
});
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Count" => 1,
"Kids" => vec![Object::Reference(page_id)],
}),
);
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"Pages" => Object::Reference(pages_id),
});
doc.trailer.set("Root", Object::Reference(catalog_id));
let mut bytes = Vec::new();
doc.save_to(&mut bytes).unwrap();
let (parsed, _warnings) = parse_with_warnings(&bytes);
assert_eq!(all_text(&parsed), "Hi", "synthesized ToUnicode should decode gids");
let parsed2 = parse(&save(&parsed));
assert_eq!(all_text(&parsed2), "Hi");
}