Files
deklarix/internal/dossier/render.go
noroot db373e51ce feat: add PDF dossier generation (internal/dossier)
Content assembly (BuildContent) is separate from PDF drawing (Render),
so the actual business logic — what goes into the evidence dossier, in
what form, with which mandatory fields — is unit-testable without
parsing PDF bytes. BuildContent refuses to produce a dossier missing
its evidentiary fields (timestamp token, asset/metadata hash, platform)
rather than emitting one with silently empty proof sections. Every
dossier carries the "this is not legal advice" disclaimer required by
CLAUDE.md's guardrails.

Uses github.com/go-pdf/fpdf (actively maintained fork of jung-kurt/
gofpdf, no dependencies beyond the Go stdlib) for rendering. Its core
fonts use cp1252 internally, so a small cp1252.map (copied from the
fpdf module, embedded via go:embed) drives UnicodeTranslator — German
umlauts render correctly without needing an external font file at
runtime, keeping Deklarix a single binary. Verified visually with
pdftotext/pdfinfo against a generated sample.

Tests build a real, structurally valid RFC-3161 token offline (a
throwaway self-signed cert + timestamp.Timestamp.CreateResponse), so
BuildContent/Render/Generate are fully tested without hitting a real
TSA — unlike the network-gated integration test in internal/evidence.

Also adds evidence.TimestampTime(), extracted from the parsing logic
already used by the TSA client, since the dossier needs to show the
timestamped time to a human reader.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:18:01 +02:00

126 lines
3.2 KiB
Go

package dossier
import (
"bytes"
"embed"
"fmt"
"io"
"strings"
"github.com/go-pdf/fpdf"
)
//go:embed assets/cp1252.map
var assetsFS embed.FS
// Generate erzeugt das PDF-Dossier für data und schreibt es nach w.
func Generate(w io.Writer, data Data) error {
content, err := BuildContent(data)
if err != nil {
return err
}
return Render(w, content)
}
// Render zeichnet ein bereits aufbereitetes Content als PDF nach w.
func Render(w io.Writer, c Content) error {
tr, err := unicodeTranslator()
if err != nil {
return fmt.Errorf("dossier: unicode translator: %w", err)
}
pdf := fpdf.New("P", "mm", "A4", "")
pdf.SetTitle(tr(c.Title), false)
pdf.AddPage()
heading := func(text string) {
pdf.SetFont("Helvetica", "B", 12)
pdf.CellFormat(0, 8, tr(text), "", 1, "L", false, 0, "")
pdf.SetFont("Helvetica", "", 10)
}
line := func(format string, args ...any) {
pdf.CellFormat(0, 6, tr(fmt.Sprintf(format, args...)), "", 1, "L", false, 0, "")
}
paragraph := func(text string) {
pdf.MultiCell(0, 5, tr(text), "", "L", false)
}
pdf.SetFont("Helvetica", "B", 16)
pdf.CellFormat(0, 10, tr(c.Title), "", 1, "L", false, 0, "")
pdf.SetFont("Helvetica", "", 10)
line("Erzeugt am %s", c.GeneratedAt.Format("02.01.2006 15:04 MST"))
pdf.Ln(4)
heading("Beitrag")
line("Plattform: %s Typ: %s", c.Platform, c.PostType)
line("Eingereicht am: %s", c.SubmittedAt.Format("02.01.2006 15:04"))
paragraph("Caption: " + c.Caption)
pdf.Ln(2)
heading("Kennzeichnung")
line("Vorhanden: %s Wortlaut: %q Vor Kürzung sichtbar: %s",
yesNo(c.DisclosurePresent), c.DisclosureWording, yesNo(c.DisclosureBeforeCut))
pdf.Ln(2)
heading("Findings")
if len(c.Findings) == 0 {
line("Keine Findings.")
}
for _, f := range c.Findings {
pdf.SetFont("Helvetica", "B", 10)
line("%s v%d — %s (%s)", f.RuleID, f.Version, f.Title, f.Severity)
pdf.SetFont("Helvetica", "", 10)
paragraph("Korrektur: " + f.Fix)
if len(f.Sources) > 0 {
paragraph("Fundstellen: " + strings.Join(f.Sources, "; "))
}
pdf.Ln(1)
}
pdf.Ln(2)
heading("Verantwortungsmatrix")
if len(c.Participants) == 0 {
line("Keine Beteiligten hinterlegt.")
}
for _, p := range c.Participants {
line("%s: %s (vorgegeben: %s, freigegeben: %s)",
p.Role, p.Name, yesNo(p.Vorgegeben), yesNo(p.Freigegeben))
}
pdf.Ln(2)
heading("Beweiskette")
line("Asset-Hash (SHA-256): %s", c.AssetHashHex)
line("Metadaten-Hash (SHA-256): %s", c.MetadataHashHex)
line("RFC-3161-Zeitstempel: %s", c.TimestampedAt.Format("02.01.2006 15:04:05 MST"))
pdf.Ln(4)
pdf.SetFont("Helvetica", "I", 8)
paragraph(c.Disclaimer)
if err := pdf.Error(); err != nil {
return fmt.Errorf("dossier: pdf aufbauen: %w", err)
}
if err := pdf.Output(w); err != nil {
return fmt.Errorf("dossier: pdf schreiben: %w", err)
}
return nil
}
func yesNo(b bool) string {
if b {
return "ja"
}
return "nein"
}
// unicodeTranslator übersetzt UTF-8 (z. B. Umlaute) in die cp1252-
// Kodierung der Helvetica-Kernschriftart. Die Map-Datei ist eingebettet,
// damit Deklarix trotz PDF-Erzeugung ein einzelnes Binary bleibt.
func unicodeTranslator() (func(string) string, error) {
data, err := assetsFS.ReadFile("assets/cp1252.map")
if err != nil {
return nil, err
}
return fpdf.UnicodeTranslator(bytes.NewReader(data))
}