use crate::font::Font;
use pdfboss_core::content::{parse_content, Op, TextItem};
use pdfboss_core::{
content_stream_data_with, page_content_with, AsyncObjectSource, Dict, Matrix, Object, Page,
Point,
};
use std::collections::HashMap;
use std::sync::Arc;
const MAX_FORM_DEPTH: usize = 16;
const MAX_FORM_INVOCATIONS: usize = 4096;
pub struct RawSpan {
pub text: String,
pub x: f32,
pub y: f32,
pub end_x: f32,
pub size: f32,
pub font: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExtractReport {
pub skipped: Vec<SkippedText>,
}
impl ExtractReport {
pub fn is_complete(&self) -> bool {
self.skipped.is_empty()
}
fn record(&mut self, kind: SkippedTextKind, cause: SkipCause) {
self.skipped.push(SkippedText { kind, cause });
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkippedText {
pub kind: SkippedTextKind,
pub cause: SkipCause,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkippedTextKind {
PageContents,
Form,
XObject,
}
impl std::fmt::Display for SkippedTextKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
SkippedTextKind::PageContents => "the page contents",
SkippedTextKind::Form => "a form XObject",
SkippedTextKind::XObject => "an XObject",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkipCause {
UnsupportedFilter(String),
Unreadable,
Parse,
Missing,
LimitExceeded,
}
impl std::fmt::Display for SkipCause {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SkipCause::UnsupportedFilter(name) => write!(f, "unsupported filter /{name}"),
SkipCause::Unreadable => f.write_str("stream would not read"),
SkipCause::Parse => f.write_str("content would not parse"),
SkipCause::Missing => f.write_str("missing resource"),
SkipCause::LimitExceeded => f.write_str("form limit exceeded"),
}
}
}
fn cause_for(error: &pdfboss_core::Error) -> SkipCause {
match error {
pdfboss_core::Error::UnsupportedFilter(name) => SkipCause::UnsupportedFilter(name.clone()),
_ => SkipCause::Unreadable,
}
}
pub async fn page_spans_with<S: AsyncObjectSource>(
src: S,
page: &Page,
) -> (Vec<RawSpan>, ExtractReport) {
let mut report = ExtractReport::default();
let content = match page_content_with(&src, page).await {
Ok(content) => content,
Err(e) => {
report.record(SkippedTextKind::PageContents, cause_for(&e));
Vec::new()
}
};
let ops = match parse_content(&content) {
Ok(ops) => ops,
Err(_) => {
report.record(SkippedTextKind::PageContents, SkipCause::Parse);
Vec::new()
}
};
let mut exec = Executor {
src: &src,
spans: Vec::new(),
fallback: Arc::new(Font::fallback()),
forms: 0,
report,
};
let root = Frame::new(
ops.into(),
vec![Arc::new(page.resources.clone())],
GState::new(),
0,
);
exec.run(root).await;
(exec.spans, exec.report)
}
#[derive(Clone)]
struct GState {
ctm: Matrix,
char_spacing: f32,
word_spacing: f32,
horiz_scale: f32,
leading: f32,
rise: f32,
font: Option<Arc<Font>>,
font_name: String,
size: f32,
}
impl GState {
fn new() -> GState {
GState {
ctm: Matrix::identity(),
char_spacing: 0.0,
word_spacing: 0.0,
horiz_scale: 1.0,
leading: 0.0,
rise: 0.0,
font: None,
font_name: String::new(),
size: 0.0,
}
}
}
fn finite(m: &Matrix) -> bool {
[m.a, m.b, m.c, m.d, m.e, m.f].iter().all(|v| v.is_finite())
}
struct Frame {
ops: Arc<[Op]>,
chain: Vec<Arc<Dict>>,
pc: usize,
depth: usize,
gs: GState,
saved: Vec<GState>,
tm: Matrix,
tlm: Matrix,
fonts: HashMap<String, Arc<Font>>,
}
impl Frame {
fn new(ops: Arc<[Op]>, chain: Vec<Arc<Dict>>, gs: GState, depth: usize) -> Frame {
Frame {
ops,
chain,
pc: 0,
depth,
gs,
saved: Vec::new(),
tm: Matrix::identity(),
tlm: Matrix::identity(),
fonts: HashMap::new(),
}
}
}
struct Executor<'a, S> {
src: &'a S,
spans: Vec<RawSpan>,
fallback: Arc<Font>,
forms: usize,
report: ExtractReport,
}
impl<S: AsyncObjectSource> Executor<'_, S> {
async fn find_res(&self, chain: &[Arc<Dict>], category: &str, name: &str) -> Option<Object> {
for res in chain {
let Some(cat) = res.get(category) else {
continue;
};
let Ok(Object::Dict(dict)) = self.src.resolve(cat).await else {
continue;
};
if let Some(value) = dict.get(name) {
if let Ok(obj) = self.src.resolve(value).await {
return Some(obj);
}
}
}
None
}
async fn font(
&self,
chain: &[Arc<Dict>],
name: &str,
cache: &mut HashMap<String, Arc<Font>>,
) -> Arc<Font> {
if let Some(f) = cache.get(name) {
return f.clone();
}
let resolved = self.find_res(chain, "Font", name).await;
let loaded = match resolved.as_ref().and_then(|o| o.as_dict()) {
Some(dict) => Arc::new(Font::load(self.src, dict).await),
None => self.fallback.clone(),
};
cache.insert(name.to_string(), loaded.clone());
loaded
}
async fn run(&mut self, root: Frame) {
let mut frames = vec![root];
'frames: while let Some(mut frame) = frames.pop() {
let ops = Arc::clone(&frame.ops);
while frame.pc < ops.len() {
let op = &ops[frame.pc];
frame.pc += 1;
match op {
Op::SetFont(name, size) => {
let loaded = self.font(&frame.chain, &name.0, &mut frame.fonts).await;
frame.gs.font = Some(loaded);
frame.gs.font_name = name.0.clone();
frame.gs.size = *size;
}
Op::XObject(name) => {
let entered = self
.form_frame(&name.0, &frame.chain, &frame.gs, frame.depth)
.await;
if let Some(child) = entered {
frames.push(frame);
frames.push(child);
continue 'frames;
}
}
op => self.step(&mut frame, op),
}
}
}
}
fn step(&mut self, frame: &mut Frame, op: &Op) {
match op {
Op::Save => frame.saved.push(frame.gs.clone()),
Op::Restore => {
if let Some(saved) = frame.saved.pop() {
frame.gs = saved;
}
}
Op::Concat(m) if finite(m) => frame.gs.ctm = m.concat(frame.gs.ctm),
Op::BeginText => {
frame.tm = Matrix::identity();
frame.tlm = Matrix::identity();
}
Op::SetCharSpacing(v) => frame.gs.char_spacing = *v,
Op::SetWordSpacing(v) => frame.gs.word_spacing = *v,
Op::SetHorizScaling(v) => frame.gs.horiz_scale = v / 100.0,
Op::SetLeading(v) => frame.gs.leading = *v,
Op::SetTextRise(v) => frame.gs.rise = *v,
Op::TextMove(tx, ty) => {
frame.tlm = Matrix::translate(*tx, *ty).concat(frame.tlm);
frame.tm = frame.tlm;
}
Op::TextMoveSetLeading(tx, ty) => {
frame.gs.leading = -ty;
frame.tlm = Matrix::translate(*tx, *ty).concat(frame.tlm);
frame.tm = frame.tlm;
}
Op::SetTextMatrix(m) if finite(m) => {
frame.tm = *m;
frame.tlm = *m;
}
Op::TextNextLine => {
frame.tlm = Matrix::translate(0.0, -frame.gs.leading).concat(frame.tlm);
frame.tm = frame.tlm;
}
Op::ShowText(s) => self.emit(frame, s),
Op::ShowTextAdjusted(items) => {
for item in items {
match item {
TextItem::Str(s) => self.emit(frame, s),
TextItem::Offset(n) => {
let tx = -n / 1000.0 * frame.gs.size * frame.gs.horiz_scale;
if tx.is_finite() {
frame.tm = Matrix::translate(tx, 0.0).concat(frame.tm);
}
}
}
}
}
Op::NextLineShowText(s) => {
frame.tlm = Matrix::translate(0.0, -frame.gs.leading).concat(frame.tlm);
frame.tm = frame.tlm;
self.emit(frame, s);
}
Op::NextLineShowTextSpaced(aw, ac, s) => {
frame.gs.word_spacing = *aw;
frame.gs.char_spacing = *ac;
frame.tlm = Matrix::translate(0.0, -frame.gs.leading).concat(frame.tlm);
frame.tm = frame.tlm;
self.emit(frame, s);
}
_ => {}
}
}
fn emit(&mut self, frame: &mut Frame, bytes: &[u8]) {
if let Some(span) = self.show(&frame.gs, &mut frame.tm, bytes) {
self.spans.push(span);
}
}
fn show(&self, gs: &GState, tm: &mut Matrix, bytes: &[u8]) -> Option<RawSpan> {
let font: &Font = gs.font.as_deref().unwrap_or(&self.fallback);
let start = tm.concat(gs.ctm);
let origin = start.apply(Point { x: 0.0, y: gs.rise });
let size = gs.size * (start.c * start.c + start.d * start.d).sqrt();
let mut text = String::new();
for code in font.codes(bytes) {
font.decode_into(code, &mut text);
let word = if font.is_space(code) {
gs.word_spacing
} else {
0.0
};
let adv =
(font.width(code) / 1000.0 * gs.size + gs.char_spacing + word) * gs.horiz_scale;
if adv.is_finite() {
*tm = Matrix::translate(adv, 0.0).concat(*tm);
}
}
let end = tm.concat(gs.ctm).apply(Point { x: 0.0, y: gs.rise });
(!text.is_empty() && origin.x.is_finite() && origin.y.is_finite()).then(|| RawSpan {
text,
x: origin.x,
y: origin.y,
end_x: end.x,
size: if size.is_finite() { size } else { 0.0 },
font: gs.font_name.clone(),
})
}
async fn form_frame(
&mut self,
name: &str,
chain: &[Arc<Dict>],
gs: &GState,
depth: usize,
) -> Option<Frame> {
if depth >= MAX_FORM_DEPTH || self.forms >= MAX_FORM_INVOCATIONS {
self.report
.record(SkippedTextKind::Form, SkipCause::LimitExceeded);
return None;
}
self.forms += 1;
let stream = match self.find_res(chain, "XObject", name).await {
Some(Object::Stream(s)) => s,
_ => {
self.report
.record(SkippedTextKind::XObject, SkipCause::Missing);
return None;
}
};
let is_form = match stream.dict.get("Subtype") {
Some(Object::Name(n)) => n.0 == "Form",
Some(indirect @ Object::Ref(_)) => self
.src
.resolve(indirect)
.await
.ok()
.and_then(|o| o.as_name().map(|n| n.0 == "Form"))
.unwrap_or(false),
_ => false,
};
if !is_form {
return None; }
let data = match content_stream_data_with(self.src, &stream).await {
Ok(data) => data,
Err(e) => {
self.report.record(SkippedTextKind::Form, cause_for(&e));
return None;
}
};
let ops = match parse_content(&data) {
Ok(ops) => ops,
Err(_) => {
self.report.record(SkippedTextKind::Form, SkipCause::Parse);
return None;
}
};
let mut inner_chain: Vec<Arc<Dict>> = Vec::with_capacity(chain.len() + 1);
if let Some(own) = self.own_resources(&stream.dict).await {
inner_chain.push(Arc::new(own));
}
inner_chain.extend_from_slice(chain);
let mut inner = gs.clone();
if let Some(m) = self.form_matrix(&stream.dict).await {
inner.ctm = m.concat(inner.ctm);
}
Some(Frame::new(ops.into(), inner_chain, inner, depth + 1))
}
async fn own_resources(&self, dict: &Dict) -> Option<Dict> {
let obj = dict.get("Resources")?;
self.src.resolve(obj).await.ok()?.as_dict().cloned()
}
async fn form_matrix(&self, dict: &Dict) -> Option<Matrix> {
let obj = self.src.resolve(dict.get("Matrix")?).await.ok()?;
let arr = obj.as_array()?;
let mut v = [0.0f32; 6];
for (slot, item) in v.iter_mut().zip(arr.iter()) {
*slot = self.src.resolve(item).await.ok()?.as_f64()? as f32;
}
if arr.len() < 6 {
return None;
}
let m = Matrix {
a: v[0],
b: v[1],
c: v[2],
d: v[3],
e: v[4],
f: v[5],
};
finite(&m).then_some(m)
}
}
pub fn layout(spans: &[RawSpan]) -> String {
struct Line<'s> {
y: f32,
size: f32,
spans: Vec<&'s RawSpan>,
}
let mut lines: Vec<Line> = Vec::new();
for span in spans {
let found = lines
.iter_mut()
.find(|line| (line.y - span.y).abs() <= 0.5 * line.size.max(span.size));
match found {
Some(line) => {
line.size = line.size.max(span.size);
line.spans.push(span);
}
None => lines.push(Line {
y: span.y,
size: span.size,
spans: vec![span],
}),
}
}
lines.sort_by(|a, b| b.y.total_cmp(&a.y)); let mut out = String::new();
for (i, line) in lines.iter_mut().enumerate() {
if i > 0 {
out.push('\n');
}
line.spans.sort_by(|a, b| a.x.total_cmp(&b.x));
let mut prev_end: Option<f32> = None;
let mut prev_size = 0.0f32;
for span in &line.spans {
if let Some(end) = prev_end {
let gap = span.x - end;
if gap > 0.25 * prev_size.max(span.size) {
out.push(' ');
}
}
out.push_str(&span.text);
prev_end = Some(span.end_x);
prev_size = span.size;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use pdfboss_core::{block_on, Document, Immediate};
use pdfboss_testkit::doc_with_graphics;
fn page_spans(doc: &Document, page: &Page) -> Vec<RawSpan> {
let (spans, report) = block_on(page_spans_with(Immediate(doc), page));
assert!(report.is_complete(), "unexpected skips: {report:?}");
spans
}
fn text_of(content: &str) -> String {
let doc = Document::load(doc_with_graphics(content)).unwrap();
let page = doc.page(0).unwrap();
layout(&page_spans(&doc, &page))
}
fn spans_of(content: &str) -> Vec<RawSpan> {
let doc = Document::load(doc_with_graphics(content)).unwrap();
let page = doc.page(0).unwrap();
page_spans(&doc, &page)
}
#[test]
fn two_td_lines_become_newline() {
let text = text_of("BT /F1 12 Tf 72 720 Td (Line one) Tj 0 -20 Td (Line two) Tj ET");
assert_eq!(text, "Line one\nLine two");
}
#[test]
fn tj_offset_space_thresholds() {
assert_eq!(
text_of("BT /F1 12 Tf 72 720 Td [(A) -300 (B)] TJ ET"),
"A B"
);
assert_eq!(text_of("BT /F1 12 Tf 72 720 Td [(A) -50 (B)] TJ ET"), "AB");
}
#[test]
fn word_spacing_applies_to_code_32_only() {
let spans = spans_of("BT /F1 12 Tf 5 Tw 72 720 Td (a b) Tj ET");
assert_eq!(spans.len(), 1);
assert!((spans[0].end_x - 95.0).abs() < 1e-3, "{}", spans[0].end_x);
}
#[test]
fn cm_and_q_q_track_ctm() {
let spans = spans_of(
"q 1 0 0 1 100 0 cm BT /F1 12 Tf 0 720 Td (X) Tj ET Q \
BT /F1 12 Tf 0 700 Td (Y) Tj ET",
);
assert_eq!(spans.len(), 2);
assert!((spans[0].x - 100.0).abs() < 1e-3);
assert!((spans[1].x - 0.0).abs() < 1e-3);
}
#[test]
fn horizontal_scaling_stretches_advances() {
let spans = spans_of("BT /F1 12 Tf 200 Tz 72 720 Td (AB) Tj ET");
assert!((spans[0].end_x - 96.0).abs() < 1e-3, "{}", spans[0].end_x);
}
#[test]
fn text_rise_shifts_baseline() {
let spans = spans_of("BT /F1 12 Tf 72 720 Td 5 Ts (R) Tj ET");
assert!((spans[0].y - 725.0).abs() < 1e-3);
}
#[test]
fn invisible_render_mode_still_extracted() {
assert_eq!(
text_of("BT /F1 12 Tf 3 Tr 72 720 Td (ghost) Tj ET"),
"ghost"
);
}
#[test]
fn leading_and_t_star_and_quote() {
let text = text_of("BT /F1 12 Tf 14 TL 72 720 Td (a) Tj T* (b) Tj (c) ' ET");
assert_eq!(text, "a\nb\nc");
let spans = spans_of("BT /F1 12 Tf 14 TL 72 720 Td (a) Tj T* (b) Tj ET");
assert!((spans[1].y - 706.0).abs() < 1e-3);
}
#[test]
fn tm_positions_directly_and_bt_resets() {
let spans = spans_of("BT /F1 12 Tf 1 0 0 1 300 100 Tm (m) Tj ET BT /F1 12 Tf (o) Tj ET");
assert!((spans[0].x - 300.0).abs() < 1e-3);
assert!((spans[0].y - 100.0).abs() < 1e-3);
assert!((spans[1].x - 0.0).abs() < 1e-3);
assert!((spans[1].y - 0.0).abs() < 1e-3);
}
#[test]
fn tm_scale_sets_device_size() {
let spans = spans_of("BT /F1 1 Tf 12 0 0 12 72 720 Tm (s) Tj ET");
assert!((spans[0].size - 12.0).abs() < 1e-3);
}
#[test]
fn layout_orders_spans_left_to_right() {
let text = text_of(
"BT /F1 12 Tf 200 720 Td (world) Tj ET \
BT /F1 12 Tf 72 720 Td (hello) Tj ET",
);
assert_eq!(text, "hello world");
}
#[test]
fn empty_content_yields_no_spans() {
assert!(spans_of("").is_empty());
assert_eq!(text_of("BT ET"), "");
}
#[test]
fn form_xobject_fanout_is_bounded() {
use pdfboss_testkit::PdfBuilder;
let chain = 6u32;
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 << /X 10 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"/X Do");
for i in 0..chain {
let num = 10 + i;
if i + 1 < chain {
let dict = format!(
"/Type /XObject /Subtype /Form \
/Resources << /XObject << /X {} 0 R >> >>",
num + 1
);
b.stream(num, &dict, "/X Do ".repeat(8).as_bytes());
} else {
b.stream(
num,
"/Type /XObject /Subtype /Form",
b"BT /F1 12 Tf 72 720 Td (L) Tj ET",
);
}
}
let doc = Document::load(b.build(1)).unwrap();
let page = doc.page(0).unwrap();
let (spans, report) = block_on(page_spans_with(Immediate(&doc), &page));
assert!(!spans.is_empty()); assert!(
spans.len() <= MAX_FORM_INVOCATIONS,
"fan-out not bounded: {} spans",
spans.len()
);
assert!(
report
.skipped
.iter()
.all(|s| s.cause == SkipCause::LimitExceeded),
"only the budget may cut this page short: {report:?}"
);
assert!(!report.is_complete(), "the cut-off must be visible");
}
#[test]
fn form_spans_are_emitted_where_the_do_appears() {
use pdfboss_testkit::PdfBuilder;
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 << /Fa 6 0 R /Fi 7 0 R >> >> /Contents 4 0 R >>",
);
b.stream(
4,
"",
b"BT /F1 12 Tf 72 720 Td (A) Tj ET /Fa Do BT /F1 12 Tf 72 660 Td (E) Tj ET",
);
b.object(
5,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
/Encoding /WinAnsiEncoding >>",
);
b.stream(
6,
"/Type /XObject /Subtype /Form /BBox [0 0 612 792]",
b"BT /F1 12 Tf 72 700 Td (B) Tj ET /Fi Do BT /F1 12 Tf 72 680 Td (D) Tj ET",
);
b.stream(
7,
"/Type /XObject /Subtype /Form /BBox [0 0 612 792]",
b"BT /F1 12 Tf 72 690 Td (C) Tj ET",
);
let doc = Document::load(b.build(1)).unwrap();
let page = doc.page(0).unwrap();
let spans = page_spans(&doc, &page);
let order: Vec<&str> = spans.iter().map(|s| s.text.as_str()).collect();
assert_eq!(order, ["A", "B", "C", "D", "E"]);
}
#[test]
fn loaded_fonts_are_shareable_across_threads() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Font>();
assert_send_sync::<Arc<Font>>();
assert_send_sync::<GState>();
}
}