How to Apply CSS from a String When Generating a PDF in Python
Use WeasyPrint’s CSS(string=...) with HTML.write_pdf() to render in-memory stylesheets, fonts, assets, and reliable PDF output.
Use WeasyPrint’s CSS(string=css_text) constructor, then pass the stylesheet to HTML.write_pdf(stylesheets=[stylesheet]). Keep the HTML in memory with HTML(string=html_text) and provide a base_url when the document refers to relative images, fonts, or other files.
This pattern applies CSS generated by templates, database values, configuration, or Python code without writing a temporary stylesheet. The complete minimal example is:
from weasyprint import CSS, HTML
html = HTML(string="""
<h1>Report</h1>
<p>Generated from strings.</p>
""")
stylesheet = CSS(string="""
@page { size: A4; margin: 2cm; }
h1 { color: #174a7e; }
""")
html.write_pdf("report.pdf", stylesheets=[stylesheet])
The named string= arguments matter. They tell WeasyPrint to interpret the values as markup and stylesheet text rather than filesystem paths. See the WeasyPrint first-steps documentation for the underlying API.
1. Install WeasyPrint
Install the Python package in a virtual environment:
python -m venv .venv
# macOS or Linux
. .venv/bin/activate
# Windows PowerShell
# .venv\Scripts\Activate.ps1
python -m pip install weasyprint
WeasyPrint also depends on native libraries. Follow the installation instructions for your operating system if importing the package fails because a platform library is missing.
2. Generate a PDF from HTML and a CSS string
Here is a runnable script that writes a PDF file and keeps both inputs in memory:
from pathlib import Path
from weasyprint import CSS, HTML
html_text = """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Monthly report</title>
</head>
<body>
<h1>Monthly report</h1>
<p class="muted">Generated entirely from Python strings.</p>
<table>
<thead><tr><th>Item</th><th>Amount</th></tr></thead>
<tbody>
<tr><td>Hosting</td><td>$120</td></tr>
<tr><td>Support</td><td>$80</td></tr>
</tbody>
</table>
</body>
</html>
"""
css_text = """
@page {
size: A4;
margin: 20mm 18mm;
}
body {
color: #20242a;
font-family: sans-serif;
font-size: 11pt;
}
h1 {
color: #174a7e;
margin-bottom: 4mm;
}
.muted {
color: #68717d;
}
table {
border-collapse: collapse;
margin-top: 10mm;
width: 100%;
}
th, td {
border: 0.2mm solid #c9d1d9;
padding: 3mm;
text-align: left;
}
"""
html = HTML(string=html_text)
stylesheet = CSS(string=css_text)
html.write_pdf("report.pdf", stylesheets=[stylesheet])
print(Path("report.pdf").resolve())
Return PDF bytes instead of writing a file
Omit the output argument to receive PDF bytes. This is useful in a web response, object-storage upload, or background job:
from weasyprint import CSS, HTML
pdf_bytes = HTML(string=html_text).write_pdf(
stylesheets=[CSS(string=css_text)]
)
with open("report.pdf", "wb") as output:
output.write(pdf_bytes)
The documented write_pdf() behavior returns bytes when no output destination is supplied. Keep the bytes binary; do not decode them as UTF-8.
3. Make relative images, fonts, and files resolve correctly
When HTML or CSS contains a relative URL such as images/logo.png or fonts/Inter.woff2, give WeasyPrint a base location:
from pathlib import Path
from weasyprint import CSS, HTML
base_dir = Path(__file__).parent.resolve()
html = HTML(
string="""
<h1>Invoice</h1>
<img src="images/logo.png" alt="Company logo">
""",
base_url=str(base_dir),
)
stylesheet = CSS(
string="""
@font-face {
font-family: InvoiceSans;
src: url("fonts/invoice-sans.woff2");
}
body { font-family: InvoiceSans, sans-serif; }
""",
base_url=str(base_dir),
)
html.write_pdf("invoice.pdf", stylesheets=[stylesheet])
The default resource fetcher can open local files and HTTP URLs, but its basic HTTP client does not provide advanced cookies or authentication. For protected resources, use a suitable custom fetcher or make the resource available through a controlled local or authenticated route. The first-steps documentation explains URL resolution and resource fetching.
4. Use custom fonts with FontConfiguration
If your stylesheet contains @font-face, create one FontConfiguration and pass it both to CSS and to write_pdf():
from pathlib import Path
from weasyprint import CSS, HTML
from weasyprint.text.fonts import FontConfiguration
base_dir = Path(__file__).parent.resolve()
font_config = FontConfiguration()
html = HTML(
string="<h1 class='title'>Branded report</h1>",
base_url=str(base_dir),
)
css = CSS(
string="""
@font-face {
font-family: ReportFont;
src: url("fonts/report-font.woff2");
}
.title { font-family: ReportFont; }
""",
base_url=str(base_dir),
font_config=font_config,
)
html.write_pdf(
"branded-report.pdf",
stylesheets=[css],
font_config=font_config,
)
Use the same configuration for the document and stylesheet so font discovery and embedding are handled consistently.
5. Combine several CSS strings
You can construct multiple stylesheet objects and pass them in order. Later rules can override earlier rules according to normal CSS cascading:
from weasyprint import CSS, HTML
base_css = CSS(string="body { color: #222; font-size: 11pt; }")
brand_css = CSS(string="h1 { color: #174a7e; }")
print_css = CSS(string="@page { size: Letter; margin: 0.75in; }")
HTML(string="<h1>Report</h1>").write_pdf(
"report.pdf",
stylesheets=[base_css, brand_css, print_css],
)
This is useful when a stable base stylesheet is combined with tenant, theme, or request-specific rules. Validate or constrain CSS supplied by untrusted users before rendering.
6. Generate CSS dynamically and safely
Build declarations with normal Python values, then pass the final text to CSS(string=...):
from html import escape
from weasyprint import CSS, HTML
accent = "#8b1e3f" # validate against an allow-list in real applications
report_name = escape("Q4 & forecast")
html_text = f"<h1>{report_name}</h1>"
css_text = f"""
@page {{ size: A4; margin: 18mm; }}
h1 {{ color: {accent}; }}
"""
HTML(string=html_text).write_pdf(
"dynamic.pdf",
stylesheets=[CSS(string=css_text)],
)
Escape untrusted values inserted into HTML. For CSS values, use validation and allow-lists rather than concatenating arbitrary input.
7. Control pages with print CSS
PDF layout follows print-oriented CSS. Common controls include:
css_text = """
@page {
size: A4 portrait;
margin: 18mm;
}
@page landscape-page {
size: A4 landscape;
}
.page-break {
break-before: page;
}
.avoid-split {
break-inside: avoid;
}
header { position: running(report-header); }
"""
Check the WeasyPrint API and feature reference for supported properties and known exceptions. Browser CSS support should not be assumed wholesale: a valid browser stylesheet can still render differently or be unsupported in WeasyPrint.
8. A complete function for applications
from pathlib import Path
from typing import Optional
from weasyprint import CSS, HTML
def render_pdf(
html_text: str,
css_text: str,
*,
base_url: Optional[str] = None,
output_path: Optional[str] = None,
) -> bytes:
"""Render HTML and an in-memory stylesheet to PDF bytes."""
html = HTML(string=html_text, base_url=base_url)
css = CSS(string=css_text, base_url=base_url)
pdf_bytes = html.write_pdf(stylesheets=[css])
if output_path is not None:
Path(output_path).write_bytes(pdf_bytes)
return pdf_bytes
html_text = "<h1>Generated report</h1>"
css_text = "@page { size: A4; margin: 2cm; } h1 { color: #174a7e; }"
pdf = render_pdf(html_text, css_text, output_path="report.pdf")
print(f"Generated {len(pdf)} bytes")
9. When CSS appears to be ignored
| Symptom | Likely cause | Fix |
|---|---|---|
FileNotFoundError or a stylesheet path error |
CSS(css_text) was used without the named argument. |
Use CSS(string=css_text). |
| HTML renders but has no styling | The stylesheet object was never passed to the document. | Call HTML(...).write_pdf(stylesheets=[stylesheet]). |
| Images or fonts are missing | Relative URLs have no base location, or a protected URL cannot be fetched. | Set base_url; check paths and use a custom fetcher for authenticated resources. |
| Custom font falls back to a default | FontConfiguration was omitted or not shared. |
Pass one FontConfiguration to both CSS and write_pdf(). |
| Browser layout differs | WeasyPrint supports a documented subset of CSS and has renderer-specific behavior. | Check the feature reference and simplify or adapt unsupported properties. |
| PDF bytes are corrupted | Binary output was decoded or treated as text. | Write the returned bytes directly with wb or return them as application/pdf. |
| Import fails after pip installation | A required native dependency is unavailable. | Install the platform packages listed in WeasyPrint’s installation documentation. |
10. Resource loading, security, and edge cases
- Relative URLs: Set
base_urlto a directory or URL that makes every relative asset resolvable. - Authenticated assets: The default HTTP client does not handle advanced cookies or authentication. Use a custom fetcher or prefetch the assets.
- Remote dependencies: Network failures can produce missing images, fonts, or styles. Prefer deterministic local assets for repeatable jobs.
- Untrusted HTML: Sanitize user content and restrict resource access before rendering. HTML-to-PDF rendering can read resources permitted by the fetcher.
- Large documents: Build only the data needed for the report, avoid unnecessarily large images, and monitor memory when retaining PDF bytes.
- Unsupported CSS: Test the exact properties your design needs against the current feature reference instead of assuming browser parity.
- Page numbering and headers: Use paged-media features supported by your WeasyPrint version and verify output with representative multi-page documents.
11. Performance, reliability, and cost considerations
Rendering time depends on document size, fonts, images, network resources, and CSS complexity. The supplied documentation does not establish a universal performance benchmark, so measure your own templates. For reliable jobs:
- Pin and update WeasyPrint deliberately.
- Keep assets local or make network fetching explicit.
- Set an application-level timeout around the rendering job.
- Log input identifiers, render duration, output size, and resource failures.
- Retry only transient resource failures; do not blindly retry invalid HTML or unsupported CSS.
- Use a queue for large reports so web requests do not wait indefinitely.
The software itself is a local Python rendering workflow, so your main costs are compute, storage, and any resources you fetch. If you need a hosted screenshot or PDF capture service instead of maintaining browser and rendering infrastructure, ScreenshotNeo provides a single API endpoint.
12. Alternatives and when they fit
xhtml2pdf converts HTML to PDF with ReportLab, html5lib, and pypdf. Its documentation describes HTML5, CSS 2.1, and some CSS 3 support, and its quickstart accepts an HTML string through pisa.CreatePDF(). Confirm the exact CSS properties and input forms your template needs; the research does not establish an equivalent standalone CSS(string=...) API.
fpdf2 is not a fit when the requirement is for its HTML feature to apply CSS: its manual states that it does not support the whole HTML5 specification or CSS, and points readers toward WeasyPrint and xhtml2pdf for more robust HTML-to-PDF conversion.
13. Or skip the browser setup
For a hosted page capture or PDF workflow, ScreenshotNeo accepts one GET request and returns a clean screenshot or PDF. See the ScreenshotNeo API documentation for all options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`ScreenshotNeo returned ${res.status}`);
const buffer = Buffer.from(await res.arrayBuffer());
require('fs').writeFileSync('shot.webp', buffer);
Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and billing state. An MCP server lets AI agents use take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
14. FAQ
Can I pass a CSS string directly to write_pdf()?
Construct a stylesheet first with CSS(string=css_text), then pass that object through stylesheets=[stylesheet].
Do I need to save the CSS to a temporary file?
No. The string= constructor is designed for in-memory stylesheet text.
How do I render HTML and CSS without creating any files?
Use HTML(string=...), CSS(string=...), and call write_pdf() without an output path to receive PDF bytes.
Why do relative assets fail when the HTML is a string?
A string has no directory context. Supply base_url on the HTML and CSS objects so relative URLs can be resolved.
Should I use WeasyPrint or xhtml2pdf?
Choose based on the CSS properties, resource handling, font behavior, and input/output APIs your document requires. Verify those requirements in each project’s documentation.


