Files
breakpilot-compliance/ai-compliance-sdk/internal/iace/document_export.go
Benjamin Admin 6d2de9b897
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 36s
CI/CD / test-python-backend-compliance (push) Successful in 33s
CI/CD / test-python-document-crawler (push) Successful in 24s
CI/CD / test-python-dsms-gateway (push) Successful in 21s
CI/CD / validate-canonical-controls (push) Successful in 13s
CI/CD / Deploy (push) Successful in 2s
feat(iace): complete CE risk assessment — LLM tech-file generation, multi-format export, TipTap editor
Phase 1: Fix completeness gates G23 (require verified/rejected mitigations) and G09 (audit trail check)
Phase 2: LLM-based tech-file section generation with 19 German prompts and RAG enrichment
Phase 3: Multi-format document export (PDF/Excel/DOCX/Markdown/JSON)
Phase 4: Company profile → IACE data flow with auto component/classification creation
Phase 5: TipTap WYSIWYG editor replacing textarea for tech-file sections
Phase 6: User journey tests, developer portal API reference, updated documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 12:50:53 +01:00

1102 lines
34 KiB
Go

package iace
import (
"archive/zip"
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
"strings"
"time"
"github.com/jung-kurt/gofpdf"
"github.com/xuri/excelize/v2"
)
// ExportFormat represents a supported document export format
type ExportFormat string
const (
ExportFormatPDF ExportFormat = "pdf"
ExportFormatXLSX ExportFormat = "xlsx"
ExportFormatDOCX ExportFormat = "docx"
ExportFormatMD ExportFormat = "md"
ExportFormatJSON ExportFormat = "json"
)
// DocumentExporter handles exporting CE technical file data into various formats
type DocumentExporter struct{}
// NewDocumentExporter creates a new DocumentExporter instance
func NewDocumentExporter() *DocumentExporter {
return &DocumentExporter{}
}
// ============================================================================
// PDF Export
// ============================================================================
// ExportPDF generates a PDF document containing the full CE technical file
func (e *DocumentExporter) ExportPDF(
project *Project,
sections []TechFileSection,
hazards []Hazard,
assessments []RiskAssessment,
mitigations []Mitigation,
classifications []RegulatoryClassification,
) ([]byte, error) {
if project == nil {
return nil, fmt.Errorf("project must not be nil")
}
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.SetFont("Helvetica", "", 12)
// --- Cover Page ---
pdf.AddPage()
e.pdfCoverPage(pdf, project)
// --- Table of Contents ---
pdf.AddPage()
e.pdfTableOfContents(pdf, sections)
// --- Sections ---
for _, section := range sections {
pdf.AddPage()
e.pdfSection(pdf, section)
}
// --- Hazard Log ---
pdf.AddPage()
e.pdfHazardLog(pdf, hazards, assessments)
// --- Risk Matrix Summary ---
e.pdfRiskMatrixSummary(pdf, assessments)
// --- Mitigations Table ---
pdf.AddPage()
e.pdfMitigationsTable(pdf, mitigations)
// --- Regulatory Classifications ---
if len(classifications) > 0 {
pdf.AddPage()
e.pdfClassifications(pdf, classifications)
}
// --- Footer on every page ---
pdf.SetFooterFunc(func() {
pdf.SetY(-15)
pdf.SetFont("Helvetica", "I", 8)
pdf.SetTextColor(128, 128, 128)
pdf.CellFormat(0, 5,
fmt.Sprintf("CE-Akte %s | Generiert am %s | BreakPilot AI Compliance SDK",
project.MachineName, time.Now().Format("02.01.2006 15:04")),
"", 0, "C", false, 0, "")
})
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, fmt.Errorf("failed to generate PDF: %w", err)
}
return buf.Bytes(), nil
}
func (e *DocumentExporter) pdfCoverPage(pdf *gofpdf.Fpdf, project *Project) {
pdf.Ln(60)
// Machine name (large, bold, centered)
pdf.SetFont("Helvetica", "B", 28)
pdf.SetTextColor(0, 0, 0)
pdf.CellFormat(0, 15, "CE-Technische Akte", "", 1, "C", false, 0, "")
pdf.Ln(5)
pdf.SetFont("Helvetica", "B", 22)
pdf.CellFormat(0, 12, project.MachineName, "", 1, "C", false, 0, "")
pdf.Ln(15)
// Metadata block
pdf.SetFont("Helvetica", "", 12)
coverItems := []struct {
label string
value string
}{
{"Hersteller", project.Manufacturer},
{"Maschinentyp", project.MachineType},
{"CE-Kennzeichnungsziel", project.CEMarkingTarget},
{"Projektstatus", string(project.Status)},
{"Datum", time.Now().Format("02.01.2006")},
}
for _, item := range coverItems {
if item.value == "" {
continue
}
pdf.SetFont("Helvetica", "B", 12)
pdf.CellFormat(60, 8, item.label+":", "", 0, "R", false, 0, "")
pdf.SetFont("Helvetica", "", 12)
pdf.CellFormat(5, 8, "", "", 0, "", false, 0, "")
pdf.CellFormat(0, 8, item.value, "", 1, "L", false, 0, "")
}
if project.Description != "" {
pdf.Ln(15)
pdf.SetFont("Helvetica", "I", 10)
pdf.MultiCell(0, 5, project.Description, "", "C", false)
}
}
func (e *DocumentExporter) pdfTableOfContents(pdf *gofpdf.Fpdf, sections []TechFileSection) {
pdf.SetFont("Helvetica", "B", 16)
pdf.SetTextColor(50, 50, 50)
pdf.CellFormat(0, 10, "Inhaltsverzeichnis", "", 1, "L", false, 0, "")
pdf.SetTextColor(0, 0, 0)
pdf.SetDrawColor(200, 200, 200)
pdf.Line(10, pdf.GetY(), 200, pdf.GetY())
pdf.Ln(8)
pdf.SetFont("Helvetica", "", 11)
// Fixed sections first
fixedEntries := []string{
"Gefaehrdungsprotokoll",
"Risikomatrix-Zusammenfassung",
"Massnahmen-Uebersicht",
}
pageEstimate := 3 // Cover + TOC use pages 1-2, sections start at 3
for i, section := range sections {
pdf.CellFormat(10, 7, fmt.Sprintf("%d.", i+1), "", 0, "R", false, 0, "")
pdf.CellFormat(5, 7, "", "", 0, "", false, 0, "")
pdf.CellFormat(130, 7, section.Title, "", 0, "L", false, 0, "")
pdf.CellFormat(0, 7, fmt.Sprintf("~%d", pageEstimate+i), "", 1, "R", false, 0, "")
}
// Append fixed sections after document sections
startPage := pageEstimate + len(sections)
for i, entry := range fixedEntries {
idx := len(sections) + i + 1
pdf.CellFormat(10, 7, fmt.Sprintf("%d.", idx), "", 0, "R", false, 0, "")
pdf.CellFormat(5, 7, "", "", 0, "", false, 0, "")
pdf.CellFormat(130, 7, entry, "", 0, "L", false, 0, "")
pdf.CellFormat(0, 7, fmt.Sprintf("~%d", startPage+i), "", 1, "R", false, 0, "")
}
}
func (e *DocumentExporter) pdfSection(pdf *gofpdf.Fpdf, section TechFileSection) {
// Section heading
pdf.SetFont("Helvetica", "B", 14)
pdf.SetTextColor(50, 50, 50)
pdf.CellFormat(0, 10, section.Title, "", 1, "L", false, 0, "")
pdf.SetTextColor(0, 0, 0)
// Status badge
pdf.SetFont("Helvetica", "I", 9)
pdf.SetTextColor(100, 100, 100)
pdf.CellFormat(0, 5,
fmt.Sprintf("Typ: %s | Status: %s | Version: %d",
section.SectionType, string(section.Status), section.Version),
"", 1, "L", false, 0, "")
pdf.SetTextColor(0, 0, 0)
pdf.SetDrawColor(200, 200, 200)
pdf.Line(10, pdf.GetY(), 200, pdf.GetY())
pdf.Ln(5)
// Content
pdf.SetFont("Helvetica", "", 10)
if section.Content != "" {
pdf.MultiCell(0, 5, section.Content, "", "L", false)
} else {
pdf.SetFont("Helvetica", "I", 10)
pdf.SetTextColor(150, 150, 150)
pdf.CellFormat(0, 7, "(Kein Inhalt vorhanden)", "", 1, "L", false, 0, "")
pdf.SetTextColor(0, 0, 0)
}
}
// buildAssessmentMap creates a lookup from HazardID to its most recent RiskAssessment
func buildAssessmentMap(assessments []RiskAssessment) map[string]*RiskAssessment {
m := make(map[string]*RiskAssessment)
for i := range assessments {
a := &assessments[i]
key := a.HazardID.String()
if existing, ok := m[key]; !ok || a.Version > existing.Version {
m[key] = a
}
}
return m
}
func (e *DocumentExporter) pdfHazardLog(pdf *gofpdf.Fpdf, hazards []Hazard, assessments []RiskAssessment) {
pdf.SetFont("Helvetica", "B", 14)
pdf.SetTextColor(50, 50, 50)
pdf.CellFormat(0, 10, "Gefaehrdungsprotokoll", "", 1, "L", false, 0, "")
pdf.SetTextColor(0, 0, 0)
pdf.SetDrawColor(200, 200, 200)
pdf.Line(10, pdf.GetY(), 200, pdf.GetY())
pdf.Ln(5)
if len(hazards) == 0 {
pdf.SetFont("Helvetica", "I", 10)
pdf.CellFormat(0, 7, "(Keine Gefaehrdungen erfasst)", "", 1, "L", false, 0, "")
return
}
assessMap := buildAssessmentMap(assessments)
// Table header
colWidths := []float64{10, 40, 30, 12, 12, 12, 30, 20}
headers := []string{"Nr", "Name", "Kategorie", "S", "E", "P", "Risiko", "OK"}
pdf.SetFont("Helvetica", "B", 9)
pdf.SetFillColor(240, 240, 240)
for i, h := range headers {
pdf.CellFormat(colWidths[i], 7, h, "1", 0, "C", true, 0, "")
}
pdf.Ln(-1)
pdf.SetFont("Helvetica", "", 8)
for i, hazard := range hazards {
if pdf.GetY() > 265 {
pdf.AddPage()
// Reprint header
pdf.SetFont("Helvetica", "B", 9)
pdf.SetFillColor(240, 240, 240)
for j, h := range headers {
pdf.CellFormat(colWidths[j], 7, h, "1", 0, "C", true, 0, "")
}
pdf.Ln(-1)
pdf.SetFont("Helvetica", "", 8)
}
a := assessMap[hazard.ID.String()]
sev, exp, prob := "", "", ""
riskLabel := "-"
acceptable := "-"
var rl RiskLevel
if a != nil {
sev = fmt.Sprintf("%d", a.Severity)
exp = fmt.Sprintf("%d", a.Exposure)
prob = fmt.Sprintf("%d", a.Probability)
rl = a.RiskLevel
riskLabel = riskLevelLabel(rl)
if a.IsAcceptable {
acceptable = "Ja"
} else {
acceptable = "Nein"
}
}
// Color-code the row based on risk level
r, g, b := riskLevelColor(rl)
pdf.SetFillColor(r, g, b)
fill := rl != ""
pdf.CellFormat(colWidths[0], 6, fmt.Sprintf("%d", i+1), "1", 0, "C", fill, 0, "")
pdf.CellFormat(colWidths[1], 6, pdfTruncate(hazard.Name, 22), "1", 0, "L", fill, 0, "")
pdf.CellFormat(colWidths[2], 6, pdfTruncate(hazard.Category, 16), "1", 0, "L", fill, 0, "")
pdf.CellFormat(colWidths[3], 6, sev, "1", 0, "C", fill, 0, "")
pdf.CellFormat(colWidths[4], 6, exp, "1", 0, "C", fill, 0, "")
pdf.CellFormat(colWidths[5], 6, prob, "1", 0, "C", fill, 0, "")
pdf.CellFormat(colWidths[6], 6, riskLabel, "1", 0, "C", fill, 0, "")
pdf.CellFormat(colWidths[7], 6, acceptable, "1", 0, "C", fill, 0, "")
pdf.Ln(-1)
}
}
func (e *DocumentExporter) pdfRiskMatrixSummary(pdf *gofpdf.Fpdf, assessments []RiskAssessment) {
pdf.Ln(10)
pdf.SetFont("Helvetica", "B", 14)
pdf.SetTextColor(50, 50, 50)
pdf.CellFormat(0, 10, "Risikomatrix-Zusammenfassung", "", 1, "L", false, 0, "")
pdf.SetTextColor(0, 0, 0)
pdf.SetDrawColor(200, 200, 200)
pdf.Line(10, pdf.GetY(), 200, pdf.GetY())
pdf.Ln(5)
counts := countByRiskLevel(assessments)
levels := []RiskLevel{
RiskLevelNotAcceptable,
RiskLevelVeryHigh,
RiskLevelCritical,
RiskLevelHigh,
RiskLevelMedium,
RiskLevelLow,
RiskLevelNegligible,
}
pdf.SetFont("Helvetica", "B", 9)
pdf.SetFillColor(240, 240, 240)
pdf.CellFormat(60, 7, "Risikostufe", "1", 0, "L", true, 0, "")
pdf.CellFormat(30, 7, "Anzahl", "1", 0, "C", true, 0, "")
pdf.Ln(-1)
pdf.SetFont("Helvetica", "", 9)
for _, level := range levels {
count := counts[level]
if count == 0 {
continue
}
r, g, b := riskLevelColor(level)
pdf.SetFillColor(r, g, b)
pdf.CellFormat(60, 6, riskLevelLabel(level), "1", 0, "L", true, 0, "")
pdf.CellFormat(30, 6, fmt.Sprintf("%d", count), "1", 0, "C", true, 0, "")
pdf.Ln(-1)
}
pdf.SetFont("Helvetica", "B", 9)
pdf.SetFillColor(240, 240, 240)
pdf.CellFormat(60, 7, "Gesamt", "1", 0, "L", true, 0, "")
pdf.CellFormat(30, 7, fmt.Sprintf("%d", len(assessments)), "1", 0, "C", true, 0, "")
pdf.Ln(-1)
}
func (e *DocumentExporter) pdfMitigationsTable(pdf *gofpdf.Fpdf, mitigations []Mitigation) {
pdf.SetFont("Helvetica", "B", 14)
pdf.SetTextColor(50, 50, 50)
pdf.CellFormat(0, 10, "Massnahmen-Uebersicht", "", 1, "L", false, 0, "")
pdf.SetTextColor(0, 0, 0)
pdf.SetDrawColor(200, 200, 200)
pdf.Line(10, pdf.GetY(), 200, pdf.GetY())
pdf.Ln(5)
if len(mitigations) == 0 {
pdf.SetFont("Helvetica", "I", 10)
pdf.CellFormat(0, 7, "(Keine Massnahmen erfasst)", "", 1, "L", false, 0, "")
return
}
colWidths := []float64{10, 45, 30, 30, 40}
headers := []string{"Nr", "Name", "Typ", "Status", "Verifikation"}
pdf.SetFont("Helvetica", "B", 9)
pdf.SetFillColor(240, 240, 240)
for i, h := range headers {
pdf.CellFormat(colWidths[i], 7, h, "1", 0, "C", true, 0, "")
}
pdf.Ln(-1)
pdf.SetFont("Helvetica", "", 8)
for i, m := range mitigations {
if pdf.GetY() > 265 {
pdf.AddPage()
pdf.SetFont("Helvetica", "B", 9)
pdf.SetFillColor(240, 240, 240)
for j, h := range headers {
pdf.CellFormat(colWidths[j], 7, h, "1", 0, "C", true, 0, "")
}
pdf.Ln(-1)
pdf.SetFont("Helvetica", "", 8)
}
pdf.CellFormat(colWidths[0], 6, fmt.Sprintf("%d", i+1), "1", 0, "C", false, 0, "")
pdf.CellFormat(colWidths[1], 6, pdfTruncate(m.Name, 25), "1", 0, "L", false, 0, "")
pdf.CellFormat(colWidths[2], 6, reductionTypeLabel(m.ReductionType), "1", 0, "C", false, 0, "")
pdf.CellFormat(colWidths[3], 6, mitigationStatusLabel(m.Status), "1", 0, "C", false, 0, "")
pdf.CellFormat(colWidths[4], 6, pdfTruncate(string(m.VerificationMethod), 22), "1", 0, "L", false, 0, "")
pdf.Ln(-1)
}
}
func (e *DocumentExporter) pdfClassifications(pdf *gofpdf.Fpdf, classifications []RegulatoryClassification) {
pdf.SetFont("Helvetica", "B", 14)
pdf.SetTextColor(50, 50, 50)
pdf.CellFormat(0, 10, "Regulatorische Klassifizierungen", "", 1, "L", false, 0, "")
pdf.SetTextColor(0, 0, 0)
pdf.SetDrawColor(200, 200, 200)
pdf.Line(10, pdf.GetY(), 200, pdf.GetY())
pdf.Ln(5)
for _, c := range classifications {
pdf.SetFont("Helvetica", "B", 11)
pdf.CellFormat(0, 7, regulationLabel(c.Regulation), "", 1, "L", false, 0, "")
pdf.SetFont("Helvetica", "", 10)
pdf.CellFormat(50, 6, "Klassifizierung:", "", 0, "L", false, 0, "")
pdf.CellFormat(0, 6, c.ClassificationResult, "", 1, "L", false, 0, "")
pdf.CellFormat(50, 6, "Risikostufe:", "", 0, "L", false, 0, "")
pdf.CellFormat(0, 6, riskLevelLabel(c.RiskLevel), "", 1, "L", false, 0, "")
if c.Reasoning != "" {
pdf.CellFormat(50, 6, "Begruendung:", "", 0, "L", false, 0, "")
pdf.MultiCell(0, 5, c.Reasoning, "", "L", false)
}
pdf.Ln(5)
}
}
// ============================================================================
// Excel Export
// ============================================================================
// ExportExcel generates an XLSX workbook with project data across multiple sheets
func (e *DocumentExporter) ExportExcel(
project *Project,
sections []TechFileSection,
hazards []Hazard,
assessments []RiskAssessment,
mitigations []Mitigation,
) ([]byte, error) {
if project == nil {
return nil, fmt.Errorf("project must not be nil")
}
f := excelize.NewFile()
defer f.Close()
// --- Sheet 1: Uebersicht ---
overviewSheet := "Uebersicht"
f.SetSheetName("Sheet1", overviewSheet)
e.xlsxOverview(f, overviewSheet, project)
// --- Sheet 2: Gefaehrdungsprotokoll ---
hazardSheet := "Gefaehrdungsprotokoll"
f.NewSheet(hazardSheet)
e.xlsxHazardLog(f, hazardSheet, hazards, assessments)
// --- Sheet 3: Massnahmen ---
mitigationSheet := "Massnahmen"
f.NewSheet(mitigationSheet)
e.xlsxMitigations(f, mitigationSheet, mitigations)
// --- Sheet 4: Risikomatrix ---
matrixSheet := "Risikomatrix"
f.NewSheet(matrixSheet)
e.xlsxRiskMatrix(f, matrixSheet, assessments)
// --- Sheet 5: Sektionen ---
sectionSheet := "Sektionen"
f.NewSheet(sectionSheet)
e.xlsxSections(f, sectionSheet, sections)
buf, err := f.WriteToBuffer()
if err != nil {
return nil, fmt.Errorf("failed to write Excel: %w", err)
}
return buf.Bytes(), nil
}
func (e *DocumentExporter) xlsxOverview(f *excelize.File, sheet string, project *Project) {
headerStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 11},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"D9E1F2"}},
})
f.SetColWidth(sheet, "A", "A", 30)
f.SetColWidth(sheet, "B", "B", 50)
rows := [][]string{
{"Eigenschaft", "Wert"},
{"Maschinenname", project.MachineName},
{"Maschinentyp", project.MachineType},
{"Hersteller", project.Manufacturer},
{"Beschreibung", project.Description},
{"CE-Kennzeichnungsziel", project.CEMarkingTarget},
{"Projektstatus", string(project.Status)},
{"Vollstaendigkeits-Score", fmt.Sprintf("%.1f%%", project.CompletenessScore*100)},
{"Erstellt am", project.CreatedAt.Format("02.01.2006 15:04")},
{"Aktualisiert am", project.UpdatedAt.Format("02.01.2006 15:04")},
}
for i, row := range rows {
rowNum := i + 1
f.SetCellValue(sheet, cellRef("A", rowNum), row[0])
f.SetCellValue(sheet, cellRef("B", rowNum), row[1])
if i == 0 {
f.SetCellStyle(sheet, cellRef("A", rowNum), cellRef("B", rowNum), headerStyle)
}
}
}
func (e *DocumentExporter) xlsxHazardLog(f *excelize.File, sheet string, hazards []Hazard, assessments []RiskAssessment) {
headerStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 10, Color: "FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}},
Alignment: &excelize.Alignment{Horizontal: "center"},
})
headers := []string{"Nr", "Name", "Kategorie", "Beschreibung", "S", "E", "P", "A",
"Inherent Risk", "C_eff", "Residual Risk", "Risk Level", "Akzeptabel"}
colWidths := map[string]float64{
"A": 6, "B": 25, "C": 20, "D": 35, "E": 8, "F": 8, "G": 8, "H": 8,
"I": 14, "J": 10, "K": 14, "L": 18, "M": 12,
}
for col, w := range colWidths {
f.SetColWidth(sheet, col, col, w)
}
cols := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M"}
for i, h := range headers {
f.SetCellValue(sheet, cellRef(cols[i], 1), h)
}
f.SetCellStyle(sheet, "A1", cellRef(cols[len(cols)-1], 1), headerStyle)
assessMap := buildAssessmentMap(assessments)
for i, hazard := range hazards {
row := i + 2
a := assessMap[hazard.ID.String()]
f.SetCellValue(sheet, cellRef("A", row), i+1)
f.SetCellValue(sheet, cellRef("B", row), hazard.Name)
f.SetCellValue(sheet, cellRef("C", row), hazard.Category)
f.SetCellValue(sheet, cellRef("D", row), hazard.Description)
if a != nil {
f.SetCellValue(sheet, cellRef("E", row), a.Severity)
f.SetCellValue(sheet, cellRef("F", row), a.Exposure)
f.SetCellValue(sheet, cellRef("G", row), a.Probability)
f.SetCellValue(sheet, cellRef("H", row), a.Avoidance)
f.SetCellValue(sheet, cellRef("I", row), fmt.Sprintf("%.1f", a.InherentRisk))
f.SetCellValue(sheet, cellRef("J", row), fmt.Sprintf("%.2f", a.CEff))
f.SetCellValue(sheet, cellRef("K", row), fmt.Sprintf("%.1f", a.ResidualRisk))
f.SetCellValue(sheet, cellRef("L", row), riskLevelLabel(a.RiskLevel))
acceptStr := "Nein"
if a.IsAcceptable {
acceptStr = "Ja"
}
f.SetCellValue(sheet, cellRef("M", row), acceptStr)
// Color-code the risk level cell
r, g, b := riskLevelColor(a.RiskLevel)
style, _ := f.NewStyle(&excelize.Style{
Fill: excelize.Fill{
Type: "pattern",
Pattern: 1,
Color: []string{rgbHex(r, g, b)},
},
Alignment: &excelize.Alignment{Horizontal: "center"},
})
f.SetCellStyle(sheet, cellRef("L", row), cellRef("L", row), style)
}
}
}
func (e *DocumentExporter) xlsxMitigations(f *excelize.File, sheet string, mitigations []Mitigation) {
headerStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 10, Color: "FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}},
Alignment: &excelize.Alignment{Horizontal: "center"},
})
headers := []string{"Nr", "Name", "Typ", "Beschreibung", "Status", "Verifikationsmethode", "Ergebnis"}
cols := []string{"A", "B", "C", "D", "E", "F", "G"}
f.SetColWidth(sheet, "A", "A", 6)
f.SetColWidth(sheet, "B", "B", 25)
f.SetColWidth(sheet, "C", "C", 15)
f.SetColWidth(sheet, "D", "D", 35)
f.SetColWidth(sheet, "E", "E", 15)
f.SetColWidth(sheet, "F", "F", 22)
f.SetColWidth(sheet, "G", "G", 25)
for i, h := range headers {
f.SetCellValue(sheet, cellRef(cols[i], 1), h)
}
f.SetCellStyle(sheet, "A1", cellRef(cols[len(cols)-1], 1), headerStyle)
for i, m := range mitigations {
row := i + 2
f.SetCellValue(sheet, cellRef("A", row), i+1)
f.SetCellValue(sheet, cellRef("B", row), m.Name)
f.SetCellValue(sheet, cellRef("C", row), reductionTypeLabel(m.ReductionType))
f.SetCellValue(sheet, cellRef("D", row), m.Description)
f.SetCellValue(sheet, cellRef("E", row), mitigationStatusLabel(m.Status))
f.SetCellValue(sheet, cellRef("F", row), string(m.VerificationMethod))
f.SetCellValue(sheet, cellRef("G", row), m.VerificationResult)
}
}
func (e *DocumentExporter) xlsxRiskMatrix(f *excelize.File, sheet string, assessments []RiskAssessment) {
headerStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 10, Color: "FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}},
Alignment: &excelize.Alignment{Horizontal: "center"},
})
f.SetColWidth(sheet, "A", "A", 25)
f.SetColWidth(sheet, "B", "B", 12)
f.SetCellValue(sheet, "A1", "Risikostufe")
f.SetCellValue(sheet, "B1", "Anzahl")
f.SetCellStyle(sheet, "A1", "B1", headerStyle)
counts := countByRiskLevel(assessments)
levels := []RiskLevel{
RiskLevelNotAcceptable,
RiskLevelVeryHigh,
RiskLevelCritical,
RiskLevelHigh,
RiskLevelMedium,
RiskLevelLow,
RiskLevelNegligible,
}
row := 2
for _, level := range levels {
count := counts[level]
f.SetCellValue(sheet, cellRef("A", row), riskLevelLabel(level))
f.SetCellValue(sheet, cellRef("B", row), count)
r, g, b := riskLevelColor(level)
style, _ := f.NewStyle(&excelize.Style{
Fill: excelize.Fill{
Type: "pattern",
Pattern: 1,
Color: []string{rgbHex(r, g, b)},
},
})
f.SetCellStyle(sheet, cellRef("A", row), cellRef("B", row), style)
row++
}
// Total row
totalStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"D9E1F2"}},
})
f.SetCellValue(sheet, cellRef("A", row), "Gesamt")
f.SetCellValue(sheet, cellRef("B", row), len(assessments))
f.SetCellStyle(sheet, cellRef("A", row), cellRef("B", row), totalStyle)
}
func (e *DocumentExporter) xlsxSections(f *excelize.File, sheet string, sections []TechFileSection) {
headerStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 10, Color: "FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}},
Alignment: &excelize.Alignment{Horizontal: "center"},
})
f.SetColWidth(sheet, "A", "A", 25)
f.SetColWidth(sheet, "B", "B", 40)
f.SetColWidth(sheet, "C", "C", 15)
headers := []string{"Sektion", "Titel", "Status"}
cols := []string{"A", "B", "C"}
for i, h := range headers {
f.SetCellValue(sheet, cellRef(cols[i], 1), h)
}
f.SetCellStyle(sheet, "A1", cellRef(cols[len(cols)-1], 1), headerStyle)
for i, s := range sections {
row := i + 2
f.SetCellValue(sheet, cellRef("A", row), s.SectionType)
f.SetCellValue(sheet, cellRef("B", row), s.Title)
f.SetCellValue(sheet, cellRef("C", row), string(s.Status))
}
}
// ============================================================================
// Markdown Export
// ============================================================================
// ExportMarkdown generates a Markdown document of the CE technical file sections
func (e *DocumentExporter) ExportMarkdown(
project *Project,
sections []TechFileSection,
) ([]byte, error) {
if project == nil {
return nil, fmt.Errorf("project must not be nil")
}
var buf bytes.Buffer
// Title
buf.WriteString(fmt.Sprintf("# CE-Akte: %s\n\n", project.MachineName))
// Metadata block
buf.WriteString("| Eigenschaft | Wert |\n")
buf.WriteString("|-------------|------|\n")
buf.WriteString(fmt.Sprintf("| Hersteller | %s |\n", project.Manufacturer))
buf.WriteString(fmt.Sprintf("| Maschinentyp | %s |\n", project.MachineType))
if project.CEMarkingTarget != "" {
buf.WriteString(fmt.Sprintf("| CE-Kennzeichnungsziel | %s |\n", project.CEMarkingTarget))
}
buf.WriteString(fmt.Sprintf("| Status | %s |\n", project.Status))
buf.WriteString(fmt.Sprintf("| Datum | %s |\n", time.Now().Format("02.01.2006")))
buf.WriteString("\n")
if project.Description != "" {
buf.WriteString(fmt.Sprintf("> %s\n\n", project.Description))
}
// Sections
for _, section := range sections {
buf.WriteString(fmt.Sprintf("## %s\n\n", section.Title))
buf.WriteString(fmt.Sprintf("*Typ: %s | Status: %s | Version: %d*\n\n",
section.SectionType, string(section.Status), section.Version))
if section.Content != "" {
buf.WriteString(section.Content)
buf.WriteString("\n\n")
} else {
buf.WriteString("*(Kein Inhalt vorhanden)*\n\n")
}
}
// Footer
buf.WriteString("---\n\n")
buf.WriteString(fmt.Sprintf("*Generiert am %s mit BreakPilot AI Compliance SDK*\n",
time.Now().Format("02.01.2006 15:04")))
return buf.Bytes(), nil
}
// ============================================================================
// DOCX Export (minimal OOXML via archive/zip)
// ============================================================================
// ExportDOCX generates a minimal DOCX file containing the CE technical file sections
func (e *DocumentExporter) ExportDOCX(
project *Project,
sections []TechFileSection,
) ([]byte, error) {
if project == nil {
return nil, fmt.Errorf("project must not be nil")
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
// [Content_Types].xml
contentTypes := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>`
if err := addZipEntry(zw, "[Content_Types].xml", contentTypes); err != nil {
return nil, fmt.Errorf("failed to write [Content_Types].xml: %w", err)
}
// _rels/.rels
rels := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>`
if err := addZipEntry(zw, "_rels/.rels", rels); err != nil {
return nil, fmt.Errorf("failed to write _rels/.rels: %w", err)
}
// word/_rels/document.xml.rels
docRels := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
</Relationships>`
if err := addZipEntry(zw, "word/_rels/document.xml.rels", docRels); err != nil {
return nil, fmt.Errorf("failed to write word/_rels/document.xml.rels: %w", err)
}
// word/document.xml — build the body
docXML := e.buildDocumentXML(project, sections)
if err := addZipEntry(zw, "word/document.xml", docXML); err != nil {
return nil, fmt.Errorf("failed to write word/document.xml: %w", err)
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("failed to close ZIP: %w", err)
}
return buf.Bytes(), nil
}
func (e *DocumentExporter) buildDocumentXML(project *Project, sections []TechFileSection) string {
var body strings.Builder
// Title paragraph (Heading 1 style)
body.WriteString(docxHeading(fmt.Sprintf("CE-Akte: %s", project.MachineName), 1))
// Metadata paragraphs
metaLines := []string{
fmt.Sprintf("Hersteller: %s", project.Manufacturer),
fmt.Sprintf("Maschinentyp: %s", project.MachineType),
}
if project.CEMarkingTarget != "" {
metaLines = append(metaLines, fmt.Sprintf("CE-Kennzeichnungsziel: %s", project.CEMarkingTarget))
}
metaLines = append(metaLines,
fmt.Sprintf("Status: %s", project.Status),
fmt.Sprintf("Datum: %s", time.Now().Format("02.01.2006")),
)
for _, line := range metaLines {
body.WriteString(docxParagraph(line, false))
}
if project.Description != "" {
body.WriteString(docxParagraph("", false)) // blank line
body.WriteString(docxParagraph(project.Description, true))
}
// Sections
for _, section := range sections {
body.WriteString(docxHeading(section.Title, 2))
body.WriteString(docxParagraph(
fmt.Sprintf("Typ: %s | Status: %s | Version: %d",
section.SectionType, string(section.Status), section.Version),
true,
))
if section.Content != "" {
// Split content by newlines into separate paragraphs
for _, line := range strings.Split(section.Content, "\n") {
body.WriteString(docxParagraph(line, false))
}
} else {
body.WriteString(docxParagraph("(Kein Inhalt vorhanden)", true))
}
}
// Footer
body.WriteString(docxParagraph("", false))
body.WriteString(docxParagraph(
fmt.Sprintf("Generiert am %s mit BreakPilot AI Compliance SDK",
time.Now().Format("02.01.2006 15:04")),
true,
))
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
xmlns:w10="urn:schemas-microsoft-com:office:word"
xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml"
xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"
xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk"
xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"
xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"
mc:Ignorable="w14 wp14">
<w:body>
%s
</w:body>
</w:document>`, body.String())
}
// ============================================================================
// JSON Export (convenience — returns marshalled project data)
// ============================================================================
// ExportJSON returns a JSON representation of the project export data
func (e *DocumentExporter) ExportJSON(
project *Project,
sections []TechFileSection,
hazards []Hazard,
assessments []RiskAssessment,
mitigations []Mitigation,
classifications []RegulatoryClassification,
) ([]byte, error) {
if project == nil {
return nil, fmt.Errorf("project must not be nil")
}
payload := map[string]interface{}{
"project": project,
"sections": sections,
"hazards": hazards,
"assessments": assessments,
"mitigations": mitigations,
"classifications": classifications,
"exported_at": time.Now().UTC().Format(time.RFC3339),
"format_version": "1.0",
}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal JSON: %w", err)
}
return data, nil
}
// ============================================================================
// Helper Functions
// ============================================================================
// riskLevelColor returns RGB values for color-coding a given risk level
func riskLevelColor(level RiskLevel) (r, g, b int) {
switch level {
case RiskLevelNotAcceptable:
return 180, 0, 0 // dark red
case RiskLevelVeryHigh:
return 220, 40, 40 // red
case RiskLevelCritical:
return 255, 80, 80 // bright red
case RiskLevelHigh:
return 255, 165, 80 // orange
case RiskLevelMedium:
return 255, 230, 100 // yellow
case RiskLevelLow:
return 180, 230, 140 // light green
case RiskLevelNegligible:
return 140, 210, 140 // green
default:
return 240, 240, 240 // light gray (unassessed)
}
}
// riskLevelLabel returns a German display label for a risk level
func riskLevelLabel(level RiskLevel) string {
switch level {
case RiskLevelNotAcceptable:
return "Nicht akzeptabel"
case RiskLevelVeryHigh:
return "Sehr hoch"
case RiskLevelCritical:
return "Kritisch"
case RiskLevelHigh:
return "Hoch"
case RiskLevelMedium:
return "Mittel"
case RiskLevelLow:
return "Niedrig"
case RiskLevelNegligible:
return "Vernachlaessigbar"
default:
return string(level)
}
}
// reductionTypeLabel returns a German label for a reduction type
func reductionTypeLabel(rt ReductionType) string {
switch rt {
case ReductionTypeDesign:
return "Konstruktiv"
case ReductionTypeProtective:
return "Schutzmassnahme"
case ReductionTypeInformation:
return "Information"
default:
return string(rt)
}
}
// mitigationStatusLabel returns a German label for a mitigation status
func mitigationStatusLabel(status MitigationStatus) string {
switch status {
case MitigationStatusPlanned:
return "Geplant"
case MitigationStatusImplemented:
return "Umgesetzt"
case MitigationStatusVerified:
return "Verifiziert"
case MitigationStatusRejected:
return "Abgelehnt"
default:
return string(status)
}
}
// regulationLabel returns a German label for a regulation type
func regulationLabel(reg RegulationType) string {
switch reg {
case RegulationNIS2:
return "NIS-2 Richtlinie"
case RegulationAIAct:
return "EU AI Act"
case RegulationCRA:
return "Cyber Resilience Act"
case RegulationMachineryRegulation:
return "EU Maschinenverordnung 2023/1230"
default:
return string(reg)
}
}
// escapeXML escapes special XML characters in text content
func escapeXML(s string) string {
var buf bytes.Buffer
if err := xml.EscapeText(&buf, []byte(s)); err != nil {
// Fallback: return input unchanged (xml.EscapeText should never error on valid UTF-8)
return s
}
return buf.String()
}
// countByRiskLevel counts assessments per risk level
func countByRiskLevel(assessments []RiskAssessment) map[RiskLevel]int {
counts := make(map[RiskLevel]int)
for _, a := range assessments {
counts[a.RiskLevel]++
}
return counts
}
// pdfTruncate truncates a string for PDF cell display
func pdfTruncate(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
if maxLen <= 3 {
return string(runes[:maxLen])
}
return string(runes[:maxLen-3]) + "..."
}
// cellRef builds an Excel cell reference like "A1", "B12"
func cellRef(col string, row int) string {
return fmt.Sprintf("%s%d", col, row)
}
// rgbHex converts RGB values to a hex color string (without #)
func rgbHex(r, g, b int) string {
return fmt.Sprintf("%02X%02X%02X", r, g, b)
}
// addZipEntry writes a text file into a zip archive
func addZipEntry(zw *zip.Writer, name, content string) error {
w, err := zw.Create(name)
if err != nil {
return err
}
_, err = w.Write([]byte(content))
return err
}
// docxHeading builds a DOCX paragraph with a heading style
func docxHeading(text string, level int) string {
// Map level to font size (in half-points): 1→32pt, 2→26pt, 3→22pt
sizes := map[int]int{1: 64, 2: 52, 3: 44}
sz, ok := sizes[level]
if !ok {
sz = 44
}
escaped := escapeXML(text)
return fmt.Sprintf(` <w:p>
<w:pPr>
<w:pStyle w:val="Heading%d"/>
<w:spacing w:after="200"/>
</w:pPr>
<w:r>
<w:rPr><w:b/><w:sz w:val="%d"/><w:szCs w:val="%d"/></w:rPr>
<w:t xml:space="preserve">%s</w:t>
</w:r>
</w:p>
`, level, sz, sz, escaped)
}
// docxParagraph builds a DOCX paragraph, optionally italic
func docxParagraph(text string, italic bool) string {
escaped := escapeXML(text)
rpr := ""
if italic {
rpr = "<w:rPr><w:i/></w:rPr>"
}
return fmt.Sprintf(` <w:p>
<w:r>
%s
<w:t xml:space="preserve">%s</w:t>
</w:r>
</w:p>
`, rpr, escaped)
}