Skip to main content

Images

XImage puts a raster image, such as a photograph, a logo or a scan, on a page. You load it once and draw it with XGraphics.DrawImage as many times as you like. The core PdfPinata package cannot decode images by itself. The backend you registered at startup does that work, through ImageSource.ImageSourceImpl (see Installation). Loading an image before a backend is registered throws an InvalidOperationException. Note that ImageSource is in the namespace PinataLayout.DocumentObjectModel.Shapes, although it ships in the PdfPinata package.

Load an image

XImage.FromFile reads a file. XImage.FromStream takes a function that opens a stream, not the stream itself, because the library may open it more than once:

src/SampleApp/Demos/ImagesDemo.cs
// The image is embedded in this assembly rather than read from disk, so it is found
// wherever the app runs. FromStream takes a factory rather than a stream: the
// library opens it when it needs it and may do so more than once.
using var photograph = XImage.FromStream(
() => Assets.Open(Assets.ImagePrefix + "pdf-pinata.jpg"));

Assets.Open is a helper of the demo app. In your own code, pass something like () => File.OpenRead(path).

To choose the JPEG quality, or to supply an image you have already decoded, build the image source yourself and pass it to XImage.FromImageSource:

XImage photo = XImage.FromImageSource(ImageSource.FromFile("photo.jpg", quality: 90));

SkiaImageSource.FromSkiaBitmap and ImageSharpImageSource<TPixel>.FromImageSharpImage wrap an image that is already in memory, so it is not encoded and decoded again.

If you pass XImage.FromFile a PDF file, you get an XPdfForm, which draws a page of that PDF. See Forms, stamps and imposition.

Formats and how they are stored

The backend decides which files it can read, because it is the backend's image library (SkiaSharp or ImageSharp) that decodes them. PNG and JPEG work with both. For any other format, check the documentation of the image library behind the backend you chose.

The backend decodes every image to pixels. What PdfPinata writes to the PDF then depends only on whether the source was a PNG:

  • A PNG is stored without loss, with its alpha channel as a soft mask. This is true of palette PNGs and truecolour PNGs alike.
  • Every other format is re-encoded as JPEG, at quality 75 unless you choose otherwise. This includes JPEG files: their bytes are not copied into the PDF as they are. Any transparency in a GIF or WebP file is lost.

Every image is written in RGB. A CMYK or greyscale JPEG is converted.

Size and place an image

DrawImage(image, x, y) draws the image at its natural size. DrawImage(image, x, y, width, height) and the XRect overload stretch it to the rectangle you give.

src/SampleApp/Demos/ImagesDemo.cs
var naturalWidth = photograph.PointWidth;
var naturalHeight = photograph.PointHeight;

// Natural size gets a row to itself: at 96 dpi this photograph is most of the width
// of an A4 page, which is the point worth making about drawing one unscaled.
double y = 92;
gfx.DrawImage(photograph, 48, y, naturalWidth, naturalHeight);
Caption("natural size, from PointWidth and PointHeight", 48, y + naturalHeight + 12);

// Everything below is placed from the sizes above rather than from numbers typed in,
// so changing the photograph cannot silently make the page overlap itself.
y += naturalHeight + 34;
gfx.DrawImage(photograph, 48, y, naturalWidth / 2, naturalHeight / 2);
Caption("half", 48, y + naturalHeight / 2 + 12);

PixelWidth and PixelHeight are the image's size in pixels. PointWidth and PointHeight are its natural size on the page, in points. PdfPinata always treats an image as 96 pixels to the inch, and ignores any resolution stored in the file.

Drawing an image smaller does not resample it. The file keeps every pixel. To make a PDF smaller, scale the image down before you load it.

Fit an image in a box

There is no helper that fits an image to a box. To fit the whole image and keep its proportions, use the smaller of the two scale factors, then centre the result:

src/SampleApp/Demos/ImagesDemo.cs
var box = new XRect(48, 92, 200, 200);

// Fit, or "contain": the largest scale at which the whole image is inside the box,
// so the box shows through on two sides. Min of the two ratios.
var fit = Math.Min(box.Width / naturalWidth, box.Height / naturalHeight);
var fitted = new XRect(
box.X + (box.Width - naturalWidth * fit) / 2,
box.Y + (box.Height - naturalHeight * fit) / 2,
naturalWidth * fit,
naturalHeight * fit);

