pdf_text 0.1.1-preview.8

Positioned glyph and text-run extraction.
Documentation
use pdf_content::{ContentOp, ContentProgram};
use spdfdiff_types::{Diagnostic, LineSegment, Point, Provenance, Rect};

#[derive(Debug, Clone, PartialEq)]
pub struct GlyphToken {
    pub id: String,
    pub unicode: Option<String>,
    pub raw_bytes: Vec<u8>,
    pub page_index: usize,
    pub bbox: Rect,
    pub baseline: LineSegment,
    pub source: Provenance,
}

#[derive(Debug, Clone, PartialEq)]
pub struct TextRun {
    pub id: String,
    pub text: String,
    pub normalized_text: String,
    pub glyphs: Vec<GlyphToken>,
    pub bbox: Rect,
    pub source: Provenance,
    pub marked_content: Option<MarkedContentRef>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarkedContentRef {
    pub tag: String,
    pub mcid: Option<usize>,
}

#[derive(Debug, Clone, PartialEq)]
pub struct TextExtraction {
    pub runs: Vec<TextRun>,
    pub diagnostics: Vec<Diagnostic>,
}

#[derive(Debug, Clone, Copy)]
struct TextState {
    x: f32,
    y: f32,
    font_size: f32,
    leading: f32,
    character_spacing: f32,
    word_spacing: f32,
    horizontal_scale: f32,
}

impl Default for TextState {
    fn default() -> Self {
        Self {
            x: 0.0,
            y: 0.0,
            font_size: 12.0,
            leading: 0.0,
            character_spacing: 0.0,
            word_spacing: 0.0,
            horizontal_scale: 100.0,
        }
    }
}

#[must_use]
pub fn extract_text_runs(program: &ContentProgram, page_index: usize) -> TextExtraction {
    let mut state = TextState::default();
    let mut graphics_stack = Vec::new();
    let mut runs = Vec::new();
    let mut diagnostics = program.diagnostics.clone();
    let mut emitted_missing_tounicode = false;
    let mut marked_content_stack: Vec<MarkedContentRef> = Vec::new();

    for operation in &program.operations {
        match operation {
            ContentOp::SetFont { size, .. } => {
                state.font_size = *size;
            }
            ContentOp::MoveTextPosition {
                tx,
                ty,
                set_leading,
                ..
            } => {
                state.x += tx;
                state.y += ty;
                if let Some(leading) = set_leading {
                    state.leading = *leading;
                }
            }
            ContentOp::MoveToNextLine { .. } => {
                state.y -= state.leading;
            }
            ContentOp::SetTextLeading { leading, .. } => {
                state.leading = *leading;
            }
            ContentOp::SetCharacterSpacing { spacing, .. } => {
                state.character_spacing = *spacing;
            }
            ContentOp::SetWordSpacing { spacing, .. } => {
                state.word_spacing = *spacing;
            }
            ContentOp::SetHorizontalScaling { scale, .. } => {
                state.horizontal_scale = *scale;
            }
            ContentOp::SetTextMatrix { e, f, .. } => {
                state.x = *e;
                state.y = *f;
            }
            ContentOp::ShowText {
                text,
                raw_bytes,
                source,
            }
            | ContentOp::ShowAdjustedText {
                text,
                raw_bytes,
                source,
                ..
            } => {
                let run_page_index = source.page_index.unwrap_or(page_index);
                if !emitted_missing_tounicode {
                    diagnostics.push(
                        Diagnostic::warning(
                            "MISSING_TOUNICODE",
                            "using literal-string fallback text because no ToUnicode map is available",
                        )
                        .with_page(run_page_index),
                    );
                    emitted_missing_tounicode = true;
                }
                emit_run(
                    &mut runs,
                    run_page_index,
                    text,
                    raw_bytes,
                    source.clone(),
                    marked_content_stack.last().cloned(),
                    &mut state,
                );
            }
            ContentOp::SaveGraphicsState { .. } => {
                graphics_stack.push(state);
            }
            ContentOp::RestoreGraphicsState { .. } => {
                if let Some(saved) = graphics_stack.pop() {
                    state = saved;
                }
            }
            ContentOp::BeginMarkedContent { tag, mcid, .. } => {
                marked_content_stack.push(MarkedContentRef {
                    tag: tag.clone(),
                    mcid: *mcid,
                });
            }
            ContentOp::EndMarkedContent { .. } => {
                marked_content_stack.pop();
            }
            ContentOp::BeginText { .. }
            | ContentOp::EndText { .. }
            | ContentOp::ConcatMatrix { .. }
            | ContentOp::RecognizedNonText { .. }
            | ContentOp::Unknown { .. } => {}
        }
    }

    TextExtraction { runs, diagnostics }
}

fn emit_run(
    runs: &mut Vec<TextRun>,
    page_index: usize,
    text: &str,
    raw_bytes: &[u8],
    source: Provenance,
    marked_content: Option<MarkedContentRef>,
    state: &mut TextState,
) {
    let width = estimate_text_width(text, *state);
    let bbox = Rect {
        x0: state.x,
        y0: state.y,
        x1: state.x + width,
        y1: state.y + state.font_size,
    };
    let glyph = GlyphToken {
        id: format!("p{page_index}.g{:04}", runs.len()),
        unicode: Some(text.to_owned()),
        raw_bytes: raw_bytes.to_vec(),
        page_index,
        bbox,
        baseline: LineSegment {
            start: Point {
                x: state.x,
                y: state.y,
            },
            end: Point {
                x: state.x + width,
                y: state.y,
            },
        },
        source: source.clone(),
    };
    runs.push(TextRun {
        id: format!("p{page_index}.r{:04}", runs.len()),
        text: text.to_owned(),
        normalized_text: normalize_text(text),
        glyphs: vec![glyph],
        bbox,
        source,
        marked_content,
    });
    state.x += width;
}

#[must_use]
pub fn normalize_text(text: &str) -> String {
    text.split_whitespace().collect::<Vec<_>>().join(" ")
}

fn estimate_text_width(text: &str, state: TextState) -> f32 {
    let base_width = text
        .chars()
        .map(|character| approximate_glyph_width(character) * state.font_size)
        .sum::<f32>();
    let spacing = text.chars().fold(0.0, |total, character| {
        let word_spacing = if character == ' ' {
            state.word_spacing
        } else {
            0.0
        };
        total + state.character_spacing + word_spacing
    });
    (base_width + spacing) * (state.horizontal_scale / 100.0)
}

fn approximate_glyph_width(character: char) -> f32 {
    match character {
        ' ' => 0.25,
        'i' | 'l' | 'I' | '!' | '.' | ',' | ':' | ';' | '|' => 0.28,
        'm' | 'w' | 'M' | 'W' => 0.78,
        character if character.is_ascii_digit() => 0.5,
        character if character.is_ascii_punctuation() => 0.35,
        character if character.len_utf8() > 1 => 0.6,
        _ => 0.5,
    }
}

trait DiagnosticExt {
    fn with_page(self, page_index: usize) -> Self;
}

impl DiagnosticExt for Diagnostic {
    fn with_page(mut self, page_index: usize) -> Self {
        self.page_index = Some(page_index);
        self
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use pdf_content::parse_content_stream;
    use spdfdiff_types::DiagnosticSeverity;

    #[test]
    fn extracts_positioned_text_run() {
        let program = parse_content_stream(b"BT /F1 12 Tf 72 720 Td (Hello) Tj ET");
        let extraction = extract_text_runs(&program, 0);

        assert_eq!(extraction.runs.len(), 1);
        assert_eq!(extraction.runs[0].text, "Hello");
        assert_eq!(extraction.runs[0].normalized_text, "Hello");
        assert_eq!(extraction.runs[0].bbox.x0, 72.0);
        assert_eq!(extraction.runs[0].bbox.y0, 720.0);
        assert!(
            extraction
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "MISSING_TOUNICODE"
                    && diagnostic.severity == DiagnosticSeverity::Warning)
        );
    }

