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>
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 20 KiB |
@@ -95,10 +95,14 @@ latex_elements = {
|
|||||||
"polyglossia": "",
|
"polyglossia": "",
|
||||||
"babel": r"\usepackage[ngerman]{babel}",
|
"babel": r"\usepackage[ngerman]{babel}",
|
||||||
# Robustheit gegen ueberlange Zeilen aus der PDF-Textextraktion:
|
# Robustheit gegen ueberlange Zeilen aus der PDF-Textextraktion:
|
||||||
|
# - pdflscape: erlaubt \begin{landscape} fuer quergestellte Verzeichnistabellen
|
||||||
|
# - \UrlBreaks: lange Quellpfade in ``...`` duerfen umbrechen
|
||||||
"preamble": r"""
|
"preamble": r"""
|
||||||
\usepackage{microtype}
|
\usepackage{microtype}
|
||||||
|
\usepackage{pdflscape}
|
||||||
\emergencystretch=3em
|
\emergencystretch=3em
|
||||||
\setcounter{tocdepth}{2}
|
\setcounter{tocdepth}{2}
|
||||||
\sloppy
|
\sloppy
|
||||||
|
\renewcommand{\sphinxbreaksbeforeactivelist}{\do\-\do\/\do\_\do\.}
|
||||||
""",
|
""",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,9 +245,16 @@ def generate_index(chapter: str, items: list[dict], source_root: Path,
|
|||||||
lines.append(" Die referenzierten Dokumente liegen im Projekt-Rohmaterial und werden")
|
lines.append(" Die referenzierten Dokumente liegen im Projekt-Rohmaterial und werden")
|
||||||
lines.append(" hier nicht dupliziert.")
|
lines.append(" hier nicht dupliziert.")
|
||||||
lines.append("")
|
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(".. list-table::")
|
||||||
lines.append(" :header-rows: 1")
|
lines.append(" :header-rows: 1")
|
||||||
lines.append(" :widths: 34 18 12 36")
|
lines.append(" :widths: 30 16 8 46")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(" * - Dokument")
|
lines.append(" * - Dokument")
|
||||||
lines.append(" - Kategorie")
|
lines.append(" - Kategorie")
|
||||||
@@ -260,6 +267,10 @@ def generate_index(chapter: str, items: list[dict], source_root: Path,
|
|||||||
lines.append(f" - {human_size(it['size_bytes'])}")
|
lines.append(f" - {human_size(it['size_bytes'])}")
|
||||||
lines.append(f" - ``{it['rel_path']}``")
|
lines.append(f" - ``{it['rel_path']}``")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
lines.append(".. raw:: latex")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(" \\end{landscape}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
rst_path.write_text("\n".join(lines), encoding="utf-8")
|
rst_path.write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,20 @@ from pathlib import Path
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Schwellwerte fuer die Symbol-/Icon-Erkennung
|
# Schwellwerte fuer die Symbol-/Icon-Erkennung
|
||||||
SYMBOL_MAX_EDGE = 260 # Bilder <= dieser Kantenlaenge gelten als Symbol-Kandidat
|
SYMBOL_MAX_EDGE = 340 # Bilder <= dieser Kantenlaenge gelten als Symbol-Kandidat
|
||||||
|
# (Sicherheitspiktogramme sind oft ~300 px)
|
||||||
SYMBOL_MIN_COUNT = 2 # ... wenn dasselbe Bild mind. so oft vorkommt
|
SYMBOL_MIN_COUNT = 2 # ... wenn dasselbe Bild mind. so oft vorkommt
|
||||||
AHASH_THRESHOLD = 6 # max. Hamming-Distanz, ab der zwei aHashes als gleich gelten
|
AHASH_THRESHOLD = 6 # max. Hamming-Distanz, ab der zwei aHashes als gleich gelten
|
||||||
|
|
||||||
|
# Manuelle Blockliste: untere 32 Bit des aHash von Bildern, die faelschlich als
|
||||||
|
# Symbol erkannt wurden (Fotos/3D-Renderings/Diagramm-Fragmente, KEINE echten
|
||||||
|
# Sicherheitspiktogramme). Diese werden nie als Symbol behandelt und nicht in
|
||||||
|
# assets/shared/symbols abgelegt/referenziert. (Kuratiert nach Sichtpruefung.)
|
||||||
|
SYMBOL_BLOCKLIST = {
|
||||||
|
0x1d0580bf, 0x71c70f3f, 0x808081ff, 0x818183ff,
|
||||||
|
0x81f1f3f3, 0xc3c1f7ff, 0xdbc3e7e7,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def average_hash(image_bytes: bytes, size: int = 8) -> int:
|
def average_hash(image_bytes: bytes, size: int = 8) -> int:
|
||||||
"""Perceptual Average-Hash (aHash) eines Bildes als Integer.
|
"""Perceptual Average-Hash (aHash) eines Bildes als Integer.
|
||||||
@@ -64,20 +74,133 @@ def hamming(a: int, b: int) -> int:
|
|||||||
return bin(a ^ b).count("1")
|
return bin(a ^ b).count("1")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_symbol(image_bytes: bytes, canvas: int = 200) -> bytes:
|
||||||
|
"""Zentriert ein Symbol auf einer quadratischen weissen Canvas.
|
||||||
|
|
||||||
|
So erscheinen alle Warn-/Gebots-/Verbotssymbole - unabhaengig vom Seiten-
|
||||||
|
verhaeltnis und der Aufloesung der Quelle - bei gleicher RST-Hoehe gleich
|
||||||
|
gross. Gibt PNG-Bytes zurueck.
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
try:
|
||||||
|
im = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
||||||
|
w, h = im.size
|
||||||
|
scale = (canvas - 16) / max(w, h) # 8 px Rand ringsum
|
||||||
|
new = im.resize((max(1, int(w * scale)), max(1, int(h * scale))))
|
||||||
|
bg = Image.new("RGB", (canvas, canvas), (255, 255, 255))
|
||||||
|
bg.paste(new, ((canvas - new.width) // 2, (canvas - new.height) // 2))
|
||||||
|
buf = io.BytesIO()
|
||||||
|
bg.save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
except Exception:
|
||||||
|
return image_bytes
|
||||||
|
|
||||||
|
|
||||||
# --- Bild-Klassifizierung -------------------------------------------------
|
# --- Bild-Klassifizierung -------------------------------------------------
|
||||||
|
|
||||||
SKETCH_MIN_BYTES = 10_000 # SW-Grafik groesser als ~10 KB gilt als Sketch
|
SKETCH_MIN_BYTES = 10_000 # SW-Grafik groesser als ~10 KB gilt als Sketch
|
||||||
|
|
||||||
|
|
||||||
|
def white_ratio(image_bytes: bytes) -> float:
|
||||||
|
"""Anteil (nahezu) weisser Pixel (Helligkeit > 230) im Bild [0..1]."""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
try:
|
||||||
|
im = Image.open(io.BytesIO(image_bytes)).convert("L")
|
||||||
|
data = list(im.getdata())
|
||||||
|
if not data:
|
||||||
|
return 0.0
|
||||||
|
step = max(1, len(data) // 400)
|
||||||
|
sample = data[::step]
|
||||||
|
return sum(1 for p in sample if p > 230) / len(sample)
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _dominant_color_count(im, thresh: float = 0.02) -> int:
|
||||||
|
"""Anzahl 'dominanter' Farben (>= thresh Anteil) in grober 16er-Quantisierung.
|
||||||
|
|
||||||
|
Piktogramme haben wenige Flaechenfarben (<= ~7), Fotos/Barcodes viele (>= 8).
|
||||||
|
"""
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
small = im.resize((48, 48))
|
||||||
|
c: Counter = Counter((r // 48, g // 48, b // 48) for r, g, b in small.getdata())
|
||||||
|
total = sum(c.values()) or 1
|
||||||
|
return sum(1 for v in c.values() if v / total >= thresh)
|
||||||
|
|
||||||
|
|
||||||
|
def _few_colors(image_bytes: bytes, max_colors: int = 7) -> bool:
|
||||||
|
"""True, wenn das Bild wenige dominante Flaechenfarben hat (Piktogramm).
|
||||||
|
|
||||||
|
Fotos und Barcodes haben viele Farbabstufungen und werden ausgeschlossen.
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
try:
|
||||||
|
im = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
||||||
|
return _dominant_color_count(im) <= max_colors
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_pictogram(image_bytes: bytes) -> bool:
|
||||||
|
"""True, wenn ein Bild wie ein Warn-/Gebots-/Verbotssymbol aussieht.
|
||||||
|
|
||||||
|
Piktogramme (Warndreieck gelb, Gebot blau, Verbot rot/weiss) bestehen aus
|
||||||
|
WENIGEN kraeftigen Flaechenfarben + viel Weiss/Schwarz. Kennzeichen:
|
||||||
|
- hoher Anteil an Signalfarben (gelb/orange, kraeftiges blau, rot) UND
|
||||||
|
- wenige dominante Farben (<= 7) -> schliesst Fotos & Barcodes aus, die zwar
|
||||||
|
blaue/farbige Anteile haben, aber viele Farbabstufungen.
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
try:
|
||||||
|
im = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
||||||
|
data = list(im.getdata())
|
||||||
|
if not data:
|
||||||
|
return False
|
||||||
|
# Foto/Barcode -> kein Piktogramm. Grenze 9 (nicht 7): JPEG-komprimierte
|
||||||
|
# Verbots-/Gebotssymbole haben durch Randartefakte etwas mehr Farben.
|
||||||
|
if _dominant_color_count(im) > 9:
|
||||||
|
return False
|
||||||
|
step = max(1, len(data) // 500)
|
||||||
|
sample = data[::step]
|
||||||
|
n = len(sample)
|
||||||
|
signal = 0
|
||||||
|
for r, g, b in sample:
|
||||||
|
# gelb/orange: hohes R+G, niedriges B
|
||||||
|
if r > 180 and g > 150 and b < 110:
|
||||||
|
signal += 1
|
||||||
|
# kraeftiges blau (Gebotssymbol): b deutlich groesser als r
|
||||||
|
elif b > 110 and b - r > 50 and r < 130:
|
||||||
|
signal += 1
|
||||||
|
# rot (Verbotssymbol): r deutlich groesser als g und b
|
||||||
|
elif r > 150 and r - g > 70 and r - b > 70:
|
||||||
|
signal += 1
|
||||||
|
return signal / n > 0.12
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def is_junk_fragment(image_bytes: bytes, w: int, h: int) -> bool:
|
def is_junk_fragment(image_bytes: bytes, w: int, h: int) -> bool:
|
||||||
"""True fuer wertlose Text-/Nummern-Fragmente (kein echtes Symbol/Bild).
|
"""True fuer wertlose Text-/Nummern-Fragmente (kein echtes Symbol/Bild).
|
||||||
|
|
||||||
Typisch: flache (Hoehe <= 55 px), breite, schwarz-weisse Schnipsel, die in
|
Erkennt:
|
||||||
Wahrheit gerenderte Kapitelnummern/Beschriftungen sind (z.B. "5.2.6",
|
a) flache (Hoehe <= 55 px), breite, schwarz-weisse Schnipsel (gerenderte
|
||||||
zwei Quadrate + Zahlen). Diese werden verworfen.
|
Beschriftungen/Kapitelnummern wie "5.2.6").
|
||||||
|
b) kleine, schwarz-weisse Bilder mit sehr hohem Weissanteil und geringer
|
||||||
|
Datenmenge - das sind gerenderte Kapitelnummern ("3.2.3", "3.2.4"), die
|
||||||
|
als eigenstaendige Grafik im PDF liegen (viel Weissraum, duenne Ziffern).
|
||||||
"""
|
"""
|
||||||
|
# a) flache breite SW-Schnipsel
|
||||||
if h <= 55 and w / max(h, 1) >= 1.6 and is_grayscale(image_bytes):
|
if h <= 55 and w / max(h, 1) >= 1.6 and is_grayscale(image_bytes):
|
||||||
return True
|
return True
|
||||||
|
# b) kleine SW-Nummerngrafik: kleine Flaeche, SW, sehr wenig "Tinte"
|
||||||
|
if (max(w, h) <= 200 and len(image_bytes) < 4000
|
||||||
|
and is_grayscale(image_bytes) and white_ratio(image_bytes) > 0.80):
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -395,8 +518,23 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
|
|||||||
logo_registry[c["ahash"]] = idx
|
logo_registry[c["ahash"]] = idx
|
||||||
continue
|
continue
|
||||||
|
|
||||||
is_symbol = (max(c["w"], c["h"]) <= SYMBOL_MAX_EDGE
|
# Symbol-Erkennung (Warn-/Gebots-/Verbotszeichen, Bedien-Piktogramme):
|
||||||
and c["count"] >= SYMBOL_MIN_COUNT)
|
# klein (<= 260px) UND quadratisch-aehnlich (ratio 0.55..1.8) UND
|
||||||
|
# entweder Piktogramm-Form (Signalfarben) ODER (mehrfach + heller Grund).
|
||||||
|
# Piktogramme werden so auch erkannt, wenn sie im Dokument nur 1x vorkommen
|
||||||
|
# (Warndreiecke stehen oft einzeln), Foto-Ausschnitte dagegen nicht.
|
||||||
|
# Manuell geblockte Fehlklassifikationen nie als Symbol behandeln.
|
||||||
|
# Vergleich ueber die unteren 32 Bit des aHash (= Wert im Dateinamen);
|
||||||
|
# bis zu 2 abweichende Bits werden toleriert (JPEG-/Skalierungsvarianz).
|
||||||
|
low = c["ahash"] & 0xffffffff
|
||||||
|
blocked = any(bin(low ^ b).count("1") <= 2 for b in SYMBOL_BLOCKLIST)
|
||||||
|
small = max(c["w"], c["h"]) <= SYMBOL_MAX_EDGE
|
||||||
|
squarish = 0.55 <= ratio <= 1.8
|
||||||
|
few_colors = _few_colors(c["data"], max_colors=9) # Piktogramm: wenige Farben
|
||||||
|
is_symbol = (not blocked) and small and squarish and few_colors and (
|
||||||
|
looks_like_pictogram(c["data"])
|
||||||
|
or (c["count"] >= SYMBOL_MIN_COUNT and white_ratio(c["data"]) > 0.35)
|
||||||
|
)
|
||||||
cluster_is_symbol[ci] = is_symbol
|
cluster_is_symbol[ci] = is_symbol
|
||||||
if is_symbol:
|
if is_symbol:
|
||||||
match = None
|
match = None
|
||||||
@@ -406,8 +544,9 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
|
|||||||
break
|
break
|
||||||
if match is None:
|
if match is None:
|
||||||
symbols_dir.mkdir(parents=True, exist_ok=True)
|
symbols_dir.mkdir(parents=True, exist_ok=True)
|
||||||
match = f"symbol_{c['ahash'] & 0xffffffff:08x}.{c['ext']}"
|
# auf quadratische Canvas normalisieren -> immer als PNG ablegen
|
||||||
(symbols_dir / match).write_bytes(c["data"])
|
match = f"symbol_{c['ahash'] & 0xffffffff:08x}.png"
|
||||||
|
(symbols_dir / match).write_bytes(normalize_symbol(c["data"]))
|
||||||
symbol_registry[c["ahash"]] = match
|
symbol_registry[c["ahash"]] = match
|
||||||
cluster_sympath[ci] = f"{sym_rel_prefix}/{match}"
|
cluster_sympath[ci] = f"{sym_rel_prefix}/{match}"
|
||||||
|
|
||||||
@@ -423,10 +562,13 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
|
|||||||
e = elements[i]
|
e = elements[i]
|
||||||
if e["kind"] == "text":
|
if e["kind"] == "text":
|
||||||
nxt = elements[i + 1] if i + 1 < len(elements) else None
|
nxt = elements[i + 1] if i + 1 < len(elements) else None
|
||||||
# a) Gliederungsnummer + Titel
|
# a) Gliederungsnummer + Titel. NUR mehrteilige Nummern (z.B. "2.3.1"),
|
||||||
|
# nicht einzelne lose Ziffern (Bildlegenden-Nummern wie "2", "24"),
|
||||||
|
# und NICHT, wenn die Folgezeile eine Bildunterschrift ist.
|
||||||
m = RE_NUM_ONLY.match(e["text"])
|
m = RE_NUM_ONLY.match(e["text"])
|
||||||
if (m and nxt and nxt["kind"] == "text"
|
if (m and "." in m.group(1) and nxt and nxt["kind"] == "text"
|
||||||
and re.match(r"[A-Za-zÄÖÜäöüß]", nxt["text"])):
|
and re.match(r"[A-Za-zÄÖÜäöüß]", nxt["text"])
|
||||||
|
and not RE_CAPTION.match(nxt["text"])):
|
||||||
merged.append({**e, "text": f"{m.group(1)} {nxt['text']}",
|
merged.append({**e, "text": f"{m.group(1)} {nxt['text']}",
|
||||||
"size": max(e["size"], nxt["size"])})
|
"size": max(e["size"], nxt["size"])})
|
||||||
i += 2
|
i += 2
|
||||||
@@ -511,9 +653,29 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
|
|||||||
# zusammenfuehren (PDF-Zeilenumbrueche sind kein Absatzende).
|
# zusammenfuehren (PDF-Zeilenumbrueche sind kein Absatzende).
|
||||||
para: list[str] = []
|
para: list[str] = []
|
||||||
|
|
||||||
|
def _join_para(lines: list[str]) -> str:
|
||||||
|
"""Fuegt Absatzzeilen zusammen und entfernt PDF-Silbentrennung.
|
||||||
|
|
||||||
|
Endet eine Zeile auf "wort-" (Trennstrich nach Kleinbuchstabe), wird sie
|
||||||
|
ohne Leerzeichen und ohne Bindestrich mit dem naechsten Wort verbunden
|
||||||
|
("Anlagenkompo-" + "nente" -> "Anlagenkomponente"). Ein Bindestrich nach
|
||||||
|
Grossbuchstabe/Ziffer (Kompositum wie "ILS-", "24-") bleibt erhalten.
|
||||||
|
"""
|
||||||
|
result = ""
|
||||||
|
for i, ln in enumerate(lines):
|
||||||
|
ln = ln.strip()
|
||||||
|
if not result:
|
||||||
|
result = ln
|
||||||
|
continue
|
||||||
|
if re.search(r"[a-zäöüß]-$", result):
|
||||||
|
result = result[:-1] + ln # Silbentrennung aufloesen
|
||||||
|
else:
|
||||||
|
result += " " + ln
|
||||||
|
return result.strip()
|
||||||
|
|
||||||
def flush_para() -> None:
|
def flush_para() -> None:
|
||||||
if para:
|
if para:
|
||||||
out.append(" ".join(para).strip())
|
out.append(_join_para(para))
|
||||||
out.append("")
|
out.append("")
|
||||||
para.clear()
|
para.clear()
|
||||||
|
|
||||||
@@ -528,12 +690,15 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
|
|||||||
if not path:
|
if not path:
|
||||||
continue
|
continue
|
||||||
if e.get("is_symbol"):
|
if e.get("is_symbol"):
|
||||||
# Symbol/Warnzeichen: kleine, zentrierte Grafik ohne Caption-Box,
|
# Symbol/Warnzeichen: einheitlich kleine Grafik, links ausgerichtet
|
||||||
# verweist auf die eine zentrale Quelle in assets/shared/symbols.
|
# (Piktogramm-Charakter, daneben steht der Warntext). Feste Hoehe UND
|
||||||
|
# Breite begrenzen, damit alle Symbole - unabhaengig vom Seiten-
|
||||||
|
# verhaeltnis der Quelle - gleich gross erscheinen.
|
||||||
out.append("")
|
out.append("")
|
||||||
out.append(f".. image:: {path}")
|
out.append(f".. image:: {path}")
|
||||||
out.append(" :align: center")
|
out.append(" :align: left")
|
||||||
out.append(" :height: 12mm")
|
out.append(" :height: 10mm")
|
||||||
|
out.append(" :class: warnsymbol")
|
||||||
out.append("")
|
out.append("")
|
||||||
blocks += 1
|
blocks += 1
|
||||||
continue
|
continue
|
||||||
@@ -558,14 +723,30 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
text = e["text"]
|
text = e["text"]
|
||||||
|
|
||||||
|
# Bildunterschriften ("Abbildung N: ...") sind NIE Ueberschriften. Wenn sie
|
||||||
|
# nicht direkt als figure-Caption konsumiert wurden (z.B. weil Text
|
||||||
|
# dazwischen stand), als eigenen (kursiven) Absatz ausgeben.
|
||||||
|
if RE_CAPTION.match(text):
|
||||||
|
flush_para()
|
||||||
|
out.append(f"*{text}*")
|
||||||
|
out.append("")
|
||||||
|
blocks += 1
|
||||||
|
continue
|
||||||
|
|
||||||
hm = RE_HEADING.match(text)
|
hm = RE_HEADING.match(text)
|
||||||
# Ueberschrift NUR mit fuehrender PDF-Gliederungsnummer akzeptieren und
|
# Ueberschrift NUR, wenn ALLE Kriterien erfuellt sind:
|
||||||
# offensichtliche Fragmente/Bildlegenden ausschliessen (sonst werden
|
# 1. fuehrende Gliederungsnummer ("N", "N.N", ...),
|
||||||
# Zellen wie "POS", "li.", "re.", "bar, max. 10 bar" zu Ueberschriften).
|
# 2. Quell-Schrift ist gross (>= 12.5pt) ODER fett -> unterscheidet echte
|
||||||
|
# Ueberschriften von normalen Textzeilen, die zufaellig mit einer Nummer
|
||||||
|
# beginnen (Kapitelverweise "gemaess 2.8.2 ...", nummerierte Aufzaehlungen).
|
||||||
|
# Das war die Hauptursache falscher Ueberschriften.
|
||||||
|
# 3. kein offensichtliches Fragment/Bildlegende ("POS", "li.", "5 mm", ...).
|
||||||
is_heading = False
|
is_heading = False
|
||||||
if hm and len(hm.group(2)) < 120:
|
if hm and len(hm.group(2)) < 120:
|
||||||
num, rest = hm.group(1), hm.group(2)
|
num, rest = hm.group(1), hm.group(2)
|
||||||
rest_stripped = rest.strip()
|
rest_stripped = rest.strip()
|
||||||
|
looks_like_title = e.get("size", 0) >= 12.5 or e.get("bold", False)
|
||||||
is_frag = (
|
is_frag = (
|
||||||
len(rest_stripped) < 4 # zu kurz ("POS", "li.")
|
len(rest_stripped) < 4 # zu kurz ("POS", "li.")
|
||||||
or rest_stripped.endswith((",",)) # abgeschnittene Zelle
|
or rest_stripped.endswith((",",)) # abgeschnittene Zelle
|
||||||
@@ -574,7 +755,7 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
|
|||||||
or re.match(r"^(li|re|oben|unten|vorne|hinten|links|rechts)\.?$",
|
or re.match(r"^(li|re|oben|unten|vorne|hinten|links|rechts)\.?$",
|
||||||
rest_stripped, re.I)
|
rest_stripped, re.I)
|
||||||
)
|
)
|
||||||
if not is_frag:
|
if looks_like_title and not is_frag:
|
||||||
is_heading = True
|
is_heading = True
|
||||||
|
|
||||||
if is_heading:
|
if is_heading:
|
||||||
|
|||||||