diff --git a/pkg/utils/globalreport.go b/pkg/utils/globalreport.go index 3c5ea7c..216859b 100644 --- a/pkg/utils/globalreport.go +++ b/pkg/utils/globalreport.go @@ -379,7 +379,7 @@ func renderScanSummarySection(pdf *gofpdf.Fpdf, summary *ScanSummary, skippedCou // PDF. When no repositories were skipped it renders a short confirmation line; when // some were, it lists each with its branch and the reason it was skipped (clone // timeout, clone failure, or analysis error). -func renderSkippedReposSection(pdf *gofpdf.Fpdf, skippedRepos []SkippedRepo, marginL, contentW float64) { +func renderSkippedReposSection(pdf *gofpdf.Fpdf, tr func(string) string, skippedRepos []SkippedRepo, marginL, contentW float64) { pdf.Ln(8) // Section header bar (amber to read as a warning, distinct from the blue @@ -396,7 +396,7 @@ func renderSkippedReposSection(pdf *gofpdf.Fpdf, skippedRepos []SkippedRepo, mar pdf.SetFont("Helvetica", "I", 8) pdf.SetTextColor(110, 110, 120) pdf.SetX(marginL) - pdf.CellFormat(contentW, 6, "None — all targeted repositories were analyzed.", "", 1, "L", false, 0, "") + pdf.CellFormat(contentW, 6, tr("None — all targeted repositories were analyzed."), "", 1, "L", false, 0, "") pdf.SetTextColor(0, 0, 0) return } @@ -448,9 +448,9 @@ func renderSkippedReposSection(pdf *gofpdf.Fpdf, skippedRepos []SkippedRepo, mar } pdf.SetX(marginL) pdf.CellFormat(colNum, 6, fmt.Sprintf("%d", i+1), "0", 0, "C", false, 0, "") - pdf.CellFormat(colRepo, 6, fit(repo, colRepo), "0", 0, "L", false, 0, "") - pdf.CellFormat(colBranch, 6, fit(r.Branch, colBranch), "0", 0, "L", false, 0, "") - pdf.CellFormat(colReason, 6, fit(r.Reason, colReason), "0", 1, "L", false, 0, "") + pdf.CellFormat(colRepo, 6, fit(tr(repo), colRepo), "0", 0, "L", false, 0, "") + pdf.CellFormat(colBranch, 6, fit(tr(r.Branch), colBranch), "0", 0, "L", false, 0, "") + pdf.CellFormat(colReason, 6, fit(tr(r.Reason), colReason), "0", 1, "L", false, 0, "") } pdf.SetTextColor(0, 0, 0) } @@ -476,6 +476,11 @@ func renderGlobalPDF(languages []LanguageData, ginfo Globalinfo, skippedRepos [] pdf.SetMargins(marginL, marginT, marginR) pdf.SetAutoPageBreak(true, marginB+8) + // gofpdf's core fonts render text as Windows-1252, so UTF-8 strings (an em + // dash, or an accented repository/organization name) would otherwise appear as + // mojibake. tr converts UTF-8 into the font's encoding before it is drawn. + tr := pdf.UnicodeTranslatorFromDescriptor("") + logoPath := GetLogoPath() _, logoErr := os.Stat(logoPath) @@ -503,7 +508,7 @@ func renderGlobalPDF(languages []LanguageData, ginfo Globalinfo, skippedRepos [] if len(org) > 50 { org = org[:47] + "..." } - pdf.CellFormat(140, 5, "Organization: "+org, "", 0, "L", false, 0, "") + pdf.CellFormat(140, 5, tr("Organization: "+org), "", 0, "L", false, 0, "") pdf.SetTextColor(0, 0, 0) pdf.SetY(marginT) }) @@ -543,11 +548,15 @@ func renderGlobalPDF(languages []LanguageData, ginfo Globalinfo, skippedRepos [] } return s } + // Translate before valOrNA so its byte-based truncation operates on the + // single-byte Windows-1252 encoding (byte slice == rune slice) — this both + // prevents mojibake for an accented largest-repo name and avoids splitting a + // multi-byte UTF-8 sequence. cards := []statCard{ - {"Total LOC", valOrNA(ginfo.TotalLinesOfCode), 0, 115, 186}, + {"Total LOC", valOrNA(tr(ginfo.TotalLinesOfCode)), 0, 115, 186}, {"Repositories", fmt.Sprintf("%d", ginfo.NumberRepos), 0, 168, 110}, - {"Largest Repo", valOrNA(ginfo.LargestRepository), 241, 146, 49}, - {"Largest Repo LOC", valOrNA(ginfo.LinesOfCodeLargestRepo), 167, 86, 180}, + {"Largest Repo", valOrNA(tr(ginfo.LargestRepository)), 241, 146, 49}, + {"Largest Repo LOC", valOrNA(tr(ginfo.LinesOfCodeLargestRepo)), 167, 86, 180}, } for i, c := range cards { x := marginL + float64(i)*(cardW+4) @@ -609,7 +618,7 @@ func renderGlobalPDF(languages []LanguageData, ginfo Globalinfo, skippedRepos [] renderScanSummarySection(pdf, summary, len(skippedRepos), marginL, contentW) // ── Skipped repositories section ───────────────────────────────── - renderSkippedReposSection(pdf, skippedRepos, marginL, contentW) + renderSkippedReposSection(pdf, tr, skippedRepos, marginL, contentW) if err := pdf.OutputFileAndClose("Results/GlobalReport.pdf"); err != nil { loggers.Errorf("Error saving PDF file: %v", err) diff --git a/pkg/utils/globalreport_test.go b/pkg/utils/globalreport_test.go index 5503b8a..cfafeb7 100644 --- a/pkg/utils/globalreport_test.go +++ b/pkg/utils/globalreport_test.go @@ -1,12 +1,62 @@ package utils import ( + "bytes" + "compress/zlib" "encoding/json" + "io" "os" "path/filepath" + "regexp" "testing" ) +// TestRenderGlobalPDF_NoMojibakeStatCard renders the global PDF with an accented +// largest-repository name and asserts the stat card text is translated to the font +// encoding — no raw multi-byte UTF-8 sequence should survive into the (decompressed) +// PDF content stream. Guards the gofpdf latin1 mojibake bug for the stat cards. +func TestRenderGlobalPDF_NoMojibakeStatCard(t *testing.T) { + _, cleanup := setupGlobalReportEnv(t) + defer cleanup() + if err := os.MkdirAll("Results", 0755); err != nil { + t.Fatalf("mkdir Results: %v", err) + } + + ginfo := Globalinfo{ + Organization: "verify-org", // ASCII, so any leak is from the stat card + TotalLinesOfCode: "29", + LargestRepository: "café-service", // accented, user-controlled + LinesOfCodeLargestRepo: "18", + DevOpsPlatform: "azure", + NumberRepos: 2, + } + langs := []LanguageData{{Language: "Golang", CodeLines: 29, Percentage: 100, CodeLinesF: "29"}} + if err := renderGlobalPDF(langs, ginfo, nil, nil); err != nil { + t.Fatalf("renderGlobalPDF: %v", err) + } + + raw, err := os.ReadFile("Results/GlobalReport.pdf") + if err != nil { + t.Fatalf("read pdf: %v", err) + } + var text bytes.Buffer + for _, m := range regexp.MustCompile(`(?s)stream\r?\n(.*?)\r?\nendstream`).FindAllSubmatch(raw, -1) { + if zr, err := zlib.NewReader(bytes.NewReader(m[1])); err == nil { + _, _ = io.Copy(&text, zr) + _ = zr.Close() + } + } + // Guard against a false pass: the stat-card text must actually be in the + // extracted stream, otherwise the assertion below is meaningless. + if !bytes.Contains(text.Bytes(), []byte("Largest Repo")) { + t.Fatal("could not find stat-card text in decompressed PDF; extraction likely failed") + } + // é -> C3 A9 in UTF-8; after translation it becomes single-byte cp1252 0xE9. + if bytes.Contains(text.Bytes(), []byte{0xC3, 0xA9}) { + t.Error("untranslated UTF-8 (é) leaked into GlobalReport.pdf stat card") + } +} + const resultMainJSON = "Result_org__repo__main.json" // helper to set up a temp workspace and chdir into it diff --git a/pkg/utils/repository_summary.go b/pkg/utils/repository_summary.go index cb63544..7680fd8 100644 --- a/pkg/utils/repository_summary.go +++ b/pkg/utils/repository_summary.go @@ -251,10 +251,13 @@ func createPDFTableHeader(pdf *gofpdf.Fpdf, codeLinesHeader string) { pdf.CellFormat(25, 8, codeLinesHeader, "1", 1, "C", true, 0, "") } -// createRepositoryPDFRow creates a single row in the PDF table -func createRepositoryPDFRow(pdf *gofpdf.Fpdf, repo RepositoryData, fill bool) { - repoName := truncateText(repo.Repository, 20) - branchName := truncateText(repo.Branch, 12) +// createRepositoryPDFRow creates a single row in the PDF table. tr converts UTF-8 +// into the font's Windows-1252 encoding so accented repository/branch names render +// correctly instead of as mojibake; it is applied before truncation so the +// byte-based length limit matches the rendered single-byte characters. +func createRepositoryPDFRow(pdf *gofpdf.Fpdf, tr func(string) string, repo RepositoryData, fill bool) { + repoName := truncateText(tr(repo.Repository), 20) + branchName := truncateText(tr(repo.Branch), 12) pdf.CellFormat(10, 6, strconv.Itoa(repo.Number), "1", 0, "C", fill, 0, "") pdf.CellFormat(50, 6, repoName, "1", 0, "L", fill, 0, "") @@ -371,6 +374,10 @@ func generateRepositoryPDFReport(summary *RepositorySummaryReport, outputPath st pdf.SetAutoPageBreak(false, 0) pdf.AddPage() + // gofpdf core fonts render text as Windows-1252, so UTF-8 repository/branch + // names must be translated before drawing to avoid mojibake. + tr := pdf.UnicodeTranslatorFromDescriptor("") + // Add logo if it exists logoPath := GetLogoPath() if _, err := os.Stat(logoPath); err == nil { @@ -426,7 +433,7 @@ func generateRepositoryPDFReport(summary *RepositorySummaryReport, outputPath st } fill := rowCount%2 == 0 - createRepositoryPDFRow(pdf, repo, fill) + createRepositoryPDFRow(pdf, tr, repo, fill) rowCount++ } diff --git a/pkg/utils/repository_summary_test.go b/pkg/utils/repository_summary_test.go index a081295..7880e01 100644 --- a/pkg/utils/repository_summary_test.go +++ b/pkg/utils/repository_summary_test.go @@ -1,6 +1,7 @@ package utils import ( + "bytes" "encoding/json" "os" "path/filepath" @@ -10,6 +11,34 @@ import ( "github.com/jung-kurt/gofpdf" ) +// TestCreateRepositoryPDFRow_NoMojibake guards against the gofpdf latin1 bug: an +// accented repository or branch name must be translated to the font encoding, so +// no raw multi-byte UTF-8 sequence survives into the PDF content stream. +func TestCreateRepositoryPDFRow_NoMojibake(t *testing.T) { + pdf := gofpdf.New("P", "mm", "A4", "") + pdf.SetCompression(false) + pdf.SetFont("Arial", "", 8) + pdf.AddPage() + tr := pdf.UnicodeTranslatorFromDescriptor("") + + createRepositoryPDFRow(pdf, tr, RepositoryData{ + Number: 1, Repository: "café-service", Branch: "fonctionnalité", + LinesF: "1", CommentsF: "0", BlankLinesF: "0", CodeLinesF: "1", + }, true) + + var buf bytes.Buffer + if err := pdf.Output(&buf); err != nil { + t.Fatalf("pdf output: %v", err) + } + raw := buf.Bytes() + // é -> C3 A9, and generally any C3-prefixed 2-byte UTF-8 must not survive. + for label, seq := range map[string][]byte{"é": {0xC3, 0xA9}} { + if bytes.Contains(raw, seq) { + t.Errorf("untranslated UTF-8 %s leaked into the repository summary PDF", label) + } + } +} + // Constants to avoid duplicating string literals (SonarQube maintainability) const ( testOrgName = "test-org" @@ -702,6 +731,7 @@ func TestCreateRepositoryPDFRow(t *testing.T) { // This tests the helper function that was extracted during refactoring pdf := gofpdf.New("P", "mm", "A4", "") pdf.AddPage() + tr := pdf.UnicodeTranslatorFromDescriptor("") repo := RepositoryData{ Number: 1, @@ -724,8 +754,8 @@ func TestCreateRepositoryPDFRow(t *testing.T) { } }() - createRepositoryPDFRow(pdf, repo, true) - createRepositoryPDFRow(pdf, repo, false) // Test alternating colors + createRepositoryPDFRow(pdf, tr, repo, true) + createRepositoryPDFRow(pdf, tr, repo, false) // Test alternating colors if pdf.PageCount() == 0 { t.Error("createRepositoryPDFRow did not add content to PDF") diff --git a/pkg/utils/skipped_test.go b/pkg/utils/skipped_test.go index b2e35b5..535957a 100644 --- a/pkg/utils/skipped_test.go +++ b/pkg/utils/skipped_test.go @@ -1,6 +1,7 @@ package utils import ( + "bytes" "os" "path/filepath" "testing" @@ -8,6 +9,50 @@ import ( "github.com/jung-kurt/gofpdf" ) +// TestRenderSkippedReposSection_NoMojibake guards against the gofpdf latin1 bug: +// UTF-8 text (the em dash in the "None" line, or an accented repo/branch/reason) +// must be translated to the font encoding, so no raw multi-byte UTF-8 sequence +// should survive into the PDF content stream (compression disabled so the stream +// is inspectable). +func TestRenderSkippedReposSection_NoMojibake(t *testing.T) { + // Raw UTF-8 byte sequences that must NOT appear once translated: + // em dash U+2014 -> E2 80 94, é U+00E9 -> C3 A9, ï U+00EF -> C3 AF. + badSeqs := map[string][]byte{ + "em-dash": {0xE2, 0x80, 0x94}, + "é": {0xC3, 0xA9}, + "ï": {0xC3, 0xAF}, + } + + render := func(repos []SkippedRepo) []byte { + pdf := gofpdf.New("P", "mm", "A4", "") + pdf.SetCompression(false) + pdf.SetFont("Helvetica", "", 8) + pdf.AddPage() + tr := pdf.UnicodeTranslatorFromDescriptor("") + renderSkippedReposSection(pdf, tr, repos, 15.0, 180.0) + var buf bytes.Buffer + if err := pdf.Output(&buf); err != nil { + t.Fatalf("pdf output: %v", err) + } + return buf.Bytes() + } + + cases := map[string][]SkippedRepo{ + "empty-none-line": nil, + "nonascii-cells": {{ProjectKey: "café", RepoSlug: "naïve-service", Branch: "main", Reason: "clone — timed out"}}, + } + for name, repos := range cases { + t.Run(name, func(t *testing.T) { + raw := render(repos) + for label, seq := range badSeqs { + if bytes.Contains(raw, seq) { + t.Errorf("untranslated UTF-8 %s leaked into the PDF content stream", label) + } + } + }) + } +} + // TestRenderSkippedReposSection exercises the PDF section renderer for both the // empty and populated cases, including a list long enough to trigger a page break // and a very long reason that must be truncated — it must not panic. @@ -32,7 +77,8 @@ func TestRenderSkippedReposSection(t *testing.T) { pdf := gofpdf.New("P", "mm", "A4", "") pdf.SetFont("Helvetica", "", 8) pdf.AddPage() - renderSkippedReposSection(pdf, repos, 15.0, 180.0) + tr := pdf.UnicodeTranslatorFromDescriptor("") + renderSkippedReposSection(pdf, tr, repos, 15.0, 180.0) if err := pdf.OutputFileAndClose(filepath.Join(t.TempDir(), "out.pdf")); err != nil { t.Fatalf("render/output failed: %v", err) }