#!/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()