In this page
User interface
Hiding the "Export" menus on Jira Software boards
Creating quick links for frequently used PDF exports
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
Breaking "non-wrappable" text into multiple lines
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
Connecting to databases to run SQL queries
Exporting additional Tempo Timesheets worklog details
Tempo Timesheets billed hours
Tempo Timesheets custom worklog attributes
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
Exporting in a different language without switching locales
Adding a cover page
Sanitizing HTML and plain text
Exporting selected custom fields only
Productivity tips
How to work fast with PDF template customization?
Further reads
Unit testing
Debugging
Logging
Troubleshooting

User interface

Hiding the "Export" menus on Jira Software boards

If you never export directly from the Jira Software boards, you may want to hide the menu drop-down buttons placed there by the app.

To do that, go to AdministrationAnnouncement banner, add the following snippet to the Announcement text area, then hit Save.

<style>
.jxls-agile-board-header .jxls-export-dropdown-trigger,
.jpdf-agile-board-header .jpdf-export-dropdown-trigger {
	display: none;
}
#announcement-banner {
	padding: 0px;
}
</style>

In case there's already some announcement text is set (i.e. you are actually using announcements), then remove this part from the code above:

#announcement-banner {
	padding: 0px;
}

Users often times prefer to have a list of ready-made export links that generate the export with a single click, instead of manually running a saved filter and then exporting the results from the "Export" drop-down menu. Quick links can save lots of time and tedious navigation.

To create a quick link:

  1. Execute the saved filter.
  2. Open the "Export" drop-down, right-click the menu item representing the export type you wanted to use, and copy the link to the clipboard.
  3. Now insert the link from the clipboard into a "Text" type Jira gadget (tutorial), into a Confluence page (tutorial), into a website, CMS or any other tool that allows sharing and categorising URL hyperlinks (bookmarks).

See this example:

<a href="http://localhost:8080/rest/com.midori.jira.plugin.pdfview/1.0/pdf/pdf-view/4/render?tempMax=100000&context=issue_navigator&filterId=10901" target="_blank">B4B Project - Q4 Sales Report(PDF)</a><br>
<a href="http://localhost:8080/rest/com.midori.jira.plugin.pdfview/1.0/pdf/pdf-view/4/render?tempMax=100000&context=issue_navigator&filterId=10900" target="_blank">B4B Project - Open tickets from last week (PDF)</a><br>
<a href="http://localhost:8080/rest/com.midori.jira.plugin.pdfview/1.0/pdf/pdf-view/4/render?tempMax=100000&context=issue_navigator&filterId=10902" target="_blank">WebStore Project - SLA breach report (PDF)</a><br>

Yes, it is really that simple. Please note that these hyperlinks are secure, meaning that even if you post them to some external system, clicking them will direct your browser to Jira, which will require you to properly login if you aren't yet.

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
(since app version 5.9.0)

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
(since app version 5.9.0)

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

Instead of just getting the comments:

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

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

#set($comments = $sorter.sort($pdfContent.commentsByIssue($issue), "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))
    to:
    #set($comments = $commentSorter.descending($pdfContent.commentsByIssue($issue)))

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

(since app version 5.9.0)

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))

...and replace it with something like:

#set($comments = $sorter.sort($pdfContent.commentsByIssue($issue), "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 = $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, "work_date")

## by worker name, then by date per worker
$sorter.sort($worklogs, ["full_name", "work_date"])

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, true))

...and replace it with something like:

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

Use the $sorter tool with the properties of the IssueLink class. Note that you will work with issue links here, because those (not the sub-task objects) provide the properties you want sort by.

Examples:

## by the manual order set by the user
$sorter.sort($subTaskIssueLinks, "sequence")

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

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

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

#foreach($subTaskIssueLink in $sorter.sort($subTaskManager.getSubTaskIssueLinks($issue.id), "sequence"))

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. $dateFormatter is the new standard way to format dates in Jira. This should be your default choice.
    (In app versions prior to 5.9.0, instead of the now available shorthand form $userDateTimeFormatter use the longer, but equivalent form $dateFormatter.forLoggedInUser().)
  2. $outlookdate is the old standard, that is still very useful and easy to use.
  3. $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 OutlookDate:
	$outlookdate.formatDMY($currentDate) $outlookdate.formatTime($currentDate)
	## output: 16/Sep/14 9:15 AM
</fo:block>
<fo:block>
	Using DateTool:
	$date.format("yyyy-'W'ww-EEE", $currentDate)
	## output: 2014-W38-Tue
