Render embedded plain footnotes

This commit is contained in:
2026-07-05 22:16:40 -05:00
parent 457600b93d
commit 0139240073
6 changed files with 299 additions and 20 deletions
+23 -2
View File
@@ -98,6 +98,21 @@ proc fetchPassages(reference, translation: string, cfg: CombinedConfig): seq[str
"unsupported translation '" & translation &
"'; supported translations: " & supportedTranslationsList())
proc fetchPlainPassages(
reference,
translation: string,
keepVerseNumbers: bool,
cfg: CombinedConfig): seq[string] =
case translation
of "akjv", "kjv":
result = kjv.fetchPlainPassages(reference, keepVerseNumbers)
of "mev":
result = mev.fetchPlainPassages(reference, keepVerseNumbers)
else:
for passage in fetchPassages(reference, translation, cfg):
result.add(formatPlain(passage, translation, keepVerseNumbers))
when isMainModule:
const USAGE = """Usage:
bibleref <reference> [options]
@@ -167,11 +182,17 @@ Options:
var formattedPassages: seq[string] = @[]
for query in queries:
case $args["--output-format"]
of "plain":
formattedPassages.add(fetchPlainPassages(
query.referenceText,
query.translation,
keepVerseNumbers = true,
cfg))
else:
for passage in fetchPassages(query.referenceText, query.translation, cfg):
formattedPassages.add(
case $args["--output-format"]:
of "plain":
formatPlain(passage, query.translation)
of "reading":
formatPlain(passage, query.translation, keepVerseNumbers = false)
of "text":
+207 -2
View File
@@ -1,4 +1,4 @@
import std/[strutils, tables]
import std/[algorithm, json, strutils, tables, wordwrap]
import ./reference_parser
@@ -16,6 +16,12 @@ type
breakKind: SegmentBreak
indent: int
text: string
annotations: seq[BibleAnnotation]
BibleAnnotation = object
kind: string
offset: int
targetText: string
BibleIndex = object
segments: seq[BibleSegment]
@@ -49,6 +55,31 @@ proc parseSegmentBreak(s: string): SegmentBreak =
else:
raise newException(ValueError, "invalid embedded Bible segment break: " & s)
proc parseAnnotations(metadata: string): seq[BibleAnnotation] =
if metadata.strip.len == 0:
return
let node = parseJson(metadata)
if node.kind != JObject or not node.hasKey("annotations"):
return
for item in node["annotations"].getElems:
let targetText =
if item.hasKey("targetText"): item["targetText"].getStr
else: ""
if targetText.strip.len == 0:
continue
result.add(BibleAnnotation(
kind:
if item.hasKey("kind"): item["kind"].getStr
else: "",
offset:
if item.hasKey("offset"): item["offset"].getInt
else: 0,
targetText: targetText))
proc addSegment(index: var BibleIndex, segment: BibleSegment) =
index.segments.add(segment)
index.verses[verseKey(segment.code, segment.chapter, segment.verse)] = true
@@ -92,7 +123,10 @@ proc loadBibleIndex(rows, translationName: string): BibleIndex =
segment: parseInt(parts[3]),
breakKind: parseSegmentBreak(parts[4]),
indent: parseInt(parts[5]),
text: parts[6]))
text: parts[6],
annotations:
if parts.len == 8: parseAnnotations(parts[7])
else: @[]))
proc requireLastChapter(index: BibleIndex, code: string): int =
if not index.lastChapterByBook.hasKey(code):
@@ -245,3 +279,174 @@ proc fetchPassages*(rows, reference, translationName: string): seq[string] =
let index = loadBibleIndex(rows, translationName)
for parsedReference in parseReferences(reference):
result.add(fetchReference(index, parsedReference))
proc footnoteLabel(index: int): string =
var n = index
while true:
result.insert($char(ord('a') + (n mod 26)), 0)
n = (n div 26) - 1
if n < 0:
break
proc addSegmentWithFootnotes(
line: var string,
segment: BibleSegment,
keepVerseNumbers: bool,
notes: var seq[string]) =
var text = segment.text
var annotations = segment.annotations
annotations.sort(proc(a, b: BibleAnnotation): int = cmp(a.offset, b.offset))
var inserted = 0
for annotation in annotations:
let label = footnoteLabel(notes.len)
notes.add(annotation.targetText)
let insertAt = max(0, min(text.len, annotation.offset + inserted))
let marker = "[" & label & "]"
text.insert(marker, insertAt)
inserted += marker.len
let marker =
if keepVerseNumbers: $segment.verse & " "
else: ""
let content = marker & text
if line.len == 0:
line.add(repeat(" ", segment.indent) & content)
elif segment.breakKind == sbSame:
line.appendSameLine(content)
else:
line.add("\n" & repeat(" ", segment.indent) & content)
proc addFootnotes(lines: var seq[string], notes: seq[string]) =
if notes.len == 0:
return
lines.add("")
for idx, note in notes:
lines.add(footnoteLabel(idx) & ". " & note)
proc selectedChapters(index: BibleIndex, reference: PassageReference):
seq[tuple[chapter: int, verses: Table[string, bool]]] =
let code = reference.book.code
if reference.ranges.len == 0:
for chapter in 1 .. index.requireLastChapter(code):
var verses: Table[string, bool]
for verse in 1 .. index.requireLastVerse(code, chapter):
discard index.requireVerse(code, chapter, verse)
verses[verseKey(code, chapter, verse)] = true
result.add((chapter, verses))
else:
var chapterPositions: Table[int, int]
for range in reference.ranges:
let selected = index.selectedVerses(reference, range)
for key in selected.keys:
let parts = key.split('\t')
let chapter = parseInt(parts[1])
if not chapterPositions.hasKey(chapter):
chapterPositions[chapter] = result.len
var verses: Table[string, bool]
result.add((chapter, verses))
result[chapterPositions[chapter]].verses[key] = true
proc plainChapter(
index: BibleIndex,
code: string,
selected: Table[string, bool],
keepVerseNumbers: bool): string =
var lines: seq[string]
var currentLine = ""
var notes: seq[string]
var emittedVerses: Table[string, bool]
var firstSelected = true
proc flushLine() =
if currentLine.len > 0:
lines.add(currentLine)
currentLine = ""
for segment in index.segments:
if segment.code != code:
continue
let key = verseKey(segment.code, segment.chapter, segment.verse)
if not selected.hasKey(key):
continue
var adjusted = segment
if firstSelected:
adjusted = normalizeFirstSelectedSegment(adjusted)
let startsNewParagraph =
adjusted.breakKind == sbParagraph and
not firstSelected and
(not emittedVerses.hasKey(key))
if startsNewParagraph:
flushLine()
lines.add("")
elif adjusted.breakKind == sbLine:
flushLine()
let marker =
if emittedVerses.hasKey(key): false
else:
emittedVerses[key] = true
true
var renderSegment = adjusted
if not marker:
renderSegment.verse = 0
if marker:
currentLine.addSegmentWithFootnotes(renderSegment, keepVerseNumbers, notes)
else:
var noVerseSegment = renderSegment
noVerseSegment.text = renderSegment.text
noVerseSegment.breakKind = sbSame
currentLine.addSegmentWithFootnotes(noVerseSegment, false, notes)
firstSelected = false
flushLine()
lines.addFootnotes(notes)
var wrapped: seq[string]
for line in lines:
if line.len == 0:
wrapped.add("")
continue
let prepared =
if line.len > 90: line.strip
else: line & " "
wrapped.add(wrapWords(prepared, maxLineWidth = 74, newLine = "\p"))
wrapped.join("\p")
proc fetchPlainPassages*(
rows,
reference,
translationName: string,
keepVerseNumbers = true): seq[string] =
let index = loadBibleIndex(rows, translationName)
for parsedReference in parseReferences(reference):
var parts: seq[string]
for chapter in index.selectedChapters(parsedReference):
parts.add(index.plainChapter(
parsedReference.book.code,
chapter.verses,
keepVerseNumbers))
let body = parts.join("\p\p")
let referenceSeparator =
if body.contains("\pa. "): "\p\p"
else: "\p"
result.add(body & referenceSeparator & " " & $parsedReference & " (" &
translationName.toUpperAscii & ")")
+3
View File
@@ -5,3 +5,6 @@ const kjvRows = embeddedTranslationData("kjv")
proc fetchPassages*(reference: string): seq[string] =
embedded_bible.fetchPassages(kjvRows, reference, "KJV")
proc fetchPlainPassages*(reference: string, keepVerseNumbers = true): seq[string] =
embedded_bible.fetchPlainPassages(kjvRows, reference, "KJV", keepVerseNumbers)
+7
View File
@@ -11,3 +11,10 @@ proc fetchPassages*(reference: string): seq[string] =
else:
raise newException(ValueError,
"MEV data is not embedded; generate data/private/mev.tsv and rebuild")
proc fetchPlainPassages*(reference: string, keepVerseNumbers = true): seq[string] =
when hasEmbeddedTranslationData("mev"):
embedded_bible.fetchPlainPassages(mevRows, reference, "MEV", keepVerseNumbers)
else:
raise newException(ValueError,
"MEV data is not embedded; generate data/private/mev.tsv and rebuild")
+34
View File
@@ -170,3 +170,37 @@ HEB 4 7 2 line 1 do not harden your hearts."
" [31] Then Jesus said. [32] You shall know the truth.\n" &
"\n" &
" [33] They answered Him."
test "plain output renders annotation footnotes":
const rows =
"JHN\t8\t1\t0\tparagraph\t0\tBut Jesus went to the Mount of Olives.\t" &
"""{"annotations":[{"kind":"crossReference","marker":"a","offset":40,"targetText":"Mt 21:1"}]}""" & "\n" &
"JHN\t8\t2\t0\tsame\t0\tEarly in the morning He returned to the temple.\t" &
"""{"annotations":[{"kind":"crossReference","marker":"a","offset":49,"targetText":"Mt 26:55; Lk 4:20"}]}""" & "\n"
let passages = embedded_bible.fetchPlainPassages(rows, "John 8:1-2", "TEST")
check passages[0] == "1 But Jesus went to the Mount of Olives.[a] 2 Early in the morning He\n" &
"returned to the temple.[b]\n" &
"\n" &
"a. Mt 21:1\n" &
"b. Mt 26:55; Lk 4:20\n" &
"\n" &
" John 8:1-2 (TEST)"
test "plain output resets footnote labels per chapter":
const rows =
"JHN\t8\t59\t0\tparagraph\t0\tThey took up stones.\t" &
"""{"annotations":[{"kind":"crossReference","marker":"a","offset":21,"targetText":"Lev 24:16"}]}""" & "\n" &
"JHN\t9\t1\t0\tparagraph\t0\tAs Jesus passed by.\t" &
"""{"annotations":[{"kind":"crossReference","marker":"a","offset":20,"targetText":"Jn 8:12"}]}""" & "\n"
let passages = embedded_bible.fetchPlainPassages(rows, "John 8:59-9:1", "TEST")
check passages[0] == "59 They took up stones.[a]\n" &
"\n" &
"a. Lev 24:16\n" &
"\n" &
"1 As Jesus passed by.[a]\n" &
"\n" &
"a. Jn 8:12\n" &
"\n" &
" John 8:59-9:1 (TEST)"
+14 -5
View File
@@ -273,15 +273,24 @@ proc annotationKind(marker: string): string =
if marker.strip.allCharsInSet({'0'..'9'}): "note"
else: "crossReference"
proc cleanTargetText(targetText: string): string =
var parts = targetText.splitWhitespace
if parts.len >= 3 and parts[0].allCharsInSet({'0'..'9'}):
parts.delete(0)
parts.delete(0)
return parts.join(" ")
targetText
proc annotationNode(marker, href, targetText: string, offset: int): JsonNode =
let kind = annotationKind(marker)
let cleanedTargetText = cleanTargetText(targetText)
result = %*{
"kind": annotationKind(marker),
"marker": marker,
"href": href,
"kind": kind,
"offset": offset
}
if targetText.len > 0:
result["targetText"] = newJString(targetText)
if cleanedTargetText.len > 0:
result["targetText"] = newJString(cleanedTargetText)
proc segmentMetadata(state: ParseState): JsonNode =
result = newJObject()