commit - a31d8fbf9d5ffda70750701ee27ed51bdbac593a
commit + 372f60546819d91aaf4368cb7ea2b8a980156bfc
blob - /dev/null
blob + 0dbf4342c0d9875e78206fd4ffe12edd84c9b3b6 (mode 644)
--- /dev/null
+++ internal/bib/accent.go
+package bib
+
+import "strings"
+
+// Accent tables covering the accented Latin letters most likely to appear
+// in author names and titles. Not exhaustive.
+var (
+ acute = map[rune]rune{
+ 'a': 'á', 'e': 'é', 'i': 'í', 'o': 'ó', 'u': 'ú', 'y': 'ý',
+ 'A': 'Á', 'E': 'É', 'I': 'Í', 'O': 'Ó', 'U': 'Ú', 'Y': 'Ý',
+ 'c': 'ć', 'n': 'ń', 's': 'ś', 'z': 'ź',
+ 'C': 'Ć', 'N': 'Ń', 'S': 'Ś', 'Z': 'Ź',
+ }
+ grave = map[rune]rune{
+ 'a': 'à', 'e': 'è', 'i': 'ì', 'o': 'ò', 'u': 'ù',
+ 'A': 'À', 'E': 'È', 'I': 'Ì', 'O': 'Ò', 'U': 'Ù',
+ }
+ umlaut = map[rune]rune{
+ 'a': 'ä', 'e': 'ë', 'i': 'ï', 'o': 'ö', 'u': 'ü', 'y': 'ÿ',
+ 'A': 'Ä', 'E': 'Ë', 'I': 'Ï', 'O': 'Ö', 'U': 'Ü',
+ }
+ tilde = map[rune]rune{
+ 'a': 'ã', 'n': 'ñ', 'o': 'õ',
+ 'A': 'Ã', 'N': 'Ñ', 'O': 'Õ',
+ }
+ circumflex = map[rune]rune{
+ 'a': 'â', 'e': 'ê', 'i': 'î', 'o': 'ô', 'u': 'û',
+ 'A': 'Â', 'E': 'Ê', 'I': 'Î', 'O': 'Ô', 'U': 'Û',
+ }
+ cedilla = map[rune]rune{'c': 'ç', 'C': 'Ç', 's': 'ş', 'S': 'Ş'}
+ caron = map[rune]rune{
+ 'c': 'č', 's': 'š', 'z': 'ž', 'e': 'ě', 'r': 'ř',
+ 'C': 'Č', 'S': 'Š', 'Z': 'Ž',
+ }
+)
+
+func accentTable(cmd rune) map[rune]rune {
+ switch cmd {
+ case '\'':
+ return acute
+ case '`':
+ return grave
+ case '"':
+ return umlaut
+ case '~':
+ return tilde
+ case '^':
+ return circumflex
+ case 'c':
+ return cedilla
+ case 'v':
+ return caron
+ }
+ return nil
+}
+
+// stripAccents converts recognized LaTeX accent commands (\'e, \"{o},
+// \c{c}, ...) to their precomposed Unicode letter, and drops any
+// remaining brace characters (typographic protection, e.g. {IEEE}, with
+// no textual meaning of their own). Unrecognized backslash commands are
+// left as-is.
+func stripAccents(s string) string {
+ var out strings.Builder
+ runes := []rune(s)
+ i := 0
+ for i < len(runes) {
+ switch runes[i] {
+ case '\\':
+ i = writeAccent(&out, runes, i)
+ case '{', '}':
+ i++
+ default:
+ out.WriteRune(runes[i])
+ i++
+ }
+ }
+ return out.String()
+}
+
+func writeAccent(out *strings.Builder, runes []rune, i int) int {
+ i++ // skip backslash
+ if i >= len(runes) {
+ out.WriteRune('\\')
+ return i
+ }
+ cmd := runes[i]
+ i++
+
+ table := accentTable(cmd)
+ if table == nil {
+ // Not a command we recognize: leave it untouched, including
+ // whatever follows, so we never eat a space that wasn't ours.
+ out.WriteRune('\\')
+ out.WriteRune(cmd)
+ return i
+ }
+
+ // Letter-based commands (\c, \v, ...) need a space or braces to
+ // separate them from the base letter, per TeX control-word lexing;
+ // that separating space carries no output of its own -- but if the
+ // base letter turns out unmapped, restore it in the fallback below.
+ spacedCmd := i < len(runes) && runes[i] == ' '
+ if spacedCmd {
+ i++
+ }
+ braced := i < len(runes) && runes[i] == '{'
+ if braced {
+ i++
+ }
+ if i >= len(runes) {
+ out.WriteRune('\\')
+ out.WriteRune(cmd)
+ return i
+ }
+
+ base := runes[i]
+ if r, ok := table[base]; ok {
+ out.WriteRune(r)
+ i++
+ if braced && i < len(runes) && runes[i] == '}' {
+ i++
+ }
+ return i
+ }
+
+ out.WriteRune('\\')
+ out.WriteRune(cmd)
+ if spacedCmd {
+ out.WriteRune(' ')
+ }
+ return i
+}
blob - /dev/null
blob + fa6dcfedf6e3bee608db4b43a5e94f2d16bdcbdd (mode 644)
--- /dev/null
+++ internal/bib/author.go
+package bib
+
+import "strings"
+
+// Author is a single name split into given (First) and family (Last)
+// parts. Others marks the "and others" sentinel in a BibTeX author field.
+type Author struct {
+ First, Last string
+ Others bool
+}
+
+// splitAuthors parses a BibTeX author/editor field ("First Last and
+// First2 Last2 and others"). It handles the two common per-author forms:
+// "Last, First" (comma present) and "First Last" (no comma, last word is
+// the family name). It doesn't handle "von"/"Jr" particles.
+func splitAuthors(field string) []Author {
+ if field == "" {
+ return nil
+ }
+ parts := strings.Split(field, " and ")
+ authors := make([]Author, 0, len(parts))
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+ if strings.EqualFold(p, "others") {
+ authors = append(authors, Author{Others: true})
+ continue
+ }
+ authors = append(authors, splitAuthor(p))
+ }
+ return authors
+}
+
+func splitAuthor(s string) Author {
+ if comma := strings.Index(s, ","); comma >= 0 {
+ return Author{
+ Last: strings.TrimSpace(s[:comma]),
+ First: strings.TrimSpace(s[comma+1:]),
+ }
+ }
+ words := strings.Fields(s)
+ switch len(words) {
+ case 0:
+ return Author{}
+ case 1:
+ return Author{Last: words[0]}
+ default:
+ return Author{
+ First: strings.Join(words[:len(words)-1], " "),
+ Last: words[len(words)-1],
+ }
+ }
+}
+
+// formatAuthors renders authors in numeric/IEEE-ish style: one author is
+// just "F. Last"; two are joined with "and"; three to six are a comma
+// list with "and" before the last; more than six (or a trailing "and
+// others") collapses to the first author plus "et al."
+func formatAuthors(authors []Author) string {
+ if len(authors) == 0 {
+ return ""
+ }
+
+ others := false
+ if authors[len(authors)-1].Others {
+ others = true
+ authors = authors[:len(authors)-1]
+ }
+ if len(authors) == 0 {
+ return ""
+ }
+ if len(authors) > 6 {
+ others = true
+ authors = authors[:1]
+ }
+
+ formatted := make([]string, len(authors))
+ for i, a := range authors {
+ formatted[i] = formatOneAuthor(a)
+ }
+
+ if others {
+ return formatted[0] + " et al."
+ }
+
+ switch len(formatted) {
+ case 1:
+ return formatted[0]
+ case 2:
+ return formatted[0] + " and " + formatted[1]
+ default:
+ return strings.Join(formatted[:len(formatted)-1], ", ") + ", and " + formatted[len(formatted)-1]
+ }
+}
+
+// formatOneAuthor renders a single author as initials + last name, e.g.
+// First "Given Names" -> "G. N. Last".
+func formatOneAuthor(a Author) string {
+ var initials []string
+ for _, w := range strings.Fields(a.First) {
+ r := []rune(w)
+ if len(r) == 0 {
+ continue
+ }
+ initials = append(initials, string(r[0])+".")
+ }
+ switch {
+ case a.Last == "":
+ return strings.Join(initials, " ")
+ case len(initials) == 0:
+ return a.Last
+ default:
+ return strings.Join(initials, " ") + " " + a.Last
+ }
+}
blob - /dev/null
blob + 03e1b44a874d322ba1389c6723c03f2337a7e737 (mode 644)
--- /dev/null
+++ internal/bib/author_test.go
+package bib
+
+import "testing"
+
+func TestSplitAuthors(t *testing.T) {
+ tests := []struct {
+ name string
+ field string
+ want []Author
+ }{
+ {"empty", "", nil},
+ {"single last-first", "Smith, John", []Author{{First: "John", Last: "Smith"}}},
+ {"single first-last", "John Smith", []Author{{First: "John", Last: "Smith"}}},
+ {
+ "two authors mixed forms",
+ "Smith, John and Jane Doe",
+ []Author{{First: "John", Last: "Smith"}, {First: "Jane", Last: "Doe"}},
+ },
+ {
+ "and others",
+ "Smith, John and others",
+ []Author{{First: "John", Last: "Smith"}, {Others: true}},
+ },
+ {"single word", "Cher", []Author{{Last: "Cher"}}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := splitAuthors(tt.field)
+ if !authorsEqual(got, tt.want) {
+ t.Errorf("splitAuthors(%q) = %+v, want %+v", tt.field, got, tt.want)
+ }
+ })
+ }
+}
+
+func authorsEqual(a, b []Author) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
+
+func TestFormatAuthors(t *testing.T) {
+ tests := []struct {
+ name string
+ authors []Author
+ want string
+ }{
+ {"none", nil, ""},
+ {"one", []Author{{First: "John", Last: "Smith"}}, "J. Smith"},
+ {
+ "two",
+ []Author{{First: "John", Last: "Smith"}, {First: "Jane", Last: "Doe"}},
+ "J. Smith and J. Doe",
+ },
+ {
+ "three",
+ []Author{{First: "A", Last: "One"}, {First: "B", Last: "Two"}, {First: "C", Last: "Three"}},
+ "A. One, B. Two, and C. Three",
+ },
+ {
+ "and others",
+ []Author{{First: "John", Last: "Smith"}, {Others: true}},
+ "J. Smith et al.",
+ },
+ {
+ "more than six collapses to et al.",
+ []Author{
+ {First: "A", Last: "One"}, {First: "B", Last: "Two"}, {First: "C", Last: "Three"},
+ {First: "D", Last: "Four"}, {First: "E", Last: "Five"}, {First: "F", Last: "Six"},
+ {First: "G", Last: "Seven"},
+ },
+ "A. One et al.",
+ },
+ {"multi-word first name", []Author{{First: "John Michael", Last: "Smith"}}, "J. M. Smith"},
+ {"no first name", []Author{{Last: "Cher"}}, "Cher"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := formatAuthors(tt.authors)
+ if got != tt.want {
+ t.Errorf("formatAuthors(%+v) = %q, want %q", tt.authors, got, tt.want)
+ }
+ })
+ }
+}
blob - /dev/null
blob + ab4242d360f3c856a526cb5e59ad618c46cfe834 (mode 644)
--- /dev/null
+++ internal/bib/cite.go
+package bib
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+var citeRe = regexp.MustCompile(`\[@([A-Za-z0-9_:.-]+)\]`)
+
+// Process scans slides in order for [@key] citations, numbers them by
+// first appearance, rewrites each [@key] into a [n] styled with the
+// theme's link_text color (a real markdown link to a #key anchor -- it
+// never resolves to anywhere, it's just how glamour picks up the color,
+// same trick as everything else in this fork that wants themed styling
+// without hardcoding a color), and appends a References slide listing
+// every cited entry in citation-number order. Citation keys not found in
+// entries are left as literal [@key] text, a visible signal that
+// something's missing rather than a silent drop.
+func Process(slides []string, entries map[string]Entry) []string {
+ numbers := make(map[string]int)
+ var order []string
+
+ for _, slide := range slides {
+ for _, m := range citeRe.FindAllStringSubmatch(slide, -1) {
+ key := m[1]
+ if _, ok := entries[key]; !ok {
+ continue
+ }
+ if _, seen := numbers[key]; !seen {
+ numbers[key] = len(order) + 1
+ order = append(order, key)
+ }
+ }
+ }
+ if len(order) == 0 {
+ return slides
+ }
+
+ out := make([]string, len(slides))
+ for i, slide := range slides {
+ out[i] = citeRe.ReplaceAllStringFunc(slide, func(match string) string {
+ key := citeRe.FindStringSubmatch(match)[1]
+ n, ok := numbers[key]
+ if !ok {
+ return match
+ }
+ return fmt.Sprintf("[[%d](#%s)]", n, key)
+ })
+ }
+ out = append(out, referencesSlide(order, entries))
+ return out
+}
+
+func referencesSlide(order []string, entries map[string]Entry) string {
+ var b strings.Builder
+ b.WriteString("## References\n\n")
+ for i, key := range order {
+ b.WriteString(FormatReference(i+1, entries[key]))
+ b.WriteString("\n\n")
+ }
+ return strings.TrimRight(b.String(), "\n")
+}
blob - /dev/null
blob + 859c5d3d3cc5bd63696febf93c6c1ac7d6b6689d (mode 644)
--- /dev/null
+++ internal/bib/cite_test.go
+package bib
+
+import (
+ "strings"
+ "testing"
+)
+
+func testEntries() map[string]Entry {
+ return map[string]Entry{
+ "smith2020": {Type: "article", Key: "smith2020", Fields: map[string]string{
+ "author": "Smith, John", "title": "First Paper", "year": "2020",
+ }},
+ "doe2019": {Type: "article", Key: "doe2019", Fields: map[string]string{
+ "author": "Doe, Jane", "title": "Second Paper", "year": "2019",
+ }},
+ }
+}
+
+func TestProcess_NumbersByFirstAppearance(t *testing.T) {
+ slides := []string{
+ "intro slide, no citations",
+ "see [@doe2019] and also [@smith2020]",
+ "cited again: [@doe2019]",
+ }
+ out := Process(slides, testEntries())
+
+ if len(out) != len(slides)+1 {
+ t.Fatalf("got %d slides, want %d (+1 for references)", len(out), len(slides)+1)
+ }
+ if out[0] != slides[0] {
+ t.Errorf("slide 0 should be untouched, got %q", out[0])
+ }
+ if !strings.Contains(out[1], "[[1](#doe2019)]") || !strings.Contains(out[1], "[[2](#smith2020)]") {
+ t.Errorf("slide 1 = %q, want [[1]] (doe2019, first seen) and [[2]] (smith2020)", out[1])
+ }
+ if !strings.Contains(out[2], "[[1](#doe2019)]") {
+ t.Errorf("slide 2 = %q, want repeated citation to reuse [[1]]", out[2])
+ }
+
+ refs := out[len(out)-1]
+ if !strings.HasPrefix(refs, "## References") {
+ t.Errorf("references slide should start with heading, got %q", refs)
+ }
+ if !strings.Contains(refs, "[1] J. Doe") {
+ t.Errorf("references slide missing entry 1 (doe2019): %q", refs)
+ }
+ if !strings.Contains(refs, "[2] J. Smith") {
+ t.Errorf("references slide missing entry 2 (smith2020): %q", refs)
+ }
+}
+
+func TestProcess_UnknownKeyLeftLiteral(t *testing.T) {
+ slides := []string{"cites [@missing] key"}
+ out := Process(slides, testEntries())
+
+ if len(out) != 1 {
+ t.Fatalf("no known citations, should not append a references slide; got %d slides", len(out))
+ }
+ if out[0] != slides[0] {
+ t.Errorf("unresolved citation should be left untouched, got %q", out[0])
+ }
+}
+
+func TestProcess_NoCitations(t *testing.T) {
+ slides := []string{"just text", "more text"}
+ out := Process(slides, testEntries())
+ if len(out) != len(slides) {
+ t.Fatalf("no citations found, slide count should be unchanged")
+ }
+}
blob - /dev/null
blob + 2a1f18b457d79ce63ec1a01f09d7de065a5c379a (mode 644)
--- /dev/null
+++ internal/bib/format.go
+package bib
+
+import (
+ "fmt"
+ "strings"
+)
+
+// refBuilder joins non-empty reference fragments with ", ", except that a
+// fragment immediately following a quoted title gets a plain space -- the
+// comma is already inside the closing quote, matching the numeric/IEEE
+// convention of `"Title," Journal, ...`.
+type refBuilder struct {
+ parts []string
+}
+
+func (r *refBuilder) add(s string) {
+ if s != "" {
+ r.parts = append(r.parts, s)
+ }
+}
+
+func (r *refBuilder) addTitle(title string) {
+ if title != "" {
+ r.parts = append(r.parts, `"`+title+`,"`)
+ }
+}
+
+func (r *refBuilder) String() string {
+ var b strings.Builder
+ for i, p := range r.parts {
+ if i > 0 {
+ if strings.HasSuffix(r.parts[i-1], `,"`) {
+ b.WriteString(" ")
+ } else {
+ b.WriteString(", ")
+ }
+ }
+ b.WriteString(p)
+ }
+ return strings.TrimRight(b.String(), ".,") + "."
+}
+
+// FormatReference renders entry e as the n-th numbered reference, in a
+// plain numeric/IEEE-ish style. Field presence varies by entry type;
+// missing fields are simply omitted rather than leaving gaps.
+func FormatReference(n int, e Entry) string {
+ var body string
+ switch e.Type {
+ case "book":
+ body = formatBook(e.Fields)
+ case "inproceedings", "conference":
+ body = formatInproceedings(e.Fields)
+ case "misc":
+ body = formatMisc(e.Fields)
+ case "techreport":
+ body = formatTechreport(e.Fields)
+ case "phdthesis":
+ body = formatThesis(e.Fields, "Ph.D. dissertation")
+ case "mastersthesis":
+ body = formatThesis(e.Fields, "M.S. thesis")
+ default: // "article" and anything unrecognized use the article template
+ body = formatArticle(e.Fields)
+ }
+ return fmt.Sprintf("[%d] %s", n, body)
+}
+
+func formatArticle(f map[string]string) string {
+ var r refBuilder
+ r.add(formatAuthors(splitAuthors(f["author"])))
+ r.addTitle(f["title"])
+ r.add(emphasize(f["journal"]))
+ r.add(formatVolNo(f))
+ r.add(formatPages(f["pages"]))
+ r.add(formatMonthYear(f))
+ return r.String()
+}
+
+func formatBook(f map[string]string) string {
+ var r refBuilder
+ r.add(formatAuthors(splitAuthors(f["author"])))
+ r.add(emphasize(f["title"]))
+ if ed := f["edition"]; ed != "" {
+ r.add(ed + " ed.")
+ }
+ r.add(formatCityPublisher(f))
+ r.add(f["year"])
+ return r.String()
+}
+
+func formatInproceedings(f map[string]string) string {
+ var r refBuilder
+ r.add(formatAuthors(splitAuthors(f["author"])))
+ r.addTitle(f["title"])
+ if bt := f["booktitle"]; bt != "" {
+ r.add("in " + emphasize(bt))
+ }
+ r.add(f["address"])
+ r.add(f["year"])
+ r.add(formatPages(f["pages"]))
+ return r.String()
+}
+
+func formatMisc(f map[string]string) string {
+ var r refBuilder
+ r.add(formatAuthors(splitAuthors(f["author"])))
+ r.addTitle(f["title"])
+ r.add(f["year"])
+ if url := entryURL(f); url != "" {
+ r.add("[Online]. Available: " + url)
+ }
+ return r.String()
+}
+
+func formatTechreport(f map[string]string) string {
+ var r refBuilder
+ r.add(formatAuthors(splitAuthors(f["author"])))
+ r.addTitle(f["title"])
+ r.add(f["institution"])
+ r.add(f["address"])
+ if num := f["number"]; num != "" {
+ r.add("Rep. " + num)
+ }
+ r.add(f["year"])
+ return r.String()
+}
+
+func formatThesis(f map[string]string, kind string) string {
+ var r refBuilder
+ r.add(formatAuthors(splitAuthors(f["author"])))
+ r.addTitle(f["title"])
+ r.add(kind)
+ r.add(f["school"])
+ r.add(f["address"])
+ r.add(f["year"])
+ return r.String()
+}
+
+func emphasize(s string) string {
+ if s == "" {
+ return ""
+ }
+ return "*" + s + "*"
+}
+
+func formatCityPublisher(f map[string]string) string {
+ city, publisher := f["address"], f["publisher"]
+ switch {
+ case city != "" && publisher != "":
+ return city + ": " + publisher
+ case publisher != "":
+ return publisher
+ default:
+ return city
+ }
+}
+
+func formatVolNo(f map[string]string) string {
+ var parts []string
+ if v := f["volume"]; v != "" {
+ parts = append(parts, "vol. "+v)
+ }
+ if n := f["number"]; n != "" {
+ parts = append(parts, "no. "+n)
+ }
+ return strings.Join(parts, ", ")
+}
+
+func formatPages(raw string) string {
+ if raw == "" {
+ return ""
+ }
+ return "pp. " + strings.ReplaceAll(raw, "--", "–")
+}
+
+func formatMonthYear(f map[string]string) string {
+ month, year := f["month"], f["year"]
+ switch {
+ case month != "" && year != "":
+ return month + " " + year
+ case year != "":
+ return year
+ default:
+ return month
+ }
+}
+
+// entryURL returns a link target for an entry, preferring an explicit url
+// field and falling back to doi, for use as a citation hyperlink.
+func entryURL(f map[string]string) string {
+ if u := f["url"]; u != "" {
+ return u
+ }
+ if doi := f["doi"]; doi != "" {
+ return "https://doi.org/" + doi
+ }
+ return ""
+}
blob - /dev/null
blob + 1d8e60f3a977c483db000ae6a52b016bee203c15 (mode 644)
--- /dev/null
+++ internal/bib/format_test.go
+package bib
+
+import "testing"
+
+func TestFormatReference(t *testing.T) {
+ tests := []struct {
+ name string
+ entry Entry
+ want string
+ }{
+ {
+ name: "article",
+ entry: Entry{Type: "article", Key: "s2020", Fields: map[string]string{
+ "author": "Smith, John", "title": "A Great Paper",
+ "journal": "Journal of Things", "volume": "5", "number": "2",
+ "pages": "100--110", "year": "2020",
+ }},
+ want: `[1] J. Smith, "A Great Paper," *Journal of Things*, vol. 5, no. 2, pp. 100–110, 2020.`,
+ },
+ {
+ name: "book",
+ entry: Entry{Type: "book", Key: "k1997", Fields: map[string]string{
+ "author": "Knuth, Donald E.", "title": "The Art of Computer Programming",
+ "address": "Boston", "publisher": "Addison-Wesley", "year": "1997",
+ }},
+ want: `[2] D. E. Knuth, *The Art of Computer Programming*, Boston: Addison-Wesley, 1997.`,
+ },
+ {
+ name: "inproceedings",
+ entry: Entry{Type: "inproceedings", Key: "p2019", Fields: map[string]string{
+ "author": "Doe, Jane", "title": "Conference Paper",
+ "booktitle": "Proc. Big Conf", "address": "NYC", "year": "2019", "pages": "1--5",
+ }},
+ want: `[3] J. Doe, "Conference Paper," in *Proc. Big Conf*, NYC, 2019, pp. 1–5.`,
+ },
+ {
+ name: "misc",
+ entry: Entry{Type: "misc", Key: "m2022", Fields: map[string]string{
+ "author": "Roe, Sam", "title": "A Blog Post", "year": "2022",
+ "url": "https://example.com",
+ }},
+ want: `[4] S. Roe, "A Blog Post," 2022, [Online]. Available: https://example.com.`,
+ },
+ {
+ name: "misc with doi fallback",
+ entry: Entry{Type: "misc", Key: "d2023", Fields: map[string]string{
+ "author": "Roe, Sam", "title": "A Dataset", "year": "2023", "doi": "10.1/x",
+ }},
+ want: `[5] S. Roe, "A Dataset," 2023, [Online]. Available: https://doi.org/10.1/x.`,
+ },
+ {
+ name: "missing fields are simply omitted",
+ entry: Entry{Type: "article", Key: "bare", Fields: map[string]string{
+ "title": "Untitled",
+ }},
+ want: `[6] "Untitled,".`,
+ },
+ }
+
+ for i, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := FormatReference(i+1, tt.entry)
+ if got != tt.want {
+ t.Errorf("FormatReference(%d, %+v) =\n %q\nwant\n %q", i+1, tt.entry, got, tt.want)
+ }
+ })
+ }
+}
blob - /dev/null
blob + a78c4a610dfa667a4892c3db19a9f03a377645b1 (mode 644)
--- /dev/null
+++ internal/bib/parse.go
+// Package bib parses BibTeX files and formats numbered references for
+// citations found in slide markdown.
+package bib
+
+import (
+ "io"
+ "os"
+ "strings"
+)
+
+// Entry is a single @type{key, ...} BibTeX entry. Fields are already
+// stripped of braces/quotes and accent-converted.
+type Entry struct {
+ Type string
+ Key string
+ Fields map[string]string
+}
+
+// ParseFile reads and parses a .bib file.
+func ParseFile(path string) (map[string]Entry, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ return Parse(f)
+}
+
+// Parse reads a .bib document from r. Entries it can't make sense of are
+// skipped rather than causing the whole file to fail -- a malformed one
+// shouldn't take down a presentation.
+func Parse(r io.Reader) (map[string]Entry, error) {
+ data, err := io.ReadAll(r)
+ if err != nil {
+ return nil, err
+ }
+ return parseString(string(data)), nil
+}
+
+func parseString(s string) map[string]Entry {
+ entries := make(map[string]Entry)
+ runes := []rune(s)
+ i := 0
+ for i < len(runes) {
+ for i < len(runes) && runes[i] != '@' {
+ i++
+ }
+ if i >= len(runes) {
+ break
+ }
+ i++ // skip '@'
+
+ typeStart := i
+ for i < len(runes) && isLetter(runes[i]) {
+ i++
+ }
+ typ := strings.ToLower(string(runes[typeStart:i]))
+
+ i = skipSpace(runes, i)
+ if i >= len(runes) || runes[i] != '{' {
+ continue
+ }
+ bodyEnd := matchBrace(runes, i)
+ if bodyEnd < 0 {
+ break // unbalanced braces from here on; nothing more to parse
+ }
+ body := runes[i+1 : bodyEnd]
+ i = bodyEnd + 1
+
+ if typ == "comment" || typ == "string" || typ == "preamble" {
+ continue
+ }
+
+ key, fields := parseEntryBody(body)
+ if key == "" {
+ continue
+ }
+ entries[key] = Entry{Type: typ, Key: key, Fields: fields}
+ }
+ return entries
+}
+
+func parseEntryBody(body []rune) (string, map[string]string) {
+ segments := splitTopLevel(body, ',')
+ if len(segments) == 0 {
+ return "", nil
+ }
+ key := strings.TrimSpace(string(segments[0]))
+ if key == "" {
+ return "", nil
+ }
+
+ fields := make(map[string]string)
+ for _, seg := range segments[1:] {
+ s := strings.TrimSpace(string(seg))
+ if s == "" {
+ continue
+ }
+ eq := strings.IndexRune(s, '=')
+ if eq < 0 {
+ continue
+ }
+ name := strings.ToLower(strings.TrimSpace(s[:eq]))
+ fields[name] = parseValue(strings.TrimSpace(s[eq+1:]))
+ }
+ return key, fields
+}
+
+// parseValue strips the {...}/"..." delimiter (bare words/numbers pass
+// through as-is) and converts LaTeX accents in the result.
+func parseValue(raw string) string {
+ runes := []rune(raw)
+ if len(runes) == 0 {
+ return ""
+ }
+
+ var inner string
+ switch runes[0] {
+ case '{':
+ if end := matchBrace(runes, 0); end >= 0 {
+ inner = string(runes[1:end])
+ } else {
+ inner = string(runes[1:])
+ }
+ case '"':
+ if end := len(runes) - 1; end > 0 && runes[end] == '"' {
+ inner = string(runes[1:end])
+ } else {
+ inner = string(runes[1:])
+ }
+ default:
+ inner = raw
+ }
+ return stripAccents(inner)
+}
+
+// splitTopLevel splits runes on sep, ignoring separators nested inside
+// {...} or "...".
+func splitTopLevel(runes []rune, sep rune) [][]rune {
+ var parts [][]rune
+ depth := 0
+ inQuote := false
+ start := 0
+ for i, r := range runes {
+ switch r {
+ case '{':
+ depth++
+ case '}':
+ if depth > 0 {
+ depth--
+ }
+ case '"':
+ if depth == 0 {
+ inQuote = !inQuote
+ }
+ case sep:
+ if depth == 0 && !inQuote {
+ parts = append(parts, runes[start:i])
+ start = i + 1
+ }
+ }
+ }
+ parts = append(parts, runes[start:])
+ return parts
+}
+
+// matchBrace returns the index of the '}' matching runes[open] (which must
+// be '{'), or -1 if unbalanced.
+func matchBrace(runes []rune, open int) int {
+ depth := 0
+ for i := open; i < len(runes); i++ {
+ switch runes[i] {
+ case '{':
+ depth++
+ case '}':
+ depth--
+ if depth == 0 {
+ return i
+ }
+ }
+ }
+ return -1
+}
+
+func skipSpace(runes []rune, i int) int {
+ for i < len(runes) && (runes[i] == ' ' || runes[i] == '\t' || runes[i] == '\n' || runes[i] == '\r') {
+ i++
+ }
+ return i
+}
+
+func isLetter(r rune) bool {
+ return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
+}
blob - /dev/null
blob + 0ef2c8261bbc4773cd64c4f37d42496bc48ee752 (mode 644)
--- /dev/null
+++ internal/bib/parse_test.go
+package bib_test
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/maaslalani/slides/internal/bib"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const sampleBib = `
+@article{smith2020,
+ author = {Smith, John and Doe, Jane},
+ title = {A Great Paper About {IEEE} Things},
+ journal = {Journal of Things},
+ year = {2020},
+ volume = {5},
+ number = {2},
+ pages = {100--110}
+}
+
+@book{knuth1997,
+ author = "Knuth, Donald E.",
+ title = "The Art of Computer Programming",
+ publisher = {Addison-Wesley},
+ year = 1997
+}
+
+@misc{accented,
+ author = {M{\"u}ller, Hans and G{\'o}mez, Ana},
+ title = {Some {\'e}scaped Ch{\c c}ars},
+ year = {2021}
+}
+
+@comment{ignore me}
+
+@string{foo = "bar"}
+`
+
+func TestParse(t *testing.T) {
+ entries, err := bib.Parse(strings.NewReader(sampleBib))
+ require.NoError(t, err)
+ require.Len(t, entries, 3)
+
+ smith := entries["smith2020"]
+ assert.Equal(t, "article", smith.Type)
+ assert.Equal(t, "smith2020", smith.Key)
+ assert.Equal(t, "Smith, John and Doe, Jane", smith.Fields["author"])
+ assert.Equal(t, "A Great Paper About IEEE Things", smith.Fields["title"])
+ assert.Equal(t, "Journal of Things", smith.Fields["journal"])
+ assert.Equal(t, "2020", smith.Fields["year"])
+ assert.Equal(t, "5", smith.Fields["volume"])
+ assert.Equal(t, "100--110", smith.Fields["pages"])
+
+ knuth := entries["knuth1997"]
+ assert.Equal(t, "book", knuth.Type)
+ assert.Equal(t, "Knuth, Donald E.", knuth.Fields["author"])
+ assert.Equal(t, "The Art of Computer Programming", knuth.Fields["title"])
+ assert.Equal(t, "1997", knuth.Fields["year"])
+
+ accented := entries["accented"]
+ assert.Equal(t, "Müller, Hans and Gómez, Ana", accented.Fields["author"])
+ assert.Equal(t, "Some éscaped Chçars", accented.Fields["title"])
+
+ _, hasComment := entries["ignore me"]
+ assert.False(t, hasComment)
+}
+
+func TestParse_EmptyOrGarbage(t *testing.T) {
+ entries, err := bib.Parse(strings.NewReader("not a bibtex file at all"))
+ require.NoError(t, err)
+ assert.Empty(t, entries)
+}
blob - f876a793ce417e13678c4b54acab7bbd818efcba
blob + a3e169bf1d27d60df9bd5f8a63cf8dc0bd82cc8f
--- internal/meta/meta.go
+++ internal/meta/meta.go
// from values set to empty strings in the YAML header. We replace values not
// set by defaults values when parsing a header.
type parsedMeta struct {
- Theme *string `yaml:"theme"`
- Author *string `yaml:"author"`
- Date *string `yaml:"date"`
- Paging *string `yaml:"paging"`
+ Theme *string `yaml:"theme"`
+ Author *string `yaml:"author"`
+ Date *string `yaml:"date"`
+ Paging *string `yaml:"paging"`
+ Bibliography *string `yaml:"bibliography"`
}
// Meta contains all of the data to be parsed
// out of a markdown file's header section
type Meta struct {
- Theme string
- Author string
- Date string
- Paging string
+ Theme string
+ Author string
+ Date string
+ Paging string
+ Bibliography string
}
// New creates a new instance of the
m.Paging = fallback.Paging
}
+ if tmp.Bibliography != nil {
+ m.Bibliography = *tmp.Bibliography
+ }
+
return m, true
}
blob - ed3b157c77527f64f9db45a9a3678880b751a849
blob + f7724c31153dacbd8a2f5162589e923b6c8dfead
--- internal/meta/meta_test.go
+++ internal/meta/meta_test.go
Paging: "Slide %d / %d",
},
},
+ {
+ name: "Parse bibliography from header",
+ slideshow: fmt.Sprintf("---\nbibliography: %q\n", "refs.bib"),
+ want: &meta.Meta{
+ Theme: "default",
+ Author: user.Name,
+ Date: date,
+ Paging: "Slide %d / %d",
+ Bibliography: "refs.bib",
+ },
+ },
+ {
+ name: "Fallback to empty if no bibliography provided",
+ slideshow: "\n# Header Slide\n > Subtitle\n",
+ want: &meta.Meta{
+ Theme: "default",
+ Author: user.Name,
+ Date: date,
+ Paging: "Slide %d / %d",
+ },
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
blob - 9c974a382e2d7dee4a0d50c1952a20defb65d494
blob + e53110565cfaf49477231a0f5efdf9b85a9b87b6
--- internal/model/model.go
+++ internal/model/model.go
uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/glamour"
+ "github.com/maaslalani/slides/internal/bib"
"github.com/maaslalani/slides/internal/code"
"github.com/maaslalani/slides/internal/image"
"github.com/maaslalani/slides/internal/latex"
if m.images == nil {
m.images = image.NewCache()
}
+ if metaData.Bibliography != "" {
+ if entries, err := bib.ParseFile(filepath.Join(m.imageBaseDir(), metaData.Bibliography)); err == nil {
+ m.Slides = bib.Process(m.Slides, entries)
+ }
+ }
return nil
}