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:
co-authored by
Claude Opus 4.8
parent
bbfe17ec37
commit
3ebfe06139
@@ -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 |
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 |
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user