15 Commits

Author SHA1 Message Date
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
12 changed files with 1925 additions and 138 deletions
+2
View File
@@ -2,3 +2,5 @@ python-3.*-embed-amd64.zip
python-embed/* python-embed/*
.vscode/launch.json .vscode/launch.json
logs/*.log 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
}
+12 -4
View File
@@ -1,6 +1,6 @@
# PPTX Image Compressor (CaesiumCLT only) # PPTX Image Compressor (CaesiumCLT only)
**Version 1.1.4** **Version 1.1.8**
Dieses Paket enthält: Dieses Paket enthält:
@@ -11,8 +11,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,19 +27,28 @@ 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, GIF** 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: `agressive`)
- 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.8
- SVG Files werden bei Vorhandensein von svg-polish anhand von 2 Profilen optimiert: balanced|agressive
## Hinweise ## Hinweise
- `-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|agressive`
## Manuelle Nutzung des .py (falls Python vorhanden) ## Manuelle Nutzung des .py (falls Python vorhanden)
```bat ```bat
@@ -50,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.
+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"
}
}
]
}
}
+77 -9
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.9" set "PY_EMBED_VERSION=3.14.5"
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"
@@ -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 ^
@@ -110,11 +122,67 @@ if not exist "%SCRIPT%" (
exit /b 6 exit /b 6
) )
set "RUN_ARGS=%*"
if "%~1"=="" 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 svg-polish
) else (
"%PY_CMD%" -m pip install --disable-pip-version-check --quiet 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.
)
) 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.
+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
+555 -116
View File
@@ -1,23 +1,26 @@
#!/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.4 Version: 1.1.8
Änderungen in 1.1.8:
- SVG Files werden bei Vorhandensein von svg_polish anhand von 2 Profilen optimiert: balanced|agressive
Highlights: Änderungen in 1.1.7:
- Caesium-Scratch außerhalb des PPTX-Arbeitsverzeichnisses -> keine Tempfiles in finaler PPTX - PNG->JPG Fallback für große PNGs hinzugefügt (wenn nach Kompression weiterhin > 500 KB)
- Safety-Cleanup: entfernt 'caesium*' Ordner und '*.tmp' in ppt/media, bevor gezippt wird - Logging erweitert: neue Spalte image_type_changed mit Wert png_jpg bei Typwechsel
- Overwrite Policy: -O bigger
- Log: image_name,size_before,size_after,saving,saving_percent
- Summary inkl. Zeit benötigt
Änderungen in 1.1.4: Änderungen in 1.1.6:
- Libcaesium 1.1.0 kann nun auch gif verkleinern - 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 +33,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.4"
ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
__version__ = "1.1.8"
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_AGGRESSIVE
@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 +126,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):
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 root, _, files in os.walk(src_dir):
for f in files: 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) rel = full.relative_to(src_dir)
z.write(full, arcname=str(rel)) z.write(full, arcname=str(rel))
def which(cmd: str): def which(cmd: str):
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", ".gif"}: 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 +178,256 @@ 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(
digits=3,
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,
)
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 +437,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,65 +566,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,
chosen_size = orig_size img_path=img_path,
try: scratch_dir=scratch_dir,
out_sub = scratch_dir / f"img_{idx:06d}" image_to_slides=image_to_slides,
caesium_out = compress_with_caesium(img_path, out_sub, caesium_threads, quality) caesium_threads=caesium_threads,
if caesium_out and caesium_out.exists(): quality=quality,
s = caesium_out.stat().st_size min_savings=min_savings,
if s < orig_size: compressor=compressor,
tmp_target = img_path.with_suffix(img_path.suffix + ".tmp") svg_profile=svg_profile,
shutil.copy2(caesium_out, tmp_target) )
tmp_target.replace(img_path)
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: with lock:
log_lines.append(f"{img_path.name};{human_kb(orig_size)};{human_kb(chosen_size)};{human_kb(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 done_count += 1
print_progress(done_count, total) 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
@@ -220,7 +650,7 @@ def process_single_deck(input_pptx: Path, output_pptx: Path, threads: int, quali
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:
@@ -229,9 +659,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})")
@@ -243,16 +673,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
@@ -306,38 +732,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
@@ -349,6 +745,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
@@ -364,14 +763,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:
@@ -379,14 +778,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:
@@ -404,6 +803,46 @@ 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')
#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('--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
-1
View File
@@ -1 +0,0 @@
Place your PPTX files here for testing, or use -i with a full path.
+417
View File
@@ -0,0 +1,417 @@
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
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)
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(captured_options["digits"], 2)
self.assertEqual(captured_options["indent_type"], "none")
self.assertEqual(captured_options["newlines"], False)
self.assertEqual(captured_options["strip_xml_prolog"], True)
self.assertEqual(captured_options["strip_comments"], True)
self.assertEqual(captured_options["strip_ids"], True)
self.assertEqual(captured_options["renderer_workaround"], False)
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["digits"], 3)
self.assertEqual(captured_options["group_collapse"], 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()