Published at: 2026-09-17
Custom data printing with HTML and APL
Use APL to query and compute data, combined with the HTML template engine, to implement advanced scenarios in PDF print templates such as cross-level object printing, irregular table merging, and data calculations.
Overview
When standard print templates cannot meet complex layout requirements (such as retrieving Associated Attributes across multiple levels, merging cells to display approval workflows, or transposing rows and columns), you can embed HTML code in a PDF template and call APL (Active PaaS Language) functions to achieve fully customized print output.
Before you begin
[!IMPORTANT] - Permission and gray-scale requirements: Contact your customer success manager to enable the Print template APL function support gray-scale feature. - Applicable template type: The approach described in this document applies only to PDF templates (online editor templates).
Core workflow
Configuring custom print data involves two core steps:
- Write query and calculation logic in APL: Return organized Map data.
- Write HTML code in the PDF editor: Render the variables returned by APL using template syntax.


Typical scenario examples
Scenario 1: Print fields from multi-level related objects
For objects such as orders or contracts, detail lists often need to display deeper attributes of related products (for example, Detail -> SKU -> SPU attributes). Since standard detail tables only support displaying fields from directly related objects, you can use APL’s
findByIds query capability to fetch and render multi-level related data.APL code
/**
* @author admin01
* @codeName Print contract product details
* @description Print contract product details
* @createTime 2024-09-04
*/
List details = context.details.SaleContractLineObj as List
List skuIds = []
details.each { entry ->
Map detail = entry as Map
String skuId = detail.get("product_id")
skuIds.add(skuId)
}
def fqa = FQLAttribute.builder().columns(["_id", "name", "price", "is_giveaway", "picture_path", "spu_id"]).build()
def sa = SelectAttribute.builder().build()
List prodctList = Fx.object.findByIds("ProductObj", skuIds, fqa, sa).result() as List
Map skuId2SpuIdMap = prodctList.collectEntries { [(((Map) it)._id): ((Map) it).spu_id] }
Map skuId2Map = prodctList.collectEntries { [(((Map) it)._id): ((Map) it)] }
log.info(skuId2Map)
def spuIds = skuId2SpuIdMap.values() as List
def fqa2 = FQLAttribute.builder().columns(["_id", "name", "is_spec"]).build()
def sa2 = SelectAttribute.builder().build()
List spuList = Fx.object.findByIds("SPUObj", spuIds, fqa2, sa2).result() as List
Map spuMap = spuList.collectEntries { [(((Map) it)._id): ((Map) it)] }
log.info(spuMap)
details.each { entry ->
Map detail = entry as Map
String skuId = detail.get("product_id")
Map skuData = skuId2Map.get(skuId)
String isGiveaway = skuData.get("is_giveaway")
String isGiveawayLabel = ""
if ("1" == isGiveaway) {
isGiveawayLabel = "Yes"
} else if ("0" == isGiveaway) {
isGiveawayLabel = "No"
} else {
isGiveawayLabel = "Unknown"
}
skuData.put("isGiveawayLabel", isGiveawayLabel)
detail.put("SKUObj", skuData)
String spuId = skuId2SpuIdMap.get(skuId) as String
Map spu = spuMap.get(spuId) as Map
boolean isSpec = spu.get("is_spec") as Boolean
Map spuData = [:]
if(isSpec){
spuData.put("is_spec", "Multi-Specification")
} else {
spuData.put("is_spec", "Single-Specification")
}
detail.put("SPUObj", spuData)
}
Map map = [:]
map.put("MySaleContractLineObj", details)
return map
HTML template code
<p>
<!--
#set(MyList = pts_ZhFaS__c.MySaleContractLineObj)
-->
</p>
<table>
<thead>
<tr class="firstRow">
<th><span class="variable-label">Sales Contract Line Item ID</span></th>
<th><span class="variable-label">Multi-Specification</span></th>
<th><span class="variable-label">Product Name</span></th>
<th><span class="variable-label">Is Giveaway</span></th>
<th><span class="variable-label">Unit Price</span></th>
</tr>
</thead>
<tbody>
<!--#for(item: MyList)-->
<tr>
<td>${item.name}</td>
<td>${item.SPUObj.is_spec}</td>
<td>${item.SKUObj.name}</td>
<td>${item.SKUObj.isGiveawayLabel}</td>
<td>${item.SKUObj.price}</td>
</tr>
<!--#end-->
</tbody>
<tfoot>
<tr>
<td colspan="5">Rows load automatically based on the available data.</td>
</tr>
</tfoot>
</table>
<p><br /></p>
Scenario 2: Print approval data in irregular table format
When outputting approval details, you often want to merge cells for Approval Records that share the same approval node (irregular table).
Prerequisites and syntax
- HTML template engine syntax: For template statement control, see Lexiang HTML template engine syntax documentation.
- HTML cell merging: To learn about table merging principles, see GeeksforGeeks HTML table colspan and rowspan tutorial and MDN documentation on the td element.


