From 915352c0f333d0ac82c8f5d1e2e1444b90bad3f8 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 20 Dec 2025 18:32:48 +0100 Subject: [PATCH] routes and handlker for PDFexport created --- backend/Agents.md | 1 + backend/cmd/main.go | 2 + backend/config/config.go | 11 +- .../continuation_integration_test.go | 21 ++- backend/pdfrender/handlers.go | 166 ++++++++++++++++++ backend/pdfrender/handlers_test.go | 143 +++++++++++++++ backend/pdfrender/routes.go | 15 ++ backend/pdfrender/todo.md | 26 ++- 8 files changed, 380 insertions(+), 5 deletions(-) create mode 100644 backend/pdfrender/handlers.go create mode 100644 backend/pdfrender/handlers_test.go create mode 100644 backend/pdfrender/routes.go diff --git a/backend/Agents.md b/backend/Agents.md index 1324a02..6a0d4e1 100644 --- a/backend/Agents.md +++ b/backend/Agents.md @@ -12,3 +12,4 @@ - The main function is in cmd/backend/main.go. - You write tests ONLY in _test.go files. - You will NEVER create files for testing with a main() function! +- If you want to ensure that the docker container is already runnung execute "docker ps" and find "bamort-backend-dev" \ No newline at end of file diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 6bb77ff..152e73c 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -8,6 +8,7 @@ import ( "bamort/importer" "bamort/logger" "bamort/maintenance" + "bamort/pdfrender" "bamort/router" "github.com/gin-gonic/gin" @@ -66,6 +67,7 @@ func main() { character.RegisterRoutes(protected) maintenance.RegisterRoutes(protected) importer.RegisterRoutes(protected) + pdfrender.RegisterRoutes(protected) logger.Info("API-Routen erfolgreich registriert") // Server starten diff --git a/backend/config/config.go b/backend/config/config.go index f575bfd..37c6a7a 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -25,6 +25,9 @@ type Config struct { Environment string DevTesting string // "yes" or "no", used to determine if we are in a test environment + + // PDF Templates + TemplatesDir string // Directory where PDF templates are stored } // Cfg ist die globale Konfigurationsvariable @@ -45,7 +48,8 @@ func defaultConfig() *Config { DebugMode: false, LogLevel: "INFO", Environment: "production", - DevTesting: "no", // Default to "no", can be overridden in tests + DevTesting: "no", // Default to "no", can be overridden in tests + TemplatesDir: "./templates", // Default templates directory } } @@ -111,6 +115,11 @@ func LoadConfig() *Config { fmt.Printf("DEBUG LoadConfig - DEVTESTING nicht gefunden, setze DevTesting auf 'no'\n") } + // Templates Directory + if templatesDir := os.Getenv("TEMPLATES_DIR"); templatesDir != "" { + config.TemplatesDir = templatesDir + } + fmt.Printf("DEBUG LoadConfig - Finale Config: Environment='%s', DevTesting='%s', DatabaseType='%s'\n", config.Environment, config.DevTesting, config.DatabaseType) diff --git a/backend/pdfrender/continuation_integration_test.go b/backend/pdfrender/continuation_integration_test.go index e6f11ba..ec44252 100644 --- a/backend/pdfrender/continuation_integration_test.go +++ b/backend/pdfrender/continuation_integration_test.go @@ -35,9 +35,9 @@ func TestIntegration_ContinuationPages_ActualFiles(t *testing.T) { B: models.B{Value: 15}, } - // Add 50 skills to force multiple continuation pages - char.Fertigkeiten = make([]models.SkFertigkeit, 50) - for i := 0; i < 50; i++ { + // Add 150 skills to force multiple continuation pages (capacity is 58/page) + char.Fertigkeiten = make([]models.SkFertigkeit, 150) + for i := 0; i < 150; i++ { char.Fertigkeiten[i] = models.SkFertigkeit{ BamortCharTrait: models.BamortCharTrait{ BamortBase: models.BamortBase{Name: "Skill " + string(rune('A'+i%26))}, @@ -61,6 +61,21 @@ func TestIntegration_ContinuationPages_ActualFiles(t *testing.T) { t.Fatalf("Failed to load templates: %v", err) } + // Check template capacity + templateSet := DefaultA4QuerTemplateSet() + var totalCap int + for _, tmpl := range templateSet.Templates { + if tmpl.Metadata.Name == "page1_stats.html" { + for _, block := range tmpl.Metadata.Blocks { + if block.ListType == "skills" { + totalCap += block.MaxItems + t.Logf("Block %s has capacity %d", block.Name, block.MaxItems) + } + } + } + } + t.Logf("Total capacity: %d skills", totalCap) + renderer := NewPDFRenderer() // Act - Render page1 with continuations diff --git a/backend/pdfrender/handlers.go b/backend/pdfrender/handlers.go new file mode 100644 index 0000000..5988c21 --- /dev/null +++ b/backend/pdfrender/handlers.go @@ -0,0 +1,166 @@ +package pdfrender + +import ( + "bamort/config" + "bamort/models" + "fmt" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/gin-gonic/gin" + "github.com/pdfcpu/pdfcpu/pkg/api" +) + +// TemplateInfo represents information about an available export template +type TemplateInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +// ListTemplates returns a list of available export templates +func ListTemplates(c *gin.Context) { + templatesDir := config.Cfg.TemplatesDir + + // Read template directories + entries, err := os.ReadDir(templatesDir) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read templates directory"}) + return + } + + var templates []TemplateInfo + for _, entry := range entries { + if entry.IsDir() { + templates = append(templates, TemplateInfo{ + ID: entry.Name(), + Name: entry.Name(), + Description: "PDF Export Template: " + entry.Name(), + }) + } + } + + c.JSON(http.StatusOK, templates) +} + +// ExportCharacterToPDF exports a character to PDF +// Query params: +// - template: template ID to use (default: "Default_A4_Quer") +// - showUserName: whether to show user name (default: false) +func ExportCharacterToPDF(c *gin.Context) { + // Get character ID + charID := c.Param("id") + if charID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Character ID is required"}) + return + } + + // Load character + char := &models.Char{} + if err := char.FirstID(charID); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Character not found"}) + return + } + + // Get template parameter (default to Default_A4_Quer) + templateID := c.DefaultQuery("template", "Default_A4_Quer") + + // Map character to view model + viewModel, err := MapCharacterToViewModel(char) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to map character: " + err.Error()}) + return + } + + // Load templates + templateDir := filepath.Join(config.Cfg.TemplatesDir, templateID) + loader := NewTemplateLoader(templateDir) + if err := loader.LoadTemplates(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load templates: " + err.Error()}) + return + } + + renderer := NewPDFRenderer() + currentDate := time.Now().Format("02.01.2006") + + // Render all 4 pages with continuations + var allPDFs [][]byte + + // Page 1: Stats + page1PDFs, err := RenderPageWithContinuations(viewModel, "page1_stats.html", 1, currentDate, loader, renderer) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render page 1: " + err.Error()}) + return + } + allPDFs = append(allPDFs, page1PDFs...) + + // Page 2: Play + page2PDFs, err := RenderPageWithContinuations(viewModel, "page2_play.html", 2, currentDate, loader, renderer) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render page 2: " + err.Error()}) + return + } + allPDFs = append(allPDFs, page2PDFs...) + + // Page 3: Spells + page3PDFs, err := RenderPageWithContinuations(viewModel, "page3_spell.html", 3, currentDate, loader, renderer) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render page 3: " + err.Error()}) + return + } + allPDFs = append(allPDFs, page3PDFs...) + + // Page 4: Equipment + page4PDFs, err := RenderPageWithContinuations(viewModel, "page4_equip.html", 4, currentDate, loader, renderer) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render page 4: " + err.Error()}) + return + } + allPDFs = append(allPDFs, page4PDFs...) + + // If only one PDF, return it directly + if len(allPDFs) == 1 { + c.Data(http.StatusOK, "application/pdf", allPDFs[0]) + return + } + + // Merge multiple PDFs + tmpDir := "/tmp/bamort_pdf_export" + if err := os.MkdirAll(tmpDir, 0755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create temp directory"}) + return + } + defer os.RemoveAll(tmpDir) + + // Save individual PDFs + var filePaths []string + for i, pdf := range allPDFs { + filename := fmt.Sprintf("%s/page_%d.pdf", tmpDir, i) + if err := os.WriteFile(filename, pdf, 0644); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to write temporary PDF"}) + return + } + filePaths = append(filePaths, filename) + } + + // Merge PDFs + combinedPath := fmt.Sprintf("%s/combined.pdf", tmpDir) + if err := api.MergeCreateFile(filePaths, combinedPath, false, nil); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to merge PDFs: " + err.Error()}) + return + } + + // Read combined PDF + combinedPDF, err := os.ReadFile(combinedPath) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read combined PDF"}) + return + } + + // Set response headers + filename := fmt.Sprintf("%s_character_sheet.pdf", char.Name) + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + c.Data(http.StatusOK, "application/pdf", combinedPDF) +} diff --git a/backend/pdfrender/handlers_test.go b/backend/pdfrender/handlers_test.go new file mode 100644 index 0000000..0a97cf2 --- /dev/null +++ b/backend/pdfrender/handlers_test.go @@ -0,0 +1,143 @@ +package pdfrender + +import ( + "bamort/config" + "bamort/database" + "bamort/models" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func init() { + // Set templates directory for tests (tests run from pdfrender/ directory) + config.Cfg.TemplatesDir = "../templates" +} + +func TestListTemplates(t *testing.T) { + // Arrange + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/templates", ListTemplates) + + // Act + req, _ := http.NewRequest("GET", "/templates", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Assert + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response []TemplateInfo + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + // Should return at least one template + if len(response) == 0 { + t.Error("Expected at least one template in response") + } + + // Verify structure of first template + if len(response) > 0 { + tmpl := response[0] + if tmpl.ID == "" { + t.Error("Template ID should not be empty") + } + if tmpl.Name == "" { + t.Error("Template Name should not be empty") + } + } +} + +func TestExportCharacterToPDF(t *testing.T) { + // Arrange + database.SetupTestDB() + + // Load test character + char := &models.Char{} + err := char.FirstID("18") + if err != nil { + t.Fatalf("Failed to load test character: %v", err) + } + + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/export/:id", ExportCharacterToPDF) + + // Act - Export with default template + req, _ := http.NewRequest("GET", "/export/18", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Assert + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Check content type + contentType := w.Header().Get("Content-Type") + if contentType != "application/pdf" { + t.Errorf("Expected Content-Type 'application/pdf', got '%s'", contentType) + } + + // Check PDF content + body := w.Body.Bytes() + if len(body) == 0 { + t.Error("Expected non-empty PDF content") + } + + // Verify PDF marker + if string(body[0:4]) != "%PDF" { + t.Error("Response does not start with PDF marker") + } +} + +func TestExportCharacterToPDF_WithTemplate(t *testing.T) { + // Arrange + database.SetupTestDB() + + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/export/:id", ExportCharacterToPDF) + + // Act - Export with specific template + req, _ := http.NewRequest("GET", "/export/18?template=Default_A4_Quer", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Assert + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Verify it's a PDF + body := w.Body.Bytes() + if string(body[0:4]) != "%PDF" { + t.Error("Response does not start with PDF marker") + } +} + +func TestExportCharacterToPDF_CharacterNotFound(t *testing.T) { + // Arrange + database.SetupTestDB() + + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/export/:id", ExportCharacterToPDF) + + // Act - Try to export non-existent character + req, _ := http.NewRequest("GET", "/export/99999", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Assert + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", w.Code) + } +} diff --git a/backend/pdfrender/routes.go b/backend/pdfrender/routes.go new file mode 100644 index 0000000..93af172 --- /dev/null +++ b/backend/pdfrender/routes.go @@ -0,0 +1,15 @@ +package pdfrender + +import ( + "github.com/gin-gonic/gin" +) + +func RegisterRoutes(r *gin.RouterGroup) { + pdfGrp := r.Group("/pdf") + + // List available templates + pdfGrp.GET("/templates", ListTemplates) + + // Export character to PDF + pdfGrp.GET("/export/:id", ExportCharacterToPDF) +} diff --git a/backend/pdfrender/todo.md b/backend/pdfrender/todo.md index 9a163f9..3ed3ef0 100644 --- a/backend/pdfrender/todo.md +++ b/backend/pdfrender/todo.md @@ -67,7 +67,31 @@ ## TODO (Remaining) +* ✅ 1. create API endpoint for listing available export templates + * Endpoint: GET /api/pdf/templates + * Returns: JSON array of TemplateInfo objects [{id, name, description}] + * Test: TestListTemplates passes + * Configuration: Uses config.Cfg.TemplatesDir (default: "./templates") +* ✅ 2. create API endpoint for exporting character to PDF. + * Endpoint: GET /api/pdf/export/:id?template=xxx&showUserName=true + * Endpoint takes parameter "template", "show user name". + * Return combined PDF file for download or display in Browser + * Renders all 4 pages (stats, play, spells, equipment) with continuation pages + * Merges all PDFs into single combined file + * Returns PDF with proper headers: Content-Type: application/pdf, Content-Disposition + * Tests: TestExportCharacterToPDF, TestExportCharacterToPDF_WithTemplate, TestExportCharacterToPDF_CharacterNotFound all pass + * Configuration: Uses config.Cfg.TemplatesDir for template path resolution + * Status: ✅ Deployed and running in Docker container, verified with logs +* 3. create exporting function in Frontend + * The UI element to start the export function should be to the left side from the characters name. + * select template + * start export + * display result in new tab + + + +### Later * continuation of lists does not work as expected but good enough for a first shot * generalize handling so that only on set of functions can handle ALL kinds of templates. Needs massive refactoring - + * currently the template fetched for rendering is set to Default_A4_Quer \ No newline at end of file