Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 32 additions & 11 deletions crates/paperback-core/src/parser/pdf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use crate::{
document::{Document, DocumentBuffer, Marker, MarkerType, ParserContext, TocItem},
ocr::image_only_placeholder,
parser::{Parser, add_heading_markers, util::path::extract_title_from_path},
pdfium::PdfDocument,
pdfium::{PAGE_IMAGE_COVERAGE, PdfDocument},
};

mod images;
Expand All @@ -20,9 +20,7 @@ mod tagged;
mod text;
mod toc;

use images::{
PAGE_IMAGE_COVERAGE, append_images, images_before_each_paragraph, page_image_tops, page_largest_image_coverage,
};
use images::{append_image, images_before_each_paragraph, page_largest_image_coverage};
use links::{PendingLink, collect_annotation_links, collect_web_links, place_links};
use metadata::{map_load_error, metadata_title, metadata_value};
pub use paragraphs::join_wrapped_lines;
Expand Down Expand Up @@ -53,6 +51,8 @@ struct PageContent {
annotation_links: Vec<PendingLink>,
/// The top edge of each image the page draws, ordered down the page.
image_tops: Vec<f64>,
/// Where each of those images sits in the page's content stream, in the same order, so the picture behind an image line can be saved later.
image_paths: Vec<String>,
/// The fraction of the page the largest single image covers. Near 1.0 marks a scanned page.
largest_image_coverage: f64,
}
Expand All @@ -76,8 +76,10 @@ fn read_page(
// because each page places its own images and because an image-only page still needs its OCR
// placeholder when earlier pages contributed text. Taken before the text so that a tagged
// page whose tree names no figure can place them among its blocks as it writes them.
let announced = page.announced_images();
let mut content = PageContent {
image_tops: page_image_tops(&page),
image_tops: announced.iter().map(|image| image.top).collect(),
image_paths: announced.into_iter().map(|image| image.path).collect(),
largest_image_coverage: page_largest_image_coverage(&page),
..Default::default()
};
Expand All @@ -97,6 +99,7 @@ fn read_page(
&mut tagged.toc_items,
render_tables_inline,
&content.image_tops,
&content.image_paths,
evidence,
) {
content.tagged = Some(tagged);
Expand Down Expand Up @@ -183,8 +186,8 @@ fn looks_like_page_number(text: &str) -> bool {
enum UntaggedLine {
/// A paragraph, and whether the size it was set in marked it out as a heading.
Text(String, bool),
/// An image the page drew here. An untagged page has no description to give for one.
Image,
/// An image the page drew here, and where its picture sits in the content stream. An untagged page has no description to give for one.
Image(String),
}

/// Work out what an untagged page contributes: its running headers dropped, its wrapped lines
Expand Down Expand Up @@ -215,11 +218,21 @@ fn untagged_page_lines(page: &PageContent, running_text: &RunningText, join: boo
.collect();
let image_counts = images_before_each_paragraph(&page.image_tops, &paragraph_tops);
let mut lines = Vec::with_capacity(paragraphs.len());
let mut next_image = 0usize;
let take_image = |next_image: &mut usize| {
let path = page.image_paths.get(*next_image).cloned().unwrap_or_default();
*next_image += 1;
UntaggedLine::Image(path)
};
for (index, (text, is_heading, _)) in paragraphs.iter().enumerate() {
lines.extend((0..image_counts[index]).map(|_| UntaggedLine::Image));
for _ in 0..image_counts[index] {
lines.push(take_image(&mut next_image));
}
lines.push(UntaggedLine::Text(text.clone(), *is_heading));
}
lines.extend((0..image_counts[paragraphs.len()]).map(|_| UntaggedLine::Image));
for _ in 0..image_counts[paragraphs.len()] {
lines.push(take_image(&mut next_image));
}
lines
}

Expand Down Expand Up @@ -436,8 +449,16 @@ impl Parser for PdfParser {
} else {
for line in untagged_page_lines(&page, &running_text, context.join_pdf_paragraphs) {
match line {
UntaggedLine::Image => {
append_images(1, &mut buffer, &mut page_display_text, &mut current_lines_info);
UntaggedLine::Image(path) => {
let reference = format!("pdf:{page_index}:{path}");
append_image(
&mut buffer,
MarkerType::Image,
"",
&mut page_display_text,
&mut current_lines_info,
&reference,
);
}
UntaggedLine::Text(text, is_heading) => {
has_any_text = true;
Expand Down
133 changes: 28 additions & 105 deletions crates/paperback-core/src/parser/pdf/images.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@
//! this purpose, so [`super::structure`] places one by walking the tree and calls
//! [`append_image`] as it meets one.
//!
//! An untagged PDF says neither. All it has is image objects with page coordinates, so
//! [`page_image_tops`] collects those and the caller places each one among the page's text lines
//! by how far down the page it sits.
//! An untagged PDF says neither. All it has is image objects with page coordinates, so the caller places each one among the page's text lines by how far down the page it sits.
//!
//! A tagged PDF that tags none of its images gets the same treatment through
//! [`UnclaimedImages`]. A page like that draws pictures and claims not to have any, so trusting
Expand All @@ -16,24 +14,11 @@

use crate::{
document::{DocumentBuffer, Marker, MarkerType},
pdfium::{ObjectKind, PageObject, PdfPage},
pdfium::{MAX_FORM_DEPTH, ObjectKind, PageObject, PdfPage},
t,
util::text::display_len,
};

/// How far to follow form `XObject`s into each other looking for the images they draw. A form
/// holding a form is ordinary enough (a placed logo, an annotation's appearance stream), while a
/// chain of them this deep is a malformed file rather than a page anyone meant to draw.
const MAX_FORM_DEPTH: u32 = 8;

/// Shortest side, in PDF user units, an image must have to be worth a line of its own. A page
/// laid out from HTML draws its spacers and its hairline rules as images a fraction of a point
/// across; announcing one of those as an image gives the reader a stop with nothing at it.
const MIN_IMAGE_SIDE: f32 = 4.0;

/// How much of the page one image can cover and still be a picture on the page rather than the page itself. A scanned book with an OCR text layer draws each sheet as one image under its invisible text, and announcing that put an "[Image]" with nothing behind it on every page (#1046).
pub(super) const PAGE_IMAGE_COVERAGE: f64 = 0.9;

/// Writes one image's line into the page, with a marker spanning it.
///
/// `description` is the image's alt text, and is empty when it has none. An undescribed image
Expand All @@ -45,13 +30,19 @@ pub(super) fn append_image(
description: &str,
page_display_text: &mut String,
current_lines_info: &mut Vec<(usize, String)>,
reference: &str,
) {
// TRANSLATORS: Label inserted before a figure or image's description, e.g. "[Figure: a cat sleeping]"
let label = if mtype == MarkerType::Figure { t("Figure") } else { t("Image") };
let line = if description.is_empty() { format!("[{label}]") } else { format!("[{label}: {description}]") };
let offset = buffer.current_position();
current_lines_info.push((offset, line.clone()));
buffer.add_marker(Marker::new(mtype, offset).with_text(description.to_string()).with_length(display_len(&line)));
buffer.add_marker(
Marker::new(mtype, offset)
.with_text(description.to_string())
.with_length(display_len(&line))
.with_reference(reference.to_string()),
);
buffer.append(&line);
buffer.append("\n");
// Kept in step with the buffer because `place_links` measures its offsets against this, and
Expand All @@ -60,24 +51,6 @@ pub(super) fn append_image(
page_display_text.push('\n');
}

/// The top edge, in PDF user units, of every image the page draws, ordered down the page.
///
/// Images reached through a form `XObject` count. A page whose content is wrapped in one form (a
/// scanner's output and a placed-artwork export both look like this) draws no image object of its
/// own, so stopping at the top level would find nothing on it at all.
pub(super) fn page_image_tops(page: &PdfPage) -> Vec<f64> {
let page_area = f64::from(page.width()) * f64::from(page.height());
let mut tops = Vec::new();
for i in 0..page.object_count() {
if let Some(object) = page.object(i) {
collect_image_tops(&object, 0, page_area, &mut tops);
}
}
// Y grows up the page, so the image nearest the top of it has the largest one.
tops.sort_unstable_by(|a, b| b.total_cmp(a));
tops
}

/// The fraction of the page one single image covers at most, from 0.0 to 1.0.
///
/// A scanned page is one big image laid over the whole sheet, so a value near 1.0 is the mark of
Expand Down Expand Up @@ -119,43 +92,6 @@ fn collect_largest_image_area(object: &PageObject, depth: u32, largest: &mut f64
}
}

fn collect_image_tops(object: &PageObject, depth: u32, page_area: f64, tops: &mut Vec<f64>) {
match object.kind() {
ObjectKind::Image => {
if let Some(bounds) = object.bounds()
&& is_announced_image((bounds.right - bounds.left).abs(), (bounds.top - bounds.bottom).abs(), page_area)
{
tops.push(f64::from(bounds.top));
}
}
ObjectKind::Form if depth < MAX_FORM_DEPTH => {
for i in 0..object.form_object_count() {
if let Some(child) = object.form_object(i) {
collect_image_tops(&child, depth + 1, page_area, tops);
}
}
}
_ => {}
}
}

fn is_announced_image(width: f32, height: f32, page_area: f64) -> bool {
let covers_page = page_area > 0.0 && f64::from(width) * f64::from(height) / page_area >= PAGE_IMAGE_COVERAGE;
width >= MIN_IMAGE_SIDE && height >= MIN_IMAGE_SIDE && !covers_page
}

/// Writes `count` undescribed images into the page, one line each.
pub(super) fn append_images(
count: usize,
buffer: &mut DocumentBuffer,
page_display_text: &mut String,
current_lines_info: &mut Vec<(usize, String)>,
) {
for _ in 0..count {
append_image(buffer, MarkerType::Image, "", page_display_text, current_lines_info);
}
}

/// The images a page draws that its structure tree never claimed, waiting to go out among the
/// page's blocks.
///
Expand All @@ -165,18 +101,19 @@ pub(super) fn append_images(
/// block that starts below it.
pub(super) struct UnclaimedImages {
/// Down the page, nearest the top first, and drained from the front as they are placed.
tops: std::collections::VecDeque<f64>,
images: std::collections::VecDeque<(f64, String)>,
/// The page they were found on, which is part of the reference that saves one.
page_index: i32,
}

impl UnclaimedImages {
/// The images to place. Empty for a page whose tree claims its figures, which is every tagged
/// page written the usual way, and the walk then does none of this.
pub(super) fn new(tops: &[f64]) -> Self {
Self { tops: tops.iter().copied().collect() }
/// The images to place, as their tops and content-stream paths. Empty for a page whose tree claims its figures, which is every tagged page written the usual way, and the walk then does none of this.
pub(super) fn new(tops: &[f64], paths: &[String], page_index: i32) -> Self {
Self { images: tops.iter().copied().zip(paths.iter().cloned()).collect(), page_index }
}

pub(super) fn is_empty(&self) -> bool {
self.tops.is_empty()
self.images.is_empty()
}

/// Writes every image drawn above `top`. Called with the top edge of each block just before
Expand All @@ -188,9 +125,11 @@ impl UnclaimedImages {
page_display_text: &mut String,
current_lines_info: &mut Vec<(usize, String)>,
) {
while self.tops.front().is_some_and(|image_top| *image_top > top) {
self.tops.pop_front();
append_image(buffer, MarkerType::Image, "", page_display_text, current_lines_info);
while self.images.front().is_some_and(|(image_top, _)| *image_top > top) {
if let Some((_, path)) = self.images.pop_front() {
let reference = format!("pdf:{}:{path}", self.page_index);
append_image(buffer, MarkerType::Image, "", page_display_text, current_lines_info, &reference);
}
}
}
}
Expand Down Expand Up @@ -218,42 +157,25 @@ pub(super) fn images_before_each_paragraph(image_tops: &[f64], paragraph_tops: &

#[cfg(test)]
mod tests {
use super::{UnclaimedImages, append_image, images_before_each_paragraph, is_announced_image};
use super::{UnclaimedImages, append_image, images_before_each_paragraph};
use crate::document::{DocumentBuffer, MarkerType};

/// The page from #1046: 323 by 518 points, under a 323 by 496 point scan.
const PAGE_AREA: f64 = 323.0 * 518.0;

#[test]
fn a_scan_under_the_whole_page_is_not_announced() {
assert!(!is_announced_image(323.0, 496.0, PAGE_AREA));
}

#[test]
fn a_figure_on_the_page_is_announced() {
assert!(is_announced_image(250.0, 300.0, PAGE_AREA));
}

#[test]
fn a_hairline_rule_is_not_announced() {
assert!(!is_announced_image(300.0, 0.5, PAGE_AREA));
}

/// A described figure reads as its description, and the marker spans the whole line so that
/// arrowing onto any part of it lands on the figure.
#[test]
fn a_described_figure_is_written_as_a_line_of_its_own() {
let mut buffer = DocumentBuffer::new();
let mut page_text = String::new();
let mut lines_info = Vec::new();
append_image(&mut buffer, MarkerType::Figure, "a cat sleeping", &mut page_text, &mut lines_info);
append_image(&mut buffer, MarkerType::Figure, "a cat sleeping", &mut page_text, &mut lines_info, "pdf:2:0/1");
assert_eq!(buffer.content, "[Figure: a cat sleeping]\n");
assert_eq!(page_text, buffer.content, "the display text has to mirror the buffer for link placement");
assert_eq!(lines_info.len(), 1);
let marker = buffer.markers.iter().find(|m| m.mtype == MarkerType::Figure).expect("Figure marker");
assert_eq!(marker.position, 0);
assert_eq!(marker.text, "a cat sleeping", "the marker keeps the description on its own");
assert_eq!(marker.length, "[Figure: a cat sleeping]".len());
assert_eq!(marker.reference, "pdf:2:0/1", "the marker names where the picture sits so it can be saved");
}

/// An image nobody described still gets a line: it is the only sign a reader who cannot see
Expand All @@ -263,7 +185,7 @@ mod tests {
let mut buffer = DocumentBuffer::new();
let mut page_text = String::new();
let mut lines_info = Vec::new();
append_image(&mut buffer, MarkerType::Image, "", &mut page_text, &mut lines_info);
append_image(&mut buffer, MarkerType::Image, "", &mut page_text, &mut lines_info, "");
assert_eq!(buffer.content, "[Image]\n");
assert!(buffer.markers.iter().any(|m| m.mtype == MarkerType::Image));
}
Expand Down Expand Up @@ -298,7 +220,8 @@ mod tests {
/// sits below every block and goes out when the page ends.
#[test]
fn unclaimed_images_go_in_front_of_the_first_block_below_each() {
let mut images = UnclaimedImages::new(&[700.0, 500.0, 100.0]);
let mut images =
UnclaimedImages::new(&[700.0, 500.0, 100.0], &["0".to_string(), "1".to_string(), "2".to_string()], 0);
let mut buffer = DocumentBuffer::new();
let mut page_text = String::new();
let mut lines_info = Vec::new();
Expand All @@ -324,7 +247,7 @@ mod tests {
/// writes an image line of its own.
#[test]
fn a_page_whose_tree_claims_its_figures_places_nothing() {
let mut images = UnclaimedImages::new(&[]);
let mut images = UnclaimedImages::new(&[], &[], 0);
let mut buffer = DocumentBuffer::new();
let mut page_text = String::new();
let mut lines_info = Vec::new();
Expand Down
Loading
Loading