use easypdf_core::PdfInput;
use easypdf_core::Result;
use easypdf_core::{PdfBlock, PdfDocumentModel, PdfPageModel};
use crate::{MarkdownProcessorCapabilities, MarkdownWarning, PdfMarkdownProcessor};
#[derive(Clone, Copy, Debug)]
pub struct HeadingDetectorProcessor {
max_heading_length: usize,
}
impl HeadingDetectorProcessor {
#[must_use]
pub const fn new() -> Self {
Self {
max_heading_length: 80,
}
}
#[must_use]
pub const fn with_max_length(mut self, max: usize) -> Self {
self.max_heading_length = max;
self
}
fn is_heading_candidate(self, text: &str) -> bool {
let trimmed = text.trim();
if trimmed.is_empty() || trimmed.len() > self.max_heading_length {
return false;
}
if trimmed.contains('.') || trimmed.contains('。') {
return false;
}
trimmed.chars().next().is_some_and(char::is_uppercase)
}
}
impl Default for HeadingDetectorProcessor {
fn default() -> Self {
Self::new()
}
}
impl PdfMarkdownProcessor for HeadingDetectorProcessor {
fn capabilities(&self) -> MarkdownProcessorCapabilities {
MarkdownProcessorCapabilities::new().with_reading_order()
}
fn process(
&self,
_input: &PdfInput,
document: PdfDocumentModel,
) -> Result<(PdfDocumentModel, Vec<MarkdownWarning>)> {
let mut new_pages = Vec::with_capacity(document.page_count());
for page in document.pages() {
let mut new_blocks = Vec::new();
for block in page.blocks() {
match block {
PdfBlock::Paragraph { text, source } => {
if self.is_heading_candidate(text) {
new_blocks.push(PdfBlock::heading(2, text, *source));
} else {
new_blocks.push(block.clone());
}
}
other => new_blocks.push(other.clone()),
}
}
let mut new_page = PdfPageModel::new(page.index());
if let (Some(w), Some(h)) = (page.width_pt(), page.height_pt()) {
new_page = new_page.with_dimensions(w, h);
}
new_page = new_page.with_rotation(page.rotation());
for block in new_blocks {
new_page = new_page.with_block(block);
}
new_pages.push(new_page);
}
Ok((
PdfDocumentModel::new(document.metadata().clone(), new_pages),
Vec::new(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use easypdf_core::{PageIndex, PdfMetadata};
use easypdf_core::{PdfPageModel, SourceLocation};
fn loc() -> SourceLocation {
SourceLocation::new(PageIndex::new(0), 1.0)
}
#[test]
fn capabilities_include_reading_order() {
let proc = HeadingDetectorProcessor::new();
assert!(proc.capabilities().reading_order());
}
#[test]
fn short_uppercase_becomes_heading() {
let proc = HeadingDetectorProcessor::new();
let page = PdfPageModel::new(PageIndex::new(0))
.with_block(PdfBlock::paragraph("INTRODUCTION", loc()));
let doc = PdfDocumentModel::new(PdfMetadata::default(), vec![page]);
let (result, _) = proc.process(&PdfInput::from_bytes(vec![]), doc).unwrap();
let blocks: Vec<_> = result.iter_all_blocks().collect();
assert_eq!(blocks.len(), 1);
assert!(matches!(blocks[0].1, PdfBlock::Heading { .. }));
}
#[test]
fn sentence_with_period_stays_paragraph() {
let proc = HeadingDetectorProcessor::new();
let page = PdfPageModel::new(PageIndex::new(0))
.with_block(PdfBlock::paragraph("This is a full sentence.", loc()));
let doc = PdfDocumentModel::new(PdfMetadata::default(), vec![page]);
let (result, _) = proc.process(&PdfInput::from_bytes(vec![]), doc).unwrap();
let blocks: Vec<_> = result.iter_all_blocks().collect();
assert!(matches!(blocks[0].1, PdfBlock::Paragraph { .. }));
}
#[test]
fn long_text_stays_paragraph() {
let proc = HeadingDetectorProcessor::new();
let long_text = "A".repeat(100);
let page =
PdfPageModel::new(PageIndex::new(0)).with_block(PdfBlock::paragraph(&long_text, loc()));
let doc = PdfDocumentModel::new(PdfMetadata::default(), vec![page]);
let (result, _) = proc.process(&PdfInput::from_bytes(vec![]), doc).unwrap();
let blocks: Vec<_> = result.iter_all_blocks().collect();
assert!(matches!(blocks[0].1, PdfBlock::Paragraph { .. }));
}
#[test]
fn empty_text_stays_paragraph() {
let proc = HeadingDetectorProcessor::new();
let page = PdfPageModel::new(PageIndex::new(0)).with_block(PdfBlock::paragraph("", loc()));
let doc = PdfDocumentModel::new(PdfMetadata::default(), vec![page]);
let (result, _) = proc.process(&PdfInput::from_bytes(vec![]), doc).unwrap();
let blocks: Vec<_> = result.iter_all_blocks().collect();
assert_eq!(blocks.len(), 1);
}
#[test]
fn custom_max_length() {
let proc = HeadingDetectorProcessor::new().with_max_length(10);
let page = PdfPageModel::new(PageIndex::new(0))
.with_block(PdfBlock::paragraph("ABCDEFGHIJK", loc()));
let doc = PdfDocumentModel::new(PdfMetadata::default(), vec![page]);
let (result, _) = proc.process(&PdfInput::from_bytes(vec![]), doc).unwrap();
let blocks: Vec<_> = result.iter_all_blocks().collect();
assert!(matches!(blocks[0].1, PdfBlock::Paragraph { .. }));
}
#[test]
fn is_heading_candidate_rules() {
let proc = HeadingDetectorProcessor::new();
assert!(proc.is_heading_candidate("Introduction"));
assert!(proc.is_heading_candidate("CHAPTER ONE"));
assert!(!proc.is_heading_candidate("this is lowercase"));
assert!(!proc.is_heading_candidate("Has a period."));
assert!(!proc.is_heading_candidate(""));
assert!(!proc.is_heading_candidate(&"A".repeat(100)));
}
}