#![cfg(feature = "html")]
use std::collections::BTreeMap;
use std::io::Write;
use std::process::{Command, Stdio};
use printpdf::*;
const FONT: &[u8] = include_bytes!("../examples/assets/fonts/RobotoMedium.ttf");
const BG: (f32, f32, f32) = (1.0, 1.0, 0.8);
const HTML: &str = r#"<html>
<head><style>
body { font-family: 'BG Probe'; font-size: 14px; }
.highlight { background-color: #ffffcc; padding: 5px; }
</style></head>
<body>
<p>before <span class="highlight">XMARKX</span> after</p>
</body></html>"#;
fn render() -> (PdfDocument, Vec<u8>) {
let mut fonts = BTreeMap::new();
fonts.insert("BG Probe".to_string(), Base64OrRaw::Raw(FONT.to_vec()));
let mut warnings = Vec::new();
let doc = PdfDocument::from_html(
HTML,
&BTreeMap::new(),
&fonts,
&GeneratePdfOptions::default(),
&mut warnings,
)
.expect("from_html");
let bytes = doc.save(&PdfSaveOptions::default(), &mut warnings);
(doc, bytes)
}
fn is_bg_color(col: &Color) -> bool {
match col {
Color::Rgb(rgb) => {
(rgb.r - BG.0).abs() < 0.01 && (rgb.g - BG.1).abs() < 0.01 && (rgb.b - BG.2).abs() < 0.01
}
_ => false,
}
}
#[test]
fn inline_background_rects_precede_span_text() {
let (doc, _) = render();
let page = doc.pages.first().expect("one page");
let mut bg_fill_indices = Vec::new(); let mut span_text_start: Option<usize> = None;
let mut current_fill_is_bg = false;
let mut section_start: Option<usize> = None;
let mut section_text = String::new();
for (idx, op) in page.ops.iter().enumerate() {
match op {
Op::SetFillColor { col } => current_fill_is_bg = is_bg_color(col),
Op::DrawPolygon { .. } => {
if current_fill_is_bg {
bg_fill_indices.push(idx);
}
}
Op::StartTextSection => {
section_start = Some(idx);
section_text.clear();
}
Op::ShowText { items } => {
for item in items {
if let TextItem::GlyphIds(gs) = item {
for cp in gs {
if let Some(cid) = &cp.cid {
section_text.push_str(cid);
}
}
}
}
}
Op::EndTextSection => {
if section_text.contains("XMARKX") && span_text_start.is_none() {
span_text_start = section_start;
}
section_start = None;
}
_ => {}
}
}
assert!(
!bg_fill_indices.is_empty(),
"the span's background-color must be painted at all"
);
let text_at = span_text_start.expect("the span's text must be painted");
let last_bg = *bg_fill_indices.iter().max().unwrap();
assert!(
last_bg < text_at,
"every inline background fill (last at op {last_bg}) must precede the \
span's text section (op {text_at}) — background painted OVER its text (F4)"
);
}
fn pdftoppm_ready() -> bool {
Command::new("pdftoppm")
.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)
}
#[test]
fn highlight_text_is_visible_over_background() {
if !pdftoppm_ready() {
eprintln!("skipping: pdftoppm (poppler) not installed");
return;
}
let (_doc, bytes) = render();
let mut child = Command::new("pdftoppm")
.args(["-r", "100", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn pdftoppm");
child
.stdin
.take()
.expect("stdin")
.write_all(&bytes)
.expect("pipe pdf");
let out = child.wait_with_output().expect("pdftoppm runs");
let (w, h, rgb) = parse_ppm(&out.stdout);
assert!(w > 0 && h > 0, "raster output must be non-empty");
let (mut min_x, mut min_y, mut max_x, mut max_y) = (usize::MAX, usize::MAX, 0usize, 0usize);
for y in 0..h {
for x in 0..w {
let p = &rgb[(y * w + x) * 3..(y * w + x) * 3 + 3];
if p[0] > 240 && p[1] > 240 && p[2] > 150 && p[2] < 235 {
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
}
}
}
assert!(
min_x < max_x && min_y < max_y,
"the yellow highlight box must be painted"
);
let inset_x = (max_x - min_x) / 5;
let inset_y = (max_y - min_y) / 5;
let mut dark = 0usize;
for y in (min_y + inset_y)..=(max_y - inset_y) {
for x in (min_x + inset_x)..=(max_x - inset_x) {
let p = &rgb[(y * w + x) * 3..(y * w + x) * 3 + 3];
if p[0] < 120 && p[1] < 120 && p[2] < 120 {
dark += 1;
}
}
}
assert!(
dark > 10,
"the highlight box interior ({min_x},{min_y})-({max_x},{max_y}) must show its \
text (found only {dark} dark pixels) — background painted OVER the text (F4)"
);
}
fn parse_ppm(data: &[u8]) -> (usize, usize, Vec<u8>) {
assert!(data.starts_with(b"P6"), "expected binary PPM from pdftoppm");
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())
}