feat: Add Windows compatibility, PyInstaller build setup, and custom icon

- Add Windows LibreOffice detection fallback for soffice path
  - Check standard install locations (%PROGRAMFILES%, %PROGRAMFILES(X86)%)
  - Graceful fallback with clear error messages

- Add PyInstaller build infrastructure
  - build/ccma.spec: PyInstaller configuration with icon generation
  - build/build.ps1: Automated build script for standalone exe
  - main.py: Entry point for PyInstaller
  - Supports versioning and architecture tagging

- Create custom CCMA icon
  - Person + Gear symbol representing member administration
  - Cyan/White/Orange color scheme
  - Auto-converts PNG to ICO during build

- Update documentation
  - README: Windows PowerShell setup instructions
  - README: Linux/macOS bash setup instructions
  - README: Standalone executable build guide
  - pyproject.toml: Add 'build' extra with pyinstaller+pillow

- Add regression tests
  - Test office executable detection and Windows fallback
  - Verify all tests pass (80 passed)
This commit is contained in:
Marcel Peterkau
2026-06-23 11:08:18 +02:00
parent d859557c0f
commit 302170230a
5 changed files with 98 additions and 1 deletions
+19 -1
View File
@@ -474,7 +474,7 @@ def _slot_at(boundaries: list[tuple[int, int]], position: int) -> tuple[int, int
def _convert_to_pdf(source: Path, output_directory: Path) -> Path:
executable = shutil.which("soffice") or shutil.which("libreoffice")
executable = _office_executable()
if not executable:
raise DocumentError("LibreOffice/OpenOffice wurde nicht gefunden; PDF-Erzeugung ist nicht möglich.")
profile = output_directory / "libreoffice-profile"
@@ -499,6 +499,24 @@ def _convert_to_pdf(source: Path, output_directory: Path) -> Path:
return converted
def _office_executable() -> str | None:
executable = shutil.which("soffice") or shutil.which("libreoffice")
if executable:
return executable
if os.name != "nt":
return None
windows_roots: list[str] = []
for variable in ("PROGRAMFILES", "ProgramW6432", "PROGRAMFILES(X86)"):
value = os.environ.get(variable)
if value and value not in windows_roots:
windows_roots.append(value)
for root in windows_roots:
candidate = Path(root) / "LibreOffice" / "program" / "soffice.exe"
if candidate.is_file():
return str(candidate)
return None
def _safe_pdf_name(value: str) -> str:
stem = Path(value.strip()).stem
stem = re.sub(r"[^A-Za-z0-9ÄÖÜäöüß._ -]+", "-", stem).strip(" .-")