feat: Bild-Routing, Logo/Symbol/Fragment-Filter, Produktprogramm-Bilder

Bild-Verarbeitung der Anleitungen (lib/pdf_extract.py) erweitert:

- Routing nach Kapitel-Typ: generelle Kapitel (Steuerung) -> assets/shared/
  images/<subdir>, projektbezogene (Betrieb/Wartung) -> projekte/fortna/images.
  Fotos (.jpg) IMMER projektspezifisch, unabhaengig vom Kapitel.
- Benennung aus Bildunterschrift: <Stichwort>_NN.png (z.B. Anlage_Uebersicht_01);
  Fotos img_NNN.jpg, SW-Skizzen >10KB sketch_NNN.png, Rest img_NNN.png.
- Logos: nur wiederkehrende (>=3x) breite Randgrafiken gelten als Logo -> alle
  Aufloesungen nach assets/shared/logos/logo_<n>_<breite>px, nicht inline.
- Wertlose Fragmente (flach <=55px, breit, SW = gerenderte Kapitelnummern wie
  "5.2.6") werden verworfen.
- Ziel-Bildordner werden vor jedem Lauf geleert.

extract_products.py: Produktprogramm-Bilder -> assets/shared/images/
produktprogramm/{omniflo,ils,cpc,geruest,trolley} (generelle Produktbilder).

.gitignore: extrahierte Bildmassen ausgeschlossen, nur kuratierte symbols/ +
logos/ versioniert.

Voller PDF-Build sauber (lualatex exit 0, 589 Seiten, 638 Bild-Refs, 0 fehlend).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Stangl
2026-09-23 14:35:19 +02:00
co-authored by Claude Opus 4.8
parent bbfe17ec37
commit 3ebfe06139
11 changed files with 356 additions and 72 deletions
+7
View File
@@ -88,3 +88,10 @@ doc/*.pdf
# und extrahierte Bilder. Nur Code/Config/Doku wird versioniert.
chapters/**/*.rst
chapters/**/images/
# Extrahierte Bilder (reproduzierbar) - NICHT versionieren ...
assets/shared/images/
projekte/fortna/images/
# ... AUSSER den kuratierten, wiederverwendbaren Symbolen und Logos.
!assets/shared/symbols/
!assets/shared/logos/
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

