TX Text Control .NET Server

Nested Blocks

Topic

Orders with their articles, customers with their contacts, departments with their employees: master-detail data has levels, and a template for it has merge blocks inside merge blocks. You shape nested data as JSON, business objects or a DataSet with relations, create nested blocks in code, read the hierarchy of a template and control what happens when a level has no data.

Merge Blocks in Getting Started shows the first nested data, and Merge Blocks describes blocks at one level.

How Nested Blocks Work

A master-detail relationship is a one-to-many relationship: an order has many articles. In a template, the outer block, here orders, is repeated for each order, and the inner block, articles, is repeated for the articles of the current order. MailMerge descends into the hierarchy recursively, so blocks can be nested in any number of levels.

  • Block names match the data. Each block is named like the array, collection or table that provides its records.
  • Fields refer to the current record of their block. In the articles block, the field qty is the quantity of the current article, and product.name addresses the name of its nested product object. Fields of the order stay outside the articles block.
  • Blocks don't overlap. An inner block lies completely inside its outer block. The start or the end of two blocks can be at the same position.

A common layout is a paragraph block for each order with a table inside, whose row is the block for the articles.

Shape Nested Data

Nested arrays in JSON map to nested blocks. Save the following data as orders.json; it extends the sample of Getting Started with a second order:

[
  {
    "company_name": "Text Control, LLC",
    "orders": [
      {
        "id": 123,
        "articles": [
          {
            "id": 1,
            "product": { "name": "Product A", "price": 200 },
            "qty": 5
          }
        ]
      },
      {
        "id": 321,
        "articles": [
          {
            "id": 2,
            "product": { "name": "Product B", "price": 50 },
            "qty": 12
          }
        ]
      }
    ]
  }
]

The template for this data has these elements:

LevelElementFields
RootMerge fields outside all blockscompany_name
1Merge block ordersid
2Merge block articles, inside ordersproduct.name, qty

The blog article Mail Merge: The Pre-Shaped Data Concept Explained designs a template for such data: the orders block holds an order number and a table, whose row is the articles block with fields such as product.name and qty:

Template with an orders merge block containing the order number and a table whose row is the nested articles merge block, with arrows to the orders, articles and product tables of the data

Merged with two orders, the orders block is repeated twice, and each table has one row for each article of its order:

Merged document with order 123 and order 321, each followed by a table that lists the products, quantities, prices and line totals of that order

Create a Nested Template in Code

MergeBlockInfo.ChildBlocks holds the blocks nested inside a block. Pass a MergeBlockInfo with child blocks to InsertMergeBlock, and all levels are inserted in one call:

using TXTextControl;
using TXTextControl.DocumentServer;
using TXTextControl.DocumentServer.DataSources;

string jsonData = File.ReadAllText("orders.json");

using (ServerTextControl tx = new ServerTextControl())
{
    tx.Create();

    DataSourceManager dataSourceManager = new DataSourceManager();
    dataSourceManager.LoadJson(jsonData);

    // Outer block: repeated for each order
    MergeBlockInfo ordersBlock = new MergeBlockInfo("orders")
    {
        ColumnNames = new List<string> { "id" },

        // Inner block: repeated for each article of the current order
        ChildBlocks = new List<MergeBlockInfo>
        {
            new MergeBlockInfo("articles")
            {
                ColumnNames = new List<string> { "product.name", "qty" }
            }
        }
    };

    dataSourceManager.InsertMergeBlock(tx, ordersBlock,
        new MergeBlockSettings(BlockTemplateType.TableRow, true));

    // Keep the generated template
    tx.Save("orders-template.tx", StreamType.InternalUnicodeFormat);

    using (MailMerge mailMerge = new MailMerge { TextComponent = tx })
    {
        mailMerge.MergeJsonData(jsonData);
    }

    tx.Save("orders.pdf", StreamType.AdobePDF);
}

The code works as follows:

  • LoadJson analyzes the data, including its nested arrays.
  • Each MergeBlockInfo describes one level: its name and, in ColumnNames, the merge fields of that level. product.name addresses the nested object of an article with dot notation.
  • InsertMergeBlock creates the tables, merge fields and blocks of all levels, each inner block inside its outer block.
  • The template is saved as orders-template.tx before the merge, so the following examples can use it.

The online demo Nested Merge Block Designer builds nested blocks with several levels in the Document Editor this way.

Read the Block Hierarchy of a Template

A MergeBlockInfo that is created from an existing block contains its child blocks. The following program prints the block hierarchy of orders-template.tx:

using TXTextControl;
using TXTextControl.DocumentServer.DataSources;

using (ServerTextControl tx = new ServerTextControl())
{
    tx.Create();
    tx.Load("orders-template.tx", StreamType.InternalUnicodeFormat);

    foreach (SubTextPart subTextPart in tx.SubTextParts)
    {
        // Start with the merge blocks at the top level
        if (DataSourceManager.IsMergeBlock(subTextPart) && subTextPart.NestedLevel == 1)
        {
            PrintBlock(new MergeBlockInfo(subTextPart), 0);
        }
    }
}

// Prints a block and, indented, its child blocks
static void PrintBlock(MergeBlockInfo block, int depth)
{
    Console.WriteLine($"{new string(' ', depth * 2)}{block.TableName}");

    if (block.ChildBlocks == null)
    {
        return;
    }

    foreach (MergeBlockInfo child in block.ChildBlocks)
    {
        PrintBlock(child, depth + 1);
    }
}

SubTextPart.NestedLevel is 1 for a block at the top level, and TableName is the name of the data a block is bound to. The program prints orders and, indented, articles.

Merge Nested Business Objects

With business objects, a property of type IEnumerable provides the records of the block with its name, at every level. The following program merges the same template with objects:

using TXTextControl;
using TXTextControl.DocumentServer;

Company company = new Company
{
    company_name = "Text Control, LLC",
    orders = new List<Order>
    {
        new Order
        {
            id = 123,
            articles = new List<Article>
            {
                new Article { id = 1, qty = 5, product = new Product { name = "Product A", price = 200 } }
            }
        }
    }
};

using (ServerTextControl tx = new ServerTextControl())
{
    tx.Create();
    tx.Load("orders-template.tx", StreamType.InternalUnicodeFormat);

    using (MailMerge mailMerge = new MailMerge { TextComponent = tx })
    {
        mailMerge.MergeObject(company);
    }

    tx.Save("orders.pdf", StreamType.AdobePDF);
}

public class Company
{
    public string company_name { get; set; } = "";
    public List<Order> orders { get; set; } = new List<Order>();
}

public class Order
{
    public int id { get; set; }
    public List<Article> articles { get; set; } = new List<Article>();
}

public class Article
{
    public int id { get; set; }
    public Product product { get; set; } = new Product();
    public int qty { get; set; }
}

public class Product
{
    public string name { get; set; } = "";
    public decimal price { get; set; }
}

The property names match the names of the blocks and fields in the template. MergeObject merges the single company; MergeObjects merges a collection of them.

Merge a DataSet With Relations

In a DataSet, relations define the levels. Each block is named like a DataTable, and a DataRelation connects each table with the table of its parent block. Merge the master table with Merge, and MailMerge follows the relations into the nested blocks:

using System.Data;
using TXTextControl;
using TXTextControl.DocumentServer;

DataSet dataSet = new DataSet();

DataTable company = dataSet.Tables.Add("company");
company.Columns.Add("company_id", typeof(int));
company.Columns.Add("company_name");
company.Rows.Add(1, "Text Control, LLC");

DataTable orders = dataSet.Tables.Add("orders");
orders.Columns.Add("company_id", typeof(int));
orders.Columns.Add("id", typeof(int));
orders.Rows.Add(1, 123);
orders.Rows.Add(1, 321);

DataTable articles = dataSet.Tables.Add("articles");
articles.Columns.Add("order_id", typeof(int));
articles.Columns.Add("product_id", typeof(int));
articles.Columns.Add("qty", typeof(int));
articles.Rows.Add(123, 1, 5);
articles.Rows.Add(321, 2, 12);

DataTable product = dataSet.Tables.Add("product");
product.Columns.Add("product_id", typeof(int));
product.Columns.Add("name");
product.Columns.Add("price", typeof(decimal));
product.Rows.Add(1, "Product A", 200m);
product.Rows.Add(2, "Product B", 50m);

// The relations connect each block with the rows of its parent
dataSet.Relations.Add(new DataRelation("company_orders",
    company.Columns["company_id"]!, orders.Columns["company_id"]!));
dataSet.Relations.Add(new DataRelation("order_articles",
    orders.Columns["id"]!, articles.Columns["order_id"]!));

// Fields with the prefix product. reach the product of an article
dataSet.Relations.Add(new DataRelation("article_product",
    articles.Columns["product_id"]!, product.Columns["product_id"]!, false));

using (ServerTextControl tx = new ServerTextControl())
{
    tx.Create();
    tx.Load("orders-template.tx", StreamType.InternalUnicodeFormat);

    using (MailMerge mailMerge = new MailMerge { TextComponent = tx })
    {
        // Merge the master table; the related tables fill the nested blocks
        mailMerge.Merge(company, true);
    }

    tx.Save("orders.pdf", StreamType.AdobePDF);
}

The code works as follows:

  • The relations company_orders and order_articles connect each block with its parent, and the tables are named like the blocks orders and articles.
  • Inside the articles block, the field qty has no prefix, because it is a column of the block's own table. The field product.name has the prefix product: a prefix inside a block starts at the table of the block and follows the relation article_product to the product of the current article.
  • The last parameter of the DataRelation constructor, false, creates the relation without constraints. With constraints, the DataSet would require a unique product_id in articles, so two articles of the same product couldn't be added.

Each order of the merged document lists its articles with the product name and the quantity. Prefixes can span more than one relation, such as product.supplier.name for a supplier table related to product, as Merge Fields describes.

Repeat Table Headers per Block

A header row of a table is repeated at the top of each page with the content it had first. When a nested block is a table row inside a table, a header row that shows data of the outer block, such as the order number, would show the first order on every page. Put the whole table, including its header row, inside the outer block instead: each order then gets its own table with its own header. The blog article MailMerge: Table Headers and Repeating Blocks shows both layouts.

Handle Levels Without Data

RemoveEmptyBlocks, true by default, applies to every level: a block without data is removed, and when the data of an outer block is missing, the block is removed with all blocks inside it. An order without articles keeps its order block, but its articles block disappears.

To merge an inner block only for parent records that meet a condition, such as orders above a certain amount, set BlockMergingCondition on the inner block, as Sorting and Filtering describes.

Next Steps