Files
doc_build/lib/extract_products.py
T

459 lines
14 KiB
Python

"""
extract_products.py - Extrahiert Bilder und Text aus Produktprogramm-PDFs.
Verwendet PyMuPDF (fitz) fuer zuverlaessige Bild- und Textextraktion.
Verwendung:
python extract_products.py [--data-dir <pfad>]
"""
from __future__ import annotations
import argparse
import logging
import re
import sys
from pathlib import Path
log = logging.getLogger(__name__)
# Mapping: PDF-Dateiname (Teilmatch) -> Zielordner fuer Bilder
PDF_TARGETS: list[tuple[str, str]] = [
("01_OMNIFLO", "omniflo"),
("02_ILS", "ils"),
("03_CPC", "cpc"),
("05_GERÜST", "geruest"),
("08_Trolley", "trolley"),
]
# rST Ueberschriften-Zeichen nach Level
RST_HEADING_CHARS = {1: "=", 2: "-", 3: "~", 4: "^"}
def find_target(pdf_name: str) -> str | None:
"""Findet den Zielordner-Namen fuer eine PDF-Datei."""
for pattern, target in PDF_TARGETS:
if pattern.lower() in pdf_name.lower():
return target
return None
def extract_images(pdf_path: Path, output_dir: Path) -> int:
"""Extrahiert alle Bilder aus einem PDF nach output_dir.
Returns:
Anzahl extrahierter Bilder.
"""
import fitz
output_dir.mkdir(parents=True, exist_ok=True)
doc = fitz.open(str(pdf_path))
count = 0
for page_num in range(len(doc)):
page = doc[page_num]
image_list = page.get_images(full=True)
for img_idx, img_info in enumerate(image_list):
xref = img_info[0]
base_image = doc.extract_image(xref)
if not base_image:
continue
image_bytes = base_image["image"]
ext = base_image.get("ext", "png")
# Kleine Bilder (Icons, Logos < 5KB) ueberspringen
if len(image_bytes) < 5000:
continue
count += 1
img_name = f"page{page_num + 1:03d}_img{img_idx + 1:02d}.{ext}"
img_path = output_dir / img_name
img_path.write_bytes(image_bytes)
log.debug(" Bild: %s (%d bytes)", img_name, len(image_bytes))
doc.close()
return count
def _extract_toc_numbers(doc) -> dict[str, str]:
"""Extrahiert Kapitelnummern aus dem Inhaltsverzeichnis des PDFs.
Sucht TOC-Seiten (Seiten mit '....' Punkt-Fuellern) und ordnet
Ueberschriftentexte ihren Kapitelnummern zu. Nur Eintraege mit
expliziter Nummer werden erfasst (bis Level 2).
Returns:
Dict title_text -> kapitelnummer, z.B. {"Weichen Omniflo": "2.3"}
"""
toc_entries: dict[str, str] = {}
for page_num in range(len(doc)):
page = doc[page_num]
text = page.get_text()
if "...." not in text:
continue
lines = text.splitlines()
current_number = ""
i = 0
while i < len(lines):
line = lines[i].strip()
i += 1
if not line:
continue
# Kapitelnummer auf separater Zeile
num_match = re.match(r"^(\d+(?:\.\d+)*)\s*$", line)
if num_match:
current_number = num_match.group(1)
continue
# Zeile mit Punkt-Fuellern = TOC-Eintrag
dot_match = re.search(r"\.{4,}", line)
if not dot_match:
continue
title = line[:dot_match.start()].strip()
if not title:
continue
# Inline-Nummer am Anfang? "1.4 Abkürzungen ....."
nm = re.match(r"^(\d+(?:\.\d+)*)\s+(.+)", title)
if nm:
toc_entries[nm.group(2).strip()] = nm.group(1)
current_number = ""
elif current_number:
toc_entries[title] = current_number
current_number = ""
return toc_entries
RE_DATE = re.compile(r"^\d{2}\.\d{2}\.\d{4}$")
RE_COPYRIGHT = re.compile(r"^Copyright\s+\d{4}", re.IGNORECASE)
RE_VERSION_LINE = re.compile(r"^Version\s*[:.]?\s*\d", re.IGNORECASE)
# Bekannte Rausch-Muster: Autorinnen, Metadaten, Copyright-Zeilen
_NOISE_KEYWORDS = {
"annette schopper", "erstellt:", "freigabe:", "nicht freigegeben",
"nur zur information", "schönenberger systeme gmbh",
"justus-von-liebig-str", "info@schoenenberger.de",
"www.schoenenberger.de",
}
def _is_noise(text: str) -> bool:
"""Erkennt Rausch-Zeilen die nicht ins RST gehoeren."""
lower = text.lower().strip()
if RE_COPYRIGHT.match(text):
return True
if RE_DATE.match(text.strip()):
return True
if RE_VERSION_LINE.match(text):
return True
for kw in _NOISE_KEYWORDS:
if kw in lower:
return True
# Seitenzahlen
if re.match(r"^Seite\s+\d+$", text, re.IGNORECASE):
return True
# Einzelne kurze Jahreszahlen mit Autorin: "2020 Annette Schopper"
if re.match(r"^\d{4}\s+[A-Z][a-z]+\s+[A-Z][a-z]+$", text):
return True
return False
def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int:
"""Extrahiert Text aus PDF und schreibt ihn als .rst.
Returns:
Anzahl extrahierter Bloecke.
"""
import fitz
doc = fitz.open(str(pdf_path))
# TOC-Nummern vorab extrahieren
toc_numbers = _extract_toc_numbers(doc)
raw_lines: list[dict] = []
for page_num in range(len(doc)):
page = doc[page_num]
page_height = page.rect.height
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])
# Positionsbasierte Kopf-/Fusszeilen-Erkennung
# Kopfzeile: Y < 100pt (Logos, Kapitelname, OMNIFLO etc.)
if bbox[1] < 100:
continue
# Fusszeile: Y > page_height - 50pt
if bbox[3] > page_height - 50:
continue
text = ""
max_size = 0.0
for span in line.get("spans", []):
span_text = span.get("text", "").strip()
if span_text:
text += span_text + " "
size = span.get("size", 0)
if size > max_size:
max_size = size
text = text.strip()
if not text:
continue
if _is_noise(text):
continue
raw_lines.append({
"text": text,
"size": max_size,
"page": page_num + 1,
})
doc.close()
if not raw_lines:
return 0
# --- Phase 1: Nummern-Zeilen mit nachfolgender Text-Zeile zusammenfuehren ---
# Im PDF stehen "2.3.1" und "Antriebsstationen" auf separaten Zeilen.
# Zusammenfuehren nur wenn:
# - Die Nummer ein Abschnitts-Format hat (N oder N.N.N...)
# - Die naechste Zeile mit einem Buchstaben beginnt
# - Die Schriftgroesse >= 12pt (verhindert Artikelnummern)
RE_SECTION_NUM = re.compile(r"^(\d+(?:\.\d+)*)$")
merged: list[dict] = []
i = 0
while i < len(raw_lines):
m = RE_SECTION_NUM.match(raw_lines[i]["text"])
if m and i + 1 < len(raw_lines) and raw_lines[i]["size"] >= 12.0:
next_line = raw_lines[i + 1]
next_text = next_line["text"].strip()
# Naechste Zeile muss mit Buchstabe beginnen
if next_text and re.match(r"[A-Za-zÄÖÜäöüß]", next_text):
merged.append({
"text": f"{raw_lines[i]['text']} {next_line['text']}",
"size": max(raw_lines[i]["size"], next_line["size"]),
"page": raw_lines[i]["page"],
})
i += 2
continue
merged.append(raw_lines[i])
i += 1
# --- Phase 2: Inhaltsverzeichnis / Index entfernen ---
# Zuerst TOC-Seiten identifizieren (Seiten mit "...." Punkt-Fuellern)
RE_TOC_LINE = re.compile(r"\.{4,}")
toc_pages: set[int] = set()
for entry in merged:
if RE_TOC_LINE.search(entry["text"]):
toc_pages.add(entry["page"])
if entry["text"].strip().lower() in ("inhalt", "inhaltsverzeichnis"):
toc_pages.add(entry["page"])
# Alle Eintraege auf TOC-Seiten entfernen
filtered: list[dict] = []
for entry in merged:
if entry["page"] in toc_pages:
continue
filtered.append(entry)
# --- Phase 3: Ueberschriften erkennen und RST generieren ---
# Heading: Nummerierung wie "1 Text", "2.3 Text", "2.3.1.4 Text"
# Text muss mit einem Buchstaben beginnen (verhindert "610 014 001" etc.)
# Jeder Nummerierungsteil max 3 Stellen (verhindert Datumsangaben wie "10.12.2020")
# Titeltext muss mit einem Wort von mind. 3 Buchstaben beginnen
# (verhindert "2 B. def.", "19.8 mm (LW13)")
RE_HEADING = re.compile(
r"^(\d{1,3}(?:\.\d{1,3})*)\s+([A-Za-zÄÖÜäöüß]{3,}.*)$"
)
rst_lines: list[str] = []
title = pdf_path.stem
rst_lines.append(RST_HEADING_CHARS[1] * len(title))
rst_lines.append(title)
rst_lines.append(RST_HEADING_CHARS[1] * len(title))
rst_lines.append("")
prev_page = 0
block_count = 0
for entry in filtered:
text = entry["text"]
# Seitenumbruch-Kommentar
if entry["page"] != prev_page:
if prev_page > 0:
rst_lines.append("")
rst_lines.append(f".. Seite {entry['page']}")
rst_lines.append("")
prev_page = entry["page"]
# Nummerierte Ueberschrift?
# Fuer Level-1 (keine Punkte, z.B. "1 Text") zusaetzlich grosse
# Schrift (>= 12pt) verlangen um Fehlerkennungen zu vermeiden.
hm = RE_HEADING.match(text)
is_heading = False
if hm and len(hm.group(2)) < 120:
num = hm.group(1)
has_dots = "." in num
is_heading = has_dots or entry["size"] >= 12.0
# Phase 3b: Nicht-nummerierte Texte mit grosser Schrift pruefen,
# ob sie im TOC eine Kapitelnummer haben (fehlende PDF-Nummerierung)
if not is_heading and entry["size"] >= 12.0:
# Text gegen TOC abgleichen
toc_num = toc_numbers.get(text)
if toc_num:
# Nummer aus TOC voranstellen
candidate = f"{toc_num} {text}"
hm = RE_HEADING.match(candidate)
if hm:
text = candidate
is_heading = True
log.debug(" TOC-Nummer ergaenzt: %s", text)
if is_heading:
num = hm.group(1)
heading_text = f"{num} {hm.group(2)}"
# Level aus Tiefe der Nummerierung: 1=level1, 1.1=level2, etc.
depth = num.count(".") + 1
level = min(depth, 4)
char = RST_HEADING_CHARS[level]
rst_lines.append("")
if level == 1:
rst_lines.append(char * len(heading_text))
rst_lines.append(heading_text)
rst_lines.append(char * len(heading_text))
rst_lines.append("")
else:
rst_lines.append(text)
rst_lines.append("")
block_count += 1
# Mehrfache Leerzeilen reduzieren
rst = "\n".join(rst_lines)
rst = re.sub(r"\n{3,}", "\n\n", rst)
# 9-stellige Nummern mit Leerzeichen zusammenfuegen: "834 372 007" -> "834372007"
rst = re.sub(r"\b(\d{3})\s(\d{3})\s(\d{3})\b", r"\1\2\3", rst)
rst = re.sub(r"\b(\d{3})\s(\d{6})\b", r"\1\2", rst)
rst = re.sub(r"\b(\d{6})\s(\d{3})\b", r"\1\2", rst)
rst = rst.strip() + "\n"
rst_path.parent.mkdir(parents=True, exist_ok=True)
rst_path.write_text(rst, encoding="utf-8")
return block_count
def dedup_images(img_dir: Path) -> int:
"""Entfernt doppelte Bilder per MD5-Hash aus einem Verzeichnis.
Returns:
Anzahl entfernter Duplikate.
"""
import hashlib
if not img_dir.exists():
return 0
seen: dict[str, Path] = {}
removed = 0
for img_path in sorted(img_dir.iterdir()):
if not img_path.is_file():
continue
md5 = hashlib.md5(img_path.read_bytes()).hexdigest()
if md5 in seen:
log.debug(" Duplikat entfernt: %s (== %s)", img_path.name, seen[md5].name)
img_path.unlink()
removed += 1
else:
seen[md5] = img_path
return removed
def process_all(data_dir: Path) -> None:
"""Verarbeitet alle konfigurierten PDFs."""
pp_dir = data_dir / "Produktprogramm"
images_base = pp_dir / "images"
if not pp_dir.exists():
log.error("Verzeichnis nicht gefunden: %s", pp_dir)
sys.exit(1)
pdf_files = list(pp_dir.glob("*.pdf"))
if not pdf_files:
log.error("Keine PDF-Dateien in %s gefunden", pp_dir)
sys.exit(1)
log.info("Gefunden: %d PDF-Dateien in %s", len(pdf_files), pp_dir)
for pdf_path in sorted(pdf_files):
target = find_target(pdf_path.name)
if target is None:
log.info("Uebersprungen (kein Ziel konfiguriert): %s", pdf_path.name)
continue
log.info("Verarbeite: %s -> %s", pdf_path.name, target)
# Bilder extrahieren und Duplikate entfernen
img_dir = images_base / target
img_count = extract_images(pdf_path, img_dir)
dup_count = dedup_images(img_dir)
log.info(" %d Bilder extrahiert, %d Duplikate entfernt -> %d einzigartig in %s",
img_count, dup_count, img_count - dup_count, img_dir)
# Text als RST extrahieren
rst_name = pdf_path.stem + ".de.rst"
rst_path = pp_dir / rst_name
block_count = extract_text_to_rst(pdf_path, rst_path)
log.info(" %d Textbloecke extrahiert nach %s", block_count, rst_path.name)
log.info("Fertig.")
def main() -> None:
parser = argparse.ArgumentParser(
description="Extrahiert Bilder und Text aus Produktprogramm-PDFs"
)
parser.add_argument("--data-dir", type=Path, default=None,
help="data/ Verzeichnis (default: aus PV_DATA oder ./data)")
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",
)
import os
if args.data_dir:
data_dir = args.data_dir.resolve()
elif os.environ.get("PV_DATA"):
data_dir = Path(os.environ["PV_DATA"]).resolve()
else:
data_dir = Path("data").resolve()
log.info("Data-Verzeichnis: %s", data_dir)
process_all(data_dir)
if __name__ == "__main__":
main()