Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

With WeasyPrint, pass an in-memory stylesheet as CSS(string=css_text), then include that object in HTML.write_pdf(stylesheets=[...]). Use HTML(string=html_text) for the HTML, too. This keeps the HTML and CSS as Python strings instead of asking the library to interpret a string as a file path or URL.

Load CSS from a string with WeasyPrint

WeasyPrint’s in-memory constructors are the direct route for converting HTML and CSS strings to PDF. The important detail is to wrap the CSS text in CSS(string=...); passing an unwrapped string where a stylesheet object is expected can lead to it being treated as a filename or URL.

from weasyprint import HTML, CSS

html_text = """<html>
  <body>
    <h1>Hello</h1>
    <p>This document was built from Python strings.</p>
  </body>
</html>"""

css_text = """
@page { size: A4; margin: 1cm; }
h1 { color: navy; }
p { font-size: 12pt; }
"""

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

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

write_pdf() returns PDF bytes when you do not supply a destination. The example writes those bytes to output.pdf. If you prefer, pass a filename or writable file object as the destination to write the PDF directly. Keep string= explicit on both constructors: HTML(string=...) means the argument is HTML source, and CSS(string=...) means it is CSS source.

Keep the CSS separate or build it dynamically

A CSS string can be a literal, loaded from another part of your application, or assembled from values before creating the CSS object. The conversion step remains the same: build the stylesheet with CSS(string=css_text) and pass it through the stylesheets list. Keeping this boundary explicit helps distinguish CSS source from a stylesheet location.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
from weasyprint import HTML, CSS

html_text = "<html><body><h1>Invoice</h1></body></html>"
accent = "#174a7e"
css_text = f"@page {{ size: A4; margin: 15mm; }} h1 {{ color: {accent}; }}"

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

Only insert values you control or have safely handled for CSS syntax. A string is still parsed as CSS; putting arbitrary user input into a CSS rule can change the stylesheet rather than merely supply a harmless value.

Make relative images, fonts, and other resources resolve

HTML created in memory has no file location of its own. That matters when the markup or stylesheet refers to resources using relative paths, such as images/logo.png or a font file. Give the HTML a meaningful base_url so those references can be resolved, or provide a custom URL fetcher when your application needs to control how resources are loaded.

from weasyprint import HTML, CSS

html_text = """
<html>
  <body>
    <img src="images/logo.png" alt="Logo">
    <h1>Report</h1>
  </body>
</html>
"""
css_text = "h1 { color: navy; }"

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

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

The base URL should correspond to the directory against which those relative resource paths make sense. If resources are not local files, a custom URL fetcher is the alternative control point. The key is to address resource lookup separately from supplying the stylesheet string: CSS(string=...) provides CSS text, while base_url or a fetcher helps WeasyPrint locate referenced assets.

Use FontConfiguration consistently for custom fonts

When the stylesheet contains custom @font-face rules, create one FontConfiguration and pass it to both the CSS object and write_pdf(). This is the documented pattern for font configuration; use the same object at both stages.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration

font_config = FontConfiguration()
html_text = "<html><body><p>Custom-font document</p></body></html>"
css_text = """
@font-face {
  font-family: ReportFont;
  src: url("fonts/report-font.ttf");
}
body { font-family: ReportFont; }
"""

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

Here, base_url gives the relative font URL a base to resolve against, and font_config is shared across stylesheet creation and PDF generation. If you omit the base when using relative resources, the HTML string alone does not tell the renderer where to find them.

Save the PDF bytes or write to a destination

Choose the output form that matches the rest of your Python code. Without a destination, WeasyPrint returns bytes, which you can write to disk, return from a web handler, or pass to another component. When saving directly is more convenient, provide a filename or writable file object to write_pdf().

# Return bytes and save them yourself
pdf_bytes = HTML(string=html_text).write_pdf(
    stylesheets=[CSS(string=css_text)]
)

# Or let write_pdf write to a destination
HTML(string=html_text).write_pdf(
    "output.pdf",
    stylesheets=[CSS(string=css_text)],
)

Use binary output for returned PDF bytes. A PDF is not ordinary text, so do not open the output file in text mode or try to decode the returned bytes as a string.

Convert HTML and CSS strings with xhtml2pdf

If your project uses xhtml2pdf, its corresponding workflow is pisa.CreatePDF. Supply the CSS string with default_css and a BytesIO object as the destination, then read the generated PDF bytes from that buffer.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
from io import BytesIO
from xhtml2pdf import pisa