gfx.DrawImage(photograph, fitted);
gfx.DrawRectangle(boxPen, box);
Caption("fit: Math.Min, the whole image, letterboxed", 48, 306);

Fill a box and crop the rest

To cover the box completely, use the larger of the two scale factors. The image then overflows the box on two sides. Clip to the box to cut the overflow off:

XRect box = new XRect(300, 92, 200, 200);
double scale = Math.Max(box.Width / image.PointWidth, box.Height / image.PointHeight);
double width = image.PointWidth * scale;
double height = image.PointHeight * scale;

XGraphicsState state = gfx.Save();
gfx.IntersectClip(box);
gfx.DrawImage(image, box.X + (box.Width - width) / 2, box.Y + (box.Height - height) / 2,
width, height);
gfx.Restore(state);

The clip hides the overflow, but the whole image is still in the file.

warning

XGraphics has a DrawImage overload that takes a source rectangle as well as a destination rectangle. It ignores the source rectangle and draws the whole image into the destination. Crop with a clip, as above.

Rotate an image

RotateAtTransform turns the page's coordinates about a point. The image is then drawn square onto the turned coordinates. Put the turn inside Save and Restore, or it applies to everything drawn after it:

src/SampleApp/Demos/ImagesDemo.cs
double[] angles = { 0, 15, 30, 45 };
double x = 110;
foreach (var angle in angles)
{
var state = gfx.Save();
gfx.RotateAtTransform(angle, new XPoint(x, 430));
gfx.DrawImage(photograph, x - 45, 430 - 30, 90, 60);
gfx.Restore(state);

Caption($"{angle:0}°", x - 6, 500);
x += 130;
}

Smoothing when an image is enlarged

XImage.Interpolate asks the PDF reader to smooth an image that is drawn larger than its own pixels. It is true by default. Set it to false for images that must stay sharp-edged, such as pixel art or a scanned barcode:

src/SampleApp/Demos/ImagesDemo.cs
using var blocky = XImage.FromStream(
() => Assets.Open(Assets.ImagePrefix + "pdf-pinata.jpg"));
blocky.Interpolate = false;

using var smooth = XImage.FromStream(
() => Assets.Open(Assets.ImagePrefix + "pdf-pinata.jpg"));
smooth.Interpolate = true;

This is only a request that PdfPinata writes into the file. The reader decides what to do with it, and some readers ignore it.

When an image cannot be read

XImage.FromFile and XImage.FromStream throw when the backend cannot decode the image. The exception comes from the backend and says what went wrong.

PinataLayout behaves differently. A document with an unreadable image still renders: PinataLayout draws a grey placeholder where the image would be, and carries on. To find out what failed, handle the ImageFailed event of the DocumentRenderer:

src/SampleApp/Demos/ImageFailuresDemo.cs
var probe = new Document();
var probePage = probe.AddSection();
foreach (var each in Cases())
{
var probeImage = probePage.AddImage(each.Source());
probeImage.Width = Unit.FromCentimeter(4);
probeImage.Height = Unit.FromCentimeter(2.5);
}

var probeRenderer =
new PdfDocumentRenderer(unicode: true) { Document = probe };

// The event lives on DocumentRenderer, which PdfDocumentRenderer builds lazily - so reading
// the property here is what creates it, and attaching before RenderDocument is what catches
// everything. Attaching afterwards would attach to a renderer that had already finished.
probeRenderer.DocumentRenderer.ImageFailed += (_, e) =>
{
failures.Add((
// The DOM Image carries the IImageSource itself rather than a path, so the name is
// whatever the source calls itself - here, the name the failing source was given.
e.Image.Source?.Name ?? "unnamed",
e.Failure.ToString(),
e.Exception?.GetType().Name ?? "none",
e.Exception?.Message ?? "no exception was thrown"));
};

probeRenderer.RenderDocument();

The event arguments carry:

  • Image: the PinataLayout image that failed. Its Source is the IImageSource it was given.
  • Failure: an ImageFailure value. FileNotFound means the file does not exist. InvalidType means the image could not be decoded at all. NotRead means it could not be measured or drawn. EmptySize means its size has no area.
  • Exception: the exception that was thrown, or null for EmptySize, where nothing throws.

