CoolFace
Apppublic

society-ethics/annotated-encyclical

sourceHugging Facemitupdated 4mo agoView on Hugging Face
20likes
build.py390 linesDownload Raw Back to root
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