In this page
Filtering the issues passed to the PDF template
Sorting the issues and metadata passed to the PDF template
Sorting issues
Using the standard $sorter tool
Using a custom issue sorter tool
Sorting comments as "newest first"
Using the standard $sorter tool
Using a custom comment sorter tool
Sorting data (in general)
Using the standard $sorter tool
Sorting issues
Sorting comments
Sorting built-in worklogs
Sorting Tempo Timesheets worklogs
Sorting sub-tasks
Using a custom sorter tool
Formatting data
Formatting numbers
Formatting dates
Generating hyperlinks for issues
Math
Basic math operations
Complex math
Sub-tasks
Exporting sub-tasks
Exporting sub-tasks exactly the same way as top-level issues
Exporting sub-task custom fields
Exporting parent issues of sub-tasks
Searching for issues
Searching with JQL queries
Searching with saved filters
Connecting to REST APIs
Connecting to the Jira REST API
Connecting to external REST APIs
More on REST authentication
Graphics
Exporting project avatar images
Exporting user avatar images
Exporting issue type icon images
Exporting priority icon images
Dynamic graphics
SVG graphics
Rotating text
Drawing lines
Groovy graphics
Auto-selecting templates
Auto-selecting templates by project
Auto-selecting templates by issue type
Other tips & tricks
Embedding issue attachments in the exported PDFs
Alternating row colors (zebra stripes)
Customizing column widths in the "issue-navigator-fo.vm" template
Adding a cover page
Sanitizing HTML and plain text
Exporting selected custom fields only
Further reads
Unit testing
Debugging
Logging
Troubleshooting

Filtering the issues passed to the PDF template

JQL, the query language used by Jira, is extremely flexible and allows implementing complex searches. You should just set up a saved filter in Jira, run that and export the result set.

In those cases when JQL filtering is not sufficient or you really need to filter the issues once again, you can do that in the template.

How? Each template contains a main loop that iterates over the issues like this:

#foreach($issue in $issues)
	## ... export body code omitted
#end

You should rewrite that in the following way to evaluate an extra condition and to export only if that condition evaluates to true:

#foreach($issue in $issues)
	## only export the issues in the 'FOO' project and ignore others
	#if($issue.key.contains("FOO"))
		## ... export body code omitted
	#end
#end

Sorting the issues and metadata passed to the PDF template

This section is dedicated to sorting the most important entities in the Jira data model. For sorting in general, also see the sorting data section.

Sorting issues

In most of the cases, you can flexibly sort your issues in JQL using the ORDER BY clause. Afterwards, if you just iterate over the $issues collection, it will access the issues in the order produced by the JQL.

In those cases when JQL sorting is not sufficient or you really need to sort the issues using custom logic, you can:

  • Sort using the standard $sorter tool (by VelocityTools).
  • Sort using a custom issue sorter tool (written in Groovy).
Using the standard $sorter tool

You can sort the issues with the standard $sorter tool.

Instead of just iterating over $issues:

#foreach($issue in $issues)

...sort the collection, then iterate over the sorted collection:

#foreach($issue in $sorter.sort($issues, "key:asc"))

This example sorts the issues by issue key ascending. See more examples in the sorting data section.

Using a custom issue sorter tool

You can sort the issues by writing a custom sorter tool in Groovy (easier than it may sound!):

  1. Create a sorter class in Groovy that implements your ordering and save it as issue-sorter-tool.groovy:
    issueSorter = new IssueSorterTool()
    
    public class IssueSorterTool {
    	public sort(issues) {
    		return issues.sort { a, b -> a.summary <=> b.summary } // sort by summary
    	}
    }
    
  2. Execute it in your template:
    $scripting.execute('issue-sorter-tool.groovy')
  3. Pass the incoming collection $issues (a Velocity context parameter) to the Groovy code and iterate over the re-sorted collection like this:
    #set($sortedIssues = $issueSorter.sort($issues))
    #foreach($issue in $sortedIssues)
    	## ... export body code omitted
    #end
    

You can implement any kind of flexible sorting logic based on this example.

