Skip to content

feat: Server side pdf report generation - #497

Draft
Zacgoose wants to merge 23 commits into
devfrom
preview/server-side-pdf-reports
Draft

feat: Server side pdf report generation#497
Zacgoose wants to merge 23 commits into
devfrom
preview/server-side-pdf-reports

Conversation

@Zacgoose

@Zacgoose Zacgoose commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Swaps react PDF report generation for server side generation allowing for scheduled PDF generation without logging into CIPP.

This pull request adds a comprehensive set of new sample data files for various report types and updates image validation logic in Add-CIPPImage.ps1 to enforce supported image formats. The main changes include the addition of realistic sample datasets for executive summaries, mail flow, permissions, sharing, shadow AI, and BEC (Business Email Compromise) reports, as well as stricter image format validation to ensure only supported types are accepted.

Sample Data Additions:

  • Executive, Mail Flow, Permissions, Sharing, Shadow AI, and BEC sample data files were added under backend/Config/ReportSamples/, providing representative data structures and values for previewing and testing report generation. [1] [2] [3] [4] [5] [6]
  • A sample report builder configuration (reportBuilder.json) was added, including various block types and content to support report layout previews.

Image Format Validation:

  • The Add-CIPPImage function in Add-CIPPImage.ps1 now restricts uploads to a defined set of supported image formats (png, jpeg, jpg, gif, bmp, tiff, webp, svg+xml). Attempts to upload unsupported formats will result in an error.

These changes provide robust sample datasets for development and demonstration purposes, and improve the reliability and security of image uploads by enforcing format restrictions.

TODO: update BEC reports once BEC preview branch is merged

Tree builders return @{ Blocks; Variables }: the cover/footer text and the
exposure, hygiene and threat grades live with the report composition instead
of being recomputed in every endpoint, and the hero dividers reference the
bundled /reportImages paths directly (Get-CippReportHeroImages removed).

ConvertTo-CippReportPdf resolves branding itself (the settings, or a
-BrandingPresetId) and every caller now passes -TenantFilter, so %cippurl%
style variables resolve in Report Builder PDFs too. The Report Builder preset
lookup used a parameter name the helper never had, so templates always
rendered with the global branding.

Report Builder PDFs are stored in their own ReportBuilderPdfs table so listing
reports never pulls the base64 again; ExecGetReportBuilderPdf reads it and
answers 404 when absent, delete removes the head and part rows, and the
preview path hands back bytes instead of a base64 round-trip.
ExecGenerateReportBuilderReport returned through Push-OutputBinding, which
does not exist under Craft, so generate and delete failed.

ExecGetMailFlowReportPdf reads the Exchange reports directly instead of
invoking the list entrypoint with a fabricated request. Chart and stat-row
builders accept empty data (a tenant with no mail crashed the render) and a
missing list no longer renders as one blank row. PowerShell scoring and sums
are built with subexpressions rather than +=.

Frontend: one useServerPdf hook and ServerPdfPane replace six copies of the
fetch/blob/iframe logic; object URLs are revoked on close and superseded
fetches are cancelled. The Executive dialog drops its organization lookup.

C#: the emoji font, coverage and Twemoji flags are lazy statics instead of
per-render mutable globals; hex parsing is shared; dead members removed.

Tests: a Pester suite renders every tree builder from sample data (including
an empty mail-flow window) and the Push-Exec tests cover the new table.
… MEM endpoints

The server-rendered reports now carry the branding logo where the client did:
on the cover at 100pt beside the date, with the hero block moving down under
it exactly as the react-pdf cover header did, and at the right of every
content-page header at 30pt. A logo the engine cannot embed only fails when
the document is serialised, so the render retries once without it rather
than losing the report.

The Sharing, Permissions and Shadow AI reports are compiled by shared
CIPPCore getters (Get-CIPPSharePointSharingReport,
Get-CIPPSharePointPermissionsReport, Get-CIPPShadowAIReport). The list
endpoints and the PDF endpoints both call them, so the PDF endpoints no
longer invoke a list entrypoint in-process with a fabricated request. The
rollups are built with Group-Object and Measure-Object rather than +=
counters, and a tenant with no AI tools gets empty chart lists instead of a
single null entry. Their output was compared against the running endpoints
for three tenants and matches.

The assignment-filter endpoints carried a stale comment about
Push-OutputBinding (they already return an HttpResponseContext) and two
debug Write-Host lines; both removed.

Tests: fixtures for each getter, and the render suite covers a logo the
engine rejects.
…and at render

