use std::collections::HashMap;
use crate::content::operators::{tokenize_content_stream, ContentToken};
use crate::core::objects::{decode_utf16be, Object};
use crate::error::PdfResult;
use crate::fonts::graphics_state::GraphicsStateStack;
use crate::fonts::Font;
pub fn extract_text_from_content(data: &[u8]) -> PdfResult<String> {
extract_text_with_fonts(data, &HashMap::new())
}
fn find_string_operand(tokens: &[ContentToken], op_index: usize) -> Option<&[u8]> {
if op_index == 0 {
return None;
}
for j in (0..op_index).rev() {
match &tokens[j] {
ContentToken::Operand(Object::String(s)) => return Some(&s.bytes),
ContentToken::Operator(_) => return None,
_ => continue,
}
}
None
}
fn find_array_operand(tokens: &[ContentToken], op_index: usize) -> Option<&[Object]> {
if op_index == 0 {
return None;
}
match &tokens[op_index - 1] {
ContentToken::Operand(Object::Array(arr)) => Some(arr),
_ => None,
}
}
fn find_numeric_operand(tokens: &[ContentToken], op_index: usize, offset: usize) -> Option<f64> {
let target = op_index.checked_sub(1 + offset)?;
match &tokens[target] {
ContentToken::Operand(obj) => obj.as_f64(),
_ => None,
}
}
pub fn extract_text_with_fonts(data: &[u8], fonts: &HashMap<String, Font>) -> PdfResult<String> {
let tokens = tokenize_content_stream(data)?;
let mut text = String::new();
let mut in_text_block = false;
let mut gs = GraphicsStateStack::new();
let mut i = 0;
while i < tokens.len() {
if let ContentToken::Operator(op) = &tokens[i] {
match op.as_str() {
"BT" => {
in_text_block = true;
}
"ET" => {
in_text_block = false;
}
"q" => {
gs.save();
}
"Q" => {
gs.restore();
}
"Tf" if in_text_block => {
if let Some(font_name) = find_name_operand(&tokens, i) {
gs.current_mut().text_state.font_name = Some(font_name.to_string());
}
if let Some(size) = find_numeric_operand(&tokens, i, 0) {
gs.current_mut().text_state.font_size = size;
}
}
"Tc" if in_text_block => {
if let Some(v) = find_numeric_operand(&tokens, i, 0) {
gs.current_mut().text_state.character_spacing = v;
}
}
"Tw" if in_text_block => {
if let Some(v) = find_numeric_operand(&tokens, i, 0) {
gs.current_mut().text_state.word_spacing = v;
}
}
"TL" if in_text_block => {
if let Some(v) = find_numeric_operand(&tokens, i, 0) {
gs.current_mut().text_state.leading = v;
}
}
"Tz" if in_text_block => {
if let Some(v) = find_numeric_operand(&tokens, i, 0) {
gs.current_mut().text_state.horizontal_scaling = v;
}
}
"Ts" if in_text_block => {
if let Some(v) = find_numeric_operand(&tokens, i, 0) {
gs.current_mut().text_state.rise = v;
}
}
"Tj" | "'" | "\"" if in_text_block => {
if let Some(s) = find_string_operand(&tokens, i) {
let font = current_font(&gs, fonts);
append_decoded_string(&mut text, s, font);
}
if op == "'" || op == "\"" {
text.push('\n');
}
}
"TJ" if in_text_block => {
if let Some(arr) = find_array_operand(&tokens, i) {
let font = current_font(&gs, fonts);
for item in arr {
match item {
Object::String(s) => {
append_decoded_string(&mut text, &s.bytes, font);
}
Object::Integer(n) if *n <= -100 => {
text.push(' ');
}
Object::Real(n) if *n <= -100.0 => {
text.push(' ');
}
_ => {}
}
}
}
}
"Td" | "TD" if in_text_block => {
if let Some(ty) = find_numeric_operand(&tokens, i, 0) {
if ty.abs() > 0.5 && !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
}
}
"T*" if in_text_block && !text.is_empty() && !text.ends_with('\n') => {
text.push('\n');
}
"Tm" if in_text_block
&& !text.is_empty()
&& !text.ends_with('\n')
&& !text.ends_with(' ') =>
{
text.push(' ');
}
_ => {}
}
}
i += 1;
}
Ok(text.trim().to_string())
}
fn current_font<'a>(gs: &GraphicsStateStack, fonts: &'a HashMap<String, Font>) -> Option<&'a Font> {
gs.current()
.text_state
.font_name
.as_deref()
.and_then(|name| fonts.get(name))
}
fn append_decoded_string(text: &mut String, bytes: &[u8], font: Option<&Font>) {
if let Some(font) = font {
font.decode_bytes_into(bytes, text);
} else {
append_string_bytes(text, bytes);
}
}
fn find_name_operand(tokens: &[ContentToken], op_index: usize) -> Option<&str> {
if op_index < 2 {
return None;
}
for j in (0..op_index).rev() {
match &tokens[j] {
ContentToken::Operand(Object::Name(name)) => return Some(name.as_str()),
ContentToken::Operator(_) => return None,
_ => continue,
}
}
None
}
const PDFDOC_ENCODING: [u16; 46] = [
0x2022, 0x2020, 0x2021, 0x2026, 0x2014, 0x2013, 0x0192, 0x2044, 0x2039, 0x203A, 0x2212, 0x2030, 0x201E, 0x201C, 0x201D, 0x2018, 0x2019, 0x201A, 0x2122, 0xFB01, 0xFB02, 0x0141, 0x0152, 0x0160, 0x0178, 0x017D, 0x0131, 0x0142, 0x0153, 0x0161, 0x017E, 0xFFFD, 0x20AC, 0x00A1, 0x00A2, 0x00A3, 0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, ];
fn pdfdoc_decode(b: u8) -> char {
match b {
0x00..=0x07 | 0x11..=0x1F | 0x7F => '\u{FFFD}', 0x08 => '\u{02D8}', 0x09 => '\u{02C7}', 0x0A => '\u{02C6}', 0x0B => '\u{02D9}', 0x0C => '\u{02DD}', 0x0D => '\u{02DB}', 0x0E => '\u{02DA}', 0x0F => '\u{02DC}', 0x10 => '\u{2003}', 0x80..=0xAD => {
let cp = PDFDOC_ENCODING[(b - 0x80) as usize];
char::from_u32(cp as u32).unwrap_or('\u{FFFD}')
}
0xAE..=0xFF => char::from_u32(b as u32).unwrap_or('\u{FFFD}'),
0x20..=0x7E => b as char, }
}
fn append_string_bytes(text: &mut String, bytes: &[u8]) {
if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
if let Some(s) = decode_utf16be(&bytes[2..]) {
text.push_str(&s);
}
} else {
for &b in bytes {
text.push(pdfdoc_decode(b));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_simple_text() {
let content = b"BT /F1 12 Tf (Hello World) Tj ET";
let text = extract_text_from_content(content).unwrap();
assert_eq!(text, "Hello World");
}
#[test]
fn extract_multiple_tj() {
let content = b"BT /F1 12 Tf (Hello ) Tj (World) Tj ET";
let text = extract_text_from_content(content).unwrap();
assert_eq!(text, "Hello World");
}
#[test]
fn extract_tj_array() {
let content = b"BT /F1 12 Tf [(Hello ) -200 (World)] TJ ET";
let text = extract_text_from_content(content).unwrap();
assert_eq!(text, "Hello World");
}
#[test]
fn extract_text_with_newline() {
let content = b"BT /F1 12 Tf 0 -14 Td (Line 1) Tj 0 -14 Td (Line 2) Tj ET";
let text = extract_text_from_content(content).unwrap();
assert!(text.contains("Line 1"));
assert!(text.contains("Line 2"));
}
#[test]
fn extract_text_t_star() {
let content = b"BT /F1 12 Tf (Line 1) Tj T* (Line 2) Tj ET";
let text = extract_text_from_content(content).unwrap();
assert!(text.contains("Line 1"));
assert!(text.contains("Line 2"));
}
#[test]
fn extract_empty_content() {
let text = extract_text_from_content(b"").unwrap();
assert!(text.is_empty());
}
#[test]
fn extract_no_text_operators() {
let content = b"q 1 0 0 1 50 50 cm Q";
let text = extract_text_from_content(content).unwrap();
assert!(text.is_empty());
}
#[test]
fn extract_hex_string_text() {
let content = b"BT /F1 12 Tf <48656C6C6F> Tj ET";
let text = extract_text_from_content(content).unwrap();
assert_eq!(text, "Hello");
}
#[test]
fn extract_multiple_text_blocks() {
let content = b"BT (First) Tj ET BT (Second) Tj ET";
let text = extract_text_from_content(content).unwrap();
assert!(text.contains("First"));
assert!(text.contains("Second"));
}
#[test]
fn pdfdocencoding_0x80_to_0x9f_decoded_correctly() {
let content = b"BT /F1 12 Tf <80848592A0> Tj ET";
let text = extract_text_from_content(content).unwrap();
assert!(
text.contains('\u{2022}'),
"0x80 should be BULLET, got: {:?}",
text
);
assert!(
text.contains('\u{2014}'),
"0x84 should be EM DASH, got: {:?}",
text
);
assert!(
text.contains('\u{2013}'),
"0x85 should be EN DASH, got: {:?}",
text
);
assert!(
text.contains('\u{2122}'),
"0x92 should be TRADE MARK, got: {:?}",
text
);
assert!(
text.contains('\u{20AC}'),
"0xA0 should be EURO SIGN, got: {:?}",
text
);
}
#[test]
fn pdfdocencoding_ascii_range_unchanged() {
let content = b"BT /F1 12 Tf (ABC xyz 123) Tj ET";
let text = extract_text_from_content(content).unwrap();
assert_eq!(text, "ABC xyz 123");
}
#[test]
fn pdfdocencoding_undefined_bytes_replaced() {
let content = b"BT /F1 12 Tf <AD> Tj ET";
let text = extract_text_from_content(content).unwrap();
assert!(
text.contains('\u{00AD}'),
"0xAD should be SOFT HYPHEN, got: {:?}",
text
);
}
#[test]
fn tj_array_small_adjustment_no_space() {
let content = b"BT [(H) -10 (ello)] TJ ET";
let text = extract_text_from_content(content).unwrap();
assert_eq!(text, "Hello");
}
use crate::fonts::encoding::Encoding;
use crate::fonts::font::FontSubtype;
fn make_test_font(encoding: Encoding) -> Font {
Font::for_test("TestFont", FontSubtype::Type1, encoding)
}
fn font_map(entries: &[(&str, Font)]) -> HashMap<String, Font> {
entries
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
}
#[test]
fn extract_with_fonts_basic() {
let fonts = font_map(&[("F1", make_test_font(Encoding::win_ansi()))]);
let content = b"BT /F1 12 Tf (Hello) Tj ET";
let text = extract_text_with_fonts(content, &fonts).unwrap();
assert_eq!(text, "Hello");
}
#[test]
fn extract_with_fonts_win_ansi_special() {
let fonts = font_map(&[("F1", make_test_font(Encoding::win_ansi()))]);
let content = b"BT /F1 12 Tf <9348659494> Tj ET";
let text = extract_text_with_fonts(content, &fonts).unwrap();
assert!(text.contains('\u{201C}')); assert!(text.contains('\u{201D}')); }
#[test]
fn extract_with_fonts_fallback_no_font() {
let fonts = HashMap::new();
let content = b"BT /F1 12 Tf (Hello) Tj ET";
let text = extract_text_with_fonts(content, &fonts).unwrap();
assert_eq!(text, "Hello");
}
#[test]
fn extract_with_fonts_graphics_state() {
let fonts = font_map(&[
("F1", make_test_font(Encoding::win_ansi())),
("F2", make_test_font(Encoding::standard())),
]);
let content = b"BT /F1 12 Tf (Hello ) Tj /F2 12 Tf (World) Tj ET";
let text = extract_text_with_fonts(content, &fonts).unwrap();
assert_eq!(text, "Hello World");
}
#[test]
fn extract_with_fonts_q_q_state() {
let fonts = font_map(&[("F1", make_test_font(Encoding::win_ansi()))]);
let content = b"BT /F1 12 Tf q (Inside) Tj Q (Outside) Tj ET";
let text = extract_text_with_fonts(content, &fonts).unwrap();
assert!(text.contains("Inside"));
assert!(text.contains("Outside"));
}
#[test]
fn extract_with_fonts_tj_array() {
let fonts = font_map(&[("F1", make_test_font(Encoding::win_ansi()))]);
let content = b"BT /F1 12 Tf [(Hello ) -200 (World)] TJ ET";
let text = extract_text_with_fonts(content, &fonts).unwrap();
assert_eq!(text, "Hello World");
}
}