Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 19 additions & 10 deletions pkg/utils/globalreport.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)

Expand Down Expand Up @@ -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, "")
Comment thread
gitar-bot[bot] marked this conversation as resolved.
pdf.SetTextColor(0, 0, 0)
pdf.SetY(marginT)
})
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
50 changes: 50 additions & 0 deletions pkg/utils/globalreport_test.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down
17 changes: 12 additions & 5 deletions pkg/utils/repository_summary.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -426,7 +433,7 @@ func generateRepositoryPDFReport(summary *RepositorySummaryReport, outputPath st
}

fill := rowCount%2 == 0
createRepositoryPDFRow(pdf, repo, fill)
createRepositoryPDFRow(pdf, tr, repo, fill)
rowCount++
}

Expand Down
34 changes: 32 additions & 2 deletions pkg/utils/repository_summary_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package utils

import (
"bytes"
"encoding/json"
"os"
"path/filepath"
Expand All @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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")
Expand Down
48 changes: 47 additions & 1 deletion pkg/utils/skipped_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,58 @@
package utils

import (
"bytes"
"os"
"path/filepath"
"testing"

"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.
Expand All @@ -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)
}
Expand Down
Loading