Generating PDFs in Salesforce Visualforce vs New Apex Blob.toPdf() (Spring ’26 Preview)

Generating PDFs in Salesforce: Visualforce vs New Apex Blob.toPdf() (Spring ’26 Preview)

,

PDF generation is a very common requirement in Salesforce for documents such as invoices, receipts, contracts, summaries, certificates, and reports.”

For many years, Salesforce developers had only one reliable way to generate PDFs:
👉 Visualforce pages rendered as PDF

Even when Apex was used, Visualforce was still mandatory for the actual PDF rendering.

With the Spring ’26 release (Preview), Salesforce introduced an important enhancement:
👉 Apex can now generate PDFs directly using Blob.toPdf(), without requiring a Visualforce page.

This blog explains:

  • How PDF generation worked earlier using Visualforce
  • Where Apex already played a role
  • Why Visualforce was still required
  • How the same use case can now be implemented using Apex only
  • Examples of Using Blob.toPdf() in Apex.
  • When to use Visualforce vs Apex PDFs

How PDF Generation Traditionally Worked in Salesforce

Before Spring ’26:

  • PDF rendering always happened through Visualforce
  • Apex could request the PDF, but could not generate complex styled PDF’s by it by itself

This resulted in two different usage patterns.

🔹 Case 1: Show PDF on the UI

If the requirement was:

“User clicks a button and sees a PDF in the browser”

Then Visualforce alone was enough using:

renderAs="pdf"

🔹 Case 2: Use PDF in Backend (Store / Email / Automation)

If the requirement was:

  • Email a PDF
  • Store a PDF in Salesforce Files
  • Generate PDFs automatically

Then:

  • Visualforce still rendered the PDF
  • Apex was required to fetch the PDF as a Blob

This is where PageReference.getContentAsPDF() is used.

Visualforce + Apex PDF Example (Existing Feature)

Let’s look at a real Salesforce example, straight from the platform documentation.

Business Requirement

  • User selects:
    • An Account
    • A report format
    • An email address
  • Salesforce generates a PDF summary
  • The PDF is emailed to the recipient

Visualforce Page (UI Layer)

This page is only a user interface.
It does not generate the PDF by itself.

<apex:page title="Account Summary"
           controller="PdfEmailerController">

    <apex:form>
        <apex:pageBlock title="Account Summary">

            <apex:pageBlockSection title="Report Format">

                <apex:pageBlockSectionItem>
                    <apex:outputLabel value="Account"/>
                    <apex:selectList value="{!selectedAccount}" size="1">
                        <apex:selectOptions value="{!recentAccounts}" />
                    </apex:selectList>
                </apex:pageBlockSectionItem>

                <apex:pageBlockSectionItem>
                    <apex:outputLabel value="Send To"/>
                    <apex:inputText value="{!recipientEmail}" />
                </apex:pageBlockSectionItem>

            </apex:pageBlockSection>

            <apex:pageBlockButtons>
                <apex:commandButton value="Send Account Summary"
                                    action="{!sendReport}"/>
            </apex:pageBlockButtons>

        </apex:pageBlock>
    </apex:form>
</apex:page>

👉 Important:
This page does not generate a PDF.
All PDF-related work happens in Apex.


Apex Controller – Where the PDF Is Actually Generated

public with sharing class PdfEmailerController {

    public Id selectedAccount { get; set; }
    public String recipientEmail { get; set; }

    public PageReference sendReport() {

        Account acc = [
            SELECT Name
            FROM Account
            WHERE Id = :selectedAccount
            LIMIT 1
        ];

        // Create email
        Messaging.SingleEmailMessage email =
            new Messaging.SingleEmailMessage();
        email.setToAddresses(new String[]{ recipientEmail });
        email.setSubject('Account Summary for ' + acc.Name);
        email.setPlainTextBody('Please find the attached PDF.');

        // Reference Visualforce PDF page
        PageReference pdfPage = Page.ReportAccountSimple;
        pdfPage.getParameters().put('id', selectedAccount);

        // Render PDF
        Blob pdfBlob;
        try {
            pdfBlob = pdfPage.getContentAsPDF();
        } catch (Exception e) {
            pdfBlob = Blob.valueOf(e.getMessage());
        }

        // Attach PDF
        Messaging.EmailFileAttachment attachment =
            new Messaging.EmailFileAttachment();
        attachment.setFileName('AccountSummary.pdf');
        attachment.setBody(pdfBlob);
        attachment.setContentType('application/pdf');

        email.setFileAttachments(
            new Messaging.EmailFileAttachment[]{ attachment });

        Messaging.sendEmail(
            new Messaging.SingleEmailMessage[]{ email });

        return null;
    }
}

What’s happening here?

  1. User submits the form
  2. Apex builds the email
  3. Apex references a Visualforce page
  4. getContentAsPDF() renders that page as a PDF
  5. The PDF is attached and emailed

The Hidden Dependency (The Real Problem)

Even though:

  • The user never sees the PDF page
  • Everything is backend-driven
  • The PDF is emailed automatically

👉 You still had to create and maintain a Visualforce page like this:

<apex:page standardController="Account"
           showHeader="false"
           standardStylesheets="false">

    <h1>Account Summary for {!Account.Name}</h1>

    <table>
        <tr><th>Phone</th><td>{!Account.Phone}</td></tr>
        <tr><th>Website</th><td>{!Account.Website}</td></tr>
    </table>
</apex:page>

This led many developers to ask:

“Why do I need a UI page just for backend PDFs?”


⚠️ Limitations of getContentAsPDF() in Apex

While PageReference.getContentAsPDF() has been widely used for years to generate PDF output from Visualforce pages, it has critical platform limitations. Architects and developers must be aware of these limitations.

❌ Where getContentAsPDF() Cannot Be Used

This method cannot be invoked in the following contexts:

  • Apex Triggers
    Since getContentAsPDF() performs an internal callout, it is not allowed inside triggers.
  • Test Methods
    Starting from API version 34.0, getContentAsPDF() is treated as a callout.
    As a result:
    • Calling it directly in test methods causes test failures
    • You cannot mock or stub this behavior easily
    • This significantly complicates test coverage
  • Apex Email Services
    PDF generation via getContentAsPDF() is not supported inside Apex Email Service handlers.

⚠️ Important Note: These restrictions often force teams to add workaround logic or conditional branching, increasing technical debt.

Blob.toPdf() Before Spring ’26 — What Didn’t Work and Why

Before Spring ’26 also, Blob.toPdf() already existed in Apex, but it came with serious limitations. Because of these limitations, it was rarely used in real projects, and developers relied almost entirely on Visualforce PDFs.

One common issue developers faced is illustrated below.

❌ Issue: Blob.toPdf() Fails with Static Resource Images

Steps to Reproduce (Pre–Spring ’26 Behavior)

  1. Create a Static Resource containing an image.
  2. Copy the image URL, for example:
https://<yourDomain>/resource/1633683579000/logo
  1. Execute the following Apex code:
String content = '<html><body>' +
                 '<img src="https://<yourDomain>/resource/logo"/>' +
                 '</body></html>';

System.debug(Blob.toPdf(content));

Result

System.InvalidParameterValueException: An error occurred while parsing the input string.

Why This Happened

Before Spring ’26:

  • Blob.toPdf() used a basic, limited PDF rendering engine
  • External references (including static resource URLs) were not reliably resolved
  • HTML parsing was fragile
  • CSS support was minimal

As a result:

  • Images often failed
  • Fonts could not be controlled
  • Unicode and multibyte characters did not render correctly
Official Workaround (Before Spring ’26)

Salesforce recommended falling back to Visualforce:

Blob body;
PageReference pdf = Page.ThePageCreated;
body = pdf.getContentAsPDF();

Why this worked:

  • Visualforce uses a more robust PDF rendering service
  • Static resources, fonts, and images are resolved correctly
  • Unicode and international characters are supported

This is why, historically, Visualforce was mandatory for generating PDFs.

What Changed in Spring ’26: Apex Blob.toPdf() (Major Upgrade)

Spring ’26 fundamentally changes how Blob.toPdf() works.

Salesforce did not introduce a new method, they upgraded the existing one.

The Method Remains the Same

Blob.toPdf(String html)
What Changed Under the Hood

Salesforce enhanced Blob.toPdf() to use the same PDF rendering service as Visualforce.

This is the critical improvement.

Why This Change Matters

Because Salesforce now uses the Visualforce PDF rendering engine:

  • ✅ Static resources (images, logos) are resolved reliably
  • ✅ Font handling matches Visualforce behavior
  • ✅ Unicode and multibyte characters are supported
  • ✅ Output quality is consistent across the platform
  • ✅ HTML parsing is more robust

Most importantly:

A Visualforce page is no longer required just to generate a PDF


Rebuilding the SAME Previous VF + Apex Use Case Using Apex Only

Let’s rebuild the same “Account Summary PDF email”, but now using Apex only.

Apex-Only PDF Generation (New Way)

public class ApexPdfEmailService {

    public static void sendAccountPdf(Id accountId, String emailAddress) {

        Account acc = [
            SELECT Name, Phone, Website
            FROM Account
            WHERE Id = :accountId
            LIMIT 1
        ];

        String html =
            '<html>' +
            '<head>' +
            '<style>' +
            'body { font-family: Helvetica; font-size: 12px; }' +
            'table { width:100%; border-collapse: collapse; }' +
            'td, th { border:1px solid #ccc; padding:6px; }' +
            '</style>' +
            '</head>' +
            '<body>' +
            '<h1>Account Summary for ' + acc.Name + '</h1>' +
            '<table>' +
            '<tr><th>Phone</th><td>' + acc.Phone + '</td></tr>' +
            '<tr><th>Website</th><td>' + acc.Website + '</td></tr>' +
            '</table>' +
            '</body>' +
            '</html>';

        Blob pdfBlob = Blob.toPdf(html);

        Messaging.EmailFileAttachment attachment =
            new Messaging.EmailFileAttachment();
        attachment.setFileName('AccountSummary.pdf');
        attachment.setBody(pdfBlob);
        attachment.setContentType('application/pdf');

        Messaging.SingleEmailMessage email =
            new Messaging.SingleEmailMessage();
        email.setToAddresses(new String[]{ emailAddress });
        email.setSubject('Account Summary for ' + acc.Name);
        email.setPlainTextBody('Please find the attached PDF.');
        email.setFileAttachments(
            new Messaging.EmailFileAttachment[]{ attachment });

        Messaging.sendEmail(
            new Messaging.SingleEmailMessage[]{ email });
    }
}

What’s gone?
  • ❌ No Visualforce page
  • ❌ No PageReference
  • ❌ No getContentAsPDF()

Same outcome. Cleaner architecture.

Note – This feature is only in preview with Spring 26 release and not generally available yet before the Sprint 26 release comes to all orgs , to test it sign up for a pre-release Developer Edition


Example 2 – Using Apex Blob.toPdf() with Custom Styling (Real-World PDF)

In the previous example, we saw how Apex can generate a simple PDF using Blob.toPdf() without relying on a Visualforce page.
Now let’s look at a more realistic, production-style example—one that includes:

  • Branding (logo & signature)
  • Multiple data sections
  • Tables with headers
  • Page breaks
  • Controlled styling
  • Large datasets split across pages
Demo –

This type of PDF was historically difficult to generate without Visualforce. Developers had to fall back to VF pages even for backend use cases.

Sample Code –

