---
name: pdf-generation-patterns
description: "Generating PDF documents from Salesforce using Visualforce renderAs='pdf', PageReference.getContentAsPDF(), and ContentVersion storage. Covers the Flying Saucer rendering engine constraints, inline CSS requirements, server-side data loading, LWC PDF limitations, and programmatic PDF attachment to records. NOT for Quote PDF template customization (use apex/quote-pdf-customization). NOT for OmniStudio DocGen document generation. NOT for Salesforce Reports exported as PDF."
category: apex
salesforce-version: "Spring '25+"
well-architected-pillars:
  - Security
  - Reliability
  - Performance
triggers:
  - "how do I generate a PDF from a Visualforce page in Salesforce and save it to Files"
  - "my Visualforce PDF is blank or broken — JavaScript not running and external stylesheets not loading"
  - "how to programmatically create a PDF and attach it to a record using Apex"
  - "LWC component needs to output a PDF — what is the supported pattern"
  - "how to store a generated PDF as a ContentVersion linked to a Salesforce record"
tags:
  - pdf-generation
  - visualforce
  - renderAs-pdf
  - flying-saucer
  - pageReference
  - contentVersion
  - contentDocumentLink
  - inline-css
  - apex
inputs:
  - "Source record Id (Opportunity, Account, custom object, etc.) whose data populates the PDF"
  - "Delivery mechanism: on-demand browser download, programmatic attachment to ContentVersion, or email attachment"
  - "Design assets: logos, fonts — whether hosted as static resources"
  - "Whether the PDF must be generated by an LWC (requires VF wrapper pattern)"
  - "Async vs. synchronous generation requirement — determines Queueable vs. direct controller approach"
outputs:
  - "Visualforce page with renderAs='pdf', showHeader='false', sidebar='false', and server-side controller"
  - "Apex controller class loading all required data server-side with FLS-safe SOQL (WITH USER_MODE)"
  - "Inline CSS using CSS 2.1 table-based layout (no Flexbox, Grid, or JS-dependent styles)"
  - "Queueable Apex class calling PageReference.getContentAsPDF(), null-checking the blob, and inserting ContentVersion + ContentDocumentLink"
  - "LWC-to-VF wrapper wiring if LWC-initiated PDF generation is required"
dependencies: []
version: 1.0.0
author: Pranav Nagrecha
updated: 2026-04-06
---

# PDF Generation Patterns

This skill activates when a Salesforce implementation needs to generate PDF documents — either rendered on demand from a Visualforce page or programmatically stored as Files (ContentVersion) on Salesforce records. It covers the complete lifecycle: Flying Saucer engine constraints, CSS and JavaScript restrictions, Apex controller design, `PageReference.getContentAsPDF()` usage, ContentVersion storage, LWC limitations, and async generation patterns.

---

## Before Starting

Gather this context before working on anything in this domain:

- **Rendering engine constraints:** Salesforce uses the Flying Saucer HTML-to-PDF engine for `renderAs='pdf'` on `<apex:page>`. This engine executes no JavaScript and ignores external CDN stylesheets. All CSS must be inlined or referenced via a Salesforce-hosted static resource, and all data must be loaded server-side in the Apex controller before the page renders.
- **Data loading:** There is no client-side execution during PDF rendering. Any SOQL, field lookups, or computed values must be resolved in the Apex controller's constructor or lazy-loaded properties before the page is returned to the renderer.
- **LWC limitation:** LWC cannot set `renderAs='pdf'` and cannot produce a PDF natively. The documented pattern is a VF wrapper page that acts as the PDF template, invoked from an LWC via a navigation API or programmatically through a headless Apex Queueable.
- **Callout limit:** `PageReference.getContentAsPDF()` counts as one callout against the 100-callout-per-transaction limit. Bulk PDF generation must be scoped accordingly.
- **Delivery mechanism:** Determine upfront whether the PDF is a browser download (VF page rendered directly), a programmatic attachment to a record (ContentVersion + ContentDocumentLink), or an email attachment (Messaging.EmailFileAttachment).

---

## Core Concepts

### Concept 1: Visualforce renderAs='pdf' and the Flying Saucer Engine

Setting `renderAs="pdf"` on `<apex:page>` directs Salesforce to pipe the rendered HTML through the Flying Saucer library (an iText-based HTML-to-PDF converter) before sending the response. Consequences that cause the majority of production bugs:

