TX Text Control .NET for Windows Forms

Mail Merge Performance

Topic

How long does a merge take, and what makes it longer? Text Control has measured MailMerge with typical invoice templates, and the numbers show which parts of a template cost time and how far one thread goes. The same measurements lead to three practices for Windows Forms applications: design templates with the costs in mind, merge in the background with visible progress, and move large batches into separate processes.

What Text Control Measured

Text Control's MailMerge benchmark of 2023 merged an invoice template with a header, an SVG logo, an address and a merge block of line items, and saved the result as PDF. It ran as a .NET console application with ServerTextControl and MailMerge of TX Text Control .NET Server 31.0 SP2, on one thread, on an AMD Ryzen 7 2700X eight-core processor with 3.70 GHz and 16 GB of memory:

ScenarioResult
One invoice of one page with 5 rows in the merge block0.35 seconds; the time grows linearly with the number of invoices, 80 invoices took about 16 seconds
One invoice of six pages with 100 rows in the merge block3.2 seconds, again growing linearly with the number of invoices
One merge block with a growing number of rows0.23 seconds for one row, growing faster than the number of rows up to 1,280 rows on 59 pages
The same rows split into two merge blocksFaster than the same number of rows in one block
Two formulas in the merge blockAcceptable for short tables; the formulas are calculated again for every row
Encryption, digital signatures and embedded files in the PDFNo significant extra time; embedded files depend on the size of the file

These numbers were measured with the server product and its hardware; they show proportions, not what your application will reach. The blog article MailMerge Class Performance Benchmark contains the charts and the code.

Design Templates With the Costs in Mind

  • Keep long tables in several merge blocks. A single merge block took disproportionately longer as its rows grew, and the same rows in two blocks merged faster. When a report lists thousands of items, group them, for example by month or category, into separate blocks. Merge Blocks describes how blocks are built.
  • Calculate long columns in the data. Formulas in a merge block are calculated for every row. For a table with many rows, pass totals and line amounts in the data instead of calculating them in the template.
  • Create separate documents when you need them. With append set to false, each record becomes a document of its own, which the DataRowMerged event delivers, as Create One Document or One per Record shows.

Merge in the Background With Progress and Cancel

A MailMerge whose TextComponent is the TextControl of the form merges on the UI thread, and the form doesn't respond until the merge ends. Connected to a ServerTextControl, it can merge on a background thread. Its DataRowMerged event occurs after each record, which makes it the place to report progress and to cancel.

The following handlers in Form1.cs merge all records of invoice.json into the template invoice.docx, move a ProgressBar named progressBar1, stop when the user clicks cancelMergeButton and show the result in textControl1:

using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using TXTextControl;
using TXTextControl.DocumentServer;

private CancellationTokenSource mergeCancellation;

private async void mergeButton_Click(object sender, EventArgs e)
{
    string json = File.ReadAllText("invoice.json");

    using (JsonDocument data = JsonDocument.Parse(json))
    {
        progressBar1.Maximum = data.RootElement.GetArrayLength();
    }

    progressBar1.Value = 0;
    mergeButton.Enabled = false;

    IProgress<int> progress = new Progress<int>(count => progressBar1.Value = count);
    mergeCancellation = new CancellationTokenSource();
    CancellationToken token = mergeCancellation.Token;

    try
    {
        byte[] result = await Task.Run(() =>
        {
            using (ServerTextControl tx = new ServerTextControl())
            {
                tx.Create();

                LoadSettings loadSettings = new LoadSettings
                {
                    ApplicationFieldFormat = ApplicationFieldFormat.MSWord,
                    LoadSubTextParts = true
                };

                tx.Load("invoice.docx", StreamType.WordprocessingML, loadSettings);

                using (MailMerge mailMerge = new MailMerge { TextComponent = tx })
                {
                    int merged = 0;

                    // Runs on this background thread after each record
                    mailMerge.DataRowMerged += (s, args) =>
                    {
                        progress.Report(++merged);
                        args.Cancel = token.IsCancellationRequested;
                    };

                    mailMerge.MergeJsonData(json);
                }

                tx.Save(out byte[] document, BinaryStreamType.InternalUnicodeFormat);
                return document;
            }
        });

        textControl1.Load(result, BinaryStreamType.InternalUnicodeFormat);
    }
    catch (MergeCanceledException)
    {
        MessageBox.Show(this, "The merge was canceled.");
    }
    finally
    {
        mergeButton.Enabled = true;
        mergeCancellation.Dispose();
        mergeCancellation = null;
    }
}

private void cancelMergeButton_Click(object sender, EventArgs e)
{
    mergeCancellation?.Cancel();
}

The code works as follows:

  • The template is loaded with LoadSettings that import the MS Word fields and the merge blocks of the DOCX file, as in Merge Data into Fields. MergeJsonData merges all records into one document.
  • The DataRowMerged handler runs on the thread that merges, not on the UI thread. It doesn't touch the form: Progress<int> passes the count to the UI thread.
  • Setting Cancel of the event arguments to true ends the merge with a MergeCanceledException, which await throws again on the UI thread.
  • The merged document returns as a byte array and is loaded into the editor on the UI thread.

For long merges, MailMerge.MergeCulture matters too: it defaults to the culture of the current thread, which on a background thread may not be the one of your form. UI Threading and Background Operations explains why.

Merge Large Batches in Separate Processes

Threads that merge in the same process can wait for each other: TX Text Control guards resources that all its instances in a process share with critical sections. For merges of hundreds or thousands of documents, Text Control therefore recommends several processes instead of several threads. In its sample of 2019, a host application passed each template and its data to a new process of a processing application, which merged it with ServerTextControl and MailMerge and returned the PDF through anonymous pipes. Merging 100 templates took about 45 seconds with normal processing and 14 seconds with parallel processes on a CPU with 16 cores. The sample is a Windows Forms application; the blog article True Parallel Processing of Documents using MailMerge describes it and its code.

Text Control also publishes the NuGet package TXTextControl.DocumentServer.ParallelMailMerge, which runs MailMerge jobs in a pool of worker processes that your application starts. It names Windows Forms applications among the project types it can be used in, but it targets .NET 10 and depends on TXTextControl.TextControl.Core.SDK, the package of TX Text Control .NET Server, not on TXTextControl.TextControl.WinForms.SDK. Multiple Processes in the documentation of TX Text Control .NET Server describes the package, its options and its measurements.

Next Steps