spectre_pdf 1.0.0

Native Rust PDF extraction engine: text, markdown for RAG, AcroForm widgets, image decoding, and encrypted PDFs. Lazy parser, persistent Document handle, no C dependencies.
Documentation
//! Heading detection and styled-line rendering for the markdown surface.
//!
//! Pure helpers (no PyO3) so they're reachable from Rust callers and from
//! the unit-test target. The Python binding in [`crate::python`] glues
//! these together with the document handle; the Rust-side `Document` will
//! gain a `markdown()` method in a future release that calls them too.

use crate::{TextBlock, TextLine, TextSpan};
use std::collections::HashMap;

pub const HEADING_PREFIXES: [&str; 6] =
    ["# ", "## ", "### ", "#### ", "##### ", "###### "];

/// Build a character-weighted font-size histogram, set
/// `body_limit = max(12, mode)` (12pt floor suppresses false positives in
/// small-font documents), and map every distinct above-body size to a
/// heading level 1..=6 by descending size. Returns the size → level map
/// plus the effective `body_limit` for downstream `fontsize > body_limit`
/// checks.
pub fn build_heading_size_map(blocks: &[TextBlock]) -> (HashMap<u32, usize>, u32) {
    let mut size_counter: HashMap<u32, u64> = HashMap::new();
    for block in blocks {
        for line in &block.lines {
            for span in &line.spans {
                let rounded = span.font_size.round() as u32;
                let len = span.text.chars().count() as u64;
                *size_counter.entry(rounded).or_insert(0) += len;
            }
        }
    }
    const BODY_LIMIT: u32 = 12;
    if size_counter.is_empty() {
        return (HashMap::new(), BODY_LIMIT);
    }
    let mode = size_counter
        .iter()
        .max_by_key(|&(_, count)| *count)
        .map(|(s, _)| *s)
        .unwrap_or(10);
    let body_limit = BODY_LIMIT.max(mode);
    let mut heading_sizes: Vec<u32> = size_counter
        .keys()
        .copied()
        .filter(|&s| s > body_limit)
        .collect();
    heading_sizes.sort_by(|a, b| b.cmp(a));
    heading_sizes.truncate(6);
    let header_map: HashMap<u32, usize> = heading_sizes
        .iter()
        .enumerate()
        .map(|(i, &s)| (s, i + 1))
        .collect();
    (header_map, body_limit)
}

/// Structural-pattern heading detector. Returns the heading level
/// (1..=6) when the line text matches a known structural pattern, even
/// if its font size doesn't qualify.
pub fn detect_structural_heading(
    trimmed: &str,
    all_bold: bool,
    stands_alone: bool,
) -> Option<usize> {
    if trimmed.is_empty() {
        return None;
    }
    let word_count = trimmed.split_whitespace().count();
    let ends_in_terminator = trimmed
        .chars()
        .last()
        .map(|c| matches!(c, '.' | ';' | ',' | '!' | '?'))
        .unwrap_or(false);
    if word_count == 0 || word_count > 16 {
        return None;
    }
    if stands_alone {
        if let Some(level) = detect_numbered_prefix_level(trimmed) {
            return Some(level);
        }
    }
    let letter_chars: Vec<char> = trimmed.chars().filter(|c| c.is_alphabetic()).collect();
    if stands_alone && letter_chars.len() >= 4 {
        let upper_ratio = letter_chars.iter().filter(|c| c.is_uppercase()).count() as f32
            / letter_chars.len() as f32;
        if upper_ratio >= 0.85 && word_count <= 12 && !ends_in_terminator {
            return Some(2);
        }
    }
    if all_bold
        && stands_alone
        && word_count >= 2
        && word_count <= 10
        && !ends_in_terminator
        && letter_chars.len() >= 3
    {
        return Some(3);
    }
    None
}

