Skip to main content

Text layout with XTextFormatter

XGraphics.DrawString draws one line and does not wrap. XTextFormatter, in the PdfPinata.Drawing.Layout namespace, flows text into a rectangle. It breaks lines at spaces, starts a new paragraph at each line feed (\n), and can align, justify, indent, split the text into columns and measure it before you draw.

Use it for a block of text in a fixed place on a page: an address, a note, a caption, a column of copy. For a document whose text runs over several pages, use PinataLayout instead (see When to use PinataLayout).

Wrap text into a rectangle

Create one formatter for the XGraphics you draw with, set its properties, and call DrawString with a rectangle. The formatter keeps its settings between calls. The demo draws the same paragraph with each of the four alignments:

src/SampleApp/Demos/LayoutDemo.cs
(XParagraphAlignment Alignment, string Label)[] alignments =
{
(XParagraphAlignment.Left, "Left - ragged on the right"),
(XParagraphAlignment.Center, "Center - ragged on both"),
(XParagraphAlignment.Right, "Right - ragged on the left"),
(XParagraphAlignment.Justify, "Justify - flush both sides, last line left")
};

double y = 78;
foreach ((var alignment, var label) in alignments)
{
gfx.DrawString(label, note, XBrushes.DimGray, new XPoint(48, y));

var rect = new XRect(48, y + 6, 240, 62);
gfx.DrawRectangle(boxPen, rect);
formatter.Alignment = alignment;
formatter.DrawString(Paragraph, body, XBrushes.Black, rect);

y += 84;
}

XParagraphAlignment.Justify makes every line of a paragraph flush at both sides, apart from the last line, which stays on the left.

Vertical alignment

VerticalAlignment places the block at the Top, Middle or Bottom of the rectangle. You can also pass a TextFormatAlignment to DrawString, which sets the horizontal and vertical alignment together:

src/SampleApp/Demos/LayoutDemo.cs
foreach ((var alignment, var column) in new[]
{
(XVerticalAlignment.Top, 0),
(XVerticalAlignment.Middle, 1),
(XVerticalAlignment.Bottom, 2)
})
{
var rect = new XRect(48 + column * 172, 78, 160, 110);
gfx.DrawRectangle(boxPen, rect);
formatter.DrawString($"{alignment} in a box taller than the text needs", body,
XBrushes.Black, rect,
new TextFormatAlignment { Horizontal = XParagraphAlignment.Left, Vertical = alignment });
}

When the text does not fit

By default, text that is too long for the rectangle is cut off at the last line that fits. Nothing marks the cut. Set Ellipsis to end the last line with a mark instead. XTextFormatter.DefaultEllipsis is the ellipsis character (…):

src/SampleApp/Demos/LayoutDemo.cs
var tooShort = new XRect(320, 78, 228, 34);
gfx.DrawRectangle(boxPen, tooShort);
formatter.Ellipsis = XTextFormatter.DefaultEllipsis;
formatter.DrawString(Paragraph, body, XBrushes.Black, tooShort);
formatter.Ellipsis = null;
gfx.DrawString("Ellipsis marks what was cut", note, XBrushes.DimGray,
new XPoint(320, 126));

Ellipsis is a string, so you can use three full stops for a font that has no ellipsis character.

Two other settings change what happens at the edges:

  • AllowVerticalOverflow = true draws every line, even below the bottom of the rectangle.
  • LineBreak = false stops the formatter wrapping. The text runs past the right edge, but line feeds in the text still start new lines.
src/SampleApp/Demos/LayoutDemo.cs
var noWrap = new XRect(320, 150, 228, 30);
gfx.DrawRectangle(boxPen, noWrap);
formatter.LineBreak = false;
formatter.DrawString("LineBreak = false runs on past the right edge", body,
XBrushes.Black, noWrap);
formatter.LineBreak = true;

Columns

Columns divides the rectangle into columns of equal width, with ColumnGap points between them. The text fills the first column, then the next:

src/SampleApp/Demos/LayoutDemo.cs
var columns = new XRect(48, 78, 500, 180);
gfx.DrawRectangle(boxPen, columns);
formatter.Columns = 3;
formatter.ColumnGap = 16;
formatter.Alignment = XParagraphAlignment.Justify;
formatter.DrawString(
string.Concat(Paragraph, " ", Paragraph, " ", Paragraph, " ", Paragraph, " ",
Paragraph, " ", Paragraph),
body, XBrushes.Black, columns);
formatter.Columns = 1;
formatter.Alignment = XParagraphAlignment.Left;

ColumnGap is 18 points unless you change it.

Indents and gaps

  • Indent indents the first line of each paragraph, in points. With IndentAllLines = true, it indents every line.
  • ParagraphGap adds space after each paragraph.
  • LineGap adds space between all lines.
