Rask.Pdf — a page or component to PDF
Rask.Pdf prints a page of your app — or a component written only for print — to PDF, exactly as the user who
asks for it sees it. The page is rendered on the server as its first GET would be, and drawn by a headless
Chromium beside the app: real fonts and every script, a table heading repeated on every page, a footer line and
page numbers.
dotnet add package Rask.Pdf
A RaskApp turns it on when the app references the package. A hand-wired host calls
builder.Services.AddRaskPdf().
Print a page
using Rask.Printing;
// A button on the report page.
Ui.Button.OnClick(async () =>
Download.File("visszaterites.pdf", await Pdf.From(Routes.RefundReport(id)), Pdf.ContentType))["Letöltés PDF-ben"]
Pdf.From(route) takes a route of the app (Routes.X(...)), never a URL from user input. It:
- Checks the page's
[Authorize]for the caller — the session's user in a handler or a render, the request's user in a minimal-API endpoint, the enqueuing user in a job. A page they could not open is never printed:UnauthorizedAccessException. - Renders it as its first GET does — the same layouts, the same data, in a session of its own that is dropped afterwards. No live connection, no runtime script. A page that redirects, or answers 404 or 500, is not printed.
- Prints it in a browser context of its own, with the app's stylesheet, the kit's sheet and the fonts served from the app's web root. Nothing else is reachable — another host, an API, a file the app does not serve — and no script on the page runs.
A page's data is read before its first render is printed
The page prints from that first GET: there is no socket, so nothing arrives after it. Rask renders the page in
waves — render, wait for the async work that render started (every OnMount and OnUpdated, a query's first read),
render again — for up to Rask:Server:QuiescenceTimeout (5 s) in all, and prints the last wave. So a report that
awaits its data in OnMount prints the data, not its "Loading…":
protected override async Task OnMount() => _rows = await Refunds.For(Quarter);
What that wait cannot cover is printed as it stands: work the page detaches (_ = Poll()), work waiting on the
browser (a JavaScript call never returns here), and anything still out when the budget is spent. A page that loads
its data some other way — a timer, a click — prints its placeholder. The budget is the server's for every first
GET; Rask:Pdf:Timeout is a separate limit on the print itself.
The page is rendered in the caller's language when the app ships it (AddRaskCulture), and in the app's default
otherwise.
The same call in a minimal-API endpoint:
app.MapGet("/refunds/{id}.pdf", async (Guid id) =>
Results.File(await Pdf.From(Routes.RefundReport(id)), Pdf.ContentType, "visszaterites.pdf"));
Or a component written for print
var receipt = await Pdf.From(Receipt.Order(order));
The component is rendered as a whole document of the app: its shell, its stylesheet, its fonts. There is no route,
so no [Authorize] to run: the caller already holds the data it was given.
Write the report with Flux pieces
A report is an ordinary component, and the kit already knows what paper is:
- Controls are left off. Every
Ui.Button, a badge's ×,Ui.Pagination,Ui.Sidebarand its toggle,Ui.Header, toasts, tooltips, open dropdowns and popovers, and a modal's backdrop print as nothing, andUi.Maindrops its screen padding and panel. Ui.Tableprints whole. Cells wrap instead of being cut, the scroll frame lets the table run on, its heading repeats at the top of every page, and a row is never split across two.Ui.Accordionprints open — every item's content, closed ones included, with no chevron.
Hide your own parts with Tailwind's print:hidden; show something the kit hides with an important utility,
print:inline-flex!. None of this touches the screen: every rule is inside @media print, so the same
defaults apply when a reader prints the page from their browser.
The report is an ordinary component written with Flux pieces. On a server, Pdf.From(Routes.RefundReportPage()) renders it as its first GET would, for the user who clicked, and prints it with headless Chromium: the table heading repeats on every page by itself. rask.sh is static files, so the button downloads the PDF Rask.Pdf printed from this very component.
using System.Globalization;
namespace Rask.Site.Features;
// A report written with Flux pieces, as an app writes one to print: Pdf.From(Routes.RefundReport(id)) on a server
// turns exactly this into the PDF the demo downloads. The table's heading is a <thead>, so Chromium repeats it on
// every printed page by itself; break-inside-avoid keeps a row from being cut across two.
public sealed partial class RefundReport : Component
{
private static readonly NumberFormatInfo Hungarian = new() { NumberGroupSeparator = " ", NumberDecimalSeparator = "," };
protected override Component? Render() =>
Div.Class("refund-report")[
Ui.Heading.Level(1).Size(Ui.HeadingSize.Xl)["Gázolaj-visszatérítés — 2026. III. negyedév"],
Ui.Text.Class("mb-4")["Kovács és Társa Fuvarozó Kft. · Győr · jövedéki szám HU00012345"],
Ui.Table[
Ui.TableColumns[
Ui.TableColumn["Dátum"],
Ui.TableColumn["Számlaszám"],
Ui.TableColumn["Kút"],
Ui.TableColumn.Align(Ui.Align.End)["Liter"],
Ui.TableColumn.Align(Ui.Align.End)["Visszatérítés"]
],
RefundData.Vehicles.Select(Group)
],
Ui.Heading.Level(2).Size(Ui.HeadingSize.Lg).Class("mt-6")[
$"Összesen: {Amount(RefundData.Vehicles.Sum(v => v.Rows.Sum(r => r.Refund)))} Ft"]
];
private static Component Group(RefundData.Vehicle vehicle) =>
Ui.TableRows.Key(vehicle.Plate)[
Ui.TableRow.Class("break-inside-avoid")[
Ui.TableCell.Variant(Ui.TableCellVariant.Strong)[vehicle.Plate],
Ui.TableCell.Variant(Ui.TableCellVariant.Strong)[vehicle.Model],
Ui.TableCell[""],
Ui.TableCell[""],
Ui.TableCell[""]
],
vehicle.Rows.Select(row => Ui.TableRow.Key(row.Invoice).Class("break-inside-avoid")[
Ui.TableCell[row.Day.ToString("yyyy.MM.dd", CultureInfo.InvariantCulture)],
Ui.TableCell[row.Invoice],
Ui.TableCell[row.Station],
Ui.TableCell.Align(Ui.Align.End)[row.Litres.ToString("N1", Hungarian)],
Ui.TableCell.Align(Ui.Align.End)[$"{Amount(row.Refund)} Ft"]
]),
Ui.TableRow.Class("break-inside-avoid")[
Ui.TableCell[""],
Ui.TableCell[""],
Ui.TableCell.Variant(Ui.TableCellVariant.Strong)[$"{vehicle.Plate} összesen"],
Ui.TableCell.Align(Ui.Align.End).Variant(Ui.TableCellVariant.Strong)[
vehicle.Rows.Sum(r => r.Litres).ToString("N1", Hungarian)],
Ui.TableCell.Align(Ui.Align.End).Variant(Ui.TableCellVariant.Strong)[
$"{Amount(vehicle.Rows.Sum(r => r.Refund))} Ft"]
]
];
private static string Amount(decimal value) => value.ToString("N0", Hungarian);
}Gázolaj-visszatérítés — 2026. III. negyedév
Kovács és Társa Fuvarozó Kft. · Győr · jövedéki szám HU00012345
Dátum | Számlaszám | Kút | Liter | Visszatérítés |
|---|---|---|---|---|
| ŐRZ-104 | Volvo FH 500 | |||
| 2026.07.05 | SZ-10001-ŐRZ | Tatabánya, Csaba utca | 228,5 | 7 632 Ft |
| 2026.07.08 | SZ-10002-ŐRZ | Budaörs, Szabadság út | 265,5 | 8 868 Ft |
| 2026.07.11 | SZ-10003-ŐRZ | Győr, Fehérvári út | 302,5 | 10 104 Ft |
| 2026.07.14 | SZ-10004-ŐRZ | Mosonmagyaróvár, Lajta út | 339,5 | 11 339 Ft |
| 2026.07.17 | SZ-10005-ŐRZ | Tatabánya, Csaba utca | 376,5 | 12 575 Ft |
| 2026.07.20 | SZ-10006-ŐRZ | Budaörs, Szabadság út | 193,5 | 6 463 Ft |
| 2026.07.23 | SZ-10007-ŐRZ | Győr, Fehérvári út | 230,5 | 7 699 Ft |
| 2026.07.26 | SZ-10008-ŐRZ | Mosonmagyaróvár, Lajta út | 267,5 | 8 934 Ft |
| 2026.07.29 | SZ-10009-ŐRZ | Tatabánya, Csaba utca | 304,5 | 10 170 Ft |
| 2026.08.01 | SZ-10010-ŐRZ | Budaörs, Szabadság út | 341,5 | 11 406 Ft |
| 2026.08.04 | SZ-10011-ŐRZ | Győr, Fehérvári út | 378,5 | 12 642 Ft |
| 2026.08.07 | SZ-10012-ŐRZ | Mosonmagyaróvár, Lajta út | 195,5 | 6 530 Ft |
| 2026.08.10 | SZ-10013-ŐRZ | Tatabánya, Csaba utca | 232,5 | 7 766 Ft |
| 2026.08.13 | SZ-10014-ŐRZ | Budaörs, Szabadság út | 269,5 | 9 001 Ft |
| 2026.08.16 | SZ-10015-ŐRZ | Győr, Fehérvári út | 306,5 | 10 237 Ft |
| ŐRZ-104 összesen | 4 232,5 | 141 366 Ft | ||
| ÜGY-731 | Scania R 450 | |||
| 2026.07.06 | SZ-20001-ÜGY | Budaörs, Szabadság út | 239,5 | 7 999 Ft |
| 2026.07.09 | SZ-20002-ÜGY | Győr, Fehérvári út | 276,5 | 9 235 Ft |
| 2026.07.12 | SZ-20003-ÜGY | Mosonmagyaróvár, Lajta út | 313,5 | 10 471 Ft |
| 2026.07.15 | SZ-20004-ÜGY | Tatabánya, Csaba utca | 350,5 | 11 707 Ft |
| 2026.07.18 | SZ-20005-ÜGY | Budaörs, Szabadság út | 387,5 | 12 942 Ft |
| 2026.07.21 | SZ-20006-ÜGY | Győr, Fehérvári út | 204,5 | 6 830 Ft |
| 2026.07.24 | SZ-20007-ÜGY | Mosonmagyaróvár, Lajta út | 241,5 | 8 066 Ft |
| 2026.07.27 | SZ-20008-ÜGY | Tatabánya, Csaba utca | 278,5 | 9 302 Ft |
| 2026.07.30 | SZ-20009-ÜGY | Budaörs, Szabadság út | 315,5 | 10 538 Ft |
| 2026.08.02 | SZ-20010-ÜGY | Győr, Fehérvári út | 352,5 | 11 774 Ft |
| 2026.08.05 | SZ-20011-ÜGY | Mosonmagyaróvár, Lajta út | 389,5 | 13 009 Ft |
| 2026.08.08 | SZ-20012-ÜGY | Tatabánya, Csaba utca | 206,5 | 6 897 Ft |
| 2026.08.11 | SZ-20013-ÜGY | Budaörs, Szabadság út | 243,5 | 8 133 Ft |
| 2026.08.14 | SZ-20014-ÜGY | Győr, Fehérvári út | 280,5 | 9 369 Ft |
| 2026.08.17 | SZ-20015-ÜGY | Mosonmagyaróvár, Lajta út | 317,5 | 10 604 Ft |
| ÜGY-731 összesen | 4 397,5 | 146 876 Ft | ||
| ÁRV-215 | MAN TGX 18.510 | |||
| 2026.07.07 | SZ-30001-ÁRV | Győr, Fehérvári út | 250,5 | 8 367 Ft |
| 2026.07.10 | SZ-30002-ÁRV | Mosonmagyaróvár, Lajta út | 287,5 | 9 602 Ft |
| 2026.07.13 | SZ-30003-ÁRV | Tatabánya, Csaba utca | 324,5 | 10 838 Ft |
| 2026.07.16 | SZ-30004-ÁRV | Budaörs, Szabadság út | 361,5 | 12 074 Ft |
| 2026.07.19 | SZ-30005-ÁRV | Győr, Fehérvári út | 398,5 | 13 310 Ft |
| 2026.07.22 | SZ-30006-ÁRV | Mosonmagyaróvár, Lajta út | 215,5 | 7 198 Ft |
| 2026.07.25 | SZ-30007-ÁRV | Tatabánya, Csaba utca | 252,5 | 8 434 Ft |
| 2026.07.28 | SZ-30008-ÁRV | Budaörs, Szabadság út | 289,5 | 9 669 Ft |
| 2026.07.31 | SZ-30009-ÁRV | Győr, Fehérvári út | 326,5 | 10 905 Ft |
| 2026.08.03 | SZ-30010-ÁRV | Mosonmagyaróvár, Lajta út | 363,5 | 12 141 Ft |
| 2026.08.06 | SZ-30011-ÁRV | Tatabánya, Csaba utca | 180,5 | 6 029 Ft |
| 2026.08.09 | SZ-30012-ÁRV | Budaörs, Szabadság út | 217,5 | 7 264 Ft |
| 2026.08.12 | SZ-30013-ÁRV | Győr, Fehérvári út | 254,5 | 8 500 Ft |
| 2026.08.15 | SZ-30014-ÁRV | Mosonmagyaróvár, Lajta út | 291,5 | 9 736 Ft |
| 2026.08.18 | SZ-30015-ÁRV | Tatabánya, Csaba utca | 328,5 | 10 972 Ft |
| ÁRV-215 összesen | 4 342,5 | 145 039 Ft |
Összesen: 433 281 Ft
Options
var pdf = await Pdf.From(Routes.RefundReport(id), new PdfOptions
{
Page = PdfPage.A4.Landscape, // PdfPage.A4 (upright) unless set
Margins = PdfMargins.Millimetres(15), // or Millimetres(vertical, horizontal); 10 mm unless set
Footer = "Visszatérítés — 2026. III. negyedév",
PageNumbers = true, // "1 / 5" at the foot, right; on unless switched off
Tagged = false, // an accessible, tagged PDF; off unless asked for
});
Footeris printed as text — markup in it is shown, never run — at the foot of every page, left.PageNumbersare written from thePdfPageNumbercatalog text ({0}the page,{1}the count) in the caller's language:1 / 5in English,1 / 5 oldalin Hungarian. Translate or reword it in your ownResources/RaskStrings.{culture}.jsonlike any framework text.Taggedwrites the document's structure (headings, tables, reading order) for screen readers. It is most of the file: a 209-page table comes out 12 MB tagged and 0.45 MB untagged, and prints in twice the time.
The browser
Rask.Pdf prints with the Chrome for Testing headless shell, at one pinned version with a SHA-256 per platform
(build/Rask.Pdf.ChromePin.props in the package). It is never downloaded by the running app:
| Where | How the browser gets there |
|---|---|
Your machine, rask dev, dotnet test, CI |
Building an app that references Rask.Pdf fetches the pinned shell into the user cache — ~/Library/Caches/Rask, ~/.cache/rask (or $XDG_CACHE_HOME/rask), %LOCALAPPDATA%\Rask\Cache — once per version, after checking its SHA-256. |
| The container image | The scaffolded Dockerfile has dotnet publish fetch it into the build stage, copies it to /opt/rask/chrome, and installs the libraries it links against plus fonts — only when the app references Rask.Pdf. About 510 MB more on aspnet:10.0. |
| Anywhere else | Install Chromium or Google Chrome and set Rask:Pdf:ChromiumPath; the build's fetch is off with <RaskPdfDownloadChromium>false</RaskPdfDownloadChromium>. |
An app scaffolded before Rask.Pdf existed adds the same lines to its Dockerfile: copy them from any template's
(src/Rask.Templates/server/Dockerfile). The pin moves with Rask's daily dependency run, only ever to a Stable
release at least a week old.
Without a browser, the first print fails with what to do — never a download.
Settings
Rask:Pdf in appsettings.json (or Rask__Pdf__… in the environment):
| Setting | Default | |
|---|---|---|
ChromiumPath |
the pinned shell, then the usual install folders and PATH |
A browser of your own. |
NoSandbox |
false |
Starts Chromium with --no-sandbox. Only for a host whose kernel forbids the sandbox's user namespaces; the documents printed are always the app's own. |
MaxConcurrentPrints |
2 |
How many documents print at once; the rest wait. Memory is the bound: a 5,000-row table peaks its renderer at about 1.2 GB. |
Timeout |
00:00:30 |
How long one document may take; longer is a TimeoutException. |
MaxPages |
1000 |
The longest document that prints; a longer one fails rather than come out cut. |
How it runs
One headless Chromium per app, started at the first print and kept: a browser that crashed is replaced at the next
print, and stopping the host closes it. Rask drives it over its own DevTools pipe (--remote-debugging-pipe) —
no Puppeteer, no Playwright, no NuGet dependency. A 5,000-row table (139 A4 landscape pages, heading on every page)
prints in under a second on Linux.
Linux and macOS for now.