html_source = "<html><body><h1>Hello</h1></body></html>"
css_text = "@page { size: A4; margin: 1cm; } h1 { color: navy; }"

result = BytesIO()
pisa.CreatePDF(
    html_source,
    dest=result,
    default_css=css_text,
)
pdf_bytes = result.getvalue()

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

For relative resources, xhtml2pdf provides a path argument and a link_callback control for resolving links. It also exposes resource-policy arguments. Choose explicit resource resolution when the HTML depends on images, fonts, or other linked files rather than expecting a standalone HTML string to provide a base location.

Choose a library based on CSS and resource needs

Need WeasyPrint xhtml2pdf
Supply CSS held in Python Wrap it in CSS(string=css_text) and pass it through stylesheets. Pass it as default_css to pisa.CreatePDF.
Resolve resources from generated HTML Use base_url or a custom URL fetcher; use a shared FontConfiguration for custom fonts. Use path, link_callback, and the available resource-policy controls.
Need broad CSS behavior The supplied documentation establishes the in-memory API and resource pattern, but does not provide a directly comparable support matrix here. The documentation lists supported properties and says all, print, and pdf media types are honored while media-query conditions are ignored.
Need the output as bytes write_pdf() returns PDF bytes when no destination is supplied. Write to BytesIO or another file-like destination, then read from it.

For a stylesheet-driven layout, inspect the library’s documented CSS support against the rules your document actually relies on. In particular, xhtml2pdf’s documented handling of media queries is a potential mismatch if your design depends on conditional CSS. The available facts do not establish that one library renders every CSS feature better in every case, so base the choice on the specific properties and resources in your document.

fpdf2 is a poor fit when broad HTML5 and CSS support is central: its manual explicitly says full HTML5 and CSS are unsupported. That does not rule it out for a use case built around its own PDF-oriented capabilities, but it is not the natural choice for converting a CSS-driven HTML string.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Troubleshoot common failures

  • The CSS string is treated like a path or URL. Wrap CSS source with CSS(string=css_text). Pass the resulting object inside stylesheets=[...]; do not use a bare string where the API expects the constructed stylesheet.
  • A relative image or font does not appear. An HTML string has no inherent file directory. Set a useful base_url when constructing HTML, or use a custom URL fetcher. For xhtml2pdf, use its path or link_callback controls.
  • A custom font is not available during PDF generation. Create one FontConfiguration, pass it to CSS(...), and pass that same object to write_pdf(). Ensure the font URL can resolve using the base URL or fetcher.
  • The result is not a PDF file on disk. If you used the no-destination form, the result is bytes, not a saved file. Write those bytes with a binary file handle, or supply a filename or writable file object as the destination.
  • Some CSS rules work differently in xhtml2pdf. Check the documented supported-property list. Its documentation says media-query conditions are ignored, even though the all, print, and pdf media types are honored. Adjust the CSS or choose a renderer whose documented behavior fits those rules.
  • You are considering fpdf2 for a CSS-heavy document. Its manual says full HTML5 and CSS are unsupported. For a workflow centered on rendering HTML with a stylesheet, use a library intended for that conversion instead.

Or skip the browser setup

If your input is a live webpage URL rather than an HTML string you generate in Python, ScreenshotNeo can return a screenshot or PDF from a GET request. It does not convert your in-memory HTML and CSS strings, so keep the WeasyPrint or xhtml2pdf workflow above when those strings are the source. ScreenshotNeo’s API and request options are documented at ScreenshotNeo docs.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
open("shot.webp", "wb").write(r.content)

For a live URL, ScreenshotNeo accepts cookie or consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each of those steps can be turned off. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and the response identifies page verdict and billing status in headers. 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 shots per month without a card; paid plans start at $5 for 3,000 shots. See ScreenshotNeo for the service and the API documentation for request details. Sign up for 1,000 free screenshots a month with no card.

Practical checklist

  • For WeasyPrint, create both inputs with HTML(string=...) and CSS(string=...).
  • Pass the stylesheet object in stylesheets=[...] on write_pdf().
  • Decide whether you want returned bytes or a destination file, and handle PDF output as binary.
  • Set base_url or a URL fetcher for relative resources; share one FontConfiguration across CSS creation and PDF rendering when custom fonts are involved.
  • For xhtml2pdf, use default_css and explicit resource resolution as needed, then check its documented CSS limitations before relying on media queries.
  • Use a URL screenshot/PDF service only when the source is a live web page; it is not a substitute for rendering Python-held HTML and CSS strings.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.