Files
docu_build/lib/fortna_convert_chapters.py
T
Michael StanglandClaude Sonnet 5 982879d9f0 fix: Ueberschriften-Erkennung, Bild-Referenzierung und Symbol-Qualitaet
Behebt mehrere Extraktionsprobleme in lib/pdf_extract.py, die zu doppelter
Nummerierung, sinnentleertem Text und falsch dimensionierten Symbolen fuehrten:

- Ueberschriften nur noch bei grosser/fetter Quellschrift (>= 12.5pt oder bold)
  statt bei jeder Zeile mit Nummern-Praefix. Vorher wurden hunderte normale
  Textzeilen (Querverweise, Aufzaehlungen) faelschlich zu Ueberschriften.
- Bildunterschriften ("Abbildung N: ...") werden nie mehr als Ueberschrift
  gerendert (Caption-Erkennung vor Heading-Check, Merge-Regel verschaerft),
  sondern als kursiver Absatz bzw. figure-Caption.
- Symbole (Warn-/Gebots-/Verbotspiktogramme) werden per Piktogramm-Heuristik
  (Signalfarben + wenige Flaechenfarben) erkannt statt nur ueber Haeufigkeit -
  findet auch Symbole, die im Dokument nur 1x vorkommen (z.B. Warndreiecke).
  Auf quadratische Canvas normalisiert (normalize_symbol) und einheitlich
  klein referenziert (:height: 10mm, :align: left), damit alle Symbole
  unabhaengig vom Seitenverhaeltnis der Quelle gleich gross erscheinen.
- Junk-Erkennung erweitert: gerenderte Kapitelnummern-Bilder, Barcodes und
  Foto-Ausschnitte werden nicht mehr als Symbol/Inhaltsbild extrahiert.
- Manuelle SYMBOL_BLOCKLIST (aHash-basiert) fuer sichtgepruefte Fehlklassi-
  fikationen (Foto-/3D-/Diagramm-Fragmente), damit sie bei erneuter
  Konvertierung nicht wieder auftauchen.
- Verzeichnislisten (Lieferantendoku etc.) werden im LaTeX-Build quer
  (landscape via pdflscape) gesetzt, da die langen Quellpfade in Hochformat
  nicht passten.

assets/shared/symbols/: auf normalisierte .png umgestellt, 7 Fehlklassi-
fikationen (Fotos/3D-Renderings/Diagramme statt Piktogramme) entfernt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:23:29 +02:00

378 lines
16 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",
}
# 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
# ---------------------------------------------------------------------------
def convert_manual(item: dict, source_root: Path, chapters_dir: Path,
dry_run: bool, symbols_dir: Path | None = None,
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 / chapter
stem = slugify(item["filename"])
rst_path = chapter_dir / f"{stem}.de.rst"
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(" Bilder -> %s (Fotos -> %s)",
image_dest.relative_to(chapters_dir.parent),
photo_dest.relative_to(chapters_dir.parent))
if dry_run:
return
if not src_pdf.exists():
log.error(" Quelle fehlt: %s", src_pdf)
return
# Zielordner vorbereiten und ALLE vorher existierenden Bilder darin loeschen.
chapter_dir.mkdir(parents=True, exist_ok=True)
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()
doc_title = CHAPTER_DOC_TITLE.get(chapter, slugify(item["filename"]))
blocks, img_count = pdf_extract.extract_manual(
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)
# ---------------------------------------------------------------------------
# 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("")
# Die Verzeichnistabelle enthaelt lange Quellpfade und ist fuer Hochformat zu
# breit -> im LaTeX/PDF-Build quer (landscape) setzen. Fuer HTML wird der
# raw:latex-Block ignoriert (dort bricht die Tabelle ohnehin um).
lines.append(".. raw:: latex")
lines.append("")
lines.append(" \\begin{landscape}")
lines.append("")
lines.append(".. list-table::")
lines.append(" :header-rows: 1")
lines.append(" :widths: 30 16 8 46")
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("")
lines.append(".. raw:: latex")
lines.append("")
lines.append(" \\end{landscape}")
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 + 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,
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 | Logos: %d -> assets/shared/",
len(symbol_registry), len(logo_registry))
# 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()