We have a problem, many reports are still designed to work best when printing to physical paper, but that’s not how they’re used anymore. In this video, I take a look at how you can separate reports into multiple PDF files. Check it out:

In this video, Erik tackles a common frustration in Business Central: reports that generate a single monolithic PDF file when you really need separate files for each customer. He demonstrates how to build a “separator” wrapper report that calls the original report once per customer and packages the results into a downloadable ZIP file — effectively modernizing a workflow that’s been stuck in the paper-printing era since 1999.
The Problem: One Report, One PDF, Many Customers
In Business Central, when you run a report like Customer Statements for multiple customers, you get a single PDF file containing all the statements combined. If you have five customers, you get one PDF with five statements. If you have a hundred invoices for different customers, you get one massive PDF.
Back when reports were printed on paper (think dot matrix printers), this wasn’t a problem — you’d print the stack, physically separate the pages, and put each set into an envelope. But in the modern world where reports are consumed as PDFs, this design is a significant limitation. How do you easily extract one customer’s statement from a combined PDF to email it to them? You can’t — at least not easily.
The Core Concept: One Report Call = One Output
The fundamental constraint in Business Central is that a single call to a report produces a single output file. There’s no way to make one report invocation generate multiple separate files. This means that if you need five separate PDFs for five customers, you need to call the report five times.
The solution is to create a wrapper report — a processing-only report that loops through the outer data element (in this case, customers) and calls the original report once per iteration, collecting each output into a ZIP file for download.
Building the Separator Report
Step 1: Create the Processing-Only Wrapper
The wrapper report is a processing-only report whose data item matches the outermost data element of the target report. Since Customer Statements uses Customer as its outer data item, our wrapper also uses Customer:
report 50124 "Customer Statement Separator"
{
ProcessingOnly = true;
UsageCategory = ReportsAndAnalysis;
ApplicationArea = All;
dataset
{
dataitem(Customer; Customer)
{
RequestFilterFields = "No.", "Date Filter";
// triggers go here...
}
}
}
The RequestFilterFields include both the customer number and the date filter — important because the original Customer Statements report requires a date filter to function properly.
Step 2: Capture Report Parameters
The original report has a request page with various options (like “Print All with Balance”). Rather than recreating all those options, we use a clever technique: run the request page of the inner report without actually executing it, and capture the parameters as a text string.
In the OnPreDataItem trigger:
trigger OnPreDataItem()
begin
Parameters := Statement.RunRequestPage();
if Parameters = '' then
Error('You must select options for the report');
Zip.CreateZipArchive();
end;
The RunRequestPage() function displays the report’s request page and returns all the selected options as an XML string — without actually running the report. If the user presses Cancel, the parameters come back blank, and we stop execution.
Step 3: Run the Report for Each Customer
In the OnAfterGetRecord trigger, we call the report once per customer using Report.SaveAs. This is where RecordRef and FieldRef come into play — we need to pass filters dynamically:
trigger OnAfterGetRecord()
var
TempBlob: Codeunit "Temp Blob";
OutStr: OutStream;
InStr: InStream;
RecRef: RecordRef;
FldRef: FieldRef;
begin
TempBlob.CreateOutStream(OutStr);
RecRef.Open(Database::Customer);
// Filter to current customer
FldRef := RecRef.Field(1); // Field 1 = "No."
FldRef.SetRange(Customer."No.");
// Pass along the date filter
FldRef := RecRef.Field(55); // Field 55 = "Date Filter"
FldRef.SetFilter(Customer.GetFilter("Date Filter"));
Report.SaveAs(
Report::"Customer - Statement",
Parameters,
ReportFormat::Pdf,
OutStr,
RecRef
);
TempBlob.CreateInStream(InStr);
Zip.AddEntry(InStr, Customer.Name + '.pdf');
end;
Let’s break down what’s happening here:
- TempBlob + OutStream: We create a temporary blob with an outstream — think of it as a pipe. The report pumps its PDF data into this pipe, and it flows into the blob.
- RecordRef + FieldRef: Since we’re working generically, we use a RecordRef to reference the Customer table and FieldRef to set filters on specific fields. Field 1 is the customer number, and field 55 is the date filter.
- Report.SaveAs: This is the key function. It takes the report ID, the captured parameters XML, the desired format (PDF), the outstream to write to, and the RecordRef with our filters. Note that other “SaveAs” variants are for on-premises only —
Report.SaveAsis the one that works in SaaS. - Zip.AddEntry: After the report runs, we create an instream from the same TempBlob and add it to our ZIP archive with the customer’s name as the filename.
Step 4: Package and Download the ZIP
After all customers have been processed, we save the ZIP archive and trigger a browser download in the OnPostDataItem trigger:
trigger OnPostDataItem()
var
TempBlob: Codeunit "Temp Blob";
OutStr: OutStream;
InStr: InStream;
FileName: Text;
begin
FileName := 'Customer Statements.zip';
TempBlob.CreateOutStream(OutStr);
Zip.SaveZipArchive(OutStr);
TempBlob.CreateInStream(InStr);
DownloadFromStream(InStr, '', '', '', FileName);
end;
This uses the same TempBlob pattern — create an outstream, save the ZIP into it, then create an instream from the same blob to feed into DownloadFromStream.
Understanding the Variable Scoping
Erik highlights an important pattern regarding local versus global variables:
- Global variables: The
Zip(Data Compression codeunit),Parameters(text), andStatement(report) variables are global — they persist across all iterations and triggers. - Local variables: The
TempBlob, streams,RecordRef, andFieldRefare local to each trigger — they get created fresh for each customer iteration, which is exactly what we want.
The Source Code: Layout Selection Bonus
The source code provided also includes a report layout selection mechanism. The MultiReport (report 50123) demonstrates how to let users pick a custom report layout before running:
trigger OnInitReport()
var
RLS: Record "Report Layout Selection";
begin
Layout.SetRange("Report ID", 50123);
if Page.RunModal(50123, Layout) = Action::LookupOK then
RLS.SetTempLayoutSelected(Layout.Code)
else
Error('Please select a layout to continue!');
end;
This is paired with a simple list page that shows available custom report layouts:
page 50123 "Select Layout"
{
PageType = List;
UsageCategory = None;
SourceTable = "Custom Report Layout";
Editable = false;
layout
{
area(Content)
{
repeater(Rep)
{
field(Description; Description)
{
ApplicationArea = All;
}
}
}
}
}
Making It Generic
Erik points out that this solution could be made more generic. Looking at the core logic, only a few things are hard-coded:
- The specific report being called (
Report::"Customer - Statement") - The table being opened in the RecordRef (
Database::Customer) - The field numbers for filtering (1 and 55)
For any report that uses Customer as its outermost data item, this wrapper could be parameterized to accept a report ID and work generically. You could extend this concept further to work with other entity types (vendors, items, etc.) by making the table and field references configurable.
Summary
The key takeaways from this technique are:
- One report call = one output file — this is a fundamental constraint you must work around.
- Wrapper reports solve the problem by iterating over the outer data element and calling the inner report once per record.
RunRequestPage()lets you capture report options without executing the report, so you can replay those options on each iteration.- RecordRef and FieldRef allow you to set filters dynamically when passing record context to
Report.SaveAs. - Data Compression (ZIP) provides a clean way to package multiple PDF outputs into a single downloadable file.
- The entire solution weighs in at roughly 59 lines of meaningful code — compact and effective.
Stop printing like it’s 1999. Separate your reports.