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

Use WeasyPrint’s in-memory constructors: create your document with HTML(string=html_text), create the stylesheet with CSS(string=css_text), and pass that stylesheet to write_pdf(stylesheets=[...]). The string= keyword is essential; without it, a CSS string can be interpreted as a filename or URL.

Minimal WeasyPrint example

This complete example converts HTML and CSS strings to PDF bytes and writes the result to a file:

from weasyprint import HTML, CSS

html_text = """
<html>
  <body>
    <h1>Invoice</h1>
    <p>Generated entirely from Python strings.</p>
  </body>
</html>
"""

css_text = """
@page { size: A4; margin: 1cm; }
body { font-family: sans-serif; color: #222; }
h1 { color: #123b70; }
"""

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

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

write_pdf() returns the PDF as bytes when no destination is supplied. You can instead pass a filename or writable binary file object directly if you do not need the bytes in memory.

How the in-memory API works

Pass HTML with HTML(string=...)

Use the string argument when the markup is already in a Python variable. This avoids creating a temporary HTML file. If your markup references relative images, stylesheets, or fonts, also provide a meaningful base_url or a custom URL fetcher.

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

Pass CSS with CSS(string=...)

The same distinction applies to CSS. CSS(string=css_text) tells WeasyPrint that the value is stylesheet content. A bare positional string may be treated as a path or URL, producing errors such as “file not found” or silently loading the wrong resource.

Attach the stylesheet in write_pdf()

Constructing a CSS object does not automatically apply it. Supply it through the stylesheets list:

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

You may pass more than one stylesheet. WeasyPrint applies them using normal CSS cascade rules, so later rules can override earlier rules when specificity and importance permit.

Relative images, stylesheets, and fonts

In-memory HTML has no filename from which a relative URL can be resolved. For example, <img src="images/logo.png"> needs a base directory or another resource-resolution strategy.

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

Set a base URL

from pathlib import Path
from weasyprint import HTML, CSS

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

With that base URL, relative references in the HTML and CSS are resolved beneath the template directory. Use an absolute path appropriate to the machine running the code; a working-directory-relative value can break when the application is launched by a service or job runner.

Use a custom URL fetcher when resources are not local

If assets come from a database, an authenticated service, or a virtual filesystem, implement a URL fetcher and pass it to the HTML and/or CSS objects as supported by your WeasyPrint version. The fetcher should return the resource data and its MIME type, and should enforce your application’s allowed hosts and schemes. Do not let untrusted document input fetch arbitrary internal URLs.

Configure custom fonts consistently

For @font-face rules, create one FontConfiguration and pass it both when constructing the CSS and when rendering the PDF:

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

font_config = FontConfiguration()
css = CSS(
    string="""
    @font-face {
      font-family: 'Report Sans';
      src: url('fonts/report-sans.woff2');
    }
    body { font-family: 'Report Sans', sans-serif; }
    """,
    font_config=font_config,
)
html = HTML(string=html_text, base_url="/absolute/path/to/template")
pdf_bytes = html.write_pdf(
    stylesheets=[css],
    font_config=font_config,
)

The same configuration is required at both stages so that font discovery and PDF embedding use the same settings. Verify that the font files are readable by the process and that the declared format matches the file.

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.

A production-friendly rendering function

Wrapping the workflow makes it easier to validate inputs, select an output destination, and reuse the renderer:

from pathlib import Path
from typing import Optional
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration

def html_css_to_pdf(
    html_text: str,
    css_text: str,
    *,
    base_url: Optional[str] = None,
    output_path: Optional[str] = None,
) -> bytes:
    font_config = FontConfiguration()
    html = HTML(string=html_text, base_url=base_url)
    css = CSS(string=css_text, base_url=base_url, font_config=font_config)
    pdf_bytes = html.write_pdf(
        stylesheets=[css],
        font_config=font_config,
    )
    if output_path is not None:
        Path(output_path).write_bytes(pdf_bytes)
    return pdf_bytes

pdf = html_css_to_pdf(
    "<h1>Monthly report</h1>",
    "@page { size: Letter; margin: 0.75in } h1 { color: #174a7e }",
    output_path="monthly-report.pdf",
)

Keep HTML and CSS generated from trusted templates or sanitize user-provided markup. Rendering can read local resources when a base URL is supplied, so resource access is a security boundary.

Controlling pages and print layout with CSS

PDF pagination is controlled primarily by print CSS. Typical rules include:

@page {
  size: A4 landscape;
  margin: 18mm 14mm;
}

@page:first { margin-top: 28mm; }

h1, h2 { break-after: avoid; }
.table-row { break-inside: avoid; }
.page-break { break-before: page; }
  • @page sets paper size, orientation, and margins.
  • break-before, break-after, and break-inside express pagination intent.
  • Long tables should have carefully chosen row styles; forcing every row not to break can create large blank areas.

Always inspect the generated PDF with representative long and short content. A layout that works for one page can fail when headings, images, or table rows cross a page boundary.

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

Returning bytes, writing files, and web responses

Write directly to a file

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

Send from a web endpoint

Because the result is bytes, a web framework can return it as an application/pdf response. Set a download disposition only when that matches your endpoint’s behavior, and avoid building unbounded documents from request data.

Using xhtml2pdf instead

xhtml2pdf’s API centers on pisa.CreatePDF. It accepts an HTML source string and a writable destination, such as BytesIO; CSS can be supplied through default_css. Use path for a base path and link_callback when you need custom resource resolution.

from io import BytesIO
from xhtml2pdf import pisa

html_source = "<h1>Report</h1>"
css_text = "@page { size: A4; margin: 1cm } h1 { color: navy }"
base_path = "/absolute/path/to/template"

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

When assets need special handling, a link_callback can translate a URL in the document into a local file or another permitted resource. Check the callback’s behavior and error reporting in the version you deploy.

WeasyPrint, xhtml2pdf, or fpdf2?

Criterion WeasyPrint xhtml2pdf fpdf2
CSS supplied from a string CSS(string=...), passed via stylesheets default_css=... or linked stylesheets Not a full HTML/CSS renderer
Resource handling base_url, URL fetchers, and FontConfiguration path, link_callback, and resource-policy controls Application-specific drawing and limited HTML helpers
Documented CSS behavior Designed for HTML/CSS-to-PDF workflows Documents supported properties; media types all, print, and pdf are honored, while media-query conditions are ignored Full HTML5 and CSS are explicitly unsupported
In-memory output Returns PDF bytes when no destination is supplied Writes to BytesIO or another file-like object Typically build the PDF through its own API

Choose WeasyPrint when a stylesheet-driven layout and direct CSS-string support are central. Choose xhtml2pdf when its supported property set matches your templates and you need its callback/path model. Do not choose fpdf2 expecting broad HTML5 and CSS fidelity.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

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

Troubleshooting

“The CSS file does not exist” or a path-looking error

Cause: the CSS text was passed as a positional value or without string=. Fix it with CSS(string=css_text).

Styles are ignored

Cause: the stylesheet was created but not attached. Pass it in stylesheets=[stylesheet] to write_pdf(). Also check CSS syntax and selector specificity.

Images or fonts disappear

Cause: relative URLs have no base. Supply base_url, use a URL fetcher, or configure xhtml2pdf’s path/link_callback. For custom fonts, share one FontConfiguration between CSS and write_pdf().

The PDF has unexpected page breaks

Cause: content dimensions changed during pagination, or a non-breaking element is larger than the page’s printable area. Reduce oversized padding or images, remove unnecessary break-inside: avoid, and test with the longest realistic content.

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

Rendering fails only in deployment

Cause: missing system libraries, fonts, permissions, or a different working directory. Use absolute base paths, install the runtime dependencies in the deployment image, and log the original exception together with the document identifier.

Untrusted HTML can access local files

Cause: a permissive base URL or fetcher. Restrict schemes and hosts, isolate rendering, sanitize markup, and avoid exposing sensitive directories to the renderer.

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

Or skip the browser setup

If the actual requirement is a screenshot or PDF of a live webpage rather than rendering HTML you already own, ScreenshotNeo provides a single HTTP call. It accepts the cookie or consent banner before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients.

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 documentation for parameters and PDF options. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

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

Further runnable clients for ScreenshotNeo

Python

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)

Node.js

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(`HTTP ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

Frequently Asked Questions

Can I keep both HTML and CSS entirely in memory?

Yes. Use HTML(string=...) and CSS(string=...); add base_url or a fetcher only when the document references external or relative resources.

Does write_pdf() always create a file?

No. With no destination argument it returns PDF bytes. You can write those bytes yourself or pass a filename or writable binary object.

Why is xhtml2pdf not applying my media query?

Its documented behavior honors media types such as print but ignores media-query conditions, so move required rules into supported print CSS or use a renderer with the needed feature set.

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.

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