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>
804 lines
32 KiB
Python
804 lines
32 KiB
Python
"""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
|