CipherShift365 — Branded PDF Generator
Component: CipherShift365.Portal.Pdf
Library: QuestPDF (pure .NET, no browser/external renderer)
Target framework: .NET 10.0
License mode: QuestPDF Community (free for <$1M revenue organizations)
What It Does
The Branded PDF Generator produces polished, client-ready PQC Readiness Reports from a DiscoveryReport. Every report is self-contained: deterministic layout, embedded branding, no dynamic content from the internet. It is designed to be run air-gapped — QuestPDF renders everything 100% in-process using the SkiaSharp graphics engine.
The generator produces a multi-page A4 document containing:
- Branded header — organization name, report title, date, colored rule line
- Executive summary — plain-language narrative with finding counts, risk score, and migration effort
- Score cards — six color-coded metric tiles (Quantum Broken, Quantum Vulnerable, Mitigable, Quantum Safe, System Score, Migration Items)
- Findings table — every cryptographic asset with algorithm, role, risk, source location, and PQC target
- Migration plan table — prioritized migration items with urgency, algorithm, target, effort, and rationale
- CNSA 2.0 compliance section (optional) — overall verdict, compliance profile, gap analysis table
- Provenance block — scan path, KB version, profile version, render timestamp, scan duration
- Branded footer — organization legal text, page numbers
Why It's Needed (Value Proposition)
| Need | How the PDF generator addresses it |
|---|---|
| Client deliverable | CISO engagements produce a branded, presentable PDF — not a raw CLI dump. |
| Air-gapped rendering | No browser (Puppeteer), no Office interop, no cloud service. QuestPDF renders entirely in-process. |
| Deterministic output | Same DiscoveryReport + same BrandingProfile = byte-identical PDF. Verifiable by hash. |
| Compliance evidence | CNSA 2.0 verdict, provenance block, and migration plan are all in one document — ready for auditor submission. |
| Zero per-use cost | QuestPDF Community tier covers organizations under $1M annual revenue. No SaaS watermark, no page limit. |
| Pure .NET | No native dependencies beyond .NET runtime and SkiaSharp. Deployable as a single NuGet reference. |
Installation
Prerequisites
| Requirement | Minimum |
|---|---|
| .NET SDK | 10.0 (build); runtime: 8.0+ |
| OS | Windows 10+ / Windows Server 2019+ / Linux x64 |
| QuestPDF license | Community (automatic for <$1M revenue) |
| Dependencies | CipherShift365.Core.Analysis (project reference) |
Package Reference
<PackageReference Include="QuestPDF" Version="2026.6.1" />
Or add via CLI:
dotnet add package QuestPDF --version 2026.6.1
Build
cd src/Portal/CipherShift365.Portal.Pdf
dotnet restore
dotnet build
How to Run / Use
Quick Start — Generate a Report
using CipherShift365.Portal.Pdf;
using CipherShift365.Core.Analysis;
// 1. Define branding
var brand = new BrandingProfile(
OrganizationName: "Acme Corp",
PrimaryColor: "#1a3a5c",
AccentColor: "#c0392b",
LegalFooter: "CipherShift365 by 365Architect — www.365architect.com — Confidential"
);
// 2. Create generator
var generator = new PdfReportGenerator(brand);
// 3. Generate PDF (DiscoveryReport comes from ScanService)
byte[] pdfBytes = generator.Generate(discoveryReport, cnsaValidationResult);
// 4. Save to disk
await File.WriteAllBytesAsync("acme-pqc-report.pdf", pdfBytes);
Via the REST API
Call POST /v1/reports/render with a JSON body:
curl -X POST http://localhost:5000/v1/reports/render \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"project":".", "organization":"Acme Corp"}' \
--output report.pdf
Via the Blazor Portal
Navigate to /reports, click "Branded PDF", and the browser will download the file. The portal calls ApiClient.GetPdfAsync(project) which POSTs to the render endpoint.
User Manual
Branding Profile
The BrandingProfile record controls every visual aspect of the report:
public sealed record BrandingProfile(
string OrganizationName, // Appears in header, adjusts pronouns in summary
string? LogoPath = null, // Future: embed PNG logo in header (currently unused by renderer)
string PrimaryColor = "#1a3a5c", // Header text, rule lines, table headers
string AccentColor = "#c0392b", // Non-compliant verdict color
string SafeColor = "#27ae60", // Compliant verdict / QuantumSafe color
string LegalFooter = "CipherShift365 by 365Architect — www.365architect.com — Post-Quantum Cryptography Readiness Platform"
);
Default profile:
BrandingProfile.Default // OrganizationName: "365Architect", all colors at defaults
Customization examples:
// Financial services firm
new BrandingProfile("Goldman Partners", PrimaryColor: "#003366", AccentColor: "#cc0000");
// Government agency — CNSA-focused
new BrandingProfile("Department of Defense — PQC Office",
PrimaryColor: "#1a3a5c",
LegalFooter: "CNSA 2.0 Readiness Assessment — For Official Use Only");
// Minimalist
new BrandingProfile("StartupXYZ", "#2c3e50", "#e74c3c", "#27ae60",
"Generated by CipherShift365 — PQC Platform by 365Architect");
Color Reference
Colors are specified as hex strings and mapped to QuestPDF's Colors palette:
| Profile field | CSS function | QuestPDF mapping |
|---|---|---|
PrimaryColor |
#1a3a5c |
Used as-is by QuestPDF FontColor() |
AccentColor |
#c0392b |
Used by Colors.Red.Darken1 for gap/warning rows |
SafeColor |
#27ae60 |
Used by Colors.Green.Darken1 for compliant verdicts |
Only the PrimaryColor is used directly from the hex string. AccentColor and SafeColor are defined in the profile record but the renderer currently uses QuestPDF's built-in palette (Colors.Red.Darken1, Colors.Green.Darken1). Future versions will use these profile values for full brand customization.
Report Structure
│ CNSA 2.0 COMPLIANCE (optional) │ │ Verdict: NonCompliant │ │ Profile: CNSA 2.0 v1.0.0 | KB: v1.0.0 │ │ Gap Analysis (table) │ │ Algorithm | Requirement | Action │ ├─────────────────────────────────────────────┤ │ PROVENANCE │ │ Scan path, KB version, profile, timestamp │ └─────────────────────────────────────────────┘ │ FOOTER: Legal text Page X / Y │ └─────────────────────────────────────────────┘
---
## Developer Manual
### Architecture
PdfReportGenerator ├── ctor(BrandingProfile) ├── Generate(DiscoveryReport, CnsaValidationResult?) → byte[] │ └── Document.Create(container => │ └── Page (A4, 40pt margin) │ ├── Header() — org name, date, rule line │ ├── Content() — all sections │ │ ├── ExecutiveSummary() │ │ ├── ScoreCards() — 6 colored tiles │ │ ├── FindingsTable() — sorted by risk severity │ │ ├── MigrationPlan() — with effort + rationale │ │ ├── CnsaSection() — conditional on cnsaResult │ │ └── Provenance() — scan metadata │ └── Footer() — legal text + page numbers └── Helper methods: ├── TableHeader(TableDescriptor, params string[]) ├── TableRow(TableDescriptor, params string[]) ├── ScoreCard(RowDescriptor, label, value, color) └── Truncate(string?, max) → string
### Project File
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>CipherShift365.Portal.Pdf</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="QuestPDF" Version="2026.6.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\CipherShift365.Core.Analysis\CipherShift365.Core.Analysis.csproj" />
</ItemGroup>
</Project>
Deterministic Rendering
The PDF generator is deterministic by design:
- Same input → same output. Given identical
DiscoveryReport,BrandingProfile, andCnsaValidationResult, the output PDF bytes are byte-identical. - Timestamp in content, not in layout. The render timestamp and scan duration are embedded as text in the provenance block — they do not affect layout decisions.
- Pagination is deterministic. QuestPDF's
Page()composable with fixed font sizes, margins, and column definitions produces the same page breaks for the same data. - No external assets. No web fonts, no CDN-hosted images, no API calls during rendering. Fonts use QuestPDF's built-in defaults (Lato-like).
Verification:
# Generate twice
dotnet run -- generate report1.pdf
dotnet run -- generate report2.pdf
# Compare
Get-FileHash report1.pdf | Select-Object -ExpandProperty Hash
Get-FileHash report2.pdf | Select-Object -ExpandProperty Hash
# → Identical hashes (if no CnsaValidationResult timestamp difference)
QuestPDF License
The generator sets the QuestPDF license in the static constructor:
static PdfReportGenerator() { QuestPDF.Settings.License = LicenseType.Community; }
Community tier conditions:
- Organization revenue < $1M USD/year OR
- Individual developer / open-source project
Organizations exceeding the revenue threshold must purchase a QuestPDF Professional or Enterprise license. See questpdf.com/license.
Adding a New Section
Sections are rendered via col.Item().Element(x => RenderMySection(x, report)):
private static void RenderMySection(IContainer c, DiscoveryReport r)
{
c.Column(col =>
{
col.Item().Text("My Custom Section").FontSize(12).Bold();
col.Item().Text($"Data: {r.Analysis.Findings.Count} findings").FontSize(10);
// Add tables, rows, score cards as needed
});
}
// Register in RenderContent():
col.Item().Element(x => RenderMySection(x, r));
Table Helpers
Two helper methods eliminate boilerplate for table rendering:
// Header row with grey background
private static void TableHeader(TableDescriptor t, params string[] h)
{
t.Header(hdr =>
{
foreach (var x in h)
hdr.Cell().Background(Colors.Grey.Lighten2).Padding(4)
.Text(x).FontSize(8).Bold();
});
}
// Data row with light bottom border
private static void TableRow(TableDescriptor t, params string[] cells)
{
foreach (var c in cells)
t.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2)
.Padding(3).Text(c).FontSize(7).LineHeight(1.2f);
}
Column Definitions
Tables use relative columns (not absolute widths):
table.ColumnsDefinition(cd =>
{
cd.RelativeColumn(2); // 2 parts (Algorithm)
cd.RelativeColumn(1); // 1 part (Role)
cd.RelativeColumn(1.5f); // 1.5 parts (Risk)
cd.RelativeColumn(3); // 3 parts (Location)
cd.RelativeColumn(1.5f); // 1.5 parts (PQC Target)
});
// Total: 9 parts → 2/9 = ~22%, 1/9 = ~11%, etc.
This ensures consistent proportions across different page sizes (A4, Letter, Legal).
Truncation Strategy
Long text fields (rationale, gap actions) are truncated to fit table cells:
private static string Truncate(string? text, int max) =>
text is null || text.Length <= max ? text ?? "" : text[..(max - 3)] + "...";
- Executive summary rationale: 60 chars
- CNSA gap actions: 60 chars
- CNSA gap requirements: 40 chars
Code Examples
Standalone CLI PDF Generator
using CipherShift365.Portal.Pdf;
using CipherShift365.Core.Analysis;
using CipherShift365.Core.Runtime;
// Minimal example: assemble a DiscoveryReport manually
var kb = new InMemoryKnowledgeBase(new KbVersion(1, 0, 0), Array.Empty<KbEntry>());
var riskModel = new RiskModel(kb);
var engine = new AnalysisEngine(riskModel);
var pipeline = new DiscoveryPipeline(engine, kb, new IdentityService(), riskModel, new PolicyEngine(riskModel));
var report = await pipeline.RunAsync(".", ct: CancellationToken.None);
var brand = new BrandingProfile("My Organization");
var generator = new PdfReportGenerator(brand);
var pdf = generator.Generate(report);
await File.WriteAllBytesAsync("output.pdf", pdf);
Console.WriteLine($"Generated {pdf.Length} bytes");
Batch Generation with Different Brands
var clients = new[]
{
new BrandingProfile("Client Alpha", PrimaryColor: "#003399"),
new BrandingProfile("Client Beta", PrimaryColor: "#990033"),
};
foreach (var brand in clients)
{
var gen = new PdfReportGenerator(brand);
var pdf = gen.Generate(report);
var filename = $"pqc-report-{brand.OrganizationName.Replace(" ", "-").ToLower()}.pdf";
await File.WriteAllBytesAsync(filename, pdf);
}
Embedding in ASP.NET Controller
[HttpPost("export/pdf")]
public async Task<IActionResult> ExportPdf([FromBody] ExportRequest req)
{
var report = await _scanService.ScanAsync(req.Project);
var brand = new BrandingProfile(req.Organization ?? "365Architect");
var generator = new PdfReportGenerator(brand);
var pdf = generator.Generate(report, _cnsaValidator.Validate(report.SignedCbom));
return File(pdf, "application/pdf",
$"ciphershift-{DateTime.UtcNow:yyyyMMdd-HHmmss}.pdf");
}
Configuration Options
| Setting | Location | Default | Description |
|---|---|---|---|
QuestPDF.Settings.License |
Static ctor | LicenseType.Community |
QuestPDF licensing tier |
| Page size | Generate() |
PageSizes.A4 |
A4 (210mm × 297mm) |
| Page margin | Generate() |
40 pts | All four sides |
| Default font size | Page() style |
10 pt | Base text size |
| Header font size | RenderHeader() |
16 pt | Organization name |
| Section title size | Section methods | 12–14 pt | Section headings |
| Table header size | TableHeader() |
8 pt bold | Column headers |
| Table cell size | TableRow() |
7 pt | Data cells |
| Score card value size | ScoreCard() |
16 pt bold | Metric numbers |
| Score card label size | ScoreCard() |
8 pt | Metric labels |
| Footer size | RenderFooter() |
7 pt | Legal text and page numbers |
Third-Party Dependencies
| Package | Version | License | Use |
|---|---|---|---|
QuestPDF |
2026.6.1 | Community / Commercial | PDF layout engine (SkiaSharp-based) |
CipherShift365.Core.Analysis |
(project ref) | Proprietary | DiscoveryReport, CnsaValidationResult types |
No other dependencies. The generator does not reference:
System.Drawing(Windows-only — QuestPDF uses SkiaSharp which is cross-platform)- Any HTML-to-PDF converter (Puppeteer, wkhtmltopdf, iText)
- Any Microsoft Office interop libraries
Design Decisions & Rationale
| Decision | Rationale |
|---|---|
| QuestPDF over Syncfusion/itext | QuestPDF Community tier has no watermark, no page limits, and no per-document fee. iText is AGPL (restrictive). Syncfusion Community requires registration. |
| Fluent API (no templates) | QuestPDF's C# fluent API (Document.Create, container.Page, col.Item()) is type-safe, discoverable via IntelliSense, and avoids a separate templating language (Razor, Liquid, XSL-FO). |
| No HTML-to-PDF bridge | Eliminates Puppeteer/Playwright dependency. The report is authored directly in QuestPDF's layout model. |
| Static helper methods | All rendering methods are static — the generator is a pure-function pipeline. Only the BrandingProfile field is instance state. |
| CNSA section is optional | Not every scan needs CNSA validation. The cnsaResult parameter is nullable — if null, the section is omitted entirely. |
| Truncation, not wrapping | Table cells have fixed proportions. Long text is truncated rather than wrapping and breaking column alignment. Full text is available in the JSON API and Markdown report. |