src/SampleApp/Demos/LayoutDemo.cs
var indented = new XRect(308, 318, 240, 130);
gfx.DrawRectangle(boxPen, indented);
formatter.Indent = 14;
formatter.ParagraphGap = 6;
formatter.LineGap = 1.5;
formatter.DrawString(twoParagraphs, body, XBrushes.Black, indented);
formatter.Indent = 0;
formatter.ParagraphGap = 0;
formatter.LineGap = 0;

To set the line height itself, pass lineHeight as the last argument of DrawString or GetLayout. LineGap is added to it.

Lists

XTextFormatter has no list support. To make a list with a hanging indent, draw the marker yourself and flow the item text into a rectangle that starts to the right of it. GetLayout tells you how tall each item was, so you know where the next one starts:

src/SampleApp/Demos/LayoutDemo.cs
string[] items =
{
"A marker drawn at the left of the line",
"The text flowed into a rectangle that starts after it, so the second and "
+ "later lines of a long item line up under the first rather than under "
+ "the marker",
"Which is all a hanging indent is"
};

y = 506;
for (var index = 0; index < items.Length; index++)
{
gfx.DrawString($"{index + 1}.", body, XBrushes.Black, new XPoint(48, y + 8));

var itemRect = new XRect(68, y, 480, 40);
formatter.DrawString(items[index], body, XBrushes.Black, itemRect);

// Measure the item to find where the next one starts, rather than assuming
// every item is one line.
y += formatter.GetLayout(items[index], body, XBrushes.Black, itemRect).Height + 4;
}

For bullet and numbered lists that the library lays out, use PinataLayout.

Measure before you draw

GetLayout lays the text out without drawing it and returns the rectangle it needs. Give it a rectangle of the width you want and more height than the text can need. Turn on AllowVerticalOverflow while you measure, so that no text is cut off:

src/SampleApp/Demos/LayoutDemo.cs
formatter.AllowVerticalOverflow = true;
var measured = formatter.GetLayout(toMeasure, body, XBrushes.Black,
new XRect(0, 0, 200, 200));
formatter.AllowVerticalOverflow = false;

measured.Location = new XPoint(48, 234);

// A wash over the box, so it is visible that the text really does fit the space
// that was measured for it.
gfx.DrawRectangle(new XSolidBrush(XColor.FromArgb(20, 0, 0, 0)), measured);
formatter.DrawString(toMeasure, body, XBrushes.Black, measured);

Use this to size a box, a background or a border to its text, or to decide whether a block fits in the space left on a page.

Rotated text

Rotation turns the text by an angle in degrees about the top left corner of the rectangle. Positive angles turn it anticlockwise:

src/SampleApp/Demos/LayoutDemo.cs
double[] rotations = { 0.0, 15.0, 45.0, 90.0 };
for (var index = 0; index < rotations.Length; index++)
{
double left = 90 + index * 130;
var rect = new XRect(left, 560, 130, 60);

gfx.DrawRectangle(new XSolidBrush(XColor.FromArgb(255, 245, 220)), left - 2, 558, 4, 4);

formatter.Rotation = rotations[index];
formatter.DrawString("Text turned about the corner", body, XBrushes.Black, rect);
formatter.Rotation = 0;

gfx.DrawString($"{rotations[index]:0}°", note, XBrushes.DimGray,
new XPoint(left, 640));
}

The text turns about the corner, not about the centre of the rectangle. At 90 degrees it runs up the page from that corner.

Drop caps and text around shapes

Two more features are for magazine-style pages:

  • DropCap takes an XDropCap, which sets the first letter of the text in a larger font, a given number of lines deep.
  • Obstacles is a list of shapes that the text flows around. Add a RectangleObstacle for each area to keep clear, for example a picture or a pull quote. Give its position relative to the top left corner of the layout rectangle.

The Magazine demo uses both, and the Newspaper demo sets a front page in columns.

When to use PinataLayout

XTextFormatter lays out one block in one rectangle that you choose. It does not continue text on the next page, and it has no styles, tables, lists, headers or footers. Move to PinataLayout when you need any of these:

  • text that flows from page to page
  • styles shared across a document
  • tables, lists, footnotes, or headers and footers
  • a structure tree for accessible, tagged PDF

See Documents, sections and styles and Paragraphs and text layout.

Things to know

  • Settings stay on the formatter. A property you set applies to every later DrawString call on that formatter. The demo sets Ellipsis, LineBreak and Columns back after each use. Passing a TextFormatAlignment to DrawString also changes the formatter's Alignment and VerticalAlignment for later calls.
  • Cut-off text is lost without a warning. Measure with GetLayout first if the whole text must appear, or set Ellipsis so that the cut shows.
  • Obstacles and Rotation cannot be used together. If both are set, DrawString and GetLayout throw InvalidOperationException. To turn text that flows around an obstacle, rotate the XGraphics instead.
  • Right-to-left paragraphs work. Set TextDirection to a BidiParagraphDirection. See International text.
  • One formatter draws on one XGraphics. For a new page, create a new formatter with the new page's XGraphics.

