mod cmap;
mod extract;
mod font;
mod sfnt;
use pdfboss_core::{
block_on, AsyncObjectSource, Document, Error, Immediate, OcState, Page, Result, StructureTree,
};
pub use extract::{ExtractReport, FontCache, SkipCause, SkippedText, SkippedTextKind};
pub use pdfboss_core::{MarkedContentId, Point, Rect};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum ReadingOrder {
#[default]
Content,
StructureTree,
Geometric,
}
impl ReadingOrder {
pub const ALL: [ReadingOrder; 3] = [
ReadingOrder::Content,
ReadingOrder::StructureTree,
ReadingOrder::Geometric,
];
pub fn as_str(self) -> &'static str {
match self {
ReadingOrder::Content => "content",
ReadingOrder::StructureTree => "structure-tree",
ReadingOrder::Geometric => "geometric",
}
}
}
impl std::fmt::Display for ReadingOrder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for ReadingOrder {
type Err = Error;
fn from_str(s: &str) -> Result<ReadingOrder> {
ReadingOrder::ALL
.into_iter()
.find(|order| order.as_str() == s)
.ok_or_else(|| {
Error::Other(format!(
"unknown reading order {s:?}: expected 'content', 'structure-tree' or 'geometric'"
))
})
}
}
fn structure_for(doc: &Document, order: ReadingOrder) -> Option<StructureTree> {
match order {
ReadingOrder::StructureTree => doc.structure_tree(),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextSpan {
pub text: String,
pub x: f32,
pub y: f32,
pub end_x: f32,
pub size: f32,
pub font: String,
pub font_name: String,
pub page: usize,
pub bbox: Rect,
pub bold: bool,
pub italic: bool,
pub monospace: bool,
pub serif: bool,
pub rise: f32,
pub vertical: bool,
pub invisible: bool,
pub color: Option<(f32, f32, f32)>,
pub underline: bool,
pub strikethrough: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Ruling {
pub start: Point,
pub end: Point,
pub width: f32,
}
pub fn extract_spans(doc: &Document, page: &Page, order: ReadingOrder) -> Result<Vec<TextSpan>> {
let oc = doc.oc_state();
let structure = structure_for(doc, order);
let (spans, _, _) = block_on(extract::page_spans_and_rulings_with(
Immediate(doc),
page,
None,
oc.as_ref(),
structure.as_ref(),
order,
));
Ok(spans)
}
pub async fn extract_spans_with<S: AsyncObjectSource>(
src: S,
page: &Page,
oc: Option<&OcState>,
structure: Option<&StructureTree>,
order: ReadingOrder,
) -> Result<Vec<TextSpan>> {
let (spans, _, _) =
extract::page_spans_and_rulings_with(src, page, None, oc, structure, order).await;
Ok(spans)
}
pub fn extract_spans_reporting(
doc: &Document,
page: &Page,
order: ReadingOrder,
) -> Result<(Vec<TextSpan>, ExtractReport)> {
let oc = doc.oc_state();
let structure = structure_for(doc, order);
let (spans, _, report) = block_on(extract::page_spans_and_rulings_with(
Immediate(doc),
page,
None,
oc.as_ref(),
structure.as_ref(),
order,
));
Ok((spans, report))
}
pub async fn extract_spans_reporting_with<S: AsyncObjectSource>(
src: S,
page: &Page,
oc: Option<&OcState>,
structure: Option<&StructureTree>,
order: ReadingOrder,
) -> Result<(Vec<TextSpan>, ExtractReport)> {
let (spans, _, report) =
extract::page_spans_and_rulings_with(src, page, None, oc, structure, order).await;
Ok((spans, report))
}
pub fn extract_spans_reporting_cached(
doc: &Document,
page: &Page,
fonts: &FontCache,
order: ReadingOrder,
) -> Result<(Vec<TextSpan>, ExtractReport)> {
let oc = doc.oc_state();
let structure = structure_for(doc, order);
let (spans, _, report) = block_on(extract::page_spans_and_rulings_with(
Immediate(doc),
page,
Some(fonts),
oc.as_ref(),
structure.as_ref(),
order,
));
Ok((spans, report))
}
pub async fn extract_spans_reporting_cached_with<S: AsyncObjectSource>(
src: S,
page: &Page,
fonts: &FontCache,
oc: Option<&OcState>,
structure: Option<&StructureTree>,
order: ReadingOrder,
) -> Result<(Vec<TextSpan>, ExtractReport)> {
let (spans, _, report) =
extract::page_spans_and_rulings_with(src, page, Some(fonts), oc, structure, order).await;
Ok((spans, report))
}
pub fn extract_spans_and_rulings_reporting(
doc: &Document,
page: &Page,
order: ReadingOrder,
) -> Result<(Vec<TextSpan>, Vec<Ruling>, ExtractReport)> {
let oc = doc.oc_state();
let structure = structure_for(doc, order);
let (spans, rulings, report) = block_on(extract::page_spans_and_rulings_with(
Immediate(doc),
page,
None,
oc.as_ref(),
structure.as_ref(),
order,
));
Ok((spans, rulings, report))
}
pub async fn extract_spans_and_rulings_reporting_with<S: AsyncObjectSource>(
src: S,
page: &Page,
oc: Option<&OcState>,
structure: Option<&StructureTree>,
order: ReadingOrder,
) -> Result<(Vec<TextSpan>, Vec<Ruling>, ExtractReport)> {
let (spans, rulings, report) =
extract::page_spans_and_rulings_with(src, page, None, oc, structure, order).await;
Ok((spans, rulings, report))
}
pub fn extract_spans_and_rulings_reporting_cached(
doc: &Document,
page: &Page,
fonts: &FontCache,
order: ReadingOrder,
) -> Result<(Vec<TextSpan>, Vec<Ruling>, ExtractReport)> {
let oc = doc.oc_state();
let structure = structure_for(doc, order);
let (spans, rulings, report) = block_on(extract::page_spans_and_rulings_with(
Immediate(doc),
page,
Some(fonts),
oc.as_ref(),
structure.as_ref(),
order,
));
Ok((spans, rulings, report))
}
pub async fn extract_spans_and_rulings_reporting_cached_with<S: AsyncObjectSource>(
src: S,
page: &Page,
fonts: &FontCache,
oc: Option<&OcState>,
structure: Option<&StructureTree>,
order: ReadingOrder,
) -> Result<(Vec<TextSpan>, Vec<Ruling>, ExtractReport)> {
let (spans, rulings, report) =
extract::page_spans_and_rulings_with(src, page, Some(fonts), oc, structure, order).await;
Ok((spans, rulings, report))
}
#[cfg(test)]
mod tests {
use super::*;
use pdfboss_core::{resolve_with, BoxFuture, ObjRef, Object, Stream};
use pdfboss_testkit::{simple_doc, PdfBuilder};
use std::future::Future;
#[test]
fn form_matrix_translates_the_nested_span() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 5 0 R >> /XObject << /Fx 6 0 R >> >> \
/Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (out) Tj ET /Fx Do");
b.object(
5,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
/Encoding /WinAnsiEncoding >>",
);
b.stream(
6,
"/Type /XObject /Subtype /Form /BBox [0 0 612 792] \
/Matrix [1 0 0 1 0 -20]",
b"BT /F1 12 Tf 72 720 Td (in) Tj ET",
);
let doc = Document::load(b.build(1)).unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
assert_eq!(spans.len(), 2);
assert!((spans[1].y - 700.0).abs() < 1e-3); }
#[test]
fn extract_spans_sane_positions() {
let doc = Document::load(simple_doc("Hi")).unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
assert_eq!(spans.len(), 1);
let s = &spans[0];
assert_eq!(s.text, "Hi");
assert!((s.x - 72.0).abs() < 1e-3);
assert!((s.y - 720.0).abs() < 1e-3);
assert!((s.size - 12.0).abs() < 1e-3);
assert_eq!(s.font, "F1");
}
#[test]
fn extract_spans_and_rulings_reports_both() {
let doc = Document::load(pdfboss_testkit::doc_with_graphics(
"BT /F1 12 Tf 72 720 Td (Hi) Tj ET 72 700 m 272 700 l S",
))
.unwrap();
let page = doc.page(0).unwrap();
let (spans, rulings, report) =
extract_spans_and_rulings_reporting(&doc, &page, ReadingOrder::Content).unwrap();
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].text, "Hi");
assert_eq!(rulings.len(), 1);
assert!((rulings[0].start.y - 700.0).abs() < 1e-3);
assert!(report.is_complete());
}
#[test]
fn extract_spans_ordering_multi_line() {
let doc = Document::load(pdfboss_testkit::doc_with_graphics(
"BT /F1 12 Tf 72 720 Td (top) Tj 0 -40 Td (bottom) Tj ET",
))
.unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
assert_eq!(spans.len(), 2);
assert!(spans[0].y > spans[1].y);
assert_eq!(spans[0].text, "top");
assert_eq!(spans[1].text, "bottom");
assert!(spans.iter().all(|s| s.size > 0.0 && s.x >= 0.0));
}
#[test]
fn span_font_name_is_the_base_font_verbatim() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (x) Tj ET");
b.object(
5,
"<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Times-Roman \
/Encoding /WinAnsiEncoding >>",
);
let doc = Document::load(b.build(1)).unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
assert_eq!(spans[0].font_name, "ABCDEF+Times-Roman");
assert_eq!(spans[0].font, "F1");
}
#[test]
fn span_font_name_falls_back_to_descriptor_font_name() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 5 0 R /F2 7 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (a) Tj /F2 12 Tf (b) Tj ET");
b.object(
5,
"<< /Type /Font /Subtype /Type1 /Encoding /WinAnsiEncoding \
/FontDescriptor 6 0 R >>",
);
b.object(6, "<< /Type /FontDescriptor /FontName /Nameless-Face >>");
b.object(
7,
"<< /Type /Font /Subtype /Type1 /Encoding /WinAnsiEncoding >>",
);
let doc = Document::load(b.build(1)).unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
assert_eq!(spans[0].font_name, "Nameless-Face");
assert_eq!(spans[1].font_name, "");
}
#[test]
fn span_carries_its_page_index() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 7 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (one) Tj ET");
b.object(
5,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >>",
);
b.stream(6, "", b"BT /F1 12 Tf 72 720 Td (two) Tj ET");
b.object(
7,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
/Encoding /WinAnsiEncoding >>",
);
let doc = Document::load(b.build(1)).unwrap();
for index in 0..2 {
let page = doc.page(index).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
assert_eq!(spans[0].page, index, "page {index}");
}
}
#[test]
fn span_bbox_uses_descriptor_metrics() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (Hi) Tj ET");
b.object(
5,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
/Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>",
);
b.object(
6,
"<< /Type /FontDescriptor /FontName /Helvetica \
/Ascent 718 /Descent -207 >>",
);
let doc = Document::load(b.build(1)).unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
let s = &spans[0];
assert!((s.bbox.x0 - s.x).abs() < 1e-3);
assert!((s.bbox.x1 - s.end_x).abs() < 1e-3);
assert!((s.bbox.y0 - (720.0 - 0.207 * 12.0)).abs() < 1e-3);
assert!((s.bbox.y1 - (720.0 + 0.718 * 12.0)).abs() < 1e-3);
}
#[test]
fn span_bbox_defaults_to_em_fractions() {
let doc = Document::load(simple_doc("Hi")).unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
let s = &spans[0];
assert!((s.bbox.y0 - (720.0 - 0.2 * 12.0)).abs() < 1e-3);
assert!((s.bbox.y1 - (720.0 + 0.8 * 12.0)).abs() < 1e-3);
}
#[test]
fn span_bbox_falls_back_to_cap_height() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (Hi) Tj ET");
b.object(
5,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
/Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>",
);
b.object(
6,
"<< /Type /FontDescriptor /FontName /Helvetica /CapHeight 700 >>",
);
let doc = Document::load(b.build(1)).unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
assert!((spans[0].bbox.y1 - (720.0 + 0.7 * 12.0)).abs() < 1e-3);
assert!((spans[0].bbox.y0 - (720.0 - 0.2 * 12.0)).abs() < 1e-3);
}
#[test]
fn fixed_pitch_flag_marks_monospace() {
let spans = flag_spans(1);
assert!(spans[0].monospace);
assert!(!spans[0].serif);
}
#[test]
fn serif_flag_marks_serif() {
let spans = flag_spans(2);
assert!(spans[0].serif);
assert!(!spans[0].monospace);
}
fn flag_spans(flags: u32) -> Vec<TextSpan> {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (x) Tj ET");
b.object(
5,
"<< /Type /Font /Subtype /Type1 /BaseFont /Custom \
/Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>",
);
b.object(
6,
&format!("<< /Type /FontDescriptor /FontName /Custom /Flags {flags} >>"),
);
let doc = Document::load(b.build(1)).unwrap();
let page = doc.page(0).unwrap();
extract_spans(&doc, &page, ReadingOrder::Content).unwrap()
}
#[test]
fn span_carries_text_rise() {
let doc = Document::load(pdfboss_testkit::doc_with_graphics(
"BT /F1 12 Tf 72 720 Td (flat) Tj 5 Ts (up) Tj ET",
))
.unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
assert_eq!(spans[0].rise, 0.0);
assert_eq!(spans[1].rise, 5.0);
}
#[test]
fn span_marks_vertical_writing() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F1 12 Tf 72 720 Td <0001> Tj ET");
b.object(
5,
"<< /Type /Font /Subtype /Type0 /BaseFont /X /Encoding /Identity-V \
/DescendantFonts [6 0 R] /ToUnicode 7 0 R >>",
);
b.object(
6,
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /X /DW 600 >>",
);
b.stream(
7,
"",
b"1 begincodespacerange <0000> <FFFF> endcodespacerange\n\
1 beginbfchar <0001> <0041> endbfchar",
);
let doc = Document::load(b.build(1)).unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
assert!(spans[0].vertical);
}
#[test]
fn invisible_render_modes_mark_the_span() {
let doc = Document::load(pdfboss_testkit::doc_with_graphics(
"BT /F1 12 Tf 72 720 Td (seen) Tj 3 Tr (ocr) Tj 7 Tr (clip) Tj 0 Tr (back) Tj ET",
))
.unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
let invisible: Vec<bool> = spans.iter().map(|s| s.invisible).collect();
assert_eq!(invisible, [false, true, true, false]);
}
#[test]
fn span_color_defaults_to_black() {
let doc = Document::load(simple_doc("Hi")).unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
assert_eq!(spans[0].color, Some((0.0, 0.0, 0.0)));
}
#[test]
fn device_fill_colors_set_the_span_color() {
let doc = Document::load(pdfboss_testkit::doc_with_graphics(
"BT /F1 12 Tf 72 720 Td 1 0 0 rg (red) Tj 0.5 g (gray) Tj \
1 0 0 0 k (cyan) Tj ET",
))
.unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
assert_eq!(spans[0].color, Some((1.0, 0.0, 0.0)));
assert_eq!(spans[1].color, Some((0.5, 0.5, 0.5)));
assert_eq!(spans[2].color, Some((0.0, 1.0, 1.0)));
}
#[test]
fn sc_components_set_the_span_color_by_count() {
let doc = Document::load(pdfboss_testkit::doc_with_graphics(
"BT /F1 12 Tf 72 720 Td /DeviceRGB cs 0 1 0 sc (green) Tj \
0.25 sc (dark) Tj ET",
))
.unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
assert_eq!(spans[0].color, Some((0.0, 1.0, 0.0)));
assert_eq!(spans[1].color, Some((0.25, 0.25, 0.25)));
}
#[test]
fn pattern_fill_leaves_color_unknown() {
let doc = Document::load(pdfboss_testkit::doc_with_graphics(
"BT /F1 12 Tf 72 720 Td /Pattern cs /P1 scn (patterned) Tj ET",
))
.unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
assert_eq!(spans[0].color, None);
}
#[test]
fn an_underline_ruling_marks_the_span() {
let doc = Document::load(pdfboss_testkit::doc_with_graphics(
"BT /F1 12 Tf 72 720 Td (Hello) Tj ET 72 718.5 m 105 718.5 l S",
))
.unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
assert!(spans[0].underline);
assert!(!spans[0].strikethrough);
}
#[test]
fn a_strikethrough_ruling_marks_the_span() {
let doc = Document::load(pdfboss_testkit::doc_with_graphics(
"BT /F1 12 Tf 72 720 Td (Hello) Tj ET 72 723.6 m 105 723.6 l S",
))
.unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
assert!(spans[0].strikethrough);
assert!(!spans[0].underline);
}
#[test]
fn a_distant_ruling_marks_nothing() {
let doc = Document::load(pdfboss_testkit::doc_with_graphics(
"BT /F1 12 Tf 72 720 Td (Hello) Tj ET 72 700 m 105 700 l S",
))
.unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
assert!(!spans[0].underline);
assert!(!spans[0].strikethrough);
}
#[test]
fn an_underline_needs_most_of_the_span_covered() {
let doc = Document::load(pdfboss_testkit::doc_with_graphics(
"BT /F1 12 Tf 72 720 Td (Hello) Tj ET 100 718.5 m 130 718.5 l S",
))
.unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
assert!(!spans[0].underline);
}
#[test]
fn the_source_generic_entry_points_honor_optional_content() {
let mut b = PdfBuilder::new();
b.object(
1,
"<< /Type /Catalog /Pages 2 0 R /OCProperties \
<< /OCGs [6 0 R] /D << /OFF [6 0 R] >> >> >>",
);
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 5 0 R >> \
/Properties << /H 6 0 R >> >> /Contents 4 0 R >>",
);
b.stream(
4,
"",
b"BT /F1 12 Tf 72 720 Td /OC /H BDC (hidden) Tj EMC (kept) Tj ET",
);
b.object(
5,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
/Encoding /WinAnsiEncoding >>",
);
b.object(6, "<< /Type /OCG /Name (hidden) >>");
let doc = Document::load(b.build(1)).unwrap();
let page = doc.page(0).unwrap();
let oc = doc.oc_state();
let gated = block_on(extract_spans_with(
Immediate(&doc),
&page,
oc.as_ref(),
None,
ReadingOrder::Content,
))
.unwrap();
let texts: Vec<&str> = gated.iter().map(|s| s.text.as_str()).collect();
assert_eq!(texts, ["kept"]);
let all = block_on(extract_spans_with(
Immediate(&doc),
&page,
None,
None,
ReadingOrder::Content,
))
.unwrap();
let texts: Vec<&str> = all.iter().map(|s| s.text.as_str()).collect();
assert_eq!(texts, ["hidden", "kept"]);
}
#[test]
fn descriptor_flags_set_span_style() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (x) Tj ET");
b.object(
5,
"<< /Type /Font /Subtype /Type1 /BaseFont /Custom \
/Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>",
);
b.object(
6,
"<< /Type /FontDescriptor /FontName /Custom /Flags 64 /FontWeight 700 >>",
);
let doc = Document::load(b.build(1)).unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
assert!(spans[0].italic, "Flags bit 7 (mask 64) is Italic");
assert!(spans[0].bold, "FontWeight 700 >= 600 is bold");
}
#[test]
fn thick_stemv_reads_as_bold() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 5 0 R /F2 7 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (a) Tj /F2 12 Tf (b) Tj ET");
b.object(
5,
"<< /Type /Font /Subtype /Type1 /BaseFont /NimbusRomNo9L-Medi \
/Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>",
);
b.object(
6,
"<< /Type /FontDescriptor /FontName /NimbusRomNo9L-Medi /Flags 4 /StemV 140 >>",
);
b.object(
7,
"<< /Type /Font /Subtype /Type1 /BaseFont /NimbusRomNo9L-Regu \
/Encoding /WinAnsiEncoding /FontDescriptor 8 0 R >>",
);
b.object(
8,
"<< /Type /FontDescriptor /FontName /NimbusRomNo9L-Regu /Flags 4 /StemV 85 >>",
);
let doc = Document::load(b.build(1)).unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
assert!(spans[0].bold, "StemV 140 is a bold stem");
assert!(!spans[1].bold, "StemV 85 is a regular stem");
}
#[test]
fn basefont_name_and_italic_angle_fallbacks() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (a) Tj /F2 12 Tf (b) Tj ET");
b.object(
5,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-BoldOblique \
/Encoding /WinAnsiEncoding >>",
);
b.object(
6,
"<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman \
/Encoding /WinAnsiEncoding /FontDescriptor 7 0 R >>",
);
b.object(
7,
"<< /Type /FontDescriptor /FontName /Times-Roman /ItalicAngle -12 >>",
);
let doc = Document::load(b.build(1)).unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
assert!(
spans[0].bold && spans[0].italic,
"BaseFont substrings Bold+Oblique"
);
assert!(!spans[1].bold && spans[1].italic, "ItalicAngle != 0 alone");
}
#[test]
fn type0_descendant_descriptor_sets_style() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F1 12 Tf 72 720 Td <0001> Tj ET");
b.object(
5,
"<< /Type /Font /Subtype /Type0 /BaseFont /X /Encoding /Identity-H \
/DescendantFonts [6 0 R] /ToUnicode 8 0 R >>",
);
b.object(
6,
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /X /DW 600 \
/FontDescriptor 7 0 R >>",
);
b.object(
7,
"<< /Type /FontDescriptor /FontName /X /Flags 64 /FontWeight 600 >>",
);
b.stream(
8,
"",
b"1 begincodespacerange <0000> <FFFF> endcodespacerange\n\
1 beginbfchar <0001> <0041> endbfchar",
);
let doc = Document::load(b.build(1)).unwrap();
let page = doc.page(0).unwrap();
let spans = extract_spans(&doc, &page, ReadingOrder::Content).unwrap();
assert!(spans[0].bold && spans[0].italic);
}
struct Counting<'a> {
inner: Immediate<&'a Document>,
resolutions: std::cell::RefCell<std::collections::HashMap<u32, usize>>,
}
impl<'a> Counting<'a> {
fn new(doc: &'a Document) -> Counting<'a> {
Counting {
inner: Immediate(doc),
resolutions: std::cell::RefCell::new(std::collections::HashMap::new()),
}
}
fn resolutions(&self, num: u32) -> usize {
self.resolutions.borrow().get(&num).copied().unwrap_or(0)
}
}
impl AsyncObjectSource for Counting<'_> {
fn get(&self, r: ObjRef) -> BoxFuture<'_, Result<Object>> {
self.inner.get(r)
}
fn stream_data<'b>(&'b self, s: &'b Stream) -> BoxFuture<'b, Result<Vec<u8>>> {
self.inner.stream_data(s)
}
fn resolve<'b>(&'b self, o: &'b Object) -> BoxFuture<'b, Result<Object>> {
if let Object::Ref(r) = o {
*self.resolutions.borrow_mut().entry(r.num).or_insert(0) += 1;
}
self.inner.resolve(o)
}
}
#[test]
fn a_font_reached_from_repeated_forms_loads_once_per_page() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /XObject << /Fx 6 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"/Fx Do /Fx Do");
b.object(
5,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
/Encoding /WinAnsiEncoding >>",
);
b.stream(
6,
"/Type /XObject /Subtype /Form /BBox [0 0 612 792] \
/Resources << /Font << /F1 5 0 R >> >>",
b"BT /F1 12 Tf 72 700 Td (x) Tj ET",
);
let doc = Document::load(b.build(1)).unwrap();
let page = doc.page(0).unwrap();
let counting = Counting::new(&doc);
let (spans, report) = block_on(extract_spans_reporting_with(
&counting,
&page,
None,
None,
ReadingOrder::Content,
))
.unwrap();
assert!(report.is_complete(), "unexpected skips: {report:?}");
assert_eq!(spans.len(), 2, "both form invocations must show text");
assert_eq!(
counting.resolutions(5),
1,
"one font dictionary resolution per page walk"
);
}
#[test]
fn a_resource_category_resolves_once_per_walk() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /ExtGState 5 0 R >> /Contents 4 0 R >>",
);
b.stream(
4,
"",
b"/G1 gs 10 10 m 100 10 l S \
/G1 gs 10 20 m 100 20 l S \
/G1 gs 10 30 m 100 30 l S",
);
b.object(5, "<< /G1 << /LW 2 >> >>");
let doc = Document::load(b.build(1)).unwrap();
let page = doc.page(0).unwrap();
let counting = Counting::new(&doc);
let (_, rulings, report) = block_on(extract_spans_and_rulings_reporting_with(
&counting,
&page,
None,
None,
ReadingOrder::Content,
))
.unwrap();
assert!(report.is_complete(), "unexpected skips: {report:?}");
assert_eq!(rulings.len(), 3, "all three strokes extract");
assert_eq!(
counting.resolutions(5),
1,
"one category dictionary resolution per page walk"
);
}
#[test]
fn a_font_shared_across_pages_loads_once_per_document() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 7 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (one) Tj ET");
b.object(
5,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >>",
);
b.stream(6, "", b"BT /F1 12 Tf 72 720 Td (two) Tj ET");
b.object(
7,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
/Encoding /WinAnsiEncoding >>",
);
let doc = Document::load(b.build(1)).unwrap();
let fonts = FontCache::default();
let counting = Counting::new(&doc);
let mut cached = Vec::new();
for index in 0..2 {
let page = doc.page(index).unwrap();
let (spans, report) = block_on(extract_spans_reporting_cached_with(
&counting,
&page,
&fonts,
None,
None,
ReadingOrder::Content,
))
.unwrap();
assert!(report.is_complete(), "unexpected skips: {report:?}");
cached.push(spans);
}
assert_eq!(
counting.resolutions(7),
1,
"one font dictionary resolution per document"
);
for (index, spans) in cached.iter().enumerate() {
let page = doc.page(index).unwrap();
let plain = extract_spans_reporting(&doc, &page, ReadingOrder::Content)
.unwrap()
.0;
assert_eq!(spans, &plain, "page {index} must extract identically");
}
}
#[test]
fn a_shared_cache_keeps_the_name_binding_per_page() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 7 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (aa) Tj ET");
b.object(
5,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 8 0 R >> >> /Contents 6 0 R >>",
);
b.stream(6, "", b"BT /F1 12 Tf 72 720 Td (aa) Tj ET");
b.object(
7,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
/Encoding /WinAnsiEncoding /FirstChar 97 /LastChar 97 /Widths [500] >>",
);
b.object(
8,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
/Encoding /WinAnsiEncoding /FirstChar 97 /LastChar 97 /Widths [1000] >>",
);
let doc = Document::load(b.build(1)).unwrap();
let fonts = FontCache::default();
let mut advances = Vec::new();
for index in 0..2 {
let page = doc.page(index).unwrap();
let (spans, _) =
extract_spans_reporting_cached(&doc, &page, &fonts, ReadingOrder::Content).unwrap();
assert_eq!(spans.len(), 1);
advances.push(spans[0].end_x - spans[0].x);
}
assert!(
(advances[0] - 12.0).abs() < 1e-3,
"page one: {}",
advances[0]
);
assert!(
(advances[1] - 24.0).abs() < 1e-3,
"page two: {}",
advances[1]
);
}
struct NullSource {
payload: Vec<u8>,
}
impl AsyncObjectSource for NullSource {
fn get(&self, _r: ObjRef) -> BoxFuture<'_, Result<Object>> {
Box::pin(std::future::ready(Ok(Object::Null)))
}
fn stream_data<'a>(&'a self, _s: &'a Stream) -> BoxFuture<'a, Result<Vec<u8>>> {
Box::pin(std::future::ready(Ok(self.payload.clone())))
}
fn resolve<'a>(&'a self, o: &'a Object) -> BoxFuture<'a, Result<Object>> {
Box::pin(resolve_with(self, o))
}
}
#[test]
fn the_async_entry_point_yields_a_spawnable_future() {
fn assert_send_static<F: Future + Send + 'static>(_: &F) {}
let doc = Document::load(simple_doc("Hello")).unwrap();
let spans_page = doc.page(0).unwrap();
drop(doc);
let spans = async move {
extract_spans_with(
NullSource {
payload: Vec::new(),
},
&spans_page,
None,
None,
ReadingOrder::Content,
)
.await
};
assert_send_static(&spans);
assert!(block_on(spans).unwrap().is_empty());
}
fn tagged_doc(
content: &[u8],
page_extra: &str,
extra: impl FnOnce(&mut PdfBuilder),
) -> Document {
let mut b = PdfBuilder::new();
b.object(
1,
"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 10 0 R >>",
);
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
&format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /StructParents 0 \
/Resources << /Font << /F1 5 0 R >> {page_extra} >> /Contents 4 0 R >>"
),
);
b.stream(4, "", content);
b.object(
5,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
);
b.object(
10,
"<< /Type /StructTreeRoot /K [11 0 R] /ParentTree 12 0 R >>",
);
b.object(
11,
"<< /Type /StructElem /S /Document /P 10 0 R /K [13 0 R 14 0 R] >>",
);
b.object(12, "<< /Nums [0 [13 0 R 14 0 R 13 0 R 14 0 R]] >>");
b.object(
13,
"<< /Type /StructElem /S /P /P 11 0 R /Pg 3 0 R /K [0 2] >>",
);
b.object(
14,
"<< /Type /StructElem /S /P /P 11 0 R /Pg 3 0 R /K [1 3] >>",
);
extra(&mut b);
Document::load(b.build(1)).unwrap()
}
const TWO_COLUMNS: &[u8] = b"BT /F1 12 Tf \
/P << /MCID 2 >> BDC 1 0 0 1 72 680 Tm (L2) Tj EMC \
/P << /MCID 3 >> BDC 1 0 0 1 300 680 Tm (R2) Tj EMC \
/P << /MCID 0 >> BDC 1 0 0 1 72 700 Tm (L1) Tj EMC \
/P << /MCID 1 >> BDC 1 0 0 1 300 700 Tm (R1) Tj EMC ET";
fn texts(spans: &[TextSpan]) -> Vec<&str> {
spans.iter().map(|s| s.text.as_str()).collect()
}
fn ordered(doc: &Document, order: ReadingOrder) -> (Vec<String>, ReadingOrder) {
let page = doc.page(0).unwrap();
let (spans, report) = extract_spans_reporting(doc, &page, order).unwrap();
(spans.into_iter().map(|s| s.text).collect(), report.order)
}
#[test]
fn reading_order_names_round_trip() {
for order in ReadingOrder::ALL {
assert_eq!(order.as_str().parse::<ReadingOrder>().unwrap(), order);
assert_eq!(order.to_string(), order.as_str());
}
assert_eq!(ReadingOrder::default(), ReadingOrder::Content);
assert!("bogus".parse::<ReadingOrder>().is_err());
}
#[test]
fn content_order_is_the_stream_as_written() {
let doc = tagged_doc(TWO_COLUMNS, "", |_| {});
let (spans, order) = ordered(&doc, ReadingOrder::Content);
assert_eq!(spans, ["L2", "R2", "L1", "R1"]);
assert_eq!(order, ReadingOrder::Content);
}
#[test]
fn geometric_order_extracts_the_stream_and_tags_the_report() {
let doc = tagged_doc(TWO_COLUMNS, "", |_| {});
let (spans, order) = ordered(&doc, ReadingOrder::Geometric);
assert_eq!(spans, ["L2", "R2", "L1", "R1"]);
assert_eq!(order, ReadingOrder::Geometric);
}
#[test]
fn structure_tree_order_follows_the_tree() {
let doc = tagged_doc(TWO_COLUMNS, "", |_| {});
let (spans, order) = ordered(&doc, ReadingOrder::StructureTree);
assert_eq!(spans, ["L1", "L2", "R1", "R2"]);
assert_eq!(order, ReadingOrder::StructureTree);
}
#[test]
fn structure_tree_order_reads_an_untagged_document_in_content_order() {
let doc = Document::load(pdfboss_testkit::multi_page_doc(&["one", "two"])).unwrap();
let page = doc.page(1).unwrap();
let (spans, report) =
extract_spans_reporting(&doc, &page, ReadingOrder::StructureTree).unwrap();
assert_eq!(texts(&spans), ["two"]);
assert_eq!(report.order, ReadingOrder::Content);
}
#[test]
fn a_page_the_tree_does_not_reach_reads_in_content_order() {
let doc = tagged_doc(TWO_COLUMNS, "", |b| {
b.object(12, "<< /Nums [7 [13 0 R]] >>");
});
let (spans, order) = ordered(&doc, ReadingOrder::StructureTree);
assert_eq!(spans, ["L2", "R2", "L1", "R1"]);
assert_eq!(order, ReadingOrder::Content);
}
#[test]
fn untagged_content_keeps_its_place_after_the_tagged_content_before_it() {
let content = b"BT /F1 12 Tf \
/P << /MCID 2 >> BDC 1 0 0 1 72 680 Tm (L2) Tj EMC \
/Artifact BMC 1 0 0 1 72 40 Tm (footer) Tj EMC \
/P << /MCID 3 >> BDC 1 0 0 1 300 680 Tm (R2) Tj EMC \
/P << /MCID 0 >> BDC 1 0 0 1 72 700 Tm (L1) Tj EMC \
/P << /MCID 1 >> BDC 1 0 0 1 300 700 Tm (R1) Tj EMC ET";
let doc = tagged_doc(content, "", |_| {});
let (spans, _) = ordered(&doc, ReadingOrder::StructureTree);
assert_eq!(spans, ["L1", "L2", "footer", "R1", "R2"]);
}
#[test]
fn named_marked_content_properties_are_read() {
let content = b"BT /F1 12 Tf \
/P /M2 BDC 1 0 0 1 72 680 Tm (L2) Tj EMC \
/P /M3 BDC 1 0 0 1 300 680 Tm (R2) Tj EMC \
/P /M0 BDC 1 0 0 1 72 700 Tm (L1) Tj EMC \
/P /M1 BDC 1 0 0 1 300 700 Tm (R1) Tj EMC ET";
let props = "/Properties << /M0 << /MCID 0 >> /M1 << /MCID 1 >> \
/M2 << /MCID 2 >> /M3 << /MCID 3 >> >>";
let doc = tagged_doc(content, props, |_| {});
let (spans, order) = ordered(&doc, ReadingOrder::StructureTree);
assert_eq!(spans, ["L1", "L2", "R1", "R2"]);
assert_eq!(order, ReadingOrder::StructureTree);
}
#[test]
fn a_form_files_its_marked_content_under_its_own_parents_key() {
let content = b"BT /F1 12 Tf \
/P << /MCID 3 >> BDC 1 0 0 1 300 680 Tm (R2) Tj EMC \
/P << /MCID 1 >> BDC 1 0 0 1 300 700 Tm (R1) Tj EMC ET /Fx Do";
let doc = tagged_doc(content, "/XObject << /Fx 6 0 R >>", |b| {
b.stream(
6,
"/Type /XObject /Subtype /Form /BBox [0 0 612 792] /StructParents 1",
b"BT /F1 12 Tf \
/P << /MCID 1 >> BDC 1 0 0 1 72 680 Tm (L2) Tj EMC \
/P << /MCID 0 >> BDC 1 0 0 1 72 700 Tm (L1) Tj EMC ET",
);
b.object(
12,
"<< /Nums [0 [null 14 0 R null 14 0 R] 1 [13 0 R 13 0 R]] >>",
);
b.object(
13,
"<< /Type /StructElem /S /P /P 11 0 R /Pg 3 0 R /K [0 1] >>",
);
});
let (spans, order) = ordered(&doc, ReadingOrder::StructureTree);
assert_eq!(spans, ["L1", "L2", "R1", "R2"]);
assert_eq!(order, ReadingOrder::StructureTree);
}
}