use super::super::*;
use super::extraction_and_rotation::create_test_font;
use std::sync::Arc;
#[test]
fn test_deduplicate_content_not_overlapping() {
let mut extractor = TextExtractor::new();
extractor.spans = vec![
TextSpan {
provenance: None,
text_rise: 0.0,
artifact_type: None,
text: "Hello World".to_string(),
bbox: Rect::new(100.0, 700.0, 60.0, 12.0),
font_name: "F1".to_string(),
font_size: 12.0,
font_weight: FontWeight::Normal,
color: Color::black(),
mcid: None,
mcid_scope: None,
sequence: 0,
split_boundary_before: false,
offset_semantic: false,
is_italic: false,
is_monospace: false,
char_spacing: 0.0,
word_spacing: 0.0,
horizontal_scaling: 100.0,
primary_detected: false,
char_widths: vec![],
char_x_offsets: Vec::new(),
heading_level: None,
rotation_degrees: 0.0,
wmode: 0,
rtl_draw_logical: false,
mirrored: false,
page_rotation_applied: 0,
},
TextSpan {
provenance: None,
text_rise: 0.0,
artifact_type: None,
text: "Hello World".to_string(), bbox: Rect::new(500.0, 700.0, 60.0, 12.0), font_name: "F1".to_string(),
font_size: 12.0,
font_weight: FontWeight::Normal,
color: Color::black(),
mcid: None,
mcid_scope: None,
sequence: 1,
split_boundary_before: false,
offset_semantic: false,
is_italic: false,
is_monospace: false,
char_spacing: 0.0,
word_spacing: 0.0,
horizontal_scaling: 100.0,
primary_detected: false,
char_widths: vec![],
char_x_offsets: Vec::new(),
heading_level: None,
rotation_degrees: 0.0,
wmode: 0,
rtl_draw_logical: false,
mirrored: false,
page_rotation_applied: 0,
},
];
extractor.deduplicate_overlapping_spans();
assert_eq!(
extractor.spans.len(),
2,
"Non-overlapping content should not be deduped"
);
}
#[test]
fn test_advance_position_no_font() {
let mut extractor = TextExtractor::new();
extractor.state_stack.current_mut().font_size = 12.0;
extractor.state_stack.current_mut().horizontal_scaling = 100.0;
let width = extractor.advance_position_for_string(b"Hello", true).unwrap();
assert!(width > 0.0, "Width should be positive even without font");
}
#[test]
fn test_advance_position_with_font() {
let mut extractor = TextExtractor::new();
let font = create_test_font();
extractor.add_font("F1".to_string(), font);
let current_font = extractor.fonts.get("F1").cloned();
extractor.set_cached_current_font(current_font);
extractor.state_stack.current_mut().font_size = 12.0;
extractor.state_stack.current_mut().font_name = Some("F1".to_string());
extractor.state_stack.current_mut().horizontal_scaling = 100.0;
let width = extractor.advance_position_for_string(b"Hi", true).unwrap();
assert!(width > 0.0, "Width should be positive with font");
}
#[test]
fn test_advance_position_with_word_space() {
let mut extractor = TextExtractor::new();
extractor.state_stack.current_mut().font_size = 12.0;
extractor.state_stack.current_mut().horizontal_scaling = 100.0;
extractor.state_stack.current_mut().word_space = 5.0;
let width = extractor.advance_position_for_string(b"A B", true).unwrap();
assert!(width > 0.0);
}
#[test]
fn test_insert_space_as_span() {
let mut extractor = TextExtractor::new();
extractor.state_stack.current_mut().font_size = 12.0;
extractor.state_stack.current_mut().horizontal_scaling = 100.0;
extractor.state_stack.current_mut().font_name = Some("F1".to_string());
let before = extractor.spans.len();
extractor.insert_space_as_span().unwrap();
assert_eq!(extractor.spans.len(), before + 1);
assert_eq!(extractor.spans.last().unwrap().text, " ");
assert!(extractor.spans.last().unwrap().offset_semantic);
}
#[test]
fn test_adaptive_threshold_with_justified_text() {
let config = TextExtractionConfig {
use_adaptive_tj_threshold: true,
word_margin_ratio: 0.1,
..TextExtractionConfig::default()
};
let mut extractor = TextExtractor::with_config(config);
extractor.state_stack.current_mut().font_size = 12.0;
for i in 0..100 {
extractor
.tj_offset_history
.push(if i % 2 == 0 { -50.0 } else { -200.0 });
}
let threshold = extractor.calculate_adaptive_tj_threshold();
assert!(threshold < 0.0);
}
#[test]
fn test_adaptive_threshold_with_font_name() {
let config = TextExtractionConfig {
use_adaptive_tj_threshold: true,
word_margin_ratio: 0.1,
..TextExtractionConfig::default()
};
let mut extractor = TextExtractor::with_config(config);
let font = create_test_font();
extractor.add_font("F1".to_string(), font);
extractor.state_stack.current_mut().font_size = 12.0;
extractor.state_stack.current_mut().font_name = Some("F1".to_string());
let threshold = extractor.calculate_adaptive_tj_threshold();
assert!(threshold < 0.0);
}
#[test]
fn test_analyze_tj_distribution_zero_mean() {
let mut extractor = TextExtractor::new();
extractor.tj_offset_history = vec![100.0, -100.0, 100.0, -100.0];
let (is_justified, cv) = extractor.analyze_tj_distribution();
assert_eq!(cv, 0.0);
}
#[test]
fn test_quote_operator_span_mode() {
let mut extractor = TextExtractor::new();
extractor.merging_config = SpanMergingConfig::legacy();
let font = create_test_font();
extractor.add_font("F1".to_string(), font);
let stream = b"BT /F1 12 Tf 14 TL 100 700 Td (Line1) Tj (Line2) ' ET";
let spans = extractor.extract_text_spans(stream).unwrap();
let text: String = spans.iter().map(|s| s.text.as_str()).collect::<Vec<_>>().join("");
assert!(text.contains("Line1"), "Should contain Line1, got: {}", text);
assert!(text.contains("Line2"), "Should contain Line2, got: {}", text);
}
#[test]
fn test_double_quote_operator_span_mode() {
let mut extractor = TextExtractor::new();
extractor.merging_config = SpanMergingConfig::legacy();
let font = create_test_font();
extractor.add_font("F1".to_string(), font);
let stream = b"BT /F1 12 Tf 14 TL 100 700 Td 1 2 (Text) \" ET";
let spans = extractor.extract_text_spans(stream).unwrap();
let text: String = spans.iter().map(|s| s.text.as_str()).collect::<Vec<_>>().join("");
assert!(text.contains("Text"), "Should extract text, got: {}", text);
}
#[test]
fn test_sort_spans_by_columns() {
let mut extractor = TextExtractor::new();
let columns = vec![(0.0, 250.0), (300.0, 550.0)];
extractor.spans = vec![
TextSpan {
provenance: None,
text_rise: 0.0,
artifact_type: None,
text: "Right Col".to_string(),
bbox: Rect::new(350.0, 700.0, 100.0, 12.0),
font_name: "F1".to_string(),
font_size: 12.0,
font_weight: FontWeight::Normal,
color: Color::black(),
mcid: None,
mcid_scope: None,
sequence: 0,
split_boundary_before: false,
offset_semantic: false,
is_italic: false,
is_monospace: false,
char_spacing: 0.0,
word_spacing: 0.0,
horizontal_scaling: 100.0,
primary_detected: false,
char_widths: vec![],
char_x_offsets: Vec::new(),
heading_level: None,
rotation_degrees: 0.0,
wmode: 0,
rtl_draw_logical: false,
mirrored: false,
page_rotation_applied: 0,
},
TextSpan {
provenance: None,
text_rise: 0.0,
artifact_type: None,
text: "Left Col".to_string(),
bbox: Rect::new(50.0, 700.0, 100.0, 12.0),
font_name: "F1".to_string(),
font_size: 12.0,
font_weight: FontWeight::Normal,
color: Color::black(),
mcid: None,
mcid_scope: None,
sequence: 1,
split_boundary_before: false,
offset_semantic: false,
is_italic: false,
is_monospace: false,
char_spacing: 0.0,
word_spacing: 0.0,
horizontal_scaling: 100.0,
primary_detected: false,
char_widths: vec![],
char_x_offsets: Vec::new(),
heading_level: None,
rotation_degrees: 0.0,
wmode: 0,
rtl_draw_logical: false,
mirrored: false,
page_rotation_applied: 0,
},
];
extractor.sort_spans_by_columns(&columns);
assert_eq!(extractor.spans[0].text, "Left Col");
assert_eq!(extractor.spans[1].text, "Right Col");
}
#[test]
fn test_tj_buffer_with_mcid() {
let state = crate::content::graphics_state::GraphicsStateStack::new();
let buffer = TjBuffer::new(state.current(), Some(42), None);
assert!(buffer.is_empty());
assert_eq!(buffer.mcid, Some(42));
}
#[test]
fn test_extractor_with_primary_word_boundary() {
let config = TextExtractionConfig {
word_boundary_mode: WordBoundaryMode::Primary,
..TextExtractionConfig::default()
};
let mut extractor = TextExtractor::with_config(config);
let font = create_test_font();
extractor.add_font("F1".to_string(), font);
extractor.merging_config = SpanMergingConfig::legacy();
let stream = b"BT /F1 12 Tf 100 700 Td (Hello) Tj ET";
let spans = extractor.extract_text_spans(stream).unwrap();
let text: String = spans.iter().map(|s| s.text.as_str()).collect::<Vec<_>>().join("");
assert!(
text.contains("Hello"),
"Primary mode should still extract text, got: {}",
text
);
}
#[test]
fn test_merge_prevents_double_space() {
let mut extractor = TextExtractor::new();
extractor.merging_config = SpanMergingConfig::legacy();
extractor.spans = vec![
TextSpan {
provenance: None,
text_rise: 0.0,
artifact_type: None,
text: "Hello ".to_string(), bbox: Rect::new(100.0, 700.0, 35.0, 12.0),
font_name: "F1".to_string(),
font_size: 12.0,
font_weight: FontWeight::Normal,
color: Color::black(),
mcid: None,
mcid_scope: None,
sequence: 0,
split_boundary_before: false,
offset_semantic: false,
is_italic: false,
is_monospace: false,
char_spacing: 0.0,
word_spacing: 0.0,
horizontal_scaling: 100.0,
primary_detected: false,
char_widths: vec![],
char_x_offsets: Vec::new(),
heading_level: None,
rotation_degrees: 0.0,
wmode: 0,
rtl_draw_logical: false,
mirrored: false,
page_rotation_applied: 0,
},
TextSpan {
provenance: None,
text_rise: 0.0,
artifact_type: None,
text: " World".to_string(), bbox: Rect::new(136.0, 700.0, 35.0, 12.0), font_name: "F1".to_string(),
font_size: 12.0,
font_weight: FontWeight::Normal,
color: Color::black(),
mcid: None,
mcid_scope: None,
sequence: 1,
split_boundary_before: true, offset_semantic: false,
is_italic: false,
is_monospace: false,
char_spacing: 0.0,
word_spacing: 0.0,
horizontal_scaling: 100.0,
primary_detected: false,
char_widths: vec![],
char_x_offsets: Vec::new(),
heading_level: None,
rotation_degrees: 0.0,
wmode: 0,
rtl_draw_logical: false,
mirrored: false,
page_rotation_applied: 0,
},
];
extractor.merge_adjacent_spans();
assert_eq!(extractor.spans.len(), 1);
assert!(!extractor.spans[0].text.contains(" "), "Should prevent triple space");
}
#[test]
fn test_extractor_with_config_copies_word_boundary_mode() {
let config = TextExtractionConfig {
word_boundary_mode: WordBoundaryMode::Primary,
..TextExtractionConfig::default()
};
let extractor = TextExtractor::with_config(config);
assert_eq!(extractor.word_boundary_mode, WordBoundaryMode::Primary);
}
#[test]
fn test_partition_boundary_at_start() {
let extractor = TextExtractor::new();
let chars = vec![
CharacterInfo {
code: 65,
glyph_id: None,
width: 10.0,
x_position: 0.0,
tj_offset: None,
font_size: 12.0,
is_ligature: false,
original_ligature: None,
protected_from_split: false,
},
CharacterInfo {
code: 66,
glyph_id: None,
width: 10.0,
x_position: 10.0,
tj_offset: None,
font_size: 12.0,
is_ligature: false,
original_ligature: None,
protected_from_split: false,
},
];
let clusters = extractor.partition_characters_by_boundaries(&chars, vec![0]);
assert!(!clusters.is_empty());
}
#[test]
fn test_fill_cmyk_then_change_color_space() {
let mut extractor = TextExtractor::new();
extractor
.execute_operator_public(Operator::SetFillCmyk {
c: 0.5,
m: 0.5,
y: 0.5,
k: 0.5,
})
.unwrap();
assert!(extractor.state_stack.current().fill_color_cmyk.is_some());
extractor
.execute_operator_public(Operator::SetFillColorSpace {
name: "DeviceRGB".to_string(),
})
.unwrap();
assert!(extractor.state_stack.current().fill_color_cmyk.is_none());
}
#[test]
fn test_bdc_with_mcid() {
let mut extractor = TextExtractor::new();
let mut props = HashMap::new();
props.insert("MCID".to_string(), Object::Integer(5));
extractor
.execute_operator_public(Operator::BeginMarkedContentDict {
tag: "P".to_string(),
properties: Box::new(Object::Dictionary(props)),
})
.unwrap();
assert_eq!(extractor.current_mcid, Some(5));
assert!(!extractor.inside_artifact);
}
#[test]
fn test_bdc_artifact_with_type() {
let mut extractor = TextExtractor::new();
let mut props = HashMap::new();
props.insert("Type".to_string(), Object::Name("Pagination".to_string()));
props.insert("Subtype".to_string(), Object::Name("Header".to_string()));
extractor
.execute_operator_public(Operator::BeginMarkedContentDict {
tag: "Artifact".to_string(),
properties: Box::new(Object::Dictionary(props)),
})
.unwrap();
assert!(extractor.inside_artifact);
}
#[test]
fn test_emc_resets_mcid() {
let mut extractor = TextExtractor::new();
extractor.current_mcid = Some(10);
extractor.marked_content_stack.push(MarkedContentContext {
artifact_type: None,
tag: "P".to_string(),
is_artifact: false,
actual_text: None,
expansion: None,
is_excluded_layer: false,
is_placed_pdf: false,
actual_text_emitted: false,
own_mcid: None,
});
extractor.execute_operator_public(Operator::EndMarkedContent).unwrap();
assert_eq!(extractor.current_mcid, None);
assert!(extractor.marked_content_stack.is_empty());
}
#[test]
fn test_emc_with_empty_stack() {
let mut extractor = TextExtractor::new();
extractor.execute_operator_public(Operator::EndMarkedContent).unwrap();
}
#[test]
fn test_bdc_with_actual_text() {
let mut extractor = TextExtractor::new();
let mut props = HashMap::new();
props.insert("ActualText".to_string(), Object::String(b"fi".to_vec()));
extractor
.execute_operator_public(Operator::BeginMarkedContentDict {
tag: "Span".to_string(),
properties: Box::new(Object::Dictionary(props)),
})
.unwrap();
let actual = extractor.get_current_actual_text();
assert_eq!(actual, Some("fi".to_string()));
}
#[test]
fn test_bdc_with_expansion() {
let mut extractor = TextExtractor::new();
let mut props = HashMap::new();
props.insert("E".to_string(), Object::String(b"PDF".to_vec()));
extractor
.execute_operator_public(Operator::BeginMarkedContentDict {
tag: "Span".to_string(),
properties: Box::new(Object::Dictionary(props)),
})
.unwrap();
let ctx = &extractor.marked_content_stack[0];
assert_eq!(ctx.expansion, Some("PDF".to_string()));
}
#[test]
fn test_do_operator_without_document() {
let mut extractor = TextExtractor::new();
extractor
.execute_operator_public(Operator::Do {
name: "Im1".to_string(),
})
.unwrap();
}
#[test]
fn test_flush_tj_span_buffer_empty_buffer() {
let mut extractor = TextExtractor::new();
let state = extractor.state_stack.current().clone();
extractor.tj_span_buffer = Some(TjBuffer::new(&state, None, None));
let before = extractor.spans.len();
extractor.flush_tj_span_buffer().unwrap();
assert_eq!(extractor.spans.len(), before);
}
#[test]
fn test_flush_tj_span_buffer_with_content() {
let mut extractor = TextExtractor::new();
let state_stack = crate::content::graphics_state::GraphicsStateStack::new();
let mut buffer = TjBuffer::new(state_stack.current(), Some(7), None);
buffer.append(b"Test").unwrap();
buffer.accumulated_width = 20.0;
extractor.tj_span_buffer = Some(buffer);
extractor.flush_tj_span_buffer().unwrap();
assert_eq!(extractor.spans.len(), 1);
assert!(extractor.spans[0].text.contains("Test"));
}
#[test]
fn test_tj_array_span_mode_with_space_insertion() {
let config = TextExtractionConfig {
use_adaptive_tj_threshold: false,
space_insertion_threshold: -120.0,
..TextExtractionConfig::default()
};
let mut extractor = TextExtractor::with_config(config);
extractor.merging_config = SpanMergingConfig::legacy();
let font = create_test_font();
extractor.add_font("F1".to_string(), font);
let stream = b"BT /F1 12 Tf 100 700 Td [(Word1) -500 (Word2)] TJ ET";
let spans = extractor.extract_text_spans(stream).unwrap();
let text: String = spans.iter().map(|s| s.text.as_str()).collect::<Vec<_>>().join("");
assert!(text.contains("Word1"), "Should contain Word1");
assert!(text.contains("Word2"), "Should contain Word2");
}
#[test]
fn test_sort_spans_single_column() {
let mut extractor = TextExtractor::new();
extractor.spans = vec![
TextSpan {
provenance: None,
text_rise: 0.0,
artifact_type: None,
text: "Line2".to_string(),
bbox: Rect::new(50.0, 680.0, 100.0, 12.0),
font_name: "F1".to_string(),
font_size: 12.0,
font_weight: FontWeight::Normal,
color: Color::black(),
mcid: None,
mcid_scope: None,
sequence: 0,
split_boundary_before: false,
offset_semantic: false,
is_italic: false,
is_monospace: false,
char_spacing: 0.0,
word_spacing: 0.0,
horizontal_scaling: 100.0,
primary_detected: false,
char_widths: vec![],
char_x_offsets: Vec::new(),
heading_level: None,
rotation_degrees: 0.0,
wmode: 0,
rtl_draw_logical: false,
mirrored: false,
page_rotation_applied: 0,
},
TextSpan {
provenance: None,
text_rise: 0.0,
artifact_type: None,
text: "Line1".to_string(),
bbox: Rect::new(50.0, 700.0, 100.0, 12.0),
font_name: "F1".to_string(),
font_size: 12.0,
font_weight: FontWeight::Normal,
color: Color::black(),
mcid: None,
mcid_scope: None,
sequence: 1,
split_boundary_before: false,
offset_semantic: false,
is_italic: false,
is_monospace: false,
char_spacing: 0.0,
word_spacing: 0.0,
horizontal_scaling: 100.0,
primary_detected: false,
char_widths: vec![],
char_x_offsets: Vec::new(),
heading_level: None,
rotation_degrees: 0.0,
wmode: 0,
rtl_draw_logical: false,
mirrored: false,
page_rotation_applied: 0,
},
];
extractor.sort_spans_by_reading_order();
assert_eq!(extractor.spans[0].text, "Line1"); assert_eq!(extractor.spans[1].text, "Line2");
}
#[test]
fn test_sort_spans_vertical_tategaki_chained_x_centers_does_not_panic() {
let mut extractor = TextExtractor::new();
extractor.spans = (0..240)
.map(|i| TextSpan {
text: format!("g{i}"),
bbox: Rect::new(20.0 + i as f32 * 0.8, 700.0 - ((i * 37) % 96) as f32 * 7.0, 1.0, 12.0),
font_size: 12.0,
wmode: 1,
..TextSpan::default()
})
.collect();
extractor.sort_spans_by_reading_order(); assert_eq!(extractor.spans.len(), 240);
}
#[test]
fn test_tm_continuation_different_transform() {
let mut extractor = TextExtractor::new();
extractor.merging_config = SpanMergingConfig::legacy();
let font = create_test_font();
extractor.add_font("F1".to_string(), font);
let stream = b"BT /F1 12 Tf 1 0 0 1 100 700 Tm (A) Tj 2 0 0 1 120 700 Tm (B) Tj ET";
let spans = extractor.extract_text_spans(stream).unwrap();
assert!(!spans.is_empty());
}
#[test]
fn test_decode_pdf_text_string_single_byte() {
let result = TextExtractor::decode_pdf_text_string(&[0x41]);
assert_eq!(result, "A");
}
#[test]
fn test_decode_pdf_text_string_invalid_utf16() {
let bytes = vec![0xFE, 0xFF, 0xD8, 0x00]; let result = TextExtractor::decode_pdf_text_string(&bytes);
assert!(!result.is_empty() || result.is_empty()); }
#[test]
fn test_decode_pdf_text_string_utf16le_invalid() {
let bytes = vec![0xFF, 0xFE, 0x41]; let result = TextExtractor::decode_pdf_text_string(&bytes);
}
#[test]
fn test_decode_pdfdocencoding_latin_byte() {
let result = TextExtractor::decode_pdf_text_string(&[0xE9]);
assert_eq!(
result, "é",
"0xE9 must decode as 'é' via PDFDocEncoding, not produce U+FFFD"
);
}
#[test]
fn test_decode_pdfdocencoding_bullet() {
let result = TextExtractor::decode_pdf_text_string(&[0x80]);
assert_eq!(result, "•", "0x80 must decode as bullet '•' via PDFDocEncoding");
}
#[test]
fn test_decode_pdfdocencoding_emdash() {
let result = TextExtractor::decode_pdf_text_string(&[0x84]);
assert_eq!(result, "—", "0x84 must decode as em-dash '—' via PDFDocEncoding");
}
#[test]
fn test_decode_pdfdocencoding_trademark() {
let result = TextExtractor::decode_pdf_text_string(&[0x92]);
assert_eq!(result, "â„¢", "0x92 must decode as trademark 'â„¢' via PDFDocEncoding");
}
#[test]
fn test_decode_pdfdocencoding_undefined_9f_is_dropped() {
let result = TextExtractor::decode_pdf_text_string(&[0x41, 0x9F, 0x42]);
assert_eq!(result, "AB", "0x9F is undefined in PDFDocEncoding and must be dropped");
}
#[test]
fn test_decode_pdfdocencoding_mixed_ascii_and_latin() {
let bytes: Vec<u8> = b"Hello".iter().copied().chain([0xE9]).collect();
let result = TextExtractor::decode_pdf_text_string(&bytes);
assert_eq!(
result, "Helloé",
"Mixed ASCII + PDFDocEncoding bytes must decode correctly"
);
}
#[test]
fn test_decode_pdfdocencoding_utf8_bytes_still_work() {
let result = TextExtractor::decode_pdf_text_string(b"ASCII text");
assert_eq!(result, "ASCII text");
}
#[test]
fn test_share_truetype_cmaps_no_donors() {
let mut extractor = TextExtractor::new();
let font = create_test_font();
extractor.add_font("F1".to_string(), font);
extractor.share_truetype_cmaps();
assert_eq!(extractor.fonts.len(), 1);
}
#[test]
fn test_extractor_with_config_and_profile() {
let config = TextExtractionConfig::new().with_profile(crate::config::ExtractionProfile::POLICY);
let mut extractor = TextExtractor::with_config(config);
let font = create_test_font();
extractor.add_font("F1".to_string(), font);
let stream = b"BT /F1 12 Tf 100 700 Td (Policy) Tj ET";
let chars = extractor.extract(stream).unwrap();
assert!(!chars.is_empty());
}
#[test]
fn test_merge_offset_semantic_space_suppression() {
let mut extractor = TextExtractor::new();
extractor.merging_config = SpanMergingConfig::legacy();
extractor.spans = vec![
TextSpan {
provenance: None,
text_rise: 0.0,
artifact_type: None,
text: "Hello".to_string(),
bbox: Rect::new(100.0, 700.0, 30.0, 12.0),
font_name: "F1".to_string(),
font_size: 12.0,
font_weight: FontWeight::Normal,
color: Color::black(),
mcid: None,
mcid_scope: None,
sequence: 0,
split_boundary_before: false,
offset_semantic: false,
is_italic: false,
is_monospace: false,
char_spacing: 0.0,
word_spacing: 0.0,
horizontal_scaling: 100.0,
primary_detected: false,
char_widths: vec![],
char_x_offsets: Vec::new(),
heading_level: None,
rotation_degrees: 0.0,
wmode: 0,
rtl_draw_logical: false,
mirrored: false,
page_rotation_applied: 0,
},
TextSpan {
provenance: None,
text_rise: 0.0,
artifact_type: None,
text: " ".to_string(), bbox: Rect::new(130.5, 700.0, 2.0, 12.0),
font_name: "F1".to_string(),
font_size: 12.0,
font_weight: FontWeight::Normal,
color: Color::black(),
mcid: None,
mcid_scope: None,
sequence: 1,
split_boundary_before: true, offset_semantic: true,
is_italic: false,
is_monospace: false,
char_spacing: 0.0,
word_spacing: 0.0,
horizontal_scaling: 100.0,
primary_detected: false,
char_widths: vec![],
char_x_offsets: Vec::new(),
heading_level: None,
rotation_degrees: 0.0,
wmode: 0,
rtl_draw_logical: false,
mirrored: false,
page_rotation_applied: 0,
},
];
extractor.merge_adjacent_spans();
let text = &extractor.spans[0].text;
assert!(!text.contains(" "), "Should not have double space, got: '{}'", text);
}
#[test]
fn test_is_monospace_font_recognizes_broad_mono_families() {
assert!(is_monospace_font("Menlo"));
assert!(is_monospace_font("Fira Code"));
assert!(is_monospace_font("Fira Mono"));
assert!(is_monospace_font("Source Code Pro"));
assert!(is_monospace_font("Inconsolata"));
assert!(is_monospace_font("CMTT10"));
assert!(is_monospace_font("LMMono10-Regular"));
assert!(is_monospace_font("DejaVu Sans Mono"));
}
#[test]
fn test_is_monospace_font_recognizes_bare_word_mono_families() {
assert!(is_monospace_font("PT Mono"));
assert!(is_monospace_font("Roboto Mono"));
assert!(is_monospace_font("Nimbus Mono"));
}
#[test]
fn test_is_monospace_font_rejects_monotype_foundry_names() {
assert!(!is_monospace_font("Monotype Corsiva"));
assert!(!is_monospace_font("Arial Monotype"));
}
#[test]
fn test_is_monospace_font_still_matches_monotype_branded_monospace_faces() {
assert!(is_monospace_font("Monotype Consolas"));
}
#[test]
fn test_is_monospace_font_rejects_fira_sans() {
assert!(!is_monospace_font("Fira Sans"));
assert!(!is_monospace_font("Fira Sans Condensed"));
}
#[test]
fn test_tj_buffer_marks_menlo_as_monospace() {
let state = crate::content::graphics_state::GraphicsStateStack::new();
let font = Arc::new(FontInfo {
base_font: "Menlo".to_string(),
..create_test_font()
});
let buffer = TjBuffer::new(state.current(), None, Some(font));
assert!(
buffer.is_monospace,
"Menlo must be recognized as monospace via is_monospace_font, not just the narrow legacy list"
);
}
#[test]
fn test_tj_buffer_does_not_mark_fira_sans_as_monospace() {
let state = crate::content::graphics_state::GraphicsStateStack::new();
let font = Arc::new(FontInfo {
base_font: "Fira Sans".to_string(),
..create_test_font()
});
let buffer = TjBuffer::new(state.current(), None, Some(font));
assert!(
!buffer.is_monospace,
"Fira Sans is proportional prose text and must not be routed toward code fencing"
);
}