Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
## [Unreleased]

### Fixed
- **Plattformstabiler Screenshot-Fonttest:** Die CI setzt nicht mehr voraus,
dass Qt unter `offscreen` auf jedem Linux-Runner zwangsläufig Tofu rendert.
Deterministische Tests sichern jetzt getrennt die explizite Offscreen-Sperre
und den negativen Glyphen-Probe-Befund auf einer nativen Plattform ab.
- **Settings persistence during maintenance self-tests:**
- `python main.py --self-test` now runs with a temporary `APPDATA` directory, so its deliberate language, theme, scroll-sync, and export-path changes never read or overwrite the user's real `%APPDATA%\CleanMarkdown\settings.json`.
- Added regression coverage for all 13 settings across a save/restart/load roundtrip and for byte- and timestamp-stable user settings while the full self-test runs.
- Corrected `SUPPORT.md` and `PRIVACY_POLICY.md`: the desktop app stores settings in JSON, not QSettings/the Windows Registry.
- **Bilder im Lesemodus wirkten wie Hintergrundelemente statt echter Bloecke
im Dokumentfluss (T-20260728-01):** Qt's `QTextDocument`-HTML-Engine
behandelt `<figure>` nicht als Block-Container und ignoriert
Expand Down
4 changes: 2 additions & 2 deletions PRIVACY_POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ CleanMarkdown ist eine vollständig lokale Desktop-Anwendung. Es werden **keine

- **Keine eigenen Netzwerkverbindungen:** CleanMarkdown stellt selbst keine Internetverbindungen her. Es gibt keine Telemetrie, kein Tracking, keine Cloud-Synchronisation und keinen automatischen Update-Mechanismus. Wenn ein Markdown-Dokument anklickbare Links enthält, öffnet ein Klick darauf den Standard-Browser des Systems — die App selbst überträgt dabei keine Daten.
- **Lokale Dateien:** Alle Markdown-Dateien (.md, .markdown, .txt) werden ausschließlich lokal auf dem Gerät des Nutzers gelesen und geschrieben.
- **Einstellungen:** Benutzereinstellungen (Theme, Sprache, Scroll-Sync) werden lokal über QSettings gespeichert.
- **Einstellungen:** Benutzereinstellungen (Theme, Sprache, Scroll-Sync) werden lokal in `%APPDATA%\CleanMarkdown\settings.json` gespeichert.
- **PDF-Export:** Der PDF-Export erfolgt lokal über QPrinter. Es wird kein externer Dienst genutzt.
- **Session-Export:** Das optionale Session-Format (`cleanmarkdown-session-v1.json`) speichert ausschließlich lokale Metadaten (Dateipfad, Cursor-Position, Scroll-Position) auf dem Gerät des Nutzers.

Expand All @@ -40,7 +40,7 @@ CleanMarkdown is a fully local desktop application. **No data is collected, stor

