Table of Contents

Mail merge: data sources and options

The invoice tutorial merges typed C# objects. This page covers every other way to feed a merge (CSV files, databases, dictionaries) and the options that shape the output.

Any data source: IMergeDataReader

A merge reads records through a four-member, forward-only interface shaped like DbDataReader. This one reads a CSV file:

using DocWright.MailMerge;

/// <summary>
/// Merge data from a CSV file. Any source - a DbDataReader, a DataTable, an API - plugs in
/// through the same four members, so DocWright.MailMerge never depends on System.Data.
/// </summary>
internal sealed class CsvMergeReader : IMergeDataReader
{
    private readonly string[] columns;
    private readonly List<string[]> rows;
    private int index = -1;

    public CsvMergeReader(string path)
    {
        string[] lines = File.ReadAllLines(path);
        this.columns = lines[0].Split(',');
        this.rows = lines.Skip(1).Select(l => l.Split(',')).ToList();
    }

    public int FieldCount => this.columns.Length;

    public string GetFieldName(int index) => this.columns[index];

    public object? GetValue(int index) => this.rows[this.index][index];

    public bool Read() => ++this.index < this.rows.Count;   // called before the first record
}

Merge with it:

using System.Globalization;
using DocWright;
using DocWright.Dom;
using DocWright.MailMerge;

using WordDocument letters = converter.Load(File.OpenRead("letter-template.docx"));

var options = new MailMergeOptions
{
    Culture = CultureInfo.GetCultureInfo("en-US"),
    RecordSeparator = MergeRecordSeparator.PageBreak,      // each letter on a new page
    RemoveEmptyParagraphs = true,                          // the default: drops the empty «MiddleName» line

    // Change a value just before it is written.
    FieldMerging = e =>
    {
        if (e.FieldName == "City" && e.RegionName is null)
        {
            e.Value = e.Value?.ToUpperInvariant();
        }
    },
};

MergeResult result = letters.Execute(new CsvMergeReader("customers.csv"), options);
Console.WriteLine($"{result.RecordsMerged} letters, {result.ParagraphsRemoved} empty line(s) removed");

using (FileStream pdf = File.Create("letters.pdf"))
{
    converter.Convert(letters, pdf);
}

Output

5 letters, 2 empty line(s) removed
letters.pdf · the first two of five letters
A letter to Ada Byron Lovelace, London in capitalsAda has a middle name
A letter to Grace Hopper with no empty middle-name lineGrace doesn't: the empty line is removed
Template: letter-template.docx. Data: DocWright's customers.csv test fixture.
  • Field names match case-insensitively.
  • Return typed values (decimal, DateTime), not strings, where you have them. Values are formatted under MailMergeOptions.Culture, and numeric switches such as \# "#,##0.00" need a number.
  • Wrapping a DbDataReader takes one line per member: FieldCount, GetName(i), GetValue(i) and Read().
  • For repeating regions, also implement IMergeRelationSource.TryGetRelation(name, out reader) on the same class and return a reader for the child rows. The child reader must be positioned before its first row.

The other data sources

Source Call
Typed objects, trim- and AOT-safe MergeDataSource.FromObjects(records, map) with a MergeFieldMap<T>. See the tutorial.
Typed objects, by reflection MergeDataSource.FromObjectsByReflection(records). Convenient, but not trim-safe.
Dictionaries MergeDataSource.FromDictionaries(rows). A nested IEnumerable under a region's name supplies its rows.
One record document.Execute(dictionary), as below.
using DocWright.Dom;
using DocWright.MailMerge;

// One letter from a dictionary: no data source needed.
using WordDocument single = converter.Load(File.OpenRead("letter-template.docx"));
single.Execute(new Dictionary<string, object?>
{
    ["FirstName"] = "Annie",
    ["LastName"] = "Easley",
    ["City"] = "Cleveland",
});

Options

Option Default Notes
Culture invariant Formats numbers and dates. Always set it. It is never taken from the machine.
RecordSeparator PageBreak None runs records together, for labels and lists. SectionBreak separates records with a section break; in the current version it is a continuous break, so records share a page.
RemoveEmptyParagraphs true A paragraph that held only empty fields disappears. One with other text too keeps that text.
UnmergedFields Keep What to do with fields the data has no column for: RemoveField keeps their text, RemoveFieldAndText removes both.
RemoveEmptyGroups false Remove a region whose collection was empty.
TrimWhitespace false Trim each value.
MaxRecords 0 (no limit) Stops the merge quietly at this count. Check RecordsMerged.
MaxRegionDepth 16 Limit on nested regions.
FieldMerging null A callback that can change each value: FieldName, Value, HasValue, RecordIndex, RegionName.
ImageResolving null Supplies images for image merge fields.
Diagnostics null Where merge diagnostics go.

Images

An image merge field, written «Image:FieldName» in the template, gets its picture only through your callback. Returning null removes the field. DocWright never fetches a URL or file path named in a template, including in INCLUDEPICTURE fields, because a template is untrusted input:

options.ImageResolving = request => request.Value is null
    ? null                                             // no image for this record
    : new MergeImage(File.ReadAllBytes(request.Value), "image/png");

The image takes the size of the template's placeholder picture unless you set Width and Height (in Emu).

The result

MergeResult reports RecordsMerged, RecordsSkipped (by a SKIPIF field), FieldsMerged, FieldsUnmerged, ParagraphsRemoved and UnmergedFieldNames. A name in UnmergedFieldNames is almost always a typo in the template. Log it.

The merged document contains no fields, only their text. Merging the same data twice produces byte-identical files. Headers, footers, text boxes and notes are merged too.