Skip to main content

DDL: documents as text

DDL is PinataLayout's own text format for a document. A Document built in C#, with its styles, sections, paragraphs, tables and fields, can be written out as DDL and read back into an equal Document. The reader and writer are in the PinataLayout.DocumentObjectModel.IO namespace, in the PinataLayout.DocumentObjectModel package. To turn a document you read into a PDF, you also need PinataLayout.Rendering.

DDL is useful for three things:

  • Seeing what a document holds. Write a document to a string and read it. This is often the fastest way to find out why a style or a table border is not what you expected.
  • Keeping templates as text. Store a report layout as a DDL file, read it at run time, and fill it in, instead of building the whole layout in C#.
  • Storing documents. Save the document model itself, and render it again later.

What DDL looks like

Keywords start with a backslash, braces hold content, and square brackets hold attributes as Name = value pairs:

\document[Info{Title = "Monthly report"}]
{
\section[PageSetup{PageFormat = A5}]
{
\paragraph
{
Totals are in \bold{bold} and notes in \italic{italic}.
}
\paragraph
{
Page \field(Page)[] of \field(NumPages)[]
}
}
}

A paragraph's text sits between its braces. Runs of spaces and line breaks in the text read as one space, so you can indent a file however you like. To write a brace or a backslash inside text, put a backslash in front of it: \{, \}, \\.

Write a document to DDL and read it back

DdlWriter.WriteToString turns a document into a string. To read it back, create a DdlReader with a DdlReaderErrors object and call ReadDocument:

src/SampleApp/Demos/DdlDemo.cs
// The whole document as a string. Styles, sections, paragraphs, runs, the table and its
// borders - all of it, in PinataLayout's own grammar rather than XML or JSON.
var ddl = DdlWriter.WriteToString(original);

// And back. A parse failure is reported through DdlReaderErrors rather than thrown, so a
// caller who wants to know has to ask - which is why the errors object is passed in.
//
// Through the instance rather than DdlReader.DocumentFromString, because the static one
// has no overload that takes an errors object where ObjectFromString beside it does. The
// constructors all take one, so the instance is the route to a reader that will tell you
// what it could not parse.
var errors = new DdlReaderErrors();
var reread = new DdlReader(new StringReader(ddl), errors).ReadDocument();

The copy is an ordinary Document. You can change it, add sections to it, or render it:

src/SampleApp/Demos/DdlDemo.cs
var renderer = new PdfDocumentRenderer(unicode: true) { Document = reread };
renderer.RenderDocument();

Other members do the same work with files and with parts of a document:

To do thisWrite withRead with
A whole document, as a stringDdlWriter.WriteToString(document)DdlReader.DocumentFromString(ddl)
A whole document, as a fileDdlWriter.WriteToFile(document, path)DdlReader.DocumentFromFile(path)
One object, such as a tableDdlWriter.WriteToString(table)DdlReader.ObjectFromString(ddl, errors)
One object, as a fileDdlWriter.WriteToFile(table, path)DdlReader.ObjectFromFile(path, errors)

DdlWriter and DdlReader also have constructors that take a Stream, a TextWriter or TextReader, or a file name. WriteToString and WriteToFile have overloads that take an indent width.

Check for errors

The reader records many problems in DdlReaderErrors instead of throwing. An attribute that names no property, for example, is skipped and reported, and the rest of the document is still read. If you pass no errors object, those problems are lost.

DdlReader.DocumentFromString and DocumentFromFile take no errors object. To find out what the reader could not parse, use a DdlReader constructor that takes one, as the demo does, and check it after reading:

if (errors.ErrorCount > 0)
{
foreach (DdlReaderError error in errors)
Console.WriteLine($"{error.SourceLine}:{error.SourceColumn} {error.ErrorMessage}");
}

ErrorCount counts only entries whose ErrorLevel is DdlErrorLevel.Error. Enumerating the object returns every entry, including warnings and information.

Some problems still throw. Text that is not DDL at all, or a keyword in a place the grammar does not allow, raises an exception with a message that says what was expected.

Use DDL as a template

There are two ways to fill in a template you have read:

  • Change the object model after reading. Read the template, find the sections or paragraphs you need through the Document object model, and add content to them in C#. This is the safer way, because nothing you add is parsed.
  • Replace text before reading. Put markers in the DDL and replace them with values before you parse it. If you do this, escape every {, } and \ in the values, or the reader will take them as DDL.

A field such as \field(Info)[Name = "Title"] prints a value from the document's Info, so you can set document.Info.Title after reading and let the renderer put it on the page.

