In this page

User interface
Hiding the "Export" menus on Jira Agile boards
Creating quick links for frequently used PDF exports
Filtering, sorting the issues passed to the PDF template
Filtering issues
Sorting issues to export those in a different order
Sorting the comments as "newest first"
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
With JQL queries
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 worklog details
Tempo billed hours
Tempo 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
Other tips & tricks
Embedding issue attachments in the exported PDFs
Loading Java classes that cannot be imported
Alternating row colors (zebra stripes)
Customizing column widths in the "issue-navigator-fo.vm" template
Exporting in a different language without switching locales
Debugging templates
Tracing variables
Logging from scripts
Debugging scripts
Productivity
Efficient workspaces for fast PDF template customization

User interface

Hiding the "Export" menus on Jira Agile boards

If you never export directly from the Jira Agile 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, sorting the issues passed to the PDF template

Filtering issues

In most of the cases, you don't need a secondary filter. 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 is not sufficient, or you really need to execute a second filter, 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 'DEV' project and ignore others
	#if($issue.key.contains("DEV"))
		## ... export body code omitted
	#end
#end

Sorting issues to export those in a different order

In most of the cases, you can flexibly sort issues in JQL using ORDER BY.

If you need to sort according to some more complex logic, follow this pattern:

  1. Create a sorter class in Groovy that implements your ordering and save it as sorter-tool.groovy:
    sorter = new SorterTool()
    
    public class SorterTool {
    	public sort(issues) {
    		return issues.sort { a, b -> a.summary <=> b.summary }
    	}
    }
    
  2. Execute it in your template:
    $scripting.execute('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 = $sorter.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 the comments as "newest first"

Comments for an issue are shown in ascending order by default, i.e. the most recent one is displayed at the bottom. To reverse this order:

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

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 3 separate 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 in a way that cannot be achieved with the previous tools. 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 isssues, 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 this iteration in the template:
    #if($issues && !$issues.empty)
    	#foreach($issue in $issues)
    	## ...
    
  2. Modify that to this:
    #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
    	## iterate over the merged list
    	#foreach($issue in $allIssues)
    	## ...
    

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

project = FOOBAR 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 { it -> templateIssueFactory.getTemplateIssue(it) }
        }
    }
  2. Execute it in your template:
    $scripting.execute("sub-task-tool.groovy")
  3. Then 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:
    #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:
    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 { it -> new TemplateIssue(it, ComponentAccessor.fieldLayoutManager, ComponentAccessor.rendererManager, ComponentAccessor.customFieldManager, null, null) }
    	}
    }
    
  2. Execute it in your template:
    $scripting.execute("jql-search-tool.groovy")

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=FOOBAR ORDER BY summary"))
#foreach($issue in $issuesFound)
	<fo:block>[$xmlutils.escape($issue.key)] $xmlutils.escape($issue.summary)</fo:block>
#end

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

If you need to connect to REST services, that's pretty easy, too.

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)
    
    issueRest = new JsonSlurper().parseText(jsonString)
    
  2. Execute it in your template:
    $scripting.execute('jira-rest-api-tool.groovy')
  3. Use the issueRest object to access the returned issue's fields in the template:
    ${issueRest.key}
    ${issueRest.fields.summary}
    ${issueRest.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()
    dataRest = jsonSlurper.parseText(new URL("http://services.groupkt.com/country/get/iso2code/US").text)
    
  2. Execute it in your template:
    $scripting.execute('external-rest-api-tool.groovy')
  3. Use the dataRest object to access the returned information in the template:
    ${dataRest.RestResponse.result.name}
    ${dataRest.RestResponse.result.alpha2_code}
    

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-acc, 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 worklog details

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

      Tempo 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 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, just 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, just 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, just 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, just 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 powerful and simple way to add dynamic vector graphics to your templates. This can be utilized to solve a plethora of problems, from rotating text, drawing lines, to enhancing your documents with geometric shapes. See the next sections for some typical use cases, and 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 graphic that is drawn by complicated logic that would be difficult to implement in SVG, you can always use Groovy scripting.

      The idea:

      1. Create the Groovy class that implements the drawing logic.
      2. The class should provide high-level methods that accept the arguments that affect the resulted graphic and return data URI strings.
      3. First, the methods create the images in memory using whatever graphic technology you prefer, and serialize the images to PNG (byte arrays in memory). Then they convert the byte arrays to data URIs, and return those as strings.
      4. In the template, execute the script, and use snippets like the one below to display the graphic:
        <fo:block>
        	<fo:external-graphic content-height="4cm" src="data:image/png;base64,$gfx.drawFooImage($issue, 400, 300)"/>
        </fo:block>
        

      (This is essentially the same technique, how the the JFreeChart rendered custom charts are inserted to templates, which is, in fact, a similar problem in nature.)

      Other tips & tricks

      Embedding issue attachments in the exported PDFs

      See the related tutorial.

      Loading Java classes that cannot be imported

      There are times when you fail to reference a Java (Groovy) class by using an import statement, as the class is made available for apps by Jira. For example, you'd like to get the JqlQueryParser component from Jira, but the Groovy interpreter cannot import its class.

      The problem is due to Jira running apps in an OSGi environment with controlled classloading. Groovy scripts run in the same environment as the app that executes those, therefore the same limits affect scripts, too.

      Luckily, Groovy being a dynamic language, there is a clever trick to overcome this:

      // load the class by name using the class-loader of a Jira-internal class
      def clazz = ComponentAccessor.class.classLoader.loadClass("com.atlassian.jira.jql.parser.JqlQueryParser")
      
      // don't define the type when getting the component
      def jqlQueryParser = ComponentAccessor.getOSGiComponentInstanceOfType(clazz)
      
      // we have a working query parser, yay!
      def query = jqlQueryParser.parseQuery("project = CONTRACTOR and assignee = ${assignee.name}")
      

      Alternating row colors (zebra stripes)

      If you want to add alternating row colors to your templates, 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
      

      It is up to you to implement more complex coloring 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 = issues.get(0).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.

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

      Debugging templates

      Tracing variables

      We use a super-simple best practice to make our templates "debuggable", both while developing it and after they are deployed to production.

      The idea is introducing a boolean variable ${debug} to control if some variables are shown (debugging time) or hidden (production time) in the exported PDF file:

      #set($debug = false) ## set to true to display debug information
      
      ##	...
      
      #if($debug)
      	<fo:block>DEBUG: number of issues: ${issues.size()}</fo:block>
      #end
      #foreach($issue in $issues)
      	#if($debug)
      		<fo:block>DEBUG: issue key: ${issue.key}</fo:block>
      	#end
      	<fo:block>$xmlutils.escape($issue.summary)</fo:block>
      	## ...
      #end
      

      The nicety here is that you can switch between the "debug" and "non-debug" modes easily and transparently even in a production Jira.

      Logging from scripts

      See the logging from scripts section in the Scripting page.

      Debugging scripts

      See the debugging scripts in Jira section in the Scripting page.

      Productivity

      Efficient workspaces for fast 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!

      Questions?

      Ask us any time.