Perceptual-Hash-basierte Symbol-/Duplikaterkennung in lib/pdf_extract.py: - average_hash() (aHash) + Hamming-Distanz statt exaktem Byte-Hash: erkennt visuell gleiche Bilder trotz unterschiedlicher Skalierung/Kompression - Symbole (klein <=260px UND mehrfach vorkommend, z.B. Warndreiecke) werden EINMAL zentral nach assets/shared/symbols/symbol_<hash>.png abgelegt und im RST als .. image:: (12mm, zentriert) auf die eine Quelle referenziert - dokumentuebergreifend (registry). Ein Warndreieck: 1x gespeichert statt 58x - grossformatige Inhaltsbild-Duplikate ebenfalls ueber aHash zusammengefuehrt - Fragment-"Ueberschriften" (POS, li., re., "bar, max. 10 bar") unterdrueckt - Altbestand assets/shared/symbols/img_*.png (durcheinander benannt) entfernt Wirkung: omniflo images/ 83 -> 62, 14 zentrale Symbole statt zig Duplikate. Voller PDF-Build sauber (lualatex exit 0, 719 Seiten). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
310 lines
13 KiB
Python
310 lines
13 KiB
Python
"""Befuellt chapters/ aus dem Fortna-Rohmaterial anhand des Inventars.
|
|
|
|
Zwei Aktionsarten (siehe doc/plan_fortna_extraktion.md):
|
|
|
|
- action="convert": Die 5 Hauptanleitungen (Betriebs-/Wartungsanleitung,
|
|
Funktionsbeschreibung ILS/OMNIFLO/LoadingLoop) werden volltextig mit Bildern
|
|
nach .rst konvertiert. Genutzt wird die positionsbasierte Extraktion aus
|
|
extract_products.py (Kopf-/Fusszeilen-Filter, Schriftgroessen-Heuristik),
|
|
die fuer Word-erstellte PDFs robuster ist als die pdfminer-Variante.
|
|
|
|
- action="list": Alle uebrigen Kapitel (Zertifikate, Layouts, Schaltplaene,
|
|
Notstopp/Bereichsstopp, Pruefberichte, Ersatzteile, Lieferantendoku) erhalten
|
|
eine generierte index.de.rst mit einer RST-list-table (Dateiname, Kategorie,
|
|
Groesse, Originalpfad). Es werden KEINE PDFs kopiert.
|
|
|
|
Nur deutschsprachige Quellen (lang == "de") werden verarbeitet.
|
|
|
|
Verwendung:
|
|
python fortna_convert_chapters.py --dry-run
|
|
python fortna_convert_chapters.py --apply
|
|
python fortna_convert_chapters.py --apply --only convert
|
|
python fortna_convert_chapters.py --apply --only list
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
# pdf_extract stellt die positionsbasierte Text+Bild-Extraktion bereit
|
|
# (verwebt Text und Bilder, entfernt PDF-Ueberschriftennummern).
|
|
import pdf_extract
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
RST_H = {1: "=", 2: "-", 3: "~", 4: "^"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hilfen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def slugify(name: str) -> str:
|
|
"""Erzeugt einen sauberen Dateinamen-Stamm aus einem PDF-Namen."""
|
|
stem = Path(name).stem.lower()
|
|
stem = re.sub(r"[^a-z0-9]+", "_", stem)
|
|
stem = re.sub(r"_+", "_", stem).strip("_")
|
|
return stem
|
|
|
|
|
|
def rst_heading(text: str, level: int = 1) -> list[str]:
|
|
"""Erzeugt eine RST-Ueberschrift als Zeilenliste."""
|
|
char = RST_H[level]
|
|
lines = []
|
|
if level == 1:
|
|
lines.append(char * max(len(text), 3))
|
|
lines.append(text)
|
|
lines.append(char * max(len(text), 3))
|
|
lines.append("")
|
|
return lines
|
|
|
|
|
|
def load_inventory(path: Path) -> dict:
|
|
with open(path, encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
|
|
# Lesbarer Dokumenttitel je Zielkapitel (statt PDF-Dateiname als Ueberschrift).
|
|
CHAPTER_DOC_TITLE = {
|
|
"04_betrieb/betriebsanleitung": "Betriebsanleitung",
|
|
"05_wartung/wartungsanleitung": "Wartungsanleitung",
|
|
"06_steuerung/ils": "Funktionsbeschreibung Steuerung ILS",
|
|
"06_steuerung/omniflo": "Funktionsbeschreibung Steuerung OMNIFLO",
|
|
"06_steuerung/loading_loop": "Funktionsbeschreibung Steuerung Loading Loop",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# action="convert": Volltext + Bilder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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>/."""
|
|
src_pdf = source_root / item["rel_path"]
|
|
chapter_dir = chapters_dir / item["chapter"]
|
|
stem = slugify(item["filename"])
|
|
rst_path = chapter_dir / f"{stem}.de.rst"
|
|
img_dir = chapter_dir / "images"
|
|
|
|
log.info("CONVERT %s", item["chapter"])
|
|
log.info(" Quelle: %s (%d MB)", item["rel_path"],
|
|
item["size_bytes"] // (1024 * 1024))
|
|
log.info(" -> %s", rst_path.relative_to(chapters_dir))
|
|
|
|
if dry_run:
|
|
return
|
|
|
|
if not src_pdf.exists():
|
|
log.error(" Quelle fehlt: %s", src_pdf)
|
|
return
|
|
|
|
# Zielordner vorbereiten: vorhandene RST/Bilder ersetzen (aus einem Guss)
|
|
chapter_dir.mkdir(parents=True, exist_ok=True)
|
|
if img_dir.exists():
|
|
shutil.rmtree(img_dir)
|
|
# Alte .de.rst im Zielordner entfernen (Testbestand)
|
|
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"]))
|
|
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",
|
|
blocks, img_count, rst_path.name)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# action="list": Verzeichnisliste index.de.rst
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def human_size(n: int) -> str:
|
|
if n >= 1024 * 1024:
|
|
return f"{n / (1024 * 1024):.1f} MB"
|
|
if n >= 1024:
|
|
return f"{n / 1024:.0f} KB"
|
|
return f"{n} B"
|
|
|
|
|
|
def category_from_path(rel_path: str, chapter: str) -> str:
|
|
"""Leitet eine Kategorie (z.B. Hersteller) aus dem Quellpfad ab.
|
|
|
|
Nimmt das Ordnersegment direkt unterhalb des Fortna-Top-Level bzw. ein
|
|
aussagekraeftiges Zwischensegment (Herstellername bei Lieferantendoku).
|
|
"""
|
|
parts = Path(rel_path).parts
|
|
# Lieferantendoku: Herstellername steckt meist tiefer im Pfad
|
|
# Heuristik: letztes Segment vor der Datei, das keine Sprach-/Nummernkennung ist
|
|
lang_dir = re.compile(r"^\d*_?(DE|EN|CZ)(_|$)", re.I)
|
|
skip = re.compile(r"^\d+_", )
|
|
candidates = [p for p in parts[1:-1] if not lang_dir.match(p)]
|
|
if candidates:
|
|
# bevorzugt ein Segment mit Buchstaben (Herstellername)
|
|
for c in reversed(candidates):
|
|
cleaned = skip.sub("", c)
|
|
if re.search(r"[A-Za-z]{3,}", cleaned):
|
|
return cleaned.split("-")[0].strip()
|
|
return ""
|
|
|
|
|
|
def generate_index(chapter: str, items: list[dict], source_root: Path,
|
|
chapters_dir: Path, dry_run: bool) -> None:
|
|
"""Erzeugt chapters/<kapitel>/index.de.rst mit RST-list-table."""
|
|
chapter_dir = chapters_dir / chapter
|
|
rst_path = chapter_dir / "index.de.rst"
|
|
|
|
title_map = {
|
|
"00_inhaltsverzeichnis": "Inhaltsverzeichnis / Dokumentennotizen",
|
|
"01_zertifikate/konformitaetserklaerung": "Konformitaetserklaerungen",
|
|
"01_zertifikate/konfigurationsbeschreibung": "Konfigurationsbeschreibung",
|
|
"07_ersatzteile": "Ersatzteillisten",
|
|
"08_lieferantendoku/antriebe": "Lieferantendoku - Antriebe",
|
|
"08_lieferantendoku/elektrik": "Lieferantendoku - Elektrik",
|
|
"08_lieferantendoku/pneumatik": "Lieferantendoku - Pneumatik",
|
|
"08_lieferantendoku/stahlbau": "Lieferantendoku - Stahlbau",
|
|
"08_lieferantendoku/sicherheitseinrichtungen": "Lieferantendoku - Sicherheitseinrichtungen",
|
|
"09_technische_unterlagen/layouts/mechanisch": "Layout mechanisch",
|
|
"09_technische_unterlagen/layouts/elektrisch": "Layout elektrisch",
|
|
"09_technische_unterlagen/schaltplaene": "Schaltplaene",
|
|
"09_technische_unterlagen/notstopp": "Not-Stopp",
|
|
"09_technische_unterlagen/bereichsstopp": "Bereichsstopp",
|
|
"10_anhang/pruefberichte": "Pruefberichte",
|
|
}
|
|
title = title_map.get(chapter, chapter)
|
|
|
|
log.info("LIST %-45s (%d Dateien) -> %s",
|
|
chapter, len(items), rst_path.relative_to(chapters_dir))
|
|
if dry_run:
|
|
return
|
|
|
|
chapter_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
lines: list[str] = []
|
|
lines += rst_heading(title, 1)
|
|
lines.append(".. note::")
|
|
lines.append("")
|
|
lines.append(" Automatisch generierte Verzeichnisliste aus dem Fortna-Quellmaterial.")
|
|
lines.append(" Die referenzierten Dokumente liegen im Projekt-Rohmaterial und werden")
|
|
lines.append(" hier nicht dupliziert.")
|
|
lines.append("")
|
|
lines.append(".. list-table::")
|
|
lines.append(" :header-rows: 1")
|
|
lines.append(" :widths: 34 18 12 36")
|
|
lines.append("")
|
|
lines.append(" * - Dokument")
|
|
lines.append(" - Kategorie")
|
|
lines.append(" - Groesse")
|
|
lines.append(" - Quellpfad")
|
|
for it in sorted(items, key=lambda x: x["rel_path"]):
|
|
cat = category_from_path(it["rel_path"], chapter)
|
|
lines.append(f" * - {it['filename']}")
|
|
lines.append(f" - {cat}")
|
|
lines.append(f" - {human_size(it['size_bytes'])}")
|
|
lines.append(f" - ``{it['rel_path']}``")
|
|
lines.append("")
|
|
|
|
rst_path.write_text("\n".join(lines), encoding="utf-8")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hauptlogik
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Befuellt chapters/ aus dem Fortna-Inventar (Volltext + Verzeichnislisten)."
|
|
)
|
|
parser.add_argument("--inventory", default=None,
|
|
help="Pfad zum Inventar-JSON (default: results/fortna_inventory.json)")
|
|
parser.add_argument("--source-dir", default=None,
|
|
help="Fortna-Quellverzeichnis (default: aus Inventar-JSON)")
|
|
parser.add_argument("--chapters-dir", default=None,
|
|
help="Ziel chapters/-Verzeichnis (default: PROJECT/chapters)")
|
|
parser.add_argument("--lang", default="de", choices=["de", "en", "cz"],
|
|
help="Zu verarbeitende Sprache (default: de)")
|
|
parser.add_argument("--only", choices=["convert", "list"], default=None,
|
|
help="Nur eine Aktionsart ausfuehren")
|
|
grp = parser.add_mutually_exclusive_group()
|
|
grp.add_argument("--dry-run", action="store_true", default=True,
|
|
help="Nur anzeigen (Default)")
|
|
grp.add_argument("--apply", action="store_true",
|
|
help="Aenderungen tatsaechlich schreiben")
|
|
parser.add_argument("--verbose", "-v", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
logging.basicConfig(
|
|
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
format="%(levelname)-7s %(message)s",
|
|
)
|
|
dry_run = not args.apply
|
|
|
|
project_root = Path(os.environ.get("PROJECT", Path(__file__).resolve().parent.parent))
|
|
inv_path = Path(args.inventory) if args.inventory else project_root / "results" / "fortna_inventory.json"
|
|
if not inv_path.exists():
|
|
log.error("Inventar nicht gefunden: %s (erst fortna_inventory.py laufen lassen)", inv_path)
|
|
sys.exit(1)
|
|
|
|
inv = load_inventory(inv_path)
|
|
source_root = Path(args.source_dir) if args.source_dir else Path(inv["source"])
|
|
chapters_dir = Path(args.chapters_dir) if args.chapters_dir else project_root / "chapters"
|
|
|
|
log.info("Quelle: %s", source_root)
|
|
log.info("Ziel: %s", chapters_dir)
|
|
log.info("Sprache: %s | Modus: %s", args.lang, "APPLY" if args.apply else "DRY-RUN")
|
|
|
|
items = [i for i in inv["items"] if i["lang"] == args.lang and i["chapter"]]
|
|
|
|
# 1. Volltext-Konvertierung (nur Zielsprache)
|
|
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"
|
|
symbol_registry: dict = {}
|
|
for it in conv:
|
|
convert_manual(it, source_root, chapters_dir, dry_run,
|
|
symbols_dir=symbols_dir, symbol_registry=symbol_registry)
|
|
if not dry_run:
|
|
log.info("Symbole zentral: %d eindeutige -> %s",
|
|
len(symbol_registry), symbols_dir)
|
|
|
|
# 2. Verzeichnislisten
|
|
if args.only in (None, "list"):
|
|
# Zielsprache bevorzugen; Kapitel, die in der Zielsprache KEINE list-Datei
|
|
# haben (z.B. Ersatzteilliste nur EN), mit den vorhandenen Fremdsprachen
|
|
# auffuellen, damit das Kapitel nicht leer bleibt.
|
|
all_list = [i for i in inv["items"]
|
|
if i["chapter"] and i["action"] == "list"]
|
|
chapters_with_lang = {i["chapter"] for i in all_list if i["lang"] == args.lang}
|
|
list_items = [i for i in all_list
|
|
if i["lang"] == args.lang or i["chapter"] not in chapters_with_lang]
|
|
by_chapter: dict[str, list[dict]] = defaultdict(list)
|
|
for it in list_items:
|
|
by_chapter[it["chapter"]].append(it)
|
|
log.info("--- %d Verzeichnislisten erzeugen ---", len(by_chapter))
|
|
for chapter in sorted(by_chapter):
|
|
generate_index(chapter, by_chapter[chapter], source_root, chapters_dir, dry_run)
|
|
|
|
if dry_run:
|
|
log.info("DRY-RUN abgeschlossen. Mit --apply tatsaechlich schreiben.")
|
|
else:
|
|
log.info("Fertig.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|