diff options
Diffstat (limited to 'lib/citation')
| -rw-r--r-- | lib/citation/book.ml | 107 | ||||
| -rw-r--r-- | lib/citation/book.mli | 20 | ||||
| -rw-r--r-- | lib/citation/parse.ml | 105 | ||||
| -rw-r--r-- | lib/citation/parse.mli | 9 | ||||
| -rw-r--r-- | lib/citation/render.ml | 8 |
5 files changed, 203 insertions, 46 deletions
diff --git a/lib/citation/book.ml b/lib/citation/book.ml index 986a898..b43cdd8 100644 --- a/lib/citation/book.ml +++ b/lib/citation/book.ml @@ -19,26 +19,26 @@ let table = ("kings_3", [ "3 Kings"; "3 Kgs."; "3 Reg"; "Liber Regum III" ]); ("kings_4", [ "4 Kings"; "4 Reg"; "Liber Regum IV"; "4 Kgs." ]); ("esdras_2", [ "2 Esd."; "2 Esdr"; "Liber Esdrae"; "2 Esdras" ]); - ("tobit", [ "Tob"; "Liber Tobiae"; "Tobias" ]); + ("tobit", [ "Tob"; "Liber Tobiae"; "Tobias"; "Tobit" ]); ("judith", [ "Judith"; "Iudith"; "Liber Iudith"; "Jth" ]); ("esther", [ "Esther"; "Esth"; "Liber Esther" ]); ("proverbs", [ "Prov"; "Proverbs" ]); - ("song_of_songs", [ "Song"; "Cant."; "Canticle of Canticles" ]); + ("song_of_songs", [ "Song"; "Cant."; "Canticle of Canticles"; "Song of Solomon" ]); ("wisdom", [ "Wis"; "Wis."; "Sap"; "Liber Sapientiae"; "Wisdom" ]); ("ecclesiasticus", [ "Ecclus"; "Sir"; "Eccli"; "Ecclesiasticus" ]); - ("isaiah", [ "Isa"; "Isa."; "Isai"; "Isaias Propheta"; "Isaias" ]); - ("jeremiah", [ "Jer"; "Ier"; "Ieremias Propheta"; "Jeremias" ]); - ("ezekiel", [ "Ezech"; "Ezek"; "Ezechiel Propheta"; "Ezechiel" ]); + ("isaiah", [ "Isa"; "Isa."; "Isai"; "Isaias Propheta"; "Isaias"; "Isaiah" ]); + ("jeremiah", [ "Jer"; "Ier"; "Ieremias Propheta"; "Jeremias"; "Jeremiah" ]); + ("ezekiel", [ "Ezech"; "Ezek"; "Ezechiel Propheta"; "Ezechiel"; "Ezekiel" ]); ("daniel", [ "Dan"; "Daniel Propheta"; "Daniel" ]); ("osee", [ "Osee"; "Osee Propheta" ]); ("joel", [ "Joel"; "Ioel"; "Ioel Propheta" ]); ("jonas", [ "Jonas"; "Ionae"; "Ionas Propheta" ]); - ("malachi", [ "Mal"; "Malach"; "Malachias Propheta"; "Malachias" ]); - ("matthew", [ "Matt"; "Matt."; "Matth"; "Evangelium secundum Matthaeum"; "Matthew" ]); + ("malachi", [ "Mal"; "Malach"; "Malachias Propheta"; "Malachias"; "Malachi" ]); + ("matthew", [ "Matt"; "Matt."; "Matth"; "Mat"; "Evangelium secundum Matthaeum"; "Matthew" ]); ("mark", [ "Mark"; "Marc"; "Evangelium secundum Marcum" ]); ("luke", [ "Luke"; "Luc"; "Evangelium secundum Lucam" ]); ("john", [ "John"; "Ioann"; "Evangelium secundum Ioannem" ]); - ("acts", [ "Acts"; "Act"; "Actus Apostolorum"; "Acts of the Apostles" ]); + ("acts", [ "Acts"; "Act"; "Actus Apostolorum"; "Acts of the Apostles"; "The Acts" ]); ("romans", [ "Rom"; "Epistola ad Romanos"; "Romans" ]); ("corinthians_1", [ "1 Cor"; "1 Cor."; "Epistola I ad Corinthios"; "1 Corinthians" ]); ("corinthians_2", [ "2 Cor"; "2 Cor."; "Epistola II ad Corinthios"; "2 Corinthians" ]); @@ -64,6 +64,73 @@ let table = an [of_token] entry would let one book carry two different ids. *) ("apocalypse", [ "Apoc"; "Rev"; "Liber Apocalypsis"; "Apocalypse" ]) ] +(* Fix wave I2 (final-review.md, 2026-08-25-colitur-of-phases-3-5): books + data/of/lectionary.sexp cites that no EF file ever needed, so the survey + above never registered them -- 345 of 730 OF citation fields printed + unconverted (35%) because [Book.of_token] had never heard of them, not + because [Parse]'s grammar itself could not read the reference. English- + canonical spellings only, verified directly against that one file + (`grep`, not invented) -- the SAME "every spelling any of the files uses" + discipline [table] above states, extended to a fourth file. + + Deliberately a SEPARATE table, not folded into [table] above, for one + reason that matters: [table]'s ids are covered by [all] below, and + book.mli's own contract for [all] is "every SHIPPED language file is + asserted complete over" it (test_lang_coverage.ml's [test_every_book_ + named] and its siblings). lang/la.ini's own header is equally strict -- + "do not invent a Latin title", every `.full`/`.abbr` row cited to an + exact scan line -- and NONE of these 25 books is attested anywhere in + the EF's own scans (the EF lectionary/propers/commons never read from + Ruth, Job, Judges, Philemon, ... at all). Registering them in [table] + would force a choice between fabricating 50 uncited Latin titles (the + one thing this project refuses hardest) and quietly weakening book.mli's + own stated promise for the ids that DO have one. Neither is right, so + these ids are resolvable ([of_token]/[tokens]/[default_spelling] below + all include them) but excluded from [all] -- a citation using one still + PARSES and RENDERS (bin/main.ml's own [names_of] already falls back to + {!default_spelling} on a lang-table miss, book.mli's own documented + reason that function exists), it just renders in its own English + spelling rather than a Latin one no primary source has ever supplied, + the exact "degraded, not broken" contract {!default_spelling} documents. + + [samuel_1]/[samuel_2] in particular are NOT [kings_1]/[kings_2] below: + those tradition targets already denote (via traditions.ini's own + [modern] mapping, "kings_3 = kings_1") the SAME physical book as the + Vulgate's own [kings_3]/[kings_4] ("3/4 Kings" = modern "1/2 Kings") -- + a different pair of books from Samuel ("Liber Regum I/II" in the + Vulgate's own naming, never cited by any shipped EF data and so never + given an id before now). *) +let of_lectionary_table = + [ ("samuel_1", [ "1 Sam"; "1 Samuel" ]); + ("samuel_2", [ "2 Sam"; "2 Samuel" ]); + ("joshua", [ "Josh"; "Joshua" ]); + ("judges", [ "Judg"; "Judges" ]); + ("ruth", [ "Ruth" ]); + ("deuteronomy", [ "Deut"; "Deuteronomy" ]); + ("chronicles_1", [ "1 Chron"; "1 Chronicles" ]); + ("chronicles_2", [ "2 Chron"; "2 Chronicles" ]); + ("ezra", [ "Ezra" ]); + ("job", [ "Job" ]); + ("ecclesiastes", [ "Eccl"; "Ecclesiastes" ]); + ("baruch", [ "Bar"; "Baruch" ]); + ("lamentations", [ "Lam"; "Lamentations" ]); + ("amos", [ "Amos" ]); + ("micah", [ "Mic"; "Micah" ]); + ("nahum", [ "Nah"; "Nahum" ]); + ("habakkuk", [ "Hab"; "Habakkuk" ]); + ("zephaniah", [ "Zeph"; "Zephaniah" ]); + ("haggai", [ "Hag"; "Haggai" ]); + ("zechariah", [ "Zech"; "Zechariah" ]); + ("maccabees_1", [ "1 Macc"; "1 Maccabees" ]); + ("maccabees_2", [ "2 Macc"; "2 Maccabees" ]); + ("philemon", [ "Phlm"; "Philemon" ]); + ("jude", [ "Jude" ]); + (* [john_2]/[john_3], siblings of [john_1] above -- three separate + Johannine epistles, three separate ids, the same one-id-per-physical- + book rule every other entry in this file follows. *) + ("john_2", [ "2 John" ]); + ("john_3", [ "3 John" ]) ] + (* Targets a tradition can map ONTO that the Vulgate data never cites directly. Present so [tradition_of_fields] can validate both sides. [sirach] and [revelation] exist ONLY here, never as an [of_token] result: @@ -89,15 +156,20 @@ let tradition_target_table = let tradition_targets = List.map fst tradition_target_table +(* Deliberately NOT [of_lectionary_table] -- see that table's own header for + why: those ids have no la.ini-verified name, so they stay out of the set + {!Colitur_naming.Lang}-completeness checks (test_lang_coverage.ml) run + against, without weakening what those checks still guarantee for every id + here. *) let all = List.map fst table @ tradition_targets let tokens = List.concat_map (fun (id, sp) -> List.map (fun s -> (s, id)) sp) - (table @ tradition_target_table) + (table @ tradition_target_table @ of_lectionary_table) let default_spelling id = - match List.assoc_opt id (table @ tradition_target_table) with + match List.assoc_opt id (table @ tradition_target_table @ of_lectionary_table) with | Some (first :: _) -> first | Some [] | None -> id @@ -120,3 +192,18 @@ let unknown_fields fields = fields let map tr id = match List.assoc_opt id tr with Some x -> x | None -> id + +(* Fix wave I2: books with exactly one chapter are conventionally cited by + VERSE ALONE, no chapter number at all ("Jude 17, 20b-25", "Phlm 7-22") -- + found while adding [of_lectionary_table] above: [jude]'s own citation + "Jude 17,20b-25" used to fail as "unknown book" (safe, if unconverted); + registering [jude] made it PARSE, but WRONGLY -- [Parse]'s existing + "a leading comma-piece that looks like a number introduces a chapter" + rule (parse.ml's own [parse_part], written for real multi-chapter data + like "15, 1-46") misread "17" as chapter 17, which does not exist in a + 25-verse, one-chapter book. A parse that SUCCEEDS with the wrong + structure is worse than one that fails: nothing downstream would ever + suspect it. [Parse.parse] consults this to skip that rule entirely for + these four ids and default to chapter 1. *) +let single_chapter_ids = [ "jude"; "philemon"; "john_2"; "john_3" ] +let is_single_chapter id = List.mem id single_chapter_ids diff --git a/lib/citation/book.mli b/lib/citation/book.mli index 0ca229c..4e91c6c 100644 --- a/lib/citation/book.mli +++ b/lib/citation/book.mli @@ -30,7 +30,18 @@ val to_string : id -> string tradition-only targets [sirach]/[revelation] -- see those below. *) val of_token : string -> id option -(** Every id this build knows, for coverage checks. *) +(** Every id surveyed against the EF's own three citation-bearing files + (lectionary, sanctoral propers, commons) -- for coverage checks: every + SHIPPED language file's [\[bible\]] section is asserted complete over + this list (test_lang_coverage.ml). NOT every id {!of_token} can resolve: + book.ml's own [of_lectionary_table] adds a further ~25 ids the OF's + English-canonical lectionary cites that no EF file ever needed and that + no primary EF source attests a Latin title for -- resolvable ([of_token]/ + {!default_spelling} both cover them) but deliberately excluded here, + rather than either fabricating an uncited Latin name or weakening this + list's own completeness promise for the ids that DO have one. Such a + book still renders, in its own English spelling ({!default_spelling}'s + own fallback), never a raw internal id. *) val all : id list (** Every accepted spelling paired with its id. *) @@ -77,3 +88,10 @@ val tradition_of_fields : (string * string) list -> tradition val unknown_fields : (string * string) list -> string list val map : tradition -> id -> id + +(** Whether [id] names a book with exactly one chapter (Jude, Philemon, + 2 John, 3 John), conventionally cited by verse alone with no chapter + number ("Jude 17", not "Jude 1:17"). {!Colitur_citation.Parse.parse} + consults this to avoid misreading a leading verse number as a chapter + -- see book.ml's own citation for the real citation this was found on. *) +val is_single_chapter : id -> bool diff --git a/lib/citation/parse.ml b/lib/citation/parse.ml index 2f2316e..2c73600 100644 --- a/lib/citation/parse.ml +++ b/lib/citation/parse.ml @@ -1,6 +1,19 @@ (* SPDX-License-Identifier: AGPL-3.0-or-later *) -type verse_range = { first : int; last : int option } +(* Fix wave I2 (final-review.md, 2026-08-25-colitur-of-phases-3-5): a VERSE + number (never a chapter number -- see [num_opt]'s own citation below) may + carry a trailing lowercase-letter sub-verse marker, standard lectionary + notation for "part of this verse" ("11a" = the first clause of verse 11) + and sometimes several run together ("1bcde" = parts b through e of verse + 1). [suffix] is [""] for the overwhelming majority of verse numbers -- + every EF citation, checked: none carries one at all (grep across + data/ef/{lectionary,sanctoral,commons}.sexp finds zero) -- so this is + purely additive for EF and does not change how any existing citation + parses or renders. Carried through and RENDERED, not stripped: dropping + it would silently lose real precision a reader can see in the source + text, trading "wrong format, complete" for "right format, incomplete". *) +type verse_num = { n : int; suffix : string } +type verse_range = { first : verse_num; last : verse_num option } type part = { chapter : int; verses : verse_range list } type t = { book : Book.id; parts : part list } @@ -75,15 +88,38 @@ let int_opt s = if not ok then None else match int_of_string_opt s with Some n when n > 0 -> Some n | _ -> None +(* [num_opt] is [int_opt] widened to accept a trailing sub-verse letter run + ([verse_num]'s own citation above) -- used ONLY for verse numbers inside + [parse_range] below. Chapter numbers ([parse_part]'s own [int_opt c] + calls) are deliberately left on plain [int_opt]: nothing in the data ever + attaches a sub-verse letter to a CHAPTER, and keeping chapter-parsing on + the original, narrower function means this change cannot loosen what a + chapter number is allowed to look like. *) +let split_num_suffix s = + let n = String.length s in + let i = ref 0 in + while !i < n && s.[!i] >= '0' && s.[!i] <= '9' do incr i done; + if !i = 0 then None + else + let digits = String.sub s 0 !i and suffix = String.sub s !i (n - !i) in + if String.for_all (fun c -> c >= 'a' && c <= 'z') suffix then Some (digits, suffix) else None + +let num_opt s : verse_num option = + match split_num_suffix (String.trim s) with + | None -> None + | Some (digits, suffix) -> ( match int_opt digits with Some n -> Some { n; suffix } | None -> None) + (* "20-32" -> {first=20; last=Some 32}; "21" -> {first=21; last=None} *) let parse_range s = match split_on '-' s with - | [ a ] -> ( match int_opt a with Some f -> Some { first = f; last = None } | None -> None) + | [ a ] -> ( match num_opt a with Some f -> Some { first = f; last = None } | None -> None) | [ a; b ] -> ( - match (int_opt a, int_opt b) with + match (num_opt a, num_opt b) with (* A descending range ("1:20-10") is always a transcription error; - accepting it would render back out as a citation nobody can follow. *) - | Some f, Some l when l >= f -> Some { first = f; last = Some l } + accepting it would render back out as a citation nobody can follow. + Compared on the NUMBER only -- "11a-11b" is a real, ascending + sub-verse range even though nothing here orders letters. *) + | Some f, Some l when l.n >= f.n -> Some { first = f; last = Some l } | _ -> None) | _ -> None @@ -97,35 +133,45 @@ let parse_ranges s = pieces (Some []) (* One ";"-separated part. [inherited] is the chapter of the previous part, - used when this one names none (rule 1 in the grammar table). *) -let parse_part ~inherited s = + used when this one names none (rule 1 in the grammar table). + [single_chapter] ({!Book.is_single_chapter}, fix wave I2) skips rule 3 + entirely: a single-chapter book's citations are bare verses with no + chapter at all, so a leading comma-piece that looks like a number is + always a VERSE, never a chapter introduction -- see book.ml's own + citation for the real, wrongly-parsed example this was found on. *) +let parse_part ~single_chapter ~inherited s = match split_on ':' s with | [ c; v ] -> ( (* explicit "chapter:verses" *) match (int_opt c, parse_ranges v) with | Some ch, Some vs -> Some { chapter = ch; verses = vs } | _ -> None) - | [ only ] -> ( - (* Either "chapter, verses" (rule 3) or bare verses inheriting a - chapter. Distinguish on whether the FIRST comma-piece is a lone - number followed by more pieces -- a leading number followed by - at least one further piece is a chapter introduction ("15, 1-46"); - a lone piece on its own, or a part with no more pieces to follow, - can only be verses inheriting the previous chapter. *) - let pieces = split_on ',' only in - match (pieces, inherited) with - | first :: (_ :: _ as rest), _ when int_opt first <> None && String.contains only ',' -> ( - (* "15, 1-46" -> chapter 15. This never fires for a part that - already named its chapter via ":" -- those match the [c; v] - branch above and never reach here. *) - match (int_opt first, parse_ranges (String.concat "," rest)) with - | Some ch, Some vs -> Some { chapter = ch; verses = vs } - | _ -> None) - | _, Some ch -> ( - match parse_ranges only with - | Some vs -> Some { chapter = ch; verses = vs } - | None -> None) - | _, None -> None) + | [ only ] -> + if single_chapter then + match parse_ranges only with + | Some vs -> Some { chapter = Option.value inherited ~default:1; verses = vs } + | None -> None + else ( + (* Either "chapter, verses" (rule 3) or bare verses inheriting a + chapter. Distinguish on whether the FIRST comma-piece is a lone + number followed by more pieces -- a leading number followed by + at least one further piece is a chapter introduction ("15, 1-46"); + a lone piece on its own, or a part with no more pieces to follow, + can only be verses inheriting the previous chapter. *) + let pieces = split_on ',' only in + match (pieces, inherited) with + | first :: (_ :: _ as rest), _ when int_opt first <> None && String.contains only ',' -> ( + (* "15, 1-46" -> chapter 15. This never fires for a part that + already named its chapter via ":" -- those match the [c; v] + branch above and never reach here. *) + match (int_opt first, parse_ranges (String.concat "," rest)) with + | Some ch, Some vs -> Some { chapter = ch; verses = vs } + | _ -> None) + | _, Some ch -> ( + match parse_ranges only with + | Some vs -> Some { chapter = ch; verses = vs } + | None -> None) + | _, None -> None) | _ -> None let parse s = @@ -144,10 +190,11 @@ let parse s = (* A trailing ";" leaves an empty piece: drop it rather than failing. Only if nothing remains is it an error. *) let pieces = List.filter (fun p -> p <> "") (split_on ';' tail) in + let single_chapter = Book.is_single_chapter book in let rec go inherited = function | [] -> Ok [] | p :: rest -> ( - match parse_part ~inherited p with + match parse_part ~single_chapter ~inherited p with | None -> Error ("cannot read reference: " ^ p) | Some part -> ( match go (Some part.chapter) rest with diff --git a/lib/citation/parse.mli b/lib/citation/parse.mli index 3d1053c..ea38d24 100644 --- a/lib/citation/parse.mli +++ b/lib/citation/parse.mli @@ -8,7 +8,14 @@ ([John 18:1-40; 19:1-42]) and really does list disjoint verse ranges within a chapter ([Dan 13:1-9, 15-17, 19-30, 33-62]). *) -type verse_range = { first : int; last : int option } +(** A verse number, plus an optional trailing sub-verse letter run ([""] for + the overwhelming majority -- see parse.ml's own citation): ["11a"] is + [{ n = 11; suffix = "a" }], preserved through parsing and rendering + rather than dropped, so a reader never loses precision the source text + actually carried. *) +type verse_num = { n : int; suffix : string } + +type verse_range = { first : verse_num; last : verse_num option } type part = { chapter : int; verses : verse_range list } type t = { book : Book.id; parts : part list } diff --git a/lib/citation/render.ml b/lib/citation/render.ml index 79aaa86..2b40ab3 100644 --- a/lib/citation/render.ml +++ b/lib/citation/render.ml @@ -80,13 +80,11 @@ let subst tmpl pairs = Buffer.contents b let render st ~names (t : Parse.t) = + let show_num (v : Parse.verse_num) = string_of_int v.Parse.n ^ v.Parse.suffix in let one_range (r : Parse.verse_range) = match r.Parse.last with - | None -> string_of_int r.Parse.first - | Some l -> - subst st.range - [ ("first", string_of_int r.Parse.first); - ("last", string_of_int l) ] + | None -> show_num r.Parse.first + | Some l -> subst st.range [ ("first", show_num r.Parse.first); ("last", show_num l) ] in let one_part (p : Parse.part) = let verses = String.concat st.verse_sep (List.map one_range p.Parse.verses) in |
