Implement HTML writer #33
No files matched your search
@@ -115,10 +115,7 @@ func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create localizer: %w", err)
|
return fmt.Errorf("create localizer: %w", err)
|
||||||
}
|
}
|
||||||
// RenderHTML never writes to the printer's output writer, so
|
return htmlwriter.NewWriter(stdout, loc, *lang).Render(writer)
|
||||||
// io.Discard is safe here.
|
|
||||||
printer := table.NewPrinter(io.Discard, loc)
|
|
||||||
return htmlwriter.NewWriter(stdout, printer, loc, *lang).Render(writer)
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported format %q: must be table, csv or html", *format)
|
return fmt.Errorf("unsupported format %q: must be table, csv or html", *format)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,6 +159,9 @@ func TestRunWithIO_HTMLFormat(t *testing.T) {
|
|||||||
t.Errorf("expected HTML output to start with <!DOCTYPE html>; got prefix: %q",
|
t.Errorf("expected HTML output to start with <!DOCTYPE html>; got prefix: %q",
|
||||||
got[:min(len(got), 64)])
|
got[:min(len(got), 64)])
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(got, "<h1>Tax report</h1>") {
|
||||||
|
t.Errorf("expected output to contain the translated report title")
|
||||||
|
}
|
||||||
if !strings.Contains(got, "table-responsive") {
|
if !strings.Contains(got, "table-responsive") {
|
||||||
t.Errorf("expected output to contain Bootstrap table-responsive wrapper")
|
t.Errorf("expected output to contain Bootstrap table-responsive wrapper")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package html
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jedib0t/go-pretty/v6/table"
|
||||||
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
|
"github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef"
|
||||||
|
)
|
||||||
|
|
||||||
|
// htmlCSSClass is the CSS class list put on the rendered report table.
|
||||||
|
const htmlCSSClass = "table table-striped table-hover table-bordered"
|
||||||
|
|
||||||
|
// widthIndex is the weight of the auto-index (row number) column; the other
|
||||||
|
// column weights come from tabledef's width budgets.
|
||||||
|
const widthIndex = 6
|
||||||
|
|
||||||
|
// htmlColumnWeights lists one weight per rendered column, starting with the
|
||||||
|
// auto-index column, mirroring the column order of tabledef.Build so the
|
||||||
|
// HTML table keeps columns in the same strict proportion to each other as
|
||||||
|
// the terminal layout.
|
||||||
|
var htmlColumnWeights = []int{
|
||||||
|
widthIndex,
|
||||||
|
tabledef.WidthCountry, tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthEuros,
|
||||||
|
tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthEuros,
|
||||||
|
tabledef.WidthEuros, tabledef.WidthEuros, tabledef.WidthCountry,
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderTableFragment renders aw as the Bootstrap-styled HTML <table>
|
||||||
|
// fragment embedded in the document. The table gets a fixed layout with
|
||||||
|
// strictly proportional column widths, so the browser — not this package —
|
||||||
|
// decides when a header or cell wraps: on wide screens everything fits on
|
||||||
|
// one line, on narrow ones it wraps automatically.
|
||||||
|
func (hw *Writer) renderTableFragment(aw *internal.AggregatorWriter) string {
|
||||||
|
t := tabledef.Build(hw.translator, aw)
|
||||||
|
t.Style().HTML = table.HTMLOptions{
|
||||||
|
CSSClass: htmlCSSClass,
|
||||||
|
EscapeText: true,
|
||||||
|
Newline: "<br/>",
|
||||||
|
EmptyColumn: " ",
|
||||||
|
}
|
||||||
|
html := t.RenderHTML()
|
||||||
|
|
||||||
|
// go-pretty cannot emit table-level attributes or a <colgroup>, so splice
|
||||||
|
// them into its output: the fixed layout makes the colgroup's relative
|
||||||
|
// widths authoritative instead of content-driven column sizing.
|
||||||
|
openTag := `<table class="` + htmlCSSClass + `">`
|
||||||
|
fixedTag := `<table class="` + htmlCSSClass + `" style="table-layout: fixed">` + "\n" + colgroupHTML()
|
||||||
|
return strings.Replace(html, openTag, fixedTag, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// colgroupHTML returns a <colgroup> with one <col> per rendered column, each
|
||||||
|
// sized strictly in proportion to its weight. With the fixed table layout
|
||||||
|
// applied in renderTableFragment these widths are authoritative: wide
|
||||||
|
// viewports fit headers on a single line, narrow ones wrap automatically.
|
||||||
|
func colgroupHTML() string {
|
||||||
|
var total int
|
||||||
|
for _, w := range htmlColumnWeights {
|
||||||
|
total += w
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("<colgroup>")
|
||||||
|
for _, w := range htmlColumnWeights {
|
||||||
|
fmt.Fprintf(&sb, `<col style="width: %.2f%%">`, float64(w)/float64(total)*100)
|
||||||
|
}
|
||||||
|
sb.WriteString("</colgroup>")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package html_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nmoniz/any2anexoj/internal/writing"
|
||||||
|
htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWriter_RenderTableFragment renders a Portuguese document and asserts
|
||||||
|
// the embedded report table fragment carries the shared structure: the
|
||||||
|
// translated headers, the totals footer, and the strictly proportional
|
||||||
|
// colgroup that keeps HTML columns in the same relation to each other as
|
||||||
|
// the terminal layout.
|
||||||
|
func TestWriter_RenderTableFragment(t *testing.T) {
|
||||||
|
aw := buildTestAggregator(t)
|
||||||
|
|
||||||
|
localizer, err := writing.NewLocalizer("pt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create localizer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
hw := htmlwriter.NewWriter(&buf, localizer, "pt")
|
||||||
|
|
||||||
|
if err := hw.Render(aw); err != nil {
|
||||||
|
t.Fatalf("Render returned an error: %v", err)
|
||||||
|
}
|
||||||
|
got := buf.String()
|
||||||
|
|
||||||
|
wantSubstrings := []string{
|
||||||
|
`<table class="table table-striped table-hover table-bordered" style="table-layout: fixed">`,
|
||||||
|
`<colgroup>`,
|
||||||
|
`</colgroup>`,
|
||||||
|
`<col style="width: 3.03%">`,
|
||||||
|
`<col style="width: 12.12%">`,
|
||||||
|
`</table>`,
|
||||||
|
`<thead>`,
|
||||||
|
`</thead>`,
|
||||||
|
`<tbody>`,
|
||||||
|
`</tbody>`,
|
||||||
|
`<tfoot>`,
|
||||||
|
`</tfoot>`,
|
||||||
|
"Realização",
|
||||||
|
"Aquisição",
|
||||||
|
"Soma",
|
||||||
|
}
|
||||||
|
for _, s := range wantSubstrings {
|
||||||
|
if !strings.Contains(got, s) {
|
||||||
|
t.Errorf("rendered document missing expected substring %q\nGot:\n%s", s, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fragment must be embedded raw, not HTML-escaped.
|
||||||
|
if strings.Contains(got, "<table") {
|
||||||
|
t.Errorf("table fragment appears to be HTML-escaped; expected raw <table> markup\nGot:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// Package html renders an internal.AggregatorWriter as a complete,
|
// Package html renders an internal.AggregatorWriter as a complete,
|
||||||
// standalone HTML document built around the same table fragment produced by
|
// standalone HTML document: the report table fragment (built from the
|
||||||
// table.Printer.RenderHTML. The document includes the Bootstrap 5.3
|
// shared table definition in internal/writing/internal/tabledef, so it can
|
||||||
// stylesheet via CDN and embeds the raw table fragment without HTML-escaping
|
// never drift from the terminal layout) embedded in a Bootstrap-styled page
|
||||||
// it so the result opens directly in a browser.
|
// with a translated title and generation timestamp.
|
||||||
package html
|
package html
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,15 +13,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/nmoniz/any2anexoj/internal"
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
"github.com/nmoniz/any2anexoj/internal/writing/table"
|
"github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef"
|
||||||
)
|
)
|
||||||
|
|
||||||
// translator is the contract the HTML writer relies on for localized
|
|
||||||
// document text such as the page title. *writing.Localizer satisfies it.
|
|
||||||
type translator interface {
|
|
||||||
Translate(key string, count int, values map[string]any) string
|
|
||||||
}
|
|
||||||
|
|
||||||
// bootstrapCSSURL is the jsDelivr CDN URL for Bootstrap 5.3.3.
|
// bootstrapCSSURL is the jsDelivr CDN URL for Bootstrap 5.3.3.
|
||||||
const bootstrapCSSURL = "https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"
|
const bootstrapCSSURL = "https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"
|
||||||
|
|
||||||
@@ -31,20 +25,17 @@ const bootstrapIntegrity = "sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bR
|
|||||||
// Writer writes a complete HTML document for an AggregatorWriter.
|
// Writer writes a complete HTML document for an AggregatorWriter.
|
||||||
type Writer struct {
|
type Writer struct {
|
||||||
output io.Writer
|
output io.Writer
|
||||||
printer *table.Printer
|
translator tabledef.Translator
|
||||||
translator translator
|
|
||||||
lang string
|
lang string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWriter builds a Writer that writes a complete HTML document to
|
// NewWriter builds a Writer that writes a complete HTML document to w. The
|
||||||
// w. The printer is used purely as a source for the raw <table> fragment via
|
// report table is rendered from the shared table definition via tr, and the
|
||||||
// RenderHTML; this writer never invokes Render or any method that writes to
|
// document title is looked up from the "report_title" translation via tr,
|
||||||
// printer's own io.Writer. The document title is looked up from the
|
// so callers never pass a pre-baked title.
|
||||||
// "report_title" translation via tr, so callers never pass a pre-baked title.
|
func NewWriter(w io.Writer, tr tabledef.Translator, lang string) *Writer {
|
||||||
func NewWriter(w io.Writer, printer *table.Printer, tr translator, lang string) *Writer {
|
|
||||||
return &Writer{
|
return &Writer{
|
||||||
output: w,
|
output: w,
|
||||||
printer: printer,
|
|
||||||
translator: tr,
|
translator: tr,
|
||||||
lang: lang,
|
lang: lang,
|
||||||
}
|
}
|
||||||
@@ -63,10 +54,7 @@ var documentTemplate = template.Must(template.New("html").Parse(documentTemplate
|
|||||||
// fragment from aw so the rendered <tr> count reflects aw's current items and
|
// fragment from aw so the rendered <tr> count reflects aw's current items and
|
||||||
// no rows are duplicated across invocations.
|
// no rows are duplicated across invocations.
|
||||||
func (hw *Writer) Render(aw *internal.AggregatorWriter) error {
|
func (hw *Writer) Render(aw *internal.AggregatorWriter) error {
|
||||||
if hw.printer == nil {
|
fragment := hw.renderTableFragment(aw)
|
||||||
return fmt.Errorf("html: nil printer")
|
|
||||||
}
|
|
||||||
fragment := hw.printer.RenderHTML(aw)
|
|
||||||
title := hw.translator.Translate("report_title", 1, nil)
|
title := hw.translator.Translate("report_title", 1, nil)
|
||||||
data := struct {
|
data := struct {
|
||||||
Lang string
|
Lang string
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html"
|
|
||||||
"github.com/nmoniz/any2anexoj/internal/writing/table"
|
|
||||||
"github.com/nmoniz/any2anexoj/internal/writing"
|
|
||||||
"github.com/nmoniz/any2anexoj/internal"
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
|
"github.com/nmoniz/any2anexoj/internal/writing"
|
||||||
|
htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,9 +63,7 @@ func TestWriter_Render(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
// RenderHTML never writes to the printer's output, so Discard is safe.
|
hw := htmlwriter.NewWriter(&buf, loc, "en")
|
||||||
pp := table.NewPrinter(&buf, loc)
|
|
||||||
hw := htmlwriter.NewWriter(&buf, pp, loc, "en")
|
|
||||||
|
|
||||||
if err := hw.Render(buildTestAggregator(t)); err != nil {
|
if err := hw.Render(buildTestAggregator(t)); err != nil {
|
||||||
t.Fatalf("Render returned an error: %v", err)
|
t.Fatalf("Render returned an error: %v", err)
|
||||||
@@ -86,7 +83,8 @@ func TestWriter_Render(t *testing.T) {
|
|||||||
`<main class="container-fluid py-4">`,
|
`<main class="container-fluid py-4">`,
|
||||||
`<h1>Tax report</h1>`,
|
`<h1>Tax report</h1>`,
|
||||||
`<div class="table-responsive">`,
|
`<div class="table-responsive">`,
|
||||||
`<table class="table table-striped table-hover table-bordered">`,
|
`<table class="table table-striped table-hover table-bordered" style="table-layout: fixed">`,
|
||||||
|
`<colgroup>`,
|
||||||
`</table>`,
|
`</table>`,
|
||||||
`<tfoot>`,
|
`<tfoot>`,
|
||||||
`</tfoot>`,
|
`</tfoot>`,
|
||||||
@@ -133,8 +131,7 @@ func TestWriter_NoRowDuplication(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
pp := table.NewPrinter(&buf, loc)
|
hw := htmlwriter.NewWriter(&buf, loc, "en")
|
||||||
hw := htmlwriter.NewWriter(&buf, pp, loc, "en")
|
|
||||||
|
|
||||||
aw := buildTestAggregator(t)
|
aw := buildTestAggregator(t)
|
||||||
|
|
||||||
@@ -167,8 +164,7 @@ func TestWriter_LangAttribute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
pp := table.NewPrinter(&buf, loc)
|
hw := htmlwriter.NewWriter(&buf, loc, "pt")
|
||||||
hw := htmlwriter.NewWriter(&buf, pp, loc, "pt")
|
|
||||||
|
|
||||||
if err := hw.Render(buildTestAggregator(t)); err != nil {
|
if err := hw.Render(buildTestAggregator(t)); err != nil {
|
||||||
t.Fatalf("Render returned an error: %v", err)
|
t.Fatalf("Render returned an error: %v", err)
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Package tabledef holds the single definition of the Anexo J report table
|
||||||
|
// — column configuration, translated headers, data rows and the totals
|
||||||
|
// footer — shared by every renderer under internal/writing. Living in a
|
||||||
|
// writing-internal package lets the table and html packages project the
|
||||||
|
// same structure into their own formats without any of them exposing
|
||||||
|
// go-pretty types in their public API.
|
||||||
|
package tabledef
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/biter777/countries"
|
||||||
|
"github.com/jedib0t/go-pretty/v6/table"
|
||||||
|
"github.com/jedib0t/go-pretty/v6/text"
|
||||||
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Translator is the contract the report table renderers rely on for
|
||||||
|
// human-readable column labels. *writing.Localizer satisfies it.
|
||||||
|
type Translator interface {
|
||||||
|
Translate(key string, count int, values map[string]any) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column width budgets enforced by the terminal renderer. The html package
|
||||||
|
// derives its proportional column widths from the same values, keeping both
|
||||||
|
// renderings in the same strict proportion to each other.
|
||||||
|
const (
|
||||||
|
// WidthCountry is the width budget of a country column ("code - name").
|
||||||
|
WidthCountry = 24
|
||||||
|
// WidthOther is the width budget of a plain column (code, year, ...).
|
||||||
|
WidthOther = 12
|
||||||
|
// WidthEuros is the width budget of a money column ("123.45 €").
|
||||||
|
WidthEuros = 15
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build constructs a fully configured go-pretty table.Writer from the items
|
||||||
|
// in aw: one column per report field, translated headers, data rows and the
|
||||||
|
// totals footer. Each call returns a fresh table so callers can render the
|
||||||
|
// same data multiple times without duplicating rows, and the returned table
|
||||||
|
// is not wired to any output.
|
||||||
|
//
|
||||||
|
// Change columns, headers or footers here so every renderer stays in sync.
|
||||||
|
func Build(tr Translator, aw *internal.AggregatorWriter) table.Writer {
|
||||||
|
tw := table.NewWriter()
|
||||||
|
tw.SetAutoIndex(true)
|
||||||
|
tw.SetStyle(table.StyleLight)
|
||||||
|
tw.SetColumnConfigs([]table.ColumnConfig{
|
||||||
|
colCountry(1),
|
||||||
|
colOther(2),
|
||||||
|
colOther(3),
|
||||||
|
colOther(4),
|
||||||
|
colOther(5),
|
||||||
|
colEuros(6),
|
||||||
|
colOther(7),
|
||||||
|
colOther(8),
|
||||||
|
colOther(9),
|
||||||
|
colEuros(10),
|
||||||
|
colEuros(11),
|
||||||
|
colEuros(12),
|
||||||
|
colCountry(13),
|
||||||
|
})
|
||||||
|
|
||||||
|
realizationTxt := tr.Translate("realization", 1, nil)
|
||||||
|
acquisitionTxt := tr.Translate("acquisition", 1, nil)
|
||||||
|
yearTxt := tr.Translate("year", 1, nil)
|
||||||
|
monthTxt := tr.Translate("month", 1, nil)
|
||||||
|
dayTxt := tr.Translate("day", 1, nil)
|
||||||
|
valorTxt := tr.Translate("value", 1, nil)
|
||||||
|
|
||||||
|
tw.AppendHeader(table.Row{"", "", realizationTxt, realizationTxt, realizationTxt, realizationTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, "", "", ""}, table.RowConfig{AutoMerge: true})
|
||||||
|
tw.AppendHeader(table.Row{
|
||||||
|
tr.Translate("source_country", 1, nil), tr.Translate("code", 1, nil),
|
||||||
|
yearTxt, monthTxt, dayTxt, valorTxt,
|
||||||
|
yearTxt, monthTxt, dayTxt, valorTxt,
|
||||||
|
tr.Translate("expenses", 2, nil), tr.Translate("foreign_tax_paid", 1, nil), tr.Translate("counter_country", 1, nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
for ri := range aw.Iter() {
|
||||||
|
tw.AppendRow(table.Row{
|
||||||
|
ri.AssetCountry, ri.Nature,
|
||||||
|
ri.SellTimestamp.Year(), int(ri.SellTimestamp.Month()), ri.SellTimestamp.Day(), ri.SellValue.StringFixed(2),
|
||||||
|
ri.BuyTimestamp.Year(), int(ri.BuyTimestamp.Month()), ri.BuyTimestamp.Day(), ri.BuyValue.StringFixed(2),
|
||||||
|
ri.Fees.StringFixed(2), ri.Taxes.StringFixed(2), ri.BrokerCountry,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sumTxt := tr.Translate("sum", 1, nil)
|
||||||
|
// The footer lists one cell per column (13) so it never relies on
|
||||||
|
// go-pretty's implicit short-row padding; the trailing empty cell is the
|
||||||
|
// counter-country column, which has no total. Totals are formatted with
|
||||||
|
// StringFixed(2) so they match the two-decimal data rows.
|
||||||
|
tw.AppendFooter(table.Row{sumTxt, sumTxt, sumTxt, sumTxt, sumTxt, aw.TotalEarned().StringFixed(2), "", "", "", aw.TotalSpent().StringFixed(2), aw.TotalFees().StringFixed(2), aw.TotalTaxes().StringFixed(2), ""}, table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight})
|
||||||
|
|
||||||
|
return tw
|
||||||
|
}
|
||||||
|
|
||||||
|
func colEuros(n int) table.ColumnConfig {
|
||||||
|
return table.ColumnConfig{
|
||||||
|
Number: n,
|
||||||
|
Align: text.AlignRight,
|
||||||
|
AlignFooter: text.AlignRight,
|
||||||
|
AlignHeader: text.AlignRight,
|
||||||
|
WidthMin: WidthOther,
|
||||||
|
WidthMax: WidthEuros,
|
||||||
|
WidthMaxEnforcer: truncateHeader,
|
||||||
|
Transformer: func(val any) string {
|
||||||
|
return fmt.Sprintf("%v €", val)
|
||||||
|
},
|
||||||
|
TransformerFooter: func(val any) string {
|
||||||
|
return fmt.Sprintf("%v €", val)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func colOther(n int) table.ColumnConfig {
|
||||||
|
return table.ColumnConfig{
|
||||||
|
Number: n,
|
||||||
|
Align: text.AlignLeft,
|
||||||
|
AlignFooter: text.AlignLeft,
|
||||||
|
AlignHeader: text.AlignLeft,
|
||||||
|
WidthMax: WidthOther,
|
||||||
|
WidthMaxEnforcer: truncateHeader,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func colCountry(n int) table.ColumnConfig {
|
||||||
|
return table.ColumnConfig{
|
||||||
|
Number: n,
|
||||||
|
Align: text.AlignLeft,
|
||||||
|
AlignFooter: text.AlignLeft,
|
||||||
|
AlignHeader: text.AlignLeft,
|
||||||
|
WidthMax: WidthCountry,
|
||||||
|
WidthMaxEnforcer: text.Trim,
|
||||||
|
Transformer: func(val any) string {
|
||||||
|
countryCode := val.(int64)
|
||||||
|
return fmt.Sprintf("%v - %s", val, countries.ByNumeric(int(countryCode)).Info().Name)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateHeader enforces a column's WidthMax on terminal output by cutting
|
||||||
|
// the text exactly at the width limit — mid-word if that is where it lands —
|
||||||
|
// and marking the cut with an ellipsis: keeping as many leading runes
|
||||||
|
// readable beats a tidy word boundary. All indexing is rune-based so
|
||||||
|
// multibyte labels (e.g. Portuguese) truncate correctly.
|
||||||
|
func truncateHeader(val string, width int) string {
|
||||||
|
runes := []rune(val)
|
||||||
|
if len(runes) <= width {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
// leave room for the ellipsis
|
||||||
|
limit := max(width-1, 1)
|
||||||
|
return string(runes[:limit]) + "…"
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package tabledef
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestTruncateHeader(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
val string
|
||||||
|
width int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "fits unchanged", val: "TAX PAID ABROAD", width: 15, want: "TAX PAID ABROAD"},
|
||||||
|
{name: "cut mid-word keeps the most runes", val: "EXPENSES AND CHARGES", width: 15, want: "EXPENSES AND C…"},
|
||||||
|
{name: "single overlong word is hard cut", val: "ESTRANGEIRO", width: 8, want: "ESTRANG…"},
|
||||||
|
{name: "cut ignores word boundaries", val: "IMPOSTO PAGO NO ESTRANGEIRO", width: 15, want: "IMPOSTO PAGO N…"},
|
||||||
|
{name: "multibyte runes count as one", val: "PAÍS DA CONTRAPARTE", width: 12, want: "PAÍS DA CON…"},
|
||||||
|
{name: "narrow width", val: "ABCD", width: 2, want: "A…"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := truncateHeader(tt.val, tt.width); got != tt.want {
|
||||||
|
t.Errorf("%s: truncateHeader(%q, %d) = %q, want %q", tt.name, tt.val, tt.width, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,155 +1,34 @@
|
|||||||
// Package table renders an internal.AggregatorWriter as the human-readable
|
// Package table renders an internal.AggregatorWriter as the human-readable
|
||||||
// Anexo J report table, for the terminal (Render) and for embedding in HTML
|
// Anexo J report table for the terminal. The shared table definition it (and
|
||||||
// documents (RenderHTML).
|
// the html package) renders from lives in internal/writing/internal/tabledef.
|
||||||
package table
|
package table
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"github.com/biter777/countries"
|
|
||||||
"github.com/jedib0t/go-pretty/v6/table"
|
|
||||||
"github.com/jedib0t/go-pretty/v6/text"
|
|
||||||
"github.com/nmoniz/any2anexoj/internal"
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
|
"github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef"
|
||||||
)
|
)
|
||||||
|
|
||||||
// translator is the contract the table printer relies on for human-readable
|
// Printer writes a simple, human readable, table to the provided io.Writer
|
||||||
// column labels. *Localizer satisfies it.
|
// for the report items in an AggregatorWriter. Each Render call builds a
|
||||||
type translator interface {
|
// fresh table from the aggregator, so invoking it repeatedly on the same
|
||||||
Translate(key string, count int, values map[string]any) string
|
// instance never duplicates rows.
|
||||||
}
|
|
||||||
|
|
||||||
// Printer writes a simple, human readable, table row to the provided io.Writer for each
|
|
||||||
// ReportItem received. Render and RenderHTML each build a fresh table.Writer so that
|
|
||||||
// invoking them (in any order or repeatedly) on the same instance never duplicates rows.
|
|
||||||
type Printer struct {
|
type Printer struct {
|
||||||
output io.Writer
|
output io.Writer
|
||||||
translator Translator
|
translator tabledef.Translator
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPrinter(w io.Writer, tr translator) *Printer {
|
func NewPrinter(w io.Writer, tr tabledef.Translator) *Printer {
|
||||||
return &Printer{
|
return &Printer{
|
||||||
output: w,
|
output: w,
|
||||||
translator: tr,
|
translator: tr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildTable constructs a fully configured table.Writer from the items in aw.
|
// Render writes the terminal table for aw to the configured output.
|
||||||
// Each call returns a new table.Writer so callers can render the same data
|
|
||||||
// multiple times without duplicating rows. The returned table is not wired to
|
|
||||||
// pp.output via an output mirror; callers that want the rendered output
|
|
||||||
// written to pp.output must set the mirror themselves before calling .Render().
|
|
||||||
func (pp *Printer) buildTable(aw *internal.AggregatorWriter) table.Writer {
|
|
||||||
tw := table.NewWriter()
|
|
||||||
tw.SetAutoIndex(true)
|
|
||||||
tw.SetStyle(table.StyleLight)
|
|
||||||
tw.SetColumnConfigs([]table.ColumnConfig{
|
|
||||||
colCountry(1),
|
|
||||||
colOther(2),
|
|
||||||
colOther(3),
|
|
||||||
colOther(4),
|
|
||||||
colOther(5),
|
|
||||||
colEuros(6),
|
|
||||||
colOther(7),
|
|
||||||
colOther(8),
|
|
||||||
colOther(9),
|
|
||||||
colEuros(10),
|
|
||||||
colEuros(11),
|
|
||||||
colEuros(12),
|
|
||||||
colCountry(13),
|
|
||||||
})
|
|
||||||
|
|
||||||
realizationTxt := pp.translator.Translate("realization", 1, nil)
|
|
||||||
acquisitionTxt := pp.translator.Translate("acquisition", 1, nil)
|
|
||||||
yearTxt := pp.translator.Translate("year", 1, nil)
|
|
||||||
monthTxt := pp.translator.Translate("month", 1, nil)
|
|
||||||
dayTxt := pp.translator.Translate("day", 1, nil)
|
|
||||||
valorTxt := pp.translator.Translate("value", 1, nil)
|
|
||||||
|
|
||||||
tw.AppendHeader(table.Row{"", "", realizationTxt, realizationTxt, realizationTxt, realizationTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, "", "", ""}, table.RowConfig{AutoMerge: true})
|
|
||||||
tw.AppendHeader(table.Row{
|
|
||||||
pp.translator.Translate("source_country", 1, nil), pp.translator.Translate("code", 1, nil),
|
|
||||||
yearTxt, monthTxt, dayTxt, valorTxt,
|
|
||||||
yearTxt, monthTxt, dayTxt, valorTxt,
|
|
||||||
pp.translator.Translate("expenses", 2, nil), pp.translator.Translate("foreign_tax_paid", 1, nil), pp.translator.Translate("counter_country", 1, nil),
|
|
||||||
})
|
|
||||||
|
|
||||||
for ri := range aw.Iter() {
|
|
||||||
tw.AppendRow(table.Row{
|
|
||||||
ri.AssetCountry, ri.Nature,
|
|
||||||
ri.SellTimestamp.Year(), int(ri.SellTimestamp.Month()), ri.SellTimestamp.Day(), ri.SellValue.StringFixed(2),
|
|
||||||
ri.BuyTimestamp.Year(), int(ri.BuyTimestamp.Month()), ri.BuyTimestamp.Day(), ri.BuyValue.StringFixed(2),
|
|
||||||
ri.Fees.StringFixed(2), ri.Taxes.StringFixed(2), ri.BrokerCountry,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
sumTxt := pp.translator.Translate("sum", 1, nil)
|
|
||||||
// The footer lists one cell per column (13) so it never relies on
|
|
||||||
// go-pretty's implicit short-row padding; the trailing empty cell is the
|
|
||||||
// counter-country column, which has no total. Totals are formatted with
|
|
||||||
// StringFixed(2) so they match the two-decimal data rows.
|
|
||||||
tw.AppendFooter(table.Row{sumTxt, sumTxt, sumTxt, sumTxt, sumTxt, aw.TotalEarned().StringFixed(2), "", "", "", aw.TotalSpent().StringFixed(2), aw.TotalFees().StringFixed(2), aw.TotalTaxes().StringFixed(2), ""}, table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight})
|
|
||||||
|
|
||||||
return tw
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pp *Printer) Render(aw *internal.AggregatorWriter) {
|
func (pp *Printer) Render(aw *internal.AggregatorWriter) {
|
||||||
t := pp.buildTable(aw)
|
t := tabledef.Build(pp.translator, aw)
|
||||||
t.SetOutputMirror(pp.output)
|
t.SetOutputMirror(pp.output)
|
||||||
t.Render()
|
t.Render()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderHTML builds a fresh table from aw and renders it as an HTML string
|
|
||||||
// using go-pretty's HTMLOptions.
|
|
||||||
func (pp *Printer) RenderHTML(aw *internal.AggregatorWriter) string {
|
|
||||||
t := pp.buildTable(aw)
|
|
||||||
t.Style().HTML = table.HTMLOptions{
|
|
||||||
CSSClass: "table table-striped table-hover table-bordered",
|
|
||||||
EscapeText: true,
|
|
||||||
Newline: "<br/>",
|
|
||||||
EmptyColumn: " ",
|
|
||||||
}
|
|
||||||
return t.RenderHTML()
|
|
||||||
}
|
|
||||||
|
|
||||||
func colEuros(n int) table.ColumnConfig {
|
|
||||||
return table.ColumnConfig{
|
|
||||||
Number: n,
|
|
||||||
Align: text.AlignRight,
|
|
||||||
AlignFooter: text.AlignRight,
|
|
||||||
AlignHeader: text.AlignRight,
|
|
||||||
WidthMin: 12,
|
|
||||||
WidthMax: 15,
|
|
||||||
Transformer: func(val any) string {
|
|
||||||
return fmt.Sprintf("%v €", val)
|
|
||||||
},
|
|
||||||
TransformerFooter: func(val any) string {
|
|
||||||
return fmt.Sprintf("%v €", val)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func colOther(n int) table.ColumnConfig {
|
|
||||||
return table.ColumnConfig{
|
|
||||||
Number: n,
|
|
||||||
Align: text.AlignLeft,
|
|
||||||
AlignFooter: text.AlignLeft,
|
|
||||||
AlignHeader: text.AlignLeft,
|
|
||||||
WidthMax: 12,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func colCountry(n int) table.ColumnConfig {
|
|
||||||
return table.ColumnConfig{
|
|
||||||
Number: n,
|
|
||||||
Align: text.AlignLeft,
|
|
||||||
AlignFooter: text.AlignLeft,
|
|
||||||
AlignHeader: text.AlignLeft,
|
|
||||||
WidthMax: 24,
|
|
||||||
WidthMaxEnforcer: text.Trim,
|
|
||||||
Transformer: func(val any) string {
|
|
||||||
countryCode := val.(int64)
|
|
||||||
return fmt.Sprintf("%v - %s", val, countries.ByNumeric(int(countryCode)).Info().Name)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ package table_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -75,8 +74,7 @@ func TestPrinter_Render(t *testing.T) {
|
|||||||
// Expected output
|
// Expected output
|
||||||
want := `┌───┬────────────────────────────┬───────────────────────────────────┬───────────────────────────────────┬──────────────────────────────────────────────────────────┐
|
want := `┌───┬────────────────────────────┬───────────────────────────────────┬───────────────────────────────────┬──────────────────────────────────────────────────────────┐
|
||||||
│ │ │ REALIZATION │ ACQUISITION │ │
|
│ │ │ REALIZATION │ ACQUISITION │ │
|
||||||
│ │ SOURCE COUNTRY │ CODE │ YEAR │ MONTH │ DAY │ VALUE │ YEAR │ MONTH │ DAY │ VALUE │ EXPENSES AND CH │ TAX PAID ABROAD │ COUNTER COUNTRY │
|
│ │ SOURCE COUNTRY │ CODE │ YEAR │ MONTH │ DAY │ VALUE │ YEAR │ MONTH │ DAY │ VALUE │ EXPENSES AND C… │ TAX PAID ABROAD │ COUNTER COUNTRY │
|
||||||
│ │ │ │ │ │ │ │ │ │ │ │ ARGES │ │ │
|
|
||||||
├───┼─────────────────────┼──────┼──────┼───────┼─────┼──────────────┼──────┼───────┼─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤
|
├───┼─────────────────────┼──────┼──────┼───────┼─────┼──────────────┼──────┼───────┼─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤
|
||||||
│ 1 │ 840 - United States │ G01 │ 2023 │ 6 │ 20 │ 150.75 € │ 2023 │ 1 │ 15 │ 100.50 € │ 2.50 € │ 5.00 € │ 826 - United Kingdom │
|
│ 1 │ 840 - United States │ G01 │ 2023 │ 6 │ 20 │ 150.75 € │ 2023 │ 1 │ 15 │ 100.50 € │ 2.50 € │ 5.00 € │ 826 - United Kingdom │
|
||||||
│ 2 │ 840 - United States │ G20 │ 2023 │ 9 │ 5 │ 225.50 € │ 2023 │ 3 │ 10 │ 200.00 € │ 3.00 € │ 7.50 € │ 826 - United Kingdom │
|
│ 2 │ 840 - United States │ G20 │ 2023 │ 9 │ 5 │ 225.50 € │ 2023 │ 3 │ 10 │ 200.00 € │ 3.00 € │ 7.50 € │ 826 - United Kingdom │
|
||||||
@@ -90,71 +88,3 @@ func TestPrinter_Render(t *testing.T) {
|
|||||||
t.Errorf("Printer.Render() output doesn't match expected.\n\nGot:\n%s\n\nWant:\n%s", got, want)
|
t.Errorf("Printer.Render() output doesn't match expected.\n\nGot:\n%s\n\nWant:\n%s", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderHTML(t *testing.T) {
|
|
||||||
// Create test data
|
|
||||||
aw := internal.NewAggregatorWriter()
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
err := aw.Write(ctx, internal.ReportItem{
|
|
||||||
Symbol: "AAPL",
|
|
||||||
Nature: internal.NatureG01,
|
|
||||||
BrokerCountry: 826, // United Kingdom
|
|
||||||
AssetCountry: 840, // United States
|
|
||||||
BuyValue: decimal.NewFromFloat(100.50),
|
|
||||||
BuyTimestamp: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC),
|
|
||||||
SellValue: decimal.NewFromFloat(150.75),
|
|
||||||
SellTimestamp: time.Date(2023, 6, 20, 0, 0, 0, 0, time.UTC),
|
|
||||||
Fees: decimal.NewFromFloat(2.50),
|
|
||||||
Taxes: decimal.NewFromFloat(5.00),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to write report item: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create Portuguese localizer so the header text is in pt
|
|
||||||
localizer, err := writing.NewLocalizer("pt")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create localizer: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output buffer used only to satisfy the constructor contract; RenderHTML
|
|
||||||
// must not write to it.
|
|
||||||
var buf bytes.Buffer
|
|
||||||
pp := table.NewPrinter(&buf, localizer)
|
|
||||||
|
|
||||||
html := pp.RenderHTML(aw)
|
|
||||||
|
|
||||||
// RenderHTML must not side-write to pp.output.
|
|
||||||
if buf.Len() != 0 {
|
|
||||||
t.Errorf("RenderHTML should not write to pp.output, but got %d bytes: %q", buf.Len(), buf.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Structural assertions.
|
|
||||||
wantSubstrings := []string{
|
|
||||||
`<table class="table table-striped table-hover table-bordered">`,
|
|
||||||
`</table>`,
|
|
||||||
`<thead>`,
|
|
||||||
`</thead>`,
|
|
||||||
`<tbody>`,
|
|
||||||
`</tbody>`,
|
|
||||||
`<tfoot>`,
|
|
||||||
`</tfoot>`,
|
|
||||||
"Realização",
|
|
||||||
"Aquisição",
|
|
||||||
"Soma",
|
|
||||||
}
|
|
||||||
for _, s := range wantSubstrings {
|
|
||||||
if !strings.Contains(html, s) {
|
|
||||||
t.Errorf("RenderHTML output missing expected substring %q\nGot:\n%s", s, html)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calling RenderHTML twice on the same instance must not duplicate rows.
|
|
||||||
html2 := pp.RenderHTML(aw)
|
|
||||||
if strings.Count(html2, "<tr") != strings.Count(html, "<tr") {
|
|
||||||
t.Errorf("expected second RenderHTML call to produce the same number of rows as the first\nFirst (%d <tr>):\n%s\nSecond (%d <tr>):\n%s",
|
|
||||||
strings.Count(html, "<tr"), html,
|
|
||||||
strings.Count(html2, "<tr"), html2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user