---
name: praesentation-erstellen
description: Baut aus einer Excel-Tabelle, einem Transkript, Notizen, einem Protokoll oder einer anderen Unterlage eine Präsentation im Folienmaster der angehängten Vorlage. Copilot liest alle Layouts der Vorlage aus, auch Bild- und benutzerdefinierte Layouts, schlägt zuerst Botschaften und ein Storyboard vor und baut nach der Freigabe mit Python eine PPTX mit den Layouts und Bildern der Vorlage, dazu Kennzahlen, echte PowerPoint-Diagramme und Tabellen. Angehängt werden diese Skill-Datei, das Musterdeck der Vorlage und die Unterlage. Gebaut wird nur mit der Python-Bibliothek dieser Datei, nie mit PptxGenJS. Nutze diesen Skill, wenn aus Daten oder Text Folien im eigenen Design entstehen sollen. Nicht für HTML-Präsentationen.
---

# Präsentation erstellen

**Version 2.1 · Stand: 06.10.2026, 18:33 Uhr**

Neu in 2.0: `zahlen_pruefen()` vor der Auslieferung. Jede Zahl auf einer Folie braucht einen Beleg in den Sprechernotizen (Absatz der Unterlage oder Excel-Zelle), selbst gerechnete Zahlen eine nachrechenbare Formel. Gebaut wird nur mit Python und `Vorlage(...)`.

Neu in 2.1: Ein Rechenweg darf nur Zellen und Zahlen aus den Unterlagen enthalten, dazu 100, 1000 und 1000000 zum Umrechnen und * -1 für das Vorzeichen. Er muss von seinen Zellen abhängen, „3536 + Zelle * 0“ fällt durch. Gerundet wird kaufmännisch (334,95 wird 335,0). Diagramme zeigen negative Balken und deutsche Zahlen (−2.874,5 T€). Ist die Designfarbe zu hell für Schrift (Vinzenz-Beige), nehmen Kennzahlen, Karten und Diagramme die erste Akzentfarbe. `v.pruefen()` meldet ein Bildlayout ohne Bild.

Dieser Skill macht aus einer Unterlage eine Präsentation, die eine Aussage hat. Die Unterlage kann eine Excel-Tabelle sein, ein
Transkript, Notizen, ein Protokoll, ein Word-Dokument, ein PDF oder eine andere Präsentation. Zuerst entstehen die Botschaften,
erst danach die Folien. Jede Folie trägt eine Botschaft als Titel, und jede Aussage darauf ist in der Unterlage belegt.

Das Aussehen kommt vollständig aus dem angehängten Musterdeck: seine Layouts, Schriften, Farben und Bilder. Der Skill nutzt jedes
Layout der Vorlage, auch Bildlayouts und benutzerdefinierte Layouts. Er legt keine eigenen Textfelder in die Layouts.

Arbeite im Modus „Codieren und Ausführen“ mit Python. Die Bausteine stehen unten in dieser Datei als Python-Bibliothek.
Du liest sie per Python heraus und baust nur mit ihr.

**Wichtigste Regel:** Die PPTX entsteht ausschließlich mit `Vorlage(…)` aus dieser Bibliothek, also im Musterdeck selbst. Copilot
hat eingebaute Wege, Präsentationen mit PptxGenJS, JavaScript oder einem eigenen Skript zu bauen. Die nutzt Du hier nie, auch
nicht nach einer Fehlermeldung, und Du baust das Design nie nach. Meldet die Bibliothek einen Fehler, zeigst Du ihn im Chat und
behebst ihn mit der Bibliothek oder fragst nach.

## Schritt 1 · Anhänge, Unterlage und Vorlage ansehen

```python
import glob, pathlib
START, ENDE = "# BIBLIOTHEK" + "-START", "# BIBLIOTHEK" + "-ENDE"
skill = next(p for p in glob.glob("/mnt/data/**/*.md", recursive=True)
             if START in pathlib.Path(p).read_text(encoding="utf-8"))
text = pathlib.Path(skill).read_text(encoding="utf-8")
a = text.rindex(START)
exec(compile(text[a:text.index(ENDE, a)], "praesentation_bausteine.py", "exec"), globals())
muster, unterlagen, excel = anhaenge()
for x in excel:
    excel_profil(x)
for u in unterlagen:
    print("\n".join(unterlage_lesen(u)))
v = Vorlage(muster[0])
v.layouts_zeigen()
```

`v.layouts_zeigen()` listet jedes Layout mit seinen Feldern in Lesereihenfolge: `titel`, `untertitel`, `text1`, `text2` … und
`bild1`, `bild2` … Zu jedem Textfeld stehen der ungefähre Platz in Zeichen, der Name im Master (etwa „Eyebrow“ oder
„Kapitelnummer“) und der Text der Beispielfolie. Daran erkennst Du, wofür ein Layout und jedes Feld gedacht ist.
`Vorlage` nennt außerdem die Bilder der Beispielfolien und auf welcher Folie sie stehen.

