In this page
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
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
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 Administration → Announcement 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; }
Creating quick links for frequently used PDF exports
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:
- Execute the saved filter.
- 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.
- 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:
-
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 } } }
-
Execute it in your template:
$scripting.execute('sorter-tool.groovy')
-
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:
-
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 } } }
-
Execute it in your template:
$scripting.execute('comment-sorter.groovy')
-
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 multiple tools to format date, time and date-time values, each with its own merits:
-
$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().) - $outlookdate is the old standard, that is still very useful and easy to use.
- $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​")) #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.
Generating hyperlinks for issues
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:
- Find the <fo:root ...> tag in the template.
-
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
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:
-
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) } } }
-
Execute it in your template:
$scripting.execute("sub-task-tool.groovy")
-
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)
-
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:
-
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 { it -> 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 { it -> new TemplateIssue(it, ComponentAccessor.fieldLayoutManager, ComponentAccessor.rendererManager, ComponentAccessor.customFieldManager, null, null) } } }
-
Jira 8 compatible version:
-
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.)
-
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)
-
Execute it in your template:
$scripting.execute('jira-rest-api-tool.groovy')
-
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:
-
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)
-
Execute it in your template:
$scripting.execute('external-rest-api-tool.groovy')
-
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:
-
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 } }
-
Execute it in your template:
$scripting.execute("database-tool.groovy")
-
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
- Create the Groovy class that implements the drawing logic.
- The class should provide high-level methods that accept the arguments that affect the resulted graphic and return data URI strings.
- 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.
-
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>
-
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 == 'TEST') $include.parse($ctx, "issue-fo-foo.vm") #else $include.parse($ctx, "issue-fo-bar.vm") #end
- Create the two actual templates issue-fo-foo.vm and issue-fo-bar.vm through the Template Manager.
-
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). -
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")
-
Execute it in your template:
$scripting.execute('locale-tool.groovy')
- From this point, all calls on $i18n will produce German texts.
- 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.)
- Open a second browser tab for the template code.
- Make a change in the template code in tab #2.
- Jump to tab #1 and refresh it. It will re-generate the PDF also using your modification, and reflect the changes immediately!
- Go tab #2, make another change, go to tab #1, refresh. That's it!
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:
(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.)
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 "TEST" project 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:
Using this technique, you can select different templates based on the view mode (single issue or search request), on the number of issues (single or multiple), on field values, or on any other condition that can be evaluated in the rendering context.
Auto-selecting templates by issue type
As another example, here is the code that selects the template based on type of the issues:
<?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 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:
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:
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.