/// Recognize `1.`, `1.1`, `1.1.1`, `Part I`, `Part II`, `Section A`,
/// `Chapter 4`. Numeric depth gives the level (`1.` → 1, `1.1` → 2);
/// `Part`/`Chapter`/`Section` lead lines get level 1.
pub fn detect_numbered_prefix_level(s: &str) -> Option<usize> {
    let head = s.trim_start();
    let lower = head.to_lowercase();
    for prefix in ["part ", "chapter ", "section "] {
        if let Some(rest) = lower.strip_prefix(prefix) {
            let mut chars = rest.chars();
            if let Some(first) = chars.next() {
                if first.is_alphanumeric() {
                    return Some(1);
                }
            }
        }
    }
    let mut depth = 0usize;
    let mut chars = head.chars().peekable();
    let mut consumed_digit = false;
    while let Some(&c) = chars.peek() {
        if c.is_ascii_digit() {
            consumed_digit = true;
            while let Some(&d) = chars.peek() {
                if d.is_ascii_digit() {
                    chars.next();
                } else {
                    break;
                }
            }
            depth += 1;
            if let Some(&dot) = chars.peek() {
                if dot == '.' {
                    chars.next();
                    continue;
                }
            }
            break;
        } else {
            break;
        }
    }
    if consumed_digit && depth >= 1 {
        let after = chars.collect::<String>();
        let after_trim = after.trim_start();
        if !after_trim.is_empty()
            && after_trim
                .chars()
                .next()
                .map(|c| c.is_alphabetic())
                .unwrap_or(false)
        {
            return Some(depth.clamp(1, 6));
        }
    }
    None
}

/// Normalize a string for fuzzy heading lookup against the TOC. Strips
/// markdown markers (`**`, `_`, backticks), collapses whitespace,
/// lowercases, and drops trailing punctuation.
pub fn normalize_heading_lookup(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut prev_space = false;
    for c in s.chars() {
        if matches!(c, '*' | '_' | '`') {
            continue;
        }
        if c.is_whitespace() {
            if !prev_space && !out.is_empty() {
                out.push(' ');
            }
            prev_space = true;
        } else {
            out.push(c.to_ascii_lowercase());
            prev_space = false;
        }
    }
    while let Some(c) = out.chars().last() {
        if matches!(c, '.' | ':' | ',' | ';' | '!' | '?' | '-' | ' ') {
            out.pop();
        } else {
            break;
        }
    }
    out
}

/// Fold a heading-sized block into a spatially-adjacent same-size
/// neighbour (same x within 5pt, vertical gap < 1.5 × font size).
/// Recovers multi-line headings the segmenter splits when body text
/// from a different column lands between the visual lines.
pub fn merge_adjacent_heading_blocks(
    blocks: Vec<TextBlock>,
    body_limit: u32,
) -> Vec<TextBlock> {
    let dom_size = |b: &TextBlock| {
        let mut counter: HashMap<u32, u64> = HashMap::new();
        for line in &b.lines {
            for span in &line.spans {
                *counter.entry(span.font_size.round() as u32).or_insert(0) +=
                    span.text.chars().count() as u64;
            }
        }
        counter
            .into_iter()
            .max_by_key(|&(_, c)| c)
            .map(|(s, _)| s)
            .unwrap_or(0)
    };
    let mut consumed: Vec<bool> = vec![false; blocks.len()];
    let mut out: Vec<TextBlock> = Vec::with_capacity(blocks.len());
    for i in 0..blocks.len() {
        if consumed[i] {
            continue;
        }
        let mut cur = blocks[i].clone();
        let cur_size = dom_size(&cur);
        if cur_size <= body_limit {
            out.push(cur);
            continue;
        }
        const MAX_LOOKAHEAD: usize = 6;
        for j in (i + 1)..(i + 1 + MAX_LOOKAHEAD).min(blocks.len()) {
            if consumed[j] {
                continue;
            }
            let cand = &blocks[j];
            if cand.page != cur.page {
                break;
            }
            let cand_size = dom_size(cand);
            if cand_size != cur_size {
                continue;
            }
            let x_diff = (cand.bbox.x0 - cur.bbox.x0).abs();
            if x_diff > 5.0 {
                continue;
            }
            let v_gap = cur.bbox.y0 - cand.bbox.y1;
            let v_gap_above = cand.bbox.y0 - cur.bbox.y1;
            let near_below = v_gap >= 0.0 && v_gap < (cur_size as f32) * 1.5;
            let near_above = v_gap_above >= 0.0 && v_gap_above < (cur_size as f32) * 1.5;
            if !(near_below || near_above) {
                continue;
            }
            for line in &cand.lines {
                cur.lines.push(line.clone());
            }
            cur.text.push(' ');
            cur.text.push_str(&cand.text);
            cur.bbox.x0 = cur.bbox.x0.min(cand.bbox.x0);
            cur.bbox.x1 = cur.bbox.x1.max(cand.bbox.x1);
            cur.bbox.y0 = cur.bbox.y0.min(cand.bbox.y0);
            cur.bbox.y1 = cur.bbox.y1.max(cand.bbox.y1);
            consumed[j] = true;
        }
        out.push(cur);
    }
    out
}

/// Concatenate text of all bold spans on a line, in reading order,
/// preserving the spans' embedded whitespace.
pub fn collect_bold_text(
    spans: &[TextSpan],
    styles: &HashMap<(u32, String), (bool, bool)>,
    page: u32,
) -> String {
    let mut out = String::new();
    for span in spans {
        let is_bold = styles
            .get(&(page, span.font.clone()))
            .map(|(b, _)| *b)
            .unwrap_or(false);
        if is_bold {
            out.push_str(&span.text);
        }
    }
    out
}

pub fn line_dominant_size(line: &TextLine) -> u32 {
    let mut counter: HashMap<u32, u64> = HashMap::new();
    for span in &line.spans {
        let rounded = span.font_size.round() as u32;
        *counter.entry(rounded).or_insert(0) += span.text.chars().count() as u64;
    }
    counter
        .into_iter()
        .max_by_key(|&(_, c)| c)
        .map(|(s, _)| s)
        .unwrap_or(0)
}

/// Render one TextLine's spans as Markdown, wrapping consecutive
/// same-style runs in `**bold**` / `*italic*` / `***bold italic***`.
/// Style lookup is keyed by `(page, font resource name)`.
pub fn render_styled_line(
    spans: &[TextSpan],
    page: u32,
    styles: &HashMap<(u32, String), (bool, bool)>,
) -> String {
    let mut out = String::with_capacity(spans.iter().map(|s| s.text.len() + 6).sum());
    let mut current_bold = false;
    let mut current_italic = false;
    let close = |out: &mut String, bold: bool, italic: bool| {
        if bold && italic {
            out.push_str("***");
        } else if bold {
            out.push_str("**");
        } else if italic {
            out.push('*');
        }
    };
    let open = |out: &mut String, bold: bool, italic: bool| {
        if bold && italic {
            out.push_str("***");
        } else if bold {
            out.push_str("**");
        } else if italic {
            out.push('*');
        }
    };
    for (i, span) in spans.iter().enumerate() {
        let (bold, italic) = styles
            .get(&(page, span.font.clone()))
            .copied()
            .unwrap_or((false, false));
        if i == 0 {
            open(&mut out, bold, italic);
            current_bold = bold;
            current_italic = italic;
        } else if bold != current_bold || italic != current_italic {
            close(&mut out, current_bold, current_italic);
            open(&mut out, bold, italic);
            current_bold = bold;
            current_italic = italic;
        }
        out.push_str(&span.text);
        if i + 1 < spans.len() && !span.text.ends_with(' ') {
            out.push(' ');
        }
    }
    close(&mut out, current_bold, current_italic);
    out
}