"""Positionsbasierte PDF-Extraktion fuer die Fortna-Hauptanleitungen. Erzeugt aus einem Word-generierten PDF eine .rst-Datei, in der Text und Bilder in der richtigen Lesereihenfolge (nach Y-Position pro Seite) verwoben sind. Verbesserungen gegenueber extract_products.extract_text_to_rst: 1. Ueberschriften: Die im PDF vorhandene Nummerierung (z.B. "1.7 Wichtige Informationen") wird NUR zur Bestimmung der Gliederungstiefe ausgewertet, im RST-Ausgabetext aber ENTFERNT. Sphinx/LaTeX nummeriert selbst - dadurch keine doppelte Nummerierung (alt 1.7 + neu 3.2.1) mehr. 2. Bilder: werden positionsbasiert (bbox) pro Seite eingelesen, dedupliziert und an der korrekten Textstelle als `.. figure::`-Direktive referenziert. Bildunterschriften ("Abbildung N: ...") werden als figure-caption uebernommen. 3. Kopf-/Fusszeilen und Inhaltsverzeichnis-Seiten werden herausgefiltert. Oeffentliche Funktion: extract_manual(pdf_path, rst_path, img_dir) -> (blocks, images) """ from __future__ import annotations import hashlib import io import logging import re from pathlib import Path log = logging.getLogger(__name__) # Schwellwerte fuer die Symbol-/Icon-Erkennung SYMBOL_MAX_EDGE = 340 # Bilder <= dieser Kantenlaenge gelten als Symbol-Kandidat # (Sicherheitspiktogramme sind oft ~300 px) SYMBOL_MIN_COUNT = 2 # ... wenn dasselbe Bild mind. so oft vorkommt AHASH_THRESHOLD = 6 # max. Hamming-Distanz, ab der zwei aHashes als gleich gelten # Manuelle Blockliste: untere 32 Bit des aHash von Bildern, die faelschlich als # Symbol erkannt wurden (Fotos/3D-Renderings/Diagramm-Fragmente, KEINE echten # Sicherheitspiktogramme). Diese werden nie als Symbol behandelt und nicht in # assets/shared/symbols abgelegt/referenziert. (Kuratiert nach Sichtpruefung.) SYMBOL_BLOCKLIST = { 0x1d0580bf, 0x71c70f3f, 0x808081ff, 0x818183ff, 0x81f1f3f3, 0xc3c1f7ff, 0xdbc3e7e7, } def average_hash(image_bytes: bytes, size: int = 8) -> int: """Perceptual Average-Hash (aHash) eines Bildes als Integer. Graustufen -> size x size -> Bit je Pixel (>= Mittelwert). Zwei visuell aehnliche Bilder (auch bei leicht anderer Skalierung/Kompression, wie mehrfach eingebettete Warnsymbole aus Word) haben eine kleine Hamming- Distanz. Robuster als ein exakter Byte-/Pixel-Hash. """ from PIL import Image try: im = Image.open(io.BytesIO(image_bytes)).convert("L").resize((size, size)) px = list(im.getdata()) avg = sum(px) / len(px) bits = 0 for i, p in enumerate(px): if p >= avg: bits |= (1 << i) return bits except Exception: # Fallback: Byte-Hash als Integer, wenn PIL das Bild nicht oeffnen kann return int(hashlib.md5(image_bytes).hexdigest()[:16], 16) def hamming(a: int, b: int) -> int: """Hamming-Distanz zweier Integer-Hashes (Anzahl unterschiedlicher Bits).""" return bin(a ^ b).count("1") def normalize_symbol(image_bytes: bytes, canvas: int = 200) -> bytes: """Zentriert ein Symbol auf einer quadratischen weissen Canvas. So erscheinen alle Warn-/Gebots-/Verbotssymbole - unabhaengig vom Seiten- verhaeltnis und der Aufloesung der Quelle - bei gleicher RST-Hoehe gleich gross. Gibt PNG-Bytes zurueck. """ from PIL import Image try: im = Image.open(io.BytesIO(image_bytes)).convert("RGB") w, h = im.size scale = (canvas - 16) / max(w, h) # 8 px Rand ringsum new = im.resize((max(1, int(w * scale)), max(1, int(h * scale)))) bg = Image.new("RGB", (canvas, canvas), (255, 255, 255)) bg.paste(new, ((canvas - new.width) // 2, (canvas - new.height) // 2)) buf = io.BytesIO() bg.save(buf, format="PNG") return buf.getvalue() except Exception: return image_bytes # --- Bild-Klassifizierung ------------------------------------------------- SKETCH_MIN_BYTES = 10_000 # SW-Grafik groesser als ~10 KB gilt als Sketch def white_ratio(image_bytes: bytes) -> float: """Anteil (nahezu) weisser Pixel (Helligkeit > 230) im Bild [0..1].""" from PIL import Image try: im = Image.open(io.BytesIO(image_bytes)).convert("L") data = list(im.getdata()) if not data: return 0.0 step = max(1, len(data) // 400) sample = data[::step] return sum(1 for p in sample if p > 230) / len(sample) except Exception: return 0.0 def _dominant_color_count(im, thresh: float = 0.02) -> int: """Anzahl 'dominanter' Farben (>= thresh Anteil) in grober 16er-Quantisierung. Piktogramme haben wenige Flaechenfarben (<= ~7), Fotos/Barcodes viele (>= 8). """ from collections import Counter small = im.resize((48, 48)) c: Counter = Counter((r // 48, g // 48, b // 48) for r, g, b in small.getdata()) total = sum(c.values()) or 1 return sum(1 for v in c.values() if v / total >= thresh) def _few_colors(image_bytes: bytes, max_colors: int = 7) -> bool: """True, wenn das Bild wenige dominante Flaechenfarben hat (Piktogramm). Fotos und Barcodes haben viele Farbabstufungen und werden ausgeschlossen. """ from PIL import Image try: im = Image.open(io.BytesIO(image_bytes)).convert("RGB") return _dominant_color_count(im) <= max_colors except Exception: return False def looks_like_pictogram(image_bytes: bytes) -> bool: """True, wenn ein Bild wie ein Warn-/Gebots-/Verbotssymbol aussieht. Piktogramme (Warndreieck gelb, Gebot blau, Verbot rot/weiss) bestehen aus WENIGEN kraeftigen Flaechenfarben + viel Weiss/Schwarz. Kennzeichen: - hoher Anteil an Signalfarben (gelb/orange, kraeftiges blau, rot) UND - wenige dominante Farben (<= 7) -> schliesst Fotos & Barcodes aus, die zwar blaue/farbige Anteile haben, aber viele Farbabstufungen. """ from PIL import Image try: im = Image.open(io.BytesIO(image_bytes)).convert("RGB") data = list(im.getdata()) if not data: return False # Foto/Barcode -> kein Piktogramm. Grenze 9 (nicht 7): JPEG-komprimierte # Verbots-/Gebotssymbole haben durch Randartefakte etwas mehr Farben. if _dominant_color_count(im) > 9: return False step = max(1, len(data) // 500) sample = data[::step] n = len(sample) signal = 0 for r, g, b in sample: # gelb/orange: hohes R+G, niedriges B if r > 180 and g > 150 and b < 110: signal += 1 # kraeftiges blau (Gebotssymbol): b deutlich groesser als r elif b > 110 and b - r > 50 and r < 130: signal += 1 # rot (Verbotssymbol): r deutlich groesser als g und b elif r > 150 and r - g > 70 and r - b > 70: signal += 1 return signal / n > 0.12 except Exception: return False def is_junk_fragment(image_bytes: bytes, w: int, h: int) -> bool: """True fuer wertlose Text-/Nummern-Fragmente (kein echtes Symbol/Bild). Erkennt: a) flache (Hoehe <= 55 px), breite, schwarz-weisse Schnipsel (gerenderte Beschriftungen/Kapitelnummern wie "5.2.6"). b) kleine, schwarz-weisse Bilder mit sehr hohem Weissanteil und geringer Datenmenge - das sind gerenderte Kapitelnummern ("3.2.3", "3.2.4"), die als eigenstaendige Grafik im PDF liegen (viel Weissraum, duenne Ziffern). """ # a) flache breite SW-Schnipsel if h <= 55 and w / max(h, 1) >= 1.6 and is_grayscale(image_bytes): return True # b) kleine SW-Nummerngrafik: kleine Flaeche, SW, sehr wenig "Tinte" if (max(w, h) <= 200 and len(image_bytes) < 4000 and is_grayscale(image_bytes) and white_ratio(image_bytes) > 0.80): return True return False def is_grayscale(image_bytes: bytes) -> bool: """True, wenn das Bild (nahezu) schwarz-weiss/graustufig ist.""" from PIL import Image try: rgb = Image.open(io.BytesIO(image_bytes)).convert("RGB") data = list(rgb.getdata()) step = max(1, len(data) // 200) for r, g, b in data[::step]: if abs(r - g) > 12 or abs(g - b) > 12: return False return True except Exception: return False def classify_image(image_bytes: bytes, ext: str, w: int, h: int) -> str: """Klassifiziert ein Bild: 'photo' | 'sketch' | 'graphic'. - photo: JPEG/JPG (Fotos sind meist projektspezifisch) - sketch: schwarz-weisse Grafik groesser als ~10 KB (Skizze/Zeichnung) - graphic: alles uebrige (farbige PNG-Grafik, Diagramm) """ if ext.lower() in ("jpg", "jpeg"): return "photo" if len(image_bytes) > SKETCH_MIN_BYTES and is_grayscale(image_bytes): return "sketch" return "graphic" # Logo-Erkennung: grosse, breite Grafik (Schriftzug-Logo) auf oberen/unteren # Seitenbereichen. Wir erkennen sie ueber Seitenposition + Seitenverhaeltnis, # da OCR des Schriftzugs zu aufwendig waere. def looks_like_logo(w: int, h: int, y_top: float, page_height: float) -> bool: """Heuristik: breites Bild (ratio > 2.2) im Kopf-/Fussbereich der Seite.""" if h == 0: return False ratio = w / h in_margin = y_top < page_height * 0.18 or y_top > page_height * 0.82 return ratio > 2.2 and in_margin and w >= 200 RST_H = {1: "=", 2: "-", 3: "~", 4: "^", 5: '"'} # Symbolfont-Zeichen (Private Use Area) auf Unicode abbilden bzw. entfernen. PUA_MAP = { "": "•", "": "•", "": "•", "": "→", "": "→", "": "←", "": "✓", "": " ", } _PUA_RE = re.compile("|".join(re.escape(k) for k in PUA_MAP)) _PUA_REST_RE = re.compile(r"[-]") # Rausch-Muster (Kopf/Fuss/Metadaten), die nicht in den Text gehoeren. _NOISE = { "services cz s.r.o", "schönenberger systeme gmbh", "justus-von-liebig-str", "info@schoenenberger.de", "www.schoenenberger.de", "www.schoenenberger-systems.com", "karolinska 661/4", } RE_DATE = re.compile(r"^\d{2}\.\d{2}\.\d{4}$") RE_PAGENO = re.compile(r"^(seite|page)\s+\d+$", re.I) RE_PHONE = re.compile(r"^[TF]:\s*\+?\d") # Ueberschrift mit fuehrender Nummer: "1.7 Wichtige Informationen" # Nummernteile max 3-stellig (verhindert Datum/Artikelnummer als Ueberschrift). RE_HEADING = re.compile(r"^(\d{1,3}(?:\.\d{1,3}){0,4})\s+([A-Za-zÄÖÜäöüß].{2,})$") # reine Nummernzeile (Nummer und Titel stehen im PDF auf getrennten Zeilen) RE_NUM_ONLY = re.compile(r"^(\d{1,3}(?:\.\d{1,3}){0,4})\.?$") # Bildunterschrift "Abbildung 17: ..." / "Abb. 17 ..." / "Figure 3: ..." RE_CAPTION = re.compile(r"^(Abbildung|Abb\.?|Bild|Figure|Tabelle)\s*\d+\s*[:.]?\s*(.*)$", re.I) def clean_pua(text: str) -> str: """Symbolfont-Zeichen ersetzen, restliche PUA-Zeichen entfernen.""" text = _PUA_RE.sub(lambda m: PUA_MAP[m.group(0)], text) return _PUA_REST_RE.sub("", text) def _is_noise(text: str) -> bool: low = text.lower().strip() if not low: return True if RE_DATE.match(low) or RE_PAGENO.match(low) or RE_PHONE.match(text): return True return any(kw in low for kw in _NOISE) def _strip_heading_number(num: str, rest: str) -> str: """Gibt den Ueberschriftentext ohne fuehrende Gliederungsnummer zurueck.""" return rest.strip() def caption_slug(caption_text: str) -> str: """Leitet ein kurzes Datei-Stichwort aus einer Bildunterschrift ab. "Abbildung 17: Weiche Typ A links" -> "Weiche_Typ_A" Nimmt die ersten Woerter nach der "Abbildung N:"-Kennung. """ m = RE_CAPTION.match(caption_text) body = (m.group(2) if m else caption_text).strip() # Umlaute/Sonderzeichen -> ASCII-nah, Wortgrenzen zu _ body = (body.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue") .replace("Ä", "Ae").replace("Ö", "Oe").replace("Ü", "Ue") .replace("ß", "ss")) words = re.findall(r"[A-Za-z0-9]+", body) if not words: return "" slug = "_".join(words[:3]) # max. 3 Woerter fuer kompakten Namen return slug[:40] def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path, title: str | None = None, symbols_dir: Path | None = None, symbol_registry: dict | None = None, logos_dir: Path | None = None, logo_registry: dict | None = None, image_dest: Path | None = None, photo_dest: Path | None = None) -> tuple[int, int]: """Extrahiert Text + Bilder eines Anleitungs-PDF in eine .rst-Datei. Bild-Routing (siehe doc/plan_fortna_extraktion.md): - Symbole (klein, mehrfach): einmal zentral -> symbols_dir (assets/shared/symbols), im RST als `.. image::` referenziert. - Logos (breite Schriftzug-Grafik im Seitenrand): pro visueller Variante alle Aufloesungen -> logos_dir (assets/shared/logos), NICHT inline referenziert (reine Deko). - Inhaltsbilder: nach image_dest (assets/... fuer generelle Kapitel bzw. projekte/fortna/images/... fuer projektspezifische). Fotos (.jpg) landen IMMER projektspezifisch; das steuert der Aufrufer ueber image_dest. Benennung: PNG mit direkt folgender Bildunterschrift -> _NN.png, Fotos -> img_NNN.jpg, SW-Skizzen (>10KB) -> sketch_NNN.png, Rest img_NNN.png. symbol_registry / logo_registry (aHash -> Datei) werden dokumentuebergreifend wiederverwendet. Returns: (anzahl_textbloecke, anzahl_bilder) """ import fitz if symbol_registry is None: symbol_registry = {} if logo_registry is None: logo_registry = {} # image_dest: Zielordner fuer Grafiken/Skizzen. photo_dest: fuer Fotos (.jpg, # immer projektspezifisch). Default beider: img_dir. if image_dest is None: image_dest = img_dir if photo_dest is None: photo_dest = image_dest doc = fitz.open(str(pdf_path)) image_dest.mkdir(parents=True, exist_ok=True) # --- Seiten identifizieren, die uebersprungen werden --- # a) TOC-Seiten (Punktfueller / "Inhaltsverzeichnis") # b) Aenderungshistorie-/Dokumentinfo-Tabellen ("Aenderungsinformation", # Spaltenkombination Version+Datum+Aenderungsbeschreibung). Diese Tabellen # zerfallen beim linearen Auslesen in sinnlose Einzelzellen. skip_pages: set[int] = set() for pn in range(len(doc)): t = doc[pn].get_text() low = t.lower() if t.count("....") >= 3 or "\ninhaltsverzeichnis\n" in low: skip_pages.add(pn) elif "änderungsinformation" in low or "dokumenteninformation" in low: skip_pages.add(pn) elif ("änderungsbeschreibung" in low and "version" in low and ("datum" in low or "kapitel" in low)): skip_pages.add(pn) # --- Pass 1: Text- und Bild-Elemente sammeln (Bilder nur puffern) --- # Bilder werden zunaechst nicht geschrieben, sondern per Perceptual-Hash # (aHash) einem Cluster zugeordnet. Visuell aehnliche Bilder (gleiches Symbol # in anderer Skalierung) landen im selben Cluster. Erst nach dem Scan steht # fest, welches Cluster mehrfach vorkommt (Symbol-Kriterium). elements: list[dict] = [] clusters: list[dict] = [] # [{ahash, data, ext, w, h, count, cls, margin_hits, variants}] def _find_cluster(ah: int) -> int: for ci, c in enumerate(clusters): if hamming(ah, c["ahash"]) <= AHASH_THRESHOLD: return ci return -1 for pn in range(len(doc)): if pn in skip_pages: continue page = doc[pn] ph = page.rect.height pw = page.rect.width # Textzeilen mit Position page_dict = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE) for block in page_dict.get("blocks", []): if block.get("type") != 0: continue for line in block.get("lines", []): bbox = line.get("bbox", [0, 0, 0, 0]) if bbox[1] < 70 or bbox[3] > ph - 45: # Kopf-/Fusszeile continue text = "" max_size = 0.0 bold = False for span in line.get("spans", []): st = clean_pua(span.get("text", "")) if st.strip(): text += st max_size = max(max_size, span.get("size", 0)) if span.get("flags", 0) & 2**4: # bold flag bold = True text = text.strip() if not text or _is_noise(text): continue elements.append({ "kind": "text", "y": bbox[1], "page": pn + 1, "text": text, "size": round(max_size, 1), "bold": bold, }) # Bilder mit Position. Titelseiten (erste 2 Seiten) = nur Logo/Deko. if pn < 2: continue for im in page.get_image_info(xrefs=True): xref = im.get("xref", 0) if not xref: continue b = im["bbox"] w, h = im.get("width", 0), im.get("height", 0) # Vollseiten-Deko / Hintergrundbilder ueberspringen if (b[2] - b[0]) > pw * 0.95 and (b[3] - b[1]) > ph * 0.95: continue if w < 40 or h < 40: # winzige Icons continue base = doc.extract_image(xref) if not base: continue data = base["image"] if len(data) < 3000: continue # Wertlose Text-/Nummern-Fragmente (flach, SW) verwerfen if is_junk_fragment(data, w, h): continue ext = base.get("ext", "png") ah = average_hash(data) in_margin = looks_like_logo(w, h, b[1], ph) ci = _find_cluster(ah) if ci < 0: clusters.append({"ahash": ah, "data": data, "ext": ext, "w": w, "h": h, "count": 1, "cls": classify_image(data, ext, w, h), "margin_hits": 1 if in_margin else 0, # groesste Auflvariante fuer Logo-Ablage merken "variants": [(w * h, w, h, data, ext)]}) ci = len(clusters) - 1 else: clusters[ci]["count"] += 1 if in_margin: clusters[ci]["margin_hits"] += 1 clusters[ci]["variants"].append((w * h, w, h, data, ext)) elements.append({ "kind": "image", "y": b[1], "page": pn + 1, "cluster": ci, }) doc.close() import os as _os # --- Zielverzeichnisse + relative Pfad-Praefixe (vom RST aus) --- rst_parent = rst_path.parent if symbols_dir is None: symbols_dir = rst_parent.parent.parent / "assets" / "shared" / "symbols" if logos_dir is None: logos_dir = symbols_dir.parent / "logos" def _rel(target: Path) -> str: try: return Path(_os.path.relpath(target, rst_parent)).as_posix() except ValueError: return target.as_posix() sym_rel_prefix = _rel(symbols_dir) dest_rel_prefix = _rel(image_dest) photo_rel_prefix = _rel(photo_dest) # --- Pass 2a: Cluster klassifizieren (Logo / Symbol / Inhaltsbild) --- # Logo: breit (ratio > 2.2), im Seitenrand UND wiederkehrend (>=3x) - das # unterscheidet ein Kopf-/Fusszeilen-Logo von einem einmaligen breiten # Diagramm. Alle Aufloesungen -> assets/shared/logos, NICHT inline. # Symbol: klein (<=260px) UND mehrfach (>=2x) -> assets/shared/symbols. cluster_is_symbol: list[bool] = [False] * len(clusters) cluster_is_logo: list[bool] = [False] * len(clusters) cluster_sympath: list[str] = [""] * len(clusters) for ci, c in enumerate(clusters): ratio = c["w"] / c["h"] if c["h"] else 0 is_logo = (ratio > 2.2 and c["margin_hits"] >= 3 and c["count"] >= 3 and c["w"] >= 200) if is_logo: cluster_is_logo[ci] = True # bereits bekannte Logo-Variante? -> nicht neu schreiben if any(hamming(c["ahash"], rah) <= AHASH_THRESHOLD + 2 for rah in logo_registry): continue logos_dir.mkdir(parents=True, exist_ok=True) idx = len(logo_registry) + 1 for (_area, w, h, data, ext) in sorted(set(c["variants"]), reverse=True): (logos_dir / f"logo_{idx:02d}_{w}px.{ext}").write_bytes(data) logo_registry[c["ahash"]] = idx continue # Symbol-Erkennung (Warn-/Gebots-/Verbotszeichen, Bedien-Piktogramme): # klein (<= 260px) UND quadratisch-aehnlich (ratio 0.55..1.8) UND # entweder Piktogramm-Form (Signalfarben) ODER (mehrfach + heller Grund). # Piktogramme werden so auch erkannt, wenn sie im Dokument nur 1x vorkommen # (Warndreiecke stehen oft einzeln), Foto-Ausschnitte dagegen nicht. # Manuell geblockte Fehlklassifikationen nie als Symbol behandeln. # Vergleich ueber die unteren 32 Bit des aHash (= Wert im Dateinamen); # bis zu 2 abweichende Bits werden toleriert (JPEG-/Skalierungsvarianz). low = c["ahash"] & 0xffffffff blocked = any(bin(low ^ b).count("1") <= 2 for b in SYMBOL_BLOCKLIST) small = max(c["w"], c["h"]) <= SYMBOL_MAX_EDGE squarish = 0.55 <= ratio <= 1.8 few_colors = _few_colors(c["data"], max_colors=9) # Piktogramm: wenige Farben is_symbol = (not blocked) and small and squarish and few_colors and ( looks_like_pictogram(c["data"]) or (c["count"] >= SYMBOL_MIN_COUNT and white_ratio(c["data"]) > 0.35) ) cluster_is_symbol[ci] = is_symbol if is_symbol: match = None for reg_ah, reg_file in symbol_registry.items(): if hamming(c["ahash"], reg_ah) <= AHASH_THRESHOLD: match = reg_file break if match is None: symbols_dir.mkdir(parents=True, exist_ok=True) # auf quadratische Canvas normalisieren -> immer als PNG ablegen match = f"symbol_{c['ahash'] & 0xffffffff:08x}.png" (symbols_dir / match).write_bytes(normalize_symbol(c["data"])) symbol_registry[c["ahash"]] = match cluster_sympath[ci] = f"{sym_rel_prefix}/{match}" # --- pro Seite nach Y-Position sortieren (Lesereihenfolge) --- elements.sort(key=lambda e: (e["page"], e["y"])) # --- Zeilen zusammenfuehren, die im PDF getrennt stehen --- # a) Nummer-only-Zeile ("2.3.1") + folgende Titelzeile # b) Bullet-only-Zeile ("•") + folgende Textzeile -> "• Text" merged: list[dict] = [] i = 0 while i < len(elements): e = elements[i] if e["kind"] == "text": nxt = elements[i + 1] if i + 1 < len(elements) else None # a) Gliederungsnummer + Titel. NUR mehrteilige Nummern (z.B. "2.3.1"), # nicht einzelne lose Ziffern (Bildlegenden-Nummern wie "2", "24"), # und NICHT, wenn die Folgezeile eine Bildunterschrift ist. m = RE_NUM_ONLY.match(e["text"]) if (m and "." in m.group(1) and nxt and nxt["kind"] == "text" and re.match(r"[A-Za-zÄÖÜäöüß]", nxt["text"]) and not RE_CAPTION.match(nxt["text"])): merged.append({**e, "text": f"{m.group(1)} {nxt['text']}", "size": max(e["size"], nxt["size"])}) i += 2 continue # b) einzelnes Bullet-Zeichen + Text der Folgezeile if e["text"].strip() in ("•", "-", "–", "*") and nxt and nxt["kind"] == "text": merged.append({**e, "text": f"• {nxt['text']}"}) i += 2 continue merged.append(e) i += 1 # --- Pass 2b: Inhaltsbilder schreiben (mit Caption-Benennung + Klasse) --- # Jetzt (nach dem Merge) ist die Folgezeile jedes Bildes bekannt -> Caption. # Pro Cluster genau einmal schreiben; Zielname aus Caption oder Klasse. # Fotos (.jpg) -> photo_dest (projektspezifisch), Rest -> image_dest. image_dest.mkdir(parents=True, exist_ok=True) photo_dest.mkdir(parents=True, exist_ok=True) counters = {"img": 0, "sketch": 0} used_names: set[str] = set() slug_counters: dict[str, int] = {} cluster_path: dict[int, str] = {} # ci -> RST-Pfad (relativ) def _next_name(cls: str, ext: str, slug: str) -> str: if slug: slug_counters[slug] = slug_counters.get(slug, 0) + 1 name = f"{slug}_{slug_counters[slug]:02d}.{ext}" elif cls == "sketch": counters["sketch"] += 1 name = f"sketch_{counters['sketch']:03d}.{ext}" else: # photo / graphic counters["img"] += 1 name = f"img_{counters['img']:03d}.{ext}" while name in used_names: counters["img"] += 1 name = f"img_{counters['img']:03d}.{ext}" used_names.add(name) return name n_m = len(merged) for idx, e in enumerate(merged): if e["kind"] != "image": continue ci = e["cluster"] if cluster_is_logo[ci]: e["path"] = "" # Logos werden nicht inline referenziert e["is_symbol"] = False continue if cluster_is_symbol[ci]: e["path"] = cluster_sympath[ci] e["is_symbol"] = True continue e["is_symbol"] = False if ci in cluster_path: # Duplikat -> bereits geschrieben e["path"] = cluster_path[ci] continue c = clusters[ci] # Caption der Folgezeile? -> Stichwort fuer Benennung slug = "" if idx + 1 < n_m and merged[idx + 1]["kind"] == "text": cm = RE_CAPTION.match(merged[idx + 1]["text"]) if cm: slug = caption_slug(merged[idx + 1]["text"]) name = _next_name(c["cls"], c["ext"], slug) # Fotos immer projektspezifisch (photo_dest), sonst image_dest if c["cls"] == "photo": (photo_dest / name).write_bytes(c["data"]) rel = f"{photo_rel_prefix}/{name}" else: (image_dest / name).write_bytes(c["data"]) rel = f"{dest_rel_prefix}/{name}" cluster_path[ci] = rel e["path"] = rel img_counter = len(cluster_path) # --- RST rendern --- if title is None: title = pdf_path.stem out: list[str] = [RST_H[1] * len(title), title, RST_H[1] * len(title), ""] # Absatz-Puffer: aufeinanderfolgende Fliesstextzeilen zu einem Absatz # zusammenfuehren (PDF-Zeilenumbrueche sind kein Absatzende). para: list[str] = [] def _join_para(lines: list[str]) -> str: """Fuegt Absatzzeilen zusammen und entfernt PDF-Silbentrennung. Endet eine Zeile auf "wort-" (Trennstrich nach Kleinbuchstabe), wird sie ohne Leerzeichen und ohne Bindestrich mit dem naechsten Wort verbunden ("Anlagenkompo-" + "nente" -> "Anlagenkomponente"). Ein Bindestrich nach Grossbuchstabe/Ziffer (Kompositum wie "ILS-", "24-") bleibt erhalten. """ result = "" for i, ln in enumerate(lines): ln = ln.strip() if not result: result = ln continue if re.search(r"[a-zäöüß]-$", result): result = result[:-1] + ln # Silbentrennung aufloesen else: result += " " + ln return result.strip() def flush_para() -> None: if para: out.append(_join_para(para)) out.append("") para.clear() RE_BULLET = re.compile(r"^\s*[•\-–*]\s+") blocks = 0 n = len(merged) for idx, e in enumerate(merged): if e["kind"] == "image": flush_para() path = e.get("path", "") if not path: continue if e.get("is_symbol"): # Symbol/Warnzeichen: einheitlich kleine Grafik, links ausgerichtet # (Piktogramm-Charakter, daneben steht der Warntext). Feste Hoehe UND # Breite begrenzen, damit alle Symbole - unabhaengig vom Seiten- # verhaeltnis der Quelle - gleich gross erscheinen. out.append("") out.append(f".. image:: {path}") out.append(" :align: left") out.append(" :height: 10mm") out.append(" :class: warnsymbol") out.append("") blocks += 1 continue caption = "" if idx + 1 < n and merged[idx + 1]["kind"] == "text": cm = RE_CAPTION.match(merged[idx + 1]["text"]) if cm: caption = merged[idx + 1]["text"] merged[idx + 1]["_consumed"] = True out.append("") out.append(f".. figure:: {path}") out.append(" :align: center") out.append(" :width: 80%") if caption: out.append("") out.append(f" {caption}") out.append("") blocks += 1 continue if e.get("_consumed"): continue text = e["text"] # Bildunterschriften ("Abbildung N: ...") sind NIE Ueberschriften. Wenn sie # nicht direkt als figure-Caption konsumiert wurden (z.B. weil Text # dazwischen stand), als eigenen (kursiven) Absatz ausgeben. if RE_CAPTION.match(text): flush_para() out.append(f"*{text}*") out.append("") blocks += 1 continue hm = RE_HEADING.match(text) # Ueberschrift NUR, wenn ALLE Kriterien erfuellt sind: # 1. fuehrende Gliederungsnummer ("N", "N.N", ...), # 2. Quell-Schrift ist gross (>= 12.5pt) ODER fett -> unterscheidet echte # Ueberschriften von normalen Textzeilen, die zufaellig mit einer Nummer # beginnen (Kapitelverweise "gemaess 2.8.2 ...", nummerierte Aufzaehlungen). # Das war die Hauptursache falscher Ueberschriften. # 3. kein offensichtliches Fragment/Bildlegende ("POS", "li.", "5 mm", ...). is_heading = False if hm and len(hm.group(2)) < 120: num, rest = hm.group(1), hm.group(2) rest_stripped = rest.strip() looks_like_title = e.get("size", 0) >= 12.5 or e.get("bold", False) is_frag = ( len(rest_stripped) < 4 # zu kurz ("POS", "li.") or rest_stripped.endswith((",",)) # abgeschnittene Zelle or re.match(r"^(mm|cm|m|kg|x|l|h|min|s|bar|POS|li|re|Pos)\b", rest_stripped, re.I) or re.match(r"^(li|re|oben|unten|vorne|hinten|links|rechts)\.?$", rest_stripped, re.I) ) if looks_like_title and not is_frag: is_heading = True if is_heading: flush_para() num, rest = hm.group(1), hm.group(2) heading = _strip_heading_number(num, rest) # NUMMER ENTFERNEN level = min(num.count(".") + 1, 5) char = RST_H[level] out.append("") if level == 1: out.append(char * max(len(heading), 3)) out.append(heading) out.append(char * max(len(heading), 3)) out.append("") blocks += 1 continue # Aufzaehlung: Zeile beginnt mit Bullet ODER enthaelt mehrere Bullets # (mehrere Listenpunkte sind im PDF oft in eine Zeile zusammengelaufen). if RE_BULLET.match(text) or text.count("•") >= 2: flush_para() # An Bullet-Zeichen aufsplitten, jeder Teil wird ein Listenpunkt. parts = re.split(r"\s*•\s*", text) for part in parts: item = RE_BULLET.sub("", part).strip().rstrip(",") if item: out.append(f"- {item}") out.append("") blocks += 1 continue # Fliesstext: an Absatz anhaengen. Endet die Zeile mit Satzzeichen, gilt # der Absatz als abgeschlossen und wird geflusht. para.append(text) if re.search(r"[.:!?]$", text): flush_para() blocks += 1 flush_para() rst = "\n".join(out) rst = re.sub(r"\n{3,}", "\n\n", rst).strip() + "\n" rst_path.parent.mkdir(parents=True, exist_ok=True) rst_path.write_text(rst, encoding="utf-8") return blocks, img_counter