A comprehensive PrintCSS / CSS Paged Media tutorial and showcase website, featuring lessons that demonstrate PDF generation from HTML and XML using CSS stylesheets across multiple vendor converters.
Website: https://www.print-css.rocks
Source: https://github.com/zopyx/print-css-rocks
Author: Andreas Jung / ZOPYX — info@zopyx.com
- Project Structure
- Prerequisites
- Running Conversions
- Generating PNG Preview Images
- Running PDF Comparisons (vdiff)
- Understanding conversion.ini
- Lesson Categories
- Converter Status Meanings
- Running the Web Service
- Vercel Deployment
- Docker Container
- Adding a New Lesson
- Web Service Internals
- Updating the Lesson Order
print-css.rocks/
├── lessons/
│ ├── lessons.ini # Ordered lesson list (controls display order)
│ ├── Makefile # Shared conversion targets (included by lessons)
│ ├── vdiff.py # Pairwise PDF visual diff script
│ ├── styles/
│ │ └── Makefile # CSS linting
│ ├── vendor-specific/ # Vendor-specific demonstrations
│ │ └── antennahouse/
│ │ ├── sidearea/
│ │ ├── docx2/
│ │ └── docx3/
│ ├── lesson-*/ # Individual lessons (~70+ lessons)
│ │ ├── index.html # HTML source document (or index.xml)
│ │ ├── styles.css # PrintCSS stylesheet
│ │ ├── conversion.ini # Lesson metadata + per-converter status
│ │ ├── Makefile # Usually just: include ../Makefile
│ │ ├── README.rst / README.md # Optional lesson description
│ │ ├── *.pdf # Generated PDFs (one per converter)
│ │ └── images/ # Generated PNG previews (per converter subdirectory)
│ └── in-progress/ # Draft/incomplete lessons
├── web/
│ ├── server.py # FastAPI web server (production, Vercel)
│ ├── server2.py # FastAPI web server (older variant, not used)
│ ├── Makefile # Install & serve targets
│ ├── requirements.txt # Python dependencies
│ ├── content/ # RST source files for static pages
│ │ ├── intro.rst # Homepage content
│ │ ├── tools.rst # Tools overview page
│ │ ├── references.rst # References page
│ │ ├── contributing.rst # Contributing guide
│ │ ├── consulting.rst # Consulting services
│ │ ├── support.rst # Support page
│ │ ├── related.rst # Related projects
│ │ ├── discussion.rst # Discussion page
│ │ ├── showcases.rst # Showcase page
│ │ ├── about.rst # About page
│ │ ├── blog.rst # Blog index
│ │ └── blog_*.rst # Individual blog posts
│ ├── templates/
│ │ ├── base.html # Base layout (nav, footer, meta)
│ │ ├── content.html # Simple page (extends base.html)
│ │ ├── lessons.html # Lessons overview (compliance matrix table)
│ │ └── lesson.html # Individual lesson detail page
│ └── static/ # CSS, JS, fonts, images, icons
├── docker/
│ └── README.md # Docker container documentation
├── vercel.json # Vercel deployment configuration
└── README.md # This file
Each converter must be installed and available on $PATH. Output file names are
fixed per converter and must match what conversion.ini declares.
| Converter | Command / Invocation | Source / Package | Output file |
|---|---|---|---|
| PDFreactor | pdfreactor.py -i <input> -o <output> |
Commercial, https://www.pdfreactor.com | pdfreactor.pdf |
| PrinceXML | prince <input> -o <output> |
Commercial, https://www.princexml.com | prince.pdf |
| Antenna House | run.sh -d <input> -o <output> |
Commercial, https://www.antennahouse.com | antennahouse.pdf |
| WeasyPrint | weasyprint <input> <output> |
pip install weasyprint |
weasyprint.pdf |
| PagedJS | pagedjs-cli -i <input> -o <output> |
npm install -g pagedjs-cli |
pagedjs.pdf |
| Vivliostyle | vivliostyle build --output <output> <input> |
npm install -g @vivliostyle/cli |
vivliostyle.pdf |
| TypeSet.sh | typesetsh.phar render:html --allow-local /home -rx <input> <output> |
PHP-based, commercial | typeset.pdf |
| BFO | bfo.sh --output <output> <input> |
Commercial, https://www.bfo.com | bfo.pdf |
| Tool | Purpose | Installation |
|---|---|---|
| ImageMagick | Generating PNG previews from PDFs | brew install imagemagick (macOS) |
| Python 3 | Web server + various utilities | System package or pyenv |
| diff-pdf | Visual PDF comparison (for vdiff) | brew install diff-pdf (macOS) |
The web server requires packages from web/requirements.txt:
aiofiles typer loguru markdown easyprocess docutils fastapi furl
h11 h2 hpack Hypercorn hyperframe Jinja2 lxml MarkupSafe
orderedmultidict priority pydantic six starlette toml typing-extensions
wsproto pygments
Installation:
cd web
python3 -m venv .
bin/pip install -r requirements.txtEach lesson is a standalone directory under lessons/. The Makefile system
allows running one, some, or all converters against a lesson.
Most lessons use HTML input (index.html) and inherit the shared Makefile:
# Convert a single lesson with all converters
cd lessons/lesson-basic
make
# This runs (in order, with FORCE so always runs):
# pdfreactor.py -i index.html -o pdfreactor.pdf
# prince --javascript index.html -o prince.pdf
# run.sh -d index.html -o antennahouse.pdf
# weasyprint -e utf-8 index.html weasyprint.pdf
# timeout --foreground -s 9 10 pagedjs-cli -i index.html -o pagedjs.pdf
# typesetsh.phar render:html --allow-local /home -rx index.html typeset.pdf
# vivliostyle build --output vivliostyle.pdf index.html
# bfo.sh --output bfo.pdf index.htmlThe shared lessons/Makefile is the source of truth for these targets. Each
lesson's Makefile typically contains only:
include ../MakefileSome lessons use XML input (index.xml with conversion.ini setting
mode = xml). They have their own non-included Makefile because the converter
invocations differ (e.g. different arguments for XML input, or per-converter
CSS files). Examples: lesson-xml, lesson-xml-images.
cd lessons/lesson-xml
make
# This runs:
# pdfreactor.py -c styles.css -i index.xml -o pdfreactor.pdf
# prince -s styles.css index.xml -o prince.pdf
# run.sh -css styles.css -d index.xml -o antennahouse.pdf
# bfo.sh --output bfo.pdf --css styles.css index.xmlNote: XML-based lessons typically support fewer converters (those with native
XML+CSS support: PDFreactor, PrinceXML, Antenna House, BFO). WeasyPrint,
PagedJS, TypeSet.sh, and Vivliostyle usually do not support raw XML input
and are marked UNSUPPORTED in conversion.ini.
cd lessons/lesson-basic
make pdfreactor # Run only PDFreactor
make prince # Run only PrinceXML
make antennahouse # Run only Antenna House
make weasyprint # Run only WeasyPrint
make pagedjs # Run only PagedJS (wraps in timeout: 9s)
make typeset.sh # Run only TypeSet.sh
make vivliostyle # Run only Vivliostyle
make bfo # Run only BFOThe leading - prefix in the Makefile (e.g. -prince ...) means Make ignores
any errors — a failed converter does not abort the build.
# Convert all published lessons
cd lessons
for d in lesson-*/; do
echo "=== Converting $d ==="
(cd "$d" && make && make images)
doneTo convert only a subset (e.g. intro category):
cd lessons
for d in lesson-basic lesson-fonts lesson-images; do
(cd "$d" && make && make images)
donecd lessons/lesson-basic
make clean
# Removes all *.pdf files in the lesson directory and subdirectoriesOr from the root:
find lessons -name "*.pdf" -exec rm {} \;After PDFs are generated, you create PNG previews for the web display:
cd lessons/lesson-basic
make imagesThis creates an images/ directory with a subfolder per converter, e.g.:
images/
├── pdfreactor/
│ ├── pdfreactor.png # Page 1 preview (first PDF page)
│ └── thumb-pdfreactor.png # 100x100 thumbnail
├── princexml/
│ ├── prince.png
│ └── thumb-prince.png
├── antennahouse/
├── weasyprint/
├── pagedjs/
├── typeset.sh/
├── vivliostyle/
└── bfo/
The ImageMagick invocation creates:
- Full-size PNGs at 150 DPI, quality 85
- Thumbnails at 100x100 with white background and alpha removal
Note: PrinceXML images go into directory princexml/ in the shared
Makefile, but the web server looks for the directory prince/ for PrinceXML
images (see the image_subdir logic in server.py line 397-399). Make sure
both exist or create a symlink.
For XML-based lessons that have their own Makefile, the images target must be run separately (the make target is built into that lesson's Makefile, not inherited).
The lessons/vdiff.py script generates pairwise visual diffs between PDFs
in a lesson directory. This is useful for comparing the output quality of
different converters.
cd lessons/lesson-basic
python3 ../vdiff.pyThe script:
- Finds all
.pdffiles in the current directory - Generates all unique unordered pairs (e.g.,
prince.pdfvspdfreactor.pdf) - Runs
diff-pdf --output-difffor each pair - Places results in
diffs/subdirectory
Output example:
diffs/
├── pdfreactor-antennahouse.pdf
├── pdfreactor-pagedjs.pdf
├── pdfreactor-prince.pdf
├── pdfreactor-vivliostyle.pdf
├── pdfreactor-weasyprint.pdf
├── pdfreactor-typeset.pdf
├── pdfreactor-bfo.pdf
├── prince-antennahouse.pdf
├── prince-pagedjs.pdf
... etc.
Requirement: diff-pdf must be
installed and available on $PATH.
Note: This is a simple script — it runs in the current directory for all
PDF files, so you must run it from within a specific lesson directory, not
from the lessons/ root.
Every lesson requires a conversion.ini file that serves as both metadata
and compliance record. Here is the full format:
[common]
category = intro # Category (see below)
title = Basic # Display title
mode = html # Source type: "html" (default) or "xml"
description = Brief overview text # Optional, shown on lessons overview page
[PDFreactor]
status = OK # Status: OK, (OK), ERROR, or UNSUPPORTED
pdf = pdfreactor.pdf # Generated output filename
message = # Optional human-readable note
[PrinceXML]
status = (OK) # (OK) = OK with issues
pdf = prince.pdf
message = Sun is missing # Explains what the issue is
[Antennahouse]
status = OK
pdf = antennahouse.pdf
message =
[Weasyprint]
status = UNSUPPORTED # Converter doesn't support this feature
pdf = weasyprint.pdf
message = No native MathML support
[PagedJS]
status = ERROR # Converter ran but produced bad output
pdf = pagedjs.pdf
message = Conversion error, no PDF generated
[Typeset.sh]
status = OK
pdf = typeset.pdf
message =
[Vivliostyle]
status = OK
pdf = vivliostyle.pdf
message =
[BFO]
status = ERROR
pdf = bfo.pdf
message = Rendering errorsNot all sections need to be present. The web server (server.py) only reads
sections matching known converter names:
PDFreactor, PrinceXML, Antennahouse, Weasyprint, PagedJS,
Typeset.sh, Vivliostyle, BFO.
The [common] section supports these fields:
| Field | Required | Default | Description |
|---|---|---|---|
category |
No | intro |
Display category on the lessons overview page |
title |
No | Lesson directory name | Human-readable lesson title |
mode |
No | html |
Input type: html or xml |
description |
No | None | Shown on the lessons overview page |
Lessons are grouped into categories on the lessons overview page (/lessons).
The category is set in conversion.ini under [common] category =.
| Category | Description |
|---|---|
intro |
Introductory / basic lessons |
advanced |
Advanced CSS Paged Media features |
special |
Special/demonstration lessons |
javascript |
Lessons using JavaScript-based charting or interactivity |
xml |
Lessons using XML input |
mathml |
Lessons demonstrating MathML support |
pdfreactor |
PDFreactor-specific features |
pagedjs |
PagedJS-specific features |
antennahouse |
Antenna House-specific features |
princexml |
PrinceXML-specific features |
weasyprint |
WeasyPrint-specific features |
vivliostyle |
Vivliostyle-specific features |
bfo |
BFO-specific features |
Each converter section in conversion.ini has a status field. The web server
renders an icon based on this value (file /static/<status>.svg):
| Status | Icon | Meaning |
|---|---|---|
OK |
ok.svg |
Green checkmark — converter produces correct output |
(OK) |
(ok).svg |
Yellow/amber — output is mostly correct but has minor issues |
ERROR |
error.svg |
Red X — converter produced broken/incorrect output |
UNSUPPORTED |
unsupported.svg |
Gray — feature not supported by this converter at all |
The web service is a FastAPI application (web/server.py) that serves the
print-css.rocks website. It renders static content pages from RST files and
dynamic lesson pages from the lessons directory.
# 1. Install dependencies (first time only)
cd web
make install
# This creates a venv and runs: bin/pip install -r requirements.txt
# 2. Start the development server (port 8000)
make serve
# Equivalent to:
# LESSON_ROOT=../lessons bin/hypercorn server:app --bind 0.0.0.0:8000 --graceful-timeout 1
# 3. Open in browser
open http://localhost:8000cd web
LESSON_ROOT=../lessons bin/hypercorn server:app \
--bind 0.0.0.0:8000 \
--graceful-timeout 1
# Alternative port for beta/staging
make serve-beta
# LESSON_ROOT=../lessons bin/hypercorn server:app --bind 0.0.0.0:8001 --graceful-timeout 1The $LESSON_ROOT environment variable is required — it tells the server
where to find the lessons/ directory. Also $GENERATED_ROOT is derived as
$LESSON_ROOT/generated, though currently the lessons store generated files
in-place (alongside source), so generated/ may not exist or be empty.
| Route | Description |
|---|---|
GET / |
Homepage (from intro.rst) |
GET /tools |
Tools overview page |
GET /references |
References page |
GET /related |
Related projects page |
GET /discussion |
Discussion page |
GET /blog |
Blog index |
GET /blog/{slug} |
Individual blog post |
GET /showcases |
Showcase page |
GET /about |
About page |
GET /consulting |
Consulting services page |
GET /contributing |
Contributing guide |
GET /support |
Support page |
GET /lessons |
Lessons overview (compliance matrix table) |
GET /lesson/{lesson} |
Individual lesson detail page |
GET /lesson/{lesson}/download/{filename} |
Download generated PDF file |
GET /lesson/{lesson}/download/images/{vendor}/{filename} |
Download preview image |
GET /static/{path} |
Static files (CSS, JS, images) — auto-mounted |
-
RST rendering: Static pages are stored as reStructuredText (
.rst) files inweb/content/. They are rendered to HTML at request time usingdocutils. The server uses a customHTMLFragmentTranslatorthat produces only the body content (no<html>,<head>, or CSS wrappers). -
Lesson pages: The server scans
$LESSON_ROOTfor directories starting withlesson-, reads eachconversion.ini, and builds the compliance data. Lesson order on the overview page is determined bylessons.ini. -
X-Forwarded-Host: The server handles reverse proxy setups (e.g. behind a load balancer or Vercel's edge network) by reading the
x-forwarded-hostheader and rewriting URLs to HTTPS.
The project deploys on Vercel's serverless platform. Configuration is in
vercel.json:
{
"version": 2,
"builds": [
{
"src": "web/server.py",
"use": "@vercel/python"
}
],
"routes": [
{
"src": "/static/(.*)",
"dest": "web/static/$1"
},
{
"src": "/(.*)",
"dest": "web/server.py"
}
],
"env": {
"LESSON_ROOT": "lessons"
}
}Deploy:
# Install Vercel CLI (first time only)
npm install -g vercel
# Deploy to production
vercel --prodKey points:
- Vercel routes
/static/*directly to the static files directory - All other requests go to
web/server.pyvia the Vercel Python runtime $LESSON_ROOTis set tolessons(relative to project root)- PDF downloads and image previews work through FastAPI routes
A Docker image with several converters pre-installed is available as
zopyx/print-css-rocks. This image exposes a REST conversion API
(via pp.server), not the tutorial
web UI.
docker run -p 8000:8000 zopyx/print-css-rocksPre-installed converters:
- PrinceXML
- PagedJS CLI
- Vivliostyle CLI
- Speedata
- WeasyPrint
REST API documentation:
- Swagger/OpenAPI: http://localhost:8000/docs
- Package: https://pypi.org/project/pp.server/
- Python client bindings: https://pypi.org/project/pp.client-python/
See docker/README.md for full details.
1. Create the lesson directory
cd lessons
mkdir lesson-my-topic
cd lesson-my-topic2. Create the source document
For HTML input, create index.html:
<html>
<head>
<link rel="stylesheet" type="text/css" href="styles.css" />
</head>
<body>
<h1>My Lesson</h1>
<p>Content goes here...</p>
</body>
</html>For XML input, create index.xml with mode = xml in conversion.ini.
3. Create the PrintCSS stylesheet
touch styles.css4. Create conversion.ini
[common]
category = intro
title = My Topic
description = A brief description of this lesson
[PDFreactor]
status = OK
pdf = pdfreactor.pdf
message =
[PrinceXML]
status = OK
pdf = prince.pdf
message =
[Antennahouse]
status = OK
pdf = antennahouse.pdf
message =
[Weasyprint]
status = OK
pdf = weasyprint.pdf
message =
[PagedJS]
status = OK
pdf = pagedjs.pdf
message =
[Typeset.sh]
status = OK
pdf = typeset.pdf
message =
[Vivliostyle]
status = OK
pdf = vivliostyle.pdf
message =
[BFO]
status = OK
pdf = bfo.pdf
message =If a converter cannot handle the feature, set its status to UNSUPPORTED
or ERROR with an appropriate message.
5. Create the Makefile
include ../MakefileFor XML-based lessons, write a standalone Makefile (see lesson-xml/Makefile
as reference).
6. Add the lesson to the ordering list
Edit lessons/lessons.ini and add the lesson directory name at the desired
position:
lesson-my-topic
The list controls the display order on the lessons overview page. Lessons not listed appear at the end (sorted alphabetically).
7. (Optional) Create README.rst
This description appears at the top of the lesson detail page:
This lesson demonstrates how to implement [feature] using CSS Paged Media.
Notes: some converters require ``--special-flag`` for this feature.8. Convert and generate images
cd lessons/lesson-my-topic
make
make images9. Verify
Start the web server:
cd web
make serveVisit http://localhost:8000/lessons and http://localhost:8000/lesson/lesson-my-topic to verify everything renders correctly.
lesson-my-topic/
├── index.html # Source document (or index.xml)
├── styles.css # PrintCSS stylesheet
├── conversion.ini # Metadata and per-converter status
├── Makefile # include ../Makefile (or custom)
├── README.rst # (Optional) lesson description
├── *.pdf # Generated PDFs (after make)
└── images/ # Generated PNG previews (after make images)
├── pdfreactor/
├── princexml/
├── antennahouse/
├── weasyprint/
├── pagedjs/
├── typeset.sh/
├── vivliostyle/
└── bfo/
| File | Status | Purpose |
|---|---|---|
server.py |
Active | Modern FastAPI app, deployed on Vercel and prod |
server2.py |
Legacy | Older variant (uses ValueUNSUPPORTED, no __main__ guard for FastAPI app.run), not deployed |
Both files share the same routing structure. server.py is the canonical
version with:
- Proper HTTPException for 404s
- Title, description, and mode fields from conversion.ini
bfocategory and converter support(OK)status icon support
- Request arrives at a route handler in
server.py - For static pages (
/,/tools, etc.):render_rst()reads the corresponding.rstfile fromweb/content/- Renders it to HTML via
docutilsusing a custom fragment translator - Returns
TemplateResponse("content.html", ...)which renders the HTML into thebase.htmllayout
- For the lessons overview (
/lessons):- Scans
$LESSON_ROOTforlesson-*/directories - Reads each
conversion.iniviaget_lesson_data() - Groups lessons by
category - Sorts by ordering from
lessons.ini - Renders the compliance matrix table via
lessons.htmltemplate
- Scans
- For a single lesson (
/lesson/{lesson}):- Reads
conversion.iniandstyles.cssandindex.html/index.xml - Lists generated PDFs and preview images
- Renders via
lesson.htmltemplate
- Reads
The get_lesson_data() function in server.py:
- Locates the lesson directory under
$LESSON_ROOT - Looks for
README.rstorREADME.mdfor the description - Parses
conversion.ini:- Reads
[common]formode,category,title,description - Reads each converter section (
PDFreactor,PrinceXML, etc.) forpdffilename,status, andmessage - Maps section name to image subdirectory (e.g.
PrinceXML→prince)
- Reads
- Checks for
styles.cssand source file (index.htmlorindex.xml) - Returns a dict with all lesson data used by the template
The file lessons/lessons.ini controls the display order on the /lessons
overview page. Each line is a lesson directory name (without lessons/ prefix).
lesson-basic
lesson-fonts
lesson-fonts-emoji
lesson-images
lesson-image-scaling
...Lessons listed here appear in this order within their category. Any lesson
not listed in lessons.ini will have ordering value 999 and appear at the
end of their category, sorted alphabetically.
To add a new lesson to a specific position:
- Edit
lessons.ini - Insert the lesson name at the desired line
- Restart the web server
The server prints warnings for PDF files declared in conversion.ini that
don't exist yet. Run make && make images in the lesson directory to
generate them.
Check that:
- PDFs exist in the lesson directory
- Image directory exists:
images/<vendor>/(e.g.images/princexml/) - For PrinceXML: the server looks for
images/prince/(notimages/princexml/) - Thumbnails exist:
images/<vendor>/thumb-*.png
The $LESSON_ROOT environment variable is required:
export LESSON_ROOT=/path/to/print-css-rocks/lessons
cd web
make serveRun make images in the lesson directory to generate PNG previews.
The leading - in Makefile commands suppresses errors. Check the converter's
own output for details. Common issues:
- Missing input file (
index.htmlorindex.xml) - Missing stylesheet (
styles.css) - Feature not supported (set
status = UNSUPPORTEDinconversion.ini) - Timeout (PagedJS has a 9-second timeout)