syncova-backup/packages/reports/pdf_writer.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

221 lines
7.6 KiB
Go
Raw 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 (
"bytes"
"fmt"
"strings"
)
// Der PDF-Schreiber ist bewusst selbst geschrieben.
//
// Der Plan sagt „PDF where practical". Die bequeme Auslegung waere, es fuer
// nicht praktikabel zu erklaeren — aber ein Bericht fuer eine Pruefung wird als
// PDF verlangt, nicht als CSV. Und was hier gebraucht wird, ist der einfachste
// Teil des Formats: Text in einer der vierzehn Standardschriften, die jeder
// Betrachter mitbringt. Keine Einbettung, keine Bilder, keine Transparenz.
//
// Der Preis ist die Breitentabelle weiter unten. Ohne sie liesse sich weder
// umbrechen noch eine Spalte bemessen; mit geschaetzten Breiten ueberlappen
// Tabellenspalten, sobald ein Wert laenger wird als erwartet.
// Seitenmasse in Punkten (72 dpi). A4 ist 595 × 842.
const (
// pageWidth ist die Seitenbreite in Punkten.
pageWidth = 595.0
// pageHeight ist die Seitenhoehe in Punkten.
pageHeight = 842.0
// pageMargin ist der Seitenrand in Punkten.
pageMargin = 48.0
// contentWidth ist die nutzbare Breite.
contentWidth = pageWidth - 2*pageMargin
// bottomLimit ist die Hoehe, ab der eine neue Seite beginnt.
bottomLimit = pageMargin + 24
)
// pdfFont benennt eine der beiden verwendeten Schriften.
type pdfFont string
const (
// fontRegular ist die Grundschrift.
fontRegular pdfFont = "F1"
// fontBold ist die Auszeichnungsschrift.
fontBold pdfFont = "F2"
)
// pdfDocument sammelt die Seiten eines Dokuments.
type pdfDocument struct {
// pageStreams sind die fertigen Inhaltsstroeme je Seite.
pageStreams []*bytes.Buffer
// currentStream ist der Strom der Seite, auf die gerade geschrieben wird.
currentStream *bytes.Buffer
// cursorY ist die aktuelle Schreibhoehe, von unten gemessen.
cursorY float64
}
// newPDFDocument beginnt ein Dokument mit einer ersten Seite.
func newPDFDocument() *pdfDocument {
document := &pdfDocument{}
document.beginPage()
return document
}
// beginPage beginnt eine neue Seite.
func (document *pdfDocument) beginPage() {
document.currentStream = &bytes.Buffer{}
document.pageStreams = append(document.pageStreams, document.currentStream)
document.cursorY = pageHeight - pageMargin
}
// ensureSpace beginnt eine neue Seite, wenn der Platz nicht reicht.
func (document *pdfDocument) ensureSpace(neededHeight float64) {
if document.cursorY-neededHeight < bottomLimit {
document.beginPage()
}
}
// drawText schreibt eine Zeile an der aktuellen Position.
func (document *pdfDocument) drawText(text string, font pdfFont, fontSize float64, offsetX float64) {
document.ensureSpace(fontSize + 2)
fmt.Fprintf(document.currentStream, "BT /%s %.1f Tf %.1f %.1f Td (%s) Tj ET\n",
font, fontSize, pageMargin+offsetX, document.cursorY, escapePDFString(text))
document.cursorY -= fontSize + 3
}
// drawRule zieht eine waagerechte Linie.
func (document *pdfDocument) drawRule(lineWidth float64, greyLevel float64) {
document.ensureSpace(6)
fmt.Fprintf(document.currentStream, "%.2f G %.2f w %.1f %.1f m %.1f %.1f l S\n",
greyLevel, lineWidth, pageMargin, document.cursorY, pageMargin+contentWidth, document.cursorY)
document.cursorY -= 8
}
// addSpace laesst senkrechten Raum.
func (document *pdfDocument) addSpace(height float64) {
document.cursorY -= height
}
// escapePDFString maskiert die drei Zeichen mit Sonderbedeutung in PDF.
func escapePDFString(text string) string {
encodedText := encodeWinAnsi(text)
replacer := strings.NewReplacer(`\`, `\\`, `(`, `\(`, `)`, `\)`)
return replacer.Replace(encodedText)
}
// encodeWinAnsi wandelt UTF-8 in die Kodierung der Standardschriften.
//
// Die vierzehn Standardschriften werden hier mit /WinAnsiEncoding eingebunden;
// jedes Zeichen ist also **ein** Byte. Ein deutscher Bericht ohne diese
// Umwandlung zeigt „Sicherungsläufe" — und zwar nicht in der Vorschau des
// Entwicklers, sondern erst im Betrachter des Empfaengers.
func encodeWinAnsi(text string) string {
encodedBytes := make([]byte, 0, len(text))
for _, textRune := range text {
switch {
case textRune < 128:
encodedBytes = append(encodedBytes, byte(textRune))
case textRune <= 255:
// Latin-1 deckt die deutschen Umlaute und das scharfe S ab.
encodedBytes = append(encodedBytes, byte(textRune))
default:
if winAnsiByte, isKnown := winAnsiSpecials[textRune]; isKnown {
encodedBytes = append(encodedBytes, winAnsiByte)
continue
}
// Ein nicht darstellbares Zeichen wird zu einem Fragezeichen. Es
// stillschweigend wegzulassen erzeugte einen Text, der etwas
// anderes bedeutet als der urspruengliche.
encodedBytes = append(encodedBytes, '?')
}
}
return string(encodedBytes)
}
// winAnsiSpecials sind die Zeichen, die WinAnsi ausserhalb von Latin-1 kennt.
//
// Nur die, die in diesen Berichten wirklich vorkommen: typografische
// Anfuehrungszeichen, Gedankenstriche, Auslassungspunkte und das Eurozeichen.
var winAnsiSpecials = map[rune]byte{
'€': 0x80, '‚': 0x82, '„': 0x84, '…': 0x85, '†': 0x86,
'‡': 0x87, '‰': 0x89, '‹': 0x8B, '‘': 0x91, '’': 0x92,
'“': 0x93, '”': 0x94, '•': 0x95, '–': 0x96, '—': 0x97,
'›': 0x9B,
}
// bytes serialisiert das Dokument.
//
// Aufbau: Katalog, Seitenbaum, zwei Schriften, dann je Seite ein Seitenobjekt
// und ein Inhaltsstrom. Die Querverweistabelle am Ende braucht die Byte-Offsets
// aller Objekte — deshalb wird beim Schreiben mitgezaehlt.
func (document *pdfDocument) bytes() []byte {
pageCount := len(document.pageStreams)
// 1 Katalog, 2 Seitenbaum, 3 und 4 Schriften, danach je Seite zwei Objekte.
const firstPageObject = 5
outputBuffer := &bytes.Buffer{}
objectOffsets := make([]int, 0, 4+2*pageCount)
writeObject := func(objectBody string) {
objectOffsets = append(objectOffsets, outputBuffer.Len())
outputBuffer.WriteString(objectBody)
}
outputBuffer.WriteString("%PDF-1.4\n")
// Ein Kommentar mit Bytes ueber 127 kennzeichnet die Datei als binaer.
// Ohne ihn behandeln manche Uebertragungswege sie als Text und ersetzen
// Zeilenenden — die Querverweistabelle stimmt danach nicht mehr.
outputBuffer.WriteString("%\xE2\xE3\xCF\xD3\n")
pageReferences := make([]string, 0, pageCount)
for pageIndex := range pageCount {
pageReferences = append(pageReferences, fmt.Sprintf("%d 0 R", firstPageObject+2*pageIndex))
}
writeObject("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n")
writeObject(fmt.Sprintf("2 0 obj\n<< /Type /Pages /Count %d /Kids [%s] >>\nendobj\n",
pageCount, strings.Join(pageReferences, " ")))
writeObject("3 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica " +
"/Encoding /WinAnsiEncoding >>\nendobj\n")
writeObject("4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold " +
"/Encoding /WinAnsiEncoding >>\nendobj\n")
for pageIndex, pageStream := range document.pageStreams {
pageObjectNumber := firstPageObject + 2*pageIndex
streamObjectNumber := pageObjectNumber + 1
writeObject(fmt.Sprintf("%d 0 obj\n<< /Type /Page /Parent 2 0 R "+
"/MediaBox [0 0 %.0f %.0f] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> "+
"/Contents %d 0 R >>\nendobj\n",
pageObjectNumber, pageWidth, pageHeight, streamObjectNumber))
writeObject(fmt.Sprintf("%d 0 obj\n<< /Length %d >>\nstream\n%s\nendstream\nendobj\n",
streamObjectNumber, pageStream.Len()+1, pageStream.String()))
}
crossReferenceOffset := outputBuffer.Len()
objectCount := len(objectOffsets) + 1
fmt.Fprintf(outputBuffer, "xref\n0 %d\n0000000000 65535 f \n", objectCount)
for _, objectOffset := range objectOffsets {
fmt.Fprintf(outputBuffer, "%010d 00000 n \n", objectOffset)
}
fmt.Fprintf(outputBuffer, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n",
objectCount, crossReferenceOffset)
return outputBuffer.Bytes()
}