ScreenshotNeo

BlogHow-to

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.

By the ScreenshotNeo team1 October 20269 min read

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_url to 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:

  1. Pin and update WeasyPrint deliberately.
  2. Keep assets local or make network fetching explicit.
  3. Set an application-level timeout around the rendering job.
  4. Log input identifiers, render duration, output size, and resource failures.
  5. Retry only transient resource failures; do not blindly retry invalid HTML or unsupported CSS.
  6. 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.