Things to know

  • Some malformed files make the reader hang. A file that ends inside a section, an attribute value that is not a valid enum member, or an attribute block with a missing bracket can make the reader run forever instead of throwing. Read only DDL that your own code wrote, or DDL you have checked. If you must read files from elsewhere, read them on a separate thread that you can abandon after a time limit.
  • Images are stored as paths. An image is written as the file path it was loaded from, not as its pixels. To render the copy, the image file must still be at that path.
  • Newer attribute values need a newer reader. The writer records enum values by name. A document that uses a value added in a later version, such as the side-wrap styles in Columns, drop caps and wrapping, cannot be read by an older version of this library.
  • The DOM is what is saved. DDL holds the Document object model, not the PDF. Settings on the renderer, such as PdfDocumentRenderer.TagContent, are not part of it.

See it in action

The Ddl demo builds a document with styles, formatted text and a table, writes it to DDL, reads it back and renders the copy. It then prints the first eighty lines of the DDL and a table of what survived the round trip, read from the copy.

The full Ddl demo
src/SampleApp/Demos/DdlDemo.cs
// ----- a document built the ordinary way -----

var original = new Document
{
Info =
{
Title = "Ddl",
Author = "PdfPinata SampleApp"
}
};

var normal = original.Styles[StyleNames.Normal];
normal.Font.Name = "Liberation Serif";
normal.Font.Size = 10.5;

var listing = original.Styles.AddStyle("Listing", StyleNames.Normal);
listing.Font.Name = "Source Code Pro";
listing.Font.Size = 7.5;
listing.ParagraphFormat.SpaceAfter = 0;
listing.ParagraphFormat.LineSpacingRule = LineSpacingRule.Exactly;
listing.ParagraphFormat.LineSpacing = Unit.FromPoint(9);

var body = original.AddSection();
body.PageSetup.TopMargin = Unit.FromCentimeter(2.5);

var title = body.AddParagraph("Written, serialised, re-read, rendered");
title.Format.Font.Name = "Liberation Sans";
title.Format.Font.Size = 18;
title.Format.Font.Bold = true;
title.Format.SpaceAfter = Unit.FromPoint(10);

body.AddParagraph(
"This page was not rendered from the document that built it. It was built, written out "
+ "as PinataLayout DDL with DdlWriter, parsed back with DdlReader, and the copy that came "
+ "out of the parser is what the renderer was given. Anything the round trip lost "
+ "would be missing from this page.");

var styled = body.AddParagraph();
styled.Format.SpaceBefore = Unit.FromPoint(8);
styled.AddText("Formatting survives too: ");
styled.AddFormattedText("bold", TextFormat.Bold);
styled.AddText(", ");
styled.AddFormattedText("italic", TextFormat.Italic);
styled.AddText(", ");
styled.AddFormattedText("underlined", TextFormat.Underline);
styled.AddText(", and a colour set on a run rather than on the paragraph.");
var coloured = styled.AddFormattedText(" Firebrick.");
coloured.Color = Colors.Firebrick;

var table = body.AddTable();
table.Borders.Width = 0.5;
table.Borders.Color = Colors.Gray;
table.Rows.LeftIndent = 0;
table.AddColumn(Unit.FromCentimeter(5));
table.AddColumn(Unit.FromCentimeter(5));
table.AddColumn(Unit.FromCentimeter(5));

var header = table.AddRow();
header.HeadingFormat = true;
header.Shading.Color = Colors.WhiteSmoke;
header.Cells[0].AddParagraph("What");
header.Cells[1].AddParagraph("Written by");
header.Cells[2].AddParagraph("Read by");

(string What, string Written, string Read)[] rows =
[
("A whole document", "DdlWriter.WriteToString", "DdlReader.DocumentFromString"),
("One object", "DdlWriter.WriteToString(obj)", "DdlReader.ObjectFromString"),
("To a file", "DdlWriter.WriteToFile", "DdlReader.DocumentFromFile")
];

foreach (var each in rows)
{
var row = table.AddRow();
row.Cells[0].AddParagraph(each.What);
row.Cells[1].AddParagraph(each.Written);
row.Cells[2].AddParagraph(each.Read);
}

// ----- out to text and back again -----

// The whole document as a string. Styles, sections, paragraphs, runs, the table and its
// borders - all of it, in PinataLayout's own grammar rather than XML or JSON.
var ddl = DdlWriter.WriteToString(original);

