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>
308 lines
11 KiB
Python
308 lines
11 KiB
Python
"""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
|