</fo:block>

Breaking "non-wrappable" text into multiple lines

If you have long text that does not contain any "wrappable" character (whitespace or dash), the text wrapping algorithm will not break it to multiple lines and the overflowing parts will be hidden. This may happen, for instance, when you want to export long product or other sort of artifical codes to narrow cells. You can use the following recipe to properly break those into multiple lines.

The trick is to insert a zero-width space after every character in the text! While these extra spaces are practically invisible, they allow the text wrapping algorithm do its job properly.

For instance, here is the trick implemented for the description field:

#set($descriptionWithSpaces = $issue.description.replaceAll("(.{1})", "$1&#x200b;"))
#set($renderedDescription = $pdfRenderer.asRendered($issue, "description", $descriptionWithSpaces))
<fo:block>$renderedDescription</fo:block>

Important: if you have a string that is rendered before displaying, then insert the space before the rendering, as seen above.

Furthermore, you can generalize this idea for more complicated wrapping needs. For instance, you could allow the algorithm to wrap the original text at every 5th character, by inserting a zero-width space after every 5th character.

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!

Assuming that you want to modify the issue-fo.vm template:

  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

Before you could get custom field values from sub-tasks using the ${subTask.customFieldValue} expression and the #cfValue() macro, you need to convert those to TemplateIssue instances. (This is necessary, because TemplateIssue is the class that provides the getCustomFieldValue() convenience getter.)

