27 Commits

Author SHA1 Message Date
frank.conrads 370cd80bbe Explain install_and_run Installation 2026-06-17 11:17:55 +02:00
frank.conrads ab62ce4f7b added concurrency to std. formula, should be still 16 Threads on local machine 2026-06-17 11:17:32 +02:00
frank.conrads dae74da39e corrected bat files for building dist 2026-06-17 11:12:56 +02:00
frank.conrads 16f30e8d38 install_and_run.bat to install svg-polish correctly in case of "new" standalone. Added requirements of packages in requirements-dev.txt and requirement.txt 2026-06-17 10:46:39 +02:00
frank.conrads e6a8fcb963 Added functionality to build an exe (with upx compression), use within an venv 2026-06-16 16:43:26 +02:00
frank.conrads d0fd8e9996 Changed check_new_version to more lightweight format. Inform only, no download 2026-06-16 15:04:53 +02:00
frank.conrads fc0d515a42 README.md aktualisiert 2026-06-10 14:57:06 +02:00
frank.conrads 3c65f8cf65 SVG-Compression Profile balanced ist wesentlich konservativer, Datei-Eigenschaften der neuen PPTX enthält Hinweis auf PPTX Image Compressor in den Kommentaren 2026-06-10 12:43:12 +02:00
frank.conrads 0549c5eae7 Cleanup SVGO 2026-06-10 10:21:00 +02:00
frank.conrads 0cec37eecd SVG-Compress hinzugefügt via svg-polish python module 2026-06-10 10:17:36 +02:00
frank.conrads b880c4f03a Trim vendored svgo-client payload
Remove unused svgo-client files while keeping bin/svgo-client/svgo.cmd runtime behavior intact.

Drop node_modules/.bin wrappers, TypeScript declaration files (*.d.ts), and obsolete svgo-cli.js wrapper script.

Co-Authored-By: Abacus.AI CLI <agent@abacus.ai>
2026-06-08 15:01:33 +02:00
frank.conrads 6c5a5256c7 Use local svgo.cmd wrapper binary
Switch SVG optimizer resolution from bin/svgo-cli.exe to bin/svgo.cmd.

Update unit tests to validate the new local binary path behavior.

Co-Authored-By: Abacus.AI CLI <agent@abacus.ai>
2026-06-08 14:51:40 +02:00
frank.conrads 75059f829a Add SVG compression via npx svgo
Add vector extension support for .svg and route SVG files through npx svgo before raster compression.

Keep behavior fail-safe: missing npx/svgo or non-zero svgo exit returns None and preserves existing flow.

Extend tests for SVG discovery, SVG routing priority, and missing npx handling.

Co-Authored-By: Abacus.AI CLI <agent@abacus.ai>
2026-06-08 13:40:45 +02:00
frank.conrads 89d0bb399c Refactor compression flow with routing seam
Introduce compress_image_with_routing and compress_raster_image to prepare extensible backend routing while keeping existing behavior unchanged.

Add unit test that verifies routing currently delegates to raster compressor.

Co-Authored-By: Abacus.AI CLI <agent@abacus.ai>
2026-06-08 13:37:05 +02:00
frank.conrads 337bf1e97b Version 1.1.7 fügt PNG to JPEG Funktion hinzu. Ist das komprimierte PNG immer noch >500 KB wird versucht, dies als JPEG mit quality=90 zu konvertieren, das ist oft sehr viel besser komprimiert 2026-06-03 14:24:25 +02:00
frank.conrads d779821198 New Python Version 3.14.5 2026-05-11 11:28:01 +02:00
frank.conrads 907ce6a0de Errorcode 1 behoben, wenn install_and_run.bat ohne Args aufgerufen wird 2026-05-06 14:32:38 +02:00
frank.conrads c69ec1eecb fix: apply medium/high severity code review findings
- Re-raise worker futures in as_completed to surface thread exceptions
- Replace hardcoded extension set with ALLOWED_EXT constant in compress_with_caesium
- Initialise work_dir/scratch_dir to None before try block to prevent NameError in finally
- Remove unused dead function get_slide_numbers_for_image
- Simplify redundant caesium_threads guard (threads and threads > 1 -> threads > 1)
- Write [Content_Types].xml first in ZIP to satisfy OOXML spec

Co-Authored-By: Abacus.AI CLI <agent@abacus.ai>
2026-04-09 10:26:45 +02:00
frank.conrads 252b2c2cd5 readme auf 1.1.6 angepasst 2026-04-09 10:14:18 +02:00
frank.conrads 698aac0aba Refactor and UnitTest 2026-04-09 10:10:57 +02:00
frank.conrads 332e62b764 Funktionalität min_savings der caesiumclt 1.3.0 implementiert (default: 2%), log-Datei um "Bild in Folien Nr." ergänzt. 2026-04-09 09:40:19 +02:00
frank.conrads 5e7a6643ea Revert tool to last version, bump internal version to 1.1.5 2026-04-08 13:52:30 +02:00
frank.conrads 0338fd6524 Bumped CaesiumCLT to 1.3.0, python to 3.14.3 2026-04-08 13:32:08 +02:00
frank.conrads 85876228eb New Version 1.1.4 2025-10-30 10:11:53 +01:00
frank.conrads f498cafdf3 Bump up libcaesium to 1.1.0, now also supporting gif 2025-10-30 10:07:56 +01:00
frank.conrads f0d07dab4b Bump up libcaesium to 1.0.2 2025-10-29 16:24:17 +01:00
frank.conrads de9c9ad48c Updated Python Version to 3.13.9 2025-10-23 10:27:21 +02:00
20 changed files with 2236 additions and 148 deletions
+5 -1
View File
@@ -1,4 +1,8 @@
python-3.*-embed-amd64.zip python-3.*-embed-amd64.zip
python-embed/* python-embed/*
.vscode/launch.json .vscode/launch.json
logs/*.log logs/*.log
__pycache__/*
.coverage
build/*
dist/*
+11
View File
@@ -0,0 +1,11 @@
{
"python.testing.unittestArgs": [
"-v",
"-s",
".",
"-p",
"test_*.py"
],
"python.testing.pytestEnabled": false,
"python.testing.unittestEnabled": true
}
+15 -8
View File
@@ -1,6 +1,5 @@
# PPTX Image Compressor (CaesiumCLT + svg-polish)
# PPTX Image Compressor (CaesiumCLT only) **Version 1.1.9**
**Version 1.1.3**
Dieses Paket enthält: Dieses Paket enthält:
@@ -11,8 +10,6 @@ PPTX-Image-Compressor/
├─ pptx_image_compress.py ├─ pptx_image_compress.py
├─ bin/ ├─ bin/
│ └─ caesiumclt.exe │ └─ caesiumclt.exe
└─ samples/
└─ README.txt
``` ```
## Schnellstart (ohne Admin-Rechte) ## Schnellstart (ohne Admin-Rechte)
@@ -29,20 +26,29 @@ 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.
Zusätzlich wird **pip** installiert, damit das **svg-polish** Modul installiert werden kann.
## 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** (Default `-q 90`, `-O bigger`) - Komprimiert **JPG/JPEG, PNG, WebP, GIF** mit **CaesiumCLT** (Default `-q 90`, `-O bigger`)
- Komprimiert **SVG** mit **svg-polish** (Default-Modus: `balanced`)
- Ersetzt Bilder nur, wenn die komprimierte Datei kleiner ist - Ersetzt Bilder nur, wenn die komprimierte Datei kleiner ist
- Versucht bei PNG zusätzlich einen PNG->JPG Wechsel, wenn das Bild nach Kompression noch größer als 500 KB ist
- Ersetzt Bilder nur, wenn sei mindestens 2% kleiner sind (verhindert *doppelte Komprimierung*)
- 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)
## Änderungen in 1.1.9
- SVG Files Default Profile: `balanced` statt `aggressive`
- Datei-Eigenschaften der neu generierten PPTX enthält Hinweis auf Compression `compressed by PPTX Image Compressor`
## 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 der PythonThreads; intern wird `caesiumclt --threads 1` gesetzt, sobald `-t > 1`, um Oversubscription zu vermeiden. Default ist 16
- `-q` steuert das Qualitätslevel; intern wird `caesiumclt -q` mit diesem Wert von `0..100` benutzt, Default ist 90 - `-q` steuert das Qualitätslevel; intern wird `caesiumclt -q` mit diesem Wert von `0..100` benutzt, Default ist 90
- `--min-savings` steuert das Mindestmass an Komprimierung zur Verhinderung von doppelter Komprimierunt, Default ist 2%
- 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`). - 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`).
- `--svg-profile` steuert das Vector-Optimierungsprofil `balanced|aggressive`
## Manuelle Nutzung des .py (falls Python vorhanden) ## Manuelle Nutzung des .py (falls Python vorhanden)
```bat ```bat
@@ -51,4 +57,5 @@ python pptx_image_compress.py -i "C:\Pfad\input.pptx" -t 8
## Quellen & Tools ## Quellen & Tools
- CaesiumCLT Projekt/Downloads: https://github.com/Lymphatus/caesium-clt - CaesiumCLT Projekt/Downloads: https://github.com/Lymphatus/caesium-clt
- SVG Polish - https://github.com/g-battaglia/svg_polish
- Windows Embeddable Python Package Doku/Downloads: https://docs.python.org/3/using/windows.html - Windows Embeddable Python Package Doku/Downloads: https://docs.python.org/3/using/windows.html
Binary file not shown.
+124
View File
@@ -0,0 +1,124 @@
@echo off
setlocal EnableExtensions
set "SELF_DIR=%~dp0"
pushd "%SELF_DIR%"
set "BUILD_DIR=%SELF_DIR%dist"
set "SPEC_DIR=%SELF_DIR%build"
set "PY_CMD=%SELF_DIR%.venv\Scripts\python.exe"
set "UPX_DIR=%SELF_DIR%buildtools"
set "MINIFY="
if /I "%~1"=="--minify" set "MINIFY=1"
echo [INFO] Build started...
rem =========================
rem Check Python
rem =========================
if not exist "%PY_CMD%" (
echo [ERROR] Python not found
exit /b 1
)
rem =========================
rem Check PyInstaller
rem =========================
"%PY_CMD%" -m pip show pyinstaller >nul 2>&1
if errorlevel 1 (
echo [INFO] Installing PyInstaller...
"%PY_CMD%" -m pip install pyinstaller --quiet
)
rem =========================
rem Clean
rem =========================
if exist "%BUILD_DIR%" rmdir /s /q "%BUILD_DIR%"
if exist "%SPEC_DIR%" rmdir /s /q "%SPEC_DIR%"
rem =========================
rem Optional icon
rem =========================
set "ICON_ARG="
if exist "%SELF_DIR%pptx-image-compress.ico" (
set "ICON_ARG=%SELF_DIR%pptx-image-compress.ico"
)
echo [INFO] Icon param: %ICON_ARG%
rem =========================
rem Build EXE (STABLE)
rem =========================
echo [INFO] Building EXE...
if defined ICON_ARG (
"%PY_CMD%" -m PyInstaller ^
--onefile ^
--console ^
--name pptx-image-compress ^
--distpath "%BUILD_DIR%" ^
--specpath "%SPEC_DIR%" ^
--workpath "%SPEC_DIR%\build" ^
--icon="%ICON_ARG%" ^
--hidden-import svg_polish ^
pptx_image_compress.py
) else (
"%PY_CMD%" -m PyInstaller ^
--onefile ^
--console ^
--name pptx-image-compress ^
--distpath "%BUILD_DIR%" ^
--specpath "%SPEC_DIR%" ^
--workpath "%SPEC_DIR%\build" ^
--hidden-import svg_polish ^
pptx_image_compress.py
)
if errorlevel 1 (
echo [ERROR] Build failed
exit /b 1
)
rem =========================
rem Optional UPX
rem =========================
if defined MINIFY (
if exist "%UPX_DIR%\upx.exe" (
echo [INFO] Running UPX compression...
"%UPX_DIR%\upx.exe" --best --force "%BUILD_DIR%\pptx-image-compress.exe"
) else (
echo [WARN] UPX not found at %UPX_DIR%
)
)
rem =========================
rem Copy templates
rem =========================
copy "%SELF_DIR%templates\run.tpl.bat" "%BUILD_DIR%\run.bat" >nul
copy "%SELF_DIR%templates\dragdrop.tpl.bat" "%BUILD_DIR%\dragdrop.bat" >nul
rem =========================
rem Copy caesiumclt.exe
rem =========================
copy "%SELF_DIR%bin\caesiumclt.exe" "%BUILD_DIR%\caesiumclt.exe"
rem =========================
rem Done
rem =========================
echo(
echo [SUCCESS] Build complete!
echo Output:
echo %BUILD_DIR%\pptx-image-compress.exe
popd
endlocal
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
set TMP_OUT=%TEMP%\update_check_%RANDOM%.txt
python check_new_version.py > "%TMP_OUT%"
set EXITCODE=%ERRORLEVEL%
if %EXITCODE%==20 (
echo Fehler beim Update-Check
goto :END
)
if %EXITCODE%==0 (
echo Keine Updates verfuegbar.
goto :END
)
echo.
type "%TMP_OUT%"
:END
del "%TMP_OUT%" >nul 2>&1
endlocal
+6
View File
@@ -0,0 +1,6 @@
[DEFAULT]
python_used_version = 3.14.6
python_latest_version = 3.14.6
caesiumclt_used_version = v1.3.0
caesiumclt_latest_version = v1.3.0
+122
View File
@@ -0,0 +1,122 @@
import argparse
import configparser
import json
import sys
import os
import urllib.request
from pathlib import Path
INI_FILE = Path("check_new_version.ini")
PYTHON_API = "https://endoflife.date/api/v1/products/python"
CAESIUM_API = "https://api.github.com/repos/Lymphatus/caesium-clt/tags"
EXIT_NO_UPDATE = 0
EXIT_UPDATE_AVAILABLE = 10
EXIT_ERROR = 20
def fetch_json(url):
with urllib.request.urlopen(url, timeout=15) as r:
return json.loads(r.read().decode("utf-8"))
def load_ini():
cfg = configparser.ConfigParser()
cfg.read(INI_FILE, encoding="utf-8")
return cfg
def save_ini(cfg):
with open(INI_FILE, "w", encoding="utf-8") as f:
cfg.write(f)
def check_python(cfg, result):
used = cfg["DEFAULT"].get("python_used_version", "").strip()
major_minor = ".".join(used.split(".")[:2])
data = fetch_json(PYTHON_API)
releases = data.get("result", {}).get("releases", [])
for rel in releases:
if rel.get("name") != major_minor:
continue
latest = rel.get("latest", {}).get("name", "")
is_maintained = rel.get("isMaintained", True)
cfg["DEFAULT"]["python_latest_version"] = latest
if used != latest:
result["updates"].append({
"tool": "python",
"used": used,
"latest": latest,
"maintained": is_maintained,
"url": (
f"https://www.python.org/ftp/python/{latest}/"
f"python-{latest}-embed-amd64.zip"
)
})
def check_caesium(cfg, result):
used = cfg["DEFAULT"].get("caesiumclt_used_version", "").strip()
tags = fetch_json(CAESIUM_API)
latest = tags[0]["name"]
cfg["DEFAULT"]["caesiumclt_latest_version"] = latest
if used != latest:
result["updates"].append({
"tool": "caesiumclt",
"used": used,
"latest": latest,
"maintained": True,
"url": (
f"https://github.com/Lymphatus/caesium-clt/releases/download/{latest}/"
f"caesiumclt-{latest}-x86_64-pc-windows-msvc.zip"
)
})
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--yes", action="store_true")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
cfg = load_ini()
result = {"updates": []}
try:
check_python(cfg, result)
check_caesium(cfg, result)
except Exception as e:
print(f"ERROR: {e}")
sys.exit(EXIT_ERROR)
save_ini(cfg)
if args.json:
print(json.dumps(result, indent=2))
else:
for u in result["updates"]:
print(
f"{u['tool'].capitalize()}: aktuell genutzt {u['used']}, "
f"neu {u['latest']}"
)
print(f"{u['tool'].upper()}_DOWNLOAD={u['url']}")
print(f"{u['tool'].upper()}_LATEST_VERSION={u['latest']}")
if not u["maintained"]:
print(
f"WARNING: Achtung Version {u['latest']} "
"wird nicht mehr gewartet!"
)
sys.exit(EXIT_UPDATE_AVAILABLE if result["updates"] else EXIT_NO_UPDATE)
if __name__ == "__main__":
main()
+655
View File
@@ -0,0 +1,655 @@
{
"schema_version": "1.2.0",
"generated_at": "2026-04-08T07:17:19+00:00",
"last_modified": "2026-04-08T00:10:16+00:00",
"result": {
"name": "python",
"aliases": [],
"label": "Python",
"category": "lang",
"tags": [
"lang"
],
"versionCommand": "python --version\n\n# or alternatively\npython3 --version",
"identifiers": [
{
"type": "purl",
"id": "pkg:generic/python"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python3.14"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python3.13"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python3.12"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python3.11"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python3.10"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python3.9"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python3.8"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python3.7"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python3.6"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python3.5"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python3.4"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python3.3"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python3.2"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python3.1"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python3.0"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python2.7"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python2.6"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python2.5"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python2.4"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python2.3"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python2.2"
},
{
"type": "purl",
"id": "pkg:deb/ubuntu/python2.1"
},
{
"type": "purl",
"id": "pkg:deb/debian/python"
},
{
"type": "purl",
"id": "pkg:deb/debian/python3.14"
},
{
"type": "purl",
"id": "pkg:deb/debian/python3.13"
},
{
"type": "purl",
"id": "pkg:deb/debian/python3.11"
},
{
"type": "purl",
"id": "pkg:deb/debian/python3.9"
},
{
"type": "purl",
"id": "pkg:deb/debian/python3.7"
},
{
"type": "purl",
"id": "pkg:deb/debian/python3.5"
},
{
"type": "purl",
"id": "pkg:deb/debian/python3.4"
},
{
"type": "purl",
"id": "pkg:deb/debian/python3.2"
},
{
"type": "purl",
"id": "pkg:deb/debian/python3.1"
},
{
"type": "purl",
"id": "pkg:deb/debian/python2.7"
},
{
"type": "purl",
"id": "pkg:deb/debian/python2.6"
},
{
"type": "purl",
"id": "pkg:deb/debian/python2.5"
},
{
"type": "purl",
"id": "pkg:deb/debian/python2.4"
},
{
"type": "purl",
"id": "pkg:deb/debian/python2.3"
},
{
"type": "purl",
"id": "pkg:deb/debian/python2.2"
},
{
"type": "purl",
"id": "pkg:deb/debian/python2.1"
},
{
"type": "purl",
"id": "pkg:deb/debian/python1.5"
},
{
"type": "purl",
"id": "pkg:rpm/fedora/python3.14"
},
{
"type": "purl",
"id": "pkg:rpm/fedora/python3.13"
},
{
"type": "purl",
"id": "pkg:rpm/fedora/python3.12"
},
{
"type": "purl",
"id": "pkg:rpm/fedora/python3.11"
},
{
"type": "purl",
"id": "pkg:rpm/fedora/python3.10"
},
{
"type": "purl",
"id": "pkg:rpm/fedora/python3.9"
},
{
"type": "purl",
"id": "pkg:rpm/fedora/python3.8"
},
{
"type": "purl",
"id": "pkg:rpm/fedora/python3.6"
},
{
"type": "purl",
"id": "pkg:rpm/fedora/python3.5"
},
{
"type": "purl",
"id": "pkg:rpm/amzn/python"
},
{
"type": "purl",
"id": "pkg:rpm/amzn/python2"
},
{
"type": "purl",
"id": "pkg:rpm/amzn/python3"
},
{
"type": "purl",
"id": "pkg:rpm/redhat/python"
},
{
"type": "purl",
"id": "pkg:rpm/redhat/python2"
},
{
"type": "purl",
"id": "pkg:rpm/redhat/python3"
},
{
"type": "purl",
"id": "pkg:rpm/centos/python"
},
{
"type": "purl",
"id": "pkg:rpm/centos/python2"
},
{
"type": "purl",
"id": "pkg:rpm/centos/python3"
},
{
"type": "purl",
"id": "pkg:docker/library/python"
},
{
"type": "purl",
"id": "pkg:docker/circleci/python"
},
{
"type": "purl",
"id": "pkg:docker/bitnami/python"
},
{
"type": "purl",
"id": "pkg:github/python/cpython"
},
{
"type": "repology",
"id": "python"
},
{
"type": "cpe",
"id": "cpe:/a:python:python"
},
{
"type": "cpe",
"id": "cpe:2.3:a:python:python"
}
],
"labels": {
"eoas": "Active Support",
"discontinued": null,
"eol": "Security Support",
"eoes": null
},
"links": {
"icon": "https://cdn.jsdelivr.net/npm/simple-icons/icons/python.svg",
"html": "https://endoflife.date/python",
"releasePolicy": "https://devguide.python.org/versions/"
},
"releases": [
{
"name": "3.14",
"codename": null,
"label": "3.14",
"releaseDate": "2025-10-07",
"isLts": false,
"ltsFrom": null,
"isEoas": false,
"eoasFrom": "2027-10-01",
"isEol": false,
"eolFrom": "2030-10-31",
"isMaintained": true,
"latest": {
"name": "3.14.4",
"date": "2026-04-07",
"link": "https://www.python.org/downloads/release/python-3144/"
},
"custom": {
"pep": "PEP-0745"
}
},
{
"name": "3.13",
"codename": null,
"label": "3.13",
"releaseDate": "2024-10-07",
"isLts": false,
"ltsFrom": null,
"isEoas": false,
"eoasFrom": "2026-10-01",
"isEol": false,
"eolFrom": "2029-10-31",
"isMaintained": true,
"latest": {
"name": "3.13.13",
"date": "2026-04-07",
"link": "https://www.python.org/downloads/release/python-31313/"
},
"custom": {
"pep": "PEP-0719"
}
},
{
"name": "3.12",
"codename": null,
"label": "3.12",
"releaseDate": "2023-10-02",
"isLts": false,
"ltsFrom": null,
"isEoas": true,
"eoasFrom": "2025-04-02",
"isEol": false,
"eolFrom": "2028-10-31",
"isMaintained": true,
"latest": {
"name": "3.12.13",
"date": "2026-03-03",
"link": "https://www.python.org/downloads/release/python-31213/"
},
"custom": {
"pep": "PEP-0693"
}
},
{
"name": "3.11",
"codename": null,
"label": "3.11",
"releaseDate": "2022-10-24",
"isLts": false,
"ltsFrom": null,
"isEoas": true,
"eoasFrom": "2024-04-01",
"isEol": false,
"eolFrom": "2027-10-31",
"isMaintained": true,
"latest": {
"name": "3.11.15",
"date": "2026-03-03",
"link": "https://www.python.org/downloads/release/python-31115/"
},
"custom": {
"pep": "PEP-0664"
}
},
{
"name": "3.10",
"codename": null,
"label": "3.10",
"releaseDate": "2021-10-04",
"isLts": false,
"ltsFrom": null,
"isEoas": true,
"eoasFrom": "2023-04-05",
"isEol": false,
"eolFrom": "2026-10-31",
"isMaintained": true,
"latest": {
"name": "3.10.20",
"date": "2026-03-03",
"link": "https://www.python.org/downloads/release/python-31020/"
},
"custom": {
"pep": "PEP-0619"
}
},
{
"name": "3.9",
"codename": null,
"label": "3.9",
"releaseDate": "2020-10-05",
"isLts": false,
"ltsFrom": null,
"isEoas": true,
"eoasFrom": "2022-05-17",
"isEol": true,
"eolFrom": "2025-10-31",
"isMaintained": false,
"latest": {
"name": "3.9.25",
"date": "2025-10-31",
"link": "https://www.python.org/downloads/release/python-3925/"
},
"custom": {
"pep": "PEP-0596"
}
},
{
"name": "3.8",
"codename": null,
"label": "3.8",
"releaseDate": "2019-10-14",
"isLts": false,
"ltsFrom": null,
"isEoas": true,
"eoasFrom": "2021-05-03",
"isEol": true,
"eolFrom": "2024-10-07",
"isMaintained": false,
"latest": {
"name": "3.8.20",
"date": "2024-09-06",
"link": "https://www.python.org/downloads/release/python-3820/"
},
"custom": {
"pep": "PEP-0569"
}
},
{
"name": "3.7",
"codename": null,
"label": "3.7",
"releaseDate": "2018-06-27",
"isLts": false,
"ltsFrom": null,
"isEoas": true,
"eoasFrom": "2020-06-27",
"isEol": true,
"eolFrom": "2023-06-27",
"isMaintained": false,
"latest": {
"name": "3.7.17",
"date": "2023-06-05",
"link": "https://www.python.org/downloads/release/python-3717/"
},
"custom": {
"pep": "PEP-0537"
}
},
{
"name": "3.6",
"codename": null,
"label": "3.6",
"releaseDate": "2016-12-23",
"isLts": false,
"ltsFrom": null,
"isEoas": true,
"eoasFrom": "2018-12-24",
"isEol": true,
"eolFrom": "2021-12-23",
"isMaintained": false,
"latest": {
"name": "3.6.15",
"date": "2021-09-03",
"link": "https://www.python.org/downloads/release/python-3615/"
},
"custom": {
"pep": "PEP-0494"
}
},
{
"name": "3.5",
"codename": null,
"label": "3.5",
"releaseDate": "2015-09-13",
"isLts": false,
"ltsFrom": null,
"isEoas": true,
"eoasFrom": null,
"isEol": true,
"eolFrom": "2020-09-30",
"isMaintained": false,
"latest": {
"name": "3.5.10",
"date": "2020-09-05",
"link": "https://www.python.org/downloads/release/python-3510/"
},
"custom": {
"pep": "PEP-0478"
}
},
{
"name": "3.4",
"codename": null,
"label": "3.4",
"releaseDate": "2014-03-16",
"isLts": false,
"ltsFrom": null,
"isEoas": true,
"eoasFrom": null,
"isEol": true,
"eolFrom": "2019-03-18",
"isMaintained": false,
"latest": {
"name": "3.4.10",
"date": "2019-03-18",
"link": "https://www.python.org/downloads/release/python-3410/"
},
"custom": {
"pep": "PEP-0429"
}
},
{
"name": "3.3",
"codename": null,
"label": "3.3",
"releaseDate": "2012-09-29",
"isLts": false,
"ltsFrom": null,
"isEoas": true,
"eoasFrom": null,
"isEol": true,
"eolFrom": "2017-09-29",
"isMaintained": false,
"latest": {
"name": "3.3.7",
"date": "2017-09-19",
"link": "https://www.python.org/downloads/release/python-337/"
},
"custom": {
"pep": "PEP-0398"
}
},
{
"name": "3.2",
"codename": null,
"label": "3.2",
"releaseDate": "2011-02-20",
"isLts": false,
"ltsFrom": null,
"isEoas": true,
"eoasFrom": null,
"isEol": true,
"eolFrom": "2016-02-20",
"isMaintained": false,
"latest": {
"name": "3.2.6",
"date": "2014-10-12",
"link": "https://www.python.org/downloads/release/python-326/"
},
"custom": {
"pep": "PEP-0392"
}
},
{
"name": "2.7",
"codename": null,
"label": "2.7",
"releaseDate": "2010-07-03",
"isLts": false,
"ltsFrom": null,
"isEoas": true,
"eoasFrom": null,
"isEol": true,
"eolFrom": "2020-01-01",
"isMaintained": false,
"latest": {
"name": "2.7.18",
"date": "2020-04-19",
"link": "https://www.python.org/downloads/release/python-2718/"
},
"custom": {
"pep": "PEP-0373"
}
},
{
"name": "3.1",
"codename": null,
"label": "3.1",
"releaseDate": "2009-06-27",
"isLts": false,
"ltsFrom": null,
"isEoas": true,
"eoasFrom": null,
"isEol": true,
"eolFrom": "2012-04-09",
"isMaintained": false,
"latest": {
"name": "3.1.5",
"date": "2012-04-06",
"link": "https://www.python.org/downloads/release/python-315/"
},
"custom": {
"pep": "PEP-0375"
}
},
{
"name": "3.0",
"codename": null,
"label": "3.0",
"releaseDate": "2008-12-03",
"isLts": false,
"ltsFrom": null,
"isEoas": true,
"eoasFrom": null,
"isEol": true,
"eolFrom": "2009-06-27",
"isMaintained": false,
"latest": {
"name": "3.0.1",
"date": "2009-02-12",
"link": "https://www.python.org/downloads/release/python-301/"
},
"custom": {
"pep": "PEP-0361"
}
},
{
"name": "2.6",
"codename": null,
"label": "2.6",
"releaseDate": "2008-10-01",
"isLts": false,
"ltsFrom": null,
"isEoas": true,
"eoasFrom": null,
"isEol": true,
"eolFrom": "2013-10-29",
"isMaintained": false,
"latest": {
"name": "2.6.9",
"date": "2013-10-29",
"link": "https://www.python.org/downloads/release/python-269/"
},
"custom": {
"pep": "PEP-0361"
}
}
]
}
}
+103 -10
View File
@@ -12,7 +12,7 @@ set "SELF_DIR=%~dp0"
set "SCRIPT=%SELF_DIR%pptx_image_compress.py" set "SCRIPT=%SELF_DIR%pptx_image_compress.py"
rem ---- Python Embeddable config ---- rem ---- Python Embeddable config ----
set "PY_EMBED_VERSION=3.13.7" set "PY_EMBED_VERSION=3.14.6"
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"
@@ -23,7 +23,7 @@ set "CAE_DIR=%SELF_DIR%bin"
set "CAE_EXE=caesiumclt.exe" set "CAE_EXE=caesiumclt.exe"
if exist "%CAE_DIR%\%CAE_EXE%" ( if exist "%CAE_DIR%\%CAE_EXE%" (
set "PATH=%CAE_DIR%;%PATH%" set "PATH=%CAE_DIR%;%PATH%"
) else ( ) else (
if exist "%SELF_DIR%%CAE_EXE%" ( if exist "%SELF_DIR%%CAE_EXE%" (
set "PATH=%SELF_DIR%;%PATH%" set "PATH=%SELF_DIR%;%PATH%"
) else ( ) else (
@@ -44,13 +44,22 @@ rem ---- Python discovery (avoid MS Store alias) ----
set "PY_CMD=" set "PY_CMD="
set "USE_PY_LAUNCHER=" set "USE_PY_LAUNCHER="
rem 1) Prefer local embeddable first if defined VIRTUAL_ENV (
if exist "%PY_EXE%" ( if exist "%VIRTUAL_ENV%\Scripts\python.exe" (
set "PY_CMD=%PY_EXE%" set "PY_CMD=%VIRTUAL_ENV%\Scripts\python.exe"
goto :have_python
)
)
if exist "%SELF_DIR%.venv\Scripts\python.exe" (
set "PY_CMD=%SELF_DIR%.venv\Scripts\python.exe"
goto :have_python
)
if exist "%SELF_DIR%venv\Scripts\python.exe" (
set "PY_CMD=%SELF_DIR%venv\Scripts\python.exe"
goto :have_python goto :have_python
) )
rem 2) Real python.exe in PATH (exclude WindowsApps alias)
for /f "delims=" %%P in ('where python.exe 2^>nul') do ( for /f "delims=" %%P in ('where python.exe 2^>nul') do (
echo %%P | find /I "WindowsApps" >nul echo %%P | find /I "WindowsApps" >nul
if errorlevel 1 ( if errorlevel 1 (
@@ -66,7 +75,6 @@ for /f "delims=" %%P in ('where python3.exe 2^>nul') do (
) )
) )
rem 3) Python launcher py.exe (exclude WindowsApps)
for /f "delims=" %%P in ('where py.exe 2^>nul') do ( for /f "delims=" %%P in ('where py.exe 2^>nul') do (
echo %%P | find /I "WindowsApps" >nul echo %%P | find /I "WindowsApps" >nul
if errorlevel 1 ( if errorlevel 1 (
@@ -76,7 +84,11 @@ for /f "delims=" %%P in ('where py.exe 2^>nul') do (
) )
) )
rem 4) Download embeddable locally if exist "%PY_EXE%" (
set "PY_CMD=%PY_EXE%"
goto :have_python
)
if not exist "%SELF_DIR%%PY_EMBED_ZIP%" ( if not exist "%SELF_DIR%%PY_EMBED_ZIP%" (
echo [INFO] Kein Python gefunden. Lade Embeddable Python %PY_EMBED_VERSION% ... echo [INFO] Kein Python gefunden. Lade Embeddable Python %PY_EMBED_VERSION% ...
powershell -NoLogo -NoProfile -Command ^ powershell -NoLogo -NoProfile -Command ^
@@ -98,6 +110,14 @@ if errorlevel 1 (
) )
set "PY_CMD=%PY_EXE%" set "PY_CMD=%PY_EXE%"
rem ---- Fix embedded Python isolation for the installation of pip ----
set "PTH_FILE=%PY_DIR%\python314._pth"
if exist "%PTH_FILE%" (
echo [INFO] Enabling site-packages in embedded Python...
powershell -Command ^
"(Get-Content '%PTH_FILE%') -replace '^#?\s*import site','import site' | Set-Content '%PTH_FILE%'"
)
:have_python :have_python
if not defined PY_CMD ( if not defined PY_CMD (
echo [ERROR] Konnte Python nicht ermitteln. Abbruch. echo [ERROR] Konnte Python nicht ermitteln. Abbruch.
@@ -110,11 +130,84 @@ if not exist "%SCRIPT%" (
exit /b 6 exit /b 6
) )
set "ALL_ARGS=%*"
set "RUN_ARGS=%ALL_ARGS%"
set "DEBUG_MODE=0"
if "%~1"=="--debug" (
set "DEBUG_MODE=1"
set "RUN_ARGS=!ALL_ARGS:~8!"
if "!RUN_ARGS:~0,1!"==" " set "RUN_ARGS=!RUN_ARGS:~1!"
)
if "!RUN_ARGS!"=="" set "RUN_ARGS=-h"
echo [INFO] Pruefe und installiere Python-Abhaengigkeit: svg-polish ...
set "PIP_OK=0"
if defined USE_PY_LAUNCHER (
"%PY_CMD%" -3 -m pip --version >nul 2>&1
) else (
"%PY_CMD%" -m pip --version >nul 2>&1
)
if not errorlevel 1 set "PIP_OK=1"
if "%PIP_OK%"=="0" (
echo [INFO] pip nicht verfuegbar. Versuche ensurepip ...
if defined USE_PY_LAUNCHER (
"%PY_CMD%" -3 -m ensurepip --upgrade >nul 2>&1
) else (
"%PY_CMD%" -m ensurepip --upgrade >nul 2>&1
)
if defined USE_PY_LAUNCHER (
"%PY_CMD%" -3 -m pip --version >nul 2>&1
) else (
"%PY_CMD%" -m pip --version >nul 2>&1
)
if not errorlevel 1 set "PIP_OK=1"
)
if "%PIP_OK%"=="0" (
if /I "%PY_CMD%"=="%PY_EXE%" (
echo [INFO] ensurepip nicht verfuegbar. Lade get-pip.py ...
powershell -NoLogo -NoProfile -Command ^
"try { Invoke-WebRequest -Uri 'https://bootstrap.pypa.io/get-pip.py' -OutFile '%SELF_DIR%get-pip.py' -UseBasicParsing; exit 0 } catch { Write-Error $_; exit 1 }"
if exist "%SELF_DIR%get-pip.py" (
"%PY_CMD%" "%SELF_DIR%get-pip.py" >nul 2>&1
del "%SELF_DIR%get-pip.py" >nul 2>&1
)
"%PY_CMD%" -m pip --version >nul 2>&1
if not errorlevel 1 set "PIP_OK=1"
)
)
if "%PIP_OK%"=="1" (
if defined USE_PY_LAUNCHER (
"%PY_CMD%" -3 -m pip install --disable-pip-version-check --quiet --no-warn-script-location svg-polish
) else (
"%PY_CMD%" -m pip install --disable-pip-version-check --quiet --no-warn-script-location svg-polish
)
if errorlevel 1 (
echo [WARN] 'svg-polish' konnte nicht installiert werden. SVG-Dateien werden nicht komprimiert.
) else (
echo [OK] 'svg-polish' ist verfuegbar.
if %DEBUG_MODE%==1 (
echo [DEBUG] pip list:
if defined USE_PY_LAUNCHER (
"%PY_CMD%" -3 -m pip list
) else (
"%PY_CMD%" -m pip list
)
)
)
) else (
echo [WARN] pip konnte nicht eingerichtet werden. SVG-Dateien werden nicht komprimiert.
)
echo. echo.
echo [%APP_NAME%] Starte ... echo [%APP_NAME%] Starte ...
echo Command: "%PY_CMD%" "%SCRIPT%" %* echo Command: "%PY_CMD%" "%SCRIPT%" %RUN_ARGS%
echo. echo.
"%PY_CMD%" "%SCRIPT%" %* "%PY_CMD%" "%SCRIPT%" %RUN_ARGS%
set "RC=%ERRORLEVEL%" set "RC=%ERRORLEVEL%"
echo. echo.
+107
View File
@@ -0,0 +1,107 @@
# PPTX Image Compressor Installation Logic
## 1. Intro One Line Summary
> The script selects the best available Python environment, falls back to a self-installed embedded runtime if necessary, ensures `pip`, installs `svg-polish` when possible, and runs the application with graceful degradation.
## 2. Lightweight Overview
```text
START
├─► Find usable Python
│ ├─ venv (active / local)
│ ├─ system Python
│ └─ fallback: download embedded Python
├─► Ensure pip works
│ ├─ pip exists → OK
│ ├─ ensurepip → try fix
│ └─ get-pip.py → fallback fix
├─► Install dependency
│ └─ svg-polish (optional but preferred)
├─► Run main script
└─► END
```
---
## 3. Detailed Logic Tree
```text
START
├─► [A] Pre-check: caesiumclt
│ ├─ found → continue
│ └─ not found → EXIT
├─► [B] Python resolution (priority-based)
│ │
│ ├─ Active venv (VIRTUAL_ENV)?
│ │ └─ use it ✅
│ │
│ ├─ Local ".venv"?
│ │ └─ use it ✅
│ │
│ ├─ Local "venv"?
│ │ └─ use it ✅
│ │
│ ├─ System Python (python.exe, excluding WindowsApps)?
│ │ └─ use it ✅
│ │
│ ├─ Python launcher (py.exe)?
│ │ └─ use py -3 ✅
│ │
│ ├─ Existing embedded Python?
│ │ └─ use it ✅
│ │
│ └─ NONE FOUND →
│ ├─ Download embeddable Python
│ ├─ Extract to python-embed
│ ├─ Fix isolation (enable "import site")
│ └─ use it ✅
├─► [C] Validate main script exists
│ ├─ missing → EXIT
│ └─ exists → continue
├─► [D] Ensure pip availability
│ │
│ ├─ pip works?
│ │ └─ YES → continue ✅
│ │
│ └─ NO →
│ ├─ try: ensurepip
│ │ ├─ success → ✅
│ │ └─ fail →
│ │
│ └─ if embedded Python:
│ ├─ download get-pip.py
│ ├─ execute it
│ ├─ remove file
│ └─ re-check pip
│ ├─ still no pip →
│ │ └─ WARN: disable SVG compression
│ │
│ └─ pip available →
│ ✅ continue
├─► [E] Install dependency
│ ├─ install svg-polish
│ │ ├─ success → ✅ SVG enabled
│ │ └─ fail → ⚠ SVG disabled
│ │
│ └─ optional debug: pip list
├─► [F] Run application
│ ├─ execute Python script
│ ├─ capture return code
│ ├─ success → "Fertig"
│ └─ error → report exit code
└─► END
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

+599 -128
View File
@@ -1,23 +1,30 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
PPTX Grafik-Komprimier-Tool (nur CaesiumCLT, Multi-Thread, Batch, sauberes Cleanup) PPTX Raster & Vector Komprimier-Tool (Raster-Iamges: via CaesiumCLT, Vector-Images: via python Module svg_polish)
Version: 1.1.3 Version: 1.1.9
Änderungen in 1.1.9
- SVG Files Default Profile: `balanced` statt `aggressive`
- Datei-Eigenschaften der neu generierten PPTX enthält Hinweis auf Compression `compressed by PPTX Image Compressor`
Highlights: Änderungen in 1.1.8:
- Caesium-Scratch außerhalb des PPTX-Arbeitsverzeichnisses -> keine Tempfiles in finaler PPTX - SVG Files werden bei Vorhandensein von svg_polish anhand von 2 Profilen optimiert: balanced|agressive
- Safety-Cleanup: entfernt 'caesium*' Ordner und '*.tmp' in ppt/media, bevor gezippt wird
- Overwrite Policy: -O bigger
- Log: image_name,size_before,size_after,saving,saving_percent
- Summary inkl. Zeit benötigt
Änderungen in 1.1.3: Änderungen in 1.1.7:
- Changed all UNICODE Chars to ASCII - PNG->JPG Fallback für große PNGs hinzugefügt (wenn nach Kompression weiterhin > 500 KB)
- Logging erweitert: neue Spalte image_type_changed mit Wert png_jpg bei Typwechsel
Änderungen in 1.1.6:
- Libcaesium 1.3.0 kann nun auch files ignorieren, wenn die Kompression kleiner als <MIN_SAVING> ist
""" """
import argparse import argparse
import importlib
import inspect
import os import os
import re
import xml.etree.ElementTree as ET
import sys import sys
import zipfile import zipfile
import tempfile import tempfile
@@ -30,12 +37,60 @@ 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 dataclasses import dataclass
from typing import Callable, List, Optional
__version__ = "1.1.3"
ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".webp", ".gif"} # GIF wird übersprungen
__version__ = "1.1.9"
RASTER_EXT = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
VECTOR_EXT = {".svg"}
ALLOWED_EXT = RASTER_EXT | VECTOR_EXT
PROGRESS_BAR_LEN = 40 PROGRESS_BAR_LEN = 40
TEMP_PREFIX = "pptx_compress_" TEMP_PREFIX = "pptx_compress_"
DEFAULT_MIN_SAVINGS = "2%"
PNG_TO_JPEG_THRESHOLD_BYTES = 500 * 1024
SVG_POLISH_MODULE_NAME = "svg_polish"
SVG_PROFILE_BALANCED = "balanced"
SVG_PROFILE_AGGRESSIVE = "aggressive"
SVG_PROFILE_DEFAULT = SVG_PROFILE_BALANCED
@dataclass
class DeckResult:
input: str
output: str
ok: bool = False
size_before: int = 0
size_after: int = 0
elapsed_sec: float = 0.0
error: Optional[str] = None
log_file: Optional[str] = None
@dataclass
class ImageProcessResult:
image_name: str
orig_size: int
chosen_size: int
slide_nr: str
image_type_changed: str = ""
def discover_images(media_dir: Path) -> list[Path]:
images: list[Path] = []
if media_dir.exists():
for f in sorted(media_dir.iterdir()):
if f.is_file() and f.suffix.lower() in ALLOWED_EXT:
images.append(f)
return images
def image_result_to_log_line(image_result: ImageProcessResult) -> str:
saving = image_result.orig_size - image_result.chosen_size
saving_percent = round((saving / image_result.orig_size) * 100, 2) if image_result.orig_size > 0 else 0.0
return f"{image_result.image_name};{human_kb(image_result.orig_size)};{human_kb(image_result.chosen_size)};{human_kb(saving)};{saving_percent};{image_result.slide_nr};{image_result.image_type_changed}\n"
# -------------------- Utilities -------------------- # -------------------- Utilities --------------------
@@ -75,25 +130,50 @@ def print_progress(i: int, total: int):
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):
all_files: list[Path] = []
for root, _, files in os.walk(src_dir):
for f in files:
all_files.append(Path(root) / f)
content_types = [f for f in all_files if f.name == "[Content_Types].xml"]
rest = [f for f in all_files if f.name != "[Content_Types].xml"]
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 full in content_types + rest:
for f in files: rel = full.relative_to(src_dir)
full = Path(root) / f z.write(full, arcname=str(rel))
rel = full.relative_to(src_dir)
z.write(full, arcname=str(rel))
def which(cmd: str): def which(cmd: str):
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,
quality: int,
min_savings: str,
output_format: str = "original",
) -> Path | None:
ext = original.suffix.lower()
if ext not in RASTER_EXT:
return None
exe = which("caesiumclt") exe = which("caesiumclt")
if not exe: if not exe:
raise RuntimeError("[ERROR] 'caesiumclt' wurde nicht gefunden. Bitte CaesiumCLT installieren und in PATH verfügbar machen.") raise RuntimeError("[ERROR] '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)
ext = original.suffix.lower() cmd = [
if ext not in {".jpg", ".jpeg", ".png", ".webp"}: exe,
return None "-q",
cmd = [exe, "-q", str(quality), "-O", "bigger", "-o", str(out_dir)] str(quality),
"-O",
"bigger",
"--min-savings",
min_savings,
"--format",
output_format,
"-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)]
@@ -102,12 +182,247 @@ def compress_with_caesium(original: Path, out_dir: Path, caesium_threads: int |
if r.returncode != 0: if r.returncode != 0:
sys.stderr.write(f"[caesiumclt] Fehler bei {original.name}:{r.stderr}") sys.stderr.write(f"[caesiumclt] Fehler bei {original.name}:{r.stderr}")
return None return None
if output_format == "jpeg":
jpg_out = out_dir / f"{original.stem}.jpg"
jpeg_out = out_dir / f"{original.stem}.jpeg"
if jpg_out.exists():
return jpg_out
if jpeg_out.exists():
return jpeg_out
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"[caesiumclt] Ausnahme bei {original.name}: {ex}") sys.stderr.write(f"[caesiumclt] Ausnahme bei {original.name}: {ex}")
return None return None
def compressor_accepts_output_format(compressor: Callable[..., Path | None]) -> bool:
if compressor is compress_with_caesium:
return True
try:
signature = inspect.signature(compressor)
except (TypeError, ValueError):
return False
return "output_format" in signature.parameters
def run_compressor(
compressor: Callable[..., Path | None],
original: Path,
out_dir: Path,
caesium_threads: int | None,
quality: int,
min_savings: str,
output_format: str = "original",
) -> Path | None:
if output_format != "original" and not compressor_accepts_output_format(compressor):
return None
if compressor is compress_with_caesium:
return compressor(original, out_dir, caesium_threads, quality, min_savings, output_format)
if compressor_accepts_output_format(compressor):
return compressor(original, out_dir, caesium_threads, quality, min_savings, output_format)
return compressor(original, out_dir, caesium_threads, quality, min_savings)
def compress_raster_image(
compressor: Callable[..., Path | None],
original: Path,
out_dir: Path,
caesium_threads: int | None,
quality: int,
min_savings: str,
) -> Path | None:
return run_compressor(
compressor=compressor,
original=original,
out_dir=out_dir,
caesium_threads=caesium_threads,
quality=quality,
min_savings=min_savings,
)
def import_svg_polish_module() -> object | None:
try:
return importlib.import_module(SVG_POLISH_MODULE_NAME)
except Exception:
return None
def build_svg_polish_options(svg_polish_module: object, profile: str = SVG_PROFILE_DEFAULT) -> object | None:
options_type = getattr(svg_polish_module, "OptimizeOptions", None)
if not callable(options_type):
return None
try:
if profile == SVG_PROFILE_BALANCED:
return options_type(
shorten_ids=True,
enable_viewboxing=True,
)
return options_type(
digits=2,
style_to_xml=True,
group_collapse=True,
simple_colors=True,
indent_type="none",
newlines=False,
strip_xml_prolog=True,
strip_comments=True,
remove_metadata=True,
remove_titles=True,
remove_descriptions=True,
remove_descriptive_elements=True,
strip_ids=True,
shorten_ids=True,
renderer_workaround=False,
)
except Exception:
return None
def call_svg_polish_callable(fn: Callable[..., object], options: object | None, *args: object) -> object:
if options is not None:
try:
return fn(*args, options=options)
except TypeError:
return fn(*args)
return fn(*args)
def optimize_svg_content_with_module(svg_polish_module: object, original: Path, svg_profile: str = SVG_PROFILE_DEFAULT) -> str | None:
options = build_svg_polish_options(svg_polish_module, svg_profile)
optimize_path = getattr(svg_polish_module, "optimize_path", None)
if callable(optimize_path):
result = call_svg_polish_callable(optimize_path, options, original)
if isinstance(result, str):
return result
optimize_file = getattr(svg_polish_module, "optimize_file", None)
if callable(optimize_file):
result = call_svg_polish_callable(optimize_file, options, str(original))
if isinstance(result, str):
return result
svg_text = original.read_text(encoding="utf-8")
optimize = getattr(svg_polish_module, "optimize", None)
if callable(optimize):
result = call_svg_polish_callable(optimize, options, svg_text)
if isinstance(result, str):
return result
optimize_string = getattr(svg_polish_module, "optimize_string", None)
if callable(optimize_string):
result = call_svg_polish_callable(optimize_string, options, svg_text)
if isinstance(result, str):
return result
polish = getattr(svg_polish_module, "polish", None)
if callable(polish):
result = polish(svg_text)
if isinstance(result, str):
return result
return None
def compress_svg_with_svg_polish(
original: Path,
out_dir: Path,
svg_profile: str = SVG_PROFILE_DEFAULT,
) -> Path | None:
if original.suffix.lower() not in VECTOR_EXT:
return None
svg_polish_module = import_svg_polish_module()
if svg_polish_module is None:
sys.stderr.write(f"[svg_polish] Modul '{SVG_POLISH_MODULE_NAME}' nicht verfügbar für {original.name}\n")
return None
out_dir.mkdir(parents=True, exist_ok=True)
out_file = out_dir / original.name
try:
optimized_svg = optimize_svg_content_with_module(svg_polish_module, original, svg_profile)
if not isinstance(optimized_svg, str):
return None
out_file.write_text(optimized_svg, encoding="utf-8")
if out_file.stat().st_size >= original.stat().st_size:
return None
return out_file
except Exception as ex:
sys.stderr.write(f"[svg_polish] Ausnahme bei {original.name}: {ex}")
return None
def compress_vector_image(
original: Path,
out_dir: Path,
svg_profile: str = SVG_PROFILE_DEFAULT,
) -> Path | None:
if original.suffix.lower() == ".svg":
return compress_svg_with_svg_polish(original=original, out_dir=out_dir, svg_profile=svg_profile)
return None
def compress_image_with_routing(
compressor: Callable[..., Path | None],
original: Path,
out_dir: Path,
caesium_threads: int | None,
quality: int,
min_savings: str,
svg_profile: str = SVG_PROFILE_DEFAULT,
) -> Path | None:
if original.suffix.lower() in VECTOR_EXT:
return compress_vector_image(original=original, out_dir=out_dir, svg_profile=svg_profile)
return compress_raster_image(
compressor=compressor,
original=original,
out_dir=out_dir,
caesium_threads=caesium_threads,
quality=quality,
min_savings=min_savings,
)
def update_relationship_targets(work_dir: Path, old_name: str, new_name: str) -> None:
rels_namespace = "{http://schemas.openxmlformats.org/package/2006/relationships}Relationship"
for rels_file in work_dir.rglob("*.rels"):
try:
tree = ET.parse(rels_file)
root = tree.getroot()
changed = False
for rel in root.findall(f".//{rels_namespace}"):
target = rel.attrib.get("Target", "")
if Path(target).name == old_name:
rel.attrib["Target"] = re.sub(r"[^/\\]+$", new_name, target)
changed = True
if changed:
tree.write(rels_file, encoding="utf-8", xml_declaration=True)
except (ET.ParseError, OSError):
continue
def ensure_jpg_content_type(work_dir: Path) -> None:
content_types_path = work_dir / "[Content_Types].xml"
if not content_types_path.exists():
return
content_ns = "{http://schemas.openxmlformats.org/package/2006/content-types}"
try:
tree = ET.parse(content_types_path)
root = tree.getroot()
has_jpg_default = False
for default in root.findall(f"{content_ns}Default"):
ext = default.attrib.get("Extension", "").lower()
if ext == "jpg":
has_jpg_default = True
break
if not has_jpg_default:
ET.SubElement(
root,
f"{content_ns}Default",
{
"Extension": "jpg",
"ContentType": "image/jpeg",
},
)
tree.write(content_types_path, encoding="utf-8", xml_declaration=True)
except (ET.ParseError, OSError):
return
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)
@@ -117,19 +432,125 @@ def format_duration(seconds: float) -> str:
return f"{hms}.{frac[:2]}" return f"{hms}.{frac[:2]}"
return base return base
def build_image_slide_index(rels_dir: Path) -> dict[str, List[int]]:
if not rels_dir.exists() or not rels_dir.is_dir():
return {}
image_to_slides: dict[str, set[int]] = {}
for rels_path in rels_dir.iterdir():
rels_file = rels_path.name
if rels_file.startswith("slide") and rels_file.endswith(".xml.rels") and rels_path.is_file():
match = re.search(r"slide(\d+)\.xml\.rels$", rels_file)
if not match:
continue
slide_number = int(match.group(1))
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", "")
image_name = Path(target).name
if image_name:
if image_name not in image_to_slides:
image_to_slides[image_name] = set()
image_to_slides[image_name].add(slide_number)
except (ET.ParseError, OSError):
print(f"Fehler beim Lesen von {rels_file}")
return {img: sorted(slides) for img, slides in image_to_slides.items()}
def process_image_file(
idx: int,
img_path: Path,
scratch_dir: Path,
image_to_slides: dict[str, List[int]],
caesium_threads: int | None,
quality: int,
min_savings: str,
compressor: Callable[..., Path | None],
svg_profile: str = SVG_PROFILE_DEFAULT,
) -> ImageProcessResult:
orig_size = img_path.stat().st_size
chosen_size = orig_size
chosen_name = img_path.name
image_type_changed = ""
found_in_slide = image_to_slides.get(img_path.name)
slide_nr = "NOT_USED" if found_in_slide is None else str(found_in_slide)
try:
out_sub = scratch_dir / f"img_{idx:06d}"
caesium_out = compress_image_with_routing(
compressor=compressor,
original=img_path,
out_dir=out_sub,
caesium_threads=caesium_threads,
quality=quality,
min_savings=min_savings,
svg_profile=svg_profile,
)
if caesium_out and caesium_out.exists():
compressed_size = caesium_out.stat().st_size
if compressed_size < orig_size:
tmp_target = img_path.with_suffix(img_path.suffix + ".tmp")
shutil.copy2(caesium_out, tmp_target)
tmp_target.replace(img_path)
chosen_size = compressed_size
if img_path.suffix.lower() == ".png" and chosen_size > PNG_TO_JPEG_THRESHOLD_BYTES:
jpg_candidate = run_compressor(
compressor=compressor,
original=img_path,
out_dir=out_sub,
caesium_threads=caesium_threads,
quality=quality,
min_savings="0%",
output_format="jpeg",
)
if jpg_candidate and jpg_candidate.exists():
jpg_size = jpg_candidate.stat().st_size
if jpg_size < chosen_size:
jpg_target = img_path.with_suffix(".jpg")
tmp_target = jpg_target.with_suffix(".jpg.tmp")
shutil.copy2(jpg_candidate, tmp_target)
tmp_target.replace(jpg_target)
img_path.unlink(missing_ok=True)
chosen_size = jpg_size
chosen_name = jpg_target.name
image_type_changed = "png_jpg"
except Exception:
chosen_size = orig_size
chosen_name = img_path.name
image_type_changed = ""
return ImageProcessResult(
image_name=chosen_name,
orig_size=orig_size,
chosen_size=chosen_size,
slide_nr=slide_nr,
image_type_changed=image_type_changed,
)
# -------------------- Core per-deck processing -------------------- # -------------------- Core per-deck processing --------------------
def process_single_deck(input_pptx: Path, output_pptx: Path, threads: int, quality: int) -> dict: def process_single_deck(
input_pptx: Path,
output_pptx: Path,
threads: int,
quality: int,
min_savings: str,
svg_profile: str = SVG_PROFILE_DEFAULT,
compressor: Callable[..., Path | None] = compress_with_caesium,
) -> DeckResult:
start_time = time.perf_counter() start_time = time.perf_counter()
result = { result = DeckResult(
"input": str(input_pptx), input=str(input_pptx),
"output": str(output_pptx), output=str(output_pptx),
"ok": False, )
"size_before": 0, work_dir: Optional[Path] = None
"size_after": 0, scratch_dir: Optional[Path] = None
"elapsed_sec": 0.0,
"error": None,
"log_file": None,
}
try: try:
if not input_pptx.exists() or input_pptx.suffix.lower() != ".pptx": if not input_pptx.exists() or input_pptx.suffix.lower() != ".pptx":
@@ -140,71 +561,69 @@ def process_single_deck(input_pptx: Path, output_pptx: Path, threads: int, quali
work_dir = Path(tempfile.mkdtemp(prefix=TEMP_PREFIX + "work_")) work_dir = Path(tempfile.mkdtemp(prefix=TEMP_PREFIX + "work_"))
scratch_dir = Path(tempfile.mkdtemp(prefix=TEMP_PREFIX + "scratch_")) scratch_dir = Path(tempfile.mkdtemp(prefix=TEMP_PREFIX + "scratch_"))
log_file = output_pptx.with_suffix(".log.csv") log_file = output_pptx.with_suffix(".log.csv")
ensure_clean_file(log_file) ensure_clean_file(log_file)
log_lines = ["image_name;size_before(kb);size_after(kb);saving(kb);saving_percent(%)\n"] log_lines = ["image_name;size_before(kb);size_after(kb);saving(kb);saving_percent(%);in_slide_number;image_type_changed\n"]
size_before = input_pptx.stat().st_size size_before = input_pptx.stat().st_size
result["size_before"] = size_before result.size_before = size_before
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"
rels_dir = slides_dir / "_rels"
media_dir = work_dir / "ppt" / "media" media_dir = work_dir / "ppt" / "media"
images = []
if media_dir.exists(): images = discover_images(media_dir)
for f in sorted(media_dir.iterdir()):
if f.is_file() and f.suffix.lower() in ALLOWED_EXT:
images.append(f)
total = len(images) total = len(images)
print(f"[Processing] {input_pptx.name}: {total} Bild(er) gefunden") print(f"[Processing] {input_pptx.name}: {total} Bild(er) gefunden")
print_progress(0, total) print_progress(0, total)
if not which("caesiumclt"): if not which("caesiumclt") and compressor is compress_with_caesium:
raise RuntimeError("'caesiumclt' nicht gefunden. Bitte installieren und in PATH verfügbar machen.") raise RuntimeError("'caesiumclt' nicht gefunden. Bitte installieren und in PATH verfügbar machen.")
caesium_threads = 1 if threads and threads > 1 else None caesium_threads = 1 if threads > 1 else None
lock = Lock() lock = Lock()
done_count = 0 done_count = 0
image_to_slides = build_image_slide_index(rels_dir)
renamed_images: list[tuple[str, str]] = []
def worker(idx: int, img_path: Path): def worker(idx: int, img_path: Path):
nonlocal done_count nonlocal done_count
ext = img_path.suffix.lower() image_result = process_image_file(
orig_size = img_path.stat().st_size idx=idx,
if ext == ".gif": img_path=img_path,
with lock: scratch_dir=scratch_dir,
done_count += 1 image_to_slides=image_to_slides,
log_lines.append(f"{img_path.name};{human_kb(orig_size)};{human_kb(orig_size)};0;0.0\n") caesium_threads=caesium_threads,
print_progress(done_count, total) quality=quality,
return min_savings=min_savings,
chosen_size = orig_size compressor=compressor,
try: svg_profile=svg_profile,
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(): with lock:
s = caesium_out.stat().st_size if image_result.image_name != img_path.name:
if s < orig_size: renamed_images.append((img_path.name, image_result.image_name))
tmp_target = img_path.with_suffix(img_path.suffix + ".tmp") log_lines.append(image_result_to_log_line(image_result))
shutil.copy2(caesium_out, tmp_target) done_count += 1
tmp_target.replace(img_path) print_progress(done_count, total)
chosen_size = s
except Exception:
chosen_size = orig_size
finally:
saving = orig_size - chosen_size
saving_percent = round((saving / orig_size) * 100, 2) if orig_size > 0 else 0.0
with lock:
log_lines.append(f"{img_path.name};{human_kb(orig_size)};{human_kb(chosen_size)};{human_kb(saving)};{saving_percent}\n")
done_count += 1
print_progress(done_count, total)
if total > 0: if total > 0:
with ThreadPoolExecutor(max_workers=max(1, 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 fut in as_completed(futures):
pass try:
fut.result()
except Exception as exc:
sys.stderr.write(f"[worker] Unerwarteter Fehler: {exc}\n")
if renamed_images:
for old_name, new_name in renamed_images:
update_relationship_targets(work_dir, old_name, new_name)
ensure_jpg_content_type(work_dir)
print() # newline print() # newline
@@ -224,9 +643,11 @@ def process_single_deck(input_pptx: Path, output_pptx: Path, threads: int, quali
except Exception: except Exception:
pass pass
update_core_description(work_dir, "PPTX Image Compressor",__version__)
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 result.size_after = size_after
try: try:
with open(log_file, "w", encoding="utf-8") as f: with open(log_file, "w", encoding="utf-8") as f:
@@ -235,9 +656,9 @@ def process_single_deck(input_pptx: Path, output_pptx: Path, threads: int, quali
pass pass
elapsed = time.perf_counter() - start_time elapsed = time.perf_counter() - start_time
result["elapsed_sec"] = elapsed result.elapsed_sec = elapsed
result["log_file"] = str(log_file) result.log_file = str(log_file)
result["ok"] = True result.ok = True
savings_pct = 0.0 if size_before == 0 else round(100.0 * (size_before - size_after) / size_before, 2) savings_pct = 0.0 if size_before == 0 else round(100.0 * (size_before - size_after) / size_before, 2)
print(f"[OK] Fertig! ({input_pptx.name})") print(f"[OK] Fertig! ({input_pptx.name})")
@@ -249,16 +670,12 @@ def process_single_deck(input_pptx: Path, output_pptx: Path, threads: int, quali
print(" Log: ", log_file) print(" Log: ", log_file)
except Exception as e: except Exception as e:
result["error"] = str(e) result.error = str(e)
finally: finally:
try: if work_dir is not None:
shutil.rmtree(work_dir, ignore_errors=True) # type: ignore[name-defined] shutil.rmtree(work_dir, ignore_errors=True)
except Exception: if scratch_dir is not None:
pass shutil.rmtree(scratch_dir, ignore_errors=True)
try:
shutil.rmtree(scratch_dir, ignore_errors=True) # type: ignore[name-defined]
except Exception:
pass
cleanup_old_temps() cleanup_old_temps()
return result return result
@@ -312,38 +729,8 @@ def collect_from_dir(input_dir: Path, pattern: str, recursive: bool) -> list[Pat
# -------------------- CLI -------------------- # -------------------- CLI --------------------
def main(): def main():
parser = argparse.ArgumentParser( parser, args = extractParserArguments()
description="PPTX Grafik-Komprimier-Tool (nur CaesiumCLT, Multi-Thread, Batch, sauberes Cleanup)", input_files = validateParserArguments(parser, args)
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 batch_mode = len(input_files) > 1
@@ -355,6 +742,9 @@ def main():
print("[ERROR] 'caesiumclt' nicht gefunden. Bitte installieren und in PATH verfügbar machen.") print("[ERROR] 'caesiumclt' nicht gefunden. Bitte installieren und in PATH verfügbar machen.")
sys.exit(3) sys.exit(3)
if import_svg_polish_module() is None:
print("[WARN] 'svg-polish' nicht gefunden. SVG-Dateien werden nicht komprimiert. Installation: python -m pip install svg-polish")
overall_before = 0 overall_before = 0
overall_after = 0 overall_after = 0
successes = 0 successes = 0
@@ -370,14 +760,14 @@ def main():
failures += 1 failures += 1
continue continue
dst = out_dir / f"{src.stem}_compressed.pptx" dst = out_dir / f"{src.stem}_compressed.pptx"
res = process_single_deck(src, dst, args.threads, args.quality) res = process_single_deck(src, dst, args.threads, args.quality, args.min_savings, args.svg_profile)
if res['ok']: if res.ok:
successes += 1 successes += 1
overall_before += res['size_before'] overall_before += res.size_before
overall_after += res['size_after'] overall_after += res.size_after
else: else:
failures += 1 failures += 1
print(f" Fehler: {src.name} -> {res['error']}") print(f" Fehler: {src.name} -> {res.error}")
else: else:
src = input_files[0] src = input_files[0]
if args.output_dir: if args.output_dir:
@@ -385,14 +775,14 @@ def main():
dst = Path(args.output_dir) / f"{src.stem}_compressed.pptx" dst = Path(args.output_dir) / f"{src.stem}_compressed.pptx"
else: else:
dst = Path(args.output).resolve() if args.output else src.with_name(f"{src.stem}_compressed.pptx") 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) res = process_single_deck(src, dst, args.threads, args.quality, args.min_savings)
if res['ok']: if res.ok:
successes += 1 successes += 1
overall_before += res['size_before'] overall_before += res.size_before
overall_after += res['size_after'] overall_after += res.size_after
else: else:
failures += 1 failures += 1
print(f" Fehler: {src.name} -> {res['error']}") print(f" Fehler: {src.name} -> {res.error}")
if batch_mode: if batch_mode:
@@ -410,6 +800,87 @@ def main():
print(f"Gesamtgröße nachher: {human_mb(overall_after)} MB") print(f"Gesamtgröße nachher: {human_mb(overall_after)} MB")
print(f"Gesamt-Ersparnis: {pct}%") print(f"Gesamt-Ersparnis: {pct}%")
def validateParserArguments(parser, 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)
return input_files
def extractParserArguments():
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('--input-dir', help='Eingabe-Verzeichnis (optional, für Batch)')
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('--pattern', default='*.pptx', help='Dateimuster für --input-dir')
parser.add_argument('--recursive', action='store_true', help='Rekursiv in --input-dir suchen')
# Standard as of https://docs.python.org/3/library/concurrent.futures.html. Changed in version 3.13: Default value of max_workers is changed to min(32, (os.process_cpu_count() or 1) + 4).
# On my machine this is 16 Threads
parser.add_argument('-t','--threads', type=int, default=min(32, (os.process_cpu_count() or 1) + 4), 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('--min-savings', default=DEFAULT_MIN_SAVINGS, help="Mindestersparnis für caesiumclt (z. B. 2%%, 100KB, 1MB oder Bytes als Zahl)")
parser.add_argument('--svg-profile', choices=[SVG_PROFILE_BALANCED, SVG_PROFILE_AGGRESSIVE], default=SVG_PROFILE_DEFAULT, help='Optimierungsprofil für SVG-Kompression')
parser.add_argument('--version', action='version', version=f'%(prog)s {__version__}', help="Zeigt die Versionsnummer an" )
args = parser.parse_args()
return parser,args
def update_core_description(base_dir, app_name, version):
core_xml_path = Path(base_dir) / "docProps" / "core.xml"
if not core_xml_path.exists():
raise FileNotFoundError(f"{core_xml_path} nicht gefunden")
# Namespaces definieren
ns = {
"cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties",
"dc": "http://purl.org/dc/elements/1.1/",
"dcterms": "http://purl.org/dc/terms/",
"xsi": "http://www.w3.org/2001/XMLSchema-instance"
}
# Registrieren, damit Prefixe erhalten bleiben
for prefix, uri in ns.items():
ET.register_namespace(prefix, uri)
tree = ET.parse(core_xml_path)
root = tree.getroot()
description_text = f"compressed by {app_name} {version}"
# Suche vorhandenes Element
desc_elem = root.find("dc:description", ns)
if desc_elem is None:
# neu anlegen
desc_elem = ET.SubElement(
root,
f"{{{ns['dc']}}}description"
)
# Text setzen/überschreiben
desc_elem.text = description_text
# Datei speichern
tree.write(core_xml_path, encoding="utf-8", xml_declaration=True)
+3
View File
@@ -0,0 +1,3 @@
svg-polish==1.0.0
pytest==9.0.3
pytest-cov==7.1.0
+1
View File
@@ -0,0 +1 @@
svg-polish==1.0.0
-1
View File
@@ -1 +0,0 @@
Place your PPTX files here for testing, or use -i with a full path.
+39
View File
@@ -0,0 +1,39 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
if "%~1"=="" goto help
set "INPUT=%~1"
set "EXT=%~x1"
set "PATH=%PATH%;%~dp0"
if /I not "%EXT%"==".pptx" (
echo [ERROR] Only .pptx supported
pause
exit /b 1
)
echo [INFO] Compressing %~n1
rem optional: ensure correct working dir
pushd "%~dp0"
"%~dp0pptx-image-compress.exe" -i "%INPUT%"
set RC=%ERRORLEVEL%
popd
if %RC% neq 0 (
echo [ERROR] Failed (%RC%)
) else (
echo [SUCCESS] Done
)
pause
exit /b %RC%
:help
echo.
echo Drag ^& Drop a .pptx file onto this script
echo.
pause
+8
View File
@@ -0,0 +1,8 @@
@echo off
setlocal EnableExtensions
if "%~1"=="" (
"%~dp0pptx-image-compress.exe" -h
) else (
"%~dp0pptx-image-compress.exe" %*
)
+414
View File
@@ -0,0 +1,414 @@
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest import mock
import pptx_image_compress as pic
class TestPptxImageCompress(unittest.TestCase):
def test_discover_images_filters_extensions(self):
with tempfile.TemporaryDirectory() as td:
media_dir = Path(td)
(media_dir / "a.jpg").write_bytes(b"1")
(media_dir / "b.png").write_bytes(b"1")
(media_dir / "c.txt").write_bytes(b"1")
(media_dir / "d.GIF").write_bytes(b"1")
(media_dir / "e.svg").write_bytes(b"<svg/>")
images = pic.discover_images(media_dir)
self.assertEqual([p.name for p in images], ["a.jpg", "b.png", "d.GIF", "e.svg"])
def test_image_result_to_log_line(self):
image_result = pic.ImageProcessResult(
image_name="image1.png",
orig_size=1000,
chosen_size=800,
slide_nr="[1, 2]",
image_type_changed="",
)
line = pic.image_result_to_log_line(image_result)
self.assertIn("image1.png", line)
self.assertIn("[1, 2]", line)
self.assertIn("20.0", line)
self.assertTrue(line.endswith(";\n"))
def test_process_image_file_replaces_when_smaller(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
img = root / "image1.png"
img.write_bytes(b"A" * 100)
scratch = root / "scratch"
def fake_compressor(original: Path, out_dir: Path, caesium_threads: int | None, quality: int, min_savings: str):
out_dir.mkdir(parents=True, exist_ok=True)
out = out_dir / original.name
out.write_bytes(b"B" * 40)
return out
result = pic.process_image_file(
idx=1,
img_path=img,
scratch_dir=scratch,
image_to_slides={"image1.png": [1]},
caesium_threads=1,
quality=90,
min_savings="2%",
compressor=fake_compressor,
)
self.assertEqual(result.chosen_size, 40)
self.assertEqual(img.stat().st_size, 40)
self.assertEqual(result.slide_nr, "[1]")
def test_process_image_file_converts_large_png_to_jpg(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
img = root / "image1.png"
img.write_bytes(b"A" * 800000)
scratch = root / "scratch"
def fake_compressor(original: Path, out_dir: Path, caesium_threads: int | None, quality: int, min_savings: str, output_format: str = "original"):
out_dir.mkdir(parents=True, exist_ok=True)
if output_format == "jpeg":
out = out_dir / "image1.jpg"
out.write_bytes(b"C" * 200000)
return out
out = out_dir / original.name
out.write_bytes(b"B" * 700000)
return out
result = pic.process_image_file(
idx=1,
img_path=img,
scratch_dir=scratch,
image_to_slides={"image1.png": [2]},
caesium_threads=1,
quality=90,
min_savings="2%",
compressor=fake_compressor,
)
self.assertEqual(result.image_name, "image1.jpg")
self.assertEqual(result.chosen_size, 200000)
self.assertEqual(result.image_type_changed, "png_jpg")
self.assertFalse(img.exists())
self.assertTrue((root / "image1.jpg").exists())
def test_process_image_file_keeps_original_when_bigger(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
img = root / "image1.png"
img.write_bytes(b"A" * 100)
scratch = root / "scratch"
def fake_compressor(original: Path, out_dir: Path, caesium_threads: int | None, quality: int, min_savings: str):
out_dir.mkdir(parents=True, exist_ok=True)
out = out_dir / original.name
out.write_bytes(b"B" * 120)
return out
result = pic.process_image_file(
idx=1,
img_path=img,
scratch_dir=scratch,
image_to_slides={},
caesium_threads=1,
quality=90,
min_savings="2%",
compressor=fake_compressor,
)
self.assertEqual(result.chosen_size, 100)
self.assertEqual(img.stat().st_size, 100)
self.assertEqual(result.slide_nr, "NOT_USED")
self.assertEqual(result.image_type_changed, "")
def test_process_single_deck_with_injected_compressor(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
input_pptx = root / "input.pptx"
output_pptx = root / "output.pptx"
source_tree = root / "src"
rels_dir = source_tree / "ppt" / "slides" / "_rels"
media_dir = source_tree / "ppt" / "media"
rels_dir.mkdir(parents=True, exist_ok=True)
media_dir.mkdir(parents=True, exist_ok=True)
rels_xml = (
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>"
"<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
"<Relationship Id=\"rId2\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\" Target=\"../media/image1.png\"/>"
"</Relationships>"
)
(rels_dir / "slide1.xml.rels").write_text(rels_xml, encoding="utf-8")
(media_dir / "image1.png").write_bytes(b"A" * 100)
with zipfile.ZipFile(input_pptx, "w", compression=zipfile.ZIP_DEFLATED) as z:
for p in source_tree.rglob("*"):
if p.is_file():
z.write(p, arcname=str(p.relative_to(source_tree)))
def fake_compressor(original: Path, out_dir: Path, caesium_threads: int | None, quality: int, min_savings: str):
out_dir.mkdir(parents=True, exist_ok=True)
out = out_dir / original.name
out.write_bytes(b"B" * 50)
return out
with mock.patch("pptx_image_compress.update_core_description", create=True) as mocked_update_core_description:
result = pic.process_single_deck(
input_pptx=input_pptx,
output_pptx=output_pptx,
threads=2,
quality=90,
min_savings="2%",
compressor=fake_compressor,
)
mocked_update_core_description.assert_called_once_with(mock.ANY, "PPTX Image Compressor", pic.__version__)
self.assertTrue(result.ok)
self.assertEqual(result.error, None)
self.assertTrue(output_pptx.exists())
self.assertIsNotNone(result.log_file)
with zipfile.ZipFile(output_pptx, "r") as z:
out_image = z.read("ppt/media/image1.png")
self.assertEqual(len(out_image), 50)
log_file = result.log_file
if log_file is None:
self.fail("log_file should not be None")
log_text = Path(log_file).read_text(encoding="utf-8")
self.assertIn("image_name;size_before(kb);size_after(kb);saving(kb);saving_percent(%);in_slide_number;image_type_changed", log_text)
self.assertIn("image1.png", log_text)
self.assertIn("[1]", log_text)
def test_process_single_deck_updates_rels_on_png_to_jpg(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
input_pptx = root / "input_convert.pptx"
output_pptx = root / "output_convert.pptx"
source_tree = root / "src_convert"
rels_dir = source_tree / "ppt" / "slides" / "_rels"
media_dir = source_tree / "ppt" / "media"
rels_dir.mkdir(parents=True, exist_ok=True)
media_dir.mkdir(parents=True, exist_ok=True)
rels_xml = (
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>"
"<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
"<Relationship Id=\"rId2\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\" Target=\"../media/image1.png\"/>"
"</Relationships>"
)
content_types_xml = (
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>"
"<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">"
"<Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>"
"<Default Extension=\"xml\" ContentType=\"application/xml\"/>"
"<Default Extension=\"png\" ContentType=\"image/png\"/>"
"</Types>"
)
(source_tree / "[Content_Types].xml").write_text(content_types_xml, encoding="utf-8")
(rels_dir / "slide1.xml.rels").write_text(rels_xml, encoding="utf-8")
(media_dir / "image1.png").write_bytes(b"A" * 800000)
with zipfile.ZipFile(input_pptx, "w", compression=zipfile.ZIP_DEFLATED) as z:
for p in source_tree.rglob("*"):
if p.is_file():
z.write(p, arcname=str(p.relative_to(source_tree)))
def fake_compressor(original: Path, out_dir: Path, caesium_threads: int | None, quality: int, min_savings: str, output_format: str = "original"):
out_dir.mkdir(parents=True, exist_ok=True)
if output_format == "jpeg":
out = out_dir / "image1.jpg"
out.write_bytes(b"C" * 200000)
return out
out = out_dir / original.name
out.write_bytes(b"B" * 700000)
return out
with mock.patch("pptx_image_compress.update_core_description", create=True) as mocked_update_core_description:
result = pic.process_single_deck(
input_pptx=input_pptx,
output_pptx=output_pptx,
threads=1,
quality=90,
min_savings="2%",
compressor=fake_compressor,
)
mocked_update_core_description.assert_called_once_with(mock.ANY, "PPTX Image Compressor", pic.__version__)
self.assertTrue(result.ok)
with zipfile.ZipFile(output_pptx, "r") as z:
self.assertIn("ppt/media/image1.jpg", z.namelist())
self.assertNotIn("ppt/media/image1.png", z.namelist())
rels_out = z.read("ppt/slides/_rels/slide1.xml.rels").decode("utf-8")
content_types_out = z.read("[Content_Types].xml").decode("utf-8")
self.assertIn("image1.jpg", rels_out)
self.assertIn("Extension=\"jpg\"", content_types_out)
log_file = result.log_file
if log_file is None:
self.fail("log_file should not be None")
log_text = Path(log_file).read_text(encoding="utf-8")
self.assertIn("png_jpg", log_text)
def test_compress_image_with_routing_delegates_to_raster(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
original = root / "image1.png"
original.write_bytes(b"A" * 100)
out_dir = root / "out"
def fake_compressor(original_path: Path, out_subdir: Path, caesium_threads: int | None, quality: int, min_savings: str):
out_subdir.mkdir(parents=True, exist_ok=True)
out = out_subdir / original_path.name
out.write_bytes(b"B" * 80)
return out
out = pic.compress_image_with_routing(
compressor=fake_compressor,
original=original,
out_dir=out_dir,
caesium_threads=1,
quality=90,
min_savings="2%",
)
self.assertIsNotNone(out)
if out is None:
self.fail("Output should not be None")
self.assertEqual(out.name, "image1.png")
self.assertEqual(out.stat().st_size, 80)
def test_compress_with_caesium_ignores_svg(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
svg = root / "vector.svg"
svg.write_text("<svg></svg>", encoding="utf-8")
out_dir = root / "out"
with mock.patch("pptx_image_compress.which") as mocked_which:
out = pic.compress_with_caesium(
original=svg,
out_dir=out_dir,
caesium_threads=1,
quality=90,
min_savings="2%",
)
self.assertIsNone(out)
mocked_which.assert_not_called()
def test_optimize_svg_content_with_module_uses_optimize_path_with_options(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
svg = root / "vector.svg"
svg.write_text("<svg></svg>", encoding="utf-8")
captured_options = {}
class FakeOptions:
def __init__(self, **kwargs):
captured_options.update(kwargs)
fake_module = mock.Mock()
fake_module.OptimizeOptions = FakeOptions
fake_module.optimize_path = mock.Mock(return_value="<svg/>")
fake_module.optimize_file = None
fake_module.optimize = None
fake_module.optimize_string = None
fake_module.polish = None
result = pic.optimize_svg_content_with_module(fake_module, svg)
self.assertEqual(result, "<svg/>")
self.assertEqual(fake_module.optimize_path.call_count, 1)
def test_build_svg_polish_options_balanced_profile(self):
captured_options = {}
class FakeOptions:
def __init__(self, **kwargs):
captured_options.update(kwargs)
fake_module = mock.Mock()
fake_module.OptimizeOptions = FakeOptions
options = pic.build_svg_polish_options(fake_module, pic.SVG_PROFILE_BALANCED)
self.assertIsNotNone(options)
self.assertEqual(captured_options["shorten_ids"], True)
self.assertEqual(captured_options["enable_viewboxing"], True)
self.assertNotIn("strip_ids", captured_options)
def test_compress_svg_with_svg_polish_returns_none_when_module_missing(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
svg = root / "vector.svg"
svg.write_text("<svg></svg>", encoding="utf-8")
out_dir = root / "out"
with mock.patch("pptx_image_compress.import_svg_polish_module", return_value=None):
out = pic.compress_svg_with_svg_polish(svg, out_dir)
self.assertEqual(out, None)
def test_compress_svg_with_svg_polish_uses_python_module(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
svg = root / "vector.svg"
svg.write_text("<svg> <g></g> </svg>", encoding="utf-8")
out_dir = root / "out"
fake_module = mock.Mock()
fake_module.optimize = mock.Mock(return_value="<svg><g/></svg>")
fake_module.optimize_path = None
fake_module.optimize_file = None
fake_module.optimize_string = None
fake_module.polish = None
fake_module.OptimizeOptions = None
with mock.patch("pptx_image_compress.import_svg_polish_module", return_value=fake_module):
out = pic.compress_svg_with_svg_polish(svg, out_dir)
self.assertEqual(out, out_dir / "vector.svg")
self.assertTrue((out_dir / "vector.svg").exists())
self.assertEqual((out_dir / "vector.svg").read_text(encoding="utf-8"), "<svg><g/></svg>")
fake_module.optimize.assert_called_once_with("<svg> <g></g> </svg>")
def test_compress_svg_with_svg_polish_uses_polish_fallback(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
svg = root / "vector.svg"
svg.write_text("<svg> <path/></svg>", encoding="utf-8")
out_dir = root / "out"
fake_module = mock.Mock()
fake_module.optimize = None
fake_module.polish = mock.Mock(return_value="<svg><path/></svg>")
with mock.patch("pptx_image_compress.import_svg_polish_module", return_value=fake_module):
out = pic.compress_svg_with_svg_polish(svg, out_dir)
self.assertEqual(out, out_dir / "vector.svg")
fake_module.polish.assert_called_once_with("<svg> <path/></svg>")
def test_compress_image_with_routing_does_not_fallback_to_raster_for_svg(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
original = root / "vector.svg"
original.write_text("<svg></svg>", encoding="utf-8")
out_dir = root / "out"
def fake_compressor(original_path: Path, out_subdir: Path, caesium_threads: int | None, quality: int, min_savings: str):
raise AssertionError("Raster compressor must not run for svg")
with mock.patch("pptx_image_compress.compress_vector_image", return_value=None):
out = pic.compress_image_with_routing(
compressor=fake_compressor,
original=original,
out_dir=out_dir,
caesium_threads=1,
quality=90,
min_savings="2%",
)
self.assertIsNone(out)
if __name__ == "__main__":
unittest.main()