12 Commits

7 changed files with 489 additions and 210 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
python-3.*-embed-amd64.zip
python-embed/*
.vscode/launch.json
logs/*.log

View File

@@ -1,11 +1,11 @@
# PPTX Image Compressor (CaesiumCLT only) # PPTX Image Compressor (CaesiumCLT only)
**Version 1.0.0** **Version 1.1.4**
Dieses Paket enthält: Dieses Paket enthält:
``` ```
PPTX-Image-Compressor-1.0.0/ PPTX-Image-Compressor/
├─ README.md ├─ README.md
├─ install_and_run.bat ├─ install_and_run.bat
├─ pptx_image_compress.py ├─ pptx_image_compress.py
@@ -18,23 +18,30 @@ PPTX-Image-Compressor-1.0.0/
## Schnellstart (ohne Admin-Rechte) ## Schnellstart (ohne Admin-Rechte)
1) Doppelklicke `install_and_run.bat` **oder** rufe es in CMD/PowerShell auf, z.B.: 1) Doppelklicke `install_and_run.bat` **oder** rufe es in CMD/PowerShell auf, z.B.:
**Single**
```bat ```bat
install_and_run.bat -i "C:\Slides\Deck.pptx" -t 8 install_and_run.bat -i "C:\Slides\Deck.pptx" -t 8 -q 90
```
**Batch**
```bat
install_and_run.bat -i "C:\Slides\*.pptx" -O "C:\Slides\out" -t 8 -q 85
install_and_run.bat --input-dir "C:\Slides" --recursive -O "C:\Slides\out" -q 80
``` ```
Die Batch lädt bei Bedarf automatisch das **Windows Embeddable Python Package**, entpackt es lokal und führt das Tool aus. Die Batch lädt bei Bedarf automatisch das **Windows Embeddable Python Package**, entpackt es lokal und führt das Tool aus.
## Was das Tool macht ## Was das Tool macht
- Entpackt die PPTX in einen TempOrdner - Entpackt die PPTX in einen TempOrdner
- Komprimiert **JPG/JPEG, PNG, WebP** mit **CaesiumCLT** (`-q 90`, `-O bigger`) - Komprimiert **JPG/JPEG, PNG, WebP, GIF** mit **CaesiumCLT** (Default `-q 90`, `-O bigger`)
- Ersetzt Bilder nur, wenn die komprimierte Datei kleiner ist - Ersetzt Bilder nur, wenn die komprimierte Datei kleiner ist
- Schreibt ein CSVLog (`.log` neben der OutputPPTX) - Schreibt ein CSVLog (`.log` neben der OutputPPTX)
- Baut eine neue PPTX und zeigt eine Summary (Name, Größe vorher/nachher, Ersparnis %, Zeit) - Baut eine neue PPTX und zeigt eine Summary (Name, Größe vorher/nachher, Ersparnis %, Zeit)
- Räumt alle temporären Dateien auf (keine CaesiumTempfiles in der finalen PPTX) - Räumt alle temporären Dateien auf (keine CaesiumTempfiles in der finalen PPTX)
## Hinweise ## Hinweise
- **GIF** wird übersprungen (keine Rekodierung). - `-t` steuert die Parallelität der PythonThreads; intern wird `caesiumclt --threads 1` gesetzt, sobald `-t > 1`, um Oversubscription zu vermeiden. Default ist 16
- `-t` steuert die Parallelität (PythonThreads); intern wird `caesiumclt --threads 1` gesetzt, sobald `-t > 1`, um Oversubscription zu vermeiden. - `-q` steuert das Qualitätslevel; intern wird `caesiumclt -q` mit diesem Wert von `0..100` benutzt, Default ist 90
- Die Batch **verwendet bevorzugt das Embeddable Python** neben der BAT; ansonsten sucht sie echte `python.exe`/`py.exe` im PATH, **ignoriert** aber die MicrosoftStoreAliasPfade (`WindowsApps`).
## Manuelle Nutzung des .py (falls Python vorhanden) ## Manuelle Nutzung des .py (falls Python vorhanden)
```bat ```bat

Binary file not shown.

View File

@@ -3,77 +3,91 @@
setlocal EnableExtensions EnableDelayedExpansion setlocal EnableExtensions EnableDelayedExpansion
rem ============================================ rem ============================================
rem PPTX Image Compressor - Installer/Runner rem PPTX Image Compressor - Installer/Runner (Batch-enabled)
rem - No admin rights required rem Fix: caesiumclt.exe aus [ROOT]\bin; Python-Discovery ohne MS Store Alias
rem - Uses local CaesiumCLT and Python Embeddable
rem - Pass-through of all CLI args to the .py
rem ============================================ rem ============================================
set "APP_NAME=PPTX Image Compressor" set "APP_NAME=PPTX Image Compressor"
set "SELF_DIR=%~dp0" set "SELF_DIR=%~dp0"
set "BIN_DIR=%SELF_DIR%\bin\"
set "SCRIPT=%SELF_DIR%pptx_image_compress.py" set "SCRIPT=%SELF_DIR%pptx_image_compress.py"
rem ---- Python Embeddable config (adjust if needed) ---- rem ---- Python Embeddable config ----
set "PY_EMBED_VERSION=3.11.9" set "PY_EMBED_VERSION=3.14.4"
set "PY_EMBED_ZIP=python-%PY_EMBED_VERSION%-embed-amd64.zip" set "PY_EMBED_ZIP=python-%PY_EMBED_VERSION%-embed-amd64.zip"
set "PY_EMBED_URL=https://www.python.org/ftp/python/%PY_EMBED_VERSION%/%PY_EMBED_ZIP%" set "PY_EMBED_URL=https://www.python.org/ftp/python/%PY_EMBED_VERSION%/%PY_EMBED_ZIP%"
set "PY_DIR=%SELF_DIR%python-embed" set "PY_DIR=%SELF_DIR%python-embed"
set "PY_EXE=%PY_DIR%\python.exe" set "PY_EXE=%PY_DIR%\python.exe"
rem ---- CaesiumCLT discovery ---- rem ---- CaesiumCLT discovery (prefer [ROOT]\bin) ----
set "CAE_DIR=%SELF_DIR%bin"
set "CAE_EXE=caesiumclt.exe" set "CAE_EXE=caesiumclt.exe"
if exist "%CAE_DIR%\%CAE_EXE%" (
if exist "%BIN_DIR%%CAE_EXE%" ( set "PATH=%CAE_DIR%;%PATH%"
rem Prefer local caesiumclt.exe near the BAT
set "PATH=%BIN_DIR%;%PATH%"
) else ( ) else (
if exist "%SELF_DIR%%CAE_EXE%" (
set "PATH=%SELF_DIR%;%PATH%"
) else (
where /q %CAE_EXE% where /q %CAE_EXE%
if errorlevel 1 ( if errorlevel 1 (
echo [ERROR] ^> 'caesiumclt.exe' nicht gefunden. echo [ERROR] ^> 'caesiumclt.exe' nicht gefunden.
echo Lege 'caesiumclt.exe' neben diese BAT (empfohlen) echo Lege 'caesiumclt.exe' in '%CAE_DIR%' oder neben diese BAT,
echo oder sorge dafuer, dass es im PATH liegt. echo oder sorge dafuer, dass es im PATH liegt.
exit /b 2 exit /b 2
) )
)
rem ---- Python discovery / installation ----
set "PY_CMD="
rem 1) existing python in PATH?
where /q python
if %errorlevel%==0 (
for /f "delims=" %%P in ('where python 2^>nul') do (
set "PY_CMD=%%P"
goto :have_python
) )
) )
rem 2) local embeddable python present? rem ---- Determine ESC for ANSI (green check) ----
for /f "delims=" %%A in ('echo prompt $E^| cmd') do set "ESC=%%A"
rem ---- Python discovery (avoid MS Store alias) ----
set "PY_CMD="
set "USE_PY_LAUNCHER="
rem 1) Prefer local embeddable first
if exist "%PY_EXE%" ( if exist "%PY_EXE%" (
set "PY_CMD=%PY_EXE%" set "PY_CMD=%PY_EXE%"
goto :have_python goto :have_python
) )
rem 3) download embeddable python locally rem 2) Real python.exe in PATH (exclude WindowsApps alias)
echo [INFO] Kein Python gefunden. Lade Embeddable Python %PY_EMBED_VERSION% ... for /f "delims=" %%P in ('where python.exe 2^>nul') do (
powershell -NoLogo -NoProfile -Command ^ echo %%P | find /I "WindowsApps" >nul
"try { Invoke-WebRequest -Uri '%PY_EMBED_URL%' -OutFile '%SELF_DIR%%PY_EMBED_ZIP%' -UseBasicParsing; exit 0 } catch { Write-Error $_; exit 1 }" if errorlevel 1 (
if errorlevel 1 ( set "PY_CMD=%%P"
echo [WARN] Automatischer Download fehlgeschlagen. goto :have_python
echo Bitte lade die Datei manuell herunter: )
echo %PY_EMBED_URL% )
echo und speichere sie als: for /f "delims=" %%P in ('where python3.exe 2^>nul') do (
echo %SELF_DIR%%PY_EMBED_ZIP% echo %%P | find /I "WindowsApps" >nul
pause if errorlevel 1 (
set "PY_CMD=%%P"
goto :have_python
)
) )
rem 3) Python launcher py.exe (exclude WindowsApps)
for /f "delims=" %%P in ('where py.exe 2^>nul') do (
echo %%P | find /I "WindowsApps" >nul
if errorlevel 1 (
set "PY_CMD=%%P"
set "USE_PY_LAUNCHER=1"
goto :have_python
)
)
rem 4) Download embeddable locally
if not exist "%SELF_DIR%%PY_EMBED_ZIP%" (
echo [INFO] Kein Python gefunden. Lade Embeddable Python %PY_EMBED_VERSION% ...
powershell -NoLogo -NoProfile -Command ^
"try { Invoke-WebRequest -Uri '%PY_EMBED_URL%' -OutFile '%SELF_DIR%%PY_EMBED_ZIP%' -UseBasicParsing; exit 0 } catch { Write-Error $_; exit 1 }"
)
if not exist "%SELF_DIR%%PY_EMBED_ZIP%" ( if not exist "%SELF_DIR%%PY_EMBED_ZIP%" (
echo [ERROR] Embeddable-Python ZIP nicht vorhanden. Abbruch. echo [ERROR] Embeddable-Python ZIP nicht vorhanden. Abbruch.
exit /b 3 exit /b 3
) )
echo [INFO] Entpacke Embeddable Python nach "%PY_DIR%" ... echo [INFO] Entpacke nach "%PY_DIR%" ...
if exist "%PY_DIR%" rmdir /s /q "%PY_DIR%" if exist "%PY_DIR%" rmdir /s /q "%PY_DIR%"
mkdir "%PY_DIR%" >nul 2>&1 mkdir "%PY_DIR%" >nul 2>&1
powershell -NoLogo -NoProfile -Command ^ powershell -NoLogo -NoProfile -Command ^
@@ -82,7 +96,6 @@ if errorlevel 1 (
echo [ERROR] Konnte ZIP nicht entpacken. Abbruch. echo [ERROR] Konnte ZIP nicht entpacken. Abbruch.
exit /b 4 exit /b 4
) )
set "PY_CMD=%PY_EXE%" set "PY_CMD=%PY_EXE%"
:have_python :have_python
@@ -91,21 +104,9 @@ if not defined PY_CMD (
exit /b 5 exit /b 5
) )
rem ---- Optional: 'import site' im Embeddable aktivieren ----
if exist "%PY_DIR%" (
for /f "delims=" %%F in ('dir /b "%PY_DIR%\python3*.pth" 2^>nul') do (
set "PTH_FILE=%PY_DIR%\%%F"
)
if defined PTH_FILE (
powershell -NoLogo -NoProfile -Command ^
"(Get-Content -Raw '%PTH_FILE%') -replace '^\s*#\s*import site','import site' | Set-Content -Encoding ASCII '%PTH_FILE%'"
)
)
rem ---- Verify script presence ---- rem ---- Verify script presence ----
if not exist "%SCRIPT%" ( if not exist "%SCRIPT%" (
echo [ERROR] Script nicht gefunden: "%SCRIPT%" echo [ERROR] Script nicht gefunden: "%SCRIPT%"
echo Lege 'pptx_image_compress.py' neben diese BAT.
exit /b 6 exit /b 6
) )
@@ -118,7 +119,7 @@ set "RC=%ERRORLEVEL%"
echo. echo.
if "%RC%"=="0" ( if "%RC%"=="0" (
echo [OK] Fertig. echo Fertig.
) else ( ) else (
echo [ERROR] Prozess endete mit Code %RC%. echo [ERROR] Prozess endete mit Code %RC%.
) )

View File

@@ -1,8 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
PPTX Grafik-Komprimier-Tool (nur CaesiumCLT, Multi-Thread, sauberes Cleanup) PPTX Grafik-Komprimier-Tool (nur CaesiumCLT, Multi-Thread, Batch, sauberes Cleanup)
Version: 1.0.0 Version: 1.1.4
Highlights: Highlights:
- Caesium-Scratch außerhalb des PPTX-Arbeitsverzeichnisses -> keine Tempfiles in finaler PPTX - Caesium-Scratch außerhalb des PPTX-Arbeitsverzeichnisses -> keine Tempfiles in finaler PPTX
@@ -11,29 +12,32 @@ Highlights:
- Log: image_name,size_before,size_after,saving,saving_percent - Log: image_name,size_before,size_after,saving,saving_percent
- Summary inkl. Zeit benötigt - Summary inkl. Zeit benötigt
Benutzung: Änderungen in 1.1.4:
python pptx_image_compress.py -i input.pptx [-o output.pptx] [-t THREADS] [--version] - Libcaesium 1.1.0 kann nun auch gif verkleinern
""" """
import argparse import argparse
import os import os
import re
import xml.etree.ElementTree as ET
import sys import sys
import zipfile import zipfile
import tempfile import tempfile
import shutil import shutil
import subprocess import subprocess
import time import time
import fnmatch
from glob import glob
from pathlib import Path from pathlib import Path
from datetime import timedelta from datetime import timedelta
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from threading import Lock from threading import Lock
from typing import List, Optional
# -------------------- Version --------------------
__version__ = "1.0.0"
# -------------------- Konfiguration -------------------- __version__ = "1.1.4"
ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".webp", ".gif"} # GIF wird übersprungen
CAESIUM_QUALITY = 90 # -q 90 ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
PROGRESS_BAR_LEN = 40 PROGRESS_BAR_LEN = 40
TEMP_PREFIX = "pptx_compress_" TEMP_PREFIX = "pptx_compress_"
@@ -42,6 +46,8 @@ TEMP_PREFIX = "pptx_compress_"
def human_mb(nbytes: int) -> float: def human_mb(nbytes: int) -> float:
return round(nbytes / (1024 * 1024), 2) return round(nbytes / (1024 * 1024), 2)
def human_kb(nbytes: int) -> float:
return round(nbytes / 1024,2)
def ensure_clean_file(path: Path): def ensure_clean_file(path: Path):
if path.exists(): if path.exists():
@@ -53,7 +59,6 @@ def ensure_clean_file(path: Path):
except Exception: except Exception:
pass pass
def cleanup_old_temps(): def cleanup_old_temps():
tmp_root = Path(tempfile.gettempdir()) tmp_root = Path(tempfile.gettempdir())
for p in tmp_root.glob(f"{TEMP_PREFIX}*"): for p in tmp_root.glob(f"{TEMP_PREFIX}*"):
@@ -65,7 +70,6 @@ def cleanup_old_temps():
except Exception: except Exception:
pass pass
def print_progress(i: int, total: int): def print_progress(i: int, total: int):
if total <= 0: if total <= 0:
return return
@@ -74,7 +78,6 @@ def print_progress(i: int, total: int):
pct = int(i * 100 / total) pct = int(i * 100 / total)
print(f"\rBilder: |{bar}| {i}/{total} ({pct}%)", end="", flush=True) print(f"\rBilder: |{bar}| {i}/{total} ({pct}%)", end="", flush=True)
def zip_dir_to_pptx(src_dir: Path, out_pptx: Path): def zip_dir_to_pptx(src_dir: Path, out_pptx: Path):
with zipfile.ZipFile(out_pptx, "w", compression=zipfile.ZIP_DEFLATED) as z: with zipfile.ZipFile(out_pptx, "w", compression=zipfile.ZIP_DEFLATED) as z:
for root, _, files in os.walk(src_dir): for root, _, files in os.walk(src_dir):
@@ -83,53 +86,32 @@ def zip_dir_to_pptx(src_dir: Path, out_pptx: Path):
rel = full.relative_to(src_dir) rel = full.relative_to(src_dir)
z.write(full, arcname=str(rel)) z.write(full, arcname=str(rel))
def which(cmd: str):
def which(cmd: str) -> str | None:
return shutil.which(cmd) return shutil.which(cmd)
def compress_with_caesium(original: Path, out_dir: Path, caesium_threads: int | None, quality: int) -> Path | None:
def compress_with_caesium(original: Path, out_dir: Path, caesium_threads: int | None) -> Path | None:
"""
Ruft caesiumclt auf, um eine komprimierte Version zu erzeugen.
Output wird ins out_dir geschrieben (gleicher Filename).
Gibt Pfad zur erzeugten Datei zurück oder None bei Fehler.
"""
exe = which("caesiumclt") exe = which("caesiumclt")
if not exe: if not exe:
raise RuntimeError( raise RuntimeError("[ERROR] 'caesiumclt' wurde nicht gefunden. Bitte CaesiumCLT installieren und in PATH verfügbar machen.")
"'caesiumclt' wurde nicht gefunden. Bitte CaesiumCLT installieren und in PATH verfügbar machen."
)
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
# Nur Formate an Caesium geben, die es unterstützt: JPG/JPEG, PNG, WEBP
ext = original.suffix.lower() ext = original.suffix.lower()
if ext not in {".jpg", ".jpeg", ".png", ".webp"}: if ext not in {".jpg", ".jpeg", ".png", ".webp", ".gif"}:
return None # GIF & andere werden übersprungen return None
cmd = [exe, "-q", str(quality), "-O", "bigger", "-o", str(out_dir)]
cmd = [
exe,
"-q", str(CAESIUM_QUALITY),
"-O", "bigger", # <<< nur überschreiben, wenn Ziel größer ist
"-o", str(out_dir),
]
if caesium_threads is not None: if caesium_threads is not None:
cmd += ["--threads", str(caesium_threads)] cmd += ["--threads", str(caesium_threads)]
cmd += [str(original)] cmd += [str(original)]
try: try:
r = subprocess.run(cmd, capture_output=True, text=True) r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0: if r.returncode != 0:
sys.stderr.write(f"\n[caesiumclt] Fehler bei {original.name}:\n{r.stderr}\n") sys.stderr.write(f"[caesiumclt] Fehler bei {original.name}:{r.stderr}")
return None return None
out_file = out_dir / original.name out_file = out_dir / original.name
return out_file if out_file.exists() else None return out_file if out_file.exists() else None
except Exception as ex: except Exception as ex:
sys.stderr.write(f"\n[caesiumclt] Ausnahme bei {original.name}: {ex}\n") sys.stderr.write(f"[caesiumclt] Ausnahme bei {original.name}: {ex}")
return None return None
def format_duration(seconds: float) -> str: def format_duration(seconds: float) -> str:
total_ms = int(round(seconds * 1000)) total_ms = int(round(seconds * 1000))
td = timedelta(milliseconds=total_ms) td = timedelta(milliseconds=total_ms)
@@ -139,84 +121,90 @@ def format_duration(seconds: float) -> str:
return f"{hms}.{frac[:2]}" return f"{hms}.{frac[:2]}"
return base return base
def get_slide_numbers_for_image(rels_dir: str, image_filename: str) -> Optional[List[int]]:
"""
Durchsucht alle .rels-Dateien im angegebenen Verzeichnis und gibt die Slide-Nummern zurück,
in denen die angegebene Bilddatei referenziert wird.
def main(): :param rels_dir: Pfad zum Verzeichnis ppt/slides/_rels
:param image_filename: z.B. 'image80.png'
:return: Liste von Slide-Nummern oder None
"""
slide_numbers = []
for rels_file in os.listdir(rels_dir):
if rels_file.startswith("slide") and rels_file.endswith(".xml.rels"):
rels_path = os.path.join(rels_dir, rels_file)
try:
tree = ET.parse(rels_path)
root = tree.getroot()
for rel in root.findall(".//{http://schemas.openxmlformats.org/package/2006/relationships}Relationship"):
target = rel.attrib.get("Target", "")
if image_filename in target:
match = re.search(r"slide(\d+).xml.rels", rels_file)
if match:
slide_number = int(match.group(1))
slide_numbers.append(slide_number)
except ET.ParseError:
print(f"Fehler beim Parsen von {rels_file}")
return slide_numbers if slide_numbers else None
# -------------------- Core per-deck processing --------------------
def process_single_deck(input_pptx: Path, output_pptx: Path, threads: int, quality: int) -> dict:
start_time = time.perf_counter() start_time = time.perf_counter()
result = {
"input": str(input_pptx),
"output": str(output_pptx),
"ok": False,
"size_before": 0,
"size_after": 0,
"elapsed_sec": 0.0,
"error": None,
"log_file": None,
}
parser = argparse.ArgumentParser( try:
description="PPTX Grafik-Komprimier-Tool (nur CaesiumCLT, Multi-Thread, sauberes Cleanup)",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("-i", "--input", help="Input-PPTX", required=False)
parser.add_argument("-o", "--output", help="Output-PPTX", required=False)
parser.add_argument(
"-t", "--threads",
type=int,
default=min(32, os.cpu_count() or 4),
help="Anzahl paralleler Threads für die Bildverarbeitung"
)
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {__version__}"
)
args = parser.parse_args()
if not args.input:
parser.print_help()
sys.exit(1)
input_pptx = Path(args.input).resolve()
if not input_pptx.exists() or input_pptx.suffix.lower() != ".pptx": if not input_pptx.exists() or input_pptx.suffix.lower() != ".pptx":
print("Eingabedatei existiert nicht oder ist keine .pptx") raise ValueError("Eingabedatei existiert nicht oder ist keine .pptx")
sys.exit(2)
if args.output:
output_pptx = Path(args.output).resolve()
else:
output_pptx = input_pptx.with_name(f"{input_pptx.stem}_compressed.pptx")
# Vorherige Temp-Files & existierendes Output löschen
cleanup_old_temps() cleanup_old_temps()
ensure_clean_file(output_pptx) ensure_clean_file(output_pptx)
# --- Zwei getrennte Temp-Verzeichnisse --- work_dir = Path(tempfile.mkdtemp(prefix=TEMP_PREFIX + "work_"))
work_dir = Path(tempfile.mkdtemp(prefix=TEMP_PREFIX + "work_")) # entpackte PPTX scratch_dir = Path(tempfile.mkdtemp(prefix=TEMP_PREFIX + "scratch_"))
scratch_dir = Path(tempfile.mkdtemp(prefix=TEMP_PREFIX + "scratch_")) # Caesium-Ausgaben (außerhalb!)
# Logdatei neben Output log_file = output_pptx.with_suffix(".log.csv")
log_file = output_pptx.with_suffix(".log")
ensure_clean_file(log_file) ensure_clean_file(log_file)
log_lines = ["image_name,size_before,size_after,saving,saving_percent\n"] log_lines = ["image_name;size_before(kb);size_after(kb);saving(kb);saving_percent(%);in_slide_number\n"]
size_before = input_pptx.stat().st_size size_before = input_pptx.stat().st_size
result["size_before"] = size_before
try:
# Entpacken
with zipfile.ZipFile(input_pptx, "r") as z: with zipfile.ZipFile(input_pptx, "r") as z:
z.extractall(work_dir) z.extractall(work_dir)
slides_dir = work_dir / "ppt" / "slides"
media_dir = work_dir / "ppt" / "media" media_dir = work_dir / "ppt" / "media"
images = [] images = []
if media_dir.exists(): if media_dir.exists():
for f in sorted(media_dir.iterdir()): for f in sorted(media_dir.iterdir()):
if f.is_file() and f.suffix.lower() in ALLOWED_EXT: if f.is_file() and f.suffix.lower() in ALLOWED_EXT:
images.append(f) images.append(f)
total = len(images) total = len(images)
print(f"🔧 Finde Bilder in {media_dir} ... {total} Kandidaten") print(f"[Processing] {input_pptx.name}: {total} Bild(er) gefunden")
print_progress(0, total) print_progress(0, total)
# Vorab prüfen, ob caesiumclt verfügbar ist
if not which("caesiumclt"): if not which("caesiumclt"):
print("\n'caesiumclt' nicht gefunden. Bitte installieren und in PATH verfügbar machen.") raise RuntimeError("'caesiumclt' nicht gefunden. Bitte installieren und in PATH verfügbar machen.")
sys.exit(3)
# Oversubscription vermeiden: viele Python-Threads -> caesium intern 1 Thread caesium_threads = 1 if threads and threads > 1 else None
caesium_threads = 1 if args.threads and args.threads > 1 else None
# Thread-sichere Fortschritts- & Log-Verwaltung
lock = Lock() lock = Lock()
done_count = 0 done_count = 0
@@ -224,52 +212,45 @@ def main():
nonlocal done_count nonlocal done_count
ext = img_path.suffix.lower() ext = img_path.suffix.lower()
orig_size = img_path.stat().st_size orig_size = img_path.stat().st_size
# GIF überspringen
if ext == ".gif":
with lock:
done_count += 1
log_lines.append(f"{img_path.name},{orig_size},{orig_size},0,0.0\n")
print_progress(done_count, total)
return
chosen_size = orig_size chosen_size = orig_size
try: found_in_slide=None
# Eigener Output-Unterordner pro Bild, um Kollisionen zu vermeiden slide_nr=""
out_sub = scratch_dir / f"img_{idx:06d}"
caesium_out = compress_with_caesium(img_path, out_sub, caesium_threads)
try:
found_in_slide = get_slide_numbers_for_image(slides_dir.name, img_path.name)
if found_in_slide is None:
slide_nr = "NOT_USED"
else:
slide_nr = str(found_in_slide)
out_sub = scratch_dir / f"img_{idx:06d}"
caesium_out = compress_with_caesium(img_path, out_sub, caesium_threads, quality)
if caesium_out and caesium_out.exists(): if caesium_out and caesium_out.exists():
s = caesium_out.stat().st_size s = caesium_out.stat().st_size
if s < orig_size: if s < orig_size:
# kleineren ersetzen (atomar)
tmp_target = img_path.with_suffix(img_path.suffix + ".tmp") tmp_target = img_path.with_suffix(img_path.suffix + ".tmp")
shutil.copy2(caesium_out, tmp_target) shutil.copy2(caesium_out, tmp_target)
tmp_target.replace(img_path) tmp_target.replace(img_path)
chosen_size = s chosen_size = s
except Exception: except Exception:
chosen_size = orig_size # Original beibehalten chosen_size = orig_size
finally: finally:
saving = orig_size - chosen_size saving = orig_size - chosen_size
saving_percent = round((saving / orig_size) * 100, 2) if orig_size > 0 else 0.0 saving_percent = round((saving / orig_size) * 100, 2) if orig_size > 0 else 0.0
with lock: with lock:
log_lines.append(f"{img_path.name},{orig_size},{chosen_size},{saving},{saving_percent}\n") log_lines.append(f"{img_path.name};{human_kb(orig_size)};{human_kb(chosen_size)};{human_kb(saving)};{saving_percent};{slide_nr}\n")
done_count += 1 done_count += 1
print_progress(done_count, total) print_progress(done_count, total)
# Parallel ausführen
if total > 0: if total > 0:
with ThreadPoolExecutor(max_workers=max(1, args.threads)) as ex: with ThreadPoolExecutor(max_workers=max(1, threads)) as ex:
futures = [ex.submit(worker, i, p) for i, p in enumerate(images, start=1)] futures = [ex.submit(worker, i, p) for i, p in enumerate(images, start=1)]
for _ in as_completed(futures): for _ in as_completed(futures):
pass # Fortschritt wird im Worker gezeichnet pass
print() # newline nach Progressbar print() # newline
# --- Safety-Cleanup innerhalb des Arbeitsverzeichnisses --- # Safety cleanup inside work_dir
# 1) Entferne evtl. vorhandene caesium*-Ordner (aus alten Runs)
for p in work_dir.rglob("*"): for p in work_dir.rglob("*"):
try: try:
if p.is_dir() and p.name.lower().startswith("caesium"): if p.is_dir() and p.name.lower().startswith("caesium"):
@@ -277,8 +258,6 @@ def main():
except Exception: except Exception:
pass pass
# 2) Lösche eventuelle .tmp-Dateien in ppt/media
media_dir = work_dir / "ppt" / "media"
if media_dir.exists(): if media_dir.exists():
for f in media_dir.iterdir(): for f in media_dir.iterdir():
if f.is_file() and f.suffix.lower() == ".tmp": if f.is_file() and f.suffix.lower() == ".tmp":
@@ -287,47 +266,194 @@ def main():
except Exception: except Exception:
pass pass
# Neue PPTX bauen (nur work_dir -> scratch_dir liegt außerhalb und ist damit sicher ausgeschlossen)
zip_dir_to_pptx(work_dir, output_pptx) zip_dir_to_pptx(work_dir, output_pptx)
size_after = output_pptx.stat().st_size size_after = output_pptx.stat().st_size
result["size_after"] = size_after
# Log schreiben
try: try:
with open(log_file, "w", encoding="utf-8") as f: with open(log_file, "w", encoding="utf-8") as f:
f.writelines(log_lines) f.writelines(log_lines)
except Exception: except Exception:
pass pass
# Summary
savings_pct = 0.0
if size_before > 0:
savings_pct = round(100.0 * (size_before - size_after) / size_before, 2)
elapsed = time.perf_counter() - start_time elapsed = time.perf_counter() - start_time
result["elapsed_sec"] = elapsed
result["log_file"] = str(log_file)
result["ok"] = True
print("\n✅ Fertig!") savings_pct = 0.0 if size_before == 0 else round(100.0 * (size_before - size_after) / size_before, 2)
print("Summary") print(f"[OK] Fertig! ({input_pptx.name})")
print("-------") print("Zusammenfassung ----------------")
print(f"Version: {__version__}") print(" Vorher: ", human_mb(size_before), "MB")
print(f"Name: {output_pptx.name}") print(" Nachher: ", human_mb(size_after), "MB")
print(f"Datei-Größe vorher: {human_mb(size_before)} MB") print(" Ersparnis: ", f"{savings_pct}%")
print(f"Datei-Größe nachher: {human_mb(size_after)} MB") print(" Zeit: ", format_duration(elapsed))
print(f"Ersparnis: {savings_pct}%") print(" Log: ", log_file)
print(f"Zeit benötigt: {format_duration(elapsed)}")
print(f"Log-Datei: {log_file}")
except Exception as e:
result["error"] = str(e)
finally: finally:
# Aufräumen ALLER temporären Dateien/Ordner
try: try:
shutil.rmtree(work_dir, ignore_errors=True) shutil.rmtree(work_dir, ignore_errors=True) # type: ignore[name-defined]
except Exception: except Exception:
pass pass
try: try:
shutil.rmtree(scratch_dir, ignore_errors=True) shutil.rmtree(scratch_dir, ignore_errors=True) # type: ignore[name-defined]
except Exception: except Exception:
pass pass
# Zusätzlich: ältere Reste entfernen
cleanup_old_temps() cleanup_old_temps()
return result
if __name__ == "__main__": # -------------------- Input helpers --------------------
def expand_inputs(inputs: list[str]) -> list[Path]:
files: list[Path] = []
for inp in inputs:
p = Path(inp)
if any(ch in inp for ch in ['*', '?']):
for g in glob(inp):
if g.lower().endswith('.pptx'):
files.append(Path(g).resolve())
else:
if p.is_dir():
for g in p.glob('*.pptx'):
files.append(g.resolve())
else:
if p.suffix.lower() == '.pptx':
files.append(p.resolve())
seen = set()
uniq = []
for f in files:
if str(f) not in seen:
uniq.append(f)
seen.add(str(f))
return uniq
def collect_from_dir(input_dir: Path, pattern: str, recursive: bool) -> list[Path]:
files: list[Path] = []
if recursive:
for root, _, names in os.walk(input_dir):
for n in names:
if fnmatch.fnmatch(n, pattern):
p = Path(root) / n
if p.suffix.lower() == '.pptx':
files.append(p.resolve())
else:
for p in input_dir.glob(pattern):
if p.suffix.lower() == '.pptx':
files.append(p.resolve())
seen = set()
out = []
for f in files:
s = str(f)
if s not in seen:
out.append(f)
seen.add(s)
return out
# -------------------- CLI --------------------
def main():
parser = argparse.ArgumentParser(
description="PPTX Grafik-Komprimier-Tool (nur CaesiumCLT, Multi-Thread, Batch, sauberes Cleanup)",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument('-i','--input', nargs='*', help='Input-PPTX (eine oder mehrere, Wildcards erlaubt). Bei mehreren: -O erforderlich.')
parser.add_argument('-o','--output', help='Output-PPTX (nur Single-Mode)')
parser.add_argument('-O','--output-dir', help='Output-Verzeichnis (erforderlich für Batch)')
parser.add_argument('--input-dir', help='Eingabe-Verzeichnis (optional, für Batch)')
parser.add_argument('--pattern', default='*.pptx', help='Dateimuster für --input-dir')
parser.add_argument('--recursive', action='store_true', help='Rekursiv in --input-dir suchen')
#parser.add_argument('-t','--threads', type=int, default=min(32, os.cpu_count() or 4), help='Anzahl paralleler Threads pro Datei')
parser.add_argument('-t','--threads', type=int, default=16, help='Anzahl paralleler Threads pro Datei')
parser.add_argument('-q','--quality', type=int, default=90, help='Qualität für caesiumclt (0..100), höher = bessere Qualität / größere Datei')
parser.add_argument('--version', action='version', version=f'%(prog)s {__version__}')
args = parser.parse_args()
print("Threads used: ", args.threads," Threads")
if args.quality < 0 or args.quality > 100:
print('[ERROR] Ungültige Qualität. Erlaubt: 0..100')
sys.exit(1)
input_files: list[Path] = []
if args.input:
input_files.extend(expand_inputs(args.input))
if args.input_dir:
input_files.extend(collect_from_dir(Path(args.input_dir), args.pattern, args.recursive))
if len(input_files) == 0:
parser.print_help()
sys.exit(1)
batch_mode = len(input_files) > 1
if batch_mode and not args.output_dir:
print('[ERROR] Batch-Modus erkannt. Bitte -O/--output-dir angeben.')
sys.exit(2)
if not which('caesiumclt'):
print("[ERROR] 'caesiumclt' nicht gefunden. Bitte installieren und in PATH verfügbar machen.")
sys.exit(3)
overall_before = 0
overall_after = 0
successes = 0
failures = 0
if batch_mode:
out_dir = Path(args.output_dir).resolve()
out_dir.mkdir(parents=True, exist_ok=True)
print(f"Batch: {len(input_files)} Datei(en). Output-Verzeichnis: {out_dir}")
for src in input_files:
if not src.exists():
print(f"- Übersprungen (nicht gefunden): {src}")
failures += 1
continue
dst = out_dir / f"{src.stem}_compressed.pptx"
res = process_single_deck(src, dst, args.threads, args.quality)
if res['ok']:
successes += 1
overall_before += res['size_before']
overall_after += res['size_after']
else:
failures += 1
print(f" Fehler: {src.name} -> {res['error']}")
else:
src = input_files[0]
if args.output_dir:
Path(args.output_dir).mkdir(parents=True, exist_ok=True)
dst = Path(args.output_dir) / f"{src.stem}_compressed.pptx"
else:
dst = Path(args.output).resolve() if args.output else src.with_name(f"{src.stem}_compressed.pptx")
res = process_single_deck(src, dst, args.threads, args.quality)
if res['ok']:
successes += 1
overall_before += res['size_before']
overall_after += res['size_after']
else:
failures += 1
print(f" Fehler: {src.name} -> {res['error']}")
if batch_mode:
print(f"====== Gesamt-Summary ======")
print(f"[SUCCESS] Dateien erfolgreich: {successes}")
if failures > 0:
print(f"[FAILED] Dateien fehlgeschlagen: {failures}")
if overall_before > 0:
pct = round(100.0 * (overall_before - overall_after) / overall_before, 2)
else:
pct = 0.0
print(f"Gesamtgröße vorher: {human_mb(overall_before)} MB")
print(f"Gesamtgröße nachher: {human_mb(overall_after)} MB")
print(f"Gesamt-Ersparnis: {pct}%")
if __name__ == '__main__':
main() main()

View File

@@ -0,0 +1,142 @@
@echo off
setlocal EnableExtensions DisableDelayedExpansion
rem ==========================================================
rem PPTX Image Compressor - Drag&Drop Wrapper (robust + logging, RC-Fix)
rem ==========================================================
set "SELF_DIR=%~dp0"
set "RUNNER=%SELF_DIR%install_and_run.bat"
set "DEFAULT_THREADS=8"
set "DEFAULT_QUALITY=90"
set "PAUSE_ON_ERROR=1"
set "PAUSE_ALWAYS=1"
for /f "delims=" %%A in ('echo prompt $E^| cmd') do set "ESC=%%A"
set "GREEN=%ESC%[92m"
set "YELLOW=%ESC%[93m"
set "RED=%ESC%[91m"
set "RESET=%ESC%[0m"
if not exist "%RUNNER%" (
echo %RED%[ERROR]%RESET% Runner nicht gefunden: "%RUNNER%"
pause
exit /b 2
)
if "%~1"=="" (
echo Ziehe 1..n ^*.pptx Dateien auf "%~nx0".
pause
exit /b 64
)
set "LOGDIR=%SELF_DIR%logs"
if not exist "%LOGDIR%" mkdir "%LOGDIR%" >nul 2>&1
for /f "usebackq delims=" %%t in (`powershell -NoLogo -NoProfile -Command "(Get-Date).ToString('yyyy-MM-dd_HH-mm-ss')"`) do set "TS=%%t"
set "LOGFILE=%LOGDIR%\dragdrop_%TS%.log"
(
echo ==========================================================
echo Drag^&Drop Session %DATE% %TIME%
echo Runner: "%RUNNER%"
echo Wrapper: "%~nx0"
echo WorkingDir: "%CD%"
echo Defaults: threads=%DEFAULT_THREADS%, quality=%DEFAULT_QUALITY%
echo Args:
for %%# in (%*) do @echo "%%~f#"
echo ==========================================================
) >>"%LOGFILE%" 2>&1
set /a TOTAL=0, OK=0, FAIL=0, SKIP=0
:loop
if "%~1"=="" goto done
set "ARG_FULL=%~f1"
set "ARG_EXT=%~x1"
set /a TOTAL+=1
if not exist "%ARG_FULL%" (
echo %YELLOW%[SKIP]%RESET% Nicht gefunden: "%ARG_FULL%"
echo [SKIP] Not found: "%ARG_FULL%" >>"%LOGFILE%" 2>&1
set /a SKIP+=1
shift & goto loop
)
if exist "%ARG_FULL%\" (
echo %YELLOW%[SKIP]%RESET% Ist ein Ordner: "%ARG_FULL%"
echo [SKIP] Is a directory: "%ARG_FULL%" >>"%LOGFILE%" 2>&1
set /a SKIP+=1
shift & goto loop
)
if /I not "%ARG_EXT%"==".pptx" (
echo %YELLOW%[SKIP]%RESET% Keine PPTX: "%ARG_FULL%"
echo [SKIP] Not a .pptx: "%ARG_FULL%" >>"%LOGFILE%" 2>&1
set /a SKIP+=1
shift & goto loop
)
echo.
echo ===== Verarbeite: "%ARG_FULL%" =====
echo ----- Processing "%ARG_FULL%" ----- >>"%LOGFILE%" 2>&1
REM --- Runner aufrufen + vollständige Ausgabe loggen
call "%RUNNER%" -i "%ARG_FULL%" -t %DEFAULT_THREADS% -q %DEFAULT_QUALITY% >>"%LOGFILE%" 2>&1
echo ----- [INFO] Errorlevel = "%ERRORLEVEL%"
set "RC=%ERRORLEVEL%"
echo ----- [INFO] ReturnCode = "%RC%"
REM --- ROBUSTE NUMERISCHE PRÜFUNG STATT STRINGVERGLEICH
REM (GEQ 1 => Fehler; EQ 0 => OK)
if "%RC%"=="" set "RC=1"
set /a RC+=0
echo ----- [INFO] ReturnCodeAgain = "%RC%"
if %RC% GEQ 1 (
echo ----- [WARN] Assuming RC GEQ 1
echo %RED%[FAIL]%RESET% "%ARG_FULL%" (Code %RC%)
echo [FAIL] "%ARG_FULL%" Code=%RC% >>"%LOGFILE%" 2>&1
set /a FAIL+=1
) else (
echo %GREEN%[OK]%RESET% "%ARG_FULL%"
echo [OK] "%ARG_FULL%" >>"%LOGFILE%" 2>&1
set /a OK+=1
set /a FAIL=0
)
echo ---- [INFO] Fail-State = "%FAIL%"
shift
goto loop
:done
echo.
echo ------------------ Zusammenfassung ------------------
echo Dateien gesamt: %TOTAL%
echo Erfolgreich: %OK%
echo Fehlgeschlagen: %FAIL%
echo Uebersprungen: %SKIP%
echo Log-Datei: "%LOGFILE%"
echo ----------------------------------------------------
echo.>>"%LOGFILE%" & echo Summary: total=%TOTAL% ok=%OK% fail=%FAIL% skip=%SKIP%>>"%LOGFILE%"
if %FAIL% GTR 0 (
echo %RED%Ergebnis:%RESET% teils fehlgeschlagen. Bitte Log pruefen:
echo "%LOGFILE%"
if "%PAUSE_ON_ERROR%"=="1" (
echo.
echo [ENTER] druecken, um das Log in Notepad zu oeffnen...
pause >nul
start "" notepad "%LOGFILE%"
echo [CMD-Fenster bleibt bis zum Schliessen von Notepad geoeffnet.]
pause
)
endlocal & exit /b 1
) else (
echo %GREEN%Ergebnis:%RESET% alle erfolgreich.
if "%PAUSE_ALWAYS%"=="1" (
echo.
pause
)
endlocal & exit /b 0
)

View File

@@ -1 +0,0 @@
Place your PPTX files here for testing, or use -i with a full path.