PDF Export Handler
 Print This Page
Post a table, get a PDF back. For developers building a PDF export outside the Data Source grid plugin.

PDF Export Handler

The PDF export handler takes a block of HTML you have already built and returns a PDF of it. You post a table, you get a document back. Nothing is written to disk and no print dialog is involved.

It exists to give Data Source report grids their PDF export button, but nothing about it is tied to Data Sources. Any page that can assemble a table can use it.

If you only want a PDF button on a Data Source grid, you do not need any of this. The grid plugin already has one, and it is switched on with a single setting. See Data Sources > Template Content > JavaScript Extension. This article is for building your own.

Before you start

Signed-in visitors only. An anonymous request is refused, so do not build this into a page anonymous visitors use. A form post carries the visitor's session automatically, so a signed-in visitor needs no special handling from you.
  • The request has to be a POST. The handler cannot be reached by a link, a redirect, or by typing the address.
  • Site-wide PDF generation has to be switched on. This is the same setting that governs the other PDF features in the product, and when it is off the handler refuses everything.

A complete example

Three pieces: a table to export, a helper that posts it, and a button that calls the helper. Together they work as written, so this is a reasonable thing to paste into a page and try before adapting it.

1. The table

Any table works. It needs an id so the script can find it, and a real <thead> so the column headers repeat on every page of the PDF.

<table id="myReport"> <thead> <tr> <th>Date</th> <th>Title</th> <th>Status</th> </tr> </thead> <tbody> <tr> <td>08/18/2026</td> <td>Quarterly review</td> <td>Approved</td> </tr> <tr> <td>08/22/2026</td> <td>Vendor assessment</td> <td>Pending</td> </tr> </tbody> </table> <button type="button" id="exportPdf">Export to PDF</button>

2. The helper

This is the only plumbing there is. It takes an object, turns each property into a hidden field, and posts it. Every example after this one calls it.

function postPdf(payload) { var form = document.createElement('form'); form.method = 'POST'; form.action = '/Scripting/DataSourceToPdf.ashx'; form.target = '_blank'; for (var name in payload) { if (!payload.hasOwnProperty(name) || payload[name] == null) continue; var input = document.createElement('input'); input.type = 'hidden'; input.name = name; input.value = payload[name]; form.appendChild(input); } document.body.appendChild(form); form.submit(); document.body.removeChild(form); }

3. The button

Html is the only field you have to send. Posting nothing else produces a plain PDF of the table, named Report.pdf, in portrait.

document.getElementById('exportPdf').addEventListener('click', function () { postPdf({ Html: document.getElementById('myReport').outerHTML }); });

Clicking the button opens a tab, downloads Report.pdf, and the tab closes itself. Everything below is about improving on that starting point.

Why a form and not an AJAX call

The response is the PDF itself, sent as a download. A browser knows what to do with that; JavaScript largely does not, and the jQuery version loaded on most pages cannot read a binary response at all. Posting a form hands the whole problem to the browser.

Posting to _blank means the download starts without navigating away from the page the visitor is on. The form is removed from the page immediately after submitting, which is safe because the request is already on its way.

The fields you can post

Field What goes in it
Html Required. The markup to render. Normally a table, though any content HTML works. An empty value is rejected, and resource-loading elements are removed before rendering - see What can be referenced.
Title A heading printed at the top of the document. Treated as plain text, so any markup in it is shown rather than rendered.
BeforeTableHtml Markup placed under the title and above your content, for a provenance band or any other preamble. Rendered as HTML, and the only field where the tokens work.
Orientation landscape turns the page. Any other value, including leaving it out, gives portrait.
FileName The downloaded file name, without the extension. Defaults to Report. Spaces and ordinary punctuation are kept.
UnfilteredRowsCount The total number of rows before any filtering, as a plain number. Only needed if you use the {unfilteredRowsCount} token below. Sent unformatted on purpose, so the handler can format it in the reader's own locale alongside the other count.
CssClass An extra class added to the document wrapper, so one export can be styled differently from the rest. Letters, digits, spaces and hyphens only; anything else is removed.

Tokens the handler fills in

These are values your page either cannot work out correctly for itself, or should not format itself. All of them work only inside BeforeTableHtml.

Token What it becomes, and why it is not yours to calculate
{totalRowsCount} The number of rows in the document being produced, counted from the markup itself rather than taken from your page. Counting it afterwards is what stops the document claiming rows it does not contain.
{unfilteredRowsCount} Whatever you sent as UnfilteredRowsCount, formatted in the reader's locale. Formatting it yourself would put a browser-locale number beside a reader-locale one in the same sentence, which is the whole reason it is a token.
{usersTime} The current time in the visitor's own time zone and date format. The browser clock would be close but is not the visitor's configured format, and the server clock is simply wrong for anyone outside it.

A band that names both looks like this:

BeforeTableHtml: '<div>Generated {usersTime}. Rows: {totalRowsCount}.</div>'

Every field, on the same example

The same table and the same postPdf helper, with the rest of the fields filled in. Only the object changes.

