- Subscribe to RSS Feed
- Mark as New
- Mark as Read
- Bookmark
- Subscribe
- Printer Friendly Page
- Report Inappropriate Content
There is an ask that sounds very common but confuses a lot of developers when they face it for the first time. A user opens a record, clicks a button, and gets a PDF copy of that record. All the important fields, laid out nicely, attached to the record itself so anyone can download it later.
Now the usual reaction to this is to start thinking about export options, third party tools, or some complicated integration. But here is the thing. ServiceNow already gives us an API that does exactly this job. It is sitting in every instance, active by default, and most developers have never touched it.
So in this article, I am going to solve this using a UI Action on a table record. We will start very small, just a button that generates a plain PDF. Then we will improve it piece by piece. We will add CSS to make it look professional. Then we will pull in related list data as well. And at the end, I will share a complete use case where I applied the full solution on the Incident table.
The API: PDFGenerationAPI
Before writing any code, let us understand the tool we are going to use, because half of the battle in ServiceNow is knowing what the platform already gives you.
The API is called PDFGenerationAPI. Here is everything you need to know about it:
Where it comes from: It is part of the PDF Generation Utilities plugin. The plugin name is com.snc.apppdfgenerator. You do not need to activate anything, it is active by default on the instance.
Namespace: All its methods live inside the sn_pdfgeneratorutils namespace. So every call starts with new sn_pdfgeneratorutils.PDFGenerationAPI().
Where it runs: This is a server side API. You can call it from a UI Action, a Business Rule, a Script Include, a Scheduled Job, or a Flow Designer script step. It will not work in client scripts.
The main method we will use:
convertToPDF(html, targetTable, targetTableSysId, pdfName)
It takes four inputs. The HTML string you want to convert, the table name of the record, the sys_id of the record, and the file name. It converts the HTML into a PDF and attaches it to that record on its own. You do not write any attachment code yourself, which is honestly the best part.
Other useful methods in the same class:
convertToPDFWithHeaderFooter() takes two extra parameters where you can pass a header image attachment, page size, margins, page numbers, and footer text.
fillDocumentFields() and fillDocumentFieldsAndFlatten() let you fill fields inside an existing PDF form, which is a different use case but good to know it exists.
A few facts that will save you time:
The default page size is A4, which is 595 by 842 points. Keep this in mind when you design wide tables.
Do not add ".pdf" in the file name parameter. The API adds the extension itself. If you pass "MyFile.pdf" you will get "MyFile.pdf.pdf". Yes, I did this during testing.
The HTML you pass is rendered by the platform's PDF engine, not by a browser. Simple HTML and inline CSS work very well. Complex modern CSS like flexbox may not behave the way you expect, so stick to tables and inline styles.
Step 1: A Basic UI Action That Generates a PDF
Let us start with the smallest possible version. One button, one line of content, one PDF. Once this works, everything else is just improving the HTML.
Navigate to System Definition > UI Actions and click New. Fill it like this:
Name: Generate PDF
Table: Incident [incident]
Action name: generate_incident_pdf
Active: checked
Show insert: unchecked
Show update: checked
Form button: checked
Why uncheck Show insert? Because a record that is not saved yet has no data worth printing, and it does not even have a sys_id for the attachment. This is also why we add a condition. Put this in the Condition field:
!current.isNewRecord()
Now paste this in the Script field:
var pdfAPI = new sn_pdfgeneratorutils.PDFGenerationAPI();
var html = "<h2>Incident Details</h2>";
html += "<p><b>Number:</b> " + current.number + "</p>";
html += "<p><b>Short Description:</b> " + current.short_description + "</p>";
var fileName = current.number + "_Incident";
var result = pdfAPI.convertToPDF(html, current.getTableName(), current.getUniqueValue(), fileName);
if (result)
gs.addInfoMessage("PDF generated and attached successfully.");
else
gs.addErrorMessage("PDF generation failed.");
action.setRedirectURL(current);
Lets save this and open incident form, first we can see UI action is showing at right top. Now lets click and check either pdf generated or not:
After clicking this UI Action, pdf will be generated as:
Now we’ll click download button for checking the preview:
Step 2: Adding CSS
The PDF looks exactly like the HTML we build. So if we want a professional document, we style the HTML. The safest way with this API is inline CSS, meaning the style attribute directly on each tag.
Let us build the record fields as a proper two column layout, the same way fields appear on the actual form. Left side label, right side value, one light line under each value. Replace the html building part of the script with this:
A quick word on the dv() helper, because this is a best practice point. Reference fields, choice fields, and date fields store raw values in the database. A caller field stores a sys_id, a state field stores a number like 2. If you print current.caller_id directly, your PDF will show a 32 character sys_id instead of a name. getDisplayValue() gives you the human readable version, and the helper also protects us from empty fields so the script never breaks on a blank record.
Generate the PDF again. Now it actually looks like the form. Header bar on top, fields in two clean columns, labels in grey, values underlined.
function dv(field) {
if (current[field] && current[field].getDisplayValue)
return current[field].getDisplayValue();
return "";
}
function formRow(label1, value1, label2, value2) {
return "<tr>" +
"<td style='padding:6px 8px; width:15%; color:#555; font-size:12px; text-align:right;'><b>" + label1 + "</b></td>" +
"<td style='padding:6px 8px; width:35%; border-bottom:1px solid #ccc; font-size:12px;'>" + (value1 || " ") + "</td>" +
"<td style='padding:6px 8px; width:15%; color:#555; font-size:12px; text-align:right;'><b>" + label2 + "</b></td>" +
"<td style='padding:6px 8px; width:35%; border-bottom:1px solid #ccc; font-size:12px;'>" + (value2 || " ") + "</td>" +
"</tr>";
}
function fullRow(label, value) {
return "<tr>" +
"<td style='padding:6px 8px; width:15%; color:#555; font-size:12px; text-align:right; vertical-align:top;'><b>" + label + "</b></td>" +
"<td colspan='3' style='padding:6px 8px; border-bottom:1px solid #ccc; font-size:12px;'>" + (value || " ") + "</td>" +
"</tr>";
}
var pdfAPI = new sn_pdfgeneratorutils.PDFGenerationAPI();
var html = "";
html += "<div style='background:#2e3a4d; color:#fff; padding:10px 15px; font-size:16px;'><b>Incident - " + current.number + "</b></div>";
html += "<br>";
html += "<table style='width:100%; border-collapse:collapse;'>";
html += formRow("Number", current.number, "Channel", dv("contact_type"));
html += formRow("Caller", dv("caller_id"), "State", dv("state"));
html += formRow("Category", dv("category"), "Impact", dv("impact"));
html += formRow("Subcategory", dv("subcategory"), "Urgency", dv("urgency"));
html += formRow("Service", dv("business_service"), "Priority", dv("priority"));
html += formRow("Service Offering", dv("service_offering"), "Assignment Group", dv("assignment_group"));
html += formRow("Configuration Item", dv("cmdb_ci"), "Assigned To", dv("assigned_to"));
html += fullRow("Short Description", current.short_description);
html += fullRow("Description", current.description);
html += "</table>";
var fileName = current.number + "_Incident";
var result = pdfAPI.convertToPDF(
html,
current.getTableName(),
current.getUniqueValue(),
fileName
);
if (result)
gs.addInfoMessage("PDF generated and attached successfully.");
else
gs.addErrorMessage("PDF generation failed.");
action.setRedirectURL(current);
Now lets move to any incident and test the functionality:
by clicking the UI Action:
So now you can see the result this time:
Step 3: Adding Related Lists
A record is rarely just its own fields. On the incident form we also see related lists at the bottom, like Task SLAs. A PDF that claims to be a copy of the record should include those too.
The approach is straightforward. Query the related table with GlideRecord, loop through the results, and build an HTML table row for each record. Add this to the script after the fields section:
html += "<br>";
html += "<div style='background:#f4f5f7; border:1px solid #ccc; padding:6px 10px; font-size:13px;'><b>Task SLAs</b></div>";
html += "<table style='width:100%; border-collapse:collapse; font-size:11px;'>";
html += "<tr style='background:#e8eaed;'>";
html += "<th style='border:1px solid #ccc; padding:5px;'>SLA Definition</th>";
html += "<th style='border:1px solid #ccc; padding:5px;'>Stage</th>";
html += "<th style='border:1px solid #ccc; padding:5px;'>Business Time Left</th>";
html += "<th style='border:1px solid #ccc; padding:5px;'>Business Elapsed Time</th>";
html += "<th style='border:1px solid #ccc; padding:5px;'>Elapsed %</th>";
html += "<th style='border:1px solid #ccc; padding:5px;'>Start Time</th>";
html += "<th style='border:1px solid #ccc; padding:5px;'>Stop Time</th>";
html += "</tr>";
var sla = new GlideRecord("task_sla");
sla.addQuery("task", current.sys_id);
sla.query();
var found = false;
while (sla.next()) {
found = true;
html += "<tr>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.sla.getDisplayValue() + "</td>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.stage.getDisplayValue() + "</td>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.business_time_left.getDisplayValue() + "</td>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.business_duration.getDisplayValue() + "</td>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.business_percentage + "%</td>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.start_time.getDisplayValue() + "</td>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + (sla.end_time.getDisplayValue() || "(empty)") + "</td>";
html += "</tr>";
}
if (!found) {
html += "<tr><td colspan='7' style='border:1px solid #ccc; padding:5px;'><b>No Task SLAs found</b></td></tr>";
}
html += "</table>";
Notice three small things in this block, all of them best practices:
First, the query is sla.addQuery("task", current.sys_id). The task_sla table points to its parent through the task field, so this pulls only the SLAs of the current record. Every related list works the same way, you just need to know which field on the child table points to the parent.
Second, sla.sla.getDisplayValue() is dot walking. The sla field on task_sla is a reference to the SLA definition, and dot walking through it gives us the definition name directly without a second query.
Third, the found flag. If the record has no SLAs, the PDF still shows the section with a clean "No Task SLAs found" row instead of an empty broken table. Always handle the empty case, your users will open this PDF on records you never tested.
The Full Use Case: Incident Form to PDF
Now let me put everything together the way I actually applied it. The requirement in my case was the Incident table. The PDF had to mirror the form itself, the main fields in the form layout, the Resolution Information section, and the Task SLAs related list at the bottom. Here is the complete final script of the UI Action:
function dv(field) {
if (current[field] && current[field].getDisplayValue)
return current[field].getDisplayValue();
return "";
}
function formRow(label1, value1, label2, value2) {
return "<tr>" +
"<td style='padding:6px 8px; width:15%; color:#555; font-size:12px; text-align:right;'><b>" + label1 + "</b></td>" +
"<td style='padding:6px 8px; width:35%; border-bottom:1px solid #ccc; font-size:12px;'>" + (value1 || " ") + "</td>" +
"<td style='padding:6px 8px; width:15%; color:#555; font-size:12px; text-align:right;'><b>" + label2 + "</b></td>" +
"<td style='padding:6px 8px; width:35%; border-bottom:1px solid #ccc; font-size:12px;'>" + (value2 || " ") + "</td>" +
"</tr>";
}
function fullRow(label, value) {
return "<tr>" +
"<td style='padding:6px 8px; width:15%; color:#555; font-size:12px; text-align:right; vertical-align:top;'><b>" + label + "</b></td>" +
"<td colspan='3' style='padding:6px 8px; border-bottom:1px solid #ccc; font-size:12px;'>" + (value || " ") + "</td>" +
"</tr>";
}
var pdfAPI = new sn_pdfgeneratorutils.PDFGenerationAPI();
var html = "";
// Header bar
html += "<div style='background:#2e3a4d; color:#fff; padding:10px 15px; font-size:16px;'><b>Incident - " + current.number + "</b></div>";
html += "<br>";
// Main form fields
html += "<table style='width:100%; border-collapse:collapse;'>";
html += formRow("Number", current.number, "Channel", dv("contact_type"));
html += formRow("Caller", dv("caller_id"), "State", dv("state"));
html += formRow("Category", dv("category"), "Impact", dv("impact"));
html += formRow("Subcategory", dv("subcategory"), "Urgency", dv("urgency"));
html += formRow("Service", dv("business_service"), "Priority", dv("priority"));
html += formRow("Service Offering", dv("service_offering"), "Assignment Group", dv("assignment_group"));
html += formRow("Configuration Item", dv("cmdb_ci"), "Assigned To", dv("assigned_to"));
html += fullRow("Short Description", current.short_description);
html += fullRow("Description", current.description);
html += "</table>";
html += "<br>";
// Resolution Information
html += "<div style='background:#f4f5f7; border:1px solid #ccc; padding:6px 10px; font-size:13px;'><b>Resolution Information</b></div>";
html += "<table style='width:100%; border-collapse:collapse;'>";
html += formRow("Resolution Code", dv("close_code"), "Resolved By", dv("resolved_by"));
html += formRow("", "", "Resolved", current.resolved_at);
html += fullRow("Resolution Notes", current.close_notes);
html += "</table>";
html += "<br>";
// Task SLAs related list
html += "<div style='background:#f4f5f7; border:1px solid #ccc; padding:6px 10px; font-size:13px;'><b>Task SLAs</b></div>";
html += "<table style='width:100%; border-collapse:collapse; font-size:11px;'>";
html += "<tr style='background:#e8eaed;'>";
html += "<th style='border:1px solid #ccc; padding:5px;'>SLA Definition</th>";
html += "<th style='border:1px solid #ccc; padding:5px;'>Type</th>";
html += "<th style='border:1px solid #ccc; padding:5px;'>Target</th>";
html += "<th style='border:1px solid #ccc; padding:5px;'>Stage</th>";
html += "<th style='border:1px solid #ccc; padding:5px;'>Business Time Left</th>";
html += "<th style='border:1px solid #ccc; padding:5px;'>Business Elapsed Time</th>";
html += "<th style='border:1px solid #ccc; padding:5px;'>Elapsed %</th>";
html += "<th style='border:1px solid #ccc; padding:5px;'>Start Time</th>";
html += "<th style='border:1px solid #ccc; padding:5px;'>Stop Time</th>";
html += "</tr>";
var sla = new GlideRecord("task_sla");
sla.addQuery("task", current.sys_id);
sla.query();
var found = false;
while (sla.next()) {
found = true;
html += "<tr>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.sla.getDisplayValue() + "</td>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.sla.type.getDisplayValue() + "</td>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.sla.target.getDisplayValue() + "</td>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.stage.getDisplayValue() + "</td>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.business_time_left.getDisplayValue() + "</td>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.business_duration.getDisplayValue() + "</td>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.business_percentage + "%</td>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.start_time.getDisplayValue() + "</td>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + (sla.end_time.getDisplayValue() || "(empty)") + "</td>";
html += "</tr>";
}
if (!found) {
html += "<tr><td colspan='9' style='border:1px solid #ccc; padding:5px;'><b>No Task SLAs found</b></td></tr>";
}
html += "</table>";
// Convert and attach
var fileName = current.number + "_Incident";
var result = pdfAPI.convertToPDF(
html,
current.getTableName(),
current.getUniqueValue(),
fileName
);
if (result)
gs.addInfoMessage("PDF generated and attached successfully.");
else
gs.addErrorMessage("PDF generation failed.");
action.setRedirectURL(current);
Click the button on an incident and the attached PDF opens with the dark header bar, all the fields laid out in the two column form style, the resolution section, and the SLA grid at the bottom. One click, one clean document.
Its result is:
Before wrapping up, we can see there is an issue that is when we click on UI Action, it will always generate pdf and we’re not requiring multiple pdfs.
For this purpose, there must be functionality defined in our script that should remove the previous one and add only new one. So, add this part into UI Action Script:
var fileName = current.number + "_Incident";
var oldPdf = new GlideRecord("sys_attachment");
oldPdf.addQuery("table_name", current.getTableName());
oldPdf.addQuery("table_sys_id", current.getUniqueValue());
oldPdf.addQuery("file_name", fileName + ".pdf");
oldPdf.query();
while (oldPdf.next()) {
oldPdf.deleteRecord();
}
And save the record, open incident record and click on UI Action:
Its preview belike:
Wrapping Up
We started with a plain requirement, generate a PDF of a record. We discovered that ServiceNow already ships the answer in the form of PDFGenerationAPI, no plugin activation, no third party tools. We built the smallest working button first, then added CSS for a form style layout, then pulled a related list into the document, and finally applied the complete solution on the Incident table.
And because the script uses getTableName() and getUniqueValue() instead of hardcoded values, moving this to Change Request, HR Case, or any custom table is a matter of changing the UI Action table and swapping the field names.
If you try this out and something behaves differently on your instance, tell me in the comments. I read all of them.
- 116 Views
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.