- **No outgoing network connections:** CleanMarkdown itself does not establish any internet connections. There is no telemetry, no tracking, no cloud synchronization and no automatic update mechanism. When a Markdown document contains clickable links, clicking one opens the system's default browser — the app itself does not transmit any data.
- **Local files:** All Markdown files (.md, .markdown, .txt) are read and written exclusively on the user's local device.
- **Settings:** User preferences (theme, language, scroll sync) are stored locally via QSettings.
- **Settings:** User preferences (theme, language, scroll sync) are stored locally in `%APPDATA%\CleanMarkdown\settings.json`.
- **PDF export:** PDF export is handled locally via QPrinter. No external service is used.
- **Session export:** The optional session format (`cleanmarkdown-session-v1.json`) stores only local metadata (file path, cursor position, scroll position) on the user's device.

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
[![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: 105 passed](https://img.shields.io/badge/Tests-105%20passed-brightgreen.svg)](tests)
[![Tests: 122 passed](https://img.shields.io/badge/Tests-122%20passed-brightgreen.svg)](tests)
[![Version: 1.0.0](https://img.shields.io/badge/Version-1.0.0-teal.svg)](CHANGELOG.md)
[![LLM-Ready: llms.txt](https://img.shields.io/badge/LLM--Ready-llms.txt-orange.svg)](llms.txt)

Expand Down Expand Up @@ -254,7 +254,7 @@ python -m pytest -q
python main.py --self-test
```

The test suite validates rendering accuracy, figure link preservation, math processing, session serialization, headless print isolation, and automated metadata contract synchronization (105 passed tests).
The test suite validates rendering accuracy, figure link preservation, math processing, session serialization, settings persistence, headless print isolation, and automated metadata contract synchronization (122 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.

Expand Down
4 changes: 2 additions & 2 deletions README_DE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ 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: 105 passed](https://img.shields.io/badge/Tests-105%20passed-brightgreen.svg)](tests)
[![Tests: 122 passed](https://img.shields.io/badge/Tests-122%20passed-brightgreen.svg)](tests)
[![Version: 1.0.0](https://img.shields.io/badge/Version-1.0.0-teal.svg)](CHANGELOG.md)
[![LLM-Ready: llms.txt](https://img.shields.io/badge/LLM--Ready-llms.txt-orange.svg)](llms.txt)

Expand Down Expand Up @@ -254,7 +254,7 @@ python -m pytest -q
python main.py --self-test
```

Die Testsuite validiert Rendering-Genauigkeit, Hyperlink-Erhalt bei Figuren, Mathe-Verarbeitung, Session-Serialisierung, Headless-Druckisolation und automatisierte Metadaten-Vertragssynchronisation (105 bestandene Tests).
Die Testsuite validiert Rendering-Genauigkeit, Hyperlink-Erhalt bei Figuren, Mathe-Verarbeitung, Session-Serialisierung, Einstellungspersistenz, Headless-Druckisolation und automatisierte Metadaten-Vertragssynchronisation (122 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.

Expand Down
4 changes: 2 additions & 2 deletions SUPPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
### Häufige Fragen (FAQ)

**Wo werden meine Einstellungen gespeichert?**
Einstellungen werden lokal über QSettings gespeichert. Unter Windows liegt die Konfiguration in der Registry unter `HKCU\Software\CleanMarkdown`.
Einstellungen werden lokal als JSON-Datei gespeichert. Unter Windows liegt die Konfiguration unter `%APPDATA%\CleanMarkdown\settings.json`.

**Welche Dateiformate werden unterstützt?**
`.md`, `.markdown` und `.txt` können geöffnet, bearbeitet und gespeichert werden.
Expand Down Expand Up @@ -57,7 +57,7 @@ Die optionale Scroll-Sync gleicht die Scroll-Position zwischen Lese- und Editor-
### Frequently Asked Questions (FAQ)

**Where are my settings stored?**
Settings are stored locally via QSettings. On Windows, the configuration is in the registry under `HKCU\Software\CleanMarkdown`.
Settings are stored locally as a JSON file. On Windows, the configuration is located at `%APPDATA%\CleanMarkdown\settings.json`.

**Which file formats are supported?**
`.md`, `.markdown` and `.txt` files can be opened, edited and saved.
Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,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 (105 tests: rendering, session, file/export edge cases, image preview, metadata contracts).
- `tests/` — pytest suite (122 tests: rendering, settings persistence, session, file/export edge cases, image preview, metadata contracts).
- `EXPORTFORMAT.md` — session and bundle format contract.

## Audience
Expand Down
17 changes: 14 additions & 3 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1889,11 +1889,11 @@ def run_smoke_test() -> int:


def run_self_test() -> int:
app = QApplication(sys.argv)
configure_application(app)
app: QApplication | None = None
results: list[tuple[str, bool]] = []
original_open = QFileDialog.getOpenFileName
original_save = QFileDialog.getSaveFileName
original_appdata = os.environ.get("APPDATA")
sample_markdown = r"""# Titel

- [ ] Offen
Expand Down Expand Up @@ -1941,7 +1941,13 @@ def fake_save(*args, **kwargs):

QFileDialog.getOpenFileName = fake_open
QFileDialog.getSaveFileName = fake_save
# MainWindow persists settings during closeEvent. Keep the self-test
# completely outside the user's real configuration so maintenance
# runs cannot replace preferences with temporary test values.
os.environ["APPDATA"] = str(tmp_path / "appdata")
try:
app = QApplication(sys.argv)
configure_application(app)
window = MainWindow()
window.show()
app.processEvents()
Expand Down Expand Up @@ -2117,11 +2123,16 @@ def fake_save(*args, **kwargs):
finally:
QFileDialog.getOpenFileName = original_open
QFileDialog.getSaveFileName = original_save
if app is not None:
app.quit()
if original_appdata is None:
os.environ.pop("APPDATA", None)
else:
os.environ["APPDATA"] = original_appdata

failed = [name for name, ok in results if not ok]
for name, ok in results:
print(f"{name}: {'ok' if ok else 'failed'}")
app.quit()
return 0 if not failed else 1


Expand Down
73 changes: 73 additions & 0 deletions tests/test_file_handling.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from __future__ import annotations

import json
import os
from pathlib import Path
import subprocess
import sys

from PySide6.QtCore import QUrl

Expand Down Expand Up @@ -63,6 +66,76 @@ def test_settings_store_ignores_unknown_json_fields(main_module, tmp_path, monke
assert settings.window_height == 777


def test_settings_persist_across_new_store_and_window_instances(main_module, tmp_path, monkeypatch):
monkeypatch.setenv("APPDATA", str(tmp_path / "appdata"))
expected = main_module.AppSettings(
language="en",
theme="bright",
default_mode="editor",
autosave_enabled=False,
autosave_interval=47,
export_mode="dedicated",
export_confirm=False,
output_dir="C:/Persist/Übergabe",
file_toolbar_visible=True,
editor_toolbar_collapsed=True,
sync_scroll_positions=False,
window_width=1111,
window_height=777,
)

main_module.SettingsStore().save(expected)

assert main_module.SettingsStore().load() == expected
_, restarted_window = _make_window(main_module)
assert restarted_window.settings == expected
restarted_window.is_modified = False
restarted_window.close()


def test_self_test_preserves_existing_user_settings_bytes_and_mtime(main_module, tmp_path, monkeypatch):
appdata = tmp_path / "appdata"
monkeypatch.setenv("APPDATA", str(appdata))
expected = main_module.AppSettings(
language="en",
theme="bright",
default_mode="editor",
autosave_enabled=False,
autosave_interval=47,
export_mode="dedicated",
export_confirm=False,
output_dir="C:/Persist/Übergabe",
file_toolbar_visible=True,
editor_toolbar_collapsed=True,
sync_scroll_positions=False,
window_width=1111,
window_height=777,
)
store = main_module.SettingsStore()
store.save(expected)
before_bytes = store.path.read_bytes()
before_mtime_ns = store.path.stat().st_mtime_ns

env = os.environ.copy()
env["APPDATA"] = str(appdata)
env["QT_QPA_PLATFORM"] = "offscreen"
env["PYTHONPATH"] = os.pathsep.join(path for path in sys.path if path)
result = subprocess.run(
[sys.executable, "-X", "utf8", str(Path(main_module.__file__)), "--self-test"],
cwd=Path(main_module.__file__).parent,
env=env,
capture_output=True,
text=True,
timeout=60,
check=False,
)

assert result.returncode == 0, result.stdout + result.stderr
assert store.path.read_bytes() == before_bytes
assert store.path.stat().st_mtime_ns == before_mtime_ns
assert main_module.SettingsStore().load() == expected


def test_main_window_sanitizes_corrupt_settings_types(main_module, tmp_path, monkeypatch):
monkeypatch.setenv("APPDATA", str(tmp_path / "appdata"))
store = main_module.SettingsStore()
Expand Down
36 changes: 27 additions & 9 deletions tests/test_store_materials.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ def test_privacy_policy_mentions_local():
def test_support_md_exists():
path = PROJECT_ROOT / "SUPPORT.md"
assert path.exists(), "SUPPORT.md fehlt"
content = path.read_text(encoding="utf-8")
assert "%APPDATA%\\CleanMarkdown\\settings.json" in content
assert "QSettings" not in content


def test_privacy_policy_names_actual_settings_store():
content = (PROJECT_ROOT / "PRIVACY_POLICY.md").read_text(encoding="utf-8")
assert "%APPDATA%\\CleanMarkdown\\settings.json" in content
assert "QSettings" not in content


def test_windows_store_prep_exists():
Expand Down Expand Up @@ -98,25 +107,35 @@ def test_generator_source_uses_native_platform_not_offscreen():


@pytest.mark.skipif(not _HAS_QT, reason="PySide6 nicht verfuegbar")
def test_font_probe_flags_tofu_under_offscreen():
"""Unter offscreen (conftest-Default) muss die Font-Probe Tofu melden."""
def test_generator_guard_rejects_offscreen_even_when_fonts_render(monkeypatch):
"""Offscreen bleibt gesperrt, auch wenn ein Runner echte Glyphen rendert."""
import generate_store_screenshots as gen

app = QApplication.instance() or QApplication([])
assert QApplication.platformName() == "offscreen", (
"Test erwartet die offscreen-Plattform aus conftest.py"
)
assert gen.font_rendering_works(app) is False
monkeypatch.setattr(gen, "font_rendering_works", lambda _app: True)

with pytest.raises(RuntimeError, match="offscreen"):
gen._assert_font_rendering(app)


@pytest.mark.skipif(not _HAS_QT, reason="PySide6 nicht verfuegbar")
def test_generator_guard_raises_on_tofu():
"""Abnahmekriterium: Der Generator wirft, statt still Tofu zu speichern."""
def test_generator_guard_raises_when_native_font_probe_fails(monkeypatch):
"""Auf nativer Plattform blockiert ein negativer Glyphen-Probe-Befund."""
import generate_store_screenshots as gen

app = QApplication.instance() or QApplication([])
with pytest.raises(RuntimeError):
gen._assert_font_rendering(app)
class NativeApplication:
@staticmethod
def platformName():
return "xcb"

monkeypatch.setattr(gen, "QApplication", NativeApplication)
monkeypatch.setattr(gen, "font_rendering_works", lambda _app: False)

with pytest.raises(RuntimeError, match="Tofu-Verdacht"):
gen._assert_font_rendering(object())


def test_build_exe_bat_guarded_preflight():
Expand All @@ -126,4 +145,3 @@ def test_build_exe_bat_guarded_preflight():
content = bat_file.read_text(encoding="utf-8")
assert "if exist \"%SCANNER%\"" in content, "Scanner-Guard fehlt in build_exe.bat"
assert "SOFTWARE_ROOT" in content, "SOFTWARE_ROOT-Variable fehlt in build_exe.bat"

Loading