7 Commits

Author SHA1 Message Date
jdb 14f5be9ad1 Add ANSI output format 2026-07-06 08:54:57 -05:00
jdb b5e8175618 Render embedded markdown footnotes 2026-07-05 22:21:37 -05:00
jdb 0139240073 Render embedded plain footnotes 2026-07-05 22:16:40 -05:00
jdb 457600b93d Preserve MEV passage metadata 2026-07-05 21:15:19 -05:00
jdb 4fcf5d3bed Bump version. 2026-07-05 21:01:25 -05:00
jdb 6972a4e531 Render embedded Bible paragraphs inline 2026-07-05 20:55:43 -05:00
jdb 98253920ca Preserve layout in embedded Bible data 2026-07-05 20:49:18 -05:00
8 changed files with 1256 additions and 81 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Package # Package
version = "1.1.0" version = "1.2.0"
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "Simple Nim CLI for retrieving Biblical passages" description = "Simple Nim CLI for retrieving Biblical passages"
license = "MIT" license = "MIT"
+88 -4
View File
@@ -98,6 +98,66 @@ proc fetchPassages(reference, translation: string, cfg: CombinedConfig): seq[str
"unsupported translation '" & translation & "unsupported translation '" & translation &
"'; supported translations: " & supportedTranslationsList()) "'; supported translations: " & supportedTranslationsList())
proc fetchPlainPassages(
reference,
translation: string,
keepVerseNumbers: bool,
cfg: CombinedConfig): seq[string] =
case translation
of "akjv", "kjv":
result = kjv.fetchPlainPassages(
reference,
keepVerseNumbers,
cfg.getVal("annotations", "all"))
of "mev":
result = mev.fetchPlainPassages(
reference,
keepVerseNumbers,
cfg.getVal("annotations", "all"))
else:
for passage in fetchPassages(reference, translation, cfg):
result.add(formatPlain(passage, translation, keepVerseNumbers))
proc fetchMarkdownPassages(
reference,
translation: string,
cfg: CombinedConfig): seq[string] =
case translation
of "akjv", "kjv":
result = kjv.fetchMarkdownPassages(
reference,
cfg.getVal("annotations", "all"))
of "mev":
result = mev.fetchMarkdownPassages(
reference,
cfg.getVal("annotations", "all"))
else:
for passage in fetchPassages(reference, translation, cfg):
result.add(formatMarkdown(passage, translation))
proc fetchAnsiPassages(
reference,
translation: string,
keepVerseNumbers: bool,
cfg: CombinedConfig): seq[string] =
case translation
of "akjv", "kjv":
result = kjv.fetchAnsiPassages(
reference,
keepVerseNumbers,
cfg.getVal("annotations", "all"))
of "mev":
result = mev.fetchAnsiPassages(
reference,
keepVerseNumbers,
cfg.getVal("annotations", "all"))
else:
for passage in fetchPassages(reference, translation, cfg):
result.add(formatPlain(passage, translation, keepVerseNumbers))
when isMainModule: when isMainModule:
const USAGE = """Usage: const USAGE = """Usage:
bibleref <reference> [options] bibleref <reference> [options]
@@ -110,7 +170,13 @@ Options:
command line for debugging purposes. command line for debugging purposes.
-f, --output-format <format> Select a specific output format. Valid values -f, --output-format <format> Select a specific output format. Valid values
are 'raw', 'markdown', 'plain', 'reading'. are 'ansi', 'raw', 'markdown', 'plain',
'reading'.
--annotations <annotations> Select embedded Bible annotations to render.
Valid values are 'none', 'all', or a
comma-separated list of annotation kinds such
as 'note,crossReference'. Defaults to 'all'.
-t, --translation <translation> -t, --translation <translation>
Select a specific translation. Supported values Select a specific translation. Supported values
@@ -138,6 +204,7 @@ Options:
--api-bible-nkjv-bible-id <id> --api-bible-nkjv-bible-id <id>
Override the API.Bible Bible ID for NKJV. Override the API.Bible Bible ID for NKJV.
""" """
const VERSION = "1.2.0"
let consoleLogger = newConsoleLogger( let consoleLogger = newConsoleLogger(
levelThreshold=lvlInfo, levelThreshold=lvlInfo,
@@ -146,7 +213,7 @@ Options:
try: try:
# Parse arguments # Parse arguments
let args = docopt(USAGE, version = "1.1.0") let args = docopt(USAGE, version = VERSION)
if args["--debug"]: if args["--debug"]:
consoleLogger.levelThreshold = lvlDebug consoleLogger.levelThreshold = lvlDebug
@@ -166,11 +233,23 @@ Options:
var formattedPassages: seq[string] = @[] var formattedPassages: seq[string] = @[]
for query in queries: for query in queries:
case $args["--output-format"]
of "ansi":
formattedPassages.add(fetchAnsiPassages(
query.referenceText,
query.translation,
keepVerseNumbers = true,
cfg))
of "plain":
formattedPassages.add(fetchPlainPassages(
query.referenceText,
query.translation,
keepVerseNumbers = true,
cfg))
of "reading", "text", "raw":
for passage in fetchPassages(query.referenceText, query.translation, cfg): for passage in fetchPassages(query.referenceText, query.translation, cfg):
formattedPassages.add( formattedPassages.add(
case $args["--output-format"]: case $args["--output-format"]:
of "plain":
formatPlain(passage, query.translation)
of "reading": of "reading":
formatPlain(passage, query.translation, keepVerseNumbers = false) formatPlain(passage, query.translation, keepVerseNumbers = false)
of "text": of "text":
@@ -179,6 +258,11 @@ Options:
passage passage
else: else:
formatMarkdown(passage, query.translation)) formatMarkdown(passage, query.translation))
else:
formattedPassages.add(fetchMarkdownPassages(
query.referenceText,
query.translation,
cfg))
echo formattedPassages.join("\p\p") echo formattedPassages.join("\p\p")
+607 -30
View File
@@ -1,19 +1,127 @@
import std/[strutils, tables] import std/[algorithm, json, strutils, tables, wordwrap]
import ./reference_parser import ./reference_parser
type BibleIndex = object type
verses: Table[string, string] SegmentBreak = enum
sbSame = "same"
sbLine = "line"
sbParagraph = "paragraph"
BibleSegment = object
code: string
chapter: int
verse: int
segment: int
breakKind: SegmentBreak
indent: int
text: string
annotations: seq[BibleAnnotation]
wordsOfChrist: bool
BibleAnnotation = object
kind: string
offset: int
targetText: string
EmbeddedOutputFormat = enum
eofPlain
eofAnsi
eofMarkdown
AnnotationFilterKind = enum
afNone
afAll
afKinds
AnnotationFilter = object
kind: AnnotationFilterKind
kinds: seq[string]
BibleIndex = object
segments: seq[BibleSegment]
verses: Table[string, bool]
lastVerseByChapter: Table[string, int] lastVerseByChapter: Table[string, int]
lastChapterByBook: Table[string, int] lastChapterByBook: Table[string, int]
translationName: string translationName: string
const
ansiReset = "\x1b[0m"
ansiDarkRed = "\x1b[31m"
ansiGray = "\x1b[90m"
ansiCyan = "\x1b[36m"
proc verseKey(code: string, chapter, verse: int): string = proc verseKey(code: string, chapter, verse: int): string =
code & "\t" & $chapter & "\t" & $verse code & "\t" & $chapter & "\t" & $verse
proc chapterKey(code: string, chapter: int): string = proc chapterKey(code: string, chapter: int): string =
code & "\t" & $chapter code & "\t" & $chapter
proc legacySegment(text: string): tuple[breakKind: SegmentBreak, text: string] =
const pilcrow = "\xC2\xB6"
let stripped = text.strip(leading = true, trailing = false)
if stripped.startsWith(pilcrow):
result.breakKind = sbParagraph
result.text = stripped[pilcrow.len .. ^1].strip(leading = true, trailing = false)
else:
result.breakKind = sbSame
result.text = text
proc parseSegmentBreak(s: string): SegmentBreak =
case s
of "same": sbSame
of "line": sbLine
of "paragraph": sbParagraph
else:
raise newException(ValueError, "invalid embedded Bible segment break: " & s)
proc parseMetadata(metadata: string):
tuple[annotations: seq[BibleAnnotation], wordsOfChrist: bool] =
if metadata.strip.len == 0:
return
let node = parseJson(metadata)
if node.kind != JObject:
return
result.wordsOfChrist =
node.hasKey("wordsOfChrist") and node["wordsOfChrist"].getBool
if 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.annotations.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
let cKey = chapterKey(segment.code, segment.chapter)
if not index.lastVerseByChapter.hasKey(cKey) or
segment.verse > index.lastVerseByChapter[cKey]:
index.lastVerseByChapter[cKey] = segment.verse
if not index.lastChapterByBook.hasKey(segment.code) or
segment.chapter > index.lastChapterByBook[segment.code]:
index.lastChapterByBook[segment.code] = segment.chapter
proc loadBibleIndex(rows, translationName: string): BibleIndex = proc loadBibleIndex(rows, translationName: string): BibleIndex =
result.translationName = translationName result.translationName = translationName
@@ -21,26 +129,36 @@ proc loadBibleIndex(rows, translationName: string): BibleIndex =
if line.strip.len == 0: if line.strip.len == 0:
continue continue
let parts = line.split('\t', maxsplit = 3) let parts = line.split('\t', maxsplit = 7)
if parts.len != 4: if parts.len != 4 and parts.len != 7 and parts.len != 8:
raise newException(ValueError, raise newException(ValueError,
"invalid embedded " & translationName & " row: " & line) "invalid embedded " & translationName & " row: " & line)
let code = parts[0] if parts.len == 4:
let chapter = parseInt(parts[1]) let legacy = legacySegment(parts[3])
let verse = parseInt(parts[2]) result.addSegment(BibleSegment(
let text = parts[3] code: parts[0],
chapter: parseInt(parts[1]),
verse: parseInt(parts[2]),
segment: 0,
breakKind: legacy.breakKind,
indent: 0,
text: legacy.text))
else:
let metadata =
if parts.len == 8: parseMetadata(parts[7])
else: (annotations: newSeq[BibleAnnotation](), wordsOfChrist: false)
result.verses[verseKey(code, chapter, verse)] = text result.addSegment(BibleSegment(
code: parts[0],
let cKey = chapterKey(code, chapter) chapter: parseInt(parts[1]),
if not result.lastVerseByChapter.hasKey(cKey) or verse: parseInt(parts[2]),
verse > result.lastVerseByChapter[cKey]: segment: parseInt(parts[3]),
result.lastVerseByChapter[cKey] = verse breakKind: parseSegmentBreak(parts[4]),
indent: parseInt(parts[5]),
if not result.lastChapterByBook.hasKey(code) or text: parts[6],
chapter > result.lastChapterByBook[code]: annotations: metadata.annotations,
result.lastChapterByBook[code] = chapter wordsOfChrist: metadata.wordsOfChrist))
proc requireLastChapter(index: BibleIndex, code: string): int = proc requireLastChapter(index: BibleIndex, code: string): int =
if not index.lastChapterByBook.hasKey(code): if not index.lastChapterByBook.hasKey(code):
@@ -62,13 +180,10 @@ proc requireVerse(index: BibleIndex, code: string, chapter, verse: int): string
raise newException(ValueError, raise newException(ValueError,
"no embedded " & index.translationName & " data for " & "no embedded " & index.translationName & " data for " &
bookInfo(code).name & " " & $chapter & ":" & $verse) bookInfo(code).name & " " & $chapter & ":" & $verse)
index.verses[vKey] ""
proc addVerseLines( proc selectedVerses(index: BibleIndex, reference: PassageReference, range: RefRange):
lines: var seq[string], Table[string, bool] =
index: BibleIndex,
reference: PassageReference,
range: RefRange) =
let code = reference.book.code let code = reference.book.code
discard index.requireLastChapter(code) discard index.requireLastChapter(code)
@@ -90,18 +205,104 @@ proc addVerseLines(
raise newException(ValueError, "reference range starts after it ends") raise newException(ValueError, "reference range starts after it ends")
for verse in startVerse .. endVerse: for verse in startVerse .. endVerse:
lines.add(" [" & $verse & "] " & index.requireVerse(code, chapter, verse)) discard index.requireVerse(code, chapter, verse)
result[verseKey(code, chapter, verse)] = true
proc shouldSeparateSegments(previous, next: string): bool =
if previous.len == 0 or next.len == 0:
return false
if previous[^1].isSpaceAscii or next[0].isSpaceAscii:
return false
next[0] notin {'.', ',', ';', ':', '?', '!', ')', ']', '}'}
proc appendSameLine(line: var string, content: string) =
if shouldSeparateSegments(line, content):
line.add(" ")
line.add(content)
proc addFormattedSegment(
lines: var seq[string],
segment: BibleSegment,
emittedVerses: var Table[string, bool]) =
let vKey = verseKey(segment.code, segment.chapter, segment.verse)
let marker =
if emittedVerses.hasKey(vKey): ""
else:
emittedVerses[vKey] = true
"[" & $segment.verse & "] "
let prefix = repeat(" ", segment.indent + 1)
let content = marker & segment.text
let hasPassageLine = lines.len > 1 and lines[^1].len > 0
if segment.breakKind == sbSame and hasPassageLine:
lines[^1].appendSameLine(content)
else:
let isMarkerlessParagraphContinuation =
segment.breakKind == sbParagraph and marker.len == 0 and hasPassageLine
if isMarkerlessParagraphContinuation:
lines[^1].appendSameLine(content)
else:
if segment.breakKind == sbParagraph and hasPassageLine:
lines.add("")
lines.add(prefix & content)
proc normalizeFirstSelectedSegment(segment: BibleSegment): BibleSegment =
result = segment
if result.breakKind == sbSame:
result.breakKind = sbLine
elif result.breakKind == sbParagraph and result.segment == 0:
result.breakKind = sbLine
proc addAllSegments(lines: var seq[string], index: BibleIndex, code: string) =
var emittedVerses: Table[string, bool]
var firstSelected = true
discard index.requireLastChapter(code)
for segment in index.segments:
if segment.code == code:
var adjusted = segment
if firstSelected:
adjusted = normalizeFirstSelectedSegment(adjusted)
lines.addFormattedSegment(adjusted, emittedVerses)
firstSelected = false
proc addRangeSeparator(lines: var seq[string]) =
if lines.len > 1 and lines[^1].len > 0:
lines.add("")
proc addVerseLines(
lines: var seq[string],
index: BibleIndex,
reference: PassageReference,
range: RefRange) =
let code = reference.book.code
let selected = index.selectedVerses(reference, range)
var emittedVerses: Table[string, bool]
var firstSelected = true
for segment in index.segments:
if segment.code == code and
selected.hasKey(verseKey(segment.code, segment.chapter, segment.verse)):
var adjusted = segment
if firstSelected:
adjusted = normalizeFirstSelectedSegment(adjusted)
lines.addFormattedSegment(adjusted, emittedVerses)
firstSelected = false
proc fetchReference(index: BibleIndex, reference: PassageReference): string = proc fetchReference(index: BibleIndex, reference: PassageReference): string =
var lines = @[$reference] var lines = @[$reference]
let code = reference.book.code let code = reference.book.code
if reference.ranges.len == 0: if reference.ranges.len == 0:
for chapter in 1 .. index.requireLastChapter(code): lines.addAllSegments(index, code)
for verse in 1 .. index.requireLastVerse(code, chapter):
lines.add(" [" & $verse & "] " & index.requireVerse(code, chapter, verse))
else: else:
for range in reference.ranges: for idx, range in reference.ranges:
if idx > 0:
lines.addRangeSeparator()
lines.addVerseLines(index, reference, range) lines.addVerseLines(index, reference, range)
lines.join("\n") lines.join("\n")
@@ -110,3 +311,379 @@ proc fetchPassages*(rows, reference, translationName: string): seq[string] =
let index = loadBibleIndex(rows, translationName) let index = loadBibleIndex(rows, translationName)
for parsedReference in parseReferences(reference): for parsedReference in parseReferences(reference):
result.add(fetchReference(index, parsedReference)) result.add(fetchReference(index, parsedReference))
proc normalizeAnnotationKind(kind: string): string =
case kind.strip
of "note":
"note"
of "crossReference", "crossreference", "cross-reference", "cross_reference":
"crossReference"
else:
raise newException(ValueError,
"unsupported annotation kind '" & kind &
"'; supported annotation kinds: note, crossReference")
proc parseAnnotationFilter(value: string): AnnotationFilter =
let normalized = value.strip
case normalized
of "", "all":
return AnnotationFilter(kind: afAll)
of "none":
return AnnotationFilter(kind: afNone)
else:
result.kind = afKinds
for part in normalized.split(','):
let annotationKind = normalizeAnnotationKind(part)
if annotationKind notin result.kinds:
result.kinds.add(annotationKind)
proc includes(filter: AnnotationFilter, annotation: BibleAnnotation): bool =
case filter.kind
of afNone:
false
of afAll:
true
of afKinds:
annotation.kind in filter.kinds
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 annotationMarker(
label: string,
outputFormat: EmbeddedOutputFormat,
resumeWordsOfChrist: bool): string =
case outputFormat
of eofPlain:
"[" & label & "]"
of eofAnsi:
ansiCyan & "[" & label & "]" & ansiReset &
(if resumeWordsOfChrist: ansiDarkRed else: "")
of eofMarkdown:
"[^" & label & "]"
proc visibleLen(s: string): int =
var idx = 0
while idx < s.len:
if s[idx] == '\x1b' and idx + 1 < s.len and s[idx + 1] == '[':
idx += 2
while idx < s.len and not s[idx].isAlphaAscii:
inc idx
if idx < s.len:
inc idx
else:
inc result
inc idx
proc wrapAnsiWords(line: string, maxLineWidth = 74): string =
let indentLen = line.len - line.strip(leading = true, trailing = false).len
let indent = repeat(" ", indentLen)
let words = line.strip.splitWhitespace
if words.len == 0:
return ""
var lines: seq[string]
var current = indent
for word in words:
let separator =
if current.strip.len == 0: ""
else: " "
if current.strip.len > 0 and
current.visibleLen + separator.len + word.visibleLen > maxLineWidth:
lines.add(current)
current = word
else:
current.add(separator & word)
if current.len > 0:
lines.add(current)
lines.join("\p")
proc addSegmentWithFootnotes(
line: var string,
segment: BibleSegment,
keepVerseNumbers: bool,
notes: var seq[string],
outputFormat: EmbeddedOutputFormat,
annotationFilter: AnnotationFilter) =
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:
if not annotationFilter.includes(annotation):
continue
let label = footnoteLabel(notes.len)
notes.add(annotation.targetText)
let insertAt = max(0, min(text.len, annotation.offset + inserted))
let marker = annotationMarker(
label,
outputFormat,
resumeWordsOfChrist = segment.wordsOfChrist)
text.insert(marker, insertAt)
inserted += marker.len
if outputFormat == eofAnsi and segment.wordsOfChrist:
text = ansiDarkRed & text & ansiReset
let marker =
if keepVerseNumbers:
case outputFormat
of eofPlain: $segment.verse & " "
of eofAnsi: ansiGray & $segment.verse & ansiReset & " "
of eofMarkdown: "**" & $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],
outputFormat: EmbeddedOutputFormat) =
if notes.len == 0:
return
lines.add("")
for idx, note in notes:
let label = footnoteLabel(idx)
lines.add(
case outputFormat
of eofPlain: label & ". " & note
of eofAnsi: ansiCyan & label & ". " & note & ansiReset
of eofMarkdown: "[^" & label & "]: " & 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 formattedChapter(
index: BibleIndex,
code: string,
selected: Table[string, bool],
keepVerseNumbers: bool,
outputFormat: EmbeddedOutputFormat,
annotationFilter: AnnotationFilter): 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,
outputFormat,
annotationFilter)
else:
var noVerseSegment = renderSegment
noVerseSegment.text = renderSegment.text
noVerseSegment.breakKind = sbSame
currentLine.addSegmentWithFootnotes(
noVerseSegment,
false,
notes,
outputFormat,
annotationFilter)
firstSelected = false
flushLine()
lines.addFootnotes(notes, outputFormat)
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 & " "
if outputFormat == eofAnsi:
wrapped.add(wrapAnsiWords(prepared))
else:
wrapped.add(wrapWords(prepared, maxLineWidth = 74, newLine = "\p"))
wrapped.join("\p")
proc fetchFormattedPassages(
rows,
reference,
translationName: string,
keepVerseNumbers: bool,
outputFormat: EmbeddedOutputFormat,
annotations: string): seq[string] =
let index = loadBibleIndex(rows, translationName)
let annotationFilter = parseAnnotationFilter(annotations)
for parsedReference in parseReferences(reference):
var parts: seq[string]
for chapter in index.selectedChapters(parsedReference):
parts.add(index.formattedChapter(
parsedReference.book.code,
chapter.verses,
keepVerseNumbers,
outputFormat,
annotationFilter))
let body = parts.join("\p\p")
let referenceSeparator =
case outputFormat
of eofPlain, eofAnsi:
if body.contains("\pa. "): "\p\p"
elif body.contains("\p" & ansiCyan & "a. "): "\p\p"
else: "\p"
of eofMarkdown:
if body.contains("\p[^a]: "): "\p\p"
else: "\p"
let referenceLine =
case outputFormat
of eofPlain, eofAnsi:
" " & $parsedReference & " (" & translationName.toUpperAscii & ")"
of eofMarkdown:
"> -- *" & $parsedReference & " (" &
translationName.toUpperAscii & ")*"
let formattedBody =
case outputFormat
of eofPlain, eofAnsi:
body
of eofMarkdown:
var markdownLines: seq[string]
for line in body.splitLines:
if line.len == 0:
markdownLines.add("")
elif line.startsWith("[^"):
markdownLines.add(line)
else:
markdownLines.add("> " & line)
markdownLines.join("\p")
result.add(formattedBody & referenceSeparator & referenceLine)
proc fetchPlainPassages*(
rows,
reference,
translationName: string,
keepVerseNumbers = true,
annotations = "all"): seq[string] =
fetchFormattedPassages(
rows,
reference,
translationName,
keepVerseNumbers,
eofPlain,
annotations)
proc fetchAnsiPassages*(
rows,
reference,
translationName: string,
keepVerseNumbers = true,
annotations = "all"): seq[string] =
fetchFormattedPassages(
rows,
reference,
translationName,
keepVerseNumbers,
eofAnsi,
annotations)
proc fetchMarkdownPassages*(
rows,
reference,
translationName: string,
annotations = "all"): seq[string] =
fetchFormattedPassages(
rows,
reference,
translationName,
keepVerseNumbers = true,
eofMarkdown,
annotations)
+27
View File
@@ -5,3 +5,30 @@ const kjvRows = embeddedTranslationData("kjv")
proc fetchPassages*(reference: string): seq[string] = proc fetchPassages*(reference: string): seq[string] =
embedded_bible.fetchPassages(kjvRows, reference, "KJV") embedded_bible.fetchPassages(kjvRows, reference, "KJV")
proc fetchPlainPassages*(
reference: string,
keepVerseNumbers = true,
annotations = "all"): seq[string] =
embedded_bible.fetchPlainPassages(
kjvRows,
reference,
"KJV",
keepVerseNumbers,
annotations)
proc fetchMarkdownPassages*(reference: string, annotations = "all"): seq[string] =
embedded_bible.fetchMarkdownPassages(kjvRows, reference, "KJV", annotations)
proc fetchAnsiPassages*(
reference: string,
keepVerseNumbers = true,
annotations = "all"): seq[string] =
embedded_bible.fetchAnsiPassages(
kjvRows,
reference,
"KJV",
keepVerseNumbers,
annotations)
+39
View File
@@ -11,3 +11,42 @@ proc fetchPassages*(reference: string): seq[string] =
else: else:
raise newException(ValueError, raise newException(ValueError,
"MEV data is not embedded; generate data/private/mev.tsv and rebuild") "MEV data is not embedded; generate data/private/mev.tsv and rebuild")
proc fetchPlainPassages*(
reference: string,
keepVerseNumbers = true,
annotations = "all"): seq[string] =
when hasEmbeddedTranslationData("mev"):
embedded_bible.fetchPlainPassages(
mevRows,
reference,
"MEV",
keepVerseNumbers,
annotations)
else:
raise newException(ValueError,
"MEV data is not embedded; generate data/private/mev.tsv and rebuild")
proc fetchMarkdownPassages*(reference: string, annotations = "all"): seq[string] =
when hasEmbeddedTranslationData("mev"):
embedded_bible.fetchMarkdownPassages(mevRows, reference, "MEV", annotations)
else:
raise newException(ValueError,
"MEV data is not embedded; generate data/private/mev.tsv and rebuild")
proc fetchAnsiPassages*(
reference: string,
keepVerseNumbers = true,
annotations = "all"): seq[string] =
when hasEmbeddedTranslationData("mev"):
embedded_bible.fetchAnsiPassages(
mevRows,
reference,
"MEV",
keepVerseNumbers,
annotations)
else:
raise newException(ValueError,
"MEV data is not embedded; generate data/private/mev.tsv and rebuild")
+243
View File
@@ -1,5 +1,6 @@
import std/[strutils, unittest] import std/[strutils, unittest]
import ../src/embedded_bible
import ../src/kjv import ../src/kjv
import ../src/reference_parser import ../src/reference_parser
@@ -82,3 +83,245 @@ suite "offline KJV backend":
check passages.len == 1 check passages.len == 1
check passages[0].startsWith("Jude 3\n") check passages[0].startsWith("Jude 3\n")
check passages[0].contains(" [3] ") check passages[0].contains(" [3] ")
suite "embedded Bible layout":
const layoutRows = """
JHN 8 31 0 paragraph 0 Then Jesus said,
JHN 8 31 1 same 0 "If you remain in My word,
JHN 8 32 0 same 0 you shall know the truth."
JHN 8 33 0 paragraph 0 They answered Him.
JHN 8 39 0 paragraph 0 They answered Him, "Abraham is our father."
JHN 8 39 1 paragraph 0 Jesus said to them, "If you were Abraham's children,
JHN 8 40 0 same 0 you would do the works of Abraham."
JHN 8 41 0 paragraph 0 You shall be free'
JHN 8 41 1 same 0 ?
HEB 4 7 0 paragraph 0 again He establishes a certain day:
HEB 4 7 1 line 0 "Today, if you will hear His voice,
HEB 4 7 2 line 1 do not harden your hearts."
"""
test "preserves inline verse markers within a paragraph":
let passages = embedded_bible.fetchPassages(layoutRows, "John 8:31-32", "TEST")
check passages.len == 1
check passages[0] == "John 8:31-32\n" &
" [31] Then Jesus said, \"If you remain in My word, [32] you shall know the truth.\""
test "preserves paragraph breaks":
let passages = embedded_bible.fetchPassages(layoutRows, "John 8:31-33", "TEST")
check passages[0] == "John 8:31-33\n" &
" [31] Then Jesus said, \"If you remain in My word, [32] you shall know the truth.\"\n" &
"\n" &
" [33] They answered Him."
test "preserves indented quote lines":
let passages = embedded_bible.fetchPassages(layoutRows, "Hebrews 4:7", "TEST")
check passages[0] == "Hebrews 4:7\n" &
" [7] again He establishes a certain day:\n" &
" \"Today, if you will hear His voice,\n" &
" do not harden your hearts.\""
test "starts mid-paragraph ranges on a fresh line":
let passages = embedded_bible.fetchPassages(layoutRows, "John 8:32-33", "TEST")
check passages[0] == "John 8:32-33\n" &
" [32] you shall know the truth.\"\n" &
"\n" &
" [33] They answered Him."
test "joins markerless paragraph continuations":
let passages = embedded_bible.fetchPassages(layoutRows, "John 8:39-40", "TEST")
check passages[0] == "John 8:39-40\n" &
" [39] They answered Him, \"Abraham is our father.\" " &
"Jesus said to them, \"If you were Abraham's children, " &
"[40] you would do the works of Abraham.\""
test "joins punctuation-only segments without extra spacing":
let passages = embedded_bible.fetchPassages(layoutRows, "John 8:41", "TEST")
check passages[0] == "John 8:41\n [41] You shall be free'?"
test "supports legacy verse rows":
const legacyRows = "JHN\t3\t16\tFor God so loved the world.\n"
let passages = embedded_bible.fetchPassages(legacyRows, "John 3:16", "TEST")
check passages[0] == "John 3:16\n [16] For God so loved the world."
test "ignores layout metadata while rendering":
const metadataRows =
"JHN\t8\t31\t0\tparagraph\t0\tJesus said.\t" &
"""{"wordsOfChrist":true,"annotations":[{"kind":"crossReference","marker":"a","href":"index_split_1.html#x","offset":6}]}""" &
"\n"
let passages = embedded_bible.fetchPassages(metadataRows, "John 8:31", "TEST")
check passages[0] == "John 8:31\n [31] Jesus said."
test "groups legacy rows into paragraphs using pilcrows":
const legacyRows =
"JHN\t8\t31\tThen Jesus said.\n" &
"JHN\t8\t32\tYou shall know the truth.\n" &
"JHN\t8\t33\t\xC2\xB6 They answered Him.\n"
let passages = embedded_bible.fetchPassages(legacyRows, "John 8:31-33", "TEST")
check passages[0] == "John 8:31-33\n" &
" [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 can suppress annotation footnotes":
const rows =
"JHN\t8\t1\t0\tparagraph\t0\tJesus spoke.\t" &
"""{"annotations":[{"kind":"crossReference","offset":12,"targetText":"Mt 21:1"}]}""" & "\n"
let passages = embedded_bible.fetchPlainPassages(
rows,
"John 8:1",
"TEST",
annotations = "none")
check passages[0] == "1 Jesus spoke.\n" &
" John 8:1 (TEST)"
test "plain output filters annotation footnotes by kind":
const rows =
"JHN\t8\t1\t0\tparagraph\t0\tJesus spoke plainly.\t" &
"""{"annotations":[{"kind":"note","offset":20,"targetText":"A note."}]}""" & "\n" &
"JHN\t8\t2\t0\tsame\t0\tHe taught.\t" &
"""{"annotations":[{"kind":"crossReference","offset":10,"targetText":"Mt 26:55"}]}""" & "\n"
let notesOnly = embedded_bible.fetchPlainPassages(
rows,
"John 8:1-2",
"TEST",
annotations = "note")
let crossReferencesOnly = embedded_bible.fetchPlainPassages(
rows,
"John 8:1-2",
"TEST",
annotations = "crossReference")
let both = embedded_bible.fetchPlainPassages(
rows,
"John 8:1-2",
"TEST",
annotations = "note,crossReference")
check notesOnly[0] == "1 Jesus spoke plainly.[a] 2 He taught.\n" &
"\n" &
"a. A note.\n" &
"\n" &
" John 8:1-2 (TEST)"
check crossReferencesOnly[0] == "1 Jesus spoke plainly. 2 He taught.[a]\n" &
"\n" &
"a. Mt 26:55\n" &
"\n" &
" John 8:1-2 (TEST)"
check both[0] == "1 Jesus spoke plainly.[a] 2 He taught.[b]\n" &
"\n" &
"a. A note.\n" &
"b. Mt 26:55\n" &
"\n" &
" John 8:1-2 (TEST)"
test "rejects unknown annotation filter kinds":
const rows =
"JHN\t8\t1\t0\tparagraph\t0\tJesus spoke.\t" &
"""{"annotations":[{"kind":"note","offset":12,"targetText":"A note."}]}""" & "\n"
expect ValueError:
discard embedded_bible.fetchPlainPassages(
rows,
"John 8:1",
"TEST",
annotations = "gloss")
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)"
test "markdown output renders native 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.fetchMarkdownPassages(rows, "John 8:1-2", "TEST")
check passages[0] == "> **1** But Jesus went to the Mount of Olives.[^a] **2** Early in the\n" &
"> morning He 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 "markdown output resets native footnotes 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.fetchMarkdownPassages(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)*"
test "ansi output renders verse numbers in gray and words of Christ in dark red":
const rows =
"JHN\t8\t1\t0\tparagraph\t0\tJesus said.\t" &
"""{"wordsOfChrist":true}""" & "\n"
let passages = embedded_bible.fetchAnsiPassages(rows, "John 8:1", "TEST")
check passages[0] == "\x1b[90m1\x1b[0m \x1b[31mJesus said.\x1b[0m\n" &
" John 8:1 (TEST)"
test "ansi output renders footnotes in cyan":
const rows =
"JHN\t8\t1\t0\tparagraph\t0\tJesus said.\t" &
"""{"wordsOfChrist":true,"annotations":[{"kind":"crossReference","offset":11,"targetText":"Mt 21:1"}]}""" &
"\n"
let passages = embedded_bible.fetchAnsiPassages(rows, "John 8:1", "TEST")
check passages[0] == "\x1b[90m1\x1b[0m \x1b[31mJesus said.\x1b[36m[a]\x1b[0m\x1b[31m\x1b[0m\n" &
"\n" &
"\x1b[36ma. Mt 21:1\x1b[0m\n" &
"\n" &
" John 8:1 (TEST)"
+52 -13
View File
@@ -84,6 +84,22 @@ proc parseVerseLine(line: string): tuple[verse: int, text: string] =
result.verse = parseInt(rest[0 ..< numberEnd]) result.verse = parseInt(rest[0 ..< numberEnd])
result.text = stripUsfmMarkup(rest[numberEnd + 1 .. ^1]) result.text = stripUsfmMarkup(rest[numberEnd + 1 .. ^1])
proc usfmBlock(line: string): tuple[matched: bool, segmentBreak: string, indent: int] =
if line.startsWith("\\p") or line.startsWith("\\m"):
return (true, "paragraph", 0)
if line.startsWith("\\q"):
var idx = 2
var digits = ""
while idx < line.len and line[idx].isDigit:
digits.add(line[idx])
inc idx
let indent =
if digits.len > 0: parseInt(digits)
else: 1
return (true, "line", indent)
proc findCanonFiles(inputDir: string): Table[string, string] = proc findCanonFiles(inputDir: string): Table[string, string] =
for path in walkFiles(inputDir / "*eng-kjv.usfm"): for path in walkFiles(inputDir / "*eng-kjv.usfm"):
let name = path.extractFilename let name = path.extractFilename
@@ -104,35 +120,58 @@ proc generate(inputDir, outputPath: string) =
var chapter = 0 var chapter = 0
var verse = 0 var verse = 0
var verseText = "" var segment = 0
var segmentText = ""
var segmentBreak = "line"
var indent = 0
proc flushVerse() = proc flushSegment() =
if chapter > 0 and verse > 0: if chapter > 0 and verse > 0:
let text = normalizeWhitespace(verseText).replace("\t", " ") let text = normalizeWhitespace(segmentText).replace("\t", " ")
if text.len > 0: if text.len > 0:
rows.add([code, $chapter, $verse, text].join("\t")) rows.add([
verse = 0 code,
verseText = "" $chapter,
$verse,
$segment,
segmentBreak,
$indent,
text
].join("\t"))
inc segment
segmentBreak = "same"
segmentText = ""
proc startBlock(nextBreak: string, nextIndent: int) =
flushSegment()
segmentBreak = nextBreak
indent = nextIndent
for rawLine in canonFiles[code].lines: for rawLine in canonFiles[code].lines:
let line = rawLine.strip let line = rawLine.strip
if line.startsWith("\\c "): if line.startsWith("\\c "):
flushVerse() flushSegment()
chapter = parseInt(line[3..^1].strip) chapter = parseInt(line[3..^1].strip)
verse = 0
elif line.startsWith("\\v "): elif line.startsWith("\\v "):
flushVerse() flushSegment()
let parsed = parseVerseLine(line) let parsed = parseVerseLine(line)
verse = parsed.verse verse = parsed.verse
verseText = parsed.text segment = 0
segmentText = parsed.text
elif verse > 0: elif verse > 0:
let block = usfmBlock(line)
if block.matched:
startBlock(block.segmentBreak, block.indent)
let continued = stripUsfmMarkup(line) let continued = stripUsfmMarkup(line)
if continued.len > 0: if continued.len > 0:
if verseText.len > 0: if segmentText.len > 0:
verseText.add(' ') segmentText.add(' ')
verseText.add(continued) segmentText.add(continued)
flushVerse() flushSegment()
createDir(outputPath.parentDir) createDir(outputPath.parentDir)
writeFile(outputPath, rows.join("\n") & "\n") writeFile(outputPath, rows.join("\n") & "\n")
+183 -17
View File
@@ -1,9 +1,11 @@
import std/[ import std/[
htmlparser, htmlparser,
json,
os, os,
osproc, osproc,
streams, streams,
strutils, strutils,
tables,
xmlparser, xmlparser,
xmltree xmltree
] ]
@@ -25,7 +27,15 @@ type
code: string code: string
chapter: int chapter: int
verse: int verse: int
verseText: string segment: int
segmentText: string
segmentBreak: string
indent: int
wordsOfChrist: bool
annotations: seq[JsonNode]
epubPath: string
targetTextCache: Table[string, string]
targetFileCache: Table[string, Table[string, string]]
rows: seq[string] rows: seq[string]
proc normalizeWhitespace(s: string): string = proc normalizeWhitespace(s: string): string =
@@ -194,9 +204,107 @@ proc hasHref(node: XmlNode): bool =
if hasHref(child): if hasHref(child):
return true return true
proc isBlockElement(node: XmlNode): bool = proc firstHref(node: XmlNode): string =
node.kind == xnElement and if node.kind == xnElement:
node.tag in ["blockquote", "br", "div", "h1", "h2", "h3", "li", "p"] let href = node.attr("href")
if href.len > 0:
return href
for child in node.items:
let href = firstHref(child)
if href.len > 0:
return href
proc collectIds(node: XmlNode, ids: var seq[string]) =
if node.kind == xnElement:
let id = node.attr("id")
if id.len > 0:
ids.add(id)
for child in node.items:
collectIds(child, ids)
proc targetParagraphs(doc: XmlNode): Table[string, string] =
var paragraphs = initTable[string, string]()
proc walk(node: XmlNode) =
if node.kind == xnElement:
if node.tag == "p":
var ids: seq[string] = @[]
collectIds(node, ids)
if ids.len > 0:
let text = normalizeWhitespace(textContent(node))
for id in ids:
paragraphs[id] = text
for child in node.items:
walk(child)
walk(doc)
paragraphs
proc linkTargetText(state: var ParseState, href: string): string =
if href.len == 0:
return ""
if state.targetTextCache.hasKey(href):
return state.targetTextCache[href]
let parts = href.split('#', maxsplit = 1)
if parts.len != 2 or parts[0].len == 0 or parts[1].len == 0:
state.targetTextCache[href] = ""
return ""
if not state.targetFileCache.hasKey(parts[0]):
try:
let html = readEpubEntry(state.epubPath, parts[0])
let doc = parseHtml(newStringStream(html))
state.targetFileCache[parts[0]] = targetParagraphs(doc)
except CatchableError:
state.targetFileCache[parts[0]] = initTable[string, string]()
if state.targetFileCache[parts[0]].hasKey(parts[1]):
result = state.targetFileCache[parts[0]][parts[1]]
else:
result = ""
state.targetTextCache[href] = result
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": kind,
"offset": offset
}
if cleanedTargetText.len > 0:
result["targetText"] = newJString(cleanedTargetText)
proc segmentMetadata(state: ParseState): JsonNode =
result = newJObject()
if state.wordsOfChrist:
result["wordsOfChrist"] = newJBool(true)
if state.annotations.len > 0:
result["annotations"] = %state.annotations
proc isParagraphElement(node: XmlNode): bool =
node.kind == xnElement and node.tag == "p" and
(node.hasClass("calibre_18") or
node.hasClass("calibre_20") or
node.hasClass("calibre_23") or
node.hasClass("calibre_34"))
proc chapterMarker(node: XmlNode): int = proc chapterMarker(node: XmlNode): int =
if node.kind == xnElement and node.tag == "span" and node.hasClass("calibre1"): if node.kind == xnElement and node.tag == "span" and node.hasClass("calibre1"):
@@ -243,13 +351,31 @@ proc leadingVerseText(s: string): tuple[verse: int, rest: string] =
if idx < text.len: if idx < text.len:
result.rest = text[idx .. ^1] result.rest = text[idx .. ^1]
proc flushVerse(state: var ParseState) = proc flushSegment(state: var ParseState) =
if state.chapter > 0 and state.verse > 0: if state.chapter > 0 and state.verse > 0:
let text = normalizeWhitespace(state.verseText).replace("\t", " ") let text = normalizeWhitespace(state.segmentText).replace("\t", " ")
if text.len > 0: if text.len > 0:
state.rows.add([state.code, $state.chapter, $state.verse, text].join("\t")) let metadata = segmentMetadata(state)
state.rows.add([
state.code,
$state.chapter,
$state.verse,
$state.segment,
state.segmentBreak,
$state.indent,
text,
$metadata
].join("\t"))
inc state.segment
state.segmentBreak = "same"
state.verseText = "" state.segmentText = ""
state.wordsOfChrist = false
state.annotations = @[]
proc startBlock(state: var ParseState, segmentBreak: string) =
state.flushSegment()
state.segmentBreak = segmentBreak
proc walkPassageText(node: XmlNode, state: var ParseState) = proc walkPassageText(node: XmlNode, state: var ParseState) =
case node.kind case node.kind
@@ -259,13 +385,14 @@ proc walkPassageText(node: XmlNode, state: var ParseState) =
let leading = leadingVerseText(node.text) let leading = leadingVerseText(node.text)
if leading.verse > 0: if leading.verse > 0:
state.verse = leading.verse state.verse = leading.verse
state.verseText.add(leading.rest) state.segment = 0
state.segmentText.add(leading.rest)
elif state.verse > 0: elif state.verse > 0:
state.verseText.add(node.text) state.segmentText.add(node.text)
of xnElement: of xnElement:
let headingChapter = headingChapterMarker(node, state.code) let headingChapter = headingChapterMarker(node, state.code)
if headingChapter > 0: if headingChapter > 0:
state.flushVerse() state.flushSegment()
state.chapter = headingChapter state.chapter = headingChapter
state.verse = 0 state.verse = 0
return return
@@ -275,25 +402,64 @@ proc walkPassageText(node: XmlNode, state: var ParseState) =
let chapter = chapterMarker(node) let chapter = chapterMarker(node)
if chapter > 0: if chapter > 0:
state.flushVerse() state.flushSegment()
state.chapter = chapter state.chapter = chapter
state.verse = 1 state.verse = 1
state.segment = 0
return return
let verse = verseMarker(node) let verse = verseMarker(node)
if verse > 0: if verse > 0:
state.flushVerse() state.flushSegment()
state.verse = verse state.verse = verse
state.segment = 0
return
if node.tag == "sup" and node.hasHref:
let marker = markerText(textContent(node))
let href = firstHref(node)
if marker.len > 0 and href.len > 0:
state.annotations.add(annotationNode(
marker,
href,
state.linkTargetText(href),
normalizeWhitespace(state.segmentText).len))
return return
if node.tag == "sup": if node.tag == "sup":
return return
if node.hasClass("calibre_40"):
state.flushSegment()
let oldWordsOfChrist = state.wordsOfChrist
state.wordsOfChrist = true
for child in node.items: for child in node.items:
walkPassageText(child, state) walkPassageText(child, state)
state.flushSegment()
state.wordsOfChrist = oldWordsOfChrist
return
if node.isBlockElement and state.chapter > 0 and state.verse > 0: if node.tag == "p":
state.verseText.add(' ') let segmentBreak =
if node.isParagraphElement: "paragraph"
else: "line"
state.startBlock(segmentBreak)
for child in node.items:
walkPassageText(child, state)
state.flushSegment()
elif node.tag == "blockquote":
let oldIndent = state.indent
state.indent = oldIndent + 1
state.startBlock("line")
for child in node.items:
walkPassageText(child, state)
state.flushSegment()
state.indent = oldIndent
elif node.tag == "br":
state.startBlock("line")
else:
for child in node.items:
walkPassageText(child, state)
else: else:
discard discard
@@ -301,7 +467,7 @@ proc indexSplitFile(index: int): string =
"index_split_" & align($index, 3, '0') & ".html" "index_split_" & align($index, 3, '0') & ".html"
proc parseBook(epubPath: string, source: BookSource): seq[string] = proc parseBook(epubPath: string, source: BookSource): seq[string] =
var state = ParseState(code: source.code) var state = ParseState(code: source.code, epubPath: epubPath)
if bookInfo(source.code).singleChapter: if bookInfo(source.code).singleChapter:
state.chapter = 1 state.chapter = 1
@@ -310,7 +476,7 @@ proc parseBook(epubPath: string, source: BookSource): seq[string] =
let doc = parseHtml(newStringStream(html)) let doc = parseHtml(newStringStream(html))
walkPassageText(doc, state) walkPassageText(doc, state)
state.flushVerse() state.flushSegment()
state.rows state.rows
proc generate(epubPath, outputPath: string) = proc generate(epubPath, outputPath: string) =