use std::collections::HashMap;
use paperforge_pdf::{ObjectId, PdfObject, StreamDecoder, StreamFilter};
use crate::error::{ExtractError, ExtractResult};
struct FontInfo {
two_byte: bool,
to_unicode: HashMap<u16, String>,
}
pub struct Extractor;
impl Extractor {
pub fn extract_text(&self, data: &[u8]) -> ExtractResult<String> {
let doc = paperforge_pdf::Parser::new()
.parse(data)
.map_err(|e| ExtractError::Parse(e.to_string()))?;
let contents = self.page_content_streams(&doc);
let mut text = String::new();
for (page_index, (content_ids, page_dict)) in contents.iter().enumerate() {
if page_index > 0 {
text.push('\n');
}
let fonts = self.page_fonts(&doc, page_dict);
for id in content_ids {
let Some(obj) = doc.get_object(*id) else {
continue;
};
let Some(stream) = obj.as_stream() else {
continue;
};
if let Ok(bytes) = self.decode(stream) {
text.push_str(&extract_text_operators(&bytes, &fonts));
}
}
}
Ok(text)
}
fn page_content_streams(
&self,
doc: &paperforge_pdf::PdfDocument,
) -> Vec<(Vec<ObjectId>, paperforge_pdf::PdfDictionary)> {
let mut out = Vec::new();
let Some(catalog) = doc.catalog() else {
return out;
};
let Some(PdfObject::Dictionary(catalog_dict)) = doc.get_object(catalog) else {
return out;
};
let Some(PdfObject::Reference(root)) = catalog_dict.get("Pages") else {
return out;
};
let mut stack = vec![*root];
while let Some(id) = stack.pop() {
let Some(PdfObject::Dictionary(dict)) = doc.get_object(id) else {
continue;
};
match dict.get_name("Type").map(|n| n.as_str()) {
Some("Pages") => {
if let Some(PdfObject::Array(kids)) = dict.get("Kids") {
for kid in kids.0.iter().rev() {
if let PdfObject::Reference(r) = kid {
stack.push(*r);
}
}
}
}
Some("Page") => {
let ids = self.contents_of(dict);
out.push((ids, dict.clone()));
}
_ => {}
}
}
out
}
fn contents_of(&self, page: &paperforge_pdf::PdfDictionary) -> Vec<ObjectId> {
let Some(contents) = page.get("Contents") else {
return Vec::new();
};
match contents {
PdfObject::Reference(id) => vec![*id],
PdfObject::Array(arr) => arr.0.iter().filter_map(|o| o.as_reference()).collect(),
_ => Vec::new(),
}
}
fn decode(&self, stream: &paperforge_pdf::PdfStream) -> ExtractResult<Vec<u8>> {
let filter = stream.dictionary.get_name("Filter");
match filter {
Some(f) if f.as_str() == "FlateDecode" || f.as_str() == "Fl" => StreamDecoder::new()
.decode_with_limit(&stream.data, StreamFilter::Flate, 100 * 1024 * 1024)
.map_err(|e| ExtractError::Parse(e.to_string())),
Some(_) => Err(ExtractError::Unsupported(
"unsupported content stream filter".into(),
)),
None => Ok(stream.data.clone()),
}
}
fn page_fonts(
&self,
doc: &paperforge_pdf::PdfDocument,
page: &paperforge_pdf::PdfDictionary,
) -> HashMap<String, FontInfo> {
let mut fonts = HashMap::new();
let mut cur: Option<&paperforge_pdf::PdfDictionary> = Some(page);
while let Some(dict) = cur {
if let Some(res) = dict.get("Resources") {
let res_dict = match res {
PdfObject::Dictionary(d) => Some(d),
PdfObject::Reference(id) => doc.get_object(*id).and_then(|o| o.as_dict()),
_ => None,
};
if let Some(res_dict) = res_dict {
if let Some(PdfObject::Dictionary(font_dict)) = res_dict.get("Font") {
for (name, obj) in font_dict.iter() {
let font_obj = match obj {
PdfObject::Dictionary(d) => Some(d),
PdfObject::Reference(id) => {
doc.get_object(*id).and_then(|o| o.as_dict())
}
_ => None,
};
if let Some(font) = font_obj.and_then(|f| self.font_info(doc, f)) {
fonts.insert(name.as_str().to_string(), font);
}
}
}
}
break; }
cur = dict
.get("Parent")
.and_then(|p| p.as_reference())
.and_then(|id| doc.get_object(id).and_then(|o| o.as_dict()));
}
fonts
}
fn font_info(
&self,
doc: &paperforge_pdf::PdfDocument,
font: &paperforge_pdf::PdfDictionary,
) -> Option<FontInfo> {
let to_unicode_ref = font.get("ToUnicode").and_then(|o| o.as_reference())?;
let stream = doc.get_object(to_unicode_ref).and_then(|o| o.as_stream())?;
let data = self.decode(stream).ok()?;
Some(FontInfo {
two_byte: font
.get_name("Subtype")
.is_some_and(|n| n.as_str() == "Type0"),
to_unicode: parse_tounicode_cmap(&data),
})
}
pub fn extract_metadata(&self, data: &[u8]) -> ExtractResult<Vec<u8>> {
let doc = paperforge_pdf::Parser::new()
.parse(data)
.map_err(|e| ExtractError::Parse(e.to_string()))?;
let Some(info_id) = doc.info() else {
return Ok(Vec::new());
};
let Some(obj) = doc.get_object(info_id) else {
return Ok(Vec::new());
};
let Some(dict) = obj.as_dict() else {
return Ok(Vec::new());
};
let mut out = String::new();
for key in [
"Title", "Author", "Subject", "Keywords", "Creator", "Producer",
] {
if let Some(bytes) = dict.get_string_bytes(key) {
out.push_str(&format!("{key}: {}\n", String::from_utf8_lossy(bytes)));
}
}
Ok(out.into_bytes())
}
}
impl Default for Extractor {
fn default() -> Self {
Self
}
}
fn extract_text_operators(data: &[u8], fonts: &HashMap<String, FontInfo>) -> String {
let mut out = String::new();
let mut i = 0usize;
let mut bracket_depth = 0usize;
let mut current_font: Option<&FontInfo> = None;
while i < data.len() {
match data[i] {
b' ' | b'\t' | b'\r' | b'\n' | 0x0c => i += 1,
b'%' => {
while i < data.len() && data[i] != b'\n' {
i += 1;
}
}
b'[' => {
bracket_depth += 1;
i += 1;
}
b']' => {
bracket_depth = bracket_depth.saturating_sub(1);
i += 1;
}
b'/' => {
let start = i;
while i < data.len()
&& !matches!(data[i], b' ' | b'\t' | b'\r' | b'\n' | 0x0c | b'[' | b'(')
{
i += 1;
}
let name = &data[start + 1..i]; let mut j = skip_ws(data, i);
if data
.get(j)
.is_some_and(|b| matches!(b, b'-' | b'+' | b'.' | b'0'..=b'9'))
{
while data
.get(j)
.is_some_and(|b| matches!(b, b'-' | b'+' | b'.' | b'0'..=b'9'))
{
j += 1;
}
j = skip_ws(data, j);
}
if data[j..].starts_with(b"Tf") {
current_font = fonts.get(std::str::from_utf8(name).unwrap_or_default());
}
}
b'(' => {
if let Some(s) = read_literal_string(data, &mut i) {
out.push_str(&decode_text_bytes(&s, current_font));
if bracket_depth > 0 {
out.push(' ');
} else if next_operator_is_text_show(data, i) {
out.push('\n');
}
}
}
b'<' => {
if data.get(i + 1) == Some(&b'<') {
while i + 1 < data.len() && !(data[i] == b'>' && data[i + 1] == b'>') {
i += 1;
}
i = (i + 2).min(data.len());
} else if let Some(s) = read_hex_string(data, &mut i) {
out.push_str(&decode_text_bytes(&s, current_font));
if bracket_depth > 0 {
out.push(' ');
} else if next_operator_is_text_show(data, i) {
out.push('\n');
}
}
}
_ => i += 1,
}
}
out
}
fn skip_ws(data: &[u8], mut i: usize) -> usize {
while data
.get(i)
.is_some_and(|b| matches!(b, b' ' | b'\t' | b'\r' | b'\n' | 0x0c))
{
i += 1;
}
i
}
fn decode_text_bytes(bytes: &[u8], font: Option<&FontInfo>) -> String {
if let Some(font) = font {
if !font.to_unicode.is_empty() {
let mut out = String::new();
let step = if font.two_byte { 2 } else { 1 };
for code in bytes.chunks(step) {
if code.len() < step {
break;
}
let value = if font.two_byte {
u16::from_be_bytes([code[0], code[1]])
} else {
u16::from(code[0])
};
match font.to_unicode.get(&value) {
Some(s) => out.push_str(s),
None => out.push('\u{FFFD}'),
}
}
return out;
}
}
let mut out = String::new();
push_text(bytes, &mut out);
out
}
fn next_operator_is_text_show(data: &[u8], mut i: usize) -> bool {
while i < data.len() && matches!(data[i], b' ' | b'\t' | b'\r' | b'\n') {
i += 1;
}
data[i..].starts_with(b"Tj") || data[i..].starts_with(b"TJ")
}
fn parse_tounicode_cmap(data: &[u8]) -> HashMap<u16, String> {
let mut map = HashMap::new();
let mut i = 0usize;
let mut in_bfchar = false;
let mut in_bfrange = false;
let mut range_array = false;
let mut range_lo: u16 = 0;
let mut pending_src: Option<Vec<u8>> = None;
let mut pending: Vec<Vec<u8>> = Vec::new();
while i < data.len() {
match data[i] {
b'a'..=b'z' => {
let start = i;
while i < data.len() && data[i].is_ascii_alphanumeric() {
i += 1;
}
match &data[start..i] {
b"beginbfchar" => {
in_bfchar = true;
pending_src = None;
}
b"endbfchar" => {
in_bfchar = false;
pending_src = None;
}
b"beginbfrange" => {
in_bfrange = true;
pending.clear();
}
b"endbfrange" => {
in_bfrange = false;
pending.clear();
}
_ => {}
}
}
b'[' => {
if in_bfrange && pending.len() == 2 {
range_array = true;
range_lo = u16::from_be_bytes([pending[0][0], pending[0][1]]);
pending.clear();
}
i += 1;
}
b']' => {
range_array = false;
pending.clear();
i += 1;
}
b'<' => {
if let Some(s) = read_hex_string(data, &mut i) {
if in_bfchar {
match pending_src.take() {
Some(src) => insert_tounicode(&mut map, &src, &s),
None => pending_src = Some(s),
}
} else if in_bfrange && range_array {
let code = range_lo.wrapping_add(pending.len() as u16);
insert_tounicode(&mut map, &code.to_be_bytes(), &s);
pending.push(s);
} else if in_bfrange {
pending.push(s);
if pending.len() == 3 {
apply_bfrange(&mut map, &pending[0], &pending[1], &pending[2]);
pending.clear();
}
}
}
}
_ => i += 1,
}
}
map
}
fn insert_tounicode(map: &mut HashMap<u16, String>, src: &[u8], dst: &[u8]) {
if src.len() == 2 {
let code = u16::from_be_bytes([src[0], src[1]]);
let s = utf16be_to_string(dst);
if !s.is_empty() {
map.insert(code, s);
}
}
}
fn apply_bfrange(map: &mut HashMap<u16, String>, lo: &[u8], hi: &[u8], dst: &[u8]) {
if lo.len() != 2 || hi.len() != 2 || dst.len() < 2 {
return;
}
let lo = u16::from_be_bytes([lo[0], lo[1]]);
let hi = u16::from_be_bytes([hi[0], hi[1]]);
let units: Vec<u16> = dst
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
let mut cur = *units.last().unwrap_or(&0);
for code in lo..=hi {
let mut s = String::new();
for (idx, u) in units.iter().enumerate() {
let v = if idx == units.len() - 1 { cur } else { *u };
s.push(char::from_u32(u32::from(v)).unwrap_or('\u{FFFD}'));
}
map.insert(code, s);
cur = cur.wrapping_add(1);
}
}
fn utf16be_to_string(bytes: &[u8]) -> String {
bytes
.chunks_exact(2)
.map(|c| {
let u = u16::from_be_bytes([c[0], c[1]]);
char::from_u32(u32::from(u)).unwrap_or('\u{FFFD}')
})
.collect()
}
fn push_text(bytes: &[u8], out: &mut String) {
if bytes.starts_with(&[0xfe, 0xff]) {
let utf16: Vec<u16> = bytes[2..]
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
out.push_str(&String::from_utf16_lossy(&utf16));
} else {
out.push_str(&String::from_utf8_lossy(bytes));
}
}
fn read_literal_string(data: &[u8], i: &mut usize) -> Option<Vec<u8>> {
debug_assert_eq!(data[*i], b'(');
*i += 1;
let mut out = Vec::new();
let mut depth = 1u32;
while *i < data.len() {
match data[*i] {
b'(' => {
depth += 1;
out.push(b'(');
*i += 1;
}
b')' => {
depth -= 1;
*i += 1;
if depth == 0 {
return Some(out);
}
out.push(b')');
}
b'\\' => {
*i += 1;
let Some(&esc) = data.get(*i) else {
return Some(out);
};
*i += 1;
match esc {
b'n' => out.push(b'\n'),
b'r' => out.push(b'\r'),
b't' => out.push(b'\t'),
b'b' => out.push(8),
b'f' => out.push(12),
b'(' => out.push(b'('),
b')' => out.push(b')'),
b'\\' => out.push(b'\\'),
b'0'..=b'7' => {
let mut v = esc - b'0';
for _ in 0..2 {
match data.get(*i) {
Some(e @ b'0'..=b'7') => {
v = v * 8 + (e - b'0');
*i += 1;
}
_ => break,
}
}
out.push(v);
}
b'\r' => {
if data.get(*i) == Some(&b'\n') {
*i += 1;
}
}
b'\n' => {}
other => out.push(other),
}
}
b'\r' => {
*i += 1;
if data.get(*i) == Some(&b'\n') {
*i += 1;
}
out.push(b'\n');
}
other => {
out.push(other);
*i += 1;
}
}
}
None
}
fn read_hex_string(data: &[u8], i: &mut usize) -> Option<Vec<u8>> {
debug_assert_eq!(data[*i], b'<');
*i += 1;
let mut out = Vec::new();
let mut hi: Option<u8> = None;
while *i < data.len() {
match data[*i] {
b'>' => {
*i += 1;
if let Some(h) = hi {
out.push(h << 4);
}
return Some(out);
}
b' ' | b'\t' | b'\r' | b'\n' => *i += 1,
b'0'..=b'9' | b'a'..=b'f' | b'A'..=b'F' => {
let v = match data[*i] {
b'0'..=b'9' => data[*i] - b'0',
b'a'..=b'f' => data[*i] - b'a' + 10,
_ => data[*i] - b'A' + 10,
};
*i += 1;
match hi {
None => hi = Some(v),
Some(h) => {
out.push((h << 4) | v);
hi = None;
}
}
}
_ => *i += 1,
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn extract(text: &str, compressed: bool) -> String {
let mut doc = paperforge_pdf::PdfDocument::new();
doc.set_catalog(ObjectId::new(1, 0));
doc.add_object(
ObjectId::new(1, 0),
PdfObject::Dictionary({
let mut d = paperforge_pdf::PdfDictionary::new();
d.insert(
"Type",
PdfObject::Name(paperforge_pdf::PdfName::new("Catalog")),
);
d.insert("Pages", PdfObject::Reference(ObjectId::new(2, 0)));
d
}),
);
doc.add_object(
ObjectId::new(2, 0),
PdfObject::Dictionary({
let mut d = paperforge_pdf::PdfDictionary::new();
d.insert(
"Type",
PdfObject::Name(paperforge_pdf::PdfName::new("Pages")),
);
d.insert("Count", PdfObject::Integer(1));
let mut kids = paperforge_pdf::PdfArray::new();
kids.push(PdfObject::Reference(ObjectId::new(4, 0)));
d.insert("Kids", PdfObject::Array(kids));
d
}),
);
let mut escaped = Vec::new();
paperforge_pdf::escape_literal_string_into(&mut escaped, text.as_bytes());
let content = format!(
"BT /F1 12 Tf 50 700 Td ({}) Tj ET",
String::from_utf8_lossy(&escaped)
);
let data = if compressed {
paperforge_pdf::StreamEncoder::new()
.encode(content.as_bytes(), StreamFilter::Flate)
.unwrap()
} else {
content.as_bytes().to_vec()
};
let stream =
paperforge_pdf::PdfStream::with_dict(paperforge_pdf::PdfDictionary::new(), data);
doc.add_object(ObjectId::new(3, 0), PdfObject::Stream(stream));
doc.add_object(
ObjectId::new(4, 0),
PdfObject::Dictionary({
let mut d = paperforge_pdf::PdfDictionary::new();
d.insert(
"Type",
PdfObject::Name(paperforge_pdf::PdfName::new("Page")),
);
d.insert("Parent", PdfObject::Reference(ObjectId::new(2, 0)));
d.insert("Contents", PdfObject::Reference(ObjectId::new(3, 0)));
d
}),
);
let mut buf = std::io::Cursor::new(Vec::new());
paperforge_pdf::Serializer::new()
.serialize(&doc, &mut buf)
.unwrap();
Extractor::extract_text(&Extractor, buf.get_ref()).unwrap()
}
#[test]
fn extracts_simple_text() {
assert_eq!(extract("Hello, world!", false), "Hello, world!\n");
}
#[test]
fn extracts_escaped_parens_and_backslashes() {
assert_eq!(extract(r"a(b)c\d", false), "a(b)c\\d\n");
}
#[test]
fn extracts_from_compressed_streams() {
assert_eq!(extract("compressed", true), "compressed\n");
}
#[test]
fn metadata_does_not_leak_into_text() {
let mut doc = paperforge_pdf::PdfDocument::new();
doc.set_catalog(ObjectId::new(1, 0));
doc.add_object(
ObjectId::new(1, 0),
PdfObject::Dictionary({
let mut d = paperforge_pdf::PdfDictionary::new();
d.insert(
"Type",
PdfObject::Name(paperforge_pdf::PdfName::new("Catalog")),
);
d.insert("Pages", PdfObject::Reference(ObjectId::new(2, 0)));
d
}),
);
doc.add_object(
ObjectId::new(2, 0),
PdfObject::Dictionary({
let mut d = paperforge_pdf::PdfDictionary::new();
d.insert(
"Type",
PdfObject::Name(paperforge_pdf::PdfName::new("Pages")),
);
d.insert("Count", PdfObject::Integer(1));
let mut kids = paperforge_pdf::PdfArray::new();
kids.push(PdfObject::Reference(ObjectId::new(3, 0)));
d.insert("Kids", PdfObject::Array(kids));
d
}),
);
doc.add_object(
ObjectId::new(3, 0),
PdfObject::Dictionary({
let mut d = paperforge_pdf::PdfDictionary::new();
d.insert(
"Type",
PdfObject::Name(paperforge_pdf::PdfName::new("Page")),
);
d.insert("Parent", PdfObject::Reference(ObjectId::new(2, 0)));
d.insert("Contents", PdfObject::Reference(ObjectId::new(4, 0)));
d
}),
);
doc.add_object(
ObjectId::new(4, 0),
PdfObject::Stream(paperforge_pdf::PdfStream::with_dict(
paperforge_pdf::PdfDictionary::new(),
b"BT (page text) Tj ET".to_vec(),
)),
);
doc.set_info(ObjectId::new(5, 0));
doc.add_object(
ObjectId::new(5, 0),
PdfObject::Dictionary({
let mut d = paperforge_pdf::PdfDictionary::new();
d.insert(
"Producer",
PdfObject::String(paperforge_pdf::PdfString::from_literal("PaperForge")),
);
d
}),
);
let mut buf = std::io::Cursor::new(Vec::new());
paperforge_pdf::Serializer::new()
.serialize(&doc, &mut buf)
.unwrap();
let text = Extractor::extract_text(&Extractor, buf.get_ref()).unwrap();
assert_eq!(text, "page text\n");
}
fn embedded_font_fixture() -> Vec<u8> {
let mut doc = paperforge_pdf::PdfDocument::new();
doc.set_catalog(ObjectId::new(1, 0));
doc.add_object(
ObjectId::new(1, 0),
PdfObject::Dictionary({
let mut d = paperforge_pdf::PdfDictionary::new();
d.insert(
"Type",
PdfObject::Name(paperforge_pdf::PdfName::new("Catalog")),
);
d.insert("Pages", PdfObject::Reference(ObjectId::new(2, 0)));
d
}),
);
doc.add_object(
ObjectId::new(2, 0),
PdfObject::Dictionary({
let mut d = paperforge_pdf::PdfDictionary::new();
d.insert(
"Type",
PdfObject::Name(paperforge_pdf::PdfName::new("Pages")),
);
d.insert("Count", PdfObject::Integer(1));
let mut kids = paperforge_pdf::PdfArray::new();
kids.push(PdfObject::Reference(ObjectId::new(4, 0)));
d.insert("Kids", PdfObject::Array(kids));
d
}),
);
let cmap = concat!(
"/CIDInit /ProcSet findresource begin\n",
"12 dict begin\nbegincmap\n",
"/CMapType 2 def\n1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n",
"2 beginbfchar\n<0048> <0048>\n<0063> <0063>\nendbfchar\n", "1 beginbfrange\n<0041> <0043> <0041>\nendbfrange\n", "1 beginbfrange\n<0061> <0062> [<00E9> <00FC>]\nendbfrange\n", "endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n",
);
doc.add_object(
ObjectId::new(5, 0),
PdfObject::Stream(paperforge_pdf::PdfStream::with_dict(
paperforge_pdf::PdfDictionary::new(),
cmap.as_bytes().to_vec(),
)),
);
let mut font = paperforge_pdf::PdfDictionary::new();
font.insert(
"Type",
PdfObject::Name(paperforge_pdf::PdfName::new("Font")),
);
font.insert(
"Subtype",
PdfObject::Name(paperforge_pdf::PdfName::new("Type0")),
);
font.insert(
"Encoding",
PdfObject::Name(paperforge_pdf::PdfName::new("Identity-H")),
);
font.insert("ToUnicode", PdfObject::Reference(ObjectId::new(5, 0)));
doc.add_object(ObjectId::new(6, 0), PdfObject::Dictionary(font));
doc.add_object(
ObjectId::new(3, 0),
PdfObject::Stream(paperforge_pdf::PdfStream::with_dict(
paperforge_pdf::PdfDictionary::new(),
b"BT /F2 12 Tf 50 700 Td <0048006300410042004300610062> Tj ET".to_vec(),
)),
);
doc.add_object(
ObjectId::new(4, 0),
PdfObject::Dictionary({
let mut d = paperforge_pdf::PdfDictionary::new();
d.insert(
"Type",
PdfObject::Name(paperforge_pdf::PdfName::new("Page")),
);
d.insert("Parent", PdfObject::Reference(ObjectId::new(2, 0)));
d.insert("Contents", PdfObject::Reference(ObjectId::new(3, 0)));
let mut resources = paperforge_pdf::PdfDictionary::new();
let mut fonts = paperforge_pdf::PdfDictionary::new();
fonts.insert("F2", PdfObject::Reference(ObjectId::new(6, 0)));
resources.insert("Font", PdfObject::Dictionary(fonts));
d.insert("Resources", PdfObject::Dictionary(resources));
d
}),
);
let mut buf = std::io::Cursor::new(Vec::new());
paperforge_pdf::Serializer::new()
.serialize(&doc, &mut buf)
.unwrap();
buf.into_inner()
}
#[test]
fn extracts_embedded_font_text_via_tounicode() {
let data = embedded_font_fixture();
let text = Extractor::extract_text(&Extractor, &data).unwrap();
assert_eq!(text, "HcABCéü\n");
}
}