//! Extração de imagens embutidas (XObjects `/Subtype /Image`). //! //! O RasgaPDF expõe **metadados** das imagens no JSON espacial e, opcionalmente, //! grava o **stream codificado original** em disco (sidecar) via [`extract_images`]. //! Nenhum pixel é decodificado: o stream é copiado como está (ex.: `DCTDecode` → JPEG). use std::error::Error; use std::path::{Path, PathBuf}; use mupdf::pdf::{PageImageInfo, PdfDocument, PdfPage}; use mupdf::Document; use sha2::{Digest, Sha256}; use crate::types::{BoundingBox, SpatialImage}; /// Uma imagem embutida extraída, já com os bytes originais do stream. #[derive(Debug, Clone)] pub struct ExtractedImageData { /// Número da página (1-indexed). pub page: usize, /// Metadados espaciais. pub image: SpatialImage, /// Stream codificado original (pode ser vazio). pub bytes: Vec, } /// Retorna o último nome de filtro declarado em `/Filter`, preservando o caso original. /// /// Em `/Filter [/ASCII85Decode /DCTDecode]` o último filtro é o codec real da imagem. pub fn last_filter_name(filter: Option<&str>) -> Option { let raw = filter?; raw.rsplit(|c: char| !c.is_ascii_alphanumeric()) .find(|token| !token.is_empty()) .map(str::to_string) } /// Versão em caixa alta do filtro, para comparação. pub fn normalized_filter(filter: Option<&str>) -> Option { last_filter_name(filter).map(|f| f.to_ascii_uppercase()) } /// MIME type do stream quando ele já é um arquivo de imagem autocontido. pub fn mime_from_filter(filter: Option<&str>) -> Option<&'static str> { Some(match normalized_filter(filter)?.as_str() { "DCTDECODE" | "DCT" => "image/jpeg", "JPXDECODE" | "JPX" => "image/jp2", "JBIG2DECODE" => "image/jbig2", "CCITTFAXDECODE" | "CCF" => "image/tiff", _ => return None, }) } /// Extensão de arquivo sugerida para o stream codificado. pub fn extension_for_filter(filter: Option<&str>) -> &'static str { match normalized_filter(filter).as_deref() { Some("DCTDECODE") | Some("DCT") => "jpg", Some("JPXDECODE") | Some("JPX") => "jp2", Some("JBIG2DECODE") => "jb2", Some("CCITTFAXDECODE") | Some("CCF") => "tif", Some("FLATEDECODE") | Some("FL") => "flate.raw", Some("LZWDECODE") | Some("LZW") => "lzw.raw", _ => "bin", } } /// SHA-256 em hexadecimal minúsculo. pub fn sha256_hex(bytes: &[u8]) -> String { use std::fmt::Write as _; let digest = Sha256::digest(bytes); let mut out = String::with_capacity(digest.len() * 2); for byte in digest { let _ = write!(out, "{byte:02x}"); } out } /// Monta os metadados espaciais de uma imagem. pub fn build_spatial_image( image_id: usize, info: &PageImageInfo, bbox: Option, bytes: Option<&[u8]>, ) -> SpatialImage { SpatialImage { image_id, name: info.name.clone(), xref: info.xref, bbox, pixel_width: info.width, pixel_height: info.height, bits_per_component: info.bits_per_component, color_space: info.color_space.clone(), filter: last_filter_name(info.filter.as_deref()), mime_type: mime_from_filter(info.filter.as_deref()).map(str::to_string), byte_length: bytes.map(|b| b.len()), sha256: bytes.map(sha256_hex), file: None, } } /// Lê o stream codificado de uma imagem pelo seu xref. pub fn extract_image_bytes(doc: &PdfDocument, xref: i32) -> Option> { if xref <= 0 { return None; } doc.extract_image(xref).ok().map(|img| img.encoded) } /// Escreve os streams extraídos em `dir` e preenche `image.file` com o nome gerado. /// /// Retorna a quantidade de arquivos gravados. Imagens sem bytes são ignoradas. pub fn write_images_to_dir( images: &mut [ExtractedImageData], dir: &Path, ) -> Result> { std::fs::create_dir_all(dir)?; let mut written = 0; for record in images.iter_mut() { if record.bytes.is_empty() { continue; } let extension = extension_for_filter(record.image.filter.as_deref()); let file_name = format!( "page_{:03}_img_{:03}_xref{}.{}", record.page, record.image.image_id, record.image.xref, extension ); std::fs::write(dir.join(&file_name), &record.bytes)?; record.image.file = Some(file_name); written += 1; } Ok(written) } /// Monta o manifesto JSON das imagens extraídas. pub fn build_manifest( images: &[ExtractedImageData], written: usize, file_name: &str, ) -> serde_json::Value { serde_json::json!({ "file_name": file_name, "total_images": images.len(), "written": written, "images": images .iter() .map(|record| serde_json::json!({ "page": record.page, "image": record.image, })) .collect::>(), }) } /// Grava `/manifest.json` e devolve o caminho escrito. pub fn write_manifest( images: &[ExtractedImageData], written: usize, file_name: &str, dir: &Path, ) -> Result> { std::fs::create_dir_all(dir)?; let path = dir.join("manifest.json"); let file = std::fs::File::create(&path)?; serde_json::to_writer_pretty(file, &build_manifest(images, written, file_name))?; Ok(path) } /// Extrai todas as imagens embutidas do documento, com os bytes originais. /// /// Percorre as páginas sequencialmente (o documento MuPDF não é `Send`, e a extração /// de bytes é dominada por I/O de stream, não por CPU). pub fn extract_images( pdf_path: &str, ) -> Result, Box> { let doc = Document::open(pdf_path)?; let page_count = doc.page_count()? as usize; let pdf_doc = PdfDocument::try_from(doc) .map_err(|e| format!("document is not a PDF: {e}"))?; let mut out: Vec = Vec::new(); for page_idx in 0..page_count { let page = pdf_doc.load_page(page_idx as i32)?; let pdf_page = PdfPage::try_from(page) .map_err(|e| format!("failed to convert page {}: {e}", page_idx + 1))?; let infos = pdf_page.images().unwrap_or_default(); for (idx, info) in infos.iter().enumerate() { let bytes = extract_image_bytes(&pdf_doc, info.xref).unwrap_or_default(); let has_bytes = !bytes.is_empty(); let image = build_spatial_image( idx + 1, info, None, if has_bytes { Some(bytes.as_slice()) } else { None }, ); out.push(ExtractedImageData { page: page_idx + 1, image, bytes, }); } } Ok(out) }