routes and handlker for PDFexport created
This commit is contained in:
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user