#![cfg(feature = "text_layout")]
use std::{
io::Write,
process::{Command, Stdio},
};
use printpdf::{
graphics::Point,
ops::PdfFontHandle,
units::{Mm, Pt},
BuiltinFont, FontId, Op, ParsedFont, PdfDocument, PdfPage, PdfSaveOptions, TextItem,
};
const ROBOTO_TTF: &[u8] = include_bytes!("../examples/assets/fonts/RobotoMedium.ttf");
const NOTO_JP_OTF: &[u8] = include_bytes!("../examples/assets/fonts/NotoSansJP-Regular.otf");
fn poppler_ready() -> bool {
["pdffonts", "pdftotext"].iter().all(|tool| {
Command::new(tool)
.arg("-v")
.output()
.map(|out| {
let banner = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
.to_lowercase();
banner.contains("poppler")
})
.unwrap_or(false)
})
}
fn poppler(tool: &str, pdf: &[u8], args: &[&str]) -> (String, String) {
let mut child = Command::new(tool)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| panic!("failed to spawn {tool}: {e}"));
child
.stdin
.as_mut()
.expect("stdin")
.write_all(pdf)
.expect("write pdf to stdin");
let out = child.wait_with_output().expect("wait");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
struct PdfFontsReport {
stderr: String,
rows: Vec<PdfFontsRow>,
}
#[derive(Debug)]
struct PdfFontsRow {
name: String,
embedded: bool,
has_unicode: bool,
}
impl PdfFontsReport {
fn run(pdf: &[u8]) -> Self {
let (stdout, stderr) = poppler("pdffonts", pdf, &["-"]);
let rows = stdout
.lines()
.skip_while(|l| !l.starts_with("---"))
.skip(1)
.filter(|l| !l.trim().is_empty())
.map(|l| {
let cols: Vec<&str> = l.split_whitespace().collect();
let n = cols.len();
PdfFontsRow {
name: cols[0].to_string(),
embedded: cols.get(n - 5).map(|c| *c == "yes").unwrap_or(false),
has_unicode: cols.get(n - 3).map(|c| *c == "yes").unwrap_or(false),
}
})
.collect();
Self { stderr, rows }
}
fn assert_no_font_errors(&self, ctx: &str) {
assert!(
!self.stderr.contains("Embedded font file may be invalid"),
"{ctx}: pdffonts rejected the embedded font program.\n\
This is issue #277 — the PDF is structurally valid but no reader can render \
the text.\npdffonts stderr:\n{}",
self.stderr.trim()
);
assert!(
!self.stderr.contains("Syntax Error"),
"{ctx}: pdffonts reported a syntax error:\n{}",
self.stderr.trim()
);
}
}
fn extract_text(pdf: &[u8]) -> String {
let (stdout, _) = poppler("pdftotext", pdf, &["-", "-"]);
stdout.replace('\u{c}', "").trim().to_string()
}
fn save_opts(subset_fonts: bool) -> PdfSaveOptions {
PdfSaveOptions {
subset_fonts,
optimize: false,
..Default::default()
}
}
fn text_ops(font: PdfFontHandle, text: &str) -> Vec<Op> {
vec![
Op::StartTextSection,
Op::SetFont {
font,
size: Pt(24.0),
},
Op::ShowText {
items: vec![TextItem::Text(text.to_string())],
},
Op::EndTextSection,
]
}
fn pdf_with_external_font(font_bytes: &[u8], text: &str, subset: bool) -> Vec<u8> {
let font = ParsedFont::from_bytes(font_bytes, 0, &mut Vec::new()).expect("parse font");
let mut doc = PdfDocument::new("external-tools-test");
let id: FontId = doc.add_font(&font);
doc.with_pages(vec![PdfPage::new(
Mm(210.0),
Mm(297.0),
text_ops(PdfFontHandle::External(id), text),
)])
.save(&save_opts(subset), &mut Vec::new())
}
fn pdf_with_builtin_font(builtin: BuiltinFont, text: &str) -> Vec<u8> {
let mut doc = PdfDocument::new("external-tools-builtin");
doc.with_pages(vec![PdfPage::new(
Mm(210.0),
Mm(297.0),
text_ops(PdfFontHandle::Builtin(builtin), text),
)])
.save(&save_opts(false), &mut Vec::new())
}
macro_rules! require_poppler {
() => {
if !poppler_ready() {
eprintln!("skipping: poppler (pdffonts + pdftotext) not installed");
return;
}
};
}
#[test]
fn pdffonts_accepts_the_embedded_truetype_program() {
require_poppler!();
for subset in [false, true] {
let pdf = pdf_with_external_font(ROBOTO_TTF, "Roboto", subset);
let report = PdfFontsReport::run(&pdf);
report.assert_no_font_errors(&format!("Roboto subset={subset}"));
let row = report
.rows
.iter()
.find(|r| r.name.contains("Roboto"))
.unwrap_or_else(|| panic!("subset={subset}: Roboto not listed by pdffonts"));
assert!(row.embedded, "subset={subset}: pdffonts says Roboto is not embedded");
assert!(
row.has_unicode,
"subset={subset}: pdffonts says Roboto has no Unicode map — text will not be \
copy-able"
);
}
}
#[test]
fn pdffonts_accepts_the_embedded_cff_program() {
require_poppler!();
for subset in [false, true] {
let pdf = pdf_with_external_font(NOTO_JP_OTF, "こんにちは", subset);
PdfFontsReport::run(&pdf)
.assert_no_font_errors(&format!("NotoSansJP (CFF) subset={subset}"));
}
}
#[test]
fn pdftotext_roundtrips_external_font_text() {
require_poppler!();
let cases = [
("latin", "Roboto"),
("latin sentence", "The quick brown fox jumps over the lazy dog"),
("cyrillic", "Привет, как дела?"),
("digits+punct", "Invoice #1234 — total: 56.78 EUR"),
("accented", "Grüße aus Köln, naïve café"),
];
for (label, text) in cases {
for subset in [false, true] {
let pdf = pdf_with_external_font(ROBOTO_TTF, text, subset);
let got = extract_text(&pdf);
assert_eq!(
got, text,
"{label} (subset={subset}): pdftotext round-trip failed.\n wrote: {text:?}\n \
read : {got:?}\nText in this PDF is not copy-able."
);
}
}
}
#[test]
fn pdftotext_roundtrips_cjk_text() {
require_poppler!();
let text = "こんにちは世界";
for subset in [false, true] {
let pdf = pdf_with_external_font(NOTO_JP_OTF, text, subset);
let got = extract_text(&pdf);
assert_eq!(
got, text,
"CJK (subset={subset}): pdftotext round-trip failed.\n wrote: {text:?}\n read : \
{got:?}"
);
}
}
#[test]
fn pdftotext_roundtrips_builtin_font_text() {
require_poppler!();
for text in ["Helvetica", "The quick brown fox"] {
let pdf = pdf_with_builtin_font(BuiltinFont::Helvetica, text);
let got = extract_text(&pdf);
assert_eq!(
got, text,
"builtin ASCII: pdftotext round-trip failed.\n wrote: {text:?}\n read : {got:?}"
);
}
}
#[test]
fn pdftotext_roundtrips_builtin_font_non_ascii() {
require_poppler!();
let text = "Grüße aus Köln";
let pdf = pdf_with_builtin_font(BuiltinFont::Helvetica, text);
let got = extract_text(&pdf);
assert_eq!(
got, text,
"builtin non-ASCII (issue #273): pdftotext round-trip failed.\n wrote: {text:?}\n \
read : {got:?}\nA built-in font declares WinAnsiEncoding; the content stream must \
carry WinAnsi bytes, not raw UTF-8."
);
}
#[test]
fn pdftotext_roundtrips_builtin_and_external_together() {
require_poppler!();
let font = ParsedFont::from_bytes(ROBOTO_TTF, 0, &mut Vec::new()).expect("parse");
let mut doc = PdfDocument::new("mixed");
let roboto = doc.add_font(&font);
let pdf = doc
.with_pages(vec![PdfPage::new(
Mm(210.0),
Mm(297.0),
vec![
Op::StartTextSection,
Op::SetFont {
font: PdfFontHandle::Builtin(BuiltinFont::Helvetica),
size: Pt(24.0),
},
Op::ShowText {
items: vec![TextItem::Text("Helvetica".to_string())],
},
Op::AddLineBreak,
Op::SetFont {
font: PdfFontHandle::External(roboto),
size: Pt(24.0),
},
Op::ShowText {
items: vec![TextItem::Text("Roboto".to_string())],
},
Op::EndTextSection,
],
)])
.save(&save_opts(false), &mut Vec::new());
PdfFontsReport::run(&pdf).assert_no_font_errors("mixed builtin+external");
let got = extract_text(&pdf);
for want in ["Helvetica", "Roboto"] {
assert!(
got.contains(want),
"mixed fonts: {want:?} missing from extracted text {got:?}"
);
}
}
#[test]
fn pdftotext_roundtrips_quote_operators() {
require_poppler!();
let mut doc = PdfDocument::new("quote-ops-builtin");
let pdf = doc
.with_pages(vec![PdfPage::new(
Mm(210.0),
Mm(297.0),
vec![
Op::StartTextSection,
Op::SetFont {
font: PdfFontHandle::Builtin(BuiltinFont::Helvetica),
size: Pt(24.0),
},
Op::SetLineHeight { lh: Pt(28.0) },
Op::SetTextCursor {
pos: Point {
x: Mm(20.0).into(),
y: Mm(250.0).into(),
},
},
Op::MoveToNextLineShowText {
text: "Café Zürich".to_string(),
},
Op::SetSpacingMoveAndShowText {
word_spacing: 0.0,
char_spacing: 0.0,
text: "Größe".to_string(),
},
Op::EndTextSection,
],
)])
.save(&save_opts(false), &mut Vec::new());
let got = extract_text(&pdf);
for want in ["Café Zürich", "Größe"] {
assert!(
got.contains(want),
"built-in `'`/`\"` operators: {want:?} missing from extracted text {got:?}"
);
}
let font = ParsedFont::from_bytes(ROBOTO_TTF, 0, &mut Vec::new()).expect("parse");
let mut doc = PdfDocument::new("quote-ops-external");
let id = doc.add_font(&font);
let pdf = doc
.with_pages(vec![PdfPage::new(
Mm(210.0),
Mm(297.0),
vec![
Op::StartTextSection,
Op::SetFont {
font: PdfFontHandle::External(id),
size: Pt(24.0),
},
Op::SetLineHeight { lh: Pt(28.0) },
Op::SetTextCursor {
pos: Point {
x: Mm(20.0).into(),
y: Mm(250.0).into(),
},
},
Op::MoveToNextLineShowText {
text: "Grüße".to_string(),
},
Op::EndTextSection,
],
)])
.save(&save_opts(true), &mut Vec::new());
PdfFontsReport::run(&pdf).assert_no_font_errors("external `'` operator");
let got = extract_text(&pdf);
assert_eq!(
got, "Grüße",
"external `'` operator: expected glyph-id encoding, got {got:?}"
);
}
#[test]
fn pdftotext_roundtrips_multipage() {
require_poppler!();
let font = ParsedFont::from_bytes(ROBOTO_TTF, 0, &mut Vec::new()).expect("parse");
let mut doc = PdfDocument::new("multipage");
let id = doc.add_font(&font);
let pages: Vec<PdfPage> = ["Page one text", "Page two text", "Page three text"]
.iter()
.map(|t| {
PdfPage::new(
Mm(210.0),
Mm(297.0),
text_ops(PdfFontHandle::External(id.clone()), t),
)
})
.collect();
let pdf = doc.with_pages(pages).save(&save_opts(true), &mut Vec::new());
PdfFontsReport::run(&pdf).assert_no_font_errors("multipage");
let got = extract_text(&pdf);
for want in ["Page one text", "Page two text", "Page three text"] {
assert!(got.contains(want), "multipage: {want:?} missing from {got:?}");
}
}