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>
This commit is contained in:
Michael Stangl
2026-09-23 14:10:33 +02:00
co-authored by Claude Opus 4.8
parent c912b1e9d5
commit bbfe17ec37
31 changed files with 173 additions and 24 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+9
View File
@@ -50,6 +50,15 @@ sauberere `.rst` erzeugt:
vorhanden – der folgenden „Abbildung N: …"-Zeile als Caption eingebaut. (625 figure-Refs, vorhanden – der folgenden „Abbildung N: …"-Zeile als Caption eingebaut. (625 figure-Refs,
0 fehlende Bilddateien.) Duplikate werden per MD5 entfernt, Titelseiten-Deko und 0 fehlende Bilddateien.) Duplikate werden per MD5 entfernt, Titelseiten-Deko und
Vollseiten-Hintergründe herausgefiltert. Vollseiten-Hintergründe herausgefiltert.
- **Symbole/Warnzeichen zentral:** Kleine (≤ 260 px), mehrfach vorkommende Bilder (Warndreiecke,
Piktogramme) werden per Perceptual-Hash (aHash + Hamming-Distanz ≤ 6) erkannt, **einmal** zentral
nach `assets/shared/symbols/symbol_<hash>.png` abgelegt und im RST als `.. image::` (12 mm,
zentriert) auf die eine Quelle verwiesen – dokumentübergreifend. Vorher lag dasselbe Warndreieck
z. B. 8× als `img_003/018/020/025/026/031/035…` in einem einzigen Kapitel. Auch großformatige
Inhaltsbild-Duplikate werden über den aHash zusammengeführt (nur einmal abgelegt, mehrfach
referenziert). Wirkung: omniflo-`images/` von 83 → 62, Warndreieck 1× statt 58× gespeichert.
- **Fragment-„Überschriften" unterdrückt:** Bildlegenden/Tabellenzellen mit führender Nummer
(„POS", „li.", „re.", „bar, max. 10 bar") werden nicht mehr als Überschrift gesetzt.
- **Fließtext zu Absätzen zusammengeführt:** PDF-Zeilenumbrüche werden nicht mehr zu je einem - **Fließtext zu Absätzen zusammengeführt:** PDF-Zeilenumbrüche werden nicht mehr zu je einem
Absatz; zusammenhängende Zeilen bilden einen Absatz, Satzende beendet ihn. Absatz; zusammenhängende Zeilen bilden einen Absatz, Satzende beendet ihn.
- **Aufzählungen als RST-Listen:** einzeln stehende Bullet-Zeichen werden mit der Folgezeile - **Aufzählungen als RST-Listen:** einzeln stehende Bullet-Zeichen werden mit der Folgezeile
+15 -4
View File
@@ -87,7 +87,8 @@ CHAPTER_DOC_TITLE = {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def convert_manual(item: dict, source_root: Path, chapters_dir: Path, def convert_manual(item: dict, source_root: Path, chapters_dir: Path,
dry_run: bool) -> None: dry_run: bool, symbols_dir: Path | None = None,
symbol_registry: dict | None = None) -> None:
"""Konvertiert eine Hauptanleitung nach chapters/<kapitel>/.""" """Konvertiert eine Hauptanleitung nach chapters/<kapitel>/."""
src_pdf = source_root / item["rel_path"] src_pdf = source_root / item["rel_path"]
chapter_dir = chapters_dir / item["chapter"] chapter_dir = chapters_dir / item["chapter"]
@@ -118,8 +119,9 @@ def convert_manual(item: dict, source_root: Path, chapters_dir: Path,
# Text + Bilder positionsbasiert extrahieren (verwoben in Lesereihenfolge, # Text + Bilder positionsbasiert extrahieren (verwoben in Lesereihenfolge,
# PDF-Ueberschriftennummern entfernt, Bilder als .. figure:: referenziert). # PDF-Ueberschriftennummern entfernt, Bilder als .. figure:: referenziert).
doc_title = CHAPTER_DOC_TITLE.get(item["chapter"], slugify(item["filename"])) doc_title = CHAPTER_DOC_TITLE.get(item["chapter"], slugify(item["filename"]))
blocks, img_count = pdf_extract.extract_manual(src_pdf, rst_path, img_dir, blocks, img_count = pdf_extract.extract_manual(
title=doc_title) src_pdf, rst_path, img_dir, title=doc_title,
symbols_dir=symbols_dir, symbol_registry=symbol_registry)
log.info(" %d Textbloecke, %d Bilder (referenziert) -> %s", log.info(" %d Textbloecke, %d Bilder (referenziert) -> %s",
blocks, img_count, rst_path.name) blocks, img_count, rst_path.name)
@@ -268,8 +270,17 @@ def main() -> None:
if args.only in (None, "convert"): if args.only in (None, "convert"):
conv = [i for i in items if i["action"] == "convert"] conv = [i for i in items if i["action"] == "convert"]
log.info("--- %d Hauptanleitungen konvertieren ---", len(conv)) log.info("--- %d Hauptanleitungen konvertieren ---", len(conv))
# Symbole dokumentuebergreifend zentral in assets/shared/symbols/ ablegen
# und nur einmal referenzieren (Warndreiecke etc. tauchen in mehreren
# Anleitungen auf). Registry ueber alle Dokumente hinweg teilen.
symbols_dir = chapters_dir.parent / "assets" / "shared" / "symbols"
symbol_registry: dict = {}
for it in conv: for it in conv:
convert_manual(it, source_root, chapters_dir, dry_run) convert_manual(it, source_root, chapters_dir, dry_run,
symbols_dir=symbols_dir, symbol_registry=symbol_registry)
if not dry_run:
log.info("Symbole zentral: %d eindeutige -> %s",
len(symbol_registry), symbols_dir)
# 2. Verzeichnislisten # 2. Verzeichnislisten
if args.only in (None, "list"): if args.only in (None, "list"):
+149 -20
View File
@@ -22,12 +22,47 @@ Oeffentliche Funktion: extract_manual(pdf_path, rst_path, img_dir) -> (blocks, i
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import io
import logging import logging
import re import re
from pathlib import Path from pathlib import Path
log = logging.getLogger(__name__) 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: '"'} RST_H = {1: "=", 2: "-", 3: "~", 4: "^", 5: '"'}
# Symbolfont-Zeichen (Private Use Area) auf Unicode abbilden bzw. entfernen. # Symbolfont-Zeichen (Private Use Area) auf Unicode abbilden bzw. entfernen.
@@ -80,14 +115,28 @@ def _strip_heading_number(num: str, rest: str) -> str:
def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path, def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
title: str | None = None) -> tuple[int, int]: 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. """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: Returns:
(anzahl_textbloecke, anzahl_bilder) (anzahl_textbloecke, anzahl_bilder)
""" """
import fitz import fitz
if symbol_registry is None:
symbol_registry = {}
doc = fitz.open(str(pdf_path)) doc = fitz.open(str(pdf_path))
img_dir.mkdir(parents=True, exist_ok=True) img_dir.mkdir(parents=True, exist_ok=True)
@@ -108,11 +157,19 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
and ("datum" in low or "kapitel" in low)): and ("datum" in low or "kapitel" in low)):
skip_pages.add(pn) skip_pages.add(pn)
# --- Elemente (Text + Bild) pro Seite in Lesereihenfolge sammeln --- # --- Pass 1: Text- und Bild-Elemente sammeln (Bilder nur puffern) ---
# element: {"kind": "text"|"image", "y": float, "page": int, ...} # 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] = [] elements: list[dict] = []
seen_hashes: set[str] = set() clusters: list[dict] = [] # [{ahash, data, ext, w, h, count}]
img_counter = 0
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)): for pn in range(len(doc)):
if pn in skip_pages: if pn in skip_pages:
@@ -148,8 +205,7 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
"text": text, "size": round(max_size, 1), "bold": bold, "text": text, "size": round(max_size, 1), "bold": bold,
}) })
# Bilder mit Position. Titelseiten (erste 2 Seiten) enthalten nur # Bilder mit Position. Titelseiten (erste 2 Seiten) = nur Logo/Deko.
# Logo-/Deko-Bilder -> ueberspringen.
if pn < 2: if pn < 2:
continue continue
for im in page.get_image_info(xrefs=True): for im in page.get_image_info(xrefs=True):
@@ -161,7 +217,7 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
# Vollseiten-Deko / Hintergrundbilder ueberspringen # Vollseiten-Deko / Hintergrundbilder ueberspringen
if (b[2] - b[0]) > pw * 0.95 and (b[3] - b[1]) > ph * 0.95: if (b[2] - b[0]) > pw * 0.95 and (b[3] - b[1]) > ph * 0.95:
continue continue
if w < 40 or h < 40: # Icons if w < 40 or h < 40: # winzige Icons
continue continue
base = doc.extract_image(xref) base = doc.extract_image(xref)
if not base: if not base:
@@ -169,20 +225,69 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
data = base["image"] data = base["image"]
if len(data) < 3000: if len(data) < 3000:
continue continue
md5 = hashlib.md5(data).hexdigest() ah = average_hash(data)
if md5 in seen_hashes: ci = _find_cluster(ah)
continue # exaktes Duplikat (wiederkehrendes Logo/Symbol) if ci < 0:
seen_hashes.add(md5) clusters.append({"ahash": ah, "data": data,
img_counter += 1 "ext": base.get("ext", "png"),
ext = base.get("ext", "png") "w": w, "h": h, "count": 1})
fname = f"img_{img_counter:03d}.{ext}" ci = len(clusters) - 1
(img_dir / fname).write_bytes(data) else:
clusters[ci]["count"] += 1
elements.append({ elements.append({
"kind": "image", "y": b[1], "page": pn + 1, "file": fname, "kind": "image", "y": b[1], "page": pn + 1, "cluster": ci,
}) })
doc.close() 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) --- # --- pro Seite nach Y-Position sortieren (Lesereihenfolge) ---
elements.sort(key=lambda e: (e["page"], e["y"])) elements.sort(key=lambda e: (e["page"], e["y"]))
@@ -233,6 +338,19 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
for idx, e in enumerate(merged): for idx, e in enumerate(merged):
if e["kind"] == "image": if e["kind"] == "image":
flush_para() 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 = "" caption = ""
if idx + 1 < n and merged[idx + 1]["kind"] == "text": if idx + 1 < n and merged[idx + 1]["kind"] == "text":
cm = RE_CAPTION.match(merged[idx + 1]["text"]) cm = RE_CAPTION.match(merged[idx + 1]["text"])
@@ -240,7 +358,7 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
caption = merged[idx + 1]["text"] caption = merged[idx + 1]["text"]
merged[idx + 1]["_consumed"] = True merged[idx + 1]["_consumed"] = True
out.append("") out.append("")
out.append(f".. figure:: images/{e['file']}") out.append(f".. figure:: {path}")
out.append(" :align: center") out.append(" :align: center")
out.append(" :width: 80%") out.append(" :width: 80%")
if caption: if caption:
@@ -255,11 +373,22 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
text = e["text"] text = e["text"]
hm = RE_HEADING.match(text) hm = RE_HEADING.match(text)
# Ueberschrift NUR mit fuehrender PDF-Gliederungsnummer akzeptieren. # 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 is_heading = False
if hm and len(hm.group(2)) < 120: if hm and len(hm.group(2)) < 120:
num, rest = hm.group(1), hm.group(2) num, rest = hm.group(1), hm.group(2)
if not re.match(r"^(mm|cm|m|kg|x|l|h|min|s)\b", rest, re.I): 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 is_heading = True
if is_heading: if is_heading: