diff --git a/.github/workflows/source-platform-smoke.yml b/.github/workflows/source-platform-smoke.yml index 2796456..0ad91c7 100644 --- a/.github/workflows/source-platform-smoke.yml +++ b/.github/workflows/source-platform-smoke.yml @@ -4,13 +4,27 @@ on: push: paths: - 'main.py' + - 'translator.py' - 'requirements.txt' + - 'pyproject.toml' + - 'locales/**' + - 'assets/**' + - '*.ico' + - '*.png' - 'tests/source_platform_smoke.py' + - '.github/workflows/source-platform-smoke.yml' pull_request: paths: - 'main.py' + - 'translator.py' - 'requirements.txt' + - 'pyproject.toml' + - 'locales/**' + - 'assets/**' + - '*.ico' + - '*.png' - 'tests/source_platform_smoke.py' + - '.github/workflows/source-platform-smoke.yml' workflow_dispatch: permissions: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e942cca..9a7d137 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -47,7 +47,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt pytest ruff + pip install -r requirements.txt pytest ruff Pillow - name: Run ruff check run: | @@ -70,3 +70,33 @@ jobs: PYTHONIOENCODING: utf-8 run: | python main.py --self-test + + flutter: + name: Flutter analyze and test + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: flutter_port + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 + with: + channel: stable + flutter-version: '3.47.5' + cache: true + + - name: Show SDK versions + run: flutter --version + + - name: Resolve locked dependencies + run: flutter pub get --enforce-lockfile + + - name: Analyze Flutter source + run: flutter analyze --no-pub + + - name: Run Flutter tests + run: flutter test --no-pub diff --git a/.gitignore b/.gitignore index 775004c..84dfca9 100644 --- a/.gitignore +++ b/.gitignore @@ -135,6 +135,9 @@ DECISIONS.md TODO.md DONE.md +# ---- Interne Abnahme-/Reviewberichte (lokale Pfade, Hostnamen, Reviewernamen) ---- +_codex/ + # ---- Developer-Backups (PWA-/Companion-Templates) ---- web_companion/_pwa_tpl_*/ @@ -144,3 +147,6 @@ _archive/ # PyInstaller-Ausgabe im Store-Paket (114 MB Qt-/Python-Laufzeit, wird gebaut, nicht versioniert) store_package/*/_internal/ store_package/*/*.exe + +# MSIX-Staging von _STORE/msstore_build_msix.ps1 +_WARTUNG/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 08b7605..8fe4ddc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,42 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). ## [Unreleased] +### Behoben / Fixed +- Der PDF-Export lehnt das aktive Markdown-Original und dessen Pfad- oder + Datei-Aliase als Ziel ab. Eine erneute Prüfung vor der Veröffentlichung + schützt auch während des Druckens neu entstandene Verknüpfungen. Bei einem + Wechsel der aktiven Datei bleiben beide Originalpfade geschützt. +- PDF export rejects the original Markdown document and its aliases as a + destination, checking again before publication. Confirmed replacement of + an ordinary PDF remains supported. +- Bestehende Markdown-Dokumente werden über `QSaveFile` atomar gespeichert. + Teil-Schreibfehler und fehlgeschlagene Ersetzungen erhalten das bisherige + Dokument und den ungespeicherten Editorzustand. UTF-8 und die bisherigen + plattformspezifischen Zeilenumbrüche bleiben erhalten. +- Existing Markdown documents now save atomically; write or replacement failures + preserve the previous file and keep editor changes marked as unsaved. + +## [1.0.5] - 2026-09-27 + +### Behoben +- `.md`-Dateien zeigten im Explorer und auf dem Desktop eine braune Platte mit kleinem Logo statt des App-Symbols (T-20260927-699609650). Ursache (gemessen): Das Dateityp-Symbol ist `CleanMarkdown.exe,0`, also das in die EXE eingebackene Icon. Die ausgelieferte 1.0.3-EXE trug noch das vor PR #5 fehlerhafte Plattendesign (Abweichung 0,35 zur Store-Kachel). Die 1.0.4-Korrektur lag nur im Quellbaum und wurde nie ausgeliefert; 1.0.5 ersetzt sie. +- Das Store-Paket registrierte trotz `file_types` in `store_package.json` keinen `.md`-Handler: Der gemeinsame MSIX-Builder erzeugte sein Manifest selbst und ließ die Dateityp-Zuordnung weg. Er übernimmt sie jetzt (mit `uap:Logo`) und baut `resources.pri`. +- Neue `Square44x44Logo.targetsize-{16…256}`-Varianten inklusive `altform-unplated`/`altform-lightunplated` (erzeugt mit `scripts/gen_targetsize_icons.py` aus der Store-Kachel). Ohne sie zeichnet Windows Dateityp- und Taskleistensymbole skaliert auf eine Platte. +- Das Icon-Gate prüft jetzt auch das gebaute MSIX (`--package`): Manifest-Version, Dateityp-Zuordnung, `resources.pri`, alle targetsize/unplated-Varianten (jede real ausgelieferte Größe, nicht nur eine Mindestliste) und das Icon jeder EXE im Paket. +- Der MSIX-Build übernimmt jetzt den vollständigen onedir-Laufzeitordner (Python-/Qt-Laufzeit) statt nur der EXE-Datei — ein vorheriger Build hätte nach Installation nicht gestartet. +- Neues `uninstall_local.ps1`: entfernt kontrolliert eine ältere lokale Installation (z. B. aus einer manuellen Vorabversion), die sonst dauerhaft die `.md`-Dateizuordnung der Store-Version überdecken würde. Läuft nur nach Bestätigung und mit vorherigem Registry-Backup. + +## [1.0.4] - 2026-09-26 + +### Behoben +- Fenster-Icon (Titelleiste, EXE-Ressource) zeigte nach dem 1.0.3-Store-Update noch ein altes, anderes Design, während Taskleisten-/Kachel-Icon bereits korrekt war (T-20260926-864299616). Ursache: Die Store-Kachel (`store_assets/Square*.png`) wurde neu gebrandet, das Desktop-`.ico` (`assets/cleanmarkdown.ico` + Duplikate) sowie die Master-/Fallback-PNGs (`icon.png`, `DesktopIcon.png`, `assets/icon.png`, `assets/favicon.png`, `assets/favicon.ico`) wurden dabei nicht mitgezogen. Alle aus derselben Quelle wie die Store-Kachel neu erzeugt. +- Neuer Regressionstest `test_window_icon_matches_store_tile_branding` prüft künftig jedes Desktop-Icon (alle eingebetteten Größen) und jede Master-/Fallback-PNG gegen die Store-Kachel. + +## [1.0.3] - 2026-09-26 + +### Behoben +- Live-Vorschau beim Tippen debounced (`fix(perf): debounce live-preview render to stop sluggish typing`, #4) — verzögertes Rendern statt Rendern pro Tastenanschlag, dadurch flüssigeres Tippen auch in großen Dokumenten. + ### Hinzugefügt / Verbessert (2026-09-18 — Web Companion & PWA Release) - **Web Companion & PWA Plattform-Transfer (P2):** diff --git a/CleanMarkdown.ico b/CleanMarkdown.ico index 3cfb3ba..65aaf2e 100644 Binary files a/CleanMarkdown.ico and b/CleanMarkdown.ico differ diff --git a/DesktopIcon.ico b/DesktopIcon.ico index 3cfb3ba..65aaf2e 100644 Binary files a/DesktopIcon.ico and b/DesktopIcon.ico differ diff --git a/DesktopIcon.png b/DesktopIcon.png index 84764af..9d8a3df 100644 Binary files a/DesktopIcon.png and b/DesktopIcon.png differ diff --git a/EXPORTFORMAT.md b/EXPORTFORMAT.md index 05c3422..c540217 100644 --- a/EXPORTFORMAT.md +++ b/EXPORTFORMAT.md @@ -47,7 +47,7 @@ Status: ```json { - "appVersion": "0.3.1", + "appVersion": "1.0.5", "settings": { "language": "de", "theme": "paper", @@ -113,7 +113,7 @@ cleanmarkdown-bundle-v1.zip "session": "cleanmarkdown-session-v1.json", "assetsRoot": "assets/", "createdAt": "2026-05-28T16:10:00", - "appVersion": "0.3.1" + "appVersion": "1.0.5" } ``` diff --git a/PORTIERUNGSPLAN.md b/PORTIERUNGSPLAN.md index 5437c2a..248915a 100644 --- a/PORTIERUNGSPLAN.md +++ b/PORTIERUNGSPLAN.md @@ -9,10 +9,10 @@ die Zielsystem-Architektur und den Austauschvertrag für **CleanMarkdown**. | Plattform | Technologie | Priorität | Status | Kanonischer Pfad / Artefakt | |---|---|---|---|---| -| **Windows Desktop** | Python 3.12, PySide6 (Qt6) | P0 (Kanonisch) | Aktiv (v1.0.2) | `main.py`, `build_exe.bat` | +| **Windows Desktop** | Python 3.12, PySide6 (Qt6) | P0 (Kanonisch) | Aktiv (v1.0.5) | `main.py`, `build_exe.bat` | | **Windows Store (MSIX)** | Desktop-Bridge, AppxManifest, WACK | P0 (Release-Ready) | Bereit | `store_package/CleanMarkdown/`, `scripts/store_assets.py` | | **Mobile Companion (Android/iOS)** | Flutter 3.x, Dart | P1 (Vollständig) | Getestet (37/37) | `flutter_port/` | -| **Web Companion & PWA** | Statisches HTML5 / WebAssembly | P2 (Vollständig) | Bereit (v1.0.2) | `web_companion/` | +| **Web Companion & PWA** | Statisches HTML5 / WebAssembly | P2 (Vollständig) | Bereit (v1.0.5) | `web_companion/` | | **macOS & Linux Desktop** | PySide6 Source-Execution | P3 (Wartung) | Kompatibel | Source-Start via Python | --- @@ -40,7 +40,7 @@ die Zielsystem-Architektur und den Austauschvertrag für **CleanMarkdown**. - **Framework:** PySide6 (Qt for Python). - **Philosophie:** Sofort einsatzbereit, reine Einzelfenster-Bedienung ohne überflüssige Menühierarchien. - **Datenhaltung:** Einstellungen ausschließlich lokal in `%APPDATA%\CleanMarkdown\settings.json`. -- **Packaging:** PyInstaller Einzelfile (`CleanMarkdown-1.0.2-win64.exe`) und MSIX-Package für den Microsoft Store. +- **Packaging:** PyInstaller Einzelfile (`CleanMarkdown-1.0.5-win64.exe`) und MSIX-Package für den Microsoft Store. ### 2.2 Mobile-Linie (`flutter_port/`) - **Framework:** Flutter (Android & iOS). @@ -97,7 +97,7 @@ vollständig offline über JSON-Dateien nach der Spezifikation `cleanmarkdown-se - **Package-ID / Identity Name:** `Geiger.CleanMarkdown` - **Publisher:** `CN=52596601-BAB4-4F3F-B182-E8F3F273B202` - **Publisher Display:** `Geiger` -- **Version:** `1.0.2.0` +- **Version:** `1.0.5.0` - **Restricted Capabilities:** `runFullTrust` - **File Type Associations:** `.md`, `.markdown`, `.mdown`, `.mkd` diff --git a/README.md b/README.md index 7a9d027..73609f8 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ [![Platform: Windows | macOS | Linux](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/doc-bricks/CleanMarkdown) [![Zero-Egress](https://img.shields.io/badge/Privacy-100%25%20Offline%20%7C%20Zero--Egress-success.svg)](SECURITY.md) [![Security: Local-First](https://img.shields.io/badge/Security-Local--First%20%7C%20Non--Elevation-blueviolet.svg)](SECURITY.md) -[![Tests: 145 passed](https://img.shields.io/badge/Tests-145%20passed-brightgreen.svg)](tests) -[![Version: 1.0.2](https://img.shields.io/badge/Version-1.0.2-teal.svg)](CHANGELOG.md) +[![Tests: 227 passed](https://img.shields.io/badge/Tests-227%20passed-brightgreen.svg)](tests) +[![Version: 1.0.5](https://img.shields.io/badge/Version-1.0.5-teal.svg)](CHANGELOG.md) [![LLM-Ready: llms.txt](https://img.shields.io/badge/LLM--Ready-llms.txt-orange.svg)](llms.txt) > [!NOTE] @@ -43,6 +43,7 @@ ## Features - **Dual-Tab Workspace**: Instant switching between rendered `Reading` view and raw `Editor` with optional scroll synchronization. +- **Safe UTF-8 Opening**: Files with invalid UTF-8 are rejected before replacing the active document, preserving unsaved edits and original bytes. Convert other encodings to UTF-8 before opening. - **Raw Markdown Editor**: Direct Markdown editing with convenient helper buttons for headings, tables, links, images, and task lists without WYSIWYG complexity. - **Clear Formatting**: Strip Markdown syntax from selected text with a single click to start clean. - **Interactive Image Inspection**: High-resolution `ImagePreviewDialog` with Zoom (+/- / Fit / 1:1), Clipboard Copying, and image metadata. @@ -186,6 +187,35 @@ Or start directly on Windows: start.bat ``` +The Windows starter runs the current source checkout, using `.venv` when present +and otherwise Python on `PATH`. It forwards arguments (for example, +`start.bat --self-test`) and does not select older release executables. + +### Local install vs. Store install — don't run both + +`install_local.ps1` registers CleanMarkdown as a local `.md` handler (its own +`CleanMarkdown.mdfile` ProgID, `%LOCALAPPDATA%\Programs\CleanMarkdown`, a +Start Menu shortcut). Installing CleanMarkdown from the Microsoft Store on +top of that **does not replace the local file association** — what matters +is whichever ProgID is currently chosen in `UserChoice`, regardless of which +copy was updated most recently. Leaving both installed causes `.md` files to +keep showing the old local EXE's icon even after the Store package has been +updated. + +**Recommendation:** use only one install method at a time, in this order: + +1. Install/update the Store version first — its manifest **must** already + declare a `.md` file-type association; `uninstall_local.ps1` checks this + and aborts otherwise (e.g. for 1.0.3, which had none). +2. Preview: `.\uninstall_local.ps1 -WhatIf` (no changes). +3. Backed-up removal: `.\uninstall_local.ps1` (asks for confirmation before + changing anything, backs up the affected registry keys via `reg export`, + then removes only what `install_local.ps1` itself created — it **only + removes the old association, it does not set a new one**). +4. Windows Settings → Apps → Default apps → ".md" → set CleanMarkdown + (Store) as the default manually. +5. Verify the result (the `.md` icon in Explorer/Desktop). + --- ## Usage & Keyboard Shortcuts @@ -246,6 +276,9 @@ CleanMarkdown is part of the [`doc-bricks`](https://github.com/doc-bricks) docum ## Development & Test Suite +The [CI contract](docs/CI-CONTRACT.md) lists the desktop, web-contract, +Flutter and source-smoke checks, their commands and acceptance limits. + ```powershell python -m pip install -r requirements.txt pytest ruff python -m py_compile main.py translator.py @@ -254,7 +287,7 @@ python -m pytest -q python main.py --self-test ``` -The test suite validates rendering accuracy, figure link preservation, math processing, session serialization, settings persistence, headless print isolation, asset parity, and automated metadata contract synchronization (135 passed tests). +The test suite validates rendering accuracy, figure link preservation, math processing, session serialization, settings persistence, atomic document saving, headless print isolation, asset parity, and automated metadata contract synchronization (227 passed tests). For the mobile line in `flutter_port/`, verified scope includes local `.md`/`.markdown` file opening, live rendering, raw editing, and local storage flows. diff --git a/README_DE.md b/README_DE.md index 155d173..27f376d 100644 --- a/README_DE.md +++ b/README_DE.md @@ -14,8 +14,8 @@ Deutsch · **[English](README.md)** [![Plattform: Windows | macOS | Linux](https://img.shields.io/badge/Plattform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/doc-bricks/CleanMarkdown) [![Zero-Egress](https://img.shields.io/badge/Datenschutz-100%25%20Offline%20%7C%20Zero--Egress-success.svg)](SECURITY.md) [![Sicherheit: Local-First](https://img.shields.io/badge/Sicherheit-Local--First%20%7C%20Non--Elevation-blueviolet.svg)](SECURITY.md) -[![Tests: 145 passed](https://img.shields.io/badge/Tests-145%20passed-brightgreen.svg)](tests) -[![Version: 1.0.2](https://img.shields.io/badge/Version-1.0.2-teal.svg)](CHANGELOG.md) +[![Tests: 227 passed](https://img.shields.io/badge/Tests-227%20passed-brightgreen.svg)](tests) +[![Version: 1.0.5](https://img.shields.io/badge/Version-1.0.5-teal.svg)](CHANGELOG.md) [![LLM-Ready: llms.txt](https://img.shields.io/badge/LLM--Ready-llms.txt-orange.svg)](llms.txt) > [!NOTE] @@ -43,6 +43,7 @@ Deutsch · **[English](README.md)** ## Funktionen - **Zwei-Tab-Arbeitsbereich**: Nahtloser Wechsel zwischen gerenderter `Lesen`-Ansicht und `Editor` mit optionaler Scroll-Synchronisierung. +- **Sicheres UTF-8-Öffnen**: Dateien mit ungültigem UTF-8 werden vor dem Austausch des aktiven Dokuments abgelehnt. Ungespeicherte Änderungen und ursprüngliche Bytes bleiben erhalten. Andere Kodierungen vor dem Öffnen nach UTF-8 konvertieren. - **Reiner Markdown-Editor**: Direkte Bearbeitung von Markdown mit praktischen Hilfsbuttons für Überschriften, Tabellen, Links, Bilder und Aufgabenlisten ohne WYSIWYG-Komplexität. - **Formatierung entfernen**: Markdown-Syntax markierter Textpassagen mit einem Klick auflösen und frisch beginnen. - **Interaktive Bild-Inspektion**: Hochauflösender `ImagePreviewDialog` mit Zoom (+/- / Einpassen / 1:1), Zwischenablage-Kopieren und Bildmetadaten. @@ -186,6 +187,37 @@ Oder direkt unter Windows starten: start.bat ``` +Der Windows-Starter führt die aktuelle Arbeitskopie aus. Er nutzt eine vorhandene +`.venv`, andernfalls Python aus `PATH`. Argumente werden weitergereicht +(zum Beispiel `start.bat --self-test`); ältere Release-EXEs werden nicht ausgewählt. + +### Lokale Installation vs. Store-Installation — nicht parallel betreiben + +`install_local.ps1` registriert CleanMarkdown lokal als `.md`-Handler (eigene +ProgID `CleanMarkdown.mdfile`, `%LOCALAPPDATA%\Programs\CleanMarkdown`, +Startmenü-Verknüpfung). Wird CleanMarkdown zusätzlich aus dem Microsoft Store +installiert, **ersetzt das Store-Paket die lokale Dateizuordnung nicht +automatisch** — entscheidend ist die aktuell in `UserChoice` gewählte +Zuordnung, unabhängig davon, welche Version zuletzt aktualisiert wurde. Beide +parallel installiert zu lassen führt dazu, dass `.md`-Dateien weiterhin das +Icon der alten lokalen EXE zeigen, selbst nachdem das Store-Paket +aktualisiert wurde. + +**Empfehlung:** nur eine der beiden Installationsarten verwenden, in dieser +Reihenfolge: + +1. Passende Store-Version installieren/aktualisieren — **muss** im Manifest + bereits eine `.md`-Dateityp-Zuordnung deklarieren; `uninstall_local.ps1` + prüft das und bricht sonst ab (z. B. bei 1.0.3, die keine hatte). +2. Vorschau: `.\uninstall_local.ps1 -WhatIf` (keine Änderung). +3. Gesicherter Rückbau: `.\uninstall_local.ps1` (fragt vor jeder Änderung + nach, sichert die betroffenen Registry-Zweige per `reg export` und + entfernt anschließend nur das, was `install_local.ps1` selbst angelegt + hat — er **entfernt nur die alte Zuordnung, er setzt keine neue**). +4. Windows-Einstellungen → Apps → Standard-Apps → „.md" → CleanMarkdown + (Store) manuell als Standard wählen. +5. Ergebnis prüfen (`.md`-Icon im Explorer/Desktop). + --- ## Verwendung & Tastenkombinationen @@ -254,7 +286,7 @@ python -m pytest -q python main.py --self-test ``` -Die Testsuite validiert Rendering-Präzision, Erhaltung von Figure-Links, Mathe-Verarbeitung, Sitzungsserialisierung, Einstellungs-Persistenz, isolierten Headless-Druck, Asset-Parität sowie automatisierte Metadaten-Vertragssynchronisation (135 bestandene Tests). +Die Testsuite validiert Rendering-Präzision, Erhaltung von Figure-Links, Mathe-Verarbeitung, Sitzungsserialisierung, Einstellungs-Persistenz, atomare Dokumentenspeicherung, isolierten Headless-Druck, Asset-Parität sowie automatisierte Metadaten-Vertragssynchronisation (227 bestandene Tests). Für den mobilen Zweig in `flutter_port/` umfasst der geprüfte Umfang das Öffnen lokaler `.md`/`.markdown`-Dateien, Live-Rendering, Raw-Editing und lokale Speicher-Abläufe. diff --git a/STORE_ISSUES.md b/STORE_ISSUES.md index 47514cd..098a8ff 100644 --- a/STORE_ISSUES.md +++ b/STORE_ISSUES.md @@ -1,20 +1,26 @@ # CleanMarkdown — Store Issues & Versionsverlauf -## Aktuelle Store-Version: 1.0.0.0 -Veroeffentlicht: 2026-08-10 +## Aktuelle Store-Version: 1.0.3.0 +Veroeffentlicht: zuletzt bestaetigt vor 2026-09-27 (Submission `1152921505701981274`, Status `Published`) -## In Zertifizierung: 1.0.1.0 -Eingereicht: 2026-08-23 | Submission-ID `1152921505701720944` | Status `Certification` +## In Zertifizierung: 1.0.5.0 +Eingereicht: 2026-09-27 | Submission-ID `1152921505701985229` | Status `Certification` +Sicht-OK Icon-Collage (Policy §7a.2): Nutzer, 2026-09-27, woertlich „sieht gut aus abgenommen" +(Collage `C:\Users\User\OneDrive\Desktop\CleanMarkdown-Icon-Collage_1.0.5_2026-09-27-final.png`). ## Offene Issues | # | Severity | Beschreibung | Datei:Zeile | Gefunden | Status | |---|----------|-------------|-------------|----------|--------| -| 1 | P1 | Store-Paket registriert keinen `.md`-Handler: `AppxManifest.xml` deklarierte weder `uap:FileTypeAssociation` noch `uap3:AppExecutionAlias`. Nach Store-Installation existiert keine Dateizuordnung; die App erscheint nicht im Dialog „Öffnen mit". Betrifft alle Store-Kunden. | `store_package/CleanMarkdown/AppxManifest.xml` | 2026-08-23 | GEFIXT (Store, in Zertifizierung) | +| 1 | P1 | Store-Paket registriert keinen `.md`-Handler: `AppxManifest.xml` deklarierte weder `uap:FileTypeAssociation` noch `uap3:AppExecutionAlias`. Nach Store-Installation existiert keine Dateizuordnung; die App erscheint nicht im Dialog „Öffnen mit". Betrifft alle Store-Kunden. | `store_package/CleanMarkdown/AppxManifest.xml` | 2026-08-23 | GEFIXT (Store, 1.0.5.0, Submission `1152921505701985229`) — war WIEDER OFFEN in 1.0.3 (gemessen 2026-09-27), siehe #6/#9 | | 2 | P2 | Store-Paket enthielt eine PyInstaller-onefile-EXE, die bei jedem Start ins Temp-Verzeichnis entpackt (Startzeit ~10 s). | `build_exe.bat` / Paketaufbau | 2026-08-23 | GEFIXT (GitHub) | | 3 | P2 | `build_exe.bat` brach jeden Build ab: ungeschützte Klammern in der `echo`-Zeile des `else`-Zweigs zerreissen den `if`-Block beim Einlesen durch `cmd`. Regression aus `914f6e0`. | `build_exe.bat:64` | 2026-08-23 | GEFIXT (GitHub) | | 4 | P2 | onefile-Release-EXE (`releases/v1.0.1/CleanMarkdown-1.0.1-win64.exe`) startet nicht zuverlässig (Prozess beendet sich nach ~30 s). Nicht ausgeliefert — das Store-Paket nutzt den geprüften onedir-Build. | `releases/v1.0.1/` | 2026-08-23 | OFFEN | | 5 | P3 | Store-Paket enthält `setuptools` als Ballast; der Exclude-Scanner lieferte eine leere Ausschlussliste (2 Byte). | `_tools/build_exclude_scanner.py` (Aufruf) | 2026-08-23 | OFFEN | +| 6 | P1 | `.md`-Dateisymbole (Desktop/Explorer) als braune Platte mit kleinem Logo: Das Symbol ist `CleanMarkdown.exe,0`; die 1.0.3-EXE trägt das fehlerhafte Plattendesign (Abweichung 0,35 zur Kachel). Zusätzlich fehlten im MSIX `resources.pri` und alle targetsize/unplated-Varianten. | EXE-Icon-Ressource, `store_assets/`, `_STORE/msstore_build_msix.ps1` | 2026-09-27 | GEFIXT (Store, 1.0.5.0, Submission `1152921505701985229`) | +| 7 | P1 | Nutzersymptom aus #6 blieb trotz 1.0.5 bestehen: `.md` -> UserChoice `CleanMarkdown.mdfile` (aus `install_local.ps1`), `DefaultIcon` zeigt auf die LOKALE EXE unter `%LOCALAPPDATA%\Programs\CleanMarkdown` (installiert per `install_local.ps1`, unabhängig von der Store-Version). Das MSIX ersetzt die lokale Dateizuordnung nicht automatisch — beide Installationsarten dürfen nicht parallel bestehen. | `install_local.ps1`, Registry `HKCU\Software\Classes\CleanMarkdown.mdfile` | 2026-09-27 | GEFIXT (GitHub, `uninstall_local.ps1` gemergt via PR #7, main `42f5249`) — 4 Abnahmerunden (astra x2, Fable x2), zuletzt OK ohne Blocker. Reale Ausfuehrung braucht weiterhin Nutzer-Freigabe (Registry-Änderung), laeuft NACH Installation von 1.0.5.0. | +| 8 | P1 | Vier manipulierte MSIX-Pakete (falsche Pixelgröße, opake unplated-Assets, fehlende Manifest-EXE, falsche PRI-Identität) kamen mit Exit 0 durch `_STORE/icon_consistency_check.py --package`; der Build erzwang `--package` zudem nicht. | `_STORE/icon_consistency_check.py`, `_STORE/msstore_build_msix.ps1` | 2026-09-27 | GEFIXT: Gate prueft jetzt alle ausgelieferten Icon-Groessen (aus Paket/`resources.pri` erkannt statt Festliste), `msstore_build_msix.ps1` erzwingt `--package` real. Alle 7 von astra reproduzierten Manipulationen (inkl. zweiter Application, korrupte Manifest-EXE, Ein-Pixel-Transparenz-Umgehung) korrekt abgelehnt, finale Fable-Abnahme OK ohne Blocker. | +| 9 | P1 | Gebautes 1.0.5-MSIX enthielt den onedir-Laufzeitordner (`_internal`, Python-DLLs, PySide6) nicht — die App wäre nach Installation nicht gestartet, obwohl das Icon-Gate „passed" meldete. Von der 2. astra-Abnahme gefunden. | `_STORE/msstore_build_msix.ps1` | 2026-09-27 | GEFIXT (Store, 1.0.5.0, Submission `1152921505701985229`): Kopierschritt erkennt jetzt das onedir-Layout (Geschwisterordner `_internal` neben der EXE) und übernimmt den kompletten Ordner, analog zu `store_packager.py::copy_app_files`. Eingereichtes MSIX verifiziert: 234 `_internal`-Einträge inkl. `python312.dll`, Icon-Gate grün, Paketgröße 52,8 MB (Bezugsgroessen 1.0.0=73,6MB/1.0.1=52,2MB/1.0.3=48MB, kein Fremdballast — frisches Build-venv gegengeprueft). | ## Geplanter naechster Release: 1.0.1.0 Trigger: **1x P1 mit breiter Auswirkung** (WINDOWS_STORE_BUGFIX_POLICY §3.2) — die @@ -39,6 +45,18 @@ Rollback-Paket: `releases/windowsstore/v1.0.0/CleanMarkdown-1.0.0.0.msix` ## Versionsverlauf +### v1.0.5.0 (2026-09-27) — Store (in Zertifizierung, Submission `1152921505701985229`) +- Behebt ein falsches Dateisymbol für `.md`-Dateien sowie ein veraltetes Fenstersymbol; + verbessert die Stabilität der Store-Installation. +- Gefixt: #1/#6/#9 `.md`-Dateisymbol zeigte eine Platte statt des Dateityp-Icons; MSIX ohne + echte Laufzeit ausgeliefert (P1) +- Gefixt: #8 Icon/Package-Gate liess manipulierte Pakete durch (P1, Haertung nicht + nutzersichtbar, aber Voraussetzung fuer #1/#6/#9) +- Gefixt (GitHub, wirkt erst nach Nutzer-Migration): #7 lokale Altinstallation ueberdeckte + die Store-Dateizuordnung dauerhaft (`uninstall_local.ps1`) +- 1.0.4 uebersprungen (nie eingereicht) +- Ticket: T-20260927-699609650, PR doc-bricks/CleanMarkdown#7 (main `42f5249`) + ### v1.0.0.0 (2026-08-10) — Store - Initialer Store-Release. diff --git a/WINDOWS_STORE_PREP.md b/WINDOWS_STORE_PREP.md index 6b0f7e5..6dfb0f2 100644 --- a/WINDOWS_STORE_PREP.md +++ b/WINDOWS_STORE_PREP.md @@ -1,7 +1,7 @@ # Windows Store Vorbereitung & Release-Readiness — CleanMarkdown **Stand:** 2026-09-12 -**Version:** 1.0.2.0 +**Version:** 1.0.5.0 **Package-ID:** `Geiger.CleanMarkdown` **Publisher:** `CN=52596601-BAB4-4F3F-B182-E8F3F273B202` (Geiger) **Status:** Release-Ready (Preflight 21/21 Kriterien erfüllt, 0 Findings) @@ -12,7 +12,7 @@ | Artefakt | Status | Anmerkung | |---|---|---| -| `store_package.json` | OK | v1.0.2.0, Identity `Geiger.CleanMarkdown`, Canonical Publisher CN, Kategorie Productivity, DE+EN | +| `store_package.json` | OK | v1.0.5.0, Identity `Geiger.CleanMarkdown`, Canonical Publisher CN, Kategorie Productivity, DE+EN | | `STORE_LISTING.md` | OK | DE+EN Listing-Texte vollständig, Partner Center Richtlinie 10.1.3 konform (exakt 7 Keywords <= 30 Zeichen, keine Markennamen) | | `PRIVACY_POLICY.md` | OK | DE+EN, Offline-Invariante garantiert, kanonischer SettingsStore `%APPDATA%\CleanMarkdown\settings.json` | | `SUPPORT.md` | OK | DE+EN, Support-URL, FAQ, Settings-Pfad | @@ -40,7 +40,7 @@ - **Identity Name:** `Geiger.CleanMarkdown` - **Publisher:** `CN=52596601-BAB4-4F3F-B182-E8F3F273B202` -- **Version:** `1.0.2.0` +- **Version:** `1.0.5.0` - **Capabilities:** `` (erforderlich für Dateisystemzugriff via Dialoge) - **File Type Associations:** `.md`, `.markdown`, `.mdown`, `.mkd` - **Visual Elements:** Kachel-Icons in allen Standardauflösungen und Splash-Screen integriert. diff --git a/assets/cleanmarkdown.ico b/assets/cleanmarkdown.ico index 3cfb3ba..65aaf2e 100644 Binary files a/assets/cleanmarkdown.ico and b/assets/cleanmarkdown.ico differ diff --git a/assets/favicon.ico b/assets/favicon.ico index 3628f98..af72e25 100644 Binary files a/assets/favicon.ico and b/assets/favicon.ico differ diff --git a/assets/favicon.png b/assets/favicon.png index 57e39d9..cea8936 100644 Binary files a/assets/favicon.png and b/assets/favicon.png differ diff --git a/assets/icon.ico b/assets/icon.ico index 3cfb3ba..65aaf2e 100644 Binary files a/assets/icon.ico and b/assets/icon.ico differ diff --git a/assets/icon.png b/assets/icon.png index 84764af..9d8a3df 100644 Binary files a/assets/icon.png and b/assets/icon.png differ diff --git a/build_exe.bat b/build_exe.bat index a336ffc..e119cfa 100644 --- a/build_exe.bat +++ b/build_exe.bat @@ -18,7 +18,7 @@ set "PROJECT_ROOT=%CD%" if "%SOFTWARE_ROOT%"=="" set "SOFTWARE_ROOT=%USERPROFILE%\OneDrive\.TOPICS\.SOFTWARE" set "SCANNER=%SOFTWARE_ROOT%\_tools\build_exclude_scanner.py" set "ICON_PATH=%PROJECT_ROOT%\assets\cleanmarkdown.ico" -set "VERSION=1.0.2" +set "VERSION=1.0.5" set "APP_NAME=CleanMarkdown" set "BUILD_ROOT=C:\_Local_DEV\codex_build\cleanmarkdown" set "VENV_DIR=%BUILD_ROOT%\.venv" diff --git a/docs/CI-CONTRACT.md b/docs/CI-CONTRACT.md new file mode 100644 index 0000000..dec1237 --- /dev/null +++ b/docs/CI-CONTRACT.md @@ -0,0 +1,59 @@ +# CleanMarkdown: CI-Vertrag + +Die aktiven Prüfwege sind Desktop-Python, Web-Vertragstests und Flutter. Der +aktuelle Web-Companion ist eine statische Vanilla-JavaScript-Anwendung ohne +`package.json` oder npm-Testsuite. Frühere Aufgabenangaben zu `npm test` und +66 Web-Tests beschreiben einen früheren Stand. + +## Desktop und Web + +`.github/workflows/tests.yml` führt auf Windows und Linux mit Python +3.10–3.13 Lint, Syntaxprüfung und die gesamte Pytest-Suite aus. Auf Windows +kommt der native Selbsttest hinzu. + +Die acht Web-Vertragstests in `tests/test_web_companion.py` sind Bestandteil +dieser Suite. Sie prüfen Assets, Manifest, Service-Worker-Inventar, +Ressourcenreferenzen, Session-Felder, Bedienelemente, Sprachschlüssel und +Desktop-Bereinigungsregeln. Sie führen die Web-Anwendung nicht in einem +Browser aus. Lokal lassen sie sich vom Repository-Stamm starten: + +```text +python -m pytest tests/test_web_companion.py +``` + +## Flutter + +Ein separater Ubuntu-Job im selben Workflow läuft bei Push auf `main` und +Pull Requests gegen `main`. Er verwendet Flutter 3.47.5 mit Dart 3.13.4, +passend zum vorhandenen Lockfile und zur SDK-Anforderung `^3.12.0`. +Die SDK-Minimalgrenze allein reicht nicht: Flutter 3.44.0 verlangt fünf +andere Paketversionen und wird vom vorhandenen Lockfile abgewiesen. +Die Setup-Action ist auf +einen konkreten Commit gepinnt. Die Paketversionen bleiben durch das +vorhandene `pubspec.lock` gebunden; Abweichungen stoppen die Auflösung. + +Alle Befehle laufen in `flutter_port`: + +```text +flutter --version +flutter pub get --enforce-lockfile +flutter analyze --no-pub +flutter test --no-pub +``` + +Analyse- und Testfehler führen zum Fehlschlag des Jobs. Diese Prüfung ist +kein APK-/IPA-Build und ersetzt keine Android-/iOS-Geräteabnahme. + +## Source-Smoke + +`.github/workflows/source-platform-smoke.yml` führt den eigenständigen +Python-Source-Smoke auf Linux und macOS aus. Änderungen an `main.py`, +`translator.py`, `requirements.txt`, `pyproject.toml`, den Sprachdateien, +Assets, Root-Icons, dem Smoke-Programm und dem Smoke-Workflow lösen die +Prüfung aus. Die Settings-Prüfungen verwenden isolierte Anwendungsprofile. + +Die Workflow-Syntax kann mit `actionlint` geprüft werden. Erfolgreiche +Syntaxprüfung ersetzt keinen tatsächlichen GitHub-Lauf der einzelnen Jobs. + +SDK- und Action-Vertrag: [offizielles Flutter-Release-Manifest](https://storage.googleapis.com/flutter_infra_release/releases/releases_linux.json), +[flutter-action](https://github.com/subosito/flutter-action). diff --git a/icon.ico b/icon.ico index 3cfb3ba..65aaf2e 100644 Binary files a/icon.ico and b/icon.ico differ diff --git a/icon.png b/icon.png index 84764af..9d8a3df 100644 Binary files a/icon.png and b/icon.png differ diff --git a/llms.txt b/llms.txt index b075738..a479d91 100644 --- a/llms.txt +++ b/llms.txt @@ -18,6 +18,7 @@ - Automatic `
` and `
` generation while fully preserving custom anchor URLs and targets. - Lightweight math preview for `$...$`, `$$...$$`, `\(...\)`, and `\[...\]`. - 6-language UI (`de`, `en`, `es`, `zh`, `ja`, `ru`) with light and dark theme. +- Desktop file opening requires valid UTF-8; invalid input is rejected without replacing the active document or silently substituting characters. - Autosave, relative image resolution, and four-group syntax highlighting. - Flutter mobile port (Android/iOS, `flutter_port/`) with native offline file access. - Local offline use — 100% Zero-Egress, no accounts, no telemetry, no cloud sync. @@ -35,7 +36,7 @@ python main.py --self-test - `main.py` — PySide6 desktop application entry point, UI workspace, and AST pipeline. - `translator.py` / `locales/translations.json` — 6-language i18n system (`de`, `en`, `es`, `zh`, `ja`, `ru`). - `flutter_port/` — Flutter Android/iOS mobile port. -- `tests/` — pytest suite (145 tests: rendering, settings persistence, session, file/export edge cases, image preview, security/license contracts, metadata contracts, asset and icon contracts). +- `tests/` — pytest suite (227 tests: rendering, settings persistence, session, atomic document saving, file/export edge cases, image preview, security/license contracts, metadata contracts, asset and icon contracts). - `EXPORTFORMAT.md` — session and bundle format contract. ## Audience diff --git a/main.py b/main.py index c07af49..3b2c680 100644 --- a/main.py +++ b/main.py @@ -11,7 +11,7 @@ from tempfile import TemporaryDirectory import markdown -from PySide6.QtCore import QMarginsF, QSize, QStandardPaths, Qt, QTimer, QUrl +from PySide6.QtCore import QIODevice, QMarginsF, QSaveFile, QSize, QStandardPaths, Qt, QTimer, QUrl from PySide6.QtGui import ( QAction, QCloseEvent, @@ -58,7 +58,7 @@ APP_NAME = "CleanMarkdown" -APP_VERSION = "1.0.2" +APP_VERSION = "1.0.5" ICON_RELATIVE_PATH = ("assets", "cleanmarkdown.ico") SESSION_VERSION = "cleanmarkdown-session-v1" @@ -316,7 +316,7 @@ def _coerce_int(value: object, default: int, minimum: int | None = None) -> int: else: try: result = int(value) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): result = default if minimum is not None and result < minimum: return minimum @@ -328,6 +328,8 @@ def _desktop_theme_to_session(theme: str) -> str: def _session_theme_to_desktop(theme: object, default: str = "dark") -> str: + if not isinstance(theme, str): + return default if default in THEMES else "dark" if theme in {"night", "dark"}: return "dark" if theme in {"paper", "bright"}: @@ -340,6 +342,8 @@ def _desktop_mode_to_workspace(mode: str) -> str: def _workspace_to_desktop_mode(workspace: object, default: str = "view") -> str: + if not isinstance(workspace, str): + return default if workspace in {"view", "editor"}: return workspace if workspace == "read": @@ -521,7 +525,19 @@ def load(self) -> AppSettings: return AppSettings() def save(self, settings: AppSettings) -> None: - self.path.write_text(json.dumps(asdict(settings), indent=2, ensure_ascii=False), encoding="utf-8") + data = json.dumps(asdict(settings), indent=2, ensure_ascii=False).replace("\n", os.linesep).encode("utf-8") + save = QSaveFile(str(self.path)) + save.setDirectWriteFallback(False) + try: + if not save.open(QIODevice.OpenModeFlag.WriteOnly): + raise OSError(save.errorString()) + if save.write(data) != len(data): + raise OSError(save.errorString() or "Incomplete settings write") + if not save.commit(): + raise OSError(save.errorString() or "Settings commit failed") + except Exception: + save.cancelWriting() + raise LANGUAGE_NAMES = { @@ -637,6 +653,8 @@ def values(self) -> AppSettings: class MainWindow(QMainWindow): + PREVIEW_DEBOUNCE_MS = 180 + def __init__(self, initial_path: Path | None = None) -> None: super().__init__() icon = load_app_icon() @@ -697,6 +715,20 @@ def __init__(self, initial_path: Path | None = None) -> None: self.autosave_timer = QTimer(self) self.autosave_timer.timeout.connect(self._autosave_if_needed) + # Debounce fuer die Live-Vorschau: _render_preview() parst das ganze + # Dokument neu (markdown.markdown() + Nachbearbeitung) und ruft + # viewer.setHtml() auf, was bei grossen Dokumenten mit Tabellen + # gemessen ~300ms kostet (T-20260924-322340371: 600-Zeilen-Tabelle, + # 56 KB Dokument -> 308ms/Aufruf, davon 211ms allein in + # markdown.markdown()). Ohne Debounce lief das bei JEDEM Tastendruck + # synchron auf dem UI-Thread -> traeges Tippen. PREVIEW_DEBOUNCE_MS + # ist knapp unter der Wahrnehmungsschwelle fuer "reagiert sofort" + # (~200ms), sammelt aber mehrere Tastendruecke in einem Render. + self.preview_debounce_timer = QTimer(self) + self.preview_debounce_timer.setSingleShot(True) + self.preview_debounce_timer.setInterval(self.PREVIEW_DEBOUNCE_MS) + self.preview_debounce_timer.timeout.connect(self._render_preview) + self._create_actions() self._create_menus() self._create_toolbars() @@ -980,11 +1012,23 @@ def _retranslate_ui(self) -> None: def _apply_theme(self) -> None: self.setStyleSheet(THEMES[self.settings.theme]["app"]) - self.editor_highlighter.update_palette(THEMES[self.settings.theme]["editor_colors"]) + # Rehighlighting emits textChanged although it only updates formatting. + # Preserve the editor's signal state and the user's actual dirty flag. + blocked = self.editor.blockSignals(True) + try: + self.editor_highlighter.update_palette(THEMES[self.settings.theme]["editor_colors"]) + finally: + self.editor.blockSignals(blocked) self._render_preview() def _apply_settings(self) -> None: - self.autosave_timer.setInterval(max(2, self.settings.autosave_interval) * 1000) + # QTimer accepts milliseconds in a signed 32-bit integer. Session and + # profile JSON can contain much larger values or overflowing numbers. + self.settings.autosave_interval = min( + (2**31 - 1) // 1000, + _coerce_int(self.settings.autosave_interval, AppSettings.autosave_interval, minimum=2), + ) + self.autosave_timer.setInterval(self.settings.autosave_interval * 1000) if self.settings.autosave_enabled: self.autosave_timer.start() else: @@ -1025,6 +1069,8 @@ def _sync_scroll_positions_between_tabs(self, source_index: int, target_index: i ) def _on_tab_changed(self, index: int) -> None: + if self.preview_debounce_timer.isActive(): + self._render_preview() previous_index = self._last_tab_index self._last_tab_index = index self._update_window_title() @@ -1077,7 +1123,8 @@ def _open_anchor(self, url: QUrl) -> None: def _on_text_changed(self) -> None: self.is_modified = not self._is_blank_untitled_document() self._update_window_title() - self._render_preview() + # Debounced statt direkt -- siehe Kommentar bei preview_debounce_timer. + self.preview_debounce_timer.start() def _render_task_lists(self, body: str) -> str: def repl(match: re.Match[str]) -> str: @@ -1209,7 +1256,9 @@ def _wrap_image_as_block(self, a_open: str, img_tag: str, a_close: str) -> str: title_text = html.unescape(title_raw) caption = title_text if title_text else alt_text - img_html = img_tag + # Qt interprets percentage HTML width attributes as zero-sized images. + # Let intrinsic dimensions and the theme's max-width constrain banners. + img_html = re.sub(r'\s+width\s*=\s*(["\'])\s*100%\s*\1', "", img_tag, flags=re.IGNORECASE) if alt_raw and not title_m: img_html = re.sub(r'alt=["\']', f'title="{html.escape(alt_text)}" alt="', img_html, count=1) @@ -1272,6 +1321,23 @@ def flush_text() -> None: return p_match.group(0) return "".join(out_parts) + # Raw HTML banners bypass Markdown's paragraph generation. Protect + # existing paragraphs and literal code before wrapping standalone lines. + raw_image = re.compile( + r'<(?Pp|pre|code)\b[^>]*>.*?' + r'|^[ \t]*(?:(?P]*>)\s*)?' + r'(?P]+>)\s*(?P)?[ \t]*$', + re.IGNORECASE | re.DOTALL | re.MULTILINE, + ) + + def repl_raw_image(match: re.Match[str]) -> str: + if match.group("protected"): + return match.group(0) + return self._wrap_image_as_block( + match.group("link") or "", match.group("image"), match.group("close") or "" + ) + + body = raw_image.sub(repl_raw_image, body) pattern = re.compile(r'

(.*?)

', re.IGNORECASE | re.DOTALL) return pattern.sub(repl_paragraph, body) @@ -1296,6 +1362,7 @@ def _wrap_html_document(self, body: str, theme: str) -> str: """ def _render_preview(self) -> None: + self.preview_debounce_timer.stop() text = self.editor.toPlainText() self.viewer.document().setBaseUrl(self._preview_base_url()) if not text.strip(): @@ -1339,7 +1406,9 @@ def open_file(self) -> None: def load_file(self, path: Path) -> None: try: - content = path.read_text(encoding="utf-8", errors="replace") + # Decode before replacing the active document. Silent replacement + # would destroy undecodable bytes on the next ordinary save. + content = path.read_text(encoding="utf-8") except Exception: QMessageBox.critical(self, self.t("error"), self.t("cannot_open")) return @@ -1389,7 +1458,8 @@ def load_session_file(self, path: Path) -> None: minimum=2, ) export_mode = settings_payload.get("exportMode", self.settings.export_mode) - self.settings.export_mode = export_mode if export_mode in {"source", "dedicated"} else self.settings.export_mode + if isinstance(export_mode, str) and export_mode in {"source", "dedicated"}: + self.settings.export_mode = export_mode self.settings.export_confirm = _coerce_bool( settings_payload.get("exportConfirm", self.settings.export_confirm), self.settings.export_confirm, @@ -1435,9 +1505,20 @@ def save_file(self) -> bool: return False if self.current_file is None: return self.save_file_as() + save = None try: - self.current_file.write_text(self.editor.toPlainText(), encoding="utf-8") + data = self.editor.toPlainText().replace("\n", os.linesep).encode("utf-8") + save = QSaveFile(str(self.current_file)) + save.setDirectWriteFallback(False) + if not save.open(QIODevice.OpenModeFlag.WriteOnly): + raise OSError(save.errorString()) + if save.write(data) != len(data): + raise OSError(save.errorString()) + if not save.commit(): + raise OSError(save.errorString()) except Exception: + if save is not None: + save.cancelWriting() QMessageBox.critical(self, self.t("error"), self.t("cannot_save")) return False self.session_display_name = self.current_file.name @@ -1500,9 +1581,20 @@ def export_session(self) -> None: }, } + save = None try: - Path(file_name).write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + data = json.dumps(payload, indent=2, ensure_ascii=False).replace("\n", os.linesep).encode("utf-8") + save = QSaveFile(file_name) + save.setDirectWriteFallback(False) + if not save.open(QIODevice.OpenModeFlag.WriteOnly): + raise OSError(save.errorString()) + if save.write(data) != len(data): + raise OSError(save.errorString() or "Incomplete session write") + if not save.commit(): + raise OSError(save.errorString() or "Session commit failed") except Exception: + if save is not None: + save.cancelWriting() QMessageBox.critical(self, self.t("error"), self.t("cannot_export_session")) return self.statusBar().showMessage(f"{self.t('session_exported')}: {Path(file_name).name}", 3500) @@ -1532,10 +1624,33 @@ def _auto_save_for_export(self) -> Path | None: base_name = _normalize_markdown_name(self.session_display_name) stem = base_name[: -len(".md")] if base_name.lower().endswith(".md") else base_name candidate = docs_dir / f"{stem}_autosave_{timestamp}.md" + created = False try: + data = self.editor.toPlainText().replace("\n", os.linesep).encode("utf-8") docs_dir.mkdir(parents=True, exist_ok=True) - candidate.write_text(self.editor.toPlainText(), encoding="utf-8") + counter = 0 + while True: + suffix = f"_{counter}" if counter else "" + candidate = docs_dir / f"{stem}_autosave_{timestamp}{suffix}.md" + try: + output = candidate.open("xb") + created = True + break + except OSError: + # Exclusive creation also protects against a competing + # process creating the candidate after we chose its name. + if not candidate.exists(): + raise + counter += 1 + with output: + if output.write(data) != len(data): + raise OSError("Incomplete automatic document save") except Exception: + if created: + try: + candidate.unlink() + except OSError: + pass return None return candidate @@ -1557,6 +1672,19 @@ def _create_pdf_printer() -> QPrinter: pass return QPrinter(QPrinter.PrinterMode.HighResolution) + def _check_pdf_original(self, target: Path, original: Path | None) -> None: + for source in dict.fromkeys((original, self.current_file)): + if source is None: + continue + if target.resolve() == source.resolve(): + raise OSError("PDF destination is an active Markdown document") + try: + same_file = target.samefile(source) + except FileNotFoundError: + same_file = False + if same_file: + raise OSError("PDF destination aliases an active Markdown document") + def export_pdf(self) -> None: auto_saved_path: Path | None = None if self.current_file is None and not self._is_blank_untitled_document(): @@ -1571,6 +1699,7 @@ def export_pdf(self) -> None: self.is_modified = False self._update_window_title() + original = self.current_file target = self._suggested_export_path() if self.settings.export_confirm: file_name, _ = QFileDialog.getSaveFileName(self, self.t("export_title"), str(target), "PDF Files (*.pdf)") @@ -1585,31 +1714,38 @@ def export_pdf(self) -> None: target = Path(file_name) try: + self._check_pdf_original(target, original) target.parent.mkdir(parents=True, exist_ok=True) - - printer = self._create_pdf_printer() - printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat) - printer.setOutputFileName(str(target)) - printer.setPageMargins(QMarginsF(15, 15, 15, 15), QPageLayout.Unit.Millimeter) - - # U1: PDF-Export ist Print-Standard -- immer hell, unabhaengig - # vom aktuell gewaehlten UI-Theme (siehe _build_export_document). - document = self._build_export_document() - document.print_(printer) + with TemporaryDirectory(prefix=".cleanmarkdown-pdf-", dir=target.parent) as export_dir: + temporary_pdf = Path(export_dir) / "export.pdf" + printer = self._create_pdf_printer() + printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat) + printer.setOutputFileName(str(temporary_pdf)) + printer.setPageMargins(QMarginsF(15, 15, 15, 15), QPageLayout.Unit.Millimeter) + + # U1: PDF export always uses the bright print theme. + try: + document = self._build_export_document() + document.print_(printer) + finally: + del printer + + # Validate this run's output, never an existing destination. + # Qt can return without raising after a failed print operation. + with temporary_pdf.open("rb") as output: + size = temporary_pdf.stat().st_size + header = output.read(5) + output.seek(max(0, size - 1024)) + trailer = output.read().rstrip() + if header != b"%PDF-" or not trailer.endswith(b"%%EOF"): + raise OSError("Missing or incomplete PDF output") + self._check_pdf_original(target, original) + os.replace(temporary_pdf, target) except Exception: QMessageBox.critical(self, self.t("error"), self.t("cannot_export")) self.statusBar().showMessage(self.t("cannot_export"), 4000) return - # Grundregel U2: Export darf NIE still scheitern. Qt's Druck-Backend - # kann in seltenen Faellen (z. B. fehlende Berechtigung, kaputter - # Drucker-Treiber-Stub) ohne Python-Exception eine leere/keine Datei - # hinterlassen -- das faengt dieser Check zusaetzlich zum try/except ab. - if not target.exists() or target.stat().st_size == 0: - QMessageBox.critical(self, self.t("error"), self.t("cannot_export")) - self.statusBar().showMessage(self.t("cannot_export"), 4000) - return - if auto_saved_path is not None: self.statusBar().showMessage( f"{self.t('autosave_saved')}: {auto_saved_path.name} | {self.t('exported')}: {target.name}", @@ -1639,8 +1775,12 @@ def open_settings(self) -> None: updated.window_width = self.width() updated.window_height = self.height() updated.editor_toolbar_collapsed = dialog.values().editor_toolbar_collapsed + try: + self.store.save(updated) + except Exception: + QMessageBox.critical(self, self.t("error"), self.t("cannot_save")) + return self.settings = updated - self.store.save(self.settings) self._apply_settings() self._apply_theme() self._retranslate_ui() @@ -1918,7 +2058,12 @@ def _insert_footnote(self) -> None: def closeEvent(self, event: QCloseEvent) -> None: self.settings.window_width = self.width() self.settings.window_height = self.height() - self.store.save(self.settings) + try: + self.store.save(self.settings) + except Exception: + event.ignore() + QMessageBox.critical(self, self.t("error"), self.t("cannot_save")) + return if self._confirm_discard(): event.accept() else: @@ -2063,6 +2208,10 @@ def fake_save(*args, **kwargs): for index in range(1, 90) ) window.editor.setPlainText(long_markdown) + # Scroll-Sync braucht den fertig gerenderten Viewer sofort, nicht + # erst nach PREVIEW_DEBOUNCE_MS -- direkt rendern statt zu warten. + window.preview_debounce_timer.stop() + window._render_preview() app.processEvents() editor_bar = window.editor.verticalScrollBar() diff --git a/pyproject.toml b/pyproject.toml index 23c9ed6..96978cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "cleanmarkdown" -version = "1.0.2" +version = "1.0.5" description = "Fast local Markdown viewer and editor with clean reading mode, raw editor, PDF export, and DE/EN UI." readme = "README.md" authors = [ @@ -35,6 +35,7 @@ dependencies = [ [project.optional-dependencies] test = [ "pytest>=9.1.1", + "Pillow>=10.0", ] dev = [ "pytest>=9.1.1", diff --git a/scripts/gen_targetsize_icons.py b/scripts/gen_targetsize_icons.py new file mode 100644 index 0000000..051b035 --- /dev/null +++ b/scripts/gen_targetsize_icons.py @@ -0,0 +1,53 @@ +"""Erzeugt die targetsize-/unplated-Varianten des Square44x44Logo für das MSIX. + +Windows zieht Taskleisten-, Startmenü-Listen- und Dateityp-Symbole +(`uap:FileTypeAssociation`) aus `Square44x44Logo.targetsize-XX[_altform-*].png` +über `resources.pri`. Fehlen sie, skaliert Windows die 44-px-Kachel und setzt +sie auf eine Platte (T-20260927-699609650). Quelle ist bewusst die Store-Kachel +selbst, damit Kachel, EXE-Icon und Dateityp-Symbol aus einer Quelle stammen. + + python scripts/gen_targetsize_icons.py +""" + +from __future__ import annotations + +from pathlib import Path + +from PIL import Image + +STORE_ASSETS = Path(__file__).resolve().parents[1] / "store_assets" +SOURCE = STORE_ASSETS / "Square310x310Logo.png" +TARGET_SIZES = (16, 20, 24, 30, 32, 36, 40, 48, 60, 64, 72, 80, 96, 256) +ALTFORMS = ("", "_altform-unplated", "_altform-lightunplated") + +# A properly-fitting unplated/lightunplated asset AVOIDS the extra, +# unwanted system plate Windows would otherwise draw around a plated icon +# in these contexts (corrected wording -- astra-Abnahme, T-20260927-699609650: +# the previous comment had this backwards). These variants must therefore +# not be an opaque copy of the plated tile (the icon-consistency gate's +# --package check rejects a build that ships one). A full inset margin +# (Microsoft's usual unplated guidance) pushes small sizes' design- +# similarity diff over tests/test_assets_and_icons.py's calibrated +# threshold (measured: 0.22 at 16px with a 12% margin, threshold 0.20) -- +# a small transparent corner is the minimum treatment that is genuinely +# non-opaque while staying well inside that calibration. +def _unplated(master: Image.Image, size: int) -> Image.Image: + img = master.resize((size, size), Image.LANCZOS) + corner = max(1, size // 8) + alpha = img.getchannel("A").copy() + alpha.paste(Image.new("L", (corner, corner), 0), (0, 0)) + img.putalpha(alpha) + return img + + +def main() -> None: + master = Image.open(SOURCE).convert("RGBA") + for size in TARGET_SIZES: + plated = master.resize((size, size), Image.LANCZOS) + for altform in ALTFORMS: + img = _unplated(master, size) if altform else plated + img.save(STORE_ASSETS / f"Square44x44Logo.targetsize-{size}{altform}.png", optimize=True) + + +if __name__ == "__main__": + main() diff --git a/start.bat b/start.bat index ea0f321..df8915d 100644 --- a/start.bat +++ b/start.bat @@ -1,22 +1,17 @@ @echo off -cd /d "%~dp0" -set PYTHONIOENCODING=utf-8 -set "FAST_EXE=%CD%\releases\v0.3.2\CleanMarkdown-fast\CleanMarkdown.exe" -set "RELEASE_EXE=%CD%\releases\v0.3.2\CleanMarkdown-0.3.2-win64.exe" -if exist "%FAST_EXE%" ( - start "" "%FAST_EXE%" - exit /b 0 -) -if exist "%RELEASE_EXE%" ( - start "" "%RELEASE_EXE%" - exit /b 0 -) -python --version >nul 2>&1 +setlocal +chcp 65001 >nul +cd /d "%~dp0" || exit /b 1 +set "PYTHONIOENCODING=utf-8" +set "APP_PYTHON=python" +if exist "%~dp0.venv\Scripts\python.exe" set "APP_PYTHON=%~dp0.venv\Scripts\python.exe" +"%APP_PYTHON%" --version >nul 2>&1 if errorlevel 1 ( - echo [FEHLER] Python wurde nicht gefunden und die Release-EXE fehlt. + echo [FEHLER] Python wurde nicht gefunden. Bitte Python und requirements.txt installieren. pause exit /b 1 ) -echo [INFO] Release-EXE fehlt, starte Python-Fallback. -python main.py -if errorlevel 1 pause +"%APP_PYTHON%" "%~dp0main.py" %* +set "APP_EXIT=%ERRORLEVEL%" +if not "%APP_EXIT%"=="0" pause +exit /b %APP_EXIT% diff --git a/store_assets/Square44x44Logo.targetsize-16.png b/store_assets/Square44x44Logo.targetsize-16.png new file mode 100644 index 0000000..657e80c Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-16.png differ diff --git a/store_assets/Square44x44Logo.targetsize-16_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-16_altform-lightunplated.png new file mode 100644 index 0000000..76560b2 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-16_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-16_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-16_altform-unplated.png new file mode 100644 index 0000000..76560b2 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-16_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-20.png b/store_assets/Square44x44Logo.targetsize-20.png new file mode 100644 index 0000000..9da0094 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-20.png differ diff --git a/store_assets/Square44x44Logo.targetsize-20_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-20_altform-lightunplated.png new file mode 100644 index 0000000..be46c3d Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-20_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-20_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-20_altform-unplated.png new file mode 100644 index 0000000..be46c3d Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-20_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-24.png b/store_assets/Square44x44Logo.targetsize-24.png new file mode 100644 index 0000000..5dcbeef Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-24.png differ diff --git a/store_assets/Square44x44Logo.targetsize-24_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-24_altform-lightunplated.png new file mode 100644 index 0000000..5b60739 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-24_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-24_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-24_altform-unplated.png new file mode 100644 index 0000000..5b60739 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-24_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-256.png b/store_assets/Square44x44Logo.targetsize-256.png new file mode 100644 index 0000000..c153097 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-256.png differ diff --git a/store_assets/Square44x44Logo.targetsize-256_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-256_altform-lightunplated.png new file mode 100644 index 0000000..d9ce6ee Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-256_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-256_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-256_altform-unplated.png new file mode 100644 index 0000000..d9ce6ee Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-256_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-30.png b/store_assets/Square44x44Logo.targetsize-30.png new file mode 100644 index 0000000..f643cf3 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-30.png differ diff --git a/store_assets/Square44x44Logo.targetsize-30_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-30_altform-lightunplated.png new file mode 100644 index 0000000..c835f7a Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-30_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-30_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-30_altform-unplated.png new file mode 100644 index 0000000..c835f7a Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-30_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-32.png b/store_assets/Square44x44Logo.targetsize-32.png new file mode 100644 index 0000000..23630ad Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-32.png differ diff --git a/store_assets/Square44x44Logo.targetsize-32_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-32_altform-lightunplated.png new file mode 100644 index 0000000..fd3f150 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-32_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-32_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-32_altform-unplated.png new file mode 100644 index 0000000..fd3f150 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-32_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-36.png b/store_assets/Square44x44Logo.targetsize-36.png new file mode 100644 index 0000000..9218c4e Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-36.png differ diff --git a/store_assets/Square44x44Logo.targetsize-36_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-36_altform-lightunplated.png new file mode 100644 index 0000000..c5c23ba Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-36_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-36_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-36_altform-unplated.png new file mode 100644 index 0000000..c5c23ba Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-36_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-40.png b/store_assets/Square44x44Logo.targetsize-40.png new file mode 100644 index 0000000..4270d11 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-40.png differ diff --git a/store_assets/Square44x44Logo.targetsize-40_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-40_altform-lightunplated.png new file mode 100644 index 0000000..7f92ef0 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-40_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-40_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-40_altform-unplated.png new file mode 100644 index 0000000..7f92ef0 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-40_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-48.png b/store_assets/Square44x44Logo.targetsize-48.png new file mode 100644 index 0000000..41f1bf1 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-48.png differ diff --git a/store_assets/Square44x44Logo.targetsize-48_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-48_altform-lightunplated.png new file mode 100644 index 0000000..042d38d Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-48_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-48_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-48_altform-unplated.png new file mode 100644 index 0000000..042d38d Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-48_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-60.png b/store_assets/Square44x44Logo.targetsize-60.png new file mode 100644 index 0000000..94a04f1 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-60.png differ diff --git a/store_assets/Square44x44Logo.targetsize-60_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-60_altform-lightunplated.png new file mode 100644 index 0000000..3464dc2 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-60_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-60_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-60_altform-unplated.png new file mode 100644 index 0000000..3464dc2 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-60_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-64.png b/store_assets/Square44x44Logo.targetsize-64.png new file mode 100644 index 0000000..7cf0e0f Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-64.png differ diff --git a/store_assets/Square44x44Logo.targetsize-64_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-64_altform-lightunplated.png new file mode 100644 index 0000000..3e98f21 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-64_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-64_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-64_altform-unplated.png new file mode 100644 index 0000000..3e98f21 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-64_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-72.png b/store_assets/Square44x44Logo.targetsize-72.png new file mode 100644 index 0000000..5a28fa0 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-72.png differ diff --git a/store_assets/Square44x44Logo.targetsize-72_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-72_altform-lightunplated.png new file mode 100644 index 0000000..5b50525 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-72_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-72_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-72_altform-unplated.png new file mode 100644 index 0000000..5b50525 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-72_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-80.png b/store_assets/Square44x44Logo.targetsize-80.png new file mode 100644 index 0000000..d6e88bd Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-80.png differ diff --git a/store_assets/Square44x44Logo.targetsize-80_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-80_altform-lightunplated.png new file mode 100644 index 0000000..6a4fa21 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-80_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-80_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-80_altform-unplated.png new file mode 100644 index 0000000..6a4fa21 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-80_altform-unplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-96.png b/store_assets/Square44x44Logo.targetsize-96.png new file mode 100644 index 0000000..103fe35 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-96.png differ diff --git a/store_assets/Square44x44Logo.targetsize-96_altform-lightunplated.png b/store_assets/Square44x44Logo.targetsize-96_altform-lightunplated.png new file mode 100644 index 0000000..83f61b1 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-96_altform-lightunplated.png differ diff --git a/store_assets/Square44x44Logo.targetsize-96_altform-unplated.png b/store_assets/Square44x44Logo.targetsize-96_altform-unplated.png new file mode 100644 index 0000000..83f61b1 Binary files /dev/null and b/store_assets/Square44x44Logo.targetsize-96_altform-unplated.png differ diff --git a/store_assets/icon_150x150.png b/store_assets/icon_150x150.png index 8b768fc..dd3dd9b 100644 Binary files a/store_assets/icon_150x150.png and b/store_assets/icon_150x150.png differ diff --git a/store_assets/icon_310x150.png b/store_assets/icon_310x150.png index 71f75af..fa70d2f 100644 Binary files a/store_assets/icon_310x150.png and b/store_assets/icon_310x150.png differ diff --git a/store_assets/icon_310x310.png b/store_assets/icon_310x310.png index 5cf6a1b..d6bd9d4 100644 Binary files a/store_assets/icon_310x310.png and b/store_assets/icon_310x310.png differ diff --git a/store_assets/icon_44x44.png b/store_assets/icon_44x44.png index b87cfe3..2552bec 100644 Binary files a/store_assets/icon_44x44.png and b/store_assets/icon_44x44.png differ diff --git a/store_assets/icon_50x50.png b/store_assets/icon_50x50.png index 454c2dc..72112aa 100644 Binary files a/store_assets/icon_50x50.png and b/store_assets/icon_50x50.png differ diff --git a/store_package.json b/store_package.json index a41150a..ce5d308 100644 --- a/store_package.json +++ b/store_package.json @@ -7,7 +7,7 @@ "publisher_id": "CN=52596601-BAB4-4F3F-B182-E8F3F273B202", "publisher_display": "Geiger", "publisher_name": "Lukas Geiger", - "version": "1.0.2.0", + "version": "1.0.5.0", "description": "Lokaler Markdown-Viewer und -Editor mit Lesemodus, Raw-Editor, PDF-Export, Mathe-Vorschau und DE/EN-Oberfläche.", "executable": "CleanMarkdown.exe", "capabilities": "runFullTrust", diff --git a/tests/source_platform_smoke.py b/tests/source_platform_smoke.py index cd95262..71e162d 100644 --- a/tests/source_platform_smoke.py +++ b/tests/source_platform_smoke.py @@ -4,6 +4,7 @@ import json import tempfile import traceback +from contextlib import contextmanager from pathlib import Path os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") @@ -17,6 +18,21 @@ results: list[tuple[int, str, str]] = [] +@contextmanager +def isolated_appdata(): + """Keep GUI close-time writes and settings roundtrips out of user data.""" + with tempfile.TemporaryDirectory(prefix="cleanmarkdown-smoke-") as tmp: + old_appdata = os.environ.get("APPDATA") + os.environ["APPDATA"] = tmp + try: + yield + finally: + if old_appdata is None: + os.environ.pop("APPDATA", None) + else: + os.environ["APPDATA"] = old_appdata + + def check(num: int, name: str, fn) -> bool: try: fn() @@ -67,9 +83,12 @@ def c3(): def c4(): import main as m _app = m.QApplication.instance() or m.QApplication([]) - win = m.MainWindow() - title = win.windowTitle() - win.close() + with isolated_appdata(): + win = m.MainWindow() + try: + title = win.windowTitle() + finally: + win.close() assert title, f"windowTitle ist leer: {title!r}" @@ -80,22 +99,14 @@ def c4(): def c5(): import main as m - with tempfile.TemporaryDirectory() as tmp: - old_appdata = os.environ.get("APPDATA") - os.environ["APPDATA"] = tmp - try: - store = m.SettingsStore() - original = store.load() - store.save(original) - loaded = store.load() - assert loaded == original, f"Roundtrip-Fehler: {loaded!r} != {original!r}" - raw = store.path.read_text(encoding="utf-8") - assert "\\u" not in raw, "ensure_ascii hat Umlaute escaped" - finally: - if old_appdata is None: - os.environ.pop("APPDATA", None) - else: - os.environ["APPDATA"] = old_appdata + with isolated_appdata(): + store = m.SettingsStore() + original = store.load() + store.save(original) + loaded = store.load() + assert loaded == original, f"Roundtrip-Fehler: {loaded!r} != {original!r}" + raw = store.path.read_text(encoding="utf-8") + assert "\\u" not in raw, "ensure_ascii hat Umlaute escaped" check(5, "SettingsStore read/write Roundtrip mit Umlauten (tmp APPDATA)", c5) diff --git a/tests/test_assets_and_icons.py b/tests/test_assets_and_icons.py index b795a97..0443984 100644 --- a/tests/test_assets_and_icons.py +++ b/tests/test_assets_and_icons.py @@ -159,6 +159,29 @@ def test_store_assets() -> None: ) +def test_filetype_icon_variants_match_store_tile() -> None: + """Regression test for T-20260927-699609650: .md-Dateien zeigten eine + Kachel auf einer Platte. Das Dateityp-/Taskleisten-Symbol kommt aus den + targetsize-/unplated-Varianten des Square44x44Logo (über resources.pri); + sie müssen vollständig vorliegen und das Kachel-Design zeigen, und + store_package.json muss die Dateitypen deklarieren, damit der MSIX-Builder + die FileTypeAssociation erzeugt.""" + store_dir = PROJECT_ROOT / "store_assets" + tile = Image.open(store_dir / "Square310x310Logo.png").convert("RGBA") + tile_rgb = Image.alpha_composite(Image.new("RGBA", tile.size, "white"), tile).convert("RGB") + for size in (16, 24, 32, 48, 256): + for altform in ("", "_altform-unplated", "_altform-lightunplated"): + path = store_dir / f"Square44x44Logo.targetsize-{size}{altform}.png" + assert path.is_file(), f"{path.name} fehlt" + img = Image.open(path).convert("RGBA") + assert img.size == (size, size), f"{path.name} hat Größe {img.size}" + rgb = Image.alpha_composite(Image.new("RGBA", img.size, "white"), img).convert("RGB") + diff = _perceptual_diff(rgb, tile_rgb) + assert diff <= CROSS_FILE_THRESHOLD, f"{path.name} weicht um {diff:.2f} von der Kachel ab" + store_pkg = json.loads((PROJECT_ROOT / "store_package.json").read_text(encoding="utf-8")) + assert ".md" in store_pkg["file_types"]["extensions"] + + def test_app_icon_loader_returns_valid_icon() -> None: from PySide6.QtWidgets import QApplication from main import load_app_icon @@ -166,3 +189,150 @@ def test_app_icon_loader_returns_valid_icon() -> None: _app = QApplication.instance() or QApplication([]) icon = load_app_icon() assert not icon.isNull(), "load_app_icon() liefert ein leeres (null) QIcon zurück" + + +def _ico_frame_on_white(ico_path: Path, size: int) -> Image.Image | None: + """Same technique as .SOFTWARE/_STORE/icon_consistency_check.py, kept as + a small self-contained copy here (not imported) so this test runs in CI + without the OneDrive-only shared-tooling checkout being present.""" + img = Image.open(ico_path) + if size not in {s[0] for s in img.info.get("sizes", [])}: + return None + img = Image.open(ico_path) + try: + img.size = (size, size) + img.load() + except (ValueError, OSError): + return None + rgba = img.convert("RGBA") + bg = Image.new("RGBA", rgba.size, (255, 255, 255, 255)) + bg.alpha_composite(rgba) + return bg.convert("RGB") + + +def _perceptual_diff(a: Image.Image, b: Image.Image, size: int = 32) -> float: + a = a.resize((size, size), Image.LANCZOS) + b = b.resize((size, size), Image.LANCZOS) + a_bytes, b_bytes = a.tobytes(), b.tobytes() + return sum(abs(x - y) for x, y in zip(a_bytes, b_bytes)) / (len(a_bytes) * 255) + + +CROSS_FILE_THRESHOLD = 0.20 + +# Every .ico that load_app_icon() (main.py) can hand to setWindowIcon()/the +# EXE resource, in its own fallback order, plus the browser favicon -- and +# every PNG master/fallback that feeds one of those .ico files or is used +# directly as a fallback icon. Review of PR#5 (T-20260926-864299616) found +# that fixing only the first two .ico candidates left the PNG masters +# (icon.png/DesktopIcon.png/assets/icon.png, load_app_icon's PNG fallback) +# and the favicon un-rebranded -- still pointing at the pre-fix design. +ALL_ICO_FILES = ( + "assets/cleanmarkdown.ico", + "assets/icon.ico", + "CleanMarkdown.ico", + "icon.ico", + "DesktopIcon.ico", + "assets/favicon.ico", +) +ALL_MASTER_PNGS = ("icon.png", "DesktopIcon.png", "assets/icon.png", "assets/favicon.png") + +# Legacy store_assets/icon_*.png mirror of Square*/StoreLogo under an older +# MSIX naming convention -- found stale (diff 0.35) in a follow-up review, +# still T-20260926-864299616: PR#5 fixed the desktop-icon lineage but this +# pre-existing mirror was never checked. Square-shaped only; the wide +# icon_310x150.png/Wide310x150Logo.png pair is checked separately below via +# a center-square crop (see WIDE_PNG_THRESHOLD comment). +LEGACY_SQUARE_MIRROR_PNGS = ( + "store_assets/icon_44x44.png", + "store_assets/icon_50x50.png", + "store_assets/icon_150x150.png", + "store_assets/icon_310x310.png", +) + +# Comparing a WIDE asset against the square tile via _perceptual_diff (which +# resizes both to a square compare_size) distorts the wide one and +# false-positives even when correctly branded. The earlier fix compared wide +# assets only against each other (Wide310x150Logo.png as reference) -- a +# merge-reviewer review of PR#6 found that leaves two gaps: (C) both the +# wide logo and its mirror stale relative to the tile (they still agree with +# each other), (D) the wide logo missing (the mirror becomes its own +# reference). Fix: anchor every wide asset directly to the square tile via +# its own center-square crop (same aspect, no distortion, no indirection). +# Calibrated on the real files: the correct Wide310x150Logo.png's center +# crop differs from the tile by 0.24, the pre-fix mirror by 0.54. +WIDE_PNG_THRESHOLD = 0.35 +WIDE_PNGS = ("store_assets/icon_310x150.png", "store_assets/Wide310x150Logo.png") + + +def _center_square_crop(img: Image.Image) -> Image.Image: + w, h = img.size + side = min(w, h) + left, top = (w - side) // 2, (h - side) // 2 + return img.crop((left, top, left + side, top + side)) + + +def test_window_icon_matches_store_tile_branding() -> None: + """Regression test for T-20260926-864299616 (CleanMarkdown 1.0.3): the + Store tile/taskbar icon was correct after a rebrand, but the desktop + icons feeding the EXE resource and the runtime window titlebar icon + (via load_app_icon()) still carried an older, different design -- + caught here by comparing every frame of every icon file against the + Store tile directly, in the built-bundle sense that matters (the actual + pixels Windows draws), not just "a QIcon exists". + """ + store_tile = PROJECT_ROOT / "store_assets" / "Square310x310Logo.png" + assert store_tile.is_file(), "store_assets/Square310x310Logo.png fehlt" + tile_img = Image.open(store_tile).convert("RGBA") + bg = Image.new("RGBA", tile_img.size, (255, 255, 255, 255)) + bg.alpha_composite(tile_img) + tile_rgb = bg.convert("RGB") + + checked = 0 + for ico_name in ALL_ICO_FILES: + ico_path = PROJECT_ROOT / ico_name + assert ico_path.is_file(), f"{ico_name} fehlt" + img = Image.open(ico_path) + sizes = sorted({s[0] for s in img.info.get("sizes", [])}) + assert sizes, f"{ico_name}: keine Groessen im .ico-Verzeichnis gefunden" + for size in sizes: + frame = _ico_frame_on_white(ico_path, size) + if frame is None: + continue # directory/loader mismatch for this size -- not this test's concern + diff = _perceptual_diff(frame, tile_rgb) + checked += 1 + assert diff <= CROSS_FILE_THRESHOLD, ( + f"{ico_name} ({size}x{size}) weicht von {store_tile.name} um {diff:.2f} ab " + f"(Grenze {CROSS_FILE_THRESHOLD}) -- Desktop-Icon und Store-Kachel zeigen " + "unterschiedliche Designs. Signatur von T-20260926-864299616: eines wurde " + "neu gebrandet, das andere nicht. .ico aus derselben Quelle wie die " + "Store-Kachel neu erzeugen." + ) + assert checked > 0, "kein einziger .ico-Frame konnte geprueft werden" + + for png_name in ALL_MASTER_PNGS + LEGACY_SQUARE_MIRROR_PNGS: + png_path = PROJECT_ROOT / png_name + assert png_path.is_file(), f"{png_name} fehlt" + png_img = Image.open(png_path).convert("RGBA") + png_bg = Image.new("RGBA", png_img.size, (255, 255, 255, 255)) + png_bg.alpha_composite(png_img) + diff = _perceptual_diff(png_bg.convert("RGB"), tile_rgb) + assert diff <= CROSS_FILE_THRESHOLD, ( + f"{png_name} weicht von {store_tile.name} um {diff:.2f} ab " + f"(Grenze {CROSS_FILE_THRESHOLD}) -- Master-/Fallback-PNG zeigt ein anderes " + "Design als die Store-Kachel. Aus derselben Quelle neu erzeugen." + ) + + # Wide assets (Wide310x150Logo.png + its icon_310x150.png mirror), each + # anchored directly to the square tile via its own center-square crop -- + # never only against each other (see WIDE_PNG_THRESHOLD comment). + for wide_name in WIDE_PNGS: + wide_path = PROJECT_ROOT / wide_name + assert wide_path.is_file(), f"{wide_name} fehlt" + wide_img = Image.open(wide_path).convert("RGBA") + cropped_bg = Image.new("RGBA", (min(wide_img.size),) * 2, (255, 255, 255, 255)) + cropped_bg.alpha_composite(_center_square_crop(wide_img)) + diff = _perceptual_diff(cropped_bg.convert("RGB"), tile_rgb) + assert diff <= WIDE_PNG_THRESHOLD, ( + f"{wide_name}: Mittelquadrat weicht von {store_tile.name} um {diff:.2f} ab " + f"(Grenze {WIDE_PNG_THRESHOLD}) -- aus derselben Quelle wie die Store-Kachel neu erzeugen." + ) diff --git a/tests/test_atomic_document_save.py b/tests/test_atomic_document_save.py new file mode 100644 index 0000000..2796bdc --- /dev/null +++ b/tests/test_atomic_document_save.py @@ -0,0 +1,74 @@ +"""Failed saves must retain both the previous document and unsaved editor state.""" + +from pathlib import Path + +import pytest + + +def make_window(main_module, path): + application = main_module.QApplication.instance() or main_module.QApplication([]) + window = main_module.MainWindow() + window.current_file = path + window.editor.setPlainText("Neue Prüfung mit Bücherliste") + window.is_modified = True + return application, window + + +@pytest.mark.parametrize("stage", ["write", "commit"]) +def test_failed_save_preserves_existing_document(main_module, tmp_path, monkeypatch, stage): + path = tmp_path / "bericht.md" + original = b"previous document" + path.write_bytes(original) + application, window = make_window(main_module, path) + original_write = Path.write_text + + def failing_write(target, content, *args, **kwargs): + if target == path: + target.write_bytes(b"partial") + raise OSError("disk full") + return original_write(target, content, *args, **kwargs) + + from PySide6.QtCore import QSaveFile + + class FailingSave(QSaveFile): + def write(self, data): + if stage == "write": + super().write(data[:3]) + return -1 + return super().write(data) + + def commit(self): + if stage == "commit": + return False + return super().commit() + + monkeypatch.setattr(Path, "write_text", failing_write) + monkeypatch.setattr(main_module, "QSaveFile", FailingSave, raising=False) + errors = [] + monkeypatch.setattr(main_module.QMessageBox, "critical", lambda *args: errors.append(args)) + try: + assert window.save_file() is False + assert path.read_bytes() == original + assert window.is_modified is True + assert window.current_file == path + assert len(errors) == 1 + assert list(tmp_path.glob("bericht*")) == [path] + finally: + window.is_modified = False + window.close() + application.processEvents() + + +def test_successful_save_roundtrips_umlauts(main_module, tmp_path): + path = tmp_path / "bericht.md" + path.write_bytes(b"old document") + application, window = make_window(main_module, path) + try: + assert window.save_file() is True + assert path.read_text(encoding="utf-8") == "Neue Prüfung mit Bücherliste" + assert window.is_modified is False + assert list(tmp_path.glob("bericht*")) == [path] + finally: + window.is_modified = False + window.close() + application.processEvents() diff --git a/tests/test_atomic_pdf_export.py b/tests/test_atomic_pdf_export.py new file mode 100644 index 0000000..5cbd6d4 --- /dev/null +++ b/tests/test_atomic_pdf_export.py @@ -0,0 +1,94 @@ +"""A failed PDF export preserves the previous PDF and never reports success.""" +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +def make_window(main_module, tmp_path, monkeypatch, target): + app = main_module.QApplication.instance() or main_module.QApplication([]) + window = main_module.MainWindow() + window.current_file = tmp_path / "source.md" + window.editor.setPlainText("# Prüfung\n\nBücher und Übergabe.") + window.is_modified = True + window.settings.export_confirm = True + monkeypatch.setattr(main_module.QFileDialog, "getSaveFileName", lambda *args: (str(target), "")) + return app, window + + +@pytest.mark.parametrize("stage", ["partial", "empty", "missing", "truncated", "publish"]) +@pytest.mark.parametrize("existing", [False, True]) +def test_failed_pdf_export_preserves_target(main_module, tmp_path, monkeypatch, stage, existing): + target = tmp_path / "exports" / "report.pdf" + target.parent.mkdir() + original = b"previous PDF bytes" + if existing: + target.write_bytes(original) + app, window = make_window(main_module, tmp_path, monkeypatch, target) + errors = [] + monkeypatch.setattr(main_module.QMessageBox, "critical", lambda *args: errors.append(args)) + + class Printer: + def setOutputFormat(self, value): + pass + + def setOutputFileName(self, name): + self.path = Path(name) + + def setPageMargins(self, *args): + pass + + def print_document(printer): + if stage == "partial": + printer.path.write_bytes(b"partial PDF") + raise OSError("print failure") + if stage == "empty": + printer.path.write_bytes(b"") + if stage == "truncated": + printer.path.write_bytes(b"%PDF-1.4\nincomplete") + if stage == "publish": + printer.path.write_bytes(b"%PDF-1.4\n%%EOF\n") + + monkeypatch.setattr(window, "_create_pdf_printer", Printer) + monkeypatch.setattr(window, "_build_export_document", lambda: SimpleNamespace(print_=print_document)) + original_replace = main_module.os.replace + + def failing_publish(source, destination): + if Path(destination) == target: + raise OSError("destination locked") + return original_replace(source, destination) + + if stage == "publish": + monkeypatch.setattr(main_module.os, "replace", failing_publish) + try: + window.export_pdf() + assert len(errors) == 1 + assert window.statusBar().currentMessage() == window.t("cannot_export") + assert target.read_bytes() == original if existing else not target.exists() + assert set(target.parent.iterdir()) == ({target} if existing else set()) + assert window.is_modified + assert window.current_file == tmp_path / "source.md" + finally: + window.is_modified = False + window.close() + app.processEvents() + + +def test_real_pdf_export_replaces_previous_pdf(main_module, tmp_path, monkeypatch): + target = tmp_path / "exports" / "report.pdf" + target.parent.mkdir() + target.write_bytes(b"old") + app, window = make_window(main_module, tmp_path, monkeypatch, target) + try: + window.export_pdf() + data = target.read_bytes() + assert data.startswith(b"%PDF-") + assert data.rstrip().endswith(b"%%EOF") + assert len(data) > 100 + assert window.t("exported") in window.statusBar().currentMessage() + assert set(target.parent.iterdir()) == {target} + assert window.is_modified + finally: + window.is_modified = False + window.close() + app.processEvents() diff --git a/tests/test_atomic_session_export.py b/tests/test_atomic_session_export.py new file mode 100644 index 0000000..ccbeb14 --- /dev/null +++ b/tests/test_atomic_session_export.py @@ -0,0 +1,106 @@ +"""Session exports replace files only after the entire JSON was written.""" +import json +from pathlib import Path + +import pytest +from PySide6.QtCore import QSaveFile + + +def make_window(main_module, tmp_path, monkeypatch, target): + app = main_module.QApplication.instance() or main_module.QApplication([]) + window = main_module.MainWindow() + window.current_file = tmp_path / "source.md" + window.session_display_name = "source.md" + window.editor.setPlainText("# Bücher\n\nÄpfel, Öl und Übergabe.") + window.is_modified = True + window.statusBar().showMessage("previous status") + monkeypatch.setattr(main_module.QFileDialog, "getSaveFileName", lambda *args: (str(target), "")) + return app, window + + +@pytest.mark.parametrize("stage", ["open", "write", "commit"]) +@pytest.mark.parametrize("existing", [False, True]) +def test_failed_session_export_preserves_target_and_editor(main_module, tmp_path, monkeypatch, stage, existing): + target = tmp_path / "session.json" + original = b'{"old":"session"}\n' + if existing: + target.write_bytes(original) + app, window = make_window(main_module, tmp_path, monkeypatch, target) + original_write = Path.write_text + + def failing_write(path, content, *args, **kwargs): + if path == target: + path.write_bytes(b"partial") + raise OSError("disk full") + return original_write(path, content, *args, **kwargs) + + class FailingSave(QSaveFile): + def open(self, mode): + return False if stage == "open" else super().open(mode) + + def write(self, data): + return super().write(data[:3]) if stage == "write" else super().write(data) + + def commit(self): + return False if stage == "commit" else super().commit() + + errors = [] + monkeypatch.setattr(Path, "write_text", failing_write) + monkeypatch.setattr(main_module, "QSaveFile", FailingSave) + monkeypatch.setattr(main_module.QMessageBox, "critical", lambda *args: errors.append(args)) + try: + window.export_session() + if existing: + assert target.read_bytes() == original + else: + assert not target.exists() + assert list(tmp_path.glob("session*")) == ([target] if existing else []) + assert window.current_file == tmp_path / "source.md" + assert window.session_display_name == "source.md" + assert window.is_modified + assert window.editor.toPlainText() == "# Bücher\n\nÄpfel, Öl und Übergabe." + assert window.statusBar().currentMessage() == "previous status" + assert len(errors) == 1 + finally: + # The failure injector is scoped to the export. Restore the normal + # writer before closeEvent saves the isolated test settings. + monkeypatch.setattr(main_module, "QSaveFile", QSaveFile) + window.is_modified = False + window.close() + app.processEvents() + + +def test_session_export_roundtrips_text_and_settings(main_module, tmp_path, monkeypatch): + target = tmp_path / "session.json" + target.write_bytes(b"old") + app, window = make_window(main_module, tmp_path, monkeypatch, target) + try: + window.settings.output_dir = "Bücher/Übergabe" + window.export_session() + payload = json.loads(target.read_text(encoding="utf-8")) + assert payload["version"] == main_module.SESSION_VERSION + assert payload["markdown"] == window.editor.toPlainText() + assert payload["settings"]["outputDir"] == "Bücher/Übergabe" + assert window.is_modified + assert window.current_file == tmp_path / "source.md" + assert window.t("session_exported") in window.statusBar().currentMessage() + assert list(tmp_path.glob("session*")) == [target] + finally: + window.is_modified = False + window.close() + app.processEvents() + + +def test_cancel_session_export_creates_no_file(main_module, tmp_path, monkeypatch): + target = tmp_path / "session.json" + app, window = make_window(main_module, tmp_path, monkeypatch, target) + monkeypatch.setattr(main_module.QFileDialog, "getSaveFileName", lambda *args: ("", "")) + try: + window.export_session() + assert not target.exists() + assert window.is_modified + assert window.statusBar().currentMessage() == "previous status" + finally: + window.is_modified = False + window.close() + app.processEvents() diff --git a/tests/test_atomic_settings_save.py b/tests/test_atomic_settings_save.py new file mode 100644 index 0000000..3ad51b4 --- /dev/null +++ b/tests/test_atomic_settings_save.py @@ -0,0 +1,107 @@ +"""Failed settings writes preserve the previous profile and usable window.""" +from dataclasses import replace +from pathlib import Path + +import pytest +from PySide6.QtCore import QSaveFile +from PySide6.QtGui import QCloseEvent + + +@pytest.mark.parametrize("stage", ["write", "commit"]) +def test_failed_settings_save_preserves_profile(main_module, monkeypatch, stage): + store = main_module.SettingsStore() + original = main_module.AppSettings(language="en", output_dir="Bücher/Übergabe") + store.save(original) + original_bytes = store.path.read_bytes() + original_write = Path.write_text + + def failing_write(target, content, *args, **kwargs): + if target == store.path: + target.write_bytes(b"partial") + raise OSError("disk full") + return original_write(target, content, *args, **kwargs) + + class FailingSave(QSaveFile): + def write(self, data): + if stage == "write": + return super().write(data[:3]) + return super().write(data) + + def commit(self): + return False if stage == "commit" else super().commit() + + monkeypatch.setattr(Path, "write_text", failing_write) + monkeypatch.setattr(main_module, "QSaveFile", FailingSave) + with pytest.raises(OSError): + store.save(replace(original, language="de")) + assert store.path.read_bytes() == original_bytes + assert store.load() == original + assert list(store.path.parent.iterdir()) == [store.path] + + +def test_settings_save_retains_utf8(main_module): + store = main_module.SettingsStore() + expected = main_module.AppSettings(output_dir="Bücher/Übergabe") + store.save(expected) + assert store.load() == expected + assert "Bücher/Übergabe" in store.path.read_text(encoding="utf-8") + assert list(store.path.parent.iterdir()) == [store.path] + + +def test_close_settings_failure_keeps_dirty_window(main_module, monkeypatch): + app = main_module.QApplication.instance() or main_module.QApplication([]) + window = main_module.MainWindow() + window.editor.setPlainText("Ungespeicherte Prüfung") + window.is_modified = True + errors = [] + monkeypatch.setattr(main_module.QMessageBox, "critical", lambda *args: errors.append(args)) + + def failing_save(settings): + raise OSError("disk full") + + with monkeypatch.context() as patch: + patch.setattr(window.store, "save", failing_save) + event = QCloseEvent() + try: + window.closeEvent(event) + assert not event.isAccepted() + assert window.is_modified + assert window.editor.toPlainText() == "Ungespeicherte Prüfung" + assert len(errors) == 1 + finally: + window.is_modified = False + window.close() + app.processEvents() + + +def test_settings_dialog_failure_keeps_current_settings(main_module, monkeypatch): + app = main_module.QApplication.instance() or main_module.QApplication([]) + window = main_module.MainWindow() + original = replace(window.settings) + errors = [] + monkeypatch.setattr(main_module.QMessageBox, "critical", lambda *args: errors.append(args)) + + class AcceptedDialog: + def __init__(self, *args): + pass + + def exec(self): + return main_module.QDialog.DialogCode.Accepted + + def values(self): + return replace(original, language="en", theme="bright") + + def failing_save(settings): + raise OSError("disk full") + + try: + with monkeypatch.context() as patch: + patch.setattr(main_module, "SettingsDialog", AcceptedDialog) + patch.setattr(window.store, "save", failing_save) + window.open_settings() + assert window.settings == original + assert len(errors) == 1 + finally: + window.is_modified = False + window.close() + app.processEvents() diff --git a/tests/test_export_autosave_collisions.py b/tests/test_export_autosave_collisions.py new file mode 100644 index 0000000..bfaa975 --- /dev/null +++ b/tests/test_export_autosave_collisions.py @@ -0,0 +1,102 @@ +"""Automatic export copies must never overwrite another document.""" +from datetime import datetime +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +@pytest.fixture +def autosave(main_module, tmp_path, monkeypatch): + class FixedTime(datetime): + @classmethod + def now(cls, tz=None): + return cls(2026, 9, 30, 12, 0, 0) + + monkeypatch.setattr(main_module, "datetime", FixedTime) + monkeypatch.setattr(main_module, "_documents_dir", lambda: tmp_path) + document = SimpleNamespace(session_display_name="Prüfung.md", editor=SimpleNamespace(toPlainText=lambda: "Bücher")) + return document, lambda: main_module.MainWindow._auto_save_for_export(document) + + +def test_same_second_autosaves_keep_both_documents(autosave): + document, save = autosave + first = save() + assert first is not None + original = first.read_bytes() + document.editor.toPlainText = lambda: "Neue Übergabe" + second = save() + assert second is not None and second != first + assert first.read_bytes() == original + assert second.read_text(encoding="utf-8") == "Neue Übergabe" + + +def test_existing_directory_name_is_skipped(autosave, tmp_path): + _, save = autosave + directory = tmp_path / "Prüfung_autosave_20260930_120000.md" + directory.mkdir() + marker = directory / "keep.txt" + marker.write_bytes(b"keep") + result = save() + assert result is not None and result != directory + assert marker.read_bytes() == b"keep" + assert result.read_text(encoding="utf-8") == "Bücher" + + +def test_concurrent_creator_is_not_overwritten(autosave, tmp_path, monkeypatch): + _, save = autosave + target = tmp_path / "Prüfung_autosave_20260930_120000.md" + original_open = Path.open + + def racing_open(path, mode="r", *args, **kwargs): + if path == target and mode == "xb" and not path.exists(): + with original_open(path, "wb") as stream: + stream.write(b"other process") + return original_open(path, mode, *args, **kwargs) + + monkeypatch.setattr(Path, "open", racing_open) + result = save() + assert target.read_bytes() == b"other process" + assert result is not None and result != target + assert result.read_text(encoding="utf-8") == "Bücher" + + +@pytest.mark.parametrize("short_write", [False, True]) +def test_failed_autosave_removes_only_its_partial_file(autosave, tmp_path, monkeypatch, short_write): + _, save = autosave + existing = tmp_path / "keep.md" + existing.write_bytes(b"original") + original_open = Path.open + original_write = Path.write_text + + class PartialWrite: + def __init__(self, stream): + self.stream = stream + + def __enter__(self): + return self + + def __exit__(self, *args): + self.stream.close() + + def write(self, data): + self.stream.write(data[:3]) + if short_write: + return 3 + raise OSError("disk full") + + def failing_open(path, mode="r", *args, **kwargs): + stream = original_open(path, mode, *args, **kwargs) + return PartialWrite(stream) if mode == "xb" else stream + + def failing_legacy_write(path, text, *args, **kwargs): + if "autosave" in path.name: + path.write_bytes(b"partial") + raise OSError("disk full") + return original_write(path, text, *args, **kwargs) + + monkeypatch.setattr(Path, "open", failing_open) + monkeypatch.setattr(Path, "write_text", failing_legacy_write) + assert save() is None + assert list(tmp_path.iterdir()) == [existing] + assert existing.read_bytes() == b"original" diff --git a/tests/test_file_encoding.py b/tests/test_file_encoding.py new file mode 100644 index 0000000..e50485b --- /dev/null +++ b/tests/test_file_encoding.py @@ -0,0 +1,62 @@ +"""Opening malformed UTF-8 must preserve the active document and source bytes.""" +import pytest + + +@pytest.mark.parametrize("raw", [b"# Caf\xe9", b"# broken \xff", b"# truncated \xe2\x82"]) +def test_invalid_utf8_preserves_unsaved_document(main_module, tmp_path, monkeypatch, raw): + app = main_module.QApplication.instance() or main_module.QApplication([]) + window = main_module.MainWindow() + old_path = tmp_path / "active.md" + old_path.write_text("# Original", encoding="utf-8") + candidate = tmp_path / "invalid.md" + candidate.write_bytes(raw) + errors = [] + monkeypatch.setattr(main_module.QMessageBox, "critical", lambda *args: errors.append(args)) + try: + window.load_file(old_path) + window.editor.setPlainText("# Ungespeicherte Änderung") + window._render_preview() + window.tabs.setCurrentIndex(1) + before = (window.current_file, window._session_asset_dir, + window.session_display_name, window.editor.toPlainText(), + window.viewer.toPlainText(), window.is_modified, + window.tabs.currentIndex(), window.windowTitle()) + assert window.is_modified + window.load_file(candidate) + assert len(errors) == 1 + after = (window.current_file, window._session_asset_dir, + window.session_display_name, window.editor.toPlainText(), + window.viewer.toPlainText(), window.is_modified, + window.tabs.currentIndex(), window.windowTitle()) + assert after == before + assert candidate.read_bytes() == raw + # A subsequent ordinary save must still target the original document. + assert window.save_file() + assert old_path.read_text(encoding="utf-8") == "# Ungespeicherte Änderung" + assert candidate.read_bytes() == raw + finally: + window.is_modified = False + window.close() + app.processEvents() + + +@pytest.mark.parametrize("text", ["# Bücher, Grüße und Öl", "# Literal replacement character: \ufffd"]) +def test_valid_utf8_still_opens_and_saves(main_module, tmp_path, monkeypatch, text): + app = main_module.QApplication.instance() or main_module.QApplication([]) + window = main_module.MainWindow() + path = tmp_path / "valid.md" + path.write_text(text, encoding="utf-8") + errors = [] + monkeypatch.setattr(main_module.QMessageBox, "critical", lambda *args: errors.append(args)) + try: + window.load_file(path) + assert not errors + assert window.current_file == path + assert window.editor.toPlainText() == text + window.editor.appendPlainText("Weiter") + assert window.save_file() + assert path.read_text(encoding="utf-8") == text + "\nWeiter" + finally: + window.is_modified = False + window.close() + app.processEvents() diff --git a/tests/test_image_block_layout.py b/tests/test_image_block_layout.py index 5724777..c3cf779 100644 --- a/tests/test_image_block_layout.py +++ b/tests/test_image_block_layout.py @@ -90,6 +90,33 @@ def _image_fragment_format(block): # Block-im-Dokumentfluss: kein Ueberlappen von Vorher-/Bild-/Nachher-Block # --------------------------------------------------------------------------- +@pytest.mark.parametrize("export", [False, True]) +def test_raw_html_banner_precedes_heading_and_text(main_module, tmp_path, export): + window = _make_window(main_module) + try: + _save_test_image(tmp_path / "banner.png", 1200, 300, "teal") + text = 'Banner\n\n# Überschrift\n\nText unter dem Banner.' + document, layout, blocks = _render_and_lay_out(window, main_module, tmp_path, text) + if export: + document = window._build_export_document() + document.setTextWidth(700) + layout = document.documentLayout() + blocks = [] + block = document.begin() + while block.isValid(): + blocks.append(block) + block = block.next() + image_block = next(b for b in blocks if _is_image_block(b)) + assert image_block.text().strip() == IMAGE_OBJECT_REPLACEMENT_CHAR + image_rect = _block_rect(layout, image_block) + assert image_rect.height() > 100 + for block in blocks: + if "Überschrift" in block.text() or "Text unter" in block.text(): + assert _block_rect(layout, block).top() >= image_rect.bottom() - 0.5 + finally: + _safe_close(window) + + def test_image_between_paragraphs_forms_own_non_overlapping_block(main_module, tmp_path): """Repro aus dem Ticket: Absatz davor. / Bild / Absatz danach., mit Leerzeilen. Kein Block darf den naechsten ueberlagern.""" diff --git a/tests/test_markdown_rendering.py b/tests/test_markdown_rendering.py index 1e349f0..31e9a74 100644 --- a/tests/test_markdown_rendering.py +++ b/tests/test_markdown_rendering.py @@ -442,6 +442,23 @@ def test_strip_markdown_formatting_is_idempotent(main_module): ) +@pytest.mark.parametrize("body", [ + '

Text im Satz.

', + '
\n\n
', + '\n\n', +]) +def test_raw_image_wrapping_preserves_inline_and_literal_code(render_helpers, body): + assert render_helpers._render_figures_and_captions(body) == body + + +def test_raw_linked_banner_preserves_target_and_has_one_block(render_helpers): + body = '\n

Heading

' + rendered = render_helpers._render_figures_and_captions(body) + assert rendered.count('

') == 1 + assert '' in rendered + assert 'width="100%"' not in rendered + + def test_render_figures_and_captions_wraps_images_in_figure_and_anchor(render_helpers): html_in = '

System Diagram

' rendered = render_helpers._render_figures_and_captions(html_in) @@ -523,4 +540,3 @@ def test_pipeline_linked_image_renders_figure_with_custom_link(render_helpers): assert '' in body assert '
Architektur-Diagramm
' in body assert body.count("
Apps > Standard-Apps > ".md" -> CleanMarkdown +# (Store) manuell als Standard waehlen -- der Rueckbau entfernt nur die +# ALTE Zuordnung, er setzt keine neue. +# 5. Ergebnis pruefen (.md-Icon im Explorer/Desktop). +# +# Nutzung: +# .\uninstall_local.ps1 # fuehrt den Rueckbau aus (fragt vorher nach) +# .\uninstall_local.ps1 -WhatIf # zeigt nur, was getan wuerde, KEINE Aenderung +# .\uninstall_local.ps1 -Force # ohne Rueckfrage (fuer Automatisierung) +# +# Sicherheitsnetz: Vor jeder Aenderung werden die betroffenen Registry-Zweige +# per `reg export` in einen eindeutigen Ordner je Lauf gesichert; schlaegt +# auch nur ein Export fehl, bricht das Skript VOR jeder Loeschung ab. + +param( + [switch]$WhatIf, + [switch]$Force +) + +$ErrorActionPreference = "Stop" + +$progId = "CleanMarkdown.mdfile" +$installDir = Join-Path $env:LOCALAPPDATA "Programs\CleanMarkdown" +$exeTarget = Join-Path $installDir "CleanMarkdown.exe" +$startMenuDir = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs" +$shortcutPath = Join-Path $startMenuDir "CleanMarkdown.lnk" +$classesRoot = "HKCU:\Software\Classes" +$fileExtsMd = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.md" +$userChoiceKey = "$fileExtsMd\UserChoice" + +# --- 1. Voraussetzung: Store-Paket MIT .md-Handler muss installiert sein --- +# Ein installiertes Paket allein genuegt nicht (astra-Fund D3): die auf +# diesem Host reale Version 1.0.3 ist installiert, ihr Manifest deklariert +# aber NULL FileTypeAssociation-Knoten fuer .md -- ein Rueckbau haette den +# User ganz ohne Handler zurueckgelassen. Deshalb zusaetzlich das Manifest +# des gefundenen Pakets pruefen, nicht nur seine blosse Existenz. +$storePkg = Get-AppxPackage -Name "Geiger.CleanMarkdown" -ErrorAction SilentlyContinue +if (-not $storePkg) { + Write-Host "ABBRUCH: Kein Store-Paket 'Geiger.CleanMarkdown' installiert." -ForegroundColor Red + Write-Host "Ohne Store-Paket wuerde dieser Rueckbau den User ganz ohne .md-Handler zuruecklassen." + Write-Host "Zuerst CleanMarkdown aus dem Microsoft Store installieren/aktualisieren, dann erneut ausfuehren." + exit 1 +} + +$manifestPath = Join-Path $storePkg.InstallLocation "AppxManifest.xml" +$hasMdHandler = $false +if (Test-Path $manifestPath) { + [xml]$manifestXml = Get-Content $manifestPath -Raw + $ns = New-Object System.Xml.XmlNamespaceManager($manifestXml.NameTable) + $ns.AddNamespace("uap", "http://schemas.microsoft.com/appx/manifest/uap/windows10") + $fileTypeNodes = $manifestXml.SelectNodes("//uap:FileTypeAssociation/uap:SupportedFileTypes/uap:FileType", $ns) + foreach ($node in $fileTypeNodes) { + if ($node.InnerText.Trim().ToLower() -eq ".md") { $hasMdHandler = $true; break } + } +} +if (-not $hasMdHandler) { + Write-Host "ABBRUCH: Store-Paket $($storePkg.PackageFullName) ist installiert, deklariert aber KEINE .md-FileTypeAssociation." -ForegroundColor Red + Write-Host "Ein Rueckbau jetzt wuerde den User ganz ohne .md-Handler zuruecklassen." + Write-Host "Zuerst auf eine Store-Version mit .md-Dateizuordnung aktualisieren, dann erneut ausfuehren." + exit 1 +} +Write-Host "Store-Paket mit .md-Handler gefunden: $($storePkg.PackageFullName)" -ForegroundColor Green + +# --- 2. Was wird entfernt? (reine Vorschau, KEINE Aenderung, KEIN Backup) --- +$actions = @() + +if (Test-Path "$classesRoot\$progId") { + $actions += "ProgID-Zweig entfernen: $classesRoot\$progId (inkl. DefaultIcon, shell\open\command)" +} +if (Test-Path "$classesRoot\Applications\CleanMarkdown.exe") { + $actions += "Applications-Zweig entfernen: $classesRoot\Applications\CleanMarkdown.exe" +} +$mdDefault = $null +if (Test-Path "$classesRoot\.md") { + $mdDefault = (Get-Item "$classesRoot\.md").GetValue("") +} +if ($mdDefault -eq $progId) { + $actions += "Default-Wert von $classesRoot\.md entfernen (zeigte auf $progId)" +} elseif ($mdDefault) { + $actions += "UEBERSPRUNGEN: $classesRoot\.md zeigt bereits auf '$mdDefault' (nicht mehr $progId) -- unveraendert gelassen" +} +if (Test-Path "$classesRoot\.md\OpenWithProgids") { + $prop = Get-ItemProperty -Path "$classesRoot\.md\OpenWithProgids" -Name $progId -ErrorAction SilentlyContinue + if ($prop) { $actions += "OpenWithProgids-Eintrag '$progId' unter $classesRoot\.md\OpenWithProgids entfernen" } +} +$userChoiceProgId = $null +if (Test-Path $userChoiceKey) { + $uc = Get-ItemProperty -Path $userChoiceKey -Name "ProgId" -ErrorAction SilentlyContinue + if ($uc) { $userChoiceProgId = $uc.ProgId } +} +if ($userChoiceProgId -eq $progId) { + # Nur den UserChoice-Unterschluessel selbst, NICHT den Elternzweig + # FileExts\.md (astra-Fund D1): der enthaelt auf realen Hosts zusaetzlich + # OpenWithList/OpenWithProgids/UserChoiceLatest, die install_local.ps1 + # nicht angelegt hat und die diesem Skript nicht gehoeren. + $actions += "UserChoice-Unterschluessel entfernen: $userChoiceKey (Explorer erinnerte sich an $progId als gewaehlten Handler; Geschwisterzweige unter FileExts\.md bleiben unangetastet)" +} elseif ($userChoiceProgId) { + $actions += "UEBERSPRUNGEN: UserChoice unter $fileExtsMd zeigt bereits auf '$userChoiceProgId' -- unveraendert gelassen" +} +if (Test-Path $shortcutPath) { + $actions += "Startmenü-Verknuepfung entfernen: $shortcutPath" +} +if (Test-Path $installDir) { + $actions += "Lokale Installation entfernen: $installDir" +} + +if ($actions.Count -eq 0) { + Write-Host "Nichts zu tun -- keine Spuren der lokalen Installation gefunden." -ForegroundColor Yellow + exit 0 +} + +Write-Host "`nFolgende Aenderungen werden vorgenommen:" +$actions | ForEach-Object { Write-Host " - $_" } + +if ($WhatIf) { + Write-Host "`n-WhatIf: keine Aenderung vorgenommen, kein Backup angelegt." -ForegroundColor Yellow + exit 0 +} + +if (-not $Force) { + $confirm = Read-Host "`nFortfahren? (j/N)" + if ($confirm -notin @("j", "J", "y", "Y")) { + Write-Host "Abgebrochen." -ForegroundColor Yellow + exit 0 + } +} + +# --- 3. Registry-Sicherung -- eindeutiger Ordner je Lauf, JEDER Export ------ +# wird geprueft; schlaegt auch nur einer fehl, bricht das Skript VOR +# jeder Loeschung ab (astra-Fund D2: ein gemockter Lauf mit 4 fehl- +# geschlagenen Exporten fuehrte vorher trotzdem alle Loeschungen aus). +$runId = Get-Date -Format "yyyyMMdd-HHmmss" +$backupDir = Join-Path $env:TEMP "CleanMarkdown-migration-backup\$runId" +New-Item -ItemType Directory -Force -Path $backupDir | Out-Null + +$keysToBackup = @( + "HKCU\Software\Classes\$progId", + "HKCU\Software\Classes\.md", + "HKCU\Software\Classes\Applications\CleanMarkdown.exe", + "HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.md" +) +$backupFailed = $false +foreach ($key in $keysToBackup) { + $psPath = "Registry::$key" + if (-not (Test-Path $psPath)) { continue } + $exportName = ($key -replace '[\\]', '_') + ".reg" + $exportPath = Join-Path $backupDir $exportName + & reg export $key $exportPath /y 2>&1 | Out-Null + $exportOk = ($LASTEXITCODE -eq 0) -and (Test-Path $exportPath) -and ((Get-Item $exportPath).Length -gt 0) + if (-not $exportOk) { + Write-Host " FEHLER: Backup fehlgeschlagen fuer $key (Exit $LASTEXITCODE, Datei $exportPath)" -ForegroundColor Red + $backupFailed = $true + } +} +if ($backupFailed) { + Write-Host "`nABBRUCH: Mindestens eine Registry-Sicherung ist fehlgeschlagen -- KEINE Aenderung vorgenommen." -ForegroundColor Red + Write-Host "Teilweise angelegte Sicherungen liegen (zur Fehlersuche) in: $backupDir" + exit 1 +} +Write-Host "Registry-Sicherung vollstaendig: $backupDir" -ForegroundColor Cyan + +# --- 4. Rueckbau durchfuehren ------------------------------------------------ +$incomplete = $false + +if (Test-Path "$classesRoot\$progId") { + Remove-Item -Path "$classesRoot\$progId" -Recurse -Force +} +if (Test-Path "$classesRoot\Applications\CleanMarkdown.exe") { + Remove-Item -Path "$classesRoot\Applications\CleanMarkdown.exe" -Recurse -Force +} +if ($mdDefault -eq $progId) { + # Remove-ItemProperty -Name "(Default)"/"(default)" does NOT work against + # the registry provider (fails silently under -ErrorAction + # SilentlyContinue, the value is left unchanged) -- verified in both + # PowerShell 7.6.6 and Windows PowerShell 5.1 (D4, Fable-Abnahme + # T-20260927-699609650). Left as-is, .md would keep pointing at the + # ProgID just deleted above -- a dangling reference, worse than the + # previous Set-Item -Value "" (which at least emptied it). Delete the + # default value directly via the .NET registry API instead. + $mdKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey("Software\Classes\.md", $true) + if ($mdKey) { + try { $mdKey.DeleteValue("", $false) } finally { $mdKey.Close() } + } +} +if (Test-Path "$classesRoot\.md\OpenWithProgids") { + Remove-ItemProperty -Path "$classesRoot\.md\OpenWithProgids" -Name $progId -ErrorAction SilentlyContinue +} +if ($userChoiceProgId -eq $progId) { + try { + # Nur den Unterschluessel, siehe Begruendung in der Vorschau oben. + Remove-Item -Path $userChoiceKey -Recurse -Force + } catch { + $incomplete = $true + Write-Host " HINWEIS: UserChoice-Unterschluessel ($userChoiceKey) konnte nicht geloescht werden (Windows schuetzt diesen Schluessel oft per ACL gegen programmatisches Loeschen): $($_.Exception.Message)" -ForegroundColor Yellow + Write-Host " Der User muss die Zuordnung manuell neu setzen: Einstellungen > Apps > Standard-Apps > '.md' waehlen." + } +} +if (Test-Path $shortcutPath) { + Remove-Item -Path $shortcutPath -Force +} +if (Test-Path $installDir) { + Remove-Item -Path $installDir -Recurse -Force +} + +# --- 5. Shell-Icon-Cache aktualisieren -------------------------------------- +$signature = @' +using System; +using System.Runtime.InteropServices; +public static class ShellNotify { + [DllImport("shell32.dll")] + public static extern void SHChangeNotify(uint wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2); +} +'@ +Add-Type -TypeDefinition $signature | Out-Null +[ShellNotify]::SHChangeNotify(0x08000000, 0x0000, [IntPtr]::Zero, [IntPtr]::Zero) + +if ($incomplete) { + Write-Host "`nRueckbau ABGESCHLOSSEN MIT EINSCHRAENKUNG -- siehe Hinweis oben (manueller Folgeschritt noetig)." -ForegroundColor Yellow +} else { + Write-Host "`nRueckbau abgeschlossen." -ForegroundColor Green +} +Write-Host "Registry-Sicherung liegt in: $backupDir" +Write-Host "Als NAECHSTES: Einstellungen > Apps > Standard-Apps > '.md' -> CleanMarkdown (Store) waehlen --" +Write-Host "dieses Skript entfernt nur die alte Zuordnung, es setzt keine neue." +Write-Host "Falls .md danach kein korrektes Icon zeigt: einmal abmelden/anmelden oder Explorer neu starten" +Write-Host "(taskkill /f /im explorer.exe && start explorer.exe), damit der Icon-Cache neu aufbaut." diff --git a/web_companion/app.js b/web_companion/app.js index 2f36445..caa66ad 100644 --- a/web_companion/app.js +++ b/web_companion/app.js @@ -7,7 +7,7 @@ (function () { 'use strict'; - const APP_VERSION = '1.0.2'; + const APP_VERSION = '1.0.5'; const SESSION_VERSION = 'cleanmarkdown-session-v1'; // Localization Dictionary (DE, EN, ES)