The branding upload took any image/* file while the PDF engine only drew PNG
and JPEG, so an SVG or GIF logo was stored, sent down on every page load and
then silently dropped from the report. Both ends now agree on one list.

At render time every raster OfficeIMO decodes (PNG, JPEG, GIF, BMP, TIFF,
WebP) is handed to it as-is, and an SVG is rasterised once at decode through
OfficeIMO's own SVG reader (transparent, 1200px on the long side) so it works
in the cover drawing, the page header and as a cover photo alike. Image type
and size now come from OfficeIMO's identifier instead of hand-written PNG and
JPEG header parsing, which also rejects a corrupt file up front.

Add-CIPPImage rejects any other subtype with a clear message. The branding
page restricts the file picker to the same list, and redraws SVG, GIF, BMP
and WebP uploads as PNG in the browser (SVG at up to 1024px) so the stored
image, the react-pdf branding preview and the server render all see a plain
raster; PNG and JPEG are stored as picked. The size ceiling is checked again
after conversion.
… the client PDF kit

The branding editor's full-report preview now asks ExecPreviewBrandingReportPdf for a
real report rendered by the server against fixed sample data, one file per report type
under Config/ReportSamples, so it shows the same document a client receives. Uploaded
logos and covers are stored as picked - the browser-side PNG conversion is gone - and
the engine now honours coverStock ("none" or a bundled photo) the way the client
resolver did.

The react-pdf documents, primitives, charts, fonts, inline-markup helpers and their
tests only served that preview and are removed; the theme, page metrics and cover
resolution stay for the report builder and the cover mock. CHART_KINDS moves to the
builder's settings. The OpenAPI spec still needs regenerating for the new endpoint.
OfficeIMO's Watermark takes (text, fontSize, colour, opacity, angle) positionally, so
the call passed 0.08 as the font size and every watermark was drawn invisibly small.
Named arguments now give it the client style: 72pt bold uppercase, 8% opacity,
rotated -45 degrees.
… format

The table export is jsPDF in the browser and only embedded a PNG or JPEG logo, so an
SVG, GIF, BMP or WebP logo from the branding gallery silently vanished from every export.
A logo jsPDF cannot take is now redrawn as a PNG through the browser's own decoder, into
four times its printed box so it stays crisp without bloating the file; TIFF or a corrupt
file is dropped without stopping the export.

The export also renders against the same report theme as the PDFs: the table role colour
on the head with readable text over it, the title and subtitle colours on the table's
title and date under the logo, and the branding footer text with the report tokens
resolved plus page numbers. Tokens only the server can resolve are dropped rather than
printed literally.
The Baseline What-If report was the last react-pdf document, and it imported the
client preview component removed with the rest of the kit, which broke the build. It
now renders through the shared CIPPSharp engine: ExecGetBaselineWhatIfReportPdf reads
the same alignment payload the page shows, resolves the optional simulated baseline by
GUID, composes it with Build-CippBaselineWhatIfReportTree and returns the PDF, so it
also carries the branding, cover, footer and watermark the other reports do. The
dialog keeps its baseline picker and previews the server PDF. The spec gains the new
endpoint.
OfficeIMO paints a page watermark before anything else, so every card, panel and image
covered it. Its PDFs are classic files with uncompressed content streams, so after
rendering the engine moves the watermark's operator block to the end of each page's
stream in place - the same bytes in a different order, no offset changes - and the mark
sits over the content the way the client reports drew it. Divider pages now carry the
mark too, in their text colour and a little stronger, as the client did on dark pages.
The builder could add five of the structured blocks the server engine renders. It now
offers all of them - titled page, note, bullet list, callout (info, good news or
warning), callout grid and table join chart, score cards, progress bars, section
divider and page break - each with a small editor in the same shape as the existing
ones. A table keeps its columns keyed so renaming a header never detaches its cells.

Adding a block is two steps, a category then a block, the way test results and database
data already asked for a suite or a source: one flat list of every block was more than
a dropdown reads well with. The category stays selected after an add so several blocks
of a kind can go in one after another. The sample template behind the branding preview
now carries the new blocks too.
A new branding setting, carried by presets too, decides which of the tenant's names a
report prints on its cover, in its text and as %tenantname%: the name CIPP shows (the
alias when one is set, the old and default behaviour), the Microsoft 365 organisation
name, or the default domain. Get-CippReportTenantName resolves it for every PDF endpoint
and the report builder, and ConvertTo-CippReportPdf substitutes %tenantname% with that
same name before the general replacement runs, so a footer agrees with the cover. The
branding preview names its sample tenant the same way.
…hic name

A Cover block sets the cover's title, accent, subtitle and label the way a fixed
report's tree builder does; left blank, the title stays the report's name. Long cover
titles now shrink to fit instead of running off the page, which the default "Quarterly
Security Review" already did. The callout grid gains a one-across layout and its editor
lays the callouts out in the grid the page will use, so the result can be judged
without a preview. "Section Divider" is now called Infographic, which is what it is.
…ages

An uploaded cover can be named in the branding gallery, and the report builder's
Infographic block lists the gallery's covers by those names beside the stock photos. A
gallery cover is stored on the block as gallery:<id> and read into the report when it
renders. Names live on small rows of their own, so naming never rewrites an image
payload, and they go when the image does.
Any figure or text in a block can name reporting-database data with a token, resolved
on the server when the report renders so a scheduled run and a preview read the same
data: &Users& counts a collection, &Users.displayName& lists a field,
&Devices.complianceState=compliant& counts the rows that match (wildcards and != work)
and &Mailboxes.TotalItemSize:sum& aggregates. A chart can be filled from a token, one
slice per value of the field, and a table from a collection with each column reading
the field it names. A token that names nothing stays as written so the mistake shows.

A builder chart's caption, centre label and axis maximum now reach the page; the engine
read them under the names the fixed reports use.
…tabase's shape

The cache writer now records each collection's shape - the fields its rows carry, one
nested level deep, with their types - on the collection's count row, and ListDBCache
serves it as type=_shape. The report builder's chart and table "fill from data" is a
picker built on it: a collection, for a chart the field to count by, and an optional
condition rows must meet, saved as an object rather than a typed token. A field can
still be typed, since a shape is sampled from the first rows. The resolver takes the
picked source and the older token form alike. Table columns offer the collection's
fields on their field input.
… plot a field's value

The token resolver returned the blocks as one wrapped array, which the generator then
wrapped again, so the renderer saw a single element holding every block and drew an
empty page: every builder preview and report since the resolver landed was blank. The
blocks now come back unrolled and the tests assert one element per block.

A chart's data source can now plot a field's value as well as count rows: "Show" picks
Count of rows or a numeric field, "Per" the field to show it against, and rows sharing
a label can be summed, averaged or given their highest or lowest. A date field makes a
trend of the last 30 points in date order, which is how a Secure Score trend is built
from the SecureScore collection. The typed points step aside while a source is picked.
…s or table's source

The source is chosen first, with a switch at the top of the card, and each mode shows
only its own controls: typed points or rows for Manual; the collection, what to show,
the field to show it per and a condition for the reporting database, with the table's
columns naming the field each reads. The "none, use what is typed" entry that used to
double as the switch is gone.
…s legend

A donut was drawn inside the 400x200 box the bar and trend charts fill, which left a
band of white above the ring and another under the legend. The ring now sits just under
the title, the legend just under the ring, and the frame is as tall as those need - one
legend row for up to three entries, two beyond.
…nder the ring

Legend entries were laid out in fixed columns from the frame's left edge, so a two-entry
legend sat at the far left and the middle. Each row is now laid out from its own centre,
sized from its text, with a gap between entries. The donut's legend also moves down a
little from the ring.
Switch CIPPSharp from the OfficeIMO.Pdf NuGet package to pinned local OfficeIMO.Core/Pdf 3.4.0 binaries so the patched CornerRadius support can be used for callout boxes and stat cards. Update report rendering code to match 3.4 API changes (PdfContentBuilder/PdfPageBuilder types, section-level page defaults, and PercentColumn layout calls) to restore compatibility and keep PDF output styling aligned with the frontend.
…DLLs

3.4.1 is the first upstream release carrying PdfTableStyle.CornerRadius /
PdfPanelStyle.CornerRadius, so CIPPSharp can consume it as a normal NuGet
PackageReference instead of the locally patched build vendored under lib/.
Delete backend/Shared/CIPPSharp/lib/ and refresh the runtime DLLs in bin/ to
the 3.4.1 build (OfficeIMO.Core resolves transitively).

3.4.1's cell-border renderer keeps every enabled side, including the
PdfCellBorder class-default grey, which lays grey over the accent at a rounded
corner. Disable the non-accent sides explicitly on the callout stripe cell and
the stat-card top-accent cell so only the accent draws and the corners stay
clean.
@Zacgoose
Zacgoose force-pushed the preview/server-side-pdf-reports branch from 22b4ad2 to ff878b7 Compare September 7, 2026 13:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant