From 29e244c48222278c2f255ff2adf45e3af7c9f895 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Fri, 24 Jul 2026 16:50:45 +0200 Subject: export: internal/export (Markdown/Text/ReadingsPDF via pure-Go go-pdf/fpdf + embedded DejaVu Sans, covers pl/la/gr); cli --md/--pdf/--out; web /export + download links; v0.22.0 --- go.mod | 1 + go.sum | 2 + internal/cli/cli.go | 62 +++++++++++++++ internal/config/config.go | 2 +- internal/export/export.go | 128 ++++++++++++++++++++++++++++++ internal/export/export_test.go | 41 ++++++++++ internal/export/fonts/DejaVuSans-Bold.ttf | Bin 0 -> 708920 bytes internal/export/fonts/DejaVuSans.ttf | Bin 0 -> 759720 bytes internal/i18n/i18n.go | 5 ++ internal/web/server.go | 44 ++++++++++ internal/web/server_test.go | 12 +++ internal/web/static/base.css | 4 + internal/web/templates/index.html | 8 ++ 13 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 internal/export/export.go create mode 100644 internal/export/export_test.go create mode 100644 internal/export/fonts/DejaVuSans-Bold.ttf create mode 100644 internal/export/fonts/DejaVuSans.ttf diff --git a/go.mod b/go.mod index 94f5ab2..f80d413 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.0 require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/go-pdf/fpdf v0.9.0 github.com/pelletier/go-toml/v2 v2.4.2 ) diff --git a/go.sum b/go.sum index cd63509..d57ae4f 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= +github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 04defbf..88d3a30 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -16,6 +16,7 @@ import ( "github.com/lukaszkasprzak/lectio/internal/bible" "github.com/lukaszkasprzak/lectio/internal/config" + "github.com/lukaszkasprzak/lectio/internal/export" "github.com/lukaszkasprzak/lectio/internal/i18n" "github.com/lukaszkasprzak/lectio/internal/liturgy" "github.com/lukaszkasprzak/lectio/internal/readings" @@ -49,6 +50,9 @@ Flags: --rand, --rand-v print a random verse and exit (uses default_version or -b VER; bt has no corpus, so it falls back to a corpus version) --rand-ch print a random chapter and exit + --md export the readings as Markdown (to --out or stdout) + --pdf export the readings as PDF (to --out or stdout) + --out FILE write --md/--pdf output to FILE -v, --version print the version and exit -h, --help this help @@ -107,6 +111,8 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { var all, raw, refresh, offline, update, clean, pagerFlag, noPager, citation, week bool var randV, randCh bool + var expMD, expPDF bool + var output string var bibleVer, compareList, lectionary, lang string var width int var list bool @@ -149,6 +155,10 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { fs.BoolVar(&randV, "rand", false, "print a random verse from the corpus and exit") fs.BoolVar(&randV, "rand-v", false, "print a random verse from the corpus and exit") fs.BoolVar(&randCh, "rand-ch", false, "print a random chapter from the corpus and exit") + fs.BoolVar(&expMD, "md", false, "export the readings as Markdown") + fs.BoolVar(&expPDF, "pdf", false, "export the readings as PDF") + fs.StringVar(&output, "out", "", "write --md/--pdf output to FILE (default: stdout)") + fs.StringVar(&output, "output", "", "write --md/--pdf output to FILE (default: stdout)") if err := fs.Parse(rest); err != nil { return 2 @@ -208,6 +218,18 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { } effAll := all || cfg.All + + if expMD || expPDF { + ver := bibleVer + if ver == "" { + ver = cfg.DefaultVersion + } + if !config.ValidVersion(ver) { + fmt.Fprintf(stderr, "lectio: unknown version %q (want one of bt, wuj, vul, grb, drb)\n", ver) + return 2 + } + return runExport(cfg, date, ver, effAll, refresh, expPDF, output, stdout, stderr) + } effWidth := width if effWidth == 0 { effWidth = cfg.Width @@ -332,6 +354,46 @@ func dialectCitation(cfg config.Config, tbl *bible.BookTable, sec liturgy.Sectio return tbl.FormatRef(cfg.SiglaLang(), canonical) } +// runExport handles --md/--pdf: load the day's readings and write them as +// Markdown or PDF to --out FILE (or stdout). Honors date/lectionary/all/offline +// and the version (bt is fine -- it is the niedziela text -- but is swapped for +// traditional/offline via EffectiveVersions). +func runExport(cfg config.Config, date, version string, all, refresh, asPDF bool, output string, stdout, stderr io.Writer) int { + secs, info, err := readings.Load(cfg, readings.Options{Date: date, Refresh: refresh, Offline: cfg.Offline, All: all}) + if err != nil { + fmt.Fprintln(stderr, "lectio:", err) + return 1 + } + if len(secs) == 0 { + fmt.Fprintln(stderr, "lectio: no readings found for", date) + return 1 + } + if vs := render.EffectiveVersions([]string{version}, cfg.Lectionary, cfg.Offline); len(vs) > 0 { + version = vs[0] + } + + var data []byte + if asPDF { + data, err = export.ReadingsPDF(date, info, secs, version, cfg.Lectionary, cfg.UILanguage) + if err != nil { + fmt.Fprintln(stderr, "lectio:", err) + return 1 + } + } else { + data = []byte(export.Markdown(date, info, secs, version, cfg.Lectionary, cfg.UILanguage)) + } + + if output != "" { + if err := os.WriteFile(output, data, 0o644); err != nil { + fmt.Fprintln(stderr, "lectio:", err) + return 1 + } + return 0 + } + _, _ = stdout.Write(data) + return 0 +} + // runCitation handles --citation: fetch the day's gospel (gospel-only) and // print just its scripture reference in the configured sigla dialect (see // dialectCitation), for scripts/cron/prompt use. Honors the resolved cfg diff --git a/internal/config/config.go b/internal/config/config.go index 8847678..0ddbaa1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,7 +25,7 @@ var seedTOML []byte // Version is lectio's release version, shared by every binary's // -v/--version output (lectio, lectio-ui, lectio-web). -const Version = "0.21.0" +const Version = "0.22.0" // validVersions are the five scripture versions lectio understands. var validVersions = map[string]bool{ diff --git a/internal/export/export.go b/internal/export/export.go new file mode 100644 index 0000000..96491db --- /dev/null +++ b/internal/export/export.go @@ -0,0 +1,128 @@ +// Package export renders a day's readings (or a passage) to shareable formats: +// plain text, Markdown, and PDF -- plus a printable A4 month calendar. The PDF +// uses an embedded DejaVu Sans (regular + bold) so Polish, Latin and Greek all +// render; it is pure Go (no cgo), keeping the single-binary cross-compile. +package export + +import ( + "bytes" + _ "embed" + "fmt" + "strings" + + "github.com/go-pdf/fpdf" + + "github.com/lukaszkasprzak/lectio/internal/i18n" + "github.com/lukaszkasprzak/lectio/internal/liturgy" + "github.com/lukaszkasprzak/lectio/internal/render" +) + +// versionLabel is the localized name of a version (i18n), or the bare code. +func versionLabel(version, lang string) string { + if l, ok := i18n.Get(lang).Version[version]; ok { + return l + } + return version +} + +// title is the document heading: the celebration name if the source carries +// one, else a plain "readings for DATE" style line. +func title(date string, info liturgy.DayInfo) string { + if info.Name != "" { + return info.Name + } + return date +} + +// Text renders a day's readings as plain UTF-8 text: a title, the day-info, +// then each section's localized heading and its version blocks. +func Text(date string, info liturgy.DayInfo, secs []liturgy.Section, version, lectionary, lang string) string { + var b strings.Builder + fmt.Fprintf(&b, "%s\n", title(date, info)) + if info.Season != "" { + fmt.Fprintf(&b, "%s\n", info.Season) + } + fmt.Fprintf(&b, "%s · %s\n\n", date, versionLabel(version, lang)) + for _, sec := range secs { + fmt.Fprintf(&b, "%s\n\n", render.HeadingWithRef(sec, lang)) + _, blocks := render.GatherVersion(version, sec, lectionary, lang) + for _, blk := range blocks { + fmt.Fprintf(&b, "%s\n", blk) + } + b.WriteString("\n") + } + return b.String() +} + +// Markdown renders a day's readings as Markdown (# title, ## section headings, +// verse/paragraph blocks). +func Markdown(date string, info liturgy.DayInfo, secs []liturgy.Section, version, lectionary, lang string) string { + var b strings.Builder + fmt.Fprintf(&b, "# %s\n\n", title(date, info)) + if info.Season != "" { + fmt.Fprintf(&b, "*%s*\n\n", info.Season) + } + fmt.Fprintf(&b, "**%s · %s**\n\n", date, versionLabel(version, lang)) + for _, sec := range secs { + fmt.Fprintf(&b, "## %s\n\n", render.HeadingWithRef(sec, lang)) + _, blocks := render.GatherVersion(version, sec, lectionary, lang) + for _, blk := range blocks { + fmt.Fprintf(&b, "%s\n\n", blk) + } + } + return b.String() +} + +// ReadingsPDF renders a day's readings as a clean printable PDF. +func ReadingsPDF(date string, info liturgy.DayInfo, secs []liturgy.Section, version, lectionary, lang string) ([]byte, error) { + pdf := newPDF() + pdf.AddPage() + + pdf.SetFont(fontFamily, "B", 18) + pdf.MultiCell(0, 9, title(date, info), "", "L", false) + if info.Season != "" { + pdf.SetFont(fontFamily, "", 10) + pdf.MultiCell(0, 6, info.Season, "", "L", false) + } + pdf.SetFont(fontFamily, "", 10) + pdf.MultiCell(0, 6, date+" · "+versionLabel(version, lang), "", "L", false) + pdf.Ln(3) + + for _, sec := range secs { + pdf.Ln(2) + pdf.SetFont(fontFamily, "B", 13) + pdf.MultiCell(0, 7, render.HeadingWithRef(sec, lang), "", "L", false) + pdf.Ln(1) + pdf.SetFont(fontFamily, "", 11) + _, blocks := render.GatherVersion(version, sec, lectionary, lang) + for _, blk := range blocks { + pdf.MultiCell(0, 6, blk, "", "L", false) + pdf.Ln(1) + } + } + + var buf bytes.Buffer + if err := pdf.Output(&buf); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +//go:embed fonts/DejaVuSans.ttf +var fontRegular []byte + +//go:embed fonts/DejaVuSans-Bold.ttf +var fontBold []byte + +const fontFamily = "dejavu" + +// newPDF returns an A4 portrait document with the embedded Unicode font +// registered (regular + bold) and sensible margins/auto page break. +func newPDF() *fpdf.Fpdf { + pdf := fpdf.New("P", "mm", "A4", "") + pdf.AddUTF8FontFromBytes(fontFamily, "", fontRegular) + pdf.AddUTF8FontFromBytes(fontFamily, "B", fontBold) + pdf.SetMargins(18, 16, 18) + pdf.SetAutoPageBreak(true, 16) + return pdf +} diff --git a/internal/export/export_test.go b/internal/export/export_test.go new file mode 100644 index 0000000..7f1c68d --- /dev/null +++ b/internal/export/export_test.go @@ -0,0 +1,41 @@ +package export + +import ( + "bytes" + "strings" + "testing" + + "github.com/lukaszkasprzak/lectio/internal/liturgy" +) + +func sampleSecs() []liturgy.Section { + return []liturgy.Section{{ + Heading: "Ewangelia (Mt 13, 1-9)", + Citation: "Mt 13, 1-9", + PartID: "ewangelia", + Paragraphs: [][]string{{"Owego dnia Jezus wyszedł z domu i usiadł nad jeziorem."}}, + }} +} + +func TestMarkdownAndText(t *testing.T) { + info := liturgy.DayInfo{Name: "Środa XV tygodnia", Colour: "green"} + md := Markdown("2026-07-22", info, sampleSecs(), "bt", "new", "en") + if !strings.Contains(md, "# Środa") || !strings.Contains(md, "## Gospel") || !strings.Contains(md, "Jezus wyszedł") { + t.Errorf("markdown:\n%s", md) + } + txt := Text("2026-07-22", info, sampleSecs(), "bt", "new", "en") + if !strings.Contains(txt, "Jezus wyszedł") || !strings.Contains(txt, "Gospel") { + t.Errorf("text:\n%s", txt) + } +} + +func TestReadingsPDF(t *testing.T) { + info := liturgy.DayInfo{Name: "Środa XV tygodnia"} + pdf, err := ReadingsPDF("2026-07-22", info, sampleSecs(), "bt", "new", "en") + if err != nil { + t.Fatal(err) + } + if len(pdf) < 1000 || !bytes.HasPrefix(pdf, []byte("%PDF")) { + t.Errorf("bad pdf len=%d", len(pdf)) + } +} diff --git a/internal/export/fonts/DejaVuSans-Bold.ttf b/internal/export/fonts/DejaVuSans-Bold.ttf new file mode 100644 index 0000000..0a7fb3f Binary files /dev/null and b/internal/export/fonts/DejaVuSans-Bold.ttf differ diff --git a/internal/export/fonts/DejaVuSans.ttf b/internal/export/fonts/DejaVuSans.ttf new file mode 100644 index 0000000..1728e9e Binary files /dev/null and b/internal/export/fonts/DejaVuSans.ttf differ diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index ed03548..cf1f550 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -53,6 +53,9 @@ type UI struct { // Web reader (lectio-web /reader) control labels. NavReader, WebBook, WebChapter string + // Export is the web daily-page download label (txt/md/pdf links). + Export string + // NoReadingsDay is the web error fragment shown when readings.Load // returns no sections at all for the requested date. NoReadingsDay string @@ -131,6 +134,7 @@ var enUI = UI{ NavReader: "reader", WebBook: "book", WebChapter: "chapter", + Export: "download", NoReadingsDay: "no readings for this day", NoVersion: "(not in %s)", NoVersionPartial: "(not in %s: %s)", @@ -193,6 +197,7 @@ var plUI = UI{ NavReader: "czytnik", WebBook: "księga", WebChapter: "rozdział", + Export: "pobierz", NoReadingsDay: "brak czytań na ten dzień", NoVersion: "(brak w „%s”)", NoVersionPartial: "(brak w „%s”: %s)", diff --git a/internal/web/server.go b/internal/web/server.go index 006bbdb..45ab06f 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -25,6 +25,7 @@ import ( "github.com/lukaszkasprzak/lectio/internal/bible" "github.com/lukaszkasprzak/lectio/internal/bookmarks" "github.com/lukaszkasprzak/lectio/internal/config" + "github.com/lukaszkasprzak/lectio/internal/export" "github.com/lukaszkasprzak/lectio/internal/i18n" "github.com/lukaszkasprzak/lectio/internal/liturgy" "github.com/lukaszkasprzak/lectio/internal/readings" @@ -67,6 +68,7 @@ func NewServer(cfg config.Config) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { indexHandler(s.get())(w, r) }) mux.HandleFunc("GET /readings", func(w http.ResponseWriter, r *http.Request) { readingsHandler(s.get())(w, r) }) + mux.HandleFunc("GET /export", func(w http.ResponseWriter, r *http.Request) { exportHandler(s.get())(w, r) }) mux.HandleFunc("GET /reader", func(w http.ResponseWriter, r *http.Request) { readerHandler(s.get(), s.table())(w, r) }) mux.HandleFunc("GET /theme.css", func(w http.ResponseWriter, r *http.Request) { themeCSSHandler(s.get())(w, r) }) mux.HandleFunc("GET /settings", settingsGet(s)) @@ -337,6 +339,48 @@ func readingsHandler(cfg config.Config) http.HandlerFunc { } } +// exportHandler serves GET /export?fmt=txt|md|pdf: the current day's readings +// as a downloadable file (Content-Disposition attachment), reusing the same +// date/lectionary/all/version resolution as the reading pane. +func exportHandler(cfg config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + date, lectionary, all, versions, _ := resolveQuery(cfg, r) + secs, dayInfo, loadVersions, err := loadSections(cfg, lectionary, date, all, versions) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if len(secs) == 0 { + http.Error(w, i18n.Get(cfg.UILanguage).NoReadingsDay, http.StatusNotFound) + return + } + version := cfg.DefaultVersion + if len(loadVersions) > 0 { + version = loadVersions[0] + } + + switch r.URL.Query().Get("fmt") { + case "pdf": + data, perr := export.ReadingsPDF(date, dayInfo, secs, version, lectionary, cfg.UILanguage) + if perr != nil { + http.Error(w, perr.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", `attachment; filename="lectio-`+date+`.pdf"`) + w.Write(data) + case "md": + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="lectio-`+date+`.md"`) + io.WriteString(w, export.Markdown(date, dayInfo, secs, version, lectionary, cfg.UILanguage)) + default: + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="lectio-`+date+`.txt"`) + io.WriteString(w, export.Text(date, dayInfo, secs, version, lectionary, cfg.UILanguage)) + } + } +} + // renderOrError returns RenderReadings' fragment, or (on a readings.Load // error, or no sections at all for the date) a small escaped error // paragraph, localised via lang -- readings.Load errors are expected in diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 0754b08..43508ba 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -633,3 +633,15 @@ func TestBookmarksFlow(t *testing.T) { t.Errorf("tag filter dropped the bookmark") } } + +func TestExportNoReadings(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + cfg := config.Default() + cfg.Offline = true // no network; uncached date -> no readings + srv := NewServer(cfg) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/export?fmt=md&date=2099-01-01", nil)) + if rec.Code != http.StatusNotFound && rec.Code != http.StatusInternalServerError { + t.Errorf("export with no readings status=%d (want 404/500)", rec.Code) + } +} diff --git a/internal/web/static/base.css b/internal/web/static/base.css index 36f2f4d..d9d0e59 100644 --- a/internal/web/static/base.css +++ b/internal/web/static/base.css @@ -292,3 +292,7 @@ a { .bookmark { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: baseline; } .bookmark .tags { font-size: 0.85rem; } .tag-filter { display: flex; flex-wrap: wrap; gap: var(--space-1); margin-bottom: var(--space-2); font-family: var(--font-ui); } + +/* Export links (download the readings). */ +.export { font-family: var(--font-ui); font-size: 0.85rem; } +.export a { margin: 0 0.15rem; } diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index d3416e9..cd30208 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -78,6 +78,14 @@ + {{/* Download the current readings; the URL is built from the live control + values at click time so it always matches what's on screen. */}} + {{.L.Export}}: + txt + md + pdf + +
{{.Reading}}
-- cgit v1.3