diff --git a/.gitignore b/.gitignore index 48da43b..54b0bad 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ # Output of the go coverage tool, specifically when used with LiteIDE *.out +# PDF files generated by tests +*.pdf + mono_test.rest .idea mono-cli \ No newline at end of file diff --git a/generate_test.go b/generate_test.go new file mode 100644 index 0000000..7ca37b1 --- /dev/null +++ b/generate_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "bufio" + "bytes" + "io/ioutil" + "os" + "testing" + + "github.com/lungria/mono-cli/statementsPdf" +) + +func TestGenerate(t *testing.T) { + var file *os.File + var fileReader *bufio.Reader + + testData := [][]string{ + {"qweqweqwe", "1569903114", "Нарахування відсотків за вересень", "4829", "true", "22", "22", "980", "0", "0", "1930"}, + {"qwesdfqwe", "1569903114", "CARD2CARD UAMAB", "4829", "true", "5066", "5066", "980", "0", "0", "193550"}, + } + + err := statementsPdf.Generate("statementsPdf/testPdf/statement_test_actual.pdf", testData) + if err != nil { + t.Errorf("Pdf isn't generated, Error appear %v ", err) + } + + // Open statement_test_actual.pdf + file, err = os.Open("statementsPdf/testPdf/statement_test_actual.pdf") + if err != nil { + t.Errorf("Open statement_test_actual.pdf failed. Error: %v \n", err) + } + fileReader = bufio.NewReader(file) + actualPdf, err := ioutil.ReadAll(fileReader) + if err != nil { + t.Errorf("Read from statement_test_actual.pdf failed. Error: %v \n", err) + } + + // Open statement_test_expected.pdf + file, err = os.Open("statementsPdf/testPdf/statement_test_expected.pdf") + if err != nil { + t.Errorf("Open statement_test_expected,pdf failed. Error: %v \n", err) + } + fileReader = bufio.NewReader(file) + expectedPdf, err := ioutil.ReadAll(fileReader) + if err != nil { + t.Errorf("Read from statement_test_actual.pdf failed. Error: %v \n", err) + } + + // Compare content of pdf files + result := bytes.Compare(actualPdf, expectedPdf) + if result != 1 { + t.Errorf("Pdf files aren't sames") + } +} + +func TestGeneratePagination(t *testing.T) { + const maxRows = 20 + var file *os.File + var fileReader *bufio.Reader + var multipliedTestData = make([][]string, maxRows) + + testData := [][]string{ + {"qweqweqwe", "1569903114", "Нарахування відсотків за вересень", "4829", "true", "22", "22", "980", "0", "0", "1930"}, + } + + for i := 0; i < maxRows; i++ { + multipliedTestData[i] = append(multipliedTestData[i], testData[0]...) + } + + err := statementsPdf.Generate("statementsPdf/testPdf/statement_test_actual_pagination.pdf", multipliedTestData) + if err != nil { + t.Errorf("Pdf isn't generated, Error appear %v ", err) + } + + // Open statement_test_actual.pdf + file, err = os.Open("statementsPdf/testPdf/statement_test_actual_pagination.pdf") + if err != nil { + t.Errorf("Open statement_test_actual.pdf failed. Error: %v \n", err) + } + fileReader = bufio.NewReader(file) + actualPdf, err := ioutil.ReadAll(fileReader) + if err != nil { + t.Errorf("Read from statement_test_actual.pdf failed. Error: %v \n", err) + } + + // Open statement_test_expected.pdf + file, err = os.Open("statementsPdf/testPdf/statement_test_expected_pagination.pdf") + if err != nil { + t.Errorf("Open statement_test_expected,pdf failed. Error: %v \n", err) + } + fileReader = bufio.NewReader(file) + expectedPdf, err := ioutil.ReadAll(fileReader) + if err != nil { + t.Errorf("Read from statement_test_actual.pdf failed. Error: %v \n", err) + } + + // Compare content of pdf files + result := bytes.Compare(actualPdf, expectedPdf) + if result != 1 { + t.Errorf("Pdf files aren't sames") + } +} diff --git a/go.mod b/go.mod index 74914b3..561328d 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module github.com/lungria/mono-cli go 1.12 -require github.com/lungria/mono v0.0.10 +require ( + github.com/jung-kurt/gofpdf v1.15.1 + github.com/lungria/mono v0.0.10 +) diff --git a/go.sum b/go.sum index 469e836..e9f1dcd 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,14 @@ +github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/jung-kurt/gofpdf v1.15.1 h1:vcj3SN7gwT+H6MoeTzZ+L58/i4cAAGW64Rl+cxKdpV0= +github.com/jung-kurt/gofpdf v1.15.1/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0= github.com/lungria/mono v0.0.10 h1:2UollwSdcqbJTbd8XhJj7Zn3HxbDcZjJjLb+LbAln3g= github.com/lungria/mono v0.0.10/go.mod h1:1ENDA5dSTj5mqUy2Kp8345W0iCOkMXYM44H2ivaQd6s= +github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/main.go b/main.go index 375936b..7d1bc36 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "encoding/csv" "errors" + "flag" "fmt" "log" "os" @@ -11,6 +12,8 @@ import ( "strconv" "time" + "github.com/lungria/mono-cli/statementsPdf" + "github.com/lungria/mono" ) @@ -27,6 +30,8 @@ func main() { if err != nil { log.Fatal(err) } + pdfFile := flag.String("o", "", "Generate statements in PDF format. e.g: ") + flag.Parse() writer = csv.NewWriter(os.Stdout) @@ -48,7 +53,15 @@ func main() { } statements = Sort(statements) - saveStatements(statements) + stmData := saveStatements(statements) + + if *pdfFile != "" { + err = statementsPdf.Generate(*pdfFile, stmData) + if err != nil { + log.Fatal("Generate pdf failed :", err) + } + return + } <-apiRateLimit } } @@ -87,9 +100,9 @@ func parseConfig() (config, error) { var newLineRegexp = regexp.MustCompile(`\r?\n`) var headerPrinted = false -func saveStatements(items []mono.StatementItem) { +func saveStatements(items []mono.StatementItem) [][]string { if len(items) == 0 { - return + return nil } if !headerPrinted { @@ -128,4 +141,5 @@ func saveStatements(items []mono.StatementItem) { if err != nil { log.Fatal(err) } + return csvData } diff --git a/statementsPdf/fonts/Alice-Regular.ttf b/statementsPdf/fonts/Alice-Regular.ttf new file mode 100644 index 0000000..4ab0ab7 Binary files /dev/null and b/statementsPdf/fonts/Alice-Regular.ttf differ diff --git a/statementsPdf/generate.go b/statementsPdf/generate.go new file mode 100644 index 0000000..09e7e02 --- /dev/null +++ b/statementsPdf/generate.go @@ -0,0 +1,146 @@ +package statementsPdf + +import ( + "strings" + + "github.com/jung-kurt/gofpdf" +) + +const ( + colCount = 4 + colWd = 45.0 + marginH = 15.0 + lineHt = 5.5 + cellGap = 2.0 +) + +const ( + imgFolder = "statementsPdf/img/" + fontsFolder = "statementsPdf/fonts/" +) + +type cpdf struct { + gofpdf.Pdf + *gofpdf.Fpdf + gofpdf.FontLoader +} + +// var colStrList [colCount]string +type cellType struct { + str string + list [][]byte + ht float64 +} + +var ( + cellList [colCount]cellType + cell cellType +) + +// Generate returns pdf file +func Generate(filename string, data [][]string) error { + statements, err := prettyCsvArr(data) + if err != nil { + return err + } + //Create new PDF + pdf := cpdf{Fpdf: gofpdf.New("P", "mm", "A4", "")} + + // Add font with support cyrillic characters + pdf.AddUTF8Font("Alice-Regular", "", fontsFolder+"Alice-Regular.ttf") + + // Set global margins + pdf.Fpdf.SetMargins(marginH, 35, marginH) + pdf.Fpdf.AddPage() + pdf.Fpdf.PageNo() + + pdf.renderHeader() + pdf.renderTableHeaders(headers) + // Render table with statements with pagination + pdf.renderStatementsTable(statements, headers) + + return pdf.Fpdf.OutputFileAndClose(filename) +} + +func (p cpdf) renderStatementsTable(data [][]string, headers []string) { + y := p.Fpdf.GetY() + count := 0 + for row := 0; row < len(data); row++ { + p.Fpdf.SetTextColor(24, 24, 24) + p.Fpdf.SetFillColor(255, 255, 255) + maxHt := lineHt + // Cell height calculation loop + for col := 0; col < colCount; col++ { + count++ + if count > len(data) { + count = 1 + } + cell.str = strings.Join(data[(len(data)-1)-row][col:col+1], " ") + cell.list = p.Fpdf.SplitLines([]byte(cell.str), colWd-cellGap-cellGap) + cell.ht = float64(len(cell.list)) * lineHt + if cell.ht > maxHt { + maxHt = cell.ht + } + cellList[col] = cell + } + + // Cell render loop + x := marginH + for colJ := 0; colJ < colCount; colJ++ { + //pdf.Rect(x, y, colWd, maxHt+cellGap+cellGap, "D") + cell = cellList[colJ] + cellY := y + cellGap + (maxHt-cell.ht)/2 + for splitJ := 0; splitJ < len(cell.list); splitJ++ { + p.Fpdf.SetXY(x+cellGap, cellY) + p.Fpdf.CellFormat(colWd-cellGap-cellGap, lineHt, string(cell.list[splitJ]), "", 0, + "C", false, 0, "") + cellY += lineHt + } + x += colWd + } + y += maxHt + cellGap + cellGap + + y = p.addNextPages(y, headers) + } +} + +func (p cpdf) renderHeader() { + p.Fpdf.SetFont("Alice-Regular", "", 24) + p.Fpdf.Text(marginH*3, 20, "Header of statements") + p.Fpdf.ImageOptions( + imgFolder+"mono-logo.jpg", + 170, 8, + 20, 20, + false, + gofpdf.ImageOptions{ImageType: "JPG", ReadDpi: true}, + 0, + "", + ) + +} + +func (p cpdf) renderTableHeaders(headers []string) { + p.Fpdf.SetFont("Alice-Regular", "", 9) + p.Fpdf.SetTextColor(224, 224, 224) + p.Fpdf.SetFillColor(45, 45, 45) + // Fill headers to cells + for colJ := 0; colJ < colCount; colJ++ { + p.Fpdf.CellFormat(colWd, 10, headers[colJ], "1", 0, "CM", true, 0, "") + } + p.Fpdf.Ln(-1) +} + +func (p cpdf) addNextPages(y float64, headers []string) float64 { + _, ht, _ := p.Fpdf.PageSize(p.Fpdf.PageNo()) + if y+cell.ht*lineHt > ht { + p.Fpdf.SetMargins(marginH, 15, marginH) + p.Fpdf.AddPage() + y = p.Fpdf.GetY() + p.Fpdf.SetFont("Alice-Regular", "", 9) + + p.renderTableHeaders(headers) + + return p.Fpdf.GetY() + } + return y +} diff --git a/statementsPdf/img/mono-logo.jpg b/statementsPdf/img/mono-logo.jpg new file mode 100644 index 0000000..2843b1f Binary files /dev/null and b/statementsPdf/img/mono-logo.jpg differ diff --git a/statementsPdf/prettifyStatements.go b/statementsPdf/prettifyStatements.go new file mode 100644 index 0000000..5137f11 --- /dev/null +++ b/statementsPdf/prettifyStatements.go @@ -0,0 +1,56 @@ +package statementsPdf + +import ( + "errors" + "strconv" + "time" +) + +const ( + dateColumn = iota + descriptionColumn + amountColumn + balanceColumn +) +// TODO: flexible headers value +var headers = []string{"Time", "Description", "Amount", "Balance"} + +func convertCoins(coins string) string { + amountCoins, _ := strconv.ParseFloat(coins, 64) + amount := amountCoins / 100 + return strconv.FormatFloat(amount, 'f', 2, 64) +} + +func convertTimestamp(timestamp string) string { + timestampInt, _ := strconv.ParseInt(timestamp, 10, 64) + timeUnix := time.Unix(timestampInt, 0) + return timeUnix.Format("2006-01-02 15:04:05") +} + +func prettyCsvArr(statements [][]string) ([][]string, error) { + statements, err := trimCsv(statements) + if err != nil { + return nil, err + } + for row := 0; row < len(statements); row++ { + statements[row][dateColumn] = convertTimestamp(statements[row][dateColumn]) + statements[row][amountColumn] = convertCoins(statements[row][amountColumn]) + statements[row][balanceColumn] = convertCoins(statements[row][balanceColumn]) + } + return statements, nil +} + +func trimCsv(statements [][]string) ([][]string, error) { + var trimmedStatements [][]string + var newRow []string + for row := 0; row < len(statements); row++ { + if len(statements[row]) != 11 { + return nil, errors.New("Count of column is incorrect ") + } + newRow := append(newRow, statements[row][1:3]...) + newRow = append(newRow, statements[row][5:6]...) + newRow = append(newRow, statements[row][10:11]...) + trimmedStatements = append(trimmedStatements, newRow) + } + return trimmedStatements, nil +} diff --git a/statementsPdf/prettifyStatements_test.go b/statementsPdf/prettifyStatements_test.go new file mode 100644 index 0000000..573c9ca --- /dev/null +++ b/statementsPdf/prettifyStatements_test.go @@ -0,0 +1,95 @@ +package statementsPdf + +import ( + "errors" + "testing" +) + +func TestConvertCoins(t *testing.T) { + var actual string + + tests := map[string]string{ + "1": "0.01", + "99": "0.99", + "110": "1.10", + "200": "2.00", + "2500": "25.00", + "894561": "8945.61", + "-500": "-5.00", + } + + for got, exp := range tests { + actual = convertCoins(got) + if actual != exp { + t.Errorf("TestConvertCoins failed. Expected %v, Got %v \n", exp, actual) + } + } +} + +func TestConvertTimestamp(t *testing.T) { + var actual string + + tests := map[string]string{ + "1573754138": "2019-11-14 19:55:38", + "946770296": "2000-01-02 01:44:56", + "0": "1970-01-01 03:00:00", + "3502914296": "2081-01-01 01:44:56", + } + + for got, exp := range tests { + actual = convertTimestamp(got) + if actual != exp { + t.Errorf("TestConvertTimestamp failed. Expected %v, Got %v \n", exp, actual) + } + } +} + +func TestTrimCsv(t *testing.T) { + testDataPositive := [][]string{ + {"qweqweqwe", "1569903114", "Нарахування відсотків за вересень", "4829", "true", "22", "22", "980", "0", "0", "1930"}, + {"qwesdfqwe", "1569903114", "CARD2CARD UAMAB", "4829", "true", "5066", "5066", "980", "0", "0", "193550"}, + } + testDataNegative := [][]string{ + {"qweqweqwe", "1569903114", "Нарахування відсотків за вересень", "4829", "true", "22", "22", "980", "0", "0", "1930"}, + {"qwesdfqwe", "1569903114", "CARD2CARD UAMAB", "4829", "true", "5066", "5066", "980", "0", "0", "193550", "193550"}, + } + + expected := [][]string{ + {"1569903114", "Нарахування відсотків за вересень", "22", "1930"}, + {"1569903114", "CARD2CARD UAMAB", "5066", "193550"}, + } + actual, _ := trimCsv(testDataPositive) + for row := 0; row < len(expected); row++ { + for col := 0; col < len(actual[row]); col++ { + if actual[row][col] != expected[row][col] { + t.Errorf("TestTrimCsv failed. Expected %v, Got %v \n", expected[row], actual[row]) + } + } + } + _, err := trimCsv(testDataNegative) + expErr := errors.New("Count of column is incorrect ") + if err.Error() != expErr.Error() { + t.Errorf("TestTrimCsv failed. Expected error %v, Got %v \n", err, expErr) + } +} + +func TestPrettyCsvArr(t *testing.T) { + testData := [][]string{ + {"qweqweqwe", "1569903114", "Нарахування відсотків за вересень", "4829", "true", "22", "22", "980", "0", "0", "1930"}, + {"qwesdfqwe", "1569903114", "CARD2CARD UAMAB", "4829", "true", "5066", "5066", "980", "0", "0", "193550"}, + } + + expected := [][]string{ + {"2019-10-01 07:11:54", "Нарахування відсотків за вересень", "0.22", "19.30"}, + {"2019-10-01 07:11:54", "CARD2CARD UAMAB", "50.66", "1935.50"}, + } + + actual, _ := prettyCsvArr(testData) + for row := 0; row < len(expected); row++ { + for col := 0; col < len(actual[row]); col++ { + if actual[row][col] != expected[row][col] { + t.Errorf("TestPrettyCsvArr failed. Expected %v, Got %v \n", expected[row], actual[row]) + } + } + } +} diff --git a/statementsPdf/testPdf/statement_test_expected.pdf b/statementsPdf/testPdf/statement_test_expected.pdf new file mode 100644 index 0000000..af1f0c4 Binary files /dev/null and b/statementsPdf/testPdf/statement_test_expected.pdf differ diff --git a/statementsPdf/testPdf/statement_test_expected_pagination.pdf b/statementsPdf/testPdf/statement_test_expected_pagination.pdf new file mode 100644 index 0000000..87d202c Binary files /dev/null and b/statementsPdf/testPdf/statement_test_expected_pagination.pdf differ