Attach the handler before you call RenderDocument. Reading PdfDocumentRenderer.DocumentRenderer creates the renderer, so attaching to it first is safe.

Supply images from anywhere

ImageSource.IImageSource has six members. Implement it to supply images from a database, a web response or a bitmap you generate:

src/SampleApp/Demos/ImageFailuresDemo.cs
public string Name { get; }

// Every one of these is read at a different point of the render, which is what lets one
// class provoke every failure kind.
public int Width => _size();
public int Height => _size();
public bool Transparent => _transparent();
public void SaveAsJpeg(MemoryStream ms) => _write();

public PixelBuffer GetPixels()
{
_write();
return default;
}

GetPixels returns a PixelBuffer: the pixels packed row by row from the top, four bytes each in blue, green, red, alpha order, with alpha not premultiplied. Transparent decides whether the image is stored losslessly with an alpha channel (true) or as a JPEG from SaveAsJpeg (false). In PinataLayout, pass your source to AddImage.

Things to know

  • A backend must be registered first. Without ImageSource.ImageSourceImpl, every image load throws.
  • Load once, draw many times. Each XImage object is stored in the file once, however often you draw it. Loading the same file twice gives two objects, and two copies in the file.
  • Only PNGs keep transparency and full quality. Other formats become JPEGs at quality 75.
  • Natural size assumes 96 dpi. A 300 dpi scan drawn with DrawImage(image, x, y) comes out more than three times its printed size. Give it a width and height.
  • The source-rectangle overload does not crop. Use IntersectClip.
  • PinataLayout reports; it does not throw. Without an ImageFailed handler, the reason for a grey box is lost. A failure found while measuring gives a placeholder of the size the document asked for, or 2.5 cm square. A failure found while drawing keeps the size the image would have had, so the page layout does not change.
  • Running out of memory is not caught. An OutOfMemoryException while loading an image in PinataLayout is thrown, not turned into a placeholder.

See it in action

The Images demo places one photograph at natural size, scaled, stretched, fitted and rotated, and draws two transparent PNGs. The ImageFailures demo renders four images that fail in four different ways, and lists what the ImageFailed handler received for each.

The full Images demo
src/SampleApp/Demos/ImagesDemo.cs
var document = new PdfDocument();

var label = new XFont("Liberation Sans", 8);
var heading = new XFont("Liberation Sans", 9, XFontStyle.Bold);
var boxPen = new XPen(XColors.Crimson, 0.5) { DashStyle = XDashStyle.Dot };

// The image is embedded in this assembly rather than read from disk, so it is found
// wherever the app runs. FromStream takes a factory rather than a stream: the
// library opens it when it needs it and may do so more than once.
using var photograph = XImage.FromStream(
() => Assets.Open(Assets.ImagePrefix + "pdf-pinata.jpg"));

// ---- Page one: sizing ---------------------------------------------------------
var page = document.AddPage();
var gfx = XGraphics.FromPdfPage(page);

void Caption(string text, double x, double y) =>
gfx.DrawString(text, label, XBrushes.DimGray, new XPoint(x, y));

void Heading(string text, double y)
{
gfx.DrawString(text.ToUpperInvariant(), heading, XBrushes.SteelBlue, new XPoint(48, y));
gfx.DrawLine(XPens.LightGray, 48, y + 5, 548, y + 5);
}

Heading("Natural size, and scaled", 56);

// PointWidth is the pixel count converted at 96 dpi, which is what the image is
// worth on the page if nothing scales it. The pixels themselves are unchanged
// whatever rectangle it is drawn into - drawing it smaller does not resample it.
Caption($"{photograph.PixelWidth} x {photograph.PixelHeight} pixels, "
+ $"{photograph.PointWidth:0.#} x {photograph.PointHeight:0.#} points at 96 dpi",
48, 78);

var naturalWidth = photograph.PointWidth;
var naturalHeight = photograph.PointHeight;

// Natural size gets a row to itself: at 96 dpi this photograph is most of the width
// of an A4 page, which is the point worth making about drawing one unscaled.
double y = 92;
gfx.DrawImage(photograph, 48, y, naturalWidth, naturalHeight);
Caption("natural size, from PointWidth and PointHeight", 48, y + naturalHeight + 12);

// Everything below is placed from the sizes above rather than from numbers typed in,
// so changing the photograph cannot silently make the page overlap itself.
y += naturalHeight + 34;
gfx.DrawImage(photograph, 48, y, naturalWidth / 2, naturalHeight / 2);
Caption("half", 48, y + naturalHeight / 2 + 12);

var quarterX = 48 + naturalWidth / 2 + 24;
gfx.DrawImage(photograph, quarterX, y, naturalWidth / 4, naturalHeight / 4);
Caption("quarter", quarterX, y + naturalHeight / 4 + 12);

y += naturalHeight / 2 + 40;
Heading("Stretched out of proportion", y);
y += 20;
gfx.DrawImage(photograph, 48, y, 300, 84);
Caption("a rectangle the image does not share the shape of", 48, y + 96);

// ---- Page two: fitting, cropping and turning -----------------------------------
page = document.AddPage();
gfx = XGraphics.FromPdfPage(page);

Heading("Fit and fill", 56);
Caption("There is no fit or cover helper. The arithmetic below is the whole of it.",
48, 78);

var box = new XRect(48, 92, 200, 200);

// Fit, or "contain": the largest scale at which the whole image is inside the box,
// so the box shows through on two sides. Min of the two ratios.
var fit = Math.Min(box.Width / naturalWidth, box.Height / naturalHeight);
var fitted = new XRect(
box.X + (box.Width - naturalWidth * fit) / 2,
box.Y + (box.Height - naturalHeight * fit) / 2,
naturalWidth * fit,
naturalHeight * fit);

gfx.DrawImage(photograph, fitted);
gfx.DrawRectangle(boxPen, box);
Caption("fit: Math.Min, the whole image, letterboxed", 48, 306);

// Fill, or "cover": the smallest scale at which the image covers the box, so the
// overflow has to be cut off. Max of the two ratios, and then the part of the
// image to keep is given as a source rectangle in the image's own pixels.
var coverBox = new XRect(300, 92, 200, 200);
var cover = Math.Max(coverBox.Width / naturalWidth, coverBox.Height / naturalHeight);
var sourceWidth = coverBox.Width / cover * photograph.PixelWidth / naturalWidth;
var sourceHeight = coverBox.Height / cover * photograph.PixelHeight / naturalHeight;

gfx.DrawImage(photograph, coverBox,
new XRect(
(photograph.PixelWidth - sourceWidth) / 2,
(photograph.PixelHeight - sourceHeight) / 2,
sourceWidth,
sourceHeight),
XGraphicsUnit.Point);

gfx.DrawRectangle(boxPen, coverBox);
Caption("fill: Math.Max, centre kept, edges cropped away", 300, 306);

Heading("Turned", 340);

// Every transform is undone by restoring the state that was saved before it. There
// is no ResetTransform, so a Save that is not Restored leaks into everything drawn
// afterwards.
double[] angles = { 0, 15, 30, 45 };
double x = 110;
foreach (var angle in angles)
{
var state = gfx.Save();
gfx.RotateAtTransform(angle, new XPoint(x, 430));
gfx.DrawImage(photograph, x - 45, 430 - 30, 90, 60);
gfx.Restore(state);

Caption($"{angle:0}°", x - 6, 500);
x += 130;
}

Caption("RotateAtTransform turns the page about a point, then the image is drawn "
+ "square onto it.", 48, 520);

// ---- Page three: transparency and interpolation --------------------------------
page = document.AddPage();
gfx = XGraphics.FromPdfPage(page);

Heading("A PNG with an alpha channel", 60);

// Two PNGs, and they are not the same kind of file. The badge is a palette image with an
// alpha channel; the disc is truecolour with one. Both arrive through the same seam and
// the same call, which is the point - the backend decodes whatever the format is.
using var badge = XImage.FromStream(
() => Assets.Open(Assets.ImagePrefix + "alpha-badge.png"));
using var disc = XImage.FromStream(
() => Assets.Open(Assets.ImagePrefix + "soft-disc.png"));

