fix: saubere PDF-Extraktion der Fortna-Anleitungen (lib/pdf_extract.py)

Neues Modul lib/pdf_extract.py fuer die Volltext-Konvertierung der 5
Hauptanleitungen, behebt die Qualitaetsprobleme der ersten Version:

- Doppelte Nummerierung entfernt: PDF-Gliederungsnummer wird nur fuer die
  Ueberschriften-Ebene ausgewertet, im RST-Text weggelassen (Sphinx nummeriert)
- Bilder positionsbasiert (bbox) extrahiert und als .. figure:: mit Caption
  an der richtigen Textstelle referenziert (625 Refs, 0 fehlende Bilder)
- Fliesstext zu Absaetzen zusammengefuehrt statt je PDF-Zeile ein Absatz
- Aufzaehlungen als RST-Listen (Bullet-only-Zeilen mit Folgezeile gemergt,
  zusammengelaufene Bullets aufgesplittet)
- Aenderungshistorie-/Dokumentinfo-Tabellen und TOC-Seiten uebersprungen
- Symbolfont-Zeichen (PUA) auf Unicode gemappt

fortna_convert_chapters.py: nutzt pdf_extract statt extract_products, lesbare
Dokumenttitel je Kapitel, Verzeichnislisten fuellen Kapitel ohne DE-Quelle mit
vorhandenen Fremdsprachen (Ersatzteilliste EN).
build_index.py / conf.py: Fremd-Altdateien aus dem Build ausgeschlossen.
Alte Test-.rst aus fruehen convert_to_rst.py-Laeufen entfernt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Stangl
2026-09-23 13:44:09 +02:00
co-authored by Claude Opus 4.8
parent e7aa3b4e4b
commit c912b1e9d5
5 changed files with 372 additions and 14 deletions
+2
View File
@@ -62,6 +62,8 @@ exclude_patterns = [
# TODO: mit fortna_convert_chapters.py sauber neu erzeugen, dann Exclude entfernen.
"chapters/02_sicherheit/risikobewertung/rb_fortna_h_m_500592_ev12_20260311.de.rst",
"chapters/00_inhaltsverzeichnis/contens_obsah_500592_135776_v2_1_en_cz.de.rst",
"chapters/10_anhang/01_getriebe_loadingloopantr_de.de.rst",
"chapters/10_anhang/02_motor_loadingloopantr_de.de.rst",
]
# -- HTML -------------------------------------------------------------------
+30 -2
View File
@@ -33,11 +33,39 @@ Ergebnis des ersten Laufs:
(polyglossia 2.13 wirft unter lualatex einen bidi-Fehler), **`iconpackage=none`**
(FontAwesome7 in MiKTeX bricht unter lualatex ab). Beides in `conf.py` kommentiert.
### Extraktion: neues Modul `lib/pdf_extract.py`
Die Volltext-Konvertierung der 5 Hauptanleitungen läuft über das neue Modul
`lib/pdf_extract.py` (`extract_manual()`), das gegenüber der ersten Version deutlich
sauberere `.rst` erzeugt:
- **Keine doppelte Nummerierung mehr:** Die im PDF vorhandene Gliederungsnummer
(z. B. „1.7 Wichtige Informationen") wird nur zur Bestimmung der Überschriften-Ebene
ausgewertet und im RST-Text **entfernt**. Sphinx/LaTeX nummeriert selbst → vorher
„3.2.1 1.1 Wichtige Informationen", jetzt nur noch „3.2.1 Wichtige Informationen".
- **Überschriften-Erkennung** nur bei führender PDF-Gliederungsnummer (kein „fett allein"),
damit Tabellenzellen nicht fälschlich zu Überschriften werden.
- **Bilder positionsbasiert extrahiert und referenziert:** pro Seite werden Text und Bilder
nach Y-Position verwoben; jedes Bild wird als `.. figure::` mit korrektem Pfad und – falls
vorhanden – der folgenden „Abbildung N: …"-Zeile als Caption eingebaut. (625 figure-Refs,
0 fehlende Bilddateien.) Duplikate werden per MD5 entfernt, Titelseiten-Deko und
Vollseiten-Hintergründe herausgefiltert.
- **Fließtext zu Absätzen zusammengeführt:** PDF-Zeilenumbrüche werden nicht mehr zu je einem
Absatz; zusammenhängende Zeilen bilden einen Absatz, Satzende beendet ihn.
- **Aufzählungen als RST-Listen:** einzeln stehende Bullet-Zeichen werden mit der Folgezeile
zusammengeführt, in eine Zeile zusammengelaufene Bullets aufgesplittet → `-`-Listenpunkte.
- **Änderungshistorie-/Dokumentinfo-Tabellen** am Dokumentanfang werden übersprungen (zerfielen
vorher in sinnlose Einzelzellen), ebenso Inhaltsverzeichnis-Seiten und Kopf-/Fußzeilen.
### Behobene Extraktionsprobleme
- **Symbolfont-Zeichen** (Wingdings/Symbol Private-Use-Area, z. B. Aufzählungs-Bullet U+F0B7):
in `extract_products.py` via `clean_pua()` auf echte Unicode-Zeichen (•, →, ✓) gemappt,
restliche PUA-Zeichen entfernt. Vorher erschienen leere Kästchen im PDF.
via `clean_pua()` auf echte Unicode-Zeichen (•, →, ✓) gemappt, restliche PUA-Zeichen entfernt.
Vorher erschienen leere Kästchen im PDF. (In `lib/pdf_extract.py`; die Variante in
`extract_products.py` bleibt für die Produktprogramm-Extraktion bestehen.)
- **Fremd-Altdateien** aus früheren `convert_to_rst.py`-Testläufen (NORD-Getriebe/-Motor im
Anhang, alte Risikobewertung/Contents/Konfig/Ersatzteile-Volltexte) wurden entfernt; die
betroffenen Kapitel liegen konzeptkonform als Verzeichnisliste vor.
### Bekannte Restpunkte (nicht Teil des Auftrags-Scopes)
+4
View File
@@ -22,6 +22,10 @@ log = logging.getLogger(__name__)
SKIP = {
"02_sicherheit/risikobewertung/rb_fortna_h_m_500592_ev12_20260311.de.rst",
"00_inhaltsverzeichnis/contens_obsah_500592_135776_v2_1_en_cz.de.rst",
# NORD-Antriebsdoku aus fruehem Testlauf: gehoert als Lieferantendoku in die
# Verzeichnisliste 08_lieferantendoku/antriebe, nicht als Volltext ins Anhang.
"10_anhang/01_getriebe_loadingloopantr_de.de.rst",
"10_anhang/02_motor_loadingloopantr_de.de.rst",
}
# Lesbare Titel je Top-Level-Kapitelordner
+29 -12
View File
@@ -34,8 +34,9 @@ import sys
from collections import defaultdict
from pathlib import Path
# extract_products stellt die robuste PDF-Extraktion bereit (gleiches lib/-Verzeichnis)
import extract_products as ep
# pdf_extract stellt die positionsbasierte Text+Bild-Extraktion bereit
# (verwebt Text und Bilder, entfernt PDF-Ueberschriftennummern).
import pdf_extract
log = logging.getLogger(__name__)
@@ -71,6 +72,16 @@ def load_inventory(path: Path) -> dict:
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
# ---------------------------------------------------------------------------
@@ -104,14 +115,13 @@ def convert_manual(item: dict, source_root: Path, chapters_dir: Path,
for old in chapter_dir.glob("*.de.rst"):
old.unlink()
# Bilder extrahieren (extract_products entfernt kleine Icons < 5KB automatisch)
img_count = ep.extract_images(src_pdf, img_dir)
dup = ep.dedup_images(img_dir)
log.info(" %d Bilder (%d Duplikate entfernt)", img_count, dup)
# Text nach RST extrahieren (positionsbasiert, Kopf-/Fusszeilen gefiltert)
blocks = ep.extract_text_to_rst(src_pdf, rst_path)
log.info(" %d Textbloecke -> %s", blocks, rst_path.name)
# 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)
log.info(" %d Textbloecke, %d Bilder (referenziert) -> %s",
blocks, img_count, rst_path.name)
# ---------------------------------------------------------------------------
@@ -254,7 +264,7 @@ def main() -> None:
items = [i for i in inv["items"] if i["lang"] == args.lang and i["chapter"]]
# 1. Volltext-Konvertierung
# 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))
@@ -263,7 +273,14 @@ def main() -> None:
# 2. Verzeichnislisten
if args.only in (None, "list"):
list_items = [i for i in items if i["action"] == "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)
+307
View File
@@ -0,0 +1,307 @@
"""Positionsbasierte PDF-Extraktion fuer die Fortna-Hauptanleitungen.
Erzeugt aus einem Word-generierten PDF eine .rst-Datei, in der Text und Bilder
in der richtigen Lesereihenfolge (nach Y-Position pro Seite) verwoben sind.
Verbesserungen gegenueber extract_products.extract_text_to_rst:
1. Ueberschriften: Die im PDF vorhandene Nummerierung (z.B. "1.7 Wichtige
Informationen") wird NUR zur Bestimmung der Gliederungstiefe ausgewertet,
im RST-Ausgabetext aber ENTFERNT. Sphinx/LaTeX nummeriert selbst - dadurch
keine doppelte Nummerierung (alt 1.7 + neu 3.2.1) mehr.
2. Bilder: werden positionsbasiert (bbox) pro Seite eingelesen, dedupliziert
und an der korrekten Textstelle als `.. figure::`-Direktive referenziert.
Bildunterschriften ("Abbildung N: ...") werden als figure-caption uebernommen.
3. Kopf-/Fusszeilen und Inhaltsverzeichnis-Seiten werden herausgefiltert.
Oeffentliche Funktion: extract_manual(pdf_path, rst_path, img_dir) -> (blocks, images)
"""
from __future__ import annotations
import hashlib
import logging
import re
from pathlib import Path
log = logging.getLogger(__name__)
RST_H = {1: "=", 2: "-", 3: "~", 4: "^", 5: '"'}
# Symbolfont-Zeichen (Private Use Area) auf Unicode abbilden bzw. entfernen.
PUA_MAP = {
"": "•", "": "•", "": "•",
"": "→", "": "→", "": "←", "": "✓",
"": " ",
}
_PUA_RE = re.compile("|".join(re.escape(k) for k in PUA_MAP))
_PUA_REST_RE = re.compile(r"[-]")
# Rausch-Muster (Kopf/Fuss/Metadaten), die nicht in den Text gehoeren.
_NOISE = {
"services cz s.r.o", "schönenberger systeme gmbh",
"justus-von-liebig-str", "info@schoenenberger.de",
"www.schoenenberger.de", "www.schoenenberger-systems.com",
"karolinska 661/4",
}
RE_DATE = re.compile(r"^\d{2}\.\d{2}\.\d{4}$")
RE_PAGENO = re.compile(r"^(seite|page)\s+\d+$", re.I)
RE_PHONE = re.compile(r"^[TF]:\s*\+?\d")
# Ueberschrift mit fuehrender Nummer: "1.7 Wichtige Informationen"
# Nummernteile max 3-stellig (verhindert Datum/Artikelnummer als Ueberschrift).
RE_HEADING = re.compile(r"^(\d{1,3}(?:\.\d{1,3}){0,4})\s+([A-Za-zÄÖÜäöüß].{2,})$")
# reine Nummernzeile (Nummer und Titel stehen im PDF auf getrennten Zeilen)
RE_NUM_ONLY = re.compile(r"^(\d{1,3}(?:\.\d{1,3}){0,4})\.?$")
# Bildunterschrift "Abbildung 17: ..." / "Abb. 17 ..." / "Figure 3: ..."
RE_CAPTION = re.compile(r"^(Abbildung|Abb\.?|Bild|Figure|Tabelle)\s*\d+\s*[:.]?\s*(.*)$", re.I)
def clean_pua(text: str) -> str:
"""Symbolfont-Zeichen ersetzen, restliche PUA-Zeichen entfernen."""
text = _PUA_RE.sub(lambda m: PUA_MAP[m.group(0)], text)
return _PUA_REST_RE.sub("", text)
def _is_noise(text: str) -> bool:
low = text.lower().strip()
if not low:
return True
if RE_DATE.match(low) or RE_PAGENO.match(low) or RE_PHONE.match(text):
return True
return any(kw in low for kw in _NOISE)
def _strip_heading_number(num: str, rest: str) -> str:
"""Gibt den Ueberschriftentext ohne fuehrende Gliederungsnummer zurueck."""
return rest.strip()
def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
title: str | None = None) -> tuple[int, int]:
"""Extrahiert Text + Bilder eines Anleitungs-PDF in eine .rst-Datei.
Returns:
(anzahl_textbloecke, anzahl_bilder)
"""
import fitz
doc = fitz.open(str(pdf_path))
img_dir.mkdir(parents=True, exist_ok=True)
# --- Seiten identifizieren, die uebersprungen werden ---
# a) TOC-Seiten (Punktfueller / "Inhaltsverzeichnis")
# b) Aenderungshistorie-/Dokumentinfo-Tabellen ("Aenderungsinformation",
# Spaltenkombination Version+Datum+Aenderungsbeschreibung). Diese Tabellen
# zerfallen beim linearen Auslesen in sinnlose Einzelzellen.
skip_pages: set[int] = set()
for pn in range(len(doc)):
t = doc[pn].get_text()
low = t.lower()
if t.count("....") >= 3 or "\ninhaltsverzeichnis\n" in low:
skip_pages.add(pn)
elif "änderungsinformation" in low or "dokumenteninformation" in low:
skip_pages.add(pn)
elif ("änderungsbeschreibung" in low and "version" in low
and ("datum" in low or "kapitel" in low)):
skip_pages.add(pn)
# --- Elemente (Text + Bild) pro Seite in Lesereihenfolge sammeln ---
# element: {"kind": "text"|"image", "y": float, "page": int, ...}
elements: list[dict] = []
seen_hashes: set[str] = set()
img_counter = 0
for pn in range(len(doc)):
if pn in skip_pages:
continue
page = doc[pn]
ph = page.rect.height
pw = page.rect.width
# Textzeilen mit Position
page_dict = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)
for block in page_dict.get("blocks", []):
if block.get("type") != 0:
continue
for line in block.get("lines", []):
bbox = line.get("bbox", [0, 0, 0, 0])
if bbox[1] < 70 or bbox[3] > ph - 45: # Kopf-/Fusszeile
continue
text = ""
max_size = 0.0
bold = False
for span in line.get("spans", []):
st = clean_pua(span.get("text", ""))
if st.strip():
text += st
max_size = max(max_size, span.get("size", 0))
if span.get("flags", 0) & 2**4: # bold flag
bold = True
text = text.strip()
if not text or _is_noise(text):
continue
elements.append({
"kind": "text", "y": bbox[1], "page": pn + 1,
"text": text, "size": round(max_size, 1), "bold": bold,
})
# Bilder mit Position. Titelseiten (erste 2 Seiten) enthalten nur
# Logo-/Deko-Bilder -> ueberspringen.
if pn < 2:
continue
for im in page.get_image_info(xrefs=True):
xref = im.get("xref", 0)
if not xref:
continue
b = im["bbox"]
w, h = im.get("width", 0), im.get("height", 0)
# Vollseiten-Deko / Hintergrundbilder ueberspringen
if (b[2] - b[0]) > pw * 0.95 and (b[3] - b[1]) > ph * 0.95:
continue
if w < 40 or h < 40: # Icons
continue
base = doc.extract_image(xref)
if not base:
continue
data = base["image"]
if len(data) < 3000:
continue
md5 = hashlib.md5(data).hexdigest()
if md5 in seen_hashes:
continue # exaktes Duplikat (wiederkehrendes Logo/Symbol)
seen_hashes.add(md5)
img_counter += 1
ext = base.get("ext", "png")
fname = f"img_{img_counter:03d}.{ext}"
(img_dir / fname).write_bytes(data)
elements.append({
"kind": "image", "y": b[1], "page": pn + 1, "file": fname,
})
doc.close()
# --- pro Seite nach Y-Position sortieren (Lesereihenfolge) ---
elements.sort(key=lambda e: (e["page"], e["y"]))
# --- Zeilen zusammenfuehren, die im PDF getrennt stehen ---
# a) Nummer-only-Zeile ("2.3.1") + folgende Titelzeile
# b) Bullet-only-Zeile ("•") + folgende Textzeile -> "• Text"
merged: list[dict] = []
i = 0
while i < len(elements):
e = elements[i]
if e["kind"] == "text":
nxt = elements[i + 1] if i + 1 < len(elements) else None
# a) Gliederungsnummer + Titel
m = RE_NUM_ONLY.match(e["text"])
if (m and nxt and nxt["kind"] == "text"
and re.match(r"[A-Za-zÄÖÜäöüß]", nxt["text"])):
merged.append({**e, "text": f"{m.group(1)} {nxt['text']}",
"size": max(e["size"], nxt["size"])})
i += 2
continue
# b) einzelnes Bullet-Zeichen + Text der Folgezeile
if e["text"].strip() in ("•", "-", "–", "*") and nxt and nxt["kind"] == "text":
merged.append({**e, "text": f"• {nxt['text']}"})
i += 2
continue
merged.append(e)
i += 1
# --- RST rendern ---
if title is None:
title = pdf_path.stem
out: list[str] = [RST_H[1] * len(title), title, RST_H[1] * len(title), ""]
# Absatz-Puffer: aufeinanderfolgende Fliesstextzeilen zu einem Absatz
# zusammenfuehren (PDF-Zeilenumbrueche sind kein Absatzende).
para: list[str] = []
def flush_para() -> None:
if para:
out.append(" ".join(para).strip())
out.append("")
para.clear()
RE_BULLET = re.compile(r"^\s*[•\-–*]\s+")
blocks = 0
n = len(merged)
for idx, e in enumerate(merged):
if e["kind"] == "image":
flush_para()
caption = ""
if idx + 1 < n and merged[idx + 1]["kind"] == "text":
cm = RE_CAPTION.match(merged[idx + 1]["text"])
if cm:
caption = merged[idx + 1]["text"]
merged[idx + 1]["_consumed"] = True
out.append("")
out.append(f".. figure:: images/{e['file']}")
out.append(" :align: center")
out.append(" :width: 80%")
if caption:
out.append("")
out.append(f" {caption}")
out.append("")
blocks += 1
continue
if e.get("_consumed"):
continue
text = e["text"]
hm = RE_HEADING.match(text)
# Ueberschrift NUR mit fuehrender PDF-Gliederungsnummer akzeptieren.
is_heading = False
if hm and len(hm.group(2)) < 120:
num, rest = hm.group(1), hm.group(2)
if not re.match(r"^(mm|cm|m|kg|x|l|h|min|s)\b", rest, re.I):
is_heading = True
if is_heading:
flush_para()
num, rest = hm.group(1), hm.group(2)
heading = _strip_heading_number(num, rest) # NUMMER ENTFERNEN
level = min(num.count(".") + 1, 5)
char = RST_H[level]
out.append("")
if level == 1:
out.append(char * max(len(heading), 3))
out.append(heading)
out.append(char * max(len(heading), 3))
out.append("")
blocks += 1
continue
# Aufzaehlung: Zeile beginnt mit Bullet ODER enthaelt mehrere Bullets
# (mehrere Listenpunkte sind im PDF oft in eine Zeile zusammengelaufen).
if RE_BULLET.match(text) or text.count("•") >= 2:
flush_para()
# An Bullet-Zeichen aufsplitten, jeder Teil wird ein Listenpunkt.
parts = re.split(r"\s*•\s*", text)
for part in parts:
item = RE_BULLET.sub("", part).strip().rstrip(",")
if item:
out.append(f"- {item}")
out.append("")
blocks += 1
continue
# Fliesstext: an Absatz anhaengen. Endet die Zeile mit Satzzeichen, gilt
# der Absatz als abgeschlossen und wird geflusht.
para.append(text)
if re.search(r"[.:!?]$", text):
flush_para()
blocks += 1
flush_para()
rst = "\n".join(out)
rst = re.sub(r"\n{3,}", "\n\n", rst).strip() + "\n"
rst_path.parent.mkdir(parents=True, exist_ok=True)
rst_path.write_text(rst, encoding="utf-8")
return blocks, img_counter