continuation of lists on a continuation page

This commit is contained in:
2025-12-20 14:37:56 +01:00
parent 4b9782c290
commit b21d3bb7eb
11 changed files with 1262 additions and 195 deletions
@@ -0,0 +1,131 @@
package pdfrender
import (
"bamort/models"
"os"
"testing"
"github.com/pdfcpu/pdfcpu/pkg/api"
)
// TestIntegration_ContinuationPages_ActualFiles tests that continuation pages
// are actually generated and saved as separate PDF files
func TestIntegration_ContinuationPages_ActualFiles(t *testing.T) {
// Arrange - Create character with 50 skills to force multiple pages
char := &models.Char{
BamortBase: models.BamortBase{
Name: "Test Character With Many Skills",
},
Typ: "Krieger",
Grad: 10,
Alter: 35,
Eigenschaften: []models.Eigenschaft{
{Name: "St", Value: 90},
{Name: "Gs", Value: 80},
{Name: "Gw", Value: 70},
{Name: "Ko", Value: 85},
{Name: "In", Value: 60},
{Name: "Zt", Value: 55},
{Name: "Au", Value: 75},
{Name: "pA", Value: 65},
{Name: "Wk", Value: 50},
},
Lp: models.Lp{Value: 30, Max: 35},
Ap: models.Ap{Value: 25, Max: 30},
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++ {
char.Fertigkeiten[i] = models.SkFertigkeit{
BamortCharTrait: models.BamortCharTrait{
BamortBase: models.BamortBase{Name: "Skill " + string(rune('A'+i%26))},
},
Fertigkeitswert: 10 + i%15,
Pp: i % 8,
}
}
// Map to view model
viewModel, err := MapCharacterToViewModel(char)
if err != nil {
t.Fatalf("Failed to map character: %v", err)
}
t.Logf("Character has %d skills", len(viewModel.Skills))
// Load templates
loader := NewTemplateLoader("../templates/Default_A4_Quer")
if err = loader.LoadTemplates(); err != nil {
t.Fatalf("Failed to load templates: %v", err)
}
renderer := NewPDFRenderer()
// Act - Render page1 with continuations
pdfs, err := RenderPageWithContinuations(
viewModel,
"page1_stats.html",
1,
"20.12.2025",
loader,
renderer,
)
// Assert
if err != nil {
t.Fatalf("Failed to render with continuations: %v", err)
}
if len(pdfs) < 2 {
t.Fatalf("Expected at least 2 PDFs (main + continuation), got %d", len(pdfs))
}
t.Logf("Generated %d PDF pages (1 main + %d continuations)", len(pdfs), len(pdfs)-1)
// Save all PDFs to disk
outputDir := "/tmp/bamort_continuation_test"
if err := os.MkdirAll(outputDir, 0755); err != nil {
t.Fatalf("Failed to create output directory: %v", err)
}
var filePaths []string
for i, pdf := range pdfs {
var filename string
if i == 0 {
filename = "page1_stats.pdf"
} else {
filename = "page1_stats_continuation_" + string(rune('0'+i)) + ".pdf"
}
path := outputDir + "/" + filename
if err := os.WriteFile(path, pdf, 0644); err != nil {
t.Errorf("Failed to write %s: %v", filename, err)
continue
}
filePaths = append(filePaths, path)
t.Logf("✓ Saved %s (%d bytes)", path, len(pdf))
// Verify PDF is valid
if string(pdf[0:4]) != "%PDF" {
t.Errorf("%s does not start with PDF marker", filename)
}
}
// Merge all PDFs into a single file
combinedPath := outputDir + "/page1_stats_combined.pdf"
if err := api.MergeCreateFile(filePaths, combinedPath, false, nil); err != nil {
t.Fatalf("Failed to merge PDFs: %v", err)
}
// Get size of combined PDF
combinedInfo, err := os.Stat(combinedPath)
if err != nil {
t.Fatalf("Failed to stat combined PDF: %v", err)
}
t.Logf("\n✓ Combined all %d pages into: %s (%d bytes)", len(pdfs), combinedPath, combinedInfo.Size())
t.Logf(" Output directory: %s", outputDir)
t.Logf("\n✅ CONTINUATION PAGES SUCCESSFULLY GENERATED AND SAVED!")
}
+229
View File
@@ -0,0 +1,229 @@
package pdfrender
import (
"testing"
)
// TestContinuationPages_WhenSkillsExceedCapacity tests that overflow items
// are distributed to continuation pages when template capacity is exceeded
func TestContinuationPages_WhenSkillsExceedCapacity(t *testing.T) {
// Arrange - Create more skills than fit on a single page
// Assuming page1_stats has capacity of 64 (2 columns x 32 each)
templateSet := DefaultA4QuerTemplateSet()
paginator := NewPaginator(templateSet)
// Create 100 skills to force overflow
skills := make([]SkillViewModel, 100)
for i := 0; i < 100; i++ {
skills[i] = SkillViewModel{
Name: "Skill " + string(rune('A'+i%26)),
Value: 10 + i%20,
}
}
// Act - Paginate skills for page1_stats
pages, err := paginator.PaginateSkills(skills, "page1_stats.html", "")
// Assert
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
// Should have more than 1 page (100 skills / 64 capacity = 2 pages)
if len(pages) < 2 {
t.Errorf("Expected at least 2 pages for 100 skills, got %d", len(pages))
}
// First page should be "page1_stats.html"
if pages[0].TemplateName != "page1_stats.html" {
t.Errorf("Expected first page template 'page1_stats.html', got '%s'", pages[0].TemplateName)
}
// Second page should be continuation page with name pattern "page1.2_stats.html"
expectedContinuation := "page1.2_stats.html"
if pages[1].TemplateName != expectedContinuation {
t.Errorf("Expected continuation page template '%s', got '%s'",
expectedContinuation, pages[1].TemplateName)
}
// Verify page numbers are sequential
for i, page := range pages {
expectedPageNum := i + 1
if page.PageNumber != expectedPageNum {
t.Errorf("Page %d: expected page number %d, got %d",
i, expectedPageNum, page.PageNumber)
}
}
// Verify all 100 skills are distributed
totalSkills := 0
for _, page := range pages {
for _, data := range page.Data {
if skillSlice, ok := data.([]SkillViewModel); ok {
totalSkills += len(skillSlice)
}
}
}
if totalSkills != 100 {
t.Errorf("Expected 100 total skills across all pages, got %d", totalSkills)
}
}
// TestContinuationPages_WhenWeaponsExceedCapacity tests overflow handling for weapons
func TestContinuationPages_WhenWeaponsExceedCapacity(t *testing.T) {
// Arrange
templateSet := DefaultA4QuerTemplateSet()
paginator := NewPaginator(templateSet)
// Get capacity for page2_play weapons (should be 12)
var weaponsCapacity int
for _, tmpl := range templateSet.Templates {
if tmpl.Metadata.Name == "page2_play.html" {
for _, block := range tmpl.Metadata.Blocks {
if block.ListType == "weapons" {
weaponsCapacity = block.MaxItems
t.Logf("Found weapons block: %s with capacity %d", block.Name, block.MaxItems)
}
}
break
}
}
t.Logf("Total weapons capacity per page: %d", weaponsCapacity)
// Create more weapons than capacity (enough for exactly 2 pages)
// If capacity is 3, create 4 weapons to get 2 pages (3 + 1)
numWeapons := weaponsCapacity + 1
weapons := make([]WeaponViewModel, numWeapons)
for i := 0; i < len(weapons); i++ {
weapons[i] = WeaponViewModel{
Name: "Weapon " + string(rune('A'+i%26)),
Value: 10 + i,
}
}
t.Logf("Created %d weapons (capacity %d)", numWeapons, weaponsCapacity)
// Act
pages, err := paginator.PaginateWeapons(weapons, "page2_play.html")
// Assert
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
// Should have 2 pages (base + continuation)
if len(pages) != 2 {
t.Errorf("Expected 2 pages (main + continuation), got %d", len(pages))
}
// First page should be original template
if pages[0].TemplateName != "page2_play.html" {
t.Errorf("Expected first page 'page2_play.html', got '%s'", pages[0].TemplateName)
}
// Second page should be continuation
if pages[1].TemplateName != "page2.2_play.html" {
t.Errorf("Expected continuation 'page2.2_play.html', got '%s'", pages[1].TemplateName)
}
}
// TestContinuationPages_MultipleOverflows tests handling of multiple continuation pages
func TestContinuationPages_MultipleOverflows(t *testing.T) {
// Arrange
templateSet := DefaultA4QuerTemplateSet()
paginator := NewPaginator(templateSet)
// Get actual capacity from template
var skillsCapacity int
for _, tmpl := range templateSet.Templates {
if tmpl.Metadata.Name == "page1_stats.html" {
for _, block := range tmpl.Metadata.Blocks {
if block.ListType == "skills" {
skillsCapacity += block.MaxItems
}
}
break
}
}
t.Logf("Skills capacity per page: %d", skillsCapacity)
// Create 200 skills to force multiple continuations
skills := make([]SkillViewModel, 200)
for i := 0; i < 200; i++ {
skills[i] = SkillViewModel{
Name: "Skill " + string(rune('A'+i%26)),
Value: 10 + i%20,
}
}
// Calculate expected pages
expectedPages := (200 + skillsCapacity - 1) / skillsCapacity
// Act
pages, err := paginator.PaginateSkills(skills, "page1_stats.html", "")
// Assert
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
// Should have calculated number of pages
if len(pages) != expectedPages {
t.Errorf("Expected %d pages for 200 skills (capacity %d), got %d", expectedPages, skillsCapacity, len(pages))
}
t.Logf("Created %d pages for 200 skills", len(pages))
// Verify template names follow pattern
expectedTemplates := []string{
"page1_stats.html",
"page1.2_stats.html",
"page1.3_stats.html",
"page1.4_stats.html",
}
for i, page := range pages {
if i < len(expectedTemplates) && page.TemplateName != expectedTemplates[i] {
t.Errorf("Page %d: expected template '%s', got '%s'",
i+1, expectedTemplates[i], page.TemplateName)
}
}
}
// TestContinuationPages_NoOverflow tests that no continuation pages are created
// when items fit within the original page capacity
func TestContinuationPages_NoOverflow(t *testing.T) {
// Arrange
templateSet := DefaultA4QuerTemplateSet()
paginator := NewPaginator(templateSet)
// Create only 10 skills (well below capacity)
skills := make([]SkillViewModel, 10)
for i := 0; i < 10; i++ {
skills[i] = SkillViewModel{
Name: "Skill " + string(rune('A'+i)),
Value: 10 + i,
}
}
// Act
pages, err := paginator.PaginateSkills(skills, "page1_stats.html", "")
// Assert
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
// Should have exactly 1 page (no overflow)
if len(pages) != 1 {
t.Errorf("Expected 1 page for 10 skills, got %d", len(pages))
}
// Should use original template, not continuation
if pages[0].TemplateName != "page1_stats.html" {
t.Errorf("Expected original template 'page1_stats.html', got '%s'", pages[0].TemplateName)
}
}
+134 -110
View File
@@ -3,7 +3,9 @@ package pdfrender
import (
"bamort/database"
"bamort/models"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
@@ -220,10 +222,25 @@ func TestIntegration_PaginationWithPDF(t *testing.T) {
t.Fatalf("Failed to paginate skills: %v", err)
}
// Should create 2 pages (64 + 36 skills)
if len(pages) != 2 {
t.Fatalf("Expected 2 pages, got %d", len(pages))
// Calculate expected pages based on actual capacity
var skillsCapacity int
for _, tmpl := range templateSet.Templates {
if tmpl.Metadata.Name == "page1_stats.html" {
for _, block := range tmpl.Metadata.Blocks {
if block.ListType == "skills" {
skillsCapacity += block.MaxItems
}
}
break
}
}
expectedPages := (100 + skillsCapacity - 1) / skillsCapacity
if len(pages) != expectedPages {
t.Fatalf("Expected %d pages for 100 skills (capacity %d), got %d", expectedPages, skillsCapacity, len(pages))
}
t.Logf("Successfully paginated 100 skills into %d pages (capacity %d per page)", len(pages), skillsCapacity)
// Load templates
loader := NewTemplateLoader("../templates/Default_A4_Quer")
@@ -281,31 +298,25 @@ func TestIntegration_PaginationWithPDF(t *testing.T) {
t.Logf("Successfully generated page 1 PDF with %d skills, size: %d bytes", len(pageData.Skills), len(pdfBytes))
// Verify second page has remaining skills
// Get expected capacity from template (reuse templateSet from above)
var page1Template *TemplateWithMeta
for i := range templateSet.Templates {
if templateSet.Templates[i].Metadata.Name == "page1_stats.html" {
page1Template = &templateSet.Templates[i]
break
// Verify second page has remaining skills (if there are multiple pages)
if len(pages) > 1 {
col1Page2 := pages[1].Data["skills_column1"].([]SkillViewModel)
col2Page2 := pages[1].Data["skills_column2"].([]SkillViewModel)
totalPage2 := len(col1Page2) + len(col2Page2)
expectedPage2 := skillsCapacity
if 100-skillsCapacity < skillsCapacity {
expectedPage2 = 100 - skillsCapacity
}
if totalPage2 != expectedPage2 {
t.Errorf("Expected %d skills on page 2, got %d", expectedPage2, totalPage2)
}
t.Logf("Page 2 has %d skills distributed across columns", totalPage2)
}
col1Block := GetBlockByName(page1Template.Metadata.Blocks, "skills_column1")
col2Block := GetBlockByName(page1Template.Metadata.Blocks, "skills_column2")
expectedPage1Capacity := col1Block.MaxItems + col2Block.MaxItems
col1Page2 := pages[1].Data["skills_column1"].([]SkillViewModel)
col2Page2 := pages[1].Data["skills_column2"].([]SkillViewModel)
totalPage2 := len(col1Page2) + len(col2Page2)
expectedPage2 := 100 - expectedPage1Capacity // 100 total - capacity from page 1
if totalPage2 != expectedPage2 {
t.Errorf("Expected %d skills on page 2 (100 total - %d from page 1), got %d", expectedPage2, expectedPage1Capacity, totalPage2)
}
t.Logf("Page 2 would have %d skills distributed across columns", totalPage2)
t.Logf("Page 2 would have %d skills distributed across columns", 100-skillsCapacity)
}
// TestIntegration_MultiPageSpellList tests spell pagination across multiple pages
@@ -534,99 +545,112 @@ func TestVisualInspection_AllPages(t *testing.T) {
renderer := NewPDFRenderer()
// Page 1: Stats page with skills
t.Log("Generating Page 1: Stats...")
page1Data, err := PreparePaginatedPageData(viewModel, "page1_stats.html", 1, "18.12.2025")
if err != nil {
t.Fatalf("Failed to prepare page1 data: %v", err)
}
html1, err := loader.RenderTemplateWithInlinedResources("page1_stats.html", page1Data)
if err != nil {
t.Fatalf("Failed to render page1: %v", err)
}
pdf1, err := renderer.RenderHTMLToPDF(html1)
if err != nil {
t.Fatalf("Failed to generate PDF for page1: %v", err)
}
// Page 2: Play/Adventure page with weapons
t.Log("Generating Page 2: Play...")
page2Data, err := PreparePaginatedPageData(viewModel, "page2_play.html", 2, "18.12.2025")
if err != nil {
t.Fatalf("Failed to prepare page2 data: %v", err)
}
html2, err := loader.RenderTemplateWithInlinedResources("page2_play.html", page2Data)
if err != nil {
t.Fatalf("Failed to render page2: %v", err)
}
pdf2, err := renderer.RenderHTMLToPDF(html2)
if err != nil {
t.Fatalf("Failed to generate PDF for page2: %v", err)
}
// Page 3: Spells page
t.Log("Generating Page 3: Spells...")
page3Data, err := PreparePaginatedPageData(viewModel, "page3_spell.html", 3, "18.12.2025")
if err != nil {
t.Fatalf("Failed to prepare page3 data: %v", err)
}
html3, err := loader.RenderTemplateWithInlinedResources("page3_spell.html", page3Data)
if err != nil {
t.Fatalf("Failed to render page3: %v", err)
}
pdf3, err := renderer.RenderHTMLToPDF(html3)
if err != nil {
t.Fatalf("Failed to generate PDF for page3: %v", err)
}
// Page 4: Equipment page
t.Log("Generating Page 4: Equipment...")
page4Data, err := PreparePaginatedPageData(viewModel, "page4_equip.html", 4, "18.12.2025")
if err != nil {
t.Fatalf("Failed to prepare page4 data: %v", err)
}
html4, err := loader.RenderTemplateWithInlinedResources("page4_equip.html", page4Data)
if err != nil {
t.Fatalf("Failed to render page4: %v", err)
}
pdf4, err := renderer.RenderHTMLToPDF(html4)
if err != nil {
t.Fatalf("Failed to generate PDF for page4: %v", err)
}
// Save all PDFs to disk
// Generate all pages with continuations if needed
allPDFs := [][]byte{}
var filePaths []string
outputDir := "/tmp/bamort_pdf_test"
if err := os.MkdirAll(outputDir, 0755); err != nil {
t.Fatalf("Failed to create output directory: %v", err)
}
files := []struct {
name string
data []byte
}{
{"page1_stats.pdf", pdf1},
{"page2_play.pdf", pdf2},
{"page3_spell.pdf", pdf3},
{"page4_equip.pdf", pdf4},
// Page 1: Stats page with skills (may have continuations)
t.Log("Generating Page 1: Stats...")
page1PDFs, err := RenderPageWithContinuations(viewModel, "page1_stats.html", 1, "18.12.2025", loader, renderer)
if err != nil {
t.Fatalf("Failed to generate page1: %v", err)
}
t.Logf(" Generated %d PDF(s) for page 1", len(page1PDFs))
var filePaths []string
for _, file := range files {
path := outputDir + "/" + file.name
if err := os.WriteFile(path, file.data, 0644); err != nil {
t.Errorf("Failed to write %s: %v", file.name, err)
for i, pdf := range page1PDFs {
allPDFs = append(allPDFs, pdf)
var filename string
if i == 0 {
filename = "page1_stats.pdf"
} else {
filename = fmt.Sprintf("page1_stats_continuation_%d.pdf", i)
}
path := filepath.Join(outputDir, filename)
if err := os.WriteFile(path, pdf, 0644); err != nil {
t.Errorf("Failed to write %s: %v", filename, err)
continue
}
filePaths = append(filePaths, path)
t.Logf("✓ Saved %s (%d bytes)", path, len(file.data))
t.Logf(" ✓ Saved %s (%d bytes)", filename, len(pdf))
}
// Page 2: Play/Adventure page with weapons (may have continuations)
t.Log("Generating Page 2: Play...")
page2PDFs, err := RenderPageWithContinuations(viewModel, "page2_play.html", 2, "18.12.2025", loader, renderer)
if err != nil {
t.Fatalf("Failed to generate page2: %v", err)
}
t.Logf(" Generated %d PDF(s) for page 2", len(page2PDFs))
for i, pdf := range page2PDFs {
allPDFs = append(allPDFs, pdf)
var filename string
if i == 0 {
filename = "page2_play.pdf"
} else {
filename = fmt.Sprintf("page2_play_continuation_%d.pdf", i)
}
path := filepath.Join(outputDir, filename)
if err := os.WriteFile(path, pdf, 0644); err != nil {
t.Errorf("Failed to write %s: %v", filename, err)
continue
}
filePaths = append(filePaths, path)
t.Logf(" ✓ Saved %s (%d bytes)", filename, len(pdf))
}
// Page 3: Spells page (may have continuations)
t.Log("Generating Page 3: Spells...")
page3PDFs, err := RenderPageWithContinuations(viewModel, "page3_spell.html", 3, "18.12.2025", loader, renderer)
if err != nil {
t.Fatalf("Failed to generate page3: %v", err)
}
t.Logf(" Generated %d PDF(s) for page 3", len(page3PDFs))
for i, pdf := range page3PDFs {
allPDFs = append(allPDFs, pdf)
var filename string
if i == 0 {
filename = "page3_spell.pdf"
} else {
filename = fmt.Sprintf("page3_spell_continuation_%d.pdf", i)
}
path := filepath.Join(outputDir, filename)
if err := os.WriteFile(path, pdf, 0644); err != nil {
t.Errorf("Failed to write %s: %v", filename, err)
continue
}
filePaths = append(filePaths, path)
t.Logf(" ✓ Saved %s (%d bytes)", filename, len(pdf))
}
// Page 4: Equipment page (may have continuations)
t.Log("Generating Page 4: Equipment...")
page4PDFs, err := RenderPageWithContinuations(viewModel, "page4_equip.html", 4, "18.12.2025", loader, renderer)
if err != nil {
t.Fatalf("Failed to generate page4: %v", err)
}
t.Logf(" Generated %d PDF(s) for page 4", len(page4PDFs))
for i, pdf := range page4PDFs {
allPDFs = append(allPDFs, pdf)
var filename string
if i == 0 {
filename = "page4_equip.pdf"
} else {
filename = fmt.Sprintf("page4_equip_continuation_%d.pdf", i)
}
path := filepath.Join(outputDir, filename)
if err := os.WriteFile(path, pdf, 0644); err != nil {
t.Errorf("Failed to write %s: %v", filename, err)
continue
}
filePaths = append(filePaths, path)
t.Logf(" ✓ Saved %s (%d bytes)", filename, len(pdf))
}
// Merge all PDFs into a single file
@@ -644,13 +668,13 @@ func TestVisualInspection_AllPages(t *testing.T) {
t.Logf("\n✓ Combined all pages into: %s (%d bytes)", combinedPath, combinedInfo.Size())
// Summary
t.Logf("\n✓ All 4 pages generated successfully!")
t.Logf("\n✓ All pages generated successfully!")
t.Logf(" Character: %s (Grade %d)", viewModel.Character.Name, viewModel.Character.Grade)
t.Logf(" Skills: %d", len(viewModel.Skills))
t.Logf(" Weapons: %d", len(viewModel.Weapons))
t.Logf(" Spells: %d", len(viewModel.Spells))
t.Logf(" Equipment: %d items", len(viewModel.Equipment))
t.Logf("\n Output directory: %s", outputDir)
t.Logf(" Individual PDFs: %d bytes", len(pdf1)+len(pdf2)+len(pdf3)+len(pdf4))
t.Logf(" Total PDFs generated: %d (including continuations)", len(allPDFs))
t.Logf(" Combined PDF: %d bytes", combinedInfo.Size())
}
+156 -2
View File
@@ -1,6 +1,70 @@
package pdfrender
import "fmt"
import (
"fmt"
"strings"
)
// GenerateContinuationTemplateName creates a continuation template name
// Example: "page1_stats.html" + pageNum 2 -> "page1.2_stats.html"
func GenerateContinuationTemplateName(originalTemplate string, pageNum int) string {
if pageNum == 1 {
return originalTemplate
}
// Split template name at first underscore to insert page continuation number
// Example: "page1_stats.html" -> "page1" + "_stats.html"
parts := strings.SplitN(originalTemplate, "_", 2)
if len(parts) != 2 {
// Fallback: just append .2, .3, etc before extension
ext := ".html"
base := strings.TrimSuffix(originalTemplate, ext)
return fmt.Sprintf("%s.%d%s", base, pageNum, ext)
}
// Extract page number and base name
// "page1" -> "page" + "1"
baseName := parts[0]
suffix := parts[1]
// Insert continuation number: "page1.2_stats.html"
return fmt.Sprintf("%s.%d_%s", baseName, pageNum, suffix)
}
// ExtractBaseTemplateName extracts the base template name from a continuation template
// Example: "page1.2_stats.html" -> "page1_stats.html"
func ExtractBaseTemplateName(templateName string) string {
// Check if it's a continuation template (contains .N_ pattern)
parts := strings.SplitN(templateName, "_", 2)
if len(parts) != 2 {
return templateName
}
baseName := parts[0]
suffix := parts[1]
// Check if baseName contains a dot followed by a number (e.g., "page1.2")
dotIdx := strings.LastIndex(baseName, ".")
if dotIdx == -1 {
return templateName // Not a continuation template
}
// Verify the part after the dot is a number
numPart := baseName[dotIdx+1:]
if len(numPart) == 0 {
return templateName
}
for _, c := range numPart {
if c < '0' || c > '9' {
return templateName // Not a number, not a continuation template
}
}
// It's a continuation template, return the base name
basePrefix := baseName[:dotIdx]
return fmt.Sprintf("%s_%s", basePrefix, suffix)
}
// SliceList slices a list based on start index and max items
// Returns the sliced list and whether there are more items
@@ -158,8 +222,11 @@ func (p *Paginator) paginateList(items interface{}, blocks []BlockMetadata, temp
currentIndex += itemsToTake
}
// Determine template name - use continuation naming for pages 2+
pageTemplateName := GenerateContinuationTemplateName(templateName, pageNum)
distributions = append(distributions, PageDistribution{
TemplateName: templateName,
TemplateName: pageTemplateName,
PageNumber: pageNum,
Data: pageData,
})
@@ -261,3 +328,90 @@ func (p *Paginator) CalculatePagesNeeded(templateName string, listType string, i
return (itemCount + capacityPerPage - 1) / capacityPerPage, nil
}
// PaginatePage2PlayLists handles pagination for page2_play.html which has both skills and weapons
// Skills and weapons overflow together - if either overflows, create continuation pages with remaining items from both
func (p *Paginator) PaginatePage2PlayLists(skills []SkillViewModel, weapons []WeaponViewModel, templateName string) ([]PageDistribution, error) {
template := p.findTemplate(templateName)
if template == nil {
return nil, fmt.Errorf("template not found: %s", templateName)
}
// Get capacities for each block type
learnedCap := GetBlockCapacity(&p.templateSet, templateName, "skills_learned")
unlearnedCap := GetBlockCapacity(&p.templateSet, templateName, "skills_unlearned")
languageCap := GetBlockCapacity(&p.templateSet, templateName, "skills_languages")
weaponsCap := GetBlockCapacity(&p.templateSet, templateName, "weapons_main")
// Filter skills into categories
var learnedSkills, unlearnedSkills, languageSkills []SkillViewModel
for _, skill := range skills {
if skill.Category == "Sprache" {
languageSkills = append(languageSkills, skill)
} else if skill.IsLearned {
learnedSkills = append(learnedSkills, skill)
} else {
unlearnedSkills = append(unlearnedSkills, skill)
}
}
// Track current position in each list
learnedIdx := 0
unlearnedIdx := 0
languageIdx := 0
weaponsIdx := 0
distributions := []PageDistribution{}
pageNum := 1
// Continue creating pages while there are remaining items in any list
for learnedIdx < len(learnedSkills) || unlearnedIdx < len(unlearnedSkills) ||
languageIdx < len(languageSkills) || weaponsIdx < len(weapons) {
pageData := make(map[string]interface{})
// Add learned skills for this page
learnedEnd := learnedIdx + learnedCap
if learnedEnd > len(learnedSkills) {
learnedEnd = len(learnedSkills)
}
pageData["skills_learned"] = learnedSkills[learnedIdx:learnedEnd]
learnedIdx = learnedEnd
// Add unlearned skills for this page
unlearnedEnd := unlearnedIdx + unlearnedCap
if unlearnedEnd > len(unlearnedSkills) {
unlearnedEnd = len(unlearnedSkills)
}
pageData["skills_unlearned"] = unlearnedSkills[unlearnedIdx:unlearnedEnd]
unlearnedIdx = unlearnedEnd
// Add language skills for this page
languageEnd := languageIdx + languageCap
if languageEnd > len(languageSkills) {
languageEnd = len(languageSkills)
}
pageData["skills_languages"] = languageSkills[languageIdx:languageEnd]
languageIdx = languageEnd
// Add weapons for this page
weaponsEnd := weaponsIdx + weaponsCap
if weaponsEnd > len(weapons) {
weaponsEnd = len(weapons)
}
pageData["weapons_main"] = weapons[weaponsIdx:weaponsEnd]
weaponsIdx = weaponsEnd
// Create page distribution
pageTemplateName := GenerateContinuationTemplateName(templateName, pageNum)
distributions = append(distributions, PageDistribution{
TemplateName: pageTemplateName,
PageNumber: pageNum,
Data: pageData,
})
pageNum++
}
return distributions, nil
}
+4 -13
View File
@@ -149,19 +149,10 @@ func PreparePaginatedPageData(viewModel *CharacterSheetViewModel, templateName s
pageData.MagicItems = magicItems
}
} else if templateName == "page4_equip.html" {
// Get capacity from template
equipmentCapacity := GetBlockCapacity(&templateSet, templateName, "equipment_worn")
// Limit and fill equipment to capacity
equipment := viewModel.Equipment
if equipmentCapacity > 0 && len(equipment) > equipmentCapacity {
equipment = equipment[:equipmentCapacity]
}
if equipmentCapacity > 0 {
pageData.Equipment = FillToCapacity(equipment, equipmentCapacity)
} else {
pageData.Equipment = equipment
}
// Page 4 needs ALL equipment to properly render containers
// The template has complex logic showing containers on left, worn items and container sections on right
// Don't truncate based on capacity - let the template handle all items
pageData.Equipment = viewModel.Equipment
}
return pageData, nil
+11 -12
View File
@@ -99,10 +99,10 @@ func TestSplitSkillsForColumns(t *testing.T) {
}{
{
name: "few skills - only column 1",
skills: 10,
skills: 3, // Less than col1Max
col1Max: col1MaxItems,
col2Max: col2MaxItems,
wantCol1: 10,
wantCol1: 3,
wantCol2: 0,
},
{
@@ -115,11 +115,11 @@ func TestSplitSkillsForColumns(t *testing.T) {
},
{
name: "overflow to column 2",
skills: col1MaxItems + 11,
skills: col1MaxItems + 3, // More than col1, but less than col1+col2
col1Max: col1MaxItems,
col2Max: col2MaxItems,
wantCol1: col1MaxItems,
wantCol2: 11,
wantCol2: 3,
},
{
name: "both columns full",
@@ -234,11 +234,10 @@ func TestPreparePaginatedPageData_Page3Spell(t *testing.T) {
}
func TestPreparePaginatedPageData_Page4Equipment(t *testing.T) {
// Get capacity from template
templateSet := DefaultA4QuerTemplateSet()
equipmentCap := GetBlockCapacity(&templateSet, "page4_equip.html", "equipment_worn")
// Page 4 equipment page needs ALL equipment (not limited by capacity)
// because the template has complex container logic that requires the full equipment list
// Create test data exceeding capacity
// Create test data
viewModel := &CharacterSheetViewModel{
Equipment: make([]EquipmentViewModel, 50),
}
@@ -253,10 +252,10 @@ func TestPreparePaginatedPageData_Page4Equipment(t *testing.T) {
t.Fatalf("PreparePaginatedPageData failed: %v", err)
}
// Verify capacity matches template
if len(pageData.Equipment) != equipmentCap {
t.Errorf("Equipment should be filled to %d (from template), got %d", equipmentCap, len(pageData.Equipment))
// Verify all equipment is included (not truncated)
if len(pageData.Equipment) != 50 {
t.Errorf("Equipment should include all items (50), got %d", len(pageData.Equipment))
}
t.Logf("Page4: %d equipment items (from template)", len(pageData.Equipment))
t.Logf("Page4: %d equipment items (all items included for container rendering)", len(pageData.Equipment))
}
+98 -57
View File
@@ -61,12 +61,28 @@ func TestPaginateSkills_SinglePage(t *testing.T) {
templateSet := DefaultA4QuerTemplateSet()
paginator := NewPaginator(templateSet)
skills := make([]SkillViewModel, 10)
for i := 0; i < 10; i++ {
// Get column capacities from template
var col1Capacity int
for _, tmpl := range templateSet.Templates {
if tmpl.Metadata.Name == "page1_stats.html" {
for _, block := range tmpl.Metadata.Blocks {
if block.Name == "skills_column1" {
col1Capacity = block.MaxItems
break
}
}
break
}
}
// Create skills that fit within first column only
numSkills := col1Capacity
skills := make([]SkillViewModel, numSkills)
for i := 0; i < numSkills; i++ {
skills[i] = SkillViewModel{Name: "Skill" + string(rune('A'+i))}
}
// Act - page1_stats has 2 columns with 32 each = 64 total capacity
// Act
pages, err := paginator.PaginateSkills(skills, "page1_stats.html", "")
// Assert
@@ -84,13 +100,13 @@ func TestPaginateSkills_SinglePage(t *testing.T) {
t.Errorf("Expected page number 1, got %d", page.PageNumber)
}
// Column 1 should have all 10 skills
// Column 1 should have all skills (exactly matching capacity)
col1Data, ok := page.Data["skills_column1"].([]SkillViewModel)
if !ok {
t.Fatal("skills_column1 data not found or wrong type")
}
if len(col1Data) != 10 {
t.Errorf("Expected 10 skills in column 1, got %d", len(col1Data))
if len(col1Data) != numSkills {
t.Errorf("Expected %d skills in column 1 (capacity), got %d", numSkills, len(col1Data))
}
// Column 2 should be empty (no overflow)
@@ -117,10 +133,18 @@ func TestPaginateSkills_MultiColumn(t *testing.T) {
}
}
col1Block := GetBlockByName(page1Template.Metadata.Blocks, "skills_column1")
col2Block := GetBlockByName(page1Template.Metadata.Blocks, "skills_column2")
totalCapacity := col1Block.MaxItems + col2Block.MaxItems
// Create 40 skills - should fill first column and spill to second
skills := make([]SkillViewModel, 40)
for i := 0; i < 40; i++ {
// Create enough skills to use both columns but fit on one page
// Use totalCapacity - 1 to test partial fill of second column
numSkills := col1Block.MaxItems + 2
if numSkills > totalCapacity {
numSkills = totalCapacity
}
skills := make([]SkillViewModel, numSkills)
for i := 0; i < numSkills; i++ {
skills[i] = SkillViewModel{Name: "Skill" + string(rune(i))}
}
@@ -132,8 +156,10 @@ func TestPaginateSkills_MultiColumn(t *testing.T) {
t.Fatalf("Expected no error, got %v", err)
}
if len(pages) != 1 {
t.Fatalf("Expected 1 page, got %d", len(pages))
// Should fit on one page
expectedPages := (numSkills + totalCapacity - 1) / totalCapacity
if len(pages) != expectedPages {
t.Fatalf("Expected %d page, got %d", expectedPages, len(pages))
}
page := pages[0]
@@ -146,9 +172,9 @@ func TestPaginateSkills_MultiColumn(t *testing.T) {
// Column 2 should have remaining skills
col2Data := page.Data["skills_column2"].([]SkillViewModel)
expectedCol2 := 40 - col1Block.MaxItems
expectedCol2 := numSkills - col1Block.MaxItems
if len(col2Data) != expectedCol2 {
t.Errorf("Expected %d skills in column 2 (40 total - %d in col1), got %d", expectedCol2, col1Block.MaxItems, len(col2Data))
t.Errorf("Expected %d skills in column 2 (%d total - %d in col1), got %d", expectedCol2, numSkills, col1Block.MaxItems, len(col2Data))
}
}
@@ -157,9 +183,22 @@ func TestPaginateSkills_MultiPage(t *testing.T) {
templateSet := DefaultA4QuerTemplateSet()
paginator := NewPaginator(templateSet)
// Create 100 skills - should span 2 pages (64 capacity per page)
skills := make([]SkillViewModel, 100)
for i := 0; i < 100; i++ {
// Get column capacities from template
var page1Template *TemplateWithMeta
for i := range templateSet.Templates {
if templateSet.Templates[i].Metadata.Name == "page1_stats.html" {
page1Template = &templateSet.Templates[i]
break
}
}
col1Block := GetBlockByName(page1Template.Metadata.Blocks, "skills_column1")
col2Block := GetBlockByName(page1Template.Metadata.Blocks, "skills_column2")
totalCapacity := col1Block.MaxItems + col2Block.MaxItems
// Create enough skills to span exactly 2 pages
numSkills := totalCapacity + 1
skills := make([]SkillViewModel, numSkills)
for i := 0; i < numSkills; i++ {
skills[i] = SkillViewModel{Name: "Skill" + string(rune(i))}
}
@@ -171,50 +210,32 @@ func TestPaginateSkills_MultiPage(t *testing.T) {
t.Fatalf("Expected no error, got %v", err)
}
if len(pages) != 2 {
t.Fatalf("Expected 2 pages, got %d", len(pages))
}
// Get column capacities from template (reuse templateSet from above)
var page1Template *TemplateWithMeta
for i := range templateSet.Templates {
if templateSet.Templates[i].Metadata.Name == "page1_stats.html" {
page1Template = &templateSet.Templates[i]
break
}
}
var col1Capacity, col2Capacity int
for i := range page1Template.Metadata.Blocks {
if page1Template.Metadata.Blocks[i].Name == "skills_column1" {
col1Capacity = page1Template.Metadata.Blocks[i].MaxItems
} else if page1Template.Metadata.Blocks[i].Name == "skills_column2" {
col2Capacity = page1Template.Metadata.Blocks[i].MaxItems
}
expectedPages := 2
if len(pages) != expectedPages {
t.Fatalf("Expected %d pages, got %d", expectedPages, len(pages))
}
// Page 1 should have full capacity (col1 + col2)
page1 := pages[0]
col1Page1 := page1.Data["skills_column1"].([]SkillViewModel)
col2Page1 := page1.Data["skills_column2"].([]SkillViewModel)
if len(col1Page1) != col1Capacity {
t.Errorf("Page 1 col1: expected %d skills (template capacity), got %d", col1Capacity, len(col1Page1))
if len(col1Page1) != col1Block.MaxItems {
t.Errorf("Page 1 col1: expected %d skills (template capacity), got %d", col1Block.MaxItems, len(col1Page1))
}
if len(col2Page1) != col2Capacity {
t.Errorf("Page 1 col2: expected %d skills (template capacity), got %d", col2Capacity, len(col2Page1))
if len(col2Page1) != col2Block.MaxItems {
t.Errorf("Page 1 col2: expected %d skills (template capacity), got %d", col2Block.MaxItems, len(col2Page1))
}
// Page 2 should have remaining skills
// Page 2 should have remaining skills (just 1 skill)
page2 := pages[1]
col1Page2 := page2.Data["skills_column1"].([]SkillViewModel)
col2Page2 := page2.Data["skills_column2"].([]SkillViewModel)
page1Total := col1Capacity + col2Capacity
remainingSkills := 100 - page1Total
if len(col1Page2) != col1Capacity {
t.Errorf("Page 2 col1: expected %d skills (template capacity), got %d", col1Capacity, len(col1Page2))
remainingSkills := numSkills - totalCapacity
if len(col1Page2) != remainingSkills {
t.Errorf("Page 2 col1: expected %d skills (remaining), got %d", remainingSkills, len(col1Page2))
}
expectedCol2 := remainingSkills - col1Capacity
if len(col2Page2) != expectedCol2 {
t.Errorf("Page 2 col2: expected %d skills (remaining), got %d", expectedCol2, len(col2Page2))
if len(col2Page2) != 0 {
t.Errorf("Page 2 col2: expected 0 skills, got %d", len(col2Page2))
}
}
@@ -342,12 +363,31 @@ func TestPaginateWeapons_SinglePage(t *testing.T) {
templateSet := DefaultA4QuerTemplateSet()
paginator := NewPaginator(templateSet)
weapons := make([]WeaponViewModel, 10)
for i := 0; i < 10; i++ {
// Get weapon capacity from template
var weaponCapacity int
for _, tmpl := range templateSet.Templates {
if tmpl.Metadata.Name == "page2_play.html" {
for _, block := range tmpl.Metadata.Blocks {
if block.ListType == "weapons" {
weaponCapacity = block.MaxItems
break
}
}
break
}
}
// Create fewer weapons than capacity
numWeapons := weaponCapacity - 2
if numWeapons < 1 {
numWeapons = 1
}
weapons := make([]WeaponViewModel, numWeapons)
for i := 0; i < numWeapons; i++ {
weapons[i] = WeaponViewModel{Name: "Weapon" + string(rune('A'+i))}
}
// Act - page2_play has weapons_main with MAX:30
// Act
pages, err := paginator.PaginateWeapons(weapons, "page2_play.html")
// Assert
@@ -355,14 +395,15 @@ func TestPaginateWeapons_SinglePage(t *testing.T) {
t.Fatalf("Expected no error, got %v", err)
}
if len(pages) != 1 {
t.Fatalf("Expected 1 page, got %d", len(pages))
expectedPages := 1
if len(pages) != expectedPages {
t.Fatalf("Expected %d page (capacity %d, items %d), got %d", expectedPages, weaponCapacity, numWeapons, len(pages))
}
page := pages[0]
weaponsData := page.Data["weapons_main"].([]WeaponViewModel)
if len(weaponsData) != 10 {
t.Errorf("Expected 10 weapons, got %d", len(weaponsData))
if len(weaponsData) != numWeapons {
t.Errorf("Expected %d weapons, got %d", numWeapons, len(weaponsData))
}
}
@@ -488,11 +529,11 @@ func TestCalculatePagesNeeded(t *testing.T) {
{"10 skills on page1", "page1_stats.html", "skills", 10, 1},
{fmt.Sprintf("%d skills on page1", skillCapacity), "page1_stats.html", "skills", skillCapacity, 1},
{fmt.Sprintf("%d skills on page1", skillCapacity+1), "page1_stats.html", "skills", skillCapacity + 1, 2},
{"100 skills on page1", "page1_stats.html", "skills", 100, 2},
{"10 weapons on page2", "page2_play.html", "weapons", 10, 1},
{"100 skills on page1", "page1_stats.html", "skills", 100, (100 + skillCapacity - 1) / skillCapacity}, // Dynamic calculation
{"10 weapons on page2", "page2_play.html", "weapons", 10, (10 + weaponCapacity - 1) / weaponCapacity}, // Dynamic calculation
{fmt.Sprintf("%d weapons on page2", weaponCapacity), "page2_play.html", "weapons", weaponCapacity, 1},
{fmt.Sprintf("%d weapons on page2", weaponCapacity+1), "page2_play.html", "weapons", weaponCapacity + 1, 2},
{"10 spells on page3", "page3_spell.html", "spells", 10, 1},
{"10 spells on page3", "page3_spell.html", "spells", 10, (10 + spellCapacity - 1) / spellCapacity}, // Dynamic calculation
{fmt.Sprintf("%d spells on page3", spellCapacity), "page3_spell.html", "spells", spellCapacity, 1},
{fmt.Sprintf("%d spells on page3", spellCapacity+1), "page3_spell.html", "spells", spellCapacity + 1, 2},
}
+104
View File
@@ -0,0 +1,104 @@
package pdfrender
import "testing"
func TestGenerateContinuationTemplateName(t *testing.T) {
tests := []struct {
name string
template string
pageNum int
expected string
}{
{
name: "First page returns original",
template: "page1_stats.html",
pageNum: 1,
expected: "page1_stats.html",
},
{
name: "Second page gets continuation name",
template: "page1_stats.html",
pageNum: 2,
expected: "page1.2_stats.html",
},
{
name: "Third page gets continuation name",
template: "page2_play.html",
pageNum: 3,
expected: "page2.3_play.html",
},
{
name: "Multiple digits",
template: "page1_stats.html",
pageNum: 10,
expected: "page1.10_stats.html",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := GenerateContinuationTemplateName(tt.template, tt.pageNum)
if result != tt.expected {
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
}
})
}
}
func TestExtractBaseTemplateName(t *testing.T) {
tests := []struct {
name string
template string
expected string
}{
{
name: "Base template returns itself",
template: "page1_stats.html",
expected: "page1_stats.html",
},
{
name: "Continuation template returns base",
template: "page1.2_stats.html",
expected: "page1_stats.html",
},
{
name: "Multiple digit continuation",
template: "page1.10_stats.html",
expected: "page1_stats.html",
},
{
name: "Different page type",
template: "page2.3_play.html",
expected: "page2_play.html",
},
{
name: "Template without underscore",
template: "simple.html",
expected: "simple.html",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ExtractBaseTemplateName(tt.template)
if result != tt.expected {
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
}
})
}
}
func TestContinuationRoundTrip(t *testing.T) {
// Test that generating a continuation name and extracting the base works correctly
original := "page1_stats.html"
for pageNum := 1; pageNum <= 5; pageNum++ {
continuation := GenerateContinuationTemplateName(original, pageNum)
extracted := ExtractBaseTemplateName(continuation)
if extracted != original {
t.Errorf("Round trip failed for page %d: original '%s', continuation '%s', extracted '%s'",
pageNum, original, continuation, extracted)
}
}
}
@@ -0,0 +1,198 @@
package pdfrender
import (
"fmt"
"github.com/pdfcpu/pdfcpu/pkg/api"
)
// RenderPageWithContinuations renders a template page and all necessary continuation pages
// Returns a slice of PDF bytes, one for each page (main + continuations)
func RenderPageWithContinuations(
viewModel *CharacterSheetViewModel,
templateName string,
startPageNumber int,
date string,
loader *TemplateLoader,
renderer *PDFRenderer,
) ([][]byte, error) {
var pdfs [][]byte
templateSet := DefaultA4QuerTemplateSet()
paginator := NewPaginator(templateSet)
// Determine which list type this template handles
var distributions []PageDistribution
var err error
switch templateName {
case "page1_stats.html":
// Paginate skills
distributions, err = paginator.PaginateSkills(viewModel.Skills, templateName, "")
if err != nil {
return nil, fmt.Errorf("failed to paginate skills: %w", err)
}
case "page2_play.html":
// Page 2 has both skills and weapons that overflow together
// Use multi-list pagination so remaining items from both lists go to continuation pages
distributions, err = paginator.PaginatePage2PlayLists(viewModel.Skills, viewModel.Weapons, templateName)
if err != nil {
return nil, fmt.Errorf("failed to paginate page2 lists: %w", err)
}
case "page3_spell.html":
// Paginate spells
distributions, err = paginator.PaginateSpells(viewModel.Spells, templateName)
if err != nil {
return nil, fmt.Errorf("failed to paginate spells: %w", err)
}
case "page4_equip.html":
// Page 4 has a complex container-based layout where items are grouped by containers.
// The template expects the full equipment list to properly render containers and their contents.
// Pagination doesn't make sense here - render as single page with all equipment.
pageData, err := PreparePaginatedPageData(viewModel, templateName, startPageNumber, date)
if err != nil {
return nil, err
}
html, err := loader.RenderTemplateWithInlinedResources(templateName, pageData)
if err != nil {
return nil, err
}
pdf, err := renderer.RenderHTMLToPDF(html)
if err != nil {
return nil, err
}
return [][]byte{pdf}, nil
default:
// For unknown templates, render single page without pagination
pageData, err := PreparePaginatedPageData(viewModel, templateName, startPageNumber, date)
if err != nil {
return nil, err
}
html, err := loader.RenderTemplateWithInlinedResources(templateName, pageData)
if err != nil {
return nil, err
}
pdf, err := renderer.RenderHTMLToPDF(html)
if err != nil {
return nil, err
}
return [][]byte{pdf}, nil
}
// If only one page, use the simplified approach
if len(distributions) == 1 {
pageData, err := PreparePaginatedPageData(viewModel, templateName, startPageNumber, date)
if err != nil {
return nil, err
}
html, err := loader.RenderTemplateWithInlinedResources(templateName, pageData)
if err != nil {
return nil, err
}
pdf, err := renderer.RenderHTMLToPDF(html)
if err != nil {
return nil, err
}
return [][]byte{pdf}, nil
}
// Multiple pages needed - render each one
for i, dist := range distributions {
pageData := &PageData{
Character: viewModel.Character,
Attributes: viewModel.Attributes,
DerivedValues: viewModel.DerivedValues,
GameResults: viewModel.GameResults,
Meta: PageMeta{
Date: date,
PageNumber: startPageNumber + i,
},
}
// Populate the page data based on the distribution
switch templateName {
case "page1_stats.html":
// Extract skills from distribution
if col1, ok := dist.Data["skills_column1"].([]SkillViewModel); ok {
pageData.SkillsColumn1 = col1
}
if col2, ok := dist.Data["skills_column2"].([]SkillViewModel); ok {
pageData.SkillsColumn2 = col2
}
// Combine for backward compatibility
pageData.Skills = append(pageData.SkillsColumn1, pageData.SkillsColumn2...)
case "page2_play.html":
// Extract all lists from distribution (skills and weapons)
if weapons, ok := dist.Data["weapons_main"].([]WeaponViewModel); ok {
pageData.Weapons = weapons
}
if learned, ok := dist.Data["skills_learned"].([]SkillViewModel); ok {
pageData.SkillsLearned = learned
}
if unlearned, ok := dist.Data["skills_unlearned"].([]SkillViewModel); ok {
// Unlearned skills are typically shown via general skills list
// Add to Skills for template compatibility
pageData.Skills = append(pageData.Skills, unlearned...)
}
if languages, ok := dist.Data["skills_languages"].([]SkillViewModel); ok {
pageData.SkillsLanguage = languages
}
case "page3_spell.html":
// Extract spells from distribution
if left, ok := dist.Data["spells_left"].([]SpellViewModel); ok {
pageData.SpellsLeft = left
}
if right, ok := dist.Data["spells_right"].([]SpellViewModel); ok {
pageData.SpellsRight = right
}
// Combine for backward compatibility
pageData.Spells = append(pageData.SpellsLeft, pageData.SpellsRight...)
case "page4_equip.html":
// Extract equipment from distribution
if equipment, ok := dist.Data["equipment_worn"].([]EquipmentViewModel); ok {
pageData.Equipment = append(pageData.Equipment, equipment...)
}
if equipment, ok := dist.Data["equipment_carried"].([]EquipmentViewModel); ok {
pageData.Equipment = append(pageData.Equipment, equipment...)
}
}
// Render the page (use continuation template name if needed)
html, err := loader.RenderTemplateWithInlinedResources(dist.TemplateName, pageData)
if err != nil {
return nil, fmt.Errorf("failed to render %s: %w", dist.TemplateName, err)
}
pdf, err := renderer.RenderHTMLToPDF(html)
if err != nil {
return nil, fmt.Errorf("failed to generate PDF for %s: %w", dist.TemplateName, err)
}
pdfs = append(pdfs, pdf)
}
return pdfs, nil
}
// MergePDFs merges multiple PDF byte slices into a single PDF
func MergePDFs(pdfList [][]byte, outputPath string) error {
if len(pdfList) == 0 {
return fmt.Errorf("no PDFs to merge")
}
if len(pdfList) == 1 {
// Single PDF, just write it
return nil
}
// Use pdfcpu to merge - this is a placeholder, actual implementation
// would need to save individual PDFs first, then merge them
// For now, this function signature is defined for future use
return api.MergeCreateFile(nil, outputPath, false, nil)
}
@@ -0,0 +1,183 @@
package pdfrender
import (
"bamort/models"
"testing"
)
// TestRenderWithContinuations_SkillsOverflow tests that continuation pages are rendered
// when skills exceed template capacity
func TestRenderWithContinuations_SkillsOverflow(t *testing.T) {
// Arrange - Create character with many skills to force continuation
char := &models.Char{
BamortBase: models.BamortBase{
Name: "Test Character With Many Skills",
},
Typ: "Krieger",
Grad: 5,
Alter: 30,
Eigenschaften: []models.Eigenschaft{
{Name: "St", Value: 90},
{Name: "Gs", Value: 80},
{Name: "Gw", Value: 70},
{Name: "Ko", Value: 85},
{Name: "In", Value: 60},
{Name: "Zt", Value: 55},
{Name: "Au", Value: 75},
{Name: "pA", Value: 65},
{Name: "Wk", Value: 50},
},
Lp: models.Lp{Value: 20, Max: 25},
Ap: models.Ap{Value: 15, Max: 20},
B: models.B{Value: 15},
}
// Add 50 skills to force multiple pages
char.Fertigkeiten = make([]models.SkFertigkeit, 50)
for i := 0; i < 50; i++ {
char.Fertigkeiten[i] = models.SkFertigkeit{
BamortCharTrait: models.BamortCharTrait{
BamortBase: models.BamortBase{Name: "Skill " + string(rune('A'+i%26))},
},
Fertigkeitswert: 10 + i%15,
Pp: i % 8,
}
}
// Map to view model
viewModel, err := MapCharacterToViewModel(char)
if err != nil {
t.Fatalf("Failed to map character: %v", err)
}
// Load templates
loader := NewTemplateLoader("../templates/Default_A4_Quer")
if err = loader.LoadTemplates(); err != nil {
t.Fatalf("Failed to load templates: %v", err)
}
renderer := NewPDFRenderer()
// Act - Render page1 with continuations
pdfs, err := RenderPageWithContinuations(
viewModel,
"page1_stats.html",
1,
"20.12.2025",
loader,
renderer,
)
// Assert
if err != nil {
t.Fatalf("Failed to render with continuations: %v", err)
}
// Get template capacity to calculate expected pages
templateSet := DefaultA4QuerTemplateSet()
var skillsCapacity int
for _, tmpl := range templateSet.Templates {
if tmpl.Metadata.Name == "page1_stats.html" {
for _, block := range tmpl.Metadata.Blocks {
if block.ListType == "skills" {
skillsCapacity += block.MaxItems
}
}
break
}
}
expectedPages := (50 + skillsCapacity - 1) / skillsCapacity
if len(pdfs) != expectedPages {
t.Errorf("Expected %d PDFs (pages) for 50 skills with capacity %d, got %d",
expectedPages, skillsCapacity, len(pdfs))
}
// Verify each PDF is valid
for i, pdf := range pdfs {
if len(pdf) == 0 {
t.Errorf("PDF %d is empty", i+1)
}
if string(pdf[0:4]) != "%PDF" {
t.Errorf("PDF %d does not start with PDF marker", i+1)
}
}
t.Logf("✓ Successfully generated %d continuation pages for 50 skills (capacity: %d)",
len(pdfs), skillsCapacity)
}
// TestRenderWithContinuations_NoOverflow tests that single page is rendered
// when items fit within capacity
func TestRenderWithContinuations_NoOverflow(t *testing.T) {
// Arrange - Create character with few skills
char := &models.Char{
BamortBase: models.BamortBase{
Name: "Test Character",
},
Typ: "Krieger",
Grad: 5,
Alter: 30,
Eigenschaften: []models.Eigenschaft{
{Name: "St", Value: 90},
{Name: "Gs", Value: 80},
{Name: "Gw", Value: 70},
{Name: "Ko", Value: 85},
{Name: "In", Value: 60},
{Name: "Zt", Value: 55},
{Name: "Au", Value: 75},
{Name: "pA", Value: 65},
{Name: "Wk", Value: 50},
},
Lp: models.Lp{Value: 20, Max: 25},
Ap: models.Ap{Value: 15, Max: 20},
B: models.B{Value: 15},
}
// Add only 3 skills - should fit on one page
char.Fertigkeiten = make([]models.SkFertigkeit, 3)
for i := 0; i < 3; i++ {
char.Fertigkeiten[i] = models.SkFertigkeit{
BamortCharTrait: models.BamortCharTrait{
BamortBase: models.BamortBase{Name: "Skill " + string(rune('A'+i))},
},
Fertigkeitswert: 10,
Pp: 5,
}
}
// Map to view model
viewModel, err := MapCharacterToViewModel(char)
if err != nil {
t.Fatalf("Failed to map character: %v", err)
}
// Load templates
loader := NewTemplateLoader("../templates/Default_A4_Quer")
if err = loader.LoadTemplates(); err != nil {
t.Fatalf("Failed to load templates: %v", err)
}
renderer := NewPDFRenderer()
// Act
pdfs, err := RenderPageWithContinuations(
viewModel,
"page1_stats.html",
1,
"20.12.2025",
loader,
renderer,
)
// Assert
if err != nil {
t.Fatalf("Failed to render: %v", err)
}
if len(pdfs) != 1 {
t.Errorf("Expected 1 PDF for 3 skills, got %d", len(pdfs))
}
t.Logf("✓ Correctly generated single page for 3 skills")
}
+14 -1
View File
@@ -68,6 +68,9 @@ func (tl *TemplateLoader) LoadTemplates() error {
}
// RenderTemplate renders a specific template with the given data
// For continuation templates (e.g., "page1.2_stats.html"), it automatically
// falls back to the base template (e.g., "page1_stats.html") if the continuation
// template doesn't exist as a physical file
func (tl *TemplateLoader) RenderTemplate(templateName string, data interface{}) (string, error) {
if tl.templates == nil {
return "", fmt.Errorf("templates not loaded, call LoadTemplates first")
@@ -75,7 +78,17 @@ func (tl *TemplateLoader) RenderTemplate(templateName string, data interface{})
tmpl := tl.templates.Lookup(templateName)
if tmpl == nil {
return "", fmt.Errorf("template not found: %s", templateName)
// Try to extract base template name for continuation pages
// e.g., "page1.2_stats.html" -> "page1_stats.html"
baseTemplateName := ExtractBaseTemplateName(templateName)
if baseTemplateName != templateName {
tmpl = tl.templates.Lookup(baseTemplateName)
if tmpl == nil {
return "", fmt.Errorf("template not found: %s (and base template %s not found)", templateName, baseTemplateName)
}
} else {
return "", fmt.Errorf("template not found: %s", templateName)
}
}
var buf bytes.Buffer