+21 -3
View File
@@ -53,10 +53,28 @@ sauberere `.rst` erzeugt:
- **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
zentriert) auf die eine Quelle verwiesen – dokumentübergreifend. 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.
referenziert).
- **Bild-Routing nach Kapitel-Typ + Dateityp:** Generelle Kapitel (Steuerungs-Funktionsbeschreibung
ILS/OMNIFLO/LoadingLoop) → `assets/shared/images/<subdir>` (wiederverwendbar). Projektbezogene
Kapitel (Betrieb/Wartung) → `projekte/fortna/images/<subdir>`. **Fotos (.jpg) immer**
projektspezifisch (`projekte/fortna/images/`), unabhängig vom Kapitel. Ziel-Bildordner werden
vor jedem Lauf geleert.
- **Bild-Benennung:** PNG mit direkt folgender Bildunterschrift → `<Stichwort>_NN.png` (z. B.
`Anlage_Uebersicht_01.png`, `Komponentenstatus_Barcode_Leser_01.png`). Fotos → `img_NNN.jpg`,
schwarz-weiße Skizzen (> 10 KB) → `sketch_NNN.png`, Rest → `img_NNN.png`.
- **Logos gesondert:** Nur wiederkehrende (≥ 3×) breite Grafiken im Seitenrand gelten als Logo
(unterscheidet Kopf-/Fußzeilen-Logo von einmaligen breiten Diagrammen) → alle Auflösungen nach
`assets/shared/logos/logo_<n>_<breite>px.png`, **nicht** inline referenziert (Deko).
- **Wertlose Fragmente verworfen:** flache (≤ 55 px), breite, schwarz-weiße Schnipsel sind meist
gerenderte Kapitelnummern („5.2.6", „3.2.3") und werden gar nicht extrahiert.
- **Produktprogramm-Bilder:** `lib/extract_products.py` legt die Bilder der Produktprogramm-PDFs
(`data/Produktprogramm/*.pdf`) als generelle Produktbilder nach
`assets/shared/images/produktprogramm/{omniflo,ils,cpc,geruest,trolley}` ab.
- **Versionierung:** Extrahierte Bildmassen (`assets/shared/images/`, `projekte/fortna/images/`)
sind reproduzierbar und via `.gitignore` ausgeschlossen; nur die kuratierten
`assets/shared/symbols/` und `assets/shared/logos/` werden versioniert.
- **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
+21 -5
View File
@@ -417,10 +417,18 @@ def dedup_images(img_dir: Path) -> int:
return removed
def process_all(data_dir: Path) -> None:
"""Verarbeitet alle konfigurierten PDFs."""
def process_all(data_dir: Path, assets_dir: Path | None = None) -> None:
"""Verarbeitet alle konfigurierten Produktprogramm-PDFs.
Bilder sind generelle Produktbilder und werden nach
assets/shared/images/produktprogramm/<ziel> abgelegt (wiederverwendbar).
Die extrahierte RST bleibt zur Information neben dem PDF.
"""
pp_dir = data_dir / "Produktprogramm"
images_base = pp_dir / "images"
if assets_dir is None:
# data/ liegt unter PROJECT -> assets/shared/images/produktprogramm
assets_dir = data_dir.parent / "assets" / "shared" / "images" / "produktprogramm"
images_base = assets_dir
if not pp_dir.exists():
log.error("Verzeichnis nicht gefunden: %s", pp_dir)
@@ -432,6 +440,7 @@ def process_all(data_dir: Path) -> None:
sys.exit(1)
log.info("Gefunden: %d PDF-Dateien in %s", len(pdf_files), pp_dir)
log.info("Bilder -> %s", images_base)
for pdf_path in sorted(pdf_files):
target = find_target(pdf_path.name)
@@ -441,8 +450,11 @@ def process_all(data_dir: Path) -> None:
log.info("Verarbeite: %s -> %s", pdf_path.name, target)
# Bilder extrahieren und Duplikate entfernen
# Bilder extrahieren und Duplikate entfernen. Vorher Zielordner leeren.
img_dir = images_base / target
if img_dir.exists():
import shutil
shutil.rmtree(img_dir)
img_count = extract_images(pdf_path, img_dir)
dup_count = dedup_images(img_dir)
log.info(" %d Bilder extrahiert, %d Duplikate entfernt -> %d einzigartig in %s",
@@ -463,6 +475,9 @@ def main() -> None:
)
parser.add_argument("--data-dir", type=Path, default=None,
help="data/ Verzeichnis (default: aus PV_DATA oder ./data)")
parser.add_argument("--assets-dir", type=Path, default=None,
help="Zielordner fuer Bilder (default: "
"assets/shared/images/produktprogramm unter PROJECT)")
parser.add_argument("--verbose", "-v", action="store_true",
help="Ausfuehrliche Ausgabe")
args = parser.parse_args()
@@ -480,8 +495,9 @@ def main() -> None:
else:
data_dir = Path("data").resolve()
assets_dir = args.assets_dir.resolve() if args.assets_dir else None
log.info("Data-Verzeichnis: %s", data_dir)
process_all(data_dir)
process_all(data_dir, assets_dir=assets_dir)
if __name__ == "__main__":
+80 -23
View File
@@ -81,6 +81,32 @@ CHAPTER_DOC_TITLE = {
"06_steuerung/loading_loop": "Funktionsbeschreibung Steuerung Loading Loop",
}
# Kapitel-Typ: "general" -> Bilder sind wiederverwendbar (Maschinenelemente,
# Steuerungs-Funktionsbeschreibungen) -> assets/shared
# "project" -> projektspezifisch (Betrieb/Wartung der Anlage)
# -> projekte/fortna/images
# Fotos (.jpg) landen unabhaengig davon IMMER projektspezifisch (siehe
# convert_manual: Routing ueber Klassifizierung im Aufrufer wird durch
# pdf_extract je Bild nicht getrennt - daher legen wir generelle Kapitel nach
# assets, projektbezogene nach projekte/fortna). Feinsteuerung .jpg->projekt
# erfolgt zusaetzlich unten.
CHAPTER_TYPE = {
"04_betrieb/betriebsanleitung": "project",
"05_wartung/wartungsanleitung": "project",
"06_steuerung/ils": "general",
"06_steuerung/omniflo": "general",
"06_steuerung/loading_loop": "general",
}
# kurzer Unterordner-Name je Kapitel fuer die Bildablage
CHAPTER_IMG_SUBDIR = {
"04_betrieb/betriebsanleitung": "betrieb",
"05_wartung/wartungsanleitung": "wartung",
"06_steuerung/ils": "steuerung_ils",
"06_steuerung/omniflo": "steuerung_omniflo",
"06_steuerung/loading_loop": "steuerung_loading_loop",
}
# ---------------------------------------------------------------------------
# action="convert": Volltext + Bilder
@@ -88,18 +114,38 @@ CHAPTER_DOC_TITLE = {
def convert_manual(item: dict, source_root: Path, chapters_dir: Path,
dry_run: bool, symbols_dir: Path | None = None,
symbol_registry: dict | None = None) -> None:
"""Konvertiert eine Hauptanleitung nach chapters/<kapitel>/."""
symbol_registry: dict | None = None,
logos_dir: Path | None = None,
logo_registry: dict | None = None,
project_dir: Path | None = None,
assets_dir: Path | None = None) -> None:
"""Konvertiert eine Hauptanleitung nach chapters/<kapitel>/.
Bild-Routing je Kapitel:
general -> assets/shared/images/<subdir>
project -> projekte/fortna/images/<subdir>
Fotos (.jpg) gehen unabhaengig davon IMMER nach projekte/fortna/images/<subdir>.
"""
chapter = item["chapter"]
src_pdf = source_root / item["rel_path"]
chapter_dir = chapters_dir / item["chapter"]
chapter_dir = chapters_dir / chapter
stem = slugify(item["filename"])
rst_path = chapter_dir / f"{stem}.de.rst"
img_dir = chapter_dir / "images"
log.info("CONVERT %s", item["chapter"])
subdir = CHAPTER_IMG_SUBDIR.get(chapter, slugify(chapter))
ctype = CHAPTER_TYPE.get(chapter, "project")
assets_base = assets_dir or (chapters_dir.parent / "assets" / "shared" / "images")
project_base = (project_dir or (chapters_dir.parent / "projekte" / "fortna")) / "images"
# generelle Grafiken -> assets, projektbezogene -> projekt; Fotos immer projekt
image_dest = (assets_base if ctype == "general" else project_base) / subdir
photo_dest = project_base / subdir
log.info("CONVERT %s (%s)", chapter, ctype)
log.info(" Quelle: %s (%d MB)", item["rel_path"],
item["size_bytes"] // (1024 * 1024))
log.info(" -> %s", rst_path.relative_to(chapters_dir))
log.info(" Bilder -> %s (Fotos -> %s)",
image_dest.relative_to(chapters_dir.parent),
photo_dest.relative_to(chapters_dir.parent))
if dry_run:
return
@@ -108,21 +154,21 @@ def convert_manual(item: dict, source_root: Path, chapters_dir: Path,
log.error(" Quelle fehlt: %s", src_pdf)
return
# Zielordner vorbereiten: vorhandene RST/Bilder ersetzen (aus einem Guss)
# Zielordner vorbereiten und ALLE vorher existierenden Bilder darin loeschen.
chapter_dir.mkdir(parents=True, exist_ok=True)
if img_dir.exists():
shutil.rmtree(img_dir)
# Alte .de.rst im Zielordner entfernen (Testbestand)
for d in (image_dest, photo_dest, chapter_dir / "images"):
if d.exists():
shutil.rmtree(d)
for old in chapter_dir.glob("*.de.rst"):
old.unlink()
# Text + Bilder positionsbasiert extrahieren (verwoben in Lesereihenfolge,
# PDF-Ueberschriftennummern entfernt, Bilder als .. figure:: referenziert).
doc_title = CHAPTER_DOC_TITLE.get(item["chapter"], slugify(item["filename"]))
doc_title = CHAPTER_DOC_TITLE.get(chapter, slugify(item["filename"]))
blocks, img_count = pdf_extract.extract_manual(
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",
src_pdf, rst_path, chapter_dir / "images", title=doc_title,
symbols_dir=symbols_dir, symbol_registry=symbol_registry,
logos_dir=logos_dir, logo_registry=logo_registry,
image_dest=image_dest, photo_dest=photo_dest)
log.info(" %d Textbloecke, %d Bilder -> %s",
blocks, img_count, rst_path.name)
@@ -270,17 +316,28 @@ def main() -> None:
if args.only in (None, "convert"):
conv = [i for i in items if i["action"] == "convert"]
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"
# Symbole + Logos dokumentuebergreifend zentral in assets/shared ablegen
# und nur einmal referenzieren. Registries ueber alle Dokumente teilen.
base = chapters_dir.parent
symbols_dir = base / "assets" / "shared" / "symbols"
logos_dir = base / "assets" / "shared" / "logos"
assets_img_dir = base / "assets" / "shared" / "images"
project_dir = base / "projekte" / "fortna"
symbol_registry: dict = {}
logo_registry: dict = {}
# Vor dem Lauf zentrale Ziel-Bildordner leeren (frischer Stand).
if not dry_run:
for d in (symbols_dir, logos_dir, assets_img_dir, project_dir / "images"):
if d.exists():
shutil.rmtree(d)
for it in conv:
convert_manual(it, source_root, chapters_dir, dry_run,
symbols_dir=symbols_dir, symbol_registry=symbol_registry)
symbols_dir=symbols_dir, symbol_registry=symbol_registry,
logos_dir=logos_dir, logo_registry=logo_registry,
project_dir=project_dir, assets_dir=assets_img_dir)
if not dry_run:
log.info("Symbole zentral: %d eindeutige -> %s",
len(symbol_registry), symbols_dir)
log.info("Symbole zentral: %d | Logos: %d -> assets/shared/",
len(symbol_registry), len(logo_registry))
# 2. Verzeichnislisten
if args.only in (None, "list"):
+227 -41
View File
@@ -63,6 +63,66 @@ def hamming(a: int, b: int) -> int:
"""Hamming-Distanz zweier Integer-Hashes (Anzahl unterschiedlicher Bits)."""
return bin(a ^ b).count("1")
# --- Bild-Klassifizierung -------------------------------------------------
SKETCH_MIN_BYTES = 10_000 # SW-Grafik groesser als ~10 KB gilt als Sketch
def is_junk_fragment(image_bytes: bytes, w: int, h: int) -> bool:
"""True fuer wertlose Text-/Nummern-Fragmente (kein echtes Symbol/Bild).
Typisch: flache (Hoehe <= 55 px), breite, schwarz-weisse Schnipsel, die in
Wahrheit gerenderte Kapitelnummern/Beschriftungen sind (z.B. "5.2.6",
zwei Quadrate + Zahlen). Diese werden verworfen.
"""
if h <= 55 and w / max(h, 1) >= 1.6 and is_grayscale(image_bytes):
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.
@@ -114,20 +174,51 @@ def _strip_heading_number(num: str, rest: str) -> str:
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) -> tuple[int, int]:
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.
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.
Bild-Routing (siehe doc/plan_fortna_extraktion.md):
Inhaltsbild-Duplikate (gleicher visueller Hash) innerhalb des Dokuments
werden ebenfalls nur einmal abgelegt und mehrfach referenziert.
- 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)
@@ -136,9 +227,17 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
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))
img_dir.mkdir(parents=True, exist_ok=True)
image_dest.mkdir(parents=True, exist_ok=True)
# --- Seiten identifizieren, die uebersprungen werden ---
# a) TOC-Seiten (Punktfueller / "Inhaltsverzeichnis")
@@ -163,7 +262,7 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
# 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}]
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):
@@ -225,45 +324,81 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
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": base.get("ext", "png"),
"w": w, "h": h, "count": 1})
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()
# --- 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.
import os as _os
# --- Zielverzeichnisse + relative Pfad-Praefixe (vom RST aus) ---
rst_parent = rst_path.parent
if symbols_dir is None:
symbols_dir = img_dir.parent.parent.parent / "assets" / "shared" / "symbols"
img_dir.mkdir(parents=True, exist_ok=True)
symbols_dir = rst_parent.parent.parent / "assets" / "shared" / "symbols"
if logos_dir is None:
logos_dir = symbols_dir.parent / "logos"
# 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()
def _rel(target: Path) -> str:
try:
return Path(_os.path.relpath(target, rst_parent)).as_posix()
except ValueError:
return target.as_posix()
cluster_target: list[str] = [""] * len(clusters) # ci -> RST-Bildpfad
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)
local_counter = 0
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
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:
@@ -274,19 +409,7 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
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
cluster_sympath[ci] = f"{sym_rel_prefix}/{match}"
# --- pro Seite nach Y-Position sortieren (Lesereihenfolge) ---
elements.sort(key=lambda e: (e["page"], e["y"]))
@@ -316,6 +439,69 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
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
View File