Files
docu_build/lib/pdf_extract.py
T
Michael StanglandClaude Sonnet 5 982879d9f0 fix: Ueberschriften-Erkennung, Bild-Referenzierung und Symbol-Qualitaet
Behebt mehrere Extraktionsprobleme in lib/pdf_extract.py, die zu doppelter
Nummerierung, sinnentleertem Text und falsch dimensionierten Symbolen fuehrten:

- Ueberschriften nur noch bei grosser/fetter Quellschrift (>= 12.5pt oder bold)
  statt bei jeder Zeile mit Nummern-Praefix. Vorher wurden hunderte normale
  Textzeilen (Querverweise, Aufzaehlungen) faelschlich zu Ueberschriften.
- Bildunterschriften ("Abbildung N: ...") werden nie mehr als Ueberschrift
  gerendert (Caption-Erkennung vor Heading-Check, Merge-Regel verschaerft),
  sondern als kursiver Absatz bzw. figure-Caption.
- Symbole (Warn-/Gebots-/Verbotspiktogramme) werden per Piktogramm-Heuristik
  (Signalfarben + wenige Flaechenfarben) erkannt statt nur ueber Haeufigkeit -
  findet auch Symbole, die im Dokument nur 1x vorkommen (z.B. Warndreiecke).
  Auf quadratische Canvas normalisiert (normalize_symbol) und einheitlich
  klein referenziert (:height: 10mm, :align: left), damit alle Symbole
  unabhaengig vom Seitenverhaeltnis der Quelle gleich gross erscheinen.
- Junk-Erkennung erweitert: gerenderte Kapitelnummern-Bilder, Barcodes und
  Foto-Ausschnitte werden nicht mehr als Symbol/Inhaltsbild extrahiert.
- Manuelle SYMBOL_BLOCKLIST (aHash-basiert) fuer sichtgepruefte Fehlklassi-
  fikationen (Foto-/3D-/Diagramm-Fragmente), damit sie bei erneuter
  Konvertierung nicht wieder auftauchen.
- Verzeichnislisten (Lieferantendoku etc.) werden im LaTeX-Build quer
  (landscape via pdflscape) gesetzt, da die langen Quellpfade in Hochformat
  nicht passten.

assets/shared/symbols/: auf normalisierte .png umgestellt, 7 Fehlklassi-
fikationen (Fotos/3D-Renderings/Diagramme statt Piktogramme) entfernt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:23:29 +02:00

804 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 -> <Stichwort>_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