Compare commits

...
39 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 b5113cefa2 chore(release): bump version to 0.3.4
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m14s
Formerkennung: Rechtecke statt Kreise, Freiformen bleiben unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:21:00 +02:00
haraldandClaude Opus 5.5 88ea7d2d53 fix(geometry): Freiformen bleiben unverändert, Vielecke werden erkannt
- Eine erkannte Form ersetzt den Strich nur noch, wenn sie ihn gut beschreibt
  (mittlere Abweichung ≤ 4 % der Strichdiagonale, gemessen gegen den
  Linienzug der Form). Vorher wurde fast jeder geschlossene Strich umgewandelt
  (Herz → Kreis, Stern/Spirale → Oktaeder, Wolke → Dreieck).
- Vielecke verlangen echte Ecken: Die Richtungsänderung muss sich an den Ecken
  konzentrieren; glatte Ovale werden so nicht mehr zu Sechs-/Achtecken.
- Vielecke konkurrieren mit Kreis, Dreieck, Rechteck und Raute (gewichtet:
  Rechteck vor Raute/Vieleck, Vieleck vor Kreis); überzählige Punkte der
  Vereinfachung (Strichende vor dem Start, Punkte auf einer Geraden) entfallen.
- Tests: Freiformen bleiben unverändert, Fünf-/Sechs-/Achtecke, Ovale;
  jeder Einzeldetektor-Test prüft zusätzlich die vollständige Erkennung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:19:57 +02:00
haraldandClaude Opus 5.5 7345c00568 fix(geometry): Rechtecke und Quadrate werden nicht mehr als Kreis erkannt
- Kreis, Dreieck, Rechteck und Raute konkurrieren; gewählt wird die Form,
  deren Kontur am besten zu den Punkten passt (RMS-Abweichung). Vorher gewann
  der Kreis immer, weil auch Quadrate nur ~10 % Radiusschwankung haben, und
  die Dreieckserkennung griff gestreckte Rechtecke ab.
- Rechteckerkennung über das kleinste umschließende Rechteck (alle Drehwinkel,
  Ausreißer-robust), Kanten auf den Median der Punkte; verlangt gezeichnete
  Ecken und enge Passung, damit Ovale und Kritzeleien keine Rechtecke werden.
  Die alte Variante schnitt bei kleinen/zittrigen Strichen die Ecken ab.
- extract_k_corners wählte die geradesten statt der markantesten Punkte
  (Sortierung verkehrt herum); betrifft auch Dreieck und Raute.
- Rechteck wird der Raute vorgezogen, wenn es fast so gut passt.
- Oktaeder nur noch, wenn keine geschlossene Form gut passt.
- Tests mit nachgebildeten Freihandstrichen: Rechtecke in vielen Größen,
  Seitenverhältnissen und Drehungen, mit runden Ecken, schiefen Kanten und
  Überstrich; Kreise, Dreiecke; Ellipsen und Freiformen nie als Rechteck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:05:37 +02:00
haraldandClaude Opus 5.5 f76fad92be chore(release): bump version to 0.3.3
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m10s
Behebt Datenverlust nach Neustart auf einer späteren Seite (v0.2.3 – v0.3.2); Seitenauswahl mit Vorschaubildern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:32:58 +02:00
haraldandClaude Opus 5.5 2c60a03460 feat(ui): Vorschaubilder in der Seitenauswahl
- Kacheln zeigen eine verkleinerte A4-Vorschau jeder Seite (Striche als
  Linien, Textmarker halbtransparent, volle Seiten auf ~1500 Punkte
  ausgedünnt), Seitenzahl darunter, aktuelle Seite blau umrandet
- Noch nicht geladene Seiten werden bei offener Seitenauswahl nachgeladen
  (UiAction::LoadAllPages)
- Kachelgröße über PAGE_TILE (vorher feste Werte)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:03:08 +02:00
haraldandClaude Opus 5.5 b6580f197d fix(storage): Inhalt der zuletzt geöffneten Seite nach Neustart nicht überschreiben
Beim Start wurde nur die erste Seite geladen. War zuletzt eine spätere Seite
offen, war sie nach dem Neustart ein leerer, ungeladener Platzhalter: Das
Blatt erschien leer, und Zeichnen/Radieren/Hintergrund wechseln speicherte
den Platzhalter und überschrieb den bisherigen Inhalt der Seite.

- load_notebook lädt zusätzlich die aktuelle Seite
- Autosave schreibt nie ungeladene Platzhalter (Schutz ging beim Merge des
  Storage-Branches verloren)
- Regressionstests für beide Fälle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:03:08 +02:00
haraldandClaude Opus 5.5 22e35f2e77 chore(release): bump version to 0.3.2
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m33s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:40:44 +02:00
haraldandClaude Opus 5.5 a77e6cde90 feat(ui): einheitliche, deckende Dialoge und fingertaugliche Popover
- Dialoge (Notizbücher, Updates, Warnfenster) mit gemeinsamem deckendem
  Rahmen, ohne doppelte Titelleiste, zentriert neben der Werkzeugleiste;
  Schließen-Knopf 36 px, größere Knöpfe, Eingabefelder und Schieberegler
- Seitenauswahl als Popover neben dem Seitenknopf mit Kachelraster
  (56 × 48 px) und „Neue Seite anhängen“
- Immer nur ein Dialog oder Popover gleichzeitig offen (close_overlays);
  Seitenauswahl schließt bei Tippen daneben
- Stift-/Marker-Popover: Farbtupfer 30 px, Stärke-Vorschau 40 px hoch,
  breiterer Schieberegler, deckender Rahmen
- Auswahl-Überlagerung und Griff-Trefferprüfung rechnen mit der
  Display-Skalierung (lag bei Skalierung ≠ 1 neben den Strichen);
  Trefferradius der Griffe 18 Punkte für Finger

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:51:48 +02:00
haraldandClaude Opus 5.5 40eb9a666f chore(release): bump version to 0.3.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 10m58s
Behebt: In v0.3.0 ließ sich auf dem Blatt nicht zeichnen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:16:54 +02:00
haraldandClaude Opus 5.5 85d1ba1647 fix(ui): Zeichnen auf dem Blatt wieder möglich; Farben und Zeichen korrigiert
Regression aus v0.3.0: Die UI-Trefferprüfung (egui layer_id_at) wertete eine
bildschirmfüllende egui-Ebene der Ordnung Background als UI. Dadurch galt jeder
Punkt auf dem Blatt als Oberfläche, und Maus, Stift und Touch zeichneten nicht.
Background-Ebenen zählen jetzt nicht mehr als UI; die Prüfung liegt in ui.rs
und ist durch einen Test abgedeckt (Blattpunkte, Leiste, Skalierung 1 und 2).

Außerdem:
- 25 ungültige vormultiplizierte Farben (RGB > Alpha) auf unmultipliziert
  umgestellt: Knöpfe in Dialogen waren weiße Kästen mit unlesbarer Schrift,
  die Auswahlfüllung war deckend statt durchscheinend
- Zeichen ohne Glyphe in den egui-Schriften ersetzt (✓ ✕ ● ↑ ↓, Variantenzeichen
  U+FE0F hinter ⚠)
- Tests finden künftig ungültige Farben und nicht darstellbare Zeichen in
  ui.rs, selection.rs und main.rs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:12:56 +02:00
haraldandClaude Opus 5.5 b03faedd27 chore(release): bump version to 0.3.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 11m42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:26:27 +02:00
haraldandClaude Opus 5.5 f17e50ec32 feat(ui): senkrechte Werkzeugleiste und Stift-/Touch-Bedienung der UI
Werkzeugleiste
- Schmale senkrechte Leiste statt ~30 Elemente in einer Zeile; seitlich oben
  angedockt (links, im ☰-Menü auf rechts umschaltbar, gespeichert in
  ~/.config/ink-notes/ui.json)
- Symbol-Knöpfe 40 px, auf niedrigen Fenstern schrittweise bis 28 px; Größe
  richtet sich nach dem höchsten Fall, damit Knöpfe beim Werkzeugwechsel
  nicht springen (fester Platz für die Farbpalette)
- Selten genutztes (PDF, Hintergrund, Zoom, Finger-Zeichnen, Leistenseite,
  Updates, Speicherstatus) im ☰-Menü; Popover öffnen zur Blattseite hin
- Blatt wird im freien Bereich neben der Leiste zentriert
- Symbole ohne Glyphe in den egui-Schriften (🖍 ⌫ ⬚ ◄ ► ● ✕ ▾) ersetzt bzw.
  selbst gezeichnet; Hover-Farbe korrigiert (falsch vormultipliziert)

Eingabe
- UI-Trefferprüfung in logischen Punkten (vorher physische Pixel gegen
  egui-Koordinaten: bei Skalierung ≠ 1 trafen Stift und Touch die Leiste nicht)
  und über egui::Context::layer_id_at für alle egui-Bereiche
- Stift-Ereignisse über egui_input_mut() statt ctx.input_mut() (wurden vom
  nächsten Frame verworfen); Stiftbewegungen für Hover/Ziehen weitergereicht,
  Stift bleibt vom Aufsetzen bis Abheben bei der UI
- Berührungen, die auf der UI beginnen, bleiben bis zum Abheben bei egui
- egui-Neuzeichenwünsche (repaint_delay) werden beachtet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:16:51 +02:00
haraldandClaude Opus 5.5 5cd45831e2 chore(release): bump version to 0.2.3
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 10m30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:04:54 +02:00
haraldandClaude Opus 5.5 71f6a70829 fix(ci): Release-Signatur mit rsign2 statt minisign-CLI
Der Signaturschlüssel wurde mit `rsign generate -W` erzeugt und ist mit
leerem Passwort verschlüsselt; das minisign-CLI aus Ubuntu (0.11) verlangt
dafür ein Terminal und brach in der CI mit "Error while loading the secret
key file" ab.

- Workflow installiert rsign2 0.6.7 (ohne -D warnings) statt minisign
- build-release.sh signiert und verifiziert mit rsign; da rsign bei falscher
  Key-ID mit Exit-Code 0 endet, wird zusätzlich die Erfolgsmeldung geprüft
- README: Signierwerkzeug und Fallstrick dokumentiert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:04:54 +02:00
haraldandClaude Opus 5.5 b751b71223 chore(release): bump version to 0.2.2
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 9m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:44:51 +02:00
haraldandClaude Opus 5.5 afb057692d fix(ci): rustfmt und clippy in der Release-Pipeline installieren
setup-rust-toolchain installiert mit --profile minimal; ohne explizite
components fehlen rustfmt und clippy, wodurch die Quality Gates bei jedem
Release-Lauf sofort abbrachen (cargo fmt --check nach 2 Sekunden).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:42:58 +02:00
haraldandClaude Opus 5.5 eeae143dc6 chore(release): bump version to 0.2.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 1m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:35:10 +02:00
haraldandClaude Opus 5.5 992c3daad7 fix(ci): Formatierung, Root-fester I/O-Test und robuste Schlüsselsuche
- updater.rs: rustfmt-konforme Formatierung des öffentlichen Schlüssels
  (cargo fmt --check ließ die Release-Pipeline scheitern)
- storage.rs: test_io_error_* simuliert den I/O-Fehler über ein Verzeichnis
  statt chmod 000, damit er auch als root (CI-Runner) greift
- build-release.sh: findet den öffentlichen Schlüssel auch, wenn rustfmt ihn
  in die Folgezeile umbricht

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:30:52 +02:00
haraldandClaude Opus 5.5 9684238a53 chore(release): bump version to 0.2.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 2m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:20:29 +02:00
haraldandClaude Opus 5.5 e8674e730f feat(updater): Minisign-Schlüssel für Release-Signaturen hinterlegen
Öffentlicher Schlüssel (Key-ID 3CFE290C190DA9D9). Der geheime Schlüssel wird
in der CI als Secret MINISIGN_SECRET_KEY erwartet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:10:57 +02:00
haraldandClaude Opus 5.5 7e99d59042 Merge fix/storage-datensicherheit in main
Konfliktlösung: Speicher-Architektur des Branches (Formatversionierung,
Papierkorb, Backups, Instance-Lock, robuster Autosave) übernommen und die
dort fehlenden Korrekturen aus main darauf übertragen:

- Autosave gleicht das Manifest bei jeder Änderung mit dem Notizbuch ab
  (mark_dirty); per Taste N/Bild-ab angelegte Seiten gingen sonst nach
  einem Neustart verloren
- Start öffnet keine beschädigten Notizbücher automatisch und verwendet den
  Ordner eines nicht ladbaren Notizbuchs nicht wieder
- PDF-Export lädt nicht geöffnete Seiten nach (load_all_pages), verweigert
  unvollständige Exporte und meldet das echte Ergebnis an die UI
- Cargo.toml: minisign-verify und fs2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:07:22 +02:00
haraldandClaude Opus 5.5 7ed971d2a2 feat(updater): Pflicht-Signaturprüfung der Releases mit Minisign
- SHA256SUMS.txt muss mit dem eingebauten Minisign-Schlüssel
  (UPDATE_SIGNING_PUBLIC_KEY) signiert sein; fehlende oder ungültige
  Signaturen und Versionen ohne Schlüssel lehnen Updates ab
- Prüfsummen und Signatur werden vor dem Paket geladen und geprüft
- build-release.sh signiert mit MINISIGN_SECRET_KEY und prüft, dass der
  Schlüssel zum eingebauten öffentlichen Schlüssel passt; in der CI ist die
  Signatur Pflicht
- publish-gitea-release.sh lädt SHA256SUMS.txt.minisig hoch
- README: Einrichtung des Schlüsselpaars dokumentiert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:49:25 +02:00
haraldandClaude Opus 5.5 5ce2c250bb fix(updater): HTTPS für alle Anfragen erzwingen
Bisher wurde nur die Server-URL geprüft. Download-Links aus der Release-API
und Weiterleitungsziele durften auch http:// sein, womit Paket und
SHA256SUMS.txt unverschlüsselt geladen werden konnten. Jetzt wird jede
Anfrage einzeln geprüft (allow_insecure_http bleibt als Ausnahme).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:45:16 +02:00
haraldandClaude Opus 5.5 d61e1819ed fix(storage): Datenverlust bei Ladefehlern, neuen Seiten und Löschen verhindern
- Beschädigte Seiten werden als page_<id>.json.corrupt-<zeit> gesichert statt
  leer überschrieben; bei I/O-Fehlern wird die Ersatzseite als load_failed
  markiert und nie gespeichert
- Autosave gleicht das Manifest (page_ids, Zähler) mit dem Notizbuch ab, damit
  neu angelegte Seiten nach einem Neustart nicht verloren gehen
- Start: nicht ladbares Notizbuch bleibt unverändert, stattdessen wird ein
  neues angelegt
- Aktuelles Notizbuch löschen: ausstehende Schreibvorgänge vorher abarbeiten,
  damit der Ordner nicht wieder angelegt wird
- PDF-Export lädt nicht geöffnete Seiten nach und verweigert unvollständige
  Exporte; Ergebnis wird per Kanal an die UI gemeldet
- Strg+S speichert alle geladenen Seiten und meldet Schreibfehler; flush()
  liefert Fehler zurück statt immer "Gespeichert"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:43:16 +02:00
harald 12755d8eea test(storage): Crash-Simulation und Stresstest gegen unvollständige Schreibvorgänge 2026-10-06 19:21:59 +02:00
harald 510035e807 perf(storage): kompaktes JSON, Stroke-Treue und Snapshot-Optimierung 2026-10-06 19:14:52 +02:00
harald 1afe4a0f9d feat(storage): Dateisystem-Härtung, Trash, Rolling-Backup und Instance-Lock 2026-10-06 18:55:08 +02:00
harald 04666bf389 feat(autosave): robuster Autosave mit Max-Wait, Retries, PageStore-Trait und Shutdown-Sicherheit 2026-10-06 17:52:09 +02:00
harald a82402bfce feat(storage): Formatversionierung, Migrations-Backups und Fixture-Tests 2026-10-06 17:16:39 +02:00
harald 06b3aeb311 fix(storage): kein stilles Überschreiben bei Ladefehlern und Quarantäne defekter Seiten 2026-10-06 16:58:23 +02:00
harald f9b39c6fe3 chore(release): bump version to 0.1.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 1m22s
2026-10-06 15:59:01 +02:00
harald f9ebcb0296 fix(ci): remove all duplicate assets before re-uploading in publish-gitea-release.sh
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 1m19s
2026-10-06 15:43:11 +02:00
harald df72981a9a feat(input): Wayland-Tablet-Protokoll für aktive Stifte (Lazarite) und Touchscreen-Pinch-to-Zoom
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 2m0s
2026-10-06 14:58:59 +02:00
harald 5cb77b9af7 feat(ui): Sichere Shortcuts, dediziertes Ctrl+S Speichern und kollisionsfreier PDF-Export 2026-10-06 12:56:39 +02:00
harald bb0faf424b chore(meta): Lizenzdatei, Cargo-Metadaten und bereinigte Dokumentation 2026-10-06 12:40:52 +02:00
harald 3999996e98 fix(packaging): Debian-Paket-Abhängigkeiten, AppStream-Metadaten und reproduzierbares AppImage-Build 2026-10-06 12:36:10 +02:00
harald 3eb21dadc4 fix(scripts): Idempotentes Release-Publishing und robuste Template-Payloads 2026-10-06 12:26:37 +02:00
harald 5780c266b7 ci(gitea): Strikte Quality-Gates und sichere Token-Übergabe in Release-Workflow 2026-10-06 12:24:33 +02:00
harald 91e2d78b4f fix(updater): Sichere In-App Updates mit ureq, SHA256-Integrität und atomarem AppImage-Tausch 2026-10-06 12:23:14 +02:00
39 changed files with 9661 additions and 2221 deletions

No files matched your search

+33 -6
View File
@@ -9,15 +9,25 @@ on:
jobs:
release:
name: Build Packages & Publish to Gitea
# Hinweis zum Gitea act_runner:
# Standardmäßig 'ubuntu-latest'. Falls Ihr Runner unter einem anderen Label registriert ist
# (z. B. 'linux', 'ubuntu-24.04' oder 'host'), passen Sie dieses Label an.
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
uses: actions/checkout@v4.2.2
- name: Install System Packaging Tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
export DEBIAN_FRONTEND=noninteractive
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
fi
$SUDO apt-get update -y
$SUDO apt-get install -y --no-install-recommends \
build-essential \
pkg-config \
libx11-dev \
@@ -31,20 +41,37 @@ jobs:
python3
- name: Setup Rust Toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
with:
toolchain: stable
# Die Action installiert mit --profile minimal; ohne diese Komponenten
# schlagen cargo fmt und cargo clippy in den Quality Gates sofort fehl
components: rustfmt, clippy
cache: true
- name: Build Release Artifacts (.deb, .AppImage, SHA256SUMS.txt)
- name: Install rsign2 (Release-Signatur)
# RUSTFLAGS leeren: die Toolchain-Action setzt -D warnings, das soll nicht für rsign2 gelten
run: RUSTFLAGS="" cargo install rsign2 --locked --version 0.6.7
- name: Code Quality & Testing (Quality Gates)
run: |
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
- name: Build Release Artifacts (.deb, .AppImage, SHA256SUMS.txt + .minisig)
run: |
chmod +x scripts/*.sh
./scripts/build-release.sh
env:
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
INK_NOTES_REQUIRE_SIGNATURE: "1"
- name: Publish Release to Gitea
run: |
./scripts/publish-gitea-release.sh "${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}"
./scripts/publish-gitea-release.sh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_SERVER: ${{ github.server_url }}
REPO_OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
Generated
+355 -8
View File
@@ -118,6 +118,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bit-set"
version = "0.5.3"
@@ -151,6 +157,15 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block-sys"
version = "0.2.1"
@@ -269,6 +284,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "codespan-reporting"
version = "0.11.1"
@@ -375,6 +396,15 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -390,6 +420,16 @@ version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "cursor-icon"
version = "1.2.0"
@@ -403,7 +443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e3d747f100290a1ca24b752186f61f6637e1deffe3bf6320de6fcb29510a307"
dependencies = [
"bitflags 2.13.2",
"libloading 0.7.4",
"libloading 0.8.9",
"winapi",
]
@@ -413,6 +453,16 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -529,6 +579,18 @@ dependencies = [
"simdutf8",
]
[[package]]
name = "enum_dispatch"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
dependencies = [
"once_cell",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "epaint"
version = "0.27.2"
@@ -620,6 +682,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "futures-core"
version = "0.3.34"
@@ -644,6 +716,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
@@ -654,6 +736,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -747,7 +840,7 @@ dependencies = [
"presser",
"thiserror",
"winapi",
"windows",
"windows 0.52.0",
]
[[package]]
@@ -801,6 +894,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.3"
@@ -949,18 +1048,25 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.1.0"
version = "0.3.4"
dependencies = [
"bytemuck",
"egui",
"egui-wgpu",
"egui-winit",
"fs2",
"glam",
"minisign-verify",
"octotablet",
"pollster",
"printpdf",
"semver",
"serde",
"serde_json",
"sha2",
"tempfile",
"ureq",
"url",
"webbrowser",
"wgpu",
"winit",
@@ -1199,6 +1305,12 @@ dependencies = [
"paste",
]
[[package]]
name = "minisign-verify"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
[[package]]
name = "miniz_oxide"
version = "0.9.1"
@@ -1349,6 +1461,25 @@ dependencies = [
"cc",
]
[[package]]
name = "octotablet"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c4b9470450ffa669cb3be55ef863dfd9592a06284cb291546363c514bb4aa3"
dependencies = [
"bitflags 2.13.2",
"cfg_aliases 0.2.2",
"enum_dispatch",
"raw-window-handle 0.6.2",
"smallvec",
"strum",
"thiserror",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"windows 0.54.0",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -1609,6 +1740,20 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -1647,6 +1792,41 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -1687,6 +1867,12 @@ dependencies = [
"tiny-skia",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
@@ -1730,6 +1916,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
@@ -1830,6 +2027,34 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.119",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "1.0.109"
@@ -2041,6 +2266,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.26"
@@ -2065,6 +2296,30 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -2099,6 +2354,12 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
@@ -2310,6 +2571,24 @@ dependencies = [
"web-sys",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
@@ -2324,7 +2603,7 @@ checksum = "cbd7311dbd2abcfebaabf1841a2824ed7c8be443a0f29166e5d3c6a53a762c01"
dependencies = [
"arrayvec",
"cfg-if",
"cfg_aliases",
"cfg_aliases 0.1.1",
"js-sys",
"log",
"naga",
@@ -2350,7 +2629,7 @@ dependencies = [
"arrayvec",
"bit-vec",
"bitflags 2.13.2",
"cfg_aliases",
"cfg_aliases 0.1.1",
"codespan-reporting",
"indexmap",
"log",
@@ -2379,7 +2658,7 @@ dependencies = [
"bit-set",
"bitflags 2.13.2",
"block",
"cfg_aliases",
"cfg_aliases 0.1.1",
"core-graphics-types",
"d3d12",
"glow",
@@ -2466,7 +2745,19 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
dependencies = [
"windows-core",
"windows-core 0.52.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
dependencies = [
"windows-core 0.54.0",
"windows-implement",
"windows-interface",
"windows-targets 0.52.6",
]
@@ -2479,12 +2770,53 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
dependencies = [
"windows-result",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-implement"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "942ac266be9249c84ca862f0a164a39533dc2f6f33dc98ec89c8da99b82ea0bd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-interface"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da33557140a288fae4e1d5f8873aaf9eb6613a9cf82c3e070223ff177f598b60"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
@@ -2503,6 +2835,15 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -2711,7 +3052,7 @@ dependencies = [
"bitflags 2.13.2",
"bytemuck",
"calloop",
"cfg_aliases",
"cfg_aliases 0.1.1",
"core-foundation",
"core-graphics",
"cursor-icon",
@@ -2895,6 +3236,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.5"
+14 -1
View File
@@ -1,7 +1,12 @@
[package]
name = "ink-notes"
version = "0.1.0"
version = "0.3.4"
edition = "2021"
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
license = "MIT"
readme = "README.md"
rust-version = "1.75"
[dependencies]
# Fenster- & Event-Handling (Wayland/X11, Stylus)
@@ -29,6 +34,13 @@ egui = "0.27"
egui-wgpu = "0.27"
egui-winit = { version = "0.27", default-features = false, features = ["links"] }
webbrowser = "0.8"
semver = "1.0"
sha2 = "0.10"
ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] }
url = "2.5"
octotablet = "0.1.0"
minisign-verify = "0.3.0"
fs2 = "0.4"
[profile.dev]
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
@@ -37,6 +49,7 @@ opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
lto = true
codegen-units = 1
opt-level = 3
strip = true
[dev-dependencies]
tempfile = "3.27.0"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Harald Pansi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+113 -45
View File
@@ -10,9 +10,10 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
### ⚡ Latenzoptimierte Inking-Pipeline
- **Hardware-Rendering:** Direct-to-Screen-Rendering mit `wgpu` (Vulkan / X11 / Wayland) unter Nutzung von `PresentMode::Mailbox` für minimale Eingabeverzögerung.
- **1€-Filter (One-Euro-Filter):** Dynamischer Tiefpassfilter zur Glättung von Zittern bei langsamen Bewegungen ohne spürbare Latenz bei schnellen Schwüngen.
- **Zwei-Stufen-Modell:**
1. *Live-Phase:* Eingehende Stylus-Samples werden in Echtzeit durch einen 1€-Filter (One-Euro-Filter) entprellt und inkrementell als GPU-Meshes dargestellt.
2. *Commit-Phase (Post-Stroke Finalisierung):* Akausale Ecken-Erkennung (Corner Preservation bei Knicken > 65°), gewichtetes 3-Punkt-Druckfenster, Ramer-Douglas-Peucker (RDP) Dezimierung und saubere runde Endkappen.
- **Centripetal Catmull-Rom Splines:** Weiche C1-stetige Kurveninterpolation ohne Überschwinger oder Schleifen.
- **Drucksensitive Dreiecksstreifen-Tessellierung:** Rechnerische Generierung von Triangle-Meshes mit winkelhalbierenden Normalenvektoren entlang des Pfads.
- **Werkzeuge:**
- **Stift (Pen):** Feine, variable Strichstärken (0.3 mm bis 1.2 mm) mit Notiz-Farben (Schwarz, Blau, Rot, Grün, Grau).
- **Textmarker (Highlighter):** Breite, halbtransparente Striche (4.0 mm bis 8.0 mm) mit Multiplikations-Blending.
@@ -29,7 +30,7 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
- **Geraden (Line):** Sehnen- zu Pfadlängenverhältnis > 0.92.
- **Rechtecke (Rectangle):** 4 orthogonale Ecken via Douglas-Peucker-Polygonzug (RDP).
- **Kreise & Ellipsen (Circle/Ellipse):** Geschlossene Pfade mit minimaler Varianz via Least-Squares Circle Fit.
- **Koordinatensysteme (Axes):** Zwei orthogonale Achsen (80°–100° gesnappt auf exakt 90°) mit optionalen Richtungspfeilen.
- **Koordinatensysteme (Axes):** Zwei orthogonale Achsen (80°–100° gesnappt auf exakt 90°) mit Richtungspfeilen.
- **Dreiecke, Rhomben / Rauten & Oktaeder:** Regelmäßige Polygone mit Eckpunkt-Analyse.
- **Interaktionsmodelle:**
- **Draw-and-Hold:** Verweilt der Stift am Ende eines Strichs für > 400 ms regungslos (< 3 px), snappt die Skizze sofort automatisch in eine perfekte Vektorform ein.
@@ -46,93 +47,158 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
### 📚 Multi-Dokumenten- & Notizbuch-Verwaltung
- **Dateisystem-Architektur:** Jedes Dokument wird in einem separaten Verzeichnis unter `~/.local/share/ink-notes/notebooks/<id>/` mit `manifest.json` und Einzelseiten (`page_<id>.json`) gespeichert.
- **Dokumenten-Manager:** Dunkles Glassmorphism-Modal direkt in der Toolbar:
- Neues Notizbuch anlegen.
- Zwischen Notizbüchern mit einem Klick wechseln.
- Dokumente direkt umbenennen (`✏`).
- Dokumente löschen (`🗑`).
- **Dokumenten-Manager:** Toolbar-Modal zum Anlegen, Wechseln, Umbenennen und Löschen von Notizbüchern.
- **Verlustfreier Wechsel:** Beim Dokumentenwechsel sichert ein synchroner Disk-Flush alle anstehenden Änderungen garantiert ab.
### 📄 Seiten & Hintergründe
- **Virtuelles DIN-A4-Raster:** 2100 × 2970 Koordinateneinheiten (10 Einheiten = 1 mm).
- **Hintergrundmuster:**
- Blanko
- Kariert (5 mm Raster)
- Liniert (7.5 mm Zeilenabstand)
- Punktraster (5 mm Dot-Grid)
- **Hintergrundmuster:** Blanko, Kariert (5 mm), Liniert (7.5 mm), Punktraster (5 mm).
- **Seitenverwaltung:** Vor- und Zurückblättern, neue Seiten anfügen, Seitensprung-Menü.
### 💾 Autosave & Persistenz
- **Asynchroner Autosave-Controller:** Sichert Änderungen im Hintergrund-Thread nach 1.5 s Ruhezeit (Debounce), ohne das Zeichnen zu blockieren.
- **Asynchroner Autosave-Controller:** Sichert Änderungen im Hintergrund nach 1.5 s Ruhezeit (Debounce), ohne das Zeichnen zu blockieren.
- **Atomares Schreiben:** Temp-Dateien mit atomarem Rename (`manifest.json.tmp` -> `manifest.json`) schützen vor Datenkorruption bei Abstürzen.
- **Sicherer Shutdown:** `ESC` oder Schließen des Fensters führt vor Programmende einen vollständigen synchronen Flush aus.
- **Dediziertes Speichern (`Strg + S`):** Sofortiger, synchroner Disk-Flush mit visueller Toast-Rückmeldung.
- **Sicherer Shutdown:** `Strg + Q` oder Schließen des Fensters führt vor Programmende einen vollständigen synchronen Flush aus.
### 🖨️ Vektor-PDF-Export
- Exportiert das gesamte Dokument via `printpdf` nach `./export.pdf`.
- Striche und geometrische Formen werden als skalierbare Vektoren ausgegeben.
- Exportiert das Dokument via `printpdf` nach `~/Documents/<Titel>.pdf` (mit automatischer Kollisionsvermeidung `_1`, `_2`).
- Striche und geometrische Formen werden als skalierbare Vektoren ausgegeben. Kürzel: `Strg + E`.
---
### 🚀 Release, Packaging & In-App Auto-Update (Zorin OS 18 / Ubuntu)
- **Natives .deb-Paket**: Optimiert für Zorin OS 18 (Ubuntu 24.04 LTS Noble-Basis) mit vollständiger XDG Desktop-Integration (`.desktop`, Hicolor-Icons SVG & 256x256 PNG, AppStream Metainfo).
- **Portables .AppImage**: Eigenständiges, distributionsunabhängiges Bundle (x86_64) mit integrierten Laufzeitabhängigkeiten.
- **In-App Gitea Updater (`src/updater.rs`)**:
- Fragt im Hintergrund die Gitea Releases API (`https://gitea.pansi.eu/api/v1/repos/harald/ink-notes/releases/latest`) ab.
- Zeigt bei neuen Versionen ein Update-Badge in der Toolbar an.
- Release-Modal mit Versionsvergleich, Changelog und 1-Click-Download / In-Place-Aktualisierung.
- **Gitea Actions CI/CD (`.gitea/workflows/release.yaml`)**: Automatisches Paketieren und Veröffentlichen auf Gitea bei Git-Tags (`v*`).
## 📦 Release, Packaging & Updater
---
## 📦 Paketierung & Installation (Zorin OS 18)
- **Natives .deb-Paket**: Optimiert für Zorin OS 18 (Ubuntu 24.04 LTS Basis) mit vollständiger XDG Desktop-Integration (`.desktop`, Hicolor-Icons SVG & PNG, AppStream Metainfo, Debian Copyright & Changelog).
- **Portables .AppImage**: Eigenständiges x86_64 Bundle.
- **In-App Updater (`src/updater.rs`)**: Automatischer Prüflauf gegen Gitea Releases, SemVer-Filterung, Minisign-Signaturprüfung, SHA-256-Prüfung und atomarer In-Place Austausch.
### 1. Release-Pakete bauen
```bash
# Baut sowohl .deb als auch .AppImage und berechnet SHA256SUMS.txt in target/
# Baut .deb, .AppImage und berechnet SHA256SUMS.txt in target/
./scripts/build-release.sh
```
Ergebnis in `target/`:
- `ink-notes_0.1.0_amd64.deb` (~4.2 MB)
- `ink-notes_0.1.0_x86_64.AppImage` (~5.6 MB)
- `ink-notes_0.1.0_amd64.deb`
- `ink-notes_0.1.0_x86_64.AppImage`
- `SHA256SUMS.txt`
- `SHA256SUMS.txt.minisig` (nur mit gesetztem `MINISIGN_SECRET_KEY`, siehe unten)
### 2. Installation auf Zorin OS 18
### Release-Signatur einrichten (einmalig)
In-App-Updates werden nur mit gültiger Minisign-Signatur installiert. Signiert wird mit [rsign2](https://crates.io/crates/rsign2), einer Minisign-kompatiblen Implementierung (`cargo install rsign2 --locked`). Einmalig ein Schlüsselpaar erzeugen (ohne Passwort, da es nur als CI-Secret verwendet wird):
```bash
rsign generate -W -p ink-notes.pub -s ink-notes.key
```
> **Wichtig:** Schlüssel und Signierwerkzeug müssen zusammenpassen. `rsign generate -W` verschlüsselt den Schlüssel mit einem leeren Passwort; das `minisign`-CLI (z. B. Ubuntu-Paket 0.11) kann ihn ohne Terminal nicht laden. `build-release.sh` und die CI signieren deshalb mit `rsign`.
1. Die zweite Zeile aus `ink-notes.pub` (beginnt mit `RW`) in `src/updater.rs` als `UPDATE_SIGNING_PUBLIC_KEY` eintragen und committen.
2. Den kompletten Inhalt von `ink-notes.key` in Gitea unter *Repository → Einstellungen → Actions → Secrets* als `MINISIGN_SECRET_KEY` anlegen.
3. `ink-notes.key` sicher offline aufbewahren und vom Rechner löschen. Geht der Schlüssel verloren, können bestehende Installationen keine Updates mehr prüfen.
Lokal signiert `build-release.sh` nur, wenn `MINISIGN_SECRET_KEY` gesetzt und `rsign` installiert ist. In der CI ist die Signatur Pflicht (`INK_NOTES_REQUIRE_SIGNATURE=1`), und das Skript prüft, dass der geheime Schlüssel zum eingebauten öffentlichen Schlüssel passt.
### 2. Installation auf Zorin OS 18 / Ubuntu 24.04
```bash
# Natives Debian-Paket installieren
sudo dpkg -i target/ink-notes_0.1.0_amd64.deb
# Oder portables AppImage direkt starten
# Oder portables AppImage starten
chmod +x target/ink-notes_0.1.0_x86_64.AppImage
./target/ink-notes_0.1.0_x86_64.AppImage
```
Nach der `.deb`-Installation ist `ink-notes` nahtlos im Zorin OS Anwendungsmenü unter *Büro* und *Grafik* integriert.
#### Hinweise zu AppImage, glibc & FUSE:
- **glibc-Mindestversion:** `ink-notes` benötigt `glibc >= 2.39` (Standard unter Ubuntu 24.04 LTS und Zorin OS 18).
- **Host-Grafikstack:** Das AppImage bündelt die Anwendungslogik, nutzt für maximale Performance und Hardwarebeschleunigung jedoch den Treiber- und Grafikstack des Host-Systems (Vulkan ICD-Treiber, Wayland bzw. X11).
- **FUSE 2 / FUSE 3 Kompatibilität:** Ubuntu 24.04-basierte Distributionen nutzen standardmäßig FUSE 3 (`fuse3`). Sollte das AppImage beim Start einen Fehler bezüglich `libfuse.so.2` ausgeben, kann entweder das Kompatibilitätspaket installiert werden:
```bash
sudo apt install libfuse2t64
```
oder das AppImage direkt ohne FUSE entpackt und ausgeführt werden:
```bash
./ink-notes_0.1.0_x86_64.AppImage --appimage-extract-and-run
```
### 3. Release auf Gitea veröffentlichen
Das Skript arbeitet vollkommen idempotent und aktualisiert bei wiederholter Ausführung Release-Notizen und Assets:
```bash
# Veröffentlicht die Artefakte auf https://gitea.pansi.eu/harald/ink-notes
./scripts/publish-gitea-release.sh <DEIN_GITEA_TOKEN>
export GITEA_TOKEN="<DEIN_GITEA_TOKEN>"
./scripts/publish-gitea-release.sh
```
---
## 🛡️ Updater-Sicherheit
Der In-App-Updater (`src/updater.rs`) folgt strengen Sicherheitsrichtlinien:
1. **Minisign-Signatur (Pflicht):**
- Jedes Release enthält `SHA256SUMS.txt.minisig`, eine Minisign-Signatur der Prüfsummendatei.
- Der Updater lädt `SHA256SUMS.txt` und die Signatur **vor** dem Paket und prüft sie gegen den in die App eingebauten öffentlichen Schlüssel (`UPDATE_SIGNING_PUBLIC_KEY` in `src/updater.rs`, Crate `minisign-verify`).
- Fehlt die Signatur, ist sie ungültig oder ist kein Schlüssel eingebaut, wird das Update abgelehnt. Ein kompromittierter Release-Server kann so keine manipulierten Pakete ausliefern.
2. **SHA-256-Integritätsprüfung:**
- Der SHA-256-Hash des heruntergeladenen Pakets wird mit dem Eintrag in der signierten `SHA256SUMS.txt` verglichen (`sha2`).
- Weicht die Prüfsumme ab oder fehlt ein Eintrag, wird die Datei sofort gelöscht und der Installationsvorgang mit Fehlermeldung abgebrochen.
3. **Redirect-Schutz & HTTPS:**
- Jede Anfrage erzwingt HTTPS – auch Download-Links aus der Release-API und jedes Weiterleitungsziel.
- Bei HTTP-Redirects werden sensible Autorisierungs-Header (`Authorization: token ...`) verworfen, sobald die Weiterleitung auf eine andere Origin oder ein externes CDN verweist.
4. **Isolierter Download-Pfad & Path-Traversal-Schutz:**
- Downloads werden in einem isolierten Benutzerverzeichnis abgelegt (`$XDG_CACHE_HOME/ink-notes/updates` bzw. `~/.cache/ink-notes/updates`, Berechtigung `0700`).
- Asset-Namen werden sanitisiert: Pfadtrenner (`/`, `\`), Pfadüberquerungen (`..`), Steuerzeichen und leere Dateinamen werden strikt abgewiesen.
5. **Atomarer AppImage-Tausch:**
- Kein direktes Überschreiben laufender Programmdateien per `fs::copy` (verhindert den Linux-Kernel-Fehler *"Text file busy"*).
- Die neue Datei wird temporär im selben Verzeichnis angelegt (`<name>.new.<pid>`), mit Ausführungsrechten (`0755`) versehen, das bestehende AppImage als `<name>.bak` gesichert (dessen Fehler nicht ignoriert werden) und anschließend per atomarem `fs::rename` ausgetauscht.
---
## ⚙️ CI-Runner einrichten (Gitea Actions / act_runner)
Der Veröffentlichungs-Workflow liegt unter [`.gitea/workflows/release.yaml`](.gitea/workflows/release.yaml).
### Runner-Label konfigurieren
Der Workflow nutzt standardmäßig das Runner-Label:
```yaml
runs-on: ubuntu-latest
```
In der Konfigurationsdatei Ihres Gitea `act_runner` (`config.yaml`) muss dieses Label auf ein passendes Container-Image oder den Host gemappt sein:
```yaml
runner:
labels:
- "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
# oder für native Host-Ausführung:
# - "ubuntu-latest:host"
```
### Diagnose & Fehlerbehebung
Bleibt ein Release-Job auf **"Waiting to run"** hängen:
1. **Runner online?** Prüfen Sie in Gitea unter *Einstellungen -> Aktionen -> Runner*, ob der `act_runner` aktiv und mit einem grünen Punkt als *Online* markiert ist.
2. **Label-Übereinstimmung:** Stimmen die im Runner registrierten Labels exakt mit dem `runs-on`-Feld in `.gitea/workflows/release.yaml` überein?
3. **Rechte:** Der Workflow prüft `if [ "$(id -u)" -ne 0 ]` und setzt `sudo` nur ein, wenn der Runner nicht als `root` läuft. Das verhindert Fehler sowohl in unprivilegierten Host-Umgebungen als auch in Root-Containern.
---
## 🚀 Schnellstart (Entwicklung)
### Voraussetzungen
- **Rust & Cargo:** Version 1.75 oder neuer (`rustup update stable`)
- **System-Bibliotheken (Linux):**
- Vulkan-Treiber (z. B. `mesa-vulkan-drivers` oder proprietäre Treiber)
- X11 / XWayland Bibliotheken (`libx11-dev`, `libxcursor-dev`, `libxrandr-dev`, `libxi-dev`, `libxkbcommon-dev`)
- X11 / Wayland Bibliotheken (`libx11-dev`, `libxcursor-dev`, `libxrandr-dev`, `libxi-dev`, `libxkbcommon-dev`)
### Bauen & Ausführen
```bash
# Repository klonen
git clone gitea@farmersdog.pansi.eu:harald/ink-notes.git
git clone https://gitea.pansi.eu/harald/ink-notes.git
cd ink-notes
# Kompilieren und starten via Starter-Skript
@@ -164,8 +230,10 @@ cargo run --release
| **Nächste Seite** | `Bild ab (PageDown)` oder `N` |
| **Vorherige Seite** | `Bild auf (PageUp)` |
| **Erste / Letzte Seite** | `Pos 1 (Home)` / `Ende (End)` |
| **PDF-Export** | `Strg + E` oder `Strg + S` |
| **Beenden (mit Disk-Flush)** | `Esc` oder `Q` |
| **Jetzt speichern (Disk Flush)** | `Strg + S` |
| **Vektor-PDF-Export** | `Strg + E` |
| **Dialoge / Menüs schließen** | `Esc` |
| **Beenden (mit Disk-Flush)** | `Strg + Q` (oder Fenster schließen) |
---
@@ -179,7 +247,7 @@ src/
├── viewport.rs # Kamera-Pan, Zoom, 2D-Transformationsmatrizen
├── shader.wgsl # WGSL Vertex- & Fragment-Shader für Inking-Meshes
├── filter.rs # One-Euro-Filter gegen Eingabe-Jitter
├── spline.rs # Centripetal Catmull-Rom & Dreiecksstreifen-Tessellierung
├── spline.rs # Catmull-Rom, Corner Preservation & Dreiecksstreifen
├── geometry.rs # RDP-Algorithmus, Least-Squares Circle Fit, Shape Recognition
├── selection.rs # Lasso-Auswahl, Bounding-Box, Griffe, Rotation & Skalierung
├── background.rs # Tessellierung für Kariert, Liniert, Punktraster
@@ -187,7 +255,7 @@ src/
├── history.rs # Undo/Redo-Transaktionsstapel (Add, Erase, Transform)
├── storage.rs # Atomares Laden/Speichern von Manifest & Seiten, Multi-Notebooks
├── autosave.rs # Debounced Hintergrund-Autosave mit MPSC-Kanal
├── updater.rs # In-App Gitea Updater (SemVer, Release-API, Download, Self-Update)
├── updater.rs # In-App Gitea Updater (SemVer, HTTPS, SHA256-Integrität, Self-Update)
├── export.rs # Skalierbarer Vektor-PDF-Export via printpdf
└── ui.rs # Floating Glassmorphism-Toolbar, Dokumenten- & Update-Manager (egui)
```
@@ -198,16 +266,16 @@ Ausführliche Details zur Architektur und mathematischen Modellen findest du unt
## 🧪 Tests ausführen
Die gesamte Inking-Pipeline, Geometrieerkennung, Speicherlogik und der Updater sind mit Unit-Tests abgesichert:
Die gesamte Inking-Pipeline, Geometrieerkennung, Speicherlogik und der Updater sind mit umfassenden Unit-Tests abgesichert:
```bash
cargo test
```
Alle 61 Unit-Tests prüfen Berechnungen, Hit-Testing, Undo/Redo, Persistenz und Versionsvergleiche konsistent ab.
Alle Unit-Tests prüfen Berechnungen, Hit-Testing, Undo/Redo, Persistenz, Updater-Sicherheit und Versionsvergleiche konsistent ab.
---
## 📄 Lizenz
MIT License / Open Source Inking Project for Linux.
Dieses Projekt ist unter der [MIT-Lizenz](LICENSE) lizenziert (Copyright © 2026 Harald Pansi).
+54 -51
View File
@@ -1,6 +1,6 @@
# Dateiformat & Speicherstruktur von ink-notes
Dieses Dokument beschreibt die Speicherstruktur und die JSON-Dateiformate, mit denen `ink-notes` Dokumente auf dem Dateisystem ablegt.
Dieses Dokument beschreibt die Speicherstruktur, die JSON-Dateiformate und die Versionshistorie, mit denen `ink-notes` Dokumente auf dem Dateisystem ablegt.
---
@@ -13,7 +13,9 @@ Notizbücher werden standardmäßig unter dem XDG-Datenpfad gespeichert:
└── notebooks/
├── <notebook_id_1>/
│ ├── manifest.json
│ ├── manifest.json.v0.bak (optional, Migrations-Backup)
│ ├── page_1.json
│ ├── page_1.json.corrupt-1775460000 (optional, Quarantäne bei Parsefehlern)
│ ├── page_2.json
│ └── ...
└── <notebook_id_2>/
@@ -29,19 +31,20 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
```json
{
"format_version": 2,
"id": "notebook-a1b2c3d4",
"title": "Mathematik Vorlesung",
"version": 1,
"pages": [
{
"id": 1,
"file_name": "page_1.json"
"background": "Grid5mm"
},
{
"id": 2,
"file_name": "page_2.json"
"background": "DotGrid"
}
],
"page_ids": [1, 2],
"current_page_index": 0,
"next_page_id": 3,
"next_stroke_id": 42,
@@ -51,10 +54,11 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
```
### Felder:
- `format_version`: Format-Versionsnummer (aktuell `2`, bei unversionierten Legacy-Dateien implizit `0`).
- `id`: Eindeutige ID des Notizbuchs (z. B. UUID oder `"default"`).
- `title`: Vom Nutzer vergebener Anzeigename des Notizbuchs.
- `version`: Format-Versionsnummer (aktuell `1`).
- `pages`: Geordnete Liste von Seitenreferenzen.
- `pages`: Geordnete Liste von Seitenmetadaten (`id`, optional `background`).
- `page_ids`: Abwärtskompatible Liste der Seiten-IDs.
- `current_page_index`: Index der zuletzt aktiven Seite für Session-Restore.
- `next_page_id`: Inkrementierender ID-Zähler für neue Seiten.
- `next_stroke_id`: Globaler ID-Zähler für neue Striche.
@@ -65,58 +69,21 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
## 3. Seitendatei (`page_<id>.json`)
Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelles inkrementelles Speichern zu ermöglichen.
Die Speicherung erfolgt ab Version 2 als kompaktes JSON (`serde_json::to_vec`), wodurch gegenüber Pretty-Printed JSON über 50 % Speicherplatz eingespart wird.
```json
{
"id": 1,
"background": "Grid5mm",
"strokes": [
{
"id": 1,
"color": [0.12, 0.12, 0.14, 1.0],
"width_mm": 0.5,
"samples": [
{
"x": 350.2,
"y": 420.5,
"pressure": 0.65,
"timestamp": 12.345
},
{
"x": 352.1,
"y": 425.8,
"pressure": 0.70,
"timestamp": 12.355
}
],
"recognized_shape": null
},
{
"id": 2,
"color": [0.15, 0.35, 0.85, 1.0],
"width_mm": 0.8,
"samples": [],
"recognized_shape": {
"Rectangle": {
"top_left": { "x": 500.0, "y": 600.0 },
"width": 300.0,
"height": 200.0
}
}
}
]
}
{"format_version":2,"id":1,"background":"Grid5mm","strokes":[{"id":1,"color":[0.12,0.12,0.14,1.0],"samples":[{"x":350.2,"y":420.5,"p":0.65,"t":12.345},{"x":352.1,"y":425.8,"p":0.70,"t":12.355}],"shape":null,"tool":"pen","width":5.0,"finalized":true},{"id":2,"color":[0.15,0.35,0.85,1.0],"samples":[],"shape":{"Rectangle":{"top_left":{"x":500.0,"y":600.0},"width":300.0,"height":200.0}},"tool":"shape","width":20.0,"finalized":true}]}
```
### Felder:
- `format_version`: Format-Versionsnummer (`2`).
- `id`: Eindeutige ID der Seite.
- `background`: Papiermuster (`Blank`, `Grid5mm`, `Lined`, `DotGrid`).
- `strokes`: Liste der auf der Seite gezeichneten Striche und Vektorformen.
- `id`: Eindeutige ID des Strichs.
- `color`: RGBA-Farbwerte im Bereich `[0.0, 1.0]`.
- `width_mm`: Basis-Strichbreite in Millimetern.
- `samples`: Rohabtastpunkte entlang des Pfads (`x`, `y` in DIN-A4-Einheiten, `pressure` in `[0.0, 1.0]`, `timestamp` in Sekunden).
- `recognized_shape`: Optionales Enum für geometrische Vektorformen:
- `samples`: Rohabtastpunkte entlang des Pfads (`x`, `y` in DIN-A4-Einheiten, `p` Druckwert in `[0.0, 1.0]`, `t` Zeitstempel in Sekunden).
- `shape`: Optionales Enum für geometrische Vektorformen:
- `Line { start, end }`
- `Rectangle { top_left, width, height }`
- `Circle { center, radius }`
@@ -124,11 +91,47 @@ Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelle
- `Triangle { p1, p2, p3 }`
- `Rhombus { center, width, height }`
- `Octahedron { center, radius }`
- `tool`: Zeichenwerkzeug (`"pen"`, `"highlighter"`, `"shape"`).
- `width`: Strichbreite in internen DIN-A4-Einheiten (10 Einheiten = 1 mm; z. B. `5.0` für 0.5 mm Stift, `60.0` für 6.0 mm Marker).
- `finalized`: Boolesches Flag für Striche, die das Zwei-Stufen-Inking (Eckenerkennung, Glättung, runde Endkappen) durchlaufen haben.
---
## 4. Atomares Schreibverfahren
## 4. Datensicherheit & Migration
Um Datenverlust bei Stromausfall oder plötzlichem Prozessabbruch zu verhindern:
1. Daten werden in `<ziel>.tmp` serialisiert und per `fsync` auf das Speichermedium geschrieben.
### Atomares Schreiben & Verzeichnis-Sync
1. Daten werden in `<ziel>.tmp.<pid>.<nanos>` serialisiert und per `fsync` auf das Speichermedium geschrieben (Dateirechte `0600` auf Unix).
2. Mittels `std::fs::rename("<ziel>.tmp", "<ziel>")` wird die Zieldatei auf POSIX-Systemen atomar ersetzt.
3. Nach dem Rename wird das übergeordnete Verzeichnis synchronisiert (`sync_all()`), um Persistenz bei Stromausfall zu garantieren. Verzeichnisse erhalten Berechtigung `0700`.
### Quarantäne bei Parsefehlern
Wird eine Seitendatei beim Laden als fehlerhaft erkannt (z. B. unvollständiges oder korruptes JSON), wird sie nicht stillschweigend gelöscht oder überschrieben, sondern nach `page_<id>.json.corrupt-<unix_ts>` verschoben. Die Ersatzseite wird als schreibgeschützt markiert.
### Migrations-Backups & Rolling Backups
Beim ersten Speichern einer Datei im Legacy-Format (Version 0) wird vor dem Überschreiben ein Backup `*.v0.bak` angelegt (z. B. `manifest.json.v0.bak`, `page_1.json.v0.bak`).
Zusätzlich existiert ein Rolling-Backup `manifest.json.bak`, auf das bei beschädigtem Manifest automatisch zurückgegriffen wird.
---
## 5. Versionshistorie
| Version | Status | Beschreibung |
|---|---|---|
| **0** | Legacy | Unversioniertes Format. `format_version` fehlt in JSON. Implizit als Version 0 interpretiert. |
| **1** | Überholt | Einführung von `format_version: u32` in `manifest.json` und `page_<id>.json`. Seiten-Hintergrund im Manifest gecached (`pages: Vec<PageMeta>`). Strenges Versionsmatching (neuere Versionen werden abgelehnt). Automatische Migration mit `*.v0.bak`. |
| **2** | **Aktuell** | Stroke-Treue: Ergänzung von `tool`, `width` und `finalized` in `StrokeData` für exakte Rekonstruktion aller Vertizes und Splines. Umstellung auf kompaktes JSON (`serde_json::to_vec`, ca. 54% Platzerparnis). |
---
## 6. Analyse & Bewertung: Binärformat (z. B. `postcard`)
Eine Umstellung auf ein Binärformat wie `postcard` wurde evaluiert:
- **Vorteile**:
- Weitere 20–30 % Reduktion der Dateigröße gegenüber kompaktem JSON.
- Minimal schnellere Serialisierung auf leistungsschwachen Systemen.
- **Nachteile**:
- Verlust der Text-Transparenz (JSON lässt sich direkt inspizieren, mit `git`/Tools diffen und bei Korruption manuell reparieren).
- Snapshotting und JSON-Verarbeitung liegen im Benchmark bereits bei **0,02 ms (1k Samples)** bis **0,70 ms (50k Samples)** – also weit unter dem Zielwert von 1 ms.
- Kompaktes JSON spart bereits über 53 % Dateigröße ein.
- **Entscheidung**:
Kompaktes JSON bietet aktuell das optimale Verhältnis aus Ausfallsicherheit, Benutzerfreundlichkeit, Performance und Portabilität. Ein Binärformat wie `postcard` bleibt als Option für spätere Riesen-Notizbücher (> 500 Seiten) denkbar, wird aktuell jedoch nicht eingeführt.
+18
View File
@@ -0,0 +1,18 @@
[Desktop Entry]
Version=1.0
Name=ink-notes
GenericName=Notizbuch- & Inking-App
Comment=Latenzoptimierte Vektor-Notizen mit Stylus, Stift & Geometrie-Erkennung
Exec=ink-notes %F
Icon=eu.pansi.ink-notes
Terminal=false
Type=Application
Categories=Office;Graphics;Utility;
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;
StartupWMClass=ink-notes
MimeType=application/x-inknotes;
Actions=NewWindow;
[Desktop Action NewWindow]
Name=Neues Fenster
Exec=ink-notes
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>eu.pansi.ink-notes</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<name>ink-notes</name>
<summary>Latenzoptimierte Vektor-Notizbuch- und Inking-App für Linux</summary>
<description>
<p>
ink-notes ist eine hardwarebeschleunigte, hochperformante Notizbuch- und Inking-Anwendung für Linux,
geschrieben in Rust mit wgpu. Sie bietet minimale Eingabelatenz mit Stylus, Touchpad oder Maus.
</p>
<p>Hauptmerkmale:</p>
<ul>
<li>Zwei-Stufen-Inking-Modell: Echtzeit-Glättung mit 1€-Filter und akausale Ecken-Erhaltung</li>
<li>Automatische Geometrie- und Formerkennung (Geraden, Rechtecke, Kreise, Koordinatensysteme, Polygone)</li>
<li>Stufenloser Viewport mit cursor-zentriertem Zoom (25% bis 500%) und Panning</li>
<li>Objektauswahl, Lasso, Skalieren, stufenloses Drehen und Multicolor-Umfärbung</li>
<li>Multi-Dokumenten- und Notizbuch-Verwaltung mit atomarem Autosave</li>
<li>Vektor-PDF-Export für Druck und Weitergabe</li>
</ul>
</description>
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
<provides>
<binary>ink-notes</binary>
</provides>
<categories>
<category>Office</category>
<category>Graphics</category>
<category>Utility</category>
</categories>
<developer id="eu.pansi">
<name>Harald</name>
</developer>
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
<content_rating type="oars-1.1" />
</component>
+1 -1
View File
@@ -4,7 +4,7 @@ Name=ink-notes
GenericName=Notizbuch- & Inking-App
Comment=Latenzoptimierte Vektor-Notizen mit Stylus, Stift & Geometrie-Erkennung
Exec=ink-notes %F
Icon=ink-notes
Icon=eu.pansi.ink-notes
Terminal=false
Type=Application
Categories=Office;Graphics;Utility;
+3 -3
View File
@@ -20,7 +20,7 @@
<li>Vektor-PDF-Export für Druck und Weitergabe</li>
</ul>
</description>
<launchable type="desktop-id">ink-notes.desktop</launchable>
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
<provides>
<binary>ink-notes</binary>
</provides>
@@ -32,7 +32,7 @@
<developer id="eu.pansi">
<name>Harald</name>
</developer>
<url type="homepage">https://farmersdog.pansi.eu/harald/ink-notes</url>
<url type="bugtracker">https://farmersdog.pansi.eu/harald/ink-notes/issues</url>
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
<content_rating type="oars-1.1" />
</component>
+28
View File
@@ -0,0 +1,28 @@
## ink-notes {{TAG_NAME}}
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
### Highlights:
- **Wichtige Fehlerbehebung**: Nach einem Neustart auf einer späteren Seite konnte Weiterschreiben den bisherigen Inhalt dieser Seite überschreiben (betrifft v0.2.3 – v0.3.2)
- **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl mit Vorschaubildern, größere Bedienelemente in allen Popovern
- **UI per Stift und Touch bedienbar**: Werkzeugleiste, Popover und Dialoge reagieren jetzt auch auf Stift und Finger, auch bei Display-Skalierung
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung
- **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Dreiecke, Rauten, Vielecke & Koordinatensysteme (Draw-and-Hold); Rechtecke werden zuverlässig erkannt, Freiformen bleiben unverändert
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave, Papierkorb und fortlaufenden Backups
- **Datensicherheit**: Versioniertes Dateiformat mit Migration, Quarantäne beschädigter Seiten, Schutz vor Doppelöffnung und Crash-getestetem Speichern
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases – mit Pflicht-Signaturprüfung (Minisign) und durchgängigem HTTPS
- **Vektor-PDF-Export**: Verlustfreie Ausgabe für Druck und Weitergabe
### Installation:
- **Zorin OS / Ubuntu / Debian (.deb)**:
```bash
sudo dpkg -i ink-notes_{{VERSION}}_amd64.deb
```
- **Portables AppImage (Alle Linux-Distributionen)**:
```bash
chmod +x ink-notes_{{VERSION}}_x86_64.AppImage
./ink-notes_{{VERSION}}_x86_64.AppImage
```
+42 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# ==============================================================================
# Haupt-Release-Build-Skript für ink-notes (Zorin OS 18 / Linux)
# Erstellt .deb, .AppImage und SHA256-Prüfsummen in target/
# Erstellt .deb, .AppImage, SHA256-Prüfsummen und deren Minisign-Signatur in target/
# ==============================================================================
set -euo pipefail
@@ -30,10 +30,51 @@ echo "=== Berechne SHA256-Prüfsummen in target/${CHECKSUM_FILE} ==="
cd "${ROOT_DIR}/target"
sha256sum "${DEB_NAME}" "${APPIMAGE_NAME}" > "${CHECKSUM_FILE}"
# 4. Prüfsummen signieren (Pflicht für In-App-Updates)
# Signiert wird mit rsign2 (minisign-kompatibles Format): Der Schlüssel wurde mit
# `rsign generate -W` erzeugt; das minisign-CLI kann ihn ohne Terminal nicht laden.
SIGNATURE_FILE="${CHECKSUM_FILE}.minisig"
rm -f "${SIGNATURE_FILE}"
# Schlüssel kann von rustfmt in die Folgezeile umgebrochen sein
EMBEDDED_PUBKEY=$(grep -m1 -A1 'pub const UPDATE_SIGNING_PUBLIC_KEY' "${ROOT_DIR}/src/updater.rs" | grep -o '"[^"]*"' | head -n1 | tr -d '"')
if [ -n "${MINISIGN_SECRET_KEY:-}" ]; then
if [ -z "${EMBEDDED_PUBKEY}" ]; then
echo "FEHLER: UPDATE_SIGNING_PUBLIC_KEY in src/updater.rs ist leer – diese Version könnte keine Updates prüfen."
exit 1
fi
if ! command -v rsign >/dev/null 2>&1; then
echo "FEHLER: rsign nicht gefunden (Installation: cargo install rsign2 --locked)."
exit 1
fi
echo "=== Signiere ${CHECKSUM_FILE} mit rsign2 ==="
KEY_FILE="$(mktemp)"
trap 'rm -f "${KEY_FILE}"' EXIT
printf '%s\n' "${MINISIGN_SECRET_KEY}" > "${KEY_FILE}"
rsign sign -W -s "${KEY_FILE}" -t "ink-notes v${VERSION}" -x "${SIGNATURE_FILE}" "${CHECKSUM_FILE}"
rm -f "${KEY_FILE}"
# Stellt sicher, dass der geheime Schlüssel zum eingebauten öffentlichen Schlüssel passt
# rsign beendet sich bei falscher Key-ID mit Exit-Code 0, daher zusätzlich die Meldung prüfen
if ! VERIFY_OUT=$(rsign verify -P "${EMBEDDED_PUBKEY}" -x "${SIGNATURE_FILE}" "${CHECKSUM_FILE}" 2>&1) \
|| ! grep -q "Signature and comment signature verified" <<<"${VERIFY_OUT}"; then
echo "${VERIFY_OUT}"
echo "FEHLER: Signatur passt nicht zum eingebauten öffentlichen Schlüssel (UPDATE_SIGNING_PUBLIC_KEY)."
rm -f "${SIGNATURE_FILE}"
exit 1
fi
echo "${VERIFY_OUT}"
elif [ "${INK_NOTES_REQUIRE_SIGNATURE:-0}" = "1" ]; then
echo "FEHLER: MINISIGN_SECRET_KEY ist nicht gesetzt, eine Signatur ist aber Pflicht."
exit 1
else
echo "WARNUNG: MINISIGN_SECRET_KEY nicht gesetzt – ${CHECKSUM_FILE} bleibt unsigniert."
echo " Installierte Apps lehnen ein solches Release als Update ab."
fi
echo "======================================================================"
echo "Release-Artefakte für v${VERSION} erfolgreich erstellt:"
echo "======================================================================"
ls -lh "${DEB_NAME}" "${APPIMAGE_NAME}" "${CHECKSUM_FILE}"
if [ -f "${SIGNATURE_FILE}" ]; then ls -lh "${SIGNATURE_FILE}"; fi
echo ""
echo "Inhalt von target/${CHECKSUM_FILE}:"
cat "${CHECKSUM_FILE}"
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# ==============================================================================
# scripts/crash-test.sh
# Stresstest für ink-notes Speicher- und Autosave-Robustheit.
# Simuliert harte Programmabstürze (SIGKILL / kill -9) während Schreibvorgängen
# und verifiziert die Integrität der Notizbücher und Seiten.
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
ITERATIONS="${1:-200}"
TEST_DIR="${REPO_ROOT}/target/crash_test_workspace"
CRASH_SIM_BIN="${REPO_ROOT}/target/release/crash_sim"
echo "=================================================================="
echo " ink-notes Crash- & Datenverlust-Stresstest"
echo " Iterationen: ${ITERATIONS}"
echo " Testverzeichnis: ${TEST_DIR}"
echo "=================================================================="
# 1. Sicherstellen, dass das Test-Binary gebaut ist
if [ ! -f "${CRASH_SIM_BIN}" ]; then
echo "Baue crash_sim Release-Binary..."
cargo build --release --bin crash_sim --manifest-path "${REPO_ROOT}/Cargo.toml"
fi
# 2. Sauberes Testverzeichnis anlegen und Notizbuch initialisieren
rm -rf "${TEST_DIR}"
mkdir -p "${TEST_DIR}"
"${CRASH_SIM_BIN}" init "${TEST_DIR}"
SUCCESS_COUNT=0
INTERRUPTED_WRITES=0
trap 'rm -rf "${TEST_DIR}"' EXIT
echo "Starte Testlauf mit ${ITERATIONS} SIGKILL-Zyklen..."
for i in $(seq 1 "${ITERATIONS}"); do
# Worker im Hintergrund starten
"${CRASH_SIM_BIN}" worker "${TEST_DIR}" > /dev/null 2>&1 &
WORKER_PID=$!
# Zufällige Schlafdauer zwischen 2ms und 35ms, um Schreiboperationen mitten im Flug zu treffen
# RANDOM % 33 + 2 ergibt 2..34 Millisekunden
MS_DELAY=$(( (RANDOM % 33) + 2 ))
DELAY="0.$(printf "%03d" "$MS_DELAY")"
sleep "${DELAY}"
# Harter Prozessabbruch ohne Clean-up (SIGKILL)
if kill -9 "${WORKER_PID}" 2>/dev/null; then
INTERRUPTED_WRITES=$((INTERRUPTED_WRITES + 1))
fi
wait "${WORKER_PID}" 2>/dev/null || true
# Integritäts- und Konsistenzprüfung
if ! "${CRASH_SIM_BIN}" verify "${TEST_DIR}" > /dev/null 2>&1; then
echo ""
echo "❌ FEHLER in Iteration ${i}!"
echo "Notizbuch im Zustand nach Absturz nicht konsistent ladbar!"
"${CRASH_SIM_BIN}" verify "${TEST_DIR}" || true
trap - EXIT
exit 1
fi
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
if [ $((i % 25)) -eq 0 ] || [ "${i}" -eq "${ITERATIONS}" ]; then
echo " [$(date +%T)] Iteration ${i}/${ITERATIONS} bestanden (erfolgreich unterbrochene Schreibvorgänge: ${INTERRUPTED_WRITES})."
fi
done
echo ""
echo "=================================================================="
echo "✅ ALLE ${ITERATIONS} CRASH-TESTS ERFOLGREICH BESTANDEN!"
echo " - Überstandene SIGKILLs: ${INTERRUPTED_WRITES}"
echo " - Konsistente Ladevorgänge: ${SUCCESS_COUNT}/${ITERATIONS}"
echo " - Keine korrupten JSON-Dateien ohne Quarantäne"
echo " - Keine unvollständigen Atom-Schreibungen"
echo "=================================================================="
+48 -11
View File
@@ -18,11 +18,22 @@ APPDIR="${ROOT_DIR}/target/AppDir"
CACHE_DIR="${ROOT_DIR}/target/tools"
APPIMAGETOOL="${CACHE_DIR}/appimagetool-x86_64.AppImage"
# Pinned appimagetool Release & SHA-256 Hash
APPIMAGETOOL_VERSION="1.9.1"
APPIMAGETOOL_URL="https://github.com/AppImage/appimagetool/releases/download/${APPIMAGETOOL_VERSION}/appimagetool-x86_64.AppImage"
APPIMAGETOOL_SHA256="ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0"
echo "=== [1/5] Stelle Release-Binary sicher (ink-notes v${VERSION}) ==="
if [ ! -f "target/release/ink-notes" ]; then
cargo build --release
fi
# Prüfe glibc-Mindestversion
echo "=== Prüfe glibc-Symbolversionen ==="
MAX_GLIBC=$(objdump -T "target/release/ink-notes" | grep "GLIBC_" | sed -e 's/.*GLIBC_//' | cut -d')' -f1 | sort -V | tail -n 1 || echo "unknown")
echo "Höchste benötigte glibc-Symbolversion: GLIBC_${MAX_GLIBC}"
echo "Hinweis: ink-notes nutzt den Host-Grafikstack (Vulkan ICD, Wayland/X11) und glibc >= ${MAX_GLIBC}."
# Sicherstellen, dass das PNG-Icon existiert
if [ ! -f "assets/ink-notes.png" ]; then
echo "Generiere PNG-Icon..."
@@ -41,15 +52,20 @@ mkdir -p "${APPDIR}/usr/share/metainfo"
cp "target/release/ink-notes" "${APPDIR}/usr/bin/ink-notes"
chmod 755 "${APPDIR}/usr/bin/ink-notes"
cp "packaging/ink-notes.desktop" "${APPDIR}/usr/share/applications/ink-notes.desktop"
cp "packaging/ink-notes.desktop" "${APPDIR}/ink-notes.desktop"
# Desktop-Datei
cp "packaging/eu.pansi.ink-notes.desktop" "${APPDIR}/usr/share/applications/eu.pansi.ink-notes.desktop"
cp "packaging/eu.pansi.ink-notes.desktop" "${APPDIR}/eu.pansi.ink-notes.desktop"
ln -sf "eu.pansi.ink-notes.desktop" "${APPDIR}/ink-notes.desktop"
cp "assets/ink-notes.svg" "${APPDIR}/usr/share/icons/hicolor/scalable/apps/ink-notes.svg"
cp "assets/ink-notes.png" "${APPDIR}/usr/share/icons/hicolor/256x256/apps/ink-notes.png"
cp "assets/ink-notes.png" "${APPDIR}/ink-notes.png"
ln -sf "ink-notes.png" "${APPDIR}/.DirIcon"
# Icons
cp "assets/ink-notes.svg" "${APPDIR}/usr/share/icons/hicolor/scalable/apps/eu.pansi.ink-notes.svg"
cp "assets/ink-notes.png" "${APPDIR}/usr/share/icons/hicolor/256x256/apps/eu.pansi.ink-notes.png"
cp "assets/ink-notes.png" "${APPDIR}/eu.pansi.ink-notes.png"
ln -sf "eu.pansi.ink-notes.png" "${APPDIR}/.DirIcon"
cp "packaging/ink-notes.metainfo.xml" "${APPDIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml"
# Metainfo
cp "packaging/eu.pansi.ink-notes.metainfo.xml" "${APPDIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml"
cp "packaging/eu.pansi.ink-notes.metainfo.xml" "${APPDIR}/usr/share/metainfo/eu.pansi.ink-notes.appdata.xml"
# AppRun Skript erstellen
cat <<'EOF' > "${APPDIR}/AppRun"
@@ -62,11 +78,32 @@ exec "${SELF_DIR}/usr/bin/ink-notes" "$@"
EOF
chmod 755 "${APPDIR}/AppRun"
echo "=== [3/5] Hole appimagetool (gecacht in ${CACHE_DIR}) ==="
echo "=== [3/5] Hole & verifiziere gepinntes appimagetool v${APPIMAGETOOL_VERSION} ==="
mkdir -p "${CACHE_DIR}"
if [ ! -f "${APPIMAGETOOL}" ]; then
echo "Lade appimagetool herunter..."
curl -fsSL -o "${APPIMAGETOOL}" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
need_download=true
if [ -f "${APPIMAGETOOL}" ]; then
CURRENT_HASH=$(sha256sum "${APPIMAGETOOL}" | cut -d' ' -f1)
if [ "${CURRENT_HASH}" = "${APPIMAGETOOL_SHA256}" ]; then
need_download=false
else
echo "Gecachtes appimagetool hat ungültigen Hash. Lade neu herunter..."
rm -f "${APPIMAGETOOL}"
fi
fi
if [ "${need_download}" = true ]; then
echo "Lade appimagetool v${APPIMAGETOOL_VERSION} herunter..."
curl -fsSL -o "${APPIMAGETOOL}.tmp" "${APPIMAGETOOL_URL}"
DOWNLOAD_HASH=$(sha256sum "${APPIMAGETOOL}.tmp" | cut -d' ' -f1)
if [ "${DOWNLOAD_HASH}" != "${APPIMAGETOOL_SHA256}" ]; then
echo "FEHLER: SHA256-Prüfsumme für appimagetool stimmt nicht überein!"
echo "Erwartet: ${APPIMAGETOOL_SHA256}"
echo "Erhalten: ${DOWNLOAD_HASH}"
rm -f "${APPIMAGETOOL}.tmp"
exit 1
fi
mv "${APPIMAGETOOL}.tmp" "${APPIMAGETOOL}"
chmod +x "${APPIMAGETOOL}"
fi
+96 -20
View File
@@ -16,7 +16,7 @@ ARCH="amd64"
DEB_NAME="${PKG_NAME}_${VERSION}_${ARCH}.deb"
STAGING_DIR="${ROOT_DIR}/target/deb_staging"
echo "=== [1/4] Baue Release-Binary mit Cargo (ink-notes v${VERSION}) ==="
echo "=== [1/5] Baue Release-Binary mit Cargo (ink-notes v${VERSION}) ==="
cargo build --release
# Sicherstellen, dass das PNG-Icon existiert
@@ -25,7 +25,23 @@ if [ ! -f "assets/ink-notes.png" ]; then
python3 scripts/generate_png_icon.py
fi
echo "=== [2/4] Erstelle Paketstruktur in ${STAGING_DIR} ==="
echo "=== [2/5] Ermittle tatsächliche Abhängigkeiten (ldd & dpkg-shlibdeps) ==="
echo "Linker-Abhängigkeiten (ldd):"
ldd target/release/ink-notes
if command -v dpkg-shlibdeps >/dev/null 2>&1; then
echo "Ermittle automatische shlibdeps via dpkg-shlibdeps..."
TEMP_CONTROL_DIR="$(mktemp -d)"
mkdir -p "${TEMP_CONTROL_DIR}/debian"
echo "Source: ${PKG_NAME}" > "${TEMP_CONTROL_DIR}/debian/control"
(
cd "${TEMP_CONTROL_DIR}"
dpkg-shlibdeps -O "${ROOT_DIR}/target/release/ink-notes" 2>/dev/null || true
)
rm -rf "${TEMP_CONTROL_DIR}"
fi
echo "=== [3/5] Erstelle Paketstruktur in ${STAGING_DIR} ==="
rm -rf "${STAGING_DIR}"
mkdir -p "${STAGING_DIR}/DEBIAN"
mkdir -p "${STAGING_DIR}/usr/bin"
@@ -33,44 +49,104 @@ mkdir -p "${STAGING_DIR}/usr/share/applications"
mkdir -p "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps"
mkdir -p "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps"
mkdir -p "${STAGING_DIR}/usr/share/metainfo"
mkdir -p "${STAGING_DIR}/usr/share/doc/${PKG_NAME}"
# 2. Dateien kopieren
# Dateien kopieren
cp "target/release/ink-notes" "${STAGING_DIR}/usr/bin/ink-notes"
# Desktop-Dateien (sowohl mit AppStream ID als auch Legacy Name)
cp "packaging/eu.pansi.ink-notes.desktop" "${STAGING_DIR}/usr/share/applications/eu.pansi.ink-notes.desktop"
cp "packaging/eu.pansi.ink-notes.desktop" "${STAGING_DIR}/usr/share/applications/ink-notes.desktop"
# Icons (eu.pansi.ink-notes & ink-notes)
cp "assets/ink-notes.svg" "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps/eu.pansi.ink-notes.svg"
cp "assets/ink-notes.svg" "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps/ink-notes.svg"
cp "assets/ink-notes.png" "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps/eu.pansi.ink-notes.png"
cp "assets/ink-notes.png" "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps/ink-notes.png"
# AppStream Metainfo
cp "packaging/eu.pansi.ink-notes.metainfo.xml" "${STAGING_DIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml"
# Debian doc: copyright
cat <<'EOF' > "${STAGING_DIR}/usr/share/doc/ink-notes/copyright"
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: ink-notes
Upstream-Contact: Harald <harald@pansi.eu>
Source: https://gitea.pansi.eu/harald/ink-notes
Files: *
Copyright: 2026 Harald Pansi <harald@pansi.eu>
License: MIT
License: MIT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
EOF
# Debian doc: changelog.gz
CHANGELOG_TMP="$(mktemp)"
cat <<EOF > "${CHANGELOG_TMP}"
ink-notes (${VERSION}-1) stable; urgency=medium
* Release v${VERSION}.
* Low-latency vector inking with two-stage pipeline.
* Shape recognition and infinite canvas with zoom/pan.
* Multi-notebook management and PDF export.
-- Harald <harald@pansi.eu> $(date -R)
EOF
gzip -n -9 -c "${CHANGELOG_TMP}" > "${STAGING_DIR}/usr/share/doc/ink-notes/changelog.gz"
rm -f "${CHANGELOG_TMP}"
# Berechtigungen penibel setzen
find "${STAGING_DIR}" -type d -exec chmod 755 {} +
find "${STAGING_DIR}/usr/share" -type f -exec chmod 644 {} +
chmod 755 "${STAGING_DIR}/DEBIAN"
chmod 755 "${STAGING_DIR}/usr/bin/ink-notes"
cp "packaging/ink-notes.desktop" "${STAGING_DIR}/usr/share/applications/ink-notes.desktop"
chmod 644 "${STAGING_DIR}/usr/share/applications/ink-notes.desktop"
cp "assets/ink-notes.svg" "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps/ink-notes.svg"
chmod 644 "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps/ink-notes.svg"
cp "assets/ink-notes.png" "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps/ink-notes.png"
chmod 644 "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps/ink-notes.png"
cp "packaging/ink-notes.metainfo.xml" "${STAGING_DIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml"
chmod 644 "${STAGING_DIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml"
# 3. DEBIAN/control Datei generieren
# DEBIAN/control Datei generieren
cat <<EOF > "${STAGING_DIR}/DEBIAN/control"
Package: ${PKG_NAME}
Version: ${VERSION}
Architecture: ${ARCH}
Maintainer: Harald <harald@pansi.eu>
Installed-Size: $(du -ks "${STAGING_DIR}/usr" | cut -f1)
Depends: libc6, libx11-6, libxcursor1, libxrandr2, libxi6, libxkbcommon0
Depends: libc6 (>= 2.38), libgcc-s1, libvulkan1, mesa-vulkan-drivers | vulkan-icd, libwayland-client0, libx11-6, libxcursor1, libxrandr2, libxi6, libxkbcommon0, xdg-utils
Section: utils
Priority: optional
Homepage: https://farmersdog.pansi.eu/harald/ink-notes
Homepage: https://gitea.pansi.eu/harald/ink-notes
Description: Latenzoptimierte Vektor-Notizbuch- und Inking-App für Linux
ink-notes kombiniert minimale Stylus-Latenz mit GPU-Hardwarebeschleunigung (wgpu),
stufenlosem Viewport, Geometrie- und Formerkennung, Objekt-Transformationen,
Multi-Dokumenten-Verwaltung und Vektor-PDF-Export.
EOF
chmod 644 "${STAGING_DIR}/DEBIAN/control"
echo "=== [3/4] Schnüre .deb-Paket mit dpkg-deb ==="
echo "=== [4/5] Schnüre .deb-Paket mit dpkg-deb ==="
dpkg-deb --build --root-owner-group "${STAGING_DIR}" "${ROOT_DIR}/target/${DEB_NAME}"
echo "=== [4/4] Paket erfolgreich erstellt: target/${DEB_NAME} ==="
echo "=== [5/5] Paket erfolgreich erstellt: target/${DEB_NAME} ==="
ls -lh "${ROOT_DIR}/target/${DEB_NAME}"
dpkg-deb -I "${ROOT_DIR}/target/${DEB_NAME}"
if command -v lintian >/dev/null 2>&1; then
echo "=== Prüfe Paket mit lintian ==="
lintian "${ROOT_DIR}/target/${DEB_NAME}" || true
fi
echo "Fertig!"
+179 -74
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# ==============================================================================
# Veröffentlicht ein Release auf dem Gitea-Server (gitea.pansi.eu)
# Lädt .deb, .AppImage und SHA256SUMS.txt als Release-Assets hoch.
# Lädt .deb, .AppImage, SHA256SUMS.txt und SHA256SUMS.txt.minisig idempotent als Release-Assets hoch.
# ==============================================================================
set -euo pipefail
@@ -14,13 +14,15 @@ GITEA_SERVER="${GITEA_SERVER:-https://gitea.pansi.eu}"
REPO_OWNER="${REPO_OWNER:-harald}"
REPO_NAME="${REPO_NAME:-ink-notes}"
# Token aus Argument oder Umgebungsvariablen ermitteln
TOKEN="${1:-${INK_NOTES_GITEA_TOKEN:-${GITEA_TOKEN:-${GITHUB_TOKEN:-}}}}"
# Token ausschließlich aus Umgebungsvariablen ermitteln (Fallback für Legacy-Aufruf mit Argument)
TOKEN="${INK_NOTES_GITEA_TOKEN:-${GITEA_TOKEN:-${GITHUB_TOKEN:-}}}"
if [ -z "${TOKEN}" ] && [ -n "${1:-}" ]; then
TOKEN="$1"
fi
if [ -z "${TOKEN}" ]; then
echo "FEHLER: Kein Gitea-API-Token angegeben."
echo "Verwendung: $0 <GITEA_API_TOKEN>"
echo "Oder setze die Umgebungsvariable INK_NOTES_GITEA_TOKEN."
echo "Bitte setze die Umgebungsvariable GITEA_TOKEN oder INK_NOTES_GITEA_TOKEN."
exit 1
fi
@@ -31,6 +33,13 @@ RELEASE_TITLE="ink-notes ${TAG_NAME}"
DEB_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_amd64.deb"
APPIMAGE_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_x86_64.AppImage"
CHECKSUMS_FILE="${ROOT_DIR}/target/SHA256SUMS.txt"
SIGNATURE_FILE="${CHECKSUMS_FILE}.minisig"
TEMPLATE_FILE="${ROOT_DIR}/packaging/release-notes.md.tmpl"
if [ ! -f "${TEMPLATE_FILE}" ]; then
echo "FEHLER: Release-Template '${TEMPLATE_FILE}' nicht gefunden."
exit 1
fi
# Sicherstellen, dass die Artefakte existieren
if [ ! -f "${DEB_FILE}" ] || [ ! -f "${APPIMAGE_FILE}" ] || [ ! -f "${CHECKSUMS_FILE}" ]; then
@@ -38,101 +47,197 @@ if [ ! -f "${DEB_FILE}" ] || [ ! -f "${APPIMAGE_FILE}" ] || [ ! -f "${CHECKSUMS_
"${SCRIPT_DIR}/build-release.sh"
fi
# Ohne Signatur lehnen installierte Apps das Release als Update ab
if [ ! -f "${SIGNATURE_FILE}" ]; then
echo "FEHLER: '${SIGNATURE_FILE}' fehlt. Release mit gesetztem MINISIGN_SECRET_KEY bauen."
exit 1
fi
echo "======================================================================"
echo "Veröffentliche Release ${TAG_NAME} auf ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}"
echo "======================================================================"
RELEASE_BODY=$(cat <<EOF
## ink-notes ${TAG_NAME}
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
### Highlights:
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Rauten, Polygone & Koordinatensysteme (Draw-and-Hold)
- **Viewport**: Stufenloser Zoom (25% - 500%) und Panning
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave
- **In-App Updater**: Automatische Versionsprüfung und Aktualisierung via Gitea Releases
- **Vektor-PDF-Export**: Verluste freie Ausgabe für Druck und Weitergabe
### Installation:
- **Zorin OS / Ubuntu / Debian (.deb)**:
\`\`\`bash
sudo dpkg -i ink-notes_${VERSION}_amd64.deb
\`\`\`
- **Portables AppImage (Alle Linux-Distributionen)**:
\`\`\`bash
chmod +x ink-notes_${VERSION}_x86_64.AppImage
./ink-notes_${VERSION}_x86_64.AppImage
\`\`\`
EOF
)
# 1. Release via Gitea REST API erstellen (oder ID abfragen, falls bereits existent)
CREATE_PAYLOAD=$(python3 -c "
import json, sys
data = {
'tag_name': '${TAG_NAME}',
'target_commitish': 'main',
'name': '${RELEASE_TITLE}',
'body': '''${RELEASE_BODY}''',
'draft': False,
'prerelease': False
# 1. Payload robust per Python aus Template erzeugen (verhindert Shell-Escaping-Probleme)
PAYLOAD_FILE="$(mktemp)"
CLEANUP_FILES=("${PAYLOAD_FILE}")
cleanup() {
for f in "${CLEANUP_FILES[@]}"; do
rm -f "${f}"
done
}
print(json.dumps(data))
")
trap cleanup EXIT
RELEASE_VERSION="${VERSION}" RELEASE_TAG="${TAG_NAME}" RELEASE_TITLE="${RELEASE_TITLE}" \
python3 -c '
import json, os, sys
template_path = sys.argv[1]
with open(template_path, "r", encoding="utf-8") as f:
template_content = f.read()
version = os.environ.get("RELEASE_VERSION", "")
tag = os.environ.get("RELEASE_TAG", "")
title = os.environ.get("RELEASE_TITLE", "")
body = template_content.replace("{{VERSION}}", version).replace("{{TAG_NAME}}", tag)
payload = {
"tag_name": tag,
"target_commitish": "main",
"name": title,
"body": body,
"draft": False,
"prerelease": False
}
print(json.dumps(payload))
' "${TEMPLATE_FILE}" > "${PAYLOAD_FILE}"
API_BASE="${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}"
echo "Erstelle Release über Gitea API..."
HTTP_RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" \
-X POST "${API_BASE}/releases" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "Authorization: token ${TOKEN}" \
-d "${CREATE_PAYLOAD}")
# 2. Prüfen, ob Release bereits existiert (Idempotenz)
echo "Prüfe bestehende Releases auf ${GITEA_SERVER}..."
TAG_RESP_FILE="$(mktemp)"
CLEANUP_FILES+=("${TAG_RESP_FILE}")
HTTP_STATUS=$(echo "${HTTP_RESPONSE}" | grep "HTTP_STATUS:" | cut -d':' -f2)
RESPONSE_BODY=$(echo "${HTTP_RESPONSE}" | sed '/HTTP_STATUS:/d')
HTTP_STATUS=$(curl -s -S -o "${TAG_RESP_FILE}" -w "%{http_code}" \
-X GET "${API_BASE}/releases/tags/${TAG_NAME}" \
-H "Accept: application/json" \
-H "Authorization: token ${TOKEN}" || echo "000")
RELEASE_ID=""
if [ "${HTTP_STATUS}" -eq 201 ]; then
RELEASE_ID=$(echo "${RESPONSE_BODY}" | python3 -c "import sys, json; print(json.load(sys.stdin)['id'])")
echo "Release erfolgreich angelegt (ID: ${RELEASE_ID})!"
else
echo "Release existiert womöglich bereits (Status ${HTTP_STATUS}). Suche bestehendes Release..."
EXISTING_RELEASE=$(curl -fsSL \
-H "Accept: application/json" \
-H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/tags/${TAG_NAME}" || true)
if [ -n "${EXISTING_RELEASE}" ]; then
RELEASE_ID=$(echo "${EXISTING_RELEASE}" | python3 -c "import sys, json; print(json.load(sys.stdin).get('id', ''))")
fi
fi
if [ -z "${RELEASE_ID}" ]; then
echo "FEHLER: Konnte Release nicht erstellen oder abfragen. Serverantwort:"
echo "${RESPONSE_BODY}"
if [ "${HTTP_STATUS}" = "200" ]; then
RELEASE_ID=$(python3 -c '
import sys, json
try:
data = json.load(open(sys.argv[1]))
print(data.get("id", ""))
except Exception:
pass
' "${TAG_RESP_FILE}")
echo "Release ${TAG_NAME} existiert bereits (ID: ${RELEASE_ID}). Aktualisiere Release-Notizen..."
PATCH_STATUS=$(curl -s -S -o /dev/null -w "%{http_code}" \
-X PATCH "${API_BASE}/releases/${RELEASE_ID}" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "Authorization: token ${TOKEN}" \
-d @"${PAYLOAD_FILE}" || echo "000")
if [ "${PATCH_STATUS}" != "200" ]; then
echo "Hinweis: Aktualisierung der Metadaten ergab HTTP ${PATCH_STATUS}."
fi
elif [ "${HTTP_STATUS}" = "404" ]; then
echo "Release existiert noch nicht. Erstelle neues Release ${TAG_NAME}..."
CREATE_RESP_FILE="$(mktemp)"
CLEANUP_FILES+=("${CREATE_RESP_FILE}")
CREATE_STATUS=$(curl -s -S -o "${CREATE_RESP_FILE}" -w "%{http_code}" \
-X POST "${API_BASE}/releases" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "Authorization: token ${TOKEN}" \
-d @"${PAYLOAD_FILE}" || echo "000")
if [ "${CREATE_STATUS}" = "201" ]; then
RELEASE_ID=$(python3 -c '
import sys, json
try:
data = json.load(open(sys.argv[1]))
print(data.get("id", ""))
except Exception:
pass
' "${CREATE_RESP_FILE}")
echo "Release erfolgreich angelegt (ID: ${RELEASE_ID})!"
else
echo "FEHLER: Release konnte nicht angelegt werden (HTTP ${CREATE_STATUS})."
cat "${CREATE_RESP_FILE}"
exit 1
fi
else
echo "FEHLER: Unerwartete Serverantwort beim Abfragen des Release-Tags (HTTP ${HTTP_STATUS})."
cat "${TAG_RESP_FILE}"
exit 1
fi
# 2. Assets hochladen
if [ -z "${RELEASE_ID}" ]; then
echo "FEHLER: Keine gültige Release-ID ermittelt."
exit 1
fi
# 3. Vorhandene Assets auflisten (um Dubletten vor Upload sauber zu löschen)
ASSETS_LIST_FILE="$(mktemp)"
CLEANUP_FILES+=("${ASSETS_LIST_FILE}")
LIST_STATUS=$(curl -s -S -o "${ASSETS_LIST_FILE}" -w "%{http_code}" \
-X GET "${API_BASE}/releases/${RELEASE_ID}/assets" \
-H "Accept: application/json" \
-H "Authorization: token ${TOKEN}" || echo "000")
if [ "${LIST_STATUS}" != "200" ]; then
echo "Hinweis: Konnte bestehende Assets nicht abrufen (HTTP ${LIST_STATUS})."
fi
upload_asset() {
local file_path="$1"
local filename="$(basename "${file_path}")"
local filename
filename="$(basename "${file_path}")"
# Asset-Namen in URL URL-kodieren
local encoded_name
encoded_name=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "${filename}")
# Prüfen, ob bereits Assets mit demselben Namen existieren (können mehrere sein)
local existing_ids=()
if [ -f "${ASSETS_LIST_FILE}" ] && [ -s "${ASSETS_LIST_FILE}" ]; then
mapfile -t existing_ids < <(python3 -c '
import sys, json
try:
assets = json.load(open(sys.argv[1]))
target = sys.argv[2]
for a in assets:
if a.get("name") == target and a.get("id"):
print(a.get("id"))
except Exception:
pass
' "${ASSETS_LIST_FILE}" "${filename}")
fi
for eid in "${existing_ids[@]}"; do
if [ -n "${eid}" ]; then
echo "Lösche bestehendes Asset '${filename}' (ID: ${eid}) vor Re-Upload..."
local del_status
del_status=$(curl -s -S -o /dev/null -w "%{http_code}" \
-X DELETE "${API_BASE}/releases/${RELEASE_ID}/assets/${eid}" \
-H "Authorization: token ${TOKEN}" || echo "000")
if [ "${del_status}" != "204" ] && [ "${del_status}" != "200" ]; then
echo "Warnung: Löschen von bestehendem Asset '${filename}' ergab HTTP ${del_status}."
fi
fi
done
echo "Lade Asset hoch: ${filename}..."
curl -fsSL \
-X POST "${API_BASE}/releases/${RELEASE_ID}/assets?name=${filename}" \
local upload_res_file="$(mktemp)"
CLEANUP_FILES+=("${upload_res_file}")
local upload_status
upload_status=$(curl -s -S -o "${upload_res_file}" -w "%{http_code}" \
-X POST "${API_BASE}/releases/${RELEASE_ID}/assets?name=${encoded_name}" \
-H "Accept: application/json" \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${file_path}" > /dev/null
echo "✓ ${filename} erfolgreich hochgeladen."
--data-binary "@${file_path}" || echo "000")
if [ "${upload_status}" = "201" ]; then
echo "✓ ${filename} erfolgreich hochgeladen."
else
echo "FEHLER: Asset-Upload für '${filename}' fehlgeschlagen (HTTP ${upload_status})."
cat "${upload_res_file}"
exit 1
fi
}
upload_asset "${DEB_FILE}"
upload_asset "${APPIMAGE_FILE}"
upload_asset "${CHECKSUMS_FILE}"
upload_asset "${SIGNATURE_FILE}"
echo "======================================================================"
echo "Release ${TAG_NAME} erfolgreich veröffentlicht!"
+902 -83
View File
File diff suppressed because it is too large. Load diff
+13 -9
View File
@@ -39,13 +39,7 @@ pub fn generate_paper_quad() -> Vec<InkVertex> {
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu.
#[inline]
fn push_quad(
vertices: &mut Vec<InkVertex>,
x_min: f32,
y_min: f32,
x_max: f32,
y_max: f32,
) {
fn push_quad(vertices: &mut Vec<InkVertex>, x_min: f32, y_min: f32, x_max: f32, y_max: f32) {
vertices.push(InkVertex::new(x_min, y_min, BACKGROUND_PATTERN_COLOR));
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR));
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR));
@@ -102,7 +96,13 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
while y < PAGE_HEIGHT {
let mut x = GRID_5MM_SPACING;
while x < PAGE_WIDTH {
push_quad(&mut vertices, x - half_d, y - half_d, x + half_d, y + half_d);
push_quad(
&mut vertices,
x - half_d,
y - half_d,
x + half_d,
y + half_d,
);
x += GRID_5MM_SPACING;
}
y += GRID_5MM_SPACING;
@@ -132,7 +132,11 @@ mod tests {
let grid = generate_background_mesh(PageBackground::Grid5mm);
assert!(!grid.is_empty());
assert_eq!(grid.len() % 6, 0, "Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)");
assert_eq!(
grid.len() % 6,
0,
"Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)"
);
let lined = generate_background_mesh(PageBackground::Lined);
assert!(!lined.is_empty());
+221
View File
@@ -0,0 +1,221 @@
//! Crash-Simulationstesthelfer für ink-notes.
//! Wird von scripts/crash-test.sh verwendet, um unvollständige Schreibvorgänge
//! und SIGKILL (kill -9) Robustheit zu verifizieren.
use std::env;
use std::path::Path;
use std::process;
use std::time::Duration;
use glam::Vec2;
use ink_notes::document::{Notebook, PageBackground, StylusSample};
use ink_notes::spline::{finalize_stroke_with_config, SplineConfig};
use ink_notes::storage::{
cleanup_orphaned_temp_files, load_notebook, load_page, save_manifest, save_notebook, save_page,
NotebookManifest, PageData,
};
fn run_init(dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
let nb = Notebook::new();
let id = dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("crash_test");
let mut m = NotebookManifest::new(id, "Crash Test Notebook");
m.sync_from_notebook(&nb);
save_notebook(dir, &nb, &mut m)?;
println!("[init] Initialized clean notebook in {:?}", dir);
Ok(())
}
fn run_worker(dir: &Path) {
let (mut notebook, mut manifest) = match load_notebook(dir) {
Ok((nb, m)) => (nb, m),
Err(_) => {
let nb = Notebook::new();
let id = dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("crash_test");
let mut m = NotebookManifest::new(id, "Crash Test Notebook");
m.sync_from_notebook(&nb);
let _ = save_notebook(dir, &nb, &mut m);
(nb, m)
}
};
let spline_cfg = SplineConfig::default();
let mut stroke_counter = manifest.next_stroke_id;
println!("[worker] Started writing loop in {:?}", dir);
loop {
// Erzeuge neuen Strich mit einigen Samples
let base_x = (stroke_counter % 500) as f32;
let samples = vec![
StylusSample {
position: Vec2::new(base_x, 100.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: Vec2::new(base_x + 10.0, 150.0),
pressure: 0.7,
timestamp: 0.01,
},
StylusSample {
position: Vec2::new(base_x + 20.0, 200.0),
pressure: 0.6,
timestamp: 0.02,
},
];
let stroke = finalize_stroke_with_config(
stroke_counter,
&samples,
[0.0, 0.0, 0.0, 1.0],
&spline_cfg,
);
stroke_counter += 1;
manifest.next_stroke_id = stroke_counter;
let page = notebook.current_page_mut();
page.add_stroke(stroke);
let page_data = PageData::from(&*page);
// 1. Schreibe Seite atomar
if let Err(e) = save_page(dir, &page_data) {
eprintln!("[worker] Error saving page: {:?}", e);
}
// 2. Aktualisiere Manifest atomar
manifest.sync_from_notebook(&notebook);
if let Err(e) = save_manifest(dir, &manifest) {
eprintln!("[worker] Error saving manifest: {:?}", e);
}
// 3. Gelegentlich Gesamtsave (wie Autosave-Flush)
if stroke_counter % 5 == 0 {
if let Err(e) = save_notebook(dir, &notebook, &mut manifest) {
eprintln!("[worker] Error saving notebook: {:?}", e);
}
}
// 4. Evtl. neue Seite anlegen und sofort sichern, bevor Manifest aktualisiert wird
if stroke_counter % 25 == 0 {
let page_idx = notebook.add_page(PageBackground::DotGrid);
notebook.current_page_index = page_idx;
let new_page_data = PageData::from(notebook.current_page());
if let Err(e) = save_page(dir, &new_page_data) {
eprintln!("[worker] Error saving new page: {:?}", e);
}
manifest.sync_from_notebook(&notebook);
if let Err(e) = save_manifest(dir, &manifest) {
eprintln!("[worker] Error saving manifest: {:?}", e);
}
}
}
}
fn run_verify(dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
println!("[verify] Checking notebook in {:?}", dir);
// 1. Bereinige eventuelle verwaiste .tmp-Dateien
let _ = cleanup_orphaned_temp_files(dir, Duration::from_secs(0));
// Prüfe, ob das Verzeichnis noch gar kein Notizbuch enthält (Prozess vor erstem Flush getötet)
let manifest_path = dir.join("manifest.json");
let manifest_bak = dir.join("manifest.json.bak");
if !manifest_path.exists() && !manifest_bak.exists() {
println!("[verify] Notebook not yet created on disk (killed prior to first save). Valid empty state.");
return Ok(());
}
// 2. Notizbuch laden
let (_notebook, manifest) = match load_notebook(dir) {
Ok(res) => res,
Err(e) => {
eprintln!("[verify] FAILED to load notebook: {:?}", e);
return Err(Box::new(e));
}
};
println!(
"[verify] Manifest loaded successfully: title='{}', pages={}, next_stroke_id={}",
manifest.title,
manifest.pages.len(),
manifest.next_stroke_id
);
// 3. Alle im Manifest registrierten Seiten laden und prüfen
for page_meta in &manifest.pages {
match load_page(dir, page_meta.id) {
Ok(page_data) => {
let page = page_data.to_page();
println!(
"[verify] Page {} verified: {} strokes, background={:?}",
page_meta.id,
page.strokes.len(),
page.background
);
}
Err(e) => {
eprintln!(
"[verify] FAILED to load page {} referenced by manifest: {:?}",
page_meta.id, e
);
return Err(Box::new(e));
}
}
}
// 4. Prüfen ob Quarantäne-Dateien existieren
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if name.contains(".corrupt-") {
println!("[verify] Notice: Found quarantined file: {}", name);
}
}
}
println!("[verify] SUCCESS: All pages and manifest are fully consistent!");
Ok(())
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 3 {
eprintln!("Usage: crash_sim <init|worker|verify> <notebook_dir>");
process::exit(2);
}
let command = &args[1];
let notebook_dir = Path::new(&args[2]);
match command.as_str() {
"init" => match run_init(notebook_dir) {
Ok(_) => process::exit(0),
Err(e) => {
eprintln!("[init] Error: {}", e);
process::exit(1);
}
},
"worker" => {
run_worker(notebook_dir);
}
"verify" => match run_verify(notebook_dir) {
Ok(_) => process::exit(0),
Err(e) => {
eprintln!("[verify] Error: {}", e);
process::exit(1);
}
},
other => {
eprintln!("Unknown command: {}", other);
process::exit(2);
}
}
}
+80 -35
View File
@@ -11,9 +11,7 @@ use std::sync::Arc;
use winit::dpi::PhysicalSize;
use winit::window::Window;
use crate::background::{
generate_background_mesh, generate_paper_quad, DESK_COLOR,
};
use crate::background::{generate_background_mesh, generate_paper_quad, DESK_COLOR};
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
use crate::filter::OneEuroFilter;
use crate::spline::{self, ControlPoint, SplineConfig};
@@ -96,6 +94,9 @@ pub struct CanvasState {
pub config: wgpu::SurfaceConfiguration,
pub size: PhysicalSize<u32>,
pub viewport: PageViewport,
/// Von seitlicher UI belegte Breite links/rechts in physischen Pixeln
ui_inset_left: f32,
ui_inset_right: f32,
pub camera: crate::viewport::ViewportState,
// Pipelines
@@ -106,6 +107,8 @@ pub struct CanvasState {
pub camera_buffer: wgpu::Buffer,
pub camera_bind_group: wgpu::BindGroup,
pub current_ink_color: [f32; 4],
pub current_stroke_width_mm: f32,
pub current_tool: crate::document::StrokeTool,
// Statische Hintergrund-Geometrie
pub paper_buffer: wgpu::Buffer,
@@ -143,7 +146,8 @@ impl CanvasState {
/// Initialisiert den CanvasState für das übergebene Winit-Fenster.
pub async fn new(window: Arc<Window>) -> Result<Self, Box<dyn std::error::Error>> {
let size = window.inner_size();
let viewport = PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN);
let viewport =
PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN);
// 1. WGPU Instance & Surface
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
@@ -187,7 +191,10 @@ impl CanvasState {
let present_mode = if std::env::var("INK_FORCE_FIFO").is_ok() {
eprintln!("Erzwinge PresentMode::Fifo (via INK_FORCE_FIFO)");
wgpu::PresentMode::Fifo
} else if surface_caps.present_modes.contains(&wgpu::PresentMode::Mailbox) {
} else if surface_caps
.present_modes
.contains(&wgpu::PresentMode::Mailbox)
{
eprintln!("Verwende Zero-Latency PresentMode::Mailbox");
wgpu::PresentMode::Mailbox
} else {
@@ -358,12 +365,7 @@ impl CanvasState {
});
// 8. egui Renderer für das schwebende UI initialisieren
let egui_renderer = egui_wgpu::Renderer::new(
&device,
config.format,
None,
1,
);
let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1);
Ok(Self {
surface,
@@ -372,11 +374,15 @@ impl CanvasState {
config,
size,
viewport,
ui_inset_left: 0.0,
ui_inset_right: 0.0,
strip_pipeline,
triangle_pipeline,
camera_buffer,
camera_bind_group,
current_ink_color: default_ink_color,
current_stroke_width_mm: 0.5,
current_tool: crate::document::StrokeTool::Pen,
paper_buffer,
background_buffer,
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
@@ -410,7 +416,34 @@ impl CanvasState {
transform_scale: scale,
transform_offset: offset,
};
self.queue.write_buffer(&self.camera_buffer, 0, bytemuck::cast_slice(&[camera_uniforms]));
self.queue.write_buffer(
&self.camera_buffer,
0,
bytemuck::cast_slice(&[camera_uniforms]),
);
}
/// Setzt die von seitlicher UI belegten Streifen (physische Pixel), damit die Seite
/// im freien Bereich zentriert wird. Gibt `true` zurück, wenn sich etwas geändert hat.
pub fn set_ui_insets(&mut self, left: f32, right: f32) -> bool {
if (self.ui_inset_left - left).abs() < 0.5 && (self.ui_inset_right - right).abs() < 0.5 {
return false;
}
self.ui_inset_left = left;
self.ui_inset_right = right;
self.recalculate_viewport();
true
}
fn recalculate_viewport(&mut self) {
self.viewport = PageViewport::calculate_with_insets(
self.size.width as f32,
self.size.height as f32,
Self::VIEWPORT_MARGIN,
self.ui_inset_left,
self.ui_inset_right,
);
self.update_uniforms();
}
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
@@ -420,26 +453,21 @@ impl CanvasState {
self.config.width = new_size.width;
self.config.height = new_size.height;
self.surface.configure(&self.device, &self.config);
self.viewport = PageViewport::calculate(
new_size.width as f32,
new_size.height as f32,
Self::VIEWPORT_MARGIN,
);
self.update_uniforms();
self.recalculate_viewport();
}
}
/// Verschiebt den Bildausschnitt um Bildschirm-Pixel delta.
pub fn pan(&mut self, delta_x: f32, delta_y: f32) {
self.camera.pan_by_screen_delta(delta_x, delta_y, &self.viewport);
self.camera
.pan_by_screen_delta(delta_x, delta_y, &self.viewport);
self.update_uniforms();
}
/// Zoomt zentriert auf eine Cursor-Bildschirmposition.
pub fn zoom_at(&mut self, cursor_x: f32, cursor_y: f32, factor: f32) {
self.camera.zoom_at_cursor([cursor_x, cursor_y], factor, &self.viewport);
self.camera
.zoom_at_cursor([cursor_x, cursor_y], factor, &self.viewport);
self.update_uniforms();
}
@@ -451,12 +479,14 @@ impl CanvasState {
/// Transformiert Fenster-Pixel in Seitenkoordinaten.
pub fn screen_to_page(&self, screen_x: f32, screen_y: f32) -> Option<glam::Vec2> {
self.camera.screen_to_page(screen_x, screen_y, &self.viewport)
self.camera
.screen_to_page(screen_x, screen_y, &self.viewport)
}
/// Transformiert Fenster-Pixel in Seitenkoordinaten und klemmt auf die Seite.
pub fn screen_to_page_clamped(&self, screen_x: f32, screen_y: f32) -> glam::Vec2 {
self.camera.screen_to_page_clamped(screen_x, screen_y, &self.viewport)
self.camera
.screen_to_page_clamped(screen_x, screen_y, &self.viewport)
}
/// Transformiert einen Punkt aus DIN-A4-Seitenkoordinaten in Fenster-Bildschirmkoordinaten.
@@ -471,7 +501,8 @@ impl CanvasState {
self.live_vertex_count = self.live_vertices.len() as u32;
if self.live_vertices.len() > self.live_vertex_capacity {
self.live_vertex_capacity = (self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY);
self.live_vertex_capacity =
(self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY);
self.live_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Live Stroke Vertex Buffer (Reallocated)"),
size: (self.live_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
@@ -496,10 +527,14 @@ impl CanvasState {
/// Konfiguriert die Strichbreite in Millimetern.
pub fn set_stroke_width_mm(&mut self, width_mm: f32) {
// 10 Einheiten = 1 mm. Radius = (width_mm * 10.0) / 2.0 = width_mm * 5.0.
let base_radius = (width_mm * 5.0).max(0.25);
self.spline_config.min_radius = (base_radius * 0.5).max(0.15);
self.spline_config.max_radius = (base_radius * 1.5).max(0.35);
self.current_stroke_width_mm = width_mm;
self.spline_config =
SplineConfig::from_width_units(width_mm * crate::document::UNITS_PER_MM);
}
/// Setzt das aktive Zeichenwerkzeug für den Stift.
pub fn set_tool(&mut self, tool: crate::document::StrokeTool) {
self.current_tool = tool;
}
/// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer.
@@ -522,7 +557,8 @@ impl CanvasState {
}
if !mesh.is_empty() {
self.queue.write_buffer(&self.background_buffer, 0, bytemuck::cast_slice(&mesh));
self.queue
.write_buffer(&self.background_buffer, 0, bytemuck::cast_slice(&mesh));
}
}
@@ -539,7 +575,8 @@ impl CanvasState {
}
if page.vertex_cache.len() > self.committed_vertex_capacity {
self.committed_vertex_capacity = (page.vertex_cache.len() * 2).max(Self::INITIAL_COMMITTED_CAPACITY);
self.committed_vertex_capacity =
(page.vertex_cache.len() * 2).max(Self::INITIAL_COMMITTED_CAPACITY);
self.committed_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Committed Strokes Vertex Buffer (Reallocated)"),
size: (self.committed_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
@@ -635,6 +672,10 @@ impl CanvasState {
self.current_ink_color,
&self.spline_config,
);
let mut stroke = stroke;
stroke.tool = self.current_tool;
stroke.width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM;
stroke.finalized = true;
// Temporären GPU-Live-Buffer vollständig leeren
self.live_samples.clear();
@@ -655,7 +696,8 @@ impl CanvasState {
}
let color = self.current_ink_color;
let raw_strip = spline::generate_triangle_strip(&self.live_control_points, &self.spline_config);
let raw_strip =
spline::generate_triangle_strip(&self.live_control_points, &self.spline_config);
self.live_vertices = raw_strip
.into_iter()
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
@@ -667,7 +709,8 @@ impl CanvasState {
}
if self.live_vertices.len() > self.live_vertex_capacity {
self.live_vertex_capacity = (self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY);
self.live_vertex_capacity =
(self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY);
self.live_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Live Stroke Vertex Buffer (Reallocated)"),
size: (self.live_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
@@ -694,7 +737,8 @@ impl CanvasState {
) -> Result<(), wgpu::SurfaceError> {
// Vor dem Renderpass: egui-Texturen aktualisieren
for (id, image_delta) in &textures_delta.set {
self.egui_renderer.update_texture(&self.device, &self.queue, *id, image_delta);
self.egui_renderer
.update_texture(&self.device, &self.queue, *id, image_delta);
}
let output = self.surface.get_current_texture()?;
@@ -784,7 +828,8 @@ impl CanvasState {
occlusion_query_set: None,
});
self.egui_renderer.render(&mut egui_pass, clipped_primitives, screen_descriptor);
self.egui_renderer
.render(&mut egui_pass, clipped_primitives, screen_descriptor);
}
self.queue.submit(std::iter::once(encoder.finish()));
+172 -23
View File
@@ -103,6 +103,16 @@ pub fn point_to_segment_distance(p: glam::Vec2, a: glam::Vec2, b: glam::Vec2) ->
(p - projection).length()
}
/// Das Werkzeug, mit dem ein Strich gezeichnet wurde.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StrokeTool {
#[default]
Pen,
Highlighter,
Shape,
}
/// Ein einzelner Vektor-Strich auf einer Seite.
#[derive(Debug, Clone, PartialEq)]
pub struct Stroke {
@@ -112,15 +122,26 @@ pub struct Stroke {
pub aabb: Aabb,
pub color: [f32; 4],
pub shape: Option<crate::geometry::RecognizedShape>,
pub tool: StrokeTool,
pub width: f32,
pub finalized: bool,
}
impl Stroke {
pub fn new(id: u64, points: Vec<StylusSample>, vertices: Vec<InkVertex>, color: [f32; 4]) -> Self {
pub fn new(
id: u64,
points: Vec<StylusSample>,
vertices: Vec<InkVertex>,
color: [f32; 4],
) -> Self {
let aabb = if !points.is_empty() {
let pts: Vec<glam::Vec2> = points.iter().map(|s| s.position).collect();
Aabb::from_points(&pts)
} else if !vertices.is_empty() {
let pts: Vec<glam::Vec2> = vertices.iter().map(|v| glam::Vec2::new(v.position[0], v.position[1])).collect();
let pts: Vec<glam::Vec2> = vertices
.iter()
.map(|v| glam::Vec2::new(v.position[0], v.position[1]))
.collect();
Aabb::from_points(&pts)
} else {
Aabb::new(glam::Vec2::ZERO, glam::Vec2::ZERO)
@@ -133,6 +154,9 @@ impl Stroke {
aabb,
color,
shape: None,
tool: StrokeTool::Pen,
width: 5.0,
finalized: true,
}
}
@@ -149,6 +173,22 @@ impl Stroke {
/// Verknüpft diesen Strich mit einer mathematisch erkannten Form.
pub fn with_shape(mut self, shape: crate::geometry::RecognizedShape) -> Self {
self.shape = Some(shape);
self.tool = StrokeTool::Shape;
self
}
pub fn with_tool(mut self, tool: StrokeTool) -> Self {
self.tool = tool;
self
}
pub fn with_width(mut self, width: f32) -> Self {
self.width = width;
self
}
pub fn with_finalized(mut self, finalized: bool) -> Self {
self.finalized = finalized;
self
}
@@ -171,7 +211,12 @@ impl Stroke {
}
/// Prüft, ob dieser Strich von einem Radierer an Position `eraser_pos` mit Radius `eraser_radius` getroffen wird.
pub fn intersects_eraser(&self, eraser_pos: glam::Vec2, eraser_radius: f32, max_stroke_radius: f32) -> bool {
pub fn intersects_eraser(
&self,
eraser_pos: glam::Vec2,
eraser_radius: f32,
max_stroke_radius: f32,
) -> bool {
// 1. Vorabtest: Schneidet der Radierer die vergrößerte AABB?
let total_margin = eraser_radius + max_stroke_radius;
if !self.aabb.expanded(total_margin).contains_point(eraser_pos) {
@@ -254,9 +299,7 @@ impl Stroke {
/// Skaliert den Strich bezüglich `origin` um die Faktoren `factors`.
pub fn scale(&mut self, origin: glam::Vec2, factors: glam::Vec2) {
let sc = |pos: glam::Vec2| -> glam::Vec2 {
origin + (pos - origin) * factors
};
let sc = |pos: glam::Vec2| -> glam::Vec2 { origin + (pos - origin) * factors };
for pt in &mut self.points {
pt.position = sc(pt.position);
@@ -293,6 +336,22 @@ pub enum PageBackground {
DotGrid,
}
/// Persistenzstatus einer Seite zum Schutz vor unbeabsichtigtem Überschreiben bei Ladefehlern.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PersistState {
#[default]
Normal,
ReadOnlyAfterLoadError,
}
/// Warnung beim Laden einer Seite (beschädigte Datei oder I/O-Fehler).
#[derive(Debug, Clone, PartialEq)]
pub struct PageLoadWarning {
pub page_id: u64,
pub message: String,
pub quarantine_path: Option<std::path::PathBuf>,
}
/// Eine einzelne Notizbuchseite mit Strichen, Hintergrund und gecachten Meshes.
#[derive(Debug, Clone)]
pub struct Page {
@@ -303,6 +362,7 @@ pub struct Page {
pub stroke_ranges: Vec<Range<u32>>,
pub is_dirty: bool,
pub is_loaded: bool,
pub persist: PersistState,
pub history: HistoryStack,
}
@@ -316,6 +376,7 @@ impl Page {
stroke_ranges: Vec::new(),
is_dirty: false,
is_loaded: true,
persist: PersistState::Normal,
history: HistoryStack::new(),
}
}
@@ -330,10 +391,19 @@ impl Page {
stroke_ranges: Vec::new(),
is_dirty: false,
is_loaded: false,
persist: PersistState::Normal,
history: HistoryStack::new(),
}
}
pub fn is_readonly(&self) -> bool {
self.persist == PersistState::ReadOnlyAfterLoadError
}
pub fn unlock_after_load_error(&mut self) {
self.persist = PersistState::Normal;
}
/// Fügt einen neuen Strich hinzu und registriert ihn im History-Stack.
pub fn add_stroke(&mut self, stroke: Stroke) {
self.history.push(HistoryAction::AddStroke(stroke.clone()));
@@ -402,7 +472,12 @@ impl Page {
/// Radiert alle Striche an der Position `eraser_pos` mit Radius `eraser_radius`.
///
/// Gibt die gelöschten Striche zurück (falls welche getroffen wurden).
pub fn erase_at(&mut self, eraser_pos: glam::Vec2, eraser_radius: f32, max_stroke_radius: f32) -> Vec<Stroke> {
pub fn erase_at(
&mut self,
eraser_pos: glam::Vec2,
eraser_radius: f32,
max_stroke_radius: f32,
) -> Vec<Stroke> {
let mut hit_indices = Vec::new();
for (i, stroke) in self.strokes.iter().enumerate() {
@@ -422,7 +497,8 @@ impl Page {
}
removed.reverse();
self.history.push(HistoryAction::RemoveStrokes(removed.clone()));
self.history
.push(HistoryAction::RemoveStrokes(removed.clone()));
self.is_dirty = true;
removed
@@ -473,7 +549,11 @@ impl Page {
/// Liefert Klone der Striche mit den angegebenen IDs.
pub fn get_strokes(&self, ids: &[u64]) -> Vec<Stroke> {
self.strokes.iter().filter(|s| ids.contains(&s.id)).cloned().collect()
self.strokes
.iter()
.filter(|s| ids.contains(&s.id))
.cloned()
.collect()
}
/// Aktualisiert eine Menge geänderter Striche transaktional im History-Stack.
@@ -482,7 +562,12 @@ impl Page {
return;
}
let ids: Vec<u64> = modified.iter().map(|s| s.id).collect();
let before: Vec<Stroke> = self.strokes.iter().filter(|s| ids.contains(&s.id)).cloned().collect();
let before: Vec<Stroke> = self
.strokes
.iter()
.filter(|s| ids.contains(&s.id))
.cloned()
.collect();
for m in modified.iter() {
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) {
@@ -517,7 +602,8 @@ impl Page {
self.strokes[pos] = m.clone();
}
}
self.history.push(HistoryAction::ModifyStrokes { before, after });
self.history
.push(HistoryAction::ModifyStrokes { before, after });
self.is_dirty = true;
}
@@ -534,7 +620,8 @@ impl Page {
});
if !removed.is_empty() {
self.history.push(HistoryAction::RemoveStrokes(removed.clone()));
self.history
.push(HistoryAction::RemoveStrokes(removed.clone()));
self.is_dirty = true;
}
removed
@@ -545,7 +632,8 @@ impl Page {
if new_strokes.is_empty() {
return;
}
self.history.push(HistoryAction::AddStrokes(new_strokes.clone()));
self.history
.push(HistoryAction::AddStrokes(new_strokes.clone()));
self.strokes.extend(new_strokes);
self.is_dirty = true;
}
@@ -558,6 +646,7 @@ pub struct Notebook {
pub current_page_index: usize,
pub next_stroke_id: u64,
pub next_page_id: u64,
pub load_warnings: std::collections::HashMap<u64, PageLoadWarning>,
}
impl Notebook {
@@ -567,6 +656,7 @@ impl Notebook {
current_page_index: 0,
next_stroke_id: 1,
next_page_id: 1,
load_warnings: std::collections::HashMap::new(),
};
// Erste Standardseite anlegen (5mm Grid)
nb.add_page(PageBackground::Grid5mm);
@@ -585,6 +675,7 @@ impl Notebook {
current_page_index,
next_stroke_id,
next_page_id,
load_warnings: std::collections::HashMap::new(),
};
if nb.pages.is_empty() {
nb.add_page(PageBackground::Grid5mm);
@@ -653,7 +744,11 @@ impl Notebook {
/// Text für das On-Screen-Display (OSD), z. B. "Seite 3 / 12".
pub fn osd_text(&self) -> String {
format!("Seite {} / {}", self.current_page_index + 1, self.pages.len())
format!(
"Seite {} / {}",
self.current_page_index + 1,
self.pages.len()
)
}
}
@@ -677,7 +772,21 @@ pub struct PageViewport {
impl PageViewport {
/// Berechnet die zentrierte DIN-A4-Projektionsfläche unter Beibehaltung des 210x297-Seitenverhältnisses.
pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self {
let avail_w = (window_width - 2.0 * margin).max(10.0);
Self::calculate_with_insets(window_width, window_height, margin, 0.0, 0.0)
}
/// Wie [`Self::calculate`], aber ohne die von seitlicher UI (z. B. Werkzeugleiste)
/// belegten Streifen links/rechts: Die Seite wird im verbleibenden Bereich zentriert.
pub fn calculate_with_insets(
window_width: f32,
window_height: f32,
margin: f32,
inset_left: f32,
inset_right: f32,
) -> Self {
let inset_left = inset_left.clamp(0.0, window_width * 0.5);
let inset_right = inset_right.clamp(0.0, window_width * 0.5);
let avail_w = (window_width - inset_left - inset_right - 2.0 * margin).max(10.0);
let avail_h = (window_height - 2.0 * margin).max(10.0);
let window_ratio = avail_w / avail_h;
@@ -695,7 +804,7 @@ impl PageViewport {
(w, h)
};
let page_x = (window_width - page_w) / 2.0;
let page_x = inset_left + (window_width - inset_left - inset_right - page_w) / 2.0;
let page_y = (window_height - page_h) / 2.0;
Self {
@@ -787,13 +896,25 @@ mod tests {
let stroke_radius = 2.0;
// Treffer: Radierer bei (150, 105) (Abstand 5 <= 12)
assert!(stroke.intersects_eraser(glam::Vec2::new(150.0, 105.0), eraser_radius, stroke_radius));
assert!(stroke.intersects_eraser(
glam::Vec2::new(150.0, 105.0),
eraser_radius,
stroke_radius
));
// Kein Treffer: Radierer bei (150, 150) (Abstand 50 > 12)
assert!(!stroke.intersects_eraser(glam::Vec2::new(150.0, 150.0), eraser_radius, stroke_radius));
assert!(!stroke.intersects_eraser(
glam::Vec2::new(150.0, 150.0),
eraser_radius,
stroke_radius
));
// Kein Treffer: Weit außerhalb der AABB
assert!(!stroke.intersects_eraser(glam::Vec2::new(500.0, 500.0), eraser_radius, stroke_radius));
assert!(!stroke.intersects_eraser(
glam::Vec2::new(500.0, 500.0),
eraser_radius,
stroke_radius
));
}
#[test]
@@ -824,6 +945,25 @@ mod tests {
assert_eq!(nb.current_page_index, 0);
}
#[test]
fn test_viewport_insets_center_page_in_free_area() {
// Breites Fenster: Seite wird durch die Höhe begrenzt und im freien Bereich zentriert
let plain = PageViewport::calculate(1600.0, 900.0, 20.0);
let inset = PageViewport::calculate_with_insets(1600.0, 900.0, 20.0, 120.0, 0.0);
assert!((inset.page_width - plain.page_width).abs() < 0.01);
let free_center = 120.0 + (1600.0 - 120.0) / 2.0;
assert!((inset.page_x + inset.page_width / 2.0 - free_center).abs() < 0.01);
// Hohes Fenster: Breite begrenzt, Seite wird schmaler und bleibt rechts der Leiste
let narrow = PageViewport::calculate_with_insets(600.0, 1200.0, 20.0, 100.0, 0.0);
assert!(narrow.page_x >= 100.0 + 20.0 - 0.01);
assert!(narrow.page_x + narrow.page_width <= 600.0 - 20.0 + 0.01);
// Leiste rechts
let right = PageViewport::calculate_with_insets(600.0, 1200.0, 20.0, 0.0, 100.0);
assert!(right.page_x + right.page_width <= 500.0 - 20.0 + 0.01);
}
#[test]
fn test_page_viewport_projection() {
let vp = PageViewport::calculate(1600.0, 900.0, 20.0);
@@ -966,9 +1106,12 @@ mod tests {
let mut moved = s.clone();
moved.translate(glam::Vec2::new(20.0, 30.0));
page.update_strokes_ephemeral(&[moved.clone()]);
assert_eq!(page.strokes[0].points[0].position, glam::Vec2::new(30.0, 40.0));
assert_eq!(
page.strokes[0].points[0].position,
glam::Vec2::new(30.0, 40.0)
);
// History should not have been updated yet
assert_eq!(page.history.can_redo(), false);
assert!(!page.history.can_redo());
// Transactional commit (mouse release)
let before = vec![s.clone()];
@@ -977,10 +1120,16 @@ mod tests {
// Verify Undo restores original position
assert!(page.undo());
assert_eq!(page.strokes[0].points[0].position, glam::Vec2::new(10.0, 10.0));
assert_eq!(
page.strokes[0].points[0].position,
glam::Vec2::new(10.0, 10.0)
);
// Verify Redo applies modified position
assert!(page.redo());
assert_eq!(page.strokes[0].points[0].position, glam::Vec2::new(30.0, 40.0));
assert_eq!(
page.strokes[0].points[0].position,
glam::Vec2::new(30.0, 40.0)
);
}
}
+137 -26
View File
@@ -12,7 +12,7 @@
use std::error::Error;
use std::fs::File;
use std::io::BufWriter;
use std::path::Path;
use std::path::{Path, PathBuf};
use printpdf::path::{PaintMode, WindingOrder};
use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb};
@@ -22,6 +22,70 @@ use crate::document::{
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
};
/// Bereinigt einen Titel für die Verwendung in Dateinamen (entfernt illegale Zeichen).
pub fn sanitize_filename_component(title: &str) -> String {
let sanitized: String = title
.chars()
.map(|c| match c {
'/' | '\\' | '\0' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
c => c,
})
.collect();
let trimmed = sanitized.trim();
if trimmed.is_empty() {
"Notizbuch".to_string()
} else {
trimmed.to_string()
}
}
/// Ermittelt das Standard-Dokumentenverzeichnis ($XDG_DOCUMENTS_DIR mit Fallback ~/Documents).
pub fn default_documents_dir() -> PathBuf {
if let Ok(dir) = std::env::var("XDG_DOCUMENTS_DIR") {
if !dir.trim().is_empty() {
return PathBuf::from(dir);
}
}
if let Ok(home) = std::env::var("HOME") {
if !home.trim().is_empty() {
return PathBuf::from(home).join("Documents");
}
}
PathBuf::from(".")
}
/// Ermittelt den kollisionsfreien Export-Pfad für ein Notizbuch im übergebenen Verzeichnis.
/// Existiert die Datei bereits, wird ein Zähler angehängt (`_1`, `_2`, etc.).
pub fn resolve_pdf_export_path_in_dir(dir: &Path, title: &str) -> PathBuf {
let sanitized_title = sanitize_filename_component(title);
let base_name = format!("{}.pdf", sanitized_title);
let primary_path = dir.join(&base_name);
if !primary_path.exists() {
return primary_path;
}
let mut counter = 1;
loop {
let candidate_name = format!("{}_{}.pdf", sanitized_title, counter);
let candidate_path = dir.join(candidate_name);
if !candidate_path.exists() {
return candidate_path;
}
counter += 1;
}
}
/// Ermittelt das Ziel für den PDF-Export unter Berücksichtigung von XDG_DOCUMENTS_DIR
/// und automatischer Namenskollisionsvermeidung. Erstellt das Verzeichnis, falls nötig.
pub fn resolve_pdf_export_path(title: &str) -> PathBuf {
let docs_dir = default_documents_dir();
let _ = std::fs::create_dir_all(&docs_dir);
resolve_pdf_export_path_in_dir(&docs_dir, title)
}
/// Wählbare Exportstrategie für Vektorstriche.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ExportStrategy {
@@ -75,15 +139,21 @@ pub fn generate_pdf_document(
if notebook.pages.is_empty() {
return Err("Notizbuch enthält keine Seiten".into());
}
// Nicht geladene Seiten würden sonst stillschweigend als leere Seiten exportiert
if let Some(page) = notebook.pages.iter().find(|p| {
!p.is_loaded || p.persist == crate::document::PersistState::ReadOnlyAfterLoadError
}) {
return Err(format!(
"Seite {} ist nicht geladen – Export abgebrochen (vorher storage::load_all_pages aufrufen)",
page.id
)
.into());
}
// 1. Erstes Dokument mit Seite 1 erstellen
let title = "ink-notes Notebook";
let (doc, page1_idx, layer1_bg) = PdfDocument::new(
title,
Mm(A4_WIDTH_MM),
Mm(A4_HEIGHT_MM),
"Background",
);
let (doc, page1_idx, layer1_bg) =
PdfDocument::new(title, Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
// Seite 1 befüllen
let page1_ref = doc.get_page(page1_idx);
@@ -95,11 +165,7 @@ pub fn generate_pdf_document(
// 2. Alle nachfolgenden Seiten hinzufügen
for page in &notebook.pages[1..] {
let (page_idx, layer_bg) = doc.add_page(
Mm(A4_WIDTH_MM),
Mm(A4_HEIGHT_MM),
"Background",
);
let (page_idx, layer_bg) = doc.add_page(Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
let page_ref = doc.get_page(page_idx);
let layer_bg_ref = page_ref.get_layer(layer_bg);
render_background_layer(&layer_bg_ref, page.background);
@@ -112,10 +178,7 @@ pub fn generate_pdf_document(
}
/// Rendert das Vektorraster auf Layer 1 ("Background").
fn render_background_layer(
layer: &printpdf::PdfLayerReference,
background: PageBackground,
) {
fn render_background_layer(layer: &printpdf::PdfLayerReference, background: PageBackground) {
let grid_color = Color::Rgb(Rgb::new(0.88, 0.88, 0.88, None));
layer.set_outline_color(grid_color.clone());
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
@@ -203,11 +266,7 @@ fn render_background_layer(
}
/// Rendert alle Striche einer Seite auf Layer 2 ("Ink").
fn render_ink_layer(
layer: &printpdf::PdfLayerReference,
page: &Page,
strategy: ExportStrategy,
) {
fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) {
for stroke in &page.strokes {
if let Some(ref shape) = stroke.shape {
render_recognized_shape(layer, shape, stroke.color);
@@ -326,7 +385,12 @@ fn render_recognized_shape(
layer.add_polygon(poly);
}
RecognizedShape::Octahedron { center, radius_x, radius_y, depth } => {
RecognizedShape::Octahedron {
center,
radius_x,
radius_y,
depth,
} => {
let edges = crate::geometry::octahedron_edges(*center, *radius_x, *radius_y, *depth);
for (p1, p2) in edges {
let (x1, y1) = page_to_pdf_mm(p1.x, p1.y);
@@ -514,7 +578,8 @@ fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
layer.set_line_join_style(printpdf::LineJoinStyle::Round);
// Durchschnittliche Linienstärke aus dem Druck berechnen
let avg_pressure = stroke.points.iter().map(|p| p.pressure).sum::<f32>() / stroke.points.len() as f32;
let avg_pressure =
stroke.points.iter().map(|p| p.pressure).sum::<f32>() / stroke.points.len() as f32;
let radius_mm = (avg_pressure * 0.75 + 0.25).max(0.25);
let thickness_pt = radius_mm * 2.0 * (72.0 / 25.4); // mm in DTP-Punkte
layer.set_outline_thickness(thickness_pt);
@@ -693,11 +758,18 @@ mod tests {
let temp_dir = std::env::temp_dir();
let pdf_path = temp_dir.join("ink_notes_test_export.pdf");
let export_res = export_notebook_to_pdf(&notebook, &pdf_path, ExportStrategy::FilledPolygon);
assert!(export_res.is_ok(), "PDF-Export zu Datei muss erfolgreich sein");
let export_res =
export_notebook_to_pdf(&notebook, &pdf_path, ExportStrategy::FilledPolygon);
assert!(
export_res.is_ok(),
"PDF-Export zu Datei muss erfolgreich sein"
);
// Datei-Existenz und Größe prüfen
assert!(pdf_path.exists(), "Die generierte PDF-Datei muss existieren");
assert!(
pdf_path.exists(),
"Die generierte PDF-Datei muss existieren"
);
let metadata = std::fs::metadata(&pdf_path).unwrap();
assert!(
metadata.len() > 1024,
@@ -821,4 +893,43 @@ mod tests {
assert!(bytes.len() > 1024);
assert!(bytes.starts_with(b"%PDF-1."));
}
#[test]
fn test_sanitize_filename_component() {
assert_eq!(sanitize_filename_component("Notizbuch 1"), "Notizbuch 1");
assert_eq!(
sanitize_filename_component("Projekt/2026: Entwurf"),
"Projekt_2026_ Entwurf"
);
assert_eq!(sanitize_filename_component(" "), "Notizbuch");
assert_eq!(sanitize_filename_component(""), "Notizbuch");
assert_eq!(
sanitize_filename_component("Mathe * Vorlesung?"),
"Mathe _ Vorlesung_"
);
}
#[test]
fn test_resolve_pdf_export_path_collision_avoidance() {
let temp_dir = tempfile::tempdir().expect("Konnte tempdir nicht erstellen");
let dir = temp_dir.path();
// 1. Erstmaliger Export: keine Kollision
let path1 = resolve_pdf_export_path_in_dir(dir, "Mathe Vorlesung");
assert_eq!(path1, dir.join("Mathe Vorlesung.pdf"));
// Datei anlegen
std::fs::write(&path1, b"dummy pdf 1").expect("Konnte Datei nicht schreiben");
// 2. Zweiter Export: Kollision -> _1
let path2 = resolve_pdf_export_path_in_dir(dir, "Mathe Vorlesung");
assert_eq!(path2, dir.join("Mathe Vorlesung_1.pdf"));
// Zweite Datei anlegen
std::fs::write(&path2, b"dummy pdf 2").expect("Konnte Datei nicht schreiben");
// 3. Dritter Export: Kollision -> _2
let path3 = resolve_pdf_export_path_in_dir(dir, "Mathe Vorlesung");
assert_eq!(path3, dir.join("Mathe Vorlesung_2.pdf"));
}
}
+28 -7
View File
@@ -265,9 +265,18 @@ mod tests {
// 4. dt > 1.0 s (großer Zeitsprung z.B. auf t = 5.0 s): Muss reset() auslösen
let (x4, y4, p4) = filter.filter(50.0, 100.0, 0.9, 5.0);
// Nach dem Reset muss der Filter sofort exakt den neuen Punkt als Startpunkt annehmen
assert_eq!(x4, 50.0, "Nach Reset muss der neue X-Wert übernommen werden");
assert_eq!(y4, 100.0, "Nach Reset muss der neue Y-Wert übernommen werden");
assert_eq!(p4, 0.9, "Nach Reset muss der neue Druckwert übernommen werden");
assert_eq!(
x4, 50.0,
"Nach Reset muss der neue X-Wert übernommen werden"
);
assert_eq!(
y4, 100.0,
"Nach Reset muss der neue Y-Wert übernommen werden"
);
assert_eq!(
p4, 0.9,
"Nach Reset muss der neue Druckwert übernommen werden"
);
}
#[test]
@@ -344,9 +353,18 @@ mod tests {
// 2. Vergiftungsversuch mit NaN und Infinity
let (x_nan, y_nan, p_nan) = filter.filter(f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.02);
assert!(x_nan.is_finite(), "Filter darf kein NaN bei Vergiftung ausgeben");
assert!(y_nan.is_finite(), "Filter darf kein Inf bei Vergiftung ausgeben");
assert!(p_nan.is_finite(), "Filter darf kein -Inf bei Vergiftung ausgeben");
assert!(
x_nan.is_finite(),
"Filter darf kein NaN bei Vergiftung ausgeben"
);
assert!(
y_nan.is_finite(),
"Filter darf kein Inf bei Vergiftung ausgeben"
);
assert!(
p_nan.is_finite(),
"Filter darf kein -Inf bei Vergiftung ausgeben"
);
let (x_nan2, y_nan2, p_nan2) = filter.filter(12.0, 22.0, 0.6, f64::NAN);
assert!(x_nan2.is_finite());
@@ -364,6 +382,9 @@ mod tests {
assert!(x3.is_finite());
assert!(y3.is_finite());
assert!(p3.is_finite());
assert!(x3 > x2, "Filter muss sich nach Vergiftung weiter normal anpassen");
assert!(
x3 > x2,
"Filter muss sich nach Vergiftung weiter normal anpassen"
);
}
}
+964 -161
View File
File diff suppressed because it is too large. Load diff
+8 -4
View File
@@ -83,7 +83,8 @@ impl HistoryStack {
if let Some(pos) = strokes.iter().position(|s| s.id == new.id) {
strokes[pos] = old.clone();
}
self.redo_stack.push(HistoryAction::ReplaceStroke { old, new });
self.redo_stack
.push(HistoryAction::ReplaceStroke { old, new });
}
HistoryAction::ModifyStrokes { before, after } => {
for b in &before {
@@ -91,7 +92,8 @@ impl HistoryStack {
strokes[pos] = b.clone();
}
}
self.redo_stack.push(HistoryAction::ModifyStrokes { before, after });
self.redo_stack
.push(HistoryAction::ModifyStrokes { before, after });
}
}
@@ -126,7 +128,8 @@ impl HistoryStack {
if let Some(pos) = strokes.iter().position(|s| s.id == old.id) {
strokes[pos] = new.clone();
}
self.undo_stack.push(HistoryAction::ReplaceStroke { old, new });
self.undo_stack
.push(HistoryAction::ReplaceStroke { old, new });
}
HistoryAction::ModifyStrokes { before, after } => {
for a in &after {
@@ -134,7 +137,8 @@ impl HistoryStack {
strokes[pos] = a.clone();
}
}
self.undo_stack.push(HistoryAction::ModifyStrokes { before, after });
self.undo_stack
.push(HistoryAction::ModifyStrokes { before, after });
}
}
+1
View File
@@ -9,6 +9,7 @@ pub mod filter;
pub mod geometry;
pub mod history;
pub mod selection;
pub mod settings;
pub mod spline;
pub mod storage;
pub mod ui;
+1106 -359
View File
File diff suppressed because it is too large. Load diff
+58 -25
View File
@@ -2,8 +2,8 @@
//!
//! Stellt transaktionale Manipulationen für einzelne oder mehrere Striche und geometrische Formen bereit.
use std::f32::consts::FRAC_PI_2;
use glam::Vec2;
use std::f32::consts::FRAC_PI_2;
use crate::canvas::CanvasState;
use crate::document::{Aabb, Page, Stroke};
@@ -115,12 +115,15 @@ impl SelectionState {
Page::selection_aabb(&strokes)
}
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten durch.
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten
/// (physische Pixel) durch. Abstände und Trefferradien sind in logischen Punkten festgelegt
/// und werden mit `pixels_per_point` skaliert, damit sie zur gezeichneten Überlagerung passen.
pub fn hit_test_handles(
&self,
page: &Page,
canvas: &CanvasState,
screen_pos: Vec2,
pixels_per_point: f32,
) -> SelectionHandleHit {
let Some(aabb) = self.get_selection_aabb(page) else {
return SelectionHandleHit::None;
@@ -134,9 +137,10 @@ impl SelectionState {
let screen_center = (screen_min + screen_max) * 0.5;
let page_center = (aabb.min + aabb.max) * 0.5;
// 1. Drehgriff testen (26 px oberhalb des oberen Randes)
let rot_screen = Vec2::new(screen_center.x, screen_min.y - 26.0);
if (screen_pos - rot_screen).length() <= 14.0 {
// 1. Drehgriff testen (oberhalb des oberen Randes, wie gezeichnet)
let ppp = pixels_per_point;
let rot_screen = Vec2::new(screen_center.x, screen_min.y - ROTATE_HANDLE_OFFSET * ppp);
if (screen_pos - rot_screen).length() <= HANDLE_HIT_RADIUS * ppp {
return SelectionHandleHit::RotateHandle {
center_page: page_center,
};
@@ -144,16 +148,16 @@ impl SelectionState {
// 2. 4 Eck-Skalierungsgriffe testen
let corners_page = [
aabb.min, // 0: oben-links
Vec2::new(aabb.max.x, aabb.min.y), // 1: oben-rechts
aabb.max, // 2: unten-rechts
Vec2::new(aabb.min.x, aabb.max.y), // 3: unten-links
aabb.min, // 0: oben-links
Vec2::new(aabb.max.x, aabb.min.y), // 1: oben-rechts
aabb.max, // 2: unten-rechts
Vec2::new(aabb.min.x, aabb.max.y), // 3: unten-links
];
let fixed_corners_page = [
aabb.max, // gegenüber 0 -> unten-rechts
Vec2::new(aabb.min.x, aabb.max.y), // gegenüber 1 -> unten-links
aabb.min, // gegenüber 2 -> oben-links
Vec2::new(aabb.max.x, aabb.min.y), // gegenüber 3 -> oben-rechts
aabb.max, // gegenüber 0 -> unten-rechts
Vec2::new(aabb.min.x, aabb.max.y), // gegenüber 1 -> unten-links
aabb.min, // gegenüber 2 -> oben-links
Vec2::new(aabb.max.x, aabb.min.y), // gegenüber 3 -> oben-rechts
];
let corners_screen = [
@@ -164,7 +168,7 @@ impl SelectionState {
];
for i in 0..4 {
if (screen_pos - corners_screen[i]).length() <= 12.0 {
if (screen_pos - corners_screen[i]).length() <= HANDLE_HIT_RADIUS * ppp {
return SelectionHandleHit::CornerHandle {
corner_idx: i,
fixed_corner_page: fixed_corners_page[i],
@@ -273,6 +277,16 @@ impl SelectionState {
}
/// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui.
/// Abstand des Drehgriffs über dem Auswahlrahmen (logische Punkte).
const ROTATE_HANDLE_OFFSET: f32 = 24.0;
/// Trefferradius der Griffe (logische Punkte), groß genug für Finger.
const HANDLE_HIT_RADIUS: f32 = 18.0;
/// Wandelt Fensterpixel (physisch) in egui-Koordinaten (logische Punkte) um.
fn to_ui(screen: [f32; 2], pixels_per_point: f32) -> egui::Pos2 {
egui::pos2(screen[0] / pixels_per_point, screen[1] / pixels_per_point)
}
pub fn render_selection_overlay(
ctx: &egui::Context,
selection: &SelectionState,
@@ -283,11 +297,17 @@ pub fn render_selection_overlay(
egui::Order::Foreground,
egui::Id::new("selection_overlay_layer"),
));
// Fensterpixel sind physisch, egui zeichnet in logischen Punkten
let ppp = ctx.pixels_per_point();
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
if let DragMode::BoxSelect { start_page, current_page } = selection.drag_mode {
let p1 = canvas.page_to_screen(start_page);
let p2 = canvas.page_to_screen(current_page);
if let DragMode::BoxSelect {
start_page,
current_page,
} = selection.drag_mode
{
let p1 = to_ui(canvas.page_to_screen(start_page), ppp);
let p2 = to_ui(canvas.page_to_screen(current_page), ppp);
let min_x = p1[0].min(p2[0]);
let min_y = p1[1].min(p2[1]);
let max_x = p1[0].max(p2[0]);
@@ -299,12 +319,15 @@ pub fn render_selection_overlay(
painter.rect_filled(
rect,
egui::Rounding::same(4.0_f32),
egui::Color32::from_rgba_premultiplied(40, 120, 240, 35),
egui::Color32::from_rgba_unmultiplied(40, 120, 240, 35),
);
painter.rect_stroke(
rect,
egui::Rounding::same(4.0_f32),
egui::Stroke::new(1.5_f32, egui::Color32::from_rgba_premultiplied(80, 160, 255, 220)),
egui::Stroke::new(
1.5_f32,
egui::Color32::from_rgba_unmultiplied(80, 160, 255, 220),
),
);
return;
}
@@ -314,8 +337,8 @@ pub fn render_selection_overlay(
return;
};
let p_min = canvas.page_to_screen(aabb.min);
let p_max = canvas.page_to_screen(aabb.max);
let p_min = to_ui(canvas.page_to_screen(aabb.min), ppp);
let p_max = to_ui(canvas.page_to_screen(aabb.max), ppp);
let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1]));
let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1]));
@@ -331,7 +354,7 @@ pub fn render_selection_overlay(
// Dreh-Stängel (Stalk) und Drehgriff
let top_center = egui::pos2(rect.center().x, rect.min.y);
let rot_handle = egui::pos2(rect.center().x, rect.min.y - 24.0_f32);
let rot_handle = egui::pos2(rect.center().x, rect.min.y - ROTATE_HANDLE_OFFSET);
painter.line_segment(
[top_center, rot_handle],
@@ -357,7 +380,11 @@ pub fn render_selection_overlay(
for c in corners {
let handle_rect = egui::Rect::from_center_size(c, egui::vec2(handle_size, handle_size));
painter.rect_filled(handle_rect, egui::Rounding::same(1.5_f32), egui::Color32::WHITE);
painter.rect_filled(
handle_rect,
egui::Rounding::same(1.5_f32),
egui::Color32::WHITE,
);
painter.rect_stroke(
handle_rect,
egui::Rounding::same(1.5_f32),
@@ -369,7 +396,11 @@ pub fn render_selection_overlay(
let badge_text = format!(
"{} {} | [R] Drehen [Entf] Löschen",
selection.count(),
if selection.count() == 1 { "Objekt" } else { "Objekte" }
if selection.count() == 1 {
"Objekt"
} else {
"Objekte"
}
);
let badge_pos = egui::pos2(rect.center().x, rect.max.y + 16.0);
painter.text(
@@ -459,7 +490,9 @@ mod tests {
let orig_pos = page.strokes[0].points[0].position;
assert!(sel.rotate_selected_90_deg(&mut page));
let rotated_pos = page.strokes[0].points[0].position;
assert!((orig_pos.x - rotated_pos.x).abs() > 0.1 || (orig_pos.y - rotated_pos.y).abs() > 0.1);
assert!(
(orig_pos.x - rotated_pos.x).abs() > 0.1 || (orig_pos.y - rotated_pos.y).abs() > 0.1
);
// Undo rotate
assert!(page.undo());
+84
View File
@@ -0,0 +1,84 @@
//! Persistente UI-Einstellungen (`$XDG_CONFIG_HOME/ink-notes/ui.json`).
//!
//! Fehlende oder unlesbare Dateien führen zu Standardwerten, damit die App immer startet.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::storage::{self, StorageError};
/// Fensterseite, an der die Werkzeugleiste angedockt ist.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum SidebarSide {
/// Links (Standard: die Schreibhand von Rechtshändern verdeckt die Leiste nicht)
#[default]
Left,
/// Rechts (für Linkshänder)
Right,
}
/// Benutzereinstellungen der Oberfläche.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct UiSettings {
pub sidebar_side: SidebarSide,
}
impl UiSettings {
/// Standardpfad gemäß XDG-Spezifikation.
pub fn default_path() -> Option<PathBuf> {
let base = match std::env::var("XDG_CONFIG_HOME") {
Ok(dir) if !dir.trim().is_empty() => PathBuf::from(dir),
_ => PathBuf::from(std::env::var("HOME").ok()?).join(".config"),
};
Some(base.join("ink-notes").join("ui.json"))
}
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
pub fn load_from(path: &Path) -> Self {
match std::fs::read_to_string(path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
eprintln!(
"[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.",
path.display()
);
Self::default()
}),
Err(_) => Self::default(),
}
}
/// Speichert die Einstellungen atomar.
pub fn save_to(&self, path: &Path) -> Result<(), StorageError> {
storage::atomic_write_json(path, self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_settings_round_trip_and_fallbacks() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ink-notes").join("ui.json");
// Fehlende Datei -> Standard (links)
assert_eq!(UiSettings::load_from(&path).sidebar_side, SidebarSide::Left);
let settings = UiSettings {
sidebar_side: SidebarSide::Right,
};
settings.save_to(&path).unwrap();
assert_eq!(UiSettings::load_from(&path), settings);
// Beschädigte Datei -> Standard statt Absturz
std::fs::write(&path, b"{ kaputt").unwrap();
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
// Unbekannte / fehlende Felder werden toleriert
std::fs::write(&path, br#"{"zukunft": 1}"#).unwrap();
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
}
}
+50 -16
View File
@@ -105,6 +105,18 @@ impl Default for SplineConfig {
}
}
impl SplineConfig {
/// Erzeugt eine Spline-Konfiguration aus der Strichbreite in internen Einheiten (10 Einheiten = 1 mm).
pub fn from_width_units(width_units: f32) -> Self {
let base_radius = (width_units / 2.0).max(0.25);
Self {
min_radius: (base_radius * 0.5).max(0.15),
max_radius: (base_radius * 1.5).max(0.35),
samples_per_segment: 16,
}
}
}
/// Berechnet den druckabhängigen Radius r(p) = min_r + p * (max_r - min_r).
pub fn calculate_radius(pressure: f32, min_radius: f32, max_radius: f32) -> f32 {
let p = pressure.clamp(0.0, 1.0);
@@ -355,11 +367,11 @@ fn rdp_mark(positions: &[glam::Vec2], start: usize, end: usize, epsilon: f32, ke
let mut max_dist = 0.0_f32;
let mut max_idx = start;
for i in (start + 1)..end {
let dist = point_to_segment_distance(positions[i], a, b);
for (offset, &pos) in positions[(start + 1)..end].iter().enumerate() {
let dist = point_to_segment_distance(pos, a, b);
if dist > max_dist {
max_dist = dist;
max_idx = i;
max_idx = start + 1 + offset;
}
}
@@ -418,10 +430,7 @@ pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec<usize
}
/// Tastet einen zusammenhängenden, glatten Teilabschnitt (Run) zwischen Ecken ab.
fn sample_smooth_run(
control_points: &[ControlPoint],
config: &SplineConfig,
) -> Vec<SplineSample> {
fn sample_smooth_run(control_points: &[ControlPoint], config: &SplineConfig) -> Vec<SplineSample> {
match control_points.len() {
0 => Vec::new(),
1 => {
@@ -621,7 +630,12 @@ pub fn generate_finalized_triangle_strip(
/// 3. Ramer-Douglas-Peucker (RDP) Dezimierung (Toleranz ca. 0.05 bis 0.1 mm)
/// 4. Generierung der finalen Triangle-Strips mit runden Kappen an Start und Ende.
pub fn finalize_stroke(raw_samples: &[StylusSample]) -> Stroke {
finalize_stroke_with_config(0, raw_samples, [0.0, 0.0, 0.0, 1.0], &SplineConfig::default())
finalize_stroke_with_config(
0,
raw_samples,
[0.0, 0.0, 0.0, 1.0],
&SplineConfig::default(),
)
}
/// Vollständige Finalisierung mit konfigurierbarer Stroke-ID, Farbe und SplineConfig.
@@ -651,7 +665,10 @@ pub fn finalize_stroke_with_config(
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
.collect();
Stroke::new(stroke_id, decimated, ink_vertices, color)
let mut stroke = Stroke::new(stroke_id, decimated, ink_vertices, color);
stroke.width = config.min_radius + config.max_radius;
stroke.finalized = true;
stroke
}
#[cfg(test)]
@@ -808,9 +825,10 @@ mod tests {
assert!(!stroke.vertices.is_empty());
// 2. Der Eckpunkt (100.0, 0.0) muss in den Strichpunkten erhalten bleiben
let corner_found = stroke.points.iter().any(|s| {
(s.position.x - 100.0).abs() < 1e-3 && (s.position.y - 0.0).abs() < 1e-3
});
let corner_found = stroke
.points
.iter()
.any(|s| (s.position.x - 100.0).abs() < 1e-3 && (s.position.y - 0.0).abs() < 1e-3);
assert!(
corner_found,
"Eckpunkt (100.0, 0.0) muss in den finalen Strichpunkten erhalten bleiben!"
@@ -837,7 +855,11 @@ mod tests {
// 4. Ecken-Erkennung prüft den 90°-Winkel (> 65 Grad)
let corners = detect_corners(&stroke.points, CORNER_ANGLE_THRESHOLD_DEG);
assert_eq!(corners.len(), 1, "Exakt eine 90-Grad-Ecke muss erkannt werden");
assert_eq!(
corners.len(),
1,
"Exakt eine 90-Grad-Ecke muss erkannt werden"
);
let corner_idx = corners[0];
let corner_pt = stroke.points[corner_idx];
assert!((corner_pt.position.x - 100.0).abs() < 1e-3);
@@ -847,9 +869,21 @@ mod tests {
#[test]
fn test_pressure_smoothing() {
let samples = vec![
StylusSample { position: glam::Vec2::new(0.0, 0.0), pressure: 0.2, timestamp: 0.0 },
StylusSample { position: glam::Vec2::new(1.0, 0.0), pressure: 0.8, timestamp: 0.1 },
StylusSample { position: glam::Vec2::new(2.0, 0.0), pressure: 0.2, timestamp: 0.2 },
StylusSample {
position: glam::Vec2::new(0.0, 0.0),
pressure: 0.2,
timestamp: 0.0,
},
StylusSample {
position: glam::Vec2::new(1.0, 0.0),
pressure: 0.8,
timestamp: 0.1,
},
StylusSample {
position: glam::Vec2::new(2.0, 0.0),
pressure: 0.2,
timestamp: 0.2,
},
];
let smoothed = smooth_pressure(&samples);
+1590 -105
View File
File diff suppressed because it is too large. Load diff
+2149 -978
View File
File diff suppressed because it is too large. Load diff
+874 -163
View File
File diff suppressed because it is too large. Load diff
+44 -6
View File
@@ -3,8 +3,8 @@
//! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel),
//! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]).
use glam::{Mat4, Vec2, Vec4};
use crate::document::{PageViewport, PAGE_HEIGHT, PAGE_WIDTH};
use glam::{Mat4, Vec2, Vec4};
/// Minimaler und maximaler Zoomfaktor.
pub const MIN_ZOOM: f32 = 0.25;
@@ -40,7 +40,12 @@ impl ViewportState {
}
/// Passt den Pan-Offset um eine Bildschirmverschiebung (in Fensterpixeln) an.
pub fn pan_by_screen_delta(&mut self, delta_x: f32, delta_y: f32, page_viewport: &PageViewport) {
pub fn pan_by_screen_delta(
&mut self,
delta_x: f32,
delta_y: f32,
page_viewport: &PageViewport,
) {
let base_scale = page_viewport.page_width / PAGE_WIDTH;
if base_scale * self.zoom > 1e-6 {
self.pan_offset[0] += delta_x / (base_scale * self.zoom);
@@ -122,10 +127,7 @@ impl ViewportState {
page_viewport: &PageViewport,
) -> Vec2 {
let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport);
Vec2::new(
pt.x.clamp(0.0, PAGE_WIDTH),
pt.y.clamp(0.0, PAGE_HEIGHT),
)
Vec2::new(pt.x.clamp(0.0, PAGE_WIDTH), pt.y.clamp(0.0, PAGE_HEIGHT))
}
/// Transformiert einen DIN-A4-Seitenpunkt in Fenster-Pixelkoordinaten.
@@ -244,4 +246,40 @@ mod tests {
assert!((offset1[0] - offset2[0]).abs() < 1e-5);
assert!((offset1[1] - offset2[1]).abs() < 1e-5);
}
#[test]
fn test_pinch_to_zoom_math() {
let mut vp = ViewportState::new();
let page_vp = PageViewport::calculate(1920.0, 1080.0, 24.0);
// Simulation von 2 Fingern:
// Finger 1 bei (400, 500), Finger 2 bei (600, 500) -> Distanz: 200, Zentrum: (500, 500)
let p1_start = (400.0_f32, 500.0_f32);
let p2_start = (600.0_f32, 500.0_f32);
let dist_start =
((p2_start.0 - p1_start.0).powi(2) + (p2_start.1 - p1_start.1).powi(2)).sqrt();
let center_start = (
(p1_start.0 + p2_start.0) * 0.5,
(p1_start.1 + p2_start.1) * 0.5,
);
assert_eq!(dist_start, 200.0);
assert_eq!(center_start, (500.0, 500.0));
// Finger auseinanderziehen und leicht verschieben:
// Finger 1 bei (350, 520), Finger 2 bei (650, 520) -> Distanz: 300, Zentrum: (500, 520)
let p1_end = (350.0_f32, 520.0_f32);
let p2_end = (650.0_f32, 520.0_f32);
let dist_end = ((p2_end.0 - p1_end.0).powi(2) + (p2_end.1 - p1_end.1).powi(2)).sqrt();
let center_end = ((p1_end.0 + p2_end.0) * 0.5, (p1_end.1 + p2_end.1) * 0.5);
assert_eq!(dist_end, 300.0);
assert_eq!(center_end, (500.0, 520.0));
let zoom_factor = dist_end / dist_start; // 1.5x Vergrößerung
let pan_delta = (center_end.0 - center_start.0, center_end.1 - center_start.1); // (0, +20)
vp.pan_by_screen_delta(pan_delta.0, pan_delta.1, &page_vp);
vp.zoom_at_cursor([center_end.0, center_end.1], zoom_factor, &page_vp);
assert!((vp.zoom - 1.5).abs() < 1e-4);
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"format_version": 99,
"id": "future_notebook",
"title": "Zukunfts-Notizbuch",
"created_at": 1800000000,
"modified_at": 1800001000,
"current_page_index": 0,
"pages": [
{ "id": 1, "background": "Grid5mm" }
],
"page_ids": [1],
"next_stroke_id": 10,
"next_page_id": 2
}
+6
View File
@@ -0,0 +1,6 @@
{
"format_version": 99,
"id": 1,
"background": "Blank",
"strokes": []
}
+10
View File
@@ -0,0 +1,10 @@
{
"id": "legacy_notebook",
"title": "Legacy Notizbuch v0",
"created_at": 1700000000,
"modified_at": 1700001000,
"current_page_index": 0,
"page_ids": [1],
"next_stroke_id": 5,
"next_page_id": 2
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": 1,
"background": "DotGrid",
"strokes": [
{
"id": 1,
"color": [0.1, 0.2, 0.3, 1.0],
"samples": [
{ "x": 100.0, "y": 200.0, "p": 0.5, "t": 1.0 },
{ "x": 110.0, "y": 210.0, "p": 0.6, "t": 1.1 }
]
}
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"format_version": 1,
"id": 1,
"background": "Lined",
"strokes": [],
"unknown_experimental_feature": {
"nested_data": [1, 2, 3],
"author": "Antigravity"
},
"random_future_flag": true
}