Задача
FOURLEDS печатает каталог продукции. Типография принимает файлы InDesign и выдаёт в ответ двухстраничные технические требования к макетам: CMYK, PDF 1.3, изображения не более 350 ppi, суммарная краска до 300 %, никакого составного чёрного на мелком тексте, TrimBox и BleedBox на каждой полосе, без встроенного выходного ICC-профиля. Сам каталог живёт в базе — 31 модель светильников со спецификациями, фотометрией и фотографиями, — так что пересобирать его руками в InDesign при каждом изменении цены невозможно.
Поэтому сервис PDF (Flask + WeasyPrint + pikepdf + Ghostscript) рендерит каталог из тех же данных о продукции, что и сайт, а потом доводит результат до печатной готовности. Сложность не в вёрстке. Сложность в последних 5 %.
Что даёт WeasyPrint и чего не даёт
WeasyPrint верстает печатный шаблон на листе 234,8 x 321,8 мм: дообрезной формат A4, 5 мм вылетов и 7,4 мм служебного поля с метками реза, приводочными крестами и шкалами контроля — повторяя уже принятый заказчиком оригинал в InDesign. Эта часть — удовольствие: обычные HTML и CSS, пагинатор на TypeScript измеряет высоту блоков в миллиметрах и раскладывает серии по разворотам, предпросмотр одной полосы мгновенный.
На выходе — RGB PDF 1.7, в котором есть только MediaBox. Ни одно требование из списка выше ещё не выполнено.
Проход 1 — Ghostscript: RGB в DeviceCMYK
gs -dBATCH -dNOPAUSE -dSAFER -sDEVICE=pdfwrite \
-dCompatibilityLevel=1.3 \
-sColorConversionStrategy=CMYK -dProcessColorModel=/DeviceCMYK \
-dEmbedAllFonts=true -dSubsetFonts=true \
-dDownsampleColorImages=true -dColorImageResolution=350 \
-dColorImageDownsampleThreshold=1.0 -dColorImageDownsampleType=/Bicubic
Результат я сверял с мастер-файлом, который типография уже приняла. Тёмная плашка #0a0a0a конвертируется в C71 M67 Y67 K85 против C72 M67 Y67 K85 в оригинале; серая шкала #B7B7B7 даёт C28 M23 Y24 K0 — совпадение точное. Фотографии сохраняют насыщенность. Разница в цветоделении на печати неразличима.
Подход, который я выбросил: -dUseFastColor с единичными кривыми BG/UCR, то есть полный GCR. Он соблазнителен тем, что делает чёрный чистым 100 K. Но заодно он заменяет чёрной краской хроматическую тёмную составляющую каждого полутона: измеренная насыщенность упала до 90 против 160 в оригинале, фотографии посерели, а все глубокие чёрные схлопнулись в 100 K. Не лечите проблему чёрного текста через GCR.
Проход 2 — pikepdf: чистые краски, TrimBox, BleedBox
ICC-конвертация верна для фотографий и неверна для тех немногих цветов, которые шаблон использует как краску. Мелкий чёрный текст обязан быть чистым 100 K; поля шкал контроля и серый клин в служебном поле должны остаться одноканальными, иначе они не выполнят свою функцию на печатном листе. При этом большие тёмные плашки в шаблоне намеренно заданы как #0a0a0a и #060606, а не #000 — они должны воспроизвестись тем самым составным чёрным около 290 %, что и в оригинале.
Поэтому pikepdf проходит по содержимому страниц и возвращает короткий список краскообразующих исходных цветов к чистым сепарациям, оставляя всё остальное — и в первую очередь пиксели изображений — с полной ICC-конвертацией. Затем он проставляет на каждой полосе TrimBox (210 x 297) и BleedBox (дообрезной + 5 мм) и не встраивает выходной профиль, как и требуется. Суммарная краска в пике — около 294 % при их лимите 300 %.
Приём, который делает это поддерживаемым: таблица замен выучивается у самого Ghostscript. Пробный PDF с этими исходными цветами прогоняется через ровно тот же вызов gs, что и документ, и те литералы CMYK, которые gs для них пишет, становятся ключами поиска. Обновится Ghostscript или его профили — таблица переедет вместе с ними, ничего версионно-зависимого в коде не зашито. Если проба выглядит подозрительно, таблица остаётся пустой и замена просто не выполняется.
Ошибка, стоившая дороже всех
В PDF 1.3 нет прозрачности, поэтому Ghostscript её сплющивает — растрированием. Первые печатные сборки вышли страницами картинок вместо векторного текста, притом что прозрачности в документе нет вообще.
Причина: WeasyPrint безусловно ставит /Group << /S /Transparency >> на каждую страницу и каждый form XObject и вешает записи ExtGState на заведомо непрозрачные обводки SVG. Одного их присутствия хватает, чтобы gs перешёл в режим сплющивания. Лечится проходом pikepdf перед Ghostscript: снять обёртку группы там, где внутри нет ни маски прозрачности, ни режима наложения, ни постоянной альфы — на композит это повлиять не может, — и нормализовать пустые ExtGState. Настоящую прозрачность трогать не нужно, её gs сплющит локально.
Эксплуатация
Самый прожорливый по памяти узел здесь — WeasyPrint. Два одновременных рендера полного каталога в оперативную память не помещаются, поэтому рендеры сериализованы семафором независимо от того, сколько потоков gunicorn свободно, с большим таймаутом очереди; health-check и предпросмотр одной полосы при этом отвечают сразу.
Что бы я сделал иначе
Я бы сначала измерил принятый мастер-файл и только потом писал конвертацию. На подход с GCR ушёл день, потому что в теории он звучал правильно; десять минут с пипеткой по файлу самой типографии закрыли бы вопрос сразу. Всё удачное в этом конвейере появилось из того, что принятый заказчиком PDF считался спецификацией, а письменные требования — его кратким пересказом.