20 Commits

Author SHA1 Message Date
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
frank.conrads 88d3793c70 Alle UNICODE Characters zu ASCII verändert 2025-09-22 15:27:58 +02:00
frank.conrads eff926a443 Hinzufügen eines Skripts zum Drag & Drop 2025-09-22 15:27:33 +02:00
frank.conrads db77a977e6 Log-Format in KB 2025-09-11 14:35:22 +02:00
frank.conrads 01455784e2 Log Entries mit Endung .log.csv und ; als delimiter, newline nach jeder Zeile korrigiert 2025-09-11 14:21:21 +02:00
frank.conrads e6f2805ccb Remove Version in tree 2025-09-10 15:51:01 +02:00
frank.conrads f2c80d9b63 Version in Readme angepasst 2025-09-10 15:49:54 +02:00
frank.conrads 7294486446 Vorbereitung Batch-Mode und Anpassung der Scripte und Readme 2025-09-10 15:46:25 +02:00
13 changed files with 1852 additions and 243 deletions
+6
View File
@@ -0,0 +1,6 @@
python-3.*-embed-amd64.zip
python-embed/*
.vscode/launch.json
logs/*.log
__pycache__/*
.coverage
+11
View File
@@ -0,0 +1,11 @@
{
"python.testing.unittestArgs": [
"-v",
"-s",
".",
"-p",
"test_*.py"
],
"python.testing.pytestEnabled": false,
"python.testing.unittestEnabled": true
}
+21 -9
View File
@@ -1,40 +1,52 @@
# PPTX Image Compressor (CaesiumCLT only)
**Version 1.0.0**
**Version 1.1.7**
Dieses Paket enthält:
```
PPTX-Image-Compressor-1.0.0/
PPTX-Image-Compressor/
├─ README.md
├─ install_and_run.bat
├─ pptx_image_compress.py
├─ bin/
│ └─ caesiumclt.exe
└─ samples/
└─ README.txt
```
## Schnellstart (ohne Admin-Rechte)
1) Doppelklicke `install_and_run.bat` **oder** rufe es in CMD/PowerShell auf, z.B.:
**Single**
```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.
## Was das Tool macht
- 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
- 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)
- 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.7
- PNG->JPG Fallback für große PNGs (> 500 KB nach Kompression) hinzugefügt
- CSV-Logging um `image_type_changed` erweitert (`png_jpg` bei Typwechsel)
## Hinweise
- **GIF** wird übersprungen (keine Rekodierung).
- `-t` steuert die Parallelität (PythonThreads); intern wird `caesiumclt --threads 1` gesetzt, sobald `-t > 1`, um Oversubscription zu vermeiden.
- `-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
- `--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`).
## Manuelle Nutzung des .py (falls Python vorhanden)
```bat
Binary file not shown.
+61
View File
@@ -0,0 +1,61 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
set UPDATE_DIR=%~dp0updates
if not exist "%UPDATE_DIR%" mkdir "%UPDATE_DIR%"
set TMP_OUT=%TEMP%\update_check_%RANDOM%.txt
set TMP_OUT_2=%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%"
choice /c jn /m "Wollen Sie die Updates-Packages herunterladen?"
if errorlevel 2 goto :END
if exist "%TMP_OUT%" (
findstr /b "PYTHON_DOWNLOAD=" "%TMP_OUT%" >nul
if not errorlevel 1 (
for /f "tokens=1,* delims==" %%a in (
'findstr /b "PYTHON_DOWNLOAD=" "%TMP_OUT%"'
) do (
echo Downloading Python update from PYTHON_DOWNLOAD
powershell -NoProfile -Command ^
"Invoke-WebRequest '%%b' -OutFile '%UPDATE_DIR%\python-embed.zip'"
)
)
findstr /b "CAESIUM_DOWNLOAD=" "%TMP_OUT_2%" >nul
if not errorlevel 1 (
for /f "tokens=1,* delims==" %%a in (
'findstr /b "CAESIUM_DOWNLOAD=" "%TMP_OUT_2%"'
) do (
echo Downloading caesiumclt update from CAESIUM_DOWNLOAD
powershell -NoProfile -Command ^
"Invoke-WebRequest '%%b' -OutFile '%UPDATE_DIR%\caesiumclt.zip'"
)
)
)
:END
del "%TMP_OUT%" >nul 2>&1
del "%TMP_OUT_2%" >nul 2>&1
endlocal
+121
View File
@@ -0,0 +1,121 @@
import argparse
import configparser
import json
import sys
import urllib.request
from pathlib import Path
INI_FILE = Path("latestversion.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']}, download?"
)
print(f"{u['tool'].upper()}_DOWNLOAD={u['url']}")
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"
}
}
]
}
}
+57 -53
View File
@@ -3,77 +3,91 @@
setlocal EnableExtensions EnableDelayedExpansion
rem ============================================
rem PPTX Image Compressor - Installer/Runner
rem - No admin rights required
rem - Uses local CaesiumCLT and Python Embeddable
rem - Pass-through of all CLI args to the .py
rem PPTX Image Compressor - Installer/Runner (Batch-enabled)
rem Fix: caesiumclt.exe aus [ROOT]\bin; Python-Discovery ohne MS Store Alias
rem ============================================
set "APP_NAME=PPTX Image Compressor"
set "SELF_DIR=%~dp0"
set "BIN_DIR=%SELF_DIR%\bin\"
set "SCRIPT=%SELF_DIR%pptx_image_compress.py"
rem ---- Python Embeddable config (adjust if needed) ----
set "PY_EMBED_VERSION=3.11.9"
rem ---- Python Embeddable config ----
set "PY_EMBED_VERSION=3.14.5"
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_DIR=%SELF_DIR%python-embed"
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"
if exist "%BIN_DIR%%CAE_EXE%" (
rem Prefer local caesiumclt.exe near the BAT
set "PATH=%BIN_DIR%;%PATH%"
if exist "%CAE_DIR%\%CAE_EXE%" (
set "PATH=%CAE_DIR%;%PATH%"
) else (
if exist "%SELF_DIR%%CAE_EXE%" (
set "PATH=%SELF_DIR%;%PATH%"
) else (
where /q %CAE_EXE%
if errorlevel 1 (
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.
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%" (
set "PY_CMD=%PY_EXE%"
goto :have_python
)
rem 3) download embeddable python locally
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 errorlevel 1 (
echo [WARN] Automatischer Download fehlgeschlagen.
echo Bitte lade die Datei manuell herunter:
echo %PY_EMBED_URL%
echo und speichere sie als:
echo %SELF_DIR%%PY_EMBED_ZIP%
pause
rem 2) Real python.exe in PATH (exclude WindowsApps alias)
for /f "delims=" %%P in ('where python.exe 2^>nul') do (
echo %%P | find /I "WindowsApps" >nul
if errorlevel 1 (
set "PY_CMD=%%P"
goto :have_python
)
)
for /f "delims=" %%P in ('where python3.exe 2^>nul') do (
echo %%P | find /I "WindowsApps" >nul
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%" (
echo [ERROR] Embeddable-Python ZIP nicht vorhanden. Abbruch.
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%"
mkdir "%PY_DIR%" >nul 2>&1
powershell -NoLogo -NoProfile -Command ^
@@ -82,7 +96,6 @@ if errorlevel 1 (
echo [ERROR] Konnte ZIP nicht entpacken. Abbruch.
exit /b 4
)
set "PY_CMD=%PY_EXE%"
:have_python
@@ -91,34 +104,25 @@ if not defined PY_CMD (
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 ----
if not exist "%SCRIPT%" (
echo [ERROR] Script nicht gefunden: "%SCRIPT%"
echo Lege 'pptx_image_compress.py' neben diese BAT.
exit /b 6
)
set "RUN_ARGS=%*"
if "%~1"=="" set "RUN_ARGS=-h"
echo.
echo [%APP_NAME%] Starte ...
echo Command: "%PY_CMD%" "%SCRIPT%" %*
echo Command: "%PY_CMD%" "%SCRIPT%" %RUN_ARGS%
echo.
"%PY_CMD%" "%SCRIPT%" %*
"%PY_CMD%" "%SCRIPT%" %RUN_ARGS%
set "RC=%ERRORLEVEL%"
echo.
if "%RC%"=="0" (
echo [OK] Fertig.
echo Fertig.
) else (
echo [ERROR] Prozess endete mit Code %RC%.
)
+6
View File
@@ -0,0 +1,6 @@
[DEFAULT]
python_used_version = 3.14.5
python_latest_version = 3.14.5
caesiumclt_used_version = v1.3.0
caesiumclt_latest_version = v1.3.0
+518 -174
View File
@@ -1,47 +1,98 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
PPTX Grafik-Komprimier-Tool (nur CaesiumCLT, Multi-Thread, sauberes Cleanup)
Version: 1.0.0
PPTX Grafik-Komprimier-Tool (nur CaesiumCLT, Multi-Thread, Batch, sauberes Cleanup)
Version: 1.1.7
Highlights:
- Caesium-Scratch außerhalb des PPTX-Arbeitsverzeichnisses -> keine Tempfiles in finaler PPTX
- 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
- Log: image_name,size_before,size_after,saving,saving_percent,in_slide_number,image_type_changed
- Summary inkl. Zeit benötigt
Benutzung:
python pptx_image_compress.py -i input.pptx [-o output.pptx] [-t THREADS] [--version]
Änderungen in 1.1.7:
- 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 inspect
import os
import re
import xml.etree.ElementTree as ET
import sys
import zipfile
import tempfile
import shutil
import subprocess
import time
import fnmatch
from glob import glob
from pathlib import Path
from datetime import timedelta
from concurrent.futures import ThreadPoolExecutor, as_completed
from threading import Lock
from dataclasses import dataclass
from typing import Callable, List, Optional
# -------------------- Version --------------------
__version__ = "1.0.0"
# -------------------- Konfiguration --------------------
ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".webp", ".gif"} # GIF wird übersprungen
CAESIUM_QUALITY = 90 # -q 90
__version__ = "1.1.7"
ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
PROGRESS_BAR_LEN = 40
TEMP_PREFIX = "pptx_compress_"
DEFAULT_MIN_SAVINGS = "2%"
PNG_TO_JPEG_THRESHOLD_BYTES = 500 * 1024
@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 --------------------
def human_mb(nbytes: int) -> float:
return round(nbytes / (1024 * 1024), 2)
def human_kb(nbytes: int) -> float:
return round(nbytes / 1024,2)
def ensure_clean_file(path: Path):
if path.exists():
@@ -53,7 +104,6 @@ def ensure_clean_file(path: Path):
except Exception:
pass
def cleanup_old_temps():
tmp_root = Path(tempfile.gettempdir())
for p in tmp_root.glob(f"{TEMP_PREFIX}*"):
@@ -65,7 +115,6 @@ def cleanup_old_temps():
except Exception:
pass
def print_progress(i: int, total: int):
if total <= 0:
return
@@ -74,61 +123,146 @@ def print_progress(i: int, total: int):
pct = int(i * 100 / total)
print(f"\rBilder: |{bar}| {i}/{total} ({pct}%)", end="", flush=True)
def zip_dir_to_pptx(src_dir: Path, out_pptx: Path):
with zipfile.ZipFile(out_pptx, "w", compression=zipfile.ZIP_DEFLATED) as z:
all_files: list[Path] = []
for root, _, files in os.walk(src_dir):
for f in files:
full = Path(root) / f
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:
for full in content_types + rest:
rel = full.relative_to(src_dir)
z.write(full, arcname=str(rel))
def which(cmd: str) -> str | None:
def which(cmd: str):
return shutil.which(cmd)
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.
"""
def compress_with_caesium(
original: Path,
out_dir: Path,
caesium_threads: int | None,
quality: int,
min_savings: str,
output_format: str = "original",
) -> Path | None:
exe = which("caesiumclt")
if not exe:
raise RuntimeError(
"'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)
# Nur Formate an Caesium geben, die es unterstützt: JPG/JPEG, PNG, WEBP
ext = original.suffix.lower()
if ext not in {".jpg", ".jpeg", ".png", ".webp"}:
return None # GIF & andere werden übersprungen
if ext not in ALLOWED_EXT:
return None
cmd = [
exe,
"-q", str(CAESIUM_QUALITY),
"-O", "bigger", # <<< nur überschreiben, wenn Ziel größer ist
"-o", str(out_dir),
"-q",
str(quality),
"-O",
"bigger",
"--min-savings",
min_savings,
"--format",
output_format,
"-o",
str(out_dir),
]
if caesium_threads is not None:
cmd += ["--threads", str(caesium_threads)]
cmd += [str(original)]
try:
r = subprocess.run(cmd, capture_output=True, text=True)
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
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
return out_file if out_file.exists() else None
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
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 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:
total_ms = int(round(seconds * 1000))
@@ -139,137 +273,198 @@ def format_duration(seconds: float) -> str:
return f"{hms}.{frac[:2]}"
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 {}
def main():
start_time = time.perf_counter()
parser = argparse.ArgumentParser(
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":
print("❌ 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()
ensure_clean_file(output_pptx)
# --- Zwei getrennte Temp-Verzeichnisse ---
work_dir = Path(tempfile.mkdtemp(prefix=TEMP_PREFIX + "work_")) # entpackte PPTX
scratch_dir = Path(tempfile.mkdtemp(prefix=TEMP_PREFIX + "scratch_")) # Caesium-Ausgaben (außerhalb!)
# Logdatei neben Output
log_file = output_pptx.with_suffix(".log")
ensure_clean_file(log_file)
log_lines = ["image_name,size_before,size_after,saving,saving_percent\n"]
size_before = input_pptx.stat().st_size
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:
# Entpacken
with zipfile.ZipFile(input_pptx, "r") as z:
z.extractall(work_dir)
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}")
media_dir = work_dir / "ppt" / "media"
images = []
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 {img: sorted(slides) for img, slides in image_to_slides.items()}
total = len(images)
print(f"🔧 Finde Bilder in {media_dir} ... {total} Kandidaten")
print_progress(0, total)
# Vorab prüfen, ob caesiumclt verfügbar ist
if not which("caesiumclt"):
print("\n'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 args.threads and args.threads > 1 else None
# Thread-sichere Fortschritts- & Log-Verwaltung
lock = Lock()
done_count = 0
def worker(idx: int, img_path: Path):
nonlocal done_count
ext = img_path.suffix.lower()
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],
) -> ImageProcessResult:
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
try:
# Eigener Output-Unterordner pro Bild, um Kollisionen zu vermeiden
out_sub = scratch_dir / f"img_{idx:06d}"
caesium_out = compress_with_caesium(img_path, out_sub, caesium_threads)
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 = run_compressor(
compressor=compressor,
original=img_path,
out_dir=out_sub,
caesium_threads=caesium_threads,
quality=quality,
min_savings=min_savings,
)
if caesium_out and caesium_out.exists():
s = caesium_out.stat().st_size
if s < orig_size:
# kleineren ersetzen (atomar)
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 = s
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 # Original beibehalten
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 --------------------
def process_single_deck(
input_pptx: Path,
output_pptx: Path,
threads: int,
quality: int,
min_savings: str,
compressor: Callable[..., Path | None] = compress_with_caesium,
) -> DeckResult:
start_time = time.perf_counter()
result = DeckResult(
input=str(input_pptx),
output=str(output_pptx),
)
work_dir: Optional[Path] = None
scratch_dir: Optional[Path] = None
try:
if not input_pptx.exists() or input_pptx.suffix.lower() != ".pptx":
raise ValueError("Eingabedatei existiert nicht oder ist keine .pptx")
cleanup_old_temps()
ensure_clean_file(output_pptx)
work_dir = Path(tempfile.mkdtemp(prefix=TEMP_PREFIX + "work_"))
scratch_dir = Path(tempfile.mkdtemp(prefix=TEMP_PREFIX + "scratch_"))
log_file = output_pptx.with_suffix(".log.csv")
ensure_clean_file(log_file)
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
result.size_before = size_before
with zipfile.ZipFile(input_pptx, "r") as z:
z.extractall(work_dir)
slides_dir = work_dir / "ppt" / "slides"
rels_dir = slides_dir / "_rels"
media_dir = work_dir / "ppt" / "media"
images = discover_images(media_dir)
total = len(images)
print(f"[Processing] {input_pptx.name}: {total} Bild(er) gefunden")
print_progress(0, total)
if not which("caesiumclt") and compressor is compress_with_caesium:
raise RuntimeError("'caesiumclt' nicht gefunden. Bitte installieren und in PATH verfügbar machen.")
caesium_threads = 1 if threads > 1 else None
lock = Lock()
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):
nonlocal done_count
image_result = process_image_file(
idx=idx,
img_path=img_path,
scratch_dir=scratch_dir,
image_to_slides=image_to_slides,
caesium_threads=caesium_threads,
quality=quality,
min_savings=min_savings,
compressor=compressor,
)
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},{orig_size},{chosen_size},{saving},{saving_percent}\n")
if image_result.image_name != img_path.name:
renamed_images.append((img_path.name, image_result.image_name))
log_lines.append(image_result_to_log_line(image_result))
done_count += 1
print_progress(done_count, total)
# Parallel ausführen
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)]
for _ in as_completed(futures):
pass # Fortschritt wird im Worker gezeichnet
for fut in as_completed(futures):
try:
fut.result()
except Exception as exc:
sys.stderr.write(f"[worker] Unerwarteter Fehler: {exc}\n")
print() # newline nach Progressbar
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)
# --- Safety-Cleanup innerhalb des Arbeitsverzeichnisses ---
# 1) Entferne evtl. vorhandene caesium*-Ordner (aus alten Runs)
print() # newline
# Safety cleanup inside work_dir
for p in work_dir.rglob("*"):
try:
if p.is_dir() and p.name.lower().startswith("caesium"):
@@ -277,8 +472,6 @@ def main():
except Exception:
pass
# 2) Lösche eventuelle .tmp-Dateien in ppt/media
media_dir = work_dir / "ppt" / "media"
if media_dir.exists():
for f in media_dir.iterdir():
if f.is_file() and f.suffix.lower() == ".tmp":
@@ -287,47 +480,198 @@ def main():
except Exception:
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)
size_after = output_pptx.stat().st_size
result.size_after = size_after
# Log schreiben
try:
with open(log_file, "w", encoding="utf-8") as f:
f.writelines(log_lines)
except Exception:
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
result.elapsed_sec = elapsed
result.log_file = str(log_file)
result.ok = True
print("\n✅ Fertig!")
print("Summary")
print("-------")
print(f"Version: {__version__}")
print(f"Name: {output_pptx.name}")
print(f"Datei-Größe vorher: {human_mb(size_before)} MB")
print(f"Datei-Größe nachher: {human_mb(size_after)} MB")
print(f"Ersparnis: {savings_pct}%")
print(f"Zeit benötigt: {format_duration(elapsed)}")
print(f"Log-Datei: {log_file}")
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("Zusammenfassung ----------------")
print(" Vorher: ", human_mb(size_before), "MB")
print(" Nachher: ", human_mb(size_after), "MB")
print(" Ersparnis: ", f"{savings_pct}%")
print(" Zeit: ", format_duration(elapsed))
print(" Log: ", log_file)
except Exception as e:
result.error = str(e)
finally:
# Aufräumen ALLER temporären Dateien/Ordner
try:
if work_dir is not None:
shutil.rmtree(work_dir, ignore_errors=True)
except Exception:
pass
try:
if scratch_dir is not None:
shutil.rmtree(scratch_dir, ignore_errors=True)
except Exception:
pass
# Zusätzlich: ältere Reste entfernen
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, args = extractParserArguments()
input_files = validateParserArguments(parser, args)
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, args.min_savings)
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, args.min_savings)
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}%")
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')
#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('--min-savings', default=DEFAULT_MIN_SAVINGS, help="Mindestersparnis für caesiumclt (z. B. 2%%, 100KB, 1MB oder Bytes als Zahl)")
parser.add_argument('--version', action='version', version=f'%(prog)s {__version__}', help="Zeigt die Versionsnummer an" )
args = parser.parse_args()
return parser,args
if __name__ == '__main__':
main()
+142
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
)
-1
View File
@@ -1 +0,0 @@
Place your PPTX files here for testing, or use -i with a full path.
+248
View File
@@ -0,0 +1,248 @@
import tempfile
import unittest
import zipfile
from pathlib import Path
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")
images = pic.discover_images(media_dir)
self.assertEqual([p.name for p in images], ["a.jpg", "b.png", "d.GIF"])
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
result = pic.process_single_deck(
input_pptx=input_pptx,
output_pptx=output_pptx,
threads=2,
quality=90,
min_savings="2%",
compressor=fake_compressor,
)
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
result = pic.process_single_deck(
input_pptx=input_pptx,
output_pptx=output_pptx,
threads=1,
quality=90,
min_savings="2%",
compressor=fake_compressor,
)
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)
if __name__ == "__main__":
unittest.main()