use crate::font::Font;
use crate::{Ruling, TextSpan};
use pdfboss_core::content::{parse_content, Op, TextItem};
use pdfboss_core::{
content_stream_data_with, page_content_with, AsyncObjectSource, Dict, Matrix, ObjRef, Object,
Page, Point,
};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
const MAX_FORM_DEPTH: usize = 16;
const MAX_FORM_INVOCATIONS: usize = 4096;
const RULING_AXIS_EPSILON: f32 = 0.5;
const RULING_MIN_LENGTH: f32 = 8.0;
const RULING_MAX_FILL_THICKNESS: f32 = 3.0;
#[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"),
}
}
}
#[derive(Default)]
pub struct FontCache {
fonts: Mutex<HashMap<ObjRef, Arc<Font>>>,
}
impl FontCache {
fn get(&self, r: ObjRef) -> Option<Arc<Font>> {
self.fonts.lock().unwrap().get(&r).cloned()
}
fn insert(&self, r: ObjRef, font: Arc<Font>) -> Arc<Font> {
self.fonts.lock().unwrap().entry(r).or_insert(font).clone()
}
}
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_and_rulings_with<S: AsyncObjectSource>(
src: S,
page: &Page,
fonts: Option<&FontCache>,
) -> (Vec<TextSpan>, Vec<Ruling>, 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(),
rulings: Vec::new(),
fallback: Arc::new(Font::fallback()),
forms: 0,
report,
loaded: HashMap::new(),
shared: fonts,
};
let root = Frame::new(
ops.into(),
vec![Arc::new(page.resources.clone())],
GState::new(),
0,
);
exec.run(root).await;
(exec.spans, exec.rulings, 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,
line_width: 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,
line_width: 1.0,
}
}
}
fn finite(m: &Matrix) -> bool {
[m.a, m.b, m.c, m.d, m.e, m.f].iter().all(|v| v.is_finite())
}
fn ctm_scale(m: &Matrix) -> f32 {
let det = (m.a * m.d - m.b * m.c).abs();
if det.is_finite() && det > 0.0 {
return det.sqrt();
}
1.0
}
fn ruling_from_segment(a: Point, b: Point, width: f32) -> Option<Ruling> {
if [a.x, a.y, b.x, b.y, width].iter().any(|v| !v.is_finite()) {
return None;
}
let dx = (b.x - a.x).abs();
let dy = (b.y - a.y).abs();
if dy <= RULING_AXIS_EPSILON && dx >= RULING_MIN_LENGTH {
let y = (a.y + b.y) / 2.0;
return Some(Ruling {
start: Point::new(a.x.min(b.x), y),
end: Point::new(a.x.max(b.x), y),
width,
});
}
if dx <= RULING_AXIS_EPSILON && dy >= RULING_MIN_LENGTH {
let x = (a.x + b.x) / 2.0;
return Some(Ruling {
start: Point::new(x, a.y.min(b.y)),
end: Point::new(x, a.y.max(b.y)),
width,
});
}
None
}
fn filled_rect_ruling(device: &[Point]) -> Option<Ruling> {
let corners = match device {
[a, b, c, d] => [*a, *b, *c, *d],
[a, b, c, d, e]
if (e.x - a.x).abs() <= RULING_AXIS_EPSILON
&& (e.y - a.y).abs() <= RULING_AXIS_EPSILON =>
{
[*a, *b, *c, *d]
}
_ => return None,
};
if corners.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
return None;
}
let axis_aligned = |a: Point, b: Point| {
(b.x - a.x).abs() <= RULING_AXIS_EPSILON || (b.y - a.y).abs() <= RULING_AXIS_EPSILON
};
for i in 0..4 {
if !axis_aligned(corners[i], corners[(i + 1) % 4]) {
return None;
}
}
let x0 = corners.iter().map(|p| p.x).fold(f32::INFINITY, f32::min);
let x1 = corners
.iter()
.map(|p| p.x)
.fold(f32::NEG_INFINITY, f32::max);
let y0 = corners.iter().map(|p| p.y).fold(f32::INFINITY, f32::min);
let y1 = corners
.iter()
.map(|p| p.y)
.fold(f32::NEG_INFINITY, f32::max);
let w = x1 - x0;
let h = y1 - y0;
if w.min(h) > RULING_MAX_FILL_THICKNESS || w.max(h) < RULING_MIN_LENGTH {
return None;
}
if h <= w {
let y = (y0 + y1) / 2.0;
return Some(Ruling {
start: Point::new(x0, y),
end: Point::new(x1, y),
width: 0.0,
});
}
let x = (x0 + x1) / 2.0;
Some(Ruling {
start: Point::new(x, y0),
end: Point::new(x, y1),
width: 0.0,
})
}
struct Subpath {
points: Vec<Point>,
closed: bool,
poisoned: bool,
}
struct Frame {
ops: Arc<[Op]>,
chain: Vec<Arc<Dict>>,
pc: usize,
depth: usize,
gs: GState,
saved: Vec<GState>,
tm: Matrix,
tlm: Matrix,
subpaths: Vec<Subpath>,
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(),
subpaths: Vec::new(),
fonts: HashMap::new(),
}
}
fn move_to(&mut self, x: f32, y: f32) {
self.subpaths.push(Subpath {
points: vec![Point::new(x, y)],
closed: false,
poisoned: false,
});
}
fn segment_to(&mut self, x: f32, y: f32, poisons: bool) {
let Some(active) = self.subpaths.last_mut() else {
return;
};
if active.closed {
let start = active.points[0];
self.subpaths.push(Subpath {
points: vec![start, Point::new(x, y)],
closed: false,
poisoned: poisons,
});
return;
}
active.points.push(Point::new(x, y));
if poisons {
active.poisoned = true;
}
}
fn close_subpath(&mut self) {
if let Some(active) = self.subpaths.last_mut() {
active.closed = true;
}
}
fn rect_subpath(&mut self, x: f32, y: f32, w: f32, h: f32) {
self.subpaths.push(Subpath {
points: vec![
Point::new(x, y),
Point::new(x + w, y),
Point::new(x + w, y + h),
Point::new(x, y + h),
],
closed: true,
poisoned: false,
});
}
}
struct Executor<'a, S> {
src: &'a S,
spans: Vec<TextSpan>,
rulings: Vec<Ruling>,
fallback: Arc<Font>,
forms: usize,
report: ExtractReport,
loaded: HashMap<ObjRef, Arc<Font>>,
shared: Option<&'a FontCache>,
}
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(
&mut 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 loaded = self.load_font(chain, name).await;
cache.insert(name.to_string(), loaded.clone());
loaded
}
async fn load_font(&mut self, chain: &[Arc<Dict>], name: &str) -> Arc<Font> {
for res in chain {
let Some(cat) = res.get("Font") else {
continue;
};
let Ok(Object::Dict(dict)) = self.src.resolve(cat).await else {
continue;
};
let Some(value) = dict.get(name) else {
continue;
};
let key = match value {
Object::Ref(r) => Some(*r),
_ => None,
};
if let Some(f) = key.and_then(|r| self.hit(r)) {
return f;
}
let Ok(obj) = self.src.resolve(value).await else {
continue;
};
let Some(font_dict) = obj.as_dict() else {
return self.fallback.clone();
};
let loaded = Arc::new(Font::load(self.src, font_dict).await);
return match key {
Some(r) => self.remember(r, loaded),
None => loaded,
};
}
self.fallback.clone()
}
fn hit(&mut self, r: ObjRef) -> Option<Arc<Font>> {
if let Some(f) = self.loaded.get(&r) {
return Some(f.clone());
}
let f = self.shared?.get(r)?;
self.loaded.insert(r, f.clone());
Some(f)
}
fn remember(&mut self, r: ObjRef, font: Arc<Font>) -> Arc<Font> {
let font = match self.shared {
Some(shared) => shared.insert(r, font),
None => font,
};
self.loaded.insert(r, font.clone());
font
}
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::SetExtGState(name) => {
if let Some(lw) = self.ext_gstate_line_width(&frame.chain, &name.0).await {
frame.gs.line_width = lw;
}
}
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),
}
}
}
}
async fn ext_gstate_line_width(&self, chain: &[Arc<Dict>], name: &str) -> Option<f32> {
let resolved = self.find_res(chain, "ExtGState", name).await?;
let dict = resolved.as_dict()?;
let lw = self.src.resolve(dict.get("LW")?).await.ok()?.as_f64()? as f32;
(lw.is_finite() && lw >= 0.0).then_some(lw)
}
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);
}
Op::SetLineWidth(w) => {
if w.is_finite() && *w >= 0.0 {
frame.gs.line_width = *w;
}
}
Op::MoveTo(x, y) => frame.move_to(*x, *y),
Op::LineTo(x, y) => frame.segment_to(*x, *y, false),
Op::CurveTo(_, _, _, _, x, y) | Op::CurveToV(_, _, x, y) | Op::CurveToY(_, _, x, y) => {
frame.segment_to(*x, *y, true)
}
Op::ClosePath => frame.close_subpath(),
Op::Rect(x, y, w, h) => frame.rect_subpath(*x, *y, *w, *h),
Op::Stroke => self.commit_rulings(frame, true, false),
Op::CloseStroke => {
frame.close_subpath();
self.commit_rulings(frame, true, false);
}
Op::Fill | Op::FillEvenOdd => self.commit_rulings(frame, false, true),
Op::FillStroke | Op::FillStrokeEvenOdd => self.commit_rulings(frame, true, true),
Op::CloseFillStroke | Op::CloseFillStrokeEvenOdd => {
frame.close_subpath();
self.commit_rulings(frame, true, true);
}
Op::EndPath => frame.subpaths.clear(),
_ => {}
}
}
fn commit_rulings(&mut self, frame: &mut Frame, stroke: bool, fill: bool) {
let ctm = frame.gs.ctm;
let width = frame.gs.line_width * ctm_scale(&ctm);
for sub in frame.subpaths.drain(..) {
if sub.poisoned {
continue;
}
let device: Vec<Point> = sub.points.iter().map(|p| ctm.apply(*p)).collect();
if stroke {
let segments = device.windows(2).map(|pair| (pair[0], pair[1]));
let closing =
(sub.closed && device.len() > 2).then(|| (device[device.len() - 1], device[0]));
for (a, b) in segments.chain(closing) {
if let Some(ruling) = ruling_from_segment(a, b, width) {
self.rulings.push(ruling);
}
}
}
if fill {
if let Some(ruling) = filled_rect_ruling(&device) {
self.rulings.push(ruling);
}
}
}
}
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<TextSpan> {
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(|| TextSpan {
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(),
bold: font.bold,
italic: font.italic,
})
}
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)
}
}
#[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<TextSpan> {
let (spans, _, report) = block_on(page_spans_and_rulings_with(Immediate(doc), page, None));
assert!(report.is_complete(), "unexpected skips: {report:?}");
spans
}
fn page_rulings(doc: &Document, page: &Page) -> Vec<Ruling> {
let (_, rulings, report) =
block_on(page_spans_and_rulings_with(Immediate(doc), page, None));
assert!(report.is_complete(), "unexpected skips: {report:?}");
rulings
}
fn spans_of(content: &str) -> Vec<TextSpan> {
let doc = Document::load(doc_with_graphics(content)).unwrap();
let page = doc.page(0).unwrap();
page_spans(&doc, &page)
}
fn rulings_of(content: &str) -> Vec<Ruling> {
let doc = Document::load(doc_with_graphics(content)).unwrap();
let page = doc.page(0).unwrap();
page_rulings(&doc, &page)
}
#[track_caller]
fn assert_ruling(r: &Ruling, x0: f32, y0: f32, x1: f32, y1: f32) {
let close = (r.start.x - x0).abs() < 1e-3
&& (r.start.y - y0).abs() < 1e-3
&& (r.end.x - x1).abs() < 1e-3
&& (r.end.y - y1).abs() < 1e-3;
assert!(close, "{r:?} is not ({x0},{y0})-({x1},{y1})");
}
#[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 t_star_advances_tlm_by_leading() {
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 empty_content_yields_no_spans() {
assert!(spans_of("").is_empty());
}
#[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_and_rulings_with(Immediate(&doc), &page, None));
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>();
assert_send_sync::<FontCache>();
}
#[test]
fn same_name_binds_a_different_font_per_resource_scope() {
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 << /Fx 6 0 R >> >> \
/Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (aa) Tj ET /Fx Do");
b.object(
5,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
/Encoding /WinAnsiEncoding /FirstChar 97 /LastChar 97 /Widths [500] >>",
);
b.stream(
6,
"/Type /XObject /Subtype /Form /BBox [0 0 612 792] \
/Resources << /Font << /F1 7 0 R >> >>",
b"BT /F1 12 Tf 72 700 Td (aa) Tj ET",
);
b.object(
7,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
/Encoding /WinAnsiEncoding /FirstChar 97 /LastChar 97 /Widths [1000] >>",
);
let doc = Document::load(b.build(1)).unwrap();
let page = doc.page(0).unwrap();
let spans = page_spans(&doc, &page);
assert_eq!(spans.len(), 2);
let advance = |s: &TextSpan| s.end_x - s.x;
assert!(
(advance(&spans[0]) - 12.0).abs() < 1e-3,
"page scope must use the 500-width font: {}",
advance(&spans[0])
);
assert!(
(advance(&spans[1]) - 24.0).abs() < 1e-3,
"form scope must use the 1000-width font: {}",
advance(&spans[1])
);
}
#[test]
fn stroked_grid_yields_rulings_with_correct_endpoints() {
let rulings = rulings_of("72 600 200 100 re S 172 600 m 172 700 l S 72 650 m 272 650 l S");
assert_eq!(rulings.len(), 6, "{rulings:?}");
assert_ruling(&rulings[0], 72.0, 600.0, 272.0, 600.0);
assert_ruling(&rulings[1], 272.0, 600.0, 272.0, 700.0);
assert_ruling(&rulings[2], 72.0, 700.0, 272.0, 700.0);
assert_ruling(&rulings[3], 72.0, 600.0, 72.0, 700.0);
assert_ruling(&rulings[4], 172.0, 600.0, 172.0, 700.0);
assert_ruling(&rulings[5], 72.0, 650.0, 272.0, 650.0);
assert!(rulings.iter().all(|r| (r.width - 1.0).abs() < 1e-3));
}
#[test]
fn w_sets_the_stroke_width() {
let rulings = rulings_of("0.5 w 72 700 m 272 700 l S");
assert_eq!(rulings.len(), 1);
assert!((rulings[0].width - 0.5).abs() < 1e-3);
}
#[test]
fn negative_or_nonfinite_w_is_ignored() {
let rulings = rulings_of("-5 w 72 700 m 272 700 l S");
assert_eq!(rulings.len(), 1, "{rulings:?}");
assert!((rulings[0].width - 1.0).abs() < 1e-3);
let rulings = rulings_of("400000000000000000000000000000000000000 w 72 700 m 272 700 l S");
assert_eq!(rulings.len(), 1, "{rulings:?}");
assert!((rulings[0].width - 1.0).abs() < 1e-3);
}
#[test]
fn ext_gstate_lw_sets_the_stroke_width() {
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 << /ExtGState << /G1 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"/G1 gs 72 700 m 272 700 l S");
b.object(5, "<< /Type /ExtGState /LW 2.5 >>");
let doc = Document::load(b.build(1)).unwrap();
let page = doc.page(0).unwrap();
let rulings = page_rulings(&doc, &page);
assert_eq!(rulings.len(), 1);
assert!((rulings[0].width - 2.5).abs() < 1e-3);
}
#[test]
fn thin_filled_rect_yields_its_centerline() {
let rulings = rulings_of("72 700 200 0.8 re f");
assert_eq!(rulings.len(), 1, "{rulings:?}");
assert_ruling(&rulings[0], 72.0, 700.4, 272.0, 700.4);
assert_eq!(rulings[0].width, 0.0, "a fill has no stroke width");
}
#[test]
fn fat_filled_rect_yields_no_rulings() {
assert!(rulings_of("72 600 200 40 re f").is_empty());
}
#[test]
fn cm_rotation_keeps_axis_aligned_segments_only() {
let rotated90 = rulings_of("q 0 1 -1 0 300 100 cm 0 0 m 100 0 l S Q");
assert_eq!(rotated90.len(), 1, "{rotated90:?}");
assert_ruling(&rotated90[0], 300.0, 100.0, 300.0, 200.0);
let rotated30 = rulings_of("q 0.866 0.5 -0.5 0.866 0 0 cm 72 700 m 172 700 l S Q");
assert!(rotated30.is_empty(), "{rotated30:?}");
}
#[test]
fn curves_poison_only_their_own_subpath() {
let rulings =
rulings_of("72 500 m 100 550 150 550 172 500 c 200 500 l 72 700 m 272 700 l S");
assert_eq!(rulings.len(), 1, "{rulings:?}");
assert_ruling(&rulings[0], 72.0, 700.0, 272.0, 700.0);
}
#[test]
fn end_path_discards_the_accumulated_path() {
assert!(rulings_of("72 700 m 272 700 l n").is_empty());
assert!(rulings_of("72 600 200 100 re W n").is_empty());
}
#[test]
fn form_matrix_lands_rulings_in_page_space() {
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 << /XObject << /Fx 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"/Fx Do");
b.stream(
5,
"/Type /XObject /Subtype /Form /BBox [0 0 612 792] \
/Matrix [1 0 0 1 0 -20]",
b"72 720 m 272 720 l S",
);
let doc = Document::load(b.build(1)).unwrap();
let page = doc.page(0).unwrap();
let rulings = page_rulings(&doc, &page);
assert_eq!(rulings.len(), 1, "{rulings:?}");
assert_ruling(&rulings[0], 72.0, 700.0, 272.0, 700.0);
}
}