""" convert_to_rst.py - Konvertiert Word-Dokumente (.docx) und Word-erstellte PDFs nach .rst Zwei-Stufen-Ansatz: 1. Textextraktion: .docx via mammoth -> HTML, PDF via pdfminer -> Text 2. Strukturerkennung: HTML/Text -> .rst mit Ueberschriften-Hierarchie Verwendung: python convert_to_rst.py [--dry-run] [--lang de] """ from __future__ import annotations import argparse import html as html_module import logging import os import re import sys from dataclasses import dataclass, field from pathlib import Path from typing import Optional # --------------------------------------------------------------------------- # Konfiguration: Mapping Quelldatei -> Zielkapitel # --------------------------------------------------------------------------- # Muster im Dateinamen -> Zielkapitel-Pfad (relativ zu chapters/) CHAPTER_MAPPING: list[tuple[re.Pattern[str], str]] = [ # 02_INSTRUCTIONS -> 04_betrieb, 05_wartung, 06_steuerung (re.compile(r"betriebsanleitung", re.I), "04_betrieb/betriebsanleitung"), (re.compile(r"wartungsanleitung|wartung", re.I), "05_wartung/wartungsanleitung"), (re.compile(r"funktion.*(steuerung|control)|steuerungsbeschreibung", re.I), "06_steuerung"), # 01_CERTIFICATES (re.compile(r"konfigurationsbeschreibung|configuration.description", re.I), "01_zertifikate/konfigurationsbeschreibung"), (re.compile(r"konformit|conformity|declaration", re.I), "01_zertifikate/konformitaetserklaerung"), # 00_CONTENTS (re.compile(r"contens|inhaltsverzeichnis|contents", re.I), "00_inhaltsverzeichnis"), # 03_SPARE_PARTS -> 07_ersatzteile (re.compile(r"spare.part|ersatzteil", re.I), "07_ersatzteile"), # 05_REPORTS -> 10_anhang (re.compile(r"wiederanlauf|drehrichtung|erprobung|protokoll|pr[uü]f", re.I), "10_anhang/pruefberichte"), # Risikobewertung (RB_ prefix) (re.compile(r"^RB_|risikobewertung|risikobeurteilung", re.I), "02_sicherheit/risikobewertung"), ] # Benutzerdefinierte Word-Styles -> Heading-Level CUSTOM_STYLE_MAP = { "Ü1-ILS": 1, "Ü2-ILS": 2, "Ü3-ILS": 3, "Ü4-ILS": 4, "Ü1": 1, "Ü2": 2, "Ü3": 3, "Ü4": 4, } # mammoth style_map String MAMMOTH_STYLE_MAP = "\n".join([ "p[style-name='Ü1-ILS'] => h1:fresh", "p[style-name='Ü2-ILS'] => h2:fresh", "p[style-name='Ü3-ILS'] => h3:fresh", "p[style-name='Ü4-ILS'] => h4:fresh", "p[style-name='Heading 1'] => h1:fresh", "p[style-name='Heading 2'] => h2:fresh", "p[style-name='Heading 3'] => h3:fresh", "p[style-name='Heading 4'] => h4:fresh", "p[style-name='Doku-Text'] => p:fresh", "p[style-name='Doku-1Aufzähl'] => ul > li:fresh", "p[style-name='Doku-1Aufzählung'] => ul > li:fresh", "p[style-name='Doku-2fettAufzählung'] => ul > li:fresh", "p[style-name='Caption'] => p:fresh", "p[style-name='Text'] => p:fresh", "p[style-name='Doku-Text fett'] => p:fresh", "p[style-name='Doku-Text12fett'] => p:fresh", "p[style-name='Vorgabetext'] => p:fresh", ]) # rST Ueberschriften-Zeichen nach Level RST_HEADING_CHARS = {1: "=", 2: "-", 3: "~", 4: "^", 5: '"'} log = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Datenklassen # --------------------------------------------------------------------------- @dataclass class RstBlock: """Ein Block im .rst-Dokument.""" kind: str # "heading", "paragraph", "list_item", "image", "table", "empty" text: str = "" level: int = 0 # nur fuer headings image_path: str = "" # nur fuer images @dataclass class ConversionResult: """Ergebnis einer Konvertierung.""" source_path: Path target_chapter: str rst_filename: str blocks: list[RstBlock] = field(default_factory=list) images: dict[str, bytes] = field(default_factory=dict) # filename -> data warnings: list[str] = field(default_factory=list) # --------------------------------------------------------------------------- # Kapitel-Erkennung # --------------------------------------------------------------------------- def detect_chapter(filename: str) -> str: """Bestimmt das Zielkapitel anhand des Dateinamens.""" for pattern, chapter in CHAPTER_MAPPING: if pattern.search(filename): return chapter return "10_anhang" # Fallback def detect_steuerung_subdir(filename: str) -> str: """Erkennt das Steuerungssystem fuer Unterordner in 06_steuerung/.""" lower = filename.lower() if "loading" in lower or "loadingloop" in lower.replace("_", ""): return "loading_loop" if "omniflo" in lower: return "omniflo" if "ils" in lower: return "ils" return "" def make_rst_filename(source_name: str) -> str: """Erzeugt einen sauberen .rst-Dateinamen.""" stem = Path(source_name).stem # Versionsnummern und Datumsangaben vereinfachen slug = stem.lower() slug = re.sub(r"[^a-z0-9_]+", "_", slug) slug = re.sub(r"_+", "_", slug).strip("_") return slug + ".de.rst" # --------------------------------------------------------------------------- # Konvertierung: .docx -> HTML -> rST-Blocks # --------------------------------------------------------------------------- def convert_docx_to_blocks(docx_path: Path) -> tuple[list[RstBlock], dict[str, bytes], list[str]]: """Konvertiert .docx via mammoth nach rST-Blocks.""" import mammoth blocks: list[RstBlock] = [] images: dict[str, bytes] = {} warnings: list[str] = [] image_counter = 0 def handle_image(image): nonlocal image_counter image_counter += 1 ext = image.content_type.split("/")[-1] if image.content_type else "png" if ext == "jpeg": ext = "jpg" img_name = f"img_{image_counter:03d}.{ext}" with image.open() as img_file: images[img_name] = img_file.read() return {"src": img_name} with open(docx_path, "rb") as f: result = mammoth.convert_to_html( f, style_map=MAMMOTH_STYLE_MAP, convert_image=mammoth.images.img_element(handle_image), ) html = result.value for msg in result.messages: warnings.append(str(msg)) # HTML parsen -> Blocks blocks = _html_to_blocks(html) return blocks, images, warnings def _html_to_blocks(html: str) -> list[RstBlock]: """Parst mammoth-HTML in RstBlock-Liste.""" blocks: list[RstBlock] = [] # Einfacher Regex-basierter Parser (mammoth erzeugt sauberes HTML) # Aufteilung in Top-Level-Elemente elements = re.split(r"(]*>)", html) current_text = "" current_kind = "paragraph" current_level = 0 in_list = False in_table = False for elem in elements: if not elem: continue # Oeffnende Tags heading_match = re.match(r"]*)?>", elem) if heading_match: _flush_block(blocks, current_kind, current_text, current_level) current_text = "" current_kind = "heading" current_level = int(heading_match.group(1)) continue if re.match(r"", elem): _flush_block(blocks, current_kind, current_text, current_level) current_text = "" current_kind = "paragraph" current_level = 0 continue if re.match(r"]*>", elem): _flush_block(blocks, current_kind, current_text, current_level) current_text = "" in_list = True continue if re.match(r"", elem): in_list = False continue if re.match(r"]*>", elem): _flush_block(blocks, current_kind, current_text, current_level) current_text = "" current_kind = "list_item" continue if re.match(r"", elem): _flush_block(blocks, current_kind, current_text, current_level) current_text = "" current_kind = "paragraph" continue if re.match(r"]*>", elem): _flush_block(blocks, current_kind, current_text, current_level) current_text = "" if current_kind != "heading": current_kind = "paragraph" continue if re.match(r"