We suggest using a one-line Groovy method to do this:

  1. Create the script named sub-task-tool.groovy:
    import com.atlassian.jira.component.ComponentAccessor
    import com.atlassian.jira.mail.TemplateIssueFactory
    
    subTaskTool = new SubTaskTool()
    
    class SubTaskTool {
    	def templateIssueFactory = ComponentAccessor.getComponentOfType(TemplateIssueFactory.class)
    
    	/**
    	 * Returns the passed issues as TemplateIssue instances.
    	 */
    	def asTemplateIssues(issues) {
    		return issues.collect { templateIssueFactory.getTemplateIssue(it) }
    	}
    }
  2. Execute it in your template:
    $scripting.execute("sub-task-tool.groovy")
  3. 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)
    	#cfValue($customField $issue)
    #end
    
    ## renders custom field values for the passed issue
    #macro(cfValue $customField $issue)
    
  4. You can then export custom field values from sub-tasks like:
    • Jira 8 compatible version:
      #foreach($subtask in $subTaskTool.asTemplateIssues($issue.subTaskObjects))
      	## ...
      	<fo:block>
      		#cfValue($customFieldManager.getCustomFieldObject("customfield_10007") $subtask)
      	</fo:block>
      	## ...
      #end
      
    • Jira 6 and 7 compatible version:
      #foreach($subtask in $subTaskTool.asTemplateIssues($issue.subTaskObjects))
      	## ...
      	<fo:block>
      		#cfValue($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:

  1. Create the script named jql-search-tool.groovy:
    • Jira 8 compatible version:
      import com.atlassian.jira.component.ComponentAccessor
      import com.atlassian.jira.issue.search.SearchRequestManager
      import com.atlassian.jira.mail.TemplateIssue
      import com.atlassian.jira.web.bean.PagerFilter
      import com.atlassian.jira.bc.issue.search.SearchService
      import org.apache.log4j.Logger
      
      jqlSearch = new JqlSearchTool(user: user)
      
      class JqlSearchTool {
      	def log = Logger.getLogger(this.getClass())
      
      	private user
      
      	/**
      	 * Returns the issues found by executing the passed JQL
      	 * (or null in case of failure).
      	 */
      	def searchByJql(def jql) {
      		def clazz = ComponentAccessor.class.classLoader.loadClass("com.atlassian.jira.jql.parser.JqlQueryParser")
      		def jqlQueryParser = ComponentAccessor.getComponentOfType(clazz)
      
      		def query = jqlQueryParser.parseQuery(jql)
      		if(query == null) {
      			log.debug("<{$query.queryString}> could not be parsed")
      			return null
      		}
      		log.debug("<{$query.queryString}> is parsed")
      
      		return search(query)
      	}
      
      	/**
      	 * Returns the issues found by executing the saved filter with the passed ID
      	 * (or null in case of failure).
      	 */
      	def searchBySavedFilter(def savedFilterId) {
      		def searchRequest = ComponentAccessor.getComponentOfType(SearchRequestManager.class).getSearchRequestById(user, savedFilterId)
      		if(searchRequest == null) {
      			log.debug("Filter #${savedFilterId} not found")
      			return null
      		}
      		log.debug("Filter #${savedFilterId} found: \"${searchRequest.name}\"")
      
      		return search(searchRequest.query)
      	}
      
      	private search(def query) {
      		def searchResults = ComponentAccessor.getComponentOfType(SearchService.class).search(user, query, PagerFilter.getUnlimitedFilter())
      		if(searchResults == null) {
      			return null
      		}
      		log.debug("<{$query.queryString}> found ${searchResults.total} issues")
      
      		return searchResults.results.collect { new TemplateIssue(it, ComponentAccessor.fieldLayoutManager, ComponentAccessor.rendererManager, ComponentAccessor.customFieldManager, null, null) }
      	}
      }
      
    • Jira 6 and Jira 7 compatible version:
      import com.atlassian.jira.component.ComponentAccessor
      import com.atlassian.jira.issue.search.SearchRequestManager
      import com.atlassian.jira.mail.TemplateIssue
      import com.atlassian.jira.web.bean.PagerFilter
      import com.atlassian.jira.issue.search.SearchProvider
      import org.apache.log4j.Logger
      
      jqlSearch = new JqlSearchTool(user: user)
      
      class JqlSearchTool {
      	def log = Logger.getLogger(this.getClass())
      
      	private user
      
      	/**
      	 * Returns the issues found by executing the passed JQL
      	 * (or null in case of failure).
      	 */
      	def searchByJql(def jql) {
      		def clazz = ComponentAccessor.class.classLoader.loadClass("com.atlassian.jira.jql.parser.JqlQueryParser")
      		def jqlQueryParser = ComponentAccessor.getComponentOfType(clazz)
      
      		def query = jqlQueryParser.parseQuery(jql)
      		if(query == null) {
      			log.debug("<{$query.queryString}> could not be parsed")
      			return null
      		}
      		log.debug("<{$query.queryString}> is parsed")
      
      		return search(query)
      	}
      
      	/**
      	 * Returns the issues found by executing the saved filter with the passed ID
      	 * (or null in case of failure).
      	 */
      	def searchBySavedFilter(def savedFilterId) {
      		def searchRequest = ComponentAccessor.getComponentOfType(SearchRequestManager.class).getSearchRequestById(user, savedFilterId)
      		if(searchRequest == null) {
      			log.debug("Filter #${savedFilterId} not found")
      			return null
      		}
      		log.debug("Filter #${savedFilterId} found: \"${searchRequest.name}\"")
      
      		return search(searchRequest.query)
      	}
      
      	private search(def query) {
      		def searchResults = ComponentAccessor.getComponentOfType(SearchProvider.class).search(query, user, PagerFilter.getUnlimitedFilter())
      		if(searchResults == null) {
      			return null
      		}
      		log.debug("<{$query.queryString}> found ${searchResults.total} issues")
      
      		return searchResults.issues.collect { new TemplateIssue(it, ComponentAccessor.fieldLayoutManager, ComponentAccessor.rendererManager, ComponentAccessor.customFieldManager, null, null) }
      	}
      }
      
  2. Execute it in your template:
    $scripting.execute("jql-search-tool.groovy")

Searching with JQL queries

After executing the script explained in the previous section, 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