// A chequer, so that transparency reads as transparency rather than as a colour. Over a
// white page a transparent pixel and a white one look identical.
for (var cx = 0; cx < 10; cx++)
{
for (var cy = 0; cy < 8; cy++)
{
gfx.DrawRectangle((cx + cy) % 2 == 0 ? XBrushes.WhiteSmoke : XBrushes.Gainsboro,
48 + cx * 12, 80 + cy * 12, 12, 12);
}
}

gfx.DrawImage(badge, 48, 80, 120, 120);
Caption("Over a chequer", 48, 216);

gfx.DrawRectangle(new XSolidBrush(XColor.FromArgb(46, 139, 87)), 200, 80, 120, 120);
gfx.DrawImage(badge, 200, 80, 120, 120);
Caption("Over a solid colour", 200, 216);

gfx.DrawRectangle(new XSolidBrush(XColor.FromArgb(218, 165, 32)), 352, 80, 120, 120);
gfx.DrawImage(disc, 352, 80, 120, 120);
Caption("A truecolour PNG, fading out", 352, 216);

Caption("The badge is a palette PNG with an alpha channel; the disc is truecolour with "
+ "one. Neither needed anything of the caller: XImage.FromStream took both.",
48, 232);

Heading("Interpolate", 270);

// Whether a reader smooths an image scaled up beyond its own resolution. It is a request
// written into the image dictionary rather than something the library does, so what the
// two panels below look like depends on the reader - and some ignore it entirely.
using var blocky = XImage.FromStream(
() => Assets.Open(Assets.ImagePrefix + "pdf-pinata.jpg"));
blocky.Interpolate = false;

using var smooth = XImage.FromStream(
() => Assets.Open(Assets.ImagePrefix + "pdf-pinata.jpg"));
smooth.Interpolate = true;

// A small piece of the photograph, blown up far past its own pixels, which is the only
// arrangement in which the setting is visible at all.
gfx.DrawImage(blocky, new XRect(48, 290, 230, 170), new XRect(60, 40, 40, 30),
XGraphicsUnit.Point);
Caption("Interpolate = false", 48, 474);

gfx.DrawImage(smooth, new XRect(300, 290, 230, 170), new XRect(60, 40, 40, 30),
XGraphicsUnit.Point);
Caption("Interpolate = true", 300, 474);

Caption("Forty by thirty points of the photograph, drawn at two hundred and thirty wide. "
+ "Interpolate writes /Interpolate true into the image dictionary and asks the "
+ "reader to smooth it; the reader decides, and several ignore the request.",
48, 492);

Heading("When an image will not load", 530);

Caption("XImage.FromStream throws, and the exception says what went wrong - this fork "
+ "swallows nothing. PinataLayout is the one place that does not throw: a failed image "
+ "becomes a grey box, and DocumentRenderer.ImageFailed is the event that says why. "
+ "Without a handler the reason is dropped. See image-failure-reporting.md.",
48, 550);
The full ImageFailures demo
src/SampleApp/Demos/ImageFailuresDemo.cs
/// <summary>
/// An image source that fails on purpose, at a point of the caller's choosing.
/// </summary>
/// <remarks>
/// IImageSource is six members, and implementing it by hand is how an application supplies
/// images from somewhere the library has never heard of - a database, an HTTP response, a
/// generated bitmap. Here it is the opposite: an image that is never going to work, so that
/// the failure path can be shown rather than described.
/// </remarks>
sealed class FailingImage : IImageSource
{
readonly Func<int> _size;
readonly Func<bool> _transparent;
readonly Action _write;

FailingImage(string name, Func<int> size, Func<bool> transparent, Action write)
{
Name = name;
_size = size;
_transparent = transparent;
_write = write;
}

public string Name { get; }

// Every one of these is read at a different point of the render, which is what lets one
// class provoke every failure kind.
public int Width => _size();
public int Height => _size();
public bool Transparent => _transparent();
public void SaveAsJpeg(MemoryStream ms) => _write();

public PixelBuffer GetPixels()
{
_write();
return default;
}

/// <summary>
/// Throws while XImage is being built, before anything is measured. XImage's constructor
/// reads Transparent to decide the format, and ImageRenderer catches an
/// InvalidOperationException from there specifically.
/// </summary>
public static FailingImage OfAnUnsupportedType() => new(
"unsupported.xyz",
() => 64,
() => throw new InvalidOperationException("xyz is not an image format anyone knows."),
() => { });

/// <summary>
/// Reports a size of nothing. Nothing throws; the image is simply of zero extent, which
/// is caught after the crop and resolution arithmetic has run.
/// </summary>
public static FailingImage OfNoSize() => new(
"empty.png", () => 0, () => false, () => { });

/// <summary>
/// Throws while being measured. XImage.PixelWidth reads straight through to Width.
/// </summary>
public static FailingImage ThatCannotBeMeasured() => new(
"truncated.png",
() => throw new InvalidDataException("The file ends in the middle of the header."),
() => false,
() => { });

/// <summary>
/// Measures perfectly and then throws on the way out. This one is worth its own case: the
/// failure is not detected until the render pass, by which time the layout has already
/// been decided around an image that is never going to arrive.
/// </summary>
public static FailingImage ThatCannotBeWritten() => new(
"unreadable.png",
() => 64,
() => false,
() => throw new IOException("The stream was closed by the other end."));
}

