#![cfg(feature = "html")]
use std::collections::{BTreeMap, BTreeSet};
use std::io::Write;
use std::process::{Command, Stdio};
use printpdf::*;
const TTF_PROBE: &[u8] = include_bytes!("../examples/assets/fonts/RobotoMedium.ttf");
const CFF_PROBE: &[u8] = include_bytes!("../examples/assets/fonts/NotoSansJP-Regular.otf");
fn probe_html(family: &str) -> String {
format!(
r#"<html>
<head><style>body {{ font-family: '{family}'; font-size: 14px; }}</style></head>
<body>
<p>Configure filter offline • probe</p>
<ul><li>alpha</li><li>beta</li></ul>
<ol><li>gamma</li><li>delta</li></ol>
<div>Page 1</div>
</body></html>"#
)
}
fn render_probe(family: &str, font_bytes: &[u8]) -> (PdfDocument, Vec<u8>) {
let mut fonts = BTreeMap::new();
fonts.insert(family.to_string(), Base64OrRaw::Raw(font_bytes.to_vec()));
let mut warnings = Vec::new();
let doc = PdfDocument::from_html(
&probe_html(family),
&BTreeMap::new(),
&fonts,
&GeneratePdfOptions::default(),
&mut warnings,
)
.expect("from_html");
let bytes = doc.save(&PdfSaveOptions::default(), &mut warnings);
(doc, bytes)
}
fn external_glyphs(doc: &PdfDocument) -> Vec<(FontId, u16, String)> {
let mut out = Vec::new();
for page in &doc.pages {
let mut cur: Option<FontId> = None;
for op in &page.ops {
match op {
Op::SetFont { font, .. } => {
cur = match font {
PdfFontHandle::External(id) => Some(id.clone()),
PdfFontHandle::Builtin(_) => None,
};
}
Op::ShowText { items } => {
if let Some(fid) = &cur {
for item in items {
if let TextItem::GlyphIds(gs) = item {
for cp in gs {
out.push((
fid.clone(),
cp.gid,
cp.cid.clone().unwrap_or_default(),
));
}
}
}
}
}
_ => {}
}
}
}
out
}
fn deref<'a>(pdf: &'a lopdf::Document, obj: &'a lopdf::Object) -> &'a lopdf::Object {
match obj.as_reference() {
Ok(r) => pdf.get_object(r).expect("dangling reference"),
Err(_) => obj,
}
}
fn font_program_for_resource(pdf: &lopdf::Document, resource_name: &str) -> Option<Vec<u8>> {
for (_, page_id) in pdf.get_pages() {
let Ok((res_dict, res_ids)) = pdf.get_page_resources(page_id) else { continue };
let mut dicts: Vec<&lopdf::Dictionary> = res_dict.into_iter().collect();
for rid in res_ids {
if let Ok(lopdf::Object::Dictionary(d)) = pdf.get_object(rid) {
dicts.push(d);
}
}
for res in dicts {
let Ok(fonts) = res.get(b"Font") else { continue };
let lopdf::Object::Dictionary(fonts) = deref(pdf, fonts) else { continue };
let Ok(font) = fonts.get(resource_name.as_bytes()) else { continue };
let lopdf::Object::Dictionary(font) = deref(pdf, font) else { continue };
let Ok(desc) = font.get(b"DescendantFonts") else { continue };
let lopdf::Object::Array(desc) = deref(pdf, desc) else { continue };
let Some(cid_font) = desc.first() else { continue };
let lopdf::Object::Dictionary(cid_font) = deref(pdf, cid_font) else { continue };
let Ok(fd) = cid_font.get(b"FontDescriptor") else { continue };
let lopdf::Object::Dictionary(fd) = deref(pdf, fd) else { continue };
for key in [b"FontFile2".as_slice(), b"FontFile3".as_slice()] {
let Ok(ff) = fd.get(key) else { continue };
let lopdf::Object::Stream(s) = deref(pdf, ff) else { continue };
return Some(
s.decompressed_content()
.unwrap_or_else(|_| s.content.clone()),
);
}
}
}
None
}
fn shown_gids_for_font(pdf: &lopdf::Document, font_resource: &str) -> Vec<u16> {
fn collect(obj: &lopdf::Object, out: &mut Vec<u16>) {
match obj {
lopdf::Object::String(bytes, lopdf::StringFormat::Hexadecimal) => {
for ch in bytes.chunks_exact(2) {
out.push(u16::from_be_bytes([ch[0], ch[1]]));
}
}
lopdf::Object::Array(items) => {
for it in items {
collect(it, out);
}
}
_ => {}
}
}
let mut out = Vec::new();
for (_, page_id) in pdf.get_pages() {
let content = pdf
.get_and_decode_page_content(page_id)
.expect("page content parses");
let mut selected = false;
for op in &content.operations {
match op.operator.as_str() {
"Tf" => {
selected = op
.operands
.first()
.and_then(|o| o.as_name().ok())
.map(|n| n == font_resource.as_bytes())
.unwrap_or(false);
}
"Tj" | "TJ" => {
if selected {
for operand in &op.operands {
collect(operand, &mut out);
}
}
}
_ => {}
}
}
}
out
}
fn advance(font: &ParsedFont, gid: u16) -> Option<u16> {
font.get_or_decode_glyph(gid).map(|g| g.horz_advance)
}
fn assert_shaped_glyphs_survive(
doc: &PdfDocument,
pdf_bytes: &[u8],
original_font: &[u8],
expect_ligature: bool,
must_reach_cmap: &[char],
) {
let glyphs = external_glyphs(doc);
assert!(!glyphs.is_empty(), "document must paint external-font glyphs");
let has = |probe: &str| glyphs.iter().any(|(_, _, cid)| cid == probe);
assert!(has("."), "list markers must produce '.' glyph runs");
assert!(has("•"), "a bullet glyph must be painted");
assert!(
glyphs
.iter()
.any(|(_, _, cid)| cid.len() == 1 && cid.chars().all(|c| c.is_ascii_digit())),
"digit glyphs (list markers / 'Page 1') must be painted"
);
let probe_font_id = if expect_ligature {
let (fid, lig_gid, _) = glyphs
.iter()
.find(|(_, _, cid)| cid == "fi")
.expect("the shaper must substitute the fi ligature (F5 precondition)");
let (_, f_gid, _) = glyphs
.iter()
.find(|(f, _, cid)| cid == "f" && f == fid)
.expect("an unligated 'f' must also be painted ('offline')");
assert_ne!(
lig_gid, f_gid,
"the fi ligature must be its own glyph, not the plain 'f'"
);
fid.clone()
} else {
glyphs
.iter()
.find(|(_, _, cid)| cid.len() == 1 && cid.chars().all(|c| c.is_ascii_digit()))
.map(|(f, _, _)| f.clone())
.unwrap()
};
let probe_glyphs: Vec<u16> = glyphs
.iter()
.filter(|(f, _, _)| *f == probe_font_id)
.map(|(_, gid, _)| *gid)
.collect();
let used_sorted: BTreeSet<u16> = probe_glyphs.iter().copied().collect();
let rank = |gid: u16| -> u16 { 1 + used_sorted.iter().position(|g| *g == gid).unwrap() as u16 };
let pdf = lopdf::Document::load_mem(pdf_bytes).expect("output PDF parses");
let subset_bytes = font_program_for_resource(&pdf, &probe_font_id.0)
.unwrap_or_else(|| panic!("no embedded font program for {}", probe_font_id.0));
let subset =
ParsedFont::from_bytes(&subset_bytes, 0, &mut Vec::new()).expect("subset font parses");
let original =
ParsedFont::from_bytes(original_font, 0, &mut Vec::new()).expect("probe font parses");
let mut cmap_checked: Vec<char> = Vec::new();
for ch in ['1', '.', '•'] {
let Some(orig_gid) = original.lookup_glyph_index(ch as u32) else {
continue;
};
if !used_sorted.contains(&orig_gid) {
continue; }
let Some(gid) = subset.lookup_glyph_index(ch as u32) else {
continue; };
assert_eq!(
gid,
rank(orig_gid),
"subset cmap must map {ch:?} to its renumbered gid"
);
assert_ne!(gid, 0, "subset cmap must not map {ch:?} to .notdef");
assert!(
subset.get_or_decode_glyph(gid).is_some(),
"subset glyph {gid} for {ch:?} must have an outline"
);
cmap_checked.push(ch);
}
for ch in must_reach_cmap {
assert!(
cmap_checked.contains(ch),
"cmap coverage of {ch:?} must have been verified (checked: {cmap_checked:?})"
);
}
let mut all_fonts: Vec<FontId> = glyphs.iter().map(|(f, _, _)| f.clone()).collect();
all_fonts.sort_by(|a, b| a.0.cmp(&b.0));
all_fonts.dedup();
for fid in &all_fonts {
let shown = shown_gids_for_font(&pdf, &fid.0);
assert!(
!shown.contains(&0),
"content stream for font {} must never paint gid 0 (.notdef tofu) — F2b",
fid.0
);
}
let shown = shown_gids_for_font(&pdf, &probe_font_id.0);
assert_eq!(
shown.len(),
probe_glyphs.len(),
"every ops glyph must be painted exactly once in the content stream"
);
let subset_gid_to_code = printpdf::font::cff_charset_gid_to_cid_map(&subset_bytes, 0);
let expected: Vec<u16> = probe_glyphs
.iter()
.map(|g| {
let new_gid = rank(*g);
subset_gid_to_code
.as_ref()
.and_then(|m| m.get(&new_gid).copied())
.unwrap_or(new_gid)
})
.collect();
assert_eq!(
shown, expected,
"content-stream codes must be the Identity-H codes (charset CIDs for CID-keyed \
CFF) of the input-order renumbered ops glyph ids"
);
for (i, orig_gid) in used_sorted.iter().enumerate() {
let new_gid = (i + 1) as u16;
assert_eq!(
advance(&subset, new_gid),
advance(&original, *orig_gid),
"subset gid {new_gid} must carry the outline of original gid {orig_gid}"
);
}
}
#[test]
fn shaper_glyphs_survive_subsetting_ttf() {
let (doc, bytes) = render_probe("Subset Probe TTF", TTF_PROBE);
assert_shaped_glyphs_survive(&doc, &bytes, TTF_PROBE, true, &['1', '.']);
}
#[test]
fn shaper_glyphs_survive_subsetting_cff() {
let (doc, bytes) = render_probe("Subset Probe CFF", CFF_PROBE);
assert_shaped_glyphs_survive(&doc, &bytes, CFF_PROBE, false, &[]);
}
fn tool_ready(tool: &str) -> bool {
Command::new(tool)
.arg("-v")
.output()
.map(|out| {
let banner = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
banner.contains("Poppler")
})
.unwrap_or(false)
}
fn run_tool_with_stdin(cmd: &mut Command, input: &[u8]) -> Vec<u8> {
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn tool");
child
.stdin
.take()
.expect("stdin")
.write_all(input)
.expect("write pdf to tool");
let out = child.wait_with_output().expect("tool runs");
out.stdout
}
#[test]
fn pdftotext_sees_ligated_words() {
if !tool_ready("pdftotext") {
eprintln!("skipping: pdftotext (poppler) not installed");
return;
}
let (_doc, bytes) = render_probe("Subset Probe TTF", TTF_PROBE);
let txt = run_tool_with_stdin(Command::new("pdftotext").args(["-", "-"]), &bytes);
let txt = String::from_utf8_lossy(&txt);
for needle in ["Configure", "filter", "offline", "Page 1"] {
assert!(
txt.contains(needle),
"pdftotext must extract {needle:?}, got:\n{txt}"
);
}
}
#[test]
fn page_rasterizes_with_text_ink() {
if !tool_ready("pdftoppm") {
eprintln!("skipping: pdftoppm (poppler) not installed");
return;
}
let (_doc, bytes) = render_probe("Subset Probe TTF", TTF_PROBE);
let ppm = run_tool_with_stdin(Command::new("pdftoppm").args(["-r", "60", "-"]), &bytes);
let (w, h, rgb) = parse_ppm(&ppm);
assert!(w > 0 && h > 0);
let dark = rgb
.chunks_exact(3)
.filter(|p| p[0] < 120 && p[1] < 120 && p[2] < 120)
.count();
assert!(
dark > 100,
"rasterized page must contain text ink (found {dark} dark pixels)"
);
}
fn parse_ppm(data: &[u8]) -> (usize, usize, Vec<u8>) {
let mut fields = Vec::new(); let mut pos = 2; while fields.len() < 3 {
while pos < data.len() && (data[pos].is_ascii_whitespace() || data[pos] == b'#') {
if data[pos] == b'#' {
while pos < data.len() && data[pos] != b'\n' {
pos += 1;
}
} else {
pos += 1;
}
}
let start = pos;
while pos < data.len() && data[pos].is_ascii_digit() {
pos += 1;
}
fields.push(
std::str::from_utf8(&data[start..pos])
.unwrap()
.parse::<usize>()
.expect("ppm header field"),
);
}
pos += 1; (fields[0], fields[1], data[pos..].to_vec())
}