See it in action

The Layout demo shows the four alignments, truncation, columns, indents, a hand-built list, vertical alignment, measuring with GetLayout, and rotated blocks.

The full Layout demo
src/SampleApp/Demos/LayoutDemo.cs
const string Sans = "Liberation Sans";
const string Serif = "Liberation Serif";

const string Paragraph =
"The quick brown fox jumps over the lazy dog, and does so repeatedly until "
+ "there is enough text here to wrap onto several lines and show what the "
+ "formatter does with the space left at the end of each of them.";

var document = new PdfDocument();
var body = new XFont(Serif, 10);
var note = new XFont(Sans, 8);
var headingFont = new XFont(Sans, 9, XFontStyle.Bold);
var boxPen = new XPen(XColors.Gainsboro, 0.5);

// ---- Page one: wrapping, alignment and truncation ------------------------------
var page = document.AddPage();
var gfx = XGraphics.FromPdfPage(page);
var formatter = new XTextFormatter(gfx);

// Headings take a left edge and a width, so that one over a right hand column does
// not rule a line straight through the column beside it.
void Heading(string text, double y, double x = 48, double width = 500)
{
gfx.DrawString(text.ToUpperInvariant(), headingFont, XBrushes.SteelBlue,
new XPoint(x, y));
gfx.DrawLine(XPens.LightGray, x, y + 5, x + width, y + 5);
}

Heading("The four alignments", 56, 48, 240);

(XParagraphAlignment Alignment, string Label)[] alignments =
{
(XParagraphAlignment.Left, "Left - ragged on the right"),
(XParagraphAlignment.Center, "Center - ragged on both"),
(XParagraphAlignment.Right, "Right - ragged on the left"),
(XParagraphAlignment.Justify, "Justify - flush both sides, last line left")
};

double y = 78;
foreach ((var alignment, var label) in alignments)
{
gfx.DrawString(label, note, XBrushes.DimGray, new XPoint(48, y));

var rect = new XRect(48, y + 6, 240, 62);
gfx.DrawRectangle(boxPen, rect);
formatter.Alignment = alignment;
formatter.DrawString(Paragraph, body, XBrushes.Black, rect);

y += 84;
}

formatter.Alignment = XParagraphAlignment.Left;

Heading("When it will not fit", 56, 320, 228);

// Vertical overflow is off by default, so a box too short for its text simply
// loses the rest. Setting Ellipsis marks where the loss happened instead of
// letting the text stop mid-sentence as though it had finished.
var tooShort = new XRect(320, 78, 228, 34);
gfx.DrawRectangle(boxPen, tooShort);
formatter.Ellipsis = XTextFormatter.DefaultEllipsis;
formatter.DrawString(Paragraph, body, XBrushes.Black, tooShort);
formatter.Ellipsis = null;
gfx.DrawString("Ellipsis marks what was cut", note, XBrushes.DimGray,
new XPoint(320, 126));

// With LineBreak off nothing wraps: the text runs straight out of the box and off
// the page. The line breaks written into the string are still obeyed.
var noWrap = new XRect(320, 150, 228, 30);
gfx.DrawRectangle(boxPen, noWrap);
formatter.LineBreak = false;
formatter.DrawString("LineBreak = false runs on past the right edge", body,
XBrushes.Black, noWrap);
formatter.LineBreak = true;

// ---- Page two: columns, indents and gaps ---------------------------------------
page = document.AddPage();
gfx = XGraphics.FromPdfPage(page);
formatter = new XTextFormatter(gfx);

Heading("Columns", 56);

// The rectangle is divided into Columns of equal width with ColumnGap between
// them, and the text fills each in turn before moving to the next.
var columns = new XRect(48, 78, 500, 180);
gfx.DrawRectangle(boxPen, columns);
formatter.Columns = 3;
formatter.ColumnGap = 16;
formatter.Alignment = XParagraphAlignment.Justify;
formatter.DrawString(
string.Concat(Paragraph, " ", Paragraph, " ", Paragraph, " ", Paragraph, " ",
Paragraph, " ", Paragraph),
body, XBrushes.Black, columns);
formatter.Columns = 1;
formatter.Alignment = XParagraphAlignment.Left;

gfx.DrawString("Columns = 3, ColumnGap = 16, justified", note, XBrushes.DimGray,
new XPoint(48, 272));

Heading("Indents and gaps", 296);

var twoParagraphs = Paragraph + "\n" + Paragraph;

var plain = new XRect(48, 318, 240, 130);
gfx.DrawRectangle(boxPen, plain);
formatter.DrawString(twoParagraphs, body, XBrushes.Black, plain);
gfx.DrawString("as it comes", note, XBrushes.DimGray, new XPoint(48, 460));

// Indent moves the first line of each paragraph, ParagraphGap opens the space
// between one paragraph and the next, LineGap the space between every line.
var indented = new XRect(308, 318, 240, 130);
gfx.DrawRectangle(boxPen, indented);
formatter.Indent = 14;
formatter.ParagraphGap = 6;
formatter.LineGap = 1.5;
formatter.DrawString(twoParagraphs, body, XBrushes.Black, indented);
formatter.Indent = 0;
formatter.ParagraphGap = 0;
formatter.LineGap = 0;
gfx.DrawString("Indent 14, ParagraphGap 6, LineGap 1.5", note, XBrushes.DimGray,
new XPoint(308, 460));

Heading("Lists, by hand", 486);

// There is no list support on this side of the library - PinataLayout has ListInfo,
// and the Tables and Invoice demos use it. Here the marker is drawn separately
// and the text flows into a rectangle inset by the width of the marker, which is
// the whole of what a hanging indent is.
string[] items =
{
"A marker drawn at the left of the line",
"The text flowed into a rectangle that starts after it, so the second and "
+ "later lines of a long item line up under the first rather than under "
+ "the marker",
"Which is all a hanging indent is"
};

y = 506;
for (var index = 0; index < items.Length; index++)
{
gfx.DrawString($"{index + 1}.", body, XBrushes.Black, new XPoint(48, y + 8));

var itemRect = new XRect(68, y, 480, 40);
formatter.DrawString(items[index], body, XBrushes.Black, itemRect);

// Measure the item to find where the next one starts, rather than assuming
// every item is one line.
y += formatter.GetLayout(items[index], body, XBrushes.Black, itemRect).Height + 4;
}

// ---- Page three: measuring, vertical alignment and rotation ---------------------
page = document.AddPage();
gfx = XGraphics.FromPdfPage(page);
formatter = new XTextFormatter(gfx);

Heading("Vertical alignment", 56);

foreach ((var alignment, var column) in new[]
{
(XVerticalAlignment.Top, 0),
(XVerticalAlignment.Middle, 1),
(XVerticalAlignment.Bottom, 2)
})
{
var rect = new XRect(48 + column * 172, 78, 160, 110);
gfx.DrawRectangle(boxPen, rect);
formatter.DrawString($"{alignment} in a box taller than the text needs", body,
XBrushes.Black, rect,
new TextFormatAlignment { Horizontal = XParagraphAlignment.Left, Vertical = alignment });
}

Heading("Measuring before drawing", 212);

// GetLayout answers "how much room would this need" without drawing anything, so
// a box can be sized to its text rather than the text squeezed into a guess.
// Vertical overflow is allowed while measuring - the point is to find out how tall
// it wants to be - and turned back off before anything is drawn.
const string toMeasure =
"Text to determine the size of the box I would like to place the text in";

formatter.AllowVerticalOverflow = true;
var measured = formatter.GetLayout(toMeasure, body, XBrushes.Black,
new XRect(0, 0, 200, 200));
formatter.AllowVerticalOverflow = false;

measured.Location = new XPoint(48, 234);

// A wash over the box, so it is visible that the text really does fit the space
// that was measured for it.
gfx.DrawRectangle(new XSolidBrush(XColor.FromArgb(20, 0, 0, 0)), measured);
formatter.DrawString(toMeasure, body, XBrushes.Black, measured);

gfx.DrawString($"GetLayout returned {measured.Width:0.#} x {measured.Height:0.#} points",
note, XBrushes.DimGray, new XPoint(48, 234 + measured.Height + 14));

Heading("Turned", 330);

// The rectangle is still given in page coordinates, and Rotation turns the text
// within it about the rectangle's top left corner, anticlockwise for a positive
// angle. So the text of a box turned 90 degrees runs upwards from that corner and
// out of the rectangle entirely - the corner is the anchor, not the box.
double[] rotations = { 0.0, 15.0, 45.0, 90.0 };
for (var index = 0; index < rotations.Length; index++)
{
double left = 90 + index * 130;
var rect = new XRect(left, 560, 130, 60);

gfx.DrawRectangle(new XSolidBrush(XColor.FromArgb(255, 245, 220)), left - 2, 558, 4, 4);

formatter.Rotation = rotations[index];
formatter.DrawString("Text turned about the corner", body, XBrushes.Black, rect);
formatter.Rotation = 0;

gfx.DrawString($"{rotations[index]:0}°", note, XBrushes.DimGray,
new XPoint(left, 640));
}

gfx.DrawString("The mark shows the corner each block is turned about.", note,
XBrushes.DimGray, new XPoint(90, 656));