document.getElementById('exportPdf').addEventListener('click', function () { postPdf({ Html: document.getElementById('myReport').outerHTML, Title: 'Quarterly Summary', FileName: 'Quarterly Summary', Orientation: 'landscape', CssClass: 'quarterly', BeforeTableHtml: '<div>Generated {usersTime}. Rows: {totalRowsCount}.</div>' }); });

Against the two-row table above, that downloads Quarterly Summary.pdf in landscape, headed Quarterly Summary, with a line underneath reading Generated 9/15/2026 10:15 AM. Rows: 2. in the visitor's own time zone.

What the handler does to your markup

  • Your site's stylesheet is linked in for you. The content arrives looking the way it does on screen without you sending any styling. Edits to a Design styles record reach the next export with nothing to clear.
  • Scripts do not run. JavaScript in what you post is ignored, so anything the document needs has to be in the HTML already. Build the finished markup and post that.
  • Images are fetched by the server, not by the visitor's browser. An image the web server itself cannot reach will be missing from the document even though it displays perfectly on screen. Relative paths are resolved against your site, and only your own site's images are fetched at all - see below.
  • Column headers repeat on every page and rows are kept from being split across a page break, provided your table uses a real <thead>.
  • Wide content is squeezed to fit rather than cut off, so nothing is ever silently lost.

What can be referenced

Because the server does the fetching, a URL in the posted markup is a request made by the web server, from inside your network, whose response is rendered into a document the caller downloads. An address the visitor could never reach themselves - a machine on the internal network, or a service bound to the server's own loopback address - would otherwise be readable through an export. Two rules follow, applied to the markup before it is rendered.

These rules are applied for you. Nothing needs configuring, and a normal report table is unaffected by either of them.
Rule What it means for your markup
Elements that load another document are removed iframe, frame, frameset, object, embed, applet, link, base, script and meta are stripped before rendering. Text inside them is kept. Build the finished content into the markup you post rather than pointing at it.
Only your own site's resources are fetched A src, srcset, data, poster or background pointing at another host is dropped, as is a CSS url(). Relative paths and absolute URLs on your own site both work normally, so an image in a report cell needs no special handling.

href on a link is not affected. A link is drawn in the document rather than followed, so it fetches nothing and an external link in a report cell stays intact and clickable in the PDF.

How wide is too wide

A table with many columns does not make a good PDF, and no setting changes that. Past roughly a dozen columns of real content the result is cramped, and past twenty it is very hard to read. Landscape helps and fewer columns helps more, but neither moves that boundary far. Where somebody needs every column of a wide report, a spreadsheet is the right format and a PDF is not.

Styling the document

Everything the handler produces is wrapped in a class that exists only in the export. That means a Design styles record can restyle the printed version without any of those rules reaching what visitors see on screen.

.cp-ds-pdf-export table { font-size: 9px; } .cp-ds-pdf-export th { background: #0a2240; color: #fff; } .cp-ds-pdf-export .cp-ds-pdf-title { font-size: 24px; } /* just this one export, posted with CssClass: 'quarterly' */ .cp-ds-pdf-export.quarterly table { font-size: 8px; }
Class What it wraps
.cp-ds-pdf-export The whole document. This is the class to target for a site-wide export style.
.cp-ds-pdf-title The heading built from Title.
.cp-ds-pdf-stamp The block under the heading, holding whatever you sent as BeforeTableHtml.

A value you post as CssClass is added to the wrapper rather than replacing it, so a site-wide rule keeps applying to an export that also brings its own class. Write the narrower rule as .cp-ds-pdf-export.quarterly so it outranks the site-wide one.

What comes back

Result Why
A PDF download It worked.
Not found The visitor is not signed in, PDF generation is switched off for the site, or the post came from another site. See below.
Method not allowed The request was not a POST.
Bad request Html was empty.
Server error The document could not be produced. Reported as a server fault rather than a bad request, so it shows up wherever server errors are being watched.

Every failure returns a short page saying the export could not be generated and suggesting the visitor sign in again. That matters because the tab has already opened by then, and a visitor whose session quietly expired would otherwise be looking at a blank window with nothing to act on. The detail behind the failure is recorded for an administrator rather than shown to the visitor.

A very large export can be rejected before the handler ever sees it. Every site has a maximum request size, and a big enough table exceeds it. If a large report will not export, that setting is what needs raising. Posting less, by filtering or by dropping columns, is the immediate way round it.

The post has to come from your own site

A post that the browser reports as coming from another site is refused. The legitimate caller is always a page on your own site, so this costs a normal integration nothing, and it means the handler does not have to rely on cookie settings to keep somebody else's page from driving it.

Nothing to configure, and nothing to send. The browser supplies what this is judged on. A form on one of your own pages, built the way the examples above build it, is never affected.

Things worth knowing

  • You own the markup, the handler owns the document. It supplies the stylesheet link, the page setup, the margins and the render. Everything inside the page is what you posted.
  • It is not limited to tables. A table is the normal case and the page setup is tuned for one, but any HTML you post is rendered.
  • Nothing is stored. The document is built and streamed back, so there is no file to clean up and no cache to invalidate.
  • A pop-up blocker set to block everything will stop it, since the post opens a new tab. Blockers normally allow it because the visitor clicked.
  • Security is unchanged. The handler renders what you posted, so anything a visitor was not allowed to see was never in your markup in the first place.