diff --git a/assets/shared/symbols/symbol_00bcbdc3.png b/assets/shared/symbols/symbol_00bcbdc3.png new file mode 100644 index 0000000..659a5d6 Binary files /dev/null and b/assets/shared/symbols/symbol_00bcbdc3.png differ diff --git a/assets/shared/symbols/symbol_043cb9c3.png b/assets/shared/symbols/symbol_043cb9c3.png new file mode 100644 index 0000000..32b17b0 Binary files /dev/null and b/assets/shared/symbols/symbol_043cb9c3.png differ diff --git a/assets/shared/symbols/symbol_089181e7.png b/assets/shared/symbols/symbol_089181e7.png new file mode 100644 index 0000000..f7fd802 Binary files /dev/null and b/assets/shared/symbols/symbol_089181e7.png differ diff --git a/assets/shared/symbols/symbol_0c0099c3.jpeg b/assets/shared/symbols/symbol_0c0099c3.jpeg deleted file mode 100644 index b237b8e..0000000 Binary files a/assets/shared/symbols/symbol_0c0099c3.jpeg and /dev/null differ diff --git a/assets/shared/symbols/symbol_0c0099c3.png b/assets/shared/symbols/symbol_0c0099c3.png new file mode 100644 index 0000000..f2961b1 Binary files /dev/null and b/assets/shared/symbols/symbol_0c0099c3.png differ diff --git a/assets/shared/symbols/symbol_0c0c81c3.png b/assets/shared/symbols/symbol_0c0c81c3.png new file mode 100644 index 0000000..897999a Binary files /dev/null and b/assets/shared/symbols/symbol_0c0c81c3.png differ diff --git a/assets/shared/symbols/symbol_189981e7.png b/assets/shared/symbols/symbol_189981e7.png new file mode 100644 index 0000000..47b65e7 Binary files /dev/null and b/assets/shared/symbols/symbol_189981e7.png differ diff --git a/assets/shared/symbols/symbol_2c99c3e7.jpeg b/assets/shared/symbols/symbol_2c99c3e7.jpeg deleted file mode 100644 index fa0d1d2..0000000 Binary files a/assets/shared/symbols/symbol_2c99c3e7.jpeg and /dev/null differ diff --git a/assets/shared/symbols/symbol_2c99c3e7.png b/assets/shared/symbols/symbol_2c99c3e7.png new file mode 100644 index 0000000..2e8dadd Binary files /dev/null and b/assets/shared/symbols/symbol_2c99c3e7.png differ diff --git a/assets/shared/symbols/symbol_4262b5c3.jpeg b/assets/shared/symbols/symbol_4262b5c3.jpeg deleted file mode 100644 index 153dbe3..0000000 Binary files a/assets/shared/symbols/symbol_4262b5c3.jpeg and /dev/null differ diff --git a/assets/shared/symbols/symbol_4262b5c3.png b/assets/shared/symbols/symbol_4262b5c3.png new file mode 100644 index 0000000..e6fb708 Binary files /dev/null and b/assets/shared/symbols/symbol_4262b5c3.png differ diff --git a/assets/shared/symbols/symbol_464abdc3.png b/assets/shared/symbols/symbol_464abdc3.png deleted file mode 100644 index ac6afde..0000000 Binary files a/assets/shared/symbols/symbol_464abdc3.png and /dev/null differ diff --git a/assets/shared/symbols/symbol_71c70f3f.jpeg b/assets/shared/symbols/symbol_71c70f3f.jpeg deleted file mode 100644 index 5b38396..0000000 Binary files a/assets/shared/symbols/symbol_71c70f3f.jpeg and /dev/null differ diff --git a/assets/shared/symbols/symbol_818183ff.png b/assets/shared/symbols/symbol_818183ff.png deleted file mode 100644 index d8307d9..0000000 Binary files a/assets/shared/symbols/symbol_818183ff.png and /dev/null differ diff --git a/assets/shared/symbols/symbol_c3c3e7e7.png b/assets/shared/symbols/symbol_c3c3e7e7.png deleted file mode 100644 index ed4357a..0000000 Binary files a/assets/shared/symbols/symbol_c3c3e7e7.png and /dev/null differ diff --git a/assets/shared/symbols/symbol_c3e7e7ff.png b/assets/shared/symbols/symbol_c3e7e7ff.png index 02fe3b0..fc14b2f 100644 Binary files a/assets/shared/symbols/symbol_c3e7e7ff.png and b/assets/shared/symbols/symbol_c3e7e7ff.png differ diff --git a/assets/shared/symbols/symbol_dbc3e7e7.png b/assets/shared/symbols/symbol_dbc3e7e7.png deleted file mode 100644 index 2b6fe80..0000000 Binary files a/assets/shared/symbols/symbol_dbc3e7e7.png and /dev/null differ diff --git a/assets/shared/symbols/symbol_e0e0f0f3.jpeg b/assets/shared/symbols/symbol_e0e0f0f3.jpeg deleted file mode 100644 index 96bed63..0000000 Binary files a/assets/shared/symbols/symbol_e0e0f0f3.jpeg and /dev/null differ diff --git a/assets/shared/symbols/symbol_e7ffffe7.png b/assets/shared/symbols/symbol_e7ffffe7.png index 4a37e99..9d9daa1 100644 Binary files a/assets/shared/symbols/symbol_e7ffffe7.png and b/assets/shared/symbols/symbol_e7ffffe7.png differ diff --git a/conf.py b/conf.py index 099573f..3f67497 100644 --- a/conf.py +++ b/conf.py @@ -95,10 +95,14 @@ latex_elements = { "polyglossia": "", "babel": r"\usepackage[ngerman]{babel}", # Robustheit gegen ueberlange Zeilen aus der PDF-Textextraktion: + # - pdflscape: erlaubt \begin{landscape} fuer quergestellte Verzeichnistabellen + # - \UrlBreaks: lange Quellpfade in ``...`` duerfen umbrechen "preamble": r""" \usepackage{microtype} +\usepackage{pdflscape} \emergencystretch=3em \setcounter{tocdepth}{2} \sloppy +\renewcommand{\sphinxbreaksbeforeactivelist}{\do\-\do\/\do\_\do\.} """, } diff --git a/lib/fortna_convert_chapters.py b/lib/fortna_convert_chapters.py index 2322d6a..711ea82 100644 --- a/lib/fortna_convert_chapters.py +++ b/lib/fortna_convert_chapters.py @@ -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(" hier nicht dupliziert.") 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(" :header-rows: 1") - lines.append(" :widths: 34 18 12 36") + lines.append(" :widths: 30 16 8 46") lines.append("") lines.append(" * - Dokument") 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" - ``{it['rel_path']}``") lines.append("") + lines.append(".. raw:: latex") + lines.append("") + lines.append(" \\end{landscape}") + lines.append("") rst_path.write_text("\n".join(lines), encoding="utf-8") diff --git a/lib/pdf_extract.py b/lib/pdf_extract.py index d87d240..ab09153 100644 --- a/lib/pdf_extract.py +++ b/lib/pdf_extract.py @@ -30,10 +30,20 @@ from pathlib import Path log = logging.getLogger(__name__) # 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 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: """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") +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 ------------------------------------------------- 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: """True fuer wertlose Text-/Nummern-Fragmente (kein echtes Symbol/Bild). - Typisch: flache (Hoehe <= 55 px), breite, schwarz-weisse Schnipsel, die in - Wahrheit gerenderte Kapitelnummern/Beschriftungen sind (z.B. "5.2.6", - zwei Quadrate + Zahlen). Diese werden verworfen. + Erkennt: + a) flache (Hoehe <= 55 px), breite, schwarz-weisse Schnipsel (gerenderte + 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): 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 @@ -395,8 +518,23 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path, logo_registry[c["ahash"]] = idx continue - is_symbol = (max(c["w"], c["h"]) <= SYMBOL_MAX_EDGE - and c["count"] >= SYMBOL_MIN_COUNT) + # Symbol-Erkennung (Warn-/Gebots-/Verbotszeichen, Bedien-Piktogramme): + # 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 if is_symbol: match = None @@ -406,8 +544,9 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path, break if match is None: symbols_dir.mkdir(parents=True, exist_ok=True) - match = f"symbol_{c['ahash'] & 0xffffffff:08x}.{c['ext']}" - (symbols_dir / match).write_bytes(c["data"]) + # auf quadratische Canvas normalisieren -> immer als PNG ablegen + match = f"symbol_{c['ahash'] & 0xffffffff:08x}.png" + (symbols_dir / match).write_bytes(normalize_symbol(c["data"])) symbol_registry[c["ahash"]] = 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] if e["kind"] == "text": 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"]) - if (m and nxt and nxt["kind"] == "text" - and re.match(r"[A-Za-zÄÖÜäöüß]", nxt["text"])): + if (m and "." in m.group(1) and nxt and nxt["kind"] == "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']}", "size": max(e["size"], nxt["size"])}) i += 2 @@ -511,9 +653,29 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path, # zusammenfuehren (PDF-Zeilenumbrueche sind kein Absatzende). 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: if para: - out.append(" ".join(para).strip()) + out.append(_join_para(para)) out.append("") para.clear() @@ -528,12 +690,15 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path, if not path: continue if e.get("is_symbol"): - # Symbol/Warnzeichen: kleine, zentrierte Grafik ohne Caption-Box, - # verweist auf die eine zentrale Quelle in assets/shared/symbols. + # Symbol/Warnzeichen: einheitlich kleine Grafik, links ausgerichtet + # (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(f".. image:: {path}") - out.append(" :align: center") - out.append(" :height: 12mm") + out.append(" :align: left") + out.append(" :height: 10mm") + out.append(" :class: warnsymbol") out.append("") blocks += 1 continue @@ -558,14 +723,30 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path, continue 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) - # Ueberschrift NUR mit fuehrender PDF-Gliederungsnummer akzeptieren und - # offensichtliche Fragmente/Bildlegenden ausschliessen (sonst werden - # Zellen wie "POS", "li.", "re.", "bar, max. 10 bar" zu Ueberschriften). + # Ueberschrift NUR, wenn ALLE Kriterien erfuellt sind: + # 1. fuehrende Gliederungsnummer ("N", "N.N", ...), + # 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 if hm and len(hm.group(2)) < 120: num, rest = hm.group(1), hm.group(2) rest_stripped = rest.strip() + looks_like_title = e.get("size", 0) >= 12.5 or e.get("bold", False) is_frag = ( len(rest_stripped) < 4 # zu kurz ("POS", "li.") 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)\.?$", rest_stripped, re.I) ) - if not is_frag: + if looks_like_title and not is_frag: is_heading = True if is_heading: