use crate::blocks::{Block, BlockKind, ExtractOptions};
use crate::error::{EngineError, Result};
use crate::render::{PixelFormat, RenderOptions};
pub const FIGURE_DPI: f64 = 150.0;
pub const MAX_GRAPHICS_SCAN_PAGES: usize = 50;
#[derive(Debug, Clone, PartialEq)]
pub struct Figure {
pub page: usize,
pub bbox: [f64; 4],
pub name: String,
pub png: Vec<u8>,
}
pub fn figure_name(page: usize, ordinal: usize) -> String {
format!("figure-{:04}-{:02}.png", page + 1, ordinal + 1)
}
fn contains(point: (f64, f64), bbox: &[f64; 4]) -> bool {
point.0 > bbox[0] && point.0 < bbox[2] && point.1 > bbox[1] && point.1 < bbox[3]
}
pub(crate) fn drop_text_over_images(blocks: Vec<Block>, images: &[Vec<[f64; 4]>]) -> Vec<Block> {
blocks
.into_iter()
.filter(|block| {
if block.text.trim().is_empty() {
return true;
}
if matches!(block.kind, BlockKind::Header | BlockKind::Footer) {
return true;
}
let centre = (
(block.bbox[0] + block.bbox[2]) / 2.0,
(block.bbox[1] + block.bbox[3]) / 2.0,
);
!images
.get(block.page)
.is_some_and(|boxes| boxes.iter().any(|bbox| contains(centre, bbox)))
})
.collect()
}
pub(crate) fn merge_figures(
blocks: Vec<Block>,
images: &[Vec<[f64; 4]>],
graphics: &[Vec<[f64; 4]>],
page_has_text: &[bool],
) -> Vec<Block> {
let (mut body, furniture): (Vec<Block>, Vec<Block>) = blocks
.into_iter()
.partition(|block| !matches!(block.kind, BlockKind::Header | BlockKind::Footer));
body.sort_by_key(|block| block.page);
let pages = images.len().max(graphics.len());
let mut merged: Vec<Block> = Vec::with_capacity(body.len() + pages);
let mut at = 0usize;
for page in 0..pages {
let end = body[at..]
.iter()
.take_while(|block| block.page == page)
.count()
+ at;
let text = &body[at..end];
at = end;
if !page_has_text.get(page).copied().unwrap_or(false) {
if let Some(boxes) = images.get(page) {
let mut figures: Vec<[f64; 4]> = boxes.clone();
figures.sort_by(|a, b| b[3].total_cmp(&a[3]));
for bbox in figures {
merged.push(Block::new(BlockKind::Figure, page, bbox, String::new(), 0));
}
}
merged.extend(text.iter().cloned());
continue;
}
let mut figures: Vec<([f64; 4], f64)> = Vec::new();
if let Some(boxes) = images.get(page) {
figures.extend(boxes.iter().map(|bbox| (*bbox, bbox[3])));
}
if let Some(boxes) = graphics.get(page) {
figures.extend(boxes.iter().map(|bbox| (*bbox, bbox[3])));
}
figures.sort_by(|a, b| b.1.total_cmp(&a.1));
let mut figures = figures.into_iter().peekable();
for block in text {
while figures.peek().is_some_and(|(_, top)| *top >= block.bbox[3]) {
if let Some((bbox, _)) = figures.next() {
merged.push(Block::new(BlockKind::Figure, page, bbox, String::new(), 0));
}
}
merged.push(block.clone());
}
for (bbox, _) in figures {
merged.push(Block::new(BlockKind::Figure, page, bbox, String::new(), 0));
}
}
merged.extend(body[at..].iter().cloned());
merged.extend(furniture);
for (index, block) in merged.iter_mut().enumerate() {
block.index = index;
}
merged
}
pub(crate) fn render_crops(
doc: &crate::PdfDocument,
page: usize,
boxes: &[[f64; 4]],
) -> Result<Vec<Vec<u8>>> {
if boxes.is_empty() {
return Ok(Vec::new());
}
let rendered = doc.render_page(
page,
&RenderOptions {
dpi: FIGURE_DPI,
..Default::default()
},
)?;
if rendered.pixel_format != PixelFormat::Rgba8 {
return Err(EngineError::RenderError(format!(
"figure render of page {} came back {:?}, not RGBA",
page, rendered.pixel_format
)));
}
let scale = FIGURE_DPI / 72.0;
let (width, height) = (rendered.width, rendered.height);
let mut out = Vec::with_capacity(boxes.len());
for bbox in boxes {
let x0 = ((bbox[0] * scale).floor() as i64).clamp(0, width as i64);
let x1 = ((bbox[2] * scale).ceil() as i64).clamp(0, width as i64);
let y1 = (height as i64 - (bbox[1] * scale).floor() as i64).clamp(0, height as i64);
let y0 = (height as i64 - (bbox[3] * scale).ceil() as i64).clamp(0, height as i64);
let (x0, x1) = (x0.min(x1), x0.max(x1));
let (y0, y1) = (y0.min(y1), y0.max(y1));
let (crop_w, crop_h) = ((x1 - x0).max(1) as u32, (y1 - y0).max(1) as u32);
let mut rgb = vec![255u8; crop_w as usize * crop_h as usize * 3];
if x1 > x0 && y1 > y0 {
for (row, y) in (y0..y1).enumerate() {
for (col, x) in (x0..x1).enumerate() {
let from = (y as usize * width as usize + x as usize) * 4;
let Some(px) = rendered.pixels.get(from..from + 4) else {
continue;
};
let missing = 255 - px[3];
let to = (row * crop_w as usize + col) * 3;
if let Some(slot) = rgb.get_mut(to..to + 3) {
slot[0] = px[0].saturating_add(missing);
slot[1] = px[1].saturating_add(missing);
slot[2] = px[2].saturating_add(missing);
}
}
}
}
out.push(png_bytes(crop_w, crop_h, &rgb));
}
Ok(out)
}
pub(crate) fn png_bytes(width: u32, height: u32, rgb: &[u8]) -> Vec<u8> {
let stride = width as usize * 3;
let mut raw = Vec::with_capacity(height as usize * (stride + 1));
for y in 0..height as usize {
raw.push(0);
match rgb.get(y * stride..(y + 1) * stride) {
Some(row) => raw.extend_from_slice(row),
None => raw.extend(std::iter::repeat_n(0, stride)),
}
}
let mut zlib = vec![0x78, 0x01];
let mut chunks = raw.chunks(65535).peekable();
while let Some(chunk) = chunks.next() {
let last = chunks.peek().is_none();
zlib.push(u8::from(last));
let len = chunk.len() as u16;
zlib.extend_from_slice(&len.to_le_bytes());
zlib.extend_from_slice(&(!len).to_le_bytes());
zlib.extend_from_slice(chunk);
}
if raw.is_empty() {
zlib.extend_from_slice(&[1, 0, 0, 255, 255]);
}
zlib.extend_from_slice(&adler32(&raw).to_be_bytes());
let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10];
let mut ihdr = Vec::with_capacity(13);
ihdr.extend_from_slice(&width.to_be_bytes());
ihdr.extend_from_slice(&height.to_be_bytes());
ihdr.extend_from_slice(&[8, 2, 0, 0, 0]);
push_chunk(&mut png, b"IHDR", &ihdr);
push_chunk(&mut png, b"IDAT", &zlib);
push_chunk(&mut png, b"IEND", &[]);
png
}
fn push_chunk(png: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
png.extend_from_slice(&(data.len() as u32).to_be_bytes());
png.extend_from_slice(kind);
png.extend_from_slice(data);
let mut crc_input = Vec::with_capacity(4 + data.len());
crc_input.extend_from_slice(kind);
crc_input.extend_from_slice(data);
png.extend_from_slice(&crc32(&crc_input).to_be_bytes());
}
fn crc32(data: &[u8]) -> u32 {
let mut crc = 0xffff_ffffu32;
for byte in data {
crc ^= u32::from(*byte);
for _ in 0..8 {
let lsb = crc & 1;
crc >>= 1;
if lsb == 1 {
crc ^= 0xedb8_8320;
}
}
}
!crc
}
fn adler32(data: &[u8]) -> u32 {
let mut low: u32 = 1;
let mut high: u32 = 0;
for byte in data {
low = (low + u32::from(*byte)) % 65521;
high = (high + low) % 65521;
}
(high << 16) | low
}
pub(crate) fn resolve_figure_texts(
doc: &crate::PdfDocument,
blocks: &mut [Block],
options: &ExtractOptions,
) -> Result<()> {
use base64::Engine as _;
use std::collections::BTreeMap;
let mut pages: BTreeMap<usize, Vec<(usize, [f64; 4])>> = BTreeMap::new();
for (at, block) in blocks.iter().enumerate() {
if matches!(block.kind, BlockKind::Figure) {
pages.entry(block.page).or_default().push((at, block.bbox));
}
}
if pages.is_empty() {
return Ok(());
}
if let Some(dir) = options.figure_image_dir.as_ref() {
std::fs::create_dir_all(dir).map_err(EngineError::Io)?;
}
for (page, refs) in &pages {
let boxes: Vec<[f64; 4]> = refs.iter().map(|(_, bbox)| *bbox).collect();
let renders = render_crops(doc, *page, &boxes)?;
for (ordinal, ((at, _), png)) in refs.iter().zip(renders).enumerate() {
let name = figure_name(*page, ordinal);
let text = match options.figure_image_dir.as_ref() {
Some(dir) => {
let path = dir.join(&name);
std::fs::write(&path, &png).map_err(EngineError::Io)?;
path.to_string_lossy().into_owned()
}
None => {
let encoded = base64::engine::general_purpose::STANDARD.encode(&png);
format!("data:image/png;base64,{encoded}")
}
};
if let Some(block) = blocks.get_mut(*at) {
block.text = text;
}
}
}
Ok(())
}