diff --git a/cmd/any2anexoj-cli/main.go b/cmd/any2anexoj-cli/main.go
index 11f50f3..b11a728 100644
--- a/cmd/any2anexoj-cli/main.go
+++ b/cmd/any2anexoj-cli/main.go
@@ -115,10 +115,7 @@ func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
if err != nil {
return fmt.Errorf("create localizer: %w", err)
}
- // RenderHTML never writes to the printer's output writer, so
- // io.Discard is safe here.
- printer := table.NewPrinter(io.Discard, loc)
- return htmlwriter.NewWriter(stdout, printer, loc, *lang).Render(writer)
+ return htmlwriter.NewWriter(stdout, loc, *lang).Render(writer)
default:
return fmt.Errorf("unsupported format %q: must be table, csv or html", *format)
}
diff --git a/cmd/any2anexoj-cli/main_test.go b/cmd/any2anexoj-cli/main_test.go
index 77a0c21..d71cb85 100644
--- a/cmd/any2anexoj-cli/main_test.go
+++ b/cmd/any2anexoj-cli/main_test.go
@@ -159,6 +159,9 @@ func TestRunWithIO_HTMLFormat(t *testing.T) {
t.Errorf("expected HTML output to start with ; got prefix: %q",
got[:min(len(got), 64)])
}
+ if !strings.Contains(got, "
Tax report
") {
+ t.Errorf("expected output to contain the translated report title")
+ }
if !strings.Contains(got, "table-responsive") {
t.Errorf("expected output to contain Bootstrap table-responsive wrapper")
}
diff --git a/internal/writing/html/fragment.go b/internal/writing/html/fragment.go
new file mode 100644
index 0000000..b4daf1a
--- /dev/null
+++ b/internal/writing/html/fragment.go
@@ -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
+// 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: "
",
+ EmptyColumn: " ",
+ }
+ html := t.RenderHTML()
+
+ // go-pretty cannot emit table-level attributes or a , so splice
+ // them into its output: the fixed layout makes the colgroup's relative
+ // widths authoritative instead of content-driven column sizing.
+ openTag := ``
+ fixedTag := `` + "\n" + colgroupHTML()
+ return strings.Replace(html, openTag, fixedTag, 1)
+}
+
+// colgroupHTML returns a with one 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("")
+ for _, w := range htmlColumnWeights {
+ fmt.Fprintf(&sb, ``, float64(w)/float64(total)*100)
+ }
+ sb.WriteString("")
+ return sb.String()
+}
diff --git a/internal/writing/html/fragment_test.go b/internal/writing/html/fragment_test.go
new file mode 100644
index 0000000..404de45
--- /dev/null
+++ b/internal/writing/html/fragment_test.go
@@ -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{
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ `
`,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ "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 markup\nGot:\n%s", got)
+ }
+}
diff --git a/internal/writing/html/writer.go b/internal/writing/html/writer.go
index 81c33ef..9cd8200 100644
--- a/internal/writing/html/writer.go
+++ b/internal/writing/html/writer.go
@@ -1,8 +1,8 @@
// Package html renders an internal.AggregatorWriter as a complete,
-// standalone HTML document built around the same table fragment produced by
-// table.Printer.RenderHTML. The document includes the Bootstrap 5.3
-// stylesheet via CDN and embeds the raw table fragment without HTML-escaping
-// it so the result opens directly in a browser.
+// standalone HTML document: the report table fragment (built from the
+// shared table definition in internal/writing/internal/tabledef, so it can
+// never drift from the terminal layout) embedded in a Bootstrap-styled page
+// with a translated title and generation timestamp.
package html
import (
@@ -13,15 +13,9 @@ import (
"time"
"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.
const bootstrapCSSURL = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
@@ -31,20 +25,17 @@ const bootstrapIntegrity = "sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bR
// Writer writes a complete HTML document for an AggregatorWriter.
type Writer struct {
output io.Writer
- printer *table.Printer
- translator translator
+ translator tabledef.Translator
lang string
}
-// NewWriter builds a Writer that writes a complete HTML document to
-// w. The printer is used purely as a source for the raw fragment via
-// RenderHTML; this writer never invokes Render or any method that writes to
-// printer's own io.Writer. The document title is looked up from the
-// "report_title" translation via tr, so callers never pass a pre-baked title.
-func NewWriter(w io.Writer, printer *table.Printer, tr translator, lang string) *Writer {
+// NewWriter builds a Writer that writes a complete HTML document to w. The
+// report table is rendered from the shared table definition via tr, and the
+// document title is looked up from the "report_title" translation via tr,
+// so callers never pass a pre-baked title.
+func NewWriter(w io.Writer, tr tabledef.Translator, lang string) *Writer {
return &Writer{
output: w,
- printer: printer,
translator: tr,
lang: lang,
}
@@ -63,10 +54,7 @@ var documentTemplate = template.Must(template.New("html").Parse(documentTemplate
// fragment from aw so the rendered count reflects aw's current items and
// no rows are duplicated across invocations.
func (hw *Writer) Render(aw *internal.AggregatorWriter) error {
- if hw.printer == nil {
- return fmt.Errorf("html: nil printer")
- }
- fragment := hw.printer.RenderHTML(aw)
+ fragment := hw.renderTableFragment(aw)
title := hw.translator.Translate("report_title", 1, nil)
data := struct {
Lang string
diff --git a/internal/writing/html/writer_test.go b/internal/writing/html/writer_test.go
index 1fec216..42eab4f 100644
--- a/internal/writing/html/writer_test.go
+++ b/internal/writing/html/writer_test.go
@@ -6,10 +6,9 @@ import (
"testing"
"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/writing"
+ htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html"
"github.com/shopspring/decimal"
)
@@ -64,9 +63,7 @@ func TestWriter_Render(t *testing.T) {
}
var buf bytes.Buffer
- // RenderHTML never writes to the printer's output, so Discard is safe.
- pp := table.NewPrinter(&buf, loc)
- hw := htmlwriter.NewWriter(&buf, pp, loc, "en")
+ hw := htmlwriter.NewWriter(&buf, loc, "en")
if err := hw.Render(buildTestAggregator(t)); err != nil {
t.Fatalf("Render returned an error: %v", err)
@@ -86,7 +83,8 @@ func TestWriter_Render(t *testing.T) {
``,
`Tax report
`,
``,
- `
`,
+ ``,
``,
``,
@@ -133,8 +131,7 @@ func TestWriter_NoRowDuplication(t *testing.T) {
}
var buf bytes.Buffer
- pp := table.NewPrinter(&buf, loc)
- hw := htmlwriter.NewWriter(&buf, pp, loc, "en")
+ hw := htmlwriter.NewWriter(&buf, loc, "en")
aw := buildTestAggregator(t)
@@ -167,8 +164,7 @@ func TestWriter_LangAttribute(t *testing.T) {
}
var buf bytes.Buffer
- pp := table.NewPrinter(&buf, loc)
- hw := htmlwriter.NewWriter(&buf, pp, loc, "pt")
+ hw := htmlwriter.NewWriter(&buf, loc, "pt")
if err := hw.Render(buildTestAggregator(t)); err != nil {
t.Fatalf("Render returned an error: %v", err)
diff --git a/internal/writing/internal/tabledef/table.go b/internal/writing/internal/tabledef/table.go
new file mode 100644
index 0000000..c18958f
--- /dev/null
+++ b/internal/writing/internal/tabledef/table.go
@@ -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]) + "…"
+}
diff --git a/internal/writing/internal/tabledef/table_test.go b/internal/writing/internal/tabledef/table_test.go
new file mode 100644
index 0000000..1bec9d2
--- /dev/null
+++ b/internal/writing/internal/tabledef/table_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/writing/table/printer.go b/internal/writing/table/printer.go
index c9d8a58..5976cdc 100644
--- a/internal/writing/table/printer.go
+++ b/internal/writing/table/printer.go
@@ -1,155 +1,34 @@
// Package table renders an internal.AggregatorWriter as the human-readable
-// Anexo J report table, for the terminal (Render) and for embedding in HTML
-// documents (RenderHTML).
+// Anexo J report table for the terminal. The shared table definition it (and
+// the html package) renders from lives in internal/writing/internal/tabledef.
package table
import (
- "fmt"
"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/writing/internal/tabledef"
)
-// translator is the contract the table printer relies on for human-readable
-// column labels. *Localizer satisfies it.
-type translator interface {
- Translate(key string, count int, values map[string]any) string
-}
-
-// 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.
+// Printer writes a simple, human readable, table to the provided io.Writer
+// for the report items in an AggregatorWriter. Each Render call builds a
+// fresh table from the aggregator, so invoking it repeatedly on the same
+// instance never duplicates rows.
type Printer struct {
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{
output: w,
translator: tr,
}
}
-// buildTable constructs a fully configured table.Writer from the items in aw.
-// 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
-}
-
+// Render writes the terminal table for aw to the configured output.
func (pp *Printer) Render(aw *internal.AggregatorWriter) {
- t := pp.buildTable(aw)
+ t := tabledef.Build(pp.translator, aw)
t.SetOutputMirror(pp.output)
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: "
",
- 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)
- },
- }
-}
diff --git a/internal/writing/table/printer_test.go b/internal/writing/table/printer_test.go
index f7c661e..9c989fa 100644
--- a/internal/writing/table/printer_test.go
+++ b/internal/writing/table/printer_test.go
@@ -2,7 +2,6 @@ package table_test
import (
"bytes"
- "strings"
"testing"
"time"
@@ -75,8 +74,7 @@ func TestPrinter_Render(t *testing.T) {
// Expected output
want := `┌───┬────────────────────────────┬───────────────────────────────────┬───────────────────────────────────┬──────────────────────────────────────────────────────────┐
│ │ │ REALIZATION │ ACQUISITION │ │
-│ │ SOURCE COUNTRY │ CODE │ YEAR │ MONTH │ DAY │ VALUE │ YEAR │ MONTH │ DAY │ VALUE │ EXPENSES AND CH │ TAX PAID ABROAD │ COUNTER COUNTRY │
-│ │ │ │ │ │ │ │ │ │ │ │ ARGES │ │ │
+│ │ SOURCE COUNTRY │ CODE │ YEAR │ MONTH │ DAY │ VALUE │ YEAR │ MONTH │ DAY │ VALUE │ EXPENSES AND C… │ TAX PAID ABROAD │ COUNTER COUNTRY │
├───┼─────────────────────┼──────┼──────┼───────┼─────┼──────────────┼──────┼───────┼─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤
│ 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 │
@@ -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)
}
}
-
-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{
- ``,
- ``,
- ``,
- ``,
- ``,
- ``,
- ``,
- "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, "):\n%s\nSecond (%d
):\n%s",
- strings.Count(html, "
|