#!/usr/bin/env python3 """Generate a structured PDF genealogy report from Gramps JSONL export.""" import json import sys from collections import defaultdict from datetime import datetime from fpdf import FPDF # ── Config ────────────────────────────────────────────────────────────────── INPUT = "/root/.hermes/webui/attachments/b3196dc4d94f/gramps-web-export-20260722234742.json" # ── Load data ────────────────────────────────────────────────────────────── objects = [] with open(INPUT) as f: for line in f: line = line.strip() if line: if "|" in line and line[0].isdigit(): line = line.split("|", 1)[1] objects.append(json.loads(line)) by_handle = {o["handle"]: o for o in objects} # ── Event type mapping ─────────────────────────────────────────────────────── EVENT_TYPES = { 0: "Mariage", 1: "Mariage", 2: "Divorce", 3: "Décès", 4: "Inhumation", 5: "Obsèques", 6: "Naissance", 7: "Baptême", 8: "Adoption", 9: "Baptême (LDS)", 10: "Endowment (LDS)", 11: "Scellement (LDS)", 12: "Naissance", 13: "Décès", 19: "Inhumation", } def event_type_str(t): return EVENT_TYPES.get(t.get("value"), f"Type {t.get('value')}") def format_date(d): if not d: return "" dv = d.get("dateval", [0, 0, 0]) y, m, day = dv[2], dv[1], dv[0] if y == 0: return "" if m == 0: return str(y) if day == 0: months = ["", "Jan", "Fév", "Mar", "Avr", "Mai", "Juin", "Juil", "Aoû", "Sep", "Oct", "Nov", "Déc"] return f"{months[m]} {y}" return f"{day:02d}/{m:02d}/{y}" def person_name(p): if not p: return "Inconnu" n = p.get("primary_name", {}) first = n.get("first_name", "").strip() sur_list = n.get("surname_list", []) sur = sur_list[0].get("surname", "").strip() if sur_list else "" suffix = n.get("suffix", "").strip() parts = [first, sur, suffix] if suffix else [first, sur] return " ".join(p for p in parts if p).strip() def get_person_events(p): birth, death = "", "" for er in p.get("event_ref_list", []): ev = by_handle.get(er.get("ref")) if ev: t = ev.get("type", {}).get("value") d = format_date(ev.get("date")) if t == 12: birth = d elif t == 13: death = d return birth, death def get_events_for_person(p): events = [] for er in p.get("event_ref_list", []): ev = by_handle.get(er.get("ref")) if ev: events.append({ "type": event_type_str(ev.get("type", {})), "date": format_date(ev.get("date")), "desc": ev.get("description", ""), "place": ev.get("place", ""), }) return events def get_parents(person): father, mother = None, None for pf in person.get("parent_family_list", []): fam = by_handle.get(pf) if fam: if fam.get("father_handle"): father = by_handle.get(fam["father_handle"]) if fam.get("mother_handle"): mother = by_handle.get(fam["mother_handle"]) return father, mother def get_families(person): return [f for f in families if f.get("father_handle") == person["handle"] or f.get("mother_handle") == person["handle"]] def get_children(family): children = [] for cr in family.get("child_ref_list", []): child = by_handle.get(cr.get("ref")) if child: children.append(child) return children def get_place_name(handle): pl = by_handle.get(handle) if pl: return pl.get("name", {}).get("value", "") return "" def get_notes_for_person(p): notes = [] for nh in p.get("note_list", []): n = by_handle.get(nh) if n: notes.append(n.get("text", {}).get("string", "")) return notes # ── Index data ────────────────────────────────────────────────────────────── persons = [o for o in objects if o.get("_class") == "Person"] families = [o for o in objects if o.get("_class") == "Family"] places = [o for o in objects if o.get("_class") == "Place"] media = [o for o in objects if o.get("_class") == "Media"] notes = [o for o in objects if o.get("_class") == "Note"] # ── PDF Generation ─────────────────────────────────────────────────────────── class GenealogyPDF(FPDF): def header(self): if self.page_no() > 1: self.set_font("Helvetica", "I", 7) self.set_text_color(130, 130, 130) self.cell(0, 5, "Rapport de Genealogie - Familles Keller, Staerle & Alliees", align="C") self.ln(7) def footer(self): self.set_y(-15) self.set_font("Helvetica", "I", 7) self.set_text_color(130, 130, 130) self.cell(0, 8, f"Page {self.page_no()}/{{nb}}", align="C") def section_title(self, title): self.set_font("Helvetica", "B", 14) self.set_text_color(15, 23, 42) self.cell(0, 8, title) self.ln(4) self.set_draw_color(15, 23, 42) self.set_line_width(0.5) self.line(self.get_x(), self.get_y(), self.get_x() + 190, self.get_y()) self.ln(6) def sub_title(self, title): self.set_font("Helvetica", "B", 11) self.set_text_color(30, 60, 100) self.cell(0, 7, title) self.ln(5) def body_text(self, text): self.set_font("Helvetica", "", 9) self.set_text_color(50, 50, 50) w = self.w - self.get_x() - self.r_margin self.multi_cell(w, 4.5, text) self.ln(1) def bullet(self, text): self.set_font("Helvetica", "", 9) self.set_text_color(50, 50, 50) x0 = self.get_x() self.cell(5, 4.5, "-") w = self.w - self.get_x() - self.r_margin self.multi_cell(w, 4.5, text) self.ln(0.5) def kv(self, key, value): self.set_font("Helvetica", "B", 9) self.set_text_color(50, 50, 50) self.cell(40, 5, key) self.set_font("Helvetica", "", 9) w = self.w - self.get_x() - self.r_margin self.multi_cell(w, 5, value) self.ln(0.5) def person_card(self, p, level=0): name = person_name(p) birth, death = get_person_events(p) gender_map = {0: "F", 1: "M", 2: "?"} gender = gender_map.get(p.get("gender"), "?") indent = level * 4 self.set_x(self.l_margin + indent) self.set_font("Helvetica", "B", 10) self.set_text_color(15, 23, 42) self.cell(0, 5, f"{name} [{gender}] ({p.get('gramps_id', '')})") self.ln(5) self.set_x(self.l_margin + indent + 4) self.set_font("Helvetica", "", 9) self.set_text_color(80, 80, 80) dates = [] if birth: dates.append(f"Ne(e) le {birth}") if death: dates.append(f"Decede(e) le {death}") if dates: self.cell(0, 4.5, " | ".join(dates)) self.ln(4.5) events = get_events_for_person(p) for ev in events: if ev["type"] not in ("Naissance", "Décès"): self.set_x(self.l_margin + indent + 4) self.set_font("Helvetica", "", 8) self.set_text_color(100, 100, 100) txt = f"{ev['type']}: {ev['date']}" if ev["date"] else ev["type"] if ev["place"]: place_name = get_place_name(ev["place"]) if place_name: txt += f" - {place_name}" if ev["desc"]: txt += f" ({ev['desc']})" self.cell(0, 4, txt) self.ln(4) # Notes (professions, etc.) p_notes = get_notes_for_person(p) for n in p_notes: self.set_x(self.l_margin + indent + 4) self.set_font("Helvetica", "I", 8) self.set_text_color(120, 120, 120) self.cell(0, 4, f"[{n.strip()}]") self.ln(4) self.ln(2) def family_section(self, family, label=""): father = by_handle.get(family.get("father_handle")) mother = by_handle.get(family.get("mother_handle")) children = get_children(family) f_name = person_name(father) if father else "?" m_name = person_name(mother) if mother else "?" marriage_date = "" marriage_place = "" for er in family.get("event_ref_list", []): ev = by_handle.get(er.get("ref")) if ev: marriage_date = format_date(ev.get("date")) marriage_place = get_place_name(ev.get("place", "")) rel_type = family.get("type", {}).get("value", 0) rel_str = "Marie" if rel_type == 0 else "Non marie" if rel_type == 3 else "" title = f"{f_name} & {m_name}" if label: title = f"{label}: {title}" self.sub_title(title) if marriage_date: txt = marriage_date if marriage_place: txt += f" - {marriage_place}" self.kv("Mariage:", txt) if rel_str: self.kv("Statut:", rel_str) if children: self.ln(1) self.set_font("Helvetica", "I", 9) self.set_text_color(80, 80, 80) self.cell(0, 5, "Enfants:") self.ln(5) for c in children: c_name = person_name(c) c_birth, c_death = get_person_events(c) c_dates = [] if c_birth: c_dates.append(f"n. {c_birth}") if c_death: c_dates.append(f"d. {c_death}") c_info = f" {c_name}" if c_dates: c_info += f" ({', '.join(c_dates)})" self.bullet(c_info) self.ln(3) # ── Build the report ───────────────────────────────────────────────────────── pdf = GenealogyPDF() pdf.alias_nb_pages() pdf.set_auto_page_break(auto=True, margin=20) pdf.add_page() # ── Cover ──────────────────────────────────────────────────────────────────── pdf.ln(30) pdf.set_font("Helvetica", "B", 24) pdf.set_text_color(15, 23, 42) pdf.cell(0, 12, "Rapport de Genealogie", align="C") pdf.ln(10) pdf.set_font("Helvetica", "", 14) pdf.set_text_color(60, 60, 60) pdf.cell(0, 8, "Familles Keller, Staerle & Alliees", align="C") pdf.ln(8) pdf.set_font("Helvetica", "", 10) pdf.cell(0, 6, "Suisse (Schwytz) - Alsace (France) - Moselle", align="C") pdf.ln(6) pdf.cell(0, 6, f"Genere le {datetime.now().strftime('%d/%m/%Y a %H:%M')}", align="C") pdf.ln(20) # Stats box pdf.set_fill_color(240, 244, 248) pdf.set_draw_color(200, 210, 220) pdf.rect(30, pdf.get_y(), 150, 35, "DF") pdf.set_xy(35, pdf.get_y() + 3) pdf.set_font("Helvetica", "B", 10) pdf.set_text_color(15, 23, 42) pdf.cell(0, 5, "Resume") pdf.ln(6) pdf.set_x(35) pdf.set_font("Helvetica", "", 9) pdf.set_text_color(50, 50, 50) pdf.cell(0, 4.5, f"Personnes: {len(persons)} | Familles: {len(families)} | Lieux: {len(places)}") pdf.ln(4.5) pdf.set_x(35) pdf.cell(0, 4.5, f"Evenements: {len([o for o in objects if o.get('_class') == 'Event'])} | Medias: {len(media)}") pdf.ln(4.5) pdf.set_x(35) pdf.cell(0, 4.5, f"Generations: 7 (de 1760 a nos jours)") pdf.ln(4.5) pdf.set_x(35) pdf.cell(0, 4.5, f"Origines: Altendorf (SZ), Alsace, Moselle, Vaud") pdf.ln(15) # ── Table of Contents ─────────────────────────────────────────────────────── pdf.add_page() pdf.section_title("Table des matieres") toc = [ "1. Arbre genealogique - Famille Keller", "2. Arbre genealogique - Famille Staerle (branche alsacienne)", "3. Branches alliees (Lemann, Kutzner, Hamm, Gangloff)", "4. Fiches individuelles detaillees", "5. Lieux", "6. Medias et documents", "7. Notes et sources", "8. Statistiques", ] for item in toc: pdf.bullet(item) # ═══════════════════════════════════════════════════════════════════════════ # 1. KELLER FAMILY TREE # ═══════════════════════════════════════════════════════════════════════════ pdf.add_page() pdf.section_title("1. Arbre genealogique - Famille Keller") # Gen 1: Jakob Keller pdf.sub_title("Generation 1 (ancetre)") jakob = by_handle.get("a37f8646-d374-40da-863b-86febf23b165") if jakob: pdf.person_card(jakob) # Family F0006: Jakob -> Silvio fam_f6 = by_handle.get("103b3ad8ce8c43637db4d46f804c") if fam_f6: pdf.family_section(fam_f6, "Famille") # Gen 2: Silvio + Margrit pdf.sub_title("Generation 2") silvio = by_handle.get("d1220b0f-9a04-4581-994a-6c4985f14c83") margrit = by_handle.get("bad07459-e2d9-4413-85bd-5b8b6e8463d5") if silvio: pdf.person_card(silvio) if margrit: pdf.person_card(margrit) fam_f1 = by_handle.get("103b34dfb7d317e6eacf93b9b322") if fam_f1: pdf.family_section(fam_f1, "Famille") # Gen 3: Lucien + Sylvie pdf.sub_title("Generation 3") lucien = by_handle.get("4c572a11-13cd-46ee-9aee-39b904e86dcd") sylvie = by_handle.get("14d0669e-3174-4a94-b5e5-23af79331f20") if lucien: pdf.person_card(lucien) if sylvie: pdf.person_card(sylvie) fam_f0 = by_handle.get("103b34b3fc3a78115abdc520a0d7") if fam_f0: pdf.family_section(fam_f0, "Famille") # Gen 4: Laurent + Lucie pdf.sub_title("Generation 4") laurent = by_handle.get("7c2b4663-ba21-49ce-af58-b268babe3e25") if laurent: pdf.person_card(laurent) lucie = by_handle.get("7cb9d60e-8a61-47d5-a4bf-69d1a23a7068") if lucie: pdf.person_card(lucie) # Gen 3 siblings: Tobias, Lea, Livia pdf.sub_title("Generation 3 - Freres et soeurs de Lucien") tobias = by_handle.get("1a501a5f-2721-491c-9bfe-7d3d1be88bdc") lea = by_handle.get("cfbe8ee0-0c31-4f92-ada8-a9054514df0c") livia = by_handle.get("b7c9c8c6-471d-481d-99c4-eb1753a0fb31") for p in [tobias, lea, livia]: if p: pdf.person_card(p) # ═══════════════════════════════════════════════════════════════════════════ # 2. STAERLE FAMILY TREE (Alsace branch) # ═══════════════════════════════════════════════════════════════════════════ pdf.add_page() pdf.section_title("2. Arbre genealogique - Famille Staerle (Alsace)") # Gen 1 (1760): Johann Michael Staerle + Catharina Bernhard pdf.sub_title("Generation 1 (v. 1760)") jm1 = by_handle.get("1c80dd1f-77b4-4ab9-940c-e0776b6161ff") cmb = by_handle.get("cc5dad4f-1963-4037-b7db-040680011dd9") if jm1: pdf.person_card(jm1) if cmb: pdf.person_card(cmb) fam_f12 = by_handle.get("103b753f4b6772d2f9cd968bc788") if fam_f12: pdf.family_section(fam_f12, "Famille") # Gen 2: Johann Michael Staerle + Christiana Metz pdf.sub_title("Generation 2") jm2 = by_handle.get("32c7f3f4-aa1c-4501-aa67-0d93ebcfbf68") cm = by_handle.get("01f5daba-73b0-4ae9-87cb-80afc755aca7") if jm2: pdf.person_card(jm2) if cm: pdf.person_card(cm) fam_f11 = by_handle.get("103b74f88457186cf98a9b6eca8a") if fam_f11: pdf.family_section(fam_f11, "Famille") # Gen 3: Jean Adam Staerle + Madeleine Helf pdf.sub_title("Generation 3") ja = by_handle.get("30aeb3bd-dafd-4a4f-baf3-d75c1fd0d535") mh = by_handle.get("ab0e498f-3e4f-41de-8766-0941d58614b9") if ja: pdf.person_card(ja) if mh: pdf.person_card(mh) fam_f10 = by_handle.get("103b744d30a81442468b19dce1e6") if fam_f10: pdf.family_section(fam_f10, "Famille") # Gen 4: Michel Staerle + Sophie Vogel pdf.sub_title("Generation 4") michel = by_handle.get("8d3313ce-d89c-473e-a7aa-4385d5bea769") sophie = by_handle.get("2c548840-a9ef-4f2c-9195-7c9007b963a2") if michel: pdf.person_card(michel) if sophie: pdf.person_card(sophie) fam_f9 = by_handle.get("103b730b5d1b5e3c15d9fa4d5111") if fam_f9: pdf.family_section(fam_f9, "Famille") # Gen 5: Guillaume Staerle + Frida Bender pdf.sub_title("Generation 5") guillaume1 = by_handle.get("3c98357d-3645-4a74-ae8d-3fb91b005fe8") frida = by_handle.get("ce24fa7a-b67d-47ba-ad46-3fc940be0c25") if guillaume1: pdf.person_card(guillaume1) if frida: pdf.person_card(frida) fam_f3 = by_handle.get("103b39893eb03c1e4ed40f6f8d9b") if fam_f3: pdf.family_section(fam_f3, "Famille") # Gen 6: Guillaume Staerle + Catherine Jund pdf.sub_title("Generation 6") guillaume2 = by_handle.get("bce9436e-8a1c-4eb1-b4c6-bfdcc7c6a935") catherine = by_handle.get("88c262e3-9d6f-4f29-a368-03732e5d4b7b") if guillaume2: pdf.person_card(guillaume2) if catherine: pdf.person_card(catherine) fam_f2 = by_handle.get("103b35aa6e2918ae0dc0317e052b") if fam_f2: pdf.family_section(fam_f2, "Famille") # Gen 7: Sylvie, Annette, Corinne, Isabelle, Anne pdf.sub_title("Generation 7 - Enfants Staerle") staerle_children = [ "14d0669e-3174-4a94-b5e5-23af79331f20", # Sylvie "e30f96b7-2860-4473-acd5-f5d9b5ae4f36", # Annette "ccf375b4-c0ca-4bba-87e6-365c6b348bb5", # Corinne "9428a2d4-6330-4a1c-835e-f92eacefb805", # Isabelle "f6d9662e-02a4-4327-99b8-9b48a30f8bc9", # Anne ] for h in staerle_children: p = by_handle.get(h) if p: pdf.person_card(p) # Christian Staerle + Yvette pdf.sub_title("Generation 7 - Christian Staerle & Yvette") christian = by_handle.get("33c893ff-60b3-4420-931a-bb3f28ce0562") yvette = by_handle.get("66c36421-4e32-42e9-bc57-c47f6e6adfe4") if christian: pdf.person_card(christian) if yvette: pdf.person_card(yvette) fam_f5 = by_handle.get("103b3ac22c3c4c35c31f77a70910") if fam_f5: pdf.family_section(fam_f5, "Famille") # Gen 8: Chris, Jean-michel Staerle pdf.sub_title("Generation 8 - Enfants Christian & Yvette") chris = by_handle.get("4e908cd1-83e6-4aa5-a004-4d510144a528") jm_staerle = by_handle.get("ed8e0c2d-49e2-43a7-9c3f-18bd72afbdc7") for p in [chris, jm_staerle]: if p: pdf.person_card(p) # ═══════════════════════════════════════════════════════════════════════════ # 3. ALLIED BRANCHES # ═══════════════════════════════════════════════════════════════════════════ pdf.add_page() pdf.section_title("3. Branches alliees") # Annette Hamm + Daniel Gangloff pdf.sub_title("Famille Hamm-Gangloff") annette = by_handle.get("e30f96b7-2860-4473-acd5-f5d9b5ae4f36") daniel = by_handle.get("f6a18cb6-d08c-44b1-b52f-53de36d24107") if annette: pdf.person_card(annette) if daniel: pdf.person_card(daniel) fam_f4 = by_handle.get("103b3aa996d7492b0a90e358032a") if fam_f4: pdf.family_section(fam_f4, "Famille") # Lea Keller + Sandro Lemann pdf.sub_title("Famille Lemann") lea = by_handle.get("cfbe8ee0-0c31-4f92-ada8-a9054514df0c") sandro = by_handle.get("ef9b14d5-ab40-48fd-a110-eb4aac2a6af2") if lea: pdf.person_card(lea) if sandro: pdf.person_card(sandro) fam_f7 = by_handle.get("103b3b4a63723ca20a2d8c369711") if fam_f7: pdf.family_section(fam_f7, "Famille") # Livia Keller + Florian Kutzner pdf.sub_title("Famille Kutzner") livia = by_handle.get("b7c9c8c6-471d-481d-99c4-eb1753a0fb31") florian = by_handle.get("b4984dbd-2f15-4a41-9e6f-900d0f67e507") if livia: pdf.person_card(livia) if florian: pdf.person_card(florian) fam_f8 = by_handle.get("103b3b5df8a1443d8513129fe995") if fam_f8: pdf.family_section(fam_f8, "Famille") # ═══════════════════════════════════════════════════════════════════════════ # 4. DETAILED PERSON CARDS # ═══════════════════════════════════════════════════════════════════════════ pdf.add_page() pdf.section_title("4. Fiches individuelles detaillees") all_persons_sorted = sorted(persons, key=lambda p: p.get("gramps_id", "")) for p in all_persons_sorted: name = person_name(p) pid = p.get("gramps_id", "") birth, death = get_person_events(p) gender_map = {0: "Femme", 1: "Homme", 2: "Autre"} gender = gender_map.get(p.get("gender"), "?") if pdf.get_y() > 230: pdf.add_page() pdf.sub_title(f"{name} ({pid})") pdf.kv("Nom:", name) pdf.kv("Genre:", gender) if birth: pdf.kv("Naissance:", birth) if death: pdf.kv("Deces:", death) father, mother = get_parents(p) if father or mother: parents_str = [] if father: parents_str.append(person_name(father)) if mother: parents_str.append(person_name(mother)) pdf.kv("Parents:", " & ".join(parents_str)) own_fams = get_families(p) if own_fams: for f in own_fams: spouse_handle = f.get("father_handle") if f.get("mother_handle") == p["handle"] else f.get("mother_handle") spouse = by_handle.get(spouse_handle) if spouse_handle else None children = get_children(f) if spouse: pdf.kv("Conjoint(e):", person_name(spouse)) if children: pdf.kv("Enfants:", ", ".join(person_name(c) for c in children)) events = get_events_for_person(p) for ev in events: txt = ev["type"] if ev["date"]: txt += f" le {ev['date']}" if ev["place"]: place_name = get_place_name(ev["place"]) if place_name: txt += f" - {place_name}" if ev["desc"]: txt += f" ({ev['desc']})" pdf.kv(ev["type"] + ":", txt if txt != ev["type"] else "-") p_notes = get_notes_for_person(p) for n in p_notes: pdf.kv("Note:", n.strip()) pdf.ln(3) # ═══════════════════════════════════════════════════════════════════════════ # 5. PLACES # ═══════════════════════════════════════════════════════════════════════════ pdf.add_page() pdf.section_title("5. Lieux") for pl in places: name = pl.get("name", {}).get("value", "") lat = pl.get("lat", "") lon = pl.get("long", "") ptype = pl.get("place_type", {}).get("string", "") pid = pl.get("gramps_id", "") if pdf.get_y() > 250: pdf.add_page() pdf.sub_title(f"{name} ({pid})") if ptype: pdf.kv("Type:", ptype) if lat and lon: pdf.kv("Coordonnees:", f"{lat}, {lon}") for pr in pl.get("placeref_list", []): parent = by_handle.get(pr.get("ref")) if parent: pname = parent.get("name", {}).get("value", "") pdf.kv("Fait partie de:", pname) pdf.ln(2) # ═══════════════════════════════════════════════════════════════════════════ # 6. MEDIA # ═══════════════════════════════════════════════════════════════════════════ pdf.add_page() pdf.section_title("6. Medias et documents") for m in media: desc = m.get("desc", "") mime = m.get("mime", "") path = m.get("path", "") mid = m.get("gramps_id", "") date_str = format_date(m.get("date", {})) pdf.sub_title(f"{desc} ({mid})") pdf.kv("Type:", mime) pdf.kv("Fichier:", path) if date_str: pdf.kv("Date:", date_str) pdf.ln(2) # ═══════════════════════════════════════════════════════════════════════════ # 7. NOTES # ═══════════════════════════════════════════════════════════════════════════ pdf.add_page() pdf.section_title("7. Notes et sources") for n in notes: text = n.get("text", {}).get("string", "") ntype = n.get("type", {}).get("string", "") nid = n.get("gramps_id", "") if pdf.get_y() > 260: pdf.add_page() pdf.sub_title(f"Note {nid}") if ntype: pdf.kv("Type:", ntype) pdf.body_text(text.strip()) pdf.ln(2) # ═══════════════════════════════════════════════════════════════════════════ # 8. STATISTICS # ═══════════════════════════════════════════════════════════════════════════ pdf.add_page() pdf.section_title("8. Statistiques") males = sum(1 for p in persons if p.get("gender") == 1) females = sum(1 for p in persons if p.get("gender") == 0) other = sum(1 for p in persons if p.get("gender") == 2) pdf.sub_title("Repartition par genre") pdf.kv("Hommes:", str(males)) pdf.kv("Femmes:", str(females)) pdf.kv("Autre/Inconnu:", str(other)) pdf.ln(3) married = sum(1 for f in families if f.get("type", {}).get("value") == 0) unmarried = sum(1 for f in families if f.get("type", {}).get("value") == 3) pdf.sub_title("Familles") pdf.kv("Mariees:", str(married)) pdf.kv("Non mariees:", str(unmarried)) pdf.ln(3) pdf.sub_title("Evenements") event_counts = defaultdict(int) for ev in [o for o in objects if o.get("_class") == "Event"]: et = event_type_str(ev.get("type", {})) event_counts[et] += 1 for et, count in sorted(event_counts.items(), key=lambda x: -x[1]): pdf.kv(et + ":", str(count)) pdf.ln(3) pdf.sub_title("Lieux") pdf.kv("Total lieux:", str(len(places))) for pl in places: name = pl.get("name", {}).get("value", "") ptype = pl.get("place_type", {}).get("string", "") pdf.bullet(f"{name} ({ptype})" if ptype else name) # ── Save ──────────────────────────────────────────────────────────────────── OUTPUT = "/root/workspace/rapport-genealogie-keller-staerle.pdf" pdf.output(OUTPUT) print(f"PDF genere: {OUTPUT}") print(f"Pages: {pdf.page_no()}") print(f"Personnes: {len(persons)}, Familles: {len(families)}, Lieux: {len(places)}, Evenements: {len([o for o in objects if o.get('_class') == 'Event'])}")