use std::collections::HashMap;
use std::str;
use crate::error::PdfError;
use crate::objects::{Dict, Object, ObjectId};
use crate::reader::document::{decode_stream, DocumentReader};
use crate::reader::encoding::{
apply_encoding_differences, parse_encoding_differences, BaseEncoding, EncodingMap,
};
#[derive(Clone, Debug, PartialEq)]
pub struct TextRun {
pub text: String,
pub position: (f32, f32),
pub font_name: String,
pub font_size: f32,
pub render_mode: TextRenderMode,
pub text_rise: f32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum TextRenderMode {
#[default]
Fill,
Stroke,
FillStroke,
Invisible,
FillClip,
StrokeClip,
FillStrokeClip,
Clip,
}
impl TextRenderMode {
pub fn from_operand(n: i64) -> Self {
match n {
0 => Self::Fill,
1 => Self::Stroke,
2 => Self::FillStroke,
3 => Self::Invisible,
4 => Self::FillClip,
5 => Self::StrokeClip,
6 => Self::FillStrokeClip,
7 => Self::Clip,
_ => Self::Fill,
}
}
pub fn paints_glyphs(self) -> bool {
!matches!(self, Self::Invisible | Self::Clip)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct MarkedTextRun {
pub run: TextRun,
pub mcid: Option<u32>,
pub page_obj_num: u32,
pub page_index: u32,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PdfTextExtraction {
pub runs: Vec<TextRun>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PdfMarkedTextExtraction {
pub runs: Vec<MarkedTextRun>,
}
impl PdfTextExtraction {
pub fn flat_text(&self) -> String {
self.runs
.iter()
.map(|r| r.text.as_str())
.collect::<Vec<_>>()
.join(" ")
}
}
impl<'a> DocumentReader<'a> {
pub fn text_extraction(&mut self) -> Result<PdfTextExtraction, PdfError> {
extract_text(self)
}
pub fn marked_text_extraction(&mut self) -> Result<PdfMarkedTextExtraction, PdfError> {
extract_text_marked(self)
}
}
pub fn extract_text(reader: &mut DocumentReader<'_>) -> Result<PdfTextExtraction, PdfError> {
let leaves = collect_page_leaves(reader)?;
let mut out = PdfTextExtraction::default();
for leaf in leaves {
extract_page(reader, leaf, &mut out)?;
}
Ok(out)
}
pub fn extract_text_marked(
reader: &mut DocumentReader<'_>,
) -> Result<PdfMarkedTextExtraction, PdfError> {
let leaves = collect_page_leaves(reader)?;
let mut out = PdfMarkedTextExtraction::default();
for (page_index, leaf) in leaves.into_iter().enumerate() {
extract_page_marked(reader, leaf, page_index as u32, &mut out)?;
}
Ok(out)
}
pub fn concatenate_page_contents(
reader: &mut DocumentReader<'_>,
page_id: ObjectId,
) -> Result<Option<Vec<u8>>, PdfError> {
let page_obj = reader.resolve(page_id)?;
let Object::Dict(page_dict) = page_obj else {
return Ok(None);
};
let contents_obj = page_dict
.entries()
.iter()
.find(|(k, _)| k == "Contents")
.map(|(_, v)| v.clone());
let bytes = match contents_obj {
Some(Object::Reference(id)) => extract_stream_data(reader, id)?,
Some(Object::Array(items)) => {
let mut all = Vec::new();
for item in items {
if let Object::Reference(id) = item {
all.extend_from_slice(&extract_stream_data(reader, id)?);
all.push(b'\n');
}
}
all
}
_ => return Ok(None),
};
Ok(Some(bytes))
}
pub(crate) fn collect_page_leaves(
reader: &mut DocumentReader<'_>,
) -> Result<Vec<ObjectId>, PdfError> {
let root_id = reader.xref().root()?;
let catalog_obj = reader.resolve(root_id)?;
let Object::Dict(catalog) = catalog_obj else {
return Err(PdfError::other(format!(
"PDF text extraction: /Root must be a dictionary (got {catalog_obj:?})"
)));
};
let pages_ref = catalog
.entries()
.iter()
.find(|(k, _)| k == "Pages")
.map(|(_, v)| v.clone())
.ok_or_else(|| PdfError::other("PDF text extraction: catalog missing /Pages"))?;
let Object::Reference(pages_root_id) = pages_ref else {
return Err(PdfError::other(format!(
"PDF text extraction: catalog /Pages must be a reference (got {pages_ref:?})"
)));
};
let mut leaves = Vec::new();
walk_pages(reader, pages_root_id, &mut leaves)?;
Ok(leaves)
}
fn walk_pages(
reader: &mut DocumentReader<'_>,
node_id: ObjectId,
out: &mut Vec<ObjectId>,
) -> Result<(), PdfError> {
let node = reader.resolve(node_id)?;
let Object::Dict(d) = node else {
return Err(PdfError::other(format!(
"PDF text extraction: /Pages node {node_id:?} is not a dict"
)));
};
let kind = d
.entries()
.iter()
.find(|(k, _)| k == "Type")
.and_then(|(_, v)| match v {
Object::Name(s) => Some(s.as_str()),
_ => None,
});
match kind {
Some("Page") => {
out.push(node_id);
Ok(())
}
Some("Pages") => {
let kids = d
.entries()
.iter()
.find(|(k, _)| k == "Kids")
.map(|(_, v)| v.clone())
.ok_or_else(|| {
PdfError::other(format!(
"PDF text extraction: /Pages node {node_id:?} missing /Kids"
))
})?;
let Object::Array(items) = kids else {
return Err(PdfError::other(format!(
"PDF text extraction: /Kids must be an array on {node_id:?}"
)));
};
for item in items {
if let Object::Reference(id) = item {
walk_pages(reader, id, out)?;
}
}
Ok(())
}
_ => {
Ok(())
}
}
}
type PageFonts = HashMap<String, FontDecoder>;
fn load_page_for_text(
reader: &mut DocumentReader<'_>,
page_id: ObjectId,
) -> Result<Option<(PageFonts, Vec<u8>)>, PdfError> {
let page_obj = reader.resolve(page_id)?;
let Object::Dict(page_dict) = page_obj else {
return Ok(None);
};
let resources = page_dict
.entries()
.iter()
.find(|(k, _)| k == "Resources")
.map(|(_, v)| v.clone());
let resources = match resources {
Some(Object::Reference(id)) => reader.resolve(id)?,
Some(other) => other,
None => return Ok(None),
};
let mut fonts: HashMap<String, FontDecoder> = HashMap::new();
if let Object::Dict(rdict) = resources {
let font_dict = rdict
.entries()
.iter()
.find(|(k, _)| k == "Font")
.map(|(_, v)| v.clone());
if let Some(font_obj) = font_dict {
let font_obj = match font_obj {
Object::Reference(id) => reader.resolve(id)?,
other => other,
};
if let Object::Dict(fd) = font_obj {
for (name, val) in fd.entries() {
let resolved = match val {
Object::Reference(id) => reader.resolve(*id)?,
other => other.clone(),
};
if let Object::Dict(font_d) = resolved {
let decoder = FontDecoder::from_dict(reader, &font_d)?;
fonts.insert(name.clone(), decoder);
}
}
}
}
}
let contents_obj = page_dict
.entries()
.iter()
.find(|(k, _)| k == "Contents")
.map(|(_, v)| v.clone());
let content_bytes = match contents_obj {
Some(Object::Reference(id)) => extract_stream_data(reader, id)?,
Some(Object::Array(items)) => {
let mut all = Vec::new();
for item in items {
if let Object::Reference(id) = item {
all.extend_from_slice(&extract_stream_data(reader, id)?);
all.push(b'\n');
}
}
all
}
_ => return Ok(None),
};
Ok(Some((fonts, content_bytes)))
}
fn extract_page(
reader: &mut DocumentReader<'_>,
page_id: ObjectId,
out: &mut PdfTextExtraction,
) -> Result<(), PdfError> {
let Some((fonts, content_bytes)) = load_page_for_text(reader, page_id)? else {
return Ok(());
};
let mut walker = TextWalker::new(fonts);
walker.parse(&content_bytes)?;
out.runs.extend(walker.into_runs());
Ok(())
}
fn extract_page_marked(
reader: &mut DocumentReader<'_>,
page_id: ObjectId,
page_index: u32,
out: &mut PdfMarkedTextExtraction,
) -> Result<(), PdfError> {
let Some((fonts, content_bytes)) = load_page_for_text(reader, page_id)? else {
return Ok(());
};
let mut walker = TextWalker::new(fonts);
walker.track_mcid = true;
walker.parse(&content_bytes)?;
let runs = walker.into_runs_with_mcid();
for (run, mcid) in runs {
out.runs.push(MarkedTextRun {
run,
mcid,
page_obj_num: page_id.number,
page_index,
});
}
Ok(())
}
fn extract_stream_data(reader: &mut DocumentReader<'_>, id: ObjectId) -> Result<Vec<u8>, PdfError> {
let obj = reader.resolve(id)?;
let Object::Stream(s) = obj else {
return Err(PdfError::other(format!(
"PDF text extraction: object {id:?} expected to be a Stream"
)));
};
decode_stream(&s)
}
#[derive(Clone, Debug)]
enum FontDecoder {
ToUnicode { map: CMap, cid_width: u8 },
IdentityNoCMap,
SimpleMap(Box<EncodingMap>),
Latin1,
}
impl FontDecoder {
fn from_dict(reader: &mut DocumentReader<'_>, font: &Dict) -> Result<FontDecoder, PdfError> {
let to_unicode = font
.entries()
.iter()
.find(|(k, _)| k == "ToUnicode")
.map(|(_, v)| v.clone());
if let Some(tu) = to_unicode {
let stream_obj = match tu {
Object::Reference(id) => reader.resolve(id)?,
other => other,
};
if let Object::Stream(s) = stream_obj {
let bytes = decode_stream(&s)?;
let map = CMap::parse(&bytes)?;
let cid_width = map.byte_width;
return Ok(FontDecoder::ToUnicode { map, cid_width });
}
}
let subtype = font
.entries()
.iter()
.find(|(k, _)| k == "Subtype")
.and_then(|(_, v)| match v {
Object::Name(s) => Some(s.as_str()),
_ => None,
})
.unwrap_or("");
if subtype == "Type0" {
let enc = font
.entries()
.iter()
.find(|(k, _)| k == "Encoding")
.map(|(_, v)| v.clone());
if let Some(Object::Name(name)) = enc {
if name == "Identity-H" || name == "Identity-V" {
return Ok(FontDecoder::IdentityNoCMap);
}
}
return Ok(FontDecoder::IdentityNoCMap);
}
let enc = font
.entries()
.iter()
.find(|(k, _)| k == "Encoding")
.map(|(_, v)| v.clone());
if let Some(Object::Name(name)) = enc {
if let Some(base) = BaseEncoding::from_name(name.as_str()) {
return Ok(FontDecoder::SimpleMap(Box::new(EncodingMap::from_base(
base,
))));
}
return Ok(FontDecoder::Latin1);
}
if let Some(Object::Dict(enc_d)) = enc {
let base_name = enc_d
.entries()
.iter()
.find(|(k, _)| k == "BaseEncoding")
.and_then(|(_, v)| match v {
Object::Name(s) => Some(s.clone()),
_ => None,
});
let base_map = match base_name.as_deref().and_then(BaseEncoding::from_name) {
Some(b) => EncodingMap::from_base(b),
None => {
let default = match subtype {
"TrueType" => BaseEncoding::WinAnsi,
_ => BaseEncoding::Standard,
};
EncodingMap::from_base(default)
}
};
let diffs_obj = enc_d
.entries()
.iter()
.find(|(k, _)| k == "Differences")
.map(|(_, v)| v.clone());
let final_map = match diffs_obj {
Some(arr @ Object::Array(_)) => {
let diffs = parse_encoding_differences(&arr)?;
apply_encoding_differences(&base_map, &diffs)
}
_ => base_map,
};
return Ok(FontDecoder::SimpleMap(Box::new(final_map)));
}
let default = match subtype {
"TrueType" => BaseEncoding::WinAnsi,
"Type1" | "Type3" | "MMType1" => BaseEncoding::Standard,
_ => return Ok(FontDecoder::Latin1),
};
Ok(FontDecoder::SimpleMap(Box::new(EncodingMap::from_base(
default,
))))
}
fn decode(&self, bytes: &[u8]) -> String {
match self {
FontDecoder::ToUnicode { map, cid_width } => {
let mut out = String::new();
if !map.codespaces.is_empty() {
let mut i = 0;
while i < bytes.len() {
if let Some(w) = map.match_codespace_width(&bytes[i..]) {
let cid = bytes_to_u32(&bytes[i..i + w]);
if let Some(s) = map.lookup(cid) {
out.push_str(s);
} else {
out.push('\u{FFFD}');
}
i += w;
} else {
out.push('\u{FFFD}');
i += 1;
}
}
return out;
}
let w = *cid_width as usize;
let mut i = 0;
while i + w <= bytes.len() {
let cid = match w {
1 => bytes[i] as u32,
2 => ((bytes[i] as u32) << 8) | (bytes[i + 1] as u32),
_ => {
break;
}
};
if let Some(s) = map.lookup(cid) {
out.push_str(s);
} else {
out.push('\u{FFFD}');
}
i += w;
}
out
}
FontDecoder::IdentityNoCMap => {
let mut out = String::new();
let mut i = 0;
while i + 2 <= bytes.len() {
let cp = ((bytes[i] as u32) << 8) | (bytes[i + 1] as u32);
if let Some(c) = char::from_u32(cp) {
out.push(c);
}
i += 2;
}
out
}
FontDecoder::SimpleMap(map) => map.decode(bytes),
FontDecoder::Latin1 => bytes.iter().map(|&b| b as char).collect(),
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct CodespaceRange {
pub lo: Vec<u8>,
pub hi: Vec<u8>,
}
impl CodespaceRange {
fn width(&self) -> usize {
self.lo.len()
}
fn matches(&self, bytes: &[u8]) -> bool {
let w = self.width();
if bytes.len() < w {
return false;
}
bytes[..w]
.iter()
.zip(self.lo.iter().zip(self.hi.iter()))
.all(|(b, (lo, hi))| b >= lo && b <= hi)
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct CMap {
table: HashMap<u32, String>,
pub(crate) byte_width: u8,
pub(crate) codespaces: Vec<CodespaceRange>,
}
impl CMap {
pub(crate) fn parse(bytes: &[u8]) -> Result<CMap, PdfError> {
let mut cm = CMap {
byte_width: 2, ..CMap::default()
};
let mut i = 0;
while i < bytes.len() {
i = skip_ws_and_comments(bytes, i);
if i >= bytes.len() {
break;
}
if let Some(rest) = peek_keyword(bytes, i, b"begincodespacerange") {
i = rest;
i = parse_codespacerange(bytes, i, &mut cm)?;
continue;
}
if let Some(rest) = peek_keyword(bytes, i, b"beginbfchar") {
i = rest;
i = parse_bfchar(bytes, i, &mut cm)?;
continue;
}
if let Some(rest) = peek_keyword(bytes, i, b"beginbfrange") {
i = rest;
i = parse_bfrange(bytes, i, &mut cm)?;
continue;
}
i = skip_token(bytes, i);
}
Ok(cm)
}
fn lookup(&self, cid: u32) -> Option<&str> {
self.table.get(&cid).map(|s| s.as_str())
}
fn match_codespace_width(&self, bytes: &[u8]) -> Option<usize> {
for cs in &self.codespaces {
if cs.matches(bytes) {
return Some(cs.width());
}
}
None
}
}
fn parse_codespacerange(bytes: &[u8], mut i: usize, cm: &mut CMap) -> Result<usize, PdfError> {
loop {
i = skip_ws_and_comments(bytes, i);
if i >= bytes.len() {
return Err(PdfError::other(
"PDF CMap: unterminated begincodespacerange block",
));
}
if let Some(rest) = peek_keyword(bytes, i, b"endcodespacerange") {
return Ok(rest);
}
let (lo, after_lo) = read_hex_string_payload(bytes, i)?;
i = after_lo;
i = skip_ws_and_comments(bytes, i);
let (hi, after_hi) = read_hex_string_payload(bytes, i)?;
i = after_hi;
if lo.is_empty() || hi.is_empty() || lo.len() != hi.len() {
continue;
}
if lo.len() > 4 {
continue;
}
cm.codespaces.push(CodespaceRange { lo, hi });
}
}
fn parse_bfchar(bytes: &[u8], mut i: usize, cm: &mut CMap) -> Result<usize, PdfError> {
loop {
i = skip_ws_and_comments(bytes, i);
if i >= bytes.len() {
return Err(PdfError::other("PDF CMap: unterminated beginbfchar block"));
}
if let Some(rest) = peek_keyword(bytes, i, b"endbfchar") {
return Ok(rest);
}
let (src_bytes, after_src) = read_hex_string_payload(bytes, i)?;
i = after_src;
i = skip_ws_and_comments(bytes, i);
let (dst_bytes, after_dst) = read_hex_string_payload(bytes, i)?;
i = after_dst;
if !src_bytes.is_empty() {
cm.byte_width = src_bytes.len() as u8;
}
let cid = bytes_to_u32(&src_bytes);
let s = utf16be_to_string(&dst_bytes);
cm.table.insert(cid, s);
}
}
fn parse_bfrange(bytes: &[u8], mut i: usize, cm: &mut CMap) -> Result<usize, PdfError> {
loop {
i = skip_ws_and_comments(bytes, i);
if i >= bytes.len() {
return Err(PdfError::other("PDF CMap: unterminated beginbfrange block"));
}
if let Some(rest) = peek_keyword(bytes, i, b"endbfrange") {
return Ok(rest);
}
let (lo_bytes, after_lo) = read_hex_string_payload(bytes, i)?;
i = after_lo;
i = skip_ws_and_comments(bytes, i);
let (hi_bytes, after_hi) = read_hex_string_payload(bytes, i)?;
i = after_hi;
if !lo_bytes.is_empty() {
cm.byte_width = lo_bytes.len() as u8;
}
let lo = bytes_to_u32(&lo_bytes);
let hi = bytes_to_u32(&hi_bytes);
i = skip_ws_and_comments(bytes, i);
if i < bytes.len() && bytes[i] == b'[' {
i += 1;
let mut dst_idx = 0u32;
loop {
i = skip_ws_and_comments(bytes, i);
if i >= bytes.len() {
return Err(PdfError::other("PDF CMap: unterminated bfrange array"));
}
if bytes[i] == b']' {
i += 1;
break;
}
let (dst_bytes, after) = read_hex_string_payload(bytes, i)?;
i = after;
let s = utf16be_to_string(&dst_bytes);
let cid = lo + dst_idx;
if cid > hi {
continue;
}
cm.table.insert(cid, s);
dst_idx += 1;
}
} else {
let (dst_bytes, after) = read_hex_string_payload(bytes, i)?;
i = after;
let dst_str = utf16be_to_string(&dst_bytes);
let count = hi.saturating_sub(lo) + 1;
if dst_str.chars().count() == 1 {
let base = dst_str.chars().next().unwrap() as u32;
for k in 0..count {
let cid = lo + k;
if let Some(c) = char::from_u32(base + k) {
cm.table.insert(cid, String::from(c));
}
}
} else {
let mut chars: Vec<char> = dst_str.chars().collect();
for k in 0..count {
let cid = lo + k;
cm.table.insert(cid, chars.iter().collect::<String>());
if let Some(last) = chars.last_mut() {
if let Some(next) = char::from_u32(*last as u32 + 1) {
*last = next;
}
}
}
}
}
}
}
fn read_hex_string_payload(bytes: &[u8], start: usize) -> Result<(Vec<u8>, usize), PdfError> {
if start >= bytes.len() || bytes[start] != b'<' {
return Err(PdfError::other(format!(
"PDF CMap: expected hex string at byte {start}"
)));
}
let mut nibbles = Vec::new();
let mut i = start + 1;
while i < bytes.len() && bytes[i] != b'>' {
let b = bytes[i];
if let Some(v) = hex_nibble(b) {
nibbles.push(v);
} else if !is_ws(b) {
}
i += 1;
}
if i >= bytes.len() {
return Err(PdfError::other(
"PDF CMap: unterminated hex string in bfchar/bfrange",
));
}
i += 1;
if nibbles.len() % 2 == 1 {
nibbles.push(0);
}
let mut out = Vec::with_capacity(nibbles.len() / 2);
for pair in nibbles.chunks_exact(2) {
out.push((pair[0] << 4) | pair[1]);
}
Ok((out, i))
}
fn bytes_to_u32(b: &[u8]) -> u32 {
let mut v = 0u32;
for &x in b {
v = (v << 8) | (x as u32);
}
v
}
fn utf16be_to_string(b: &[u8]) -> String {
if b.len() == 1 {
return String::from(b[0] as char);
}
let mut units: Vec<u16> = Vec::with_capacity(b.len() / 2);
for chunk in b.chunks_exact(2) {
units.push(u16::from_be_bytes([chunk[0], chunk[1]]));
}
String::from_utf16_lossy(&units)
}
fn hex_nibble(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(10 + (b - b'a')),
b'A'..=b'F' => Some(10 + (b - b'A')),
_ => None,
}
}
fn is_ws(b: u8) -> bool {
matches!(b, 0x00 | b'\t' | b'\n' | 0x0C | b'\r' | b' ')
}
fn skip_ws_and_comments(bytes: &[u8], mut i: usize) -> usize {
loop {
while i < bytes.len() && is_ws(bytes[i]) {
i += 1;
}
if i < bytes.len() && bytes[i] == b'%' {
while i < bytes.len() && bytes[i] != b'\n' && bytes[i] != b'\r' {
i += 1;
}
continue;
}
return i;
}
}
fn peek_keyword(bytes: &[u8], i: usize, kw: &[u8]) -> Option<usize> {
if i + kw.len() > bytes.len() {
return None;
}
if &bytes[i..i + kw.len()] != kw {
return None;
}
let after = i + kw.len();
if after < bytes.len() {
let b = bytes[after];
if !is_ws(b) && !matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'/' | b'%') {
return None;
}
}
Some(after)
}
fn skip_token(bytes: &[u8], i: usize) -> usize {
if i >= bytes.len() {
return i;
}
let b = bytes[i];
if b == b'<' && bytes.get(i + 1) == Some(&b'<') {
let mut depth = 1u32;
let mut j = i + 2;
while j + 1 < bytes.len() && depth > 0 {
if bytes[j] == b'<' && bytes[j + 1] == b'<' {
depth += 1;
j += 2;
continue;
}
if bytes[j] == b'>' && bytes[j + 1] == b'>' {
depth -= 1;
j += 2;
continue;
}
j += 1;
}
return j;
}
if b == b'<' {
let mut j = i + 1;
while j < bytes.len() && bytes[j] != b'>' {
j += 1;
}
return j.saturating_add(1).min(bytes.len());
}
if b == b'(' {
let mut depth = 1u32;
let mut j = i + 1;
while j < bytes.len() && depth > 0 {
let c = bytes[j];
if c == b'\\' && j + 1 < bytes.len() {
j += 2;
continue;
}
if c == b'(' {
depth += 1;
}
if c == b')' {
depth -= 1;
}
j += 1;
}
return j;
}
if b == b'[' {
let mut depth = 1u32;
let mut j = i + 1;
while j < bytes.len() && depth > 0 {
let c = bytes[j];
if c == b'[' {
depth += 1;
} else if c == b']' {
depth -= 1;
}
j += 1;
}
return j;
}
let mut j = i + 1;
while j < bytes.len()
&& !is_ws(bytes[j])
&& !matches!(
bytes[j],
b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
)
{
j += 1;
}
j
}
struct TextWalker {
fonts: HashMap<String, FontDecoder>,
runs: Vec<TextRun>,
run_mcids: Vec<Option<u32>>,
operands: Vec<TextOperand>,
in_text: bool,
cur_font: String,
cur_size: f32,
tm: [f32; 6],
tlm: [f32; 6],
leading: f32,
render_mode: TextRenderMode,
text_rise: f32,
saved: Vec<SavedTextState>,
track_mcid: bool,
mcid_stack: Vec<Option<u32>>,
}
#[derive(Clone, Debug)]
enum TextOperand {
Number(f32),
String(Vec<u8>),
Array(Vec<TJItem>),
Name(String),
Dict {
mcid: Option<u32>,
},
}
#[derive(Clone, Debug)]
enum TJItem {
Str(Vec<u8>),
Kern(f32),
}
#[derive(Clone, Debug)]
struct SavedTextState {
font: String,
size: f32,
tm: [f32; 6],
tlm: [f32; 6],
leading: f32,
render_mode: TextRenderMode,
text_rise: f32,
}
impl TextWalker {
fn new(fonts: HashMap<String, FontDecoder>) -> Self {
Self {
fonts,
runs: Vec::new(),
run_mcids: Vec::new(),
operands: Vec::new(),
in_text: false,
cur_font: String::new(),
cur_size: 0.0,
tm: identity(),
tlm: identity(),
leading: 0.0,
render_mode: TextRenderMode::Fill,
text_rise: 0.0,
saved: Vec::new(),
track_mcid: false,
mcid_stack: Vec::new(),
}
}
fn into_runs(self) -> Vec<TextRun> {
self.runs
}
fn into_runs_with_mcid(self) -> Vec<(TextRun, Option<u32>)> {
self.runs.into_iter().zip(self.run_mcids).collect()
}
fn parse(&mut self, input: &[u8]) -> Result<(), PdfError> {
let mut i = 0;
while i < input.len() {
let b = input[i];
if is_ws(b) {
i += 1;
continue;
}
if b == b'%' {
while i < input.len() && input[i] != b'\n' && input[i] != b'\r' {
i += 1;
}
continue;
}
if b == b'(' {
let (end, payload) = read_literal_string(input, i)?;
self.operands.push(TextOperand::String(payload));
i = end;
continue;
}
if b == b'<' && input.get(i + 1) != Some(&b'<') {
let (payload, end) = read_hex_string_payload(input, i)?;
self.operands.push(TextOperand::String(payload));
i = end;
continue;
}
if b == b'<' && input.get(i + 1) == Some(&b'<') {
let start = i;
let mut depth = 1u32;
i += 2;
while i + 1 < input.len() && depth > 0 {
if input[i] == b'<' && input[i + 1] == b'<' {
depth += 1;
i += 2;
} else if input[i] == b'>' && input[i + 1] == b'>' {
depth -= 1;
i += 2;
} else {
i += 1;
}
}
let mcid = scan_inline_mcid(&input[start..i]);
self.operands.push(TextOperand::Dict { mcid });
continue;
}
if b == b'[' {
let (end, items) = read_tj_array(input, i)?;
self.operands.push(TextOperand::Array(items));
i = end;
continue;
}
if b == b'/' {
let mut end = i + 1;
while end < input.len() && !is_ws(input[end]) && !is_delim(input[end]) {
end += 1;
}
let name = String::from_utf8_lossy(&input[i + 1..end]).into_owned();
self.operands.push(TextOperand::Name(name));
i = end;
continue;
}
if matches!(b, b'+' | b'-' | b'.' | b'0'..=b'9') {
let mut end = i;
if matches!(input[end], b'+' | b'-') {
end += 1;
}
let mut saw_digit = false;
let mut saw_dot = false;
while end < input.len() {
let c = input[end];
if c.is_ascii_digit() {
end += 1;
saw_digit = true;
} else if c == b'.' && !saw_dot {
end += 1;
saw_dot = true;
} else {
break;
}
}
if !saw_digit {
let kw_end = scan_kw_end(input, i);
self.dispatch(&input[i..kw_end])?;
i = kw_end;
continue;
}
let s = str::from_utf8(&input[i..end]).map_err(|_| {
PdfError::other(format!("PDF text walker: non-UTF-8 number at byte {i}"))
})?;
let f: f32 = s.parse().map_err(|_| {
PdfError::other(format!("PDF text walker: invalid number `{s}` at byte {i}"))
})?;
self.operands.push(TextOperand::Number(f));
i = end;
continue;
}
let kw_end = scan_kw_end(input, i);
if kw_end == i {
i += 1;
continue;
}
self.dispatch(&input[i..kw_end])?;
i = kw_end;
}
Ok(())
}
fn dispatch(&mut self, op: &[u8]) -> Result<(), PdfError> {
match op {
b"q" => {
self.saved.push(SavedTextState {
font: self.cur_font.clone(),
size: self.cur_size,
tm: self.tm,
tlm: self.tlm,
leading: self.leading,
render_mode: self.render_mode,
text_rise: self.text_rise,
});
self.operands.clear();
}
b"Q" => {
if let Some(s) = self.saved.pop() {
self.cur_font = s.font;
self.cur_size = s.size;
self.tm = s.tm;
self.tlm = s.tlm;
self.leading = s.leading;
self.render_mode = s.render_mode;
self.text_rise = s.text_rise;
}
self.operands.clear();
}
b"BT" => {
self.in_text = true;
self.tm = identity();
self.tlm = identity();
self.operands.clear();
}
b"ET" => {
self.in_text = false;
self.operands.clear();
}
b"Tf" => {
let size = self.pop_num().unwrap_or(0.0);
let name = self.pop_name().unwrap_or_default();
self.cur_font = name;
self.cur_size = size;
self.operands.clear();
}
b"Tm" => {
let nums = self.take_n(6);
if let Some(n) = nums {
self.tm = n;
self.tlm = n;
}
}
b"Td" => {
let nums = self.take_n(2);
if let Some(n) = nums {
let tx = n[0];
let ty = n[1];
let translate = [1.0, 0.0, 0.0, 1.0, tx, ty];
self.tlm = mul(translate, self.tlm);
self.tm = self.tlm;
}
}
b"TD" => {
let nums = self.take_n(2);
if let Some(n) = nums {
let tx = n[0];
let ty = n[1];
self.leading = -ty;
let translate = [1.0, 0.0, 0.0, 1.0, tx, ty];
self.tlm = mul(translate, self.tlm);
self.tm = self.tlm;
}
}
b"TL" => {
if let Some(n) = self.pop_num() {
self.leading = n;
}
}
b"T*" => {
let translate = [1.0, 0.0, 0.0, 1.0, 0.0, -self.leading];
self.tlm = mul(translate, self.tlm);
self.tm = self.tlm;
self.operands.clear();
}
b"Tj" => {
let s = self.pop_string().unwrap_or_default();
self.emit_show(&s);
}
b"TJ" => {
let arr = self.pop_array().unwrap_or_default();
self.emit_show_tj(&arr);
}
b"'" => {
let translate = [1.0, 0.0, 0.0, 1.0, 0.0, -self.leading];
self.tlm = mul(translate, self.tlm);
self.tm = self.tlm;
let s = self.pop_string().unwrap_or_default();
self.emit_show(&s);
}
b"\"" => {
let s = self.pop_string().unwrap_or_default();
let _ac = self.pop_num().unwrap_or(0.0);
let _aw = self.pop_num().unwrap_or(0.0);
let translate = [1.0, 0.0, 0.0, 1.0, 0.0, -self.leading];
self.tlm = mul(translate, self.tlm);
self.tm = self.tlm;
self.emit_show(&s);
}
b"Tr" => {
if let Some(n) = self.pop_num() {
self.render_mode = TextRenderMode::from_operand(n as i64);
}
self.operands.clear();
}
b"Ts" => {
if let Some(n) = self.pop_num() {
self.text_rise = n;
}
self.operands.clear();
}
b"Tc" | b"Tw" | b"Tz" => {
self.operands.clear();
}
b"BDC" => {
let mcid = match self.operands.pop() {
Some(TextOperand::Dict { mcid }) => mcid,
Some(TextOperand::Name(_)) => {
None
}
Some(other) => {
self.operands.push(other);
None
}
None => None,
};
let _tag = self.pop_name();
if self.track_mcid {
self.mcid_stack.push(mcid);
}
self.operands.clear();
}
b"BMC" => {
let _tag = self.pop_name();
if self.track_mcid {
self.mcid_stack.push(None);
}
self.operands.clear();
}
b"EMC" => {
if self.track_mcid {
self.mcid_stack.pop();
}
self.operands.clear();
}
b"MP" => {
self.operands.clear();
}
b"DP" => {
self.operands.clear();
}
_ => {
self.operands.clear();
}
}
Ok(())
}
fn pop_num(&mut self) -> Option<f32> {
match self.operands.pop()? {
TextOperand::Number(n) => Some(n),
other => {
self.operands.push(other);
None
}
}
}
fn pop_name(&mut self) -> Option<String> {
match self.operands.pop()? {
TextOperand::Name(s) => Some(s),
other => {
self.operands.push(other);
None
}
}
}
fn pop_string(&mut self) -> Option<Vec<u8>> {
match self.operands.pop()? {
TextOperand::String(s) => Some(s),
other => {
self.operands.push(other);
None
}
}
}
fn pop_array(&mut self) -> Option<Vec<TJItem>> {
match self.operands.pop()? {
TextOperand::Array(a) => Some(a),
other => {
self.operands.push(other);
None
}
}
}
fn take_n(&mut self, n: usize) -> Option<[f32; 6]> {
if self.operands.len() < n {
self.operands.clear();
return None;
}
let split = self.operands.len() - n;
let tail: Vec<TextOperand> = self.operands.drain(split..).collect();
let mut nums = [0.0f32; 6];
for (i, op) in tail.into_iter().enumerate() {
match op {
TextOperand::Number(f) => nums[i] = f,
_ => return None,
}
}
Some(nums)
}
fn decode_bytes(&self, bytes: &[u8]) -> String {
match self.fonts.get(&self.cur_font) {
Some(d) => d.decode(bytes),
None => bytes.iter().map(|&b| b as char).collect(),
}
}
fn emit_show(&mut self, bytes: &[u8]) {
if !self.in_text {
self.operands.clear();
return;
}
let text = self.decode_bytes(bytes);
self.push_run(text);
self.operands.clear();
}
fn emit_show_tj(&mut self, arr: &[TJItem]) {
if !self.in_text {
self.operands.clear();
return;
}
let mut text = String::new();
let mut pending_gap = 0.0f32;
for item in arr {
match item {
TJItem::Str(b) => {
if pending_gap >= Self::WORD_BREAK_GAP
&& !text.is_empty()
&& !text.ends_with(' ')
{
text.push(' ');
}
pending_gap = 0.0;
text.push_str(&self.decode_bytes(b));
}
TJItem::Kern(adj) => pending_gap += -adj,
}
}
self.push_run(text);
self.operands.clear();
}
fn push_run(&mut self, text: String) {
let rise = self.text_rise;
let x = self.tm[2] * rise + self.tm[4];
let y = self.tm[3] * rise + self.tm[5];
self.runs.push(TextRun {
text,
position: (x, y),
font_name: self.cur_font.clone(),
font_size: self.cur_size,
render_mode: self.render_mode,
text_rise: rise,
});
let cur_mcid = self.mcid_stack.last().copied().unwrap_or(None);
self.run_mcids.push(cur_mcid);
}
const WORD_BREAK_GAP: f32 = 250.0;
}
fn identity() -> [f32; 6] {
[1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
}
fn mul(a: [f32; 6], b: [f32; 6]) -> [f32; 6] {
[
a[0] * b[0] + a[1] * b[2],
a[0] * b[1] + a[1] * b[3],
a[2] * b[0] + a[3] * b[2],
a[2] * b[1] + a[3] * b[3],
a[4] * b[0] + a[5] * b[2] + b[4],
a[4] * b[1] + a[5] * b[3] + b[5],
]
}
fn is_delim(b: u8) -> bool {
matches!(
b,
b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
)
}
fn scan_kw_end(input: &[u8], start: usize) -> usize {
let mut end = start;
while end < input.len() && !is_ws(input[end]) && !is_delim(input[end]) {
end += 1;
}
end
}
fn scan_inline_mcid(bytes: &[u8]) -> Option<u32> {
if bytes.len() < 4 || &bytes[..2] != b"<<" {
return None;
}
let body = &bytes[2..bytes.len().saturating_sub(2)];
let mut i = 0;
let mut depth = 0u32;
while i < body.len() {
let b = body[i];
if is_ws(b) {
i += 1;
continue;
}
if b == b'<' && body.get(i + 1) == Some(&b'<') {
depth += 1;
i += 2;
continue;
}
if b == b'>' && body.get(i + 1) == Some(&b'>') {
depth = depth.saturating_sub(1);
i += 2;
continue;
}
if depth > 0 {
i += 1;
continue;
}
if b == b'/' {
let mut end = i + 1;
while end < body.len() && !is_ws(body[end]) && !is_delim(body[end]) {
end += 1;
}
let name = &body[i + 1..end];
i = end;
if name == b"MCID" {
while i < body.len() && is_ws(body[i]) {
i += 1;
}
let mut e = i;
while e < body.len() && (body[e].is_ascii_digit() || body[e] == b'-') {
e += 1;
}
if e == i {
return None;
}
let s = std::str::from_utf8(&body[i..e]).ok()?;
return s.parse::<u32>().ok();
}
continue;
}
i += 1;
}
None
}
fn read_literal_string(input: &[u8], start: usize) -> Result<(usize, Vec<u8>), PdfError> {
let mut end = start + 1;
let mut depth = 1u32;
let mut decoded = Vec::new();
while end < input.len() {
let b = input[end];
if b == b'\\' {
end += 1;
if end >= input.len() {
break;
}
let c = input[end];
match c {
b'n' => {
decoded.push(b'\n');
end += 1;
}
b'r' => {
decoded.push(b'\r');
end += 1;
}
b't' => {
decoded.push(b'\t');
end += 1;
}
b'b' => {
decoded.push(0x08);
end += 1;
}
b'f' => {
decoded.push(0x0C);
end += 1;
}
b'(' | b')' | b'\\' => {
decoded.push(c);
end += 1;
}
b'\n' | b'\r' => {
end += 1;
if c == b'\r' && end < input.len() && input[end] == b'\n' {
end += 1;
}
}
b'0'..=b'7' => {
let mut v = 0u32;
let mut k = 0;
while k < 3 && end < input.len() && matches!(input[end], b'0'..=b'7') {
v = v * 8 + (input[end] - b'0') as u32;
end += 1;
k += 1;
}
decoded.push((v & 0xFF) as u8);
}
_ => {
decoded.push(c);
end += 1;
}
}
continue;
}
if b == b'(' {
depth += 1;
decoded.push(b);
end += 1;
continue;
}
if b == b')' {
depth -= 1;
if depth == 0 {
end += 1;
return Ok((end, decoded));
}
decoded.push(b);
end += 1;
continue;
}
decoded.push(b);
end += 1;
}
Err(PdfError::other(
"PDF text walker: unterminated literal string",
))
}
fn read_tj_array(input: &[u8], start: usize) -> Result<(usize, Vec<TJItem>), PdfError> {
let mut i = start + 1;
let mut items = Vec::new();
loop {
i = {
let mut k = i;
while k < input.len() && (is_ws(input[k]) || input[k] == b'\n') {
k += 1;
}
k
};
if i >= input.len() {
return Err(PdfError::other("PDF text walker: unterminated TJ array"));
}
if input[i] == b']' {
return Ok((i + 1, items));
}
if input[i] == b'(' {
let (end, payload) = read_literal_string(input, i)?;
items.push(TJItem::Str(payload));
i = end;
continue;
}
if input[i] == b'<' && input.get(i + 1) != Some(&b'<') {
let (payload, end) = read_hex_string_payload(input, i)?;
items.push(TJItem::Str(payload));
i = end;
continue;
}
if matches!(input[i], b'+' | b'-' | b'.' | b'0'..=b'9') {
let mut end = i;
if matches!(input[end], b'+' | b'-') {
end += 1;
}
let mut saw_dot = false;
while end < input.len()
&& (input[end].is_ascii_digit() || (input[end] == b'.' && !saw_dot))
{
if input[end] == b'.' {
saw_dot = true;
}
end += 1;
}
if let Ok(s) = str::from_utf8(&input[i..end]) {
if let Ok(f) = s.parse::<f32>() {
items.push(TJItem::Kern(f));
}
}
i = end;
continue;
}
i += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cmap_bfchar_simple() {
let cmap = b"
/CIDInit /ProcSet findresource begin
12 dict begin
beginbfchar
<0001> <0041>
<0002> <0042>
<0003> <0043>
endbfchar
";
let parsed = CMap::parse(cmap).unwrap();
assert_eq!(parsed.byte_width, 2);
assert_eq!(parsed.lookup(1), Some("A"));
assert_eq!(parsed.lookup(2), Some("B"));
assert_eq!(parsed.lookup(3), Some("C"));
}
#[test]
fn cmap_bfrange_scalar_form() {
let cmap = b"beginbfrange <0010> <0012> <0041> endbfrange";
let parsed = CMap::parse(cmap).unwrap();
assert_eq!(parsed.lookup(0x10), Some("A"));
assert_eq!(parsed.lookup(0x11), Some("B"));
assert_eq!(parsed.lookup(0x12), Some("C"));
}
#[test]
fn cmap_bfrange_array_form() {
let cmap = b"beginbfrange <0001> <0003> [ <0041> <0042> <0043> ] endbfrange";
let parsed = CMap::parse(cmap).unwrap();
assert_eq!(parsed.lookup(1), Some("A"));
assert_eq!(parsed.lookup(2), Some("B"));
assert_eq!(parsed.lookup(3), Some("C"));
}
#[test]
fn winansi_smart_quote_via_encoding_map() {
let m = EncodingMap::from_base(BaseEncoding::WinAnsi);
assert_eq!(m.decode(&[0x93]), "\u{201C}");
assert_eq!(m.decode(b"A"), "A");
}
#[test]
fn flat_text_joins_runs_with_spaces() {
let pe = PdfTextExtraction {
runs: vec![
TextRun {
text: "Hello".into(),
position: (0.0, 0.0),
font_name: "F0".into(),
font_size: 12.0,
render_mode: TextRenderMode::Fill,
text_rise: 0.0,
},
TextRun {
text: "World".into(),
position: (40.0, 0.0),
font_name: "F0".into(),
font_size: 12.0,
render_mode: TextRenderMode::Fill,
text_rise: 0.0,
},
],
};
assert_eq!(pe.flat_text(), "Hello World");
}
#[test]
fn tm_matrix_multiply_translates() {
let id = identity();
let trans = [1.0, 0.0, 0.0, 1.0, 100.0, 200.0];
let r = mul(trans, id);
assert_eq!(r[4], 100.0);
assert_eq!(r[5], 200.0);
}
#[test]
fn cmap_bfchar_multichar_target() {
let cmap = b"beginbfchar <0001> <00660069> endbfchar";
let parsed = CMap::parse(cmap).unwrap();
assert_eq!(parsed.lookup(1), Some("fi"));
}
#[test]
fn cmap_codespacerange_single_width_parses() {
let cmap = b"\
1 begincodespacerange
<0000> <FFFF>
endcodespacerange
2 beginbfchar
<0041> <0041>
<0042> <0042>
endbfchar
";
let parsed = CMap::parse(cmap).unwrap();
assert_eq!(parsed.codespaces.len(), 1);
assert_eq!(parsed.codespaces[0].width(), 2);
assert_eq!(parsed.codespaces[0].lo, vec![0x00, 0x00]);
assert_eq!(parsed.codespaces[0].hi, vec![0xFF, 0xFF]);
}
#[test]
fn cmap_codespacerange_mixed_width_parses_and_selects() {
let cmap = b"\
2 begincodespacerange
<00> <7F>
<8140> <FCFC>
endcodespacerange
";
let parsed = CMap::parse(cmap).unwrap();
assert_eq!(parsed.codespaces.len(), 2);
assert_eq!(parsed.match_codespace_width(&[0x41]), Some(1));
assert_eq!(parsed.match_codespace_width(&[0x81, 0x40]), Some(2));
assert_eq!(parsed.match_codespace_width(&[0x81, 0x39]), None);
assert_eq!(parsed.match_codespace_width(&[0xFD]), None);
}
#[test]
fn cmap_codespacerange_component_wise_match() {
let cmap = b"1 begincodespacerange <8140> <FCFC> endcodespacerange";
let parsed = CMap::parse(cmap).unwrap();
assert_eq!(parsed.match_codespace_width(&[0x81, 0x40]), Some(2));
assert_eq!(parsed.match_codespace_width(&[0xFC, 0xFC]), Some(2));
assert_eq!(parsed.match_codespace_width(&[0x81, 0x39]), None);
assert_eq!(parsed.match_codespace_width(&[0xFD, 0x00]), None);
}
#[test]
fn cmap_codespacerange_skips_mismatched_widths() {
let cmap = b"\
2 begincodespacerange
<00> <FFFF>
<0000> <FFFF>
endcodespacerange
";
let parsed = CMap::parse(cmap).unwrap();
assert_eq!(parsed.codespaces.len(), 1);
assert_eq!(parsed.codespaces[0].width(), 2);
}
#[test]
fn cmap_decode_mixed_width_picks_per_position() {
let cmap = b"\
2 begincodespacerange
<00> <7F>
<8140> <FCFC>
endcodespacerange
1 beginbfchar
<41> <0041>
endbfchar
1 beginbfchar
<8140> <4E00>
endbfchar
";
let parsed = CMap::parse(cmap).unwrap();
let decoder = FontDecoder::ToUnicode {
map: parsed,
cid_width: 1, };
let s = decoder.decode(&[0x41, 0x81, 0x40]);
assert_eq!(s, "A\u{4E00}");
}
#[test]
fn cmap_decode_unmapped_in_codespace_emits_replacement() {
let cmap = b"1 begincodespacerange <00> <FF> endcodespacerange";
let parsed = CMap::parse(cmap).unwrap();
let decoder = FontDecoder::ToUnicode {
map: parsed,
cid_width: 1,
};
let s = decoder.decode(&[0x41, 0x42]);
assert_eq!(s, "\u{FFFD}\u{FFFD}");
}
#[test]
fn cmap_decode_out_of_codespace_emits_replacement_and_advances() {
let cmap = b"\
1 begincodespacerange
<00> <7F>
endcodespacerange
1 beginbfchar
<41> <0041>
endbfchar
";
let parsed = CMap::parse(cmap).unwrap();
let decoder = FontDecoder::ToUnicode {
map: parsed,
cid_width: 1,
};
let s = decoder.decode(&[0xFF, 0x41]);
assert_eq!(s, "\u{FFFD}A");
}
#[test]
fn cmap_decode_legacy_no_codespacerange_uses_byte_width_fallback() {
let cmap = b"beginbfchar <0041> <0048> <0042> <0069> endbfchar";
let parsed = CMap::parse(cmap).unwrap();
assert!(parsed.codespaces.is_empty());
assert_eq!(parsed.byte_width, 2);
let decoder = FontDecoder::ToUnicode {
map: parsed,
cid_width: 2,
};
let s = decoder.decode(&[0x00, 0x41, 0x00, 0x42]);
assert_eq!(s, "Hi");
}
#[test]
fn read_literal_string_handles_escapes() {
let input = b"(Hello\\nWorld)";
let (end, payload) = read_literal_string(input, 0).unwrap();
assert_eq!(end, input.len());
assert_eq!(payload, b"Hello\nWorld");
}
#[test]
fn read_literal_string_handles_octal() {
let input = b"(\\101BC)";
let (_, payload) = read_literal_string(input, 0).unwrap();
assert_eq!(payload, b"ABC");
}
#[test]
fn read_tj_array_alternates_strings_and_kerns() {
let input = b"[(Hi) -120 (World)]";
let (_, items) = read_tj_array(input, 0).unwrap();
assert_eq!(items.len(), 3);
assert!(matches!(&items[0], TJItem::Str(s) if s == b"Hi"));
assert!(matches!(&items[1], TJItem::Kern(k) if (*k - -120.0).abs() < 1e-3));
assert!(matches!(&items[2], TJItem::Str(s) if s == b"World"));
}
}