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:
  1. Write query and calculation logic in APL: Return organized Map data.
  2. Write HTML code in the PDF editor: Render the variables returned by APL using template syntax.
APL binding entry
HTML editing interface

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

Rowspan explanation
Colspan explanation

Design approach

  1. APL receives the instanceId (unique workflow instance ID) from the page.
  2. APL uses Fx.approval.findTasks to query the corresponding approval task list.
  3. Group by task node name and calculate each cell’s rowspan and colspan. If a cell is merged, its rowspan or colspan is set to 0.
  4. In HTML, use rowspan!= 0 to determine whether to output a <td>, and dynamically bind its rowspan attribute.
APL logic diagram

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):
Regions in columns
Desired layout (quarters in columns, regions in rows):
Quarters in columns

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.

Related topics

Submit Feedback