← All posts

A PDF from the screen you already built

A PDF from the screen you already built

Every line-of-business application eventually has to put something on paper. An invoice, an order confirmation, a packing list, a VAT summary — a document with a company logo on it that somebody outside the building is going to read.

This is usually the point where a second product arrives. A reporting tool, with its own connection to the database, its own query language, its own designer, its own deployment step and its own licence. You now model your data twice: once for the application, and again for the report. When a column changes, you change it in two places and find out which one you forgot when a customer phones.

The irony is that you already described that data perfectly well. You built a screen for it.

A report in TSQL.APP is a Word template plus the card you already defined. The data contract comes from the screen.

A button that produces a PDF

Here is the whole of it, as a card action:

sql
DECLARE @out NVARCHAR(MAX);
DECLARE @b64 NVARCHAR(MAX);

EXEC dbo.sp_api_carbone_report_generate
    @report_name   = N'invoice',
    @context_id    = @id,
    @download_only = 1,
    @dataout       = @out OUTPUT;

SELECT @b64 = value FROM OPENJSON(@out) WHERE [key] = 'base64data';

EXEC sp_api_modal_download @filename = N'invoice.pdf',
                           @mimetype = N'application/pdf',
                           @base64   = @b64;

@id is the record the user is looking at — the framework puts it there. The first call renders the document and hands back the bytes; the second pushes them at the browser as a download. There is no report server in the middle, no separate credential, and nothing to deploy.

Drop the @download_only and give the report a delivery rule instead, and the same call emails the PDF as an attachment or sends it to a printer. The routing is a small query stored with the report, so "who gets this" is data, not code.

One sharp edge worth knowing before you hit it: read the result with OPENJSON, as above, and not with JSON_VALUE. JSON_VALUE silently truncates at 4000 characters, which is shorter than any real PDF. The symptom is an attachment that arrives weighing nothing, and it is not obvious from the T-SQL that anything is wrong.

The template is a .docx, and that matters more than it sounds

The layout is a Word document with placeholders in it — {d.customer.name}, {d.lines[i].description}. You open it in Word, you move the logo, you change the font, you save it, you upload it to the report.

That is a deliberately unglamorous choice and it is the one people are most relieved by. The person who actually cares what an invoice looks like is rarely the person writing T-SQL. It is the office manager who wants the address block two millimetres left, or the accountant who needs the VAT line to say something specific. With a Word file, they do it themselves and send it back. Nobody waits for a release.

It also means the document is reviewable the way documents are. You can open last year's template and see exactly what went out.

The part that surprises people

Now the mechanism, and it is the reason this stops being "we also have reporting" and starts being something you would actually choose.

Your card is already a data contract, so the report can be derived from it.

A card in TSQL.APP is metadata. api_card_fields lists the columns on the screen, in the order you put them. api_card_children lists the related sets that hang off it — the order lines under the order, the payments under the invoice, each with the foreign key that connects them.

Read that honestly and it is a JSON schema. The parent is an object. Every child is an array. The field list is the set of properties. Nobody wrote that down for reporting purposes; it is a by-product of having built the screen.

So the framework can read your card and generate the query that produces exactly that shape:

json
{
  "invoice":  { "number": "2026-0042", "date": "2026-10-01", "total": 1250.00 },
  "lines":    [ { "description": "Consultancy", "amount": 1000.00 },
                { "description": "Travel",      "amount":  250.00 } ],
  "vat":      [ { "rate": 21, "amount": 262.50 } ]
}

And because it knows the shape, it can also generate a first-draft Word template to match — a table per child set, a field per column, placeholders already wired to the right names. Not a pretty document. A correct one, that renders, that you then hand to whoever owns the house style.

The practical effect is that the slow part of adding a report disappears. You are not deciding what the data should look like and then writing a query to produce it. The screen settled that question when you built it. What is left is layout, which is the only part that genuinely needs a human with an opinion.

The obvious objection

A generated query is a starting point, not a prophecy. Real documents want things no screen shows — a subtotal grouped three ways, last year's figure next to this year's, a line that only appears for export customers.

So the generated query is stored with the report and you edit it like any other T-SQL. From that moment it is yours; the framework does not come back and regenerate over your work. The derivation is scaffolding, which is the right thing for scaffolding to be: it gets you to a working report quickly and then gets out of the way.

The same applies to the template. Once a human has styled it, it is a file like any other file.

Why it ends up in the database

Rendering is real work, and SQL Server should not be doing it. It does not: the request is queued and a worker outside the database turns the template and the JSON into a document. The same route already carries email, Excel and HTTP fetches.

Which means a report can be synchronous when a user is waiting for a download, or queued when it is a month-end run of statements that should not hold a connection open. Queue it with sp_api_add_sql_task and the button returns immediately; the PDFs arrive by mail while the user carries on working.

The part that stays in the database is the part that should: which report, which template, which rows, who receives it. All of it is queryable, and all of it is in the same place as the rest of the application logic.

Where to look next

Document generation has its own chapter in the programming reference — the report registry, the data contract, and deriving a report from a card — next to the api_card, api_card_fields and api_card_children metadata it is built on. There is broader documentation on the framework, and if you would rather be shown than read, you can book a demo.