
You've built a beautiful Canvas App. Users are entering data, approvals are flowing, dashboards are lighting up. Then someone from operations walks over and says, "Can it print a report?" And just like that, you're staring into an abyss. Power Apps doesn't have a native "export to PDF" button. There's no built-in document generation engine. And the gap between "data in a form" and "formatted PDF in someone's inbox" is wider than most Canvas App tutorials will admit.
This lesson closes that gap completely. We're going to build a real, production-grade document automation pipeline using three tools in concert: Power Automate, Word Online templates with content controls, and the HtmlText control inside Canvas Apps. By the end of this lesson, you'll understand not just how to wire these pieces together, but why the architecture works the way it does — the constraints that drive each design decision, the failure modes that will bite you at 4 PM on a Friday, and the patterns that scale from a single report to an enterprise document factory.
What you'll learn:
Before diving in, you should be comfortable with:
You do not need prior experience with document generation or PDF tools — we'll build that knowledge from scratch.
The single biggest mistake developers make when approaching Canvas App PDF generation is treating it as a Canvas App problem. It isn't. Canvas Apps live in a browser sandbox. They have no filesystem access, no ability to shell out to a PDF renderer, and no built-in templating engine. Trying to solve document generation purely inside Power Apps is like trying to carve marble with a butter knife.
The correct mental model is this: Canvas Apps collect and present data. Power Automate renders documents. The app's job is to assemble a payload and fire a flow. The flow's job is to take that payload, merge it into a template, convert it to PDF, and deliver it somewhere useful — an email, a SharePoint library, a Teams channel, or a download link passed back to the app.
Here's the end-to-end architecture we'll build:
Canvas App
→ User triggers "Generate Report" button
→ App assembles JSON payload from collections/Dataverse data
→ App calls Power Automate flow via HTTP action or connector
Power Automate Flow
→ Receives payload
→ Queries any additional data (if needed)
→ Populates Word template (Word Online Business connector)
→ Converts Word to PDF (Word Online Business or OneDrive)
→ Saves PDF to SharePoint / sends email / returns link
Canvas App
→ Receives response (optional: PDF URL or base64)
→ Shows confirmation to user or opens PDF link
This separation of concerns matters deeply. It means your template logic lives in Power Automate, not scattered across dozens of Power Fx formulas. It means your Word template is a real Word document that your comms team can style without touching the app. And it means your Canvas App stays fast and responsive because the heavy lifting happens asynchronously in a cloud flow.
Now let's build it.
The Word Online connector in Power Automate uses content controls — not mail merge fields, not bookmarks — to identify where dynamic data goes. If you've used mail merge before, unlearn it. Content controls are more structured, more reliable, and far more powerful for dynamic document generation.
Open Microsoft Word (desktop client, not the web). Go to File → Options → Customize Ribbon and check the "Developer" checkbox. The Developer tab will appear in your ribbon. You'll live here for the next few minutes.
For our working example, imagine you're building a Sales Opportunity Report for a B2B sales team. The report needs to include:
Start by designing the Word document visually first — set fonts, colors, spacing, and layout exactly as you want them in the final PDF. Content controls sit inside your design; they don't replace it.
Click in the document where you want a field — say, the account name in the header. On the Developer tab, click "Plain Text Content Control" (the Aa icon). A blue-bordered box appears. Now, critically important: set the tag. Click "Properties" in the Developer group. You'll see a dialog with Title and Tag fields. Set both to something clean and meaningful:
AccountNameAccountNameThe tag is what Power Automate will use to identify this field. Keep tags PascalCase, no spaces, no special characters. Do this for every scalar field:
| Tag | Purpose |
|---|---|
AccountName |
Name of the account |
ReportDate |
Date the report was generated |
SalesRepName |
Assigned sales representative |
ExecutiveSummary |
Dynamically generated summary text |
TotalPipelineValue |
Formatted currency total |
Here's where it gets interesting. For the opportunities table, you don't want to hardcode five rows — you want the table to grow based on how many opportunities exist. This is where Repeating Section Content Controls come in.
In your document, create a table with a header row containing your column labels. In the first data row, you'll embed the repeating section control:
OpportunityRowsDealNameStageEstimatedCloseDateDealValueThe repeating section tells Word Online: "For each item in the collection I receive for OpportunityRows, render one of these rows." This is the core mechanism for dynamic tables.
Warning: The content control tags inside a repeating section must be unique within that section but don't need to be unique across the whole document. However, if you use the same tag name at the document level AND inside a repeating section, Power Automate will behave unpredictably. Namespace your inner tags if there's any risk of collision.
For the "at risk deals" section, we need content that only renders when certain conditions are true. Word Online's connector doesn't support native conditional rendering — this is a limitation you need to architect around, not pretend doesn't exist.
There are two practical approaches:
Approach 1: Pre-filter in Power Automate. Don't put conditional logic in the template at all. Instead, in your flow, check whether at-risk deals exist. If they do, populate a content control with the at-risk section content (pre-formatted as text). If they don't, populate it with an empty string and set the content control's surrounding paragraph to white text (invisible). This is hacky but functional.
Approach 2: Generate the full HTML section in Power Automate and inject it. This is cleaner. Use Power Automate's string composition capabilities to build an HTML snippet for the at-risk section, then pass it to the ExecutiveSummary field or a dedicated AtRiskSection rich text content control. We'll cover this shortly.
Tip: For most enterprise scenarios, Approach 2 scales much better. Compose your dynamic sections in Power Automate using string operations and HTML, then render them via Rich Text content controls in Word. This keeps your template simple and puts logic where it belongs — in the flow.
Save the document to SharePoint or OneDrive for Business — not your local drive, not a personal OneDrive. The Word Online Business connector requires the file to be accessible via a SharePoint or OneDrive for Business path. Name it something unambiguous: SalesOpportunityReport_Template.docx. Note the exact SharePoint site URL and library path — you'll need them in Power Automate.
Now we build the engine. In Power Automate, create a new Instant cloud flow with a "PowerApps (V2)" trigger. This trigger type lets you define strongly-typed input parameters — a critical advantage over the older V1 trigger.
In the PowerApps V2 trigger, add the following input parameters:
AccountName - Text
SalesRepName - Text
ReportDate - Text
ExecutiveSummary - Text
OpportunityRowsJSON - Text
OpportunityRowsJSON will carry the entire opportunities collection as a serialized JSON string. You'll deserialize it in the flow. Using a single JSON string for collections is a pragmatic choice — Power Apps V2 triggers support arrays directly, but array handling can be finicky when you have nested objects. JSON strings are explicit and debuggable.
Add a Parse JSON action immediately after the trigger. For the Content field, reference OpportunityRowsJSON from the trigger. For the Schema, click "Generate from sample" and paste in a representative JSON array:
[
{
"DealName": "Contoso Enterprise License",
"Stage": "Proposal Sent",
"EstimatedCloseDate": "2024-03-31",
"DealValue": "$125,000",
"IsAtRisk": true
},
{
"DealName": "Fabrikam Pilot Expansion",
"Stage": "Negotiation",
"EstimatedCloseDate": "2024-04-15",
"DealValue": "$48,000",
"IsAtRisk": false
}
]
Power Automate will generate a schema that looks like:
{
"type": "array",
"items": {
"type": "object",
"properties": {
"DealName": { "type": "string" },
"Stage": { "type": "string" },
"EstimatedCloseDate": { "type": "string" },
"DealValue": { "type": "string" },
"IsAtRisk": { "type": "boolean" }
}
}
}
Add the Word Online (Business) action: "Populate a Microsoft Word template." Configure it:
SalesOpportunityReport_Template.docxAfter selecting the file, Power Automate reads the template and exposes all your content control tags as input fields. This is the magic moment — you'll see AccountName, SalesRepName, ReportDate, etc. appear as fields in the action.
Fill in the scalar fields by referencing the trigger outputs:
AccountName → triggerBody()?['AccountName']
SalesRepName → triggerBody()?['SalesRepName']
ReportDate → triggerBody()?['ReportDate']
ExecutiveSummary → triggerBody()?['ExecutiveSummary']
For OpportunityRows (the repeating section), you'll see a different UI — an "Item" section that lets you map to the parsed JSON array. Set the array source to the output of your Parse JSON action (the body), then map each inner field:
DealName → items('Apply_to_each')?['DealName']
Stage → items('Apply_to_each')?['Stage']
EstimatedCloseDate → items('Apply_to_each')?['EstimatedCloseDate']
DealValue → items('Apply_to_each')?['DealValue']
Warning: The Word Online "Populate template" action uses a proprietary rendering engine that does not perfectly replicate Word's own rendering. Complex formatting, nested tables, and certain fonts may render differently in the output. Always test with your actual template before deploying. A simple, clean template design beats a complex one every time.
After populating the template, the output is a Word document (DOCX) — not yet a PDF. To convert it, you have options:
Option A: Word Online Business — Convert to PDF
Add the action "Convert Word Document to PDF" from the Word Online Business connector. Provide the file content from the previous step's output (Microsoft Word Document body). This is the simplest path.
Option B: OneDrive — Save DOCX, then convert Save the DOCX to a temp location in OneDrive, then use the OneDrive "Convert file" action to get PDF content. This gives you more control but adds latency.
For most use cases, Option A is sufficient. Use Option B when you need to also retain the DOCX, or when the conversion quality of Option A is insufficient for your document complexity.
After conversion, you have the PDF as a binary content output. Now decide on delivery:
Save to SharePoint:
Add action: SharePoint — Create File
Site Address: [your site]
Folder Path: /Reports/[AccountName]
File Name: @{triggerBody()?['AccountName']}_Report_@{formatDateTime(utcNow(), 'yyyyMMdd')}.pdf
File Content: [PDF content from Convert action]
Send via Email:
Add action: Office 365 Outlook — Send an email (V2)
To: [recipient from trigger input or dynamic]
Subject: Sales Opportunity Report - @{triggerBody()?['AccountName']}
Body: Please find the attached report.
Attachments:
- Name: Report.pdf
- Content: [PDF content from Convert action]
Return URL to Canvas App:
After saving to SharePoint, add a "Get file properties" action to retrieve the absolute URL, then add a "Respond to a PowerApp or flow" action with a Text output called ReportURL containing the file's URL.
Back in your Canvas App, the button that triggers document generation needs to do several things in one go: serialize the opportunities collection, call the flow, and handle the response.
Power Apps has JSON() — a built-in function that serializes tables and records to JSON strings. Here's how to serialize your opportunities collection:
Set(
varOpportunityJSON,
JSON(
AddColumns(
Filter(
colOpportunities,
AccountID = varSelectedAccount.AccountID
),
"IsAtRisk",
DaysToClose < 14 && Stage <> "Closed Won"
),
JSONFormat.IndentFour
)
)
This is doing real work: filtering to the selected account, adding a derived IsAtRisk column calculated from business logic, and serializing the result. JSONFormat.IndentFour makes it human-readable for debugging — switch to JSONFormat.Compact in production for smaller payloads.
Tip: Always validate your JSON output before wiring it to a flow. Use a label control with Text set to
varOpportunityJSONtemporarily so you can inspect the serialized payload. Malformed JSON passed to Parse JSON in Power Automate is one of the top causes of document generation failures.
Now wire up the button:
// On button OnSelect:
Set(varGeneratingReport, true);
Set(
varReportResult,
SalesReportFlow.Run(
varSelectedAccount.AccountName,
varCurrentUser.FullName,
Text(Today(), "[$-en-US]mmmm d, yyyy"),
varExecutiveSummary,
varOpportunityJSON
)
);
Set(varGeneratingReport, false);
If(
!IsBlank(varReportResult.reporturl),
Launch(varReportResult.reporturl),
Notify(
"Report generated successfully. Check your email.",
NotificationType.Success
)
)
The SalesReportFlow.Run() call is synchronous from Power Apps' perspective — the app waits for the flow to complete and return. This is important: if your flow takes 30+ seconds, the user is staring at a spinner. Set varGeneratingReport to true before the call and false after it, then bind a loading overlay's Visible property to varGeneratingReport to keep the UX honest.
For long-running flows, consider a fire-and-forget pattern instead:
// Fire-and-forget: don't wait for return value
SalesReportFlow.Run(
varSelectedAccount.AccountName,
varCurrentUser.FullName,
Text(Today(), "[$-en-US]mmmm d, yyyy"),
varExecutiveSummary,
varOpportunityJSON
);
Notify(
"Your report is being generated. You'll receive it by email shortly.",
NotificationType.Information
)
This returns immediately, the flow runs in the background, and delivery happens via email. Users get a responsive app experience. The trade-off: you can't return a URL link directly. For high-volume report generation in an enterprise context, this pattern is almost always preferable.
Sometimes users want to see what the report will look like before committing to generation. Or they want a lightweight "preview" that they can export only if they approve it. This is where the HtmlText control inside Canvas Apps becomes genuinely powerful.
The HtmlText control renders a subset of HTML — not a full browser engine, but enough for formatted text, tables, and basic styling. It's the closest thing Canvas Apps has to a rich document renderer.
Canvas Apps' HtmlText control supports:
<h1> through <h4> headings<p>, <br> for paragraphs and line breaks<b>, <i>, <u>, <strong>, <em> for inline formatting<table>, <tr>, <td>, <th> for tables (basic styling only)<ul>, <ol>, <li> for listsstyle attributes for color, font-size, text-align, font-weight<a> tags (rendered as text; not clickable in most contexts)What it does not support well:
Work within these constraints and HtmlText becomes a powerful preview tool. Fight against them and you'll spend hours trying to render something the control was never designed to handle.
Here's a realistic approach: build the HTML string in Power Fx and bind it to the HtmlText control's HtmlText property.
Create a variable varReportPreviewHTML assembled like this:
Set(
varReportPreviewHTML,
"<div style='font-family: Calibri, sans-serif; color: #222;'>" &
// Header Section
"<h2 style='color: #1a3c5e; border-bottom: 2px solid #1a3c5e; padding-bottom: 6px;'>" &
"Sales Opportunity Report</h2>" &
"<p><strong>Account:</strong> " & varSelectedAccount.AccountName & "</p>" &
"<p><strong>Sales Rep:</strong> " & varCurrentUser.FullName & "</p>" &
"<p><strong>Report Date:</strong> " & Text(Today(), "[$-en-US]mmmm d, yyyy") & "</p>" &
// Executive Summary
"<h3 style='color: #1a3c5e;'>Executive Summary</h3>" &
"<p>" & varExecutiveSummary & "</p>" &
// Opportunities Table Header
"<h3 style='color: #1a3c5e;'>Open Opportunities</h3>" &
"<table style='width:100%; border-collapse: collapse; font-size: 13px;'>" &
"<tr style='background-color: #1a3c5e; color: white;'>" &
"<th style='padding: 8px; text-align: left;'>Deal Name</th>" &
"<th style='padding: 8px; text-align: left;'>Stage</th>" &
"<th style='padding: 8px; text-align: left;'>Close Date</th>" &
"<th style='padding: 8px; text-align: right;'>Value</th>" &
"</tr>" &
// Table Rows (concatenated from collection)
Concat(
colOpportunities,
"<tr style='border-bottom: 1px solid #ddd; background-color: " &
If(IsAtRisk, "#fff3cd", "white") & ";'>" &
"<td style='padding: 8px;'>" & DealName & "</td>" &
"<td style='padding: 8px;'>" & Stage & "</td>" &
"<td style='padding: 8px;'>" & Text(EstimatedCloseDate, "[$-en-US]mmm d, yyyy") & "</td>" &
"<td style='padding: 8px; text-align: right;'>" & Text(DealValue, "$#,##0") & "</td>" &
"</tr>"
) &
// Table Footer
"<tr style='background-color: #f0f0f0; font-weight: bold;'>" &
"<td colspan='3' style='padding: 8px;'>Total Pipeline</td>" &
"<td style='padding: 8px; text-align: right;'>" &
Text(Sum(colOpportunities, DealValue), "$#,##0") &
"</td></tr>" &
"</table>" &
// At-Risk Notice
If(
CountIf(colOpportunities, IsAtRisk) > 0,
"<div style='background-color: #f8d7da; border: 1px solid #f5c6cb; " &
"padding: 10px; margin-top: 16px; border-radius: 4px;'>" &
"<strong style='color: #721c24;'>⚠ At-Risk Deals:</strong> " &
Text(CountIf(colOpportunities, IsAtRisk)) &
" deal(s) require immediate attention.</div>",
""
) &
"</div>"
)
Set your HtmlText control's HtmlText property to varReportPreviewHTML.
This gives users a live, formatted preview inside the app. The conditional at-risk notice appears only when relevant. Rows are highlighted differently based on risk status. This is genuinely useful — not a toy.
Warning:
Concat()with complex string templates can become a performance bottleneck with large collections. If you're rendering previews for collections larger than 100 rows, consider filtering to a summary (top 10 + totals) rather than rendering all rows. The HtmlText control is not virtualized — it renders everything you give it.
One powerful pattern: update varReportPreviewHTML every time the user changes a selection. Rather than making it a button action, make it a formula in response to data changes:
// In a Timer control's OnTimerEnd, or on a Gallery's OnSelect:
Set(
varSelectedAccount,
Gallery_Accounts.Selected
);
ClearCollect(
colOpportunities,
Filter(Opportunities, AccountId = varSelectedAccount.AccountID)
);
// varReportPreviewHTML auto-updates because it references the collection
Because varReportPreviewHTML is defined as a formula referencing colOpportunities, Power Apps' reactive engine will recompute it automatically when the collection changes. You don't need to explicitly call a "refresh preview" action.
Here's a technique that combines the Power Automate flow and the HtmlText control in a more sophisticated way. Instead of using the Word template for all content composition, use Power Automate to generate an HTML string for complex dynamic sections — conditional risk analysis, ranking tables, trend callouts — and inject that HTML into a Rich Text content control in your Word template.
Rich Text content controls in Word (as opposed to Plain Text controls) accept HTML-like formatting markup. When populated via the Word Online connector, they can interpret and apply basic HTML structure.
In your Power Automate flow, add a Compose action to build the at-risk section HTML:
Name: Compose_AtRiskSection
Inputs:
<concat expression>
Using Power Automate's expression language:
@{if(
greater(length(body('Filter_AtRisk_Opportunities')), 0),
concat(
'<h3>At-Risk Deals Requiring Attention</h3>',
'<p>The following ',
string(length(body('Filter_AtRisk_Opportunities'))),
' deal(s) are at risk of missing their close dates:</p>',
'<ul>',
... (built via Apply to Each over the filtered array),
'</ul>'
),
'<p>No deals are currently at risk. Pipeline health is good.</p>'
)}
Then map this Compose output to your AtRiskSection Rich Text content control in the Word template action. The Word Online engine will render the HTML formatting inside the Word document.
This pattern lets you have a template that's visually stable — your design team can open and edit the DOCX without breaking anything — while all the conditional, data-driven content is generated programmatically in Power Automate.
Document generation is inherently heavyweight. Let's talk about where the time goes and how to minimize it.
A typical document generation flow takes 15–60 seconds. Here's roughly where that time is spent:
The Word Online actions are your bottleneck. You can't meaningfully optimize them — they're cloud service calls with fixed overhead. What you can do is manage user expectations and architect around the latency.
For reports that reliably take more than 30 seconds, move to an async pattern with status tracking:
JobIDGenerating, Complete, Failed)// Timer control, Duration: 5000, Repeat: true
// OnTimerEnd:
If(
varGenerating,
Set(
varJobStatus,
LookUp(
ReportJobs,
JobID = varCurrentJobID,
Status
)
);
If(
varJobStatus = "Complete",
Set(varGenerating, false);
Set(
varReportURL,
LookUp(ReportJobs, JobID = varCurrentJobID, ReportURL)
);
Notify("Your report is ready!", NotificationType.Success)
)
)
This keeps the app responsive and gives users real-time feedback without blocking on a long-running HTTP call.
Every additional content control, every additional row in a repeating section, and every piece of complex formatting adds to generation time. Profile your templates:
If your reports routinely exceed 30 rows in repeating sections, consider generating summary reports in Canvas Apps (with aggregation applied first) rather than raw data dumps. Paging large datasets into multiple reports is also a valid pattern — generate a 1-per-account report rather than one massive multi-account report.
Build the following end-to-end:
Scenario: Your company tracks IT support tickets in Dataverse. Build a Canvas App that lets IT managers select a department and generate a PDF "Weekly Support Digest" report.
Step 1: Dataverse Setup Create a simple Tickets table with columns: TicketTitle (Text), Priority (Choice: High/Medium/Low), Status (Choice: Open/In Progress/Resolved), ResolvedDate (Date), AssignedTechnician (Text), DepartmentID (Lookup).
Step 2: Word Template Design a Word template with:
DepartmentName, WeekOf, GeneratedByTotalTickets, OpenTickets, ResolvedTicketsTicketRows with inner fields TicketTitle, Priority, Status, AssignedTechnicianCriticalAlert plain text field for a dynamically composed high-priority warningSave the template to SharePoint.
Step 3: Power Automate Flow Create a PowerApps V2-triggered flow that:
DepartmentName, WeekOf, GeneratedBy, TicketSummaryJSONCriticalAlert message: if any ticket in the JSON has Priority = "High" and Status = "Open", compose "⚠ [N] high-priority tickets are unresolved. Escalation recommended."ReportStatus string of "Sent" or "Failed"Step 4: Canvas App Build a Canvas App with:
colTickets via Dataverse FiltercolTickets to JSON (including a computed IsHighPriority column) and calls the flowvarGenerating booleanChallenge: Add a filter chip above the ticket gallery that lets the user select "This Week Only" vs "All Open" — and make sure the report accurately reflects whichever filter is active at generation time.
Symptom: Power Automate shows no fields when you select your Word template, or some fields are missing.
Cause: The Tag property of your content controls doesn't match what Power Automate expects. Power Automate reads the Tag, not the Title.
Fix: In Word's Developer tab, select each content control and click Properties. Verify that the Tag field is set and spelled exactly as you expect. Re-save the DOCX to SharePoint after any changes — the connector caches template metadata.
Symptom: Your Word template generates with a blank table body — no rows appear.
Cause: The array mapped to the repeating section is empty, or the JSON parsing schema doesn't match the actual payload structure.
Fix: Add a "Compose" action immediately after Parse JSON and output length(body('Parse_JSON')) to verify the array has data. Then verify the inner field mappings match your schema exactly (case-sensitive).
Symptom: Your Power Automate Parse JSON action fails with schema validation errors.
Cause: The JSON() function in Power Apps serializes certain data types in unexpected ways — Choice fields may serialize as records with Value and other properties, Dates may serialize as ISO 8601 strings or numbers depending on context.
Fix: Use AddColumns() to explicitly cast all fields before serialization:
JSON(
AddColumns(
colTickets,
"TicketTitleText", TicketTitle,
"PriorityText", Priority.Value,
"StatusText", Status.Value,
"ResolvedDateText", Text(ResolvedDate, "[$-en-US]yyyy-mm-dd"),
"IsHighPriority", Priority.Value = "High"
),
JSONFormat.Compact
)
Symptom: The generated PDF looks different from the Word template — fonts changed, spacing off, colors wrong.
Cause: The Word Online conversion engine doesn't have access to fonts that aren't installed in the cloud service. Custom or specialty fonts will fall back to defaults.
Fix: Use web-safe or Microsoft 365-bundled fonts: Calibri, Calibri Light, Cambria, Arial, Times New Roman. Test conversion in the environment where Power Automate runs (consider that Government Cloud vs. Commercial Cloud may have different font sets).
Symptom: The Power Apps call to the flow returns an error after 120 seconds.
Cause: Canvas App HTTP calls to Power Automate flows time out after 120 seconds by default.
Fix: Switch to a fire-and-forget pattern (don't return a value from the flow). Or split large report generation into multiple parallel flows using Power Automate's parallel branch feature and then merge results. For truly complex documents, consider Azure Logic Apps with longer timeout configurations rather than Power Automate.
Symptom: The report preview HTML renders incorrectly or cuts off partway through.
Cause: Data values contain characters that break HTML parsing: <, >, &, " appearing in text fields.
Fix: Sanitize values before injecting them into HTML:
// Simple sanitization function (put in a named formula or variable)
Substitute(
Substitute(
Substitute(
Substitute(fieldValue, "&", "&"),
"<", "<"),
">", ">"),
"""", """
)
Apply this to every data value you inject into HTML strings.
You now have a complete, production-grade understanding of Canvas App document automation. Let's recap the architecture and the key decisions:
The pipeline: Canvas App → JSON payload → Power Automate flow → Word template population → PDF conversion → delivery (email/SharePoint/link returned). Each stage has a clear responsibility and can be tested independently.
Word templates: Content controls with precise Tag properties are your interface contract between the template and Power Automate. Repeating sections handle dynamic tables. Complex conditional content is best composed in Power Automate and injected into Rich Text controls.
Power Automate: The V2 PowerApps trigger with strong typing is the correct connector. Parse JSON early, compose dynamic sections as strings, and use error handling (Scope + Configure Run After) to catch and report failures gracefully.
HtmlText controls: Powerful for in-app previews. Build HTML strings using Concat() over collections and inline Power Fx expressions. Sanitize user data before injection. Keep HTML simple — the control is a formatter, not a browser.
Performance: Expect 15–60 seconds per document. Use async patterns with status polling for anything over 30 seconds. Optimize payload size through aggregation and sampling for previews.
For your next steps, explore:
The skills you've built here — JSON serialization, Word content controls, dynamic HTML composition, async flow patterns — are transferable to almost every document automation scenario you'll encounter. The specific tools will evolve; the architecture thinking won't.