Files
pdf-inspector/src/extractor.rs
T
Abimael MartellandClaude Opus 4.6 a38f26abbf fix(spaces): Gate CID zero-gap heuristic on font name to fix word fusions
The CID word-separation heuristic was applying to ALL fonts based solely on
gap size, causing spurious splits in non-CID fonts ("elevat ing") and missed
spaces in CID fonts ("commitmenttoimproving"). Add is_cid_font() helper to
detect CID fonts by resource name prefix (C2_*, C0_*) and only apply the
heuristic to actual CID fonts. Remove the fragile char-count guards that
were proxying for font awareness.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:25:26 -08:00

2695 lines
99 KiB
Rust

//! Text extraction from PDF using lopdf
//!
//! This module extracts text with position information for structure detection.
use crate::glyph_names::glyph_to_char;
use crate::tounicode::FontCMaps;
use crate::PdfError;
use lopdf::{Document, Object, ObjectId};
use std::collections::HashMap;
use std::path::Path;
/// Font encoding map: maps byte codes to Unicode characters
type FontEncodingMap = HashMap<u8, char>;
/// All font encodings for a page
type PageFontEncodings = HashMap<String, FontEncodingMap>;
/// Font width information extracted from PDF font dictionaries
#[derive(Debug, Clone)]
#[allow(dead_code)]
struct FontWidthInfo {
/// Glyph widths: maps character code to width in font units
widths: HashMap<u16, u16>,
/// Default width for glyphs not in the widths table
default_width: u16,
/// Width of the space character (code 32) if known
space_width: u16,
/// Whether this is a CID font (2-byte character codes)
is_cid: bool,
/// Scale factor to convert font units to text space units.
/// For Type1/TrueType: 0.001 (widths in 1000ths of em)
/// For Type3: FontMatrix[0] (e.g., 0.00048828125 for 2048-unit grid)
units_scale: f32,
}
/// All font width info for a page, keyed by font resource name
type PageFontWidths = HashMap<String, FontWidthInfo>;
/// Resolve a PDF object reference to an array
fn resolve_array<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Vec<Object>> {
match obj {
Object::Array(arr) => Some(arr),
Object::Reference(r) => {
if let Ok(Object::Array(arr)) = doc.get_object(*r) {
Some(arr)
} else {
None
}
}
_ => None,
}
}
/// Resolve a PDF object reference to a dictionary
fn resolve_dict<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a lopdf::Dictionary> {
match obj {
Object::Dictionary(d) => Some(d),
Object::Reference(r) => doc.get_dictionary(*r).ok(),
_ => None,
}
}
/// Build font width info for all fonts on a page
fn build_font_widths(
doc: &Document,
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
) -> PageFontWidths {
let mut widths = PageFontWidths::new();
for (font_name, font_dict) in fonts {
let resource_name = String::from_utf8_lossy(font_name).to_string();
if let Some(info) = parse_font_widths(doc, font_dict) {
widths.insert(resource_name, info);
}
}
widths
}
/// Parse font widths from a font dictionary, dispatching by Subtype
fn parse_font_widths(doc: &Document, font_dict: &lopdf::Dictionary) -> Option<FontWidthInfo> {
// Get the font subtype
let subtype = font_dict.get(b"Subtype").ok()?;
let subtype_name = subtype.as_name().ok()?;
match subtype_name {
b"Type0" => parse_type0_widths(doc, font_dict),
b"Type1" | b"TrueType" | b"MMType1" | b"Type3" => parse_simple_font_widths(doc, font_dict),
_ => None,
}
}
/// Parse widths for simple fonts (Type1, TrueType, MMType1, Type3)
/// Reads FirstChar, LastChar, and Widths array.
/// For Type3 fonts, reads FontMatrix to determine the correct units_scale.
fn parse_simple_font_widths(
doc: &Document,
font_dict: &lopdf::Dictionary,
) -> Option<FontWidthInfo> {
let first_char = font_dict.get(b"FirstChar").ok().and_then(|o| match o {
Object::Integer(n) => Some(*n as u16),
Object::Reference(r) => doc.get_object(*r).ok().and_then(|o| {
if let Object::Integer(n) = o {
Some(*n as u16)
} else {
None
}
}),
_ => None,
})?;
let last_char = font_dict.get(b"LastChar").ok().and_then(|o| match o {
Object::Integer(n) => Some(*n as u16),
Object::Reference(r) => doc.get_object(*r).ok().and_then(|o| {
if let Object::Integer(n) = o {
Some(*n as u16)
} else {
None
}
}),
_ => None,
})?;
let widths_obj = font_dict.get(b"Widths").ok()?;
let widths_array = resolve_array(doc, widths_obj)?;
let mut widths = HashMap::new();
let mut space_width: u16 = 0;
for (i, w_obj) in widths_array.iter().enumerate() {
let code = first_char + i as u16;
if code > last_char {
break;
}
let w = match w_obj {
Object::Integer(n) => *n as u16,
Object::Real(n) => *n as u16,
Object::Reference(r) => {
if let Ok(obj) = doc.get_object(*r) {
match obj {
Object::Integer(n) => *n as u16,
Object::Real(n) => *n as u16,
_ => continue,
}
} else {
continue;
}
}
_ => continue,
};
if code == 32 {
space_width = w;
}
widths.insert(code, w);
}
// If space width wasn't found in the table, use a reasonable default
if space_width == 0 {
space_width = 250;
}
// Determine units_scale: for Type3 fonts, use FontMatrix[0]; for others, use 1/1000
let units_scale = if let Ok(fm) = font_dict.get(b"FontMatrix") {
if let Some(arr) = resolve_array(doc, fm) {
if !arr.is_empty() {
match &arr[0] {
Object::Real(r) => r.abs(),
Object::Integer(i) => (*i as f32).abs(),
_ => 0.001,
}
} else {
0.001
}
} else {
0.001
}
} else {
0.001 // Standard 1000-unit system
};
Some(FontWidthInfo {
widths,
default_width: 0,
space_width,
is_cid: false,
units_scale,
})
}
/// Parse widths for Type0 (composite/CID) fonts
/// Reads DescendantFonts → CIDFont → W array and DW value
fn parse_type0_widths(doc: &Document, font_dict: &lopdf::Dictionary) -> Option<FontWidthInfo> {
let desc_fonts_obj = font_dict.get(b"DescendantFonts").ok()?;
let desc_fonts = resolve_array(doc, desc_fonts_obj)?;
if desc_fonts.is_empty() {
return None;
}
// Get the first descendant font dictionary
let cid_font_dict = resolve_dict(doc, &desc_fonts[0])?;
// Get DW (default width)
let default_width = cid_font_dict
.get(b"DW")
.ok()
.and_then(|o| match o {
Object::Integer(n) => Some(*n as u16),
Object::Real(n) => Some(*n as u16),
_ => None,
})
.unwrap_or(1000);
let mut widths = HashMap::new();
// Parse W array if present
if let Ok(w_obj) = cid_font_dict.get(b"W") {
if let Some(w_array) = resolve_array(doc, w_obj) {
parse_cid_w_array(doc, w_array, &mut widths);
}
}
// Try to determine space width (CID 32 or CID 3 are common for space)
let space_width = widths
.get(&32)
.or_else(|| widths.get(&3))
.copied()
.unwrap_or(if default_width > 0 {
default_width / 4
} else {
250
});
Some(FontWidthInfo {
widths,
default_width,
space_width,
is_cid: true,
units_scale: 0.001, // CID fonts use standard 1000-unit system
})
}
/// Parse a CID W array into widths map
/// Format: [c [w1 w2 ...]] (consecutive from c) or [c_first c_last w] (range with same width)
fn parse_cid_w_array(doc: &Document, w_array: &[Object], widths: &mut HashMap<u16, u16>) {
let mut i = 0;
while i < w_array.len() {
let start_cid = match &w_array[i] {
Object::Integer(n) => *n as u16,
Object::Real(n) => *n as u16,
_ => {
i += 1;
continue;
}
};
i += 1;
if i >= w_array.len() {
break;
}
// Check if next element is an array (consecutive widths) or integer (range)
match &w_array[i] {
Object::Array(arr) => {
// [c [w1 w2 ...]] — consecutive widths starting at c
for (j, w_obj) in arr.iter().enumerate() {
let w = match w_obj {
Object::Integer(n) => *n as u16,
Object::Real(n) => *n as u16,
_ => continue,
};
widths.insert(start_cid + j as u16, w);
}
i += 1;
}
Object::Reference(r) => {
// Could be a reference to an array
if let Ok(Object::Array(arr)) = doc.get_object(*r) {
for (j, w_obj) in arr.iter().enumerate() {
let w = match w_obj {
Object::Integer(n) => *n as u16,
Object::Real(n) => *n as u16,
_ => continue,
};
widths.insert(start_cid + j as u16, w);
}
i += 1;
} else {
// Treat as c_first c_last w
i += 1; // skip this
}
}
Object::Integer(end_cid) => {
// [c_first c_last w] — range with uniform width
let end = *end_cid as u16;
i += 1;
if i >= w_array.len() {
break;
}
let w = match &w_array[i] {
Object::Integer(n) => *n as u16,
Object::Real(n) => *n as u16,
_ => {
i += 1;
continue;
}
};
for cid in start_cid..=end {
widths.insert(cid, w);
}
i += 1;
}
Object::Real(end_cid) => {
let end = *end_cid as u16;
i += 1;
if i >= w_array.len() {
break;
}
let w = match &w_array[i] {
Object::Integer(n) => *n as u16,
Object::Real(n) => *n as u16,
_ => {
i += 1;
continue;
}
};
for cid in start_cid..=end {
widths.insert(cid, w);
}
i += 1;
}
_ => {
i += 1;
}
}
}
}
/// Compute the width of a string in text space units,
/// given raw bytes and font width info.
/// Returns width in text space units (font_units * units_scale * font_size).
fn compute_string_width_ts(bytes: &[u8], font_info: &FontWidthInfo, font_size: f32) -> f32 {
let mut total: f32 = 0.0;
if font_info.is_cid {
// 2-byte (big-endian) character codes
let mut j = 0;
while j + 1 < bytes.len() {
let cid = u16::from_be_bytes([bytes[j], bytes[j + 1]]);
let w = font_info
.widths
.get(&cid)
.copied()
.unwrap_or(font_info.default_width);
total += w as f32;
j += 2;
}
} else {
// 1-byte character codes
for &b in bytes {
let code = b as u16;
let w = font_info
.widths
.get(&code)
.copied()
.unwrap_or(font_info.default_width);
total += w as f32;
}
}
// Convert from font units to text space using the font's scale factor
total * font_info.units_scale * font_size
}
/// Extract raw bytes from a PDF operand (String object)
fn get_operand_bytes(obj: &Object) -> Option<&[u8]> {
if let Object::String(bytes, _) = obj {
Some(bytes)
} else {
None
}
}
/// Build encoding maps for all fonts on a page
fn build_font_encodings(
doc: &Document,
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
) -> PageFontEncodings {
let mut encodings = PageFontEncodings::new();
for (font_name, font_dict) in fonts {
let resource_name = String::from_utf8_lossy(font_name).to_string();
if let Some(encoding_map) = parse_font_encoding(doc, font_dict) {
encodings.insert(resource_name, encoding_map);
}
}
encodings
}
/// Parse font encoding from a font dictionary
fn parse_font_encoding(doc: &Document, font_dict: &lopdf::Dictionary) -> Option<FontEncodingMap> {
let encoding_obj = font_dict.get(b"Encoding").ok()?;
// Encoding can be a name or a dictionary
match encoding_obj {
Object::Name(_name) => {
// Standard encoding name (e.g., MacRomanEncoding, WinAnsiEncoding)
// For standard encodings, we can use the standard tables
// But we still need to check for Differences
None // Let lopdf handle standard encodings
}
Object::Reference(obj_ref) => {
// Reference to encoding dictionary
if let Ok(enc_dict) = doc.get_dictionary(*obj_ref) {
parse_encoding_dictionary(doc, enc_dict)
} else {
None
}
}
Object::Dictionary(enc_dict) => parse_encoding_dictionary(doc, enc_dict),
_ => None,
}
}
/// Parse an encoding dictionary with Differences array
fn parse_encoding_dictionary(
doc: &Document,
enc_dict: &lopdf::Dictionary,
) -> Option<FontEncodingMap> {
let differences = enc_dict.get(b"Differences").ok()?;
let diff_array = match differences {
Object::Array(arr) => arr.clone(),
Object::Reference(obj_ref) => {
if let Ok(Object::Array(arr)) = doc.get_object(*obj_ref) {
arr.clone()
} else {
return None;
}
}
_ => return None,
};
let mut encoding_map = FontEncodingMap::new();
let mut current_code: u8 = 0;
for item in diff_array {
match item {
Object::Integer(n) => {
// This sets the starting code for subsequent glyph names
current_code = n as u8;
}
Object::Name(name) => {
// Map current code to glyph name -> Unicode
let glyph_name = String::from_utf8_lossy(&name).to_string();
if let Some(ch) = glyph_to_char(&glyph_name) {
encoding_map.insert(current_code, ch);
}
current_code = current_code.wrapping_add(1);
}
_ => {}
}
}
if encoding_map.is_empty() {
None
} else {
Some(encoding_map)
}
}
/// Type of content item
#[derive(Debug, Clone, PartialEq, Default)]
pub enum ItemType {
/// Regular text content
#[default]
Text,
/// Image placeholder
Image,
/// Hyperlink (with URL)
Link(String),
}
/// A text item with position information
#[derive(Debug, Clone)]
pub struct TextItem {
/// The text content
pub text: String,
/// X position on page
pub x: f32,
/// Y position on page (PDF coordinates, origin at bottom-left)
pub y: f32,
/// Width of text
pub width: f32,
/// Height (approximated from font size)
pub height: f32,
/// Font name
pub font: String,
/// Font size
pub font_size: f32,
/// Page number (1-indexed)
pub page: u32,
/// Whether the font is bold
pub is_bold: bool,
/// Whether the font is italic
pub is_italic: bool,
/// Type of item (text, image, link)
pub item_type: ItemType,
}
/// A line of text (grouped text items)
#[derive(Debug, Clone)]
pub struct TextLine {
pub items: Vec<TextItem>,
pub y: f32,
pub page: u32,
}
impl TextLine {
pub fn text(&self) -> String {
self.text_with_formatting(false, false)
}
/// Get text with optional bold/italic markdown formatting
pub fn text_with_formatting(&self, format_bold: bool, format_italic: bool) -> String {
if !format_bold && !format_italic {
return self.text_plain();
}
let mut result = String::new();
let mut current_bold = false;
let mut current_italic = false;
for (i, item) in self.items.iter().enumerate() {
let text = item.text.as_str();
let text_trimmed = text.trim();
// Skip empty items
if text_trimmed.is_empty() {
continue;
}
// Determine spacing
let needs_space = if i == 0 || result.is_empty() {
false
} else {
let prev_item = &self.items[i - 1];
self.needs_space_between(prev_item, item, &result)
};
// Preserve leading whitespace from the item text.
// Items like " means any person" have a leading space that indicates
// a word boundary. needs_space_between returns false for these (because
// space_already_exists), but we still need to emit the space since
// we push text_trimmed below (which strips it).
let has_leading_space = text.starts_with(' ');
// Check for style changes
let item_bold = format_bold && item.is_bold;
let item_italic = format_italic && item.is_italic;
// Close previous styles if they change
if current_italic && !item_italic {
result.push('*');
current_italic = false;
}
if current_bold && !item_bold {
result.push_str("**");
current_bold = false;
}
// Add space: either from spacing logic or preserved from item text
if needs_space || (has_leading_space && !result.is_empty() && !result.ends_with(' ')) {
result.push(' ');
}
// Open new styles
if item_bold && !current_bold {
result.push_str("**");
current_bold = true;
}
if item_italic && !current_italic {
result.push('*');
current_italic = true;
}
result.push_str(text_trimmed);
}
// Close any remaining open styles
if current_italic {
result.push('*');
}
if current_bold {
result.push_str("**");
}
result
}
/// Get plain text without formatting
fn text_plain(&self) -> String {
let mut result = String::new();
for (i, item) in self.items.iter().enumerate() {
let text = item.text.as_str();
if i == 0 {
result.push_str(text);
} else {
let prev_item = &self.items[i - 1];
if self.needs_space_between(prev_item, item, &result) {
result.push(' ');
}
result.push_str(text);
}
}
result
}
/// Determine if a space is needed between two items
fn needs_space_between(&self, prev_item: &TextItem, item: &TextItem, result: &str) -> bool {
let text = item.text.as_str();
// Don't add space before/after hyphens for hyphenated words
let prev_ends_with_hyphen = result.ends_with('-');
let curr_is_hyphen = text.trim() == "-";
let curr_starts_with_hyphen = text.starts_with('-');
// Detect subscript/superscript: smaller font size and/or Y offset
let font_ratio = item.font_size / prev_item.font_size;
let reverse_font_ratio = prev_item.font_size / item.font_size;
let y_diff = (item.y - prev_item.y).abs();
let is_sub_super = font_ratio < 0.85 && y_diff > 1.0;
let was_sub_super = reverse_font_ratio < 0.85 && y_diff > 1.0;
// Use position-based spacing detection
let should_join = should_join_items(prev_item, item);
// Check if space already exists
let prev_ends_with_space = result.ends_with(' ');
let curr_starts_with_space = text.starts_with(' ');
let space_already_exists = prev_ends_with_space || curr_starts_with_space;
// Add space unless one of these conditions applies
!(prev_ends_with_hyphen
|| curr_is_hyphen
|| curr_starts_with_hyphen
|| is_sub_super
|| was_sub_super
|| should_join
|| space_already_exists)
}
}
/// Determine if two adjacent text items should be joined without a space
/// based on their physical positions on the page and character case.
/// Uses a hybrid approach: position-based with case-aware thresholds.
/// CID fonts emit one word per text operator with gaps ≈ 0 between words.
/// Non-CID (Type1/TrueType) fonts emit phrases or fragments.
fn is_cid_font(font: &str) -> bool {
font.starts_with("C2_") || font.starts_with("C0_")
}
fn should_join_items(prev_item: &TextItem, curr_item: &TextItem) -> bool {
// If either text explicitly has leading/trailing spaces, respect them
if prev_item.text.ends_with(' ') || curr_item.text.starts_with(' ') {
return false;
}
// Get the last character of previous and first character of current
let prev_last = prev_item.text.trim_end().chars().last();
let curr_first = curr_item.text.trim_start().chars().next();
// Always join if current starts with punctuation that typically follows without space
// e.g., "www" + ".com" → "www.com", not "www .com"
if let Some(c) = curr_first {
if matches!(c, '.' | ',' | ';' | '!' | '?' | ')' | ']' | '}' | '\'') {
return true;
}
}
// After colons, add space if followed by alphanumeric (typical label:value pattern)
// e.g., "Clave:" + "T9N2I6" → "Clave: T9N2I6"
if let (Some(p), Some(c)) = (prev_last, curr_first) {
if p == ':' && c.is_alphanumeric() {
return false;
}
}
// When we have accurate width from font metrics, use a tight threshold
if prev_item.width > 0.0 {
let prev_end_x = prev_item.x + prev_item.width;
let gap = curr_item.x - prev_end_x;
let font_size = prev_item.font_size;
// Never join across column-scale gaps
if gap > font_size * 3.0 {
return false;
}
// CID fonts (C2_*, C0_*) emit one word per text operator with gaps ≈ 0
// between words. Detect these and add spaces. Only applies to CID fonts —
// non-CID fonts (Type1/TrueType) emit phrases or fragments with small gaps
// from positioning imprecision and should NOT trigger this.
// Skip for CJK text — CJK languages don't use spaces between words.
let prev_chars = prev_item.text.trim().chars().count();
let curr_chars = curr_item.text.trim().chars().count();
let prev_last_char = prev_item.text.trim().chars().last();
let curr_first_char = curr_item.text.trim().chars().next();
let is_cjk =
prev_last_char.is_some_and(is_cjk_char) || curr_first_char.is_some_and(is_cjk_char);
if !is_cjk && gap >= 0.0 && gap < font_size * 0.01 && is_cid_font(&prev_item.font) {
let prev_word_count = prev_item.text.split_whitespace().count();
if prev_word_count >= 3 {
// Multi-word phrase from a line-level CID operator — likely mid-word boundary
return gap < font_size * 0.15;
}
// CID font: each text operator is a separate word. Always add space.
return false;
}
// Numeric continuity: digits, commas, periods, and percent signs that
// are positioned close together are almost always a single number.
// e.g., "34,20" + "8" → "34,208", "+13." + "0" + "%" → "+13.0%"
// Use a generous threshold since word spaces in numbers are rare.
if let (Some(p), Some(c)) = (prev_last, curr_first) {
let prev_is_numeric = p.is_ascii_digit() || p == ',' || p == '.';
let curr_is_numeric = c.is_ascii_digit() || c == '%' || c == '.';
if prev_is_numeric && curr_is_numeric {
return gap < font_size * 0.3;
}
// Sign characters (+/-) followed by digits
if (p == '+' || p == '-') && c.is_ascii_digit() {
return gap < font_size * 0.3;
}
}
// Single-character fragment joined to a multi-character item: use a
// more generous threshold to rejoin split words like "b" + "illion"
// or "C" + "ultural".
if (prev_chars == 1) != (curr_chars == 1) {
return gap < font_size * 0.25;
}
// Both single-char: per-glyph positioning. For numeric characters
// (digits within "100,000"), use generous threshold. For alphabetic
// per-glyph text ("E"+"f"+"f"...), use normal threshold to preserve
// word spaces between letters like "e" (end of word) + "D" (start).
if prev_chars == 1 && curr_chars == 1 {
if let (Some(p), Some(c)) = (prev_last, curr_first) {
let p_numeric = p.is_ascii_digit() || matches!(p, ',' | '.' | '%' | '+' | '-');
let c_numeric = c.is_ascii_digit() || matches!(c, ',' | '.' | '%');
if p_numeric && c_numeric {
return gap < font_size * 0.25;
}
}
}
// With accurate widths, a gap < 15% of font size means glyphs are
// adjacent (same word). Anything larger is a deliberate space.
// For multi-char items with a lowercase→lowercase junction, use a
// slightly wider threshold (0.18) to avoid mid-word space injection
// with imprecise CID font metrics (e.g. "enterta"+"inment").
// All-caps or mixed-case junctions keep the tighter 0.15 threshold
// to preserve word boundaries (e.g. "LCOE"+"WITH").
if prev_item.text.trim().chars().count() >= 2 && curr_item.text.trim().chars().count() >= 2
{
let prev_ends_lower = prev_item
.text
.trim()
.chars()
.last()
.map_or(false, |c| c.is_lowercase());
let curr_starts_lower = curr_item
.text
.trim()
.chars()
.next()
.map_or(false, |c| c.is_lowercase());
if prev_ends_lower && curr_starts_lower {
return gap < font_size * 0.18;
}
}
return gap < font_size * 0.15;
}
// Fallback: estimate width from font size heuristics
let char_width = prev_item.font_size * 0.45;
let prev_text_len = prev_item.text.chars().count() as f32;
let estimated_prev_width = prev_text_len * char_width;
// Calculate expected end position of previous item
let prev_end_x = prev_item.x + estimated_prev_width;
// Calculate gap between items
let gap = curr_item.x - prev_end_x;
// Never join across column-scale gaps (fallback path)
if gap > char_width * 6.0 {
return false;
}
// CJK text: always join adjacent items — CJK languages don't use spaces between words.
// The Latin case-based heuristics below would incorrectly insert spaces within CJK words.
let is_cjk = prev_last.is_some_and(is_cjk_char) || curr_first.is_some_and(is_cjk_char);
if is_cjk {
return gap < char_width * 0.8;
}
// Use different thresholds based on character case
// Same-case sequences (ALL CAPS or all lowercase) are more likely to be
// word fragments that got split. Mixed case suggests word boundaries.
match (prev_last, curr_first) {
(Some(p), Some(c)) if p.is_alphabetic() && c.is_alphabetic() => {
let same_case =
(p.is_uppercase() && c.is_uppercase()) || (p.is_lowercase() && c.is_lowercase());
if same_case {
// Same case: use generous threshold (likely same word fragment)
// e.g., "CONST" + "ANCIA" → "CONSTANCIA"
gap < char_width * 0.8
} else if p.is_lowercase() && c.is_uppercase() {
// Lowercase to uppercase transition (e.g., "presente" → "CONSTANCIA")
// This is typically a word boundary. In Spanish/English, words don't
// transition from lowercase to uppercase mid-word.
// Always add a space for this case, regardless of position.
false
} else {
// Uppercase to lowercase (e.g., "REGISTRO" → "para")
// Use stricter threshold (likely word boundary)
gap < char_width * 0.3
}
}
_ => {
// Non-alphabetic: use moderate threshold
gap < char_width * 0.5
}
}
}
/// Extract text from PDF file as plain string
pub fn extract_text<P: AsRef<Path>>(path: P) -> Result<String, PdfError> {
let doc = Document::load(path)?;
extract_text_from_doc(&doc)
}
/// Extract text from PDF memory buffer
pub fn extract_text_mem(buffer: &[u8]) -> Result<String, PdfError> {
let doc = Document::load_mem(buffer)?;
extract_text_from_doc(&doc)
}
/// Extract text from loaded document
fn extract_text_from_doc(doc: &Document) -> Result<String, PdfError> {
let pages = doc.get_pages();
let page_nums: Vec<u32> = pages.keys().cloned().collect();
doc.extract_text(&page_nums)
.map_err(|e| PdfError::Parse(e.to_string()))
}
/// Extract text with position information from PDF file
pub fn extract_text_with_positions<P: AsRef<Path>>(path: P) -> Result<Vec<TextItem>, PdfError> {
// Read the raw PDF bytes for ToUnicode extraction
let pdf_bytes = std::fs::read(path.as_ref())?;
let font_cmaps = FontCMaps::from_pdf_bytes(&pdf_bytes);
let doc = Document::load_mem(&pdf_bytes)?;
extract_positioned_text_from_doc(&doc, &font_cmaps)
}
/// Extract text with positions from memory buffer
pub fn extract_text_with_positions_mem(buffer: &[u8]) -> Result<Vec<TextItem>, PdfError> {
// Extract ToUnicode CMaps from raw PDF bytes
let font_cmaps = FontCMaps::from_pdf_bytes(buffer);
let doc = Document::load_mem(buffer)?;
extract_positioned_text_from_doc(&doc, &font_cmaps)
}
/// Extract positioned text from loaded document
fn extract_positioned_text_from_doc(
doc: &Document,
font_cmaps: &FontCMaps,
) -> Result<Vec<TextItem>, PdfError> {
let pages = doc.get_pages();
let mut all_items = Vec::new();
for (page_num, &page_id) in pages.iter() {
let items = extract_page_text_items(doc, page_id, *page_num, font_cmaps)?;
all_items.extend(items);
// Extract hyperlinks from page annotations
let links = extract_page_links(doc, page_id, *page_num);
all_items.extend(links);
}
Ok(all_items)
}
/// Multiply two 2D transformation matrices
/// Matrix format: [a, b, c, d, e, f] representing:
/// | a b 0 |
/// | c d 0 |
/// | e f 1 |
fn multiply_matrices(m1: &[f32; 6], m2: &[f32; 6]) -> [f32; 6] {
[
m1[0] * m2[0] + m1[1] * m2[2],
m1[0] * m2[1] + m1[1] * m2[3],
m1[2] * m2[0] + m1[3] * m2[2],
m1[2] * m2[1] + m1[3] * m2[3],
m1[4] * m2[0] + m1[5] * m2[2] + m2[4],
m1[4] * m2[1] + m1[5] * m2[3] + m2[5],
]
}
/// Extract text items from a single page
fn extract_page_text_items(
doc: &Document,
page_id: ObjectId,
page_num: u32,
font_cmaps: &FontCMaps,
) -> Result<Vec<TextItem>, PdfError> {
use lopdf::content::Content;
let mut items = Vec::new();
// Get fonts for encoding
let fonts = doc.get_page_fonts(page_id).unwrap_or_default();
// Build font encoding maps from Differences arrays
let font_encodings = build_font_encodings(doc, &fonts);
// Build font width info for accurate text positioning
let font_widths = build_font_widths(doc, &fonts);
// Build maps of font resource names to their base font names and ToUnicode object refs
let mut font_base_names: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut font_tounicode_refs: std::collections::HashMap<String, u32> =
std::collections::HashMap::new();
for (font_name, font_dict) in &fonts {
let resource_name = String::from_utf8_lossy(font_name).to_string();
if let Ok(base_font) = font_dict.get(b"BaseFont") {
if let Ok(name) = base_font.as_name() {
let base_name = String::from_utf8_lossy(name).to_string();
font_base_names.insert(resource_name.clone(), base_name);
}
}
// Track ToUnicode object reference
if let Ok(tounicode) = font_dict.get(b"ToUnicode") {
if let Ok(obj_ref) = tounicode.as_reference() {
font_tounicode_refs.insert(resource_name, obj_ref.0);
}
}
}
// Get XObjects (images) from page resources
let xobjects = get_page_xobjects(doc, page_id);
// Get content
let content_data = doc
.get_page_content(page_id)
.map_err(|e| PdfError::Parse(e.to_string()))?;
let content = Content::decode(&content_data).map_err(|e| PdfError::Parse(e.to_string()))?;
// Graphics state tracking
let mut ctm = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0]; // Current Transformation Matrix
let mut ctm_stack: Vec<[f32; 6]> = Vec::new();
// Text state tracking
let mut current_font = String::new();
let mut current_font_size: f32 = 12.0;
let mut text_matrix = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0];
let mut line_matrix = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0];
let mut in_text_block = false;
for op in &content.operations {
match op.operator.as_str() {
"q" => {
// Save graphics state
ctm_stack.push(ctm);
}
"Q" => {
// Restore graphics state
if let Some(saved) = ctm_stack.pop() {
ctm = saved;
}
}
"cm" => {
// Concatenate matrix to CTM
if op.operands.len() >= 6 {
let new_matrix = [
get_number(&op.operands[0]).unwrap_or(1.0),
get_number(&op.operands[1]).unwrap_or(0.0),
get_number(&op.operands[2]).unwrap_or(0.0),
get_number(&op.operands[3]).unwrap_or(1.0),
get_number(&op.operands[4]).unwrap_or(0.0),
get_number(&op.operands[5]).unwrap_or(0.0),
];
ctm = multiply_matrices(&new_matrix, &ctm);
}
}
"BT" => {
// Begin text block
in_text_block = true;
text_matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
line_matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
}
"ET" => {
// End text block
in_text_block = false;
}
"Tf" => {
// Set font and size
if op.operands.len() >= 2 {
if let Ok(name) = op.operands[0].as_name() {
current_font = String::from_utf8_lossy(name).to_string();
}
if let Ok(size) = op.operands[1].as_f32() {
current_font_size = size;
} else if let Ok(size) = op.operands[1].as_i64() {
current_font_size = size as f32;
}
}
}
"Td" | "TD" => {
// Move text position
if op.operands.len() >= 2 {
let tx = get_number(&op.operands[0]).unwrap_or(0.0);
let ty = get_number(&op.operands[1]).unwrap_or(0.0);
line_matrix[4] += tx;
line_matrix[5] += ty;
text_matrix = line_matrix;
}
}
"Tm" => {
// Set text matrix
if op.operands.len() >= 6 {
for (i, operand) in op.operands.iter().take(6).enumerate() {
text_matrix[i] =
get_number(operand).unwrap_or(if i == 0 || i == 3 { 1.0 } else { 0.0 });
}
line_matrix = text_matrix;
}
}
"T*" => {
// Move to start of next line
line_matrix[5] -= current_font_size * 1.2; // Approximate line height
text_matrix = line_matrix;
}
"Tj" => {
// Show text string
if in_text_block && !op.operands.is_empty() {
if let Some(text) = extract_text_from_operand(
&op.operands[0],
doc,
&fonts,
&current_font,
font_cmaps,
&font_base_names,
&font_tounicode_refs,
&font_encodings,
) {
if !text.trim().is_empty() {
let rendered_size =
effective_font_size(current_font_size, &text_matrix);
// Transform position through CTM
let combined = multiply_matrices(&text_matrix, &ctm);
let (x, y) = (combined[4], combined[5]);
// Compute width from font widths if available
let width = if let Some(font_info) = font_widths.get(&current_font) {
if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) {
let w_ts = compute_string_width_ts(
raw_bytes,
font_info,
current_font_size,
);
// Advance text matrix by string width
text_matrix[4] += w_ts * text_matrix[0];
text_matrix[5] += w_ts * text_matrix[1];
// Transform width through text matrix and CTM
(w_ts * (text_matrix[0] * ctm[0] + text_matrix[1] * ctm[2]))
.abs()
} else {
0.0
}
} else {
0.0
};
// Detect bold/italic from font name
let base_font = font_base_names
.get(&current_font)
.map(|s| s.as_str())
.unwrap_or(&current_font);
items.push(TextItem {
text: expand_ligatures(&text),
x,
y,
width,
height: rendered_size,
font: current_font.clone(),
font_size: rendered_size,
page: page_num,
is_bold: is_bold_font(base_font),
is_italic: is_italic_font(base_font),
item_type: ItemType::Text,
});
}
}
}
}
"TJ" => {
// Show text with positioning
if in_text_block && !op.operands.is_empty() {
if let Ok(array) = op.operands[0].as_array() {
let font_info = font_widths.get(&current_font);
// Compute space threshold based on font metrics when available
let space_threshold = if let Some(font_info) = font_info {
// Use 40% of the font's space width (in thousandths of text space)
let space_em = font_info.space_width as f32 * font_info.units_scale;
let threshold = space_em * 1000.0 * 0.4;
// Clamp to reasonable range: at least 80, at most 200
threshold.clamp(80.0, 200.0)
} else {
120.0 // fallback threshold
};
let mut combined_text = String::new();
let mut total_width_ts: f32 = 0.0;
for element in array {
match element {
Object::Integer(n) => {
let n_val = *n as f32;
// Track displacement for total width
total_width_ts += -n_val / 1000.0 * current_font_size;
if n_val < -space_threshold
&& !combined_text.is_empty()
&& !combined_text.ends_with(' ')
{
combined_text.push(' ');
}
continue;
}
Object::Real(n) => {
total_width_ts += -(*n) / 1000.0 * current_font_size;
if *n < -space_threshold
&& !combined_text.is_empty()
&& !combined_text.ends_with(' ')
{
combined_text.push(' ');
}
continue;
}
_ => {}
}
// Compute string width for total
if let Some(fi) = font_info {
if let Some(raw_bytes) = get_operand_bytes(element) {
total_width_ts +=
compute_string_width_ts(raw_bytes, fi, current_font_size);
}
}
if let Some(text) = extract_text_from_operand(
element,
doc,
&fonts,
&current_font,
font_cmaps,
&font_base_names,
&font_tounicode_refs,
&font_encodings,
) {
combined_text.push_str(&text);
}
}
if !combined_text.trim().is_empty() {
let rendered_size =
effective_font_size(current_font_size, &text_matrix);
let combined = multiply_matrices(&text_matrix, &ctm);
let (x, y) = (combined[4], combined[5]);
// Compute accurate width if font widths available
let width = if font_info.is_some() {
(total_width_ts
* (text_matrix[0] * ctm[0] + text_matrix[1] * ctm[2]))
.abs()
} else {
0.0
};
let base_font = font_base_names
.get(&current_font)
.map(|s| s.as_str())
.unwrap_or(&current_font);
items.push(TextItem {
text: expand_ligatures(&combined_text),
x,
y,
width,
height: rendered_size,
font: current_font.clone(),
font_size: rendered_size,
page: page_num,
is_bold: is_bold_font(base_font),
is_italic: is_italic_font(base_font),
item_type: ItemType::Text,
});
// Advance text matrix by total width
if font_info.is_some() {
text_matrix[4] += total_width_ts * text_matrix[0];
text_matrix[5] += total_width_ts * text_matrix[1];
}
}
}
}
}
"'" => {
// Move to next line and show text
line_matrix[5] -= current_font_size * 1.2;
text_matrix = line_matrix;
if !op.operands.is_empty() {
if let Some(text) = extract_text_from_operand(
&op.operands[0],
doc,
&fonts,
&current_font,
font_cmaps,
&font_base_names,
&font_tounicode_refs,
&font_encodings,
) {
if !text.trim().is_empty() {
let rendered_size =
effective_font_size(current_font_size, &text_matrix);
// Transform position through CTM
let combined = multiply_matrices(&text_matrix, &ctm);
let (x, y) = (combined[4], combined[5]);
// Detect bold/italic from font name
let base_font = font_base_names
.get(&current_font)
.map(|s| s.as_str())
.unwrap_or(&current_font);
items.push(TextItem {
text: expand_ligatures(&text),
x,
y,
width: 0.0,
height: rendered_size,
font: current_font.clone(),
font_size: rendered_size,
page: page_num,
is_bold: is_bold_font(base_font),
is_italic: is_italic_font(base_font),
item_type: ItemType::Text,
});
}
}
}
}
"Do" => {
// XObject invocation - could be an image or form
if !op.operands.is_empty() {
if let Ok(name) = op.operands[0].as_name() {
let xobj_name = String::from_utf8_lossy(name).to_string();
if let Some(xobj_type) = xobjects.get(&xobj_name) {
match xobj_type {
XObjectType::Image => {
// Get position from CTM
let (x, y) = (ctm[4], ctm[5]);
// Get dimensions from CTM scale factors
let width = ctm[0].abs();
let height = ctm[3].abs();
items.push(TextItem {
text: format!("[Image: {}]", xobj_name),
x,
y,
width,
height,
font: String::new(),
font_size: 0.0,
page: page_num,
is_bold: false,
is_italic: false,
item_type: ItemType::Image,
});
}
XObjectType::Form(form_id) => {
// Extract text from Form XObject
let form_items = extract_form_xobject_text(
doc, *form_id, page_num, font_cmaps, &ctm,
);
items.extend(form_items);
}
}
}
}
}
}
_ => {}
}
}
Ok(items)
}
/// Helper to get f32 from Object
fn get_number(obj: &Object) -> Option<f32> {
match obj {
Object::Integer(i) => Some(*i as f32),
Object::Real(r) => Some(*r),
_ => None,
}
}
/// Get XObject names that are images from page resources
/// XObject info - either Image or Form
#[derive(Debug)]
enum XObjectType {
Image,
Form(ObjectId),
}
/// Get XObjects from page resources, categorized by type
fn get_page_xobjects(
doc: &Document,
page_id: ObjectId,
) -> std::collections::HashMap<String, XObjectType> {
let mut xobject_types = std::collections::HashMap::new();
// Try to get the page dictionary
if let Ok(page_dict) = doc.get_dictionary(page_id) {
// Get Resources dictionary
let resources = if let Ok(res_ref) = page_dict.get(b"Resources") {
if let Ok(obj_ref) = res_ref.as_reference() {
doc.get_dictionary(obj_ref).ok()
} else {
res_ref.as_dict().ok()
}
} else {
None
};
if let Some(resources) = resources {
// Get XObject dictionary from Resources
if let Ok(xobjects_ref) = resources.get(b"XObject") {
let xobjects = if let Ok(obj_ref) = xobjects_ref.as_reference() {
doc.get_dictionary(obj_ref).ok()
} else {
xobjects_ref.as_dict().ok()
};
if let Some(xobjects) = xobjects {
for (name, value) in xobjects.iter() {
let name_str = String::from_utf8_lossy(name).to_string();
// Check XObject subtype
if let Ok(obj_ref) = value.as_reference() {
if let Ok(Object::Stream(stream)) = doc.get_object(obj_ref) {
if let Ok(subtype) = stream.dict.get(b"Subtype") {
if let Ok(subtype_name) = subtype.as_name() {
if subtype_name == b"Image" {
xobject_types.insert(name_str, XObjectType::Image);
} else if subtype_name == b"Form" {
xobject_types
.insert(name_str, XObjectType::Form(obj_ref));
}
}
}
}
}
}
}
}
}
}
xobject_types
}
/// Extract text items from a Form XObject
fn extract_form_xobject_text(
doc: &Document,
form_id: ObjectId,
page_num: u32,
font_cmaps: &FontCMaps,
parent_ctm: &[f32; 6],
) -> Vec<TextItem> {
use lopdf::content::Content;
let mut items = Vec::new();
// Get the Form XObject stream
let Ok(Object::Stream(stream)) = doc.get_object(form_id) else {
return items;
};
// Decompress the content stream
let Ok(content_data) = stream.decompressed_content() else {
return items;
};
// Decode the content stream
let Ok(content) = Content::decode(&content_data) else {
return items;
};
// Get fonts from the Form's Resources
let form_fonts = get_form_fonts(doc, &stream.dict);
let font_encodings = build_font_encodings(doc, &form_fonts);
// Build font width info for the form
let font_widths = build_font_widths(doc, &form_fonts);
// Build font base names and ToUnicode refs for the form
let mut font_base_names: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut font_tounicode_refs: std::collections::HashMap<String, u32> =
std::collections::HashMap::new();
for (font_name, font_dict) in &form_fonts {
let resource_name = String::from_utf8_lossy(font_name).to_string();
if let Ok(base_font) = font_dict.get(b"BaseFont") {
if let Ok(name) = base_font.as_name() {
let base_name = String::from_utf8_lossy(name).to_string();
font_base_names.insert(resource_name.clone(), base_name);
}
}
if let Ok(tounicode) = font_dict.get(b"ToUnicode") {
if let Ok(obj_ref) = tounicode.as_reference() {
font_tounicode_refs.insert(resource_name, obj_ref.0);
}
}
}
// Process the content stream
let mut current_font = String::new();
let mut current_font_size: f32 = 12.0;
let mut text_matrix = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0];
let mut in_text_block = false;
for op in &content.operations {
match op.operator.as_str() {
"BT" => {
in_text_block = true;
text_matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
}
"ET" => {
in_text_block = false;
}
"Tf" => {
if op.operands.len() >= 2 {
if let Ok(name) = op.operands[0].as_name() {
current_font = String::from_utf8_lossy(name).to_string();
}
current_font_size = get_number(&op.operands[1]).unwrap_or(12.0);
}
}
"Td" | "TD" => {
if op.operands.len() >= 2 {
let tx = get_number(&op.operands[0]).unwrap_or(0.0);
let ty = get_number(&op.operands[1]).unwrap_or(0.0);
text_matrix[4] += tx;
text_matrix[5] += ty;
}
}
"Tm" => {
if op.operands.len() >= 6 {
for (i, operand) in op.operands.iter().take(6).enumerate() {
text_matrix[i] =
get_number(operand).unwrap_or(if i == 0 || i == 3 { 1.0 } else { 0.0 });
}
}
}
"Tj" => {
if in_text_block && !op.operands.is_empty() {
if let Some(text) = extract_text_from_operand(
&op.operands[0],
doc,
&form_fonts,
&current_font,
font_cmaps,
&font_base_names,
&font_tounicode_refs,
&font_encodings,
) {
if !text.trim().is_empty() {
let rendered_size =
effective_font_size(current_font_size, &text_matrix);
let combined = multiply_matrices(&text_matrix, parent_ctm);
let (x, y) = (combined[4], combined[5]);
// Compute width from font widths if available
let width = if let Some(font_info) = font_widths.get(&current_font) {
if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) {
let w_ts = compute_string_width_ts(
raw_bytes,
font_info,
current_font_size,
);
text_matrix[4] += w_ts * text_matrix[0];
text_matrix[5] += w_ts * text_matrix[1];
(w_ts
* (text_matrix[0] * parent_ctm[0]
+ text_matrix[1] * parent_ctm[2]))
.abs()
} else {
0.0
}
} else {
0.0
};
let base_font = font_base_names
.get(&current_font)
.map(|s| s.as_str())
.unwrap_or(&current_font);
items.push(TextItem {
text: expand_ligatures(&text),
x,
y,
width,
height: rendered_size,
font: current_font.clone(),
font_size: rendered_size,
page: page_num,
is_bold: is_bold_font(base_font),
is_italic: is_italic_font(base_font),
item_type: ItemType::Text,
});
}
}
}
}
"TJ" => {
if in_text_block && !op.operands.is_empty() {
if let Ok(array) = op.operands[0].as_array() {
let font_info = font_widths.get(&current_font);
// Compute space threshold based on font metrics when available
let space_threshold = if let Some(fi) = font_info {
let space_em = fi.space_width as f32 * fi.units_scale;
let threshold = space_em * 1000.0 * 0.4;
threshold.clamp(80.0, 200.0)
} else {
120.0
};
let mut combined_text = String::new();
let mut total_width_ts: f32 = 0.0;
for element in array {
match element {
Object::Integer(n) => {
let n_val = *n as f32;
total_width_ts += -n_val / 1000.0 * current_font_size;
if n_val < -space_threshold
&& !combined_text.is_empty()
&& !combined_text.ends_with(' ')
{
combined_text.push(' ');
}
continue;
}
Object::Real(n) => {
total_width_ts += -(*n) / 1000.0 * current_font_size;
if *n < -space_threshold
&& !combined_text.is_empty()
&& !combined_text.ends_with(' ')
{
combined_text.push(' ');
}
continue;
}
_ => {}
}
if let Some(fi) = font_info {
if let Some(raw_bytes) = get_operand_bytes(element) {
total_width_ts +=
compute_string_width_ts(raw_bytes, fi, current_font_size);
}
}
if let Some(text) = extract_text_from_operand(
element,
doc,
&form_fonts,
&current_font,
font_cmaps,
&font_base_names,
&font_tounicode_refs,
&font_encodings,
) {
combined_text.push_str(&text);
}
}
if !combined_text.trim().is_empty() {
let rendered_size =
effective_font_size(current_font_size, &text_matrix);
let combined_mat = multiply_matrices(&text_matrix, parent_ctm);
let (x, y) = (combined_mat[4], combined_mat[5]);
let width = if font_info.is_some() {
(total_width_ts
* (text_matrix[0] * parent_ctm[0]
+ text_matrix[1] * parent_ctm[2]))
.abs()
} else {
0.0
};
let base_font = font_base_names
.get(&current_font)
.map(|s| s.as_str())
.unwrap_or(&current_font);
items.push(TextItem {
text: expand_ligatures(&combined_text),
x,
y,
width,
height: rendered_size,
font: current_font.clone(),
font_size: rendered_size,
page: page_num,
is_bold: is_bold_font(base_font),
is_italic: is_italic_font(base_font),
item_type: ItemType::Text,
});
if font_info.is_some() {
text_matrix[4] += total_width_ts * text_matrix[0];
text_matrix[5] += total_width_ts * text_matrix[1];
}
}
}
}
}
_ => {}
}
}
items
}
/// Get fonts from a Form XObject's Resources
fn get_form_fonts<'a>(
doc: &'a Document,
form_dict: &lopdf::Dictionary,
) -> std::collections::BTreeMap<Vec<u8>, &'a lopdf::Dictionary> {
let mut fonts = std::collections::BTreeMap::new();
// Get Resources from Form dictionary
let resources = if let Ok(res_ref) = form_dict.get(b"Resources") {
if let Ok(obj_ref) = res_ref.as_reference() {
doc.get_dictionary(obj_ref).ok()
} else {
res_ref.as_dict().ok()
}
} else {
return fonts;
};
let Some(resources) = resources else {
return fonts;
};
// Get Font dictionary
let font_dict = if let Ok(font_ref) = resources.get(b"Font") {
if let Ok(obj_ref) = font_ref.as_reference() {
doc.get_dictionary(obj_ref).ok()
} else {
font_ref.as_dict().ok()
}
} else {
return fonts;
};
let Some(font_dict) = font_dict else {
return fonts;
};
// Collect fonts
for (name, value) in font_dict.iter() {
if let Ok(obj_ref) = value.as_reference() {
if let Ok(dict) = doc.get_dictionary(obj_ref) {
fonts.insert(name.clone(), dict);
}
}
}
fonts
}
/// Extract hyperlinks from page annotations
pub fn extract_page_links(doc: &Document, page_id: ObjectId, page_num: u32) -> Vec<TextItem> {
let mut links = Vec::new();
// Try to get the page dictionary
if let Ok(page_dict) = doc.get_dictionary(page_id) {
// Get Annots array
let annots = if let Ok(annots_ref) = page_dict.get(b"Annots") {
if let Ok(obj_ref) = annots_ref.as_reference() {
doc.get_object(obj_ref)
.ok()
.and_then(|o| o.as_array().ok().cloned())
} else {
annots_ref.as_array().ok().cloned()
}
} else {
None
};
if let Some(annots) = annots {
for annot_ref in annots {
// Get annotation dictionary
let annot_dict = if let Ok(obj_ref) = annot_ref.as_reference() {
doc.get_dictionary(obj_ref).ok()
} else {
annot_ref.as_dict().ok()
};
if let Some(annot_dict) = annot_dict {
// Check if this is a Link annotation
if let Ok(subtype) = annot_dict.get(b"Subtype") {
if let Ok(subtype_name) = subtype.as_name() {
if subtype_name != b"Link" {
continue;
}
}
}
// Get the Rect (position)
let rect = if let Ok(rect_obj) = annot_dict.get(b"Rect") {
if let Ok(rect_array) = rect_obj.as_array() {
if rect_array.len() >= 4 {
let x1 = get_number(&rect_array[0]).unwrap_or(0.0);
let y1 = get_number(&rect_array[1]).unwrap_or(0.0);
let x2 = get_number(&rect_array[2]).unwrap_or(0.0);
let y2 = get_number(&rect_array[3]).unwrap_or(0.0);
Some((x1, y1, x2 - x1, y2 - y1))
} else {
None
}
} else {
None
}
} else {
None
};
// Get the action (A dictionary) or Dest
let uri = extract_link_uri(doc, annot_dict);
if let (Some((x, y, width, height)), Some(url)) = (rect, uri) {
links.push(TextItem {
text: url.clone(),
x,
y,
width,
height,
font: String::new(),
font_size: 0.0,
page: page_num,
is_bold: false,
is_italic: false,
item_type: ItemType::Link(url),
});
}
}
}
}
}
links
}
/// Extract URI from a link annotation
fn extract_link_uri(doc: &Document, annot_dict: &lopdf::Dictionary) -> Option<String> {
// Try to get the A (Action) dictionary
if let Ok(action_ref) = annot_dict.get(b"A") {
let action_dict = if let Ok(obj_ref) = action_ref.as_reference() {
doc.get_dictionary(obj_ref).ok()
} else {
action_ref.as_dict().ok()
};
if let Some(action_dict) = action_dict {
// Check for URI action
if let Ok(uri_obj) = action_dict.get(b"URI") {
if let Ok(uri_str) = uri_obj.as_str() {
return Some(String::from_utf8_lossy(uri_str).to_string());
}
}
}
}
// Try Dest (named destination) - less common for external links
// We'll skip this for now as it requires looking up named destinations
None
}
/// Compute effective font size from base size and text matrix
/// Text matrix is [a, b, c, d, tx, ty] where a,d are scale factors
fn effective_font_size(base_size: f32, text_matrix: &[f32; 6]) -> f32 {
// The scale factor is typically the magnitude of the transformation
// For most PDFs, text_matrix[0] (a) is the horizontal scale
// and text_matrix[3] (d) is the vertical scale
let scale_x = (text_matrix[0].powi(2) + text_matrix[1].powi(2)).sqrt();
let scale_y = (text_matrix[2].powi(2) + text_matrix[3].powi(2)).sqrt();
// Use the larger of the two scales (usually they're equal for non-rotated text)
let scale = scale_x.max(scale_y);
base_size * scale
}
/// Check if a character is CJK (Chinese, Japanese, Korean).
/// CJK languages don't use spaces between words, so word-boundary
/// heuristics should not apply when CJK characters are involved.
fn is_cjk_char(c: char) -> bool {
matches!(c,
'\u{3000}'..='\u{303F}' // CJK Symbols and Punctuation
| '\u{3040}'..='\u{309F}' // Hiragana
| '\u{30A0}'..='\u{30FF}' // Katakana
| '\u{4E00}'..='\u{9FFF}' // CJK Unified Ideographs
| '\u{F900}'..='\u{FAFF}' // CJK Compatibility Ideographs
| '\u{FF00}'..='\u{FFEF}' // Halfwidth and Fullwidth Forms
)
}
/// Detect if a font name indicates bold style
/// Common patterns: "Bold", "Bd", "Black", "Heavy", "Demi", "Semi" (semi-bold)
pub fn is_bold_font(font_name: &str) -> bool {
let lower = font_name.to_lowercase();
// Check for common bold indicators
// Note: Need to be careful with "Oblique" not matching "Obl" + false positive for bold
lower.contains("bold")
|| lower.contains("-bd")
|| lower.contains("_bd")
|| lower.contains("black")
|| lower.contains("heavy")
|| lower.contains("demibold")
|| lower.contains("semibold")
|| lower.contains("demi-bold")
|| lower.contains("semi-bold")
|| lower.contains("extrabold")
|| lower.contains("ultrabold")
|| lower.contains("medium") && !lower.contains("mediumitalic") // Some fonts use Medium for semi-bold
}
/// Detect if a font name indicates italic/oblique style
/// Common patterns: "Italic", "It", "Oblique", "Obl", "Slant", "Inclined"
pub fn is_italic_font(font_name: &str) -> bool {
let lower = font_name.to_lowercase();
// Check for common italic indicators
lower.contains("italic")
|| lower.contains("oblique")
|| lower.contains("-it")
|| lower.contains("_it")
|| lower.contains("slant")
|| lower.contains("inclined")
|| lower.contains("kursiv") // German for italic
}
/// Extract text from a text operand, handling encoding
#[allow(clippy::too_many_arguments)]
fn extract_text_from_operand(
obj: &Object,
doc: &Document,
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
current_font: &str,
font_cmaps: &FontCMaps,
font_base_names: &std::collections::HashMap<String, String>,
font_tounicode_refs: &std::collections::HashMap<String, u32>,
font_encodings: &PageFontEncodings,
) -> Option<String> {
if let Object::String(bytes, _) = obj {
// First, try to look up CMap by ToUnicode object reference (most reliable)
// This handles cases where multiple fonts have the same BaseFont but different ToUnicode
if let Some(&obj_num) = font_tounicode_refs.get(current_font) {
if let Some(cmap) = font_cmaps.get_by_obj(obj_num) {
let decoded = cmap.decode_cids(bytes);
if !decoded.is_empty() {
return Some(decoded);
}
}
}
// Fall back to base name lookup with object number
if let (Some(base_name), Some(&obj_num)) = (
font_base_names.get(current_font),
font_tounicode_refs.get(current_font),
) {
if let Some(cmap) = font_cmaps.get_with_obj(base_name, obj_num) {
let decoded = cmap.decode_cids(bytes);
if !decoded.is_empty() {
return Some(decoded);
}
}
}
// Try base name only (legacy fallback)
if let Some(base_name) = font_base_names.get(current_font) {
if let Some(cmap) = font_cmaps.get(base_name) {
let decoded = cmap.decode_cids(bytes);
if !decoded.is_empty() {
return Some(decoded);
}
}
}
// Also try looking up by resource name directly
if let Some(cmap) = font_cmaps.get(current_font) {
let decoded = cmap.decode_cids(bytes);
if !decoded.is_empty() {
return Some(decoded);
}
}
// Try our custom encoding map from Differences arrays.
// The Differences array overrides specific codes in a base encoding (typically
// WinAnsiEncoding). We must combine Differences entries with the base encoding
// rather than using filter_map which silently drops unmapped bytes.
if let Some(encoding_map) = font_encodings.get(current_font) {
let has_diff_match = bytes.iter().any(|b| encoding_map.contains_key(b));
if has_diff_match {
let decoded: String = bytes
.iter()
.filter_map(|&b| {
if let Some(&ch) = encoding_map.get(&b) {
Some(ch)
} else if b >= 0x20 {
// Base encoding fallback for printable bytes.
// For codes 0x20-0x7E this matches all standard PDF encodings.
Some(b as char)
} else {
None // Skip unmapped control characters
}
})
.collect();
if !decoded.is_empty() {
return Some(decoded);
}
}
}
// Try to decode using font encoding from lopdf
if let Some(font_dict) = fonts.get(current_font.as_bytes()) {
if let Ok(encoding) = font_dict.get_font_encoding(doc) {
if let Ok(text) = Document::decode_text(&encoding, bytes) {
return Some(text);
}
}
}
// Fallback: try UTF-16BE then Latin-1
if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
let utf16: Vec<u16> = bytes[2..]
.chunks_exact(2)
.map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
.collect();
return Some(String::from_utf16_lossy(&utf16));
}
// Latin-1 fallback
Some(bytes.iter().map(|&b| b as char).collect())
} else {
None
}
}
/// Expand Unicode ligature characters to their component characters.
/// This makes extracted text more searchable and semantically correct.
fn expand_ligatures(text: &str) -> String {
// Strip null bytes and other control characters (except newline/tab)
let text = if text
.bytes()
.any(|b| b < 0x20 && b != b'\n' && b != b'\r' && b != b'\t')
{
text.chars()
.filter(|&c| c >= ' ' || c == '\n' || c == '\r' || c == '\t')
.collect::<String>()
} else {
text.to_string()
};
if !text.contains('\u{FB00}')
&& !text.contains('\u{FB01}')
&& !text.contains('\u{FB02}')
&& !text.contains('\u{FB03}')
&& !text.contains('\u{FB04}')
{
return text;
}
text.replace('\u{FB00}', "ff")
.replace('\u{FB01}', "fi")
.replace('\u{FB02}', "fl")
.replace('\u{FB03}', "ffi")
.replace('\u{FB04}', "ffl")
}
/// Estimate the width of a text item, falling back to a character-count heuristic when width is 0.
fn effective_width(item: &TextItem) -> f32 {
if item.width > 0.0 {
item.width
} else {
item.text.chars().count() as f32 * item.font_size * 0.5
}
}
/// Represents a column region on a page
#[derive(Debug, Clone)]
struct ColumnRegion {
x_min: f32,
x_max: f32,
}
/// Detect column boundaries on a page using a horizontal projection profile.
///
/// Builds an occupancy histogram across the page width and finds empty valleys
/// (gutters) where no text exists. Validates valleys with vertical consistency
/// checks to avoid false positives.
fn detect_columns(items: &[TextItem], page: u32) -> Vec<ColumnRegion> {
const BIN_WIDTH: f32 = 2.0;
const MIN_GUTTER_WIDTH: f32 = 8.0;
const MIN_VERTICAL_SPAN_RATIO: f32 = 0.30;
const MIN_ITEMS_PER_COLUMN: usize = 10;
const NOISE_FRACTION: f32 = 0.05;
// Get items for this page
let page_items: Vec<&TextItem> = items.iter().filter(|i| i.page == page).collect();
if page_items.is_empty() {
return vec![];
}
// Find page bounds
let x_min = page_items.iter().map(|i| i.x).fold(f32::INFINITY, f32::min);
let x_max = page_items
.iter()
.map(|i| i.x + effective_width(i))
.fold(f32::NEG_INFINITY, f32::max);
let page_width = x_max - x_min;
if page_width < 200.0 {
return vec![ColumnRegion { x_min, x_max }];
}
if page_items.len() < 20 {
return vec![ColumnRegion { x_min, x_max }];
}
// Build occupancy histogram
let num_bins = ((page_width / BIN_WIDTH).ceil() as usize).max(1);
let mut histogram = vec![0u32; num_bins];
for item in &page_items {
let w = effective_width(item);
let left = ((item.x - x_min) / BIN_WIDTH).floor() as usize;
let right = (((item.x + w) - x_min) / BIN_WIDTH).ceil() as usize;
let left = left.min(num_bins);
let right = right.min(num_bins);
for count in histogram.iter_mut().take(right).skip(left) {
*count += 1;
}
}
// Find the noise threshold: bins with count <= max_count * NOISE_FRACTION are "empty"
let max_count = *histogram.iter().max().unwrap_or(&0);
let noise_threshold = (max_count as f32 * NOISE_FRACTION) as u32;
// Find empty valleys (consecutive runs of low-count bins)
// Each valley is stored as (start_bin, end_bin)
let mut valleys: Vec<(usize, usize)> = Vec::new();
let mut valley_start: Option<usize> = None;
for (i, &count) in histogram.iter().enumerate() {
if count <= noise_threshold {
if valley_start.is_none() {
valley_start = Some(i);
}
} else if let Some(start) = valley_start {
valleys.push((start, i));
valley_start = None;
}
}
// Close any valley that extends to the end
if let Some(start) = valley_start {
valleys.push((start, num_bins));
}
// Filter valleys: must be wide enough and not at page margins
let margin_threshold = page_width * 0.05;
let valleys: Vec<(usize, usize)> = valleys
.into_iter()
.filter(|&(start, end)| {
let width_pts = (end - start) as f32 * BIN_WIDTH;
if width_pts < MIN_GUTTER_WIDTH {
return false;
}
// Valley center must not be within 5% of page edges
let center_pts = ((start + end) as f32 / 2.0) * BIN_WIDTH;
center_pts > margin_threshold && center_pts < (page_width - margin_threshold)
})
.collect();
if valleys.is_empty() {
return vec![ColumnRegion { x_min, x_max }];
}
// Compute Y range of the page
let y_min = page_items.iter().map(|i| i.y).fold(f32::INFINITY, f32::min);
let y_max = page_items
.iter()
.map(|i| i.y)
.fold(f32::NEG_INFINITY, f32::max);
let y_range = y_max - y_min;
// Validate each valley with vertical consistency
let mut valid_valleys: Vec<(usize, usize)> = Vec::new();
for &(start, end) in &valleys {
let gutter_left = x_min + start as f32 * BIN_WIDTH;
let gutter_right = x_min + end as f32 * BIN_WIDTH;
let gutter_center = (gutter_left + gutter_right) / 2.0;
// Collect items on each side of the gutter
let left_items: Vec<&&TextItem> = page_items
.iter()
.filter(|i| i.x + effective_width(i) <= gutter_center)
.collect();
let right_items: Vec<&&TextItem> =
page_items.iter().filter(|i| i.x >= gutter_center).collect();
if left_items.len() < MIN_ITEMS_PER_COLUMN || right_items.len() < MIN_ITEMS_PER_COLUMN {
continue;
}
// Check vertical overlap
if y_range > 0.0 {
let left_y_min = left_items.iter().map(|i| i.y).fold(f32::INFINITY, f32::min);
let left_y_max = left_items
.iter()
.map(|i| i.y)
.fold(f32::NEG_INFINITY, f32::max);
let right_y_min = right_items
.iter()
.map(|i| i.y)
.fold(f32::INFINITY, f32::min);
let right_y_max = right_items
.iter()
.map(|i| i.y)
.fold(f32::NEG_INFINITY, f32::max);
let overlap_min = left_y_min.max(right_y_min);
let overlap_max = left_y_max.min(right_y_max);
let overlap = (overlap_max - overlap_min).max(0.0);
if overlap / y_range < MIN_VERTICAL_SPAN_RATIO {
continue;
}
}
valid_valleys.push((start, end));
}
if valid_valleys.is_empty() {
return vec![ColumnRegion { x_min, x_max }];
}
// Limit to at most 3 gutters (4 columns) — keep the widest if more found
if valid_valleys.len() > 3 {
valid_valleys.sort_by(|a, b| {
let wa = (a.1 - a.0) as f32;
let wb = (b.1 - b.0) as f32;
wb.partial_cmp(&wa).unwrap_or(std::cmp::Ordering::Equal)
});
valid_valleys.truncate(3);
// Re-sort by position (left to right)
valid_valleys.sort_by_key(|v| v.0);
}
// Build column regions from gutter boundaries
let mut columns = Vec::new();
let mut col_start = x_min;
for &(start, end) in &valid_valleys {
let gutter_center = x_min + ((start + end) as f32 / 2.0) * BIN_WIDTH;
columns.push(ColumnRegion {
x_min: col_start,
x_max: gutter_center,
});
col_start = gutter_center;
}
columns.push(ColumnRegion {
x_min: col_start,
x_max,
});
columns
}
/// Determines if a text item spans across multiple column regions (e.g. full-width headers/titles).
fn spans_multiple_columns(item: &TextItem, columns: &[ColumnRegion]) -> bool {
let w = effective_width(item);
let item_right = item.x + w;
let overlap_count = columns
.iter()
.filter(|col| {
let overlap_start = item.x.max(col.x_min);
let overlap_end = item_right.min(col.x_max);
let overlap = (overlap_end - overlap_start).max(0.0);
overlap > (col.x_max - col.x_min) * 0.10 || overlap > 20.0
})
.count();
overlap_count >= 2
}
/// Check if a text item is likely a page number
fn is_page_number(item: &TextItem) -> bool {
let text = item.text.trim();
// Must be 1-4 digits only
if text.is_empty() || text.len() > 4 {
return false;
}
if !text.chars().all(|c| c.is_ascii_digit()) {
return false;
}
// Must be at top or bottom of page.
// US Letter = 792pt, A4 = 841pt. Page numbers are typically in the
// top ~5% or bottom ~12% of the page.
item.y > 720.0 || item.y < 100.0
}
/// Group text items into lines, with multi-column support
pub fn group_into_lines(items: Vec<TextItem>) -> Vec<TextLine> {
if items.is_empty() {
return Vec::new();
}
// Filter out page numbers (standalone numbers at top/bottom of page)
let items: Vec<TextItem> = items
.into_iter()
.filter(|item| !is_page_number(item))
.collect();
// Get unique pages
let mut pages: Vec<u32> = items.iter().map(|i| i.page).collect();
pages.sort();
pages.dedup();
let mut all_lines = Vec::new();
for page in pages {
let page_items: Vec<TextItem> = items.iter().filter(|i| i.page == page).cloned().collect();
// Detect columns for this page
let columns = detect_columns(&page_items, page);
if columns.len() <= 1 {
// Single column - use simple sorting
let lines = group_single_column(page_items);
all_lines.extend(lines);
} else {
// Multi-column - separate spanning items from column items
let mut spanning_items: Vec<TextItem> = Vec::new();
let mut column_items: Vec<TextItem> = Vec::new();
for item in &page_items {
if spans_multiple_columns(item, &columns) {
spanning_items.push(item.clone());
} else {
column_items.push(item.clone());
}
}
// Process each column's items independently, preserving column identity.
// Assign each item to the column with greatest horizontal overlap
// (instead of center-point) to avoid gutter mis-assignment.
let mut col_buckets: Vec<Vec<TextItem>> = vec![Vec::new(); columns.len()];
for item in &column_items {
let item_left = item.x;
let item_right = item.x + effective_width(item);
let mut best_col = 0;
let mut best_overlap = f32::NEG_INFINITY;
for (ci, col) in columns.iter().enumerate() {
let overlap = (item_right.min(col.x_max) - item_left.max(col.x_min)).max(0.0);
if overlap > best_overlap {
best_overlap = overlap;
best_col = ci;
}
}
col_buckets[best_col].push(item.clone());
}
let mut per_column_lines: Vec<Vec<TextLine>> = Vec::new();
for col_items in col_buckets {
let lines = group_single_column(col_items);
per_column_lines.push(lines);
}
// Process spanning items as their own group
let mut spanning_lines = group_single_column(spanning_items);
// Sort spanning lines by Y descending (top-first in PDF coords)
spanning_lines
.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap_or(std::cmp::Ordering::Equal));
// Section-based merge: spanning items define vertical sections.
// Within each section, emit all column lines (left-to-right, top-to-bottom)
// before emitting the spanning line.
let mut merged: Vec<TextLine> = Vec::new();
let mut col_cursors: Vec<usize> = vec![0; per_column_lines.len()];
for span_line in &spanning_lines {
let span_y = span_line.y;
// Emit all column lines above this spanning line, column by column
for (ci, col_lines) in per_column_lines.iter().enumerate() {
while col_cursors[ci] < col_lines.len()
&& col_lines[col_cursors[ci]].y >= span_y
{
merged.push(col_lines[col_cursors[ci]].clone());
col_cursors[ci] += 1;
}
}
merged.push(span_line.clone());
}
// Emit remaining column lines below all spanning items, column by column
for (ci, col_lines) in per_column_lines.iter().enumerate() {
while col_cursors[ci] < col_lines.len() {
merged.push(col_lines[col_cursors[ci]].clone());
col_cursors[ci] += 1;
}
}
all_lines.extend(merged);
}
}
all_lines
}
/// Determine if Y-sorting should be used instead of stream order.
/// Returns true if the stream order appears chaotic (items jump around in Y position).
fn should_use_y_sorting(items: &[TextItem]) -> bool {
if items.len() < 5 {
return false; // Not enough items to judge
}
// Sample Y positions from stream order
let y_positions: Vec<f32> = items.iter().map(|i| i.y).collect();
// Count "order violations" - cases where Y increases (going up) when it should decrease
// In proper reading order, Y should generally decrease (top to bottom)
let mut large_jumps_up = 0;
let mut large_jumps_down = 0;
let jump_threshold = 50.0; // Significant Y jump
for window in y_positions.windows(2) {
let delta = window[1] - window[0];
if delta > jump_threshold {
large_jumps_up += 1; // Y increased significantly (jumped up on page)
} else if delta < -jump_threshold {
large_jumps_down += 1; // Y decreased significantly (normal reading direction)
}
}
// If there are many upward jumps relative to downward jumps, order is chaotic
// A well-ordered document should have mostly downward progression
let total_jumps = large_jumps_up + large_jumps_down;
if total_jumps < 3 {
return false; // Not enough jumps to judge
}
// If more than 40% of large jumps are upward, use Y-sorting
let chaos_ratio = large_jumps_up as f32 / total_jumps as f32;
chaos_ratio > 0.4
}
/// Group items from a single column into lines
/// Uses heuristics to decide between PDF stream order and Y-position sorting.
fn group_single_column(items: Vec<TextItem>) -> Vec<TextLine> {
if items.is_empty() {
return Vec::new();
}
// Decide whether to use stream order or Y-sorting
let use_y_sorting = should_use_y_sorting(&items);
let items = if use_y_sorting {
// Sort by Y descending (top to bottom in PDF coords)
let mut sorted = items;
sorted.sort_by(|a, b| {
b.y.partial_cmp(&a.y)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal))
});
sorted
} else {
items
};
// Group items into lines
let mut lines: Vec<TextLine> = Vec::new();
let y_tolerance = 3.0;
for item in items {
// Only check the most recent line for merging
let should_merge = lines.last().is_some_and(|last_line| {
if last_line.page != item.page {
return false;
}
let y_diff = (last_line.y - item.y).abs();
if y_diff >= y_tolerance {
return false;
}
// Check if this looks like a new line despite similar Y:
// If items are at the same X position (left margin) but different Y,
// they're vertically stacked lines, not the same line
let has_y_change = y_diff > 0.5;
if has_y_change {
if let Some(first_item) = last_line.items.first() {
let at_same_x = (item.x - first_item.x).abs() < 5.0;
// If at same X (left margin) with Y change, it's likely a new line
if at_same_x {
return false;
}
// If new item starts significantly to the left with Y change,
// it's a new line (not just out-of-order items on same line)
if let Some(last_item) = last_line.items.last() {
if item.x < last_item.x - 10.0 {
return false;
}
}
}
}
true
});
if should_merge {
// Add to the most recent line
lines.last_mut().unwrap().items.push(item);
} else {
// Create new line
let y = item.y;
let page = item.page;
lines.push(TextLine {
items: vec![item],
y,
page,
});
}
}
// Sort items within each line by X position (left to right)
for line in &mut lines {
line.items
.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal));
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_group_into_lines() {
let items = vec![
TextItem {
text: "Hello".into(),
x: 100.0,
y: 700.0,
width: 50.0,
height: 12.0,
font: "F1".into(),
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
},
TextItem {
text: "World".into(),
x: 160.0,
y: 700.0,
width: 50.0,
height: 12.0,
font: "F1".into(),
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
},
TextItem {
text: "Next line".into(),
x: 100.0,
y: 680.0,
width: 80.0,
height: 12.0,
font: "F1".into(),
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
},
];
let lines = group_into_lines(items);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].text(), "Hello World");
assert_eq!(lines[1].text(), "Next line");
}
#[test]
fn test_bold_italic_detection() {
// Test bold detection
assert!(is_bold_font("Arial-Bold"));
assert!(is_bold_font("TimesNewRoman-Bold"));
assert!(is_bold_font("Helvetica-BoldOblique"));
assert!(is_bold_font("ABCDEF+ArialMT-Bold"));
assert!(is_bold_font("NotoSans-Black"));
assert!(is_bold_font("Roboto-SemiBold"));
assert!(!is_bold_font("Arial"));
assert!(!is_bold_font("TimesNewRoman-Italic"));
// Test italic detection
assert!(is_italic_font("Arial-Italic"));
assert!(is_italic_font("TimesNewRoman-Italic"));
assert!(is_italic_font("Helvetica-Oblique"));
assert!(is_italic_font("ABCDEF+ArialMT-Italic"));
assert!(is_italic_font("Helvetica-BoldOblique"));
assert!(!is_italic_font("Arial"));
assert!(!is_italic_font("TimesNewRoman-Bold"));
// Test bold-italic detection
assert!(is_bold_font("Arial-BoldItalic"));
assert!(is_italic_font("Arial-BoldItalic"));
assert!(is_bold_font("Helvetica-BoldOblique"));
assert!(is_italic_font("Helvetica-BoldOblique"));
}
#[test]
fn test_word_level_items_get_spaces() {
// Simulate CID font per-word items touching with gap=0
let items = vec![
TextItem {
text: "the".into(),
x: 100.0,
y: 500.0,
width: 19.5,
height: 12.0,
font: "C2_0".into(),
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
},
TextItem {
text: "Prague".into(),
x: 119.5,
y: 500.0,
width: 42.0,
height: 12.0,
font: "C2_0".into(),
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
},
TextItem {
text: "Rules".into(),
x: 161.5,
y: 500.0,
width: 35.0,
height: 12.0,
font: "C2_0".into(),
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
},
];
let lines = group_into_lines(items);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text(), "the Prague Rules");
}
#[test]
fn test_single_char_items_still_join() {
// Per-glyph positioning: single chars should join into words
let items = vec![
TextItem {
text: "N".into(),
x: 100.0,
y: 500.0,
width: 8.0,
height: 12.0,
font: "F1".into(),
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
},
TextItem {
text: "A".into(),
x: 108.0,
y: 500.0,
width: 8.0,
height: 12.0,
font: "F1".into(),
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
},
TextItem {
text: "V".into(),
x: 116.0,
y: 500.0,
width: 8.0,
height: 12.0,
font: "F1".into(),
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
},
];
let lines = group_into_lines(items);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text(), "NAV");
}
#[test]
fn test_cjk_items_join_without_spaces() {
// Japanese text items touching at gap=0 should join without spaces
let items = vec![
TextItem {
text: "である".into(),
x: 100.0,
y: 500.0,
width: 24.0,
height: 12.0,
font: "C2_0".into(),
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
},
TextItem {
text: "履行義務".into(),
x: 124.0,
y: 500.0,
width: 32.0,
height: 12.0,
font: "C2_0".into(),
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
},
TextItem {
text: "を識別す".into(),
x: 156.0,
y: 500.0,
width: 32.0,
height: 12.0,
font: "C2_0".into(),
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
},
];
let lines = group_into_lines(items);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text(), "である履行義務を識別す");
}
}