Sorting comments as "newest first"

Issue comments are exported in "newest last" order by default.

To sort the comments in "newest first" order (the reverse order), you can:

  • Sort using the standard $sorter tool (by VelocityTools).
  • Sort using a custom comment sorter tool (written in Groovy).
Using the standard $sorter tool

You can sort the comments with the standard $sorter tool.

Instead of just getting the comments:

#set($comments = $pdfContent.commentsByIssue($issue, $exportServiceDeskInternalComments))

...get the comments, then sort the collection:

#set($comments = $sorter.sort($pdfContent.commentsByIssue($issue, $exportServiceDeskInternalComments), "created:desc"))

This example sorts the comments as "newest first". See more examples in the sorting data section.

Using a custom comment sorter tool

You can sort the comments by writing a custom sorter tool in Groovy (easier than it may sound!):

  1. Write a short sorter class in Groovy that implements your ordering and save it as comment-sorter-tool.groovy:
    commentSorter = new CommentSorterTool()
    
    public class CommentSorterTool {
    	public descending(comments) {
    		return comments.sort { a, b -> b.created <=> a.created } // sort in reverse order
    	}
    }
    
  2. Execute it in your template:
    $scripting.execute('comment-sorter-tool.groovy')
  3. Change the line:
    #set($comments = $pdfContent.commentsByIssue($issue, $exportServiceDeskInternalComments))
    to:
    #set($comments = $commentSorter.descending($pdfContent.commentsByIssue($issue, $exportServiceDeskInternalComments)))

You can implement any kind of flexible sorting logic based on this example.

Sorting data (in general)

There are multiple ways to sort collections. Choose the best way based on "where" you want to sort (in PDF templates or in Groovy scripts?) and how complex the sorting criteria is.

Using the standard $sorter tool

Velocity provides a universal tool intuitively called SortTool to sort collections of issues, comments, worklogs and other type of data. It is accessible by the name $sorter in templates.

Sorting issues

Use the $sorter tool with the properties of the Issue class.

Examples:

## sort by a property
$sorter.sort($issues, "name")

## sort by a nested property
$sorter.sort($issues, "creator.displayName")

## sort in descending order (ascending is the default)
$sorter.sort($issues, "startDate:desc")

## sort by multiple properties
$sorter.sort($issues, ["startDate", "summary"])

## all these combined
$sorter.sort($issues, ["creator.displayName:asc", "updated:desc"])
Sorting comments

Use the $sorter tool with the properties of the Comment class.

Examples:

## by creation date, oldest first
$sorter.sort($comments, "created:asc")

## by creation date, newest first
$sorter.sort($comments, "created:desc")

## by update date, newest first
$sorter.sort($comments, "updated:desc")

To apply this to the issue-fo.vm template, look for this line:

#set($comments = $pdfContent.commentsByIssue($issue, $exportServiceDeskInternalComments))

...and replace it with something like:

#set($comments = $sorter.sort($pdfContent.commentsByIssue($issue, $exportServiceDeskInternalComments), "created:desc"))
Sorting built-in worklogs

Use the $sorter tool with the properties of the Worklog class.

Examples:

## by start date, oldest first
$sorter.sort($worklogs, "startDate")

## by author name, then by start date per issue
$sorter.sort($worklogs, ["authorObject.displayName", "startDate"])

To apply this to the issue-fo.vm and timesheet-fo.vm templates, look for this line to modify sorting:

#set($worklogs = $worklogManager.getByIssue($issue))

...and replace it with something like:

#set($worklogs = $sorter.sort($worklogManager.getByIssue($issue), "startDate:asc"))
Sorting Tempo Timesheets worklogs

Use the $sorter tool with the properties defined in tempo-tool.groovy. You can even extend those if you need more!

Examples:

## by work date, oldest first
$sorter.sort($worklogs, "startDate")

## by worker name, then by date per worker
$sorter.sort($worklogs, ["author.displayName", "startDate"])

To apply this to the issue-fo.vm and timesheet-fo.vm templates, look for this line to modify sorting:

