Files
docu_build/lib/pdf_extract.py
T
Michael StanglandClaude Opus 4.8 bbfe17ec37 feat: Symbole zentralisieren + Fragment-Ueberschriften filtern
Perceptual-Hash-basierte Symbol-/Duplikaterkennung in lib/pdf_extract.py:

- average_hash() (aHash) + Hamming-Distanz statt exaktem Byte-Hash: erkennt
  visuell gleiche Bilder trotz unterschiedlicher Skalierung/Kompression
- Symbole (klein <=260px UND mehrfach vorkommend, z.B. Warndreiecke) werden
  EINMAL zentral nach assets/shared/symbols/symbol_<hash>.png abgelegt und im
  RST als .. image:: (12mm, zentriert) auf die eine Quelle referenziert -
  dokumentuebergreifend (registry). Ein Warndreieck: 1x gespeichert statt 58x
- grossformatige Inhaltsbild-Duplikate ebenfalls ueber aHash zusammengefuehrt
- Fragment-"Ueberschriften" (POS, li., re., "bar, max. 10 bar") unterdrueckt
- Altbestand assets/shared/symbols/img_*.png (durcheinander benannt) entfernt

Wirkung: omniflo images/ 83 -> 62, 14 zentrale Symbole statt zig Duplikate.
Voller PDF-Build sauber (lualatex exit 0, 719 Seiten).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-23 14:10:33 +02:00

437 lines
17 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 = 260 # Bilder <= dieser Kantenlaenge gelten als Symbol-Kandidat
SYMBOL_MIN_COUNT = 2 # ... wenn dasselbe Bild mind. so oft vorkommt
AHASH_THRESHOLD = 6 # max. Hamming-Distanz, ab der zwei aHashes als gleich gelten
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")
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 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) -> tuple[int, int]:
"""Extrahiert Text + Bilder eines Anleitungs-PDF in eine .rst-Datei.
Symbole/Icons (kleine, mehrfach vorkommende Bilder wie Warndreiecke) werden
NICHT pro Vorkommen dupliziert, sondern einmal zentral nach symbols_dir
(assets/shared/symbols) als symbol_<hash>.png abgelegt und im RST als
`.. image::` mit relativem Pfad referenziert. symbol_registry (dict
visual_hash -> relativer Pfad) wird dokumentuebergreifend wiederverwendet.
Inhaltsbild-Duplikate (gleicher visueller Hash) innerhalb des Dokuments
werden ebenfalls nur einmal abgelegt und mehrfach referenziert.
Returns:
(anzahl_textbloecke, anzahl_bilder)
"""
import fitz
if symbol_registry is None:
symbol_registry = {}
doc = fitz.open(str(pdf_path))
img_dir.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}]
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
ah = average_hash(data)
ci = _find_cluster(ah)
if ci < 0:
clusters.append({"ahash": ah, "data": data,
"ext": base.get("ext", "png"),
"w": w, "h": h, "count": 1})
ci = len(clusters) - 1
else:
clusters[ci]["count"] += 1
elements.append({
"kind": "image", "y": b[1], "page": pn + 1, "cluster": ci,
})
doc.close()
# --- Pass 2: entscheiden, wohin jedes Bild-Cluster geschrieben wird ---
# Symbol: klein UND mehrfach vorkommend -> zentral assets/shared/symbols.
# Sonst (inkl. mehrfach genutzte Inhaltsbilder): einmal in Kapitel-images.
if symbols_dir is None:
symbols_dir = img_dir.parent.parent.parent / "assets" / "shared" / "symbols"
img_dir.mkdir(parents=True, exist_ok=True)
# relativer Pfad-Praefix vom RST (liegt in img_dir.parent) zu symbols_dir
try:
sym_rel_prefix = Path(
__import__("os").path.relpath(symbols_dir, img_dir.parent)
).as_posix()
except ValueError:
sym_rel_prefix = symbols_dir.as_posix()
cluster_target: list[str] = [""] * len(clusters) # ci -> RST-Bildpfad
cluster_is_symbol: list[bool] = [False] * len(clusters)
local_counter = 0
for ci, c in enumerate(clusters):
is_symbol = (max(c["w"], c["h"]) <= SYMBOL_MAX_EDGE
and c["count"] >= SYMBOL_MIN_COUNT)
cluster_is_symbol[ci] = is_symbol
if is_symbol:
# Dokumentuebergreifende Registry per aHash-Naehe durchsuchen.
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)
match = f"symbol_{c['ahash'] & 0xffffffff:08x}.{c['ext']}"
(symbols_dir / match).write_bytes(c["data"])
symbol_registry[c["ahash"]] = match
cluster_target[ci] = f"{sym_rel_prefix}/{match}"
else:
local_counter += 1
fname = f"img_{local_counter:03d}.{c['ext']}"
(img_dir / fname).write_bytes(c["data"])
cluster_target[ci] = f"images/{fname}"
# Bild-Elemente mit Zielpfad versehen
for e in elements:
if e["kind"] == "image":
e["path"] = cluster_target[e["cluster"]]
e["is_symbol"] = cluster_is_symbol[e["cluster"]]
img_counter = local_counter
# --- 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
m = RE_NUM_ONLY.match(e["text"])
if (m and nxt and nxt["kind"] == "text"
and re.match(r"[A-Za-zÄÖÜäöüß]", 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
# --- 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 flush_para() -> None:
if para:
out.append(" ".join(para).strip())
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: kleine, zentrierte Grafik ohne Caption-Box,
# verweist auf die eine zentrale Quelle in assets/shared/symbols.
out.append("")
out.append(f".. image:: {path}")
out.append(" :align: center")
out.append(" :height: 12mm")
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"]
hm = RE_HEADING.match(text)
# Ueberschrift NUR mit fuehrender PDF-Gliederungsnummer akzeptieren und
# offensichtliche Fragmente/Bildlegenden ausschliessen (sonst werden
# Zellen wie "POS", "li.", "re.", "bar, max. 10 bar" zu Ueberschriften).
is_heading = False
if hm and len(hm.group(2)) < 120:
num, rest = hm.group(1), hm.group(2)
rest_stripped = rest.strip()
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 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