Blog About
Published on 204 views

Как парсить PDF

А в чём проблема?

Формат PDF был придуман ещё в прошлом веке и предназначался для того, чтобы документ на разных устройствах отображался одинаково. До 2000 года формат был закрытым, а ISO-стандарт для PDF появился только в 2008 году. По этой причине появилось множество инструментов для создания PDF-файлов, которые реализовывали свои варианты стандарта. Плюс к этому информация может храниться как в текстовом виде, так и в графическом.

Казалось бы, в чём проблема распарсить текст? Но дело в том, что в PDF хранится не сам текст, а инструкции для его рендеринга в виде блоков: сам текст + инструкция. В блоках может содержаться как весь текст, так и одна буква — всё зависит от реализации. При этом сами блоки могут идти в любой последовательности. Например, фраза «Мама мыла раму» может храниться в виде:

BT
210 700 Td  (раму) Tj
100 700 Td  (Мама) Tj
160 700 Td  (мыла) Tj
ET

Отдельную боль доставляют табличные данные: колонки могут быть перепутаны, и под каждый файл приходится писать свой парсер. Мне как-то пришлось парсить таблицы из PDF, собранные за 20 лет, и пришлось чуть ли не под каждый год писать свой парсер, потому что для сохранения этих данных использовались разные версии ПО. При этом файлы визуально выглядели одинаково.

Казалось бы, решение простое: переведи PDF в изображение и используй OCR — тот же Tesseract от Google. Но это будет работать только в случае чёрного текста на идеально белом фоне. Если в документе есть какие-то изображения или это отсканенный текст с артефактами, то будет много ошибок, и такие документы нуждаются в предварительной очистке. А если парсить таблицы при помощи OCR… у меня ни разу не получилось получить приемлемый результат без боли.

Как же парсить?

С появлением фундаментальных моделей ситуация кардинально изменилась, и можно больше не заморачиваться: конвертируем каждую страницу в изображение и отправляем в LLM и, собственно всё.

Сейчас для работы с ИИ я предпочитаю использовать pydantic-ai, а ниже — пример кода, который парсит PDF.

import asyncio
import os
import sys
from pathlib import Path
from typing import Literal

from dotenv import load_dotenv
from loguru import logger
from openai import AsyncOpenAI
from pdf2image import convert_from_path
from pydantic_ai import Agent, BinaryContent
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider
from pydantic import BaseModel, Field

SYSTEM_PROMPT = (
    "You extract PDF page content into structured markdown. "
    "Return strict JSON matching the DocumentPage schema. "
    "Rules: preserve all visible text as markdown; "
    "format tables strictly as markdown tables; "
    "describe images as separate blocks with type=image_description in the correct reading position; "
    "preserve reading order top-to-bottom, left-to-right; "
    "do not skip headings, captions, footnotes, or lists; "
    "do not invent missing text; if uncertain, mark it explicitly in markdown as [Uncertain: ...]."
)


class ContentBlock(BaseModel):
    # Тип блока нужен, чтобы дальше можно было отдельно обрабатывать обычный текст,
    # таблицы и описания изображений (например, для пост-обработки или аналитики).
    type: Literal["text", "table", "image_description"]
    # Markdown хранит финальное представление блока в универсальном формате,
    # который удобно сразу склеивать в итоговый output.md без дополнительного парсинга.
    markdown: str = Field(min_length=1)


class DocumentPage(BaseModel):
    # Номер страницы сохраняем явно, чтобы гарантированно восстановить правильный
    # порядок документа даже при параллельной обработке.
    page_number: int = Field(ge=1)
    # Страница состоит из последовательности блоков; список отражает порядок чтения,
    # что важно для сохранения смысла при рендеринге в markdown.
    blocks: list[ContentBlock] = Field(default_factory=list)


class PdfConverter:
    def convert(self, pdf_path: Path, images_dir: Path) -> list[Path]:
        """Конвертирует PDF в PNG-изображения страниц и возвращает пути к файлам."""
        logger.info(f"Starting PDF conversion: {pdf_path}")

        images_dir.mkdir(parents=True, exist_ok=True)
        pages = convert_from_path(str(pdf_path))
        logger.info(f"Pages found for conversion: {len(pages)}")

        image_paths: list[Path] = []
        for index, page_image in enumerate(pages, start=1):
            output_path = images_dir / f"page_{index:04d}.png"
            page_image.save(output_path, format="PNG")
            image_paths.append(output_path)

        sorted_paths = sorted(image_paths)
        logger.info(f"Images saved to directory: {images_dir}")
        return sorted_paths


