mod cmap;
mod extract;
mod font;
mod sfnt;
use pdfboss_core::{block_on, AsyncObjectSource, Document, Immediate, OcState, Page, Result};
pub use extract::{ExtractReport, FontCache, SkipCause, SkippedText, SkippedTextKind};
pub use pdfboss_core::{Point, Rect};
#[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) -> Result<Vec<TextSpan>> {
let oc = doc.oc_state();
let (spans, _, _) = block_on(extract::page_spans_and_rulings_with(
Immediate(doc),
page,
None,
oc.as_ref(),
));
Ok(spans)
}
pub async fn extract_spans_with<S: AsyncObjectSource>(
src: S,
page: &Page,
oc: Option<&OcState>,
) -> Result<Vec<TextSpan>> {
let (spans, _, _) = extract::page_spans_and_rulings_with(src, page, None, oc).await;
Ok(spans)
}
pub fn extract_spans_reporting(
doc: &Document,
page: &Page,
) -> Result<(Vec<TextSpan>, ExtractReport)> {
let oc = doc.oc_state();
let (spans, _, report) = block_on(extract::page_spans_and_rulings_with(
Immediate(doc),
page,
None,
oc.as_ref(),
));
Ok((spans, report))
}
pub async fn extract_spans_reporting_with<S: AsyncObjectSource>(
src: S,
page: &Page,
oc: Option<&OcState>,
) -> Result<(Vec<TextSpan>, ExtractReport)> {
let (spans, _, report) = extract::page_spans_and_rulings_with(src, page, None, oc).await;
Ok((spans, report))
}
pub fn extract_spans_reporting_cached(
doc: &Document,
page: &Page,
fonts: &FontCache,
) -> Result<(Vec<TextSpan>, ExtractReport)> {
let oc = doc.oc_state();
let (spans, _, report) = block_on(extract::page_spans_and_rulings_with(
Immediate(doc),
page,
Some(fonts),
oc.as_ref(),
));
Ok((spans, report))
}
pub async fn extract_spans_reporting_cached_with<S: AsyncObjectSource>(
src: S,
page: &Page,
fonts: &FontCache,
oc: Option<&OcState>,
) -> Result<(Vec<TextSpan>, ExtractReport)> {
let (spans, _, report) = extract::page_spans_and_rulings_with(src, page, Some(fonts), oc).await;
Ok((spans, report))
}
pub fn extract_spans_and_rulings_reporting(
doc: &Document,
page: &Page,
) -> Result<(Vec<TextSpan>, Vec<Ruling>, ExtractReport)> {
let oc = doc.oc_state();
let (spans, rulings, report) = block_on(extract::page_spans_and_rulings_with(
Immediate(doc),
page,
None,
oc.as_ref(),
));
Ok((spans, rulings, report))
}
pub async fn extract_spans_and_rulings_reporting_with<S: AsyncObjectSource>(
src: S,
page: &Page,
oc: Option<&OcState>,
) -> Result<(Vec<TextSpan>, Vec<Ruling>, ExtractReport)> {
let (spans, rulings, report) = extract::page_spans_and_rulings_with(src, page, None, oc).await;
Ok((spans, rulings, report))
}
pub fn extract_spans_and_rulings_reporting_cached(
doc: &Document,
page: &Page,
fonts: &FontCache,
) -> Result<(Vec<TextSpan>, Vec<Ruling>, ExtractReport)> {
let oc = doc.oc_state();
let (spans, rulings, report) = block_on(extract::page_spans_and_rulings_with(
Immediate(doc),
page,
Some(fonts),
oc.as_ref(),
));
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>,
) -> Result<(Vec<TextSpan>, Vec<Ruling>, ExtractReport)> {
let (spans, rulings, report) =
extract::page_spans_and_rulings_with(src, page, Some(fonts), oc).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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())).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)).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).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).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).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).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)).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_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,
))
.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).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).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,
)
.await
};
assert_send_static(&spans);
assert!(block_on(spans).unwrap().is_empty());
}
}