Design approach
- APL receives the
instanceId(unique workflow instance ID) from the page. - APL uses
Fx.approval.findTasksto query the corresponding approval task list. - Group by task node name and calculate each cell’s
rowspanandcolspan. If a cell is merged, itsrowspanorcolspanis set to0. - In HTML, use
rowspan!= 0to determine whether to output a<td>, and dynamically bind itsrowspanattribute.

1. APL cell merge calculation logic
Below is the core APL code for rowspan merging of identical task names:
/**
* @author admin01
* @codeName Print approval workflow
* @description Fetch and process approval workflow merged row data
* @createTime 2024-07-08
*/
Map map = [:]
def (Boolean err, List instanceList, String errMsg) = Fx.approval.findTasks(instanceId)
List printList = []
instanceList.each { inst ->
List opinions = ((Map) inst).opinions as List
opinions.eachWithIndex { opin, i ->
Map newOpin = [:]
newOpin.task_name = ((Map) inst).task_name
String replyUser = ((List) ((Map) opin).reply_user)[0]
def (Boolean err1, Map userInfo, String errMsg1) = Fx.org.findUserById(replyUser)
if(!err1){
newOpin.reply_user = userInfo.full_name
} else {
log.info(errMsg1)
}
String type = ((Map) opin).action_type
if(type == 'agree'){
newOpin.action_type = 'Approved'
} else if(type == 'reject'){
newOpin.action_type = 'Rejected'
} else if(type == 'cancel'){
newOpin.action_type = 'Canceled'
}
newOpin.opinion = ((Map) opin).opinion
Map spanTaskName = [task_name: [colspan: 1, rowspan: 1]]
newOpin.span = spanTaskName
printList.add(newOpin)
}
}
// Group by task node name and calculate rowspan
Map groupedByTaskName = printList.groupBy { ((Map) it).task_name }.collectEntries { [(it.key): (it.value)] }
groupedByTaskName.each { entry ->
def taskList = entry.value as List<Map>
if (taskList.size() <= 1) return
taskList.eachWithIndex { task, i ->
Map span = ((Map) task).span as Map
Map taskNameSpan = span.task_name as Map
if (i == 0) {
taskNameSpan.rowspan = taskList.size()
} else {
taskNameSpan.rowspan = 0
}
}
}
map.put('instanceList', printList)
return map
2. HTML binding and rendering
In HTML, combine
#if statements to hide <td> elements where rowspan is 0, rendering the cell only when rowspan!= 0:<p><br /></p>
<table width="100%">
<thead>
<tr class="firstRow">
<th>Task Node Name</th>
<th>Assignee</th>
<th>Action Result</th>
<th>Approval Comment</th>
</tr>
</thead>
<tbody>
<!--#for(entry:pts_sNsPA__c.instanceList)-->
<tr>
<!-- #if (entry.span.task_name.rowspan!= 0) -->
<td rowspan="${entry.span.task_name.rowspan}">${entry.task_name}</td>
<!-- #end -->
<td>${entry.reply_user}</td>
<td>${entry.action_type}</td>
<td>${entry.opinion}</td>
</tr>
<!--#end-->
</tbody>
</table>
<p><br /></p>
Practice: Transposing rows and columns (transposed table)
In certain statistical analysis scenarios, you need to swap the rows and columns of query results.
Original data layout (regions in columns, quarters in rows):

Desired layout (quarters in columns, regions in rows):

Sample data JSON structure
Header
[
"Sales by Region",
"Europe",
"Asia",
"North America"
]
Original detail data
[
{
"quarter": "Qt 1",
"eu": "21704714",
"as": "8774099",
"na": "12094215"
},
{
"quarter": "Qt 2",
"eu": "17987034",
"as": "12214447",
"na": "10873099"
},
{
"quarter": "Qt 3",
"eu": "19485029",
"as": "14536879",
"na": "15689543"
},
{
"quarter": "Qt 4",
"eu": "22567894",
"as": "15763492",
"na": "17456723"
}
]
Use APL loops to reorganize the data, and use nested
<!--#for--> statements in the HTML template to align vertical and horizontal fields for transposed output.Summary
By combining print templates with APL, you can:
- Group and render detail data into multiple tables based on different Record Types or option values in a record.
- Pre-aggregate detail fields (sum, average) and conditionally hide or highlight data rows based on the results.
- Query multiple levels deep along the object reference chain to produce highly complex business document prints.