/// <summary>The four ways to fail, and where in the render each of them lands.</summary>
static (string What, Func<IImageSource> Source, string When)[] Cases() => new[]
{
("A type nothing can decode", (Func<IImageSource>)FailingImage.OfAnUnsupportedType,
"throws while XImage is built, before any measuring"),
("An image of no extent", FailingImage.OfNoSize,
"no exception at all - it measures to nothing"),
("A file that ends too soon", FailingImage.ThatCannotBeMeasured,
"throws while being measured"),
("A stream that dies on the way out", FailingImage.ThatCannotBeWritten,
"measures fine, throws while being drawn")
};

protected override PdfDocument Build(DemoContext context)
{
// ----- the probe: the same four images, rendered once to collect the events -----

// A document of its own, because a PinataLayout Document binds to the first renderer it is
// given and refuses a second - so the report below cannot be rendered once to find out
// what happens and again to say so. The probe is thrown away; only its findings are kept.
List<(string Name, string Failure, string Exception, string Message)> failures = new();

var probe = new Document();
var probePage = probe.AddSection();
foreach (var each in Cases())
{
var probeImage = probePage.AddImage(each.Source());
probeImage.Width = Unit.FromCentimeter(4);
probeImage.Height = Unit.FromCentimeter(2.5);
}

var probeRenderer =
new PdfDocumentRenderer(unicode: true) { Document = probe };

// The event lives on DocumentRenderer, which PdfDocumentRenderer builds lazily - so reading
// the property here is what creates it, and attaching before RenderDocument is what catches
// everything. Attaching afterwards would attach to a renderer that had already finished.
probeRenderer.DocumentRenderer.ImageFailed += (_, e) =>
{
failures.Add((
// The DOM Image carries the IImageSource itself rather than a path, so the name is
// whatever the source calls itself - here, the name the failing source was given.
e.Image.Source?.Name ?? "unnamed",
e.Failure.ToString(),
e.Exception?.GetType().Name ?? "none",
e.Exception?.Message ?? "no exception was thrown"));
};

probeRenderer.RenderDocument();

// ----- the document the demo hands back -----

var report = new Document
{
Info =
{
Title = "ImageFailures"
}
};

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

var heading = report.Styles[StyleNames.Heading1];
heading.Font.Name = "Liberation Sans";
heading.Font.Size = 18;
heading.Font.Bold = true;
heading.ParagraphFormat.SpaceAfter = Unit.FromPoint(8);

var caption = report.Styles.AddStyle("Caption", StyleNames.Normal);
caption.Font.Size = 8.5;
caption.Font.Italic = true;
caption.Font.Color = Colors.DimGray;

var page = report.AddSection();
page.PageSetup.TopMargin = Unit.FromCentimeter(2.5);

page.AddParagraph("Four images that will not load").Style = StyleNames.Heading1;

page.AddParagraph(
"Each of the four below is an IImageSource written to fail, and each fails at a "
+ "different point of the render. PinataLayout draws a placeholder where the picture would "
+ "have gone and carries on - which is the contract, because one unreadable image "
+ "should not cost a five hundred page report - and raises an event saying what "
+ "happened. The next page is that event, collected.");

foreach (var each in Cases())
{
var label = page.AddParagraph(each.What);
label.Format.Font.Bold = true;
label.Format.SpaceBefore = Unit.FromPoint(10);
label.Format.SpaceAfter = Unit.FromPoint(2);

page.AddParagraph(each.When).Style = "Caption";

var image = page.AddImage(each.Source());
image.Width = Unit.FromCentimeter(4);
image.Height = Unit.FromCentimeter(2.5);
}

// ----- what the handler saw -----

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

verdict.AddParagraph("What the handler was told").Style = StyleNames.Heading1;

verdict.AddParagraph(
$"{failures.Count} failures, each reported once, at the moment its placeholder was "
+ "drawn. The exception is the instance that was thrown - not a message, not a copy - "
+ "so a handler can log it, rethrow it, or match on its type.");

var table = verdict.AddTable();
table.Borders.Width = 0.5;
table.Borders.Color = Colors.Gainsboro;
table.Rows.LeftIndent = 0;
table.Format.Font.Size = 9;
table.Format.SpaceAfter = 0;
table.AddColumn(Unit.FromCentimeter(3.2));
table.AddColumn(Unit.FromCentimeter(2.4));
table.AddColumn(Unit.FromCentimeter(4.4));
table.AddColumn(Unit.FromCentimeter(6.0));

var header = table.AddRow();
header.HeadingFormat = true;
header.Shading.Color = Colors.WhiteSmoke;
header.Cells[0].AddParagraph("Image.Name");
header.Cells[1].AddParagraph("Failure");
header.Cells[2].AddParagraph("Exception");
header.Cells[3].AddParagraph("Message");

foreach (var failure in failures)
{
var row = table.AddRow();
row.Cells[0].AddParagraph(failure.Name);
row.Cells[1].AddParagraph(failure.Failure);
row.Cells[2].AddParagraph(failure.Exception);
row.Cells[3].AddParagraph(failure.Message);
}

var why = verdict.AddParagraph();
why.Format.SpaceBefore = Unit.FromPoint(14);
why.AddFormattedText("Why an event and not a throw. ", TextFormat.Bold);
why.AddText(
"PinataLayout's contract is that a document with a bad image still renders, and callers "
+ "depend on it. Throwing would be the simpler change and the wrong one. The event "
+ "leaves the contract alone while making the reason reachable, which is what issue "
+ "366 asked for - the exception used to go to Debug.WriteLine and nowhere else, which "
+ "a release build compiles away entirely.");

var kinds = verdict.AddParagraph();
kinds.Format.SpaceBefore = Unit.FromPoint(10);
kinds.AddFormattedText("The fifth kind. ", TextFormat.Bold);
kinds.AddText(
"ImageFailure has five values and only four appear above. FileNotFound has a "
+ "placeholder string of its own but nothing in this fork ever assigns it: images "
+ "arrive through IImageSource rather than by path, so there is no file for the "
+ "renderer to fail to find. It is left in the enum because removing a public value "
+ "would break callers switching on it.");

var where = verdict.AddParagraph();
where.Format.SpaceBefore = Unit.FromPoint(10);
where.AddFormattedText("Measured or drawn. ", TextFormat.Bold);
where.AddText(
"The last of the four is the one worth remembering. It measures perfectly and fails "
+ "only when its bytes are asked for, by which time the layout has been decided around "
+ "an image that is never going to arrive - so the placeholder is exactly the size the "
+ "picture would have been, and the page does not reflow. The other three are caught "
+ "while measuring, and their placeholder is sized by SetFallbackDimensions instead.");

verdict.AddParagraph(
"See docs/specs/image-failure-reporting.md for what was wrong before this and why each "
+ "part of it is the way it is.").Style = "Caption";

// The four images on page one fail all over again here, into a DocumentRenderer nothing is
// listening to. That is the point: the placeholders on the page and the rows in the table
// are two views of the same four failures, taken by two different runs, and they agree.
var renderer = new PdfDocumentRenderer(unicode: true) { Document = report };
renderer.RenderDocument();