dobrovolskiy.com
RU
← Notes2026-08-31 · by Dmitriy Dobrovolskiy

Press-ready CMYK PDFs from HTML: WeasyPrint, Ghostscript and pikepdf against a printer's spec

How a product database becomes an offset-ready catalogue: WeasyPrint for layout, Ghostscript for RGB to DeviceCMYK measured against the printer's accepted master, pikepdf for pure inks, TrimBox and BleedBox, and the transparency bug that silently rasterised every page.

WeasyPrint renders a beautiful HTML catalogue and emits an RGB PDF 1.7 with only a MediaBox, which no printing house will accept. Ghostscript converts to DeviceCMYK through its ICC pipeline (verified against the printer's own accepted master: #0a0a0a becomes C71 M67 Y67 K85 against their C72 M67 Y67 K85), pikepdf snaps ink-keyed colours back to pure separations so small text stays 100 K, and a pre-pass strips WeasyPrint's unconditional transparency groups that otherwise make PDF 1.3 conversion rasterise the whole page.

The problem

FOURLEDS prints a product catalogue. The printing house accepts InDesign files and hands back a two-page list of technical requirements: CMYK, PDF 1.3, images no finer than 350 ppi, total ink under 300 %, no composite black on small text, TrimBox and BleedBox on every page, no embedded ICC output intent. The catalogue itself lives in a database - 31 fixture models with specs, photometrics and photos - so regenerating it from InDesign by hand every time a price changes is not an option.

So the PDF service (Flask + WeasyPrint + pikepdf + Ghostscript) renders the catalogue from the same product data the website uses, and then makes the result press-ready. The hard part is not the layout. It is the last 5 %.

What WeasyPrint gives you, and what it doesn't

WeasyPrint renders the press template onto a 234.8 x 321.8 mm sheet: A4 trim, 5 mm bleed, and a 7.4 mm slug carrying crop marks, registration targets and colour bars - mirroring the customer's already-accepted InDesign original. That part is a pleasure: it is HTML and CSS, the paginator is TypeScript that measures block heights in millimetres and packs product series onto offset spreads, and previewing a single page is instant.

What comes out is an RGB PDF 1.7 with only a MediaBox. Every requirement in the list above is still unmet.

Pass 1 - Ghostscript: RGB to 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

I measured the output against the master the printing house had already accepted. The dark band #0a0a0a converts to C71 M67 Y67 K85 against the original's C72 M67 Y67 K85; the grey bar #B7B7B7 lands on C28 M23 Y24 K0, identical. Photos keep their chroma. Close enough that the separations are indistinguishable on press.

The approach I threw away: -dUseFastColor with identity BG/UCR transfer functions - full grey component replacement. It looks tempting because it makes blacks a clean 100 K. It also replaces the chromatic dark component of every mid-tone with black ink: measured chroma dropped to 90 against the original's 160, photos went grey, and every rich black flattened to 100 K. Do not reach for GCR to fix a black-text problem.

Pass 2 - pikepdf: pure inks, TrimBox, BleedBox

The ICC conversion is right for photos and wrong for the few colours the template uses as ink. Small black text must be pure 100 K; the slug's colour-bar patches and grey ramp must stay single-ink, or they cannot do their job on the press sheet. Meanwhile the big dark panels in the template are deliberately #0a0a0a and #060606, not #000 - they are supposed to come out as the original's rich black of about 290 %.

So pikepdf walks the content streams and snaps a short list of ink-keyed source colours back to pure separations, leaving everything else - crucially, image pixels - with the full ICC conversion. Then it stamps TrimBox (210 x 297) and BleedBox (trim + 5 mm) on every page and leaves the output intent out, as required. Total ink peaks at about 294 %, under their 300 % limit.

The trick that makes this maintainable: the remap table is learned from Ghostscript itself. A probe PDF containing those source colours goes through the very same gs invocation as the document, and the CMYK literals gs writes for them become the lookup keys. Upgrade Ghostscript or its profiles and the table moves with them; nothing version-specific is hardcoded. If the probe comes back looking wrong, the table is empty and the remap simply skips.

The bug that cost the most time

PDF 1.3 has no transparency, so Ghostscript flattens it - by rasterising. My first press builds came out as pages of images instead of vector text, on a document that uses no alpha whatsoever.

The cause: WeasyPrint stamps /Group << /S /Transparency >> onto every page and every form XObject unconditionally, and puts ExtGState entries on plainly opaque SVG strokes. Their mere presence flips gs into flattening mode. The fix is a pikepdf pass before Ghostscript that removes the group wrapper where there is no soft mask, no blend mode and no constant alpha inside - which cannot change the composite - and normalises the no-op ExtGStates. Genuine transparency is left alone for gs to flatten locally.

Operational note

WeasyPrint is the memory hog in this pipeline. Two concurrent full-catalogue renders do not fit in RAM, so renders are serialised behind a semaphore regardless of how many gunicorn threads are idle, with a long queue timeout; health checks and single-page previews still answer immediately.

What I'd do differently

I would measure against the accepted master first, before writing any conversion code. I spent a day on the GCR approach because it sounded correct in theory; ten minutes with a colour picker on the printing house's own file would have killed it immediately. Everything good in this pipeline came from treating the customer's accepted PDF as the specification, and the written requirements as a summary of it.

FAQ

Why not just use InDesign?

Because the source of truth is a database of 31 fixture models with specs, photometrics, photos and prices. Every price change would mean redoing the layout by hand. The HTML template plus a paginator that measures blocks in millimetres regenerates the whole catalogue from current data in one run.

Does WeasyPrint support CMYK?

No. WeasyPrint emits an RGB PDF 1.7 with a MediaBox only. CMYK, PDF 1.3, image downsampling, TrimBox/BleedBox and pure-ink control all come from the Ghostscript and pikepdf passes after it.

Why does my Ghostscript CMYK conversion turn vector text into a bitmap?

PDF 1.3 has no transparency, so Ghostscript flattens by rasterising, and WeasyPrint stamps a /Group << /S /Transparency >> onto every page and form XObject even when the document uses no alpha at all. Remove those group wrappers with pikepdf before the gs pass - only where there is no soft mask, blend mode or constant alpha inside - and the text stays vector.

Want something like this?

I build it live on a Zoom call, you watch the screen, the timer stops when you say stop. First 15 minutes are free.

$50 / hourBook an hour

More projects

FOURLEDS lighting catalogueSingle source of truth for FOURLEDS products: public catalogue, print-ready PDF generator, product API for the rest of the software family, AI-assisted content