routes and handlker for PDFexport created

This commit is contained in:
2025-12-20 18:32:48 +01:00
parent 58692f54d6
commit 915352c0f3
8 changed files with 380 additions and 5 deletions
+1
View File
@@ -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"
+2
View File
@@ -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
+10 -1
View File
@@ -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)
@@ -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
+166
View File
@@ -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)
}
+143
View File
@@ -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)
}
}
+15
View File
@@ -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)
}
+25 -1
View File
@@ -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