“Just email them the report as a PDF.” It is one line in a ticket, and on a developer machine every approach works. Then it goes to the server, and each one fails in its own way: the machine has no Excel, the container has no print spooler, and the PDF that does come out has lost the column widths somebody spent an afternoon on.
The awkward part is that the spreadsheet already knows how it should look on paper. It has column widths, merges, borders, number formats, a print area, a page setup. Most PDF pipelines throw all of that away and rebuild it.
ReoGrid V5 exports PDF from the same renderer that paints the grid on screen, with no external packages at all. This article covers the call, the page setup, the Japanese font, and the one behaviour that will bite a headless service if you do not know about it.
The four usual approaches, and what each costs
| Approach | The catch |
|---|---|
Office Interop — automate Excel’s ExportAsFixedFormat | Needs Excel installed on the server, and Microsoft neither recommends nor supports server-side automation of Office. Windows only. Orphaned EXCEL.EXE processes are a genre of bug on their own |
Microsoft Print to PDF — print to a virtual printer | Windows only, and it is a printer driver: it wants a print spooler, a user session, and a writable spool directory. Containers have none of those |
| Render HTML, screenshot it with headless Chromium | Ships a browser inside your image, and you have re-implemented pagination in CSS. Page breaks in @media print are not Excel’s page breaks |
| A PDF library (iText, QuestPDF, PdfSharp) | Excellent libraries, but they draw your layout, not the sheet’s. Every column width, merge and border gets restated in code, and it drifts from the workbook the moment someone edits it |
Each of these is the right answer to some problem. None of them is “render this worksheet, the way it is set up, on a server.”
What V5 does instead
The V5 core is UI-independent. Drawing goes through an IGridGraphics interface, and each platform supplies an implementation — GDI+ on WinForms, DrawingContext on WPF and Avalonia. unvell.ReoGrid.IO.Pdf supplies one more: PdfGraphics, which writes PDF content streams.
That has two consequences worth stating plainly:
- The PDF is what the grid draws. Not a re-implementation of it. Conditional formatting, rich text, merges, dashed and double borders, number formats — they render because the same viewport code produces them.
- There is nothing to install.
ReoGrid.IO.Pdftargetsnet10.0, has noPackageReferenceat all, and does not touchSystem.Drawing. It runs on Linux and macOS as happily as on Windows.
Pagination is not part of the PDF code either. Page breaks come from the shared Paginator, which packs whole rows and whole columns into the printable area — a row is never sliced across two pages, exactly as in Excel.
For a server or batch process, the package is unvell.ReoGrid.One.Core (net10.0, no dependencies, no UI):
dotnet add package unvell.ReoGrid.One.Core
The WinForms, WPF and Avalonia packages already contain everything — the core, the formula engine, and XLSX and PDF I/O — so a desktop app does not add .Core alongside them.
The call
using unvell.ReoGrid.Core;
using unvell.ReoGrid.IO.Pdf;
PdfExporter.Export(wb, "book.pdf"); // every sheet in the workbook
PdfExporter.Export(ws, "sheet.pdf"); // one worksheet
byte[] bytes = PdfExporter.ExportToBytes(wb); // no file involved
ExportToBytes is the one a web API wants — you never touch the filesystem.
The end-to-end server job is three steps:
using unvell.ReoGrid.Core;
using unvell.ReoGrid.IO.Excel;
using unvell.ReoGrid.IO.Pdf;
var workbook = XlsxReader.Read("input.xlsx");
// XLSX carries the values Excel cached, and those are read as-is — nothing is
// re-evaluated on load. Recalculate only when you have actually changed something.
var sheet = workbook[0];
sheet.SetNumber(1, 1, 500);
sheet.Recalculate();
PdfExporter.Export(workbook, "output.pdf");
The output is PDF 1.7. An empty workbook still produces a valid one-page file rather than a zero-page PDF that some readers refuse to open.
Page setup
Every overload takes an optional PrintSettings. Omit it and each sheet falls back to its own ws.PrintSettings — which is what the XLSX reader populated from the file. A workbook whose summary sheet is A4 portrait and whose data sheet is A3 landscape exports the way it was authored, with no code on your side.
using unvell.ReoGrid.Core.Printing;
var settings = new PrintSettings
{
Paper = PaperKind.A4,
Orientation = PageOrientation.Landscape,
ShowGridLines = false,
FitToPagesWide = 1,
};
PdfExporter.Export(ws, "sheet.pdf", settings);
The knobs worth knowing:
| Property | Default | Notes |
|---|---|---|
Paper | A4 | A5–A3, JIS B5/B4, Letter, Legal, Tabloid, Executive, Custom |
Orientation | Portrait | Landscape swaps the paper dimensions |
MarginLeft / Right / Top / Bottom | 54 | Points, not inches or millimetres. 72 pt = 1 inch, so 54 is Excel’s 0.75” “Normal” |
PrintArea | null | RangePosition.Parse("A1:H60"). Null prints the used range |
Scale | 1.0 | 0.1–4.0, matching Excel’s 10%–400%. Ignored once a fit-to-page target is set |
FitToPagesWide / Tall | null | Wide = 1, Tall = null is the common one: never spill sideways, run as long as it needs to |
CenterHorizontally / Vertically | false | Centres the content in the printable area |
Order | DownThenOver | Excel’s default page numbering when the area spans pages both ways |
ShowGridLines | true | |
ShowHeaders | false | Row numbers and column letters. Off by default, like Excel |
ShowImages | true | Off gives a text-only export |
Measuring margins in points surprises people, so it is worth being explicit: MarginLeft = 54 is three quarters of an inch, and 20 * 72 / 25.4 is 20 mm.
If you only need the page count — for a progress bar, or to reject a 400-page request before rendering it — paginate without rendering anything:
PrintLayout layout = Paginator.Paginate(ws, ws.PrintSettings);
Console.WriteLine($"{layout.Pages.Count} pages");
Repeated headers and page numbers
A 200-row table across six pages needs its header row on all six. That is Excel’s print titles, and it is two properties:
// Repeat rows 1-2 at the top of every page, and column A down the left.
ws.PrintSettings.RepeatRows = new LineSpan(0, 1);
ws.PrintSettings.RepeatColumns = new LineSpan(0, 0);
The repeated band is drawn as a pinned strip and left out of the scrolling body, so a row never prints twice on the same page — the failure mode you get from naively re-drawing the header.
Headers and footers use Excel’s & codes, so a workbook authored in Excel keeps working:
var hf = ws.PrintSettings.HeaderFooter;
hf.Header.Center = "&A"; // sheet name
hf.Footer.Right = "&P / &N"; // 2 / 5
hf.Footer.Left = "&D &T"; // date and time
hf.FontSizePt = 9;
// A different banner on page 1 only.
hf.DifferentFirst = true;
hf.FirstHeader.Center = "Quarterly report";
| Code | Expands to |
|---|---|
&P | Page number. &P+2 and &P-1 offset it |
&N | Total page count |
&A | Sheet name |
&F | Source file name |
&Z | Source folder path |
&D / &T | Date / time, in the current culture |
&& | A literal & |
&F and &Z come from the third parameter, not from the output path:
PdfExporter.Export(wb, "out.pdf", settings, documentName: "/srv/reports/Q3.xlsx");
Leave it out and both expand to empty rather than to an invented name — a footer that quietly claims the wrong source file is worse than one with a gap in it.
Appearance codes (&B bold, &I italic, &K colour, &"font") are parsed and dropped, since a band is drawn in one style. They survive in the stored string, so a workbook round-trips through XLSX with its codes intact.
Japanese text
This is where server-side PDF usually falls apart. The container has no fonts installed, so CJK text renders as boxes — or the export throws.
IPAexGothic ships inside the DLL, zlib-compressed as an embedded resource, and is emitted as a Type0 / CIDFontType2 font with Identity-H encoding. Japanese renders on a machine with no fonts installed at all.
Three details that matter more than they sound:
- The text stays text. A
ToUnicodeCMap is written alongside the glyphs, so the PDF is searchable and copy-pastable — 商品名 comes back out of the PDF as 商品名, not as glyph indices. Reports that are only searchable as images are a recurring complaint about generated PDFs; this is the thing that prevents it. - You do not pay for it unless you use it. The font is inflated lazily on first CJK draw, and a sheet with no CJK text emits no embedded font at all — an ASCII-only PDF uses the four standard Helvetica faces and stays small. When Japanese is present, expect roughly 4 MB of font in the file.
- Line breaking follows 禁則処理. Wrapped Japanese breaks between any two characters, except where it would strand punctuation: a line never opens with
。. The break moves back a character instead.
Two limitations to plan around:
- No font subsetting. A PDF containing Japanese embeds the whole face. For one report that is fine; for ten thousand small PDFs it is 40 GB of duplicated font, and you would want to merge or post-process
- No bold or italic CJK face. Japanese set in bold renders in the regular weight. Latin text has all four Helvetica faces
Pictures
Cell-anchored pictures embed directly: PNG (FlateDecode, with alpha becoming a /SMask) and JPEG (the original bytes pass straight through as /DCTDecode, so nothing is re-encoded and nothing is lost).
An image that appears on twelve pages — a logo in a repeated header — is embedded once and referenced twelve times. An image that cannot be decoded is skipped and the export continues; one corrupt logo does not fail a nightly batch.
In an ASP.NET Core endpoint
The whole point of ExportToBytes is that this is the entire handler:
using unvell.ReoGrid.Core;
using unvell.ReoGrid.Core.License;
using unvell.ReoGrid.IO.Excel;
using unvell.ReoGrid.IO.Pdf;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Once, at startup — before any export runs.
ReoGridLicense.SetLicense(builder.Configuration["ReoGrid:License"]!);
app.MapPost("/reports/pdf", async (IFormFile xlsx) =>
{
await using var stream = xlsx.OpenReadStream();
var workbook = XlsxReader.Read(stream);
byte[] pdf = PdfExporter.ExportToBytes(workbook, documentName: xlsx.FileName);
return Results.File(pdf, "application/pdf",
Path.ChangeExtension(xlsx.FileName, ".pdf"));
}).DisableAntiforgery();
app.Run();
There is no Dockerfile section to this article, because there is nothing to add to it. mcr.microsoft.com/dotnet/aspnet:10.0 and your app — no fonts package, no libgdiplus, no Chromium, no spooler.
For a very large input, XlsxReader.OpenVirtual("large.xlsx", SheetLoadMode.OnDemand) streams sheets in on demand instead of loading the whole book.
The one behaviour to know about: the licence throws
This is the gotcha, and it is deliberate.
Without a valid licence key, PDF export throws ReoGridLicenseException. It does not emit a watermarked PDF.
using unvell.ReoGrid.Core.License;
// Read it from configuration or the environment — not from a string literal in source.
ReoGridLicense.SetLicense(Environment.GetEnvironmentVariable("REOGRID_LICENSE")!);
The reasoning is worth spelling out, because the on-screen grid behaves the opposite way: an unlicensed control stays usable read-only and shows a watermark, because a person looking at the screen can see what is wrong and act on it. A headless service has no such channel. If file I/O degraded quietly, the watermark’s first reader would be your customer, on an invoice. So file I/O fails loudly instead — XLSX, CSV, PDF and reogrid-json all refuse rather than emit output.
The same applies to a trial key that expires: the day it lapses, the deployed service starts throwing on export. Worth a health check that calls ReoGridLicense.IsLicensed at startup rather than discovering it from a 500 at month end:
if (!ReoGridLicense.IsLicensed)
throw new InvalidOperationException("ReoGrid licence missing or expired — PDF export will fail.");
One key covers every version and platform, and it is perpetual: a purchased key keeps working after the update subscription lapses, and a key issued for V4 activates V5 unchanged.
On V4
ReoGrid V4 has no PDF exporter. Its only route to a PDF is the print pipeline pointed at a virtual printer:
sheet.PrintSettings.PrinterName = "Microsoft Print to PDF";
sheet.CreatePrintSession().Print();
That works well on a desktop — it is covered in Printing a Spreadsheet in C# — but it inherits every constraint of a printer driver, and the model was tightly coupled to the control, so server-side use was never practical. The UI/core separation in V5 exists precisely so this scenario works.
Summary
- Exporting a worksheet to PDF on a server does not need Office, a printer driver, a headless browser, or a PDF package.
unvell.ReoGrid.One.Coreisnet10.0with zero dependencies and noSystem.Drawing - The PDF comes out of the same renderer that paints the screen, so the sheet’s own layout — merges, borders, conditional formatting, number formats — carries over instead of being rebuilt
PdfExporter.Export(...)writes a file;ExportToBytes(...)is the one an API endpoint wants- Omit
PrintSettingsand each sheet uses its own page setup, loaded from the XLSX. Margins are in points RepeatRowsputs the header row on every page without printing it twice, and&P/&N/&Awork as in Excel- Japanese is already handled: IPAexGothic is embedded, the text stays searchable via a
ToUnicodeCMap, 禁則処理 is applied, and non-CJK exports carry no font at all - A missing licence throws, it does not watermark. Set the key at startup and assert
IsLicensed— the design assumes a headless service would rather fail than ship a watermarked invoice
See what’s new in V5 / Start a 30-day trial
Further reading
- PDF Export — the reference page for
PdfExporter - Running Headless — servers and batch jobs with no UI
- Printing and Page Setup — every
PrintSettingsoption in detail - XLSX Import and Export — reading the workbook in the first place
- Printing a Spreadsheet in C# — Print, Preview, and PDF in WinForms and WPF
- Building an Input Sheet Users Can’t Break