Как извлечь текст из PDF и собрать генератор КП
Мой генератор КП однажды споткнулся о 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 слайдов про ИИ в работе парикмахеров и стилистов) пайплайн выглядел так:
- Распаковать ассеты — 10 фотографий. Сразу всплыла мелочь: одной картинки в архиве не было, и слайд под неё пришлось переверстать как текстовый разворот без фото. Пайплайн не должен падать из-за одного отсутствующего файла — он должен деградировать аккуратно.
- Сверстать 18 HTML-слайдов на общем
base.css, сохранив исходную палитру (forest/sand/gold/sage) и шрифты (Playfair Display для заголовков, Plus Jakarta Sans для текста). - Прогнать через Playwright-скрипт, получить
slide-01.png … slide-18.png. - Собрать 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 →