ScreenshotNeo

BlogHow-to

Load CSS from a String for HTML-to-PDF in Python

Convert in-memory HTML and CSS to PDF with WeasyPrint, resolve relative assets, and see when xhtml2pdf is a better fit.

By the ScreenshotNeo team30 September 202610 min read

Load CSS from a String for HTML-to-PDF in Python

To load CSS from a Python string with WeasyPrint, wrap it in CSS(string=css_text) and pass that object to HTML(string=html_text).write_pdf(stylesheets=[...]). This keeps both inputs in memory and returns PDF bytes. The string= keyword is essential: it tells WeasyPrint that the value is stylesheet content rather than a path or URL.

This guide shows the complete workflow, saving and returning the PDF, resolving images and fonts, applying multiple stylesheets, handling errors, and choosing between WeasyPrint and xhtml2pdf. It also explains when a browser screenshot is the right output instead of a PDF.

1. Install WeasyPrint and convert HTML and CSS strings

Install WeasyPrint in the Python environment used by your application:

In-memory HTML and CSS can flow directly into a PDF renderer without temporary source files.
In-memory HTML and CSS can flow directly into a PDF renderer without temporary source files.
python -m pip install weasyprint

Then create the PDF from two strings:

from weasyprint import CSS, HTML

html_text = """\
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Monthly report</title>
</head>
<body>
  <h1>Monthly report</h1>
  <p>Revenue increased this month.</p>
</body>
</html>
"""

css_text = """\
@page {
  size: A4;
  margin: 18mm;
}

body {
  font-family: sans-serif;
  color: #202938;
}

h1 {
  color: navy;
  border-bottom: 1px solid #ccd3dd;
  padding-bottom: 0.3em;
}
"""

pdf_bytes = HTML(string=html_text).write_pdf(
    stylesheets=[CSS(string=css_text)]
)

with open("report.pdf", "wb") as pdf_file:
    pdf_file.write(pdf_bytes)

write_pdf() returns bytes when you do not provide a destination. Opening the output file in binary mode (wb) preserves the PDF data exactly. The same bytes can be returned from a web endpoint or stored in object storage.

Why use the string keyword?