class PdfExtractorService:
    def __init__(self, model_name: str) -> None:
        load_dotenv()

        api_key = os.getenv("OPENROUTER_API_KEY")
        if not api_key:
            raise ValueError(
                "OPENROUTER_API_KEY is missing. Create a .env file and add: OPENROUTER_API_KEY=..."
            )

        self.client = AsyncOpenAI(
            base_url="https://openrouter.ai/api/v1",
            api_key=api_key,
        )
        provider = OpenAIProvider(openai_client=self.client)
        model = OpenAIChatModel(model_name=model_name, provider=provider)

        self.agent = Agent[None, DocumentPage](
            model=model,
            output_type=DocumentPage,
            instructions=SYSTEM_PROMPT,
        )

    @staticmethod
    def _render_page_markdown(page: DocumentPage) -> str:
        blocks = [
            block.markdown.strip() for block in page.blocks if block.markdown.strip()
        ]
        if not blocks:
            blocks = ["> [warning] Could not extract content from this page."]

        return f"## Page {page.page_number}\n\n" + "\n\n".join(blocks)

    async def _process_page(
        self,
        image_path: Path,
        page_number: int,
        semaphore: asyncio.Semaphore,
    ) -> tuple[int, str]:
        async with semaphore:
            logger.info(f"Starting page processing {page_number}: {image_path}")

            prompt = [
                (
                    f"page_number={page_number}\n"
                    "Extract this page content from the provided image following the system prompt rules."
                ),
                BinaryContent(data=image_path.read_bytes(), media_type="image/png"),
            ]

            result = await self.agent.run(prompt)
            page = result.output
            if page.page_number != page_number:
                page.page_number = page_number

            return page_number, self._render_page_markdown(page)

    async def extract_to_markdown(
        self, image_paths: list[Path], output_md: Path
    ) -> None:
        semaphore = asyncio.Semaphore(10)
        sorted_paths = sorted(image_paths)

        tasks = [
            asyncio.create_task(self._process_page(image_path, index, semaphore))
            for index, image_path in enumerate(sorted_paths, start=1)
        ]
        logger.info(f"Submitted page processing tasks: {len(tasks)}")

        results = await asyncio.gather(*tasks)

        logger.info(f"Processed pages: {len(results)}/{len(tasks)}")
        ordered_markdown = [
            content for _, content in sorted(results, key=lambda item: item[0])
        ]

        output_md.parent.mkdir(parents=True, exist_ok=True)
        output_md.write_text("\n\n---\n\n".join(ordered_markdown), encoding="utf-8")
        logger.info(f"Final markdown saved to: {output_md}")


async def main() -> None:
    if len(sys.argv) < 2:
        raise ValueError(
            "Pass PDF path as the first argument: uv run python pdf_parser.py ./file.pdf"
        )

    # Эти параметры вынесены в начало main для учебного сценария: новичку проще
    # быстро поменять папки/модель в одном месте и перезапустить скрипт.
    pdf_path = Path(sys.argv[1])
    images_dir = Path("images")
    output_md = Path("output.md")
    model_name = os.getenv("OPENROUTER_MODEL", "openai/gpt-4.1-mini")

    if not pdf_path.exists():
        raise FileNotFoundError(
            f"PDF file not found: {pdf_path}. Please provide a valid path."
        )

    converter = PdfConverter()
    image_paths = converter.convert(pdf_path, images_dir)
    logger.info(f"Conversion complete. Files saved: {len(image_paths)}")

    extractor = PdfExtractorService(model_name=model_name)
    await extractor.extract_to_markdown(image_paths=image_paths, output_md=output_md)


if __name__ == "__main__":
    # Run example:
    # uv run python pdf_parser.py ./path/to/document.pdf
    # Optional env vars:
    # OPENROUTER_API_KEY=your_key
    # OPENROUTER_MODEL=openai/gpt-4.1-mini
    # main асинхронный, поэтому используем asyncio.run: это корректно создает
    # и закрывает event loop для всего конвейера обработки.
    asyncio.run(main())

код: https://github.com/dmitriiweb/parse-pdf-article-example