#set($worklogs = $tempo.getWorklogs($issue.key))

...and replace it with something like:

#set($worklogs = $sorter.sort($tempo.getWorklogs($issue.key), "author.displayName"))
Sorting sub-tasks

Use the $sorter tool with the properties of the Issue class. Note that you will work with sub-task objects here, but those are instances of the Issue class, too.

Examples:

## by the reporter's name
$sorter.sort($issue.subTaskObjects, "creator.displayName")

## by issue key
$sorter.sort($issue.subTaskObjects, "key")

## by issue summary
$sorter.sort($issue.subTaskObjects, "summary")

To apply this to the issue-fo.vm template, look for this line to modify sorting:

#foreach($subTask in $issue.subTaskObjects)

...and replace it with something like:

#foreach($subTask in $sorter.sort($issue.subTaskObjects, "summary:asc"))

Using a custom sorter tool

The idea is simple: you can use Groovy to solve any types of problems, sorting also included. Just implement the sorting logic in Groovy, execute the script, pass the collection to the sorter, return the sorted collection, and use that in your PDF template!

See this section for a full working example.

Formatting data

Formatting numbers

There is a tool called $number you can use in the template code:

$number.format('###,###.00', $unitPrice)

The first argument is the format string, the second argument is the numerical value.

Formatting dates

There are multiple tools to format date, time and date-time values, each with its own merits:

  1. $userDateTimeFormatter is the new standard way to format dates in Jira. This should be your default choice.
  2. $date should be used when you need to format dates with full control. It supports using the standard Java date format patterns, as seen in the code below.

These code snippets show formatting $currentDate with all the methods:

<fo:block>
	Using DateTimeFormatter (user timezone, date and time):
	$userDateTimeFormatter.withStyle($dateTimeStyle.COMPLETE).format($currentDate)
	## output: 16/Sep/14 9:15 AM
</fo:block>
<fo:block>
	Using DateTimeFormatter (user timezone, date only):
	$userDateTimeFormatter.withStyle($dateTimeStyle.DATE).format($currentDate)
	## output: 16/Sep/14
</fo:block>
<fo:block>
	Using DateTimeFormatter (default timezone, date only):
	$userDateTimeFormatter.withDefaultZone().withStyle($dateTimeStyle.DATE).format($currentDate)
	## output: 16/Sep/14
</fo:block>
<fo:block>
	Using DateTimeFormatter (user timezone, RFC822 format):
	$userDateTimeFormatter.withStyle($dateTimeStyle.RSS_RFC822_DATE_TIME).format($currentDate)
	## output: Tue, 16 Sep 2014 09:15:26 +0200
</fo:block>
<fo:block>
	Using DateTool:
	$date.format("yyyy-'W'ww-EEE", $currentDate)
	## output: 2014-W38-Tue
</fo:block>

Although the most typical examples are the issue key and summary fields, you can generate hyperlinks for any piece of information using the example below.

This allows users intuitively click issue keys or summaries to jump to the Jira page of the corresponding issue.

<fo:basic-link color="#036" text-decoration="underline" external-destination="url('${requestContext.baseUrl}/browse/$xmlutils.escape($issue.key)')">$xmlutils.escape($issue.key)</fo:basic-link>

Math

Basic math operations

A Velocity tool called $math is available for the templates. You can use it for basic mathematical operations, like adding the value of two number type custom fields.

Example:

#set($qty = $issue.getCustomFieldValue("customfield_10000"))
#set($unitPrice = $issue.getCustomFieldValue("customfield_10001"))

#set($linePrice = $math.mul($qty, $unitPrice))
#set($totalPrice = $math.add($totalPrice, $linePrice))

Complex math

If you need more than this basic math, you will write Groovy scripts. See the scripting tutorial first, then continue learning about Groovy operators.

Sub-tasks

An issue can return its sub-tasks using the following getter:

Collection<Issue> getSubTaskObjects()

Exporting sub-tasks

If you want to iterate over sub-tasks and export those, the iteration looks like this:

## assuming that you have the parent issue available in $issue
#foreach($subTask in $issue.subTaskObjects)
	## ... export the $subTask object here the same way as a top-level issue
#end

Exporting sub-tasks exactly the same way as top-level issues

If you want to export sub-tasks in the same way (same fields, same formatting, etc.) as the top-level issues that were passed to the export, you can use a simple trick: prepare a list by merging the top-level issues and their sub-tasks, and then iterate over the merged list!

If you are working with issue-fo.vm template, you only have to set this configuration variable in the top to true:

#set($exportSubTasks = true)

For other templates, follow these steps:

  1. Find the <fo:root ...> tag in the template.
  2. Add the following code before that tag:
    #if($issues && !$issues.empty)
    	## merge top-level issues and sub-tasks
    	#set($allIssues = [])
    	#foreach($issue in $issues)
    		## assign the return value to a dummy variable to hide that in the output
    		#set($dummmy = $allIssues.add($issue))
    		#foreach($subTask in $issue.subTaskObjects)
    			#set($dummmy = $allIssues.add($subTask))
    		#end
    	#end
    	## replace original input data
    	#set($issues = '')
    	#set($issues = $allIssues)
    #end
    

Tip: make sure to filter out sub-tasks from the input by using a JQL like this, otherwise those will appear twice in the export:

project = FOO AND type != Sub-task

(You can, alternatively, filter out sub-tasks while creating the merged list, but doing that with JQL is simpler.)

Exporting sub-task custom fields

  1. Modify the declaration of the #cfValue() macro in issue-fo.vm to also accept sub-task objects. Just replace this line:
    ## renders custom field values
    #macro(cfValue $customField)
    
    ...with:
    ## renders custom field values
    #macro(cfValue $customField)
    	#cfValueForIssue($customField $issue)
    #end
    
    ## renders custom field values for the passed issue
    #macro(cfValueForIssue $customField $issue)
    
  2. You can then export custom field values from sub-tasks like:
    #foreach($subTask in $issue.subTaskObjects)
    	## ...
    	<fo:block>
    		#cfValueForIssue($subTask.getCustomField("customfield_10007") $subTask)
    	</fo:block>
    	## ...
    #end
    

Exporting parent issues of sub-tasks

Typically you export sub-tasks while exporting their parents, but sometimes the situation may be reversed. In that case, any field of the parent issue is accessible from a sub-task through its parentObject property.

You can, for example, get the key of the parent issue with this expression:

${subTask.parentObject.key}

Searching for issues

In addition to the issues passed to the template, it is possible to execute further JQL searches and also use those issues in your template.

Searching with JQL queries

Execute a JQL query and iterate through the results:

#set($issuesFound = $jqlSearch.searchByJql("project=FOO ORDER BY summary"))
#foreach($issue in $issuesFound)
	<fo:block>[$xmlutils.escape($issue.key)] $xmlutils.escape($issue.summary)</fo:block>
#end

Searching with saved filters

Execute the saved filter with the ID=13100 and iterate through the results:

#set($issuesFound = $jqlSearch.searchBySavedFilter(13100))
#foreach($issue in $issuesFound)
	<fo:block>[$xmlutils.escape($issue.key)] $xmlutils.escape($issue.summary)</fo:block>
#end

Connecting to REST APIs

It's pretty easy to connect to REST API based services to include information from that data source in your PDF files.

Connecting to the Jira REST API

This example demonstrates connecting to the Jira REST API using BASIC authentication and getting an issue.

  1. Create the script called jira-rest-api-tool.groovy that implements the REST API invocation:
    import groovy.json.JsonSlurper
    import org.apache.commons.io.IOUtils
    
    def user = "admin"
    def password = "admin"
    def urlConnection = new URL("https://mysite.atlassian.net/rest/api/3/issue/DEMO-1").openConnection()
    urlConnection.setRequestProperty("Authorization", "Basic " + (user + ":" + password).bytes.encodeBase64().toString())
    def jsonString = IOUtils.toString(urlConnection.inputStream)
    
    issueFromApi = new JsonSlurper().parseText(jsonString)
    
  2. Execute it in your template:
    $scripting.execute('jira-rest-api-tool.groovy')
  3. Use the issueFromApi object to access the returned issue's fields in the template:
    ${issueFromApi.key}
    ${issueFromApi.fields.summary}
    ${issueFromApi.fields.status.name}
    

