diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/extract_of_calendar.py | 677 |
1 files changed, 677 insertions, 0 deletions
diff --git a/tools/extract_of_calendar.py b/tools/extract_of_calendar.py new file mode 100644 index 0000000..864c409 --- /dev/null +++ b/tools/extract_of_calendar.py @@ -0,0 +1,677 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-or-later +"""tools/extract_of_calendar.py -- transcribes the Calendarium Romanum +Generale (General Roman Calendar) from the 2002 Missale Romanum, editio +typica tertia, into data/of/calendar-2002.sexp: a Colitur_kernel.Layer.t of +Colitur_kernel.Celebration.t entries, one per FIXED (month, day) universal +calendar entry. + +SOURCE DISCIPLINE (do not weaken this): the 2002 Missale Romanum PDF is the +authority. lectio's ~/git/projects/lectio/internal/caldata/roman-calendar.ini +is read here ONLY for two cross-check purposes, never as structural source: + (a) comparing (month, day, rank) to surface divergences, each of which is + adjudicated by re-reading the Missal (see the divergence log this + script prints), never by silently preferring lectio; + (b) copying `name.en` -- lectio's OWN `name.la` field is independently + known to be unreliable (it contains English text for at least one + entry, verified during planning: mary-mother-of-god-octave-of-christmas + has name.la = "Mary, Mother of God (Octave of Christmas)"), so Latin + is NEVER taken from lectio, only English. + +Run: `python3 tools/extract_of_calendar.py [pretext-file] > data/of/calendar-2002.sexp` +With no argument, runs `pdftotext -layout` on the PDF itself. A pre-extracted +text file may be given instead (for reproducibility without re-invoking +pdftotext). + +WHAT THIS FILE DELIBERATELY EXCLUDES, and why (each is a real design +decision, not an oversight): + +1. Every MOVABLE universal solemnity/feast printed in the calendar table + under a "Dominica ... :" / "Feria ... :" / "Sabbato ... :" heading + (Baptism of the Lord, Holy Family, Trinity Sunday, Corpus Christi, the + Sacred Heart of Jesus, the Immaculate Heart of Mary, Christ the King). + None of these has a (month, day) Date_spec.Fixed key at all -- they are + Easter-relative, and Date_spec.Fixed is this file's only key shape (see + the task interface). Trinity/Corpus Christi/Christ the King/Holy Family/ + Baptism of the Lord are ALREADY computed by lib/rites/rite_of/ + temporal_of.ml's own `named`/`holy_family`/`baptism_of_the_lord` (Phase + 1, already merged, frozen for this task) -- duplicating them here would + create two competing candidates for the same day. + + FINDING (report this loudly, it is not this task's to fix): the Sacred + Heart of Jesus ("Feria VI post dominicam secundam post Pentecosten: + SACRATISSIMI CORDIS IESU Sollemnitas") and the Immaculate Heart of Mary + ("Sabbato post dominicam secundam post Pentecosten: Immaculati Cordis B. + Mariae Virginis Memoria") are printed in the SAME base-2002 calendar + table as Trinity/Corpus Christi/Christ the King, but temporal_of.ml + covers NEITHER of them -- grep for "Cordis"/"Sacred"/"Sacratissim" in + that file finds nothing. This is a genuine gap in Phase 1's own + coverage, orthogonal to Task 1 (a Fixed-only data file cannot express + an Easter-relative feast), not caused by this task and not fixable by + it -- Temporal_of would need two more named-day branches + (Pentecost+19 and Pentecost+20 respectively, i.e. Easter+68/+69). + +2. Three FIXED entries that ARE in the printed table but are ALSO already + computed by temporal_of.ml's own `named`: 1 January (Mary, Mother of + God), 6 January (Epiphany), 25 December (the Nativity). Shipping them + here too would duplicate a candidate Precedence_of.band would then have + to arbitrate between a temporal-origin and a data-origin copy of the + IDENTICAL office -- pointless and risks a silent divergence between the + two copies. Excluded explicitly (SKIP_DATES below), not merely absent + by accident. + +DATA DECISIONS made while transcribing, each cited in the emitted file's +own provenance header: + +- A blank grade column means Memoria ad libitum, per the calendar's own + footnote (`* Quando non indicatur gradus celebrationis, fit Memoria ad + libitum.`) -- Normae/Missale Romanum 2002, Calendarium Romanum Generale, + its own footnote marker. +- 2 November (Commemoratio omnium fidelium defunctorum, All Souls) carries + NO grade word in the table at all -- applying the footnote naively would + misclassify the second-most solemn day of November as an optional + memorial. Normae n. 59's own Tabula dierum liturgicorum, entry 3, places + it explicitly alongside "Sollemnitates ... in Calendario generali + inscriptae" ("Sollemnitates Domini, beatae Mariae Virginis, et Sanctorum + in Calendario generali inscriptae. Commemoratio omnium fidelium + defunctorum.") -- same table entry, same precedence tier. Tagged + Sollemnitas here on that citation, as an explicit, documented override, + not the generic blank-grade default. +- Colour is derived from IGMR n. 346 (a)-(d): white for Christ's + non-Passion celebrations / the BVM / Angels / non-martyr Saints / All + Saints / John the Baptist's Nativity / John the Evangelist / Chair of + Peter / Conversion of Paul (346(a), the last three named explicitly); + red for Passion-related celebrations of the Lord, "festis nataliciis + Apostolorum et Evangelistarum" (an Apostle's or Evangelist's own feast), + and any celebration of Martyr Saints (346(b)); violet, chosen over the + also-permitted black, for the Commemoration of All the Souls (346(d): + "Assumi potest etiam in Officiis et Missis defunctorum" -- explicitly + authorised for the dead; 346(a)'s white clause has no textual reach here, + since All Souls is not "Sanctorum" (canonised Saints) at all). This + DIVERGES from lectio, which colours 2 November white -- see the + divergence log below; adjudicated colitur/violet on 346(d)'s explicit + text, flagging lectio's white as reflecting a widespread modern PASTORAL + custom the Missal's own words do not themselves authorise as the + default. Three explicit exceptions to the apostle/evangelist-red rule + (Chair of Peter, Conversion of Paul, John the Evangelist) and one to the + martyr/apostle-red rule (three "In Dedicatione ..." church-dedication + entries, always white) are coded as override tables, cited alongside the + rule. + +EXTRACTION ARTIFACT: some titles are printed letter-spaced by pdftotext +(e.g. "S . P e t r i D a m i a n i"). `collapse_letterspacing` mechanically +rejoins runs of >=4 single-character tokens, using punctuation (periods, +commas) and a lowercase-then-uppercase transition as word-boundary signals. +This CANNOT recover a lowercase-to-lowercase boundary (no case signal +survives the original single-spacing), so it is a first pass only; the sole +entry it fires on in this table (21 February, S. Petri Damiani) is then +hand-verified against the well-attested standard title and corrected +explicitly, with the exact reason recorded at the call site. The count of +entries needing this repair is printed to stderr. +""" + +import hashlib +import re +import subprocess +import sys +import unicodedata +from datetime import date, timezone, datetime + +PDF_PATH = "docs/research/of/missale-romanum-2002.pdf" +TOOL_PATH = "tools/extract_of_calendar.py" +LECTIO_INI = "../lectio/internal/caldata/roman-calendar.ini" + +MONTHS = [ + "IANUARIUS", "FEBRUARIUS", "MARTIUS", "APRILIS", "MAIUS", "IUNIUS", + "IULIUS", "AUGUSTUS", "SEPTEMBER", "OCTOBER", "NOVEMBER", "DECEMBER", +] +MONTH_NUM = {m: i + 1 for i, m in enumerate(MONTHS)} + +ROW_RE = re.compile(r"^(Cal\.|Prid\.|Non\.|Idib\.|[IVXL]+)\s*(\d{1,2})\b\s*(.*)$") +MOVABLE_HEADING_RE = re.compile(r"^(Dominica|Feria\s+[IVXL]+|Sabbato)\b.*\b(post|ultima|infra)\b") +ENTRY_MARKER_RE = re.compile(r"^(S\.|Ss\.|B\.)\s") +FOOTNOTE_RE = re.compile(r"^\*\s*Quando non indicatur") + +GRADE_WORDS = ["Sollemnitas", "Festum", "Memoria"] +GRADE_TO_RANK = { + "Sollemnitas": "Sollemnitas", + "Festum": "Festum", + "Memoria": "Memoria_obligatoria", + None: "Memoria_ad_libitum", +} + +# Excluded because Phase 1 (lib/rites/rite_of/temporal_of.ml) already +# computes these as temporal-cycle offices. See module docstring, point 2. +SKIP_DATES = { + (1, 1): "of-mary-mother-of-god (Temporal_of.named, m=1 dd=1)", + (1, 6): "of-epiphany (Temporal_of.named, m=1 dd=6)", + (12, 25): "of-nativity (Temporal_of.named, m=12 dd=25)", +} + +# IGMR 346(a)'s own three explicit white exceptions to the apostle/ +# evangelist-red rule. +WHITE_APOSTLE_EXCEPTIONS = {(1, 25), (2, 22), (12, 27)} + +# Church-dedication feasts: always white, never coloured by the martyr/ +# apostle words that happen to appear in their own titles (the apostles +# named are the church's patrons, not the day's own honouree). +DEDICATION_DATES = {(8, 5), (11, 9), (11, 18)} + +# All Souls: see module docstring for the 346(d) citation. +ALL_SOULS_OVERRIDE = (11, 2) + +# Explicit red override: the Exaltation of the Holy Cross is a celebration +# "Passionis Domini" (346(b)) even though its own title contains neither +# "martyr" nor "apostol"/"evangelist". +RED_OVERRIDES = {(9, 14)} + +# subject = Lord: celebrations of the Lord not already excluded above, +# plus the three church-dedication feasts (a dedication is itself +# classified a "festum Domini" -- see CLAUDE.md's EF precedent, and +# lectio's own roman-calendar.ini tags all three `class = lord`). +LORD_DATES = {(2, 2), (3, 25), (8, 6), (1, 3), (9, 14)} | DEDICATION_DATES + + +def sha256_file(path): + h = hashlib.sha256() + with open(path, "rb") as f: + h.update(f.read()) + return h.hexdigest() + + +def get_text(pdf_path): + out = subprocess.run(["pdftotext", "-layout", pdf_path, "-"], capture_output=True, check=True) + return out.stdout.decode("utf-8") + + +def collapse_letterspacing(line): + """Mechanically rejoins a run of >=4 single-character tokens. Returns + (new_line, changed). See module docstring's "EXTRACTION ARTIFACT" note + for what this can and cannot recover.""" + tokens = line.split(" ") + out = [] + i = 0 + changed = False + while i < len(tokens): + if len(tokens[i]) == 1: + j = i + while j < len(tokens) and len(tokens[j]) == 1: + j += 1 + run = tokens[i:j] + if len(run) >= 4: + changed = True + merged = "".join(run) + merged = re.sub(r"(?<=[.,])(?=\S)", " ", merged) + merged = re.sub(r"(?<=[a-zà-öø-ÿæœ])(?=[A-ZÀ-ÖØ-ÝÆŒ])", " ", merged) + out.append(merged) + else: + out.extend(run) + i = j + else: + out.append(tokens[i]) + i += 1 + return " ".join(out), changed + + +def bound_table(text): + lines = text.split("\n") + # First IANUARIUS before line 5000, immediately preceded (allowing for a + # blank line) by "CALENDARIUM ROMANUM GENERALE" -- rules out the second, + # Proprium Sanctorum run near line 17874. + start = None + for i, l in enumerate(lines[:5000]): + if l.strip("\x0c").strip() == "IANUARIUS": + # confirm CALENDARIUM ROMANUM GENERALE appears in the preceding + # few lines + window = "\n".join(lines[max(0, i - 4):i]) + if "CALENDARIUM ROMANUM GENERALE" in window: + start = i + break + if start is None: + raise SystemExit("could not find the start of the calendar table (IANUARIUS after " + "CALENDARIUM ROMANUM GENERALE, before line 5000)") + end = None + for i in range(start, min(len(lines), 5000)): + if lines[i].strip() == "TABELLA TEMPORARIA": + end = i + break + if end is None: + raise SystemExit("could not find the end of the calendar table (TABELLA TEMPORARIA)") + return start, end, lines[start:end] + + +def is_month_header(line): + return line.strip("\x0c").strip() in MONTH_NUM + + +def parse_rows(table_lines): + """Returns (entries, letterspace_count) where entries is a list of dicts + with month, day, latin (raw joined title), grade (str or None).""" + # Pass 1: strip form feeds and collapse letter-spacing line by line. + letterspace_count = 0 + lines = [] + for raw in table_lines: + l = raw.replace("\x0c", "") + l2, changed = collapse_letterspacing(l) + if changed: + letterspace_count += 1 + lines.append(l2) + + entries = [] + current_month = None + i = 0 + n = len(lines) + while i < n: + line = lines[i].strip() + if line == "": + i += 1 + continue + if is_month_header(line): + current_month = MONTH_NUM[line] + i += 1 + continue + if FOOTNOTE_RE.match(line): + i += 1 + continue + if MOVABLE_HEADING_RE.match(line): + i += 1 + while i < n: + nxt = lines[i].strip() + if (nxt == "" or is_month_header(nxt) or ROW_RE.match(nxt) + or MOVABLE_HEADING_RE.match(nxt) or FOOTNOTE_RE.match(nxt)): + break + i += 1 + continue + rm = ROW_RE.match(line) + if rm: + if current_month is None: + raise SystemExit(f"row before any month header: {line!r}") + _kalend, day_s, rest = rm.groups() + day = int(day_s) + blocks = [[]] + if rest.strip(): + blocks[0].append(rest.strip()) + i += 1 + while i < n: + nxt_raw = lines[i] + nxt = nxt_raw.strip() + if (nxt == "" or is_month_header(nxt) or ROW_RE.match(nxt) + or MOVABLE_HEADING_RE.match(nxt) or FOOTNOTE_RE.match(nxt)): + break + if ENTRY_MARKER_RE.match(nxt) and blocks[-1]: + blocks.append([nxt]) + else: + blocks[-1].append(nxt) + i += 1 + for block in blocks: + if not block: + continue + text = " ".join(block).strip() + grade = None + for g in GRADE_WORDS: + if text == g or text.endswith(" " + g): + grade = g + text = text[: -len(g)].strip() + break + # Jan 3: "Ss.mi Nominis Iesu *" -- the footnote's own marker + # asterisk, not a grade word. Strip it; the footnote's blank + # -> Memoria_ad_libitum mapping still applies. + if text.endswith("*"): + text = text[:-1].strip() + if text: + entries.append({"month": current_month, "day": day, "latin": text, "grade": grade}) + continue + raise SystemExit(f"unrecognised line in calendar table (month={current_month}): {line!r}") + + return entries, letterspace_count + + +LATIN_TO_ASCII = str.maketrans({ + # Æ (uppercase ligature) is verified, across every occurrence in the + # extracted calendar table, to appear ONLY inside an ALL-CAPS heading + # ("SANCTÆ", "PRÆSENTATIONE", "ASSUMPTIONE BEATÆ") -- never as the + # initial letter of a Title Case word -- so it maps to "AE" (both + # capitals), not "Ae", to stay consistent with its own surrounding case. + "æ": "ae", "Æ": "AE", "œ": "oe", "Œ": "OE", "ø": "o", "Ø": "O", +}) + + +def normalize_latin(text): + """æ -> ae, œ -> oe, matching the existing la-name convention already + used by lib/rites/rite_of/temporal_of.ml's own holy_family_names/ + baptism_names (e.g. "Sanctae Familiae Iesu, Mariae et Ioseph"), not the + ligature glyphs the PDF itself prints. Apostrophes and other punctuation + are left untouched here -- they are real printed characters in the `la` + field; only slugify()/title_slug() (which call this first) strip them + further, for the slug alone.""" + return text.translate(LATIN_TO_ASCII) + + +def slugify(latin): + s = normalize_latin(latin) + s = unicodedata.normalize("NFKD", s) + s = "".join(c for c in s if not unicodedata.combining(c)) + s = s.lower() + s = re.sub(r"[^a-z0-9]+", "-", s) + s = re.sub(r"-+", "-", s).strip("-") + return s + + +# Words to drop when deriving a slug from a title, so slugs read as names +# rather than descriptions (kept short and stable, matching data/ef/ +# sanctoral.sexp's own style, e.g. "agnes", "all-saints"). +DROP_WORDS = { + "s", "ss", "b", "in", "et", "de", "ad", "a", "the", "of", +} + + +def title_slug(latin, month, day): + words = re.split(r"[\s,]+", normalize_latin(latin)) + kept = [] + for w in words: + base = re.sub(r"\.$", "", w) + if base.lower() in DROP_WORDS or base == "": + continue + kept.append(base) + if len(kept) >= 6: + break + if not kept: + kept = [f"of-{month:02d}-{day:02d}"] + return slugify(" ".join(kept)) + + +def classify_colour(month, day, latin): + key = (month, day) + if key == ALL_SOULS_OVERRIDE: + return "Violet" + if key in RED_OVERRIDES: + return "Red" + if key in DEDICATION_DATES: + return "White" + if "martyr" in latin.lower(): + return "Red" + if key in WHITE_APOSTLE_EXCEPTIONS: + return "White" + lower = latin.lower() + if "apostol" in lower or "evangelist" in lower: + return "Red" + return "White" + + +def classify_subject(month, day, latin): + key = (month, day) + if key in LORD_DATES: + return "Lord" + lower = normalize_latin(latin).lower() + if key != (3, 19) and ("maria" in lower or "b.m.v" in lower or "bmv" in lower): + return "Bvm" + return "Saint" + + +def parse_lectio_ini(path): + entries = [] + cur = None + try: + with open(path, encoding="utf-8") as f: + raw = f.read() + except FileNotFoundError: + return entries + for line in raw.split("\n"): + line = line.rstrip("\n") + if line.startswith("[") and line.endswith("]"): + if cur is not None: + entries.append(cur) + cur = {"slug": line[1:-1]} + elif "=" in line and cur is not None: + k, _, v = line.partition("=") + cur[k.strip()] = v.strip() + if cur is not None: + entries.append(cur) + return [e for e in entries if "date" in e] + + +LECTIO_RANK_TO_VOCAB = { + "solemnity": "Sollemnitas", + "feast": "Festum", + "memorial": "Memoria_obligatoria", + "optional": "Memoria_ad_libitum", +} + + +def significant_words(text): + words = re.split(r"[\s,.;]+", normalize_latin(text).lower()) + return {w for w in words if w and w not in DROP_WORDS and len(w) > 2} + + +def cross_check(entries, lectio_entries): + """Returns (report_lines, name_en_by_id) where name_en_by_id maps + id(entry dict) -> matched lectio name.en, and report_lines is the + divergence log for the provenance header.""" + by_date = {} + for le in lectio_entries: + d = le.get("date", "") + m = re.match(r"^(\d\d)-(\d\d)$", d) + if not m: + continue + key = (int(m.group(1)), int(m.group(2))) + by_date.setdefault(key, []).append(le) + + by_date_missal = {} + for e in entries: + by_date_missal.setdefault((e["month"], e["day"]), []).append(e) + + report = [] + name_en = {} + lectio_slug = {} + agree_rank = 0 + total_matched = 0 + matched_lectio_ids = set() + + for key, missal_list in sorted(by_date_missal.items()): + lectio_list = by_date.get(key, []) + if len(missal_list) == 1 and len(lectio_list) == 1: + pairs = [(missal_list[0], lectio_list[0])] + else: + pairs = [] + remaining = list(lectio_list) + for me in missal_list: + if not remaining: + break + mwords = significant_words(me["latin"]) + best = max(remaining, key=lambda le: len(mwords & significant_words(le.get("name.la", "")))) + pairs.append((me, best)) + remaining.remove(best) + for me, le in pairs: + total_matched += 1 + matched_lectio_ids.add(id(le)) + name_en[id(me)] = le.get("name.en") + lectio_slug[id(me)] = le.get("slug") + missal_rank = GRADE_TO_RANK[me["grade"]] + lectio_rank = LECTIO_RANK_TO_VOCAB.get(le.get("rank", ""), "?") + if missal_rank == lectio_rank: + agree_rank += 1 + else: + report.append( + f" {key[0]:02d}-{key[1]:02d} RANK colitur={missal_rank} lectio={lectio_rank} " + f"[{me['latin']!r} vs {le.get('name.la','')!r}]" + ) + missal_colour = classify_colour(key[0], key[1], me["latin"]) + lectio_colour = (le.get("colour", "") or "").capitalize() + if missal_colour != lectio_colour: + report.append( + f" {key[0]:02d}-{key[1]:02d} COLOUR colitur={missal_colour} lectio={lectio_colour} " + f"[{me['latin']!r}]" + ) + if not lectio_list: + for me in missal_list: + report.append(f" {key[0]:02d}-{key[1]:02d} MISSAL-ONLY {me['latin']!r} (grade={me['grade']})") + elif len(lectio_list) > len(missal_list): + # A shared date where lectio carries MORE entries than the + # Missal transcription found -- the pairing loop above only + # consumes len(missal_list) of them, so the rest would + # otherwise vanish silently. Surfaced explicitly. + for le in lectio_list: + if id(le) not in matched_lectio_ids: + report.append( + f" {key[0]:02d}-{key[1]:02d} LECTIO-EXTRA (same date, uncovered by Missal pairing) " + f"{le.get('name.la','')!r} rank={le.get('rank')} slug={le.get('slug')}" + ) + + missal_dates = set(by_date_missal) + for key, lectio_list in sorted(by_date.items()): + if key in SKIP_DATES: + continue + for le in lectio_list: + if id(le) not in matched_lectio_ids and key not in missal_dates: + report.append(f" {key[0]:02d}-{key[1]:02d} LECTIO-ONLY {le.get('name.la','')!r} " + f"rank={le.get('rank')} slug={le.get('slug')}") + + summary = [ + f"lectio cross-check: {total_matched} dates paired; rank agrees on {agree_rank}/{total_matched}.", + ] + return summary + report, name_en, lectio_slug + + +def render_sexp(entries, name_en, letterspace_count, skipped, meta): + lines = [] + lines.append(f"; data/of/calendar-2002.sexp -- OF (post-1970) General Roman Calendar,") + lines.append(f"; transcribed from the Missale Romanum, editio typica tertia (2002). The") + lines.append(f"; 2002 Missal is the AUTHORITY; this file is never edited afterwards --") + lines.append(f"; spec sec4.1. lectio's roman-calendar.ini is a cross-check only, never a") + lines.append(f"; source (see tools/extract_of_calendar.py's own header for the full") + lines.append(f"; source-discipline statement and every classification citation).") + lines.append(f";") + lines.append(f"; Generator: {TOOL_PATH} -- do not hand-edit; re-run against the same") + lines.append(f"; PDF (this file is pinned, so a re-run should reproduce it byte-for-byte)") + lines.append(f"; and diff before committing.") + lines.append(f";") + lines.append(f"; Source: {PDF_PATH}") + lines.append(f"; SHA-256: {meta['pdf_sha256']}") + lines.append(f"; Extracted lines (pdftotext -layout, 0-based): {meta['start']}-{meta['end']}") + lines.append(f"; Extraction date (UTC): {meta['extraction_date']}") + lines.append(f";") + lines.append(f"; {len(entries)} entries. {letterspace_count} entry needed letter-spacing repair") + lines.append(f"; (collapse_letterspacing's own mechanical pass, then hand-verified -- see") + lines.append(f"; the module docstring's EXTRACTION ARTIFACT note).") + lines.append(f";") + lines.append(f"; Excluded, deliberately (see module docstring for the full reasoning):") + for (m, d), why in sorted(skipped.items()): + lines.append(f"; {m:02d}-{d:02d}: {why}") + lines.append(f"; Movable universal solemnities (Baptism of the Lord, Holy Family,") + lines.append(f"; Trinity, Corpus Christi, Christ the King -- Temporal_of code; the") + lines.append(f"; Sacred Heart of Jesus and the Immaculate Heart of Mary -- present in") + lines.append(f"; the 2002 table but covered by NEITHER Temporal_of NOR this file, a") + lines.append(f"; genuine gap this task found and reports but does not fix).") + lines.append(f";") + for l in meta["cross_check_report"]: + lines.append(f"; {l}") + lines.append("") + + def esc(s): + return s.replace("\\", "\\\\").replace('"', '\\"') + + lines.append("((id of-universal) (name \"OF (2002) General Roman Calendar\")") + lines.append(" (entries") + lines.append(" (") + for idx, e in enumerate(entries): + la = normalize_latin(e["latin"]) + en = name_en.get(id(e)) + names_parts = [] + if en: + names_parts.append(f'(en "{esc(en)}")') + names_parts.append(f'(la "{esc(la)}")') + names_str = " ".join(names_parts) + rank = GRADE_TO_RANK[e["grade"]] + colour = classify_colour(e["month"], e["day"], e["latin"]) + subject = classify_subject(e["month"], e["day"], e["latin"]) + slug = e["slug"] + prefix = " " if idx > 0 else " " + lines.append(f"{prefix}((date (Fixed (month {e['month']}) (day {e['day']})))") + lines.append(f" (cel") + lines.append(f" ((slug {slug})") + lines.append(f" (names ({names_str}))") + lines.append(f" (rank {rank}) (status Feast) (colour {colour})") + lines.append(f" (subject {subject}) (citations ()) (layer of-universal))))") + lines.append(" )))") + return "\n".join(lines) + "\n" + + +def main(): + if len(sys.argv) > 1: + with open(sys.argv[1], encoding="utf-8") as f: + text = f.read() + else: + text = get_text(PDF_PATH) + + start, end, table_lines = bound_table(text) + entries, letterspace_count = parse_rows(table_lines) + + # Hand-verified correction for the one letter-spaced entry the + # mechanical pass cannot fully resolve (see module docstring). + for e in entries: + if (e["month"], e["day"]) == (2, 21) and "episcopiet" in e["latin"]: + e["latin"] = "S. Petri Damiani, episcopi et Ecclesiae doctoris" + + skipped = {} + kept = [] + for e in entries: + key = (e["month"], e["day"]) + if key in SKIP_DATES: + skipped[key] = SKIP_DATES[key] + continue + kept.append(e) + entries = kept + + # All Souls override (see module docstring). + for e in entries: + if (e["month"], e["day"]) == ALL_SOULS_OVERRIDE: + e["grade"] = "Sollemnitas" + + lectio_entries = parse_lectio_ini(LECTIO_INI) + cross_check_report, name_en, lectio_slug = cross_check(entries, lectio_entries) + + # Slugs: prefer lectio's OWN identifier when a confident match exists -- + # it is already a clean, conventional, human-authored identifier + # ("raymond-of-penyafort-priest") matching data/ef/sanctoral.sexp's own + # style, and reusing it is not a source-discipline violation: a slug is + # a stable ENGINEERING KEY, not liturgical content -- every substantive + # field (date, rank, colour, the `la` name) still comes from the Missal + # alone. Falls back to a mechanical slug built from the Latin title for + # any entry lectio has no match for (none, as it happens, in this run -- + # every one of the 206 entries paired -- but kept for robustness against + # a future lectio update dropping an entry). Collisions suffixed either + # way. + seen = {} + for e in entries: + candidate = lectio_slug.get(id(e)) + base = slugify(candidate) if candidate else title_slug(e["latin"], e["month"], e["day"]) + if not base: + base = title_slug(e["latin"], e["month"], e["day"]) + if base in seen: + seen[base] += 1 + e["slug"] = f"{base}-{seen[base]}" + else: + seen[base] = 1 + e["slug"] = base + + entries.sort(key=lambda e: e["slug"]) + + meta = { + "pdf_sha256": sha256_file(PDF_PATH), + "start": start, + "end": end, + "extraction_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"), + "cross_check_report": cross_check_report, + } + + sys.stdout.write(render_sexp(entries, name_en, letterspace_count, skipped, meta)) + + print(f"[extract_of_calendar] {len(entries)} entries emitted, {letterspace_count} needed " + f"letter-spacing repair, {len(skipped)} dates excluded (temporal_of.ml coverage)", + file=sys.stderr) + for l in cross_check_report: + print(f"[extract_of_calendar] {l}", file=sys.stderr) + + +if __name__ == "__main__": + main() |
