Implement HTML writer (#33)
Badges / coveralls (push) Successful in 1m1s

Reviewed-on: #33
Co-authored-by: Natercio Moniz <[email protected]>
This commit was merged in pull request #33.
This commit is contained in:
natercio authored and Natercio Moniz committed 2026-10-05 16:25:21 +01:00
1 parent 6c989c338c
commit 47f38fce2b
20 files changed
+782 -315

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
@charset "UTF-8";/*!
* Bootstrap v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherLine truncated
/*# sourceMappingURL=bootstrap.min.css.map */
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="{{.Lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<style>{{.CSS}}</style>
</head>
<body>
<main class="container-fluid py-4">
<h1>{{.Title}}</h1>
<div class="table-responsive">
{{.TableFragment}}
</div>
<p class="text-muted small">Generated at {{.GeneratedAt}}</p>
</main>
</body>
</html>
+69
View File
@@ -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: "&nbsp;",
}
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()
}
+60
View File
@@ -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, "&lt;table") {
t.Errorf("table fragment appears to be HTML-escaped; expected raw <table> markup\nGot:\n%s", got)
}
}
+85
View File
@@ -0,0 +1,85 @@
// Package html renders an internal.AggregatorWriter as a complete,
// 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. The official Bootstrap
// 5.3.3 stylesheet is inlined via go:embed, so the document renders fully
// offline.
package html
import (
_ "embed"
"fmt"
"html/template"
"io"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef"
)
// bootstrapMinCSS is the official Bootstrap 5.3.3 minified CSS distribution
// file, embedded so every rendered document renders fully offline. After any
// version bump, re-verify provenance with:
//
// openssl dgst -sha384 -binary bootstrap.min.css | base64
//
// It must print the official SRI hash published at
// https://getbootstrap.com/docs/5.3/getting-started/download/
// (sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH).
//
//go:embed bootstrap.min.css
var bootstrapMinCSS string
// Writer writes a complete HTML document for an AggregatorWriter.
type Writer struct {
output io.Writer
translator tabledef.Translator
lang string
}
// 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,
translator: tr,
lang: lang,
}
}
//go:embed document.tmpl
var documentTemplateBody string
// documentTemplate renders the full HTML page from document.tmpl. The table
// fragment is typed as template.HTML so the template engine emits it
// verbatim without escaping the angle brackets in the <table> markup.
var documentTemplate = template.Must(template.New("html").Parse(documentTemplateBody))
// Render writes a complete standalone HTML document for aw to the configured
// output. The same Writer may be reused; each call rebuilds the table
// fragment from aw so the rendered <tr> count reflects aw's current items and
// no rows are duplicated across invocations.
func (hw *Writer) Render(aw *internal.AggregatorWriter) error {
fragment := hw.renderTableFragment(aw)
title := hw.translator.Translate("report_title", 1, nil)
data := struct {
Lang string
Title string
CSS template.CSS
TableFragment template.HTML
GeneratedAt string
}{
Lang: hw.lang,
Title: title,
CSS: template.CSS(bootstrapMinCSS),
TableFragment: template.HTML(fragment),
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}
if err := documentTemplate.Execute(hw.output, data); err != nil {
return fmt.Errorf("render html document: %w", err)
}
return nil
}
+188
View File
@@ -0,0 +1,188 @@
package html_test
import (
"bytes"
"strings"
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/writing"
htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html"
"github.com/shopspring/decimal"
)
// buildTestAggregator returns an AggregatorWriter seeded with two
// deterministic ReportItems so every test can assert on a stable document
// shape. Mirrors the fixture pattern used in
// internal/writing/table/printer_test.go.
func buildTestAggregator(t *testing.T) *internal.AggregatorWriter {
t.Helper()
aw := internal.NewAggregatorWriter()
ctx := t.Context()
err := aw.Write(ctx, internal.ReportItem{
Symbol: "AAPL",
Nature: internal.NatureG01,
BrokerCountry: 826,
AssetCountry: 840,
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 first report item: %v", err)
}
err = aw.Write(ctx, internal.ReportItem{
Symbol: "GOOGL",
Nature: internal.NatureG20,
BrokerCountry: 826,
AssetCountry: 840,
BuyValue: decimal.NewFromFloat(200.00),
BuyTimestamp: time.Date(2023, 3, 10, 0, 0, 0, 0, time.UTC),
SellValue: decimal.NewFromFloat(225.50),
SellTimestamp: time.Date(2023, 9, 5, 0, 0, 0, 0, time.UTC),
Fees: decimal.NewFromFloat(3.00),
Taxes: decimal.NewFromFloat(7.50),
})
if err != nil {
t.Fatalf("failed to write second report item: %v", err)
}
return aw
}
func TestWriter_Render(t *testing.T) {
loc, err := writing.NewLocalizer("en")
if err != nil {
t.Fatalf("create localizer: %v", err)
}
var buf bytes.Buffer
hw := htmlwriter.NewWriter(&buf, loc, "en")
if err := hw.Render(buildTestAggregator(t)); err != nil {
t.Fatalf("Render returned an error: %v", err)
}
got := buf.String()
wantSubstrings := []string{
"<!DOCTYPE html>",
`<html lang="en">`,
`<meta charset="utf-8">`,
`<meta name="viewport" content="width=device-width, initial-scale=1">`,
`<title>Tax report</title>`,
`<style>`,
`Bootstrap v5.3.3`,
`<main class="container-fluid py-4">`,
`<h1>Tax report</h1>`,
`<div class="table-responsive">`,
`<table class="table table-striped table-hover table-bordered" style="table-layout: fixed">`,
`<colgroup>`,
`</table>`,
`<tfoot>`,
`</tfoot>`,
`</html>`,
`<p class="text-muted small">Generated at `,
}
for _, s := range wantSubstrings {
if !strings.Contains(got, s) {
t.Errorf("rendered HTML missing expected substring %q\nGot:\n%s", s, got)
}
}
// The table fragment must be embedded as raw HTML, not escaped.
if strings.Contains(got, "&lt;table") {
t.Errorf("table fragment appears to be HTML-escaped; expected raw <table> markup\nGot:\n%s", got)
}
if !strings.Contains(got, "<table") {
t.Errorf("expected raw <table> tag in output\nGot:\n%s", got)
}
// The document must be fully self-contained: no external stylesheet
// <link> and no CDN reference anywhere.
if strings.Contains(got, "<link") {
t.Errorf("expected no external stylesheet <link>; document must be self-contained\nGot:\n%s", got)
}
if strings.Contains(got, "cdn.jsdelivr.net") {
t.Errorf("expected no CDN references; document must be self-contained\nGot:\n%s", got)
}
// Generated timestamp must be valid RFC3339 UTC.
startIdx := strings.Index(got, "Generated at ")
if startIdx == -1 {
t.Fatalf("missing 'Generated at' prefix in output")
}
timestampLine := got[startIdx:]
endIdx := strings.Index(timestampLine, "</p>")
if endIdx == -1 {
t.Fatalf("missing </p> closing tag after 'Generated at' line")
}
timestampStr := strings.TrimPrefix(strings.SplitN(timestampLine, "</p>", 2)[0], "Generated at ")
if _, err := time.Parse(time.RFC3339, timestampStr); err != nil {
t.Errorf("generation timestamp is not valid RFC3339: %q (err: %v)", timestampStr, err)
}
}
// TestWriter_NoRowDuplication verifies that calling Render twice on the
// same writer produces documents with the same number of <tr> rows, just
// like table.Printer's no-duplication guarantee.
func TestWriter_NoRowDuplication(t *testing.T) {
loc, err := writing.NewLocalizer("en")
if err != nil {
t.Fatalf("create localizer: %v", err)
}
var buf bytes.Buffer
hw := htmlwriter.NewWriter(&buf, loc, "en")
aw := buildTestAggregator(t)
if err := hw.Render(aw); err != nil {
t.Fatalf("first Render returned an error: %v", err)
}
first := buf.String()
buf.Reset()
if err := hw.Render(aw); err != nil {
t.Fatalf("second Render returned an error: %v", err)
}
second := buf.String()
firstRows := strings.Count(first, "<tr")
secondRows := strings.Count(second, "<tr")
if firstRows == 0 {
t.Fatalf("expected at least one <tr> row in the first render; got:\n%s", first)
}
if firstRows != secondRows {
t.Errorf("expected second Render to produce the same number of <tr> rows as the first (no duplication)\nfirst rows=%d, second rows=%d\nFirst:\n%s\nSecond:\n%s",
firstRows, secondRows, first, second)
}
}
func TestWriter_LangAttribute(t *testing.T) {
loc, err := writing.NewLocalizer("pt")
if err != nil {
t.Fatalf("create localizer: %v", err)
}
var buf bytes.Buffer
hw := htmlwriter.NewWriter(&buf, loc, "pt")
if err := hw.Render(buildTestAggregator(t)); err != nil {
t.Fatalf("Render returned an error: %v", err)
}
got := buf.String()
if !strings.Contains(got, `<html lang="pt">`) {
t.Errorf("expected <html lang=\"pt\"> attribute; got:\n%s", got)
}
if !strings.Contains(got, `<h1>Relatório fiscal</h1>`) {
t.Errorf("expected Portuguese title in <h1>; got:\n%s", got)
}
}