    #[test]
    fn extracts_adjusted_text_array_as_one_run() {
        let program = parse_content_stream(b"BT /F1 10 Tf 10 20 Td [(Hel) -120 (lo)] TJ ET");
        let extraction = extract_text_runs(&program, 0);

        assert_eq!(extraction.runs.len(), 1);
        assert_eq!(extraction.runs[0].text, "Hello");
        assert_eq!(extraction.runs[0].glyphs[0].raw_bytes, b"Hello");
        assert!(extraction.runs[0].bbox.width() > 0.0);
    }

    #[test]
    fn line_movement_changes_y_coordinate() {
        let program =
            parse_content_stream(b"BT /F1 12 Tf 72 720 Td 14 TL (First) Tj T* (Second) Tj ET");
        let extraction = extract_text_runs(&program, 0);

        assert_eq!(extraction.runs.len(), 2);
        assert_eq!(extraction.runs[0].bbox.y0, 720.0);
        assert_eq!(extraction.runs[1].bbox.y0, 706.0);
    }

    #[test]
    fn spacing_state_affects_text_advance() {
        let plain = extract_text_runs(
            &parse_content_stream(b"BT /F1 10 Tf 0 0 Td (A A) Tj (B) Tj ET"),
            0,
        );
        let spaced = extract_text_runs(
            &parse_content_stream(b"BT /F1 10 Tf 0 0 Td 2 Tc 4 Tw (A A) Tj (B) Tj ET"),
            0,
        );

        assert!(spaced.runs[1].bbox.x0 > plain.runs[1].bbox.x0);
    }

    #[test]
    fn glyph_width_estimate_uses_character_shape_heuristics() {
        let state = TextState {
            font_size: 10.0,
            ..TextState::default()
        };

        assert!(estimate_text_width("WWW", state) > estimate_text_width("iii", state));
        assert!(estimate_text_width("A A", state) < estimate_text_width("AAA", state));
    }

    #[test]
    fn preserves_marked_content_mcid_on_text_runs() {
        let program = parse_content_stream(b"/P << /MCID 3 >> BDC BT (Tagged) Tj ET EMC");
        let extraction = extract_text_runs(&program, 0);

        assert_eq!(extraction.runs.len(), 1);
        assert_eq!(
            extraction.runs[0].marked_content,
            Some(MarkedContentRef {
                tag: "P".to_owned(),
                mcid: Some(3),
            })
        );
    }
}