// Package render turns a liturgy.Section, one version at a time, into text // output: the deduped Polish paragraphs or a bible tool's verse lines, and // lays several versions out side by side for comparison. It is shared by // the CLI and the TUI (which re-styles the same gathered data). package render import ( "fmt" "strings" "github.com/lukaszkasprzak/lectio/internal/bible" "github.com/lukaszkasprzak/lectio/internal/i18n" "github.com/lukaszkasprzak/lectio/internal/liturgy" ) // versionLabel returns version's column-header/prose label in lang (see // internal/i18n.Get), falling back to the bare version code if lang has no // entry for it. lang="pl" reproduces ewangelia.py's original VERSION_LABELS // exactly. func versionLabel(version, lang string) string { if l, ok := i18n.Get(lang).Version[version]; ok { return l } return version } // modernPartOrder is the fixed, deterministic order LocalizeHeading tries // the known modern (niedziela.pl) pl part labels in -- a plain slice rather // than ranging over i18n.UI.PartLabel (a map, so Go randomises its // iteration order). None of the five labels is a prefix of another, so the // order never changes which one matches, only determinism. // // Its *set* of five IDs must match internal/readings/offline.go's // ofPartOrder, which lists the same modern IDs in a different, deliberate // order (display order, unrelated to this package's prefix-match need). // Nothing enforces that agreement mechanically -- internal/render and // internal/readings do not import each other (adding a cross-package test // would create a new dependency edge that does not exist today) -- so if a // sixth modern part is ever added to ofPartOrder, add it here too, by hand. var modernPartOrder = []string{"pierwsze_czytanie", "drugie_czytanie", "psalm", "aklamacja", "ewangelia"} // LocalizeHeading swaps a modern (niedziela.pl) section heading's leading // label word for its lang translation, keeping the rest of the heading (the // parenthetical citation, exactly as scraped) untouched: e.g. // "Ewangelia (J 20, 1. 11-18)" with lang "en" becomes "Gospel (J 20, 1. // 11-18)". It only ever localises the label word, never the citation or the // verse text. // // It matches against heading's own leading text, not partID: a split // (two-reading) feast day scrapes PartID="drugie_czytanie" onto a heading // that niedziela.pl still literally titles "1. czytanie ..." (its own // numbering quirk carries over from the undivided day), so looking up the // Polish label by partID and checking heading's prefix against only that // one label misses it. Instead every known pl label (modernPartOrder) is // tried against heading in turn; partID itself is unused -- kept in the // signature for callers, which all have it in hand already (sec.PartID). // // It is a safe no-op (returns heading unchanged) unless lang is "en" and // heading actually starts with one of the known pl labels -- which excludes // traditional (missalemeum) headings, already in the requested language, // and anything unrecognised. func LocalizeHeading(heading, partID, lang string) string { if lang != "en" { return heading } plUI, enUI := i18n.Get("pl"), i18n.Get("en") for _, id := range modernPartOrder { plLabel := plUI.PartLabel[id] if strings.HasPrefix(heading, plLabel) { return enUI.PartLabel[id] + heading[len(plLabel):] } } return heading } // HeadingWithRef returns a section's localised heading with its scripture // reference appended in parentheses when the heading does not already carry // one. The modern (niedziela) heading already embeds its citation -- e.g. // "Ewangelia (J 20, 1. 11-18)" -- while the traditional (missalemeum) heading // is a bare label ("Gospel"/"Ewangelia") with the reference only in // sec.Citation; this appends it so every reading shows where it is from, // uniformly across cli/tui/web. The citation is source-form (like the modern // one), never translated. func HeadingWithRef(sec liturgy.Section, lang string) string { heading := LocalizeHeading(sec.Heading, sec.PartID, lang) if sec.Citation != "" && !strings.Contains(heading, "(") { heading += " (" + sec.Citation + ")" } return heading } // versionSystem maps a bible version to the Psalter system bible.ToEnglishRef // expects: vul/grb/wuj follow the Vulgate/Septuagint numbering, drb follows // the Hebrew chapter with the DRB title-fold verse shift. Ported from // ewangelia.py PSALM_SYSTEM. var versionSystem = map[string]string{ "vul": "vulgate", "grb": "vulgate", "wuj": "vulgate", "drb": "drb", } func system(version string) string { if s, ok := versionSystem[version]; ok { return s } return "vulgate" } // GatherVersion returns (label, blocks) for one version of one reading // section. Each block is a verse line; on any failure the blocks hold a single // short note instead. // // lectionary selects how sec.Ref is renumbered: "new" (Ordinary Form) refs are // modern-numbered and go through bible.OFRef for the target Psalter; // "traditional" (1962) refs are already Vulgate-numbered and used as-is. lang // selects the UI chrome language the label comes from (see internal/i18n); it // never affects the verse text/citation itself. func GatherVersion(version string, sec liturgy.Section, lectionary, lang string) (label string, blocks []string) { label = versionLabel(version, lang) ref, err := resolveRef(version, sec, lectionary, lang) if err != nil { return label, []string{err.Error()} } verses, missing := bible.Lookup(version, ref) if len(verses) == 0 { return label, []string{fmt.Sprintf(i18n.Get(lang).NoVersion, version)} } blocks = make([]string, 0, len(verses)) for _, v := range verses { blocks = append(blocks, fmt.Sprintf("%d:%d %s", v.Chapter, v.Verse, v.Text)) } if len(missing) > 0 { blocks = append(blocks, fmt.Sprintf(i18n.Get(lang).NoVersionPartial, version, strings.Join(missing, ", "))) } return label, blocks } // resolveRef resolves a non-"bt" version's bible.Lookup reference for sec: // sec.Citation (or, failing that, the citation extracted from sec.Heading), // converted to English/kjv-style via bible.ToEnglishRef when // lectionary=="new" (a "traditional" citation is already English-style and // used as-is). Shared by GatherVersion and GatherVerses so both apply the // exact same resolution. lang selects the wording of the two failure // messages it can return (see internal/i18n.UI.NoReference/NoReferenceErr); // it never affects which reference is resolved. func resolveRef(version string, sec liturgy.Section, lectionary, lang string) (string, error) { ui := i18n.Get(lang) // Ref is the English-canonical lookup reference; fall back to the display // citation (or the one embedded in the heading) for sections that carry none. citation := sec.Ref if citation == "" { citation = sec.Citation } if citation == "" { if c, err := liturgy.ExtractCitation(sec.Heading); err == nil { citation = c } } if citation == "" { return "", fmt.Errorf("%s", ui.NoReference) } if lectionary != "new" { return citation, nil // Extraordinary Form: already Vulgate-numbered } // Ordinary Form: the citation is lectio's English-canonical, modern-numbered // form; renumber only the Psalms for the target corpus's Psalter. return bible.OFRef(citation, system(version)), nil } // GatherVerses returns one version's verses for a section as raw bible.Verse // structs (for column/interlinear alignment). versified is false on any // resolution/lookup failure -- callers fall back to GatherVersion's string // blocks for those. lang selects the UI chrome language the label comes from, // same as GatherVersion. func GatherVerses(version string, sec liturgy.Section, lectionary, lang string) (label string, verses []bible.Verse, versified bool) { label = versionLabel(version, lang) ref, err := resolveRef(version, sec, lectionary, lang) if err != nil { return label, nil, false } verses, _ = bible.Lookup(version, ref) if len(verses) == 0 { return label, nil, false } return label, verses, true } // OfflineVersions maps a legacy "bt" version (the retired niedziela.pl scrape, // which has no embedded corpus) to "wuj", or drops it when "wuj" is already // present so the set keeps a single Polish column. Config load migrates bt out // (see config.migrateBT), so this only guards versions passed in directly. func OfflineVersions(versions []string) []string { hadWuj := false for _, v := range versions { if v == "wuj" { hadWuj = true break } } out := make([]string, 0, len(versions)) for _, v := range versions { if v == "bt" { if hadWuj { continue } out = append(out, "wuj") continue } out = append(out, v) } return out } // EffectiveVersions is the version set actually loadable for a request: "bt" // (the former niedziela.pl modern scrape) has no embedded corpus, so it is // always dropped -- substituting "wuj" if it was the only Polish column, via // OfflineVersions. Every reading is now rendered offline from an embedded // corpus, so the swap is unconditional; the lectionary and offline arguments // are retained for caller compatibility but no longer change the result. // Shared by cli, tui and web so all three binaries agree on the version set. func EffectiveVersions(versions []string, lectionary string, offline bool) []string { return OfflineVersions(versions) } // Compare lays the versions of one reading section out as parallel columns, // side by side, wrapped to fit width. Ports render_compare. lang selects the // column-header label language (see GatherVersion). func Compare(secs []liturgy.Section, versions []string, width int, lectionary, lang string) string { var out []string for _, sec := range secs { out = append(out, compareSection(sec, versions, width, lectionary, lang)) } return strings.Join(out, "\n\n") } func compareSection(sec liturgy.Section, versions []string, width int, lectionary, lang string) string { type column struct { label string lines []string } n := len(versions) if n == 0 { return "" } const gap = " " w := (width - len(gap)*(n-1)) / n if w < 24 { w = 24 } cols := make([]column, 0, n) height := 0 for _, v := range versions { label, blocks := GatherVersion(v, sec, lectionary, lang) var lines []string for _, b := range blocks { lines = append(lines, strings.Split(Wrap(b, w), "\n")...) lines = append(lines, "") } if len(lines) > 0 { lines = lines[:len(lines)-1] // drop trailing blank } if len(lines) > height { height = len(lines) } cols = append(cols, column{label: label, lines: lines}) } labelCells := make([]string, n) dashCells := make([]string, n) for i, c := range cols { labelCells[i] = ljust(truncate(c.label, w), w) dashCells[i] = strings.Repeat("-", w) } rows := []string{ strings.TrimRight(strings.Join(labelCells, gap), " "), strings.Join(dashCells, gap), } for i := 0; i < height; i++ { cells := make([]string, n) for ci, c := range cols { cell := "" if i < len(c.lines) { cell = c.lines[i] } cells[ci] = ljust(cell, w) } rows = append(rows, strings.TrimRight(strings.Join(cells, gap), " ")) } return strings.Join(rows, "\n") } // wrap wraps line to width, greedily packing whole words onto each output // line and never splitting a word (even one longer than width), matching // ewangelia.py's textwrap.fill(..., break_long_words=False, // break_on_hyphens=False). // Wrap greedily word-wraps line to width without padding (each output line is // at most width runes, no trailing spaces), never splitting a word. Shared by // the CLI, the TUI reader and Compare so all three wrap identically. func Wrap(line string, width int) string { words := strings.Fields(line) if len(words) == 0 { return "" } var out []string cur := words[0] for _, word := range words[1:] { if len([]rune(cur))+1+len([]rune(word)) <= width { cur += " " + word } else { out = append(out, cur) cur = word } } out = append(out, cur) return strings.Join(out, "\n") } func ljust(s string, w int) string { if n := w - len([]rune(s)); n > 0 { return s + strings.Repeat(" ", n) } return s } func truncate(s string, w int) string { r := []rune(s) if len(r) <= w { return s } return string(r[:w]) }