summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/extract_of_calendar.py259
1 files changed, 208 insertions, 51 deletions
diff --git a/tools/extract_of_calendar.py b/tools/extract_of_calendar.py
index 864c409..50b570e 100644
--- a/tools/extract_of_calendar.py
+++ b/tools/extract_of_calendar.py
@@ -26,30 +26,54 @@ 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.
+1. MOVABLE universal solemnities/feasts printed in the calendar table
+ under a "Dominica ... :" / "Feria ... :" / "Sabbato ... :" heading. MOST
+ of these (Baptism of the Lord, Holy Family, Trinity Sunday, Corpus
+ Christi, Christ the King) have no (month, day) Date_spec.Fixed key at
+ all, are ALREADY computed by lib/rites/rite_of/temporal_of.ml's own
+ `named`/`holy_family`/`baptism_of_the_lord` (Phase 1, already merged),
+ and are excluded here for that reason -- shipping them too 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).
+ MOVABLE-HEADING AUDIT (fix round 1, 2026-08-25 -- the coordinator asked
+ for a full accounting after the gap below was found, not just the two
+ entries that closed it). There are EXACTLY 7 such headings in the whole
+ calendar table (verified mechanically: every "Dominica/Feria/Sabbato
+ ... :" line the table contains, listed by MOVABLE_HEADING_RE with no
+ heuristic filtering). All 7, and their fate:
+ - Baptism of the Lord (Jan) -> Temporal_of.baptism_of_the_lord
+ - Trinity Sunday (end of May) -> Temporal_of.named (off 56)
+ - Corpus Christi (end of May) -> Temporal_of.named (off 60)
+ - Sacred Heart of Jesus (Jun) -> THIS FILE, Easter_offset 68 (below)
+ - Immaculate Heart of Mary (Jun) -> THIS FILE, Easter_offset 69 (below)
+ - Christ the King (Nov) -> Temporal_of.christ_the_king
+ - Holy Family (Dec) -> Temporal_of.holy_family (its own
+ heading carries a FIXED-DATE FALLBACK, "vel, ea deficiente, die 30
+ decembris" -- correctly Temporal_of's job, not this file's, despite
+ the prose shape; left alone on the coordinator's own confirmation)
+ No 8th heading exists -- the audit found nothing else of this shape.
+
+ RESOLVED (fix round 1): 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 covered NEITHER --
+ found and reported by this extractor's own first pass; researched and
+ confirmed by the coordinator (extracted lines ~4219-4222, immediately
+ after 30 June). They belong HERE, not in temporal_of.ml: both are
+ printed in the Calendarium Romanum Generale itself, the exact table this
+ file transcribes, so adding them completes the transcription rather than
+ amending it. Produced as ordinary Date_spec.Easter_offset entries (the
+ same variant EF's Rogation Wednesday already uses, and Task 2 will reuse
+ for Mary, Mother of the Church -- no kernel change). Pentecost is
+ Easter+49 (Normae n. 22-23); the Second Sunday after Pentecost is
+ Easter+63; the Friday after it is Easter+68 (Sacred Heart), the Saturday
+ after it Easter+69 (Immaculate Heart) -- verified against three real
+ Easter dates in test_calendar_of_data.ml, not merely computed on paper.
+ See MOVABLE_ENTRY_HEADINGS/matching_movable_entry_heading below for how
+ the extractor recognises these two headings specifically (and only
+ these two) among the other 5 it still correctly skips.
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
@@ -244,9 +268,79 @@ def is_month_header(line):
return line.strip("\x0c").strip() in MONTH_NUM
+def extract_grade(text):
+ """Splits a trailing grade word off `text`. Shared by fixed-date rows
+ and movable-heading entries (parse_rows below) so both go through the
+ identical rule: a printed grade word (Sollemnitas/Festum/Memoria) is
+ stripped; its absence, or Jan 3's own trailing footnote asterisk, both
+ fall through to the calendar's blank-grade footnote (Memoria ad
+ libitum)."""
+ grade = None
+ for g in GRADE_WORDS:
+ if text == g or text.endswith(" " + g):
+ grade = g
+ text = text[: -len(g)].strip()
+ break
+ if text.endswith("*"):
+ text = text[:-1].strip()
+ return text, grade
+
+
+# Fix round 1 (coordinator, 2026-08-25): two movable universal celebrations
+# are printed as prose HEADINGS between fixed-date rows, not as (kalends,
+# day, title, grade) rows -- exactly the shape parse_rows' generic
+# MOVABLE_HEADING_RE skip-and-discard branch was built for, which is why the
+# original extraction silently dropped them (found and reported by the
+# extractor's own author; researched and confirmed by the coordinator).
+# Both belong HERE, not in temporal_of.ml: they are printed in the
+# Calendarium Romanum Generale itself, the exact table this file
+# transcribes, so adding them completes the transcription rather than
+# amending it (contrast Holy Family, whose heading is likewise prose but
+# whose FIXED-DATE FALLBACK, "vel, ea deficiente, die 30 decembris", makes
+# it correctly Temporal_of's job -- see the re-audit note in the module
+# docstring's "MOVABLE-HEADING AUDIT" section for the full accounting of
+# all 7 such headings in the table).
+#
+# Feria VI post dominicam secundam post Pentecosten:
+# SACRATISSIMI CORDIS IESU Sollemnitas
+# Sabbato post dominicam secundam post Pentecosten:
+# Immaculati Cordis B. Mariae Virginis Memoria
+#
+# (extracted lines ~4219-4222, immediately after 30 June). Pentecost is
+# Easter+49 (Normae n. 22-23, already temporal_of.ml's own citation); "the
+# Second Sunday after Pentecost" is therefore Easter+63, the Friday after it
+# Easter+68, the Saturday after it Easter+69 -- independently verified
+# against three real Easter dates (2026-04-05, 2027-03-28, 2035-03-25) in
+# test_calendar_of_data.ml, not merely computed on paper here.
+#
+# Date_spec.Easter_offset (built for EF's Rogation Wednesday) needs no
+# kernel change and is the same mechanism Task 2 will use for Mary, Mother
+# of the Church -- so these become ordinary Easter_offset entries in this
+# file, produced by the extractor like every other entry, not hand-appended:
+# a heading this specific is no less mechanical to recognise than a
+# (kalends, day) row, and hand-appending would be the one entry pair in this
+# file with no SHA-256-verifiable path back to the PDF text.
+MOVABLE_ENTRY_HEADINGS = [
+ # (heading-substring, Easter offset, weekday name for the test/sanity check)
+ ("Feria VI post dominicam secundam post Pentecosten", 68, "Fri"),
+ ("Sabbato post dominicam secundam post Pentecosten", 69, "Sat"),
+]
+
+
+def matching_movable_entry_heading(line):
+ for substring, offset, weekday in MOVABLE_ENTRY_HEADINGS:
+ if line.startswith(substring):
+ return offset, weekday
+ return None
+
+
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)."""
+ """Returns (entries, movable_entries, letterspace_count, movable_audit)
+ where entries is a list of dicts with month, day, latin, grade;
+ movable_entries is the same shape but with easter_offset instead of
+ month/day; movable_audit lists EVERY "Dominica/Feria/Sabbato ... :"
+ heading found, with its own fate (produced here, or the reason it is
+ skipped), for the re-audit this fix round asked for."""
# Pass 1: strip form feeds and collapse letter-spacing line by line.
letterspace_count = 0
lines = []
@@ -258,6 +352,8 @@ def parse_rows(table_lines):
lines.append(l2)
entries = []
+ movable_entries = []
+ movable_audit = []
current_month = None
i = 0
n = len(lines)
@@ -274,13 +370,26 @@ def parse_rows(table_lines):
i += 1
continue
if MOVABLE_HEADING_RE.match(line):
+ heading = line
i += 1
+ block = []
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
+ block.append(nxt)
i += 1
+ match = matching_movable_entry_heading(heading)
+ if match is None:
+ movable_audit.append({"heading": heading, "content": block, "produced": False})
+ continue
+ offset, weekday = match
+ text = " ".join(block).strip()
+ text, grade = extract_grade(text)
+ movable_entries.append({"easter_offset": offset, "weekday": weekday, "latin": text, "grade": grade})
+ movable_audit.append({"heading": heading, "content": block, "produced": True,
+ "easter_offset": offset})
continue
rm = ROW_RE.match(line)
if rm:
@@ -303,27 +412,17 @@ def parse_rows(table_lines):
else:
blocks[-1].append(nxt)
i += 1
- for block in blocks:
- if not block:
+ for cblock in blocks:
+ if not cblock:
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()
+ text = " ".join(cblock).strip()
+ text, grade = extract_grade(text)
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
+ return entries, movable_entries, letterspace_count, movable_audit
LATIN_TO_ASCII = str.maketrans({
@@ -557,11 +656,15 @@ def render_sexp(entries, name_en, letterspace_count, skipped, meta):
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"; Movable universal solemnities STILL excluded, correctly (Baptism of")
+ lines.append(f"; the Lord, Holy Family, Trinity, Corpus Christi, Christ the King) --")
+ lines.append(f"; Temporal_of code already covers each; see the module docstring's")
+ lines.append(f"; MOVABLE-HEADING AUDIT for the full 7-heading accounting.")
+ lines.append(f";")
+ lines.append(f"; Movable entries produced from a prose heading, not a (kalends, day) row")
+ lines.append(f"; (fix round 1, 2026-08-25 -- see MOVABLE_ENTRY_HEADINGS):")
+ for me in meta.get("movable_entries", []):
+ lines.append(f"; Easter_offset {me['easter_offset']} ({me['weekday']}): {me['latin']!r}")
lines.append(f";")
for l in meta["cross_check_report"]:
lines.append(f"; {l}")
@@ -582,11 +685,21 @@ def render_sexp(entries, name_en, letterspace_count, skipped, meta):
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"])
+ if "colour" in e:
+ colour = e["colour"]
+ else:
+ colour = classify_colour(e["month"], e["day"], e["latin"])
+ if "subject" in e:
+ subject = e["subject"]
+ else:
+ 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']})))")
+ if "easter_offset" in e:
+ date_sexp = f"(Easter_offset {e['easter_offset']})"
+ else:
+ date_sexp = f"(Fixed (month {e['month']}) (day {e['day']}))"
+ lines.append(f"{prefix}((date {date_sexp})")
lines.append(f" (cel")
lines.append(f" ((slug {slug})")
lines.append(f" (names ({names_str}))")
@@ -596,6 +709,27 @@ def render_sexp(entries, name_en, letterspace_count, skipped, meta):
return "\n".join(lines) + "\n"
+# Fix round 1: hand-assigned, clean English-style slugs for the two
+# movable entries, keyed by Easter offset. Not run through title_slug's
+# Latin-mechanical fallback (which would produce "sacratissimi-cordis-iesu")
+# because lectio -- the only source this file ever takes a slug FROM when
+# one exists (see the "Slugs:" comment in main() below) -- carries neither
+# entry at all (its own header states both are "computed by internal/
+# calendar", so its roman-calendar.ini has no bracket slug to reuse). This
+# is the one place in the file a slug is authored rather than derived --
+# recorded here rather than left implicit.
+MOVABLE_SLUG_OVERRIDE = {68: "sacred-heart-of-jesus", 69: "immaculate-heart-of-mary"}
+
+# Fix round 1: subject/colour for the two movable entries, set directly
+# rather than through classify_colour/classify_subject (which key off
+# (month, day) -- meaningless for an Easter_offset entry). Both white
+# (IGMR 346(a): "celebrationibus Domini quae non sint de eius Passione" for
+# the Sacred Heart; the same clause's "beatae Mariae Virginis" for the
+# Immaculate Heart -- neither is a Passion or martyr celebration). Subject
+# Lord/Bvm respectively, read directly off each title's own referent.
+MOVABLE_SUBJECT_OVERRIDE = {68: "Lord", 69: "Bvm"}
+
+
def main():
if len(sys.argv) > 1:
with open(sys.argv[1], encoding="utf-8") as f:
@@ -604,7 +738,7 @@ def main():
text = get_text(PDF_PATH)
start, end, table_lines = bound_table(text)
- entries, letterspace_count = parse_rows(table_lines)
+ entries, movable_entries, letterspace_count, movable_audit = parse_rows(table_lines)
# Hand-verified correction for the one letter-spaced entry the
# mechanical pass cannot fully resolve (see module docstring).
@@ -627,6 +761,11 @@ def main():
if (e["month"], e["day"]) == ALL_SOULS_OVERRIDE:
e["grade"] = "Sollemnitas"
+ for e in movable_entries:
+ e["slug"] = MOVABLE_SLUG_OVERRIDE[e["easter_offset"]]
+ e["colour"] = "White"
+ e["subject"] = MOVABLE_SUBJECT_OVERRIDE[e["easter_offset"]]
+
lectio_entries = parse_lectio_ini(LECTIO_INI)
cross_check_report, name_en, lectio_slug = cross_check(entries, lectio_entries)
@@ -654,6 +793,16 @@ def main():
seen[base] = 1
e["slug"] = base
+ # Movable entries carry their own hand-assigned slugs already (see
+ # MOVABLE_SLUG_OVERRIDE); still registered in `seen` and defensively
+ # collision-checked like everything else, rather than assumed safe.
+ for e in movable_entries:
+ base = e["slug"]
+ if base in seen:
+ raise SystemExit(f"movable entry slug {base!r} collides with a fixed-date entry")
+ seen[base] = 1
+
+ entries = entries + movable_entries
entries.sort(key=lambda e: e["slug"])
meta = {
@@ -662,13 +811,21 @@ def main():
"end": end,
"extraction_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
"cross_check_report": cross_check_report,
+ "movable_entries": movable_entries,
}
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)",
+ print(f"[extract_of_calendar] {len(entries)} entries emitted ({len(movable_entries)} of them "
+ f"movable, Easter_offset), {letterspace_count} needed letter-spacing repair, "
+ f"{len(skipped)} dates excluded (temporal_of.ml coverage)",
file=sys.stderr)
+ print(f"[extract_of_calendar] MOVABLE-HEADING AUDIT: {len(movable_audit)} \"Dominica/Feria/"
+ f"Sabbato ... :\" headings found in the table:", file=sys.stderr)
+ for ma in movable_audit:
+ fate = (f"PRODUCED as Easter_offset {ma['easter_offset']}" if ma["produced"]
+ else "skipped -- covered by Temporal_of code")
+ print(f"[extract_of_calendar] {ma['heading']!r} -> {fate}", file=sys.stderr)
for l in cross_check_report:
print(f"[extract_of_calendar] {l}", file=sys.stderr)