П/ВИН

Как извлечь текст из PDF и собрать генератор КП

·9 мин чтения

Мой генератор КП однажды споткнулся о presentation, где клиент прислал диагностику картинкой, и попытка извлечь текст из PDF вернула пустоту - файл оказался image-PDF, который наотрез отказывался отдавать содержимое. Тогда я и понял: когда клиент платит за коммерческое предложение или презентацию, он покупает не текст, а ощущение, что им занимались всерьёз. Любой кривой отступ, обрезанная по краю фотография или формулировка в духе «клиент утверждает, что...» вместо уверенного «у вас выявлено...» - и доверие проседает. Именно поэтому генератор КП превратился у нас из «склейки промптом» в полноценный продакшен-пайплайн, где каждый слайд проходит рендер, визуальную проверку и сборку в PDF: от промпта, который врал в выводах диагностики, до пиксель-перфект вёрстки 18-слайдовой презентации без единого обрезанного кадра.

Что вообще такое генератор КП

Генератор КП (коммерческих предложений) — это связка из двух частей. Первая отвечает за смысл: на основе данных клиента из базы формируется текст предложения — итоги диагностики, рекомендации, объём работ. Вторая отвечает за форму: HTML-шаблоны слайдов рендерятся в картинки и собираются в готовый PDF, который можно отправить заказчику. Звучит просто, пока не начинаешь разбираться, почему именно «итоги диагностики» читаются так, будто их писал не эксперт, а скептик.

Изначально проблема пришла с конкретной жалобой: промпт, отвечающий за секцию «Итоги диагностики», выдавал неверный результат. Чтобы понять, что именно не так, нужно было сравнить два документа — отредактированный вручную PDF, который клиент прислал как эталон, и то, что генерировал промпт для того же клиента из базы. Казалось бы, тривиальная задача: вытащить текст из PDF и сравнить. На деле это превратилось в небольшое расследование.

Проблема №1: PDF, который не отдаёт текст

Первый же шаг — извлечь текст из присланного diagnostika.pdf — упёрся в стену. Файл на 6 страниц, весит почти 3 МБ, существует, открывается глазами. Но стандартный путь через Python-библиотеку не сработал: модуль pypdf в окружении сервера просто не был установлен.

Логичное «ну так поставь» обернулось второй стеной. Современный системный Python на Ubuntu защищён политикой PEP 668 — externally-managed-environment. Это значит, что pip install в системное окружение блокируется намеренно, чтобы не сломать пакеты, которыми управляет apt. Сообщение об ошибке прямо говорит: либо ставь через виртуальное окружение, либо явно соглашайся на риск флагом.

# Так — блокируется политикой PEP 668
pip install pypdf
# error: externally-managed-environment
 
# Так — работает, но осознанно ломая системную защиту
pip install --break-system-packages --user pypdf

Окей, библиотека встала. Запускаем извлечение текста — и получаем пустоту. Все 6 страниц, extract_text() возвращает пустые строки. Это классический симптом: перед нами не текстовый PDF, а image-based — по сути, скан или экспорт картинками, где буквы — это пиксели, а не символы. pypdf в принципе не умеет читать такое, ему нечего извлекать.

from pypdf import PdfReader
r = PdfReader('diagnostika.pdf')
print(f'pages: {len(r.pages)}')          # pages: 6
for i, p in enumerate(r.pages):
    print(repr(p.extract_text()))         # '' '' '' '' '' '' — пусто на всех

Единственный путь к тексту в таком случае — это рендер страниц в изображения с последующим OCR, либо инструменты, которые умеют работать с PDF на более низком уровне. Мы пошли в сторону poppler-utils — это пакет с утилитой pdftotext и компаньонами вроде pdftoppm, которые превращают страницы в картинки для дальнейшего распознавания. Установка через системный пакетный менеджер прошла штатно:

sudo apt-get install -y poppler-utils

Главный урок из этого эпизода — не технический, а методологический. Когда тебе говорят «промпт работает неверно», первый инстинкт — лезть править промпт. Но правильный шаг — сначала получить эталон и фактический вывод рядом, на одном экране. И вот тут выяснилось, что эталон физически недоступен в виде текста, потому что это картинка. Половина «багов в логике» на самом деле оказываются «багами в инструментах», и пока ты не разложил оба артефакта рядом, ты чинишь вслепую.

