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.
|
# und extrahierte Bilder. Nur Code/Config/Doku wird versioniert.
|
||||||
chapters/**/*.rst
|
chapters/**/*.rst
|
||||||
chapters/**/images/
|
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,
|
- **Symbole/Warnzeichen zentral:** Kleine (≤ 260 px), mehrfach vorkommende Bilder (Warndreiecke,
|
||||||
Piktogramme) werden per Perceptual-Hash (aHash + Hamming-Distanz ≤ 6) erkannt, **einmal** zentral
|
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,
|
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
|
zentriert) auf die eine Quelle verwiesen – dokumentübergreifend. Auch großformatige
|
||||||
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
|
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
|
- **Fragment-„Überschriften" unterdrückt:** Bildlegenden/Tabellenzellen mit führender Nummer
|
||||||
(„POS", „li.", „re.", „bar, max. 10 bar") werden nicht mehr als Überschrift gesetzt.
|
(„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
|
||||||
|
|||||||
+21
-5
@@ -417,10 +417,18 @@ def dedup_images(img_dir: Path) -> int:
|
|||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
|
||||||
def process_all(data_dir: Path) -> None:
|
def process_all(data_dir: Path, assets_dir: Path | None = None) -> None:
|
||||||
"""Verarbeitet alle konfigurierten PDFs."""
|
"""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"
|
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():
|
if not pp_dir.exists():
|
||||||
log.error("Verzeichnis nicht gefunden: %s", pp_dir)
|
log.error("Verzeichnis nicht gefunden: %s", pp_dir)
|
||||||
@@ -432,6 +440,7 @@ def process_all(data_dir: Path) -> None:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
log.info("Gefunden: %d PDF-Dateien in %s", len(pdf_files), pp_dir)
|
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):
|
for pdf_path in sorted(pdf_files):
|
||||||
target = find_target(pdf_path.name)
|
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)
|
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
|
img_dir = images_base / target
|
||||||
|
if img_dir.exists():
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(img_dir)
|
||||||
img_count = extract_images(pdf_path, img_dir)
|
img_count = extract_images(pdf_path, img_dir)
|
||||||
dup_count = dedup_images(img_dir)
|
dup_count = dedup_images(img_dir)
|
||||||
log.info(" %d Bilder extrahiert, %d Duplikate entfernt -> %d einzigartig in %s",
|
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,
|
parser.add_argument("--data-dir", type=Path, default=None,
|
||||||
help="data/ Verzeichnis (default: aus PV_DATA oder ./data)")
|
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",
|
parser.add_argument("--verbose", "-v", action="store_true",
|
||||||
help="Ausfuehrliche Ausgabe")
|
help="Ausfuehrliche Ausgabe")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -480,8 +495,9 @@ def main() -> None:
|
|||||||
else:
|
else:
|
||||||
data_dir = Path("data").resolve()
|
data_dir = Path("data").resolve()
|
||||||
|
|
||||||
|
assets_dir = args.assets_dir.resolve() if args.assets_dir else None
|
||||||
log.info("Data-Verzeichnis: %s", data_dir)
|
log.info("Data-Verzeichnis: %s", data_dir)
|
||||||
process_all(data_dir)
|
process_all(data_dir, assets_dir=assets_dir)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -81,6 +81,32 @@ CHAPTER_DOC_TITLE = {
|
|||||||
"06_steuerung/loading_loop": "Funktionsbeschreibung Steuerung Loading Loop",
|
"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
|
# action="convert": Volltext + Bilder
|
||||||
@@ -88,18 +114,38 @@ 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, symbols_dir: Path | None = None,
|
dry_run: bool, symbols_dir: Path | None = None,
|
||||||
symbol_registry: dict | None = None) -> None:
|
symbol_registry: dict | None = None,
|
||||||
"""Konvertiert eine Hauptanleitung nach chapters/<kapitel>/."""
|
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"]
|
src_pdf = source_root / item["rel_path"]
|
||||||
chapter_dir = chapters_dir / item["chapter"]
|
chapter_dir = chapters_dir / chapter
|
||||||
stem = slugify(item["filename"])
|
stem = slugify(item["filename"])
|
||||||
rst_path = chapter_dir / f"{stem}.de.rst"
|
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"],
|
log.info(" Quelle: %s (%d MB)", item["rel_path"],
|
||||||
item["size_bytes"] // (1024 * 1024))
|
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:
|
if dry_run:
|
||||||
return
|
return
|
||||||
@@ -108,21 +154,21 @@ def convert_manual(item: dict, source_root: Path, chapters_dir: Path,
|
|||||||
log.error(" Quelle fehlt: %s", src_pdf)
|
log.error(" Quelle fehlt: %s", src_pdf)
|
||||||
return
|
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)
|
chapter_dir.mkdir(parents=True, exist_ok=True)
|
||||||
if img_dir.exists():
|
for d in (image_dest, photo_dest, chapter_dir / "images"):
|
||||||
shutil.rmtree(img_dir)
|
if d.exists():
|
||||||
# Alte .de.rst im Zielordner entfernen (Testbestand)
|
shutil.rmtree(d)
|
||||||
for old in chapter_dir.glob("*.de.rst"):
|
for old in chapter_dir.glob("*.de.rst"):
|
||||||
old.unlink()
|
old.unlink()
|
||||||
|
|
||||||
# Text + Bilder positionsbasiert extrahieren (verwoben in Lesereihenfolge,
|
doc_title = CHAPTER_DOC_TITLE.get(chapter, slugify(item["filename"]))
|
||||||
# PDF-Ueberschriftennummern entfernt, Bilder als .. figure:: referenziert).
|
|
||||||
doc_title = CHAPTER_DOC_TITLE.get(item["chapter"], slugify(item["filename"]))
|
|
||||||
blocks, img_count = pdf_extract.extract_manual(
|
blocks, img_count = pdf_extract.extract_manual(
|
||||||
src_pdf, rst_path, img_dir, title=doc_title,
|
src_pdf, rst_path, chapter_dir / "images", title=doc_title,
|
||||||
symbols_dir=symbols_dir, symbol_registry=symbol_registry)
|
symbols_dir=symbols_dir, symbol_registry=symbol_registry,
|
||||||
log.info(" %d Textbloecke, %d Bilder (referenziert) -> %s",
|
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)
|
blocks, img_count, rst_path.name)
|
||||||
|
|
||||||
|
|
||||||
@@ -270,17 +316,28 @@ 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
|
# Symbole + Logos dokumentuebergreifend zentral in assets/shared ablegen
|
||||||
# und nur einmal referenzieren (Warndreiecke etc. tauchen in mehreren
|
# und nur einmal referenzieren. Registries ueber alle Dokumente teilen.
|
||||||
# Anleitungen auf). Registry ueber alle Dokumente hinweg teilen.
|
base = chapters_dir.parent
|
||||||
symbols_dir = chapters_dir.parent / "assets" / "shared" / "symbols"
|
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 = {}
|
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:
|
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)
|
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:
|
if not dry_run:
|
||||||
log.info("Symbole zentral: %d eindeutige -> %s",
|
log.info("Symbole zentral: %d | Logos: %d -> assets/shared/",
|
||||||
len(symbol_registry), symbols_dir)
|
len(symbol_registry), len(logo_registry))
|
||||||
|
|
||||||
# 2. Verzeichnislisten
|
# 2. Verzeichnislisten
|
||||||
if args.only in (None, "list"):
|
if args.only in (None, "list"):
|
||||||
|
|||||||
+225
-39
@@ -63,6 +63,66 @@ def hamming(a: int, b: int) -> int:
|
|||||||
"""Hamming-Distanz zweier Integer-Hashes (Anzahl unterschiedlicher Bits)."""
|
"""Hamming-Distanz zweier Integer-Hashes (Anzahl unterschiedlicher Bits)."""
|
||||||
return bin(a ^ b).count("1")
|
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: '"'}
|
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.
|
||||||
@@ -114,20 +174,51 @@ def _strip_heading_number(num: str, rest: str) -> str:
|
|||||||
return rest.strip()
|
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,
|
def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
symbols_dir: Path | 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.
|
"""Extrahiert Text + Bilder eines Anleitungs-PDF in eine .rst-Datei.
|
||||||
|
|
||||||
Symbole/Icons (kleine, mehrfach vorkommende Bilder wie Warndreiecke) werden
|
Bild-Routing (siehe doc/plan_fortna_extraktion.md):
|
||||||
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
|
- Symbole (klein, mehrfach): einmal zentral -> symbols_dir
|
||||||
werden ebenfalls nur einmal abgelegt und mehrfach referenziert.
|
(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:
|
Returns:
|
||||||
(anzahl_textbloecke, anzahl_bilder)
|
(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:
|
if symbol_registry is None:
|
||||||
symbol_registry = {}
|
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))
|
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 ---
|
# --- Seiten identifizieren, die uebersprungen werden ---
|
||||||
# a) TOC-Seiten (Punktfueller / "Inhaltsverzeichnis")
|
# 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
|
# in anderer Skalierung) landen im selben Cluster. Erst nach dem Scan steht
|
||||||
# fest, welches Cluster mehrfach vorkommt (Symbol-Kriterium).
|
# fest, welches Cluster mehrfach vorkommt (Symbol-Kriterium).
|
||||||
elements: list[dict] = []
|
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:
|
def _find_cluster(ah: int) -> int:
|
||||||
for ci, c in enumerate(clusters):
|
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"]
|
data = base["image"]
|
||||||
if len(data) < 3000:
|
if len(data) < 3000:
|
||||||
continue
|
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)
|
ah = average_hash(data)
|
||||||
|
in_margin = looks_like_logo(w, h, b[1], ph)
|
||||||
ci = _find_cluster(ah)
|
ci = _find_cluster(ah)
|
||||||
if ci < 0:
|
if ci < 0:
|
||||||
clusters.append({"ahash": ah, "data": data,
|
clusters.append({"ahash": ah, "data": data, "ext": ext,
|
||||||
"ext": base.get("ext", "png"),
|
"w": w, "h": h, "count": 1,
|
||||||
"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
|
ci = len(clusters) - 1
|
||||||
else:
|
else:
|
||||||
clusters[ci]["count"] += 1
|
clusters[ci]["count"] += 1
|
||||||
|
if in_margin:
|
||||||
|
clusters[ci]["margin_hits"] += 1
|
||||||
|
clusters[ci]["variants"].append((w * h, w, h, data, ext))
|
||||||
elements.append({
|
elements.append({
|
||||||
"kind": "image", "y": b[1], "page": pn + 1, "cluster": ci,
|
"kind": "image", "y": b[1], "page": pn + 1, "cluster": ci,
|
||||||
})
|
})
|
||||||
|
|
||||||
doc.close()
|
doc.close()
|
||||||
|
|
||||||
# --- Pass 2: entscheiden, wohin jedes Bild-Cluster geschrieben wird ---
|
import os as _os
|
||||||
# Symbol: klein UND mehrfach vorkommend -> zentral assets/shared/symbols.
|
|
||||||
# Sonst (inkl. mehrfach genutzte Inhaltsbilder): einmal in Kapitel-images.
|
# --- Zielverzeichnisse + relative Pfad-Praefixe (vom RST aus) ---
|
||||||
|
rst_parent = rst_path.parent
|
||||||
if symbols_dir is None:
|
if symbols_dir is None:
|
||||||
symbols_dir = img_dir.parent.parent.parent / "assets" / "shared" / "symbols"
|
symbols_dir = rst_parent.parent.parent / "assets" / "shared" / "symbols"
|
||||||
img_dir.mkdir(parents=True, exist_ok=True)
|
if logos_dir is None:
|
||||||
|
logos_dir = symbols_dir.parent / "logos"
|
||||||
|
|
||||||
# relativer Pfad-Praefix vom RST (liegt in img_dir.parent) zu symbols_dir
|
def _rel(target: Path) -> str:
|
||||||
try:
|
try:
|
||||||
sym_rel_prefix = Path(
|
return Path(_os.path.relpath(target, rst_parent)).as_posix()
|
||||||
__import__("os").path.relpath(symbols_dir, img_dir.parent)
|
|
||||||
).as_posix()
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
sym_rel_prefix = symbols_dir.as_posix()
|
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)
|
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):
|
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
|
is_symbol = (max(c["w"], c["h"]) <= SYMBOL_MAX_EDGE
|
||||||
and c["count"] >= SYMBOL_MIN_COUNT)
|
and c["count"] >= SYMBOL_MIN_COUNT)
|
||||||
cluster_is_symbol[ci] = is_symbol
|
cluster_is_symbol[ci] = is_symbol
|
||||||
if is_symbol:
|
if is_symbol:
|
||||||
# Dokumentuebergreifende Registry per aHash-Naehe durchsuchen.
|
|
||||||
match = None
|
match = None
|
||||||
for reg_ah, reg_file in symbol_registry.items():
|
for reg_ah, reg_file in symbol_registry.items():
|
||||||
if hamming(c["ahash"], reg_ah) <= AHASH_THRESHOLD:
|
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']}"
|
match = f"symbol_{c['ahash'] & 0xffffffff:08x}.{c['ext']}"
|
||||||
(symbols_dir / match).write_bytes(c["data"])
|
(symbols_dir / match).write_bytes(c["data"])
|
||||||
symbol_registry[c["ahash"]] = match
|
symbol_registry[c["ahash"]] = match
|
||||||
cluster_target[ci] = f"{sym_rel_prefix}/{match}"
|
cluster_sympath[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"]))
|
||||||
@@ -316,6 +439,69 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
|
|||||||
merged.append(e)
|
merged.append(e)
|
||||||
i += 1
|
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 ---
|
# --- RST rendern ---
|
||||||
if title is None:
|
if title is None:
|
||||||
title = pdf_path.stem
|
title = pdf_path.stem
|
||||||
|
|||||||
Reference in New Issue
Block a user