// And back. A parse failure is reported through DdlReaderErrors rather than thrown, so a
// caller who wants to know has to ask - which is why the errors object is passed in.
//
// Through the instance rather than DdlReader.DocumentFromString, because the static one
// has no overload that takes an errors object where ObjectFromString beside it does. The
// constructors all take one, so the instance is the route to a reader that will tell you
// what it could not parse.
var errors = new DdlReaderErrors();
var reread = new DdlReader(new StringReader(ddl), errors).ReadDocument();

// ----- the listing, added to the re-read copy -----

// Added after the round trip, so the page that shows the DDL is not itself in the DDL -
// which would otherwise grow the listing by exactly as much as the listing.
var listingSection = reread.AddSection();
listingSection.PageSetup.TopMargin = Unit.FromCentimeter(2.5);

var listingTitle = listingSection.AddParagraph("The DDL it went through");
listingTitle.Format.Font.Name = "Liberation Sans";
listingTitle.Format.Font.Size = 18;
listingTitle.Format.Font.Bold = true;
listingTitle.Format.SpaceAfter = Unit.FromPoint(8);

var lines = ddl.Replace("\r\n", "\n").Split('\n');

var summary = listingSection.AddParagraph(
$"{lines.Length} lines, {ddl.Length:N0} characters, and "
+ $"{errors.ErrorCount} error(s) reported by the reader. The first eighty lines "
+ "follow. The format is PinataLayout's own: braces nest, an attribute is name colon "
+ "value, and a paragraph's text is written between its braces.");
summary.Format.SpaceAfter = Unit.FromPoint(10);

foreach (var line in lines.Take(80))
{
// A tab in the source would be a tab stop here, so the indentation is turned into
// spaces the paragraph can simply carry.
var row = listingSection.AddParagraph(line.Replace("\t", " "));
row.Style = "Listing";
}

if (lines.Length > 80)
{
var more = listingSection.AddParagraph($"... and {lines.Length - 80} more lines.");
more.Style = "Listing";
more.Format.Font.Italic = true;
more.Format.SpaceBefore = Unit.FromPoint(6);
}

// ----- what the round trip did and did not keep -----

var verdict = reread.AddSection();
verdict.PageSetup.TopMargin = Unit.FromCentimeter(2.5);

var verdictTitle = verdict.AddParagraph("What survived");
verdictTitle.Format.Font.Name = "Liberation Sans";
verdictTitle.Format.Font.Size = 18;
verdictTitle.Format.Font.Bold = true;
verdictTitle.Format.SpaceAfter = Unit.FromPoint(8);

// Read off the re-read document rather than asserted, so the page cannot claim something
// the round trip did not actually do.
var firstAgain = reread.Sections[0];
var tableAgain = firstAgain.Elements
.OfType<Table>()
.First();

(string Question, string Answer)[] checks =
[
("Sections", reread.Sections.Count.ToString()),
("Elements in the first section", firstAgain.Elements.Count.ToString()),
("Styles defined", reread.Styles.Count.ToString()),
("Does the Listing style survive", reread.Styles["Listing"] != null ? "yes" : "no"),
// Read through the same lookup the question above asks, and tolerant of the answer
// being "no": a page whose job is to report what the round trip lost cannot throw on
// the way to saying something was lost.
("Its font", reread.Styles["Listing"]?.Font.Name ?? "-"),
("Table columns", tableAgain.Columns.Count.ToString()),
("Table rows", tableAgain.Rows.Count.ToString()),
("Is the first row still a heading", tableAgain.Rows[0].HeadingFormat ? "yes" : "no"),
("Border width", tableAgain.Borders.Width.ToString()),
("Document title", reread.Info.Title),
("Reader errors", errors.ErrorCount.ToString())
];

var results = verdict.AddTable();
results.Borders.Width = 0;
results.AddColumn(Unit.FromCentimeter(8));
results.AddColumn(Unit.FromCentimeter(7));

foreach (var check in checks)
{
var row = results.AddRow();
row.Cells[0].AddParagraph(check.Question);
var answer = row.Cells[1].AddParagraph(check.Answer);
answer.Format.Font.Name = "Source Code Pro";
answer.Format.Font.Size = 9;
}

var closing = verdict.AddParagraph();
closing.Format.SpaceBefore = Unit.FromPoint(14);
closing.AddText(
"Every number above was read off the document the parser produced, not off the one "
+ "that was written - so this page is a round-trip test somebody can look at. DDL is "
+ "worth knowing about for two reasons beyond serialisation: a document dumped to it "
+ "is readable, which makes it the fastest way to see what a document object model "
+ "actually holds, and a report template can be kept as text and filled in at run "
+ "time rather than being written out in C#.");

var renderer = new PdfDocumentRenderer(unicode: true) { Document = reread };
renderer.RenderDocument();