From 3ebfe061393da7defa31c5c7426e1747b4ef1b41 Mon Sep 17 00:00:00 2001 From: Michael Stangl Date: Wed, 23 Sep 2026 14:35:19 +0200 Subject: [PATCH] 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/, projektbezogene (Betrieb/Wartung) -> projekte/fortna/images. Fotos (.jpg) IMMER projektspezifisch, unabhaengig vom Kapitel. - Benennung aus Bildunterschrift: _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__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) --- .gitignore | 7 + assets/shared/logos/logo_01_551px.png | Bin 0 -> 16452 bytes assets/shared/symbols/.gitkeep | 0 assets/shared/symbols/symbol_2cae247c.png | Bin 3054 -> 0 bytes assets/shared/symbols/symbol_36161676.png | Bin 3259 -> 0 bytes assets/shared/symbols/symbol_6d6d2466.png | Bin 3465 -> 0 bytes doc/plan_fortna_extraktion.md | 24 +- lib/extract_products.py | 26 ++- lib/fortna_convert_chapters.py | 103 +++++++-- lib/pdf_extract.py | 268 ++++++++++++++++++---- projekte/fortna/images/.gitkeep | 0 11 files changed, 356 insertions(+), 72 deletions(-) create mode 100644 assets/shared/logos/logo_01_551px.png delete mode 100644 assets/shared/symbols/.gitkeep delete mode 100644 assets/shared/symbols/symbol_2cae247c.png delete mode 100644 assets/shared/symbols/symbol_36161676.png delete mode 100644 assets/shared/symbols/symbol_6d6d2466.png delete mode 100644 projekte/fortna/images/.gitkeep diff --git a/.gitignore b/.gitignore index ddb8cd8..af71c98 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/assets/shared/logos/logo_01_551px.png b/assets/shared/logos/logo_01_551px.png new file mode 100644 index 0000000000000000000000000000000000000000..4cdfb92c6c950a076101470c5aeaae01984a6aba GIT binary patch literal 16452 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 literal 0 HcmV?d00001 diff --git a/assets/shared/symbols/.gitkeep b/assets/shared/symbols/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/assets/shared/symbols/symbol_2cae247c.png b/assets/shared/symbols/symbol_2cae247c.png deleted file mode 100644 index 212aa89599d1773b6bcaaf737c182ef152abbc4c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3054 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 diff --git a/assets/shared/symbols/symbol_6d6d2466.png b/assets/shared/symbols/symbol_6d6d2466.png deleted file mode 100644 index 1a72b5dbb8c0cf436079d892589c84cc380484c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3465 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 diff --git a/doc/plan_fortna_extraktion.md b/doc/plan_fortna_extraktion.md index 6ebaca0..da36d8d 100644 --- a/doc/plan_fortna_extraktion.md +++ b/doc/plan_fortna_extraktion.md @@ -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_.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/` (wiederverwendbar). Projektbezogene + Kapitel (Betrieb/Wartung) → `projekte/fortna/images/`. **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 → `_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__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 diff --git a/lib/extract_products.py b/lib/extract_products.py index e2b754c..1151c02 100644 --- a/lib/extract_products.py +++ b/lib/extract_products.py @@ -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/ 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__": diff --git a/lib/fortna_convert_chapters.py b/lib/fortna_convert_chapters.py index d20d0f3..2322d6a 100644 --- a/lib/fortna_convert_chapters.py +++ b/lib/fortna_convert_chapters.py @@ -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//.""" + 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//. + + Bild-Routing je Kapitel: + general -> assets/shared/images/ + project -> projekte/fortna/images/ + Fotos (.jpg) gehen unabhaengig davon IMMER nach projekte/fortna/images/. + """ + 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"): diff --git a/lib/pdf_extract.py b/lib/pdf_extract.py index 74f89e9..d87d240 100644 --- a/lib/pdf_extract.py +++ b/lib/pdf_extract.py @@ -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_.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 -> _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 diff --git a/projekte/fortna/images/.gitkeep b/projekte/fortna/images/.gitkeep deleted file mode 100644 index e69de29..0000000