Guidance for AI agents (and humans) working in this project.
A small Rust CLI, jw-bible-crossref-graph, that turns a JW Library Study Bible
publication (nwtsty_X.jwpub) into a circular cross-reference graphic — the 66 Bible books
arranged around a ring, with every marginal cross-reference drawn as a chord through the circle,
coloured by source book, and Old↔New Testament links highlighted.
It produces a CSV edge list, an SVG, a 300-dpi PNG, a small cropped README thumbnail, and a self-contained interactive HTML viewer. It ships as a single static-ish binary (the only native dependency, SQLite, is bundled and compiled in).
This was originally a Python/matplotlib script; it was rewritten in Rust so it can ship as cross-platform release binaries. Behaviour, flags, output filenames and the layout are preserved.
cargo build --release # binary at target/release/jw-bible-crossref-graph
cargo run --release -- nwtsty_X.jwpub # explicit input
cargo run --release -- # auto-finds the first *.jwpub in the cwd
cargo run --release -- --lang en # English labels/UI/CSV headers (default: de)
cargo run --release -- --formats png-sm,html # produce only some outputs (default: all)
cargo fmt --all --check # CI gate
cargo clippy --all-targets -- -D warnings # CI gate
cargo test # pure-function unit tests (no .jwpub needed)CLI (clap derive in src/main.rs):
--lang {de,en}selects the activeSTRINGSentry intobible.lang(book names, abbreviations, titles, OT/NT labels, HTML UI strings, CSV header). Defaultde.--formatsis a comma-separated subset ofALL_FORMATS = [db, csv, svg, png, png-sm, html], orall(default). It sets theFormatsbools.dbcontrols whether the extracted.dbis kept (it's always extracted, then deleted unless requested).
Outputs are written next to the input .jwpub: nwtsty_X.db, bible_crossrefs.csv,
bible_circle.svg, bible_circle.png, bible_circle_sm.png (small cropped README thumbnail,
re-rendered at decreasing DPI until it fits cfg::PNG_SM_MAX_BYTES), bible_circle.html.
A .jwpub is a ZIP containing manifest.json + a contents blob. contents is itself a plain
ZIP (PK header, not encrypted) holding a ~49 MB plaintext SQLite database (nwtsty_X.db).
jwpub::extract_db() does outer-unzip → read manifest.json for the db filename
(publication.fileName) → inner-unzip → write the .db.
Relevant tables (queried in Bible::load):
BibleCitation— the cross-references = the graph edges.BibleVerseId= source verse;FirstBibleVerseId/LastBibleVerseId= target verse range (the code uses the midpoint);MarginalClassification(0–3) = reference type. Only rows with a non-null source and target are used (~65,600 edges; ~7,570 are Old↔New).BibleBook(66 rows) —FirstVerseId/LastVerseIdper book. Books 1–39 = Old Testament, 40–66 = New Testament.BibleVerse— every verse has a global linear indexBibleVerseId(0–31193), used as the angular coordinate on the ring.BibleChapter(BookNumber,ChapterNumber,FirstVerseId,LastVerseId) — used to turn a verse id back into a label likeJes 7:14.
This particular publication is the German Study Bible (Studienbibel). The .jwpub supplies
only verse ids; book names and all UI text come from the STRINGS table, so --lang only
relabels the graphic (default German) — it does not translate any Bible text.
main.rs runs a linear pipeline (extract → load → csv → static(svg/png) → png-sm → html):
src/jwpub.rs—extract_db()(zip-in-zip → SQLite file) via thezip+serde_jsoncrates.src/bible.rs— theBiblestruct andBible::load()(3rusqliteSELECTs), pluslayout_books()(assigns each book an angular wedge),book_idx_of(partition_point= Python'sbisect_right),verse_angle,verse_label,verse_xy. Also thecfgmodule of geometry/style consts,chord_control,hsv_to_rgb/book_hue_rgb/hex, and#[cfg(test)]unit tests.src/render.rs— the renderers.build_svg(&Bible, draw_text) -> Stringis the shared renderer (replaces the old matplotlibbuild_figure): faint all-edges layer + highlighted Old↔New layer + a coloured rim arc per book, plus labels/title whendraw_text.svg_to_pixmaprasterizes via resvg/usvg/tiny-skia;crop_nonwhitetrims the white border for the thumbnail.render_static(SVG+PNG),render_png_sm(DPI-ladder byte budget),render_html(label-free PNG background base64-embedded + interactive Old↔New<path>s with<title>tooltips + vector labels + vanilla JS). Text is rendered with a host system font picked bytext_options()/pick_sans_family()(no font is bundled).src/strings.rs—enum Lang, theStringsstruct (#[derive(Deserialize)]), and aLazyLockper language that parses the embedded YAML (include_str!("../i18n/<lang>.yaml")) viaserde_yaml_ng, validating 66abbr/nameentries + 6csv_headercolumns.N_OT_BOOKS = 39.i18n/<lang>.yaml— the actual translations (titles, OT/NT, HTML UI strings,csv_header, and the 66-bookabbr/namelists). Edited without touching Rust; embedded at build time.
- The Old Testament fills the left half
[90°+GAP .. 270°−GAP]; the New Testament fills the right half[270°+GAP .. 450°−GAP]. The two seam gaps land at top (Offenbarung↔1.Mose) and bottom (Maleachi↔Matthäus). - Within each half, every book gets a wedge proportional to its verse count, separated by
BOOK_GAP_DEG. The NT addsBRACKET_GAP_DEGbefore Matthäus and after Johannes to bracket the four Gospels. - Chords are quadratic Béziers with the control point pulled toward the centre by
BOW.
Geometry/style live in the cfg module in src/bible.rs: R, L (canvas half-extent — increase
if long labels clip), GAP_DEG, BOOK_GAP_DEG, BRACKET_GAP_DEG, BOW; FAINT_ALPHA/FAINT_LW,
HL_ALPHA/HL_LW, RIM_LW, LABEL_FONTSIZE; FIG_INCHES, DPI; and the thumbnail knobs
PNG_SM_MAX_BYTES / PNG_SM_DPIS. Stroke widths are matplotlib point sizes converted to world
units via PT2WORLD (the consts in render.rs) — this is the main lever if chord density looks off.
Localisation lives in i18n/<lang>.yaml (parsed by src/strings.rs).
- Coordinate transforms: world space is y-up; the SVG is y-down, so
render.rsnegates y on output. Rim arcs use the SVGAcommand withlarge-arc=0,sweep=1— if a rim bows the wrong way, that's the flag to flip. - resvg/usvg/tiny-skia/fontdb must stay a matched set (resvg re-exports its own usvg/tiny-skia).
Bump them together;
dependabot.ymlgroups them. - Fonts come from the host system (
fontdb::load_system_fonts); no font is bundled.pick_sans_familychooses the first installed of Arial/Helvetica/Liberation Sans/DejaVu Sans/… Renders are therefore not byte-reproducible across machines, and a fontless host yields a graphic with no text labels (chords/arcs still render). The<text>carries nofont-family, so usvg falls back toOptions.font_family. - The SVG/PNG contain ~65k chord paths, so the in-memory SVG is ~9–16 MB and rendering takes a few seconds; that's expected. The HTML keeps only the ~7.5k Old↔New chords interactive (the background is a raster).
- Releases:
release-please(config in.github/) drives versioning from Conventional Commits; theReleaseworkflow builds the 7 platform binaries withcrossand uploads them.Cargo.lockis committed (this is a binary).