WeasyPrint provides in-memory constructors for HTML and CSS. Use HTML(string=...) for markup and CSS(string=...) for stylesheet text. If a CSS string is passed as a positional argument, WeasyPrint may interpret it as a filename or URL. That can produce a confusing missing-file error even though the CSS itself is valid. The official [WeasyPrint API documentation](https://doc.courtbouillon.org/weasyprint/stable/api_reference.html) describes these constructors and the stylesheet argument to write_pdf().

Write directly to a destination

For small or moderate PDFs, returning bytes is convenient. If you want WeasyPrint to write to a file path directly, pass the path to write_pdf():

from weasyprint import CSS, HTML

HTML(string=html_text).write_pdf(
    "report.pdf",
    stylesheets=[CSS(string=css_text)],
)

A writable binary file object is also a suitable destination. Choose the bytes-returning form when another part of your code needs the result in memory; choose a destination when direct output is simpler for your workflow.

2. Handle relative images, stylesheets, and fonts

HTML created from a string has no obvious document location. A relative reference such as images/logo.png needs a base directory to resolve against. Set base_url when creating the HTML, or provide a custom URL fetcher for application-specific resource lookup.

A base URL gives relative images and fonts a location to resolve from.
A base URL gives relative images and fonts a location to resolve from.
from pathlib import Path
from weasyprint import CSS, HTML

html_text = """\
<html>
  <body>
    <img src="images/logo.png" alt="Company logo">
    <h1>Report</h1>
  </body>
</html>
"""
css_text = "body { font-family: sans-serif } img { width: 140px }"

base_dir = Path("templates").resolve()
pdf_bytes = HTML(
    string=html_text,
    base_url=base_dir.as_uri(),
).write_pdf(stylesheets=[CSS(string=css_text)])

Path("report.pdf").write_bytes(pdf_bytes)

Here, templates/images/logo.png is resolved relative to the absolute template directory. For a remote base, use a complete URL appropriate to your document. A custom URL fetcher is useful when resources come from a controlled store, need authentication, or must follow application-specific rules. WeasyPrint’s documentation covers base_url, URL fetchers, and resource fetching in its [API reference](https://doc.courtbouillon.org/weasyprint/stable/api_reference.html).

Custom fonts with FontConfiguration

If the stylesheet uses @font-face, create one FontConfiguration and pass the same instance to the CSS object and PDF rendering call:

from weasyprint import CSS, HTML
from weasyprint.text.fonts import FontConfiguration

font_config = FontConfiguration()
html_text = """\
<html><body><p class="brand">A branded report</p></body></html>
"""
css_text = """\
@font-face {
  font-family: Brand;
  src: url("fonts/brand-regular.woff2");
}
.brand { font-family: Brand, sans-serif }
"""

stylesheet = CSS(
    string=css_text,
    base_url="/absolute/template/directory",
    font_config=font_config,
)
pdf_bytes = HTML(
    string=html_text,
    base_url="/absolute/template/directory",
).write_pdf(
    stylesheets=[stylesheet],
    font_config=font_config,
)

Use a real absolute directory or URI for your deployment environment. The font file must be readable by the process and referenced from a resolvable location. WeasyPrint documents sharing the font configuration when custom fonts are involved in the [fonts and text section](https://doc.courtbouillon.org/weasyprint/stable/api_reference.html).

Resource handling checklist

  • Use base_url for relative image, font, and linked-resource references.
  • Confirm the referenced file exists from the application process’s perspective.
  • Use a custom URL fetcher when resources need custom resolution or access rules.
  • Use one FontConfiguration for CSS construction and write_pdf() when defining custom fonts.
  • Check the generated PDF when fonts or images seem to fall back silently.

3. Combine stylesheets and control printed pages

The stylesheets argument accepts a list, so you can keep a base style and a request-specific override separately. Later stylesheets participate in the cascade according to normal CSS rules, including specificity and source order.

from weasyprint import CSS, HTML

base_css = CSS(string="body { color: #222; font-family: sans-serif }")
report_css = CSS(string="h1 { color: navy } @page { size: A4; margin: 15mm }")

pdf_bytes = HTML(string=html_text).write_pdf(
    stylesheets=[base_css, report_css]
)

For print pagination, put page rules in the stylesheet. Common controls include paper size, margins, page breaks, and avoiding breaks inside short blocks. For example:

css_text = """\
@page { size: A4; margin: 16mm }
@page :first { margin-top: 24mm }
.chapter { break-before: page }
.card { break-inside: avoid }
"""

HTML-to-PDF rendering is a print layout task. Test long tables, oversized images, headings at the bottom of a page, and content whose length varies. CSS that looks acceptable in a browser viewport may paginate differently on paper.

4. Use xhtml2pdf when its constraints fit

xhtml2pdf offers a different API. Its pisa.CreatePDF() function accepts HTML source, a file-like destination, and a default_css string. Supply a path for resolving resources when needed:

from io import BytesIO
from xhtml2pdf import pisa

html_source = """\
<html><body><h1>Hello</h1><p>PDF from Python</p></body></html>
"""
css_text = "@page { size: a4 portrait; margin: 1cm } h1 { color: navy }"

result = BytesIO()
status = pisa.CreatePDF(
    html_source,
    dest=result,
    default_css=css_text,
    path="/absolute/project/templates",
)

if status.err:
    raise RuntimeError("xhtml2pdf reported an error while creating the PDF")

pdf_bytes = result.getvalue()
with open("report.pdf", "wb") as pdf_file:
    pdf_file.write(pdf_bytes)

The [xhtml2pdf quickstart](https://xhtml2pdf.readthedocs.io/en/latest/quickstart.html) shows conversion to a BytesIO destination. Its [API reference](https://xhtml2pdf.readthedocs.io/en/latest/reference.html) documents default_css, path, link_callback, and resource-policy arguments. Use a link_callback when a simple base path is not enough to map document references to local resources.

Choose based on the CSS and document features your output depends on. xhtml2pdf documents supported properties and says that all, print, and pdf media types are honored while media-query conditions are ignored. Consult its [supported CSS reference](https://xhtml2pdf.readthedocs.io/en/latest/reference.html) before committing to a layout with media queries or newer CSS. fpdf2’s own [HTML manual](https://py-pdf.github.io/fpdf2/HTML.html) states that full HTML5 and CSS are unsupported, so it is a poor fit when a stylesheet-driven layout is central.

5. Return a PDF from a Python web endpoint

When an endpoint generates a PDF, return the bytes with a PDF content type and a download filename. The framework-specific response class varies; this minimal Flask example shows the shape:

from flask import Flask, Response
from weasyprint import CSS, HTML

app = Flask(__name__)

@app.get("/report.pdf")
def report_pdf():
    html_text = "<html><body><h1>Report</h1></body></html>"
    css_text = "@page { size: A4; margin: 16mm } h1 { color: navy }"
    pdf_bytes = HTML(string=html_text).write_pdf(
        stylesheets=[CSS(string=css_text)]
    )
    return Response(
        pdf_bytes,
        mimetype="application/pdf",
        headers={"Content-Disposition": 'attachment; filename="report.pdf"'},
    )

if __name__ == "__main__":
    app.run()

For a user-specific report, validate and authorize the data before rendering. Avoid accepting arbitrary filesystem paths or unrestricted resource URLs from a request; a custom fetcher or controlled asset lookup can keep resource access within the application’s intended boundaries.

6. Or skip the browser setup

If what you need is a screenshot of a live web page rather than a document you assemble in Python, ScreenshotNeo can capture the page with one request. It is a website screenshot API and MCP server from ScreenshotNeo. It does not replace a PDF library for arbitrary HTML and CSS strings: use WeasyPrint for that job. For a rendered website screenshot or PDF, use the screenshot API.

The following cURL example saves a WebP capture:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

See the ScreenshotNeo API documentation for request options. Python and Node.js equivalents:

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(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);

ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before capture; each cleanup step can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, with response headers indicating the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.

The free plan includes 1,000 screenshots a month with no card. Paid plans start at $5 for 3,000 shots; all features are available on every plan. If a live-page capture fits your task, sign up for 1,000 free screenshots a month with no card.

7. Troubleshooting common problems

Symptom Likely cause Fix
CSS is reported as a missing file The CSS content was passed without string=, so it was treated as a location. Construct it as CSS(string=css_text).
Relative image or font is missing The HTML was built from a string without a base location. Pass base_url to HTML, verify the path, or implement a URL fetcher.
Custom font is not used The font URL cannot resolve, the resource is inaccessible, or the font configuration was not shared. Check @font-face URLs and use the same FontConfiguration for CSS and PDF writing.
PDF has default styling The stylesheet object was not included in the stylesheets list, or another rule overrides it. Pass stylesheets=[CSS(string=css_text)] and inspect selectors, specificity, and ordering.
Output is empty or incomplete The source may be empty, an asset may fail to load, or the document may contain unexpected page-break behavior. Log the final HTML and CSS strings, verify fetched resources, and inspect page rules and generated page count.
Layout differs from browser rendering PDF pagination and print layout differ from a screen viewport; the chosen library may also support a narrower CSS subset. Test the final PDF. If using xhtml2pdf, confirm each needed property is supported and avoid relying on media queries.
System dependency or font errors during installation/rendering The deployment image may lack libraries or fonts required by the installed WeasyPrint setup. Follow the installation guidance for the operating system and environment, then test rendering in the same container used in production.

8. Performance, reliability, and cost

Rendering cost grows with document complexity and the resources that must be fetched. Large images, custom fonts, many pages, and remote assets add work. Keep assets local or on a dependable origin when possible, avoid repeated resource fetches, and set sensible time limits around application-level jobs. If generating many PDFs, measure the workload in the deployment environment and manage concurrency so render jobs do not exhaust memory or workers.

For repeatable output, control the HTML, CSS, fonts, and image versions used for each render. A URL that points to a changing asset can make successive PDFs differ. Test page breaks and missing-resource behavior with representative short and long documents. Catch rendering exceptions and return an application error rather than serving a partial or mislabeled PDF.

WeasyPrint and xhtml2pdf are Python libraries, so there is no per-document ScreenshotNeo charge when you render with them; infrastructure and engineering time are your costs. ScreenshotNeo pricing is relevant only when using its hosted screenshot service: Free includes 1,000 shots/month, then Starter is $5 for 3,000, Growth $15 for 15,000, Pro $39 for 60,000, Scale $99 for 250,000, and Business $249 for 1,000,000. Yearly billing gives two months free. Every listed feature is available on every plan.

9. Frequently asked questions

Can I make a PDF without writing HTML to disk?

Yes. Pass the markup to HTML(string=html_text) and stylesheet text to CSS(string=css_text). Provide a base URL only when the document references relative resources.

Can I use several in-memory CSS strings?

Yes. Convert each string to a CSS object and pass the objects in a list to stylesheets. Keep cascade order and selector specificity in mind.

Can ScreenshotNeo convert my arbitrary HTML and CSS strings?

ScreenshotNeo captures rendered websites and can capture a URL as a screenshot or PDF. For standalone HTML and CSS strings that are not hosted as a page, use a Python PDF renderer such as WeasyPrint.

Which library should I start with?

Start with WeasyPrint when you need to pass CSS directly as a string and want returned PDF bytes. Consider xhtml2pdf when its documented CSS support and resource-resolution controls meet your layout needs.