Connecting to external REST APIs

This is an example of calling an external REST API without authorization:

  1. Create the script called external-rest-api-tool.groovy that implements the REST API invocation:
    import groovy.json.JsonSlurper
    
    def jsonSlurper = new JsonSlurper()
    dataFromApi = jsonSlurper.parseText(new URL("https://www.foo.com/rest/1/user/123").text)
    
  2. Execute it in your template:
    $scripting.execute('external-rest-api-tool.groovy')
  3. If the REST API returns a user object like this, for instance:
    { "id": 123, "name": "John Doe", "email": "john.doe@example.com" }
    ...then use the $dataFromApi object to access the returned information in the template:
    ${dataFromApi.id}
    ${dataFromApi.name}
    

More on REST authentication

Some thoughts on REST authentication:

  • If you need to pull data from the running Jira instance only, prefer using our helpers and tools over the REST API. That's faster, easier and you completely eliminate the need for authentication.
  • If you are worried about using BASIC authentication, it is basically fine if used over HTTPS. If that's the case in your environment, keep it simple and just use BASIC.
  • You have full control over the user account used for making REST calls. This means, you can set up a dedicated, restricted Jira user for REST. For instance, create a user account named rest-client-account, remove all "write" permissions, only add "read" permissions for certain projects, and then use this account in REST calls.

Graphics

Exporting project avatar images

To export project avatars, construct their URLs:

## assuming that the issue is available in $issue
<fo:block>
	#set($project = $issue.projectObject)
	#set($avatarImageUrl = "${requestContext.baseUrl}/secure/projectavatar?pid=${project.id}&avatarId=${project.avatar.id}")
	<fo:external-graphic content-height="4em" vertical-align="middle" src="url($xmlutils.escape($avatarImageUrl))"/>
	$xmlutils.escape($project.name)
</fo:block>

Exporting user avatar images

To export user avatars, construct their URLs:

## assuming that the issue is available in $issue
<fo:block>
	#if($issue.assignee)
		#set($avatarImageUrl = "${requestContext.baseUrl}/secure/useravatar?avatarId=${avatarService.getAvatar($user, $issue.assignee.name).id}&ownerId=${issue.assignee.name}")
		<fo:external-graphic content-height="2em" vertical-align="middle" src="url($xmlutils.escape($avatarImageUrl))"/>
		$xmlutils.escape($issue.assignee.displayName)
	#else
		$i18n.getText("common.concepts.unassigned")
	#end
</fo:block>

Exporting issue type icon images

To export issue type icons, use the URLs returned by the issues:

## assuming that the issue is available in $issue
<fo:block>
	#if(${issue.issueTypeObject.completeIconUrl})
		#set($iconImageUrl = ${issue.issueTypeObject.completeIconUrl})
	#else
		#set($iconImageUrl = ${issue.issueTypeObject.iconurl})
	#end
	<fo:external-graphic content-height="1.5em" vertical-align="middle" src="url($xmlutils.escape($iconImageUrl))"/>
	$xmlutils.escape($issue.issueTypeObject.nameTranslation)
</fo:block>

Exporting priority icon images

To export priority icons, use the URLs returned by the issues:

## assuming that the issue is available in $issue
<fo:block>
	#if(${issue.priorityObject})
		#if(${issue.priorityObject.completeIconUrl})
			#set($iconImageUrl = ${issue.priorityObject.completeIconUrl})
		#else
			#set($iconImageUrl = ${issue.priorityObject.iconurl})
		#end
		<fo:external-graphic content-height="1.5em" vertical-align="middle" src="url($xmlutils.escape($iconImageUrl))"/>
		$xmlutils.escape($issue.priorityObject.nameTranslation)
	#end
</fo:block>

Dynamic graphics

