Search Docs
Loading...
Skip to content

Data Merge

Generate personalized designs at scale using CE.SDK’s headless Android engine to batch process templates with external data.

10 mins
estimated time
GitHub

Data merge generates multiple personalized designs from a single template by replacing variable content with external data. On Android, this works best as an engine-only workflow: build a reusable scene once, load it for each record, set variable values, update named placeholder blocks, and export the result.

This guide covers how to prepare data, build templates with variables, and process multiple records in a batch workflow.

Initialize the Engine#

We start by initializing the headless Creative Engine. In production, replace the evaluation-mode license = null value with your own key.

engine.start(license = license, userId = userId)
engine.bindOffscreen(width = 1050, height = 600)

Prepare Data Records#

Data typically comes from a CSV file, database query, or API response. Here we define sample records with the fields we want to merge into the template.

val records = listOf(
MergeRecord(
fullName = "Alex Rivera",
jobTitle = "Senior Product Designer",
email = "alex.rivera@example.com",
photoUri = "https://img.ly/static/ubq_samples/sample_1.jpg",
),
MergeRecord(
fullName = "Jordan Lee",
jobTitle = "Lifecycle Marketing Lead",
email = "jordan.lee@example.com",
photoUri = "https://img.ly/static/ubq_samples/sample_2.jpg",
),
)

Each record contains field names that map to template variables and the named placeholder block that holds the profile image.

Build the Template#

We build a reusable business-card layout with one named image placeholder and two text blocks that contain variable placeholders. The scene is then serialized once so the loop can reload it for every record.

val templateScene = engine.scene.create()
val page = engine.block.create(DesignBlockType.Page)
engine.block.setWidth(page, value = 1050F)
engine.block.setHeight(page, value = 600F)
engine.block.appendChild(parent = templateScene, child = page)
val background = engine.block.create(DesignBlockType.Graphic)
val backgroundFill = engine.block.createFill(FillType.Color)
engine.block.setShape(background, shape = engine.block.createShape(ShapeType.Rect))
engine.block.setFill(background, fill = backgroundFill)
engine.block.appendChild(parent = page, child = background)
engine.block.fillParent(background)
engine.block.setColor(
block = backgroundFill,
property = "fill/color/value",
value = Color.fromHex("#FFF7F0"),
)
val photoBlock = engine.block.create(DesignBlockType.Graphic)
val placeholderFill = engine.block.createFill(FillType.Image)
engine.block.setName(photoBlock, name = "profile-photo")
engine.block.setShape(photoBlock, shape = engine.block.createShape(ShapeType.Rect))
engine.block.setPositionX(photoBlock, value = 48F)
engine.block.setPositionY(photoBlock, value = 48F)
engine.block.setWidth(photoBlock, value = 280F)
engine.block.setHeight(photoBlock, value = 504F)
engine.block.setEnum(photoBlock, property = "contentFill/mode", value = "Cover")
engine.block.setString(
block = placeholderFill,
property = "fill/image/imageFileURI",
value = "https://img.ly/static/ubq_samples/sample_1.jpg",
)
engine.block.setFill(photoBlock, fill = placeholderFill)
engine.block.appendChild(parent = page, child = photoBlock)
val nameText = engine.block.create(DesignBlockType.Text)
engine.block.replaceText(nameText, text = "{{full_name}}")
engine.block.setPositionX(nameText, value = 368F)
engine.block.setPositionY(nameText, value = 110F)
engine.block.setWidth(nameText, value = 620F)
engine.block.setHeightMode(nameText, mode = SizeMode.AUTO)
engine.block.setTextFontSize(nameText, fontSize = 56F)
engine.block.setTextColor(nameText, color = Color.fromHex("#211B17"))
engine.block.appendChild(parent = page, child = nameText)
val detailsText = engine.block.create(DesignBlockType.Text)
engine.block.replaceText(detailsText, text = "{{job_title}}\n{{email}}")
engine.block.setPositionX(detailsText, value = 368F)
engine.block.setPositionY(detailsText, value = 214F)
engine.block.setWidth(detailsText, value = 580F)
engine.block.setHeightMode(detailsText, mode = SizeMode.AUTO)
engine.block.setTextFontSize(detailsText, fontSize = 28F)
engine.block.setTextColor(detailsText, color = Color.fromHex("#5D5248"))
engine.block.appendChild(parent = page, child = detailsText)
val templateSceneString = engine.scene.saveToString(scene = templateScene)

Using setName() for the image placeholder keeps later updates predictable and avoids depending on transient block handles.

Batch Processing Loop#

We iterate through each data record, clear previously assigned variables, and load a fresh copy of the template scene before applying the next merge payload.