", elem): _flush_block(blocks, current_kind, current_text, current_level) current_text = "" current_kind = "paragraph" current_level = 0 continue # Bilder img_match = re.search(r']*src="([^"]+)"[^>]*/?\s*>', elem) if img_match: src = img_match.group(1) if not src.startswith("data:"): # Skip base64 inline images blocks.append(RstBlock(kind="image", image_path=src)) continue # table-Tags: vereinfacht als Text-Block if re.match(r"]*>", elem): _flush_block(blocks, current_kind, current_text, current_level) current_text = "" in_table = True continue if re.match(r"", elem): in_table = False _flush_block(blocks, "paragraph", current_text, 0) current_text = "" continue # Text-Inhalt: HTML-Tags entfernen text = re.sub(r"<[^>]+>", "", elem) text = html_module.unescape(text) current_text += text _flush_block(blocks, current_kind, current_text, current_level) return blocks def _flush_block(blocks: list[RstBlock], kind: str, text: str, level: int) -> None: """Fuegt einen Block hinzu wenn Text vorhanden.""" clean = text.strip() if not clean: return # Tab-getrennte Inhalte bereinigen clean = re.sub(r"\t+", " ", clean) blocks.append(RstBlock(kind=kind, text=clean, level=level)) # --------------------------------------------------------------------------- # Konvertierung: PDF -> Text -> rST-Blocks # --------------------------------------------------------------------------- def convert_pdf_to_blocks(pdf_path: Path) -> tuple[list[RstBlock], list[str]]: """Konvertiert PDF via pdfminer nach rST-Blocks.""" from pdfminer.high_level import extract_text warnings: list[str] = [] try: text = extract_text(str(pdf_path)) except Exception as e: warnings.append(f"PDF-Extraktion fehlgeschlagen: {e}") return [], warnings blocks = _text_to_blocks(text) return blocks, warnings def _text_to_blocks(text: str) -> list[RstBlock]: """Heuristik: Plain-Text in Blocks mit Ueberschriften-Erkennung.""" blocks: list[RstBlock] = [] lines = text.split("\n") i = 0 while i < len(lines): line = lines[i].rstrip() if not line.strip(): i += 1 continue # Heuristik: Nummerierte Ueberschriften (1, 1.1, 1.1.1 etc.) heading_match = re.match(r"^(\d+(?:\.\d+)*)\s+(.+)$", line.strip()) if heading_match: num = heading_match.group(1) title = heading_match.group(2).strip() level = min(num.count(".") + 1, 4) # Pruefen ob die naechste Zeile leer ist (typisch fuer Ueberschriften) next_empty = (i + 1 < len(lines) and not lines[i + 1].strip()) # Oder ob der Text kurz genug fuer eine Ueberschrift ist if next_empty or len(title) < 80: blocks.append(RstBlock(kind="heading", text=f"{num} {title}", level=level)) i += 1 continue # Aufzaehlungszeichen list_match = re.match(r"^\s*[•\-–]\s+(.+)$", line.strip()) if list_match: blocks.append(RstBlock(kind="list_item", text=list_match.group(1).strip())) i += 1 continue # Normaler Absatz: Sammle zusammenhaengende Zeilen para_lines = [line.strip()] i += 1 while i < len(lines) and lines[i].strip() and not re.match(r"^\d+(?:\.\d+)*\s+", lines[i].strip()): if re.match(r"^\s*[•\-–]\s+", lines[i].strip()): break para_lines.append(lines[i].strip()) i += 1 para_text = " ".join(para_lines) if para_text: blocks.append(RstBlock(kind="paragraph", text=para_text)) return blocks # --------------------------------------------------------------------------- # Blocks -> .rst Rendering # --------------------------------------------------------------------------- def render_rst(blocks: list[RstBlock], title: str = "") -> str: """Rendert RstBlock-Liste als .rst-String.""" lines: list[str] = [] if title: lines.append(RST_HEADING_CHARS[1] * len(title)) lines.append(title) lines.append(RST_HEADING_CHARS[1] * len(title)) lines.append("") prev_kind = "" for block in blocks: if block.kind == "heading": text = block.text if not text: continue level = max(1, min(block.level, 5)) char = RST_HEADING_CHARS[level] lines.append("") if level == 1: lines.append(char * len(text)) lines.append(text) lines.append(char * len(text)) lines.append("") elif block.kind == "paragraph": if prev_kind != "paragraph": lines.append("") lines.append(block.text) lines.append("") elif block.kind == "list_item": lines.append(f"- {block.text}") elif block.kind == "image": lines.append("") lines.append(f".. figure:: images/{block.image_path}") lines.append(" :align: center") lines.append("") prev_kind = block.kind # Bereinigung: Mehrfache Leerzeilen reduzieren rst = "\n".join(lines) rst = re.sub(r"\n{3,}", "\n\n", rst) return rst.strip() + "\n" # --------------------------------------------------------------------------- # Erkennung: Ist PDF Word-erstellt? # --------------------------------------------------------------------------- def is_word_pdf(pdf_path: Path) -> bool: """Prueft ob ein PDF mit Microsoft Word erstellt wurde.""" from pdfminer.pdfparser import PDFParser from pdfminer.pdfdocument import PDFDocument try: with open(pdf_path, "rb") as f: parser = PDFParser(f) doc = PDFDocument(parser) if not doc.info: return False info = doc.info[0] def decode(v: object) -> str: if isinstance(v, bytes): # UTF-16 BOM erkennen if v[:2] in (b"\xff\xfe", b"\xfe\xff"): return v.decode("utf-16", errors="replace") return v.decode("utf-8", errors="replace") return str(v) if v else "" creator = decode(info.get("Creator", b"")) producer = decode(info.get("Producer", b"")) combined = (creator + " " + producer).lower() return "word" in combined or "microsoft" in combined except Exception: return False # --------------------------------------------------------------------------- # Hauptlogik # --------------------------------------------------------------------------- def find_sources(input_dir: Path, lang: str = "de") -> list[Path]: """Findet alle konvertierbaren Quelldateien (DE).""" sources: list[Path] = [] # 1. .docx am Root for p in sorted(input_dir.glob("*.docx")): sources.append(p) # 2. Deutsche PDFs aus Unterordnern lang_patterns = [f"*{lang}*", f"*{lang.upper()}*", f"*DE*", f"*_de_*"] de_dirs = [f"*{lang}*", f"*{lang.upper()}*", "*DE*", "*original*"] for pdf in sorted(input_dir.rglob("*.pdf")): rel = pdf.relative_to(input_dir) parts_lower = str(rel).lower() # Nur DE-Dateien oder Dateien aus DE-Ordnern is_de = False for part in rel.parts: pl = part.lower() if "de" in pl.split("_") or pl.startswith("01_de") or pl.endswith("_de"): is_de = True break if "_de." in parts_lower or "_de_" in parts_lower: is_de = True # Dateien im Root (ohne Sprachordner) sind auch DE if len(rel.parts) <= 2: is_de = True if not is_de: continue # Nur Word-erstellte PDFs if is_word_pdf(pdf): sources.append(pdf) return sources def convert_file(source: Path, input_dir: Path, chapters_dir: Path) -> ConversionResult: """Konvertiert eine einzelne Datei und bestimmt Zielkapitel.""" filename = source.name # Kapitel bestimmen chapter = detect_chapter(filename) # Spezialfall: Steuerung -> Unterordner if chapter == "06_steuerung": sub = detect_steuerung_subdir(filename) if sub: chapter = f"06_steuerung/{sub}" rst_filename = make_rst_filename(filename) result = ConversionResult( source_path=source, target_chapter=chapter, rst_filename=rst_filename, ) # Konvertierung if source.suffix.lower() == ".docx": blocks, images, warnings = convert_docx_to_blocks(source) result.blocks = blocks result.images = images result.warnings = warnings elif source.suffix.lower() == ".pdf": blocks, warnings = convert_pdf_to_blocks(source) result.blocks = blocks result.warnings = warnings else: result.warnings.append(f"Unbekanntes Format: {source.suffix}") return result def write_result(result: ConversionResult, chapters_dir: Path, dry_run: bool = False) -> None: """Schreibt das Konvertierungsergebnis ins Dateisystem.""" target_dir = chapters_dir / result.target_chapter target_file = target_dir / result.rst_filename # Titel aus erstem Heading oder Dateiname title = "" for block in result.blocks: if block.kind == "heading" and block.text: title = block.text break if not title: title = result.source_path.stem rst_content = render_rst(result.blocks, title="") if dry_run: log.info("[DRY-RUN] %s -> %s (%d blocks, %d images)", result.source_path.name, target_file.relative_to(chapters_dir), len(result.blocks), len(result.images)) return # Ordner anlegen target_dir.mkdir(parents=True, exist_ok=True) # .rst schreiben target_file.write_text(rst_content, encoding="utf-8") log.info("Geschrieben: %s (%d blocks)", target_file.relative_to(chapters_dir), len(result.blocks)) # Bilder speichern if result.images: img_dir = target_dir / "images" img_dir.mkdir(exist_ok=True) for img_name, img_data in result.images.items(): img_file = img_dir / img_name img_file.write_bytes(img_data) log.info(" %d Bilder nach %s", len(result.images), img_dir.relative_to(chapters_dir)) def main() -> None: parser = argparse.ArgumentParser( description="Konvertiert Word-Dokumente (.docx) und Word-PDFs nach .rst" ) parser.add_argument("input_dir", type=Path, help="Quellverzeichnis mit .docx/.pdf Dateien") parser.add_argument("output_dir", type=Path, help="Ziel-chapters/ Verzeichnis") parser.add_argument("--dry-run", action="store_true", help="Nur anzeigen, nichts schreiben") parser.add_argument("--lang", default="de", help="Zielsprache (default: de)") parser.add_argument("--verbose", "-v", action="store_true", help="Ausfuehrliche Ausgabe") args = parser.parse_args() logging.basicConfig( level=logging.DEBUG if args.verbose else logging.INFO, format="%(levelname)-7s %(message)s", ) input_dir = args.input_dir.resolve() chapters_dir = args.output_dir.resolve() if not input_dir.exists(): log.error("Quellverzeichnis nicht gefunden: %s", input_dir) sys.exit(1) if not chapters_dir.exists(): log.error("Zielverzeichnis nicht gefunden: %s", chapters_dir) sys.exit(1) # Quellen finden log.info("Suche Quelldateien in %s ...", input_dir) sources = find_sources(input_dir, args.lang) log.info("Gefunden: %d Dateien", len(sources)) if not sources: log.warning("Keine konvertierbaren Dateien gefunden.") sys.exit(0) # Anzeige for src in sources: rel = src.relative_to(input_dir) chapter = detect_chapter(src.name) if chapter == "06_steuerung": sub = detect_steuerung_subdir(src.name) if sub: chapter = f"06_steuerung/{sub}" log.info(" %s -> %s", rel, chapter) print() # Konvertierung success = 0 errors = 0 for src in sources: try: result = convert_file(src, input_dir, chapters_dir) if result.blocks: write_result(result, chapters_dir, dry_run=args.dry_run) success += 1 else: log.warning("Keine Inhalte extrahiert: %s", src.name) errors += 1 for w in result.warnings: log.warning(" %s: %s", src.name, w) except Exception as e: log.error("Fehler bei %s: %s", src.name, e) errors += 1 print() log.info("Fertig: %d erfolgreich, %d Fehler", success, errors) if __name__ == "__main__": main()