society-ethics/annotated-encyclical
20
1# -*- coding: utf-8 -*-2"""Build the Annotated Encyclical static Space from encyclical.txt + the official Vatican3translations (encyclical.<lang>.txt). Emits index.html plus index.<lang>.html for each language."""4import re, json, html, os5 6# ---------------------------------------------------------------------------7# Data tables (SOURCES catalog + A annotations) live in annotations.py.8# ---------------------------------------------------------------------------9from annotations import SOURCES, A10 11 12# ---------------------------------------------------------------------------13# 2b. COMMUNITY CONTRIBUTORS. If you open a PR that's merged, add yourself here and14# you'll be credited in the footer. Name required; HF profile URL ("u") optional.15# ---------------------------------------------------------------------------16CONTRIBUTORS = [17 # {"name":"Your Name", "u":"https://huggingface.co/your-handle"},18]19 20# ---------------------------------------------------------------------------21# 3. Multilingual build. The English encyclical.txt is the structural SKELETON;22# each translation (encyclical.<lang>.txt, fetched by fetch_translations.py from23# the official Vatican site) supplies the text of the §-numbered paragraphs,24# the footnotes, the headings and the signature, aligned by the universal25# §1-245 / [1]-[224] numbers. HF annotations ("takes") and cited sources stay26# in English for every language; non-English pages carry a caveat banner.27# ---------------------------------------------------------------------------28from collections import defaultdict29 30LANGS = ['en', 'it', 'es', 'fr', 'de', 'pt', 'pl', 'ar']31RTL = {'ar'}32LANG_NAMES = {'en': 'English', 'it': 'Italiano', 'es': 'Español', 'fr': 'Français',33 'de': 'Deutsch', 'pt': 'Português', 'pl': 'Polski', 'ar': 'العربية'}34# Eyebrow (Pope + date) and subtitle taken verbatim from each official Vatican title block.35EYEBROW = {36 'en': 'Pope Leo XIV · 15 May 2026', 'it': 'Papa Leone XIV · 15 maggio 2026',37 'es': 'Papa León XIV · 15 de mayo de 2026', 'fr': 'Pape Léon XIV · 15 mai 2026',38 'de': 'Papst Leo XIV. · 15. Mai 2026', 'pt': 'Papa Leão XIV · 15 de maio de 2026',39 'pl': 'Papież Leon XIV · 15 maja 2026', 'ar': 'البابا لاوُن الرّابع عشر · ١٥ مايو ٢٠٢٦',40}41SUBTITLE = {42 'en': 'On safeguarding the human person in the time of artificial intelligence',43 'it': 'Sulla custodia della persona umana nel tempo dell’intelligenza artificiale',44 'es': 'Sobre la custodia de la persona humana en el tiempo de la inteligencia artificial',45 'fr': 'Sur la protection de la personne humaine à l’ère de l’intelligence artificielle',46 'de': 'Über die Bewahrung des Menschen im Zeitalter der künstlichen Intelligenz',47 'pt': 'Sobre a salvaguarda da pessoa humana na era da inteligência artificial',48 'pl': 'O trosce o osobę ludzką w dobie sztucznej inteligencji',49 'ar': 'في حماية الإنسان في عصر الذّكاء الاصطناعيّ',50}51NOTES_LABEL = {'en': 'Notes', 'it': 'Note', 'es': 'Notas', 'fr': 'Notes', 'de': 'Fußnoten',52 'pt': 'Notas', 'pl': 'Przypisy', 'ar': 'الحواشي'}53BACK_LABEL = {'en': 'Back to text', 'it': 'Torna al testo', 'es': 'Volver al texto',54 'fr': 'Retour au texte', 'de': 'Zurück zum Text', 'pt': 'Voltar ao texto',55 'pl': 'Powrót do tekstu', 'ar': 'العودة إلى النص'}56 57UI = json.load(open('ui_strings.json', encoding='utf-8'))58# Footnote HTML (text + original hyperlinks) extracted from vatican.va by59# fetch_footnotes.py; the plain-text sources drop the links. Falls back to the60# escaped source text for any footnote not present here.61FN_HTML = json.load(open('footnotes.json', encoding='utf-8')) if os.path.exists('footnotes.json') else {}62 63# ---------------------------------------------------------------------------64# Generic extractor: any language file -> ordered skeleton + text by §-number.65# ---------------------------------------------------------------------------66def load_lines(path):67 out = []68 for l in open(path, encoding='utf-8'):69 l = l.replace('\xa0', ' ').replace('', '')70 l = re.sub(r'[ \t]+', ' ', l).strip()71 if not l:72 continue73 l = re.sub(r'^(\d+)\.([^\s\d])', r'\1. \2', l) # "23.La" -> "23. La"74 out.append(l)75 return out76 77def extract(path):78 lines = load_lines(path)79 p1 = next(i for i, l in enumerate(lines) if re.match(r'^1\.\s', l)) # first §180 body = lines[max(0, p1 - 1):] # include the heading before it81 order, heads, paras, fns, sig = [], {}, {}, {}, []82 pending, cur_fn, in_fn, seen_last = [], None, False, False83 for l in body:84 mf = re.match(r'^\[\s*(\d+)\s*\]\s*(.*)', l) # tolerate stray spaces e.g. "[ 124]"85 mp = re.match(r'^(\d+)\.\s(.*)', l)86 if mf:87 in_fn = True; cur_fn = int(mf.group(1)); fns[cur_fn] = mf.group(2); continue88 if in_fn:89 fns[cur_fn] = (fns[cur_fn] + ' ' + l).strip(); continue90 if mp:91 num = int(mp.group(1)); paras[num] = mp.group(2)92 for j, h in enumerate(pending):93 heads[(num, j)] = h; order.append(('head', num, j))94 pending = []95 order.append(('para', num, 0))96 if num == 245:97 seen_last = True98 continue99 (sig if seen_last else pending).append(l)100 return dict(order=order, heads=heads, paras=paras, fns=fns, sig=' '.join(sig).strip())101 102EN = extract('encyclical.txt')103 104def classify(t):105 if t in ('INTRODUCTION', 'CONCLUSION'):106 return 'section'107 if re.match(r'^CHAPTER (ONE|TWO|THREE|FOUR|FIVE)$', t):108 return 'chap'109 if t.isupper() and len(t) > 3:110 return 'chaptitle'111 return 'sub'112HTYPE = {k: classify(v) for k, v in EN['heads'].items()}113 114# ---------------------------------------------------------------------------115# Title-casing for chapter eyebrows/titles (sources are ALL CAPS). English keeps116# its smart casing; other languages get a multilingual small-word / acronym pass.117# ---------------------------------------------------------------------------118SMALL = {'and', 'the', 'of', 'in', 'to', 'a', 'for', 'at', 'on', 'as', 'an', 'or', 'by', 'with', 'from',119 'e', 'di', 'del', 'della', 'dei', 'delle', 'nel', 'nella', 'la', 'il', 'lo', 'al', 'che', "dell'", 'un',120 'y', 'de', 'el', 'en', 'los', 'las',121 'et', 'le', 'du', 'des', 'à', 'aux', "l'", 'la',122 'und', 'der', 'die', 'das', 'im', 'am', 'von', 'zu',123 'da', 'do', 'na', 'no', 'dos', 'das',124 'i', 'w', 'o', 'we', 'na'}125# Curated to avoid collisions with common words (no 'UN'/'SI'/'UE' — they are articles126# /conjunctions in it/es/fr). Sources are ALL CAPS, so collisions can't be detected by case.127ACR = {'AI', 'IA', 'KI', 'GDP', 'DNA', 'USA', 'GPT', 'NATO'}128 129def _cap(piece):130 # capitalize one apostrophe-segment, upper-casing genuine acronyms (e.g. "ia" -> "IA")131 if not piece:132 return piece133 return piece.upper() if piece.upper() in ACR else piece[:1].upper() + piece[1:].lower()134 135def titlecase(s, lang='en'):136 s = s.strip().rstrip('.')137 out = []138 for i, w in enumerate(s.split()):139 bare = w.strip('.,;:')140 if bare.upper() in ACR:141 out.append(bare.upper()); continue142 if i > 0 and bare.lower() in SMALL:143 out.append(w.lower()); continue144 out.append(''.join(p if p in ("'", "’") else _cap(p) for p in re.split(r"(['’])", w)))145 return ' '.join(out)146 147# ---------------------------------------------------------------------------148# Annotations indexed by §-number (English anchors/themes, shared by all langs).149# ---------------------------------------------------------------------------150by_para = defaultdict(list)151for i, a in enumerate(A, 1):152 a["n"] = i153 by_para[a["p"]].append(a)154 155def esc(t):156 return html.escape(t, quote=False)157 158def refs(t, fn_set):159 t = re.sub(r'\[(\d+)\]',160 lambda m: (f'<a class="ref" id="rn{m.group(1)}" href="#fn{m.group(1)}">{m.group(1)}</a>'161 if int(m.group(1)) in fn_set else f'<sup class="ref">{m.group(1)}</sup>'),162 t)163 t = re.sub(r'\[([a-z]{1,3})\]', r'<sup class="ref">\1</sup>', t)164 return t165 166def render_para(num, text, lang, fn_set):167 out = esc(text)168 anns = by_para.get(num, [])169 aria = UI[lang]['ann_aria']170 if lang == 'en':171 for a in anns:172 anc = esc(a["anchor"])173 if anc not in out:174 raise SystemExit(f"ANCHOR NOT FOUND in §{num}: {a['anchor']!r}")175 rep = (f'<span class="ann" data-n="{a["n"]}" tabindex="0" role="button" '176 f'aria-label="{esc(aria)} {a["n"]}: {esc(a["theme"])}">{anc}'177 f'<sup class="annnum">{a["n"]}</sup></span>')178 out = out.replace(anc, rep, 1)179 return f'<p class="vp" id="p{num}"><span class="pnum">{num}</span>{refs(out, fn_set)}</p>'180 # Translations: the English anchor phrase can't be located in the translated181 # text, so the whole §-paragraph is marked and a numbered note badge opens the drawer.182 badges = ''183 for a in anns:184 badges += (f'<button class="ann annmark" data-n="{a["n"]}" '185 f'aria-label="{esc(aria)} {a["n"]}: {esc(a["theme"])}">{a["n"]}</button>')186 marks = f'<span class="annmarks">{badges}</span>' if badges else ''187 cls = 'vp annp' if anns else 'vp'188 return f'<p class="{cls}" id="p{num}"><span class="pnum">{num}</span>{refs(out, fn_set)}{marks}</p>'189 190# ---------------------------------------------------------------------------191# Build the chapter list for one language from the shared English skeleton.192# ---------------------------------------------------------------------------193CHAP_IDS = ['ch-intro', 'ch1', 'ch2', 'ch3', 'ch4', 'ch5', 'ch-conclusion']194CHAP_OPEN = {3, 4, 5} # Chapters Three-Five (the AI material) open by default195 196PARA_ORDER = [num for kind, num, j in EN['order'] if kind == 'para']197 198def en_gap_types(num):199 items = sorted((j, t) for (n, j), t in EN['heads'].items() if n == num)200 return [classify(t) for _, t in items], [t for _, t in items]201 202def semantic_gap(num, L):203 """Headings that precede paragraph `num`, as (type, text) pairs in the target language.204 A chapter title can wrap into a different number of lines per language, so the single-line205 section/CHAPTER/subhead lines are anchored at the ends of the gap and all the middle lines206 are absorbed into one chaptitle — keeping alignment regardless of how the title is split."""207 en_types, en_lines = en_gap_types(num)208 if not en_types:209 return []210 tr_lines = [t for _, t in sorted((j, t) for (n, j), t in L['heads'].items() if n == num)]211 lead = 1 if en_types[0] in ('section', 'chap') else 0212 trail, k = 0, len(en_types) - 1213 while k >= lead and en_types[k] == 'sub':214 trail += 1; k -= 1215 has_title = any(t == 'chaptitle' for t in en_types[lead:len(en_types) - trail])216 if len(tr_lines) < lead + trail: # structure didn't line up: fall back to English217 tr_lines = en_lines218 mid_lo, mid_hi = lead, len(tr_lines) - trail219 tr_lead = tr_lines[:lead]220 tr_mid = tr_lines[mid_lo:mid_hi]221 tr_trail = tr_lines[mid_hi:] if trail else []222 out = []223 for i in range(lead):224 out.append((en_types[i], tr_lead[i] if i < len(tr_lead) else en_lines[i]))225 if has_title:226 out.append(('chaptitle', tr_mid or en_lines[mid_lo:len(en_lines) - trail]))227 else:228 for m in tr_mid: # no title here -> stray middle lines are subheads229 out.append(('sub', m))230 for i in range(trail):231 out.append(('sub', tr_trail[i] if i < len(tr_trail) else ''))232 return out233 234def build_chapters(lang, L):235 fn_set = set(L['fns'])236 chapters, cur = [], None237 for num in PARA_ORDER:238 for typ, text in semantic_gap(num, L):239 if typ in ('section', 'chap'):240 cur = {'eyebrow': '', 'title': '', 'body': [], 'npara': 0}241 chapters.append(cur)242 if typ == 'section':243 cur['title'] = text.title() if lang != 'ar' else text244 else:245 cur['eyebrow'] = titlecase(text, lang)246 elif typ == 'chaptitle':247 joined = ' · '.join(titlecase(x, lang) for x in text if x.strip())248 cur['title'] = (cur['title'] + ' · ' + joined) if cur['title'] else joined249 elif text.strip():250 cur['body'].append(f'<h3 class="sub">{esc(text)}</h3>')251 cur['body'].append(render_para(num, L['paras'].get(num) or EN['paras'][num], lang, fn_set))252 cur['npara'] += 1253 return chapters254 255def render_body(lang, L):256 u = UI[lang]257 chapters = build_chapters(lang, L)258 parts = []259 for idx, c in enumerate(chapters):260 cid = CHAP_IDS[idx] if idx < len(CHAP_IDS) else ''261 is_open = idx in CHAP_OPEN262 desc = u['chap_desc'][idx] if idx < len(u['chap_desc']) else ''263 n = c['npara']264 eyebrow = f'<span class="csum-eyebrow">{esc(c["eyebrow"])}</span>' if c['eyebrow'] else ''265 title = f'<span class="csum-title">{esc(c["title"])}</span>' if c['title'] else ''266 cue = (f'<span class="csum-desc">{esc(desc)} '267 f'<span class="csum-n">· {n} {esc(u["sections_word"])}</span></span>')268 cta = (f'<span class="csum-cta"><span class="chev">›</span>'269 f'<span class="cta-read">{esc(u["click_read"])}</span>'270 f'<span class="cta-collapse">{esc(u["click_collapse"])}</span></span>')271 parts.append(272 f'<details class="chapter{" ai" if is_open else ""}" id="{cid}"{" open" if is_open else ""}>'273 f'<summary><div class="csum">{eyebrow}{title}{cue}{cta}</div></summary>'274 f'<div class="chapter-body">{"".join(c["body"])}</div></details>')275 276 parts.append(f'<div class="sig"><p>{esc(L["sig"])}</p><p class="signame">LEO PP. XIV</p></div>')277 278 fn_items = []279 fn_html = FN_HTML.get(lang, {})280 for k in sorted(L['fns']):281 body = fn_html.get(str(k)) or esc(L["fns"][k]) # original (with links) else plain text282 fn_items.append(283 f'<li id="fn{k}"><a class="fnback" href="#rn{k}" aria-label="{esc(BACK_LABEL[lang])}">{k}.</a>'284 f'<span>{body} <a class="fnup" href="#rn{k}">↩</a></span></li>')285 # Footnotes follow the signature, always visible and unlabelled, as in the286 # original document (no collapsible "Notes" heading).287 parts.append(288 '<section class="footnotes" id="endnotes"><ol>' + ''.join(fn_items) + '</ol></section>')289 return '\n'.join(parts), len(chapters)290 291# ---------------------------------------------------------------------------292# Source classification + annotation JSON (shared, English) with the anchor as `q`.293# ---------------------------------------------------------------------------294# ---------------------------------------------------------------------------295# Source kind: stored as "k" in each SOURCES entry; defaults to "Paper".296# ---------------------------------------------------------------------------297def kind_of(key):298 return SOURCES[key].get("k", "Paper")299 300client_ann = {}301for a in A:302 srcs = []303 for k in a["src"]:304 s = SOURCES[k]305 srcs.append({"t": s["t"], "a": s["a"], "y": s["y"], "v": s["v"], "u": s["u"],306 "hf": s.get("hf", 0), "k": kind_of(k)})307 client_ann[a["n"]] = {"theme": a["theme"], "take": a["take"], "p": a["p"],308 "q": a["anchor"], "src": srcs}309ANN_JSON = json.dumps(client_ann, ensure_ascii=False)310 311n_ann = len(A)312n_src = len({k for a in A for k in a["src"]})313 314# ---------------------------------------------------------------------------315# Emit ONE self-contained page. All languages are embedded and switched client-side316# (no navigation) so the page works inside a private HF Space, where navigating to a317# second file would lose the signed-iframe auth. English chrome is baked in as the318# default first paint; the JS swaps body + chrome + direction on language change.319# ---------------------------------------------------------------------------320def vat_url(code):321 return f'https://www.vatican.va/content/leo-xiv/{code}/encyclicals/documents/20260515-magnifica-humanitas.html'322 323def lang_menu():324 return ''.join(325 f'<button class="langopt" type="button" data-lang="{c}" lang="{c}" '326 f'dir="{"rtl" if c in RTL else "ltr"}">{LANG_NAMES[c]}</button>' for c in LANGS)327 328def contributors_html():329 if not CONTRIBUTORS:330 return ''331 names = []332 for c in CONTRIBUTORS:333 n = esc(c["name"])334 names.append(f'<a href="{c["u"]}" target="_blank" rel="noopener">{n}</a>' if c.get("u") else n)335 return ('<p class="foot-contrib"><b>With thanks to community contributors:</b> '336 + ' · '.join(names) + '</p>')337 338# Render every language's body, plus the per-language metadata the client needs.339print(f"Rendering {len(LANGS)} language bodies …")340BODIES, META = {}, {}341for lang in LANGS:342 L = EN if lang == 'en' else extract(f'encyclical.{lang}.txt')343 BODIES[lang], nchap = render_body(lang, L)344 META[lang] = {'name': LANG_NAMES[lang], 'dir': 'rtl' if lang in RTL else 'ltr',345 'eyebrow': EYEBROW[lang], 'subtitle': SUBTITLE[lang], 'vat': vat_url(lang)}346 print(f" {lang}: {nchap} chapters · §{len(L['paras'])} · {len(L['fns'])} notes")347 348def embed(obj): # </ would otherwise close the <script>; neutralize it inside JS string data349 return json.dumps(obj, ensure_ascii=False).replace('</', '<\\/')350 351en = UI['en']352repl = {353 "__EYEBROW__": esc(EYEBROW['en']),354 "__SUBTITLE__": esc(SUBTITLE['en']),355 "__READING_BY__": en["reading_by"],356 "__STAT_ANN__": en["stat_ann"], "__STAT_SRC__": en["stat_src"],357 "__STANDFIRST__": en["standfirst"],358 "__HOWTO1H__": en["howto1_h"], "__HOWTO1__": en["howto1"],359 "__HOWTO2H__": en["howto2_h"], "__HOWTO2__": en["howto2"],360 "__JUMP__": en["jump"],361 "__TOC_BTN__": en["toc_btn"], "__TOC_TITLE__": en["toc_title"],362 "__ORIG__": en["orig"], "__ORIG_TITLE__": en["orig_title"], "__ORIG_URL__": vat_url('en'),363 "__LANG_LABEL__": en["lang_label"], "__LANGMENU__": lang_menu(),364 "__DR_EYEBROW__": en["dr_eyebrow"], "__DR_ARTICLES__": en["dr_articles"],365 "__DR_PREV__": en["dr_prev"], "__DR_NEXT__": en["dr_next"],366 "__THEME_TOGGLE__": en["theme_toggle"],367 "__CONTRIBUTORS__": contributors_html(),368 "__FOOTER__": en["footer"],369 "__NANN__": str(n_ann), "__NSRC__": str(n_src),370}371 372TEMPLATE = open('template.html', encoding='utf-8').read()373out = (TEMPLATE374 .replace("/*__BODY__*/", BODIES['en'])375 .replace("/*__ANN__*/", ANN_JSON)376 .replace("/*__BODIES__*/", embed(BODIES))377 .replace("/*__UI__*/", embed(UI))378 .replace("/*__META__*/", embed(META)))379for k, v in sorted(repl.items(), key=lambda kv: -len(kv[0])): # longest first: __LANG_LABEL__ before __LANG__380 out = out.replace(k, v)381 382OUT = 'space' if os.path.isdir('space') else '.'383open(os.path.join(OUT, 'index.html'), 'w', encoding='utf-8').write(out)384# A single self-contained page now holds every language; remove any stale per-language files.385for code in LANGS:386 stale = os.path.join(OUT, f'index.{code}.html')387 if os.path.exists(stale):388 os.remove(stale)389print(f"Wrote {OUT}/index.html ({len(out):,} bytes) · {len(LANGS)} languages · annotations={n_ann} sources={n_src}")390 