for (record in records) {
engine.variable.findAll().forEach { key -> engine.variable.remove(key) }
engine.scene.load(scene = templateSceneString)
val exportPage = engine.scene.getPages().first()

Loading the serialized template for each record keeps the block hierarchy stable while isolating changes between exports.

Set Variable Values#

Android uses engine.variable.set(key =, value =) to assign text values. Once the keys are set, text blocks that reference {{full_name}}, {{job_title}}, or {{email}} update automatically during export.

engine.variable.set(key = "full_name", value = record.fullName)
engine.variable.set(key = "job_title", value = record.jobTitle)
engine.variable.set(key = "email", value = record.email)

Variable values persist on the engine until you overwrite or remove them, which is why the batch loop clears them before loading the next scene copy.

Verify Variables#

On Android, engine.variable.findAll() reports the variable keys that currently have values stored in the engine. Pair it with engine.block.referencesAnyVariables() to confirm that your text blocks still reference variable placeholders in the loaded template.

val variableNames = engine.variable.findAll()
check(variableNames.containsAll(listOf("full_name", "job_title", "email")))
val variableBlocks = engine.block.findByType(DesignBlockType.Text).filter { block ->
engine.block.referencesAnyVariables(block)
}
check(variableBlocks.isNotEmpty())

This is useful for validating that your data model and template stay aligned before exporting a larger batch.

Find and Update Placeholder Blocks#

Use engine.block.findByName() to locate the named placeholder block, then update its image fill URI before the export.

val profilePhoto = engine.block.findByName("profile-photo").first()
val profileFill = engine.block.getFill(profilePhoto)
engine.block.setString(
block = profileFill,
property = "fill/image/imageFileURI",
value = record.photoUri,
)
engine.block.resetCrop(profilePhoto)

Resetting the crop after swapping the image keeps the placeholder framing consistent when source images have different aspect ratios.

Export Each Design#

After merging one record into the loaded scene, export the personalized card as a PNG and store the bytes with a record-specific filename.

val pngBuffer = engine.block.export(exportPage, mimeType = MimeType.PNG)
val pngBytes = ByteArray(pngBuffer.remaining())
pngBuffer.get(pngBytes)
mergedCards += MergedCard(
fileName = record.fullName.lowercase().replace(" ", "-") + ".png",
pngBytes = pngBytes,
)

You can switch the MimeType to JPEG, WebP, or PDF if your batch job targets different delivery channels.

Cleanup Resources#

Always stop the engine when the batch completes. Wrapping cleanup in a finally block ensures the engine shuts down even if one record fails.

engine.stop()

Troubleshooting#

Variables Not Rendering#

If placeholder text appears in the export instead of merged data:

  • Verify the variable keys match the placeholders exactly, including case.
  • Confirm engine.variable.findAll() contains the keys you expected to set for the current record.
  • Check that the text blocks still return true from engine.block.referencesAnyVariables().

Placeholder Block Not Found#

If findByName("profile-photo") returns an empty list:

  • Make sure the template uses engine.block.setName() before it is serialized.
  • Keep the placeholder name stable across template revisions so the batch loop does not need special cases.
  • Reload a fresh template scene instead of mutating one scene indefinitely between records.

Export Failures#

If one record fails to export:

  • Validate that the current scene still has a page block before calling engine.block.export().
  • Check that the image URI assigned to the placeholder is reachable on the device.
  • Keep the loop sequential on Android and write the exported bytes out before moving to the next record.

API Reference#

MethodDescription
engine.variable.set(key, value)Set a text variable value for the current engine session
engine.variable.get(key)Read back a previously assigned variable value
engine.variable.findAll()List the variable keys that currently have values stored in the engine
engine.variable.remove(key)Remove a previously assigned variable value
engine.block.setName(block, name)Assign a stable semantic name to a block
engine.block.findByName(name)Find blocks by their semantic name
engine.block.findByType(type)Find blocks by design-block type
engine.block.referencesAnyVariables(block)Check whether a block still contains variable placeholders
engine.block.getFill(block)Get the fill block attached to a design block
engine.block.setString(block, property, value)Update string-backed properties such as image file URIs
engine.block.export(block, mimeType)Export a block to an image format
engine.scene.create()Create a new scene for the template
engine.scene.getPages()Get the page blocks from the currently loaded scene
engine.scene.saveToString(scene)Serialize the template scene so it can be reloaded for each record
engine.scene.load(scene)Load a serialized scene into the active engine
engine.stop()Release the engine after the batch run finishes

Next Steps#

  • Batch Processing — Automate generation of multiple designs from a template in a loop.