Что касается самого промпта — корневая причина была в постановке. Промпт фреймил данные клиента как пересказ с чужих слов («со слов клиента», «клиент сообщает»), тогда как в итогах диагностики это должны быть утверждения от лица эксперта, констатация фактов. Разница между «вы жалуетесь на X» и «у вас диагностировано X» — это разница между анкетой и заключением специалиста. КП должно звучать как заключение.

Проблема №2: пиксель-перфект рендер презентаций

Вторая большая часть продукта — это превращение HTML-шаблонов в готовую презентацию. Сценарий типовой: приходит свёрстанный HTML (Tailwind, Google Fonts, кастомная палитра), и нужна «пиксель-перфект презентация как обычно» — то есть набор слайдов фиксированного размера, которые гарантированно выглядят одинаково у всех, без сюрпризов от браузера получателя.

Решение — рендерить каждый слайд в PNG фиксированного разрешения 1920×1080 через headless-браузер, а потом собирать всё в PDF. Для этого мы используем Playwright, запущенный через Bun как рантайм для TypeScript-скрипта рендера. Playwright открывает каждый HTML, ждёт загрузки шрифтов и ассетов, делает скриншот вьюпорта ровно 1920×1080 — и на выходе получается растровый слайд, который выглядит идентично везде.

На кейсе презентации «tatiana-2026» (18 слайдов про ИИ в работе парикмахеров и стилистов) пайплайн выглядел так:

  1. Распаковать ассеты — 10 фотографий. Сразу всплыла мелочь: одной картинки в архиве не было, и слайд под неё пришлось переверстать как текстовый разворот без фото. Пайплайн не должен падать из-за одного отсутствующего файла — он должен деградировать аккуратно.
  2. Сверстать 18 HTML-слайдов на общем base.css, сохранив исходную палитру (forest/sand/gold/sage) и шрифты (Playfair Display для заголовков, Plus Jakarta Sans для текста).
  3. Прогнать через Playwright-скрипт, получить slide-01.png … slide-18.png.
  4. Собрать PDF (18 страниц, ~2.7 МБ) и общий ZIP-архив со всеми исходниками и рендерами.

Ключевая мысль здесь: HTML — это исходник, а доставляемый артефакт — это PNG и PDF. Браузер клиента не участвует в финальном отображении вообще. Это убирает целый класс проблем «а у меня шрифт другой» и «а у меня поехала сетка».

Проблема №3: картинки, которые обрезаются

И вот тут начался самый интересный и самый «дизайнерский» этап. Заказчик сформулировал предельно чётко: картинки не должны обрезаться, ни одна фотография не должна быть пропущена, и нужно «подумать как дизайнер». А ещё — убрать визуальный мусор: сноски про Awwwards, бейджи «Edition 2026» в футерах, технические file-бейджи, которые засоряли вид.

Корень проблемы — в соотношениях сторон. Слайд у нас 16:9 (1.78), а фотографии пришли в трёх разных пропорциях:

  • 16:9 (1.78) — слайды 1, 3, 6, 11, 13, 18. Идеально совпадают с пропорцией слайда.
  • 3:4 / 4:5 (портрет) — слайды 8, 9, 10.
  • 2:1 (ультра-широкая) — слайд 14.

Если вставлять такие картинки в фиксированные фреймы «в лоб» через object-fit: cover, браузер заполняет фрейм целиком и обрезает всё, что не влезло. Для портрета в горизонтальном фрейме это означает отрезанные макушки и подбородки. Неприемлемо.

Решение оказалось простым и системным — фундамент на object-fit: contain:

/* Было: картинка заполняет фрейм и обрезается по краям */
.col-image .frame img { object-fit: cover; }
 
/* Стало: картинка целиком вписывается в фрейм, кроп нулевой */
.col-image .frame img { object-fit: contain; }

contain вписывает картинку в контейнер целиком, сохраняя пропорции — ничего не режется. Платой за это становится letterbox: пустые поля сверху-снизу или по бокам там, где пропорции картинки и фрейма не совпадают. И вот ключевой трюк, который делает это решение «дизайнерским», а не «костыльным»: фон фрейма красится в тот же цвет, что и фон слайда (sand-100 на светлых слайдах, forest-950 на тёмных). В результате letterbox становится невидимым — глаз не видит «полей», он видит картинку, аккуратно лежащую на фоне.

