#!/usr/bin/env python3 """Parse the InDesign HTML export of *A la fuente cada día*. The output is deliberately source-only: it contains no civil-date mapping. That mapping belongs in the separate, reviewable liturgical calendar JSON. """ from __future__ import annotations import argparse import html import json import re from pathlib import Path TITLE_SOLEMNITY = "V-T-tulo-2-l-nea" TITLE_OR_CITATION = "V-T-tulo-L-a-S" GOSPEL = "Sangr-a-2-de-t--independiente" MOTTO = "V-P-rrafo-2" PARAGRAPH = "V-P-rrafo" CITATION_RE = re.compile(r"\((?:Mt|Mc|Lc|Jn)\b[^)]*\)", re.IGNORECASE) MANUAL_CITATIONS = { # Three malformed/omitted references in the InDesign export, resolved from # the printed Gospel text in the same source entry. "Miércoles de la 32ª semana Lc 17,11-19)": "Lc 17,11-19", "22 de Julio": "Jn 20,1-2.11-18", "2 de noviembre": "Jn 11,21-44", } def text(fragment: str) -> str: """Return normalized text from the small, well-formed HTML fragments.""" fragment = re.sub(r"<[^>]+>", "", fragment) return re.sub(r"\s+", " ", html.unescape(fragment).replace("\xa0", " ")).strip() def css_base(class_name: str) -> str: return class_name.split(" ", 1)[0] def is_citation(value: str) -> bool: return bool(CITATION_RE.fullmatch(value.strip())) def clean_citation(value: str) -> str: match = CITATION_RE.search(value) return (match.group(0) if match else value).strip().removeprefix("(").removesuffix(")").strip() def new_entry(title: str, kind: str) -> dict[str, object]: return { "source_index": 0, "kind": kind, "title": title, "citation": "", "gospel": "", "motto": "", "paragraphs": [], } def set_citation_from(value: str, entry: dict[str, object]) -> None: """Capture a reference embedded in a weekday or feast-title line.""" if entry["citation"]: return match = CITATION_RE.search(value) if match: entry["citation"] = clean_citation(match.group(0)) elif "pasión según" in value.lower(): entry["citation"] = value.strip().removeprefix("(").removesuffix(")") def parse(source: Path) -> list[dict[str, object]]: raw = source.read_text(encoding="utf-8") blocks = re.findall(r'
]*>(.*?)
', raw, re.S) entries: list[dict[str, object]] = [] current: dict[str, object] | None = None def finish() -> None: nonlocal current if current is not None: current["source_index"] = len(entries) + 1 entries.append(current) current = None for class_name, fragment in blocks: role, value = css_base(class_name), text(fragment) if not value: continue if role == TITLE_SOLEMNITY: finish() current = new_entry(value, "solemnity") set_citation_from(value, current) continue if role == TITLE_OR_CITATION and not is_citation(value): # This class is overloaded. After an already complete entry it is # the next weekday title (and also the Holy Family title). Right # after a large title it is instead a rubric such as "INMACULADA" # or "Pasión según Mt", so it must remain part of that entry. if current is None or current["motto"] or current["paragraphs"]: finish() current = new_entry(value, "weekday") set_citation_from(value, current) continue if current is None: continue if role == TITLE_OR_CITATION and is_citation(value): current["citation"] = clean_citation(value) elif role == TITLE_OR_CITATION: set_citation_from(value, current) elif role == GOSPEL: current["gospel"] = value elif role == MOTTO: current["motto"] = value elif role == PARAGRAPH: # One source inconsistency (Easter VI, cycle A) styles the Gospel as # a normal paragraph. Before the motto, that position is unambiguous. if not current["gospel"] and not current["motto"]: current["gospel"] = value else: current["paragraphs"].append(value) finish() for entry in entries: if not entry["citation"] and entry["title"] in MANUAL_CITATIONS: entry["citation"] = MANUAL_CITATIONS[entry["title"]] return entries def audit(entries: list[dict[str, object]]) -> list[str]: issues: list[str] = [] for entry in entries: missing = [field for field in ("citation", "gospel", "motto") if not entry[field]] if not entry["paragraphs"]: missing.append("paragraphs") if missing: issues.append(f"#{entry['source_index']} {entry['title']}: missing {', '.join(missing)}") return issues def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("source", type=Path) parser.add_argument("output", type=Path) args = parser.parse_args() entries = parse(args.source) issues = audit(entries) # The earlier exploratory JSON had 504 records, but four were artificial # citation-only splits (Holy Family and Baptism A/B/C). The source has 500 # complete, independently publishable comments. if len(entries) != 500: raise SystemExit(f"expected 500 complete entries; got {len(entries)}") if issues: raise SystemExit("source audit failed:\n" + "\n".join(issues)) args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(entries, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(f"wrote {len(entries)} complete entries to {args.output}") return 0 if __name__ == "__main__": raise SystemExit(main())