syncova-backup/packages/reports/export_pdf.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

288 lines
8.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package reports
import "io"
// Schriftgrade des Berichts.
const (
// titleFontSize ist der Grad der Berichtsueberschrift.
titleFontSize = 18.0
// sectionFontSize ist der Grad einer Abschnittsueberschrift.
sectionFontSize = 13.0
// bodyFontSize ist der Grad des Fliesstexts.
bodyFontSize = 9.5
// tableFontSize ist der Grad in Tabellen.
//
// Kleiner als der Fliesstext: Eine Tabelle mit sechs Spalten passt sonst
// nicht auf A4, und eine querformatige Seite mitten im Bericht ist
// unangenehmer als eine halbe Stufe kleinere Schrift.
tableFontSize = 8.0
)
// WritePDF schreibt einen Bericht als PDF.
func WritePDF(outputWriter io.Writer, report *Report) error {
document := newPDFDocument()
writePDFHeader(document, report)
for _, section := range report.Sections {
writePDFSection(document, section)
}
writePDFNotes(document, report)
_, writeError := outputWriter.Write(document.bytes())
return writeError
}
// writePDFHeader schreibt Titel und Kopfangaben.
func writePDFHeader(document *pdfDocument, report *Report) {
document.drawText(report.Title, fontBold, titleFontSize, 0)
document.addSpace(2)
for _, descriptionLine := range wrapText(report.Description, fontRegular, bodyFontSize, contentWidth) {
document.drawText(descriptionLine, fontRegular, bodyFontSize, 0)
}
document.addSpace(4)
// Ein Zustandsbericht bekommt keinen Zeitraum angezeigt. Ihn mit zwei
// gleichen Zeitpunkten auszuweisen sähe nach einem Fehler aus.
if report.PeriodFrom.Before(report.PeriodTo) {
document.drawText("Zeitraum: "+FormatTimestamp(report.PeriodFrom)+" bis "+
FormatTimestamp(report.PeriodTo), fontRegular, bodyFontSize, 0)
} else {
document.drawText("Zustand zum "+FormatTimestamp(report.PeriodTo),
fontRegular, bodyFontSize, 0)
}
generationLine := "Erzeugt am " + FormatTimestamp(report.GeneratedAt)
if report.GeneratedBy != "" {
generationLine += " von " + report.GeneratedBy
}
document.drawText(generationLine, fontRegular, bodyFontSize, 0)
document.addSpace(4)
document.drawRule(1, 0.3)
}
// writePDFSection schreibt einen Abschnitt.
func writePDFSection(document *pdfDocument, section Section) {
document.addSpace(8)
document.drawText(section.Title, fontBold, sectionFontSize, 0)
if section.Description != "" {
for _, descriptionLine := range wrapText(section.Description, fontRegular, bodyFontSize, contentWidth) {
document.drawText(descriptionLine, fontRegular, bodyFontSize, 0)
}
}
document.addSpace(4)
writePDFMetrics(document, section.Metrics)
for _, table := range section.Tables {
writePDFTable(document, table)
}
}
// writePDFMetrics schreibt die Kennzahlen eines Abschnitts.
//
// Die Beschriftung steht links, der Wert an einer festen Spalte rechts davon.
// Eine ungemessene Kennzahl zeigt „nicht gemessen" und darunter den Grund —
// nicht eine Null, die im Bericht wie eine Messung aussaehe.
func writePDFMetrics(document *pdfDocument, metrics []Metric) {
const valueColumnOffset = 260.0
for _, metric := range metrics {
document.ensureSpace(bodyFontSize + 4)
labelY := document.cursorY
document.drawText(truncateToWidth(metric.Label, fontRegular, bodyFontSize, valueColumnOffset-8),
fontRegular, bodyFontSize, 0)
// Der Wert gehoert auf dieselbe Zeile; drawText hat den Zeiger schon
// weitergesetzt, also wird er kurz zurueckgestellt.
valueY := document.cursorY
document.cursorY = labelY
document.drawText(metric.Display(), fontBold, bodyFontSize, valueColumnOffset)
document.cursorY = valueY
if !metric.IsKnown && metric.UnknownReason != "" {
for _, reasonLine := range wrapText(metric.UnknownReason, fontRegular, bodyFontSize-1,
contentWidth-valueColumnOffset) {
document.drawText(reasonLine, fontRegular, bodyFontSize-1, valueColumnOffset)
}
}
}
}
// writePDFTable schreibt eine Tabelle.
func writePDFTable(document *pdfDocument, table Table) {
document.addSpace(6)
document.drawText(table.Title, fontBold, bodyFontSize+0.5, 0)
document.addSpace(2)
if len(table.Rows) == 0 {
document.drawText(emptyNoticeOf(table), fontRegular, bodyFontSize, 8)
return
}
columnOffsets, columnWidths := measureColumns(table)
document.ensureSpace(3 * (tableFontSize + 3))
headerY := document.cursorY
for columnIndex, columnLabel := range table.Columns {
document.cursorY = headerY
document.drawText(truncateToWidth(columnLabel, fontBold, tableFontSize, columnWidths[columnIndex]),
fontBold, tableFontSize, columnOffsets[columnIndex])
}
document.cursorY = headerY - tableFontSize - 3
document.drawRule(0.5, 0.6)
for _, tableRow := range table.Rows {
document.ensureSpace(tableFontSize + 4)
rowY := document.cursorY
for columnIndex := range table.Columns {
cellText := ""
if columnIndex < len(tableRow) {
cellText = tableRow[columnIndex]
}
document.cursorY = rowY
document.drawText(truncateToWidth(cellText, fontRegular, tableFontSize, columnWidths[columnIndex]),
fontRegular, tableFontSize, columnOffsets[columnIndex])
}
document.cursorY = rowY - tableFontSize - 3
}
}
// measureColumns bestimmt Position und Breite jeder Spalte.
//
// Gemessen wird der breiteste tatsaechliche Inhalt, nicht die Spaltenzahl:
// Gleich breite Spalten verschenken den Platz an eine Datumsspalte und nehmen
// ihn dem Pfad, der die eigentliche Auskunft traegt.
//
// Passt die Summe nicht auf die Seite, geben **nur die breitesten Spalten** ab.
// Ein gleichmaessiger Stauchfaktor waere der naheliegende Weg und der falsche:
// Er kuerzte in der Probe „TEILWEISE FEHLGESCHLAGEN" zu „TEILWEISE FEHL…",
// weil daneben ein langer Pfad stand — ausgerechnet der Wert, wegen dessen man
// den Bericht liest. Gesucht wird deshalb eine gemeinsame Obergrenze: Spalten
// darunter bleiben unangetastet, alles darueber wird auf sie gekappt.
func measureColumns(table Table) ([]float64, []float64) {
const columnGap = 8.0
columnCount := len(table.Columns)
naturalWidths := make([]float64, columnCount)
for columnIndex, columnLabel := range table.Columns {
naturalWidths[columnIndex] = textWidth(columnLabel, fontBold, tableFontSize)
}
for _, tableRow := range table.Rows {
for columnIndex := 0; columnIndex < columnCount && columnIndex < len(tableRow); columnIndex++ {
cellWidth := textWidth(tableRow[columnIndex], fontRegular, tableFontSize)
if cellWidth > naturalWidths[columnIndex] {
naturalWidths[columnIndex] = cellWidth
}
}
}
totalWidth := 0.0
for _, naturalWidth := range naturalWidths {
totalWidth += naturalWidth
}
availableWidth := contentWidth - float64(columnCount-1)*columnGap
widthCap := findWidthCap(naturalWidths, availableWidth, totalWidth)
columnOffsets := make([]float64, columnCount)
columnWidths := make([]float64, columnCount)
currentOffset := 0.0
for columnIndex := range columnCount {
columnWidths[columnIndex] = min(naturalWidths[columnIndex], widthCap)
columnOffsets[columnIndex] = currentOffset
currentOffset += columnWidths[columnIndex] + columnGap
}
return columnOffsets, columnWidths
}
// findWidthCap sucht die Obergrenze, bei der alle Spalten auf die Seite passen.
//
// Die Spalten werden aufsteigend durchlaufen: Bleibt fuer die verbleibenden im
// Schnitt mehr Platz uebrig, als die aktuelle braucht, bekommt sie ihre volle
// Breite und gibt den Rest weiter. Andernfalls teilen sich alle uebrigen den
// Rest zu gleichen Teilen — das ist die gesuchte Grenze.
func findWidthCap(naturalWidths []float64, availableWidth float64, totalWidth float64) float64 {
if totalWidth <= availableWidth {
return availableWidth
}
sortedWidths := make([]float64, len(naturalWidths))
copy(sortedWidths, naturalWidths)
for outerIndex := 1; outerIndex < len(sortedWidths); outerIndex++ {
currentWidth := sortedWidths[outerIndex]
innerIndex := outerIndex - 1
for innerIndex >= 0 && sortedWidths[innerIndex] > currentWidth {
sortedWidths[innerIndex+1] = sortedWidths[innerIndex]
innerIndex--
}
sortedWidths[innerIndex+1] = currentWidth
}
remainingWidth := availableWidth
for widthIndex, sortedWidth := range sortedWidths {
remainingColumns := float64(len(sortedWidths) - widthIndex)
equalShare := remainingWidth / remainingColumns
if sortedWidth > equalShare {
return equalShare
}
remainingWidth -= sortedWidth
}
return availableWidth
}
// writePDFNotes schreibt die Hinweise ans Ende.
func writePDFNotes(document *pdfDocument, report *Report) {
if len(report.Notes) == 0 {
return
}
document.addSpace(10)
document.drawRule(0.5, 0.6)
document.drawText("Hinweise", fontBold, bodyFontSize+1, 0)
document.addSpace(2)
for _, note := range report.Notes {
noteLines := wrapText(note, fontRegular, bodyFontSize-0.5, contentWidth-12)
for lineIndex, noteLine := range noteLines {
linePrefix := " "
if lineIndex == 0 {
linePrefix = "– "
}
document.drawText(linePrefix+noteLine, fontRegular, bodyFontSize-0.5, 0)
}
}
}