(.*?)
', 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 = '
\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') == 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 = ' 