Дальше — индивидуальный раскрой под каждое соотношение:

  • Слайды 1 и 18 (16:9) — full-bleed: фотография занимает весь слайд целиком, текст идёт оверлеем поверх затемняющего градиента. Пропорции совпадают, кроп нулевой, а картинка работает как полноценный hero-шот.
  • Слайды 3, 6, 11, 13 (16:9) — узкая колонка ~38% с contain на фоне слайда: лёгкий letterbox сверху-снизу, но кадр целый.
  • Слайды 8, 9, 10 (портрет) — узкие портретные фреймы, заполняющие высоту.
  • Слайд 14 (2:1) — отдельная история. Сначала мы попробовали растянуть его в широкую hero-полосу сверху, но картинка кропалась под баннер, а заголовок налезал на фото. Присмотревшись, поняли: это по сути скриншот контент-плана, его нельзя кадрировать как декоративный баннер — он несёт информацию. Переделали его как референс-карточку, где изображение показывается целиком как самостоятельный объект.

Параллельно вычистили мусор: убрали из футеров всех слайдов сноски Awwwards и «Edition 2026», убрали file-бейджи. Презентация перестала выглядеть как экспорт из чужого шаблона и стала выглядеть как заказная работа.

Результат

После всех итераций мы прошлись по всем 18 слайдам с рендером и визуальной проверкой — кроп нулевой везде, ни одна фотография не пропущена, ни одна не обрезана. Конкретные цифры по кейсу:

  • 18 слайдов отрендерены в строго 1920×1080 — размеры точные, без отклонений.
  • PDF на 18 страниц, ~2.7 МБ — финальный артефакт для отправки клиенту.
  • ZIP ~28 МБ — единый архив со всеми исходниками, фото и рендерами, чтобы заказчик мог забрать всё одним файлом.
  • 0 обрезанных картинок — против полного хаоса при наивном cover.
  • 1 отсутствующий ассет обработан без падения пайплайна — слайд аккуратно переверстан как текстовый.

Главное — это воспроизводимый процесс. HTML-исходники, общий base.css, скрипт рендера на Playwright, сборка PDF и ZIP. Следующая презентация пройдёт по тем же рельсам, и борьбу с пропорциями уже не нужно изобретать заново — она зашита в CSS-фундамент.

Выводы

Сначала эталон, потом исправление. История с image-PDF — наглядный пример того, как нельзя чинить промпт «по ощущениям». Пока у тебя нет фактического вывода и эталона рядом на одном экране, любая правка — это стрельба в тумане. Половину времени мы потратили не на логику, а на то, чтобы вообще получить эталонный текст из картиночного PDF. И это нормально: добывание данных для сравнения — это часть отладки, а не прелюдия к ней.

Инфраструктурные грабли важнее, чем кажутся. PEP 668, --break-system-packages, отсутствующий poppler-utils — всё это «не наша задача», но именно это блокировало работу. Современные дистрибутивы намеренно защищают системный Python, и это правильно, но об этом надо знать заранее. Урок на будущее: для разовой обработки файлов лучше держать изолированное окружение, а не воевать с системным пакетным менеджером.

Финальный артефакт не должен зависеть от браузера получателя. Рендер HTML в PNG и PDF через headless-браузер — это не оверинжиниринг, а единственный способ гарантировать, что клиент увидит ровно то, что мы сверстали. Шрифты, сетки, цвета — всё запекается в растр на нашей стороне. Это снимает целый класс «а у меня выглядит по-другому» ещё до того, как он возникнет.

object-fit: contain + фон в цвет слайда — это паттерн, а не костыль. Самое элегантное решение в проекте оказалось и самым простым: не подгонять картинку под фрейм, а подгонять фон под картинку. Когда letterbox сливается с фоном, пользователь видит цельный кадр без полей и без кропа. Этот принцип переносится на любую галерею или презентацию, где картинки приходят в разных пропорциях — а они всегда приходят в разных пропорциях. И последнее: «подумать как дизайнер» почти всегда означает не добавить, а убрать — лишние бейджи, сноски и технический мусор крадут у работы ощущение завершённости сильнее, чем кажется.

Паша Вин
Паша Вин

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.

Связанный проект

Slides Generator