use crate::reader_output::{Unshown, UnshownKind};
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 std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
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";
pub(crate) const CFB_MAGIC: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
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>,
unshown: Unshown,
all_page_text_lost: bool,
},
Unreadable { reason: String },
Unsupported,
}
enum DocumentKind {
Pdf,
Docx,
Xlsx,
Pptx,
LegacyDoc,
LegacyXls,
LegacyPpt,
EncryptedOoxml,
PlainText,
Unsupported,
}
#[must_use]
pub(crate) fn is_zip_container(head: &[u8]) -> bool {
head.starts_with(ZIP_MAGIC)
}
fn classify(bytes: &[u8], path: &Path) -> DocumentKind {
if bytes.starts_with(PDF_MAGIC) {
return DocumentKind::Pdf;
}
if bytes.starts_with(CFB_MAGIC) {
if crate::ooxml::family_of(path).is_some() {
return DocumentKind::EncryptedOoxml;
}
return match crate::legacy::family_of(path) {
Some(crate::legacy::Family::Doc) => DocumentKind::LegacyDoc,
Some(crate::legacy::Family::Xls) => DocumentKind::LegacyXls,
Some(crate::legacy::Family::Ppt) => DocumentKind::LegacyPpt,
None => DocumentKind::Unsupported,
};
}
if bytes.starts_with(ZIP_MAGIC) {
return match crate::ooxml::family_of(path) {
Some(crate::ooxml::Family::Docx) => DocumentKind::Docx,
Some(crate::ooxml::Family::Xlsx) => DocumentKind::Xlsx,
Some(crate::ooxml::Family::Pptx) => DocumentKind::Pptx,
None => 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::Xlsx
| DocumentKind::Pptx
| DocumentKind::LegacyDoc
| DocumentKind::LegacyXls
| DocumentKind::LegacyPpt
| 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 => crate::ooxml::convert_docx(bytes, out_dir),
DocumentKind::Xlsx => crate::ooxml::convert_xlsx(bytes, out_dir),
DocumentKind::Pptx => crate::ooxml::convert_pptx(bytes, out_dir),
DocumentKind::LegacyDoc => crate::legacy::convert(bytes, crate::legacy::Family::Doc),
DocumentKind::LegacyXls => crate::legacy::convert(bytes, crate::legacy::Family::Xls),
DocumentKind::LegacyPpt => crate::legacy::convert(bytes, crate::legacy::Family::Ppt),
DocumentKind::PlainText => DocOutcome::Text {
text: String::from_utf8_lossy(bytes).into_owned(),
images: Vec::new(),
notes: Vec::new(),
unshown: Unshown::default(),
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(),
}
}
}
}
pub(crate) async fn read_pptx_diagram_text(path: &Path) -> HashMap<String, Vec<String>> {
let _permit = DOCUMENT_CONVERSIONS.acquire().await;
let Ok(bytes) = tokio::fs::read(path).await else {
return HashMap::new();
};
tokio::task::spawn_blocking(move || crate::ooxml::pptx_diagram_text(&bytes))
.await
.unwrap_or_default()
}
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 blocks: Vec<String> = Vec::new();
let mut images: Vec<PathBuf> = Vec::new();
let mut skipped = SkippedImages::default();
let mut unshown = Unshown::default();
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 text = text.trim();
let has_text = !text.is_empty();
if has_text {
blocks.push(page_block(page_number, text));
}
if failed {
if has_text {
unread.partial.push(page_number);
} else {
unread.lost.push(page_number);
}
if !rasterize_page(page, page_number, out_dir, &mut images) {
unshown.add(UnshownKind::Page, 1);
}
} else if is_usable_page_text(text) {
if let Some(page) = page {
write_embedded_images(
page,
page_number,
out_dir,
&mut written,
&mut images,
&mut skipped,
);
}
} else {
if !rasterize_page(page, page_number, out_dir, &mut images) {
unshown.add(UnshownKind::Page, 1);
}
}
}
let marks = pdf.as_ref().map(crate::pdf_marks::read).unwrap_or_default();
unshown.merge(&marks.unshown);
let undelivered = unshown.count(UnshownKind::Page);
if undelivered > 0 {
tracing::warn!(
pages = undelivered,
"document: pages could not be delivered as images"
);
}
let mut notes = unread.notes();
notes.extend(marks.notes);
notes.extend(skipped.notes());
blocks.extend(marks.annotations);
blocks.extend(marks.fields);
DocOutcome::Text {
text: blocks.join("\n\n"),
images,
notes,
unshown,
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(", ")
}
#[derive(Default, Clone, Copy)]
pub(crate) struct SkippedImages {
unsupported: usize,
failed: usize,
oversized: usize,
}
impl SkippedImages {
pub(crate) fn add(&mut self, reason: SkipReason) {
match reason {
SkipReason::Unsupported => self.unsupported += 1,
SkipReason::Failed => self.failed += 1,
SkipReason::Oversized => self.oversized += 1,
}
}
pub(crate) 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)]
pub(crate) 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>,
) -> bool {
let Some(page) = page else {
return false;
};
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 false;
};
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);
true
}
Err(e) => {
tracing::warn!(page = page_number, error = %e, "document: JPEG encoding failed for rasterized page");
false
}
}
}
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "document: failed to create rasterized page file");
false
}
}
}
pub(crate) 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.chars().count() >= MIN_PAGE_TEXT_CHARS
}
fn page_block(page_number: usize, text: &str) -> String {
format!("Page {page_number}:\n{text}")
}
fn is_plain_utf8(bytes: &[u8]) -> bool {
!bytes.contains(&0) && std::str::from_utf8(bytes).is_ok()
}
#[cfg(test)]
pub(crate) mod test_fixtures {
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])
}
pub(crate) struct PdfFixture {
bodies: Vec<Option<Vec<u8>>>,
}
impl PdfFixture {
#[must_use]
pub(crate) fn new() -> Self {
Self { bodies: vec![None] }
}
pub(crate) fn reserve(&mut self) -> String {
self.bodies.push(None);
format!("{} 0 R", self.bodies.len())
}
pub(crate) fn set(&mut self, reference: &str, body: String) {
let number = reference
.split(' ')
.next()
.and_then(|number| number.parse::<usize>().ok())
.filter(|number| (2..=self.bodies.len()).contains(number))
.unwrap_or_else(|| panic!("PDF fixture reference {reference} was never reserved"));
let slot = &mut self.bodies[number - 1];
assert!(
slot.is_none(),
"PDF fixture object {number} already has a body"
);
*slot = Some(body.into_bytes());
}
pub(crate) fn push(&mut self, body: String) -> String {
let reference = self.reserve();
self.set(&reference, body);
reference
}
pub(crate) fn build(mut self, entries: &str) -> Vec<u8> {
assert!(
self.bodies[0].is_none(),
"PDF fixture object 1 is the catalog"
);
self.bodies[0] = Some(format!("<< /Type /Catalog {entries} >>").into_bytes());
let objects = self
.bodies
.into_iter()
.map(|body| body.unwrap_or_else(|| panic!("PDF fixture object body missing")))
.collect::<Vec<_>>();
assemble_pdf(&objects)
}
}
pub(crate) fn one_page_pdf(
mut fixture: PdfFixture,
content: &[u8],
page_entries: &str,
catalog_extra: &str,
) -> Vec<u8> {
let font =
fixture.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string());
let contents = fixture.push(format!(
"<< /Length {} >>\nstream\n{}\nendstream",
content.len(),
String::from_utf8_lossy(content)
));
let page = fixture.reserve();
let pages = fixture.push(format!("<< /Type /Pages /Kids [{page}] /Count 1 >>"));
fixture.set(
&page,
format!(
"<< /Type /Page /Parent {pages} /Resources << /Font << /F1 {font} >> >> \
/Contents {contents}{page_entries} >>"
),
);
fixture.build(&format!("/Pages {pages}{catalog_extra}"))
}
pub(crate) fn pdf_text_string(text: &str) -> String {
let mut bytes = vec![0xFE, 0xFF];
for unit in text.encode_utf16() {
bytes.extend_from_slice(&unit.to_be_bytes());
}
format!("<{}>", crate::util::hex_string(&bytes))
}
}
#[cfg(test)]
mod tests {
use super::test_fixtures::*;
use super::*;
use crate::ooxml::test_fixtures::zip_fixture;
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 cfb_magic_classifies_by_name() {
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");
for name in ["secret.docx", "book.xlsx", "deck.pptx"] {
assert!(matches!(
convert_document(&bytes, name, dir.path()),
DocOutcome::Unreadable { reason } if reason == "password-protected"
));
}
for (name, format) in [
("old.doc", ".doc"),
("old.xls", ".xls"),
("old.ppt", ".ppt"),
] {
assert!(matches!(
convert_document(&bytes, name, dir.path()),
DocOutcome::Unreadable { reason }
if reason == format!("corrupt or unreadable {format}")
));
assert!(
needs_extraction(&bytes, name),
"{name} must be extracted, not returned as bytes"
);
}
assert!(matches!(
convert_document(&bytes, "blob.bin", 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_ooxml_extension_is_unsupported() {
let bytes = zip_fixture(&[("xl/workbook.xml", b"<workbook/>")]);
let dir = tempfile::tempdir().expect("tempdir");
for name in ["book.zip", "book.bin"] {
assert!(matches!(
convert_document(&bytes, name, dir.path()),
DocOutcome::Unsupported
));
}
}
#[test]
fn ooxml_zip_packages_dispatch_to_their_arm() {
let dir = tempfile::tempdir().expect("tempdir");
let xlsx = zip_fixture(&[
(
"xl/workbook.xml",
br#"<workbook xmlns:r="r"><sheets><sheet name="S" r:id="rId1"/></sheets></workbook>"#,
),
(
"xl/_rels/workbook.xml.rels",
br#"<Relationships><Relationship Id="rId1" Target="worksheets/sheet1.xml"/></Relationships>"#,
),
(
"xl/worksheets/sheet1.xml",
br#"<worksheet><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>cell</t></is></c></row></sheetData></worksheet>"#,
),
]);
assert!(matches!(
convert_document(&xlsx, "book.xlsx", dir.path()),
DocOutcome::Text { text, .. } if text == "Sheet \"S\":\n A1: cell"
));
let pptx = zip_fixture(&[
(
"ppt/presentation.xml",
br#"<p:presentation xmlns:p="p" xmlns:r="r"><p:sldIdLst><p:sldId r:id="rId1"/></p:sldIdLst></p:presentation>"#,
),
(
"ppt/_rels/presentation.xml.rels",
br#"<Relationships><Relationship Id="rId1" Target="slides/slide1.xml"/></Relationships>"#,
),
(
"ppt/slides/slide1.xml",
br"<p:sld><a:p><a:r><a:t>hello</a:t></a:r></a:p></p:sld>",
),
]);
assert!(matches!(
convert_document(&pptx, "deck.pptx", dir.path()),
DocOutcome::Text { text, .. } if text == "Slide 1:\n hello"
));
}
#[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 1:\nPage one text long enough"),
"page 1 carries its own label: {text:?}"
);
assert!(
text.contains("Page 3:\nPage three text long enough"),
"page 3 carries its own label: {text:?}"
);
assert!(
text.contains("Page 4:\nKept page text long enough"),
"what a page produced before it failed is kept under its label: {text:?}"
);
assert!(
!text.contains("Page two text long enough"),
"the reader panics on page 2 before reading it: {text:?}"
);
assert!(
!text.contains("Page 2:"),
"the page whose text was lost gets no label: {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 a_page_that_cannot_be_written_is_reported() {
let dir = tempfile::tempdir().expect("tempdir");
let blocked = dir.path().join("not-a-directory");
std::fs::write(&blocked, b"").expect("write the file standing in for the directory");
let bytes = multi_page_pdf(&[(
"/MediaBox [0 0 612 792]",
b"0.1 0.5 0.9 rg 0 0 612 792 re f",
)]);
let outcome = convert_document(&bytes, "scan.pdf", &blocked);
let DocOutcome::Text {
images, unshown, ..
} = outcome
else {
panic!("expected Text outcome for a PDF whose page could not be written");
};
assert!(images.is_empty(), "nothing was written: {images:?}");
assert_eq!(unshown.lines(), ["1 page(s) not shown"]);
}
#[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"
);
}
#[test]
fn pdf_labels_each_page_that_produced_text_and_keeps_the_short_ones() {
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",
),
(
"/MediaBox [0 0 612 792]",
b"0.1 0.5 0.9 rg 0 0 612 792 re f",
),
(
"/MediaBox [0 0 612 792]",
b"BT /F1 24 Tf 72 700 Td (Hi) Tj ET",
),
]);
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 report with a short text page");
};
assert_eq!(
text, "Page 1:\nPage one text long enough\n\nPage 3:\nHi",
"each page that produced text is labeled with its own number, and the \
text-free page gets none"
);
assert_eq!(
images,
vec![dir.path().join("page_2.jpg"), dir.path().join("page_3.jpg")],
"the text-free page and the short page are both rasterized"
);
assert!(notes.is_empty(), "nothing failed: {notes:?}");
assert!(!all_page_text_lost);
}
#[test]
fn pdf_delivers_annotations_and_form_values_as_document_text() {
const CONTENT: &[u8] = b"BT /F1 24 Tf 72 700 Td (Page one text long enough) Tj ET";
let mut fixture = PdfFixture::new();
let highlight = fixture.push(format!(
"<< /Subtype /Highlight /T {} /Contents {} >>",
pdf_text_string("Иван Петров"),
pdf_text_string("проверьте третью фигуру")
));
let field = fixture.push(
"<< /Subtype /Widget /FT /Tx /T (Name) /V (value) /Rect [0 0 0 0] >>".to_string(),
);
let acroform = fixture.push(format!("<< /Fields [{field}] >>"));
let bytes = one_page_pdf(
fixture,
CONTENT,
&format!("/MediaBox [0 0 612 792] /Annots [{highlight} {field}]"),
&format!(" /AcroForm {acroform}"),
);
let dir = tempfile::tempdir().expect("tempdir");
let outcome = convert_document(&bytes, "form.pdf", dir.path());
let DocOutcome::Text {
text,
images,
notes,
..
} = outcome
else {
panic!("expected Text outcome for a PDF with marks");
};
assert_eq!(
text,
"Page 1:\nPage one text long enough\n\nAnnotations:\n\
highlight (Иван Петров) on page 1:\n проверьте третью фигуру\n\n\
Form fields:\nName on page 1: value"
);
assert!(
images.is_empty(),
"the text page is delivered as text, not rasterized: {images:?}"
);
assert!(notes.is_empty(), "{notes:?}");
}
#[test]
fn pdf_whose_pages_have_no_text_still_delivers_its_form_values() {
const CONTENT: &[u8] = b"0.1 0.5 0.9 rg 0 0 612 792 re f";
let mut fixture = PdfFixture::new();
let field = fixture.push(
"<< /Subtype /Widget /FT /Tx /T (Name) /V (value) /Rect [0 0 0 0] >>".to_string(),
);
let acroform = fixture.push(format!("<< /Fields [{field}] >>"));
let bytes = one_page_pdf(
fixture,
CONTENT,
&format!("/MediaBox [0 0 612 792] /Annots [{field}]"),
&format!(" /AcroForm {acroform}"),
);
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 a form");
};
assert_eq!(text, "Form fields:\nName on page 1: value");
assert_eq!(images, vec![dir.path().join("page_1.jpg")]);
assert!(notes.is_empty(), "{notes:?}");
assert!(
!all_page_text_lost,
"the page has no text, so none was lost"
);
}
}