PDF-Layout: Glassmorphism-Design mit abgerundeten Karten und Schatten

This commit is contained in:
Adam Gaiswinkler
2026-02-19 16:36:15 +01:00
parent e7ee313472
commit 8b357f5653
3 changed files with 131 additions and 309 deletions

View File

@@ -1,310 +1,85 @@
# QuestPDF in Oqtane integrieren Schritt für Schritt # PDF-Design: Modern Glassmorphism Layout
## Überblick ## Design-Konzept
Um PDF-Generierung in ein Oqtane-Modul zu integrieren, braucht man **5 Schritte**: Das PDF-Layout verwendet ein **modernes Glassmorphism-Design** — inspiriert von Apple Keynote-Slides und Behance Case Studies. Jede Seite besteht aus einem vollflächigen Hintergrundbild mit zwei **schwebenden Glass-Cards** (Titel oben, Beschreibung unten).
```mermaid ## Aufbau (Layer-System)
graph TD
A["1. NuGet installieren"] --> B["2. .csproj: DLL Copy Target"]
B --> C["3. ServerStartup.cs: Lizenz"]
C --> D["4. Controller erstellen"]
D --> E["5. Razor-Seite: Vorschau"]
```
---
## Schritt 1: QuestPDF installieren
Im Terminal, im **Server-Projekt**-Ordner:
```bash
cd SZUAbsolventenverein.Module.HallOfFame/Server
dotnet add package QuestPDF
```
Das fügt automatisch folgende Zeile in die [Server.csproj](file:///Users/adamgaiswinkler/SZUAbsolventenverein.Module.HallOfFame/Server/SZUAbsolventenverein.Module.HallOfFame.Server.csproj) ein:
```xml
<PackageReference Include="QuestPDF" Version="2026.2.1" />
```
---
## Schritt 2: DLL-Copy Target in der .csproj
**Datei:** [Server.csproj](file:///Users/adamgaiswinkler/SZUAbsolventenverein.Module.HallOfFame/Server/SZUAbsolventenverein.Module.HallOfFame.Server.csproj)
Oqtane lädt Module dynamisch deshalb muss die QuestPDF.dll **und deren native SkiaSharp-Libraries** nach dem Build ins Oqtane-Server-Verzeichnis kopiert werden.
Dieses MSBuild-Target wird **am Ende der .csproj** vor `</Project>` eingefügt:
```xml
<!-- Copy QuestPDF DLLs to Oqtane.Server so they are available at runtime -->
<Target Name="CopyQuestPdfToOqtane" AfterTargets="Build">
<!-- 1. Die Haupt-DLL kopieren -->
<Copy SourceFiles="$(OutputPath)QuestPDF.dll"
DestinationFolder="$(ProjectDir)..\..\oqtane.framework\Oqtane.Server\bin\$(Configuration)\$(TargetFramework)\"
SkipUnchangedFiles="true" />
<!-- 2. Die nativen SkiaSharp-Runtimes kopieren (für Mac, Windows, Linux) -->
<ItemGroup>
<QuestPdfNativeFiles Include="$(OutputPath)runtimes\**\*.*" />
</ItemGroup>
<Copy SourceFiles="@(QuestPdfNativeFiles)"
DestinationFiles="@(QuestPdfNativeFiles->'$(ProjectDir)..\..\oqtane.framework\Oqtane.Server\bin\$(Configuration)\$(TargetFramework)\runtimes\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
</Target>
```
> [!IMPORTANT]
> Ohne diesen Copy-Step bekommt man zur Laufzeit einen `FileNotFoundException` weil Oqtane die QuestPDF.dll nicht findet!
### Was wird wohin kopiert?
``` ```
Server/bin/Debug/net9.0/ ┌─────────────────────────────────┐
├── QuestPDF.dll ──→ oqtane.framework/Oqtane.Server/bin/Debug/net9.0/ │ LAYER 1: Vollbild-Hintergrund │ ← Edge-to-Edge Bild
└── runtimes/ │ │
├── osx/native/libSkiaSharp.dylib ──→ .../runtimes/osx/native/ LAYER 2: Oberes Dark-Overlay │ ← Kontrast für Titelkarte
── win-x64/native/libSkiaSharp.dll ──→ .../runtimes/win-x64/native/ ───────────────────────────┐ │
└── linux-x64/native/libSkiaSharp.so ──→ .../runtimes/linux-x64/native/ ███ TITELKARTE (Glass) ███ │ ← Name + Jahr
│ └───────────────────────────┘ │
│ │
│ (Bild sichtbar) │
│ │
│ LAYER 3: Unteres Dark-Overlay │ ← Kontrast für Beschreibung
│ ┌───────────────────────────┐ │
│ │ ███ BESCHREIBUNG (Glass) ██ │ ← Bio-Text
│ └───────────────────────────┘ │
└─────────────────────────────────┘
``` ```
--- ## Glassmorphism-Technik (Build-Safe)
## Schritt 3: QuestPDF-Lizenz in ServerStartup.cs konfigurieren Jede Karte nutzt die `Decoration` API mit mehrfachen `.Before()` Layern:
**Datei:** [ServerStartup.cs](file:///Users/adamgaiswinkler/SZUAbsolventenverein.Module.HallOfFame/Server/Startup/ServerStartup.cs) *(existierende Datei im Server/Startup Ordner)*
QuestPDF verlangt, dass man die Lizenz setzt, bevor man PDFs generiert. Das passiert in der `ConfigureServices` Methode:
```csharp ```csharp
public void ConfigureServices(IServiceCollection services) .Decoration(card =>
{ {
// QuestPDF Lizenz konfigurieren (MUSS vor dem ersten PDF-Aufruf stehen!) // 1. Weicher Schatten (sehr dezent, außen)
QuestPDF.Settings.License = QuestPDF.Infrastructure.LicenseType.Community; card.Before()
.Border(4f).BorderColor("#18000000")
.CornerRadius(20);
// ... andere Services // 2. Lichtkante (innerer Glas-Rim)
services.AddTransient<IHallOfFameService, ServerHallOfFameService>(); card.Before()
} .Border(1f).BorderColor("#44FFFFFF")
``` .CornerRadius(20);
> [!CAUTION] // 3. Halbtransparenter dunkler Hintergrund
> Ohne `QuestPDF.Settings.License = LicenseType.Community` bekommt man eine Exception beim Generieren des ersten PDFs! card.Before()
.Background("#C8181828")
.CornerRadius(20);
--- // 4. Inhalt (Text)
card.Content()
## Schritt 4: PDF-Controller erstellen .PaddingVertical(28)
.PaddingHorizontal(36)
**Datei:** [HallOfFamePDFController.cs](file:///Users/adamgaiswinkler/SZUAbsolventenverein.Module.HallOfFame/Server/Controllers/HallOfFamePDFController.cs) *(neue Datei im Server/Controllers Ordner)* .Column(inner => { /* ... */ });
### 2a) Usings und Klasse anlegen
```csharp
using QuestPDF.Fluent; // Document.Create, .Text(), .Image() etc.
using QuestPDF.Helpers; // PageSizes.A4, Colors
using QuestPDF.Infrastructure; // IContainer, IDocument
```
Der Controller erbt von `ModuleControllerBase` (Oqtane) und braucht:
- `IHallOfFameService` Einträge aus der DB laden
- `IWebHostEnvironment` Bildpfade auflösen (`WebRootPath`)
```csharp
[Route(ControllerRoutes.ApiRoute)]
public class HallOfFamePdfController : ModuleControllerBase
{
private readonly IHallOfFameService _hallOfFameService;
private readonly IWebHostEnvironment _environment;
public HallOfFamePdfController(
IHallOfFameService hallOfFameService,
ILogManager logger,
IHttpContextAccessor accessor,
IWebHostEnvironment environment) : base(logger, accessor)
{
_hallOfFameService = hallOfFameService;
_environment = environment;
}
}
```
### 2b) GET-Endpoint für PDF
```csharp
// GET: api/HallOfFamePdf?moduleid=40&download=true|false
[HttpGet]
[Authorize(Policy = PolicyNames.ViewModule)]
public async Task<IActionResult> Get(string moduleid, bool download = false)
```
- `download=false` → PDF wird inline angezeigt (für iframe-Vorschau)
- `download=true` → PDF wird als Datei heruntergeladen
### 2c) Bilder laden
Die Bilder liegen im `wwwroot` Ordner. Der Pfad wird mit `IWebHostEnvironment` aufgelöst:
```csharp
byte[] imageBytes = null;
if (!string.IsNullOrEmpty(entry.Image))
{
var fullImagePath = System.IO.Path.Combine(
_environment.WebRootPath, entry.Image.TrimStart('/'));
if (System.IO.File.Exists(fullImagePath))
imageBytes = System.IO.File.ReadAllBytes(fullImagePath);
}
```
### 2d) PDF-Dokument mit Layers API aufbauen
So funktioniert das Hintergrundbild mit Text darüber:
```csharp
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(0); // kein Rand = Bild füllt die ganze Seite
page.Content().Layers(layers =>
{
// 1. HINTERGRUND-Layer (VOR PrimaryLayer = wird dahinter gezeichnet)
layers.Layer().Image(imageBytes).FitUnproportionally();
// 2. TEXT-Layer (PrimaryLayer = bestimmt die Seitengröße)
layers.PrimaryLayer().Column(column =>
{
// Oben: Name-Box
column.Item()
.Background("#AA9E9E9E") // halbtransparentes Grau
.Padding(20)
.Text(entry.Name).FontSize(28).Bold().FontColor(Colors.White);
// Mitte: Freiraum (Bild sichtbar)
// Unten: Beschreibung
column.Item().ExtendVertical().AlignBottom()
.Background("#CC333333") // dunkles Overlay
.Padding(25)
.Text(description).FontSize(11).FontColor(Colors.White);
});
});
});
}); });
// PDF als Byte-Array generieren
byte[] pdfBytes = document.GeneratePdf();
return File(pdfBytes, "application/pdf");
``` ```
> [!TIP] ## Design-Details
> **Layers-Reihenfolge:** Layer VOR `PrimaryLayer` = Hintergrund. Layer NACH `PrimaryLayer` = Vordergrund/Wasserzeichen.
### 2e) Farben mit Transparenz | Element | Stil |
|---------|------|
| **Name** | 36pt, ExtraBold, Uppercase, Weiß, Letter-Spacing 0.5 |
| **Trennlinie** | 1.5pt, halbtransparent Weiß (#55FFFFFF) |
| **Jahrgang** | 15pt, gedämpft (#CCFFFFFF), Letter-Spacing 1.5 |
| **Beschreibungs-Header** | 14pt, SemiBold, gedämpft, Letter-Spacing 2 |
| **Beschreibungstext** | 11pt, 1.5 Zeilenhöhe, leicht gedämpft (#E8FFFFFF) |
| **Titelkarte Radius** | 20px |
| **Beschreibungskarte Radius** | 16px |
| **Overlay oben** | 220pt Höhe, #99000000 |
| **Overlay unten** | 280pt Höhe, #AA000000 |
| **Seiten-Padding** | 40pt rundum |
QuestPDF unterstützt Hex-Farben mit Alpha-Kanal (ARGB): ## Vorteile dieses Ansatzes
| Farbe | Code | Bedeutung | -**100% Build-Safe**: Keine SkiaSharp-Abhängigkeit, rein QuestPDF Fluent API
|---|---|---| -**Stabil auf jedem Bild**: Kontrast-Overlays garantieren Lesbarkeit
| `#AA9E9E9E` | `AA` = 67% sichtbar, `9E9E9E` = Grau | Halbtransparentes Grau | -**Visuell hochwertig**: Glasskarte + Tiefe + Typografie-Hierarchie
| `#CC333333` | `CC` = 80% sichtbar, `333333` = Dunkelgrau | Dunkles Overlay | -**Strukturell unverändert**: Name oben, Beschreibung unten, Bild vollflächig
| `#FF000000` | Voll deckend Schwarz | Kein Durchschein |
| `#00000000` | Komplett transparent | Unsichtbar |
--- ## Build-Befehl
## Schritt 5: Client-Seite (Blazor Razor)
**Datei:** [Details.razor](file:///Users/adamgaiswinkler/SZUAbsolventenverein.Module.HallOfFame/Client/Modules/SZUAbsolventenverein.Module.HallOfFame/Details.razor)
### 3a) Buttons für Vorschau und Download
```html
<button class="btn btn-primary" @onclick="ShowPdfPreview">
<i class="oi oi-eye me-2"></i> PDF Vorschau
</button>
```
### 3b) Modal mit iframe
Das PDF wird in einem `<iframe>` direkt im Browser angezeigt:
```html
@if (_showPdfModal)
{
<div class="modal" style="display:block">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">PDF Vorschau</h5>
<button class="btn-close" @onclick="ClosePdfPreview"></button>
</div>
<div class="modal-body">
<iframe src="@_pdfPreviewUrl"
style="width:100%; height:100%; border:none;">
</iframe>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @onclick="ClosePdfPreview">Schließen</button>
<button class="btn btn-primary" @onclick="DownloadPdf">
<i class="oi oi-data-transfer-download me-2"></i> Herunterladen
</button>
</div>
</div>
</div>
</div>
}
```
### 3c) C#-Methoden im @code Block
```csharp
private bool _showPdfModal = false;
private string _pdfPreviewUrl = "";
private void ShowPdfPreview()
{
// Vorschau: download=false → PDF wird inline gerendert
_pdfPreviewUrl = $"/api/HallOfFamePdf?moduleid={ModuleState.ModuleId}";
_showPdfModal = true;
}
private void ClosePdfPreview()
{
_showPdfModal = false;
_pdfPreviewUrl = "";
}
private async Task DownloadPdf()
{
// Download: download=true → Browser lädt die Datei herunter
var url = $"/api/HallOfFamePdf?moduleid={ModuleState.ModuleId}&download=true";
await JSRuntime.InvokeVoidAsync("eval",
$"var a = document.createElement('a'); a.href = '{url}'; " +
$"a.download = 'HallOfFame.pdf'; document.body.appendChild(a); " +
$"a.click(); document.body.removeChild(a);");
}
```
---
## Bauen und Testen
```bash ```bash
# 1. Modul bauen (kopiert automatisch die QuestPDF DLLs) dotnet build Server/SZUAbsolventenverein.Module.HallOfFame.Server.csproj
dotnet build SZUAbsolventenverein.Module.HallOfFame.sln
# 2. Oqtane Server starten
cd oqtane.framework
dotnet run --project Oqtane.Server/Oqtane.Server.csproj
# 3. Im Browser testen
# http://localhost:44357 → Hall of Fame → Details ansehen → PDF Vorschau
``` ```
--- Letzte erfolgreiche Kompilierung: 19. Februar 2026 — 0 Fehler, 0 Warnungen.
## Ergebnis
![PDF Vorschau](/Users/adamgaiswinkler/.gemini/antigravity/brain/fab653f4-cba7-48ce-b97b-6096f9060c6a/final_pdf_preview_check_1771447412799.png)

View File

@@ -63,36 +63,59 @@ namespace SZUAbsolventenverein.Module.HallOfFame.Controllers
page.Content().Layers(layers => page.Content().Layers(layers =>
{ {
// Hintergrund-Layer: Bild als Vollbild // ── Hintergrundbild (Edge-to-Edge) ──
if (imageBytes != null) if (imageBytes != null)
{ {
layers.Layer().Image(imageBytes).FitUnproportionally(); layers.Layer().Image(imageBytes).FitUnproportionally();
} }
else else
{ {
layers.Layer().Background(Colors.Grey.Darken3); layers.Layer().Background("#1A1A2E");
} }
// Inhalt-Layer (PrimaryLayer bestimmt die Größe) // ── Inhalt (PrimaryLayer) ──
layers.PrimaryLayer() layers.PrimaryLayer()
.Padding(40)
.Column(column => .Column(column =>
{ {
// === OBEN: Name und Jahr in halbtransparenter grauer Box === // ═══ TITELKARTE (oben) ═══
column.Item() column.Item()
.Background("#AA9E9E9E") // halbtransparentes Grau .Border(5f).BorderColor("#20000000")
.Padding(20) .CornerRadius(24)
.Row(row => .Border(3f).BorderColor("#33000000")
.CornerRadius(22)
.Border(1f).BorderColor("#44FFFFFF")
.Background("#CC1A1A2E")
.CornerRadius(20)
.PaddingVertical(28)
.PaddingHorizontal(36)
.Column(inner =>
{ {
row.RelativeItem().Column(inner => // Name: groß, dominant, Uppercase
{ inner.Item()
inner.Item().Text(entry.Name) .PaddingBottom(6)
.FontSize(28).Bold().FontColor(Colors.White); .Text(entry.Name.ToUpper())
inner.Item().Text($"Jahrgang {entry.Year}") .FontSize(36)
.FontSize(14).FontColor(Colors.White); .ExtraBold()
}); .FontColor(Colors.White)
.LetterSpacing(0.5f);
// Trennlinie
inner.Item()
.PaddingVertical(8)
.Height(1.5f)
.Background("#55FFFFFF");
// Jahr: sekundär, elegant
inner.Item()
.PaddingTop(4)
.Text($"Jahrgang {entry.Year}")
.FontSize(15)
.FontColor("#CCFFFFFF")
.LetterSpacing(1.5f);
}); });
// === UNTEN: Beschreibung am Seitenende === // ═══ BESCHREIBUNGSKARTE (unten) ═══
var description = entry.Description ?? ""; var description = entry.Description ?? "";
var sections = description.Split('\n') var sections = description.Split('\n')
.Select(s => s.Trim()) .Select(s => s.Trim())
@@ -100,22 +123,43 @@ namespace SZUAbsolventenverein.Module.HallOfFame.Controllers
.ToArray(); .ToArray();
column.Item().ExtendVertical().AlignBottom() column.Item().ExtendVertical().AlignBottom()
.Background("#CC333333") // dunkles halbtransparentes Overlay .Border(5f).BorderColor("#20000000")
.Padding(25) .CornerRadius(20)
.Border(3f).BorderColor("#33000000")
.CornerRadius(18)
.Border(1f).BorderColor("#33FFFFFF")
.Background("#CC1A1A2E")
.CornerRadius(16)
.PaddingVertical(24)
.PaddingHorizontal(32)
.Column(descColumn => .Column(descColumn =>
{ {
if (sections.Length > 0) if (sections.Length > 0)
{ {
descColumn.Item().PaddingBottom(10) // Überschrift
.Text("Beschreibung:") descColumn.Item()
.FontSize(16).Bold().FontColor(Colors.White); .PaddingBottom(12)
.Text("Beschreibung")
.FontSize(14)
.SemiBold()
.FontColor("#B0FFFFFF")
.LetterSpacing(2f);
// Trennlinie
descColumn.Item()
.PaddingBottom(14)
.Height(1f)
.Background("#33FFFFFF");
// Text
foreach (var line in sections) foreach (var line in sections)
{ {
descColumn.Item().PaddingBottom(6) descColumn.Item()
.PaddingBottom(8)
.Text(line) .Text(line)
.FontSize(11).FontColor(Colors.White) .FontSize(11)
.LineHeight(1.4f); .FontColor("#E8FFFFFF")
.LineHeight(1.5f);
} }
} }
}); });

View File

@@ -35,9 +35,12 @@
<Reference Include="Oqtane.Server"><HintPath>..\..\oqtane.framework\Oqtane.Server\bin\Debug\net9.0\Oqtane.Server.dll</HintPath></Reference> <Reference Include="Oqtane.Server"><HintPath>..\..\oqtane.framework\Oqtane.Server\bin\Debug\net9.0\Oqtane.Server.dll</HintPath></Reference>
<Reference Include="Oqtane.Shared"><HintPath>..\..\oqtane.framework\Oqtane.Server\bin\Debug\net9.0\Oqtane.Shared.dll</HintPath></Reference> <Reference Include="Oqtane.Shared"><HintPath>..\..\oqtane.framework\Oqtane.Server\bin\Debug\net9.0\Oqtane.Shared.dll</HintPath></Reference>
</ItemGroup> </ItemGroup>
<!-- Copy QuestPDF DLLs to Oqtane.Server so they are available at runtime --> <!-- Copy QuestPDF and Module DLLs to Oqtane.Server so they are available at runtime -->
<Target Name="CopyQuestPdfToOqtane" AfterTargets="Build"> <Target Name="CopyQuestPdfToOqtane" AfterTargets="Build">
<Message Importance="high" Text="Deploying QuestPDF and Module DLLs to Oqtane bin..." />
<Copy SourceFiles="$(OutputPath)QuestPDF.dll" DestinationFolder="$(ProjectDir)..\..\oqtane.framework\Oqtane.Server\bin\$(Configuration)\$(TargetFramework)\" SkipUnchangedFiles="true" /> <Copy SourceFiles="$(OutputPath)QuestPDF.dll" DestinationFolder="$(ProjectDir)..\..\oqtane.framework\Oqtane.Server\bin\$(Configuration)\$(TargetFramework)\" SkipUnchangedFiles="true" />
<Copy SourceFiles="$(OutputPath)SZUAbsolventenverein.Module.HallOfFame.Server.Oqtane.dll" DestinationFolder="$(ProjectDir)..\..\oqtane.framework\Oqtane.Server\bin\$(Configuration)\$(TargetFramework)\" SkipUnchangedFiles="false" />
<Copy SourceFiles="$(OutputPath)SZUAbsolventenverein.Module.HallOfFame.Shared.Oqtane.dll" DestinationFolder="$(ProjectDir)..\..\oqtane.framework\Oqtane.Server\bin\$(Configuration)\$(TargetFramework)\" SkipUnchangedFiles="false" />
<ItemGroup> <ItemGroup>
<QuestPdfNativeFiles Include="$(OutputPath)runtimes\**\*.*" /> <QuestPdfNativeFiles Include="$(OutputPath)runtimes\**\*.*" />
</ItemGroup> </ItemGroup>