public class AccountPdfBlobGenerator {
public static Id generatePdf(Id accountId) {
/* ================= DATA ================= */
Account acc = [
SELECT Name, Industry, Phone, Website,
BillingStreet, BillingCity, BillingCountry
FROM Account WHERE Id = :accountId
];
List<Contact> contacts = [
SELECT Name, Email, Phone
FROM Contact
ORDER BY Name
];
List<Opportunity> opps = [
SELECT Name, Amount, CloseDate, StageName
FROM Opportunity
ORDER BY CloseDate DESC
];
String baseUrl = URL.getOrgDomainUrl().toExternalForm();
String tdStyle = 'style="border:1px solid #ccc; padding:6px;"';
String thStyle = 'style="border:1px solid #ccc; padding:6px; background:#1F4E79; color:white;"';
/* ================= HTML PARTS ================= */
List<String> htmlParts = new List<String>();
htmlParts.add('<html>');
htmlParts.add('<body style="font-family:Arial; font-size:11px; color:#333;">');
/* ——– LOGO ——– */
htmlParts.add(
'<div style="text-align:center; margin-bottom:20px;">' +
'<img src="' + baseUrl + '/resource/logo" width="160"/>' +
'</div>'
);
/* ——– ACCOUNT DETAILS ——– */
htmlParts.add('<h2 style="color:#1F4E79;">Account Details</h2>');
htmlParts.add('<table style="width:100%; border:1px solid #ccc;">');
htmlParts.add(buildKeyValueRow('Name', acc.Name, tdStyle));
htmlParts.add(buildKeyValueRow('Industry', acc.Industry, tdStyle));
htmlParts.add(buildKeyValueRow('Phone', acc.Phone, tdStyle));
htmlParts.add(buildKeyValueRow('Website', acc.Website, tdStyle));
htmlParts.add(buildKeyValueRow(
'Address',
acc.BillingStreet + ', ' + acc.BillingCity + ', ' + acc.BillingCountry,
tdStyle
));
htmlParts.add('</table>');
htmlParts.add('<div style="page-break-before:always;"></div>');
/* ——– CONTACTS ——– */
htmlParts.add('<h2 style="color:#1F4E79;">Contacts</h2>');
htmlParts.add('<table style="width:100%; border:1px solid #ccc;-fs-table-paginate: paginate;">');
htmlParts.add(
'<tr>' +
'<th ' + thStyle + '>Name</th>' +
'<th ' + thStyle + '>Email</th>' +
'<th ' + thStyle + '>Phone</th>' +
'</tr>'
);
Integer CONTACT_ROWS_PER_PAGE = 25;
Integer contactRowCount = 0;
for (Contact c : contacts) {
if (contactRowCount > 0 && Math.mod(contactRowCount, CONTACT_ROWS_PER_PAGE) == 0) {
htmlParts.add('</table>');
htmlParts.add('<div style="page-break-before:always;"></div>');
htmlParts.add('<table style="width:100%; border:1px solid #ccc;">');
htmlParts.add(
'<tr>' +
'<th ' + thStyle + '>Name</th>' +
'<th ' + thStyle + '>Email</th>' +
'<th ' + thStyle + '>Phone</th>' +
'</tr>'
);
}
htmlParts.add(
'<tr>' +
'<td ' + tdStyle + '>' + esc(c.Name) + '</td>' +
'<td ' + tdStyle + '>' + esc(c.Email) + '</td>' +
'<td ' + tdStyle + '>' + esc(c.Phone) + '</td>' +
'</tr>'
);
contactRowCount++;
}
htmlParts.add('</table>');
htmlParts.add('<div style="page-break-before:always;"></div>');
/* ——– OPPORTUNITIES ——– */
htmlParts.add('<h2 style="color:#1F4E79;">Opportunities</h2>');
htmlParts.add('<table style="width:100%; border:1px solid #ccc;-fs-table-paginate: paginate;">');
htmlParts.add(
'<tr>' +
'<th ' + thStyle + '>Name</th>' +
'<th ' + thStyle + '>Amount</th>' +
'<th ' + thStyle + '>Close Date</th>' +
'<th ' + thStyle + '>Stage</th>' +
'</tr>'
);
Integer oppRowCount = 0;
Integer OPP_ROWS_PER_PAGE = 20;
for (Opportunity o : opps) {
if (oppRowCount > 0 && Math.mod(oppRowCount, OPP_ROWS_PER_PAGE) == 0) {
htmlParts.add('</table>');
htmlParts.add('<div style="page-break-before:always;"></div>');
htmlParts.add('<table style="width:100%; border:1px solid #ccc;">');
htmlParts.add(
'<tr>' +
'<th ' + thStyle + '>Name</th>' +
'<th ' + thStyle + '>Amount</th>' +
'<th ' + thStyle + '>Close Date</th>' +
'<th ' + thStyle + '>Stage</th>' +
'</tr>'
);
}
htmlParts.add(
'<tr>' +
'<td ' + tdStyle + '>' + esc(o.Name) + '</td>' +
'<td ' + tdStyle + '>' +
(o.Amount == null ? '' : String.valueOf(o.Amount)) + '</td>' +
'<td ' + tdStyle + '>' + String.valueOf(o.CloseDate) + '</td>' +
'<td ' + tdStyle + '>' + esc(o.StageName) + '</td>' +
'</tr>'
);
oppRowCount++;
}
htmlParts.add('</table>');
/* ——– SIGNATURE ——– */
htmlParts.add(
'<div style="margin-top:50px;">' +
'<p>Authorized Signature</p>' +
'<img src="' + baseUrl + '/resource/sign" width="140"/>' +
'</div>'
);
htmlParts.add('</body></html>');
/* ================= FINALIZE ================= */
String finalHtml = String.join(htmlParts, '');
System.debug('HTML LENGTH => ' + finalHtml.length());
Blob pdfBlob = Blob.toPdf(finalHtml);
ContentVersion cv = new ContentVersion();
cv.Title = 'Account_Full_Details';
cv.PathOnClient = 'AccountDetails.pdf';
cv.VersionData = pdfBlob;
cv.FirstPublishLocationId = accountId;
insert cv;
return cv.Id;
}
/* ================= HELPERS ================= */
private static String buildKeyValueRow(String label, String value, String tdStyle) {
return
'<tr>' +
'<td ' + tdStyle + '><b>' + esc(label) + '</b></td>' +
'<td ' + tdStyle + '>' + esc(value) + '</td>' +
'</tr>';
}
private static String esc(String value) {
if (value == null) return '';
return value
.replace('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('"', '&quot;')
.replace('\'', '&#39;');
}
}
What This Example Is Doing (High-Level)

This Apex class generates a multi-section Account Summary PDF that includes:

  1. Company branding
    • Logo at the top
    • Signature at the bottom
  2. Account details
    • Name, industry, phone, website, address
  3. Related Contacts
    • Rendered as a paginated table
  4. Related Opportunities
    • Rendered as a paginated table
  5. Clean styling
    • Table borders
    • Header colors
    • Consistent fonts
  6. Automatic storage
    • PDF is saved as a Salesforce File and linked to the Account

All of this is done purely in Apex, using Blob.toPdf().


Step 1: Collecting Data in Apex

At the top of the class, we query all the data required for the PDF:

  • Account (primary record)
  • Contacts
  • Opportunities

This is intentional.

Best practice:

Always fetch all data first, then build the HTML.
Do not mix SOQL and HTML generation inside loops.

This keeps the logic readable, testable, and governor-limit safe.


Step 2: Preparing Styling in Apex (Inline CSS)

Because PDFs rendered via Salesforce:

  • Ignore external CSS
  • Ignore JavaScript

We define inline styles as strings:

String tdStyle = 'style="border:1px solid #ccc; padding:6px;"';
String thStyle = 'style="border:1px solid #ccc; padding:6px; background:#1F4E79; color:white;"';

This approach gives us:

  • Consistent table styling
  • Reusable styles
  • Cleaner HTML assembly

Best Practice:
Inlining styles like this is far more maintainable than hardcoding styles repeatedly in every <td> or <th>.


Step 3: Building HTML in Parts (Scalable Pattern)

Instead of creating one massive string, this example uses:

List<String> htmlParts = new List<String>();

Each section of the PDF is added step-by-step:

  • Logo
  • Account details
  • Contacts
  • Opportunities
  • Signature

Finally, everything is joined using:

String finalHtml = String.join(htmlParts, '');

Why this matters:

  • Easier to debug
  • Easier to extend
  • Easier to refactor into reusable templates later

This is a professional pattern for complex PDF generation.


Step 4: Adding Branding (Static Resources)

The logo and signature are loaded from Static Resources:

<img src="{baseUrl}/resource/logo"/>
<img src="{baseUrl}/resource/sign"/>

This works reliably now because:

  • Since Spring ’26, Blob.toPdf() uses the Visualforce PDF rendering service
  • Static resources are resolved the same way as in Visualforce PDFs

Earlier, this was a major limitation of Blob.toPdf()—now it is fully supported.


Step 5: Handling Page Breaks Correctly

Large PDFs must control pagination explicitly.

This example uses:

<div style="page-break-before:always;"></div>

to:

  • Start new sections on a fresh page
  • Prevent tables from breaking awkwardly

Additionally, for large tables (Contacts and Opportunities), the code:

  • Tracks row counts
  • Manually inserts page breaks after a fixed number of rows

This ensures:

  • Clean page layout
  • Readable tables
  • No content overflow
Step 6: Escaping Data Safely

All dynamic values are passed through a helper method:

private static String esc(String value)

This prevents:

  • Broken HTML
  • Rendering errors
  • Injection of invalid characters

Best practice:

Never insert raw database values directly into HTML strings.

This is just as important in PDFs as it is in web pages.


Step 7: Generating the PDF Using Blob.toPdf()

Once the HTML is complete:

Blob pdfBlob = Blob.toPdf(finalHtml);

At this point:

  • HTML is converted into a PDF
  • Using the Visualforce PDF rendering engine
  • With full font, Unicode, and static resource support

No Visualforce page is involved.


Step 8: Saving the PDF as a Salesforce File

Finally, the PDF is stored as a Salesforce File:

ContentVersion cv = new ContentVersion();
cv.Title = 'Account_Full_Details';
cv.PathOnClient = 'AccountDetails.pdf';
cv.VersionData = pdfBlob;
cv.FirstPublishLocationId = accountId;
insert cv;

This:

  • Automatically links the PDF to the Account
  • Makes it available in Files
  • Allows sharing, emailing, and automation

Example 3 – Generating Invoice PDF from Opportunity using Apex Blob.toPdf() + LWC

💡 Business Scenario

A sales user needs to generate a professional invoice PDF directly from Salesforce. They should do this without exporting data or relying on Visualforce pages. The sales user clicks “Generate Quote PDF” button in LWC to generate Invoice PDF.

In the earlier examples, we explored how Apex can generate PDFs using Blob.toPdf() without relying on Visualforce pages.

Now let’s look at a real-world, end-to-end invoice generation example that combines:

  • Apex (Blob.toPdf())
  • Lightning Web Component (Quick Action)
  • Opportunity & Opportunity Products
  • Branding (logo & signature)
  • Salesforce Files storage

This pattern is production-ready and commonly used for Invoices, Proforma Invoices, Quotes, and Billing Documents.

Sample Code –

LWC + Apex

Demo –

What This Example Is Doing (High-Level)

This solution generates a professional Invoice PDF directly from an Opportunity.

It includes:

  • Lightning Web Component (Quick Action)
    • Triggers PDF generation automatically
    • Opens the generated PDF
    • Closes the Quick Action modal
  • Apex PDF Generation
    • Builds structured HTML
    • Converts HTML → PDF using Blob.toPdf()
  • Invoice Content
    • Opportunity details
    • Account billing address
    • Opportunity Products as line items
    • Subtotal calculation
  • Branding
    • Company logo (Static Resource)
    • Authorized signature (Static Resource)
  • Automatic Storage
    • PDF saved as a Salesforce File
    • Linked back to the Opportunity

All of this is achieved without any Visualforce page.

Step 1: Triggering PDF Generation from LWC

The Lightning Web Component acts as a Quick Action entry point.

What the LWC Does

  • Receives recordId (Opportunity Id) automatically
  • Calls Apex to generate the Invoice PDF
  • Opens the generated PDF in a new tab

This creates a single-click user experience.

LWC JavaScript – Core Logic
generateInvoicePdf({ opportunityId: this.recordId })
    .then(contentDocumentId => {
        window.open(
            '/sfc/servlet.shepherd/document/download/' + contentDocumentId,
            '_blank'
        );
        this.dispatchEvent(new CloseActionScreenEvent());
    })

Why this pattern is important:

  • No button clicks required
  • Ideal for “Generate & Download” actions
  • Clean UX for business users

Step 2: Collecting All Required Data in Apex

The Apex class starts by querying all required data upfront.

Data Collected

  • Opportunity
    • Name
    • Amount
    • Close Date
  • Account (Billing Details)
    • Address fields
  • OpportunityLineItems
    • Product name
    • Quantity
    • Unit price
    • Total price
Opportunity opp = [SELECT ... FROM Opportunity WHERE Id = :opportunityId];
List<OpportunityLineItem> items = [SELECT ... FROM OpportunityLineItem];

Best Practice

  • Fetch all data before building HTML
  • Never mix SOQL queries inside HTML loops
  • Keeps logic readable and governor-limit safe

Step 3: Preparing Invoice Metadata

Invoice-specific values are generated in Apex:

String invoiceNumber = 'INV-' + String.valueOf(DateTime.now().getTime());
String invoiceDate   = String.valueOf(Date.today());

This allows:

  • Unique invoice numbers
  • Dynamic invoice dates
  • Easy extension later (custom numbering logic)
Step 4: Preparing Inline Styling (PDF-Safe CSS)

Salesforce PDF rendering:

  • ❌ Ignores external CSS
  • ❌ Ignores JavaScript
  • ✅ Supports inline styles

So we define reusable styles as strings:

String tableHeaderStyle =
 'style="background:#1F4E79;color:white;padding:8px;border:1px solid #ccc;font-weight:bold;"';

String tableCellStyle =
 'style="padding:8px;border:1px solid #ccc;"';
Why This Is a Best Practice
  • Consistent styling across tables
  • Easy to maintain
  • Cleaner HTML generation

Step 5: Adding Branding Using Static Resources

The logo and signature are loaded from Static Resources:

String baseUrl = URL.getOrgDomainUrl().toExternalForm();
<img src="{baseUrl}/resource/logo"/>
<img src="{baseUrl}/resource/sign"/>
Step 6: Building the Invoice Layout (HTML Sections)

The HTML is built in structured sections:

Header Section

  • Opportunity name
  • Account billing address
  • Invoice number & date

Bill To Section

  • Clearly separated billing block
  • Styled header for clarity

Line Items Table

  • Opportunity Products rendered dynamically
  • Quantity, unit price, total
  • Subtotal calculated in Apex
Decimal subTotal = 0;
for (OpportunityLineItem oli : items) {
    subTotal += oli.TotalPrice;
}

This keeps business calculations out of HTML, which is a best practice.

Step 7: Final PDF Generation Using Blob.toPdf()

Once HTML is complete:

Blob pdfBlob = Blob.toPdf(html);

At this point:

  • HTML → PDF conversion happens
  • Uses Salesforce’s internal PDF engine
  • Supports Unicode, fonts, images, and tables

No Visualforce page is involved.

Step 8: Saving the Invoice as a Salesforce File

The generated PDF is stored as a Salesforce File:

ContentVersion cv = new ContentVersion();
cv.Title = 'Invoice - ' + opp.Name;
cv.PathOnClient = 'Invoice.pdf';
cv.VersionData = pdfBlob;
cv.FirstPublishLocationId = opportunityId;
insert cv;
What This Gives You
  • File automatically linked to Opportunity
  • Visible in Files related list
  • Can be emailed, shared, or automated further
  • Fully compatible with Flow and Approval Processes

This approach is ideal when:

  • You need dynamic PDFs
  • You want to avoid Visualforce
  • You want LWC-first architecture
  • You need branding and structured layouts
  • You want reusable logic for Invoices, Quotes, or Contracts

Visualforce vs Apex PDF – When to Use Which

Use Visualforce PDFs when:
  • Layout is complex
  • Headers, footers, page numbers are required
  • Heavy CSS or branding is needed
  • Existing VF templates already exist
  • PDF is user-facing
Use Apex Blob.toPdf() when:
  • PDF is backend-only
  • Automation is required
  • Used in batch, queueable, or flow
  • Layout is simple to moderate
  • You want fewer artifacts to maintain

Final Advice

  • Visualforce PDFs are not deprecated
  • Apex PDFs do not replace Visualforce
  • This release gives developers architectural freedom
  • Backend PDFs no longer require UI artifacts

Leave a comment