You can vastly improve the readability and value of your PDF documents with graphical illustrations. For static graphics, simply use images. For dynamic graphics, there are two technology options detailed in the next sections.

SVG graphics

Using embedded SVG is a simple, high-quality and powerful way to add vector graphics to your PDF documents. SVG can be used to rotate text, draw lines, add geometric shapes and so on. See the next sections for a couple of typical use cases, and also study this SVG tutorial for more examples.

Rotating text

When the horizontal space is limited, you may want display texts like the issue summary rotated to vertical position:

## assuming that the issue is available in $issue
<fo:block>
	<fo:instream-foreign-object>
		<svg:svg xmlns:svg="http://www.w3.org/2000/svg" width="11" height="300">
			<svg:text transform="rotate(-90)" style="text-anchor:end;" x="0" y="9" font-size="9">$xmlutils.escape($issue.summary)</svg:text>
		</svg:svg>
	</fo:instream-foreign-object>
</fo:block>
Drawing lines

You can easily draw arbitrary lines or arrows, to enhance your documents. This example use lines to add red strike-through decoration over text:

## assuming that the issue is available in $issue
<fo:block>
	<fo:instream-foreign-object>
		<svg:svg xmlns:svg="http://www.w3.org/2000/svg" width="500" height="11">
			<svg:text style="text-anchor:start;" x="0" y="9" font-size="9">$xmlutils.escape($issue.summary)</svg:text>
			<svg:line x1="0" y1="5%" x2="100%" y2="95%" style="stroke:rgb(255,0,0);"/>
			<svg:line x1="0" y1="95%" x2="100%" y2="5%" style="stroke:rgb(255,0,0);"/>
		</svg:svg>
	</fo:instream-foreign-object>
</fo:block>

Groovy graphics

When you have a graphic-related use case too difficult to implement in SVG, you can use Groovy scripting as an alternative.

The idea:

  1. Create the Groovy class that implements the drawing logic.
  2. The class should provide a method that accepts the arguments that affect the resulted graphic (e.g. pixel dimensions). The implementation of the method should create the image in memory, then serialize the image to PNG (byte array, still in memory). Finally, it should convert the byte array to a data URI, and return that as string.
  3. In the template, execute the script, and use a snippet like this to display the graphic:
    <fo:block>
    	<fo:external-graphic content-height="4cm" src="data:image/png;base64,${gfx.drawFooImage($issue, 400, 300)}"/>
    </fo:block>
    

Note that this is essentially the same technique how the custom charts are inserted to templates. It is the same technique, because these are similar problems in nature.

Auto-selecting templates

Auto-selecting templates by project

Say, you have two templates issue-fo-foo.vm and issue-fo-bar.vm. You want to define a single PDF view "My PDF export" which should render issue-fo-foo.vm in the project "FOO" and issue-fo-bar.vm in all other projects. In other words, the PDF view should intelligently select the template based on the project of the first passed issue.

Steps:

  1. Use the following dispatcher code in the main issue-fo.vm. This reads the project key from the first passed issue and dispatches to different templates based on the project key:
    <?xml version="1.0" encoding="ISO-8859-1"?>
    
    ## dispatch to a template based on the project key
    #set($projectKey = $issues.get(0).project.key)
    #if($projectKey == 'FOO')
    	$include.parse($ctx, "issue-fo-foo.vm")
    #else
    	$include.parse($ctx, "issue-fo-bar.vm")
    #end
  2. Create the two actual templates issue-fo-foo.vm and issue-fo-bar.vm through the Template Manager.
  3. Remove the first line in each of the actual templates. Delete this in both issue-fo-foo.vm and issue-fo-bar.vm:
    <?xml version="1.0" encoding="ISO-8859-1"?>
    If you forget this, then this declaration will be there both in the first line of the dispatcher template (which is fine) and in the first lines of the actual templates (which is not allowed and will raise an XML parsing error).

Using this technique, you can dynamically select templates based on the PDF view, on the number of issues (single or multiple), on field values, or on any other condition that can be evaluated during the rendering.

