use crate::util::media_target::{RASTER_DECODE_MAX_ALLOC_BYTES, RASTER_DECODE_MAX_DIMENSION_PX};
use hayro::hayro_interpret::InterpreterSettings;
use hayro::hayro_syntax::content::TypedIter;
use hayro::hayro_syntax::content::ops::TypedInstruction;
use hayro::hayro_syntax::object::dict::Dict;
use hayro::hayro_syntax::object::stream::{ImageColorSpace, ImageDecodeParams, Stream};
use hayro::hayro_syntax::object::{Array, Name, Object, ObjectIdentifier};
use hayro::hayro_syntax::page::Page;
use hayro::hayro_syntax::{DecryptionError, LoadPdfError, Pdf};
use hayro::vello_cpu::color::Rgba8;
use hayro::vello_cpu::color::palette::css::WHITE;
use hayro::{RenderCache, RenderSettings};
use image::codecs::jpeg::JpegEncoder;
use image::{RgbImage, RgbaImage};
use pdf_extract::{Document, PlainTextOutput, output_doc_page};
use quick_xml::Reader;
use quick_xml::events::{BytesRef, Event};
use std::collections::{HashMap, HashSet, VecDeque};
use std::io::{Cursor, Read, Seek};
use std::path::{Path, PathBuf};
use zip::ZipArchive;
pub(crate) const INLINE_TEXT_MAX_CHARS: usize = 5000;
const MIN_PAGE_TEXT_CHARS: usize = 16;
const RASTER_LONG_SIDE_PX: f32 = 1600.0;
const RASTER_MAX_SCALE: f32 = 3.0;
const RASTER_JPEG_QUALITY: u8 = 90;
const PDF_MAGIC: &[u8] = b"%PDF-";
const ZIP_MAGIC: &[u8] = b"PK\x03\x04";
const CFB_MAGIC: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
const DOCX_EXTENSIONS: &[&str] = &["docx", "docm"];
const MAX_ZIP_ENTRY_BYTES: u64 = 64 * 1024 * 1024;
const DOCX_BODY_PART: &str = "word/document.xml";
const DOCX_MEDIA_PREFIX: &str = "word/media/";
const EMBEDDED_IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp"];
const PLAIN_TEXT_EXTENSIONS: &[&str] = &[
"md", "markdown", "txt", "text", "rst", "adoc", "org", "csv", "json", "yaml", "yml", "toml",
"log",
];
const UNREADABLE_REASON: &str = "could not be read";
static DOCUMENT_CONVERSIONS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(2);
pub(crate) const NO_TEXT_LAYER_NOTE: &str =
"no text could be extracted — the pages were provided as images";
pub(crate) const NO_TEXT_NOTE: &str = "no text could be extracted";
#[must_use]
pub(crate) fn spilled_text_note(chars: usize, path: &Path) -> String {
format!(
"the extracted text is too long to inline ({chars} characters); the full text was saved to {} — read that file.",
path.display()
)
}
#[derive(Debug)]
pub(crate) enum DocOutcome {
Text {
text: String,
images: Vec<PathBuf>,
notes: Vec<String>,
all_page_text_lost: bool,
},
Unreadable { reason: String },
Unsupported,
}
enum DocumentKind {
Pdf,
Docx,
EncryptedOoxml,
PlainText,
Unsupported,
}
fn classify(bytes: &[u8], path: &Path) -> DocumentKind {
if bytes.starts_with(PDF_MAGIC) {
return DocumentKind::Pdf;
}
if bytes.starts_with(CFB_MAGIC) {
return if crate::util::has_extension(path, DOCX_EXTENSIONS) {
DocumentKind::EncryptedOoxml
} else {
DocumentKind::Unsupported
};
}
if bytes.starts_with(ZIP_MAGIC) {
return if crate::util::has_extension(path, DOCX_EXTENSIONS) {
DocumentKind::Docx
} else {
DocumentKind::Unsupported
};
}
if crate::util::has_extension(path, PLAIN_TEXT_EXTENSIONS) || is_plain_utf8(bytes) {
return DocumentKind::PlainText;
}
DocumentKind::Unsupported
}
#[must_use]
pub(crate) fn needs_extraction(head: &[u8], file_name: &str) -> bool {
matches!(
classify(head, Path::new(file_name)),
DocumentKind::Pdf | DocumentKind::Docx | DocumentKind::EncryptedOoxml
)
}
#[must_use]
fn convert_document(bytes: &[u8], file_name: &str, out_dir: &Path) -> DocOutcome {
match classify(bytes, Path::new(file_name)) {
DocumentKind::Pdf => convert_pdf(bytes, out_dir),
DocumentKind::EncryptedOoxml => DocOutcome::Unreadable {
reason: "password-protected".to_string(),
},
DocumentKind::Docx => convert_docx(bytes, out_dir),
DocumentKind::PlainText => DocOutcome::Text {
text: String::from_utf8_lossy(bytes).into_owned(),
images: Vec::new(),
notes: Vec::new(),
all_page_text_lost: false,
},
DocumentKind::Unsupported => DocOutcome::Unsupported,
}
}
pub(crate) async fn convert_document_file(
path: &Path,
file_name: &str,
out_dir: &Path,
) -> DocOutcome {
let _permit = DOCUMENT_CONVERSIONS.acquire().await;
let bytes = match tokio::fs::read(path).await {
Ok(bytes) => bytes,
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"Failed to read the document"
);
return DocOutcome::Unreadable {
reason: UNREADABLE_REASON.to_string(),
};
}
};
let name = file_name.to_string();
let out_dir = out_dir.to_path_buf();
match tokio::task::spawn_blocking(move || convert_document(&bytes, &name, &out_dir)).await {
Ok(outcome) => outcome,
Err(e) => {
tracing::warn!(error = %e, "Document conversion failed");
DocOutcome::Unreadable {
reason: UNREADABLE_REASON.to_string(),
}
}
}
}
fn convert_pdf(bytes: &[u8], out_dir: &Path) -> DocOutcome {
let pdf = match std::panic::catch_unwind(|| Pdf::new(bytes.to_vec())) {
Ok(Ok(pdf)) => Some(pdf),
Ok(Err(LoadPdfError::Decryption(DecryptionError::PasswordProtected))) => {
return DocOutcome::Unreadable {
reason: "password-protected".to_string(),
};
}
Ok(Err(_)) | Err(_) => None,
};
let reader = PdfTextReader::open(bytes);
if matches!(reader, PdfTextReader::Unavailable) && pdf.is_none() {
return DocOutcome::Unreadable {
reason: "could not be parsed".to_string(),
};
}
let page_count = reader
.page_count()
.max(pdf.as_ref().map_or(0, |pdf| pdf.pages().len()));
ensure_out_dir(out_dir);
let mut text_pages: Vec<String> = Vec::new();
let mut images: Vec<PathBuf> = Vec::new();
let mut skipped = SkippedImages::default();
let mut unrendered = 0usize;
let mut written = WrittenImages::default();
let mut unread = UnreadablePages {
total: page_count,
..UnreadablePages::default()
};
for index in 0..page_count {
let page_number = index + 1;
let page = pdf.as_ref().and_then(|pdf| pdf.pages().get(index));
let read = reader.read_page(page_number);
if read.is_some() {
unread.attempted += 1;
}
let (text, failed) = match read {
Some(PageText::Read(text)) => (text, false),
Some(PageText::Failed(text)) => (text, true),
None => (String::new(), false),
};
let kept = is_usable_page_text(&text).then(|| text.trim().to_string());
if failed {
match kept {
Some(text) => {
text_pages.push(text);
unread.partial.push(page_number);
}
None => unread.lost.push(page_number),
}
rasterize_page(page, page_number, out_dir, &mut images, &mut unrendered);
} else if let Some(text) = kept {
text_pages.push(text);
if let Some(page) = page {
write_embedded_images(
page,
page_number,
out_dir,
&mut written,
&mut images,
&mut skipped,
);
}
} else {
rasterize_page(page, page_number, out_dir, &mut images, &mut unrendered);
}
}
if unrendered > 0 {
tracing::warn!(
pages = unrendered,
"document: pages with no page-tree entry could not be delivered as images"
);
}
let mut notes = unread.notes();
notes.extend(skipped.notes());
DocOutcome::Text {
text: text_pages.join("\n\n"),
images,
notes,
all_page_text_lost: unread.lost_all_page_text(),
}
}
enum PageText {
Read(String),
Failed(String),
}
#[expect(clippy::large_enum_variant)] enum PdfTextReader {
Open { doc: Document, pages: usize },
Unavailable,
}
impl PdfTextReader {
fn open(bytes: &[u8]) -> Self {
let Ok(Ok(mut doc)) =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Document::load_mem(bytes)))
else {
return Self::Unavailable;
};
if doc.is_encrypted() {
let decrypted =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| doc.decrypt("")));
if !matches!(decrypted, Ok(Ok(()))) {
return Self::Unavailable;
}
}
let Ok(pages) =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| doc.get_pages().len()))
else {
return Self::Unavailable;
};
if pages == 0 {
return Self::Unavailable;
}
Self::Open { doc, pages }
}
fn page_count(&self) -> usize {
match self {
Self::Open { pages, .. } => *pages,
Self::Unavailable => 0,
}
}
fn read_page(&self, page_number: usize) -> Option<PageText> {
let Self::Open { doc, pages } = self else {
return Some(PageText::Failed(String::new()));
};
if !(1..=*pages).contains(&page_number) {
return None;
}
#[allow(clippy::cast_possible_truncation)]
let number = page_number as u32;
let mut text = String::new();
let outcome = crate::shutdown::contain_panics(|| {
let mut output = PlainTextOutput::new(&mut text);
output_doc_page(doc, &mut output, number)
});
Some(match outcome {
Ok(Ok(())) => PageText::Read(text),
Ok(Err(_)) | Err(_) => PageText::Failed(text),
})
}
}
#[derive(Debug, Default)]
struct UnreadablePages {
lost: Vec<usize>,
partial: Vec<usize>,
attempted: usize,
total: usize,
}
impl UnreadablePages {
fn lost_all_page_text(&self) -> bool {
self.attempted > 0 && self.lost.len() == self.attempted
}
fn notes(&self) -> Vec<String> {
let mut notes = Vec::new();
for (pages, suffix) in [(&self.lost, ""), (&self.partial, " in full")] {
if !pages.is_empty() {
notes.push(format!(
"the text of {} could not be read{suffix} ({} of {} pages)",
page_list(pages),
pages.len(),
self.total
));
}
}
notes
}
}
const MAX_NOTE_RANGES: usize = 8;
fn page_list(pages: &[usize]) -> String {
let noun = if pages.len() == 1 { "page" } else { "pages" };
format!("{noun} {}", page_ranges(pages))
}
fn page_ranges(pages: &[usize]) -> String {
let mut ranges: Vec<String> = Vec::new();
let mut remaining = pages.iter().copied().peekable();
while let Some(first) = remaining.next() {
let mut last = first;
while remaining.peek() == Some(&(last + 1)) {
last = remaining.next().unwrap_or(last);
}
if ranges.len() == MAX_NOTE_RANGES {
ranges.push("…".to_string());
break;
}
ranges.push(match last - first {
0 => first.to_string(),
1 => format!("{first}, {last}"),
_ => format!("{first}-{last}"),
});
}
ranges.join(", ")
}
fn convert_docx(bytes: &[u8], out_dir: &Path) -> DocOutcome {
let Ok(mut archive) = ZipArchive::new(Cursor::new(bytes)) else {
return unreadable_docx();
};
let Some(body_xml) = read_zip_entry(&mut archive, DOCX_BODY_PART) else {
return unreadable_docx();
};
let Some(text) = docx_body_text(&body_xml) else {
return unreadable_docx();
};
ensure_out_dir(out_dir);
let entries: Vec<String> = archive
.file_names()
.filter(|name| name.starts_with(DOCX_MEDIA_PREFIX) && Path::new(name).extension().is_some())
.map(str::to_owned)
.collect();
let mut images = Vec::new();
let mut next_suffix = HashMap::new();
let mut skipped = SkippedImages::default();
for entry in entries {
let file_name = crate::util::neutralized_name(crate::util::file_name_or_path(&entry));
if !crate::util::has_extension(Path::new(&file_name), EMBEDDED_IMAGE_EXTENSIONS) {
skipped.add(SkipReason::Unsupported);
continue;
}
let Some(content) = read_zip_entry(&mut archive, &entry) else {
tracing::warn!(%file_name, "document: failed to read embedded .docx image");
skipped.add(SkipReason::Failed);
continue;
};
let path = unique_out_path(out_dir, &file_name, &mut next_suffix);
match std::fs::write(&path, &content) {
Ok(()) => images.push(path),
Err(e) => {
tracing::warn!(%file_name, error = %e, "document: failed to write embedded .docx image");
skipped.add(SkipReason::Failed);
}
}
}
DocOutcome::Text {
text,
images,
notes: skipped.notes(),
all_page_text_lost: false,
}
}
fn unreadable_docx() -> DocOutcome {
DocOutcome::Unreadable {
reason: "corrupt or unsupported .docx".to_string(),
}
}
fn unique_out_path(
out_dir: &Path,
file_name: &str,
next_suffix: &mut HashMap<String, u32>,
) -> PathBuf {
let n = next_suffix.entry(file_name.to_owned()).or_insert(1);
while out_dir
.join(crate::util::suffixed_name(file_name, *n))
.exists()
{
*n += 1;
}
out_dir.join(crate::util::suffixed_name(file_name, *n))
}
#[derive(Default, Clone, Copy)]
struct SkippedImages {
unsupported: usize,
failed: usize,
oversized: usize,
}
impl SkippedImages {
fn add(&mut self, reason: SkipReason) {
match reason {
SkipReason::Unsupported => self.unsupported += 1,
SkipReason::Failed => self.failed += 1,
SkipReason::Oversized => self.oversized += 1,
}
}
fn notes(&self) -> Vec<String> {
let mut notes = Vec::new();
for (count, text) in [
(self.unsupported, "in a format this pipeline cannot convert"),
(self.failed, "that could not be extracted"),
(self.oversized, "over the image size limit"),
] {
if count > 0 {
notes.push(format!("skipped {count} embedded image(s) {text}"));
}
}
notes
}
}
#[derive(Clone, Copy)]
enum SkipReason {
Unsupported,
Failed,
Oversized,
}
#[derive(Default)]
struct WrittenImages {
x_objects: HashSet<ObjectIdentifier>,
forms: HashSet<ObjectIdentifier>,
}
fn write_embedded_images(
page: &Page<'_>,
page_number: usize,
out_dir: &Path,
written: &mut WrittenImages,
images: &mut Vec<PathBuf>,
skipped: &mut SkippedImages,
) {
let mut slot = 0;
let (image_streams, form_streams) = page_image_streams(page);
for stream in image_streams {
if let Some(id) = stream.dict().obj_id()
&& !written.x_objects.insert(id)
{
continue;
}
if let Some(reason) = write_embedded_image(&stream, page_number, &mut slot, out_dir, images)
{
skipped.add(reason);
}
}
if let Some(content) = page.page_stream() {
write_inline_images(content, page_number, &mut slot, out_dir, images, skipped);
}
for form in form_streams {
if let Some(id) = form.dict().obj_id()
&& !written.forms.insert(id)
{
continue;
}
if let Ok(content) = form.decoded() {
write_inline_images(&content, page_number, &mut slot, out_dir, images, skipped);
}
}
}
fn write_embedded_image(
stream: &Stream<'_>,
page_number: usize,
slot: &mut usize,
out_dir: &Path,
images: &mut Vec<PathBuf>,
) -> Option<SkipReason> {
let jpeg = match embedded_image_jpeg(stream) {
Ok(jpeg) => jpeg,
Err(reason) => return Some(reason),
};
let path = out_dir.join(format!("page_{page_number}_img_{slot}.jpg"));
*slot += 1;
match std::fs::write(&path, jpeg) {
Ok(()) => {
images.push(path);
None
}
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "document: failed to write embedded PDF image");
Some(SkipReason::Failed)
}
}
}
fn write_inline_images(
content: &[u8],
page_number: usize,
slot: &mut usize,
out_dir: &Path,
images: &mut Vec<PathBuf>,
skipped: &mut SkippedImages,
) {
let mut operations = TypedIter::new(content);
while let Some(operation) = operations.next() {
if let TypedInstruction::InlineImage(image) = operation
&& let Some(reason) = write_embedded_image(image.0, page_number, slot, out_dir, images)
{
skipped.add(reason);
}
}
}
fn page_image_streams<'a>(page: &Page<'a>) -> (Vec<Stream<'a>>, Vec<Stream<'a>>) {
let mut images = Vec::new();
let mut forms = Vec::new();
let mut visited: HashSet<ObjectIdentifier> = HashSet::new();
let mut pending: VecDeque<Stream<'a>> = page_x_objects(page).into();
while let Some(stream) = pending.pop_front() {
if let Some(id) = stream.dict().obj_id()
&& !visited.insert(id)
{
continue;
}
match x_object_subtype(&stream) {
XObjectSubtype::Image => images.push(stream),
XObjectSubtype::Form => {
pending.extend(form_x_objects(&stream));
forms.push(stream);
}
XObjectSubtype::Other => {}
}
}
(images, forms)
}
fn page_x_objects<'a>(page: &Page<'a>) -> Vec<Stream<'a>> {
let mut seen: HashSet<Name<'a>> = HashSet::new();
let mut streams = Vec::new();
let mut level = Some(page.resources());
while let Some(resources) = level {
for name in resources.x_objects.keys() {
if seen.insert(name.clone())
&& let Some(stream) = resources.get_x_object(&name)
{
streams.push(stream);
}
}
level = resources.parent();
}
streams
}
fn form_x_objects<'a>(form: &Stream<'a>) -> Vec<Stream<'a>> {
let Some(x_objects) = form
.dict()
.get::<Dict<'a>>(b"Resources")
.and_then(|resources| resources.get::<Dict<'a>>(b"XObject"))
else {
return Vec::new();
};
x_objects
.keys()
.filter_map(|name| x_objects.get::<Stream<'a>>(&name))
.collect()
}
enum XObjectSubtype {
Image,
Form,
Other,
}
fn x_object_subtype(stream: &Stream<'_>) -> XObjectSubtype {
match stream.dict().get::<Name<'_>>(b"Subtype").as_deref() {
Some(b"Image") => XObjectSubtype::Image,
Some(b"Form") => XObjectSubtype::Form,
_ => XObjectSubtype::Other,
}
}
fn embedded_image_jpeg(stream: &Stream<'_>) -> Result<Vec<u8>, SkipReason> {
let dict = stream.dict();
let width = dict
.get::<u32>(b"Width")
.or_else(|| dict.get::<u32>(b"W"))
.filter(|width| *width > 0)
.ok_or(SkipReason::Unsupported)?;
let height = dict
.get::<u32>(b"Height")
.or_else(|| dict.get::<u32>(b"H"))
.filter(|height| *height > 0)
.ok_or(SkipReason::Unsupported)?;
if width > RASTER_DECODE_MAX_DIMENSION_PX || height > RASTER_DECODE_MAX_DIMENSION_PX {
return Err(SkipReason::Oversized);
}
if is_lone_dct(dict) {
return Ok(stream.raw_data().into_owned());
}
let is_mask = dict
.get::<bool>(b"ImageMask")
.or_else(|| dict.get::<bool>(b"IM"))
.unwrap_or(false);
let colour = if is_mask {
ColourSpace::Gray
} else {
let colour_space = dict
.get::<Object<'_>>(b"ColorSpace")
.or_else(|| dict.get::<Object<'_>>(b"CS"))
.ok_or(SkipReason::Unsupported)?;
parse_colour_space(colour_space).ok_or(SkipReason::Unsupported)?
};
let bits_per_component = dict
.get::<u8>(b"BitsPerComponent")
.or_else(|| dict.get::<u8>(b"BPC"))
.unwrap_or(if is_mask { 1 } else { 8 });
if !matches!(bits_per_component, 1 | 2 | 4 | 8 | 16) {
return Err(SkipReason::Unsupported);
}
let declared_samples = u64::from(width)
* u64::from(height)
* u64::from(colour.components())
* u64::from(bits_per_component)
/ 8;
if declared_samples > RASTER_DECODE_MAX_ALLOC_BYTES {
return Err(SkipReason::Oversized);
}
let decoded = stream
.decoded_image(&ImageDecodeParams {
is_indexed: matches!(colour, ColourSpace::Indexed(_)),
bpc: Some(bits_per_component),
num_components: Some(colour.components()),
target_dimension: Some(scaled_dimensions(width, height)),
width,
height,
})
.map_err(|_| SkipReason::Failed)?;
if decoded.data.is_empty() {
return Err(SkipReason::Failed);
}
let (width, height, bits_per_component, colour) = match &decoded.image_data {
Some(data) => {
let colour = if matches!(colour, ColourSpace::Indexed(_)) {
colour
} else {
colour_space_of(data.color_space).map_err(|()| SkipReason::Unsupported)?
};
(data.width, data.height, data.bits_per_component, colour)
}
None => (width, height, bits_per_component, colour),
};
if !matches!(bits_per_component, 1 | 2 | 4 | 8 | 16) {
return Err(SkipReason::Unsupported);
}
let decode = decode_ranges(dict, colour.components());
let shape = ImageShape {
width,
height,
bits_per_component,
colour,
decode,
alpha: decoded
.image_data
.as_ref()
.and_then(|data| data.alpha.as_deref()),
};
let raster = samples_to_raster(&shape, &decoded.data).ok_or(SkipReason::Failed)?;
encode_jpeg(&raster).map_err(|()| SkipReason::Failed)
}
fn is_lone_dct(dict: &Dict<'_>) -> bool {
fn is_dct(name: &Name<'_>) -> bool {
name.as_ref() == b"DCTDecode" || name.as_ref() == b"DCT"
}
match dict
.get::<Object<'_>>(b"Filter")
.or_else(|| dict.get::<Object<'_>>(b"F"))
{
Some(Object::Name(name)) => is_dct(&name),
Some(Object::Array(filters)) => {
let mut entries = filters.iter::<Name<'_>>();
entries.next().is_some_and(|name| is_dct(&name)) && entries.next().is_none()
}
_ => false,
}
}
fn colour_space_of(colour_space: Option<ImageColorSpace>) -> Result<ColourSpace, ()> {
match colour_space {
Some(ImageColorSpace::Gray) => Ok(ColourSpace::Gray),
Some(ImageColorSpace::Rgb) => Ok(ColourSpace::Rgb),
Some(ImageColorSpace::Cmyk) => Ok(ColourSpace::Cmyk),
Some(ImageColorSpace::Unknown(_)) | None => Err(()),
}
}
enum ColourSpace {
Gray,
Rgb,
Cmyk,
Indexed(Vec<[u8; 3]>),
}
impl ColourSpace {
fn components(&self) -> u8 {
match self {
Self::Gray | Self::Indexed(_) => 1,
Self::Rgb => 3,
Self::Cmyk => 4,
}
}
}
fn parse_colour_space(object: Object<'_>) -> Option<ColourSpace> {
parse_colour_space_within(object, true)
}
fn parse_colour_space_within(object: Object<'_>, allow_indexed: bool) -> Option<ColourSpace> {
let name = match &object {
Object::Name(name) => name.clone(),
Object::Array(entries) => entries.iter::<Name<'_>>().next()?,
_ => return None,
};
match name.as_ref() {
b"DeviceGray" | b"CalGray" | b"G" => Some(ColourSpace::Gray),
b"DeviceRGB" | b"CalRGB" | b"RGB" => Some(ColourSpace::Rgb),
b"DeviceCMYK" | b"CMYK" => Some(ColourSpace::Cmyk),
b"ICCBased" => match icc_components(object.into_array()?.iter::<Object<'_>>().nth(1)?)? {
1 => Some(ColourSpace::Gray),
3 => Some(ColourSpace::Rgb),
4 => Some(ColourSpace::Cmyk),
_ => None,
},
b"Indexed" | b"I" => {
if !allow_indexed {
return None;
}
let mut entries = object.into_array()?.iter::<Object<'_>>();
let base = parse_colour_space_within(entries.nth(1)?, false)?;
let hival = usize::try_from(entries.next()?.into_i32()?).ok()?;
Some(ColourSpace::Indexed(palette_entries(
&base,
hival,
entries.next()?,
)?))
}
_ => None,
}
}
fn icc_components(profile: Object<'_>) -> Option<u8> {
let components = match profile {
Object::Stream(stream) => stream.dict().get::<u8>(b"N")?,
Object::Dict(dict) => dict.get::<u8>(b"N")?,
_ => return None,
};
matches!(components, 1 | 3 | 4).then_some(components)
}
fn palette_entries(base: &ColourSpace, hival: usize, lookup: Object<'_>) -> Option<Vec<[u8; 3]>> {
let entry_len = match base {
ColourSpace::Gray => 1,
ColourSpace::Rgb => 3,
_ => return None,
};
let lookup = match lookup {
Object::String(lookup) => lookup.as_bytes().to_vec(),
Object::Stream(lookup) => lookup.decoded().ok()?.into_owned(),
_ => return None,
};
let len = hival.checked_add(1)?.checked_mul(entry_len)?;
if lookup.len() < len {
return None;
}
Some(
lookup[..len]
.chunks_exact(entry_len)
.map(|entry| match *entry {
[gray] => [gray; 3],
[red, green, blue] => [red, green, blue],
_ => [0, 0, 0],
})
.collect(),
)
}
struct ImageShape<'a> {
width: u32,
height: u32,
bits_per_component: u8,
colour: ColourSpace,
decode: Vec<(f32, f32)>,
alpha: Option<&'a [u8]>,
}
fn decode_ranges(dict: &Dict<'_>, components: u8) -> Vec<(f32, f32)> {
let declared: Vec<(f32, f32)> = dict
.get::<Array<'_>>(b"Decode")
.or_else(|| dict.get::<Array<'_>>(b"D"))
.map(|decode| decode.iter::<(f32, f32)>().collect())
.unwrap_or_default();
(0..components)
.map(|component| {
declared
.get(usize::from(component))
.copied()
.unwrap_or((0.0, 1.0))
})
.collect()
}
fn samples_to_raster(shape: &ImageShape<'_>, data: &[u8]) -> Option<RgbImage> {
let (width, height) = scaled_dimensions(shape.width, shape.height);
let components = usize::from(shape.colour.components());
let mut raster = Vec::new();
for y in 0..height {
let sy = scaled_index(y, shape.height, height);
for x in 0..width {
let sx = scaled_index(x, shape.width, width);
let mut samples = [0u16; 4];
for (component, sample) in samples.iter_mut().take(components).enumerate() {
*sample = source_sample(shape, data, sx, sy, component);
}
let pixel = pixel_rgb(shape, &samples, sy * shape.width as usize + sx);
raster.extend_from_slice(&pixel);
}
}
RgbImage::from_raw(width, height, raster)
}
fn scaled_index(target: u32, source_len: u32, target_len: u32) -> usize {
target as usize * source_len as usize / target_len as usize
}
fn source_sample(
shape: &ImageShape<'_>,
data: &[u8],
sx: usize,
sy: usize,
component: usize,
) -> u16 {
let bits = usize::from(shape.bits_per_component);
let components = usize::from(shape.colour.components());
let row_bits = (shape.width as usize * components * bits).div_ceil(8) * 8;
let bit = sy * row_bits + (sx * components + component) * bits;
let byte = |index: usize| data.get(index).copied().unwrap_or(0);
if bits == 16 {
return u16::from(byte(bit / 8)) << 8 | u16::from(byte(bit / 8 + 1));
}
let shift = 8 - bits - bit % 8;
(u16::from(byte(bit / 8)) >> shift) & ((1 << bits) - 1)
}
fn pixel_rgb(shape: &ImageShape<'_>, samples: &[u16], source_pixel: usize) -> [u8; 3] {
let channel = |component: usize| {
let range = shape.decode.get(component).copied().unwrap_or((0.0, 1.0));
scale_sample(samples[component], shape.bits_per_component, range)
};
let rgb = match &shape.colour {
ColourSpace::Indexed(palette) => palette
.get(usize::from(samples[0]))
.copied()
.unwrap_or([0, 0, 0]),
ColourSpace::Gray => [channel(0); 3],
ColourSpace::Rgb => [channel(0), channel(1), channel(2)],
ColourSpace::Cmyk => {
let key = channel(3);
[
subtractive(channel(0), key),
subtractive(channel(1), key),
subtractive(channel(2), key),
]
}
};
match shape.alpha {
Some(alpha) => over_white(rgb, alpha.get(source_pixel).copied().unwrap_or(u8::MAX)),
None => rgb,
}
}
fn subtractive(ink: u8, key: u8) -> u8 {
255 - ink.saturating_add(key)
}
fn over_white(rgb: [u8; 3], alpha: u8) -> [u8; 3] {
rgb.map(|channel| {
let alpha = u32::from(alpha);
let blended = (u32::from(channel) * alpha + 255 * (255 - alpha) + 127) / 255;
u8::try_from(blended).unwrap_or(u8::MAX)
})
}
fn scale_sample(sample: u16, bits_per_component: u8, (min, max): (f32, f32)) -> u8 {
let full = f32::from(u16::try_from((1u32 << bits_per_component) - 1).unwrap_or(u16::MAX));
let decoded = min + (max - min) * (f32::from(sample) / full);
to_byte(decoded)
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the value is clamped to the byte range first"
)]
fn to_byte(value: f32) -> u8 {
(value * 255.0 + 0.5).clamp(0.0, 255.0) as u8
}
#[expect(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "pixel dimensions are far below the f32 integer range and the scaled sides are clamped to at least one pixel"
)]
fn scaled_dimensions(width: u32, height: u32) -> (u32, u32) {
let long_side = width.max(height) as f32;
if long_side <= RASTER_LONG_SIDE_PX {
return (width, height);
}
let scale = RASTER_LONG_SIDE_PX / long_side;
(
((width as f32 * scale).round() as u32).max(1),
((height as f32 * scale).round() as u32).max(1),
)
}
fn encode_jpeg(image: &RgbImage) -> Result<Vec<u8>, ()> {
let mut jpeg = Vec::new();
JpegEncoder::new_with_quality(&mut jpeg, RASTER_JPEG_QUALITY)
.encode_image(image)
.map_err(|_| ())?;
Ok(jpeg)
}
fn rasterize_page(
page: Option<&Page<'_>>,
page_number: usize,
out_dir: &Path,
images: &mut Vec<PathBuf>,
unrendered: &mut usize,
) {
let Some(page) = page else {
*unrendered += 1;
return;
};
let (width, height) = page.render_dimensions();
let long_side = width.max(height);
let scale = (RASTER_LONG_SIDE_PX / long_side).min(RASTER_MAX_SCALE);
let settings = RenderSettings {
x_scale: scale,
y_scale: scale,
bg_color: WHITE,
..RenderSettings::default()
};
let pixmap = hayro::render(
page,
&RenderCache::new(),
&InterpreterSettings::default(),
&settings,
);
let (pixel_width, pixel_height) = (pixmap.width(), pixmap.height());
let raw: Vec<u8> = pixmap
.take_unpremultiplied()
.into_iter()
.flat_map(Rgba8::to_u8_array)
.collect();
let Some(image) = RgbaImage::from_raw(u32::from(pixel_width), u32::from(pixel_height), raw)
else {
tracing::warn!(
page = page_number,
"document: rasterized page has no pixels"
);
return;
};
let path = out_dir.join(format!("page_{page_number}.jpg"));
match std::fs::File::create(&path) {
Ok(file) => {
let mut encoder = JpegEncoder::new_with_quality(file, RASTER_JPEG_QUALITY);
match encoder.encode_image(&image) {
Ok(()) => images.push(path),
Err(e) => {
tracing::warn!(page = page_number, error = %e, "document: JPEG encoding failed for rasterized page");
}
}
}
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "document: failed to create rasterized page file");
}
}
}
fn docx_body_text(xml: &[u8]) -> Option<String> {
let mut reader = Reader::from_reader(xml);
let mut buffer = Vec::new();
let mut text = String::new();
let mut in_run_text = false;
loop {
match reader.read_event_into(&mut buffer) {
Ok(Event::Start(event)) => match event.local_name().as_ref() {
b"t" => in_run_text = true,
b"tab" => text.push('\t'),
b"br" => text.push('\n'),
_ => {}
},
Ok(Event::Empty(event)) => match event.local_name().as_ref() {
b"tab" => text.push('\t'),
b"br" => text.push('\n'),
_ => {}
},
Ok(Event::Text(event)) if in_run_text => {
if let Ok(chunk) = event.xml10_content() {
text.push_str(&chunk);
}
}
Ok(Event::GeneralRef(event)) if in_run_text => append_entity(&mut text, &event),
Ok(Event::End(event)) => match event.local_name().as_ref() {
b"t" => in_run_text = false,
b"p" => text.push('\n'),
_ => {}
},
Ok(Event::Eof) => break,
Ok(_) => {}
Err(_) => return None,
}
buffer.clear();
}
Some(text.trim_end().to_string())
}
fn append_entity(text: &mut String, reference: &BytesRef<'_>) {
if let Ok(name) = reference.decode()
&& let Some(resolved) = quick_xml::escape::resolve_predefined_entity(&name)
{
text.push_str(resolved);
return;
}
if let Ok(Some(character)) = reference.resolve_char_ref() {
text.push(character);
}
}
fn read_zip_entry<R: Read + Seek>(archive: &mut ZipArchive<R>, name: &str) -> Option<Vec<u8>> {
let mut entry = archive.by_name(name).ok()?;
if entry.size() > MAX_ZIP_ENTRY_BYTES {
tracing::warn!(
%name,
declared_bytes = entry.size(),
"document: ZIP entry exceeds the decompression bound"
);
return None;
}
let mut bytes = Vec::new();
entry
.by_ref()
.take(MAX_ZIP_ENTRY_BYTES + 1)
.read_to_end(&mut bytes)
.ok()?;
if bytes.len() as u64 > MAX_ZIP_ENTRY_BYTES {
tracing::warn!(%name, "document: ZIP entry exceeds the decompression bound");
return None;
}
Some(bytes)
}
fn ensure_out_dir(out_dir: &Path) {
if let Err(e) = std::fs::create_dir_all(out_dir) {
tracing::warn!(path = %out_dir.display(), error = %e, "document: failed to create extraction output dir");
}
}
fn is_usable_page_text(text: &str) -> bool {
text.trim().chars().count() >= MIN_PAGE_TEXT_CHARS
}
fn is_plain_utf8(bytes: &[u8]) -> bool {
!bytes.contains(&0) && std::str::from_utf8(bytes).is_ok()
}
#[cfg(test)]
pub(crate) mod test_fixtures {
use super::*;
use std::io::Write;
pub(crate) const DOCX_BODY: &[u8] = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>First paragraph</w:t></w:r></w:p><w:p><w:r><w:t>Second paragraph</w:t></w:r></w:p></w:body></w:document>"#;
pub(crate) fn zip_fixture(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for (path, contents) in entries {
writer.start_file(*path, options).expect("start zip entry");
writer.write_all(contents).expect("write zip entry");
}
writer.finish().expect("finish zip").into_inner()
}
pub(crate) fn assemble_pdf(objects: &[Vec<u8>]) -> Vec<u8> {
let mut pdf = b"%PDF-1.4\n".to_vec();
let mut offsets = Vec::new();
for (index, object) in objects.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n", index + 1).as_bytes());
pdf.extend_from_slice(object);
pdf.extend_from_slice(b"\nendobj\n");
}
let start_xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
pdf.extend_from_slice(b"0000000000 65535 f \n");
for offset in offsets {
pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start_xref}\n%%EOF\n",
objects.len() + 1
)
.as_bytes(),
);
pdf
}
pub(crate) fn multi_page_pdf(pages: &[(&str, &[u8])]) -> Vec<u8> {
let kids: Vec<String> = (0..pages.len())
.map(|slot| format!("{} 0 R", 4 + 2 * slot))
.collect();
let mut objects: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
format!(
"<< /Type /Pages /Kids [{}] /Count {} >>",
kids.join(" "),
pages.len()
)
.into_bytes(),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
];
for (slot, (entries, content)) in pages.iter().enumerate() {
objects.push(
format!(
"<< /Type /Page /Parent 2 0 R {entries} /Resources \
<< /Font << /F1 3 0 R >> >> /Contents {} 0 R >>",
5 + 2 * slot
)
.into_bytes(),
);
objects.push(
format!(
"<< /Length {} >>\nstream\n{}\nendstream",
content.len(),
String::from_utf8_lossy(content)
)
.into_bytes(),
);
}
assemble_pdf(&objects)
}
pub(crate) const PAGE_THAT_FAILS_MIDWAY: &[u8] =
b"BT /F1 24 Tf 72 700 Td (Kept page text long enough) Tj ET BT /F1 24 Tf 72 600 Td 42 Tj ET";
pub(crate) fn pdf_with_all_pages_failing_midway() -> Vec<u8> {
let page = ("/MediaBox [0 0 612 792]", PAGE_THAT_FAILS_MIDWAY);
multi_page_pdf(&[page, page])
}
}
#[cfg(test)]
mod tests {
use super::test_fixtures::*;
use super::*;
use std::io::Write;
struct ImageXObject {
width: u32,
height: u32,
colour_space: &'static str,
filter: Option<&'static str>,
decode_parms: Option<&'static str>,
data: Vec<u8>,
}
impl ImageXObject {
fn rgb(width: u32, height: u32, filter: Option<&'static str>, data: Vec<u8>) -> Self {
Self {
width,
height,
colour_space: "/DeviceRGB",
filter,
decode_parms: None,
data,
}
}
fn decode_parms(mut self, decode_parms: &'static str) -> Self {
self.decode_parms = Some(decode_parms);
self
}
fn body(&self) -> Vec<u8> {
let filter = self
.filter
.map(|filter| format!(" /Filter /{filter}"))
.unwrap_or_default();
let decode_parms = self
.decode_parms
.map(|parms| format!(" /DecodeParms << {parms} >>"))
.unwrap_or_default();
let mut body = format!(
"<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {} \
/BitsPerComponent 8{filter}{decode_parms} /Length {} >>\nstream\n",
self.width,
self.height,
self.colour_space,
self.data.len()
)
.into_bytes();
body.extend_from_slice(&self.data);
body.extend_from_slice(b"\nendstream");
body
}
}
fn flate(data: &[u8]) -> Vec<u8> {
let mut encoder =
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(data).expect("write flate data");
encoder.finish().expect("finish flate stream")
}
fn pdf_fixture(content_stream: &[u8], images: &[ImageXObject]) -> Vec<u8> {
let contents_id = 5 + images.len();
let xobjects = if images.is_empty() {
String::new()
} else {
let refs: Vec<String> = (0..images.len())
.map(|slot| format!(" /Im{slot} {} 0 R", 5 + slot))
.collect();
format!(" /XObject <<{} >>", refs.concat())
};
let mut objects: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [4 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources \
<< /Font << /F1 3 0 R >>{xobjects} >> /Contents {contents_id} 0 R >>"
)
.into_bytes(),
];
objects.extend(images.iter().map(ImageXObject::body));
objects.push(
format!(
"<< /Length {} >>\nstream\n{}\nendstream",
content_stream.len(),
String::from_utf8_lossy(content_stream)
)
.into_bytes(),
);
assemble_pdf(&objects)
}
fn text_pdf(images: &[ImageXObject]) -> Vec<u8> {
pdf_fixture(
b"BT /F1 24 Tf 72 700 Td (Hello PDF text layer) Tj ET",
images,
)
}
const SOLID_RGB: [u8; 3] = [200, 40, 40];
fn solid_rgb_samples() -> Vec<u8> {
SOLID_RGB.repeat(8)
}
fn solid_rgb_inline_image() -> String {
let hex = crate::util::hex_string(&solid_rgb_samples());
format!("BI /W 4 /H 2 /CS /RGB /BPC 8 /F /AHx ID {hex}> EI")
}
fn image_only_pdf() -> Vec<u8> {
let image = ImageXObject::rgb(4, 2, Some("FlateDecode"), flate(&solid_rgb_samples()));
pdf_fixture(b"0.1 0.5 0.9 rg 0 0 612 792 re f", &[image])
}
#[test]
fn docx_extracts_paragraphs_and_media() {
let bytes = zip_fixture(&[
("word/document.xml", DOCX_BODY),
("word/media/pic.png", b"\x89PNG\r\n\x1a\nfake image bytes"),
("word/media/", b""),
]);
let dir = tempfile::tempdir().expect("tempdir");
let outcome = convert_document(&bytes, "report.docx", dir.path());
let DocOutcome::Text {
text,
images,
notes,
..
} = outcome
else {
panic!("expected Text outcome for a well-formed docx");
};
assert_eq!(text, "First paragraph\nSecond paragraph");
assert_eq!(images, vec![dir.path().join("pic.png")]);
assert!(notes.is_empty());
assert_eq!(
std::fs::read(&images[0]).expect("read written image"),
b"\x89PNG\r\n\x1a\nfake image bytes"
);
}
#[test]
fn docx_notes_media_entries_it_cannot_convert() {
let bytes = zip_fixture(&[
("word/document.xml", DOCX_BODY),
(
"word/media/diagram.emf",
b"EMF bytes this stack cannot decode",
),
(
"word/media/vector.wmf",
b"WMF bytes this stack cannot decode",
),
]);
let dir = tempfile::tempdir().expect("tempdir");
let DocOutcome::Text { images, notes, .. } =
convert_document(&bytes, "report.docx", dir.path())
else {
panic!("expected Text outcome for a well-formed docx");
};
assert!(images.is_empty(), "an undecodable entry yields no image");
assert_eq!(
notes,
["skipped 2 embedded image(s) in a format this pipeline cannot convert"]
);
}
#[test]
fn docx_media_entry_names_are_marker_safe() {
let bytes = zip_fixture(&[
("word/document.xml", DOCX_BODY),
("word/media/a]b.png", b"\x89PNG\r\n\x1a\nfake image bytes"),
]);
let dir = tempfile::tempdir().expect("tempdir");
let DocOutcome::Text { images, .. } = convert_document(&bytes, "report.docx", dir.path())
else {
panic!("expected Text outcome for a well-formed docx");
};
assert_eq!(images, vec![dir.path().join("a_b.png")]);
}
#[test]
fn docx_duplicate_media_base_names_get_distinct_files() {
let bytes = zip_fixture(&[
("word/document.xml", DOCX_BODY),
("word/media/pic.png", b"\x89PNG\r\n\x1a\nfirst"),
("word/media/sub/pic.png", b"\x89PNG\r\n\x1a\nsecond"),
]);
let dir = tempfile::tempdir().expect("tempdir");
let DocOutcome::Text { images, .. } = convert_document(&bytes, "report.docx", dir.path())
else {
panic!("expected Text outcome for a well-formed docx");
};
assert_eq!(
images,
vec![dir.path().join("pic.png"), dir.path().join("pic_2.png")]
);
}
#[test]
fn encrypted_docx_reports_password_protected() {
let mut bytes = CFB_MAGIC.to_vec();
bytes.extend_from_slice(b"OLE container body that is not a zip");
let dir = tempfile::tempdir().expect("tempdir");
assert!(matches!(
convert_document(&bytes, "secret.docx", dir.path()),
DocOutcome::Unreadable { reason } if reason == "password-protected"
));
assert!(matches!(
convert_document(&bytes, "old.doc", dir.path()),
DocOutcome::Unsupported
));
}
#[test]
fn garbage_pdf_is_reported_unreadable() {
let dir = tempfile::tempdir().expect("tempdir");
let outcome = convert_document(
b"%PDF-1.7\nthis is not a PDF body at all",
"x.pdf",
dir.path(),
);
assert!(
matches!(outcome, DocOutcome::Unreadable { reason } if reason == "could not be parsed")
);
}
#[test]
fn markdown_and_extensionless_utf8_are_text() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(matches!(
convert_document(b"# Title\n\nBody", "notes.md", dir.path()),
DocOutcome::Text { text, .. } if text == "# Title\n\nBody"
));
assert!(matches!(
convert_document(b"plain words", "README", dir.path()),
DocOutcome::Text { text, .. } if text == "plain words"
));
}
#[test]
fn zip_with_non_docx_extension_is_unsupported() {
let bytes = zip_fixture(&[("xl/workbook.xml", b"<workbook/>")]);
let dir = tempfile::tempdir().expect("tempdir");
assert!(matches!(
convert_document(&bytes, "book.xlsx", dir.path()),
DocOutcome::Unsupported
));
}
#[test]
fn pdf_with_text_layer_extracts_text_without_rasterizing() {
let dir = tempfile::tempdir().expect("tempdir");
let outcome = convert_document(&text_pdf(&[]), "hello.pdf", dir.path());
let DocOutcome::Text { text, images, .. } = outcome else {
panic!("expected Text outcome for a text-layer PDF");
};
assert!(
text.contains("Hello PDF text layer"),
"unexpected extracted text: {text:?}"
);
assert!(images.is_empty(), "text pages must not be rasterized");
}
fn assert_solid_rgb(actual: [u8; 3]) {
for (channel, expected) in actual.into_iter().zip(SOLID_RGB) {
assert!(
channel.abs_diff(expected) <= 20,
"expected {SOLID_RGB:?}, got {actual:?}"
);
}
}
fn decode_written_embedded_image(image: ImageXObject) -> image::RgbImage {
let dir = tempfile::tempdir().expect("tempdir");
let outcome = convert_document(&text_pdf(&[image]), "scan.pdf", dir.path());
let DocOutcome::Text { images, notes, .. } = outcome else {
panic!("expected Text outcome for a text-layer PDF");
};
assert!(notes.is_empty(), "nothing was skipped: {notes:?}");
assert_eq!(images, vec![dir.path().join("page_1_img_0.jpg")]);
image::open(&images[0])
.expect("decode the written image")
.to_rgb8()
}
#[test]
fn pdf_text_page_writes_embedded_flate_image() {
let written = decode_written_embedded_image(ImageXObject::rgb(
4,
2,
Some("FlateDecode"),
flate(&solid_rgb_samples()),
));
assert_eq!(written.dimensions(), (4, 2));
assert_solid_rgb(written.get_pixel(0, 0).0);
}
#[test]
fn pdf_text_page_writes_image_nested_in_a_form() {
let content = "BT /F1 24 Tf 72 700 Td (Hello PDF text layer) Tj ET";
let form = "/Im0 Do";
let objects = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [4 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources \
<< /Font << /F1 3 0 R >> /XObject << /Fm0 5 0 R >> >> /Contents 7 0 R >>"
.to_vec(),
format!(
"<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources \
<< /XObject << /Im0 6 0 R >> >> /Length {} >>\nstream\n{form}\nendstream",
form.len()
)
.into_bytes(),
ImageXObject::rgb(4, 2, Some("FlateDecode"), flate(&solid_rgb_samples())).body(),
format!(
"<< /Length {} >>\nstream\n{content}\nendstream",
content.len()
)
.into_bytes(),
];
let dir = tempfile::tempdir().expect("tempdir");
let outcome = convert_document(&assemble_pdf(&objects), "scan.pdf", dir.path());
let DocOutcome::Text { images, notes, .. } = outcome else {
panic!("expected Text outcome for a text-layer PDF");
};
assert!(notes.is_empty(), "nothing was skipped: {notes:?}");
assert_eq!(images, vec![dir.path().join("page_1_img_0.jpg")]);
}
#[test]
fn pdf_text_page_writes_inline_image() {
let content = format!(
"BT /F1 24 Tf 72 700 Td (Hello PDF text layer) Tj ET {}",
solid_rgb_inline_image()
);
let dir = tempfile::tempdir().expect("tempdir");
let outcome = convert_document(
&pdf_fixture(content.as_bytes(), &[]),
"scan.pdf",
dir.path(),
);
let DocOutcome::Text { images, notes, .. } = outcome else {
panic!("expected Text outcome for a text-layer PDF");
};
assert!(notes.is_empty(), "nothing was skipped: {notes:?}");
assert_eq!(images, vec![dir.path().join("page_1_img_0.jpg")]);
let written = image::open(&images[0])
.expect("decode the written image")
.to_rgb8();
assert_eq!(written.dimensions(), (4, 2));
assert_solid_rgb(written.get_pixel(0, 0).0);
}
#[test]
fn pdf_text_page_writes_inline_image_nested_in_a_form() {
let content = "BT /F1 24 Tf 72 700 Td (Hello PDF text layer) Tj ET";
let form = solid_rgb_inline_image();
let objects = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [4 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources \
<< /Font << /F1 3 0 R >> /XObject << /Fm0 5 0 R >> >> /Contents 6 0 R >>"
.to_vec(),
format!(
"<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Length {} >>\nstream\n{form}\nendstream",
form.len()
)
.into_bytes(),
format!(
"<< /Length {} >>\nstream\n{content}\nendstream",
content.len()
)
.into_bytes(),
];
let dir = tempfile::tempdir().expect("tempdir");
let outcome = convert_document(&assemble_pdf(&objects), "scan.pdf", dir.path());
let DocOutcome::Text { images, notes, .. } = outcome else {
panic!("expected Text outcome for a text-layer PDF");
};
assert!(notes.is_empty(), "nothing was skipped: {notes:?}");
assert_eq!(images, vec![dir.path().join("page_1_img_0.jpg")]);
}
#[test]
fn pdf_text_page_writes_png_predictor_image() {
let mut encoded = vec![2];
encoded.extend_from_slice(&SOLID_RGB.repeat(4));
encoded.push(2);
encoded.extend_from_slice(&[0; 12]);
let written = decode_written_embedded_image(
ImageXObject::rgb(4, 2, Some("FlateDecode"), flate(&encoded))
.decode_parms("/Predictor 12 /Colors 3 /Columns 4 /BitsPerComponent 8"),
);
assert_eq!(written.dimensions(), (4, 2));
assert_solid_rgb(written.get_pixel(0, 0).0);
}
#[test]
fn pdf_notes_embedded_images_that_cannot_be_decoded() {
let image = ImageXObject::rgb(2, 2, Some("JPXDecode"), b"RGBRGB12".to_vec());
let dir = tempfile::tempdir().expect("tempdir");
let outcome = convert_document(&text_pdf(&[image]), "scan.pdf", dir.path());
let DocOutcome::Text { images, notes, .. } = outcome else {
panic!("expected Text outcome for a text-layer PDF");
};
assert!(
images.is_empty(),
"an undecodable embedded image yields no file"
);
assert_eq!(
notes,
["skipped 1 embedded image(s) that could not be extracted"]
);
}
#[test]
fn pdf_notes_a_leniently_empty_decode() {
let image = ImageXObject::rgb(4, 2, Some("FlateDecode"), b"not a flate stream".to_vec());
let dir = tempfile::tempdir().expect("tempdir");
let outcome = convert_document(&text_pdf(&[image]), "scan.pdf", dir.path());
let DocOutcome::Text { images, notes, .. } = outcome else {
panic!("expected Text outcome for a text-layer PDF");
};
assert!(
images.is_empty(),
"a decode that returned no samples yields no file"
);
assert_eq!(
notes,
["skipped 1 embedded image(s) that could not be extracted"]
);
}
#[test]
fn pdf_notes_a_declared_geometry_over_the_raster_envelope() {
let image = ImageXObject::rgb(20_000, 20_000, None, Vec::new());
let dir = tempfile::tempdir().expect("tempdir");
let outcome = convert_document(&text_pdf(&[image]), "scan.pdf", dir.path());
let DocOutcome::Text { images, notes, .. } = outcome else {
panic!("expected Text outcome for a text-layer PDF");
};
assert!(
images.is_empty(),
"an image over the envelope yields no file"
);
assert_eq!(
notes,
["skipped 1 embedded image(s) over the image size limit"]
);
}
#[test]
fn pdf_text_page_passes_jpeg_through_verbatim() {
let source = RgbImage::from_pixel(4, 2, image::Rgb(SOLID_RGB));
let mut jpeg = Vec::new();
JpegEncoder::new_with_quality(&mut jpeg, RASTER_JPEG_QUALITY)
.encode_image(&source)
.expect("encode the source JPEG");
let image = ImageXObject::rgb(4, 2, Some("DCTDecode"), jpeg.clone());
let dir = tempfile::tempdir().expect("tempdir");
let outcome = convert_document(&text_pdf(&[image]), "scan.pdf", dir.path());
let DocOutcome::Text { images, notes, .. } = outcome else {
panic!("expected Text outcome for a text-layer PDF");
};
assert!(notes.is_empty(), "nothing was skipped: {notes:?}");
assert_eq!(images, vec![dir.path().join("page_1_img_0.jpg")]);
assert_eq!(
std::fs::read(&images[0]).expect("read the written image"),
jpeg
);
}
#[test]
fn pdf_indexed_image_uses_its_palette() {
let image = ImageXObject {
width: 16,
height: 4,
colour_space: "[/Indexed /DeviceRGB 3 <FF000000FF000000FF000000FF>]",
filter: None,
decode_parms: None,
data: [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3].repeat(4),
};
let dir = tempfile::tempdir().expect("tempdir");
let outcome = convert_document(&text_pdf(&[image]), "scan.pdf", dir.path());
let DocOutcome::Text { images, notes, .. } = outcome else {
panic!("expected Text outcome for a text-layer PDF");
};
assert!(notes.is_empty(), "nothing was skipped: {notes:?}");
assert_eq!(images, vec![dir.path().join("page_1_img_0.jpg")]);
let written = image::open(&images[0])
.expect("decode the written image")
.to_rgb8();
assert_eq!(written.dimensions(), (16, 4));
let [red, green, blue] = written.get_pixel(5, 0).0;
assert!(
green > red && green > blue,
"the second palette entry is green, got {red},{green},{blue}"
);
}
#[test]
fn pdf_keeps_the_text_of_pages_the_reader_can_read() {
let bytes = multi_page_pdf(&[
(
"/MediaBox [0 0 612 792]",
b"BT /F1 24 Tf 72 700 Td (Page one text long enough) Tj ET",
),
(
"",
b"BT /F1 24 Tf 72 700 Td (Page two text long enough) Tj ET",
),
(
"/MediaBox [0 0 612 792]",
b"BT /F1 24 Tf 72 700 Td (Page three text long enough) Tj ET",
),
("/MediaBox [0 0 612 792]", PAGE_THAT_FAILS_MIDWAY),
]);
let dir = tempfile::tempdir().expect("tempdir");
let outcome = convert_document(&bytes, "report.pdf", dir.path());
let DocOutcome::Text {
text,
images,
notes,
all_page_text_lost,
} = outcome
else {
panic!("expected Text outcome for a PDF whose pages partly read");
};
assert!(text.contains("Page one text long enough"), "{text:?}");
assert!(text.contains("Page three text long enough"), "{text:?}");
assert!(
text.contains("Kept page text long enough"),
"what a page produced before it failed is kept: {text:?}"
);
assert!(
!text.contains("Page two text long enough"),
"the reader panics on page 2 before reading it: {text:?}"
);
assert_eq!(
images,
vec![dir.path().join("page_2.jpg"), dir.path().join("page_4.jpg")],
"only the pages whose text could not be read are rasterized"
);
assert_eq!(
notes,
vec![
"the text of page 2 could not be read (1 of 4 pages)",
"the text of page 4 could not be read in full (1 of 4 pages)",
]
);
assert!(!all_page_text_lost);
}
#[test]
fn pdf_keeps_the_text_of_pages_that_fail_after_producing_it() {
let bytes = pdf_with_all_pages_failing_midway();
let dir = tempfile::tempdir().expect("tempdir");
let outcome = convert_document(&bytes, "report.pdf", dir.path());
let DocOutcome::Text {
text,
images,
notes,
all_page_text_lost,
} = outcome
else {
panic!("expected Text outcome for a PDF whose every page failed part-way");
};
assert_eq!(
text.matches("Kept page text long enough").count(),
2,
"both pages delivered the text they produced before failing: {text:?}"
);
assert_eq!(
images,
vec![dir.path().join("page_1.jpg"), dir.path().join("page_2.jpg")],
"every page whose text could not be read in full is rasterized"
);
assert_eq!(
notes,
vec!["the text of pages 1, 2 could not be read in full (2 of 2 pages)"]
);
assert!(
!all_page_text_lost,
"no page's text was lost: both delivered what they produced"
);
}
#[test]
fn pdf_names_every_page_when_the_reader_cannot_read_the_document() {
let mut bytes = text_pdf(&[]);
bytes.insert(b"%PDF-1.4\n".len(), b' ');
let dir = tempfile::tempdir().expect("tempdir");
let outcome = convert_document(&bytes, "report.pdf", dir.path());
let DocOutcome::Text {
text,
images,
notes,
all_page_text_lost,
} = outcome
else {
panic!("expected Text outcome for a PDF whose pages parse");
};
assert!(text.trim().is_empty(), "nothing was read: {text:?}");
assert_eq!(images, vec![dir.path().join("page_1.jpg")]);
assert_eq!(
notes,
vec!["the text of page 1 could not be read (1 of 1 pages)"]
);
assert!(all_page_text_lost);
}
#[test]
fn pdf_distinguishes_a_page_with_no_text_from_one_that_could_not_be_read() {
let bytes = multi_page_pdf(&[
(
"/MediaBox [0 0 612 792]",
b"0.1 0.5 0.9 rg 0 0 612 792 re f",
),
(
"",
b"BT /F1 24 Tf 72 700 Td (Page two text long enough) Tj ET",
),
]);
let dir = tempfile::tempdir().expect("tempdir");
let outcome = convert_document(&bytes, "scan.pdf", dir.path());
let DocOutcome::Text {
text,
images,
notes,
all_page_text_lost,
} = outcome
else {
panic!("expected Text outcome for a scan with one unreadable page");
};
assert!(text.trim().is_empty(), "no text layer to extract: {text:?}");
assert_eq!(
images,
vec![dir.path().join("page_1.jpg"), dir.path().join("page_2.jpg")],
"the text-free page and the unreadable one are both delivered as images"
);
assert_eq!(
notes,
vec!["the text of page 2 could not be read (1 of 2 pages)"],
"only the page that could not be read is named"
);
assert!(
!all_page_text_lost,
"page 1 was read and has nothing to read: the document is not one \
whose text the reader failed on"
);
}
#[test]
fn unreadable_page_notes_name_ranges_within_a_bound() {
let unread = UnreadablePages {
lost: vec![1, 2, 3, 7, 9, 11, 13, 15, 17, 19, 21],
partial: vec![30, 31],
attempted: 44,
total: 44,
};
assert_eq!(
unread.notes(),
vec![
"the text of pages 1-3, 7, 9, 11, 13, 15, 17, 19, … could not be read (11 of 44 pages)",
"the text of pages 30, 31 could not be read in full (2 of 44 pages)",
]
);
assert!(!unread.lost_all_page_text(), "33 pages kept their text");
assert_eq!(page_list(&[5]), "page 5");
assert!(
UnreadablePages {
lost: vec![1, 2],
attempted: 2,
total: 3,
..UnreadablePages::default()
}
.lost_all_page_text(),
"the reader could only read pages 1 and 2, and failed on both"
);
assert!(
!UnreadablePages {
lost: vec![2],
attempted: 2,
total: 2,
..UnreadablePages::default()
}
.lost_all_page_text(),
"page 1 has no text, which is not the reader failing"
);
assert!(
!UnreadablePages {
lost: vec![],
partial: vec![30, 31],
attempted: 2,
..UnreadablePages::default()
}
.lost_all_page_text(),
"both pages delivered the text they produced, so none was lost"
);
}
#[test]
fn pdf_page_without_text_layer_is_rasterized() {
let dir = tempfile::tempdir().expect("tempdir");
let outcome = convert_document(&image_only_pdf(), "scan.pdf", dir.path());
let DocOutcome::Text {
text,
images,
notes,
..
} = outcome
else {
panic!("expected Text outcome for an image-only PDF");
};
assert!(text.trim().is_empty(), "no text layer to extract: {text:?}");
assert_eq!(images, vec![dir.path().join("page_1.jpg")]);
assert!(notes.is_empty(), "nothing was skipped: {notes:?}");
let page = image::open(&images[0]).expect("decode the rasterized page");
let long_side = f64::from(page.width().max(page.height()));
let target = f64::from(RASTER_LONG_SIDE_PX);
assert!(
long_side <= target && long_side > target - 5.0,
"long side {long_side} must be the RASTER_LONG_SIDE_PX target"
);
}
}