After executing the script explained in the previous section, 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. (You could, of course, solve this particular use case easier in a local Jira using IssueManager, but we use this to demonstrate making a simple REST API call.)

  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("http://jira.acme.com/rest/api/2/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 local Jira instance only, prefer using Jira's internal Java API 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 (or the loop-back interface of your server). 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.

Connecting to databases to run SQL queries

Retrieving data from databases, including both the Jira database and external databases, is possible with some Groovy scripting:

  1. Create the script named database-tool.groovy:
    import com.atlassian.jira.component.ComponentAccessor
    import groovy.sql.Sql
    import org.apache.log4j.Logger
    
    database = new DatabaseTool()
    
    class DatabaseTool {
    	def log = Logger.getLogger(this.getClass())
    
    	def executeSql(def jdbcDriverClassName, def url, def user, def password, def sqlQuery) {
    		def result
    
    		def sql
    		def conn
    
    		try {
    			// assumes that the JDBC driver is available on the classpath
    			def jdbcDriverClazz = ComponentAccessor.class.classLoader.loadClass(jdbcDriverClassName)
    			log.debug("JDBC driver class: " + jdbcDriverClazz.canonicalName)
    
    			def jdbcDriver = jdbcDriverClazz.newInstance()
    			log.debug("JDBC driver: " + jdbcDriver)
    
    			def props = new Properties()
    			props.put("user", user)
    			props.put("password", password)
    
    			conn = jdbcDriver.connect(url, props)
    			sql = Sql.newInstance(conn)
    			result = sql.rows(sqlQuery)
    			log.debug("Results found: " + result.size())
    		} catch (Exception ex) {
    			log.error("Failed to execute SQL", ex)
    		} finally {
    			sql.close()
    			conn.close()
    		}
    
    		return result
    	}
    }
  2. Execute it in your template:
    $scripting.execute("database-tool.groovy")
  3. Call the database.executeSql method, iterate over the result, and access database column values via properties with the same name. For example, query the Jira user accounts directly from the database:
    #set($result = $database.executeSql("com.mysql.jdbc.Driver", "jdbc:mysql://localhost:3306/my_jira_database", "root", "admin", "SELECT * FROM cwd_user"))
    #foreach($row in $result)
    	<fo:block>$row.display_name ($row.user_name)</fo:block>
    #end
    
    1. Please read the official Groovy documentation on working with relational databases for more details.

      Exporting additional Tempo Timesheets worklog details

      Tempo Timesheets worklog information are collected using the Tempo Timesheets Servlet API. Please quickly read through the details of the XML format returned by Tempo Timesheets to understand the theory behind the following recipes.

      Tempo Timesheets billed hours

      To export the billed hours, see this example based on the issue-fo.vm template:

      #set($worklogs = $tempo.getWorklogs($issue.key))
      #foreach($worklog in $worklogs.iterator())
      	<fo:block>$worklog.getProperty('billed_hours')</fo:block>
      #end
      

      Tempo Timesheets custom worklog attributes

      To export the custom worklog attributes, see this example based on the issue-fo.vm template:

      #set($worklogs = $tempo.getWorklogs($issue.key))
      #foreach($worklog in $worklogs.iterator())
      	<fo:block>$xmlutils.escape($worklog.getProperty('billing_attributes').toString())</fo:block>
      #end
      

      Please note that the worklog attributes are returned as a single comma-separated string, like: "Country=Germany,Region=EMEA,Foobar=123". Trivially, it should be split at the comma-character to get the individual attribute name-value pairs, and then split at the equals sign character to separate names and values.

      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: (since 5.2.0)

      ## 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.

      Please note that SVG graphics are supported only in Better PDF Exporter 5.2.0 or newer and Jira 7.0.0 or newer.

      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.

      Exporting in a different language without switching locales

      PDF files are exported using the language selected in the Jira user's profile who initiated the export. Sometimes, although you are using English, want to create exports in German without switching your language to German for the time of export. This is also doable.

      Localized texts are produced by the bean called $i18n which is normally initialized to use the language of the current user. You can, however, replace the default instance on the fly by instantiating a new object with the same name, but using a specific locale!

      In the following example we change the locale to German:

      1. Create the one-line Groovy script named locale-tool.groovy:
        // awkward constructor invocation to avoid classloading problems
        i18n = i18n.getClass().forName('com.atlassian.jira.web.bean.I18nBean').getDeclaredConstructor(String.class).newInstance("de_DE")
        
      2. Execute it in your template:
        $scripting.execute('locale-tool.groovy')
      3. From this point, all calls on $i18n will produce German texts.

      Tip: see the commonly used locales in the Jira manual.

      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.

      Productivity tips

      How to work fast with PDF template customization?

      The fastest way of working on your PDF templates is simply using two browser tabs: one for the template code editor and another for the rendered PDF document. This is how:

      1. Export an issue to a PDF document using the template you wanted to edit. (Your browser's PDF viewer plugin will display the result right in the browser.)
      2. Open a second browser tab for the template code.
      3. Make a change in the template code in tab #2.
      4. Jump to tab #1 and refresh it. It will re-generate the PDF also using your modification, and reflect the changes immediately!
      5. Go tab #2, make another change, go to tab #1, refresh. That's it!

      Watch this video to see this quick round-trip approach in action:

      To make it ultra-mega-fast, forget the mouse and use hotkeys only: make a change, hit CTRL+S to save it, hit CTRL+Tab to go to the other browser tab, hit F5 to refresh!

      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.