Published on October 22, 2023.
In a recent blog post, I mentioned that a common request that I get from my NetSuite clients is to customize the Global Search feature, and I shared a Suitelet that often serves as a good alternative.
Another common request that I get from my clients is to generate custom PDFs for transactions (for things like estimates, sales orders, and purchase orders) based on their specific, and often quite complex, business rules.
When I first started working with NetSuite, one of the first projects that I worked on involved customizing an Advanced PDF/HTML Template for sales orders. It was my first time working with FreeMarker, and needless to say, I learned quite a bit.
Since then, requests to customize Advanced PDF/HTML Templates have continued at a rapid pace. In most cases, when a customer's needs are particularly complicated, I programmatically generate the PDFs using SuiteScript. In most cases, those scripts are executed by clicking on buttons that I add to the UI.But I also run into cases where a client prefers that the SuiteScript run when the standard Print icon is clicked, or when they're printing transactions in bulk.
In this post I'm going to share a technique that I use to handle those requests. The technique is made of up two parts. The first part is an Advanced PDF/HTML Template that is used to inject dynamically generated XML context. The second part is a User Event Script that will run when the print button is clicked, which generates the dynamically generated XML context, and injects it into the template.
I think one of the nost surprising things about this technique is the simplicity of the Advanced PDF/HTML Template. There's not much to it.
Here it is.
<?xml version="1.0"?><!DOCTYPE pdf PUBLIC "-//big.faceless.org//report" "report-1.1.dtd"> <pdf> <#assign dynamicXML = record.custpage_xml?interpret /> <@dynamicXML /> </pdf>
The template uses the FreeMarker assign directive to assign the value of an InlineHTML field (custpage_xml) that will be added to the form by the User Event Script.
But it does a little more than that. It also interprets the field value using the FreeMarker "interpret" built-in. So essentially it is taking the XML that was dynamically generated in the script, treating it as if it were another template, processing it, and injecting the results into the template. That's what makes this technique work.
If you're not already familiar with Advanced PDF/HTML Templates, you can learn about them here.
As I mentioned earlier, this technique relies on implementing a User Event Script. Specifically, the script implements the beforeLoad entry point, which normally runs before a record is loaded. In this case, it runs before a PDF is rendered.
In any case, the transaction record that is to be printed is available via the context's newRecord parameter. This provides full access to the record itself, and the ability to use the record's internal ID to do things such as load related data via SuiteQL, saved searches, and so on. (I'll discuss more on that below.)
The script is included at the end of this post. And just like the Advanced PDF/HTML template, I think you'll be surprised at just how simple and straightforward it is.
The beforeLoad function (the entry point) first checks to see if the transaction is being printed. If not, the script essentially aborts, as there's nothing more to do. Otherwise, it calls the generateXML function, and passes it the script's context.
The generateXML function builds the dynamic XML document by putting together components such as the meta tags, stylesheet, macros, and the page body. In this example, one of the macros that I'm using is conditional. It adds a watermark to the PDF if the purchase order's status is "Pending Supervisor Approval." Another macro is used to display information about each page, including the page number and total number of pages in the document. You could enhance the macro to display additional information, such as the date and time that the document was generated, and the user who generated it.
Also notice that at the start of the generateXML function, I'm using the JSON.parse method to convert the SuiteScript record object into a JavaScript object.
var transaction = JSON.parse( JSON.stringify( context.newRecord ) );
I do this so that I can easily refer to attributes of a record. In this case, you'll see that I'm referring to things such as the transaction's ID (transaction.fields.tranid), status (transaction.fields.status), the vendor's address, and so on.
Keep in mind that when using this technique, the XML doesn't have to be completely dynamic. You can combine dynamically generated XML with static XML. This might be a good approach in situations where one developer (who has Advanced PDF skills) works on the template and another developer (with SuiteScript skills) works on the XML that is to be injected into the page.
With that in mind, notice that to create the "itemsTable" portion of the document, I'm using FreeMarker's List Directive to add rows to the table of lineitems. I could also have generated the list using SuiteScript - and could also have used the results of a SuiteQL query to do so. My point here is that you have a lot of options with regards to how you mix and match the capabilities of SuiteScript and FreeMarker.
The generateXML function returns the fully formed, dynamically generated XML document to the beforeLoad function. At that point, the beforeLoad function takes the document, and passes it to a function named cleanseXML. The cleanseXML function resolves potential issues with the XML and helps to ensure that it meets the XML specification, so that when XML is used to render the PDF document, it doesn't fail. For example, if your PDF includes an address that is being sourced directly from a transaction record, then that field might include ampersands and HTML tags (such as non-breaking space tags). Some of the fields might also be nulls. Again, this function helps to clean those sorts of things up, so that the XML document is "well-formed."
The beforeLoad function's final step is to use the N/ui/serverWidget Module to add the dynamically generated XML to the context's form. It first adds an InlineHTML field (custpage_xml) to the form, and then sets the field's default value to the dynamically generated XML string.
At this point, NetSuite handles the print request as it normally would. It takes the Advanced PDF/HTML Template and processes it. FreeMarke's assign directive assigns the InlineHTML field value to a FreeMarker value, and then interprets / executes it (via the interpret "built-in" function). The end result is a dynamically generated PDF document.
Let's talk about some of the advantages of this technique.
I think its biggest advantage is that it gives you a very high level of control over the generation of transaction PDFs. You can change the appearance and/or content of a document based on very specific business rules.
For example, you might generate documents whose format and content are based on a transaction's status, the related entity (customer / vendor), the amount of the transaction, the currency being used, and so on. And, in cases where you have multiple subsidiaries, you can generate documents based on a subsidiary's unique business rules and requirements.
Another advantage of this technique is that you can generate PDFs that are based on a combination of data from various sources, including the results of SuiteQL queries and saved searches, as well as other records that have been loaded. The big advantage of using SuiteQL is its ability to provide access to related data at very deep levels.
You can also use data obtained from external Web services, and use files (data, content, images, etc) that are stored in the File Cabinet. For example, you could include the scanned signatures of a purchase order's approvers.
The technique also provides you with access to all of FreeMarker's capabilities. You can implement complex layouts that consist of a combination of different page sizes and/or orientations (where some pages are rendered in landscape mode and others are rendered in portrait mode), add watermarks, and password protect a rendered document (so that passwords are required to view and/or edit a document. You can even embed forms into the rendered document. For example, I have a client whose rendered purchase order includes a form that the vendor can use to confirm that they're received and accepted a purchase order. (To learn how to embed forms into your PDFs, search for "Interactive Forms Support" in the BFO manual.)
Additionally, because you have the full power and flexibility that SuiteScript provides, you can do all sorts of things when a transaction is rendered. For example, you can automatically save a copy of the PDF to the file cabinet, send a notification to a vendor or customer (via email, an API call, etc), and more.
And finally, it's important to note that this technique works when printing transactions in bulk.
The code referenced in this post is available free of charge under an MIT license.
You can download the files here: https://tdietrich-opensource.s3.amazonaws.com/suitescripts/po-print.userevent-20231022.zip
In this post, the example that I used is designed to generate PDFs for purchase orders. You can use the same technique to generate PDFs for other transaction types, including sales orders, estimates, and so on.
As I mentioned earlier, I find that requests for advanced PDFs are quite common. If you're also getting this type of request, I hope that the technique I've shared in this post will help you address them.
/** * @NApiVersion 2.1 * @NScriptType UserEventScript * @NModuleScope Public */ /* ---------------------------------------------------------------------------------------------------- Script Information ---------------------------------------------------------------------------------------------------- Name: PO Printer ID: _po_printer Description Programmatically generates a PDF document when the native Print button is clicked. Applies To • Purchase Order ------------------------------------------------------------------------------------------ MIT License ------------------------------------------------------------------------------------------ Copyright (c) 2023 Timothy Dietrich. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ---------------------------------------------------------------------------------------------------- Developer(s) ---------------------------------------------------------------------------------------------------- Tim Dietrich • timdietrich@me.com • https://timdietrich.me ---------------------------------------------------------------------------------------------------- History ---------------------------------------------------------------------------------------------------- 20231022 - Tim Dietrich • Initial version. */ var log, serverWidget; define( [ 'N/log', 'N/ui/serverWidget' ], main ); function main( logModule, serverWidgetModule ) { log = logModule; serverWidget = serverWidgetModule; return { beforeLoad: beforeLoad } } function beforeLoad( context ) { if ( context.type != context.UserEventType.PRINT ) { return; } var xml = generateXML( context ); xml = cleanseXML( xml ); var field = context.form.addField( { id : 'custpage_xml', label: 'XML', type : serverWidget.FieldType.INLINEHTML } ); field.defaultValue = xml; } function generateXML( context ) { var transaction = JSON.parse( JSON.stringify( context.newRecord ) ); var metatags = ` <meta name="title" value="Purchase Order ${transaction.fields.tranid}" /> <meta name="author" value="Ironforge Software" /> <meta name="subject" value="Purchase Order ${transaction.fields.tranid}" /> <meta name="keywords" value="purchase order, ${transaction.fields.tranid}" /> <meta name="creator" value="NetSuite" /> `; var stylesheet = ` <style type="text/css"> body { font-size: 12pt; font-family: sans-serif; } h1 { font-family: sans-serif; color: #000; } tr.columnHeaders { background: #cccccc; color: #000000; } </style> `; if ( transaction.fields.status == "Pending Supervisor Approval" ) { $watermark = ` <macro id="watermark"> <p rotate="-25" valign="middle" align="center" style="font-size: 48pt; color: red;"> ${transaction.fields.status} </p> </macro> `; } else { $watermark = ''; } var macrolist = ` <macrolist> <macro id="footer"> <p align="center" style="font-size: 9pt; margin-top: 24px; font-weight: bold;"> - Page <pagenumber/> of <totalpages/> - </p> </macro> ${\} </macrolist> `; var headerTable = ` <table style="width: 100%;"> <tr> <td width="50%" align="left" valign="middle"> <h1>Purchase Order </h1> </td> <td width="50%" align="right" valign="middle"> <img src="http://tstdrv2533109.shop.netsuite.com/core/media/media.nl?id=369793&c=TSTDRV2533109&h=mk4zbSgw9VQ-n6Prk0F0bAIPFUtPgtMl14xHRkHwXiRiRFgG" style="float: left; margin: 7px; width: 114px; height: 30px;" /><br /> </td> </tr> </table> <table style="width: 100%; margin-top: 18px;" cellpadding="3"> <tr> <td valign="top"> <span style="font-weight: bold;">Vendor:</span><br /> ${record.billaddress} </td> <td valign="top"> <span style="font-weight: bold;">Ordered By:</span><br /> Ironforge Software, LLC<br /> 1000 William Hilton Pkwy<br /> Hilton Head Island, SC 29928<br /> United States<br /> </td> </tr> </table> <table style="width: 100%; margin-top: 18px; line-height: 150%;" cellpadding="3"> <thead> <tr class="columnHeaders"> <th align="center">PO Number</th> <th align="center">Status</th> <th align="center">Date Ordered</th> </tr> </thead> <tbody> <tr> <td align="center">${transaction.fields.tranid}</td> <td align="center">${transaction.fields.status}</td> <td align="center">${transaction.fields.trandate}</td> </tr> </tbody> </table> `; var itemsTable = ` <table style="width: 100%; margin-top: 18px; line-height: 150%;" cellpadding="3"> <thead> <tr class="columnHeaders"> <th align="center">Quantity</th> <th>Item</th> <th align="right">Unit Price</th> <th align="right">Ext. Price</th> </tr> </thead> <tbody> <#list record.item as item> <tr> <td align="center">${item.quantity}</td> <td>${item.item}</td> <td align="right">${item.rate}</td> <td align="right">${item.amount}</td> </tr> </#list> <tr> <td colspan="4"> <hr style="width: 100%;" /> </td> </tr> <tr> <td colspan="3" align="right" style="font-weight: bold;"> Order Total: </td> <td align="right" style="font-weight: bold;"> ${record.total} </td> </tr> </tbody> </table> `; return ` <head> ${metatags} ${stylesheet} ${macrolist} </head> <body header="header" header-height="12%" footer="footer" footer-height="6%" background-macro="watermark" padding="0.5in 0.5in 0.5in 0.5in" size="Letter"> ${headerTable} ${itemsTable} </body> `; } function cleanseXML( xml ) { // For the XML spec, see: https://www.w3.org/TR/xml/ // Resolve issues with ampersands and non-breaking spaces in field values. xml = xml.replace( /&/g, 'XXXAMPXXX'); xml = xml.replace( / /g, 'XXXNBSPXXX'); xml = xml.replace( /&/g, "&"); xml = xml.replace( /XXXAMPXXX/g, '&'); xml = xml.replace( /XXXNBSPXXX/g, ' '); // Resolve issues where a referenced value does not exist. xml = xml.replace( /null/g, ''); xml = xml.replace( /undefined/g, ''); return xml; }
Hello, I'm Tim Dietrich. I develop custom software for businesses that are running on NetSuite, including mobile apps, Web portals, Web APIs, and more.
I'm the developer of several popular NetSuite open source solutions, including the SuiteQL Query Tool, SuiteAPI, and more.
I founded SuiteStep, a NetSuite development studio, to provide custom software and AI solutions - and continue pushing the boundaries of what's possible on the NetSuite platform.
Copyright © 2025 Tim Dietrich.