Auto-selecting templates by issue type

As another example, here is the code that selects the template based on the type of the first issue:

<?xml version="1.0" encoding="ISO-8859-1"?>

## dispatch to a template based on the issue type identifier
#set($issueTypeId = $issues.get(0).issueTypeObject.id)
#if($issueTypeId == '6')
	$include.parse($ctx, "issue-fo-foo.vm")
#else
	$include.parse($ctx, "issue-fo-bar.vm")
#end

Other tips & tricks

Embedding issue attachments in the exported PDFs

See the related tutorial.

Alternating row colors (zebra stripes)

If you want to add alternating row colors to your PDF template, just calculate the style based on the loop counter:

#foreach($issue in $issues)
	## select the style depending on whether the loop counter is odd or even
	#set($coloredRow = "")
	#if($velocityCount % 2 == 0)
		#set($coloredRow = 'background-color="#ffffb3"')
	#end
	<fo:block $coloredRow>$xmlutils.escape($issue.summary)</fo:block>
#end

You can implement more complex coloring logic (e.g. choosing the row color by the priority of the issue) based on this example.

Customizing column widths in the "issue-navigator-fo.vm" template

The issue-navigator-fo.vm renders column widths based on customizable "weights". By default, the long Summary is set to the weight of 3, the super-long Description, Environment, Table Grid custom fields and all text-type custom fields are set to 5, while all other fields are set to 1:

#foreach($columnLayoutItem in $issueTableLayoutBean.columns)
	## select the proportional column width based on the field
	#set($columnWidth = "1")
	#set($fieldId = $columnLayoutItem.navigableField.id)
	#if($fieldId == "summary")
		#set($columnWidth = "3")
	#elseif($fieldId == "description" || $fieldId == "environment")
		#set($columnWidth = "5")
	#elseif($fieldId.contains("customfield_"))
		#set($customFieldTypeKey = $columnLayoutItem.navigableField.customFieldType.key)
		#if($customFieldTypeKey.contains(":text") || $customFieldTypeKey == "com.idalko.jira.plugins.igrid:tableGridCFType")
			## text type custom fields
			#set($columnWidth = "5")
		#end
	#end
	<fo:table-column column-width="proportional-column-width($columnWidth)"/>
#end

You can modify or extend this simple logic, as you wish.

Adding a cover page

If you want to add a cover page with custom content to the exported PDF file, it is easy. We demonstrate the steps on the issue-fo.vm template, but it is applicable to other templates in a similar way.

Steps:

  1. Find the <fo:bookmark-tree> tag in the template.
  2. If you want to have a PDF bookmark to the cover page (it is optional), add this code inside the <fo:bookmark-tree> tag:
    <fo:bookmark internal-destination="_cover">
    	<fo:bookmark-title>Cover</fo:bookmark-title>
    </fo:bookmark>
    
  3. Add this code after the </fo:bookmark-tree> closing tag:
    <fo:page-sequence master-reference="A4">
    	<fo:flow flow-name="page-body">
    		<fo:block id="_cover" text-align="center">
    			Cover content goes here
    		</fo:block>
    	</fo:flow>
    </fo:page-sequence>
    
    It starts a new page sequence that contains only a single page. You can freely customize its geometry (not necessarily A4) and its content.

Sanitizing HTML and plain text

In some situations, you want to programmatically "sanitize" HTML markup or other sort of plain text before passing that to the PDF renderer. It can be because you want to fix the corrupt HTML your users entered to field values (can happen with the JEditor app), you want to remove certain text fragments from the export, or other reasons. You can solve all these with the following straight-forward recipe.

The idea is dead-simple: implement a Groovy tool that accepts an input string, applies a sequence of string operations (typically regex-based replaces) to that, then returns the resulted string.

