#![forbid(unsafe_code)]
use crate::color::cmyk_to_rgb;
use crate::config::ExtractionProfile;
use crate::content::graphics_state::{GraphicsStateStack, Matrix};
use crate::content::operators::{Operator, TextElement};
use crate::content::parse_and_execute_text_only;
use crate::content::parse_content_stream;
use crate::error::Result;
use crate::extract_log_debug;
use crate::fonts::FontInfo;
use crate::fonts::unicode_decode::{
DecodePolicy, TextCharIter, decode_text_to_unicode, fallback_char_to_unicode, strip_subset_prefix,
};
use crate::geometry::Rect;
use crate::layout::{Color, FontWeight, TextChar, TextSpan};
use crate::object::{Object, ObjectRef};
use crate::pipeline::config::WordBoundaryMode;
use crate::text::{BoundaryContext, CharacterInfo, DocumentScript, WordBoundaryDetector};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
static PRESERVE_UNMAPPED_GLYPHS: AtomicBool = AtomicBool::new(false);
pub fn set_preserve_unmapped_glyphs(preserve: bool) -> bool {
PRESERVE_UNMAPPED_GLYPHS.swap(preserve, Ordering::SeqCst)
}
#[inline]
pub(crate) fn preserve_unmapped_glyphs() -> bool {
PRESERVE_UNMAPPED_GLYPHS.load(Ordering::Relaxed)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpaceSource {
TjOffset,
GeometricGap,
CharacterHeuristic,
AlreadyPresent,
NoSpace,
IntraWordKerning,
WordBoundaryAnalysis,
}
#[derive(Debug, Clone)]
pub struct SpaceDecision {
pub insert_space: bool,
pub source: SpaceSource,
pub confidence: f32,
}
impl SpaceDecision {
pub fn insert(source: SpaceSource, confidence: f32) -> Self {
Self {
insert_space: true,
source,
confidence: confidence.clamp(0.0, 1.0),
}
}
pub fn no_space(source: SpaceSource, confidence: f32) -> Self {
Self {
insert_space: false,
source,
confidence: confidence.clamp(0.0, 1.0),
}
}
}
#[derive(Debug, Clone)]
pub struct TextExtractionConfig {
pub profile: Option<ExtractionProfile>,
pub space_insertion_threshold: f32,
pub word_margin_ratio: f32,
pub use_adaptive_tj_threshold: bool,
pub word_boundary_mode: WordBoundaryMode,
}
impl Default for TextExtractionConfig {
fn default() -> Self {
Self {
profile: None,
space_insertion_threshold: -120.0,
word_margin_ratio: 0.1,
use_adaptive_tj_threshold: false,
word_boundary_mode: WordBoundaryMode::default(),
}
}
}
impl TextExtractionConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_space_threshold(threshold: f32) -> Self {
Self {
profile: None,
space_insertion_threshold: threshold,
word_margin_ratio: 0.1,
use_adaptive_tj_threshold: false,
word_boundary_mode: WordBoundaryMode::default(),
}
}
pub fn with_word_margin_ratio(ratio: f32) -> Self {
Self {
profile: None,
space_insertion_threshold: -120.0,
word_margin_ratio: ratio,
use_adaptive_tj_threshold: true,
word_boundary_mode: WordBoundaryMode::default(),
}
}
pub fn set_word_margin_ratio(mut self, ratio: f32) -> Self {
self.word_margin_ratio = ratio;
self.use_adaptive_tj_threshold = true;
self
}
pub fn set_adaptive_tj_threshold(mut self, enabled: bool) -> Self {
self.use_adaptive_tj_threshold = enabled;
self
}
pub fn with_profile(mut self, profile: ExtractionProfile) -> Self {
let tj_offset = profile.tj_offset_threshold;
let word_margin = profile.word_margin_ratio;
let use_adaptive = profile.use_adaptive_threshold;
self.profile = Some(profile);
self.space_insertion_threshold = tj_offset;
self.word_margin_ratio = word_margin;
self.use_adaptive_tj_threshold = use_adaptive;
self
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct SpanMergingConfig {
pub space_threshold_em_ratio: f32,
pub conservative_threshold_pt: f32,
pub column_boundary_threshold_pt: f32,
pub severe_overlap_threshold_pt: f32,
pub use_adaptive_threshold: bool,
pub adaptive_config: Option<crate::extractors::gap_statistics::AdaptiveThresholdConfig>,
pub detect_email_patterns: bool,
pub email_threshold_multiplier: f32,
pub detect_citation_markers: bool,
pub citation_font_size_ratio: f32,
pub merge_tm_tj_runs: bool,
}
impl Default for SpanMergingConfig {
fn default() -> Self {
Self {
space_threshold_em_ratio: 0.25,
conservative_threshold_pt: 0.1, column_boundary_threshold_pt: 5.0,
severe_overlap_threshold_pt: -0.5,
use_adaptive_threshold: true,
adaptive_config: None,
detect_email_patterns: false,
email_threshold_multiplier: 2.5,
detect_citation_markers: false,
citation_font_size_ratio: 0.75,
merge_tm_tj_runs: true,
}
}
}
impl SpanMergingConfig {
pub fn new() -> Self {
Self::default()
}
pub fn aggressive() -> Self {
Self {
space_threshold_em_ratio: 0.15,
conservative_threshold_pt: 0.1,
column_boundary_threshold_pt: 5.0,
severe_overlap_threshold_pt: -0.5,
use_adaptive_threshold: false,
adaptive_config: None,
detect_email_patterns: false,
email_threshold_multiplier: 2.5,
detect_citation_markers: false,
citation_font_size_ratio: 0.75,
merge_tm_tj_runs: true,
}
}
pub fn conservative() -> Self {
Self {
space_threshold_em_ratio: 0.33,
conservative_threshold_pt: 0.3,
column_boundary_threshold_pt: 5.0,
severe_overlap_threshold_pt: -0.5,
use_adaptive_threshold: false,
adaptive_config: None,
detect_email_patterns: false,
email_threshold_multiplier: 2.5,
detect_citation_markers: false,
citation_font_size_ratio: 0.75,
merge_tm_tj_runs: true,
}
}
pub fn custom(space_threshold_em: f32, conservative_pt: f32, column_boundary_pt: f32, overlap_pt: f32) -> Self {
Self {
space_threshold_em_ratio: space_threshold_em,
conservative_threshold_pt: conservative_pt,
column_boundary_threshold_pt: column_boundary_pt,
severe_overlap_threshold_pt: overlap_pt,
use_adaptive_threshold: false,
adaptive_config: None,
detect_email_patterns: false,
email_threshold_multiplier: 2.5,
detect_citation_markers: false,
citation_font_size_ratio: 0.75,
merge_tm_tj_runs: true,
}
}
pub fn adaptive() -> Self {
Self {
space_threshold_em_ratio: 0.25,
conservative_threshold_pt: 0.1,
column_boundary_threshold_pt: 5.0,
severe_overlap_threshold_pt: -0.5,
use_adaptive_threshold: true,
adaptive_config: Some(crate::extractors::gap_statistics::AdaptiveThresholdConfig::default()),
detect_email_patterns: false,
email_threshold_multiplier: 2.5,
detect_citation_markers: false,
citation_font_size_ratio: 0.75,
merge_tm_tj_runs: true,
}
}
pub fn adaptive_with_config(adaptive_config: crate::extractors::gap_statistics::AdaptiveThresholdConfig) -> Self {
Self {
space_threshold_em_ratio: 0.25,
conservative_threshold_pt: 0.1,
column_boundary_threshold_pt: 5.0,
severe_overlap_threshold_pt: -0.5,
use_adaptive_threshold: true,
adaptive_config: Some(adaptive_config),
detect_email_patterns: false,
email_threshold_multiplier: 2.5,
detect_citation_markers: false,
citation_font_size_ratio: 0.75,
merge_tm_tj_runs: true,
}
}
pub fn legacy() -> Self {
Self {
space_threshold_em_ratio: 0.25,
conservative_threshold_pt: 0.1,
column_boundary_threshold_pt: 5.0,
severe_overlap_threshold_pt: -0.5,
use_adaptive_threshold: false,
adaptive_config: None,
detect_email_patterns: false,
email_threshold_multiplier: 2.5,
detect_citation_markers: false,
citation_font_size_ratio: 0.75,
merge_tm_tj_runs: true,
}
}
}
fn corrected_space_gap(raw_gap: f32, reliable_widths: bool, bbox_width: f32, text_empty: bool) -> f32 {
if !reliable_widths && raw_gap < 0.0 && bbox_width > 0.0 && !text_empty {
raw_gap + bbox_width * (1.0 - 1.0 / 1.22)
} else {
raw_gap
}
}
#[inline]
pub(crate) fn starts_with_agl_ligature(text: &str) -> bool {
let mut chars = text.chars();
let Some(first) = chars.next() else {
return false;
};
if ('\u{FB00}'..='\u{FB06}').contains(&first) && chars.next().is_none() {
return true;
}
matches!(text, "ff" | "fi" | "fl" | "ffi" | "ffl")
}
pub(crate) fn is_monospace_font(font_name: &str) -> bool {
let lower = font_name.to_lowercase();
if lower.contains("mono") && !lower.contains("monotype") {
return true;
}
const MONO_MARKERS: &[&str] = &[
"courier",
"consolas",
"menlo",
"fira code", "source code",
"inconsolata",
"cmtt", "letter gothic",
"ocr ", "fixedsys",
"terminal",
];
MONO_MARKERS.iter().any(|m| lower.contains(m))
}
pub(crate) fn is_pictographic(c: char) -> bool {
matches!(c as u32,
0x1F300..=0x1FAFF | 0x1F000..=0x1F0FF | 0x2600..=0x27BF | 0xFE0F) }
pub(crate) fn strip_cjk_digit_boundary_spaces(text: &str) -> String {
if !text.contains(' ') {
return text.to_string();
}
let is_cjk = |c: char| {
matches!(c as u32,
0x3040..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0x20000..=0x2A6DF | 0xFF66..=0xFF9F )
};
let is_cjk_or_hangul = |c: char| {
is_cjk(c)
|| matches!(c as u32,
0xAC00..=0xD7A3 | 0x1100..=0x11FF | 0x3130..=0x318F )
};
let is_hug_bracket = |c: char| matches!(c, '(' | ')' | '[' | ']' | '{' | '}');
let chars: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len());
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == ' ' && i > 0 && i + 1 < chars.len() {
let (p, n) = (chars[i - 1], chars[i + 1]);
if (is_cjk(p) && n.is_ascii_digit()) || (p.is_ascii_digit() && is_cjk(n)) {
i += 1;
continue;
}
if (is_cjk_or_hangul(p) && is_hug_bracket(n)) || (is_hug_bracket(p) && is_cjk_or_hangul(n)) {
i += 1;
continue;
}
}
out.push(c);
i += 1;
}
out
}
pub(crate) fn strip_prime_decimal_boundary_spaces(text: &str) -> String {
if !text.contains(' ') {
return text.to_string();
}
let is_prime = |c: char| matches!(c, '\u{2032}' | '\u{2033}' | '\u{2034}');
let chars: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len());
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == ' ' && i > 0 && i + 1 < chars.len() {
let (p, n) = (chars[i - 1], chars[i + 1]);
if is_prime(p) && n == '.' {
i += 1;
continue;
}
if p == '.' && n.is_ascii_digit() && i >= 2 && is_prime(chars[i - 2]) {
i += 1;
continue;
}
}
out.push(c);
i += 1;
}
out
}
fn decimal_gap_has_ink(ink_boxes: &[Rect], left: &Rect, right: &Rect) -> bool {
const EPS: f32 = 0.01;
let gap_start = left.x + left.width;
let gap_end = right.x;
if gap_end - gap_start <= 2.0 * EPS {
return false;
}
let band_bottom = left.y.min(right.y);
let band_top = (left.y + left.height).max(right.y + right.height);
ink_boxes.iter().any(|b| {
b.x + b.width > gap_start + EPS && b.x < gap_end - EPS && b.y < band_top && b.y + b.height > band_bottom
})
}
fn gap_has_intervening_glyph(ink_boxes: &[Rect], left: &Rect, right: &Rect) -> bool {
let gap_start = left.x + left.width;
let gap_end = right.x;
let gap_w = gap_end - gap_start;
if gap_w <= 0.5 {
return false;
}
let band_bottom = left.y.min(right.y);
let band_top = (left.y + left.height).max(right.y + right.height);
ink_boxes.iter().any(|b| {
let overlap = (b.x + b.width).min(gap_end) - b.x.max(gap_start);
overlap > gap_w * 0.35 && b.y < band_top && b.y + b.height > band_bottom
})
}
fn should_insert_space(
preceding_text: &str,
following_text: &str,
gap_pt: f32,
font_size: f32,
font_name: &str,
fonts: &std::collections::HashMap<String, std::sync::Arc<crate::fonts::FontInfo>>,
tj_offset_triggered: bool,
config: &SpanMergingConfig,
prev_bbox: Option<&crate::geometry::Rect>,
next_bbox: Option<&crate::geometry::Rect>,
prev_font_size: f32,
next_font_size: f32,
) -> SpaceDecision {
if has_boundary_space(preceding_text, following_text) {
return SpaceDecision::no_space(SpaceSource::AlreadyPresent, 1.0);
}
if let (Some(pc), Some(nc)) = (preceding_text.chars().next_back(), following_text.chars().next()) {
use crate::text::complex_script_detector::{detect_complex_script, is_complex_script_mark};
if is_complex_script_mark(pc as u32) && detect_complex_script(nc as u32).is_some() {
return SpaceDecision::no_space(SpaceSource::NoSpace, 0.9);
}
}
if gap_pt >= 0.0
&& preceding_text.chars().next_back().is_some_and(is_pictographic)
&& following_text.chars().next().is_some_and(char::is_alphabetic)
{
return SpaceDecision::insert(SpaceSource::GeometricGap, 0.85);
}
if config.detect_email_patterns && is_email_context(preceding_text, following_text) {
let geometric_threshold = if let Some(font_info) = fonts.get(font_name) {
let space_width_units = font_info.get_space_glyph_width();
let space_width_pt = (space_width_units / 1000.0) * font_size;
let word_margin_ratio = 0.5;
space_width_pt * word_margin_ratio
} else {
font_size * 0.25
};
let email_threshold = geometric_threshold * config.email_threshold_multiplier;
if gap_pt > email_threshold {
tracing::trace!(
"Email context detected: gap={:.2}pt > {:.2}pt email threshold - inserting space",
gap_pt,
email_threshold
);
return SpaceDecision::insert(SpaceSource::GeometricGap, 0.85);
}
tracing::trace!(
"Email context detected: gap={:.2}pt <= {:.2}pt email threshold - suppressing space",
gap_pt,
email_threshold
);
return SpaceDecision::no_space(SpaceSource::NoSpace, 1.0);
}
if let (Some(prev_box), Some(next_box)) = (prev_bbox, next_bbox) {
let y_diff = (prev_box.y - next_box.y).abs();
let line_break_threshold = font_size * 0.5;
let is_line_break = y_diff > line_break_threshold;
if is_line_break {
let same_column = (prev_box.left() - next_box.left()).abs() < (font_size * 2.0);
if same_column {
tracing::trace!(
"Detected line break: y_diff={:.2}pt > {:.2}pt threshold, same_column=true",
y_diff,
line_break_threshold
);
if preceding_text.ends_with('-') {
tracing::trace!(
"Soft hyphen detected: '{}' ends with '-', suppressing space insertion",
preceding_text
);
return SpaceDecision::no_space(SpaceSource::NoSpace, 1.0);
} else {
tracing::trace!("Hard line break detected: inserting space for word continuation");
return SpaceDecision::insert(SpaceSource::GeometricGap, 0.9);
}
}
}
}
if config.detect_citation_markers
&& is_citation_context(prev_bbox, next_bbox, font_size, prev_font_size, next_font_size)
{
let citation_geometric_threshold = if let Some(font_info) = fonts.get(font_name) {
let space_width_units = font_info.get_space_glyph_width();
let space_width_pt = (space_width_units / 1000.0) * font_size;
space_width_pt * 0.5
} else {
font_size * 0.25
};
if tj_offset_triggered || gap_pt > citation_geometric_threshold {
tracing::trace!(
"Citation context detected: using relaxed spacing rules (gap={:.2}pt, tj={})",
gap_pt,
tj_offset_triggered
);
return SpaceDecision::insert(SpaceSource::TjOffset, 0.90);
}
}
let geometric_threshold = if let Some(font_info) = fonts.get(font_name) {
let space_width_units = font_info.get_space_glyph_width(); let space_width_pt = (space_width_units / 1000.0) * font_size;
let mut word_margin_ratio = if is_monospace_font(font_name) {
1.2
} else {
0.5 };
if (prev_font_size - next_font_size).abs() > 0.5 {
word_margin_ratio *= 0.7;
}
let threshold = space_width_pt * word_margin_ratio;
tracing::trace!(
"Font-aware spacing for '{}' @ {:.1}pt: space_width={:.1}pt, threshold={:.1}pt (mono={})",
font_name,
font_size,
space_width_pt,
threshold,
is_monospace_font(font_name),
);
threshold
} else {
tracing::trace!(
"Font '{}' not found in font map, using default 0.25em threshold for {:.1}pt",
font_name,
font_size
);
font_size * 0.25
};
let ligature_boundary = starts_with_agl_ligature(following_text)
|| preceding_text
.chars()
.last()
.map(|c| ('\u{FB00}'..='\u{FB06}').contains(&c))
.unwrap_or(false);
let geometric_threshold = if ligature_boundary {
geometric_threshold * 1.5
} else {
geometric_threshold
};
let geometric_suggests_space = gap_pt > geometric_threshold;
let kerning_guard_threshold = if fonts.contains_key(font_name) {
Some(geometric_threshold * 1.5)
} else {
None
};
if let Some(thr) = kerning_guard_threshold
&& gap_pt < thr
{
let prev_last = preceding_text.chars().last();
let next_first = following_text.chars().next();
if let (Some(pc), Some(nc)) = (prev_last, next_first) {
if pc.is_lowercase() && nc.is_lowercase() {
tracing::trace!(
"intra-word kerning guard: suppressing space between '{pc}' and '{nc}' (gap={gap_pt:.2}pt < {thr:.2}pt, threshold = 0.75× space-glyph width)"
);
return SpaceDecision::no_space(SpaceSource::IntraWordKerning, 0.9);
}
}
}
if tj_offset_triggered && geometric_suggests_space {
tracing::trace!(
"Space decision: CONSENSUS - both TJ and geometric signals triggered (gap={:.2}pt > {:.2}pt) - inserting space",
gap_pt,
geometric_threshold
);
return SpaceDecision::insert(SpaceSource::TjOffset, 1.0);
}
if tj_offset_triggered && gap_pt > geometric_threshold * 0.5 {
tracing::trace!(
"Space decision: TJ + relaxed geometric (gap={:.2}pt > {:.2}pt relaxed threshold) - inserting space",
gap_pt,
geometric_threshold * 0.5
);
return SpaceDecision::insert(SpaceSource::TjOffset, 0.9);
}
if tj_offset_triggered != geometric_suggests_space
&& let (Some(prev_box), Some(next_box)) = (prev_bbox, next_bbox)
{
let (characters, context) = build_boundary_characters(
preceding_text,
following_text,
prev_box,
next_box,
font_size,
tj_offset_triggered,
);
let script = DocumentScript::detect_from_characters(&characters);
let detector = WordBoundaryDetector::new()
.with_document_script(script)
.with_geometric_gap_ratio(0.5);
let boundaries = detector.detect_word_boundaries(&characters, &context);
if !boundaries.is_empty() {
tracing::trace!(
"Space decision: WordBoundaryDetector resolved conflict (TJ={}, geo={}) - inserting space",
tj_offset_triggered,
geometric_suggests_space
);
return SpaceDecision::insert(SpaceSource::WordBoundaryAnalysis, 0.85);
}
}
if gap_pt > geometric_threshold {
tracing::trace!(
"Space decision: STRONG GEOMETRIC - gap={:.2}pt > {:.2}pt threshold - inserting space",
gap_pt,
geometric_threshold
);
return SpaceDecision::insert(SpaceSource::GeometricGap, 0.95);
}
let min_token_gap = 0.01; if gap_pt > min_token_gap {
let prev_last = preceding_text.chars().last();
let next_first = following_text.chars().next();
if let (Some(pc), Some(nc)) = (prev_last, next_first) {
let prev_is_value_end = pc.is_ascii_digit() || pc == '%' || pc == ')' || pc == ']';
let digit_digit = nc.is_ascii_digit() && pc.is_ascii_digit();
let digit_digit_gap_ok = !digit_digit || gap_pt > geometric_threshold * 0.5;
let next_is_value_start = nc == '$'
|| nc == '('
|| nc == '['
|| (nc == '-' && following_text.len() > 1)
|| (nc.is_ascii_digit() && prev_is_value_end && digit_digit_gap_ok);
let text_then_currency =
(pc.is_ascii_alphabetic() || pc.is_ascii_digit()) && (nc == '$' || nc == '€' || nc == '£');
if (prev_is_value_end && next_is_value_start) || text_then_currency {
tracing::trace!(
"Space decision: SEPARATE VALUES - gap={:.2}pt > {:.2}pt min, prev='{}', next='{}' - inserting space",
gap_pt,
min_token_gap,
crate::utils::safe_suffix(preceding_text, 5),
crate::utils::safe_prefix(following_text, 5),
);
return SpaceDecision::insert(SpaceSource::GeometricGap, 0.85);
}
}
}
tracing::trace!(
"Space decision: Insufficient consensus (TJ={}, gap={:.2}pt <= {:.2}pt) - no space",
tj_offset_triggered,
gap_pt,
geometric_threshold
);
SpaceDecision::no_space(SpaceSource::NoSpace, 1.0)
}
fn has_boundary_space(preceding: &str, following: &str) -> bool {
let has_trailing_space = preceding.ends_with(|c: char| c.is_whitespace());
let has_leading_space = following.starts_with(|c: char| c.is_whitespace());
has_trailing_space || has_leading_space
}
fn build_boundary_characters(
prev_text: &str,
next_text: &str,
prev_bbox: &Rect,
next_bbox: &Rect,
font_size: f32,
tj_offset_triggered: bool,
) -> (Vec<CharacterInfo>, BoundaryContext) {
let prev_last_char = prev_text.chars().last().unwrap_or(' ');
let next_first_char = next_text.chars().next().unwrap_or(' ');
let prev_char_count = prev_text.len().max(1) as f32;
let prev_char_width = prev_bbox.width / prev_char_count;
let prev_last_x = prev_bbox.x + prev_bbox.width - prev_char_width;
let next_char_count = next_text.len().max(1) as f32;
let next_char_width = next_bbox.width / next_char_count;
let characters = vec![
CharacterInfo {
code: prev_last_char as u32,
glyph_id: None,
width: prev_char_width,
x_position: prev_last_x,
tj_offset: if tj_offset_triggered { Some(-200) } else { None },
font_size,
is_ligature: false, original_ligature: None,
protected_from_split: false,
},
CharacterInfo {
code: next_first_char as u32,
glyph_id: None,
width: next_char_width,
x_position: next_bbox.x,
tj_offset: None,
font_size,
is_ligature: false, original_ligature: None,
protected_from_split: false,
},
];
let context = BoundaryContext {
font_size,
horizontal_scaling: 100.0, word_spacing: 0.0,
char_spacing: 0.0,
};
(characters, context)
}
fn is_email_context(preceding_text: &str, following_text: &str) -> bool {
let prev_start = preceding_text.len().saturating_sub(64);
let prev_start = {
let mut i = prev_start;
while i < preceding_text.len() && !preceding_text.is_char_boundary(i) {
i += 1;
}
i
};
let prev = preceding_text[prev_start..].trim_end();
let next = following_text.trim_start();
if prev.contains('@') {
let after_at = prev.split('@').next_back().unwrap_or("");
if !after_at.is_empty() && next.starts_with('.') {
return true;
}
if after_at.ends_with('.') && next.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
return true;
}
}
if prev.ends_with('@') && next.chars().next().is_some_and(|c| c.is_ascii_alphanumeric()) {
return true;
}
false
}
fn is_citation_context(
prev_bbox: Option<&crate::geometry::Rect>,
next_bbox: Option<&crate::geometry::Rect>,
current_font_size: f32,
prev_font_size: f32,
next_font_size: f32,
) -> bool {
let prev_ratio = prev_font_size / current_font_size;
let next_ratio = next_font_size / current_font_size;
const SUPERSCRIPT_MIN: f32 = 0.5;
const SUPERSCRIPT_MAX: f32 = 0.75;
let prev_is_superscript = (SUPERSCRIPT_MIN..=SUPERSCRIPT_MAX).contains(&prev_ratio);
let next_is_superscript = (SUPERSCRIPT_MIN..=SUPERSCRIPT_MAX).contains(&next_ratio);
if let (Some(prev_box), Some(next_box)) = (prev_bbox, next_bbox) {
let vertical_offset = (prev_box.y - next_box.y).abs();
let is_raised = vertical_offset > (current_font_size * 0.2);
if (prev_is_superscript || next_is_superscript) && is_raised {
return true;
}
}
prev_is_superscript || next_is_superscript
}
#[derive(Debug)]
struct TjBuffer {
unicode: String,
start_matrix: Matrix,
font_name: Option<String>,
fill_color_rgb: (f32, f32, f32),
char_space: f32,
word_space: f32,
horizontal_scaling: f32,
mcid: Option<u32>,
accumulated_width: f32,
cached_font: Option<Arc<FontInfo>>,
effective_font_size: f32,
font_weight: FontWeight,
is_italic: bool,
is_monospace: bool,
char_widths: Vec<f32>,
user_pos_x: f32,
user_pos_y: f32,
user_h_scale: f32,
rotation_degrees: f32,
mirrored: bool,
wmode: u8,
text_rise: f32,
render_mode: u8,
}
fn snap_run_rotation(combined: &Matrix) -> f32 {
const SNAP_TOL_DEG: f32 = 5.0;
let (a, b, c, d) = (combined.a, combined.b, combined.c, combined.d);
if b.abs() < 1e-4 && c.abs() < 1e-4 {
return if a < 0.0 { 180.0 } else { 0.0 };
}
let mut deg = b.atan2(a).to_degrees();
while deg > 180.0 {
deg -= 360.0;
}
while deg <= -180.0 {
deg += 360.0;
}
let det = a * d - b * c;
if det < 0.0 {
return if deg.abs() < SNAP_TOL_DEG { 0.0 } else { deg };
}
for &q in &[0.0_f32, 90.0, 180.0, -90.0] {
if (deg - q).abs() <= SNAP_TOL_DEG {
return q;
}
}
deg
}
fn run_is_mirrored(combined: &Matrix) -> bool {
combined.a * combined.d - combined.b * combined.c < 0.0
}
impl TjBuffer {
fn new(
state: &crate::content::graphics_state::GraphicsState,
mcid: Option<u32>,
cached_font: Option<Arc<FontInfo>>,
) -> Self {
let combined = state.ctm.multiply(&state.text_matrix);
let effective_font_size = state.font_size * (combined.d * combined.d + combined.b * combined.b).sqrt();
let user_h_scale = (combined.a * combined.a + combined.c * combined.c).sqrt();
let font_weight = match &cached_font {
Some(f) if f.is_bold() => FontWeight::Bold,
_ => FontWeight::Normal,
};
let is_italic = cached_font.as_ref().map(|f| f.is_italic()).unwrap_or(false);
let is_invisible_or_glyphless = state.render_mode == 3
|| state.render_mode == 7
|| cached_font
.as_ref()
.is_some_and(|f| f.base_font.to_uppercase().contains("GLYPHLESS"));
let is_monospace = !is_invisible_or_glyphless
&& cached_font
.as_ref()
.is_some_and(|f| f.flags.is_some_and(|flags| flags & 1 != 0) || is_monospace_font(&f.base_font));
let rotation_degrees = snap_run_rotation(&combined);
let text_pos = state.text_matrix.transform_point(0.0, 0.0);
let user_pos = state.ctm.transform_point(text_pos.x, text_pos.y);
Self {
unicode: String::new(),
start_matrix: state.text_matrix,
font_name: state.font_name.clone(),
fill_color_rgb: state.fill_color_rgb,
char_space: state.char_space,
word_space: state.word_space,
horizontal_scaling: state.horizontal_scaling,
mcid,
accumulated_width: 0.0,
cached_font,
effective_font_size,
font_weight,
is_italic,
is_monospace,
char_widths: Vec::new(),
user_pos_x: user_pos.x,
user_pos_y: user_pos.y,
user_h_scale,
rotation_degrees,
mirrored: run_is_mirrored(&combined),
wmode: state.text_wmode,
text_rise: if state.font_size > 0.0 {
state.text_rise / state.font_size
} else {
0.0
},
render_mode: state.render_mode,
}
}
fn is_empty(&self) -> bool {
self.unicode.is_empty()
}
fn push_filtered_unicode(unicode: &mut String, s: &str) {
for ch in s.chars() {
if ch >= '\x20' || ch == '\t' || ch == '\n' || ch == '\r' {
unicode.push(ch);
}
}
}
fn append(&mut self, bytes: &[u8]) -> Result<()> {
let bytes = if bytes.len() > 32_767 { &bytes[..32_767] } else { bytes };
let font = self.cached_font.as_deref();
if let Some(font) = font
&& font.subtype != "Type0"
{
if font.to_unicode.is_none() && bytes.len() >= 2 {
let has_high = bytes.iter().any(|&b| b >= 0x80);
if has_high
&& let Ok(decoded) = std::str::from_utf8(bytes)
&& decoded.chars().any(|c| c as u32 > 0xFF)
{
for ch in decoded.chars() {
self.unicode.push(ch);
}
return Ok(());
}
}
let table = font.get_byte_to_char_table();
for &byte in bytes {
let c = table[byte as usize];
if c != '\0' {
self.unicode.push(c);
} else if let Some(s) = font.char_to_unicode(byte as u32) {
if s != "\u{FFFD}" || preserve_unmapped_glyphs() {
Self::push_filtered_unicode(&mut self.unicode, &s);
}
} else {
let fb = fallback_char_to_unicode(byte as u32);
if fb != "\u{FFFD}" || preserve_unmapped_glyphs() {
Self::push_filtered_unicode(&mut self.unicode, &fb);
}
}
}
return Ok(());
}
let unicode_text = decode_text_to_unicode(
bytes,
font,
DecodePolicy {
preserve_unmapped: preserve_unmapped_glyphs(),
decompose_ligatures: false,
question_mark_for_invalid: true,
},
None,
);
self.unicode.push_str(&unicode_text);
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub enum ArtifactType {
Pagination(PaginationSubtype),
Layout,
Page,
Background,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub enum PaginationSubtype {
Header,
Footer,
Watermark,
PageNumber,
Other,
}
#[derive(Debug, Clone, Default)]
struct MarkedContentContext {
#[allow(dead_code)]
tag: String,
is_artifact: bool,
artifact_type: Option<ArtifactType>,
actual_text: Option<String>,
actual_text_emitted: bool,
#[allow(dead_code)]
expansion: Option<String>,
is_excluded_layer: bool,
is_placed_pdf: bool,
own_mcid: Option<u32>,
}
#[derive(Debug)]
pub struct TextExtractor<'doc> {
state_stack: GraphicsStateStack,
fonts: HashMap<String, Arc<FontInfo>>,
spans: Vec<TextSpan>,
chars: Vec<TextChar>,
resources: Option<Object>,
document: Option<&'doc crate::document::PdfDocument>,
processed_xobjects: HashSet<(ObjectRef, [i64; 6])>,
cached_xobject_refs: HashMap<String, Option<ObjectRef>>,
xobject_depth: u32,
xobject_decode_count: u32,
config: TextExtractionConfig,
merging_config: SpanMergingConfig,
current_mcid: Option<u32>,
mc_actualtext_mcids: HashSet<u32>,
marked_content_stack: Vec<MarkedContentContext>,
saw_reversed_chars: bool,
inside_artifact: bool,
excluded_layers: HashSet<String>,
inside_excluded_layer: bool,
inside_placed_pdf: bool,
placed_pdf_keep: bool,
excluded_inks: HashSet<String>,
inside_excluded_ink: bool,
extract_spans: bool,
tj_span_buffer: Option<TjBuffer>,
span_sequence_counter: usize,
tj_offset_history: Vec<f32>,
tj_sum: f64,
tj_sum_sq: f64,
tj_stats_len: usize,
tj_character_array: Vec<CharacterInfo>,
current_x_position: f32,
word_boundary_mode: WordBoundaryMode,
cached_current_font: Option<Arc<FontInfo>>,
extraction_width_tables: HashMap<usize, (Arc<FontInfo>, Arc<[f32; 256]>)>,
cached_extraction_widths: Option<Arc<[f32; 256]>>,
mcid_scope_stack: Vec<crate::structure::McidScope>,
}
pub(super) const LOG_TARGET: &str = module_path!();
impl<'doc> TextExtractor<'doc> {
const DEDUP_OVERLAP_RATIO: f32 = 0.30;
const DEDUP_OVERLAP_CAP_PT: f32 = 2.0;
const MAX_XOBJECT_DEPTH: u32 = 10;
const MAX_XOBJECT_DECODES: u32 = 500;
}
impl<'doc> Default for TextExtractor<'doc> {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod config_tests;
mod adaptive_spacing;
mod advance;
mod clustering;
mod marked_content;
mod operators;
mod run;
mod setup;
mod span_merging;
mod span_ordering;
mod tj_arrays;
mod xobjects;