Source code for pdf_jsonl_smith.extractor

from __future__ import annotations

import hashlib
import re
from collections.abc import Iterator
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

import fitz

from pdf_jsonl_smith.records import make_text_unit_record
from pdf_jsonl_smith.writer import write_jsonl

TextBlock = tuple[float, float, float, float, str]


[docs] def extract_pdf_to_jsonl( input_path: str | Path, output_path: str | Path, *, source_uri: str | None = None, schema_version: str = "0.1", ) -> int: """Extract PDF text blocks to BigQuery-ready JSONL. Returns the number of records written. """ records = iter_text_unit_records( input_path=input_path, source_uri=source_uri, schema_version=schema_version, ) return write_jsonl(records, output_path)
[docs] def iter_text_unit_records( *, input_path: str | Path, source_uri: str | None = None, schema_version: str = "0.1", ) -> Iterator[dict[str, Any]]: """Yield one flat record for each merged text unit.""" path = Path(input_path) doc_id = _sha256_doc_id(path) extracted_at = _utc_now_iso() with fitz.open(path) as document: for page_index, page in enumerate(document): blocks = _merge_adjacent_text_blocks(_iter_page_text_blocks(page)) for unit_index, block in enumerate(blocks): x0, y0, x1, y1, text = block yield make_text_unit_record( schema_version=schema_version, doc_id=doc_id, source_uri=source_uri, filename=path.name, page_number=page_index + 1, unit_type="paragraph", unit_index=unit_index, text=text, bbox=(x0, y0, x1, y1), extracted_at=extracted_at, )
def _iter_page_text_blocks(page: fitz.Page) -> Iterator[TextBlock]: for block in page.get_text("blocks", sort=True): x0, y0, x1, y1, text, *_ = block block_type = block[6] if len(block) > 6 else 0 if block_type != 0: continue text = _normalize_extracted_text(text) if not text: continue yield (float(x0), float(y0), float(x1), float(y1), text) def _merge_adjacent_text_blocks(blocks: Iterator[TextBlock]) -> list[TextBlock]: merged: list[TextBlock] = [] current: TextBlock | None = None last_block: TextBlock | None = None for block in blocks: if current is None: current = block last_block = block continue if last_block is not None and _should_merge_blocks(last_block, block): current = _combine_blocks(current, block) last_block = block continue merged.append(current) current = block last_block = block if current is not None: merged.append(current) return merged def _should_merge_blocks(previous: TextBlock, current: TextBlock) -> bool: prev_x0, prev_y0, _prev_x1, prev_y1, _prev_text = previous curr_x0, curr_y0, _curr_x1, curr_y1, _curr_text = current vertical_gap = curr_y0 - prev_y1 previous_height = prev_y1 - prev_y0 current_height = curr_y1 - curr_y0 if vertical_gap < -1: return False if vertical_gap > max(12.0, previous_height * 0.8): return False if previous_height > current_height * 1.25: return False left_delta = curr_x0 - prev_x0 same_left_edge = abs(left_delta) <= 3.0 outdent_continuation = -24.0 <= left_delta < -3.0 lowercase_continuation = _looks_like_lowercase_continuation( previous_text=_prev_text, current_text=_curr_text, ) return same_left_edge or outdent_continuation or lowercase_continuation def _combine_blocks(previous: TextBlock, current: TextBlock) -> TextBlock: prev_x0, prev_y0, prev_x1, prev_y1, prev_text = previous curr_x0, curr_y0, curr_x1, curr_y1, curr_text = current return ( min(prev_x0, curr_x0), min(prev_y0, curr_y0), max(prev_x1, curr_x1), max(prev_y1, curr_y1), _join_text(prev_text, curr_text), ) def _join_text(previous: str, current: str) -> str: previous = previous.strip() current = current.strip() if not previous: return current if not current: return previous if _is_cjk(previous[-1]) and _is_cjk(current[0]): return f"{previous}{current}" return f"{previous} {current}" def _normalize_extracted_text(text: str) -> str: text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text) parts = [part.strip() for part in text.splitlines() if part.strip()] if not parts: return "" normalized = parts[0] for part in parts[1:]: normalized = _join_text(normalized, part) return re.sub(r"[ \t\f\v]+", " ", normalized).strip() def _looks_like_lowercase_continuation( *, previous_text: str, current_text: str, ) -> bool: previous_text = previous_text.strip() current_text = current_text.strip() if not previous_text or not current_text: return False if previous_text.endswith((".", "!", "?", ":", ";", "。", "!", "?")): return False return current_text[0].islower() def _is_cjk(character: str) -> bool: codepoint = ord(character) return ( 0x3000 <= codepoint <= 0x303F or 0x3040 <= codepoint <= 0x30FF or 0x3400 <= codepoint <= 0x4DBF or 0x4E00 <= codepoint <= 0x9FFF or 0xF900 <= codepoint <= 0xFAFF or 0xFF00 <= codepoint <= 0xFFEF ) def _sha256_doc_id(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as input_file: for chunk in iter(lambda: input_file.read(1024 * 1024), b""): digest.update(chunk) return f"sha256:{digest.hexdigest()}" def _utc_now_iso() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace( "+00:00", "Z" )