Steps:

  1. Create the script named sanitizer-tool.groovy:
    sanitizer = new SanitizerTool()
    
    class SanitizerTool {
    	def sanitize(String input) {
    		// Fix for: if an external image was referenced and the target site redirects
    		// HTTP requests to HTTPS, Better PDF Exporter won't follow the redirect
    		// and the image will be missing from the exported PDF.
    		input = input.replaceAll("http://","https://") // forces HTTPS
    
    		// Fix for: if the font-family attribute in HTML is invalid (references multiple
    		// font families or an invalid one), non-latin characters may be replaced with
    		// hash marks in the exported PDF.
    		input = input.replaceAll("font-family:.*?\"", "font-family:auto\"") // forces "auto" font
    
    		// ... add your own sanitizations here!
    
    		return input
    	}
    }
    
  2. To apply this to a template, first execute the script in the top part of the template:
    $scripting.execute("sanitizer-tool.groovy")
  3. Then to sanitize a field value or any other string in the template, you have to "filter" that through the sanitizer tool. For example, for the Description field, replace this:
    $pdfRenderer.asRendered($issue, "description", $issue.description)
    with:
    $pdfRenderer.asRendered($issue, "description", $sanitizer.sanitize($issue.description))
    (As you see, you just have to wrap the expression to $sanitizer.sanitize(...).)
  4. That's it!

Exporting selected custom fields only

If you want to include only a pre-selected set of custom fields in the PDF exports, it is easy. The following steps are explaining the required template modifications on the issue-fo.vm template, but those can be applied to other templates similarly.

Steps:

  1. Find this line in the template:
    #set($excludedCustomFieldIds = []))
  2. After that line, add this empty collection:
    #set($includedCustomFieldIds = [])
    When modifying the behavior in the next steps, you will add the numeric IDs of the allowed custom fields to this collection:
    #set($includedCustomFieldIds = [ 10001, 10004, 10017 ])
  3. To modify the behavior of the "current fields" mode, apply the changes below. Then, only those fields will be exported that are visible in the Jira screen and are contained by $includedCustomFieldIds.
    1. Find this line in the template:
      #foreach($layoutItem in $tab.fieldScreenRenderLayoutItems)
    2. Replace that line with this fragment:
      #if($includedCustomFieldIds.isEmpty())
      	#set($layoutItems = $tab.fieldScreenRenderLayoutItems)
      #else
      	#set($layoutItems = [])
      	#foreach($layoutItem in $tab.fieldScreenRenderLayoutItems)
      		#if($includedCustomFieldIds.contains($layoutItem.orderableField.idAsLong.intValue()))
      			#set($dummy = $layoutItems.add($layoutItem))
      		#end
      	#end
      #end
      
      #foreach($layoutItem in $layoutItems)
      
  4. To modify the behavior of the "all fields" mode, apply the changes below. Then, only those fields will be exported that are contained by $includedCustomFieldIds.
    1. Find this line in the template:
      #set($customFields = $customFieldManager.getCustomFieldObjects($issue))
    2. Replace that line with this fragment:
      #if($includedCustomFieldIds.isEmpty())
      	#set($customFields = $customFieldManager.getCustomFieldObjects($issue))
      #else
      	#set($customFields = [])
      	#foreach($customField in $customFieldManager.getCustomFieldObjects($issue))
      		#if($includedCustomFieldIds.contains($customField.idAsLong.intValue()))
      			#set($dummy = $customFields.add($customField))
      		#end
      	#end
      #end
      

Notes:

  • If $includedCustomFieldIds is an empty list, it means that you effectively disabled the template modification: fields will be exported according to original behavior.
  • Both the $excludedCustomFieldIds and $excludedCustomFieldTypeKeys settings have priority over $includedCustomFieldIds. It means that if you enter a custom field ID to $includedCustomFieldIds, but the same ID is also in $excludedCustomFieldIds or the type key of that custom field is in $excludedCustomFieldTypeKeys, then the custom field will not be exported.

Further reads

Unit testing

Learn more about writing unit tests to increase the quality and reliability of your Groovy scripts.

Debugging

Learn more about debugging your Groovy scripts in the IDE or in Jira.

Logging

Learn more about writing to the Jira log from your Groovy scripts.

Troubleshooting

Learn more about finding the root cause of PDF export problems faster.

Questions?

Ask us any time.