- **JavaScript is completely ignored.** There is no JS engine in the rendering pipeline. Scripts embedded in or referenced by the page produce no output and throw no error — they are silently skipped.
- **External CDN stylesheets are not loaded.** `<link href="https://cdn.example.com/style.css">` references are ignored by the renderer because it does not make external authenticated calls on behalf of the browser session. All CSS must be inline `<style>` blocks or Salesforce-hosted static resources.
- **Only CSS 2.1 is supported.** Flexbox (`display: flex`), CSS Grid (`display: grid`), CSS custom properties (`var(--color)`), and CSS animations are silently ignored. Use table-based layout (`display: table`, `display: table-cell`) or floats for column alignment.
- **Page attributes required for clean PDF output:** `showHeader="false" sidebar="false"` on `<apex:page>` suppress the Salesforce chrome that would otherwise appear as page headers in the PDF.
- **Page breaks** are controlled by the CSS properties `page-break-before`, `page-break-after`, and `page-break-inside`.

### Concept 2: Server-Side Data Loading in the Apex Controller

Because the PDF renderer has no JavaScript engine, every piece of data shown in the PDF must be resolved in Apex before the page HTML is produced. Specific requirements:

- SOQL queries run in the constructor or in `@AuraEnabled get` properties — never deferred.
- All conditional sections use `rendered="{!booleanProperty}"` where `booleanProperty` is a server-side Boolean on the controller. Do not use CSS `display:none` to hide sensitive content; hidden elements are still present in the HTML and visible if the raw HTML is inspected.
- Use `WITH USER_MODE` on all SOQL queries (available Summer '23+) to enforce FLS at the database level without manual `Schema` checks.
- Images and logos must be referenced by absolute URL (built in Apex using `URL.getSalesforceBaseUrl().toExternalForm()`) or embedded as base64 data URIs. The `{!$Resource.LogoName}` expression resolves to a relative path that the Flying Saucer renderer cannot follow.

### Concept 3: PageReference.getContentAsPDF() for Programmatic PDF Generation

`PageReference.getContentAsPDF()` renders a Visualforce page to a raw PDF `Blob` from within Apex server-side code, enabling automated PDF creation without user interaction. This method:

- Performs an internal HTTP callout to the VF page URL. It counts against the 100-callout-per-transaction limit.
- **Returns `null` (not an exception) when the VF page throws an unhandled exception.** Code that skips a null check will silently insert a 0-byte file or throw a NullPointerException downstream.
- Cannot be called inside a trigger context (callout-after-DML restriction). Must be invoked from a Queueable implementing `Database.AllowsCallouts`, a `@future(callout=true)` method, or a Batch class implementing `Database.AllowsCallouts`.
- Is subject to a 120-second timeout. Pages with large SOQL result sets or complex rendering must be optimized for server-side speed.
- The returned `Blob` is stored as a `ContentVersion` (field `VersionData`) and linked to the source record via `ContentDocumentLink`.

### Concept 4: LWC PDF Limitation and the VF Wrapper Pattern

LWC does not support `renderAs="pdf"` and cannot produce a PDF natively. The documented Salesforce pattern for LWC-initiated PDF generation is:

1. Author a Visualforce page as the PDF template with a server-side Apex controller.
2. Either navigate to the VF page URL from the LWC (using `NavigationMixin.Navigate` with `type: 'standard__webPage'`) for a browser-download flow, or invoke a headless Apex method from the LWC that enqueues a Queueable to generate and store the PDF as a ContentVersion.

---

## Common Patterns

### Pattern 1: On-Demand PDF Rendered Directly in Browser

**When to use:** A user opens a record page, clicks "Download PDF," and the PDF downloads directly — no file storage needed.

**How it works:**
1. Create a Visualforce page: `<apex:page controller="InvoicePdfController" renderAs="pdf" showHeader="false" sidebar="false">`.
2. In `InvoicePdfController`, query all required data in the constructor using `WITH USER_MODE`.
3. Build the logo absolute URL in the constructor: `logoUrl = URL.getSalesforceBaseUrl().toExternalForm() + '/resource/' + RESOURCE_ID + '/logo.png';` or embed as base64.
4. Use inline `<style>` with CSS 2.1 table layout for columns. No `<script>` tags.
5. Expose the page via a button on the record page, passing `?id={!recordId}` as a parameter.

**Why not the alternative:** Using `{!$Resource.Logo}` directly in an `<img src>` produces a relative URL the renderer cannot follow, resulting in a broken image. Loading data via AJAX/JS fails silently — the table renders empty.

### Pattern 2: Programmatic PDF Attachment via Queueable

**When to use:** A PDF must be automatically generated and stored as a File on a record when triggered by a process (Flow, trigger, or platform event) — no user interaction.

**How it works:**
1. An Apex trigger or Flow invokes a Queueable class (`PdfAttachmentJob`) that implements `Database.AllowsCallouts`, passing the source record Id.
2. Inside `execute()`: build a `PageReference` to the VF page with `?id=<recordId>`, call `.getContentAsPDF()`, check for null, create a `ContentVersion` with `VersionData = blob`, then link it via `ContentDocumentLink` with `LinkedEntityId = recordId`.
3. Null-check pattern: `Blob pdf = pageRef.getContentAsPDF(); if (pdf == null) { /* log and return */ }`.

**Why not the alternative:** Calling `getContentAsPDF()` synchronously in a trigger violates the callout-after-DML restriction and throws a `System.CalloutException: Callout from triggers are currently not supported.` The async Queueable pattern is the only safe path.

### Pattern 3: LWC-Initiated PDF Generation

**When to use:** An LWC component on the record page has a "Generate Report" button and the PDF must be saved to the record's Files and surfaced back in the UI.

**How it works:**
1. LWC calls an `@AuraEnabled` Apex method that enqueues `PdfAttachmentJob` (Pattern 2).
2. The LWC displays a spinner while the job completes. On completion, it refreshes the Files related list using `refreshApex` or LMS.
3. Alternatively, for a synchronous browser-download flow, the LWC uses `NavigationMixin.Navigate` to open the VF page URL in a new tab.

**Why not the alternative:** Attempting to generate the PDF entirely in the LWC JavaScript layer is not possible — there is no Salesforce-native PDF library available in client-side JS. Third-party client-side libraries (jsPDF, etc.) are unofficial, unsupported, and unreliable for complex layouts.

---

## Decision Guidance

| Situation | Recommended Approach | Reason |
|---|---|---|
| User-triggered browser download | VF page with `renderAs='pdf'`, opened via button URL | Simplest path; no async complexity |
| Auto-attach PDF on record change | Queueable implementing `Database.AllowsCallouts` | Callouts prohibited in trigger context |
| LWC button → save PDF to Files | LWC calls `@AuraEnabled` method → enqueues Queueable | LWC cannot render PDF natively |
| Logo not appearing in PDF output | Absolute URL in Apex or base64 data URI in CSS | Renderer cannot follow relative `$Resource` paths |
| CSS layout broken in PDF | Replace Flexbox/Grid with CSS 2.1 table layout | Flying Saucer supports CSS 2.1 only |
| Bulk generation (100+ records) | Batch Apex with scope=1, each scope generates one PDF | `getContentAsPDF()` counts against 100-callout limit |
| Quote-specific PDF | Use `apex/quote-pdf-customization` skill instead | Quote line item and template concerns handled separately |

---

## Recommended Workflow

Step-by-step instructions for an AI agent or practitioner working on this task:

1. **Clarify the trigger and delivery mechanism** — Determine whether the PDF is user-initiated (browser download) or system-initiated (attached to a record). Identify the source record type and the data required. Confirm whether an LWC is involved.
2. **Design the Apex controller** — Write a custom controller (not a standard controller extension unless the object is natively supported). Load all required data in the constructor with `WITH USER_MODE` SOQL. Build all computed values (logo URL, conditional flags, formatted dates) as server-side properties.
3. **Author the Visualforce page** — Set `renderAs="pdf" showHeader="false" sidebar="false"` on `<apex:page>`. Include no `<script>` tags. Use `<style>` blocks with CSS 2.1 table-based layout only. Reference logos via absolute URL or base64. Use `rendered="{!property}"` for conditional sections.
4. **Test the VF page iteratively** — Open the VF page URL in a browser with `?id=<recordId>`. Toggle `renderAs="pdf"` on and off to compare HTML vs. PDF output. Confirm logo visibility, column alignment, and page breaks.
5. **Implement programmatic generation if needed** — Build a Queueable implementing `Database.AllowsCallouts`. In `execute()`: call `getContentAsPDF()`, null-check the blob, insert a `ContentVersion` (with `PathOnClient`, `Title`, `VersionData`), query the resulting `ContentDocumentId`, then insert a `ContentDocumentLink` to the source record.
6. **Wire the delivery path** — For trigger/Flow invocation: enqueue the Queueable from a trigger handler or an Apex action in Flow. For LWC: expose the enqueue call via an `@AuraEnabled` method and handle spinner/refresh in the component.
7. **Validate security** — Run the full flow as a non-admin user with minimum read access. Confirm no FLS violations in debug logs. Confirm `ContentDocumentLink` `ShareType` is set appropriately (`'V'` for Viewer, `'I'` for Inferred).

---

## Review Checklist

Run through these before marking work in this area complete:

- [ ] `<apex:page>` has `renderAs="pdf" showHeader="false" sidebar="false"`
- [ ] No `<script>` tags in the VF page — all logic is server-side Apex
- [ ] SOQL uses `WITH USER_MODE` or explicit FLS/CRUD checks
- [ ] Logo/images use absolute URL built in Apex or are embedded as base64 data URIs — not `{!$Resource.X}` directly in `<img src>`
- [ ] CSS uses CSS 2.1 table layout — no Flexbox, Grid, or CSS custom properties
- [ ] Conditional sections use `rendered="{!boolProp}"` — not CSS `display:none`
- [ ] If programmatic: `getContentAsPDF()` is called from Queueable or Future method with `Database.AllowsCallouts`
- [ ] `getContentAsPDF()` return value is checked for null before inserting ContentVersion
- [ ] ContentDocumentLink is inserted with correct `LinkedEntityId` and `ShareType`
- [ ] Tested as a non-admin user; no data exposure through hidden rendered sections

---

## Salesforce-Specific Gotchas

Non-obvious platform behaviors that cause real production problems:

1. **JavaScript is silently ignored by the PDF renderer** — The Flying Saucer engine has no JS execution context. Scripts and any layout they produce are absent in the PDF with no error. All layout and data must be resolved server-side.
2. **External CDN stylesheets are not fetched** — A `<link>` to an external stylesheet (Bootstrap, Tailwind, any CDN URL) is silently skipped by the renderer. CSS must be in an inline `<style>` block or a Salesforce-hosted static resource.
3. **`{!$Resource.LogoName}` in `<img src>` produces a broken image** — The expression resolves to a relative URL. The renderer makes its own HTTP request and cannot resolve it. Build the absolute URL in Apex using `URL.getSalesforceBaseUrl()` or embed as base64.
4. **`getContentAsPDF()` returns null on VF page error — not an exception** — If the VF page throws, the method silently returns null. Omitting a null check leads to a NullPointerException when reading the blob or inserts a 0-byte ContentVersion.
5. **Callout-after-DML restriction prevents use in triggers** — If any DML occurred before `getContentAsPDF()` in the same transaction, a `CalloutException` is thrown. Always call it from a Queueable or `@future(callout=true)` method.

---

## Output Artifacts

| Artifact | Description |
|---|---|
| Visualforce page | `.page` file with `renderAs="pdf"`, CSS 2.1 table layout, no script tags, conditional `rendered` attributes |
| Apex controller class | Loads all data server-side, builds absolute logo URL, exposes Boolean flags for conditional sections |
| Queueable PDF attachment class | Calls `getContentAsPDF()`, null-checks blob, inserts ContentVersion + ContentDocumentLink |
| LWC wiring (if applicable) | `@AuraEnabled` Apex method + LWC spinner/refresh pattern |
| Checker script output | Issues list from `check_pdf_generation_patterns.py` |

---

## Related Skills

- `apex/quote-pdf-customization` — Quote-specific PDF generation including CPQ line items, multi-language quote templates, and standard controller extensions; use instead of this skill for Quote PDFs
- `apex/visualforce-fundamentals` — Core VF rendering concepts, controller types, view state, and LEX compatibility that underpin this skill
- `omnistudio/document-generation-omnistudio` — Alternative when OmniStudio is licensed; supports LWC-based document templates and server-side Word/PDF output
- `apex/apex-queueable-patterns` — Queueable implementation patterns for async PDF generation and ContentVersion attachment
