Bei Interesse an die Setting-Engine ca. 2500 Zeilen Phyton Code (1 Run mit 150 Seiten DIN A4 dauert aktuell 90 Minuten, Win 11, aktuellen intel 7 CPU, 64 GB RAM, gerne melden.
Texte, Infografiken kommen über gamma.app
Es sind daraus Exporte als PowerPoint und png Formate nötig für die Vorlagen.
Kernidee:
- Slide-PNGs sind die Rendering-Wahrheit (scharf, “present-mode”).
- PPTX ist die Struktur-Wahrheit (Text, Textbox-Positionen, Reihenfolge).
- Wir machen “Cluster & Crop”: Boxen finden -> zusammengehörige Boxen clustern -> als PNG exportieren -> JSON-Hints erzeugen.
- Scribus nutzt diese Hints (kind=infographic + relbbox + image) für Layout (bestehendes setzereiengine.py bleibt primär.
REPO STRUKTUR:
/gammascribuspack/
README.md
requirements.txt
tools/
pipeline.py
ingestgammaexport.py
pptxtextextract.py
gammacards.py
anchormap.py
jsonpatchpptx.py
scribusextension/
setzereigammabridge.py
PATCHES.md
ZIELAUSGABE:
- tools/pipeline.py erzeugt:
- output/slides/slide0001.png ... (falls nicht schon vorhanden; sonst wird nur kopiert/normalisiert)
- output/crops/slide0001cluster01.png ...
- output/hintsbyci.json (Mapping (chapter,pageinchapter)-> list(hints))
- optional: output/debugoverlay/slide0001overlay.png (für QC)
- jsonpatchpptx.py kann dein existierendes pptx-json (aus mediapool/pptx/...) patchen:
- ergänzt slide.imageboxes (oder separate hints-Datei), so dass setzereiengine.py es automatisch nutzt.
INTEGRATION IN SCRIBUS:
- scribusextension/setzereigammabridge.py:
- läuft in Scribus (Script-Menü).
- ruft externen Python (venv) auf: tools/pipeline.py und tools/jsonpatchpptx.py
- danach startet es (importiert) dein bestehendes setzereiengine.py (oder zeigt Pfad-Hinweis), so dass Layout läuft.
- Konfiguration über ENV:
ZCGAMMAEXPORTDIR (Ordner oder Zip aus Gamma export)
ZCPROJECTDIR (wo output/ und mediapool/ liegen sollen)
ZCPPTXDIR (optional; wenn nicht, wird unter ZCPROJECTDIR/mediapool/pptx erzeugt)
ZCVENVPY (Pfad zu python.exe in venv, damit opencv/pillow sicher vorhanden sind)
- Wenn ZCVENVPY fehlt: weise Nutzer sauber darauf hin.
ABHÄNGIGKEITEN (requirements.txt):
- python-pptx
- opencv-python
- pillow
- numpy
KEIN ASPOSE im Default. Gamma-PNG Export ist unser Rendering. LibreOffice Render ist optionaler Fallback (nicht zwingend).
CARD DETECTION (gammacards.py):
- Input: slide PNG
- Output: candidate boxes (bboxnorm) + score
- Algorithmus robust gegen Gamma:
- Kanten: Canny + morph close + Contours
- Filter: min area, aspect, rectangularity
- Zusatzscore: “border contrast” (Farbkontrast entlang Kanten gegen innen/außen)
- Merge: wenn Boxen eng aneinander liegen / gleiche Höhe / gleiche y (Row) oder gleiche x (Column)
- Clustering:
- Union-Find über Boxen
- Merge-Regeln:
* gleiche Höhe innerhalb tolh AND y0 innerhalb toly AND x-gap <= tolgap -> row cluster
* gleiche Breite innerhalb tolw AND x0 innerhalb tolx AND y-gap <= tolgap -> col cluster
- Ergebnis: max 1-3 Cluster pro Slide (parametrierbar).
TEXT ANCHORING (pptxtextextract.py + anchormap.py):
- pptxtextextract:
- extrahiert pro Folie Textboxen: text + relbbox
- extrahiert slide order (z-index)
- anchormap:
- für jeden Cluster wähle:
precedingtext = nächster Textblock “oberhalb” mit hoher horizontaler Überlappung (IoU in X)
followingtext = nächster Textblock “unterhalb” dito
insidetexts = alle Textboxen deren bbox innerhalb cluster bbox liegt (optional)
- schreibe anchors in hint meta.
jsonpatchpptx.py:
- nimmt:
input: pptxjson (dein slide-JSON, wie du es bereits nutzt: slides[].imageboxes/textboxes/texts)
clusters output vom pipeline-run
- patcht slide.imageboxes:
image = relativer Pfad zu crop png
relbbox = cluster bbox norm
forced = True
tagmeta.region = "sidebar" wenn cluster schmal, sonst "body"
tagmeta.fit = "contain"
tagmeta.mindpi = 240
- alternativ: exportiere hintsbyci.json direkt kompatibel zu deinem setzereiengine (kind="infographic").
PATCH in setzereiengine.py (nur dokumentieren, nicht riesig neu schreiben):
- In buildentriesfrompptx():
wenn slide.imageboxes zu wenige sind (oder tags fehlen):
lade output clusters hints und ergänze rawimages
- Dokumentiere das in scribusextension/PATCHES.md.
README.md:
- Install: venv, pip install -r requirements
- Gamma Export: “Export -> PPTX + PNG”
- Run pipeline: python tools/pipeline.py --gammaexport <dir|zip> --project <dir> --chaptermap <json optional>
- Scribus: Template öffnen -> Script ausführen: scribusextension/setzereigammabridge.py
ERSTELLE JETZT DIE DATEIEN MIT FOLGENDEM CODE: (Auszug)
---
## 1) requirements.txttxt<br>python-pptx==1.0.2<br>opencv-python==4.10.0.84<br>Pillow==10.4.0<br>numpy==2.0.2<br>
---
## 2) tools/ingest_gamma_export.pypython<br># -*- coding: utf-8 -*-<br>import os<br>import re<br>import zipfile<br>import shutil<br>from typing import Dict, List, Tuple, Optional<br><br>IMG_EXTS = (".png", ".jpg", ".jpeg", ".webp")<br><br>def _ensure_dir(p: str) -> str:<br> os.makedirs(p, exist_ok=True)<br> return p<br><br>def is_zip(path: str) -> bool:<br> return path.lower().endswith(".zip") and os.path.isfile(path)<br><br>def unpack_if_zip(gamma_export: str, work_dir: str) -> str:<br> """<br> Returns a directory containing the exported files.<br> If gamma_export is a zip -> extracts into work_dir/_gamma_unpacked<br> If gamma_export is a dir -> returns it<br> """<br> if os.path.isdir(gamma_export):<br> return gamma_export<br> if is_zip(gamma_export):<br> out = _ensure_dir(os.path.join(work_dir, "_gamma_unpacked"))<br> with zipfile.ZipFile(gamma_export, "r") as zf:<br> zf.extractall(out)<br> return out<br> raise FileNotFoundError(f"gamma_export not found: {gamma_export}")<br><br>def find_pptx(root: str) -> Optional[str]:<br> for base, _dirs, files in os.walk(root):<br> for fn in files:<br> if fn.lower().endswith(".pptx"):<br> return os.path.join(base, fn)<br> return None<br><br>def find_slide_pngs(root: str) -> List[str]:<br> """<br> Gamma exports often include slide PNGs. We look for pngs with 'slide' in name,<br> but also accept any pngs if nothing else found.<br> """<br> pngs = []<br> loose = []<br> for base, _dirs, files in os.walk(root):<br> for fn in files:<br> if fn.lower().endswith(".png"):<br> p = os.path.join(base, fn)<br> if re.search(r"slide|seite|page", fn.lower()):<br> pngs.append(p)<br> else:<br> loose.append(p)<br> return sorted(pngs) if pngs else sorted(loose)<br><br>def normalize_slide_pngs(slide_pngs: List[str], out_dir: str) -> List[str]:<br> """<br> Copies slide images to out_dir as slide_0001.png ... in sorted order.<br> """<br> _ensure_dir(out_dir)<br> normed = []<br> for i, src in enumerate(slide_pngs, start=1):<br> dst = os.path.join(out_dir, f"slide_{i:04d}.png")<br> if os.path.abspath(src) != os.path.abspath(dst):<br> shutil.copy2(src, dst)<br> normed.append(dst)<br> return normed<br>
---
## 3) tools/pptx_text_extract.pypython<br># -*- coding: utf-8 -*-<br>import os<br>import json<br>from typing import Any, Dict, List, Tuple, Optional<br><br>from pptx import Presentation<br>from pptx.enum.shapes import MSO_SHAPE_TYPE<br><br>def _emu(v: int) -> float:<br> return float(v or 0)<br><br>def _clamp01(x: float) -> float:<br> return max(0.0, min(1.0, x))<br><br>def _bbox_norm(left, top, width, height, sw, sh):<br> x0 = _clamp01(_emu(left) / sw)<br> y0 = _clamp01(_emu(top) / sh)<br> x1 = _clamp01((_emu(left) + _emu(width)) / sw)<br> y1 = _clamp01((_emu(top) + _emu(height)) / sh)<br> return [x0, y0, x1, y1]<br><br>def _iter_shapes(shape) -> List[Any]:<br> """<br> Recursively yield shapes including inside groups.<br> """<br> out = [shape]<br> if shape.shape_type == MSO_SHAPE_TYPE.GROUP:<br> for s in shape.shapes:<br> out.extend(_iter_shapes(s))<br> return out<br><br>def extract_text_boxes(pptx_path: str) -> Dict[str, Any]:<br> prs = Presentation(pptx_path)<br> sw = float(prs.slide_width)<br> sh = float(prs.slide_height)<br><br> slides_out = []<br> for si, slide in enumerate(prs.slides, start=1):<br> tbs = []<br> flat = []<br> for shp in slide.shapes:<br> flat.extend(_iter_shapes(shp))<br><br> for zi, shp in enumerate(flat):<br> # python-pptx has text_frame on many types; we only keep meaningful text<br> txt = ""<br> try:<br> if hasattr(shp, "text_frame") and shp.text_frame:<br> txt = (shp.text_frame.text or "").strip()<br> except Exception:<br> txt = ""<br> if not txt:<br> continue<br><br> try:<br> bb = _bbox_norm(shp.left, shp.top, shp.width, shp.height, sw, sh)<br> except Exception:<br> bb = [0.0, 0.0, 1.0, 1.0]<br><br> tbs.append({<br> "z": zi,<br> "text": txt,<br> "rel_bbox": bb,<br> })<br><br> slide_texts = []<br> # convenience: concat textboxes as "texts"<br> for tb in sorted(tbs, key=lambda x: (x["rel_bbox"][1], x["rel_bbox"][0])):<br> slide_texts.append(tb["text"])<br><br> slides_out.append({<br> "slide": si,<br> "text_boxes": tbs,<br> "texts": slide_texts,<br> })<br><br> return {<br> "pptx": os.path.basename(pptx_path),<br> "slide_width_emu": sw,<br> "slide_height_emu": sh,<br> "slides": slides_out,<br> }<br><br>def main():<br> import argparse<br> ap = argparse.ArgumentParser()<br> ap.add_argument("--pptx", required=True)<br> ap.add_argument("--out", required=True)<br> args = ap.parse_args()<br> data = extract_text_boxes(args.pptx)<br> os.makedirs(os.path.dirname(args.out), exist_ok=True)<br> with open(args.out, "w", encoding="utf-8") as f:<br> json.dump(data, f, ensure_ascii=False, indent=2)<br> print("Wrote", args.out)<br><br>if __name__ == "__main__":<br> main()<br>
---
## 4) tools/gamma_cards.py (Grid-Clustering + Border-Contrast)
python<br># -*- coding: utf-8 -*-<br>import os<br>import json<br>from dataclasses import dataclass<br>from typing import List, Tuple, Dict, Any, Optional<br><br>import cv2<br>import numpy as np<br><br>def clamp01(x: float) -> float:<br> return max(0.0, min(1.0, x))<br><br>@dataclass<br>class Box:<br> x0: int<br> y0: int<br> x1: int<br> y1: int<br> score: float = 0.0<br><br> @property<br> def w(self): return max(0, self.x1 - self.x0)<br> @property<br> def h(self): return max(0, self.y1 - self.y0)<br> @property<br> def area(self): return self.w * self.h<br><br> def to_norm(self, W: int, H: int) -> List[float]:<br> return [clamp01(self.x0 / W), clamp01(self.y0 / H), clamp01(self.x1 / W), clamp01(self.y1 / H)]<br><br>def _rectangularity(cnt) -> float:<br> x, y, w, h = cv2.boundingRect(cnt)<br> if w <= 0 or h <= 0:<br> return 0.0<br> a = cv2.contourArea(cnt)<br> return float(a) / float(w * h)<br><br>def _border_contrast_score(img_bgr: np.ndarray, box: Box) -> float:<br> """<br> Gamma-cards often have visible border/gradient transitions.<br> We measure contrast between a thin outer ring and inner ring.<br> """<br> H, W = img_bgr.shape[:2]<br> x0, y0, x1, y1 = box.x0, box.y0, box.x1, box.y1<br> if box.w < 20 or box.h < 20:<br> return 0.0<br><br> pad = max(2, int(min(box.w, box.h) * 0.02))<br> x0i, y0i = max(0, x0 + pad), max(0, y0 + pad)<br> x1i, y1i = min(W, x1 - pad), min(H, y1 - pad)<br> if x1i <= x0i or y1i <= y0i:<br> return 0.0<br><br> outer = img_bgr[max(0,y0-pad):min(H,y1+pad), max(0,x0-pad):min(W,x1+pad)]<br> inner = img_bgr[y0i:y1i, x0i:x1i]<br> if outer.size == 0 or inner.size == 0:<br> return 0.0<br><br> # Use LAB for perceptual difference<br> outer_lab = cv2.cvtColor(outer, cv2.COLOR_BGR2LAB)<br> inner_lab = cv2.cvtColor(inner, cv2.COLOR_BGR2LAB)<br><br> o = np.mean(outer_lab.reshape(-1,3), axis=0)<br> i = np.mean(inner_lab.reshape(-1,3), axis=0)<br> d = float(np.linalg.norm(o - i))<br> # normalize to ~0..1 range<br> return min(1.0, d / 40.0)<br><br>def detect_card_candidates(png_path: str,<br> min_rel_area: float = 0.03,<br> max_rel_area: float = 0.95,<br> min_aspect: float = 0.15,<br> max_aspect: float = 6.5) -> Tuple[np.ndarray, List<div>]:<br> img = cv2.imread(png_path, cv2.IMREAD_COLOR)<br> if img is None:<br> raise FileNotFoundError(png_path)<br> H, W = img.shape[:2]<br><br> gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)<br> # slight blur helps for gradients<br> gray = cv2.GaussianBlur(gray, (5,5), 0)<br><br> edges = cv2.Canny(gray, 40, 120)<br> # close gaps<br> k = max(3, int(min(W, H) * 0.004) | 1)<br> kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (k, k))<br> edges2 = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel, iterations=2)<br> edges2 = cv2.dilate(edges2, kernel, iterations=1)<br><br> contours, _ = cv2.findContours(edges2, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)<br><br> boxes: List<div> = []<br> for cnt in contours:<br> x, y, w, h = cv2.boundingRect(cnt)<br> if w <= 0 or h <= 0:<br> continue<br> rel_area = (w*h) / float(W*H)<br> if rel_area < min_rel_area or rel_area > max_rel_area:<br> continue<br> aspect = h / float(w)<br> if aspect < min_aspect or aspect > max_aspect:<br> continue<br><br> recty = _rectangularity(cnt)<br> if recty < 0.45:<br> continue<br><br> b = Box(x, y, x+w, y+h, score=0.0)<br> bc = _border_contrast_score(img, b)<br> # score: rectangularity + border contrast + area bonus<br> b.score = 0.55*recty + 0.35*bc + 0.10*min(1.0, rel_area/0.20)<br> boxes.append(b)<br><br> # NMS-like prune: remove boxes heavily contained by bigger better boxes<br> boxes.sort(key=lambda b: (b.score, b.area), reverse=True)<br> kept: List<div> = []<br> for b in boxes:<br> drop = False<br> for kbox in kept:<br> # containment test<br> if b.x0 >= kbox.x0 and b.y0 >= kbox.y0 and b.x1 <= kbox.x1 and b.y1 <= kbox.y1:<br> if kbox.area >= b.area * 1.1:<br> drop = True<br> break<br> if not drop:<br> kept.append(b)<br><br> return img, kept<br><br>class UnionFind:<br> def __init__(self, n: int):<br> self.p = list(range(n))<br> self.r = [0]*n<br> def find(self, a: int) -> int:<br> while self.p[a] != a:<br> self.p[a] = self.p[self.p[a]]<br> a = self.p[a]<br> return a<br> def union(self, a: int, b: int):<br> ra, rb = self.find(a), self.find(b)<br> if ra == rb: return<br> if self.r[ra] < self.r[rb]:<br> self.p[ra] = rb<br> elif self.r[ra] > self.r[rb]:<br> self.p[rb] = ra<br> else:<br> self.p[rb] = ra<br> self.r[ra] += 1<br><br>def _gap(a0, a1, b0, b1) -> int:<br> # gap between intervals [a0,a1] and [b0,b1]<br> if a1 < b0: return b0 - a1<br> if b1 < a0: return a0 - b1<br> return 0<br><br>def cluster_boxes(boxes: List<div>, W: int, H: int,<br> tol_y: float = 0.02, tol_h: float = 0.06,<br> tol_x: float = 0.02, tol_w: float = 0.06,<br> tol_gap: float = 0.03) -> List[List<div>]:<br> """<br> Merge boxes that likely belong together (Gamma grids / stacked cards).<br> """<br> if not boxes:<br> return []<br> n = len(boxes)<br> uf = UnionFind(n)<br><br> ty = int(tol_y * H)<br> th = int(tol_h * H)<br> tx = int(tol_x * W)<br> tw = int(tol_w * W)<br> tg = int(tol_gap * max(W, H))<br><br> for i in range(n):<br> for j in range(i+1, n):<br> a, b = boxes<em class="text-italics">, boxes[j]<br><br> # row merge: similar y0 and height and small x gap<br> row_ok = abs(a.y0 - b.y0) <= ty and abs(a.h - b.h) <= th<br> if row_ok:<br> gx = _gap(a.x0, a.x1, b.x0, b.x1)<br> if gx <= tg:<br> uf.union(i, j)<br> continue<br><br> # column merge: similar x0 and width and small y gap<br> col_ok = abs(a.x0 - b.x0) <= tx and abs(a.w - b.w) <= tw<br> if col_ok:<br> gy = _gap(a.y0, a.y1, b.y0, b.y1)<br> if gy <= tg:<br> uf.union(i, j)<br> continue<br><br> # “attached” merge: heavy overlap in one axis + tiny gap in other<br> ox = max(0, min(a.x1, b.x1) - max(a.x0, b.x0))<br> oy = max(0, min(a.y1, b.y1) - max(a.y0, b.y0))<br> if ox > 0.6*min(a.w, b.w) and _gap(a.y0, a.y1, b.y0, b.y1) <= tg:<br> uf.union(i, j)<br> continue<br> if oy > 0.6*min(a.h, b.h) and _gap(a.x0, a.x1, b.x0, b.x1) <= tg:<br> uf.union(i, j)<br> continue<br><br> clusters: Dict[int, List</em><div><em class="text-italics">] = {}<br> for i in range(n):<br> r = uf.find(i)<br> clusters.setdefault(r, []).append(boxes<em class="text-italics">)<br><br> # merge cluster bbox (represented as 1 Box)<br> merged: List[Tuple[Box, List</em></em><div><em class="text-italics"><em class="text-italics">]] = []<br> for _, items in clusters.items():<br> x0 = min(b.x0 for b in items); y0 = min(b.y0 for b in items)<br> x1 = max(b.x1 for b in items); y1 = max(b.y1 for b in items)<br> score = float(np.mean([b.score for b in items]))<br> merged.append((Box(x0,y0,x1,y1,score=score), items))<br><br> # Sort by area+score, keep top 3 by default (param in pipeline)<br> merged.sort(key=lambda t: (t[0].area, t[0].score), reverse=True)<br> return [m[1] for m in merged] # return original boxes per cluster<br><br>def merge_cluster_bbox(cluster: List</em></em><div><em class="text-italics"><em class="text-italics">) -> Box:<br> x0 = min(b.x0 for b in cluster); y0 = min(b.y0 for b in cluster)<br> x1 = max(b.x1 for b in cluster); y1 = max(b.y1 for b in cluster)<br> score = float(np.mean([b.score for b in cluster]))<br> return Box(x0,y0,x1,y1,score=score)<br><br>def export_debug_overlay(img: np.ndarray, candidates: List</em></em><div><em class="text-italics"><em class="text-italics">, clusters: List[List</em></em><div><em class="text-italics"><em class="text-italics">], out_path: str):<br> vis = img.copy()<br> for b in candidates:<br> cv2.rectangle(vis, (b.x0,b.y0), (b.x1,b.y1), (0,255,255), 2)<br> for cl in clusters:<br> mb = merge_cluster_bbox(cl)<br> cv2.rectangle(vis, (mb.x0,mb.y0), (mb.x1,mb.y1), (0,255,0), 3)<br> os.makedirs(os.path.dirname(out_path), exist_ok=True)<br> cv2.imwrite(out_path, vis)<br><br>def crop_png(img_path: str, box: Box, out_path: str, pad_px: int = 10):<br> img = cv2.imread(img_path, cv2.IMREAD_COLOR)<br> H, W = img.shape[:2]<br> x0 = max(0, box.x0 - pad_px)<br> y0 = max(0, box.y0 - pad_px)<br> x1 = min(W, box.x1 + pad_px)<br> y1 = min(H, box.y1 + pad_px)<br> crop = img[y0:y1, x0:x1]<br> os.makedirs(os.path.dirname(out_path), exist_ok=True)<br> cv2.imwrite(out_path, crop)<br><br>def run_on_slide(png_path: str,<br> out_crops_dir: str,<br> out_overlay_dir: str,<br> max_clusters: int = 3) -> Dict[str, Any]:<br> img, candidates = detect_card_candidates(png_path)<br> H, W = img.shape[:2]<br> clusters = cluster_boxes(candidates, W, H)<br><br> # Convert clusters to merged boxes and keep top N<br> merged = [merge_cluster_bbox(cl) for cl in clusters]<br> merged.sort(key=lambda b: (b.area, b.score), reverse=True)<br> merged = merged[:max_clusters]<br><br> slide_id = os.path.splitext(os.path.basename(png_path))[0]<br> overlay_path = os.path.join(out_overlay_dir, f"{slide_id}_overlay.png")<br> export_debug_overlay(img, candidates, clusters, overlay_path)<br><br> items = []<br> for i, mb in enumerate(merged, start=1):<br> crop_name = f"{slide_id}_cluster_{i:02d}.png"<br> crop_path = os.path.join(out_crops_dir, crop_name)<br> crop_png(png_path, mb, crop_path, pad_px=max(8, int(0.008*max(W,H))))<br> items.append({<br> "cluster_index": i,<br> "rel_bbox": mb.to_norm(W, H),<br> "score": mb.score,<br> "crop": crop_path,<br> })<br><br> return {<br> "slide_png": png_path,<br> "slide_id": slide_id,<br> "overlay": overlay_path,<br> "clusters": items,<br> "candidates": len(candidates),<br> }<br><br>def main():<br> import argparse<br> ap = argparse.ArgumentParser()<br> ap.add_argument("--slide_png", required=True)<br> ap.add_argument("--out_dir", required=True)<br> ap.add_argument("--max_clusters", type=int, default=3)<br> args = ap.parse_args()<br><br> crops_dir = os.path.join(args.out_dir, "crops")<br> ov_dir = os.path.join(args.out_dir, "debug_overlay")<br> res = run_on_slide(args.slide_png, crops_dir, ov_dir, max_clusters=args.max_clusters)<br> out = os.path.join(args.out_dir, f"{res['slide_id']}_clusters.json")<br> os.makedirs(args.out_dir, exist_ok=True)<br> with open(out, "w", encoding="utf-8") as f:<br> json.dump(res, f, ensure_ascii=False, indent=2)<br> print("Wrote", out)<br><br>if __name__ == "__main__":<br> main()<br>
---
## 5) tools/anchor_map.pypython<br># -*- coding: utf-8 -*-<br>from typing import Any, Dict, List, Optional, Tuple<br><br>def bbox_area(bb):<br> x0,y0,x1,y1 = bb<br> return max(0.0, x1-x0) * max(0.0, y1-y0)<br><br>def x_overlap(a, b) -> float:<br> ax0,_,ax1,_ = a<br> bx0,_,bx1,_ = b<br> inter = max(0.0, min(ax1,bx1) - max(ax0,bx0))<br> denom = max(1e-6, min(ax1-ax0, bx1-bx0))<br> return inter / denom<br><br>def inside(inner, outer) -> bool:<br> x0,y0,x1,y1 = inner<br> ox0,oy0,ox1,oy1 = outer<br> return x0>=ox0 and y0>=oy0 and x1<=ox1 and y1<=oy1<br><br>def find_anchors(cluster_bb, text_boxes, min_x_ov=0.35):<br> """<br> preceding: closest textbox above cluster with good x overlap<br> following: closest textbox below cluster with good x overlap<br> inside_texts: all textboxes inside cluster<br> """<br> cx0,cy0,cx1,cy1 = cluster_bb<br><br> inside_t = [tb for tb in text_boxes if inside(tb["rel_bbox"], cluster_bb)]<br> inside_t.sort(key=lambda t: (t["rel_bbox"][1], t["rel_bbox"][0]))<br><br> above = []<br> below = []<br> for tb in text_boxes:<br> bb = tb["rel_bbox"]<br> ov = x_overlap(cluster_bb, bb)<br> if ov < min_x_ov:<br> continue<br> if bb[3] <= cy0: # below top of cluster<br> dist = cy0 - bb[3]<br> above.append((dist, tb))<br> elif bb[1] >= cy1:<br> dist = bb[1] - cy1<br> below.append((dist, tb))<br><br> above.sort(key=lambda x: x[0])<br> below.sort(key=lambda x: x[0])<br><br> preceding = above[0][1]["text"] if above else ""<br> following = below[0][1]["text"] if below else ""<br> return {<br> "preceding_text": preceding,<br> "following_text": following,<br> "inside_texts": [t["text"] for t in inside_t],<br> }<br>
---
## 6) tools/pipeline.py (End-to-End)
python<br># -*- coding: utf-8 -*-<br>import os<br>import json<br>from typing import Dict, Any, List, Optional<br><br>from ingest_gamma_export import unpack_if_zip, find_pptx, find_slide_pngs, normalize_slide_pngs<br>from pptx_text_extract import extract_text_boxes<br>from gamma_cards import run_on_slide<br>from anchor_map import find_anchors<br><br>def _ensure_dir(p: str) -> str:<br> os.makedirs(p, exist_ok=True)<br> return p<br><br>def run_pipeline(gamma_export: str, project_dir: str, max_clusters_per_slide: int = 3) -> Dict[str, Any]:<br> project_dir = os.path.abspath(project_dir)<br> out_dir = _ensure_dir(os.path.join(project_dir, "output"))<br> slides_dir = _ensure_dir(os.path.join(out_dir, "slides"))<br> crops_dir = _ensure_dir(os.path.join(out_dir, "crops"))<br> overlay_dir = _ensure_dir(os.path.join(out_dir, "debug_overlay"))<br><br> work_dir = _ensure_dir(os.path.join(out_dir, "_work"))<br> root = unpack_if_zip(gamma_export, work_dir)<br><br> pptx = find_pptx(root)<br> if not pptx:<br> raise RuntimeError("No PPTX found in Gamma export.")<br><br> slide_pngs = find_slide_pngs(root)<br> if not slide_pngs:<br> raise RuntimeError("No slide PNGs found in Gamma export. Export PNG from Gamma or add a renderer fallback.")<br> normed_pngs = normalize_slide_pngs(slide_pngs, slides_dir)<br><br> # extract text boxes<br> pptx_data = extract_text_boxes(pptx)<br> slide_text_map = {s["slide"]: s for s in pptx_data["slides"]}<br><br> all_slide_clusters = []<br> hints_by_slide = {} # slide_index -> hints[]<br><br> for i, png in enumerate(normed_pngs, start=1):<br> res = run_on_slide(png_path=png, out_crops_dir=crops_dir, out_overlay_dir=overlay_dir, max_clusters=max_clusters_per_slide)<br> slide_idx = i<br> tb = (slide_text_map.get(slide_idx) or {}).get("text_boxes", [])<br> hints = []<br> for c in res["clusters"]:<br> bb = c["rel_bbox"]<br> anchors = find_anchors(bb, tb)<br> hints.append({<br> "kind": "infographic",<br> "rel_bbox": bb,<br> "image": os.path.relpath(c["crop"], project_dir).replace("\\", "/"),<br> "fit": "contain",<br> "anchor": (0.5, 0.5),<br> "min_dpi": 240,<br> "meta": {<br> "score": c.get("score", 0.0),<br> "anchors": anchors,<br> "slide": slide_idx,<br> }<br> })<br> hints_by_slide[slide_idx] = hints<br> all_slide_clusters.append(res)<br><br> # write outputs<br> with open(os.path.join(out_dir, "pptx_text_boxes.json"), "w", encoding="utf-8") as f:<br> json.dump(pptx_data, f, ensure_ascii=False, indent=2)<br><br> with open(os.path.join(out_dir, "slide_clusters.json"), "w", encoding="utf-8") as f:<br> json.dump(all_slide_clusters, f, ensure_ascii=False, indent=2)<br><br> with open(os.path.join(out_dir, "hints_by_slide.json"), "w", encoding="utf-8") as f:<br> json.dump(hints_by_slide, f, ensure_ascii=False, indent=2)<br><br> return {<br> "project_dir": project_dir,<br> "pptx": pptx,<br> "slides": normed_pngs,<br> "out_dir": out_dir,<br> "hints_by_slide": os.path.join(out_dir, "hints_by_slide.json"),<br> }<br><br>def main():<br> import argparse<br> ap = argparse.ArgumentParser()<br> ap.add_argument("--gamma_export", required=True, help="Gamma export zip or folder")<br> ap.add_argument("--project", required=True, help="Project dir (creates output/)")<br> ap.add_argument("--max_clusters", type=int, default=3)<br> args = ap.parse_args()<br><br> res = run_pipeline(args.gamma_export, args.project, max_clusters_per_slide=args.max_clusters)<br> print("OK:", res)<br><br>if __name__ == "__main__":<br> main()<br>
---
## 7) tools/json_patch_pptx.py (kompatibel zu deinem Scribus-Engine-Format)
python<br># -*- coding: utf-8 -*-<br>import os<br>import json<br>from typing import Dict, Any, List<br><br>def load_json(p: str):<br> with open(p, "r", encoding="utf-8") as f:<br> return json.load(f)<br><br>def save_json(p: str, obj: Any):<br> os.makedirs(os.path.dirname(p), exist_ok=True)<br> with open(p, "w", encoding="utf-8") as f:<br> json.dump(obj, f, ensure_ascii=False, indent=2)<br><br>def patch_pptx_slides(pptx_json_path: str, hints_by_slide_path: str, project_dir: str, min_existing: int = 1) -> Dict[str, Any]:<br> pptx = load_json(pptx_json_path)<br> hints_by_slide = load_json(hints_by_slide_path)<br><br> for slide in pptx.get("slides", []):<br> si = int(slide.get("slide", 0) or 0)<br> # only patch if too few image_boxes<br> img_boxes = slide.get("image_boxes", []) or []<br> if len(img_boxes) >= min_existing:<br> continue<br><br> hints = hints_by_slide.get(str(si)) or hints_by_slide.get(si) or []<br> for h in hints:<br> if h.get("kind") != "infographic":<br> continue<br> slide.setdefault("image_boxes", []).append({<br> "rel_bbox": h.get("rel_bbox"),<br> "image": h.get("image"), # relative to project_dir<br> })<br><br> return pptx<br><br>def main():<br> import argparse<br> ap = argparse.ArgumentParser()<br> ap.add_argument("--pptx_json", required=True, help="Your pptx parsed JSON (slides[].image_boxes/text_boxes/texts)")<br> ap.add_argument("--hints_by_slide", required=True, help="output/hints_by_slide.json from pipeline")<br> ap.add_argument("--project", required=True, help="Project dir base")<br> ap.add_argument("--out", required=True, help="Output patched JSON path")<br> ap.add_argument("--min_existing", type=int, default=1)<br> args = ap.parse_args()<br><br> patched = patch_pptx_slides(args.pptx_json, args.hints_by_slide, args.project, min_existing=args.min_existing)<br> save_json(args.out, patched)<br> print("Wrote", args.out)<br><br>if __name__ == "__main__":<br> main()<br>
---
## 8) scribus_extension/setzerei_gamma_bridge.py (Scribus-Script, ruft externes Python)
python<br># -*- coding: utf-8 -*-<br>import os<br>import sys<br>import subprocess<br><br>try:<br> import scribus<br>except Exception:<br> scribus = None<br><br>def _msg(t, s):<br> if scribus:<br> try:<br> scribus.messageBox(t, s)<br> return<br> except Exception:<br> pass<br> print(f"[{t}] {s}")<br><br>def run():<br> if scribus and not scribus.haveDoc():<br> _msg("Setzerei Gamma Bridge", "Bitte zuerst das Scribus-Template (.sla) öffnen.")<br> return<br><br> gamma_export = os.environ.get("ZC_GAMMA_EXPORT_DIR", "").strip()<br> project_dir = os.environ.get("ZC_PROJECT_DIR", "").strip()<br> venv_py = os.environ.get("ZC_VENV_PY", "").strip()<br><br> if not gamma_export or not os.path.exists(gamma_export):<br> _msg("Setzerei Gamma Bridge", "ENV ZC_GAMMA_EXPORT_DIR fehlt oder ungültig.\n"<br> "Setze es auf Gamma-Export ZIP oder Ordner.")<br> return<br> if not project_dir:<br> _msg("Setzerei Gamma Bridge", "ENV ZC_PROJECT_DIR fehlt. Setze Projektordner.")<br> return<br> if not venv_py or not os.path.isfile(venv_py):<br> _msg("Setzerei Gamma Bridge", "ENV ZC_VENV_PY fehlt/ungültig.\n"<br> "Setze auf venv python.exe (mit opencv+pillow).")<br> return<br><br> tool_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "tools"))<br> pipeline_py = os.path.join(tool_dir, "pipeline.py")<br><br> cmd = [venv_py, pipeline_py, "--gamma_export", gamma_export, "--project", project_dir, "--max_clusters", "3"]<br> _msg("Setzerei Gamma Bridge", "Starte Pipeline:\n" + " ".join(cmd))<br> try:<br> subprocess.check_call(cmd, cwd=tool_dir)<br> except Exception as e:<br> _msg("Setzerei Gamma Bridge", f"Pipeline Fehler: {e}")<br> return<br><br> _msg("Setzerei Gamma Bridge", "Pipeline OK.\n\nJetzt:\n"<br> "1) Stelle sicher, dass dein pptx_json ggf. gepatcht ist (json_patch_pptx.py).\n"<br> "2) Starte anschließend dein setzerei_engine.py wie gewohnt (Script-Menü).")<br><br>if __name__ == "__main__":<br> run()<br>
---
## 9) scribus_extension/PATCHES.md (minimaler Patch-Plan für dein bestehendes
setzerei_engine.py)md<br># PATCHES – Setzerei Engine (minimal)<br><br>## Ziel<br>Wenn Gamma-PPTX zu wenig `image_boxes` liefert, sollen automatisch Card-Crops aus `output/hints_by_slide.json` als Infografiken verwendet werden.<br><br>## Minimal-Patch in `build_entries_from_pptx()`<br><br>1) Lade einmal am Anfang (optional) `output/hints_by_slide.json`:<br>- Pfad: `${PROJECT_DIR}/output/hints_by_slide.json` oder relativ zu PPTX_DIR/../output/<br><br>2) Pro Slide:<br>- Wenn `slide.get("image_boxes",[])` leer oder sehr klein:<br> - Hänge `image_boxes` an aus hints_by_slide[slide_idx]<br> - Map: hint["image"] -> ib["image"], hint["rel_bbox"] -> ib["rel_bbox"]<br><br>3) Optional:<br>- Setze `forced=True` und `tag_meta.region` = "body"/"sidebar" je nach Breite.<br><br>Dadurch nutzt dein vorhandener Codepfad (merge_image_boxes, heuristics, packer, run_image_jobs) automatisch die neuen Crops.<br>
---
## 10) README.mdmd<br># Gamma → Card-Crops → Scribus (Setzerei Erweiterung)<br><br>## Ziel<br>Gamma-Exports sind oft "zerstückelt" (viele Shapes). Statt hunderter Einzelobjekte erzeugen wir semantische Einheiten:<br>- Slide-PNG (Rendering-Wahrheit)<br>- PPTX (Text/Position-Wahrheit)<br>- Cluster & Crop (Card-Boxen erkennen, gruppieren, als PNG ausschneiden)<br>- Anchors: Text davor/dahinter + optional inside texts<br><br>Am Ende nutzt Scribus diese Crops als Infografiken im Magazinlayout.<br><br>---<br><br>## Installation (Windows)<br>bash
cd gammascribuspack
py -m venv .venv
..venv\Scripts\python.exe -m pip install -r requirements.txt<br><br>## Gamma Export<br>In Gamma:<br>- Export: PPTX<br>- Export: PNGs (Slides) – idealerweise in derselben Export-ZIP oder im gleichen Ordner.<br><br>## Pipeline Run (standalone)<br>bash
..venv\Scripts\python.exe tools\pipeline.py --gammaexport "C:\path\gammaexport.zip" --project "C:\path\myproject" --maxclusters 3<br><br>Outputs:<br>- my_project/output/slides/slide_0001.png ...<br>- my_project/output/crops/slide_0001_cluster_01.png ...<br>- my_project/output/hints_by_slide.json<br>- my_project/output/debug_overlay/... (QC Overlay)<br><br>## Scribus Integration<br>1) Kopiere `scribus_extension/setzerei_gamma_bridge.py` in deinen Scribus Scripts-Ordner.<br>2) Setze ENV Variablen:<br>- ZC_GAMMA_EXPORT_DIR = Gamma Export ZIP/Ordner<br>- ZC_PROJECT_DIR = Projektordner (wo output/ landet)<br>- ZC_VENV_PY = Pfad zur .venv python.exe<br><br>Beispiel (Windows PowerShell):<br>powershell
$env:ZCGAMMAEXPORTDIR="C:\exports\gamma.zip"
$env:ZCPROJECTDIR="C:\projects\zeitcode"
$env:ZCVENVPY="C:\projects\gammascribus_pack.venv\Scripts\python.exe"<br><br>3) Scribus öffnen, Template laden<br>4) Script ausführen: `setzerei_gamma_bridge.py`<br>5) Danach dein `setzerei_engine.py` ausführen (wie bisher).<br><br>## Qualität / QC<br>- Schau in `output/debug_overlay/`:<br> - Gelb: Kandidaten<br> - Grün: Cluster-BBox (was geschnitten wird)<br>- Wenn zu viel/zu wenig:<br> - tools/gamma_cards.py Parameter: min_rel_area, tol_* , max_clusters<br><br>## Warum Text nicht schwammig wird<br>- Wir croppen aus hochqualitativen Slide-PNGs.<br>- Für textlastige Karten kannst du optional in Scribus rekonstruktiv zeichnen (Shapes + Text) – als next step.<br>
---
# Kurz: Was du damit sofort bekommst
- No-hands-on Rendering (weil Gamma-PNGs schon rendern).
- Grid-/Stack-Clustering für nebeneinander/gestapelte Cards.
- Crops + Anchors (davor/dahinter) als JSON-Metadaten.
- Scribus-Bridge als Erweiterung (Script), die alles anstößt. ….
Wie gesagt: bei Interesse sende ich gerne den Code für die parsen-engine und setzerei-engine.
Entwicklung • AI unterstütztes Magazin Layouten und Inhaltsgenerierung - Die Zukunft beginnt hier
[ Translate ]