**Fehlt das Musterdeck,** such nicht selbst nach einer Vorlage. Schreib der Person: „Bitte häng das Musterdeck Deiner Vorlage an,
eine PowerPoint-Datei mit Beispielfolien. Zum Üben passt das Almgrün-Musterdeck:
[Almgruen_Foliendesign_2026_Musterdeck.pptx](https://viakanta.sharepoint.com/sites/Copilot_Rallye_Angels/Freigegebene%20Dokumente/Themensammlung/Interne%20Prozesse%20%26%20Assets/85%20Promptsammlung/Skills/praesentation-erstellen/Almgruen_Foliendesign_2026_Musterdeck.pptx)“
und wartest.

Frag dann in **einer** Nachricht nach allem, was fehlt:
- für wen die Präsentation ist und wie lange sie dauert (ohne Angabe planst Du fünf bis acht Folien),
- wenn die Vorlage Bildlayouts hat: „Sollen Bilder aus der Vorlage auf die Folien oder eigene Bilder? Eigene Bilder hängst Du mit
  der Freigabe an, höchstens drei pro Nachricht.“

Copilot nimmt höchstens drei Dateien pro Nachricht an: Skill, Musterdeck, Unterlage. Eigene Bilder kommen deshalb erst mit der
Freigabe. Du findest sie danach mit `eigene_bilder()`.

## Schritt 2 · Auswerten

**Bei einer Excel-Tabelle** rechnest Du alles mit pandas. Keine Zahl entsteht im Kopf oder aus dem Profil in Schritt 1.
- Steht die Überschrift einer Tabelle nicht in Zeile 1, suchst Du die Kopfzeile, bevor Du rechnest. Summen- und Gesamtzeilen
  zählst Du nicht als eigene Gruppe mit.
- Runde jede Zahl einmal mit `runden(wert, 1)` und speichere sie in einer Variablen. Diese gerundete Zahl nutzt Du überall: im
  Titel, im Diagramm und in der Tabelle.
- Gib den Rechenweg als Tabelle im Chat aus: Nr. · Kennzahl · Wert · Rechnung (Spalten, Filter, Anzahl der Zeilen `n`).
- Gruppen mit weniger als fünf Einträgen wertest Du nicht einzeln aus. Das schützt die Anonymität bei Befragungen.
- Freitexte zählst oder gruppierst Du. Du zitierst sie nicht, wenn Personen darin erkennbar sein könnten.

**Bei Text** (Transkript, Notizen, Protokoll, Dokument) liest Du die Unterlage vollständig. `unterlage_lesen` nummeriert die
Absätze mit A1, A2 und so weiter.
- Gib eine Belegtabelle im Chat aus: Nr. · Aussage · Quelle (zum Beispiel A12).
- Beschlüsse, Aufgaben, Termine und Zahlen übernimmst Du so, wie sie in der Unterlage stehen. Fehlt eine Angabe, schreibst Du
  [offen].
- Nichts dazuerfinden, keine Zitate von Personen erfinden. Namen nur, wenn sie in der Unterlage stehen und für die Aussage
  nötig sind.

## Schritt 3 · Botschaften und Storyboard, dann Freigabe

Leite aus dem Rechenweg oder der Belegtabelle drei bis fünf Botschaften ab. Eine Botschaft ist das, was der Adressat nach der
Folie wissen soll.

**So ist eine Botschaft gebaut**
- Ein ganzer Satz mit Verb, höchstens zwölf Wörter, in der Sprache des Adressaten. Er wird der Folientitel.
- Beleg: die Nummern aus dem Rechenweg oder die Quellen aus der Belegtabelle.
- Bedeutung: ein Satz, was das für den Adressaten heißt. Er kommt in die Sprechernotizen.

**Was eine Botschaft darf und was nicht**
- Sie sagt nur, was die Unterlage trägt. „Weil“ und andere Ursachen schreibst Du nur, wenn die Unterlage sie nennt.
- Eine Rangfolge wie „am höchsten“ oder „am schwächsten“ gilt nur bei deutlichem Abstand: auf einer Skala von 1 bis 5 mindestens
  0,2 Punkte, bei Anteilen und allen Prozentwerten (etwa Auslastung oder Quote) mindestens 5 Prozentpunkte. Bei kleinerem
  Abstand schreibst Du „liegen gleichauf“ oder nennst beide Werte.
- Anteile wie „die Hälfte“, „ein Drittel“, „die meisten“ oder „mehr als“ schreibst Du nur, wenn der Anteil als eigene Zeile im
  Rechenweg steht (Teil geteilt durch Ganzes, in Prozent). Sonst nennst Du die beiden Beträge.
- Maßnahmen stehen nur auf einer Folie, wenn sie in der Unterlage genannt sind. Sonst formulierst Du offene Fragen.
- Keine Sätze ohne Verb, kein Gegensatz als Pointe, keine Ausrufezeichen.

**So klingt der Text**
Schreib so, wie Du es dem Adressaten im Gespräch sagen würdest. Subjekt ist etwas, das er kennt (das Budget, die Station, das
Team), dazu ein Verb und die Zahl. Keine Amtswörter wie „ausgewiesen“, „dokumentiert“, „vorliegend“, „hinsichtlich“,
„erfolgt“, „im Rahmen“, „Mittelabfluss“; `pruefen` lehnt sie ab. Kein Satz, der nur sagt, was in der Unterlage fehlt. Fehlt etwas Wichtiges,
wird es eine offene Frage mit dem Namen, wer sie klärt.
Nicht so: „Der Planbetrag deckt die ausgewiesenen Reste. Weitere Ausgaben sind nicht dokumentiert.“
Sondern so: „Im Budget bleiben noch 5.510 € frei.“

**Storyboard**
Gib Botschaften und Storyboard in einer Tabelle aus: Nr. · Folientitel · Beleg · Layout oder Baustein · Bild · Bedeutung.
Der Bogen: Titelfolie → Überblick → eine Folie je Botschaft → offene Fragen oder nächste Schritte → Schlussfolie mit der
Kernbotschaft als Satz.

So wählst Du die Folienart:
- **Layouts der Vorlage zuerst.** Nimm für Titel, Kapitel, Text, Spalten, Bilder, Zitate und Schluss das Layout, dessen
  Beispielfolie zum Inhalt passt. Gibt es ein Layout für genau diesen Zweck (etwa „Status-Spalten“, „Prozess“, „Zeitstrahl“,
  „Vorher Nachher“), nimmst Du es.
- **Für Zahlen die Bausteine** auf dem schlichten Layout „Nur Titel“:

| Die Daten zeigen | Baustein |
|---|---|
| zwei bis vier Eckwerte | `kennzahlen` |
| Werte von Gruppen oder Fragen im Vergleich | `balken` (quer bei langen Bezeichnungen) |
| Verlauf über Tage, Wochen oder Monate | `linie` |
| Ist gegen Ziel | `balken` mit zwei Reihen, zum Beispiel `[("Ist", ist), ("Ziel", ziel)]` |
| genaue Werte zum Nachlesen | `tabelle` |
| Schritte, Termine, offene Fragen | `prozess`, `zeitstrahl` oder `karten`, wenn die Vorlage kein passendes Layout hat |

- **Bilder:** Hat die Vorlage Bildlayouts und Bilder auf den Beispielfolien, planst Du mindestens zwei Folien mit Bild. Du wählst
  die Bilder nach ihrer Beispielfolie (`Vorlage` nennt sie). In der Spalte Bild steht der Name des Bilds oder „eigenes Bild“.
- **Kapiteltrenner nur bei echten Kapiteln.** Ein Kapitellayout („Kapitel“, „Kapiteltrenner“, „Section“) nimmst Du nur, wenn
  die Präsentation mindestens zwei Kapitel mit je mehreren Folien hat. Bei fünf bis acht Folien gibt es keine Kapitel. Die
  Kapitelnummer zählt die Kapitel (01, 02), nie die Folien.
- **Die letzte Folie ist nie „Vielen Dank“, „Danke“ oder „Fragen?“.** Sie trägt die Kernbotschaft des Themas als Satz. Dafür
  nimmst Du ein Layout für Schluss, Abschluss, Zitat oder Statement. Hat die Vorlage keines, nimmst Du das Titellayout. Nie
  ein Kapitellayout.

Warte danach auf die Freigabe oder auf Änderungen. Steht im Auftrag „ohne Rückfrage“, baust Du sofort und nimmst Bilder aus
der Vorlage.

## Schritt 4 · Bauen

Gebaut wird **ausschließlich mit Python und der Bibliothek dieses Skills** (python-pptx im Musterdeck). Kein PptxGenJS,
kein JavaScript, kein nachgebautes Design. Das gilt auch dann, wenn Deine allgemeine Anleitung für PowerPoint-Dateien einen
anderen Weg vorsieht: Für diesen Auftrag gilt diese Datei. Die Bibliothek nur zum Auslesen zu nutzen und dann anders zu bauen,
ist der häufigste Fehler. Lässt sich die Bibliothek nicht laden, meldest Du die Fehlermeldung und baust nichts.
Lade die Bibliothek wie in Schritt 1 und rechne Zahlen im selben Code neu. Tipp keine Zahlen ab.

```python
v = Vorlage(muster[0])
v.layout_folie("Titelfolie", titel="Digitales Übergabeprotokoll", untertitel="Stand für die Geschäftsführung · Oktober 2026",
               notizen="Worum es geht …")
s = v.neue_folie("Das Projekt bleibt mit 12.450 € im Budget.", notizen="Bedeutung …")
v.kennzahlen(s, [(zahl(plan, 0, "€"), "Plan 2026", ""), (zahl(ist, 0, "€"), "Ist 31.08.", ""), (zahl(quote, 1, "%"), "ausgeschöpft", "")])
v.quelle(s, f"Quelle: {excel[0].name}, Blatt Kosten, n = {len(df)}")
v.layout_folie("Bild rechts, Text links", titel="Drei Stationen arbeiten schon digital",
               text1=["Station A seit Juni", "Station B seit Juli", ("Offen", "Frühdienst Station C")],
               bild1="almgruen_05_weg", notizen="Quellen A4, A9 …")
v.layout_folie("Kernaussage", text1="Die Übergabe wird schneller, wenn alle Stationen mitziehen.")
v.pruefen()
v.speichern("Uebergabeprotokoll_Geschaeftsfuehrung")
```

**Damit nichts verrutscht**
1. Felder heißen so, wie `v.layouts_zeigen()` sie nennt. Ein Feld, das Du nicht füllst, entfernt die Bibliothek.
2. Ein Gedanke, ein Punkt. Gehören Bezeichnung und Erklärung zusammen, schreibst Du sie als Paar `("Offen", "Frühdienst")`.
   Die Bibliothek setzt beides in eine Zeile, die Bezeichnung fett.
3. Höchstens sechs Punkte je Feld. Halte Dich an den Platz, den `layouts_zeigen` je Feld nennt. Mehr Inhalt heißt: eine zweite Folie.
4. Felder wie „Eyebrow“ oder „Kicker“ füllst Du so wie auf der Beispielfolie, oder Du lässt sie weg. „Kapitelnummer“ füllst
   Du nur auf Kapitelfolien mit der Nummer des Kapitels.
5. Hat ein Bildlayout ein Feld für den Bildnachweis, füllst Du es: bei Bildern aus der Vorlage so wie auf der Beispielfolie, bei
   eigenen Bildern mit dem Nachweis, den Dir die Person nennt.
6. Keine eigenen Textfelder, Schriftgrößen oder Farben. Nur Layouts und Bausteine der Bibliothek.
7. Schreib alle Texte mit echten Umlauten und ß. Nie „ae“, „oe“, „ue“ oder „ss“ als Ersatz.
8. Heißt das Titellayout anders als „Titelfolie“, nimmst Du den Namen aus der Layoutliste.
9. Kategorien im Diagramm bekommen kurze Bezeichnungen, die volle Frage steht in den Sprechernotizen. Jede Folie mit Zahlen
   bekommt eine Quellenzeile mit Datei, Blatt und `n`.
10. Meldet ein Baustein „Der Text passt nicht“ oder `pruefen` „zu lang“, kürzt Du genau diesen Text. Du änderst keine
    Schriftgröße und keine Maße.

Hat die Vorlage kein Layout „Nur Titel“, nimmt die Bibliothek für Zahlenfolien selbst das schlichteste Inhaltslayout und nennt
es. Du baust einfach weiter. Nur wenn sie meldet, dass es kein schlichtes Layout gibt, fragst Du die Person.

`v.pruefen()` muss „bestanden“ melden, bevor Du speicherst. Ohne die Ausgaben von `v.pruefen()` und `v.speichern()` gilt die
Präsentation als nicht gebaut.

## Schritt 5 · Ausliefern

Zuerst prüfst Du die fertige Datei:

```python
datei_pruefen(pfad)       # pfad = Rückgabe von v.speichern()
zahlen_pruefen(pfad, berechnet={"103": "3.972 - 3.869", "1,2": "Budgetstatus+VS!E37 / 1000000"})
# berechnet: nur selbst gerechnete Zahlen, der Rechenweg als Formel aus Zellen, Zahlen und + - * / ( ), ohne Worte.
# Feste Zahlen nur, wenn sie in einer Unterlage stehen, sonst nur 100, 1000, 1000000 zum Umrechnen.
# Prozent aus einer Zelle mit Anteil 0,0354: "Budgetstatus+VS!F47 * 100". Nie eine Zahl hinschreiben und die Zelle mit 0 malnehmen.
```

Meldet `datei_pruefen` „AUS DEM MUSTERDECK: NEIN“ oder `zahlen_pruefen` „ZAHLEN: NEIN“, lieferst Du die Datei nicht aus,
sondern korrigierst und baust sie mit der Bibliothek neu. Jede Zahl auf einer Folie steht so in der Unterlage oder ist als
berechnet mit Rechenweg angegeben. Schreib in die Sprechernotizen jeder Zahlenfolie ihre Belege: bei Text und PDF die Absätze („Beleg: A12, A15“),
bei Excel die Zellen („Beleg: Budgetstatus+VS!F12, F14“ gilt nicht, schreib jede Zelle mit Blatt: Budgetstatus+VS!F12,
Budgetstatus+VS!F14 oder einen Bereich Budgetstatus+VS!F12:F20). `zahlen_pruefen` prüft jede Zahl nur gegen diese Belege. Erst wenn beide „ja“ melden, gibst Du im Chat aus:
0. Die Ausgabezeilen von `datei_pruefen` und `zahlen_pruefen` wörtlich.
1. Den Downloadlink zur Datei.
2. Die Folienliste: Nr. · Titel · Layout oder Baustein · Bild. Die Layoutnamen stammen aus der Ausgabe von `v.speichern()`.
3. Das Prüfergebnis von `v.pruefen()` in einer Zeile.
4. Was aus der Unterlage bewusst nicht auf die Folien kam, in einem Satz.
5. Bei Diagrammen diesen Hinweis: „Die Diagramme sind echte PowerPoint-Diagramme. Mit Rechtsklick › Daten bearbeiten änderst
   Du die Werte.“

Wünscht die Person Änderungen, baust Du neu und speicherst als nächste Version.

## Was Du nicht tust

- Keine Zahl, die nicht im Rechenweg oder wörtlich in der Unterlage steht, und keine Aussage ohne Quelle. Zahlen tippst Du nie ab,
  Du nimmst sie aus der Belegtabelle.
- Keine eigenen Farbwerte, Schriften, Formen, Bilder oder Icons. Keine Bilder erzeugen und keine Bilder aus dem Netz holen.
- Keine Diagramme als Bild. Die Bibliothek baut echte Diagramme.
- Keine Werbesprache und keine Ausrufezeichen.

## Bausteine der Bibliothek

- `anhaenge()`: Musterdeck, Unterlagen und Excel-Dateien. `eigene_bilder()`: angehängte Bilder.
- `unterlage_lesen(pfad)`: Text aus Word, PDF, Text, Untertiteln oder Präsentation, Absätze nummeriert.
- `excel_profil(pfad)`: Blätter, Spalten, Datentypen und Wertebereiche. `folien_uebersicht(pfad)`, `folie_lesen(pfad, nr)`.
- `runden(wert, stellen)` und `zahl(wert, stellen, einheit)`: einmal runden, deutsch schreiben (`zahl(3.05)` ergibt „3,1“).
- `v = Vorlage(pfad)`, `v.layouts_zeigen()`, `v.layout_folie(layout, titel=…, text1=…, bild1=…, notizen=…)`.
- `v.neue_folie(titel, notizen)` für Bausteine, dazu `v.kennzahlen`, `v.balken`, `v.linie`, `v.tabelle`, `v.karten`,
  `v.prozess`, `v.zeitstrahl`, `v.vergleich`, `v.merksatz`, `v.bild`, `v.quelle`. Flächen teilen mit `v.teile` und `v.spalten`.
- `v.pruefen()` und `v.speichern(name)`, danach `datei_pruefen(pfad)` und `zahlen_pruefen(pfad, berechnet=…)` auf der gespeicherten Datei.

## Beispielprompts

„Arbeite mit dem Skill praesentation-erstellen. Angehängt sind der Skill als SKILL.md, das Almgrün-Musterdeck und der Kostenplan
zum Projekt Digitales Übergabeprotokoll. Adressat: die Geschäftsführung, fünf Minuten im nächsten Jour fixe.“

„Arbeite mit dem Skill praesentation-erstellen. Angehängt sind der Skill als SKILL.md, das Almgrün-Musterdeck und das
Jour-fixe-Transkript vom 24. August. Adressat: die Geschäftsführung, fünf Minuten im nächsten Jour fixe. Bilder aus dem Musterdeck.“

## Bibliothek

Nicht abtippen, nicht verändern. Per Python aus dieser Datei lesen (Schritt 1).

```python
# BIBLIOTHEK-START
# praesentation-erstellen · Fassung 1 · 01.10.2026 · aus praesentation-aus-excel (Bausteine für jede Vorlage)
# und folien-in-almgruen (Folienarten und Bilder des Almgrün-Musterdecks) zusammengeführt
import math, pathlib, re, zipfile
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.oxml.ns import qn
from pptx.enum.dml import MSO_THEME_COLOR
from pptx.enum.shapes import MSO_SHAPE, MSO_CONNECTOR, MSO_SHAPE_TYPE, PP_PLACEHOLDER
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR, MSO_AUTO_SIZE
from pptx.chart.data import CategoryChartData
from pptx.enum.chart import XL_CHART_TYPE, XL_LABEL_POSITION, XL_LEGEND_POSITION

DATEN = pathlib.Path("/mnt/data") if pathlib.Path("/mnt/data").exists() else pathlib.Path(".")
# Farbrollen aus dem Theme des Masters. In der Almgrün-Vorlage: Tannengrün, Wiesengrün, Sand, Anthrazit, Warmgrau.
DUNKEL, AKZENT, FLAECHE, TEXT, GRAU, WEISS = (MSO_THEME_COLOR.ACCENT_2, MSO_THEME_COLOR.ACCENT_1, MSO_THEME_COLOR.ACCENT_3,
                                              MSO_THEME_COLOR.TEXT_2, MSO_THEME_COLOR.ACCENT_5, MSO_THEME_COLOR.BACKGROUND_1)
TITELSCHRIFT, TEXTSCHRIFT = "+mj-lt", "+mn-lt"
_RANDTYPEN = (PP_PLACEHOLDER.DATE, PP_PLACEHOLDER.FOOTER, PP_PLACEHOLDER.SLIDE_NUMBER)
_TITELTYPEN = (PP_PLACEHOLDER.TITLE, PP_PLACEHOLDER.CENTER_TITLE)


def _zoll(v):
    return Emu(v or 0).inches


def zahl(wert, stellen=1, einheit=""):
    """Deutsche Schreibweise: zahl(4.05) -> '4,1', zahl(17960, 0, '€') -> '17.960 €'. Rundet kaufmännisch."""
    from decimal import Decimal, ROUND_HALF_UP
    d = Decimal(str(wert)).quantize(Decimal(1).scaleb(-stellen), rounding=ROUND_HALF_UP)
    s = f"{d:,.{stellen}f}".replace(",", "X").replace(".", ",").replace("X", ".")
    return f"{s} {einheit}".strip()


def de_format(fmt):
    """Diagramm-Zahlformat, das in jeder Anzeigesprache deutsch aussieht: '#,##0" €"' -> 7.800 €.
    Die Vorschau in Copilot Chat zeigt Diagramme sonst mit englischem Tausender-Komma (7,800 €)."""
    import re as _re
    m = _re.fullmatch(r'#,##0(.*)', fmt or '')
    if not m:
        return fmt
    suffix = m.group(1)
    return f'[>=1000000]#"."###"."##0{suffix};[>=1000]#"."##0{suffix};0{suffix}'


def de_zahl(wert, zahlformat='#,##0'):
    """Zahl als deutscher Text nach einem Diagramm-Zahlformat: '#,##0.0" T€"' -> 3.536,6 T€, negative mit −."""
    import re as _re
    m = _re.match(r'[#,]*0(?:\.(0+))?(.*)', zahlformat or '#,##0')
    stellen = len(m.group(1) or '') if m else 0
    suffix = (m.group(2) if m else '').replace('"', '')
    from decimal import Decimal, ROUND_HALF_UP
    q = Decimal(repr(float(wert))).quantize(Decimal(1).scaleb(-stellen), rounding=ROUND_HALF_UP)
    text = f"{abs(q):,.{stellen}f}".replace(",", "X").replace(".", ",").replace("X", ".")
    return ("−" if q < 0 else "") + text + suffix


def runden(wert, stellen=1):
    """Einmal runden, überall dieselbe Zahl verwenden (Diagramm, Tabelle, Titel)."""
    from decimal import Decimal, ROUND_HALF_UP
    return float(Decimal(str(wert)).quantize(Decimal(1).scaleb(-stellen), rounding=ROUND_HALF_UP))


def anhaenge():
    """Findet Musterdeck, Unterlagen (Word, PDF, Text, weitere Präsentationen) und Excel-Dateien unter /mnt/data.
    Eigene Ergebnisse (…-vN), die Skill-Datei und ausgelesene Deckbilder zählen nicht."""
    eigen = lambda p: (re.search(r"-v\d+(?:\s*\(?\d+\)?)?$", p.stem) or p.name.startswith("~$")
                       or "deckbilder" in p.parts or p.name.startswith("mnt_data_"))
    alle = [p for p in DATEN.rglob("*") if p.is_file() and not eigen(p)]
    pptx = sorted(p for p in alle if p.suffix.lower() in (".pptx", ".potx"))
    muster = [p for p in pptx if p.suffix.lower() == ".potx" or re.search(r"muster|vorlage|foliendesign|master|template|design", p.name, re.I)]
    if not muster and len(pptx) == 1:
        muster = list(pptx)   # die einzige angehängte PowerPoint-Datei ist die Vorlage
    excel = sorted(p for p in alle if p.suffix.lower() in (".xlsx", ".xlsm"))

    def ist_skill(p):
        try:
            return "# BIBLIOTHEK" + "-START" in p.read_text(encoding="utf-8", errors="ignore")
        except Exception:
            return False
    unterlagen = sorted(p for p in alle if (p.suffix.lower() in (".docx", ".pdf", ".txt", ".vtt") or
                        (p.suffix.lower() == ".md" and not ist_skill(p))))
    unterlagen += [p for p in pptx if p not in muster]
    bilder = sorted(p for p in alle if p.suffix.lower() in (".png", ".jpg", ".jpeg"))
    print("Musterdeck:", [p.name for p in muster] or "keins erkannt")
    print("Unterlagen:", [p.name for p in unterlagen] or "keine")
    print("Excel:", [p.name for p in excel] or "keine")
    print("Eigene Bilder:", [p.name for p in bilder] or "keine")
    if len(muster) != 1:
        print("HINWEIS: Genau ein Musterdeck ist nötig. Frag nach, welche Datei die Vorlage ist.")
    return muster, unterlagen, excel


def eigene_bilder():
    """Bilder, die die Person angehängt hat (zum Beispiel mit der Freigabe)."""
    return sorted(p for p in DATEN.rglob("*") if p.suffix.lower() in (".png", ".jpg", ".jpeg") and "deckbilder" not in p.parts)


def unterlage_lesen(pfad):
    """Text einer Unterlage (Word, PDF, Text, Untertitel, Präsentation) als nummerierte Absätze A1, A2 … für die Quelle."""
    p = pathlib.Path(pfad)
    endung = p.suffix.lower()
    if endung == ".docx":
        import docx
        d = docx.Document(str(p))
        absaetze = [a.text for a in d.paragraphs if a.text.strip()]
        for t in d.tables:
            for r in t.rows:
                absaetze.append(" | ".join(c.text.strip() for c in r.cells))
    elif endung == ".pdf":
        from pypdf import PdfReader
        absaetze = []
        for i, seite in enumerate(PdfReader(str(p)).pages, 1):
            absaetze += [f"[S. {i}] {z}" for z in (seite.extract_text() or "").split("\n") if z.strip()]
    elif endung == ".pptx":
        prs = Presentation(str(p))
        absaetze = [f"[Folie {i}] {_text(f)}" for i, s in enumerate(prs.slides, 1) for f in _formen(s.shapes) if _text(f)]
    else:
        absaetze = [z for z in p.read_text(encoding="utf-8", errors="ignore").splitlines() if z.strip()]
    print(f"{p.name}: {len(absaetze)} Absätze, {sum(len(a.split()) for a in absaetze)} Wörter")
    return [f"A{i}: {a}" for i, a in enumerate(absaetze, 1)]


def _formen(shapes):
    for f in shapes:
        if f.shape_type == MSO_SHAPE_TYPE.GROUP:
            yield from _formen(f.shapes)
        else:
            yield f


def _text(f):
    if f.has_text_frame:
        return "\n".join(p.text.strip() for p in f.text_frame.paragraphs if p.text.strip())
    if getattr(f, "has_table", False) and f.has_table:
        return "\n".join(" | ".join(c.text.strip() for c in r.cells) for r in f.table.rows)
    return ""


def _titel(folie):
    for f in folie.placeholders:
        if f.placeholder_format.type in _TITELTYPEN and f.text_frame.text.strip():
            return f.text_frame.text.strip()
    kandidaten = [f for f in _formen(folie.shapes) if f.has_text_frame and f.text_frame.text.strip()]
    return kandidaten[0].text_frame.text.strip().split("\n")[0] if kandidaten else ""


def folien_uebersicht(pfad):
    prs = Presentation(pfad)
    print(f"{pathlib.Path(pfad).name}: {len(prs.slides)} Folien")
    print("Nr | Titel | Wörter | Absätze | Hinweis")
    for i, s in enumerate(prs.slides, 1):
        titel = _titel(s)
        woerter, absaetze, extra = 0, 0, []
        for f in _formen(s.shapes):
            t = _text(f)
            if f.is_placeholder and f.placeholder_format.type in _RANDTYPEN:
                continue
            woerter += len(t.split())
            if t and t != titel:
                absaetze += len(t.split("\n"))
            if f.shape_type == MSO_SHAPE_TYPE.PICTURE:
                extra.append("Bild")
            if getattr(f, "has_chart", False) and f.has_chart:
                extra.append("Diagramm")
            if getattr(f, "has_table", False) and f.has_table:
                extra.append("Tabelle")
        hinweis = "voll" if woerter > 40 or absaetze > 5 else ""
        print(f"{i} | {titel[:70]} | {woerter} | {absaetze} | {', '.join([hinweis] + sorted(set(extra))).strip(', ')}")


def folie_lesen(pfad, nr):
    prs = Presentation(pfad)
    s = prs.slides[nr - 1]
    print(f"Folie {nr} · Layout „{s.slide_layout.name}“ · Titel: {_titel(s)}")
    for f in sorted(_formen(s.shapes), key=lambda f: (round(_zoll(f.top), 1), _zoll(f.left))):
        if f.is_placeholder and f.placeholder_format.type in _RANDTYPEN:
            continue
        t = _text(f)
        art = "Diagramm" if getattr(f, "has_chart", False) and f.has_chart else ("Bild" if f.shape_type == MSO_SHAPE_TYPE.PICTURE else "Text")
        if t or art != "Text":
            print(f"- [{art}] {t}")
    if s.has_notes_slide and s.notes_slide.notes_text_frame is not None:
        print("Sprechernotizen:", s.notes_slide.notes_text_frame.text.strip() or "keine")


def excel_profil(pfad):
    import pandas as pd
    x = pd.ExcelFile(pfad)
    for blatt in x.sheet_names:
        df = x.parse(blatt)
        print(f"Blatt „{blatt}“: {len(df)} Zeilen, {df.shape[1]} Spalten")
        if sum(str(c).startswith("Unnamed") for c in df.columns) > df.shape[1] / 2:
            print("  HINWEIS: Die Überschriften stehen nicht in Zeile 1. Kopfzeile suchen, bevor Du auswertest.")
        for c in df.columns:
            s = df[c]
            n = int(s.notna().sum())
            if pd.api.types.is_bool_dtype(s):
                print(f"  {c} · Ja/Nein · {n} Werte")
            elif pd.api.types.is_numeric_dtype(s):
                print(f"  {c} · Zahl · {n} Werte · min {s.min():g} · max {s.max():g} · Mittel {s.mean():.2f}")
            elif pd.api.types.is_datetime64_any_dtype(s):
                print(f"  {c} · Datum · {n} Werte · {s.min():%d.%m.%Y} bis {s.max():%d.%m.%Y}")
            else:
                u = s.dropna().astype(str)
                print(f"  {c} · Text · {n} Werte · {u.nunique()} verschiedene · häufig: {', '.join(u.value_counts().index[:4])}")


GRENZEN = {"titel": 12, "punkte": 6}
VERBOTEN_AM_SCHLUSS = re.compile(r"vielen dank|danke|fragen\?|thank you", re.I)
KAPITEL_LAYOUT = re.compile(r"kapitel|section|trenner|divider|abschnitt", re.I)
PAPIERDEUTSCH = re.compile(r"\b(ausgewiesen\w*|dokumentiert\w*|vorliegend\w*|diesbezüglich\w*|seitens|hinsichtlich|erfolgt\w*|sämtlich\w*|im Rahmen|zeitnah|Mittelabfl\w*)\b", re.I)
_BILDTYPEN = (PP_PLACEHOLDER.PICTURE,)


def _felder(layout_oder_folie):
    """Platzhalter in Lesereihenfolge mit Namen titel, untertitel, text1 … und bild1 …; Fußzeile und Seitenzahl zählen nicht."""
    phs = [p for p in layout_oder_folie.placeholders if p.placeholder_format.type not in _RANDTYPEN]
    phs.sort(key=lambda p: (round(_zoll(p.top) * 2) / 2, _zoll(p.left)))
    felder, t, b = {}, 0, 0
    for p in phs:
        typ = p.placeholder_format.type
        if typ in _TITELTYPEN and "titel" not in felder:
            felder["titel"] = p
        elif typ == PP_PLACEHOLDER.SUBTITLE and "untertitel" not in felder:
            felder["untertitel"] = p
        elif typ in _BILDTYPEN:
            b += 1; felder[f"bild{b}"] = p
        elif typ in (PP_PLACEHOLDER.TABLE, PP_PLACEHOLDER.CHART, PP_PLACEHOLDER.MEDIA_CLIP, PP_PLACEHOLDER.ORG_CHART):
            continue
        else:
            t += 1; felder[f"text{t}"] = p
    return felder


def _schriftgroesse(ph):
    """Schriftgröße eines Platzhalters aus Layout oder Master (erste Ebene), sonst 18 pt."""
    for el in (ph._element, ):
        sz = el.xpath(".//a:lvl1pPr/a:defRPr/@sz") or el.xpath(".//a:defRPr/@sz") or el.xpath(".//a:rPr/@sz")
        if sz:
            return int(sz[0]) / 100
    try:
        master = ph.part.slide_master if hasattr(ph.part, "slide_master") else ph.part.slide_layout.slide_master
        typ = ph.placeholder_format.type
        stil = "titleStyle" if typ in _TITELTYPEN else "bodyStyle"
        sz = master._element.xpath(f".//p:txStyles/p:{stil}/a:lvl1pPr/a:defRPr/@sz")
        if sz:
            return int(sz[0]) / 100
    except Exception:
        pass
    return 18.0


def _platz(ph):
    """Ungefähr wie viele Zeichen ein Feld fasst (Breite, Höhe, Schriftgröße)."""
    gr = _schriftgroesse(ph)
    zeichen_zeile = max(1, _zoll(ph.width) * 72 / (gr * 0.52))
    zeilen = max(1, int(_zoll(ph.height) * 72 / (gr * 1.25)))
    return int(zeichen_zeile * zeilen), gr


def _schreiben(tf, inhalt):
    """Text, Liste oder (Bezeichnung, Text)-Paare. Ein Gedanke = ein Absatz, Bezeichnung fett in derselben Zeile."""
    werte = inhalt if isinstance(inhalt, list) else [inhalt]
    tf.clear()
    for i, w in enumerate(werte):
        p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
        if isinstance(w, tuple):
            r = p.add_run(); r.text = w[0]; r.font.bold = True
            r = p.add_run(); r.text = ": " + w[1]
        else:
            p.add_run().text = w


class Vorlage:
    def __init__(self, pfad, layout=None):
        """layout: Name des schlichten Inhaltslayouts, falls es in der Vorlage nicht „Nur Titel“ heißt."""
        self.pfad = pathlib.Path(pfad)
        if self.pfad.suffix.lower() == ".potx":
            # Vorlagendatei: als Präsentation öffnen (nur der Inhaltstyp unterscheidet sich)
            import io
            puffer = io.BytesIO()
            with zipfile.ZipFile(self.pfad) as alt, zipfile.ZipFile(puffer, "w", zipfile.ZIP_DEFLATED) as neu:
                for eintrag in alt.infolist():
                    daten = alt.read(eintrag)
                    if eintrag.filename == "[Content_Types].xml":
                        daten = daten.replace(b"presentationml.template.main+xml", b"presentationml.presentation.main+xml")
                    neu.writestr(eintrag, daten)
            puffer.seek(0)
            self.prs = Presentation(puffer)
        else:
            self.prs = Presentation(self.pfad)
        self.beispiele = list(self.prs.slides._sldIdLst)
        self.b, self.h = _zoll(self.prs.slide_width), _zoll(self.prs.slide_height)
        self.layouts = {l.name: l for l in self.prs.slide_layouts}
        self.inhaltslayout = layout
        self.ohne_notizen = False
        self.hauptfarbe = self._hauptfarbe()
        self.bildherkunft = {}
        self.deckbilder = self._deckbilder()
        try:
            lay = self._layout_nur_titel()
            self.ohne_nur_titel = None
        except ValueError as fehler:
            self.ohne_nur_titel = str(fehler)
            lay = next(l for l in self.prs.slide_layouts if any(p.placeholder_format.type in _TITELTYPEN for p in l.placeholders))
        t = next(p for p in lay.placeholders if p.placeholder_format.type in _TITELTYPEN)
        rand = [p for p in lay.placeholders if p.placeholder_format.type in _RANDTYPEN]
        self.fuss = min((_zoll(p.top) for p in rand), default=self.h - 0.35)
        self.links = _zoll(t.left)
        self.rechts = self.b - self.links
        self.oben = _zoll(t.top) + _zoll(t.height) + 0.2
        self.unten = self.fuss - 0.4
        self.folien = []
        print("Layouts:", " · ".join(self.layouts))
        print("Bilder aus den Beispielfolien:" + ("" if self.deckbilder else " keine"))
        for name in self.deckbilder:
            print(f"  {name}: {self.bildherkunft[name]}")
        print("Alle Layouts mit ihren Feldern zeigt v.layouts_zeigen().")
        print(f"Inhaltsbereich: links {self.links:.2f}, oben {self.oben:.2f}, Breite {self.rechts - self.links:.2f}, Höhe {self.unten - self.oben:.2f} Zoll")

    def _layout_nur_titel(self):
        if self.inhaltslayout:
            if self.inhaltslayout not in self.layouts:
                raise ValueError(f"Layout „{self.inhaltslayout}“ gibt es nicht. Vorhanden: {', '.join(self.layouts)}")
            return self.layouts[self.inhaltslayout]
        for name in ("Nur Titel", "Title Only"):
            if name in self.layouts:
                return self.layouts[name]
        # Kein „Nur Titel“: das schlichteste Inhaltslayout nehmen. Es hat einen Titel, kein Bildfeld, kein Hintergrundbild
        # und keine Zierformen; Zusatzfelder entfernt neue_folie. Titel-, Kapitel- und Schlusslayouts kommen nicht infrage.
        ausschluss = re.compile(r"titel(folie)?$|^title|kapitel|section|abschluss|schluss|ende|zitat|quote|statement|video|chat|leer|blank", re.I)
        wahl = []
        for l in self.prs.slide_layouts:
            phs = list(l.placeholders)
            if not any(p.placeholder_format.type == PP_PLACEHOLDER.TITLE for p in phs) or ausschluss.search(l.name):
                continue
            if any(p.placeholder_format.type == PP_PLACEHOLDER.PICTURE for p in phs):
                continue
            zier = [sh for sh in l.shapes if not sh.is_placeholder]
            if any(sh.shape_type == MSO_SHAPE_TYPE.PICTURE and _zoll(sh.width) > 3 for sh in zier):
                continue
            titel = next(p for p in phs if p.placeholder_format.type == PP_PLACEHOLDER.TITLE)
            felder = [p for p in phs if p.placeholder_format.type not in _RANDTYPEN + _TITELTYPEN]
            wahl.append((len(zier), len(felder), _zoll(titel.top), l))
        if not wahl:
            raise ValueError("Das Musterdeck hat kein schlichtes Layout mit Titel. Nenne der Person die Layouts und frag, "
                             f"welches für Diagramme passt, dann Vorlage(pfad, layout=\"Name\"). Layouts: {', '.join(self.layouts)}")
        lay = sorted(wahl, key=lambda w: (w[1] + 2 * w[0], w[2]))[0][3]
        print(f"HINWEIS: Kein Layout „Nur Titel“. Für Kennzahlen, Diagramme und Tabellen nimmt die Bibliothek „{lay.name}“ "
              "und entfernt dort die übrigen Felder. Ein anderes Layout wählst Du mit Vorlage(pfad, layout=\"Name\").")
        return lay

    def _hauptfarbe(self):
        """Diagramm- und Kennzahlfarbe: Akzent 2 des Designs. Ist Akzent 2 ein Grauton oder so hell, dass er auf
        Weiß nicht lesbar ist (z. B. Beige #F1E8D8), nimmt die Bibliothek Akzent 1."""
        import colorsys, re
        try:
            theme = next(r.target_part for r in self.prs.slide_master.part.rels.values() if r.reltype.endswith("/theme"))
            xml = theme.blob.decode("utf-8", "ignore")
            m = re.search(r"<a:accent2>\s*<a:srgbClr val=\"([0-9A-Fa-f]{6})\"", xml)
            if m:
                r_, g_, b_ = (int(m.group(1)[i:i + 2], 16) / 255 for i in (0, 2, 4))
                _, hell, satt = colorsys.rgb_to_hls(r_, g_, b_)
                if satt < 0.15 or hell > 0.75:
                    return AKZENT
        except Exception:
            pass
        return DUNKEL

    # Flächen -----------------------------------------------------------------------------------------------
    def bereich(self):
        return (self.links, self.oben, self.rechts - self.links, self.unten - self.oben)

    def teile(self, anteil=0.45, abstand=0.3, box=None):
        x, y, w, h = box or self.bereich()
        h1 = (h - abstand) * anteil
        return (x, y, w, h1), (x, y + h1 + abstand, w, h - h1 - abstand)

    def spalten(self, anteil=0.5, abstand=0.4, box=None):
        x, y, w, h = box or self.bereich()
        w1 = (w - abstand) * anteil
        return (x, y, w1, h), (x + w1 + abstand, y, w - w1 - abstand, h)

    # Folien ------------------------------------------------------------------------------------------------
    def _notizen(self, s, notizen):
        if not notizen:
            return
        tf = s.notes_slide.notes_text_frame
        if tf is None:
            if not self.ohne_notizen:
                print("HINWEIS: Das Musterdeck hat keinen Platz für Sprechernotizen. Gib die Notizen je Folie im Chat aus.")
            self.ohne_notizen = True
        else:
            tf.text = notizen

    def neue_folie(self, titel, notizen="", layout=None):
        if not layout and self.ohne_nur_titel:
            raise ValueError(self.ohne_nur_titel)
        lay = self.layouts.get(layout) if layout else self._layout_nur_titel()
        s = self.prs.slides.add_slide(lay)
        if s.shapes.title is not None:
            s.shapes.title.text = titel
        # Zusatzfelder des Layouts (Label, Unterzeile, Text) bleiben leer und werden entfernt
        for p in list(s.placeholders):
            if p.placeholder_format.type not in _TITELTYPEN + _RANDTYPEN:
                p._element.getparent().remove(p._element)
        self._notizen(s, notizen)
        self.folien.append(s)
        return s

    def platzhalter_folie(self, layout, texte, notizen=""):
        """Füllt ein Layout über seine Platzhalter: zuerst der Titel, dann die übrigen von oben nach unten und links nach rechts.
        Nicht gebrauchte Text- und Bildplatzhalter werden entfernt."""
        if layout not in self.layouts:
            raise ValueError(f"Layout „{layout}“ gibt es nicht. Vorhanden: {', '.join(self.layouts)}")
        s = self.prs.slides.add_slide(self.layouts[layout])
        phs = list(s.placeholders)
        titel = [p for p in phs if p.placeholder_format.type in _TITELTYPEN]
        unter = [p for p in phs if p.placeholder_format.type == PP_PLACEHOLDER.SUBTITLE]
        rest = sorted((p for p in phs if p not in titel + unter and p.placeholder_format.type not in (PP_PLACEHOLDER.TABLE,) + _RANDTYPEN),
                      key=lambda p: (round(_zoll(p.top), 1), _zoll(p.left)))
        reihe = titel + unter + rest
        for p, t in zip(reihe, texte):
            p.text_frame.text = t
        for p in reihe[len(texte):]:
            p._element.getparent().remove(p._element)
        self._notizen(s, notizen)
        self.folien.append(s)
        return s

    # Jedes Layout der Vorlage -------------------------------------------------------------------------------
    def _deckbilder(self):
        """Sichert alle Bilder der Beispielfolien unter /mnt/data/deckbilder. Name aus der Bildbeschreibung, sonst Folie und Nummer.
        Kleine Bilder (Logos, Icons unter 1,5 Zoll) zählen nicht."""
        ziel = DATEN / "deckbilder"
        ziel.mkdir(exist_ok=True)
        bilder, gesehen = {}, set()
        for nr, s in enumerate(self.prs.slides, 1):
            for k, sh in enumerate(_formen(s.shapes), 1):
                if not hasattr(sh, "image") or max(_zoll(sh.width), _zoll(sh.height)) < 1.5:
                    continue
                blob = sh.image.blob
                if hash(blob) in gesehen:
                    continue
                gesehen.add(hash(blob))
                descr = sh._element.xpath(".//p:cNvPr/@descr")
                name = re.sub(r"[^\w\-äöüÄÖÜß]+", "_", (descr[0] if descr and descr[0].strip() else f"folie{nr}_bild{k}").rsplit(".", 1)[0])[:40]
                pfad = ziel / f"{name}.{sh.image.ext}"
                pfad.write_bytes(blob)
                bilder[name] = str(pfad)
                self.bildherkunft[name] = f"Folie {nr} „{_titel(s)[:40]}“, Layout „{s.slide_layout.name}“"
        return bilder

    def layouts_zeigen(self, nur=None):
        """Alle Layouts mit ihren Feldern in Lesereihenfolge: Name im Master, ungefährer Platz und Text der Beispielfolie.
        nur: Liste von Layoutnamen, wenn Du einzelne Layouts genauer ansehen willst."""
        beispiele = {}
        for nr, s in enumerate(self.prs.slides, 1):
            beispiele.setdefault(s.slide_layout.name, (nr, s))
        allgemein = re.compile(r"^(Text|Picture|Content|Title|Subtitle|Inhalt|Bild|Textplatzhalter|Titel|Untertitel|Inhaltsplatzhalter)\b", re.I)
        for l in self.prs.slide_layouts:
            if nur and l.name not in nur:
                continue
            felder = _felder(l)
            if not felder:
                continue
            bsp = beispiele.get(l.name)
            bsp_text = {}
            if bsp:
                for p in bsp[1].placeholders:
                    if p.has_text_frame and p.text_frame.text.strip():
                        bsp_text[p.placeholder_format.idx] = " / ".join(z.strip() for z in p.text_frame.text.splitlines() if z.strip())[:45]
            print(f"„{l.name}“" + (f" (Beispiel: Folie {bsp[0]})" if bsp else " (keine Beispielfolie)"))
            for name, p in felder.items():
                rolle = "" if allgemein.match(p.name) else f" „{p.name}“"
                if name.startswith("bild"):
                    print(f"  {name}: Bild {_zoll(p.width):.1f} × {_zoll(p.height):.1f} Zoll{rolle}")
                else:
                    platz, gr = _platz(p)
                    beispiel = bsp_text.get(p.placeholder_format.idx)
                    print(f"  {name}: ≈{platz} Zeichen, {gr:g} pt{rolle}" + (f" · im Beispiel: {beispiel}" if beispiel else ""))

    def layout_folie(self, layout, notizen="", **felder):
        """Füllt ein beliebiges Layout der Vorlage. Felder wie in layouts_zeigen(): titel, untertitel, text1 …, bild1 ….
        Text: Zeichenkette, Liste (ein Gedanke je Punkt) oder (Bezeichnung, Erklärung)-Paare. Bild: Pfad oder Name eines Deckbilds.
        Nicht gefüllte Felder werden entfernt."""
        if layout not in self.layouts:
            raise ValueError(f"Layout „{layout}“ gibt es nicht. Vorhanden: {', '.join(self.layouts)}")
        erlaubt = _felder(self.layouts[layout])
        unbekannt = [f for f in felder if f not in erlaubt]
        if unbekannt:
            raise ValueError(f"Layout „{layout}“ hat kein Feld {unbekannt}. Felder: {', '.join(erlaubt)}")
        s = self.prs.slides.add_slide(self.layouts[layout])
        ph = _felder(s)
        for name, wert in felder.items():
            p = ph[name]
            if name.startswith("bild"):
                pfad = self.deckbilder.get(str(wert), str(wert))
                p.insert_picture(pfad)
            else:
                _schreiben(p.text_frame, wert)
        for name, p in ph.items():
            if name not in felder:
                p._element.getparent().remove(p._element)
        self._notizen(s, notizen)
        self.folien.append(s)
        return s

    def bild(self, s, pfad, box=None):
        """Ein Bild ganz zeigen (Screenshot, Grafik, Zeichnung), seitenverhältnistreu im Inhaltsbereich oder in box."""
        from PIL import Image
        pfad = self.deckbilder.get(str(pfad), str(pfad))
        w, h = Image.open(pfad).size
        x, y, bw, bh = box or self.bereich()
        breite, hoehe = bw, bw * h / w
        if hoehe > bh:
            hoehe, breite = bh, bh * w / h
        s.shapes.add_picture(str(pfad), Inches(x + (bw - breite) / 2), Inches(y), Inches(breite), Inches(hoehe))

    # Hilfen ------------------------------------------------------------------------------------------------
    @staticmethod
    def _ohne_stil(form):
        st = form._element.find(qn("p:style"))
        if st is not None:
            form._element.remove(st)

    @staticmethod
    def _hoehe(zeilen, w):
        noetig = 0.04
        for text, gr, fett, schrift in zeilen:
            faktor = (0.56 if schrift == TITELSCHRIFT else 0.53) + (0.04 if fett else 0)
            pro_zeile = max(1, int((w - 0.08) * 72 / (gr * faktor)))
            anzahl = sum(max(1, math.ceil(len(teil) * 1.12 / pro_zeile)) for teil in str(text).split("\n"))
            noetig += anzahl * gr * 1.22 / 72
        return noetig

    def _passt(self, zeilen, w, h, name):
        noetig = self._hoehe(zeilen, w)
        if noetig > h + 0.01:
            raise ValueError(f"{name}: Der Text passt nicht ({noetig:.2f} Zoll nötig, {h:.2f} Zoll Platz). Kürze diesen Text: "
                             + " / ".join(str(z[0]) for z in zeilen))

    def _textfeld(self, ziel, x, y, w, h, zeilen, name, ausrichtung=PP_ALIGN.LEFT, anker=MSO_ANCHOR.TOP, farben=None):
        """zeilen: Liste aus (Text, Größe in pt, fett, Schrift). farben: Liste der Farbrollen je Zeile."""
        self._passt(zeilen, w, h, name)
        tb = ziel.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
        tb.name = name
        tf = tb.text_frame
        tf.word_wrap, tf.auto_size, tf.vertical_anchor = True, MSO_AUTO_SIZE.NONE, anker
        tf.margin_left = tf.margin_right = Inches(0.04)
        tf.margin_top = tf.margin_bottom = Inches(0.02)
        for i, (text, gr, fett, schrift) in enumerate(zeilen):
            p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
            p.alignment = ausrichtung
            r = p.add_run()
            r.text = str(text)
            r.font.size, r.font.bold, r.font.name = Pt(gr), fett, schrift
            r.font.color.theme_color = (farben[i] if farben else TEXT)
        return tb

    def _linie(self, ziel, x1, y1, x2, y2, farbe=AKZENT, staerke=2.0):
        c = ziel.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, Inches(x1), Inches(y1), Inches(x2), Inches(y2))
        c.line.color.theme_color = farbe
        c.line.width = Pt(staerke)
        self._ohne_stil(c)
        return c

    def _flaeche(self, ziel, x, y, w, h, farbe=FLAECHE, form=MSO_SHAPE.RECTANGLE):
        f = ziel.shapes.add_shape(form, Inches(x), Inches(y), Inches(w), Inches(h))
        f.fill.solid()
        f.fill.fore_color.theme_color = farbe
        f.line.fill.background()
        self._ohne_stil(f)
        return f

    @staticmethod
    def _gruppe(s, name):
        g = s.shapes.add_group_shape()
        g.name = name
        return g

    @staticmethod
    def _anzahl(items, mini, maxi, name):
        if not mini <= len(items) <= maxi:
            raise ValueError(f"{name}: {len(items)} Einträge, erlaubt sind {mini} bis {maxi}. Zusammenfassen oder auf zwei Folien verteilen.")

    # Bausteine ---------------------------------------------------------------------------------------------
    def kennzahlen(self, s, items, box=None):
        """items: 2 bis 4 Einträge (Wert, Bezeichnung, Erläuterung oder "")."""
        self._anzahl(items, 2, 4, "Kennzahlen")
        x, y, w, h = box or self.bereich()
        g = self._gruppe(s, "Baustein Kennzahlen")
        luecke = 0.35
        bw = (w - luecke * (len(items) - 1)) / len(items)
        wert_gr = 30 if h >= 1.9 else 24
        block = 0.12 + wert_gr / 72 * 1.35 + (0.75 if any(d for _, _, d in items) else 0.45)
        if box is None and h > block + 0.8:
            y, h = y + (h - block) * 0.4, block + 0.3   # Block ins obere Drittel der Mitte, kein Loch unten
        for i, (wert, label, detail) in enumerate(items):
            bx = x + i * (bw + luecke)
            self._linie(g, bx, y, bx + bw, y)
            self._textfeld(g, bx, y + 0.1, bw, wert_gr / 72 * 1.35, [(wert, wert_gr, False, TITELSCHRIFT)], f"Kennzahl {i + 1} Wert", farben=[self.hauptfarbe])
            ly = y + 0.12 + wert_gr / 72 * 1.35
            zeilen = [(label, 12, True, TEXTSCHRIFT)] + ([(detail, 11, False, TEXTSCHRIFT)] if detail else [])
            self._textfeld(g, bx, ly, bw, y + h - ly, zeilen, f"Kennzahl {i + 1} Text")
        return g

    def zeitstrahl(self, s, punkte, box=None):
        """punkte: 3 bis 6 Einträge (Datum, Text)."""
        self._anzahl(punkte, 3, 6, "Zeitstrahl")
        x, y, w, h = box or self.bereich()
        g = self._gruppe(s, "Baustein Zeitstrahl")
        slot = w / len(punkte)
        linie_y = y + 0.5
        self._linie(g, x, linie_y, x + w, linie_y)
        for i, (datum, text) in enumerate(punkte):
            cx = x + slot * (i + 0.5)
            self._textfeld(g, cx - slot / 2 + 0.05, y, slot - 0.1, 0.38, [(datum, 14, False, TITELSCHRIFT)], f"Zeitpunkt {i + 1} Datum",
                           ausrichtung=PP_ALIGN.CENTER, anker=MSO_ANCHOR.BOTTOM, farben=[self.hauptfarbe])
            self._flaeche(g, cx - 0.08, linie_y - 0.08, 0.16, 0.16, self.hauptfarbe, MSO_SHAPE.OVAL)
            self._textfeld(g, cx - slot / 2 + 0.05, linie_y + 0.2, slot - 0.1, y + h - linie_y - 0.2, [(text, 12, False, TEXTSCHRIFT)],
                           f"Zeitpunkt {i + 1} Text", ausrichtung=PP_ALIGN.CENTER)
        return g

    def prozess(self, s, schritte, box=None):
        """schritte: 3 bis 5 Einträge (Schritt, Erläuterung oder "")."""
        self._anzahl(schritte, 3, 5, "Prozess")
        x, y, w, h = box or self.bereich()
        g = self._gruppe(s, "Baustein Prozess")
        slot = w / len(schritte)
        d = 0.44
        for i, (titel, detail) in enumerate(schritte):
            cx = x + slot * (i + 0.5)
            if i < len(schritte) - 1:
                self._linie(g, cx + d / 2 + 0.08, y + d / 2, cx + slot - d / 2 - 0.08, y + d / 2, GRAU, 1.5)
            kreis = self._flaeche(g, cx - d / 2, y, d, d, self.hauptfarbe, MSO_SHAPE.OVAL)
            tf = kreis.text_frame
            tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
            tf.vertical_anchor = MSO_ANCHOR.MIDDLE
            p = tf.paragraphs[0]
            p.alignment = PP_ALIGN.CENTER
            r = p.add_run()
            r.text = str(i + 1)
            r.font.size, r.font.name = Pt(15), TITELSCHRIFT
            r.font.color.theme_color = WEISS
            zeilen = [(titel, 12, True, TEXTSCHRIFT)] + ([(detail, 11, False, TEXTSCHRIFT)] if detail else [])
            self._textfeld(g, cx - slot / 2 + 0.06, y + d + 0.15, slot - 0.12, h - d - 0.15, zeilen, f"Schritt {i + 1} Text", ausrichtung=PP_ALIGN.CENTER)
        return g

    def karten(self, s, items, box=None, hervorheben=None):
        """items: 2 bis 4 Einträge (Überschrift, Text). hervorheben: Nummer der einen Karte mit Sandfläche, sonst None."""
        self._anzahl(items, 2, 4, "Karten")
        x, y, w, h = box or self.bereich()
        g = self._gruppe(s, "Baustein Karten")
        raster = [(i % 2, i // 2) for i in range(4)] if len(items) == 4 and h >= 2.6 and w < 7 else [(i, 0) for i in range(len(items))]
        spalten = max(c for c, _ in raster[:len(items)]) + 1
        reihen = max(r for _, r in raster[:len(items)]) + 1
        lx, ly = 0.35, 0.3
        kw, kh = (w - lx * (spalten - 1)) / spalten, (h - ly * (reihen - 1)) / reihen
        innen_max = 0.12 if hervorheben else 0
        bedarf = max(self._hoehe([(ti, 15, False, TITELSCHRIFT), (te, 11.5, False, TEXTSCHRIFT)], kw - 2 * innen_max) for ti, te in items)
        kh = min(kh, bedarf + 0.12 + 2 * innen_max + 0.15)
        gesamt = reihen * kh + (reihen - 1) * ly
        if box is None and h > gesamt + 0.8:
            y += (h - gesamt) * 0.4   # Karten in die Mitte der Inhaltsfläche, kein Loch unten
        for i, (titel, text) in enumerate(items):
            c, r = raster[i]
            kx, ky = x + c * (kw + lx), y + r * (kh + ly)
            innen = 0
            if hervorheben == i + 1:
                self._flaeche(g, kx, ky, kw, kh)
                innen = 0.12
            self._linie(g, kx, ky, kx + kw, ky)
            self._textfeld(g, kx + innen, ky + 0.12, kw - 2 * innen, kh - 0.12 - innen,
                           [(titel, 15, False, TITELSCHRIFT), (text, 11.5, False, TEXTSCHRIFT)], f"Karte {i + 1}", farben=[self.hauptfarbe, TEXT])
        return g

    def vergleich(self, s, links, rechts, box=None):
        """links und rechts: (Überschrift, [Punkte]) mit je 1 bis 5 Punkten. Rechts ist der Zielzustand und bekommt die Sandfläche."""
        x, y, w, h = box or self.bereich()
        g = self._gruppe(s, "Baustein Vergleich")
        pfeil = 0.5
        sw = (w - pfeil) / 2
        bedarf = max(self._hoehe([("• " + p, 12, False, TEXTSCHRIFT) for p in punkte], sw - 0.3) for _, punkte in (links, rechts))
        h = min(h, max(1.5, 0.72 + bedarf + 0.3))
        for seite, (ueberschrift, punkte) in enumerate((links, rechts)):
            self._anzahl(punkte, 1, 5, f"Vergleich {ueberschrift}")
            sx = x + seite * (sw + pfeil)
            innen = 0.15 if seite == 1 else 0
            if seite == 1:
                self._flaeche(g, sx, y, sw, h)
            self._textfeld(g, sx + innen, y + 0.08, sw - 2 * innen, 0.45, [(ueberschrift, 16, False, TITELSCHRIFT)], f"Vergleich {seite + 1} Überschrift", farben=[self.hauptfarbe])
            self._linie(g, sx + innen, y + 0.6, sx + sw - innen, y + 0.6, AKZENT if seite == 1 else GRAU)
            self._textfeld(g, sx + innen, y + 0.72, sw - 2 * innen, h - 0.72 - innen, [("• " + p, 12, False, TEXTSCHRIFT) for p in punkte],
                           f"Vergleich {seite + 1} Punkte")
        self._textfeld(g, x + sw, y + h / 2 - 0.25, pfeil, 0.5, [("→", 20, False, TEXTSCHRIFT)], "Vergleich Pfeil", ausrichtung=PP_ALIGN.CENTER, farben=[AKZENT])
        return g

    def merksatz(self, s, text, box=None):
        """Ein Satz, der bleiben soll. Sandfläche über die ganze Breite; höchstens eine Fläche je Folie."""
        x, y, w, h = box or self.bereich()
        g = self._gruppe(s, "Baustein Merksatz")
        self._flaeche(g, x, y, w, h)
        self._linie(g, x, y, x, y + h, self.hauptfarbe, 4)
        self._textfeld(g, x + 0.3, y + 0.1, w - 0.5, h - 0.2, [(text, 16, False, TITELSCHRIFT)], "Merksatz", anker=MSO_ANCHOR.MIDDLE, farben=[self.hauptfarbe])
        return g

    def tabelle(self, s, kopf, zeilen, box=None, breiten=None):
        """kopf: Spaltennamen (2 bis 5). zeilen: bis 8 Zeilen. breiten: Anteile je Spalte, z. B. [2, 1, 1]."""
        self._anzahl(kopf, 2, 5, "Tabelle Spalten")
        self._anzahl(zeilen, 1, 8, "Tabelle Zeilen")
        x, y, w, h = box or self.bereich()
        zh = min(0.42, h / (len(zeilen) + 1))
        gf = s.shapes.add_table(len(zeilen) + 1, len(kopf), Inches(x), Inches(y), Inches(w), Inches(zh * (len(zeilen) + 1)))
        gf.name = "Baustein Tabelle"
        t = gf.table
        t.first_row, t.horz_banding = True, False
        anteile = breiten or [1] * len(kopf)
        for i, a in enumerate(anteile):
            t.columns[i].width = Inches(w * a / sum(anteile))
        for r in range(len(zeilen) + 1):
            t.rows[r].height = Inches(zh)
            werte = kopf if r == 0 else zeilen[r - 1]
            for c, wert in enumerate(werte):
                zelle = t.cell(r, c)
                text = "" if wert is None else str(wert)
                self._passt([(text, 11, r == 0, TEXTSCHRIFT)], w * anteile[c] / sum(anteile), zh + 0.02, f"Tabelle Zeile {r} Spalte {c + 1}")
                zelle.margin_left = zelle.margin_right = Inches(0.06)
                zelle.margin_top = zelle.margin_bottom = Inches(0.02)
                zelle.vertical_anchor = MSO_ANCHOR.MIDDLE
                tf = zelle.text_frame
                tf.text = ""
                p = tf.paragraphs[0]
                p.alignment = PP_ALIGN.RIGHT if re.fullmatch(r"[-+]?[\d.,\s]+\s*(%|€|Tage|h)?", text) and r > 0 else PP_ALIGN.LEFT
                run = p.add_run()
                run.text = text
                run.font.size, run.font.bold, run.font.name = Pt(11), r == 0, TEXTSCHRIFT
                run.font.color.theme_color = self.hauptfarbe if r == 0 else TEXT
                if r == 0:
                    zelle.fill.solid()
                    zelle.fill.fore_color.theme_color = FLAECHE
                else:
                    zelle.fill.background()
        return gf

    def _diagramm(self, s, art, kategorien, reihen, box, zahlformat, maximum, name):
        if isinstance(reihen, (list, tuple)) and reihen and not isinstance(reihen[0], (list, tuple)):
            reihen = [("Wert", list(reihen))]
        self._anzahl(kategorien, 2, 12, name)
        self._anzahl(reihen, 1, 3, f"{name} Reihen")
        x, y, w, h = box or self.bereich()
        daten = CategoryChartData()
        daten.categories = [str(k) for k in kategorien]
        for rname, werte in reihen:
            if len(werte) != len(kategorien):
                raise ValueError(f"{name}: Reihe „{rname}“ hat {len(werte)} Werte für {len(kategorien)} Kategorien.")
            daten.add_series(str(rname), [float(v) for v in werte])
        g = self._gruppe(s, f"Baustein {name}")
        gf = g.shapes.add_chart(art, Inches(x), Inches(y), Inches(w), Inches(h), daten)
        ch = gf.chart
        ch.font.size = Pt(10)
        ch.font.name = TEXTSCHRIFT
        ch.font.color.theme_color = TEXT
        ch.has_title = False
        ch.has_legend = len(reihen) > 1
        if ch.has_legend:
            ch.legend.position, ch.legend.include_in_layout = XL_LEGEND_POSITION.BOTTOM, False
        farben = [self.hauptfarbe, GRAU, AKZENT if self.hauptfarbe == DUNKEL else DUNKEL]
        plot = ch.plots[0]
        linie = art in (XL_CHART_TYPE.LINE, XL_CHART_TYPE.LINE_MARKERS)
        for i, ser in enumerate(plot.series):
            if linie:
                ser.format.line.color.theme_color = farben[i]
                ser.format.line.width = Pt(2.25)
                ser.smooth = False
            else:
                ser.format.fill.solid()
                ser.format.fill.fore_color.theme_color = farben[i]
        if not linie:
            plot.gap_width = 60
            if len(reihen) > 1:
                plot.overlap = -10
        plot.has_data_labels = True
        dl = plot.data_labels
        dl.number_format, dl.number_format_is_linked = de_format(zahlformat), False
        dl.font.size = Pt(10)
        dl.position = XL_LABEL_POSITION.ABOVE if linie else XL_LABEL_POSITION.OUTSIDE_END
        # Beschriftung als fertige deutsche Zahl (3.536,6 · −2.874,5), unabhängig von Anzeigesprache und Vorschau
        for (rname, werte), ser in zip(reihen, plot.series):
            if not linie:
                ser.invert_if_negative = False
            for i, v in enumerate(werte):
                tf = ser.points[i].data_label.text_frame
                tf.text = de_zahl(v, zahlformat)
                tf.paragraphs[0].runs[0].font.size = Pt(10)
        va, ca = ch.value_axis, ch.category_axis
        va.has_major_gridlines = False
        va.visible = linie
        alle = [float(v) for _, werte in reihen for v in werte]
        if not linie and maximum is None and max(alle) > 0:
            va.maximum_scale = max(alle) * 1.25           # Platz für die Beschriftung am längsten Balken
        if not linie and min(alle) < 0:
            va.minimum_scale = min(alle) * 1.35
        if min(alle) >= 0:
            va.minimum_scale = 0
        else:
            from pptx.enum.chart import XL_TICK_LABEL_POSITION
            ca.tick_label_position = XL_TICK_LABEL_POSITION.LOW   # Namen am Rand, nicht im negativen Balken
        if maximum is not None:
            va.maximum_scale = maximum
        ca.has_major_gridlines = False
        ca.tick_labels.font.size = Pt(10)
        if art == XL_CHART_TYPE.BAR_CLUSTERED:
            try:
                ca.reverse_order = True
            except AttributeError:
                ca._element.get_or_add_scaling().get_or_add_orientation().set("val", "maxMin")
        return g

    def balken(self, s, kategorien, reihen, box=None, zahlformat='#,##0', maximum=None, quer=True):
        """Echtes PowerPoint-Diagramm. reihen: Liste von Werten oder [(Name, Werte), …] mit bis zu 3 Reihen.
        zahlformat z. B. '#,##0.0' oder '0.0" %"'. quer=True: waagrechte Balken (gut bei langen Bezeichnungen)."""
        art = XL_CHART_TYPE.BAR_CLUSTERED if quer else XL_CHART_TYPE.COLUMN_CLUSTERED
        return self._diagramm(s, art, kategorien, reihen, box, zahlformat, maximum, "Balken")

    def linie(self, s, kategorien, reihen, box=None, zahlformat='#,##0', maximum=None):
        """Echtes PowerPoint-Liniendiagramm für Verläufe über die Zeit."""
        return self._diagramm(s, XL_CHART_TYPE.LINE_MARKERS, kategorien, reihen, box, zahlformat, maximum, "Linie")

    def quelle(self, s, text):
        """Kleine Quellenzeile unter dem Inhaltsbereich, über der Fußzeile."""
        return self._textfeld(s, self.links, self.unten + 0.05, self.rechts - self.links, 0.3, [(text, 9, False, TEXTSCHRIFT)], "Quelle", farben=[TEXT])

    # Prüfen und Speichern ----------------------------------------------------------------------------------
    def pruefen(self):
        alles_gut = True
        for nr, s in enumerate(self.folien, 1):
            probleme, boxen = [], []
            for f in s.shapes:
                if f.is_placeholder:
                    typ = f.placeholder_format.type
                    if f.has_text_frame and not f.text_frame.text.strip() and typ not in _RANDTYPEN:
                        probleme.append(f"leerer Platzhalter „{f.name}“")
                    if typ in _TITELTYPEN and _zoll(f.top) >= 0:
                        boxen.append(("Titel", _zoll(f.left), _zoll(f.top), _zoll(f.left) + _zoll(f.width), _zoll(f.top) + _zoll(f.height)))
                    continue
                l, t, r, u = _zoll(f.left), _zoll(f.top), _zoll(f.left) + _zoll(f.width), _zoll(f.top) + _zoll(f.height)
                if l < -0.01 or t < -0.01 or r > self.b + 0.01 or u > self.h + 0.01:
                    probleme.append(f"„{f.name}“ ragt über den Folienrand")
                elif u > self.fuss + 0.01:
                    probleme.append(f"„{f.name}“ ragt in die Fußzeile")
                boxen.append((f.name, l, t, r, u))
            for i in range(len(boxen)):
                for j in range(i + 1, len(boxen)):
                    a, b = boxen[i], boxen[j]
                    ueber = max(0, min(a[3], b[3]) - max(a[1], b[1])) * max(0, min(a[4], b[4]) - max(a[2], b[2]))
                    if ueber > 0.02:
                        probleme.append(f"„{a[0]}“ überdeckt „{b[0]}“")
            if s.shapes.title is not None and len(s.shapes.title.text_frame.text.split()) > GRENZEN["titel"]:
                probleme.append("Titel hat mehr als zwölf Wörter")
            for f in s.placeholders:
                if f.placeholder_format.type in _RANDTYPEN:
                    continue
                if f.placeholder_format.type == PP_PLACEHOLDER.PICTURE and not f._element.tag.endswith("}pic"):
                    probleme.append("Bildplatzhalter ohne Bild")
                if not f.has_text_frame or not f.text_frame.text.strip():
                    continue
                absaetze = [p.text for p in f.text_frame.paragraphs if p.text.strip()]
                if f.placeholder_format.type not in _TITELTYPEN and len(absaetze) > GRENZEN["punkte"]:
                    probleme.append(f"mehr als {GRENZEN['punkte']} Punkte in „{f.name}“")
                try:
                    vorbild = s.slide_layout.placeholders.get(idx=f.placeholder_format.idx)
                except Exception:
                    vorbild = None
                if vorbild is not None:
                    platz, _ = _platz(vorbild)
                    laenge = len(f.text_frame.text) + 25 * (len(absaetze) - 1)
                    if laenge > platz * 1.15:
                        probleme.append(f"Text in „{f.name}“ zu lang (≈{laenge} von ≈{platz} Zeichen), kürzen oder aufteilen")
            bildfelder = [p for p in s.slide_layout.placeholders if p.placeholder_format.type == PP_PLACEHOLDER.PICTURE]
            bilder = [f for f in _formen(s.shapes) if f._element.tag.endswith("}pic")]
            if bildfelder and len(bilder) < len(bildfelder):
                probleme.append(f"Bildlayout mit {len(bildfelder)} Bildfeld(ern), aber nur {len(bilder)} Bild(ern): "
                                "Bild aus der Vorlage einsetzen (bild1=…) oder ein Layout ohne Bild nehmen")
            notiz = s.notes_slide.notes_text_frame.text.strip() if s.has_notes_slide and s.notes_slide.notes_text_frame is not None else ""
            if not notiz and not self.ohne_notizen:
                probleme.append("keine Sprechernotizen")
            alles_gut = alles_gut and not probleme
            print(f"Folie {nr} · {s.slide_layout.name} · " + ("in Ordnung" if not probleme else "; ".join(probleme)))
        alles = " ".join(f.text_frame.text for s in self.folien for f in s.shapes if f.has_text_frame)
        ersatz = re.findall(r"[A-Za-z]*(?:ae|oe|ue)[a-z]*", alles)
        if not re.search(r"[äöüÄÖÜß]", alles) and len(ersatz) >= 3:
            print("Umlaute fehlen (zum Beispiel " + ", ".join(ersatz[:3]) + "): schreib ä, ö, ü und ß")
            alles_gut = False
        kapitel = [s for s in self.folien if KAPITEL_LAYOUT.search(s.slide_layout.name)]
        if kapitel and KAPITEL_LAYOUT.search(self.folien[-1].slide_layout.name):
            print(f"Letzte Folie: „{self.folien[-1].slide_layout.name}“ ist ein Kapitellayout. Nimm für den Schluss das Schluss-, Zitat- oder Titellayout")
            alles_gut = False
        if len(kapitel) == 1:
            print(f"Nur ein Kapiteltrenner („{kapitel[0].shapes.title.text_frame.text if kapitel[0].shapes.title else ''}“). Kapiteltrenner nur bei mindestens zwei Kapiteln, sonst ein anderes Layout")
            alles_gut = False
        for k, s in enumerate(kapitel, 1):
            for f in s.placeholders:
                t = f.text_frame.text.strip() if f.has_text_frame else ""
                if f.placeholder_format.type not in _RANDTYPEN and re.fullmatch(r"\d{1,2}", t) and int(t) != k:
                    print(f"Kapitelnummer „{t}“ auf Folie {self.folien.index(s) + 1}: das ist Kapitel {k}. Kapitel zählen, nicht Folien")
                    alles_gut = False
        papier = sorted({m.group(0) for m in PAPIERDEUTSCH.finditer(alles)})
        if papier:
            print("Amtsdeutsch: " + ", ".join(papier) + ". Schreib so, wie Du es dem Adressaten im Gespräch sagst")
            alles_gut = False
        if self.folien:
            letzter = " ".join(f.text_frame.text for f in self.folien[-1].shapes if f.has_text_frame)
            if VERBOTEN_AM_SCHLUSS.search(letzter):
                print("Letzte Folie: kein Dank, sondern eine Botschaft zum Thema")
                alles_gut = False
        print("PRÜFUNG:", "bestanden" if alles_gut else "nicht bestanden, bitte korrigieren")
        return alles_gut

    def speichern(self, name):
        liste = self.prs.slides._sldIdLst
        for sid in self.beispiele:
            self.prs.part.drop_rel(sid.rId)
            liste.remove(sid)
        self.beispiele = []
        self.prs.part.rename_slide_parts([sid.rId for sid in liste])
        name = re.sub(r"[^\w\-äöüÄÖÜß]+", "_", name).strip("_")
        n = 1
        while (DATEN / f"{name}-v{n}.pptx").exists():
            n += 1
        ziel = DATEN / f"{name}-v{n}.pptx"
        self.prs.save(ziel)
        teile = [i.filename for i in zipfile.ZipFile(ziel).infolist()]
        doppelt = sorted({t for t in teile if teile.count(t) > 1})
        kontrolle = Presentation(ziel)
        print("Gespeichert:", ziel)
        print("Folien:", len(kontrolle.slides), "· Layouts:", " · ".join(s.slide_layout.name for s in kontrolle.slides))
        print("Datei in Ordnung" if not doppelt else f"FEHLER: doppelte Teile {doppelt}")
        return ziel


def datei_pruefen(pfad, muster=None):
    """Prüft die fertige Datei vor der Auslieferung: Stammt sie aus dem Musterdeck, also mit dessen Layouts gebaut?"""
    if muster is None:
        muster = anhaenge()[0][0]
    soll = [l.name for l in Presentation(muster).slide_layouts]
    datei = Presentation(pfad)
    ist = [l.name for l in datei.slide_layouts]
    genutzt = sorted({s.slide_layout.name for s in datei.slides})
    fremd = [n for n in genutzt if n not in soll]
    if ist == soll and not fremd:
        print(f"AUS DEM MUSTERDECK: ja · {len(datei.slides)} Folien · Layouts: " + " · ".join(genutzt))
        return True
    print(f"AUS DEM MUSTERDECK: NEIN · Die Datei hat die Layouts {', '.join(ist) or 'keine'}, das Musterdeck hat "
          f"{len(soll)} andere. Sie wurde nicht mit dieser Bibliothek gebaut (etwa mit PptxGenJS). Nicht ausliefern. "
          "Baue sie mit Vorlage, layout_folie, neue_folie, pruefen und speichern neu.")
    return False


_ZAHL = re.compile(r"(?<![\w,.])[+\-−–]?\d{1,3}(?:\.\d{3})+(?:,\d+)?|(?<![\w,.])[+\-−–]?\d+(?:,\d+)?")


def _zahlen(text):
    """Alle Zahlen eines Textes als (Wert, Nachkommastellen, Originaltext). 3.972 -> 3972, 3,4 -> 3.4"""
    out = []
    for m in _ZAHL.finditer(text or ""):
        roh = m.group(0).lstrip("+-−–")
        stellen = len(roh.split(",")[1]) if "," in roh else 0
        try:
            out.append((float(roh.replace(".", "").replace(",", ".")), stellen, m.group(0)))
        except ValueError:
            pass
    return out


def _runde(x, stellen):
    """Kaufmännisch runden (334,95 -> 335,0), nicht wie Pythons round()."""
    from decimal import Decimal, ROUND_HALF_UP
    return float(Decimal(repr(float(x))).quantize(Decimal(1).scaleb(-stellen), rounding=ROUND_HALF_UP))


def _passt_zu(wert, stellen, quellwerte):
    """Ist der Folienwert (kaufmännisch gerundet, evtl. in anderer Einheit) einer der Quellwerte?"""
    for q in quellwerte:
        for faktor in (1, 1e3, 1e6, 1e-3, 1e-6):
            if _runde(abs(q) / faktor, stellen) == _runde(wert, stellen):
                return True
    return False


def zahlen_pruefen(pfad, unterlagen=None, berechnet=None, excel=None):
    """Prüft jede Zahl auf jeder Folie (Text, Tabellen, Diagrammwerte) gegen ihren Beleg.
    Jede Folie mit Zahlen nennt in den Sprechernotizen ihre Belege: Absätze der Text- oder PDF-Unterlage (A12, A12–A15)
    oder Excel-Zellen (Budgetstatus+VS!F12, 'Patienten kum'!C5:C9). Geprüft wird nur gegen diese Belege.
    berechnet: {"103": "3.972 - 3.869"} für Zahlen, die Du selbst gerechnet hast; sie werden mit Rechenweg ausgegeben."""
    if unterlagen is None or excel is None:
        _, u2, x2 = anhaenge()
        unterlagen = u2 if unterlagen is None else unterlagen
        excel = x2 if excel is None else excel
    nach_nr = {}
    for u in unterlagen:
        for a in unterlage_lesen(u):
            nr, _, inhalt = a.partition(": ")
            nach_nr[int(nr[1:])] = [w for w, _, _ in _zahlen(inhalt)]
    mappen = []
    if excel:
        import openpyxl
        mappen = [openpyxl.load_workbook(x, data_only=True) for x in excel]

    def zellwerte(blatt, bereich):
        werte = []
        for wb in mappen:
            if blatt not in wb.sheetnames:
                continue
            zellen = wb[blatt][bereich]
            if not isinstance(zellen, tuple):
                zellen = ((zellen,),)
            elif zellen and not isinstance(zellen[0], tuple):
                zellen = (zellen,)
            werte += [float(c.value) for r in zellen for c in r if isinstance(c.value, (int, float)) and not isinstance(c.value, bool)]
        return werte

    berechnet = {str(k): v for k, v in (berechnet or {}).items()}
    fehler = []
    for nr, s in enumerate(Presentation(pfad).slides, 1):
        notiz = s.notes_slide.notes_text_frame.text if s.has_notes_slide and s.notes_slide.notes_text_frame is not None else ""
        quelle, belegt = [], False
        for von, bis in re.findall(r"\bA(\d+)(?:\s*[–-]\s*A?(\d+))?\b", notiz):
            for b in range(int(von), int(bis or von) + 1):
                quelle += nach_nr.get(b, []); belegt = True
        for b1, b2, bereich in re.findall(r"(?:'([^']+)'|([\w+\-.]+))!\$?([A-Z]{1,3}\$?\d+(?::\$?[A-Z]{1,3}\$?\d+)?)", notiz):
            quelle += zellwerte((b1 or b2).strip(), bereich.replace("$", "")); belegt = True
        texte = []
        for f in _formen(s.shapes):
            if f.has_text_frame:
                texte.append(f.text_frame.text)
            if getattr(f, "has_table", False) and f.has_table:
                texte += [c.text for r in f.table.rows for c in r.cells]
            if getattr(f, "has_chart", False) and f.has_chart:
                for pl in f.chart.plots:
                    for ser in pl.series:
                        texte += [f"{v:.{2 if v % 1 else 0}f}".replace(".", ",") for v in ser.values if v is not None]
        for text in texte:
            for wert, stellen, roh in _zahlen(text):
                if stellen == 0 and (wert <= 12 or 1990 <= wert <= 2035):
                    continue          # Aufzählungen, Monate, Jahreszahlen
                if roh.lstrip("+-−–") in berechnet:
                    continue
                if not belegt:
                    fehler.append(f"Folie {nr}: „{roh}“ ohne Beleg in den Sprechernotizen")
                elif not _passt_zu(wert, stellen, quelle):
                    fehler.append(f"Folie {nr}: „{roh}“ steht nicht in den Belegen der Notizen")
    zellref = r"(?:'([^']+)'|([\w+\-.]+))!\$?([A-Z]{1,3}\$?\d+)"
    # Feste Zahlen im Rechenweg sind nur erlaubt, wenn sie in einer angehängten Unterlage stehen,
    # dazu 100, 1000, 1000000 zum Umrechnen und 1 für den Vorzeichenwechsel (* -1)
    unterlagenzahlen = [w for ws in nach_nr.values() for w in ws]
    umrechnung = {1.0, 100.0, 1000.0, 1000000.0}

    def auswerten(formel, stoerung=None):
        def zelle(m):
            w = zellwerte((m.group(1) or m.group(2)).strip(), m.group(3).replace("$", ""))
            if not w:
                return "None"
            return repr(w[0] * 1.1 + 1 if stoerung == m.start() else w[0])
        a = re.sub(zellref, zelle, str(formel))
        a = re.sub(r"(?<=\d)\.(?=\d{3}\b)", "", a).replace(",", ".").replace("−", "-").replace("×", "*").replace(":", "/")
        if not re.fullmatch(r"[\d\s.+\-*/()eE]+", a):
            raise ValueError
        return eval(a, {"__builtins__": {}}, {})

    for k, formel in berechnet.items():
        try:
            ergebnis = auswerten(formel)
        except Exception:
            fehler.append(f"Rechenweg für „{k}“ ist keine prüfbare Formel („{formel}“). Schreib nur Zellen, Zahlen und + - * / ( )")
            continue
        ohne_zellen = re.sub(zellref, " ", str(formel))
        fremd = [roh for w, _, roh in _zahlen(ohne_zellen)
                 if w not in umrechnung and not any(_runde(abs(q), 6) == _runde(w, 6) for q in unterlagenzahlen)]
        if fremd:
            fehler.append(f"Rechenweg für „{k}“ enthält die feste Zahl {', '.join(fremd)}, die in keiner Unterlage steht. "
                          "Rechne nur mit Zellen und Zahlen aus den Unterlagen; erlaubt sind sonst nur 100, 1000, 1000000 zum Umrechnen und * -1 für das Vorzeichen")
            continue
        zellen = [m.start() for m in re.finditer(zellref, str(formel))]
        aus_unterlage = [w for w, _, _ in _zahlen(ohne_zellen) if w not in umrechnung]
        if not zellen and not aus_unterlage:
            fehler.append(f"Rechenweg für „{k}“ enthält weder eine Zelle noch eine Zahl aus der Unterlage („{formel}“)")
            continue
        if zellen and all(auswerten(formel, z) == ergebnis for z in zellen):
            fehler.append(f"Rechenweg für „{k}“ hängt von keiner Zelle ab („{formel}“). Die Zahl muss aus den Zellen folgen")
            continue
        werte = _zahlen(k)
        if not werte or _runde(abs(ergebnis), werte[0][1]) != _runde(werte[0][0], werte[0][1]):
            fehler.append(f"Rechenweg für „{k}“ ergibt {ergebnis:.6g}, kaufmännisch gerundet nicht {k}")
        else:
            print(f"Berechnet und nachgerechnet: {k} = {formel}")
    if fehler:
        print("ZAHLEN: NEIN · " + "; ".join(dict.fromkeys(fehler)) + ". Nicht ausliefern. Zahl aus dem Beleg übernehmen, Beleg in "
              "die Notizen schreiben oder als berechnet mit Rechenweg angeben, dann neu bauen.")
        return False
    print("ZAHLEN: ja · jede Zahl steht in ihrem Beleg" + (" oder ist mit Rechenweg ausgewiesen" if berechnet else ""))
    return True

# BIBLIOTHEK-ENDE
```
