Compare commits

...
Author SHA1 Message Date
haraldandClaude Opus 5.5 f9a6f8524d feat: Lange drücken zum Einfügen (Issue #16)
- Ein Finger, der eine halbe Sekunde still auf dem Blatt liegt, öffnet ein
  Kontextmenü mit „Einfügen“ und „Bild aus Datei …“ (wie in Rnote)
- Bild, Text oder kopierte Objekte aus der Zwischenablage landen an der
  gedrückten Stelle (Strg+V weiterhin in der Mitte)
- Meldung, wenn die Zwischenablage leer ist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:08:55 +02:00
haraldandClaude Opus 5.5 4a4492d034 chore(release): bump version to 0.16.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:53:15 +02:00
haraldandClaude Opus 5.5 bb9dbb52d0 feat: Eigene Farben, Bildschirmfarben wie im Export (Issue #13)
- Jede Farbe der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen:
  gewählte Farbe erneut antippen, Vorgabe oder mit 🎨 frei wählen (Farbwähler
  direkt im Popover). Die Leiste merkt sich die Farben (ui.json).
- Tintenfarben sind sRGB-Werte wie im Export: Gezeichnet wird ohne
  sRGB-Umrechnung der GPU, Papier und Muster rechnet der Shader um. Schwarz ist
  am Bildschirm jetzt schwarz statt grau, der Textmarker mischt wie im PDF.
- PDF-Export: Musterfarbe wird nach sRGB umgerechnet (war zu dunkel).
- Auswahl wird in die Farben der Stifte umgefärbt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:51:39 +02:00
haraldandClaude Opus 5.5 568714d727 Merge origin/main (Banner-Logo) nach v0.15.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:04:02 +02:00
haraldandClaude Opus 5.5 c87f4102d7 chore(release): bump version to 0.15.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m47s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:03:39 +02:00
haraldandClaude Opus 5.5 3890b6f182 feat: Stifte, Seiten löschen, lückenlose Seiten, sauberer Textmarker (Issues #11, #12, #14, #15, #17, #19)
- #15: Koordinatensystem ohne Kerbe am Ursprung (Achsen beginnen eine halbe
  Linienbreite hinter dem Ursprung; im PDF ein Linienzug mit spitzer Ecke)
- #17: Jeder Strich deckt ein Pixel nur einmal (Stencil-Puffer), der
  Textmarker wird innerhalb eines Strichs nicht dunkler. Im PDF-Export ist der
  Textmarker jetzt halbtransparent statt deckend.
- #11: ➕ in der Werkzeugleiste fügt nach der aktuellen Seite eine Seite ein
- #12: Seite löschen im ☰-Menü mit Rückfrage, in der Sitzung wiederherstellbar
- #14: Seiten liegen lückenlos untereinander, eine dunkle Linie markiert die Grenze
- #19: Vier Stifte mit eigener Farbe und Stärke, die sich Änderungen merken
  (ui.json); Blätterpfeile nur noch in der Einzelseitenansicht

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:55:32 +02:00
harald 5115e217c2 docs: Banner-Logo hinzugefügt und in README.md eingebunden 2026-10-08 13:37:13 +02:00
haraldandClaude Opus 5.5 904b36e5f7 chore(release): bump version to 0.14.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:44:53 +02:00
haraldandClaude Opus 5.5 80d319099f fix: Karos und Linien in gleichmäßigen Abständen (Issue #10)
Karo, Linien und Punkte waren Dreiecke mit 0,15 mm breiten Linien –
schmaler als ein Bildschirmpixel. Je nach Lage auf dem Pixelraster
erschienen sie stärker, schwächer oder versetzt, die Abstände wirkten
unregelmäßig. Jetzt berechnet ein Fragment-Shader das Muster für jedes
Pixel aus der Seitenkoordinate: Linien und Punkte sind mindestens ein
Pixel breit, kantengeglättet und gleichmäßig; bei sehr kleinen Abständen
(weit herausgezoomt) blendet das Muster aus. Gemessen: Karo-Abstand
13,5 ± 0,04 Pixel bei gleicher Linienstärke.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:21:16 +02:00
haraldandClaude Opus 5.5 ee5257d744 fix: Umbenennen und Löschen in der Notizbuchverwaltung reagieren (Issue #9)
Die Klickfläche zum Öffnen lag über der ganzen Zeile und wurde nach den
Knöpfen angelegt; egui gibt Klicks dem zuletzt angelegten Element, die
Knöpfe ✏ und 🗑 bekamen daher nie einen Klick. Jetzt öffnet nur der
Titelbereich das Notizbuch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:16:54 +02:00
haraldandClaude Opus 5.5 fa1ab5ab8f chore(release): bump version to 0.13.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:01:25 +02:00
haraldandClaude Opus 5.5 47300f2ca2 fix: Glatte Strichkanten (Issue #7)
Die Zeichenfläche wird mit 4× Multisampling gezeichnet und in die
Fensterfläche aufgelöst; Strichkanten, Formen und Bildränder sind damit
kantengeglättet (ohne Unterstützung des Formats wie bisher). Runde
Strichenden und Punkte sind feiner unterteilt (10 statt 4 Schritte je
Viertelkreis, 20 statt 8 für Punkte) und wirken auch stark vergrößert rund.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:54:12 +02:00
haraldandClaude Opus 5.5 27f84ff1b1 fix: Kleine Schleifen bleiben rund (Issue #8)
Der fertige Strich entsteht jetzt aus den ungefilterten Eingabepunkten mit
einer symmetrischen 3-Punkt-Glättung (ohne Verzögerung); der 1€-Filter
dient nur der Anzeige während des Schreibens. Der Filter arbeitet mit der
Geschwindigkeit in der Ebene statt je Achse und reagiert schneller. Ecken
werden nur zwischen Abschnitten ab 0,6 mm getrennt. Ein „o“ mit 0,8 mm
Radius behielt bisher im Mittel 6,1 mm/10 statt 8 und bekam zwei Zacken,
jetzt 7,8 ohne Zacken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:05:17 +02:00
haraldandClaude Opus 5.5 ffab2cea7a chore(release): bump version to 0.12.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:31:45 +02:00
haraldandClaude Opus 5.5 614dd140a1 docs: README und Dokumentation auf den aktuellen Stand
README, ARCHITECTURE.md und FILE_FORMAT.md beschreiben jetzt alle
Funktionen bis v0.11 (Text, Bilder, PDF-Import, Seitenformate, Lineal,
durchgehendes Scrollen, Musterfarben, Dateiformat-Version 8). Werbliche
Formulierungen durch überprüfbare Angaben ersetzt, auch in Paket-
beschreibung, Desktop-Datei, AppStream-Metadaten und Release-Notizen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:21:42 +02:00
haraldandClaude Opus 5.5 0a6b5e78ef feat: Farbe von Karos, Linien und Punkten wählbar
Im ☰-Menü unter „Karos, Linien und Punkte“: hell (bisher), mittel,
kräftig, blau oder grün – passend zur gewählten Papierfarbe. Die Wahl
wird gespeichert und gilt auch für PDF-, PNG- und SVG-Export (Issue #6).
Punkte des Punktrasters sind am Bildschirm jetzt 0,4 mm groß wie im
Export (bisher 0,25 mm, beim Herauszoomen kaum sichtbar).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:21:42 +02:00
haraldandClaude Opus 5.5 901c91d10c chore(release): bump version to 0.11.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m13s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:50:54 +02:00
haraldandClaude Opus 5.5 6caf3f7476 feat: Seitenformat folgt dem PDF (Querformat für Folien)
Seiten haben jetzt eine eigene Größe. Beim PDF-Import bleiben Hochformat-
Seiten DIN A4; Querformat-Seiten wie PowerPoint-Folien bekommen eine
Querformat-Seite (297 mm breit, Höhe nach Seitenverhältnis) ohne leeren
Streifen darunter (Issue #4). Eingefügte und angehängte Seiten übernehmen
das Format der aktuellen Seite.

Anzeige: Maßstab aus dem Rahmen aller Seiten, die durchgehende Ansicht
stapelt verschieden große Seiten zentriert untereinander. Export (PDF,
PNG, SVG) in der jeweiligen Seitengröße. Dateiformat-Version 8 (Größe in
Seite und Manifest; A4-Seiten speichern keine Größe).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:20:44 +02:00
haraldandClaude Opus 5.5 9fde59798f feat: Neue Seite direkt nach der aktuellen einfügen
„Neue Seite einfügen“ (☰-Menü und Seitenauswahl) legt die leere Seite
direkt hinter der aktuellen an und wechselt dorthin, statt sie ans Ende
zu hängen – etwa zwischen zwei Folien eines PDFs (Issue #3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:02:30 +02:00
haraldandClaude Opus 5.5 438106cafd fix: Auswahlwerkzeug funktioniert mit dem Stift
Bisher reagierte das Auswahlwerkzeug nur auf Maus und Finger (Issue #2).
Drücken, Ziehen und Loslassen der Auswahl sind jetzt gemeinsame Funktionen
für Maus, Finger und Stift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:01:23 +02:00
haraldandClaude Opus 5.5 392c22bc4a feat: Zoom bis 1000 %, PDF-Seiten beim Hineinzoomen schärfer
Höchster Zoom 1000 % statt 500 % (Issue #1). Importierte PDF-Seiten werden
mit doppelter Auflösung nachgerendert, sobald die Standardauflösung
sichtbar vergrößert würde; bis dahin bleibt sie als Ersatz sichtbar.
Texte werden bis 8 Pixel je Einheit gerastert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:59:27 +02:00
haraldandClaude Opus 5.5 6002eee9e8 chore(release): bump version to 0.10.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m39s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:43:56 +02:00
haraldandClaude Opus 5.5 2eab394257 feat: Text-Werkzeug
Textfelder per Tastatur (T): normale oder Code-Schrift, 8–36 pt, Farben
der Stiftpalette, Umbruch am rechten Feldrand. Textfelder sind Objekte
mit vier Ecken wie Bilder (verschieben, drehen, skalieren) und werden als
Textur gerastert; im PDF-Export echter, durchsuchbarer Text mit
eingebetteter Schrift. Kopierter Text wird mit Strg+V zum Textfeld.
Dateiformat-Version 7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:40:45 +02:00
haraldandClaude Opus 5.5 6a51ca3aa4 chore(release): bump version to 0.9.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 5m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:52:01 +02:00
haraldandClaude Opus 5.5 6f2b414bb8 feat: durchgehendes Scrollen
Seiten hängen untereinander (8 mm Abstand) und werden mit Mausrad, Finger
oder Touchpad fließend gescrollt; Strg+Mausrad zoomt. Sichtbare Nachbarseiten
werden bei Bedarf geladen und mit eigener Kamera gezeichnet. Die Seite in der
Fenstermitte wird nach dem Scrollen zur aktuellen; Zeichnen, Radieren und
Auswählen auf einer Nachbarseite wechseln zuerst dorthin. Wer im unteren
Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite.
Scrollen bleibt auf das Notizbuch begrenzt. Abschaltbar im ☰-Menü (Ansicht).

Nie gespeicherte, angehängte Seiten laden jetzt als leere Seite statt als
schreibgeschützte Fehlerseite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:36:19 +02:00
haraldandClaude Opus 5.5 9dec688b84 chore(release): bump version to 0.8.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m53s
Virtuelles Lineal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:52:19 +02:00
haraldandClaude Opus 5.5 7e05b33ee3 feat: virtuelles Lineal
- Lineal ein-/ausblenden über die Werkzeugleiste oder Taste L
  (26 cm, mm-/cm-Skala an beiden Kanten, Winkelanzeige)
- Fläche ziehen verschiebt, Griff ⟳ dreht; 15°-Schritte rasten ein
- Striche, die bis 6 mm neben einer Längskante beginnen, folgen bis zum
  Abheben exakt der Kante (Stift, Textmarker, Formen); die Tinte liegt
  um die halbe Strichbreite neben dem Lineal
- Lineal bleibt beim Blättern an seiner Stelle; nur seine Fläche und
  der Griff nehmen Eingaben an, an den Kanten wird gezeichnet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:46:08 +02:00
haraldandClaude Opus 5.5 587632b173 chore(release): bump version to 0.7.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 12m16s
Teilstrich-Radierer, Seiten-Export als PNG/SVG, Auswahl als Bild, Seitentitel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:32:56 +02:00
haraldandClaude Opus 5.5 701fa03f33 feat: Teilstrich-Radierer, Seiten-Export (PNG/SVG), Auswahl als Bild, Seitentitel
- Radierer-Modus „✂ Teilstrich“: entfernt nur den überstrichenen Teil, der Rest
  bleibt in Stücken; ein Radierzug ist ein Undo-Schritt, Formen werden ganz radiert
- ☰ → „Seite als PNG / PNG transparent / SVG“ nach ~/Documents (über den
  PDF-Export gerendert: Bilder und importierte PDF-Seiten inklusive)
- Strg+C legt die Auswahl zusätzlich als Bild in die Systemzwischenablage
  (für Chat, Mail, Office); in ink-notes fügt Strg+V weiter die Objekte ein
- Seitentitel in der Seitenübersicht, Ansicht „Inhalt“ als Inhaltsverzeichnis,
  Lesezeichen-Ecke an benannten Seiten, Titel im Fenstertitel und als
  PDF-Lesezeichen (Umlaute als UTF-16)
- Dateiformat-Version 6 (Seitentitel)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:19:13 +02:00
haraldandClaude Opus 5.5 91a24b9f82 chore(release): bump version to 0.6.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 11m45s
PDF importieren und annotiert exportieren (v0.6.0 scheiterte in der CI an Clippy).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:29:30 +02:00
haraldandClaude Opus 5.5 64e13e1155 fix(ci): Clippy-Hinweise seit Anhebung der Mindest-Rust-Version
Mit rust-version 1.92 meldet Clippy is_none_or statt map_or(true, …) und
as_chunks statt chunks_exact mit fester Größe; die CI bricht bei Warnungen ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:25:30 +02:00
haraldandClaude Opus 5.5 8a0477740e chore(release): bump version to 0.6.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 3m32s
PDF importieren und annotiert exportieren, Dateiformat-Version 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:07:25 +02:00
haraldandClaude Opus 5.5 66679e51ae feat: PDF importieren und annotiert exportieren
- ☰ → „PDF importieren …“ oder PDF ins Fenster ziehen: neues Notizbuch
  mit einer Seite je PDF-Seite, die Originalseite als Hintergrund
- Querformat-Folien liegen oben auf dem A4-Blatt, darunter ist Platz für
  Notizen; gedrehte Seiten (/Rotate) erscheinen wie im PDF-Betrachter
- Anzeige mit hayro (reines Rust) im Hintergrund-Thread, nächste Seite
  wird vorab gerendert
- PDF-Export legt die Originalseiten als Vektorgrafik (Form-XObject via
  lopdf) unter Bilder und Tinte; nicht übernehmbare Seiten (z. B.
  verschlüsselt) werden als Bild mit 200 dpi eingebettet
- Dateiformat-Version 5 (pdf_background an der Seite)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:47:49 +02:00
haraldandClaude Opus 5.5 9a39276da4 chore(release): bump version to 0.5.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m17s
Bilder einfügen (Zwischenablage, Dateiauswahl, Drag & Drop), Dateiformat-Version 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:04:09 +02:00
haraldandClaude Opus 5.5 b5c1da07fa feat: Bilder einfügen (Datei, Zwischenablage, Drag & Drop)
- Bilder per ☰ → „Bild einfügen …“ (Strg+I, eingebaute Dateiauswahl),
  Strg+V aus der Systemzwischenablage oder durch Ziehen ins Fenster
- Bilder sind Objekte mit vier Ecken: verschieben, drehen, skalieren,
  kopieren, löschen und Undo über die bestehende Auswahl
- Gespeichert einmalig unter ihrer Prüfsumme in <Notizbuch>/assets
  (JPEG unverändert, sonst PNG); Dateiformat-Version 4
- GPU-Darstellung mit Mipmaps unter der Tinte; Radierer und Umfärben
  lassen Bilder unberührt, Tinte auf Bildern hat beim Antippen Vorrang
- PDF-Export: JPEG verlustfrei eingebettet, sonst bis 300 dpi
- Strg+V: die jüngste Kopie gewinnt (Systembild oder interne Objekte);
  Bilder werden beim Einfügen in ein anderes Notizbuch mitkopiert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:50:54 +02:00
haraldandClaude Opus 5.5 c52e958c7f chore(release): bump version to 0.4.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 11m12s
Dunkelmodus & Papierfarben, Textmarker-Radierer, einstellbare Druckkurve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:18:43 +02:00
haraldandClaude Opus 5.5 3e2b627d6a feat: Textmarker-Radierer, Papierfarben/Dunkelmodus und Druckkurve
- Radierer: Modus „Nur Textmarker“ (gilt auch für Stiftrückseite und Seitentaste)
- Striche speichern jetzt ihr Werkzeug; bisher wurde jeder Strich als "pen"
  gespeichert. Ältere Textmarker werden an der Halbtransparenz erkannt.
- Papierfarben Weiß/Creme/Dunkelgrau/Schwarz (nur Anzeige); auf dunklem Papier
  hellt der Shader dunkle Tinte auf (HSL-Helligkeit gespiegelt)
- Einstellbare Druckkurve (weich/hart, Mindestdruck) mit Vorschau
- Einstellungen in ui.json; ☰-Menü scrollt bei kleinen Fenstern
- Test prüft den WGSL-Shader mit naga

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:04:59 +02:00
haraldandClaude Opus 5.5 616fc57666 chore(release): bump version to 0.3.7
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m48s
Ellipsen- und Pfeilerkennung, spitze Pfeilspitzen, Dateiformat-Version 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:42:24 +02:00
haraldandClaude Opus 5.5 d150fd1883 feat(geometry): Ellipsen- und Pfeilerkennung
Ellipse
- Achsen aus der Hauptkomponentenanalyse des gleichmäßig abgetasteten Strichs,
  Halbachsen aus der Ausdehnung (2.–98. Perzentil); fast runde Formen
  (Achsenverhältnis ≥ 0,85) bleiben Kreise; leicht schiefe werden gerade
- konkurriert im Formvergleich mit Kreis, Rechteck, Dreieck, Raute, Vieleck

Pfeil / Doppelpfeil
- in einem Zug gezeichnet: langer Schaft, an einem oder beiden Enden
  Widerhaken auf beiden Seiten (10°–80° schräg nach hinten); T-Enden und
  Zickzack sind keine Pfeile
- Schaft wird über fast gerade weiterlaufende Punkte verlängert (die
  Vereinfachung teilt ihn sonst kurz vor der Spitze)
- Spitzen über die gemeinsame arrow_head-Geometrie, Schaft endet an der Basis

Außerdem: Drehen/Skalieren/Verschieben, PDF-Export, Namen für die
Einrast-Meldung; Dateiformat-Version 3, damit ältere App-Versionen das
Notizbuch sauber ablehnen statt Seiten mit neuen Formen für beschädigt zu
halten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:28:17 +02:00
haraldandClaude Opus 5.5 f9ede537ed fix(geometry): spitze Pfeilspitzen beim Koordinatensystem
Die Achsenlinien liefen in voller Strichbreite bis zur Spitze und überdeckten
sie; zudem war die Spitze klein und breit (~67° Öffnung).

- Gemeinsame Pfeilspitzen-Geometrie (arrow_head) für Bildschirm und PDF:
  ~4 mm lang (dicke Linien länger, höchstens 1/5 der Achse), ~40° Öffnung
- Achsen enden an der Basis der Spitze
- PDF-Export nutzt dieselben Maße (vorher eigene, abweichende Werte)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:14:06 +02:00
haraldandClaude Opus 5.5 9571e2121c chore(release): bump version to 0.3.6
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m51s
Unter WSL standardmäßig X11 statt Wayland.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:50:24 +02:00
haraldandClaude Opus 5.5 4a7627e461 fix(wsl): unter WSL standardmäßig X11 statt Wayland verwenden
Unter WSLg trennt der Wayland-Compositor die Verbindung, kurz nachdem die App
ein Bild über Vulkan übergibt ("Connection reset by peer", ExitFailure). Über
X11 (Xwayland) läuft die App stabil – deshalb funktionierte sie als root, wo
der Wayland-Socket nicht gefunden wird und winit auf X11 ausweicht.

- WSL-Erkennung (WSL_DISTRO_NAME/WSL_INTEROP, /proc/sys/kernel/osrelease)
- Unter WSL mit gesetztem DISPLAY wird die Ereignisschleife mit X11 erzeugt
- INK_NOTES_DISPLAY=x11|wayland übersteuert die Wahl

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:42:21 +02:00
haraldandClaude Opus 5.5 cb11b1f828 chore(release): bump version to 0.3.5
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m34s
Kein Absturz mehr ohne nutzbare Grafikkarte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:17:49 +02:00
haraldandClaude Opus 5.5 89005f718e fix(grafik): kein Absturz ohne nutzbare Darstellungsmodi
Ohne Zugriff auf die Grafikkarte (z. B. Benutzer nicht in der Gruppe render)
lieferte EGL/Zink zunächst Darstellungsmodi, später eine leere Liste; die
nächste Neueinrichtung der Surface ließ wgpu abstürzen ("Requested present
mode Mailbox is not in the list of supported present modes: []").

- Adapter der Reihe nach prüfen (GPU, dann Software-Renderer); nur Adapter
  mit Formaten und Darstellungsmodi für das Fenster werden genommen
- Darstellungsmodus: Mailbox, sonst Fifo, sonst der erste angebotene
- Bei Größenänderung/verlorener Surface Fähigkeiten neu abfragen statt blind
  neu einzurichten; ohne Modi wird nicht eingerichtet (einmalige Meldung)
- Ohne nutzbaren Adapter verständliche Meldung (Gruppe render, /dev/dri,
  Software-Renderer, WGPU_BACKEND) statt Panic; WGPU_BACKEND wird beachtet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:14:43 +02:00
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
49 changed files with 19990 additions and 2879 deletions
+11 -1
View File
@@ -44,18 +44,28 @@ jobs:
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: 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)
- 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: |
Generated
+730 -19
View File
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -1,12 +1,12 @@
[package]
name = "ink-notes"
version = "0.1.0"
version = "0.16.0"
edition = "2021"
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
license = "MIT"
readme = "README.md"
rust-version = "1.75"
rust-version = "1.92"
[dependencies]
# Fenster- & Event-Handling (Wayland/X11, Stylus)
@@ -40,6 +40,17 @@ ureq = { version = "2.10", default-features = false, features = ["tls", "gzip",
url = "2.5"
octotablet = "0.1.0"
# Bilder einfügen: Dekodieren (PNG/JPEG/GIF/WebP/BMP) und Systemzwischenablage
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] }
arboard = { version = "3", default-features = false, features = ["image-data"] }
minisign-verify = "0.3.0"
fs2 = "0.4"
hayro = "0.8"
hayro-svg = "0.8"
# Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui)
ab_glyph = "0.2"
[profile.dev]
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
@@ -51,3 +62,5 @@ strip = true
[dev-dependencies]
tempfile = "3.27.0"
# Prüft den WGSL-Shader in den Tests (dieselbe Version wie in wgpu 0.19)
naga = { version = "0.19", features = ["wgsl-in"] }
+188 -225
View File
@@ -1,264 +1,227 @@
# 🖋️ ink-notes
<p align="center">
<img src="assets/ink-notes-banner.svg" alt="ink-notes Banner" width="100%">
</p>
Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung für Linux, entwickelt in **Rust** mit **wgpu** und **winit**.
`ink-notes` kombiniert minimale Eingabelatenz beim Zeichnen mit Stift, Touchpad oder Maus mit einem flüssigen stufenlosen Viewport, automatischer Geometrie- und Formerkennung, Objekttransformationen, Multi-Dokumenten-Verwaltung und Vektor-PDF-Export.
<p align="center">
<strong>Eine moderne Notizbuch-App für Linux zum Schreiben mit Stift, Touch und Maus</strong><br>
Geschrieben in <strong>Rust</strong> mit <strong>wgpu</strong>, <strong>winit</strong> und <strong>egui</strong>.
</p>
---
## ✨ Features im Überblick
ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund.
### ⚡ Latenzoptimierte Inking-Pipeline
- **Hardware-Rendering:** Direct-to-Screen-Rendering mit `wgpu` (Vulkan / X11 / Wayland) unter Nutzung von `PresentMode::Mailbox` für minimale Eingabeverzögerung.
- **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.
- **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.
- **Radierer (Eraser):** Segment- und abstandsbasierte Löschung einzelner Striche auf dem DIN-A4-Canvas.
### 🔍 Stufenloser Zoom & Pan (Kamera)
- **Viewport-Transformation:** 2D-Projektionsmatrix im WGSL-Vertex-Shader.
- **Cursor-zentrierter Zoom:** Stufenloses Zoomen von **25 % bis 500 %** zentriert auf die Cursor-Position via Mausrad, Trackpad-Pinch oder Tastenkürzel (`Strg +` / `Strg -` / `Strg 0`).
- **Pan:** Verschieben des Canvas per mittlerer Maustaste, `Leertaste + Drag` oder Trackpad-Pan.
- **Inverse Koordinatentransformation:** Präzise Umrechnung von Bildschirm- in DIN-A4-Dokumentkoordinaten (`screen_to_page`).
### 📐 Geometrie- & Objekterkennung (Shape Recognition)
- **Erkannte Primitiven:**
- **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 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.
- **Formen-Werkzeug (Shape Mode):** Direkte Erkennung und Ersetzung beim Loslassen.
### 🖐️ Objektauswahl & Transformation (Lasso / Select)
- **Lasso & Klick-Auswahl:** Schnelles Markieren einzelner Striche oder Gruppen per Rahmen (`V`).
- **Echtzeit-Verschieben:** Freies Verschieben ausgewählter Objekte über die Seite.
- **Stufenlose Skalierung:** Größenänderung über interaktive Eck-Griffe.
- **Drehung:** Stufenlose Rotation um das Objektzentrum über den oberen Dreh-Griff oder exakte 90°-Schritte mit Taste `R`.
- **Multicolor-Umfärbung:** Ausgewählte Objekte direkt über die Farbpalette umfärben.
- **Clipboard & Editieren:** Kopieren (`Strg + C`), Einfügen (`Strg + V`) und Löschen (`Entf` / `Backspace`).
- **Undo / Redo:** Vollständige Transaktionshistorie (`Strg + Z` / `Strg + Y`) für alle Aktionen inklusive Transformationen.
### 📚 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:** 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), 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 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.
- **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 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`.
Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
---
## 📦 Release, Packaging & Updater
## Funktionen
- **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, SHA-256-Prüfung und atomarer In-Place Austausch.
### Schreiben und Zeichnen
- **Vier Stifte** in der Werkzeugleiste, jeder mit eigener Farbe und Stärke (0,15–5 mm, mit Druckstärke). Ändert man Farbe oder Stärke, merkt sich der gewählte Stift das – auch über einen Neustart hinweg. **Textmarker** (halbtransparent, 4–8 mm) in vier Farben; innerhalb eines Strichs wird er nicht dunkler, erst ein neuer Strich deckt stärker.
- **Eigene Farben:** Jede Farbe in der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – gewählte Farbe erneut antippen, eine Vorgabe oder mit 🎨 einen freien Farbton wählen. Die Leiste merkt sich die Farben. Am Bildschirm erscheinen sie genauso wie im PDF- und Bildexport.
- **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund.
- **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Stiftrückseite und Seitentaste des Stifts radieren ebenfalls.
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
### 1. Release-Pakete bauen
### Formerkennung
- **Formen-Werkzeug** (`S`/`F`) oder **Halten** am Strichende (über 400 ms stillhalten): Die Skizze wird durch eine saubere Form ersetzt.
- Erkannt werden Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Vielecke, Koordinatensysteme und Oktaeder (Skizze als 3D-Körper).
- Die Erkennung arbeitet rein geometrisch (Bogenlänge, Douglas-Peucker, Kreis-Fit), ohne Machine Learning. Nicht alles wird erkannt – was nicht passt, bleibt Handschrift.
### Auswählen und Bearbeiten
- **Auswahlwerkzeug** (`V`): antippen oder Rahmen aufziehen, dann verschieben, an den Ecken skalieren, am Griff drehen oder mit `R` um 90° drehen. Funktioniert mit Maus, Finger und Stift.
- Auswahl über die Palette **umfärben**, **kopieren/einfügen** (`Strg + C/V`), **löschen** (`Entf`).
- `Strg + C` legt die Auswahl zusätzlich als Bild in die Zwischenablage, z. B. für Chat oder Mail.
- **Rückgängig/Wiederholen** (`Strg + Z/Y`) für alle Änderungen, je Seite.
### Text, Bilder und PDFs
- **Text-Werkzeug** (`T`): ins Blatt tippen und schreiben – normale oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8–36 pt, Zeilenumbruch am Feldrand. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren. In anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
- **Bilder** (PNG, JPEG, GIF, WebP, BMP) über `Strg + I`, aus der Zwischenablage oder per Ziehen ins Fenster einfügen.
- **Einfügen per Finger:** Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle.
- **PDF importieren** (☰-Menü): Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert.
### Seiten und Ansicht
- **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export.
- **Papierfarbe** der Anzeige: weiß, creme, dunkelgrau, schwarz; auf dunklem Papier werden dunkle Stiftfarben auf Wunsch aufgehellt. Gespeichert und exportiert wird immer auf weißem Papier.
- **Durchgehendes Scrollen:** Seiten hängen lückenlos untereinander, eine dunkle Linie markiert die Grenze; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Abschaltbar (dann Seite für Seite blättern, mit Pfeilen in der Werkzeugleiste).
- **Seiten einfügen und löschen:** ➕ in der Werkzeugleiste fügt nach der aktuellen Seite eine leere Seite ein. Löschen im ☰-Menü (mit Rückfrage); die zuletzt gelöschte Seite lässt sich dort wiederherstellen, solange die App läuft.
- **Zoom** von 25 % bis 1000 % (`Strg + Mausrad`, zwei Finger, `Strg + +/-/0`), Verschieben mit mittlerer Maustaste, `Leertaste + Ziehen` oder einem Finger.
- **Seiten** anhängen oder direkt nach der aktuellen Seite einfügen, **Seitentitel** vergeben; die Seitenübersicht zeigt Vorschaubilder oder ein Inhaltsverzeichnis. Seitentitel werden im PDF zu Lesezeichen.
- **Werkzeugleiste** links oder rechts andocken (für Links- und Rechtshänder).
### Notizbücher und Speichern
- Mehrere **Notizbücher** anlegen, umbenennen, wechseln und löschen (gelöschte landen zunächst im Papierkorb und lassen sich wiederherstellen).
- **Automatisches Speichern** im Hintergrund: 1,5 Sekunden nach der letzten Änderung, bei ununterbrochenem Schreiben spätestens alle 10 Sekunden. Zeichnen wird dabei nicht unterbrochen.
- Jede Datei wird erst vollständig in eine temporäre Datei geschrieben und dann ersetzt – ein Absturz beim Speichern hinterlässt keine halbe Datei.
- **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an.
- `Strg + S` speichert sofort. Der Punkt am ☰-Knopf zeigt den Stand: grün = gespeichert, orange = Änderungen warten, gelb = speichert gerade, rot = Fehler.
### Export
- **PDF** (`Strg + E`) nach `~/Documents/<Titel>.pdf`: Striche und Formen als Vektoren, Textfelder als echter, durchsuchbarer Text, importierte PDF-Seiten als Vektor-Unterlage, Seitentitel als Lesezeichen.
- **Einzelne Seite** als PNG (mit oder ohne Hintergrund) oder SVG über das ☰-Menü.
### Eingabegeräte
- Stifte über das Tablet-Protokoll von Wayland bzw. X11 (Druck, Stiftrückseite, Seitentaste).
- Solange der Stift aufliegt oder in der Nähe ist, werden Berührungen mit der Hand ignoriert.
- Finger: standardmäßig scrollen und zoomen; Zeichnen mit dem Finger lässt sich im ☰-Menü einschalten.
### Updates
- Die App prüft auf neue Versionen auf Gitea und installiert sie auf Wunsch. Installiert wird nur, wenn die Minisign-Signatur und die SHA-256-Prüfsumme stimmen (Details unten).
---
## Installation
Pakete gibt es unter [Releases](https://gitea.pansi.eu/harald/ink-notes/releases) als `.deb` und `.AppImage` (x86_64). `<version>` steht für die Versionsnummer, z. B. `0.11.0`.
```bash
# Baut .deb, .AppImage und berechnet SHA256SUMS.txt in target/
./scripts/build-release.sh
# Debian-Paket (Zorin OS 18 / Ubuntu 24.04)
sudo apt install ./ink-notes_<version>_amd64.deb
# oder AppImage
chmod +x ink-notes_<version>_x86_64.AppImage
./ink-notes_<version>_x86_64.AppImage
```
Ergebnis in `target/`:
- `ink-notes_0.1.0_amd64.deb`
- `ink-notes_0.1.0_x86_64.AppImage`
- `SHA256SUMS.txt`
### 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 starten
chmod +x target/ink-notes_0.1.0_x86_64.AppImage
./target/ink-notes_0.1.0_x86_64.AppImage
```
#### 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
export GITEA_TOKEN="<DEIN_GITEA_TOKEN>"
./scripts/publish-gitea-release.sh
```
Hinweise:
- Benötigt `glibc >= 2.39` (Ubuntu 24.04, Zorin OS 18) und einen Grafiktreiber mit Vulkan oder OpenGL.
- Meldet das AppImage einen Fehler zu `libfuse.so.2`, entweder `sudo apt install libfuse2t64` installieren oder mit `--appimage-extract-and-run` starten.
---
## 🛡️ Updater-Sicherheit
## Tastenkürzel
Der In-App-Updater (`src/updater.rs`) folgt strengen Sicherheitsrichtlinien:
1. **SHA-256-Integritätsprüfung:**
- Vor jeder Ausführung oder Installation wird die offizielle Prüfsummendatei `SHA256SUMS.txt` aus dem Release bezogen.
- Der SHA-256-Hash des heruntergeladenen Pakets wird berechnet (`sha2`).
- Weicht die Prüfsumme ab oder fehlt ein Eintrag, wird die Datei sofort gelöscht und der Installationsvorgang mit Fehlermeldung abgebrochen.
- *Hinweis zur Authentizität:* Eine Prüfsumme vom selben Release-Server garantiert Schutz gegen Übertragungsfehler und Datei-Beschädigungen, jedoch keine kryptografische Nicht-Abstreitbarkeit gegenüber einem kompromittierten Server.
2. **Signatur-Ausblick:**
- Die Updater-Architektur enthält Schnittstellen für optionale `.sig`/`.minisig`-Signaturen zur zukünftigen End-to-End-Kryptoverifikation über öffentliche Schlüssel.
3. **Redirect-Schutz & HTTPS:**
- Alle API- und Download-Endpunkte erzwingen standardmäßig HTTPS.
- 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.
| Aktion | Taste / Geste |
| :--- | :--- |
| Zeichnen | Stiftspitze, linke Maustaste (Finger, wenn eingeschaltet) |
| Radieren | `E` umschalten, rechte Maustaste, Stiftrückseite oder Seitentaste |
| Stift in Schwarz / Blau / Rot | `B` / `U` / `R` |
| Formen-Werkzeug | `S` oder `F` |
| Auswahlwerkzeug | `V` |
| Text-Werkzeug | `T`, dann ins Blatt klicken; `Esc` beendet die Eingabe |
| Lineal ein/aus | `L` |
| Hintergrund wechseln | `G` |
| Auswahl um 90° drehen | `R` (bei Auswahl) |
| Kopieren / Einfügen | `Strg + C` / `Strg + V` |
| Auswahl löschen | `Entf` oder `Rücktaste` |
| Rückgängig / Wiederholen | `Strg + Z` / `Strg + Y` oder `Strg + Umschalt + Z` |
| Bild einfügen | `Strg + I` |
| Scrollen | Mausrad (`Umschalt` = waagerecht), ein Finger |
| Zoomen | `Strg + Mausrad`, zwei Finger, `Strg + +` / `Strg + -` |
| Zoom zurücksetzen | `Strg + 0` |
| Verschieben | mittlere Maustaste oder `Leertaste + Ziehen` |
| Nächste / vorherige Seite | `Bild ab` oder `N` / `Bild auf` |
| Erste / letzte Seite | `Pos 1` / `Ende` |
| Jetzt speichern | `Strg + S` |
| PDF exportieren | `Strg + E` |
| Menüs schließen, Auswahl aufheben | `Esc` |
| Beenden (speichert vorher) | `Strg + Q` oder Fenster schließen |
---
## ⚙️ CI-Runner einrichten (Gitea Actions / act_runner)
## Entwicklung
Der Veröffentlichungs-Workflow liegt unter [`.gitea/workflows/release.yaml`](.gitea/workflows/release.yaml).
### Voraussetzungen
- Rust 1.92 oder neuer (`rustup update stable`)
- Vulkan- oder OpenGL-Treiber (z. B. `mesa-vulkan-drivers`)
- X11-/Wayland-Bibliotheken: `libx11-dev`, `libxcursor-dev`, `libxrandr-dev`, `libxi-dev`, `libxkbcommon-dev`
### Runner-Label konfigurieren
Der Workflow nutzt standardmäßig das Runner-Label:
```yaml
runs-on: ubuntu-latest
### Bauen, starten, testen
```bash
git clone https://gitea.pansi.eu/harald/ink-notes.git
cd ink-notes
cargo run --release # oder ./run.sh
cargo test # Unit- und Integrationstests
```
In der Konfigurationsdatei Ihres Gitea `act_runner` (`config.yaml`) muss dieses Label auf ein passendes Container-Image oder den Host gemappt sein:
Die CI prüft zusätzlich `cargo fmt --check` und `cargo clippy --all-targets -- -D warnings`.
### Projektstruktur
```text
src/
├── main.rs # Event-Loop (winit), Eingaben von Maus, Touch und Stift, Ablaufsteuerung
├── lib.rs # Bibliotheks-Exporte (für Tests)
├── canvas.rs # wgpu-Rendering: Papier, Muster, Bilder, Tinte, Nachbarseiten
├── shader.wgsl # Vertex- und Fragment-Shader
├── viewport.rs # Kamera (Zoom, Verschieben), Seitenstapel der durchgehenden Ansicht
├── filter.rs # 1€-Filter gegen Zittern
├── spline.rs # Catmull-Rom-Splines, Ecken-Erhaltung, Dreiecksstreifen
├── geometry.rs # Formerkennung (Douglas-Peucker, Kreis-Fit, …)
├── selection.rs # Auswahl, Griffe, Verschieben/Drehen/Skalieren
├── ruler.rs # Virtuelles Lineal
├── text.rs # Textfelder: Satz, Zeilenumbruch, Rasterung
├── background.rs # Papierfarben, Musterfarben, Karo/Linien/Punkte
├── image_layer.rs # Texturen für Bilder, PDF-Seiten und Text
├── assets.rs # Bilder und PDFs im Notizbuchordner
├── pdf_doc.rs # PDF lesen und rendern (hayro)
├── pdf_merge.rs # Importierte PDF-Seiten beim Export unterlegen
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
├── history.rs # Rückgängig/Wiederholen
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
├── autosave.rs # Speichern im Hintergrund
├── export.rs # PDF-Export (printpdf)
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
├── file_picker.rs # Eingebaute Dateiauswahl
├── settings.rs # Einstellungen (~/.config/ink-notes/ui.json)
├── platform.rs # Wayland/X11-Erkennung
├── updater.rs # Updates von Gitea (Signatur- und Prüfsummenprüfung)
└── ui.rs # Oberfläche (egui): Werkzeugleiste, Menüs, Dialoge
```
Mehr dazu in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md), das Speicherformat steht in [`docs/FILE_FORMAT.md`](docs/FILE_FORMAT.md).
---
## Releases bauen und veröffentlichen
```bash
./scripts/build-release.sh # .deb, .AppImage und SHA256SUMS.txt in target/
```
Veröffentlicht wird über Gitea Actions ([`.gitea/workflows/release.yaml`](.gitea/workflows/release.yaml)), sobald ein Tag `vX.Y.Z` gepusht wird: Tests, Pakete bauen, signieren, Release anlegen. Manuell geht es mit `GITEA_TOKEN=… ./scripts/publish-gitea-release.sh`.
### Release-Signatur einrichten (einmalig)
Updates werden in der App nur mit gültiger Minisign-Signatur installiert. Signiert wird mit [rsign2](https://crates.io/crates/rsign2) (`cargo install rsign2 --locked`). Schlüsselpaar ohne Passwort erzeugen (es wird nur als CI-Secret verwendet):
```bash
rsign generate -W -p ink-notes.pub -s ink-notes.key
```
1. Die zweite Zeile von `ink-notes.pub` (beginnt mit `RW`) in `src/updater.rs` als `UPDATE_SIGNING_PUBLIC_KEY` eintragen.
2. Den Inhalt von `ink-notes.key` in Gitea unter *Repository → Einstellungen → Actions → Secrets* als `MINISIGN_SECRET_KEY` hinterlegen.
3. `ink-notes.key` offline aufbewahren. Geht er verloren, können bestehende Installationen keine Updates mehr prüfen.
`rsign generate -W` verschlüsselt den Schlüssel mit leerem Passwort; das `minisign`-Programm (z. B. Ubuntu-Paket 0.11) kann ihn ohne Terminal nicht lesen, deshalb signieren Skript und CI mit `rsign`. 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 passt.
### CI-Runner (act_runner)
Der Workflow läuft auf dem Label `ubuntu-latest`. Im `config.yaml` des Runners muss es zugeordnet sein, z. B.:
```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.
Bleibt ein Job auf „Waiting to run“ stehen: Ist der Runner online (*Einstellungen → Aktionen → Runner*) und stimmt das Label mit `runs-on` überein? `sudo` verwendet der Workflow nur, wenn der Runner nicht als `root` läuft.
---
## 🚀 Schnellstart (Entwicklung)
## Sicherheit der Updates
### 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 / Wayland Bibliotheken (`libx11-dev`, `libxcursor-dev`, `libxrandr-dev`, `libxi-dev`, `libxkbcommon-dev`)
### Bauen & Ausführen
```bash
# Repository klonen
git clone https://gitea.pansi.eu/harald/ink-notes.git
cd ink-notes
# Kompilieren und starten via Starter-Skript
./run.sh
```
Alternativ direkt mit Cargo:
```bash
cargo run --release
```
1. **Signatur:** Jedes Release enthält `SHA256SUMS.txt.minisig`. Die App lädt `SHA256SUMS.txt` und die Signatur vor dem Paket und prüft sie gegen den eingebauten öffentlichen Schlüssel (`minisign-verify`). Ohne gültige Signatur wird nicht installiert.
2. **Prüfsumme:** Der SHA-256-Wert des heruntergeladenen Pakets muss zum Eintrag in der signierten Liste passen, sonst wird die Datei gelöscht.
3. **Nur HTTPS:** Auch Download-Links und Weiterleitungen müssen HTTPS sein. Bei Weiterleitungen auf einen anderen Server wird der `Authorization`-Header nicht mitgeschickt.
4. **Eigener Download-Ordner:** `~/.cache/ink-notes/updates` (Rechte `0700`); Dateinamen mit Pfadtrennern, `..` oder Steuerzeichen werden abgelehnt.
5. **AppImage-Austausch:** Die neue Datei wird neben der alten angelegt, die alte als `.bak` gesichert und dann per `rename` ersetzt – die laufende Datei wird nie überschrieben.
---
## ⌨️ Tastatur- & Maus-Shortcuts
## Lizenz
| Aktion | Tastenkürzel / Mausgeste |
| :--- | :--- |
| **Zeichnen** | Linke Maustaste / Stiftspitze |
| **Canvas verschieben (Pan)** | Mittlere Maustaste **oder** `Leertaste + Drag` |
| **Zoom zentriert auf Cursor** | Mausrad / Touchpad-Pinch |
| **Zoom zurücksetzen (100 %)** | `Strg + 0` |
| **Zoom In / Out** | `Strg + +` / `Strg + -` |
| **Auswahlwerkzeug (Lasso)** | `V` |
| **Auswahl um 90° drehen** | `R` |
| **Kopieren & Einfügen** | `Strg + C` / `Strg + V` |
| **Auswahl löschen** | `Entf` oder `Backspace` |
| **Undo / Redo** | `Strg + Z` / `Strg + Y` (oder `Strg + Shift + Z`) |
| **Nächste Seite** | `Bild ab (PageDown)` oder `N` |
| **Vorherige Seite** | `Bild auf (PageUp)` |
| **Erste / Letzte Seite** | `Pos 1 (Home)` / `Ende (End)` |
| **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) |
---
## 🏗️ Projekt- und Modul-Architektur
```text
src/
├── main.rs # Event-Loop (winit), Input-Routing, App-Orchestrierung
├── lib.rs # Bibliotheks-Exporte für Tests und Komponenten
├── canvas.rs # wgpu Render-Pipeline, GPU-Buffer, Zeichenzustand
├── 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 # 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
├── document.rs # Notizbuch-Datenmodell, Seiten, DIN-A4-Koordinatensystem
├── 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, HTTPS, SHA256-Integrität, Self-Update)
├── export.rs # Skalierbarer Vektor-PDF-Export via printpdf
└── ui.rs # Floating Glassmorphism-Toolbar, Dokumenten- & Update-Manager (egui)
```
Ausführliche Details zur Architektur und mathematischen Modellen findest du unter [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
---
## 🧪 Tests ausführen
Die gesamte Inking-Pipeline, Geometrieerkennung, Speicherlogik und der Updater sind mit umfassenden Unit-Tests abgesichert:
```bash
cargo test
```
Alle Unit-Tests prüfen Berechnungen, Hit-Testing, Undo/Redo, Persistenz, Updater-Sicherheit und Versionsvergleiche konsistent ab.
---
## 📄 Lizenz
Dieses Projekt ist unter der [MIT-Lizenz](LICENSE) lizenziert (Copyright © 2026 Harald Pansi).
[MIT](LICENSE), Copyright © 2026 Harald Pansi.
+178
View File
@@ -0,0 +1,178 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 360" width="1200" height="360">
<defs>
<!-- Haupt-Hintergrund-Verlauf -->
<linearGradient id="bannerBg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0f1420"/>
<stop offset="40%" stop-color="#141a29"/>
<stop offset="100%" stop-color="#090b12"/>
</linearGradient>
<!-- Subtiles Ambient Glow -->
<radialGradient id="ambientGlow" cx="15%" cy="50%" r="45%">
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.22"/>
<stop offset="70%" stop-color="#1d4ed8" stop-opacity="0.05"/>
<stop offset="100%" stop-color="#1d4ed8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="ambientGlowRight" cx="85%" cy="30%" r="40%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.10"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<!-- Icon Hintergrund-Verlauf -->
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e2638"/>
<stop offset="50%" stop-color="#141824"/>
<stop offset="100%" stop-color="#0c0e17"/>
</linearGradient>
<!-- Stiftkörper-Verlauf -->
<linearGradient id="penBody" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3b82f6"/>
<stop offset="100%" stop-color="#1d4ed8"/>
</linearGradient>
<!-- Goldene Feder-Spitze -->
<linearGradient id="nibGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#fbbf24"/>
<stop offset="50%" stop-color="#f59e0b"/>
<stop offset="100%" stop-color="#d97706"/>
</linearGradient>
<!-- Tintenspur-Verlauf -->
<linearGradient id="inkGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#60a5fa" stop-opacity="0.95"/>
<stop offset="50%" stop-color="#3b82f6" stop-opacity="0.85"/>
<stop offset="100%" stop-color="#2563eb" stop-opacity="0.4"/>
</linearGradient>
<!-- Text Ink Gradient -->
<linearGradient id="textInkGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#60a5fa"/>
<stop offset="100%" stop-color="#38bdf8"/>
</linearGradient>
<!-- Leucht-Effekt -->
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="8" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
<filter id="iconShadow" x="-10%" y="-10%" width="120%" height="130%">
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#000000" flood-opacity="0.6"/>
</filter>
<!-- Gitter-Muster für Hintergrund -->
<pattern id="gridPattern" width="30" height="30" patternUnits="userSpaceOnUse">
<path d="M 30 0 L 0 0 0 30" fill="none" stroke="#94a3b8" stroke-width="0.8" opacity="0.07"/>
</pattern>
</defs>
<!-- Banner-Rahmen & Hintergrund -->
<rect width="1200" height="360" rx="20" fill="url(#bannerBg)" stroke="#27324b" stroke-width="2"/>
<!-- Hintergrund-Gitter (Notizbuch-Raster) -->
<rect width="1200" height="360" rx="20" fill="url(#gridPattern)"/>
<!-- Subtile Glows -->
<rect width="1200" height="360" rx="20" fill="url(#ambientGlow)"/>
<rect width="1200" height="360" rx="20" fill="url(#ambientGlowRight)"/>
<!-- ==================== LINKER BEREICH: APP-ICON ==================== -->
<g transform="translate(65, 55) scale(0.488)" filter="url(#iconShadow)">
<!-- App-Icon Hintergrund (Squircle) -->
<rect x="24" y="24" width="464" height="464" rx="104" ry="104" fill="url(#bgGrad)" stroke="#384260" stroke-width="4"/>
<!-- DIN-A4 Dokumenten-Gitter im Icon -->
<g opacity="0.14" stroke="#94a3b8" stroke-width="2">
<line x1="80" y1="160" x2="432" y2="160" />
<line x1="80" y1="220" x2="432" y2="220" />
<line x1="80" y1="280" x2="432" y2="280" />
<line x1="80" y1="340" x2="432" y2="340" />
<line x1="80" y1="400" x2="432" y2="400" />
<line x1="140" y1="100" x2="140" y2="430" />
<line x1="200" y1="100" x2="200" y2="430" />
<line x1="260" y1="100" x2="260" y2="430" />
<line x1="320" y1="100" x2="320" y2="430" />
<line x1="380" y1="100" x2="380" y2="430" />
</g>
<!-- Tintenspur -->
<path d="M 110 390 C 180 430, 260 380, 240 310 C 220 240, 280 190, 340 220"
fill="none" stroke="url(#inkGrad)" stroke-width="26" stroke-linecap="round" filter="url(#glow)"/>
<path d="M 110 390 C 180 430, 260 380, 240 310 C 220 240, 280 190, 340 220"
fill="none" stroke="#93c5fd" stroke-width="12" stroke-linecap="round"/>
<!-- Stylus / Füllfederhalter -->
<g transform="translate(240, 110) rotate(45)">
<rect x="-24" y="-140" width="48" height="150" rx="8" fill="url(#penBody)" stroke="#60a5fa" stroke-width="3"/>
<rect x="-22" y="10" width="44" height="26" rx="4" fill="#1e293b" stroke="#cbd5e1" stroke-width="2"/>
<rect x="-25" y="6" width="50" height="6" fill="#f8fafc"/>
<path d="M -20 36 L 20 36 L 12 85 L 0 115 L -12 85 Z" fill="url(#nibGrad)" stroke="#fef08a" stroke-width="2"/>
<line x1="0" y1="46" x2="0" y2="108" stroke="#78350f" stroke-width="3" stroke-linecap="round"/>
<circle cx="0" cy="65" r="4" fill="#78350f"/>
</g>
<!-- Glanzpunkt -->
<circle cx="110" cy="390" r="14" fill="#60a5fa" filter="url(#glow)"/>
<circle cx="110" cy="390" r="8" fill="#ffffff"/>
</g>
<!-- ==================== RECHTER BEREICH: TYPOGRAFIE & BADGES ==================== -->
<g transform="translate(360, 0)">
<!-- Version & Platform Pill -->
<g transform="translate(0, 68)">
<rect width="254" height="30" rx="15" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
<circle cx="16" cy="15" r="4" fill="#22c55e"/>
<text x="28" y="20" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#cbd5e1" letter-spacing="0.5">
v0.14.0 • LINUX • RUST &amp; WGPU
</text>
</g>
<!-- Haupttitel: ink-notes -->
<text x="0" y="162" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="800" font-size="64" letter-spacing="-1">
<tspan fill="url(#textInkGrad)">ink</tspan><tspan fill="#f8fafc">-notes</tspan>
</text>
<!-- Subtitle / Tagline -->
<text x="0" y="206" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="400" font-size="22" fill="#94a3b8">
Notizbuch- &amp; PDF-Annotations-App für Stift, Touch und Maus
</text>
<!-- Feature Pills / Highlights -->
<g transform="translate(0, 248)">
<!-- Pill 1: Niedrige Latenz -->
<g transform="translate(0, 0)">
<rect width="186" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
⚡ Schnelles wgpu-Inking
</text>
</g>
<!-- Pill 2: Stylus mit Druck -->
<g transform="translate(198, 0)">
<rect width="190" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
🖊️ Druck &amp; Palm Rejection
</text>
</g>
<!-- Pill 3: Formerkennung -->
<g transform="translate(400, 0)">
<rect width="182" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
📐 Formenerkennung &amp; Lineal
</text>
</g>
<!-- Pill 4: 100% lokal -->
<g transform="translate(594, 0)">
<rect width="176" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
🔒 100% lokal &amp; atomar
</text>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.2 KiB

+156 -117
View File
@@ -1,153 +1,192 @@
# Architektur & Technische Dokumentation von ink-notes
# Architektur von ink-notes
Dieses Dokument beschreibt die interne Architektur, mathematische Modelle und Rendering-Pipelines von `ink-notes`.
Dieses Dokument beschreibt, wie ink-notes intern aufgebaut ist: Eingabe und Strichverarbeitung, Rendering, Seiten und Ansicht, Formerkennung, Speichern und Export. Das Speicherformat steht in [`FILE_FORMAT.md`](FILE_FORMAT.md).
---
## 1. Die Inking-Pipeline
## 1. Überblick
ink-notes ist ein einzelnes Programm mit einem Event-Loop (`winit`) im Haupt-Thread. Dazu kommen drei Hintergrund-Threads:
| Thread | Aufgabe |
| :--- | :--- |
| Haupt-Thread | Eingaben, Datenmodell, Rendering (`wgpu`), Oberfläche (`egui`) |
| Autosave | Seiten und Manifest auf die Festplatte schreiben (`autosave.rs`) |
| PDF-Rendering | Importierte PDF-Seiten zu Texturen rendern (`pdf_doc.rs`, `hayro`) |
| Export | PDF/PNG/SVG-Export, damit die Oberfläche nicht hängt (`main.rs`) |
Stifteingaben kommen über `octotablet` (Tablet-Protokoll von Wayland bzw. X11), Maus und Touch über `winit`. Die Oberfläche (Werkzeugleiste, Menüs, Dialoge, Textbearbeitung) ist mit `egui` gebaut und wird im selben Render-Durchgang über das Blatt gezeichnet.
---
## 2. Vom Stift zum Strich
```
[Hardware Stylus / Touch / Maus]
│
▼
[winit Event-Loop]
│ (tx, ty, pressure, timestamp)
▼
[Viewport Invertierung] (screen_to_page: Umrechnung in DIN-A4-Einheiten)
│
▼
[1€-Filter] (Jitter-Beseitigung bei langsamen Bewegungen)
│
▼
[Centripetal Catmull-Rom] (C1-stetige Spline-Interpolation, alpha = 0.5)
│
▼
[Mesh-Tessellierung] (Drucksensitive Dreiecksstreifen via Normalen)
│
▼
[wgpu GPU-Buffer] (Vertex- & Index-Buffer im VRAM)
[Stift / Touch / Maus]
│ Bildschirmposition, Druck, Zeit
▼
[Bildschirm → Seitenkoordinaten] (viewport.rs)
│
▼
[Druckkurve] (settings.rs, vom Nutzer einstellbar)
│
▼
[1€-Filter] (filter.rs)
│
▼
[Catmull-Rom-Spline + Dreiecksstreifen] (spline.rs) → Live-Puffer auf der GPU
│ Stift abgehoben
▼
[Nachbearbeitung] (spline.rs: finalize_stroke)
│
▼
[Strich auf der Seite + Undo-Eintrag] (document.rs, history.rs)
```
### 1.1. 1€-Filter (One-Euro-Filter)
Stift- und Touch-Eingaben unterliegen bei niedrigen Geschwindigkeiten oft Mikrozittern (Jitter). Standard-Tiefpassfilter verursachen jedoch bei schnellen Schwüngen eine störende Verzögerung (Latenz).
### 2.1 1€-Filter
Langsame Bewegungen zittern, schnelle sollen nicht nachhinken. Der 1€-Filter (`filter.rs`) passt seine Grenzfrequenz an die Geschwindigkeit an:
Der 1€-Filter (`filter.rs`) löst dieses Dilemma mit einer adaptiven Grenzfrequenz $f_c$:
$$f_c = f_{c,\min} + \beta \cdot |\dot{x}|$$
- Bei geringer Geschwindigkeit ($|\dot{x}| \approx 0$) dominiert $f_{c,\min}$, wodurch Zittern maximal geglättet wird.
- Bei schnellen Zügen wächst $\beta \cdot |\dot{x}|$ an, wodurch der Filter durchlässig wird und Latenz eliminiert.
### 1.2. Centripetal Catmull-Rom Splines
Um Ecken zu vermeiden und eine organische Schriftführung zu erzielen, werden Abtastpunkte mit einem zentripetalen Catmull-Rom-Spline (`spline.rs`) interpoliert ($\alpha = 0.5$):
$$t_{i+1} = t_i + ||P_{i+1} - P_i||^\alpha$$
Diese Parametrisierung garantiert:
- Keine Selbstüberschneidungen bei scharfen Wendungen.
- Keine unerwünschten Cusp-Schleifen.
- Perfekte $C^1$-Stetigkeit an den Stützstellen.
Bei langsamer Bewegung glättet er stark, bei schneller kaum.
### 1.3. Drucksensitive Dreiecksstreifen (Tessellierung)
Aus den interpolierten Spline-Punkten mit Radius $r_i = f(\text{pressure}_i)$ wird ein Triangle-Strip erzeugt:
1. Richtungsvektor $T_i = P_{i+1} - P_i$.
2. Senkrechter Normalenvektor $N_i = (-T_{i,y}, T_{i,x}) / ||T_i||$.
3. An inneren Stützstellen werden Normalen benachbarter Segmente gemittelt (Winkelhalbierende).
4. Eckpunkte des Dreiecksstreifens:
$$V_{i,\text{left}} = P_i + N_i \cdot r_i, \quad V_{i,\text{right}} = P_i - N_i \cdot r_i$$
### 2.2 Zentripetaler Catmull-Rom-Spline
Die gefilterten Punkte werden mit einem zentripetalen Catmull-Rom-Spline ($\alpha = 0{,}5$) interpoliert:
### 1.4. Zwei-Stufen-Modell (Live-Glättung vs. akausale Post-Stroke-Finalisierung)
Um minimale Latenz beim Zeichnen mit maximaler Präzision und Ecken-Erhaltung zu verbinden, arbeitet `ink-notes` mit einem strikten Zwei-Stufen-Modell:
$$t_{i+1} = t_i + \lVert P_{i+1} - P_i \rVert^{\alpha}$$
1. **Live-Phase (Kausal, Zero-Latency):**
- Eingehende Punkte durchlaufen in Echtzeit den 1€-Filter (`filter.rs`).
- `spline.rs` tesselliert inkrementell temporäre Triangle-Strips für die unverzögerte Bildschirmdarstellung im GPU-Live-Buffer.
2. **Commit-Phase (Akausal, `finalize_stroke`):**
- Beim Absetzen des Stifts wird der gesamte Strich akausal analysiert:
- **Druckglättung:** Glättung des Druckverlaufs $p$ über ein gewichtetes 3-Punkt-Fenster $[0.25, 0.5, 0.25]$ zur Ausblendung von Digitalisierer-Stufen.
- **RDP-Dezimierung:** Ausdünnung kollinearer Zwischenpunkte mit Ramer-Douglas-Peucker ($\epsilon \approx 0.075\text{ mm} = 0.75\text{ Einheiten}$).
- **Ecken-Erkennung (Corner Preservation):** Bei Richtungsänderungen $> 65^\circ$ wird die $C^1$-Spline-Kontinuität zu $C^0$ aufgebrochen, sodass Ecken (z. B. 90°-L-Knicke oder Zickzack-Linien) gestochen scharf bleiben und nicht rundgeschliffen werden.
- **Runde Kappen & Miter-Joins:** Generierung sauberer runder Kappen an Start und Ende des Triangle-Strips.
- Der temporäre Live-Buffer wird vollständig entleert und der bereinigte `Stroke` in den persistenten Puffer der Seite überführt.
Diese Parametrisierung erzeugt bei engen Kurven keine Schlaufen oder Spitzen und ist an den Stützstellen $C^1$-stetig.
### 2.3 Dreiecksstreifen
Aus den Spline-Punkten mit Radius $r_i$ (aus Druck und Strichstärke) entsteht ein Dreiecksstreifen:
$$V_{i,\text{links}} = P_i + N_i \cdot r_i, \qquad V_{i,\text{rechts}} = P_i - N_i \cdot r_i$$
$N_i$ ist die Normale der Laufrichtung; an inneren Punkten wird über benachbarte Segmente gemittelt.
### 2.4 Zwei Stufen
1. **Während des Schreibens:** Filter und Spline arbeiten nur mit bisherigen Punkten; der Strich wird sofort in einen eigenen Live-Puffer geschrieben.
2. **Beim Absetzen** wird der ganze Strich noch einmal bearbeitet (`finalize_stroke`):
- Druckverlauf mit dem Fenster $[0{,}25;\ 0{,}5;\ 0{,}25]$ glätten,
- Punkte mit Ramer-Douglas-Peucker ausdünnen ($\varepsilon = 0{,}75$ Einheiten $= 0{,}075$ mm),
- an Knicken über 65° den Spline auftrennen, damit Ecken nicht abgerundet werden,
- runde Endkappen anfügen.
Danach wird der Live-Puffer geleert und der Strich in die Seite übernommen.
### 2.5 Lineal
Beginnt ein Strich nahe einer Linealkante (`ruler.rs`), werden alle seine Punkte auf diese Kante projiziert, bis der Stift abgehoben wird.
---
## 2. Rendering & Kamera-Pipeline (`wgpu`)
## 3. Koordinaten, Seiten und Ansicht
### 2.1. Virtuelles Koordinatensystem
Die Anwendung rechnet intern in virtuellen DIN-A4-Koordinaten:
- Breite: **2100 Einheiten** (210 mm)
- Höhe: **2970 Einheiten** (297 mm)
- 1 mm entspricht exakt **10 Einheiten**.
### 3.1 Seitenkoordinaten
Gerechnet wird in Seiteneinheiten: **10 Einheiten = 1 mm**. Eine DIN-A4-Seite ist 2100 × 2970 Einheiten groß. Jede Seite hat ihre eigene Größe (`Page::size`): Standard ist A4 hoch; beim PDF-Import bekommen Querformat-Seiten 2970 Einheiten Breite und eine Höhe nach Seitenverhältnis (`document::pdf_page_size`). Neue Seiten übernehmen die Größe der aktuellen.
### 2.2. Viewport & Projektionsmatrix (`viewport.rs`)
Der Vertex-Shader transformiert DIN-A4-Koordinaten über eine Orthogonal-Projektion kombiniert mit Pan und Zoom in Normalized Device Coordinates (NDC, $[-1, 1]$):
### 3.2 Maßstab und Kamera
- `PageViewport` (`document.rs`) passt einen Rahmen, in den alle Seiten des Notizbuchs passen (`Notebook::fit_box`), ins Fenster ein. Daraus ergibt sich ein Maßstab (Bildschirmpixel je Einheit), der für alle Seiten gilt. Die aktuelle Seite liegt waagerecht zentriert und oben im Rahmen. Den Platz der Werkzeugleiste rechnet der Viewport heraus.
- `ViewportState` (`viewport.rs`) ergänzt Zoom (25 % bis 1000 %) und Verschiebung (Pan). Beim Zoomen bleibt der Punkt unter dem Mauszeiger stehen:
```wgsl
struct CameraUniform {
transform: mat4x4<f32>,
};
```
Die Projektionsmatrix $M$ wird berechnet aus:
1. Skalierung des DIN-A4-Papiers auf den Viewport unter Berücksichtigung des Seitenverhältnisses.
2. Anwenden des Zoom-Faktors $z \in [0.25, 5.0]$.
3. Translation durch den Pan-Offset $(p_x, p_y)$.
### 2.3. Cursor-zentrierter Zoom
Beim Zoomen mit dem Mausrad wird der neue Pan-Offset so korrigiert, dass der Punkt unter dem Cursor fixiert bleibt:
$$\vec{p}_{\text{neu}} = \vec{c} - (\vec{c} - \vec{p}_{\text{alt}}) \cdot \frac{z_{\text{neu}}}{z_{\text{alt}}}$$
### 2.4. Render-Passes
In jedem Frame werden zwei Hauptpässe durchlaufen:
1. **Pass 1 (Canvas):**
- Hintergrund-Quad und Linien/Gitter (Instanced oder Batch Vertex-Buffer).
- Gespeicherte Striche und Geometrie-Meshes.
- Aktiver Live-Strich (Ephemeral Buffer).
- Auswahl-Overlay & Transformationsgriffe.
2. **Pass 2 (egui UI):**
- Floating Glassmorphism-Toolbar.
- Farb- und Werkzeug-Popovers.
- Modal-Dokumentenmanager.
- Im Shader wird jeder Punkt mit `transform_scale` und `transform_offset` (aus `CameraUniforms`) direkt in Gerätekoordinaten umgerechnet.
### 3.3 Durchgehende Ansicht
Alle Koordinaten beziehen sich auf die **aktuelle Seite**. `PageStack` (`viewport.rs`) legt die übrigen Seiten lückenlos darunter bzw. darüber (die Grenze zeichnet die Oberfläche als dunkle Linie), waagerecht zur aktuellen Seite zentriert. Jede sichtbare Nachbarseite wird mit einer eigenen Kamera gezeichnet, die um die Lage der Seite verschoben ist (höchstens 12 Nachbarseiten).
Nach dem Scrollen wird die Seite in der Fenstermitte zur aktuellen Seite; die Kamera wird dabei so verschoben, dass sich das Bild nicht bewegt. Während eines Strichs, einer Auswahl, beim Ziehen des Lineals oder beim Schreiben von Text wird nicht gewechselt. Wer auf einer sichtbaren Nachbarseite zu zeichnen beginnt, wechselt zuerst auf diese Seite.
---
## 3. Geometrieerkennung & Shape-Fitting (`geometry.rs`)
## 4. Rendering
Die Erkennung erfolgt rein analytisch und deterministisch ohne schwere ML-Frameworks:
Ein Frame besteht aus einem Render-Durchgang auf die Fensterfläche (`canvas.rs`):
### 3.1. Ramer-Douglas-Peucker (RDP)
Reduziert den Rohpfad auf signifikante Eckpunkte mit konfigurierbarem Toleranzabstand $\epsilon$.
1. Für jede sichtbare Nachbarseite: Papier und Muster, Bilder/PDF-Seite/Text, Tinte.
2. Für die aktuelle Seite: Papier, Muster (Karo, Linien, Punkte), Bildebene, gespeicherte Striche, Live-Strich.
3. Die `egui`-Oberfläche: Werkzeugleiste, Menüs, Seitengrenzen, Auswahlrahmen und Griffe, Lineal, Textbearbeitung.
### 3.2. Erkennungsregeln:
- **Gerade:** Verhältnis von Sehnenlänge ($||P_{\text{end}} - P_{\text{start}}||$) zur Pfad-Bogenlänge $> 0.92$.
- **Rechteck:** RDP liefert 4 dominante Ecken; Kantenwinkel liegen zwischen $75^\circ$ und $105^\circ$.
- **Kreis / Ellipse:** Pfad ist geschlossen ($||P_{\text{start}} - P_{\text{end}}|| < 0.1 \times \text{Umfang}$); Standardabweichung der Radien vom Mittelpunkt ist minimal ($< 0.12 \times \bar{r}$).
- **Koordinatensystem:** Zwei orthogonale Schenkel mit Schnittpunkt nahe dem Start/Ende; Winkel $80^\circ$ bis $100^\circ$ wird exakt auf $90^\circ$ eingerastet; positive Achsenenden erhalten Pfeilspitzen.
- **Dreiecke / Rhomben / Oktaeder:** Spezifische Eckpunkt- und Symmetrieanalysen der RDP-Kontur.
Jeder Strich ist ein eigener Draw-Call mit eigener Nummer im Stencil-Puffer: Ein Pixel, das der Strich schon gefärbt hat, wird kein zweites Mal gefärbt. So bleibt halbtransparente Tinte (Textmarker) gleichmäßig, auch wo sich der Strich selbst überlappt (Anfangskappe, Kehren, langsames Hin- und Herfahren); erst ein neuer Strich deckt stärker. Nach 255 Strichen setzt ein bildschirmfüllendes Dreieck den Stencil-Puffer zurück.
### 3.3. Draw-and-Hold Erkennung
Hält der Anwender den Stift am Ende eines Strichs für $> 400\text{ ms}$ innerhalb eines Radius von $< 3\text{ px}$ ruhig, wird die Form im fliegenden Wechsel als Vektorform visualisiert und beim Abheben des Stifts fixiert.
### 4.1 Bildebene
`image_layer.rs` zeichnet texturierte Vierecke unter der Tinte: importierte PDF-Seiten, eingefügte Bilder und Textfelder. Texturen werden beim ersten Bedarf geladen, bekommen Mipmaps und werden freigegeben, wenn mehr als 384 MB belegt sind.
- **PDF-Seiten** rendert ein Hintergrund-Thread mit `hayro` (längste Kante 2560 Pixel, beim starken Hineinzoomen 5120 Pixel). Bis die schärfere Fassung fertig ist, bleibt die vorhandene sichtbar.
- **Textfelder** werden mit `ab_glyph` gerastert, in einer Auflösung, die dem Zoom folgt (in Vierteln einer Zweierpotenz, höchstens 8 Pixel je Einheit). Der Texturschlüssel enthält einen Hash von Inhalt, Schrift und Farbe; jede Änderung erzeugt eine neue Textur.
### 4.2 Papier- und Musterfarben
Papierfarbe (`PaperTheme`) und Musterfarbe (`PatternColor`) stehen in `background.rs`; sie sind linear angegeben und werden im Shader nach sRGB umgerechnet. Tintenfarben (Striche, Text) sind sRGB-Werte – dieselben Zahlen landen im PDF und in den Farbpunkten der Oberfläche. Gezeichnet wird ohne sRGB-Umrechnung der GPU (`render_format`), Texturen sind `Rgba8Unorm`: So wird halbtransparente Tinte wie im PDF in sRGB gemischt und sieht am Bildschirm genauso aus wie im Export. Auf dunklem Papier kann der Shader dunkle Tinte aufhellen (`ink_params` in `CameraUniforms`), gespeichert werden immer die Originalfarben.
---
## 4. Persistenz & Autosave-Architektur (`storage.rs`, `autosave.rs`)
## 5. Objekte auf der Seite
Alles auf einer Seite ist ein `Stroke` (`document.rs`):
- **Tinte:** Abtastpunkte, Werkzeug (Stift, Textmarker, Form), Breite, Farbe; die Dreiecke werden beim Laden aus den Punkten neu berechnet.
- **Formen:** zusätzlich eine `RecognizedShape` (siehe Abschnitt 6).
- **Bilder und Textfelder:** vier Eckpunkte (oben links, oben rechts, unten rechts, unten links) plus Verweis auf die Bilddatei bzw. ein `TextBox`. Weil die Ecken normale Punkte sind, wirken Verschieben, Drehen und Skalieren der Auswahl ohne Sonderfälle.
### 5.1 Textfelder
`text.rs` setzt den Text selbst: Zeilenumbruch an Leerzeichen (zu lange Wörter zeichenweise), Unterschneidung, Tabulator = 4 Leerzeichen. Bildschirm-Textur, PDF-Export und Bearbeitung verwenden dieselben Schriften (Ubuntu Light und Hack aus den eingebauten `egui`-Schriften) und denselben Umbruch. Bearbeitet wird in einem `egui`-Textfeld an der Stelle des Felds; danach wird die Höhe des Felds an den Text angepasst, Lage, Drehung und Skalierung bleiben.
### 5.2 Rückgängig
Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ersetzen und Ändern von Strichen. Verschieben/Drehen/Skalieren einer Auswahl, ein Radierzug mit dem Teilstrich-Radierer und eine Textbearbeitung sind jeweils ein Schritt.
---
## 6. Formerkennung
`geometry.rs` erkennt Formen rein geometrisch, ohne Machine Learning. Geschlossene Formen (Kreis, Ellipse, Dreieck, Rechteck, Raute) treten gegeneinander an; gewählt wird die, deren Umriss am besten zu den Punkten passt. Danach folgen Vielecke, Pfeile, Koordinatensysteme und Linien. Übernommen wird eine Form nur, wenn der Strich im Mittel höchstens 4 % seiner Diagonale von ihr abweicht – sonst bleibt er Handschrift.
- **Linie:** Verhältnis von Abstand Anfang–Ende zur Strichlänge über 0,92.
- **Kreis, Ellipse:** Kreis- bzw. Ellipsen-Fit über die Punkte.
- **Rechteck, Dreieck, Raute, Vieleck, Pfeil:** Analyse der mit Douglas-Peucker vereinfachten Kontur (Eckenzahl, Winkel, Seitenverhältnisse).
- **Koordinatensystem:** zwei etwa rechtwinklige Achsen; der Winkel wird auf 90° gesetzt.
- **Oktaeder:** Skizze einer Doppelpyramide als 3D-Kantenmodell; nur, wenn kein einfacher Umriss passt.
**Halten am Strichende:** Bleibt der Stift über 400 ms innerhalb von 3 Pixeln ruhig, wird die erkannte Form schon vor dem Absetzen angezeigt und beim Absetzen übernommen. Mit dem Formen-Werkzeug wird jeder Strich beim Absetzen geprüft.
---
## 7. Speichern
```
[UI / Inking Event]
│
▼
[mark_page_dirty]
│
▼
[AutosaveController] ──(1.5s Debounce)──► [Hintergrund-Thread (MPSC)]
│
▼
[Temp-Datei *.tmp schreiben]
│
▼
[Atomares std::fs::rename]
│
▼
[manifest.json / page_<id>.json]
[Änderung]
│ mark_page_dirty / mark_manifest_dirty
▼
[AutosaveController] ── 1,5 s nach der letzten Änderung, spätestens nach 10 s ──► [Autosave-Thread]
│
▼
[temporäre Datei schreiben + fsync]
│
▼
[rename über die alte Datei, Ordner-fsync]
```
- **Keine UI-Blockierung:** Disk-I/O erfolgt vollständig in einem dedizierten Worker-Thread.
- **Atomarität:** Daten werden stets zuerst in `.json.tmp` geschrieben und via `fs::rename` atomar ersetzt.
- **Verlustfreier Notizbuchwechsel:** Bei `switch_notebook_dir` wird synchron `flush()` aufgerufen, um alle ungespeicherten Daten des aktuellen Dokuments sicherzustellen, bevor der Pfad gewechselt wird.
- Der Haupt-Thread schreibt nie selbst; er übergibt fertige Seitendaten an den Autosave-Thread.
- Schlägt ein Schreibvorgang fehl, wird die Seite mit wachsendem Abstand (1 s bis 30 s) erneut versucht; der Status wird rot.
- `flush()` schreibt alles Ausstehende und wartet darauf. Es läuft bei `Strg + S`, beim Wechsel des Notizbuchs und beim Beenden. Schlägt es beim Beenden fehl, bleibt die App offen und bietet Wiederholen oder einen Notfall-Export der ungespeicherten Seiten an (`~/.local/share/ink-notes/recovery/`).
- Seiten werden erst beim Anzeigen geladen (Lazy Loading); Titel, Hintergrund und Größe stehen im Manifest, damit Seitenübersicht und durchgehende Ansicht sie vorher kennen.
- Ein zweites Fenster mit demselben Notizbuch öffnet es schreibgeschützt (Sperrdatei).
---
## 8. Export
`export.rs` erzeugt das PDF mit `printpdf`. Jede Seite bekommt ihre eigene Größe und diese Ebenen:
1. **Background:** Karo, Linien oder Punkte in der gewählten Musterfarbe (immer auf weißem Papier).
2. **PDF** (nur im Notfall): importierte PDF-Seite als Bild, falls sie sich nicht als Vektor übernehmen lässt.
3. **Images:** eingefügte Bilder (höchstens 300 dpi).
4. **Text:** Textfelder als echter Text mit eingebetteter Schrift (durchsuchbar, kopierbar). Jedes Feld liegt in einem eigenen Koordinatensystem, das eine Transformationsmatrix auf seine Ecken abbildet.
5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade. Halbtransparente Striche (Textmarker) bekommen einen Grafikzustand mit Deckkraft (`ca`/`CA`); printpdf kann solche Zustände nicht anlegen, deshalb trägt `add_alpha_states` sie nach dem Speichern mit `lopdf` in die Seitenressourcen ein. Jeder Strich ist ein einziger Pfad und deckt sich daher nicht selbst doppelt.
Importierte PDF-Seiten werden anschließend als Form-XObject unter die Seite gelegt (`pdf_merge.rs`), bleiben also Vektorgrafik. Seitentitel werden zu Lesezeichen.
PNG und SVG einer Seite (`page_export.rs`) entstehen aus diesem PDF: `hayro` rastert es bzw. wandelt es in SVG um. So sehen alle Exporte gleich aus.
---
## 9. Updates
`updater.rs` fragt die Gitea-Releases ab, vergleicht die Version (SemVer) und lädt auf Wunsch das passende Paket. Vor der Installation prüft er die Minisign-Signatur von `SHA256SUMS.txt` gegen den eingebauten Schlüssel und danach die SHA-256-Prüfsumme des Pakets. Ein AppImage wird per `rename` ersetzt; ein `.deb`-Paket wird mit `xdg-open` an die Softwareinstallation des Systems übergeben. Details im README unter „Sicherheit der Updates“.
+144 -102
View File
@@ -1,134 +1,176 @@
# Dateiformat & Speicherstruktur von ink-notes
# Dateiformat von ink-notes
Dieses Dokument beschreibt die Speicherstruktur und die JSON-Dateiformate, mit denen `ink-notes` Dokumente auf dem Dateisystem ablegt.
Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die Versionen des Dateiformats. Aktuell ist **Version 8**.
---
## 1. Verzeichnisstruktur
Notizbücher werden standardmäßig unter dem XDG-Datenpfad gespeichert:
## 1. Ordner
```text
~/.local/share/ink-notes/
└── notebooks/
├── <notebook_id_1>/
│ ├── manifest.json
│ ├── page_1.json
│ ├── page_2.json
│ └── ...
└── <notebook_id_2>/
├── manifest.json
└── page_1.json
~/.local/share/ink-notes/ ($XDG_DATA_HOME/ink-notes)
├── notebooks/
│ ├── <notizbuch-id>/
│ │ ├── manifest.json Metadaten und Seitenreihenfolge
│ │ ├── manifest.json.bak Kopie des vorherigen Manifests
│ │ ├── page_1.json eine Datei je Seite
│ │ ├── page_2.json
│ │ ├── page_7.json.corrupt-<ts> beiseitegelegte beschädigte Seite (falls vorgekommen)
│ │ ├── assets/ eingefügte Bilder und importierte PDFs
│ │ │ ├── <sha256>.png
│ │ │ └── <sha256>.pdf
│ │ └── .lock Sperrdatei, solange das Notizbuch geöffnet ist
│ └── .trash/ gelöschte Notizbücher (<id>_<zeitstempel>)
└── recovery/ Notfall-Sicherungen ungespeicherter Seiten
~/.config/ink-notes/ui.json ($XDG_CONFIG_HOME) Einstellungen der Oberfläche
```
- **Notizbuch-IDs** werden aus dem Titel und dem Zeitpunkt gebildet (z. B. `mathematik_1775460000`); das erste Notizbuch heißt `default`.
- **Assets** sind nach dem SHA-256-Hash ihres Inhalts benannt; dasselbe Bild wird nur einmal gespeichert. Bilder werden als PNG oder (unverändert) JPEG abgelegt, PDFs unverändert.
- Alle Dateien werden mit Rechten `0600`, Ordner mit `0700` angelegt.
---
## 2. Dokumenten-Manifest (`manifest.json`)
Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge und IDs aller Seiten:
## 2. Manifest (`manifest.json`)
```json
{
"id": "notebook-a1b2c3d4",
"title": "Mathematik Vorlesung",
"version": 1,
"pages": [
{
"id": 1,
"file_name": "page_1.json"
},
{
"id": 2,
"file_name": "page_2.json"
}
],
"current_page_index": 0,
"next_page_id": 3,
"next_stroke_id": 42,
"format_version": 8,
"id": "vorlesung_1775460000",
"title": "Vorlesung",
"created_at": 1775460000,
"modified_at": 1775463600
"modified_at": 1775463600,
"current_page_index": 0,
"pages": [
{ "id": 1, "background": "Blank", "title": "Einführung", "size": [2970.0, 1671.0] },
{ "id": 2, "background": "Grid5mm" }
],
"page_ids": [1, 2],
"next_stroke_id": 42,
"next_page_id": 3
}
```
### Felder:
- `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.
- `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.
- `created_at` / `modified_at`: UNIX-Zeitstempel in Sekunden.
| Feld | Bedeutung |
| :--- | :--- |
| `format_version` | Formatversion (siehe Abschnitt 5) |
| `id`, `title` | ID (= Ordnername) und Anzeigename |
| `created_at`, `modified_at` | UNIX-Zeit in Sekunden |
| `current_page_index` | zuletzt geöffnete Seite |
| `pages` | Seiten in ihrer Reihenfolge: `id`, `background`, optional `title` und `size` (siehe unten) – damit Seitenübersicht und Ansicht sie kennen, ohne jede Seite zu laden |
| `page_ids` | dieselbe Reihenfolge als reine ID-Liste (für ältere Versionen) |
| `next_stroke_id`, `next_page_id` | nächste freie IDs |
---
## 3. Seitendatei (`page_<id>.json`)
## 3. Seite (`page_<id>.json`)
Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelles inkrementelles Speichern zu ermöglichen.
Jede Seite liegt in einer eigenen Datei (kompaktes JSON, hier zur Lesbarkeit umbrochen):
```json
{
"format_version": 8,
"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
}
}
}
]
"background": "Blank",
"title": "Einführung",
"size": [2970.0, 1671.0],
"pdf_background": { "asset": "<sha256>.pdf", "page": 0, "width_pt": 960.0, "height_pt": 540.0 },
"strokes": [ … ]
}
```
### Felder:
- `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:
- `Line { start, end }`
- `Rectangle { top_left, width, height }`
- `Circle { center, radius }`
- `Axes { origin, x_end, y_end, has_arrows }`
- `Triangle { p1, p2, p3 }`
- `Rhombus { center, width, height }`
- `Octahedron { center, radius }`
| Feld | Bedeutung |
| :--- | :--- |
| `background` | Muster: `Blank`, `Grid5mm`, `Lined`, `DotGrid` |
| `title` | Seitentitel (optional) |
| `size` | Seitengröße `[breite, höhe]` in Seiteneinheiten (10 = 1 mm); fehlt bei DIN A4 hoch (`[2100, 2970]`) |
| `pdf_background` | importierte PDF-Seite als Hintergrund (optional): Datei, Seitennummer ab 0, Größe in Punkt |
| `strokes` | alle Objekte der Seite, von unten nach oben |
### 3.1 Objekte (`strokes`)
Jedes Objekt hat eine `id`, eine `color` (RGBA, linear, Werte 0–1) und `samples`: Punkte mit `x`, `y` (Seiteneinheiten), `p` (Druck 0–1) und `t` (Zeit in Sekunden). Die Dreiecke für die Anzeige werden beim Laden aus den Punkten berechnet und nicht gespeichert.
**Tinte**
```json
{ "id": 7, "color": [0.12, 0.12, 0.14, 1.0],
"samples": [{ "x": 350.2, "y": 420.5, "p": 0.65, "t": 12.345 }, …],
"tool": "pen", "width": 5.0, "finalized": true }
```
- `tool`: `pen`, `highlighter` oder `shape`
- `width`: Strichbreite in Seiteneinheiten (5.0 = 0,5 mm)
- `finalized`: Der Strich wurde nach dem Absetzen nachbearbeitet (Glättung, Ecken, Endkappen)
**Formen** haben zusätzlich `shape`, z. B.:
```json
"shape": { "Rectangle": { "corners": [[500, 600], [800, 600], [800, 800], [500, 800]] } }
```
Mögliche Formen: `Line { start, end }`, `Arrow { start, end, double }`, `Circle { center, radius }`, `Ellipse { center, radius_x, radius_y, rotation }`, `Rectangle { corners }`, `Triangle { corners }`, `Rhombus { corners }`, `Polygon { vertices }`, `CoordinateSystem { origin, x_axis_end, y_axis_end, with_arrows }`, `Octahedron { … }`. Punkte sind `[x, y]`.
**Bilder** haben `image` (Dateiname in `assets/`); die vier `samples` sind die Ecken oben links, oben rechts, unten rechts, unten links:
```json
{ "id": 9, "color": [1, 1, 1, 1], "samples": [ …4 Ecken… ], "width": 0.0, "image": "<sha256>.png" }
```
**Textfelder** haben `text`; die vier `samples` sind wie bei Bildern die Ecken des Felds:
```json
"text": { "content": "Überschrift\nzweite Zeile", "size": 42.3, "font": "Sans", "wrap_width": 1500.0 }
```
- `size`: Schriftgröße (Em) in Seiteneinheiten (12 pt ≈ 42,3)
- `font`: `Sans` (Ubuntu Light) oder `Mono` (Hack)
- `wrap_width`: Umbruchbreite in Seiteneinheiten; die Ecken bilden das Feld dieser Breite (und der Höhe des gesetzten Texts) ab, Drehung und Skalierung stecken in den Ecken
---
## 4. Atomares Schreibverfahren
## 4. Sicherheit beim Speichern
Um Datenverlust bei Stromausfall oder plötzlichem Prozessabbruch zu verhindern:
1. Daten werden in `<ziel>.tmp` serialisiert und per `fsync` auf das Speichermedium geschrieben.
2. Mittels `std::fs::rename("<ziel>.tmp", "<ziel>")` wird die Zieldatei auf POSIX-Systemen atomar ersetzt.
### Atomares Schreiben
1. Der Inhalt wird in `<datei>.tmp.<pid>.<nanosekunden>` geschrieben und mit `fsync` auf den Datenträger gebracht.
2. `rename` ersetzt die alte Datei in einem Schritt.
3. Danach wird auch der Ordner mit `fsync` gesichert.
Ein Absturz während des Speicherns hinterlässt deshalb entweder die alte oder die neue Datei, nie eine halbe.
### Beschädigte Dateien
- Lässt sich eine Seite nicht lesen, wird sie nach `page_<id>.json.corrupt-<zeitstempel>` verschoben (nicht gelöscht) und die Seite schreibgeschützt angezeigt.
- Ist `manifest.json` beschädigt oder fehlt, wird `manifest.json.bak` verwendet.
### Gelöschte Seiten
Eine gelöschte Seite verschwindet aus `manifest.json`; ihre Datei `page_<id>.json` bleibt im Ordner liegen. Seitenkennungen werden nie wiederverwendet (`next_page_id`), die Datei kann also mit keiner neuen Seite kollidieren. Wird die Seite in derselben Sitzung wiederhergestellt, schreibt die App sie neu.
### Neuere Versionen
Dateien mit einer höheren `format_version` als der unterstützten werden nicht geöffnet – so kann eine ältere App-Version keine Inhalte verlieren, die sie nicht kennt.
### Migration
Beim ersten Speichern einer unversionierten Datei (Version 0) wird vorher eine Kopie `*.v0.bak` angelegt.
---
## 5. Versionen
| Version | App | Änderung |
| :--- | :--- | :--- |
| 0 | – | ohne `format_version` |
| 1 | – | `format_version`, Hintergründe im Manifest |
| 2 | – | `tool`, `width`, `finalized` an Strichen; kompaktes JSON |
| 3 | 0.3.7 | Formen `Ellipse` und `Arrow` |
| 4 | 0.5 | Bilder (`image`, Ordner `assets/`) |
| 5 | 0.6.1 | importierte PDF-Seiten (`pdf_background`) |
| 6 | 0.7 | Seitentitel (`title` in Seite und Manifest) |
| 7 | 0.10 | Textfelder (`text`) |
| 8 | 0.11 | Seitengröße je Seite (`size` in Seite und Manifest) |
Jede neue Version ändert nur Ergänzungen; ältere Dateien werden ohne Umwandlung gelesen und beim nächsten Speichern mit der aktuellen Versionsnummer geschrieben.
---
## 6. Warum JSON
Ein Binärformat (z. B. `postcard`) würde Dateien um weitere 20–30 % verkleinern. JSON bleibt trotzdem, weil es sich ansehen, vergleichen und im Notfall von Hand reparieren lässt und das Speichern einer Seite auch bei vielen Strichen deutlich unter einer Millisekunde dauert.
+2 -2
View File
@@ -2,13 +2,13 @@
Version=1.0
Name=ink-notes
GenericName=Notizbuch- & Inking-App
Comment=Latenzoptimierte Vektor-Notizen mit Stylus, Stift & Geometrie-Erkennung
Comment=Handschriftliche Notizen mit Stift, Touch und Maus; PDFs beschriften
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;
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;Text;Annotation;
StartupWMClass=ink-notes
MimeType=application/x-inknotes;
Actions=NewWindow;
+12 -10
View File
@@ -4,20 +4,22 @@
<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>
<summary>Notizbuch-App für Stift, Touch und Maus</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.
ink-notes ist eine Notizbuch-App für Linux, geschrieben in Rust mit wgpu. Handschrift wird als
Vektoren gespeichert; geschrieben wird mit Stift, Finger, Touchpad oder Maus.
</p>
<p>Hauptmerkmale:</p>
<p>Funktionen:</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>
<li>Stift und Textmarker mit Druckstärke, Glättung beim Schreiben und Ecken-Erhaltung</li>
<li>Radierer für ganze Striche, Teilstriche oder nur Textmarker; virtuelles Lineal</li>
<li>Formerkennung (Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Koordinatensysteme)</li>
<li>Auswählen, Verschieben, Drehen, Skalieren und Umfärben; Rückgängig für alle Änderungen</li>
<li>Textfelder, Bilder und PDF-Import zum Beschriften (Querformat-Folien bekommen Querformat-Seiten)</li>
<li>Durchgehendes Scrollen, Zoom von 25 bis 1000 Prozent, Papier- und Musterfarben</li>
<li>Mehrere Notizbücher, automatisches Speichern im Hintergrund</li>
<li>Export als PDF (mit durchsuchbarem Text), PNG oder SVG</li>
</ul>
</description>
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
+2 -2
View File
@@ -2,13 +2,13 @@
Version=1.0
Name=ink-notes
GenericName=Notizbuch- & Inking-App
Comment=Latenzoptimierte Vektor-Notizen mit Stylus, Stift & Geometrie-Erkennung
Comment=Handschriftliche Notizen mit Stift, Touch und Maus; PDFs beschriften
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;
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;Text;Annotation;
StartupWMClass=ink-notes
MimeType=application/x-inknotes;
Actions=NewWindow;
+12 -10
View File
@@ -4,20 +4,22 @@
<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>
<summary>Notizbuch-App für Stift, Touch und Maus</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.
ink-notes ist eine Notizbuch-App für Linux, geschrieben in Rust mit wgpu. Handschrift wird als
Vektoren gespeichert; geschrieben wird mit Stift, Finger, Touchpad oder Maus.
</p>
<p>Hauptmerkmale:</p>
<p>Funktionen:</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>
<li>Stift und Textmarker mit Druckstärke, Glättung beim Schreiben und Ecken-Erhaltung</li>
<li>Radierer für ganze Striche, Teilstriche oder nur Textmarker; virtuelles Lineal</li>
<li>Formerkennung (Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Koordinatensysteme)</li>
<li>Auswählen, Verschieben, Drehen, Skalieren und Umfärben; Rückgängig für alle Änderungen</li>
<li>Textfelder, Bilder und PDF-Import zum Beschriften (Querformat-Folien bekommen Querformat-Seiten)</li>
<li>Durchgehendes Scrollen, Zoom von 25 bis 1000 Prozent, Papier- und Musterfarben</li>
<li>Mehrere Notizbücher, automatisches Speichern im Hintergrund</li>
<li>Export als PDF (mit durchsuchbarem Text), PNG oder SVG</li>
</ul>
</description>
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
+34 -8
View File
@@ -1,21 +1,47 @@
## ink-notes {{TAG_NAME}}
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit 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**: Verlustfreie Ausgabe für Druck und Weitergabe
- **Eigene Farben**: Jede Farbe der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – erneut antippen, Vorgabe oder mit 🎨 frei wählen; die Leiste merkt sich die Farben. Farben am Bildschirm wie im PDF-/Bildexport (Schwarz ist schwarz, Textmarker mischt wie im PDF); Karos und Linien im PDF so hell wie am Bildschirm
- **Beta-Feedback II**: Vier Stifte mit eigener Farbe und Stärke, die sich Änderungen merken; Textmarker wird innerhalb eines Strichs nicht dunkler und ist jetzt auch im PDF-/PNG-/SVG-Export durchsichtig; Seiten lückenlos untereinander mit Trennlinie; neue Seite per ➕ in der Werkzeugleiste; Seite löschen im ☰-Menü (wiederherstellbar); Koordinatensystem ohne Loch am Ursprung
- **Fehlerbehebungen**: Umbenennen und Löschen in der Notizbuchverwaltung reagieren wieder; Karos, Linien und Punkte werden pro Pixel berechnet und liegen in jedem Zoom gleichmäßig (mindestens ein Pixel breit, kantengeglättet)
- **Strichqualität**: Kleine Schleifen wie in „o“, „a“ oder „e“ bleiben rund (der fertige Strich entsteht aus den ungefilterten Stiftpunkten); Strichkanten werden kantengeglättet (4× Multisampling), runde Strichenden sind feiner unterteilt
- **Musterfarbe**: Karos, Linien und Punkte in hell, mittel, kräftig, blau oder grün (☰-Menü, gilt auch im Export); Punkte des Punktrasters am Bildschirm so groß wie im Export (0,4 mm). README und Dokumentation überarbeitet
- **Beta-Feedback**: Querformat-PDFs (z. B. PowerPoint-Folien) bekommen Querformat-Seiten ohne leeren Streifen (Dateiformat-Version 8); neue Seiten direkt nach der aktuellen einfügen; Auswahlwerkzeug funktioniert mit dem Stift; Zoom bis 1000 % mit schärfer nachgerenderten PDF-Seiten
- **Text-Werkzeug**: Mit T ins Blatt tippen und schreiben – normale oder Code-Schrift, 8–36 pt, Zeilenumbruch am Feldrand; Textfelder verschieben, drehen und skalieren wie Bilder; im PDF echter, durchsuchbarer Text; kopierter Text wird mit Strg+V zum Textfeld (Dateiformat-Version 7)
- **Durchgehendes Scrollen**: Seiten untereinander mit Mausrad, Finger oder Touchpad scrollen (Strg+Mausrad zoomt); Schreiben im unteren Fünftel der letzten Seite hängt automatisch eine neue an; abschaltbar unter ☰ → Ansicht
- **Virtuelles Lineal**: Ein- und ausblenden mit L oder über die Werkzeugleiste; verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden exakt gerade – mit Stift, Textmarker und Formwerkzeug
- **Teilstrich-Radierer**: Nur den überstrichenen Teil eines Strichs entfernen – ein Radierzug ist ein Undo-Schritt
- **Export & Zwischenablage**: Aktuelle Seite als PNG (auch transparent) oder SVG; Strg+C legt die Auswahl zusätzlich als Bild in die Zwischenablage für Chat, Mail oder Office
- **Seitentitel & Inhaltsverzeichnis**: Seiten benennen, Inhaltsansicht in der Seitenübersicht, Titel werden zu PDF-Lesezeichen (Dateiformat-Version 6)
- **PDF importieren & annotieren**: Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen (☰-Menü oder Drag & Drop), darauf schreiben und markieren; beim Export liegen die Originalseiten verlustfrei als Vektor unter den Notizen (Dateiformat-Version 5: ältere Versionen öffnen solche Notizbücher nicht)
- **Bilder einfügen**: Screenshots per Strg+V, Bilddateien über die eingebaute Dateiauswahl (Strg+I) oder per Drag & Drop; verschieben, drehen, skalieren und darauf schreiben – auch im PDF-Export (Dateiformat-Version 4: ältere Versionen öffnen solche Notizbücher nicht)
- **Dunkelmodus & Papierfarben**: Weiß, Creme, Dunkelgrau oder Schwarz; auf dunklem Papier werden dunkle Stiftfarben automatisch aufgehellt (nur Anzeige – Dateien und PDF-Export bleiben unverändert)
- **Nur Textmarker radieren**: Markierungen korrigieren, ohne die Handschrift darunter zu löschen – auch mit Stiftrückseite und Seitentaste
- **Einstellbare Druckkurve**: Kennlinie weich bis hart und Mindestdruck mit Vorschau, zum Anpassen an den eigenen Stift
- **Neue Formen**: Ellipsen sowie in einem Zug gezeichnete Pfeile und Doppelpfeile werden erkannt (Dateiformat-Version 3: ältere Versionen öffnen solche Notizbücher nicht)
- **Stabiler Start**: Kein Absturz mehr, wenn die Grafikkarte nicht nutzbar ist (z. B. fehlende Rechte auf /dev/dri); Software-Renderer als Ausweichlösung und verständliche Fehlermeldung
- **WSL (WSLg)**: Läuft unter WSL automatisch über X11 statt Wayland, da WSLg die Wayland-Verbindung abbricht; übersteuerbar mit `INK_NOTES_DISPLAY=x11|wayland`
- **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)**: Stifteingabe über das Tablet-Protokoll von Wayland bzw. X11, mit Druckstärke
- **Touchscreen Multi-Touch**: Zoomen mit zwei Fingern, Verschieben mit einem; Zeichnen mit dem Finger zuschaltbar
- **Zwei-Stufen-Inking-Modell**: Glättung während des Schreibens (1€-Filter), beim Absetzen Nachbearbeitung mit Ecken-Erhaltung
- **Geometrie- und Formerkennung**: Geraden, Pfeile & Doppelpfeile, Rechtecke, Kreise, Ellipsen, Dreiecke, Rauten, Vielecke & Koordinatensysteme mit spitzen Pfeilspitzen (Draw-and-Hold); Freiformen bleiben unverändert
- **Mehrere Notizbücher**: Anlegen, umbenennen, löschen (mit Papierkorb); automatisches Speichern im Hintergrund
- **Datensicherheit**: Versioniertes Dateiformat mit Migration, beschädigte Seiten werden beiseitegelegt statt überschrieben, Schutz vor gleichzeitigem Öffnen, atomares Speichern
- **In-App Updater**: Prüft auf neue Versionen auf Gitea und installiert sie auf Wunsch – nur mit gültiger Minisign-Signatur und über HTTPS
- **PDF-Export**: Striche und Formen als Vektoren, 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)**:
- **AppImage (andere Distributionen mit glibc ≥ 2.39)**:
```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 "=================================================================="
+5 -7
View File
@@ -104,9 +104,7 @@ 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.
* See the release notes on https://gitea.pansi.eu/harald/ink-notes/releases
-- Harald <harald@pansi.eu> $(date -R)
EOF
@@ -130,10 +128,10 @@ Depends: libc6 (>= 2.38), libgcc-s1, libvulkan1, mesa-vulkan-drivers | vulkan-ic
Section: utils
Priority: optional
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.
Description: Notizbuch-App für Stift, Touch und Maus
ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen und
kann PDFs zum Beschriften importieren sowie Bilder und Textfelder einfügen.
Mehrere Notizbücher, automatisches Speichern und Export als PDF, PNG oder SVG.
EOF
chmod 644 "${STAGING_DIR}/DEBIAN/control"
+9 -1
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 idempotent als Release-Assets hoch.
# Lädt .deb, .AppImage, SHA256SUMS.txt und SHA256SUMS.txt.minisig idempotent als Release-Assets hoch.
# ==============================================================================
set -euo pipefail
@@ -33,6 +33,7 @@ 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
@@ -46,6 +47,12 @@ 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 "======================================================================"
@@ -230,6 +237,7 @@ except Exception:
upload_asset "${DEB_FILE}"
upload_asset "${APPIMAGE_FILE}"
upload_asset "${CHECKSUMS_FILE}"
upload_asset "${SIGNATURE_FILE}"
echo "======================================================================"
echo "Release ${TAG_NAME} erfolgreich veröffentlicht!"
+366
View File
@@ -0,0 +1,366 @@
//! Eingebettete Bilder eines Notizbuchs (`<Notizbuch>/assets/`).
//!
//! Jedes Bild wird einmal unter dem SHA-256 seines Inhalts gespeichert (`<hash>.png` bzw.
//! `<hash>.jpg`). Seiten verweisen nur auf diesen Namen; dasselbe Bild mehrfach einzufügen
//! kostet keinen zusätzlichen Platz. Bilddateien werden nie gelöscht, damit Undo und
//! Kopien auf anderen Seiten immer funktionieren.
//!
//! JPEG wird unverändert übernommen (verlustfrei, auch im PDF-Export); alle anderen Formate
//! werden als PNG gespeichert.
use std::io::Cursor;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::storage;
/// Name des Unterordners mit den Bilddateien.
pub const ASSETS_DIR: &str = "assets";
/// Größte Kantenlänge eines eingefügten Bildes in Pixeln; größere Bilder werden verkleinert
/// (8000 px ≈ 300 dpi über die doppelte A4-Breite – mehr ist auf einer Seite nie sichtbar).
pub const MAX_IMPORT_DIMENSION: u32 = 8000;
/// Größte Kantenlänge der Bildschirmdarstellung (GPU-Textur).
pub const MAX_DISPLAY_DIMENSION: u32 = 2048;
/// Größte Dateigröße beim Einfügen.
pub const MAX_IMPORT_BYTES: usize = 64 * 1024 * 1024;
/// Fehler beim Einfügen oder Laden eines Bildes.
#[derive(Debug)]
pub enum AssetError {
/// Format nicht unterstützt oder Datei beschädigt
Decode(String),
/// Datei zu groß
TooLarge(usize),
/// Ungültiger Asset-Name (z. B. manipulierte Seitendatei)
InvalidName(String),
Io(std::io::Error),
}
impl std::fmt::Display for AssetError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AssetError::Decode(msg) => write!(f, "Bild kann nicht gelesen werden: {msg}"),
AssetError::TooLarge(bytes) => write!(
f,
"Bild ist zu groß ({} MB, höchstens {} MB)",
bytes / (1024 * 1024),
MAX_IMPORT_BYTES / (1024 * 1024)
),
AssetError::InvalidName(name) => write!(f, "Ungültiger Bildverweis '{name}'"),
AssetError::Io(e) => write!(f, "Ein-/Ausgabefehler: {e}"),
}
}
}
impl std::error::Error for AssetError {}
impl From<std::io::Error> for AssetError {
fn from(e: std::io::Error) -> Self {
AssetError::Io(e)
}
}
/// Ergebnis eines erfolgreichen Imports.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportedImage {
/// Dateiname im Asset-Ordner, z. B. `3f…a9.png`
pub asset: String,
pub width: u32,
pub height: u32,
}
/// Dekodiertes Bild in 8-Bit-RGBA (sRGB).
#[derive(Debug, Clone, PartialEq)]
pub struct RgbaImage {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
}
/// Prüft einen Asset-Namen: genau 64 Hex-Zeichen und `.png`, `.jpg` oder `.pdf`. Verhindert, dass
/// eine manipulierte Seitendatei auf Dateien außerhalb des Asset-Ordners verweist.
pub fn is_valid_asset_name(name: &str) -> bool {
let Some((hash, ext)) = name.split_once('.') else {
return false;
};
hash.len() == 64
&& hash
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
&& matches!(ext, "png" | "jpg" | "pdf")
}
/// Bilddateien eines Notizbuchs.
#[derive(Debug, Clone)]
pub struct AssetStore {
dir: PathBuf,
}
impl AssetStore {
/// Asset-Ordner des Notizbuchs in `notebook_dir`.
pub fn new(notebook_dir: &Path) -> Self {
Self {
dir: notebook_dir.join(ASSETS_DIR),
}
}
pub fn dir(&self) -> &Path {
&self.dir
}
/// Pfad einer Bilddatei (nur für gültige Namen).
pub fn path_of(&self, asset: &str) -> Result<PathBuf, AssetError> {
if is_valid_asset_name(asset) {
Ok(self.dir.join(asset))
} else {
Err(AssetError::InvalidName(asset.to_string()))
}
}
/// Übernimmt eine Bilddatei (Inhalt einer PNG-, JPEG-, GIF-, WebP- oder BMP-Datei).
pub fn import_bytes(&self, bytes: &[u8]) -> Result<ImportedImage, AssetError> {
if bytes.len() > MAX_IMPORT_BYTES {
return Err(AssetError::TooLarge(bytes.len()));
}
let format = image::guess_format(bytes).map_err(|e| AssetError::Decode(e.to_string()))?;
let decoded = image::load_from_memory_with_format(bytes, format)
.map_err(|e| AssetError::Decode(e.to_string()))?;
let fits =
decoded.width() <= MAX_IMPORT_DIMENSION && decoded.height() <= MAX_IMPORT_DIMENSION;
if format == image::ImageFormat::Jpeg && fits {
// JPEG unverändert übernehmen (kein erneuter Qualitätsverlust)
return self.store(bytes, "jpg", decoded.width(), decoded.height());
}
let decoded = if fits {
decoded
} else {
decoded.resize(
MAX_IMPORT_DIMENSION,
MAX_IMPORT_DIMENSION,
image::imageops::FilterType::Triangle,
)
};
let png = encode_png(&decoded)?;
self.store(&png, "png", decoded.width(), decoded.height())
}
/// Übernimmt eine PDF-Datei unverändert (der Inhalt muss vorher geprüft sein).
pub fn import_pdf(&self, bytes: &[u8]) -> Result<String, AssetError> {
let hash = Sha256::digest(bytes);
let asset = format!("{}.pdf", hex(&hash));
let path = self.path_of(&asset)?;
if !path.exists() {
storage::atomic_write_file(&path, bytes)?;
}
Ok(asset)
}
/// Übernimmt rohe RGBA-Pixel (z. B. aus der Zwischenablage) als PNG.
pub fn import_rgba(
&self,
width: u32,
height: u32,
pixels: Vec<u8>,
) -> Result<ImportedImage, AssetError> {
let buffer = image::RgbaImage::from_raw(width, height, pixels)
.ok_or_else(|| AssetError::Decode("Pixeldaten passen nicht zur Bildgröße".into()))?;
let mut decoded = image::DynamicImage::ImageRgba8(buffer);
if width > MAX_IMPORT_DIMENSION || height > MAX_IMPORT_DIMENSION {
decoded = decoded.resize(
MAX_IMPORT_DIMENSION,
MAX_IMPORT_DIMENSION,
image::imageops::FilterType::Triangle,
);
}
let png = encode_png(&decoded)?;
self.store(&png, "png", decoded.width(), decoded.height())
}
/// Kopiert ein Bild aus dem Asset-Ordner eines anderen Notizbuchs (Einfügen über
/// Notizbuchgrenzen hinweg). Ist es schon vorhanden, passiert nichts.
pub fn copy_from(&self, other: &AssetStore, asset: &str) -> Result<(), AssetError> {
let target = self.path_of(asset)?;
if target.exists() || other.dir == self.dir {
return Ok(());
}
let bytes = std::fs::read(other.path_of(asset)?)?;
storage::atomic_write_file(&target, &bytes)?;
Ok(())
}
/// Liest die gespeicherte Bilddatei unverändert.
pub fn read_bytes(&self, asset: &str) -> Result<Vec<u8>, AssetError> {
Ok(std::fs::read(self.path_of(asset)?)?)
}
/// Lädt ein Bild für die Bildschirmdarstellung, verkleinert auf höchstens
/// `max_dimension` Pixel Kantenlänge.
pub fn load_rgba(&self, asset: &str, max_dimension: u32) -> Result<RgbaImage, AssetError> {
let bytes = self.read_bytes(asset)?;
let mut decoded =
image::load_from_memory(&bytes).map_err(|e| AssetError::Decode(e.to_string()))?;
if decoded.width() > max_dimension || decoded.height() > max_dimension {
decoded = decoded.resize(
max_dimension,
max_dimension,
image::imageops::FilterType::Triangle,
);
}
let rgba = decoded.into_rgba8();
Ok(RgbaImage {
width: rgba.width(),
height: rgba.height(),
pixels: rgba.into_raw(),
})
}
fn store(
&self,
bytes: &[u8],
ext: &str,
width: u32,
height: u32,
) -> Result<ImportedImage, AssetError> {
let hash = Sha256::digest(bytes);
let asset = format!("{}.{ext}", hex(&hash));
let path = self.path_of(&asset)?;
if !path.exists() {
storage::atomic_write_file(&path, bytes)?;
}
Ok(ImportedImage {
asset,
width,
height,
})
}
}
fn encode_png(image: &image::DynamicImage) -> Result<Vec<u8>, AssetError> {
let mut png = Vec::new();
image
.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png)
.map_err(|e| AssetError::Decode(e.to_string()))?;
Ok(png)
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// Ist die Datei dem Namen nach ein unterstütztes Bild?
pub fn is_supported_image_path(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| {
matches!(
e.to_ascii_lowercase().as_str(),
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp"
)
})
.unwrap_or(false)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// Kleines Testbild als Dateiinhalt im gewünschten Format.
pub(crate) fn sample_image_bytes(
width: u32,
height: u32,
format: image::ImageFormat,
) -> Vec<u8> {
let img = image::RgbImage::from_fn(width, height, |x, y| {
image::Rgb([
(x * 255 / width.max(1)) as u8,
(y * 255 / height.max(1)) as u8,
90,
])
});
let mut bytes = Vec::new();
image::DynamicImage::ImageRgb8(img)
.write_to(&mut Cursor::new(&mut bytes), format)
.unwrap();
bytes
}
#[test]
fn test_asset_names_are_validated() {
let hash = "a".repeat(64);
assert!(is_valid_asset_name(&format!("{hash}.png")));
assert!(is_valid_asset_name(&format!("{hash}.jpg")));
assert!(!is_valid_asset_name(&format!("{hash}.exe")));
assert!(!is_valid_asset_name("../../etc/passwd"));
assert!(!is_valid_asset_name(&format!("../{}.png", "a".repeat(61))));
assert!(!is_valid_asset_name(&format!("{}.png", "A".repeat(64))));
assert!(!is_valid_asset_name(""));
let store = AssetStore::new(Path::new("/tmp/nb"));
assert!(store.path_of("../manifest.json").is_err());
}
#[test]
fn test_import_png_jpeg_and_deduplicate() {
let dir = tempfile::tempdir().unwrap();
let store = AssetStore::new(dir.path());
let png = sample_image_bytes(40, 20, image::ImageFormat::Png);
let a = store.import_bytes(&png).unwrap();
assert_eq!((a.width, a.height), (40, 20));
assert!(a.asset.ends_with(".png"));
assert!(store.path_of(&a.asset).unwrap().exists());
// Gleiches Bild nochmals: gleicher Name, keine zweite Datei
let b = store.import_bytes(&png).unwrap();
assert_eq!(a, b);
assert_eq!(std::fs::read_dir(store.dir()).unwrap().count(), 1);
// JPEG bleibt byte-identisch
let jpg = sample_image_bytes(30, 30, image::ImageFormat::Jpeg);
let c = store.import_bytes(&jpg).unwrap();
assert!(c.asset.ends_with(".jpg"));
assert_eq!(store.read_bytes(&c.asset).unwrap(), jpg);
// BMP wird als PNG gespeichert
let bmp = sample_image_bytes(8, 4, image::ImageFormat::Bmp);
let d = store.import_bytes(&bmp).unwrap();
assert!(d.asset.ends_with(".png"));
let rgba = store.load_rgba(&a.asset, MAX_DISPLAY_DIMENSION).unwrap();
assert_eq!((rgba.width, rgba.height), (40, 20));
assert_eq!(rgba.pixels.len(), 40 * 20 * 4);
// Verkleinerung für die Anzeige behält das Seitenverhältnis
let small = store.load_rgba(&a.asset, 10).unwrap();
assert_eq!((small.width, small.height), (10, 5));
}
#[test]
fn test_import_rejects_garbage_and_copies_between_notebooks() {
let dir = tempfile::tempdir().unwrap();
let store = AssetStore::new(&dir.path().join("a"));
assert!(matches!(
store.import_bytes(b"kein Bild"),
Err(AssetError::Decode(_))
));
assert!(store.import_rgba(4, 4, vec![0; 3]).is_err());
let imported = store.import_rgba(2, 2, vec![255; 16]).unwrap();
let other = AssetStore::new(&dir.path().join("b"));
assert!(other.read_bytes(&imported.asset).is_err());
other.copy_from(&store, &imported.asset).unwrap();
assert_eq!(
other.read_bytes(&imported.asset).unwrap(),
store.read_bytes(&imported.asset).unwrap()
);
}
#[test]
fn test_supported_image_paths() {
assert!(is_supported_image_path(Path::new("/x/Foto.JPG")));
assert!(is_supported_image_path(Path::new("a.webp")));
assert!(!is_supported_image_path(Path::new("a.pdf")));
assert!(!is_supported_image_path(Path::new("png")));
}
}
+899 -85
View File
File diff suppressed because it is too large Load Diff
+326 -109
View File
@@ -1,149 +1,366 @@
//! Vektor-Generierung für DIN-A4-Seitenhintergründe (Kariert, Liniert, Punktraster, Blanko).
use serde::{Deserialize, Serialize};
use crate::canvas::InkVertex;
use crate::document::{PageBackground, PAGE_HEIGHT, PAGE_WIDTH};
use crate::document::PageBackground;
/// Farbe von Karos, Linien und Punkten (wird gespeichert und gilt auch für den Export).
/// `Light` ist die zurückhaltende Standardfarbe des jeweiligen Papiers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PatternColor {
#[default]
Light,
Medium,
Strong,
Blue,
Green,
}
impl PatternColor {
pub const ALL: [PatternColor; 5] = [
PatternColor::Light,
PatternColor::Medium,
PatternColor::Strong,
PatternColor::Blue,
PatternColor::Green,
];
pub fn label(self) -> &'static str {
match self {
PatternColor::Light => "Hell",
PatternColor::Medium => "Mittel",
PatternColor::Strong => "Kräftig",
PatternColor::Blue => "Blau",
PatternColor::Green => "Grün",
}
}
/// Linearer Farbwert des Musters auf dem Papier `paper` (für den Export: weißes Papier).
pub fn color(self, paper: PaperTheme) -> [f32; 4] {
let light = paper.pattern_color();
let dark = paper.is_dark();
let scaled = |f: f32| [light[0] * f, light[1] * f, light[2] * f, 1.0];
match (self, dark) {
(PatternColor::Light, _) => light,
// Helles Papier: kräftiger = dunkler
(PatternColor::Medium, false) => scaled(0.62),
(PatternColor::Strong, false) => scaled(0.28),
// Dunkles Papier: kräftiger = heller
(PatternColor::Medium, true) => [0.16, 0.16, 0.17, 1.0],
(PatternColor::Strong, true) => [0.32, 0.32, 0.34, 1.0],
(PatternColor::Blue, false) => [0.35, 0.55, 0.95, 1.0],
(PatternColor::Blue, true) => [0.06, 0.14, 0.42, 1.0],
(PatternColor::Green, false) => [0.32, 0.72, 0.38, 1.0],
(PatternColor::Green, true) => [0.05, 0.22, 0.09, 1.0],
}
}
}
/// Linienstärke für Gitter- und Schreiblinien in Seiteneinheiten (1.5 = 0.15 mm).
pub const LINE_THICKNESS: f32 = 1.5;
/// Punktgröße für das Punktraster in Seiteneinheiten (2.5 = 0.25 mm).
pub const DOT_SIZE: f32 = 2.5;
/// Punktgröße für das Punktraster in Seiteneinheiten (4 = 0,4 mm, wie im PDF-Export).
pub const DOT_SIZE: f32 = 4.0;
/// Rasterabstand 5 mm (50 Einheiten).
pub const GRID_5MM_SPACING: f32 = 50.0;
/// Zeilenabstand für liniertes Papier (7.5 mm = 75 Einheiten).
pub const LINED_SPACING: f32 = 75.0;
/// Hellgraue Farbe für Hintergrundmuster (#E0E0E0).
pub const BACKGROUND_PATTERN_COLOR: [f32; 4] = [0.88, 0.88, 0.88, 1.0];
/// Papierweiß für das DIN-A4-Blatt (#FFFFFF).
pub const PAPER_COLOR: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
/// Schreibtisch-Hintergrundfarbe (Letterbox-Bereich, #28282D).
pub const DESK_COLOR: wgpu::Color = wgpu::Color {
r: 0.16,
g: 0.16,
b: 0.18,
a: 1.0,
};
/// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte
/// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier).
///
/// Papier- und Musterfarben sind linear (der Shader rechnet sie in sRGB um); Tintenfarben
/// dagegen sind sRGB-Werte wie im Export.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PaperTheme {
#[default]
White,
/// Cremefarben (augenschonend, wie Recyclingpapier)
Cream,
/// Dunkelgrau (Nachtmodus)
DarkGray,
/// Schwarz (OLED)
Black,
}
/// Erzeugt die 6 Vertizes (2 Dreiecke) für das weiße DIN-A4-Papierblatt.
pub fn generate_paper_quad() -> Vec<InkVertex> {
impl PaperTheme {
pub const ALL: [PaperTheme; 4] = [
PaperTheme::White,
PaperTheme::Cream,
PaperTheme::DarkGray,
PaperTheme::Black,
];
pub fn label(self) -> &'static str {
match self {
PaperTheme::White => "Weiß",
PaperTheme::Cream => "Creme",
PaperTheme::DarkGray => "Dunkelgrau",
PaperTheme::Black => "Schwarz",
}
}
/// Dunkles Papier: dunkle Tinte wird bei Bedarf aufgehellt.
pub fn is_dark(self) -> bool {
matches!(self, PaperTheme::DarkGray | PaperTheme::Black)
}
/// Farbe des Blatts.
pub fn paper_color(self) -> [f32; 4] {
match self {
PaperTheme::White => [1.0, 1.0, 1.0, 1.0],
// sRGB #FBF5E6
PaperTheme::Cream => [0.965, 0.913, 0.791, 1.0],
// sRGB #2B2B2E
PaperTheme::DarkGray => [0.024, 0.024, 0.027, 1.0],
PaperTheme::Black => [0.0, 0.0, 0.0, 1.0],
}
}
/// Farbe von Karos, Linien und Punkten.
pub fn pattern_color(self) -> [f32; 4] {
match self {
PaperTheme::White => [0.88, 0.88, 0.88, 1.0],
PaperTheme::Cream => [0.78, 0.72, 0.60, 1.0],
// sRGB #48484D bzw. #38383C
PaperTheme::DarkGray => [0.064, 0.064, 0.074, 1.0],
PaperTheme::Black => [0.040, 0.040, 0.045, 1.0],
}
}
/// Schreibtisch-Hintergrund um das Blatt; bei dunklem Papier noch dunkler, damit sich
/// das Blatt abhebt.
pub fn desk_color(self) -> wgpu::Color {
let (r, g, b) = match self {
PaperTheme::White | PaperTheme::Cream => (0.16, 0.16, 0.18),
PaperTheme::DarkGray => (0.004, 0.004, 0.005),
PaperTheme::Black => (0.03, 0.03, 0.035),
};
// Linear angegeben; gezeichnet wird ohne sRGB-Umrechnung
let srgb = |c: f64| f64::from(linear_to_srgb(c as f32));
wgpu::Color {
r: srgb(r),
g: srgb(g),
b: srgb(b),
a: 1.0,
}
}
}
/// Linearer Farbanteil (Papier, Muster) als sRGB-Wert (Tinte, Export).
pub fn linear_to_srgb(c: f32) -> f32 {
let c = c.clamp(0.0, 1.0);
if c <= 0.003_130_8 {
c * 12.92
} else {
1.055 * c.powf(1.0 / 2.4) - 0.055
}
}
/// Hellt dunkle Tinte für dunkles Papier auf: die HSL-Helligkeit L wird zu 1 − L gespiegelt,
/// Farbton und Sättigung bleiben erhalten. Farben mit L ≥ 0.5 (Rot, Blau, Textmarker)
/// bleiben unverändert. Muss mit `fs_ink` in `shader.wgsl` übereinstimmen.
pub fn adapt_ink_color(color: [f32; 4]) -> [f32; 4] {
let max = color[0].max(color[1]).max(color[2]);
let min = color[0].min(color[1]).min(color[2]);
let lift = (1.0 - max - min).max(0.0);
[color[0] + lift, color[1] + lift, color[2] + lift, color[3]]
}
/// Erzeugt die 6 Vertizes (2 Dreiecke) für ein Blatt der Größe `size`.
pub fn generate_paper_quad(color: [f32; 4], size: glam::Vec2) -> Vec<InkVertex> {
let (page_width, page_height) = (size.x, size.y);
vec![
InkVertex::new(0.0, 0.0, PAPER_COLOR),
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR),
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR),
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR),
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR),
InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, PAPER_COLOR),
InkVertex::new(0.0, 0.0, color),
InkVertex::new(page_width, 0.0, color),
InkVertex::new(0.0, page_height, color),
InkVertex::new(0.0, page_height, color),
InkVertex::new(page_width, 0.0, color),
InkVertex::new(page_width, page_height, color),
]
}
/// 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) {
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));
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR));
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR));
vertices.push(InkVertex::new(x_max, y_max, BACKGROUND_PATTERN_COLOR));
/// Oberer und unterer Rand ohne Linien bei liniertem Papier (15 mm).
pub const LINED_MARGIN: f32 = 150.0;
/// Ecke des Musterrechtecks: Das Muster selbst berechnet der Fragment-Shader (`fs_pattern`)
/// für jedes Bildschirmpixel. So liegen Linien und Punkte in jedem Zoom exakt gleichmäßig,
/// sind mindestens ein Pixel breit und kantengeglättet – vorher gerasterte Haarlinien
/// fielen je nach Lage auf das Pixelraster und wirkten unregelmäßig.
#[repr(C)]
#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct PatternVertex {
/// Seitenkoordinate
pub position: [f32; 2],
pub color: [f32; 4],
/// Art (1 = Karo, 2 = Linien, 3 = Punkte), Abstand, Linienstärke, Punktgröße
pub params: [f32; 4],
/// Seitenbreite, Seitenhöhe, Rand oben/unten bei Linien, ungenutzt
pub bounds: [f32; 4],
}
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund.
pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
match background {
PageBackground::Blank => Vec::new(),
PageBackground::Grid5mm => {
let mut vertices = Vec::with_capacity(1200);
let half_t = LINE_THICKNESS / 2.0;
impl PatternVertex {
const ATTRIBUTES: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
0 => Float32x2,
1 => Float32x4,
2 => Float32x4,
3 => Float32x4
];
// Vertikale Linien alle 5 mm
let mut x = GRID_5MM_SPACING;
while x < PAGE_WIDTH {
push_quad(&mut vertices, x - half_t, 0.0, x + half_t, PAGE_HEIGHT);
x += GRID_5MM_SPACING;
}
// Horizontale Linien alle 5 mm
let mut y = GRID_5MM_SPACING;
while y < PAGE_HEIGHT {
push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t);
y += GRID_5MM_SPACING;
}
vertices
}
PageBackground::Lined => {
let mut vertices = Vec::with_capacity(600);
let half_t = LINE_THICKNESS / 2.0;
// Oberer und unterer Randabstand (z. B. 15 mm)
let margin_top = 150.0;
let margin_bottom = 150.0;
let mut y = margin_top;
while y <= PAGE_HEIGHT - margin_bottom {
push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t);
y += LINED_SPACING;
}
vertices
}
PageBackground::DotGrid => {
let mut vertices = Vec::with_capacity(15000);
let half_d = DOT_SIZE / 2.0;
let mut y = GRID_5MM_SPACING;
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,
);
x += GRID_5MM_SPACING;
}
y += GRID_5MM_SPACING;
}
vertices
pub fn desc() -> wgpu::VertexBufferLayout<'static> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<PatternVertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &Self::ATTRIBUTES,
}
}
}
/// Rechteck über das ganze Blatt der Größe `size`, auf dem der Shader das Muster
/// `background` in `color` zeichnet; leer bei blankem Papier.
pub fn generate_pattern_quad(
background: PageBackground,
color: [f32; 4],
size: glam::Vec2,
) -> Vec<PatternVertex> {
let params = match background {
PageBackground::Blank => return Vec::new(),
PageBackground::Grid5mm => [1.0, GRID_5MM_SPACING, LINE_THICKNESS, DOT_SIZE],
PageBackground::Lined => [2.0, LINED_SPACING, LINE_THICKNESS, DOT_SIZE],
PageBackground::DotGrid => [3.0, GRID_5MM_SPACING, LINE_THICKNESS, DOT_SIZE],
};
let bounds = [size.x, size.y, LINED_MARGIN, 0.0];
let corner = |x: f32, y: f32| PatternVertex {
position: [x, y],
color,
params,
bounds,
};
vec![
corner(0.0, 0.0),
corner(size.x, 0.0),
corner(0.0, size.y),
corner(0.0, size.y),
corner(size.x, 0.0),
corner(size.x, size.y),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::{A4_SIZE, PAGE_HEIGHT, PAGE_WIDTH};
#[test]
fn test_pattern_colors_get_more_visible() {
// Luminanz (linear) als Abstand zum Papier
let lum = |c: [f32; 4]| 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
for paper in PaperTheme::ALL {
let p = lum(paper.paper_color());
let contrast = |c: PatternColor| (lum(c.color(paper)) - p).abs();
assert_eq!(PatternColor::Light.color(paper), paper.pattern_color());
assert!(
contrast(PatternColor::Medium) > contrast(PatternColor::Light),
"{paper:?}"
);
assert!(
contrast(PatternColor::Strong) > contrast(PatternColor::Medium),
"{paper:?}"
);
for c in [PatternColor::Blue, PatternColor::Green] {
assert!(
contrast(c) > contrast(PatternColor::Light),
"{c:?} auf {paper:?}"
);
}
}
}
#[test]
fn test_paper_quad_generation() {
let quad = generate_paper_quad();
let quad = generate_paper_quad(PaperTheme::White.paper_color(), A4_SIZE);
assert_eq!(quad.len(), 6);
assert_eq!(quad[0].position, [0.0, 0.0]);
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
let slide = generate_paper_quad(
PaperTheme::White.paper_color(),
glam::Vec2::new(2970.0, 1671.0),
);
assert_eq!(slide[5].position, [2970.0, 1671.0]);
}
const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
#[test]
fn test_pattern_quads() {
assert!(generate_pattern_quad(PageBackground::Blank, PATTERN, A4_SIZE).is_empty());
for (background, kind) in [
(PageBackground::Grid5mm, 1.0),
(PageBackground::Lined, 2.0),
(PageBackground::DotGrid, 3.0),
] {
let quad = generate_pattern_quad(background, PATTERN, A4_SIZE);
assert_eq!(quad.len(), 6);
assert!(quad
.iter()
.all(|v| v.params[0] == kind && v.color == PATTERN));
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
assert_eq!(quad[0].bounds[..2], [PAGE_WIDTH, PAGE_HEIGHT]);
}
}
#[test]
fn test_background_meshes() {
let blank = generate_background_mesh(PageBackground::Blank);
assert!(blank.is_empty());
fn test_background_mesh_uses_theme_color() {
let quad = generate_paper_quad(PaperTheme::Cream.paper_color(), A4_SIZE);
assert!(quad
.iter()
.all(|v| v.color == PaperTheme::Cream.paper_color()));
}
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)"
);
#[test]
fn test_adapt_ink_color() {
let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5);
// Schwarz wird Weiß, Deckkraft bleibt
assert!(close(
adapt_ink_color([0.0, 0.0, 0.0, 0.7]),
[1.0, 1.0, 1.0, 0.7]
));
// Notiz-Schwarz wird hell
let ink = adapt_ink_color([0.12, 0.12, 0.14, 1.0]);
assert!(ink[0] > 0.85 && ink[2] > 0.85);
// Dunkelblau wird Hellblau: Farbton bleibt (Blau bleibt der stärkste Kanal)
let blue = adapt_ink_color([0.0, 0.0, 0.5, 1.0]);
assert!(close(blue, [0.5, 0.5, 1.0, 1.0]));
// Helle und gesättigte Farben bleiben unverändert (Rot, Universitäts-Blau, Textmarker)
for c in [
[0.85, 0.20, 0.20, 1.0],
[0.15, 0.35, 0.85, 1.0],
[1.0, 0.95, 0.1, 0.35],
[1.0, 1.0, 1.0, 1.0],
] {
assert!(close(adapt_ink_color(c), c), "{c:?} wurde verändert");
}
// Ergebnis ist nie heller als Weiß
for c in [[0.0, 0.3, 0.1, 1.0], [0.45, 0.45, 0.48, 1.0]] {
assert!(adapt_ink_color(c)[..3].iter().all(|&x| x <= 1.0 + 1e-6));
}
}
let lined = generate_background_mesh(PageBackground::Lined);
assert!(!lined.is_empty());
assert_eq!(lined.len() % 6, 0);
let dots = generate_background_mesh(PageBackground::DotGrid);
assert!(!dots.is_empty());
assert_eq!(dots.len() % 6, 0);
#[test]
fn test_paper_themes_contrast() {
for theme in PaperTheme::ALL {
let paper = theme.paper_color()[0];
let pattern = theme.pattern_color()[0];
assert!(
(paper - pattern).abs() > 0.03,
"{theme:?}: Muster hebt sich nicht vom Papier ab"
);
// Helles Papier ↔ dunkles Papier
assert_eq!(theme.is_dark(), paper < 0.5, "{theme:?}");
}
}
}
+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);
}
}
}
+976 -87
View File
File diff suppressed because it is too large Load Diff
+1148 -54
View File
File diff suppressed because it is too large Load Diff
+998 -110
View File
File diff suppressed because it is too large Load Diff
+248
View File
@@ -0,0 +1,248 @@
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder und PDFs.
//!
//! Bewusst ohne Systemdialog: der bräuchte GTK oder das Desktop-Portal, die unter WSL und auf
//! schlanken Systemen oft fehlen. Die Auswahl zeigt Ordner und die passenden Dateien des
//! aktuellen Ordners; versteckte Dateien bleiben ausgeblendet.
use std::path::{Path, PathBuf};
use crate::assets::is_supported_image_path;
/// Ein Eintrag der Dateiliste.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerEntry {
pub name: String,
pub path: PathBuf,
pub is_dir: bool,
}
/// Welche Dateien die Auswahl anbietet.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PickerKind {
/// Bilder zum Einfügen auf der aktuellen Seite
Image,
/// PDF zum Importieren als neues Notizbuch
Pdf,
}
impl PickerKind {
/// Passt die Datei zu dieser Auswahl?
pub fn accepts(self, path: &Path) -> bool {
match self {
PickerKind::Image => is_supported_image_path(path),
PickerKind::Pdf => is_pdf_path(path),
}
}
}
/// Ist die Datei dem Namen nach ein PDF?
pub fn is_pdf_path(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("pdf"))
}
/// Zustand der Dateiauswahl.
#[derive(Debug, Clone, PartialEq)]
pub struct FilePicker {
pub kind: PickerKind,
pub dir: PathBuf,
pub entries: Vec<PickerEntry>,
/// Ordner nicht lesbar
pub error: Option<String>,
}
impl FilePicker {
/// Öffnet die Auswahl in `dir`, sonst im Bilder-Ordner (Bilder) bzw. Downloads (PDF),
/// sonst im Home-Ordner.
pub fn open(kind: PickerKind, dir: Option<&Path>) -> Self {
let start = dir
.filter(|d| d.is_dir())
.map(Path::to_path_buf)
.or_else(|| {
let preferred = match kind {
PickerKind::Image => "🖼 Bilder",
PickerKind::Pdf => "⬇ Downloads",
};
let all = shortcuts();
all.iter()
.find(|(label, _)| *label == preferred)
.or_else(|| all.last())
.map(|(_, p)| p.clone())
})
.unwrap_or_else(|| PathBuf::from("/"));
let mut picker = Self {
kind,
dir: start.clone(),
entries: Vec::new(),
error: None,
};
picker.navigate(&start);
picker
}
/// Wechselt in `dir` und liest den Inhalt neu ein.
pub fn navigate(&mut self, dir: &Path) {
match list_dir(dir, self.kind) {
Ok(entries) => {
self.dir = dir.to_path_buf();
self.entries = entries;
self.error = None;
}
Err(err) => {
self.error = Some(format!(
"'{}' kann nicht geöffnet werden: {err}",
dir.display()
));
}
}
}
/// Eine Ebene nach oben.
pub fn go_up(&mut self) {
if let Some(parent) = self.dir.parent().map(Path::to_path_buf) {
self.navigate(&parent);
}
}
}
/// Ordner und passende Dateien in `dir`: erst Ordner, dann Dateien, jeweils alphabetisch
/// ohne Groß-/Kleinschreibung; versteckte Einträge (Punkt am Anfang) fehlen.
pub fn list_dir(dir: &Path, kind: PickerKind) -> std::io::Result<Vec<PickerEntry>> {
let mut entries: Vec<PickerEntry> = std::fs::read_dir(dir)?
.filter_map(Result::ok)
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
return None;
}
let path = entry.path();
// Symlinks auflösen: ein Link auf einen Ordner ist ein Ordner
let is_dir = path.is_dir();
(is_dir || kind.accepts(&path)).then_some(PickerEntry { name, path, is_dir })
})
.collect();
entries.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
Ok(entries)
}
/// Schnellzugriffe: Bilder, Downloads, Schreibtisch und Home-Ordner (soweit vorhanden),
/// mit den Namen aus `~/.config/user-dirs.dirs` (z. B. „Bilder“ statt „Pictures“).
pub fn shortcuts() -> Vec<(&'static str, PathBuf)> {
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
return Vec::new();
};
let user_dirs =
std::fs::read_to_string(home.join(".config/user-dirs.dirs")).unwrap_or_default();
let xdg = |key: &str, fallbacks: &[&str]| -> Option<PathBuf> {
parse_user_dir(&user_dirs, key, &home)
.into_iter()
.chain(fallbacks.iter().map(|f| home.join(f)))
.find(|p| p.is_dir() && *p != home)
};
let mut list = Vec::new();
if let Some(p) = xdg("XDG_PICTURES_DIR", &["Bilder", "Pictures"]) {
list.push(("🖼 Bilder", p));
}
if let Some(p) = xdg("XDG_DOWNLOAD_DIR", &["Downloads"]) {
list.push(("⬇ Downloads", p));
}
if let Some(p) = xdg("XDG_DESKTOP_DIR", &["Schreibtisch", "Desktop"]) {
list.push(("🖥 Schreibtisch", p));
}
list.push(("🏠 Persönlicher Ordner", home));
list
}
/// Liest einen Eintrag wie `XDG_PICTURES_DIR="$HOME/Bilder"` aus `user-dirs.dirs`.
pub fn parse_user_dir(content: &str, key: &str, home: &Path) -> Option<PathBuf> {
content.lines().find_map(|line| {
let value = line
.trim()
.strip_prefix(key)?
.trim_start()
.strip_prefix('=')?;
let value = value.trim().trim_matches('"');
let path = match value.strip_prefix("$HOME") {
Some(rest) => home.join(rest.trim_start_matches('/')),
None => PathBuf::from(value),
};
path.is_absolute().then_some(path)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_list_dir_sorts_and_filters() {
let dir = tempfile::tempdir().unwrap();
for name in [
"b.png",
"A.jpg",
"notiz.txt",
".versteckt.png",
"c.webp",
"Skript.PDF",
] {
std::fs::write(dir.path().join(name), b"x").unwrap();
}
std::fs::create_dir(dir.path().join("zOrdner")).unwrap();
std::fs::create_dir(dir.path().join("aOrdner")).unwrap();
std::fs::create_dir(dir.path().join(".cache")).unwrap();
let names = |kind| -> Vec<String> {
list_dir(dir.path(), kind)
.unwrap()
.into_iter()
.map(|e| e.name)
.collect()
};
assert_eq!(
names(PickerKind::Image),
["aOrdner", "zOrdner", "A.jpg", "b.png", "c.webp"]
);
assert_eq!(names(PickerKind::Pdf), ["aOrdner", "zOrdner", "Skript.PDF"]);
}
#[test]
fn test_navigation_and_errors() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("Fotos");
std::fs::create_dir(&sub).unwrap();
std::fs::write(sub.join("tafel.jpg"), b"x").unwrap();
let mut picker = FilePicker::open(PickerKind::Image, Some(&sub));
assert_eq!(picker.dir, sub);
assert_eq!(picker.entries.len(), 1);
picker.go_up();
assert_eq!(picker.dir, dir.path());
assert!(picker.entries.iter().any(|e| e.is_dir && e.name == "Fotos"));
// Nicht vorhandener Ordner: Fehlermeldung, aktueller Ordner bleibt
picker.navigate(&dir.path().join("gibt-es-nicht"));
assert!(picker.error.is_some());
assert_eq!(picker.dir, dir.path());
}
#[test]
fn test_parse_user_dirs() {
let home = Path::new("/home/anna");
let content = "# Kommentar\nXDG_DESKTOP_DIR=\"$HOME/Schreibtisch\"\nXDG_PICTURES_DIR=\"$HOME/Bilder\"\nXDG_MUSIC_DIR=\"/daten/Musik\"\n";
assert_eq!(
parse_user_dir(content, "XDG_PICTURES_DIR", home),
Some(PathBuf::from("/home/anna/Bilder"))
);
assert_eq!(
parse_user_dir(content, "XDG_MUSIC_DIR", home),
Some(PathBuf::from("/daten/Musik"))
);
assert_eq!(parse_user_dir(content, "XDG_DOWNLOAD_DIR", home), None);
}
}
+91 -15
View File
@@ -10,10 +10,13 @@ use std::f32::consts::PI;
/// Standard-Minimal-Grenzfrequenz in Hz (reduziert Jitter bei niedrigen Geschwindigkeiten)
pub const DEFAULT_FC_MIN: f32 = 4.5;
/// Standard-Geschwindigkeitskoeffizient (reduziert Latenz bei schnellen Bewegungen)
pub const DEFAULT_BETA: f32 = 0.05;
/// Standard-Grenzfrequenz für die Ableitung in Hz
pub const DEFAULT_D_CUTOFF: f32 = 1.0;
/// Standard-Geschwindigkeitskoeffizient (Hz je Seiteneinheit/s): Bei 400 Einheiten/s
/// (4 cm/s, zügige Handschrift) liegt die Grenzfrequenz bei rund 65 Hz, kleine Schleifen
/// werden kaum noch verkleinert
pub const DEFAULT_BETA: f32 = 0.15;
/// Standard-Grenzfrequenz für die Geschwindigkeitsschätzung in Hz (reagiert nach wenigen
/// Millisekunden auf schnellere Bewegung)
pub const DEFAULT_D_CUTOFF: f32 = 8.0;
/// Berechnet den Glättungsfaktor alpha = 1 / (1 + tau / dt) = 2*pi*fc*dt / (2*pi*fc*dt + 1).
///
@@ -157,13 +160,18 @@ pub struct FilteredPoint {
}
/// Adaptiver 1€-Filter für 2D-Position (x, y) und Stylus-Druck p ∈ [0.0, 1.0].
///
/// Die Position wird mit der Geschwindigkeit in der Ebene gefiltert (nicht je Achse):
/// Auf einer Kreisbahn ist eine Achse an den Umkehrpunkten in Ruhe, eine Filterung je
/// Achse würde dort stark glätten und Schleifen zusammenziehen.
#[derive(Debug, Clone)]
pub struct OneEuroFilter {
pub fc_min: f32,
pub beta: f32,
pub d_cutoff: f32,
x_filter: OneEuroFilter1D,
y_filter: OneEuroFilter1D,
pos_prev: Option<glam::Vec2>,
speed_prev: f32,
t_prev: Option<f64>,
p_filter: OneEuroFilter1D,
last_output: Option<(f32, f32, f32)>,
}
@@ -175,8 +183,9 @@ impl OneEuroFilter {
fc_min,
beta,
d_cutoff,
x_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
y_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
pos_prev: None,
speed_prev: 0.0,
t_prev: None,
p_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
last_output: None,
}
@@ -184,8 +193,9 @@ impl OneEuroFilter {
/// Setzt den Filter vollständig zurück (z. B. bei neuem Stroke).
pub fn reset(&mut self) {
self.x_filter.reset();
self.y_filter.reset();
self.pos_prev = None;
self.speed_prev = 0.0;
self.t_prev = None;
self.p_filter.reset();
self.last_output = None;
}
@@ -202,8 +212,8 @@ impl OneEuroFilter {
// Druck immer auf [0.0, 1.0] klemmen
let p_in = pressure.clamp(0.0, 1.0);
let x_out = self.x_filter.filter(x, t);
let y_out = self.y_filter.filter(y, t);
let pos = self.filter_position(glam::Vec2::new(x, y), t);
let (x_out, y_out) = (pos.x, pos.y);
let p_out = self.p_filter.filter(p_in, t).clamp(0.0, 1.0);
let out = (x_out, y_out, p_out);
@@ -211,6 +221,51 @@ impl OneEuroFilter {
out
}
/// 1€-Filter der Position mit der Geschwindigkeit in der Ebene.
fn filter_position(&mut self, x: glam::Vec2, t: f64) -> glam::Vec2 {
let (Some(prev), Some(prev_t)) = (self.pos_prev, self.t_prev) else {
self.pos_prev = Some(x);
self.speed_prev = 0.0;
self.t_prev = Some(t);
return x;
};
let raw_dt = t - prev_t;
// Lange Pause: neu beginnen statt über die Pause hinweg zu glätten
if raw_dt > 1.0 {
self.pos_prev = Some(x);
self.speed_prev = 0.0;
self.t_prev = Some(t);
return x;
}
let dt = if raw_dt <= 0.0 || !raw_dt.is_finite() {
1e-4_f32
} else {
raw_dt as f32
};
self.t_prev = Some(t);
let mut speed = (x - prev).length() / dt;
if !speed.is_finite() {
speed = 0.0;
}
let a_d = smoothing_factor(dt, self.d_cutoff);
let speed_hat = a_d * speed + (1.0 - a_d) * self.speed_prev;
let fc = (self.fc_min + self.beta * speed_hat).max(self.fc_min);
let a = smoothing_factor(dt, fc);
let mut out = prev + (x - prev) * a;
if !out.is_finite() {
out = prev;
}
self.pos_prev = Some(out);
self.speed_prev = if speed_hat.is_finite() {
speed_hat
} else {
0.0
};
out
}
/// Filtert einen 2D-Punkt `pos` und Druck `pressure` zum Zeitstempel `t`.
pub fn filter_point(&mut self, pos: glam::Vec2, pressure: f32, t: f64) -> (glam::Vec2, f32) {
let (x, y, p) = self.filter(pos.x, pos.y, pressure, t);
@@ -279,6 +334,25 @@ mod tests {
);
}
#[test]
fn test_small_loops_do_not_shrink() {
// Kreis mit 0,8 mm Radius in 0,1 s: die Filterung in der Ebene folgt ihm
let mut filter = OneEuroFilter::default();
let (r, period, hz) = (8.0_f32, 0.1_f64, 200.0);
let mut radii = Vec::new();
for i in 0..=(period * hz) as usize * 2 {
let t = i as f64 / hz;
let a = std::f32::consts::TAU * (t / period) as f32;
let p = glam::Vec2::new(500.0 + r * a.sin(), 500.0 - r * a.cos());
let (out, _) = filter.filter_point(p, 0.5, t);
if t > period {
radii.push((out - glam::Vec2::new(500.0, 500.0)).length());
}
}
let mean = radii.iter().sum::<f32>() / radii.len() as f32;
assert!(mean > r * 0.9, "Schleife geschrumpft: {mean}");
}
#[test]
fn test_high_frequency_jitter() {
let mut filter = OneEuroFilter::default();
@@ -286,7 +360,8 @@ mod tests {
let base_x = 100.0_f32;
let base_y = 50.0_f32;
let base_p = 0.5_f32;
let jitter_amplitude = 2.0_f32;
// Realistisches Digitizer-Rauschen (±0,05 mm) bei ruhendem Stift
let jitter_amplitude = 0.5_f32;
let num_samples = 120;
let dt = 1.0 / 120.0; // 120 Hz Abtastrate
@@ -331,9 +406,10 @@ mod tests {
.sum::<f32>()
/ count;
// Der 1€-Filter muss das hochfrequente Jitter massiv dämpfen
// Der 1€-Filter muss das hochfrequente Jitter deutlich dämpfen (der fertige Strich
// wird zusätzlich symmetrisch geglättet)
assert!(
filtered_variance < raw_variance * 0.05,
filtered_variance < raw_variance * 0.2,
"Gefilterte Varianz ({filtered_variance}) muss deutlich kleiner sein als Raw-Varianz ({raw_variance})"
);
}
+1425 -165
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -18,6 +18,13 @@ pub enum HistoryAction {
before: Vec<Stroke>,
after: Vec<Stroke>,
},
/// Teilradierer: Striche wurden durch ihre übrig gebliebenen Stücke ersetzt.
SplitStrokes {
removed: Vec<Stroke>,
added: Vec<Stroke>,
/// Zeitpunkt des letzten Schnitts (zum Zusammenfassen eines Radierzugs)
at: std::time::Instant,
},
}
/// History-Stack für Transaktionsverwaltung (Undo / Redo).
@@ -41,6 +48,40 @@ impl HistoryStack {
self.redo_stack.clear();
}
/// Registriert einen Teilradier-Schnitt. Folgt er innerhalb von
/// [`crate::document::PARTIAL_ERASE_MERGE_WINDOW`] auf einen vorherigen, werden beide zu
/// einem Undo-Schritt zusammengefasst: Stücke, die im selben Zug wieder zerschnitten
/// wurden, verschwinden aus der Bilanz.
pub fn push_split(&mut self, removed: Vec<Stroke>, added: Vec<Stroke>) {
let now = std::time::Instant::now();
if let Some(HistoryAction::SplitStrokes {
removed: prev_removed,
added: prev_added,
at,
}) = self.undo_stack.last_mut()
{
if now.duration_since(*at) <= crate::document::PARTIAL_ERASE_MERGE_WINDOW
&& self.redo_stack.is_empty()
{
for stroke in removed {
if let Some(pos) = prev_added.iter().position(|s| s.id == stroke.id) {
prev_added.remove(pos);
} else {
prev_removed.push(stroke);
}
}
prev_added.extend(added);
*at = now;
return;
}
}
self.push(HistoryAction::SplitStrokes {
removed,
added,
at: now,
});
}
/// Gibt zurück, ob Undo verfügbar ist.
pub fn can_undo(&self) -> bool {
!self.undo_stack.is_empty()
@@ -95,6 +136,12 @@ impl HistoryStack {
self.redo_stack
.push(HistoryAction::ModifyStrokes { before, after });
}
HistoryAction::SplitStrokes { removed, added, at } => {
strokes.retain(|s| !added.iter().any(|a| a.id == s.id));
strokes.extend(removed.iter().cloned());
self.redo_stack
.push(HistoryAction::SplitStrokes { removed, added, at });
}
}
true
@@ -140,6 +187,12 @@ impl HistoryStack {
self.undo_stack
.push(HistoryAction::ModifyStrokes { before, after });
}
HistoryAction::SplitStrokes { removed, added, at } => {
strokes.retain(|s| !removed.iter().any(|r| r.id == s.id));
strokes.extend(added.iter().cloned());
self.undo_stack
.push(HistoryAction::SplitStrokes { removed, added, at });
}
}
true
+449
View File
@@ -0,0 +1,449 @@
//! GPU-Darstellung eingefügter Bilder: ein texturiertes Viereck pro Bild, gezeichnet nach dem
//! Papier und vor der Tinte.
//!
//! Texturen liefert beim ersten Bedarf eine Texturquelle (Bilddatei oder gerenderte
//! PDF-Seite); sie werden mit Mipmaps versehen, damit verkleinerte Bilder beim
//! Herauszoomen nicht flimmern.
use std::collections::{HashMap, HashSet};
use crate::assets::RgbaImage;
/// Vertex eines Bildvierecks: Seitenkoordinate und Texturkoordinate.
#[repr(C)]
#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct ImageVertex {
pub position: [f32; 2],
pub uv: [f32; 2],
}
impl ImageVertex {
const ATTRIBUTES: [wgpu::VertexAttribute; 2] =
wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2];
fn desc() -> wgpu::VertexBufferLayout<'static> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<ImageVertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &Self::ATTRIBUTES,
}
}
}
/// Zwei Dreiecke für ein Bild mit den Ecken oben links, oben rechts, unten rechts, unten links.
pub fn quad_vertices(corners: &[glam::Vec2; 4]) -> [ImageVertex; 6] {
let v = |i: usize, uv: [f32; 2]| ImageVertex {
position: corners[i].to_array(),
uv,
};
let (tl, tr, br, bl) = (
v(0, [0.0, 0.0]),
v(1, [1.0, 0.0]),
v(2, [1.0, 1.0]),
v(3, [0.0, 1.0]),
);
[tl, tr, bl, bl, tr, br]
}
/// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1.
pub fn mip_chain(base: &RgbaImage) -> Vec<RgbaImage> {
let mut levels = vec![base.clone()];
let Some(mut current) =
image::RgbaImage::from_raw(base.width, base.height, base.pixels.clone())
else {
return levels;
};
while current.width() > 1 || current.height() > 1 {
let (w, h) = ((current.width() / 2).max(1), (current.height() / 2).max(1));
current = image::imageops::resize(&current, w, h, image::imageops::FilterType::Triangle);
levels.push(RgbaImage {
width: w,
height: h,
pixels: current.as_raw().clone(),
});
}
levels
}
/// Name der Ersatztextur für Bilder, deren Datei fehlt oder beschädigt ist.
const MISSING: &str = "";
/// Bis zu dieser Grafikspeichermenge bleiben Texturen anderer Seiten erhalten (schnelles
/// Zurückblättern); darüber werden alle nicht mehr sichtbaren freigegeben.
const TEXTURE_CACHE_BYTES: u64 = 384 * 1024 * 1024;
/// Antwort einer Texturquelle für einen Schlüssel.
pub enum TextureSource {
Ready(RgbaImage),
/// Wird gerade erzeugt; später erneut fragen
Pending,
Failed(String),
}
pub struct ImageLayer {
pipeline: wgpu::RenderPipeline,
texture_layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
/// Texturen nach Schlüssel (Asset-Name bzw. `<pdf>#<seite>`) mit belegtem Speicher
textures: HashMap<String, (wgpu::BindGroup, u64)>,
texture_bytes: u64,
/// Bilder je Seite (Gruppe 0: aktuelle Seite, danach die sichtbaren Nachbarseiten):
/// Asset-Name und Ecken in Koordinaten der jeweiligen Seite
groups: Vec<Vec<(String, [glam::Vec2; 4])>>,
vertex_buffer: Option<wgpu::Buffer>,
vertices_dirty: bool,
/// Importierte PDF-Seiten in höherer Auflösung (längste Kante in Pixeln) anzeigen;
/// bis sie gerendert ist, bleibt die Standardauflösung sichtbar
pdf_pixels: Option<u32>,
}
impl ImageLayer {
pub fn new(
device: &wgpu::Device,
shader: &wgpu::ShaderModule,
camera_layout: &wgpu::BindGroupLayout,
format: wgpu::TextureFormat,
sample_count: u32,
) -> Self {
let texture_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Image Texture Bind Group Layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Image Pipeline Layout"),
bind_group_layouts: &[camera_layout, &texture_layout],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Image Render Pipeline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: shader,
entry_point: "vs_image",
buffers: &[ImageVertex::desc()],
},
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: "fs_image",
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(crate::canvas::passive_stencil()),
multisample: wgpu::MultisampleState {
count: sample_count,
..Default::default()
},
multiview: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Image Sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Linear,
..Default::default()
});
Self {
pipeline,
texture_layout,
sampler,
textures: HashMap::new(),
texture_bytes: 0,
groups: vec![Vec::new()],
vertex_buffer: None,
vertices_dirty: false,
pdf_pixels: None,
}
}
/// Übernimmt die Bilder der aktuellen Seite (Asset-Name und Ecken in Seitenkoordinaten).
pub fn set_quads(&mut self, quads: Vec<(String, [glam::Vec2; 4])>) {
if quads != self.groups[0] {
self.groups[0] = quads;
self.vertices_dirty = true;
}
}
/// Übernimmt die Bilder der sichtbaren Nachbarseiten (Gruppen 1, 2, …).
pub fn set_neighbor_quads(&mut self, neighbors: Vec<Vec<(String, [glam::Vec2; 4])>>) {
if neighbors[..] != self.groups[1..] {
self.groups.truncate(1);
self.groups.extend(neighbors);
self.vertices_dirty = true;
}
}
fn all_quads(&self) -> impl Iterator<Item = &(String, [glam::Vec2; 4])> {
self.groups.iter().flatten()
}
/// Auflösung importierter PDF-Seiten (`None`: Standard), z. B. beim Hineinzoomen.
pub fn set_pdf_pixels(&mut self, pixels: Option<u32>) {
self.pdf_pixels = pixels;
}
/// Schlüssel der anzuzeigenden Textur: PDF-Seiten ggf. in höherer Auflösung.
fn texture_key(&self, key: &str) -> String {
match self.pdf_pixels {
Some(pixels) if key.contains('#') => format!("{key}@{pixels}"),
_ => key.to_string(),
}
}
/// Lädt fehlende Texturen und den Vertex-Puffer hoch. Vor jedem Zeichnen aufrufen;
/// ohne Änderungen kostet das nichts.
pub fn prepare(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
load: &mut dyn FnMut(&str) -> TextureSource,
) {
let missing: Vec<String> = self
.all_quads()
.map(|(key, _)| self.texture_key(key))
.filter(|key| !self.textures.contains_key(key))
.collect();
for key in missing {
if self.textures.contains_key(&key) {
continue;
}
let (bind_group, bytes) = match load(&key) {
TextureSource::Ready(image) => (
self.upload_texture(device, queue, &key, &image),
image.pixels.len() as u64 * 4 / 3,
),
// Noch in Arbeit (PDF-Seite wird gerendert): beim nächsten Bild erneut fragen
TextureSource::Pending => continue,
TextureSource::Failed(err) => {
eprintln!("[Bilder] '{key}' kann nicht angezeigt werden: {err}");
(self.missing_texture(device, queue), 4)
}
};
self.texture_bytes += bytes;
self.textures.insert(key, (bind_group, bytes));
}
// Nicht mehr benötigte Texturen freigeben, wenn der Grafikspeicher knapp wird
if self.texture_bytes > TEXTURE_CACHE_BYTES {
// Die Standardauflösung bleibt als Ersatz, bis die höhere fertig ist
let used: HashSet<String> = self
.all_quads()
.flat_map(|(key, _)| [key.clone(), self.texture_key(key)])
.collect();
self.textures.retain(|name, _| used.contains(name));
self.texture_bytes = self.textures.values().map(|(_, b)| b).sum();
}
if !self.vertices_dirty {
return;
}
self.vertices_dirty = false;
let vertices: Vec<ImageVertex> = self
.all_quads()
.flat_map(|(_, corners)| quad_vertices(corners))
.collect();
if vertices.is_empty() {
return;
}
let bytes: &[u8] = bytemuck::cast_slice(&vertices);
let too_small = self
.vertex_buffer
.as_ref()
.is_none_or(|b| b.size() < bytes.len() as u64);
if too_small {
self.vertex_buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Image Vertex Buffer"),
size: (bytes.len() as u64 * 2).max(1024),
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}));
}
if let Some(buffer) = &self.vertex_buffer {
queue.write_buffer(buffer, 0, bytes);
}
}
/// Wird die Textur `key` von einer sichtbaren Seite verwendet?
pub fn uses(&self, key: &str) -> bool {
self.all_quads().any(|(k, _)| k == key)
}
/// Gibt alle Texturen frei, deren Schlüssel `matches` erfüllt (sie werden beim nächsten
/// `prepare` neu angefordert).
pub fn forget_textures(&mut self, mut matches: impl FnMut(&str) -> bool) {
self.textures.retain(|key, _| !matches(key));
self.texture_bytes = self.textures.values().map(|(_, b)| b).sum();
}
/// Fehlen noch Texturen sichtbarer Seiten (z. B. PDF-Seite im Hintergrund-Rendering)?
pub fn is_waiting(&self) -> bool {
self.all_quads()
.any(|(key, _)| !self.textures.contains_key(&self.texture_key(key)))
}
/// Zeichnet die Bilder einer Seite (Gruppe 0: aktuelle Seite). Bind Group 0 (Kamera der
/// Seite) muss gesetzt sein.
pub fn draw<'a>(&'a self, render_pass: &mut wgpu::RenderPass<'a>, group: usize) {
let Some(buffer) = &self.vertex_buffer else {
return;
};
let Some(quads) = self.groups.get(group) else {
return;
};
if quads.is_empty() || self.vertices_dirty {
return;
}
let first: usize = self.groups[..group].iter().map(Vec::len).sum();
render_pass.set_pipeline(&self.pipeline);
render_pass.set_vertex_buffer(0, buffer.slice(..));
for (i, (asset, _)) in quads.iter().enumerate() {
let Some((bind_group, _)) = self
.textures
.get(&self.texture_key(asset))
.or_else(|| self.textures.get(asset))
else {
continue;
};
render_pass.set_bind_group(1, bind_group, &[]);
let start = ((first + i) * 6) as u32;
render_pass.draw(start..start + 6, 0..1);
}
}
fn missing_texture(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::BindGroup {
// Hellgraue Fläche statt eines unsichtbaren Bildes
let gray = RgbaImage {
width: 1,
height: 1,
pixels: vec![200, 200, 200, 255],
};
self.upload_texture(device, queue, MISSING, &gray)
}
fn upload_texture(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
label: &str,
image: &RgbaImage,
) -> wgpu::BindGroup {
let levels = mip_chain(image);
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: image.width,
height: image.height,
depth_or_array_layers: 1,
},
mip_level_count: levels.len() as u32,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
// sRGB-Werte ohne Umrechnung: gezeichnet wird in sRGB (siehe canvas::render_format)
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
for (level, mip) in levels.iter().enumerate() {
queue.write_texture(
wgpu::ImageCopyTexture {
texture: &texture,
mip_level: level as u32,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&mip.pixels,
wgpu::ImageDataLayout {
offset: 0,
bytes_per_row: Some(4 * mip.width),
rows_per_image: Some(mip.height),
},
wgpu::Extent3d {
width: mip.width,
height: mip.height,
depth_or_array_layers: 1,
},
);
}
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Image Texture Bind Group"),
layout: &self.texture_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quad_vertices_map_corners_to_texture_corners() {
let corners = [
glam::Vec2::new(10.0, 10.0),
glam::Vec2::new(50.0, 10.0),
glam::Vec2::new(50.0, 30.0),
glam::Vec2::new(10.0, 30.0),
];
let quad = quad_vertices(&corners);
for v in quad {
// Texturkoordinate entspricht der relativen Lage im Rechteck
let u = (v.position[0] - 10.0) / 40.0;
let w = (v.position[1] - 10.0) / 20.0;
assert_eq!(v.uv, [u, w]);
}
// Beide Dreiecke decken alle vier Ecken ab
for c in corners {
assert!(quad.iter().any(|v| v.position == c.to_array()));
}
}
#[test]
fn test_mip_chain_halves_down_to_one_pixel() {
let base = RgbaImage {
width: 8,
height: 3,
pixels: vec![128; 8 * 3 * 4],
};
let levels = mip_chain(&base);
let sizes: Vec<(u32, u32)> = levels.iter().map(|l| (l.width, l.height)).collect();
assert_eq!(sizes, vec![(8, 3), (4, 1), (2, 1), (1, 1)]);
for l in &levels {
assert_eq!(l.pixels.len(), (l.width * l.height * 4) as usize);
}
}
}
+10
View File
@@ -1,16 +1,26 @@
//! ink-notes: Latenzoptimierte Inking-App für Linux.
pub mod assets;
pub mod autosave;
pub mod background;
pub mod canvas;
pub mod document;
pub mod export;
pub mod file_picker;
pub mod filter;
pub mod geometry;
pub mod history;
pub mod image_layer;
pub mod page_export;
pub mod pdf_doc;
pub mod pdf_merge;
pub mod platform;
pub mod ruler;
pub mod selection;
pub mod settings;
pub mod spline;
pub mod storage;
pub mod text;
pub mod ui;
pub mod updater;
pub mod viewport;
+1832 -546
View File
File diff suppressed because it is too large Load Diff
+457
View File
@@ -0,0 +1,457 @@
//! Export einzelner Seiten als PNG oder SVG und einer Auswahl als Bild.
//!
//! Grundlage ist immer der PDF-Export: die Seite wird als einseitiges PDF erzeugt (mit
//! Bildern, importierten PDF-Seiten und Formen) und anschließend mit `hayro` gerastert bzw.
//! mit `hayro-svg` nach SVG umgewandelt. So sehen PDF, PNG und SVG gleich aus.
use std::error::Error;
use hayro::hayro_interpret::InterpreterSettings;
use hayro::hayro_syntax::Pdf;
use hayro::vello_cpu::color::palette::css::{TRANSPARENT, WHITE};
use hayro::{PixmapSettings, RenderCache, RenderSettings};
use crate::assets::{AssetStore, RgbaImage};
use crate::background::PatternColor;
use crate::document::{Aabb, Notebook, Page, PageBackground, A4_WIDTH_MM, PAGE_WIDTH};
use crate::export::{export_notebook_to_bytes, ExportStrategy};
/// Auflösung des PNG-Exports einer Seite.
pub const PAGE_PNG_DPI: f32 = 200.0;
/// Auflösung einer in die Zwischenablage kopierten Auswahl.
pub const SELECTION_PNG_DPI: f32 = 150.0;
/// Hintergrund beim Bild-Export.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageBackground {
/// Weißes Papier mit Raster bzw. importierter PDF-Seite
Paper,
/// Transparent, nur Bilder und Tinte (ohne Raster und ohne importierte PDF-Seite)
Transparent,
}
/// Erzeugt ein einseitiges PDF aus `page`.
fn single_page_pdf(
page: &Page,
assets: Option<&AssetStore>,
pattern: PatternColor,
) -> Result<Vec<u8>, Box<dyn Error>> {
let notebook = Notebook::from_pages(vec![page.clone()], 0, 1, page.id + 1);
export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, assets, pattern)
}
/// Seite ohne Raster und PDF-Hintergrund (für transparenten Export und Auswahlen).
fn content_only(page: &Page) -> Page {
let mut page = page.clone();
page.background = PageBackground::Blank;
page.pdf_background = None;
page
}
/// Rastert die erste Seite eines PDFs mit `dpi`; Farben ohne Vormultiplikation.
fn rasterize(pdf_bytes: Vec<u8>, dpi: f32, transparent: bool) -> Result<RgbaImage, Box<dyn Error>> {
let pdf = Pdf::new(pdf_bytes).map_err(|e| format!("PDF nicht lesbar: {e:?}"))?;
let page = pdf.pages().first().ok_or("PDF ohne Seite")?;
let scale = dpi / 72.0;
let pixmap = hayro::render(
page,
&RenderCache::new(),
&InterpreterSettings::default(),
&RenderSettings::default(),
&PixmapSettings {
x_scale: scale,
y_scale: scale,
bg_color: if transparent { TRANSPARENT } else { WHITE },
},
);
let mut pixels = pixmap.data_as_u8_slice().to_vec();
// hayro liefert vormultiplizierte Farben; PNG erwartet gerade Farben
for px in pixels.as_chunks_mut::<4>().0 {
let a = px[3] as u32;
if a > 0 && a < 255 {
for c in &mut px[..3] {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
Ok(RgbaImage {
width: pixmap.width() as u32,
height: pixmap.height() as u32,
pixels,
})
}
/// Kodiert ein Bild als PNG.
pub fn encode_png(image: &RgbaImage) -> Result<Vec<u8>, Box<dyn Error>> {
let buffer = image::RgbaImage::from_raw(image.width, image.height, image.pixels.clone())
.ok_or("Pixeldaten passen nicht zur Bildgröße")?;
let mut out = Vec::new();
image::DynamicImage::ImageRgba8(buffer)
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)?;
Ok(out)
}
/// Seite als PNG (A4 mit [`PAGE_PNG_DPI`]).
pub fn page_png(
page: &Page,
assets: Option<&AssetStore>,
background: ImageBackground,
pattern: PatternColor,
) -> Result<Vec<u8>, Box<dyn Error>> {
let image = match background {
ImageBackground::Paper => {
rasterize(single_page_pdf(page, assets, pattern)?, PAGE_PNG_DPI, false)?
}
ImageBackground::Transparent => rasterize(
single_page_pdf(&content_only(page), assets, pattern)?,
PAGE_PNG_DPI,
true,
)?,
};
encode_png(&image)
}
/// Seite als SVG (Vektor; Schriften importierter PDFs werden zu Pfaden).
pub fn page_svg(
page: &Page,
assets: Option<&AssetStore>,
pattern: PatternColor,
) -> Result<String, Box<dyn Error>> {
let pdf = Pdf::new(single_page_pdf(page, assets, pattern)?)
.map_err(|e| format!("PDF nicht lesbar: {e:?}"))?;
let pdf_page = pdf.pages().first().ok_or("PDF ohne Seite")?;
Ok(hayro_svg::convert(
pdf_page,
&hayro_svg::RenderCache::new(),
&InterpreterSettings::default(),
&hayro_svg::SvgRenderSettings {
bg_color: [255, 255, 255, 255],
},
))
}
/// Bild der Objekte `ids` auf `page`, auf ihren Umriss zugeschnitten, mit transparentem
/// Hintergrund. `None`, wenn keines der Objekte auf der Seite liegt.
pub fn selection_image(
page: &Page,
ids: &[u64],
assets: Option<&AssetStore>,
) -> Result<Option<RgbaImage>, Box<dyn Error>> {
let mut selected = content_only(page);
selected.strokes.retain(|s| ids.contains(&s.id));
let Some(bounds) = Page::selection_aabb(&selected.strokes) else {
return Ok(None);
};
let max_width = selected.strokes.iter().map(|s| s.width).fold(0.0, f32::max);
let bounds: Aabb = bounds.expanded(max_width / 2.0 + 4.0);
let full = rasterize(
single_page_pdf(&selected, assets, PatternColor::default())?,
SELECTION_PNG_DPI,
true,
)?;
// Seiteneinheiten → Pixel
let px_per_unit = SELECTION_PNG_DPI / 25.4 * A4_WIDTH_MM / PAGE_WIDTH;
let x0 = ((bounds.min.x * px_per_unit).floor().max(0.0) as u32).min(full.width);
let y0 = ((bounds.min.y * px_per_unit).floor().max(0.0) as u32).min(full.height);
let x1 = ((bounds.max.x * px_per_unit).ceil().max(0.0) as u32).min(full.width);
let y1 = ((bounds.max.y * px_per_unit).ceil().max(0.0) as u32).min(full.height);
if x1 <= x0 || y1 <= y0 {
return Ok(None);
}
let (w, h) = (x1 - x0, y1 - y0);
let mut pixels = Vec::with_capacity((w * h * 4) as usize);
for y in y0..y1 {
let start = ((y * full.width + x0) * 4) as usize;
pixels.extend_from_slice(&full.pixels[start..start + (w * 4) as usize]);
}
Ok(Some(RgbaImage {
width: w,
height: h,
pixels,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::{Stroke, StylusSample, PAGE_HEIGHT};
fn page_with_line() -> Page {
let mut page = Page::new(1, PageBackground::Grid5mm);
let samples: Vec<StylusSample> = (0..=20)
.map(|i| StylusSample {
position: glam::Vec2::new(500.0 + i as f32 * 20.0, 1000.0),
pressure: 0.6,
timestamp: i as f64 * 0.01,
})
.collect();
let vertices = crate::storage::reconstruct_stroke_vertices(
&samples,
[0.9, 0.1, 0.1, 1.0],
20.0,
Default::default(),
true,
);
let mut stroke = Stroke::new(7, samples, vertices, [0.9, 0.1, 0.1, 1.0]);
stroke.width = 20.0;
page.strokes.push(stroke);
page
}
fn decode(png: &[u8]) -> image::RgbaImage {
image::load_from_memory(png).unwrap().into_rgba8()
}
#[test]
fn test_page_png_paper_and_transparent() {
let page = page_with_line();
let paper = decode(
&page_png(&page, None, ImageBackground::Paper, PatternColor::default()).unwrap(),
);
// A4 mit 200 dpi
assert_eq!(paper.width(), (A4_WIDTH_MM / 25.4 * PAGE_PNG_DPI) as u32);
assert_eq!(paper.get_pixel(5, 5).0[3], 255, "Papier ist deckend");
let clear = decode(
&page_png(
&page,
None,
ImageBackground::Transparent,
PatternColor::default(),
)
.unwrap(),
);
assert_eq!(clear.get_pixel(5, 5).0[3], 0, "Hintergrund ist transparent");
// Die Linie ist rot und deckend
let px_per_unit = clear.width() as f32 / PAGE_WIDTH;
let line = clear.get_pixel((700.0 * px_per_unit) as u32, (1000.0 * px_per_unit) as u32);
assert!(
line.0[0] > 180 && line.0[1] < 80 && line.0[3] > 200,
"{line:?}"
);
assert!(clear.height() as f32 / clear.width() as f32 - PAGE_HEIGHT / PAGE_WIDTH < 0.01);
}
#[test]
fn test_highlighter_is_translucent_in_export() {
// Schwarze Linie, darüber ein gelber Textmarker, der hin und zurück läuft
let mut page = page_with_line();
page.strokes[0].color = [0.0, 0.0, 0.0, 1.0];
for v in &mut page.strokes[0].vertices {
v.color = [0.0, 0.0, 0.0, 1.0];
}
let color = [1.0, 0.95, 0.1, 0.35];
let samples: Vec<StylusSample> = (0..=40)
.map(|i| {
let x = if i <= 20 { i } else { 40 - i } as f32;
StylusSample {
position: glam::Vec2::new(500.0 + x * 20.0, 1000.0 + (i as f32 * 0.5)),
pressure: 0.6,
timestamp: i as f64 * 0.01,
}
})
.collect();
let vertices = crate::storage::reconstruct_stroke_vertices(
&samples,
color,
60.0,
Default::default(),
true,
);
let mut stroke = Stroke::new(8, samples, vertices, color);
stroke.width = 60.0;
stroke.tool = crate::document::StrokeTool::Highlighter;
page.strokes.push(stroke);
let png = decode(
&page_png(&page, None, ImageBackground::Paper, PatternColor::default()).unwrap(),
);
let px_per_unit = png.width() as f32 / PAGE_WIDTH;
let at = |x: f32, y: f32| {
png.get_pixel((x * px_per_unit) as u32, (y * px_per_unit) as u32)
.0
};
// Die schwarze Linie bleibt unter dem Textmarker sichtbar
let line = at(700.0, 1000.0);
assert!(line[0] < 110 && line[1] < 110, "Linie verdeckt: {line:?}");
// Neben der Linie: einmal gelb getönt, auch wo der Strich sich selbst überlappt
let tint = at(700.0, 1020.0);
assert!(tint[2] < 200 && tint[0] > 200, "kein Textmarker: {tint:?}");
let expected_blue = 255.0 * (1.0 - 0.35) + 0.1 * 255.0 * 0.35;
assert!(
(tint[2] as f32 - expected_blue).abs() < 25.0,
"Textmarker mehrfach gedeckt: {tint:?}, erwartet Blau ≈ {expected_blue}"
);
}
#[test]
fn test_export_uses_pattern_color() {
// Kariertes Blatt: das kräftige Muster ist im Export dunkler als das helle
let page = Page::new(1, PageBackground::Grid5mm);
let darkest = |pattern| {
let png = decode(&page_png(&page, None, ImageBackground::Paper, pattern).unwrap());
png.pixels().map(|p| p.0[1]).min().unwrap()
};
let light = darkest(PatternColor::Light);
let strong = darkest(PatternColor::Strong);
assert!(strong + 60 < light, "hell {light}, kräftig {strong}");
}
#[test]
fn test_page_svg_contains_ink() {
let svg = page_svg(&page_with_line(), None, PatternColor::default()).unwrap();
assert!(svg.starts_with("<svg") || svg.contains("<svg"));
assert!(svg.contains("<path"));
assert!(svg.trim_end().ends_with("</svg>"));
}
/// Begrenzungsrechteck (x0, y0, x1, y1) der deckenden Pixel.
fn ink_bounds(width: u32, pixels: &[u8]) -> (u32, u32, u32, u32) {
let mut b = (u32::MAX, u32::MAX, 0, 0);
for (i, p) in pixels.as_chunks::<4>().0.iter().enumerate() {
if p[3] > 128 {
let (x, y) = (i as u32 % width, i as u32 / width);
b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y));
}
}
b
}
fn page_with_text() -> (Page, crate::text::TextBox) {
let text = crate::text::TextBox::new(
"Hallo Welt\nzweite Zeile".into(),
100.0,
crate::text::TextFont::Sans,
1200.0,
);
let size = glam::Vec2::new(1200.0, text.layout().height());
let corners = crate::text::frame_corners(
glam::Vec2::new(300.0, 400.0),
[glam::Vec2::X, glam::Vec2::Y],
size,
);
let mut page = Page::new(1, PageBackground::Grid5mm);
page.strokes.push(Stroke::new_text(
3,
text.clone(),
corners,
[0.0, 0.0, 0.0, 1.0],
));
(page, text)
}
#[test]
fn test_exported_text_matches_screen_rendering() {
let (page, text) = page_with_text();
let png = decode(
&page_png(
&page,
None,
ImageBackground::Transparent,
PatternColor::default(),
)
.unwrap(),
);
let px_per_unit = png.width() as f32 / PAGE_WIDTH;
let exported = ink_bounds(png.width(), png.as_raw());
// Dieselbe Stelle, wie sie die Bildschirmtextur zeigt
let screen = crate::text::rasterize(&text, [0.0, 0.0, 0.0, 1.0], px_per_unit);
let local = ink_bounds(screen.width, &screen.pixels);
let (ox, oy) = ((300.0 * px_per_unit) as u32, (400.0 * px_per_unit) as u32);
let expected = (local.0 + ox, local.1 + oy, local.2 + ox, local.3 + oy);
for (a, b) in [
(exported.0, expected.0),
(exported.1, expected.1),
(exported.2, expected.2),
(exported.3, expected.3),
] {
assert!(
a.abs_diff(b) <= 3,
"PDF {exported:?} ≠ Bildschirm {expected:?}"
);
}
}
#[test]
fn test_exported_text_follows_rotation() {
let (mut page, _) = page_with_text();
let png = decode(
&page_png(
&page,
None,
ImageBackground::Transparent,
PatternColor::default(),
)
.unwrap(),
);
let straight = ink_bounds(png.width(), png.as_raw());
let center = page.strokes[0].aabb.min;
page.strokes[0].rotate(center, std::f32::consts::FRAC_PI_2);
let png = decode(
&page_png(
&page,
None,
ImageBackground::Transparent,
PatternColor::default(),
)
.unwrap(),
);
let rotated = ink_bounds(png.width(), png.as_raw());
// 90° im Uhrzeigersinn: Breite und Höhe tauschen, Text liegt links vom Drehpunkt
let (w, h) = (straight.2 - straight.0, straight.3 - straight.1);
let (rw, rh) = (rotated.2 - rotated.0, rotated.3 - rotated.1);
assert!(
rw.abs_diff(h) <= 4 && rh.abs_diff(w) <= 4,
"{straight:?} → {rotated:?}"
);
let px_per_unit = png.width() as f32 / PAGE_WIDTH;
assert!((rotated.2 as f32) < 300.0 * px_per_unit + 2.0);
}
#[test]
fn test_landscape_page_export_keeps_positions() {
// Strich unten rechts auf einer Querformat-Seite: im PNG an derselben Stelle
let mut page = page_with_line();
page.size = glam::Vec2::new(2970.0, 1671.0);
page.strokes[0].translate(glam::Vec2::new(1900.0, 500.0)); // x 2400–2800, y 1500
let png = decode(
&page_png(
&page,
None,
ImageBackground::Transparent,
PatternColor::default(),
)
.unwrap(),
);
let ratio = png.width() as f32 / png.height() as f32;
assert!((ratio - 2970.0 / 1671.0).abs() < 0.01, "{ratio}");
let px_per_unit = png.width() as f32 / 2970.0;
let at = |x: f32, y: f32| {
png.get_pixel((x * px_per_unit) as u32, (y * px_per_unit) as u32)
.0
};
assert!(at(2600.0, 1500.0)[3] > 200, "Strich liegt unten rechts");
assert!(at(2600.0, 1300.0)[3] == 0);
let svg = page_svg(&page, None, PatternColor::default()).unwrap();
assert!(svg.contains("<path"));
}
#[test]
fn test_selection_image_is_cropped() {
let page = page_with_line();
let image = selection_image(&page, &[7], None).unwrap().unwrap();
// Linie 400 Einheiten (40 mm) lang: Bild etwas breiter als 40 mm bei 150 dpi
let expected = 40.0 / 25.4 * SELECTION_PNG_DPI;
assert!(
(image.width as f32) > expected && (image.width as f32) < expected * 1.25,
"{} px",
image.width
);
assert!(image.height < image.width / 3);
assert!(image.pixels.as_chunks::<4>().0.iter().any(|p| p[3] > 200));
assert!(selection_image(&page, &[99], None).unwrap().is_none());
}
}
+325
View File
@@ -0,0 +1,325 @@
//! Lesen und Rendern importierter PDF-Dateien mit `hayro` (reines Rust, nur CPU).
//!
//! Das Rendern einer Seite kann einige hundert Millisekunden dauern; [`PdfRenderService`]
//! erledigt es deshalb in einem Hintergrund-Thread und meldet fertige Seiten zurück.
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver, Sender};
use hayro::hayro_interpret::InterpreterSettings;
use hayro::hayro_syntax::{LoadPdfError, Pdf};
use hayro::vello_cpu::color::palette::css::WHITE;
use hayro::{PixmapSettings, RenderCache, RenderSettings};
use crate::assets::RgbaImage;
/// Längste Kante einer gerenderten Seite für die Anzeige in Pixeln (A4 ≈ 220 dpi).
pub const DISPLAY_RENDER_PIXELS: u32 = 2560;
/// Längste Kante beim starken Hineinzoomen (A4 ≈ 440 dpi).
pub const ZOOMED_RENDER_PIXELS: u32 = 5120;
/// Texturschlüssel einer PDF-Seite: `<asset>#<seite>`, gerendert mit mehr als
/// [`DISPLAY_RENDER_PIXELS`] als `<asset>#<seite>@<pixel>`. Liefert Asset, Seite und Pixel.
pub fn parse_page_key(key: &str) -> Option<(&str, usize, u32)> {
let (asset, rest) = key.split_once('#')?;
let (page, pixels) = match rest.split_once('@') {
Some((page, pixels)) => (page, pixels.parse().ok()?),
None => (rest, DISPLAY_RENDER_PIXELS),
};
Some((asset, page.parse().ok()?, pixels))
}
/// Größte Seitenzahl, die importiert wird.
pub const MAX_IMPORT_PAGES: usize = 2000;
/// Öffnet ein PDF und liefert die angezeigte Größe jeder Seite in Punkt (Drehung
/// berücksichtigt).
pub fn page_sizes(bytes: Vec<u8>) -> Result<Vec<(f32, f32)>, String> {
let pdf = open(bytes)?;
let sizes: Vec<(f32, f32)> = pdf.pages().iter().map(|p| p.render_dimensions()).collect();
if sizes.is_empty() {
return Err("Das PDF enthält keine Seiten".into());
}
if sizes.len() > MAX_IMPORT_PAGES {
return Err(format!(
"Das PDF hat {} Seiten, höchstens {MAX_IMPORT_PAGES} werden unterstützt",
sizes.len()
));
}
Ok(sizes)
}
fn open(bytes: Vec<u8>) -> Result<Pdf, String> {
Pdf::new(bytes).map_err(|e| match e {
LoadPdfError::Decryption(_) => {
"Das PDF ist verschlüsselt oder passwortgeschützt und kann nicht geöffnet werden"
.to_string()
}
LoadPdfError::Invalid => "Die Datei ist kein lesbares PDF".to_string(),
})
}
/// Rendert Seite `index` so, dass die längste Kante höchstens `max_pixels` lang ist,
/// auf weißem Grund.
pub fn render_page(pdf: &Pdf, index: usize, max_pixels: u32) -> Result<RgbaImage, String> {
let page = pdf
.pages()
.get(index)
.ok_or_else(|| format!("Seite {} fehlt im PDF", index + 1))?;
let (w, h) = page.render_dimensions();
let scale = (max_pixels as f32 / w.max(h).max(1.0)).min(16.0);
let pixmap = hayro::render(
page,
&RenderCache::new(),
&InterpreterSettings::default(),
&RenderSettings::default(),
&PixmapSettings {
x_scale: scale,
y_scale: scale,
bg_color: WHITE,
},
);
// Auf weißem Grund ist jedes Pixel deckend: vormultipliziert = gerade Farbe
Ok(RgbaImage {
width: pixmap.width() as u32,
height: pixmap.height() as u32,
pixels: pixmap.data_as_u8_slice().to_vec(),
})
}
/// Öffnet die Datei und rendert eine Seite (für den PDF-Export als Ersatzbild).
pub fn render_file_page(
path: &std::path::Path,
index: usize,
max_pixels: u32,
) -> Result<RgbaImage, String> {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
render_page(&open(bytes)?, index, max_pixels)
}
struct RenderRequest {
key: String,
path: PathBuf,
page: usize,
max_pixels: u32,
}
/// Ergebnis eines Render-Auftrags.
pub type RenderResult = Result<RgbaImage, String>;
/// Rendert PDF-Seiten im Hintergrund. Fertige Seiten holt der Event-Loop mit
/// [`PdfRenderService::take`] ab; `on_ready` weckt ihn dafür auf.
pub struct PdfRenderService {
requests: Sender<RenderRequest>,
results: Receiver<(String, RenderResult)>,
pending: HashSet<String>,
ready: HashMap<String, RenderResult>,
}
impl PdfRenderService {
pub fn new(on_ready: impl Fn() + Send + 'static) -> Self {
let (req_tx, req_rx) = mpsc::channel::<RenderRequest>();
let (res_tx, res_rx) = mpsc::channel();
std::thread::Builder::new()
.name("pdf-render".into())
.spawn(move || {
// Zuletzt geöffnetes PDF behalten: Blättern rendert meist dieselbe Datei
let mut open_pdf: Option<(PathBuf, Pdf)> = None;
while let Ok(request) = req_rx.recv() {
if open_pdf.as_ref().map(|(p, _)| p) != Some(&request.path) {
open_pdf = std::fs::read(&request.path)
.map_err(|e| e.to_string())
.and_then(open)
.map_err(|e| eprintln!("[PDF] '{}': {e}", request.path.display()))
.ok()
.map(|pdf| (request.path.clone(), pdf));
}
let result = match &open_pdf {
Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels),
None => Err("PDF kann nicht geöffnet werden".into()),
};
if res_tx.send((request.key, result)).is_err() {
break;
}
on_ready();
}
})
.expect("PDF-Render-Thread konnte nicht gestartet werden");
Self {
requests: req_tx,
results: res_rx,
pending: HashSet::new(),
ready: HashMap::new(),
}
}
/// Rendert eine Seite vorab (z. B. die nächste), ohne auf das Ergebnis zu warten;
/// [`Self::take`] liefert es später sofort.
pub fn prefetch(&mut self, key: &str, path: PathBuf, page: usize) {
self.collect();
if self.ready.contains_key(key) || self.pending.contains(key) {
return;
}
// Vorab gerenderte Seiten nicht unbegrenzt aufheben
if self.ready.len() >= 2 {
self.ready.clear();
}
self.request(key, path, page, DISPLAY_RENDER_PIXELS);
}
fn collect(&mut self) {
while let Ok((done, result)) = self.results.try_recv() {
self.pending.remove(&done);
self.ready.insert(done, result);
}
}
/// Erteilt den Auftrag, falls noch keiner läuft. `false`, wenn der Thread beendet ist.
fn request(&mut self, key: &str, path: PathBuf, page: usize, max_pixels: u32) -> bool {
if !self.pending.insert(key.to_string()) {
return true;
}
let request = RenderRequest {
key: key.to_string(),
path,
page,
max_pixels,
};
if self.requests.send(request).is_err() {
self.pending.remove(key);
return false;
}
true
}
/// Fertiges Ergebnis für `key` abholen; sonst den Auftrag (einmalig) erteilen und `None`.
/// Die Seite wird so gerendert, dass die längste Kante `max_pixels` lang ist.
pub fn take(
&mut self,
key: &str,
path: PathBuf,
page: usize,
max_pixels: u32,
) -> Option<RenderResult> {
self.collect();
if let Some(result) = self.ready.remove(key) {
return Some(result);
}
if !self.request(key, path, page, max_pixels) {
return Some(Err("PDF-Render-Thread beendet".into()));
}
None
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// Minimales gültiges PDF mit Seiten der angegebenen Größen (Punkt); jede Seite zeigt ein
/// rotes Rechteck in der linken oberen Ecke. Optional mit `/Rotate`.
pub(crate) fn sample_pdf(pages: &[(f32, f32, i32)]) -> Vec<u8> {
let mut objects: Vec<String> = Vec::new();
let n = pages.len();
// 1: Katalog, 2: Seitenbaum, danach je Seite: Seite + Inhalt
objects.push("<< /Type /Catalog /Pages 2 0 R >>".into());
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 3 + 2 * i)).collect();
objects.push(format!(
"<< /Type /Pages /Kids [{}] /Count {n} >>",
kids.join(" ")
));
for (i, (w, h, rotate)) in pages.iter().enumerate() {
let content_id = 4 + 2 * i;
objects.push(format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Rotate {rotate} /Contents {content_id} 0 R /Resources << >> >>"
));
let content = format!("1 0 0 rg 0 {} 40 40 re f", h - 40.0);
objects.push(format!(
"<< /Length {} >>\nstream\n{content}\nendstream",
content.len()
));
}
let mut out = b"%PDF-1.4\n".to_vec();
let mut offsets = Vec::new();
for (i, obj) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{obj}\nendobj\n", i + 1).as_bytes());
}
let xref_pos = out.len();
out.extend_from_slice(
format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
);
for off in offsets {
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n",
objects.len() + 1
)
.as_bytes(),
);
out
}
#[test]
fn test_page_sizes_and_errors() {
let pdf = sample_pdf(&[(595.0, 842.0, 0), (842.0, 595.0, 0), (595.0, 842.0, 90)]);
let sizes = page_sizes(pdf).unwrap();
assert_eq!(sizes, vec![(595.0, 842.0), (842.0, 595.0), (842.0, 595.0)]);
assert!(page_sizes(b"kein pdf".to_vec()).is_err());
}
#[test]
fn test_render_page_draws_content() {
let pdf = open(sample_pdf(&[(200.0, 100.0, 0)])).unwrap();
let image = render_page(&pdf, 0, 400).unwrap();
assert_eq!((image.width, image.height), (400, 200));
let px = |x: u32, y: u32| {
let i = ((y * image.width + x) * 4) as usize;
&image.pixels[i..i + 4]
};
// Rotes Quadrat oben links, sonst weiß
assert_eq!(px(20, 20), &[255, 0, 0, 255]);
assert_eq!(px(300, 150), &[255, 255, 255, 255]);
assert!(render_page(&pdf, 5, 400).is_err());
}
#[test]
fn test_render_service_delivers_in_background() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("a.pdf");
std::fs::write(&path, sample_pdf(&[(400.0, 300.0, 0)])).unwrap();
let (wake_tx, wake_rx) = mpsc::channel();
let mut service = PdfRenderService::new(move || {
let _ = wake_tx.send(());
});
let px = DISPLAY_RENDER_PIXELS;
assert!(service.take("a#0", path.clone(), 0, px).is_none());
// Zweite Anfrage erteilt keinen weiteren Auftrag
assert!(service.take("a#0", path.clone(), 0, px).is_none());
wake_rx
.recv_timeout(std::time::Duration::from_secs(20))
.unwrap();
let image = service.take("a#0", path.clone(), 0, px).unwrap().unwrap();
assert_eq!(image.width, DISPLAY_RENDER_PIXELS);
// Vorab rendern: danach sofort verfügbar
service.prefetch("a#0", path.clone(), 0);
wake_rx
.recv_timeout(std::time::Duration::from_secs(20))
.unwrap();
assert!(service.take("a#0", path, 0, px).unwrap().is_ok());
}
#[test]
fn test_parse_page_key() {
assert_eq!(
parse_page_key("abc.pdf#3"),
Some(("abc.pdf", 3, DISPLAY_RENDER_PIXELS))
);
assert_eq!(parse_page_key("abc.pdf#3@5120"), Some(("abc.pdf", 3, 5120)));
assert_eq!(parse_page_key("abc.png"), None);
assert_eq!(parse_page_key("abc.pdf#x"), None);
}
}
+383
View File
@@ -0,0 +1,383 @@
//! Legt beim PDF-Export die Originalseiten importierter PDFs als Vektorgrafik unter die
//! Notizen (Text bleibt scharf und auswählbar).
//!
//! Vorgehen: Das von printpdf erzeugte PDF wird mit `lopdf` geöffnet. Jede benötigte Seite
//! des Quell-PDFs wird als Form-XObject (Inhalt + Ressourcen der Seite) übernommen und am
//! Anfang der Zielseite gezeichnet, also unter Hintergrund, Bildern und Tinte.
use std::collections::{BTreeMap, HashMap};
use printpdf::lopdf::{Dictionary, Document, Object, ObjectId, Stream};
/// Eine Zielseite, unter die eine Seite eines Quell-PDFs gelegt wird.
#[derive(Debug, Clone, PartialEq)]
pub struct Underlay {
/// Seitennummer im Ziel-PDF, ab 0
pub target_page: usize,
/// Schlüssel des Quell-PDFs in `sources`
pub source: String,
/// Seitennummer im Quell-PDF, ab 0
pub source_page: usize,
/// Zielrechteck in PDF-Punkten (links, unten, Breite, Höhe; Ursprung unten links)
pub rect: [f32; 4],
}
/// Nicht übernommene Unterlagen samt Grund.
pub type FailedUnderlays = Vec<(Underlay, String)>;
/// Affine PDF-Matrix `[a b c d e f]`: x' = a·x + c·y + e, y' = b·x + d·y + f.
pub type Matrix = [f32; 6];
/// `outer ∘ inner`: erst `inner`, dann `outer` anwenden.
pub fn compose(outer: Matrix, inner: Matrix) -> Matrix {
let [a1, b1, c1, d1, e1, f1] = outer;
let [a2, b2, c2, d2, e2, f2] = inner;
[
a1 * a2 + c1 * b2,
b1 * a2 + d1 * b2,
a1 * c2 + c1 * d2,
b1 * c2 + d1 * d2,
a1 * e2 + c1 * f2 + e1,
b1 * e2 + d1 * f2 + f1,
]
}
/// Matrix, die den Seitenbereich `bbox` (links, unten, rechts, oben) einer um `rotate` Grad
/// im Uhrzeigersinn gedrehten Seite so abbildet, wie ein Betrachter sie zeigt, eingepasst
/// in `rect` (links, unten, Breite, Höhe).
pub fn placement_matrix(bbox: [f32; 4], rotate: i64, rect: [f32; 4]) -> Matrix {
let (w, h) = ((bbox[2] - bbox[0]).max(1e-3), (bbox[3] - bbox[1]).max(1e-3));
let to_origin = [1.0, 0.0, 0.0, 1.0, -bbox[0], -bbox[1]];
let (rotation, shown_w, shown_h): (Matrix, f32, f32) = match rotate.rem_euclid(360) {
90 => ([0.0, -1.0, 1.0, 0.0, 0.0, w], h, w),
180 => ([-1.0, 0.0, 0.0, -1.0, w, h], w, h),
270 => ([0.0, 1.0, -1.0, 0.0, h, 0.0], h, w),
_ => ([1.0, 0.0, 0.0, 1.0, 0.0, 0.0], w, h),
};
let scale = (rect[2] / shown_w).min(rect[3] / shown_h);
let place = [scale, 0.0, 0.0, scale, rect[0], rect[1]];
compose(place, compose(rotation, to_origin))
}
/// Erbt ein Seitenattribut über die `Parent`-Kette.
fn inherited<'a>(doc: &'a Document, page: &'a Dictionary, key: &[u8]) -> Option<&'a Object> {
let mut node = page;
for _ in 0..64 {
if let Ok(value) = node.get(key) {
return Some(value);
}
let parent = node.get(b"Parent").and_then(Object::as_reference).ok()?;
node = doc.get_dictionary(parent).ok()?;
}
None
}
fn resolve<'a>(doc: &'a Document, object: &'a Object) -> &'a Object {
doc.dereference(object).map(|(_, o)| o).unwrap_or(object)
}
fn read_box(doc: &Document, object: Option<&Object>) -> Option<[f32; 4]> {
let array = resolve(doc, object?).as_array().ok()?;
let values: Vec<f32> = array
.iter()
.filter_map(|v| resolve(doc, v).as_float().ok())
.collect();
let [x0, y0, x1, y1] = <[f32; 4]>::try_from(values).ok()?;
Some([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)])
}
/// Baut aus einer Seite des (bereits umnummerierten) Quelldokuments ein Form-XObject.
/// Liefert das XObject und die Matrix für die Platzierung in `rect`.
fn page_as_form(
doc: &Document,
page_id: ObjectId,
rect: [f32; 4],
) -> Result<(Stream, Matrix), String> {
let page = doc.get_dictionary(page_id).map_err(|e| e.to_string())?;
let media =
read_box(doc, inherited(doc, page, b"MediaBox")).unwrap_or([0.0, 0.0, 595.0, 842.0]);
let bbox = match read_box(doc, inherited(doc, page, b"CropBox")) {
// Sichtbarer Bereich: Schnitt aus Crop- und Media-Box
Some(c) => [
c[0].max(media[0]),
c[1].max(media[1]),
c[2].min(media[2]),
c[3].min(media[3]),
],
None => media,
};
let rotate = inherited(doc, page, b"Rotate")
.and_then(|r| resolve(doc, r).as_i64().ok())
.unwrap_or(0);
let resources = inherited(doc, page, b"Resources")
.cloned()
.unwrap_or_else(|| Object::Dictionary(Dictionary::new()));
// Ein einzelner Inhaltsstrom wird unverändert (samt Kompression) übernommen,
// mehrere werden entpackt und aneinandergehängt
let streams = doc.get_page_contents(page_id);
let (content, filter) = match streams.as_slice() {
[single] => {
let stream = doc
.get_object(*single)
.and_then(Object::as_stream)
.map_err(|e| e.to_string())?;
let filter = ["Filter", "DecodeParms"]
.into_iter()
.filter_map(|k| Some((k, stream.dict.get(k.as_bytes()).ok()?.clone())))
.collect::<Vec<_>>();
(stream.content.clone(), filter)
}
_ => {
let mut content = Vec::new();
for id in streams {
let stream = doc
.get_object(id)
.and_then(Object::as_stream)
.map_err(|e| e.to_string())?;
let data = stream
.decompressed_content()
.map_err(|e| format!("Inhalt nicht lesbar: {e}"))?;
content.extend_from_slice(&data);
content.push(b'\n');
}
(content, Vec::new())
}
};
let mut dict = Dictionary::new();
dict.set("Type", Object::Name(b"XObject".to_vec()));
dict.set("Subtype", Object::Name(b"Form".to_vec()));
dict.set(
"BBox",
Object::Array(bbox.iter().map(|&v| Object::Real(v)).collect()),
);
dict.set("Resources", resources);
for (key, value) in filter {
dict.set(key, value);
}
Ok((
Stream::new(dict, content),
placement_matrix(bbox, rotate, rect),
))
}
/// Legt die Quellseiten unter die Zielseiten. `sources` enthält die Quell-PDFs als Bytes.
/// Gibt die neuen PDF-Bytes zurück und die Underlays, die nicht übernommen werden konnten
/// (z. B. verschlüsselte PDFs), samt Grund.
pub fn apply_underlays(
target_bytes: &[u8],
underlays: &[Underlay],
sources: &HashMap<String, Vec<u8>>,
) -> Result<(Vec<u8>, FailedUnderlays), String> {
let mut target = Document::load_mem(target_bytes).map_err(|e| e.to_string())?;
let target_pages: Vec<ObjectId> = target.get_pages().into_values().collect();
let mut failed = Vec::new();
// Quell-PDFs einmal laden und mit freien Objektnummern in das Ziel übernehmen
let mut loaded: HashMap<&str, Result<BTreeMap<u32, ObjectId>, String>> = HashMap::new();
for underlay in underlays {
if loaded.contains_key(underlay.source.as_str()) {
continue;
}
let result = (|| {
let bytes = sources
.get(&underlay.source)
.ok_or_else(|| "Quell-PDF fehlt".to_string())?;
let mut source = Document::load_mem(bytes).map_err(|e| e.to_string())?;
if source.is_encrypted() {
return Err("Quell-PDF ist verschlüsselt".to_string());
}
source.renumber_objects_with(target.max_id + 1);
let pages = source.get_pages();
target.max_id = target.max_id.max(source.max_id);
// Katalog und Seitenbaum der Quelle werden nicht referenziert und beim Speichern
// mit `prune_objects` entfernt
target.objects.extend(source.objects);
Ok(pages)
})();
loaded.insert(&underlay.source, result);
}
for underlay in underlays {
let result = (|| {
let pages = loaded[underlay.source.as_str()]
.as_ref()
.map_err(Clone::clone)?;
let source_page = *pages
.get(&(underlay.source_page as u32 + 1))
.ok_or_else(|| format!("Seite {} fehlt im Quell-PDF", underlay.source_page + 1))?;
let target_page = *target_pages
.get(underlay.target_page)
.ok_or_else(|| "Zielseite fehlt".to_string())?;
let (form, matrix) = page_as_form(&target, source_page, underlay.rect)?;
let form_id = target.add_object(Object::Stream(form));
let name = format!("InkPdf{}", form_id.0);
add_xobject(&mut target, target_page, &name, form_id)?;
let ops = format!(
"q {} {} {} {} {} {} cm /{name} Do Q\n",
matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5]
);
prepend_content(&mut target, target_page, ops.into_bytes())
})();
if let Err(err) = result {
failed.push((underlay.clone(), err));
}
}
target.prune_objects();
// Neue, noch unkomprimierte Ströme packen (bereits gefilterte bleiben unverändert)
target.compress();
let mut out = Vec::new();
target.save_to(&mut out).map_err(|e| e.to_string())?;
Ok((out, failed))
}
/// Trägt ein XObject in die Ressourcen der Seite ein.
fn add_xobject(
doc: &mut Document,
page_id: ObjectId,
name: &str,
id: ObjectId,
) -> Result<(), String> {
// Ressourcen der Seite als eigenes Wörterbuch an der Seite (geerbte werden übernommen)
let resources = {
let page = doc.get_dictionary(page_id).map_err(|e| e.to_string())?;
match inherited(doc, page, b"Resources").map(|r| resolve(doc, r)) {
Some(Object::Dictionary(d)) => d.clone(),
_ => Dictionary::new(),
}
};
let mut xobjects = match resources.get(b"XObject").map(|x| resolve(doc, x)) {
Ok(Object::Dictionary(d)) => d.clone(),
_ => Dictionary::new(),
};
xobjects.set(name, Object::Reference(id));
let mut resources = resources;
resources.set("XObject", Object::Dictionary(xobjects));
let page = doc
.get_object_mut(page_id)
.and_then(Object::as_dict_mut)
.map_err(|e| e.to_string())?;
page.set("Resources", Object::Dictionary(resources));
Ok(())
}
/// Stellt einen Inhaltsstrom an den Anfang der Seite (zeichnet also zuunterst).
fn prepend_content(doc: &mut Document, page_id: ObjectId, content: Vec<u8>) -> Result<(), String> {
let existing: Vec<Object> = doc
.get_page_contents(page_id)
.into_iter()
.map(Object::Reference)
.collect();
let id = doc.add_object(Object::Stream(Stream::new(Dictionary::new(), content)));
let mut contents = vec![Object::Reference(id)];
contents.extend(existing);
let page = doc
.get_object_mut(page_id)
.and_then(Object::as_dict_mut)
.map_err(|e| e.to_string())?;
page.set("Contents", Object::Array(contents));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn apply(m: Matrix, x: f32, y: f32) -> (f32, f32) {
(m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])
}
fn close(a: (f32, f32), b: (f32, f32)) -> bool {
(a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3
}
#[test]
fn test_placement_matrix() {
// Querformat-Folie 800 × 600 ohne Drehung in ein 400 × 300-Rechteck bei (10, 20)
let m = placement_matrix([0.0, 0.0, 800.0, 600.0], 0, [10.0, 20.0, 400.0, 300.0]);
assert!(close(apply(m, 0.0, 0.0), (10.0, 20.0)));
assert!(close(apply(m, 800.0, 600.0), (410.0, 320.0)));
// Verschobene MediaBox
let m = placement_matrix([100.0, 50.0, 300.0, 150.0], 0, [0.0, 0.0, 200.0, 100.0]);
assert!(close(apply(m, 100.0, 50.0), (0.0, 0.0)));
// Um 90° gedrehte Hochformatseite (200 × 400) erscheint quer (400 × 200):
// die linke untere Ecke landet links oben
let m = placement_matrix([0.0, 0.0, 200.0, 400.0], 90, [0.0, 0.0, 400.0, 200.0]);
assert!(close(apply(m, 0.0, 0.0), (0.0, 200.0)));
assert!(close(apply(m, 200.0, 0.0), (0.0, 0.0)));
assert!(close(apply(m, 0.0, 400.0), (400.0, 200.0)));
// 180° und 270° (= -90°)
let m = placement_matrix([0.0, 0.0, 200.0, 400.0], 180, [0.0, 0.0, 200.0, 400.0]);
assert!(close(apply(m, 0.0, 0.0), (200.0, 400.0)));
let m = placement_matrix([0.0, 0.0, 200.0, 400.0], -90, [0.0, 0.0, 400.0, 200.0]);
assert!(close(apply(m, 0.0, 0.0), (400.0, 0.0)));
}
#[test]
fn test_apply_underlays_embeds_source_pages() {
let source = crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (595.0, 842.0, 0)]);
let target = crate::pdf_doc::tests::sample_pdf(&[(595.0, 842.0, 0), (595.0, 842.0, 0)]);
let sources = HashMap::from([("folien".to_string(), source)]);
let underlays = vec![
Underlay {
target_page: 0,
source: "folien".into(),
source_page: 0,
rect: [0.0, 421.0, 595.0, 421.0],
},
Underlay {
target_page: 1,
source: "folien".into(),
source_page: 5,
rect: [0.0, 0.0, 595.0, 842.0],
},
Underlay {
target_page: 1,
source: "fehlt".into(),
source_page: 0,
rect: [0.0, 0.0, 595.0, 842.0],
},
];
let (bytes, failed) = apply_underlays(&target, &underlays, &sources).unwrap();
// Fehlende Quellseite und fehlendes PDF werden gemeldet, der Rest klappt
assert_eq!(failed.len(), 2);
let doc = Document::load_mem(&bytes).unwrap();
let pages: Vec<ObjectId> = doc.get_pages().into_values().collect();
assert_eq!(
pages.len(),
2,
"Quellseiten dürfen nicht als eigene Seiten auftauchen"
);
let first = doc.get_page_content(pages[0]).unwrap();
let text = String::from_utf8_lossy(&first);
assert!(
text.starts_with("q "),
"Unterlage muss zuerst gezeichnet werden: {text}"
);
assert!(text.contains("Do Q"));
// Ursprünglicher Seiteninhalt bleibt erhalten
assert!(text.contains("re f"));
// Form-XObject mit dem Inhalt der Folie ist in den Ressourcen eingetragen
let page = doc.get_dictionary(pages[0]).unwrap();
let resources = page.get(b"Resources").unwrap().as_dict().unwrap();
let xobjects = resources.get(b"XObject").unwrap().as_dict().unwrap();
let (_, form_ref) = xobjects.iter().next().unwrap();
let form = doc
.get_object(form_ref.as_reference().unwrap())
.unwrap()
.as_stream()
.unwrap();
assert_eq!(
form.dict.get(b"Subtype").unwrap().as_name().unwrap(),
b"Form"
);
assert!(String::from_utf8_lossy(&form.content).contains("1 0 0 rg"));
}
}
+96
View File
@@ -0,0 +1,96 @@
//! Erkennung der Laufzeitumgebung und Wahl des Anzeigesystems (X11 oder Wayland).
//!
//! Unter WSL (WSLg) trennt der Wayland-Compositor die Verbindung, sobald die App ein Bild
//! über Vulkan übergibt ("Connection reset by peer"). Über X11 (Xwayland) läuft sie stabil,
//! daher wird unter WSL standardmäßig X11 verwendet. `INK_NOTES_DISPLAY=x11|wayland`
//! übersteuert die Wahl.
/// Anzeigesystem, mit dem die Ereignisschleife erzeugt wird.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisplayBackend {
/// Automatische Wahl von winit (Wayland, falls verfügbar, sonst X11)
Automatic,
X11,
Wayland,
}
/// Wählt das Anzeigesystem.
///
/// - `override_value`: Inhalt von `INK_NOTES_DISPLAY` (`x11` oder `wayland`, sonst ignoriert)
/// - `is_wsl`: läuft unter WSL
/// - `has_x11_display`: `DISPLAY` ist gesetzt (X11/Xwayland erreichbar)
pub fn choose_display_backend(
override_value: Option<&str>,
is_wsl: bool,
has_x11_display: bool,
) -> DisplayBackend {
match override_value
.map(|v| v.trim().to_ascii_lowercase())
.as_deref()
{
Some("x11") => return DisplayBackend::X11,
Some("wayland") => return DisplayBackend::Wayland,
_ => {}
}
if is_wsl && has_x11_display {
DisplayBackend::X11
} else {
DisplayBackend::Automatic
}
}
/// Läuft das Programm unter WSL (Windows-Subsystem für Linux)?
pub fn is_wsl() -> bool {
if std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() {
return true;
}
std::fs::read_to_string("/proc/sys/kernel/osrelease")
.map(|release| release.to_ascii_lowercase().contains("microsoft"))
.unwrap_or(false)
}
/// Wahl anhand der aktuellen Umgebung.
pub fn display_backend_from_env() -> DisplayBackend {
choose_display_backend(
std::env::var("INK_NOTES_DISPLAY").ok().as_deref(),
is_wsl(),
std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_choose_display_backend() {
// WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab)
assert_eq!(
choose_display_backend(None, true, true),
DisplayBackend::X11
);
// WSL ohne X11-Display: winit entscheiden lassen
assert_eq!(
choose_display_backend(None, true, false),
DisplayBackend::Automatic
);
// Natives Linux: winit entscheiden lassen
assert_eq!(
choose_display_backend(None, false, true),
DisplayBackend::Automatic
);
// Übersteuerung
assert_eq!(
choose_display_backend(Some("wayland"), true, true),
DisplayBackend::Wayland
);
assert_eq!(
choose_display_backend(Some(" X11 "), false, true),
DisplayBackend::X11
);
assert_eq!(
choose_display_backend(Some("quatsch"), true, true),
DisplayBackend::X11
);
}
}
+239
View File
@@ -0,0 +1,239 @@
//! Virtuelles Lineal: ein verschieb- und drehbares Lineal über dem Blatt. Striche, die nahe
//! einer Längskante beginnen, rasten auf dieser Kante ein und werden zu geraden Linien.
//!
//! Alle Maße in Seiteneinheiten (10 Einheiten = 1 mm).
use glam::Vec2;
use crate::document::UNITS_PER_MM;
/// Länge des Lineals (26 cm).
pub const RULER_LENGTH: f32 = 260.0 * UNITS_PER_MM;
/// Breite des Lineals (3,2 cm).
pub const RULER_WIDTH: f32 = 32.0 * UNITS_PER_MM;
/// Abstand zu einer Längskante, innerhalb dessen ein Strich einrastet (innen wie außen).
pub const SNAP_DISTANCE: f32 = 6.0 * UNITS_PER_MM;
/// Radius des Dreh-Griffs.
pub const HANDLE_RADIUS: f32 = 8.0 * UNITS_PER_MM;
/// Winkel rasten in diesen Schritten ein …
pub const ANGLE_SNAP_STEP_DEG: f32 = 15.0;
/// … wenn sie höchstens so weit davon entfernt sind.
pub const ANGLE_SNAP_TOLERANCE_DEG: f32 = 2.0;
/// Lage des Lineals auf dem Blatt.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Ruler {
/// Mittelpunkt
pub center: Vec2,
/// Drehwinkel der Längsachse in Bogenmaß (0 = waagerecht)
pub angle: f32,
}
/// Was an einer Stelle des Lineals liegt.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RulerHit {
/// Fläche zum Verschieben
Body,
/// Griff zum Drehen
RotateHandle,
}
/// Eine Kante, an der ein Strich eingerastet ist: Gerade durch `origin` in Richtung `dir`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EdgeLock {
pub origin: Vec2,
pub dir: Vec2,
}
impl EdgeLock {
/// Fußpunkt von `p` auf der Geraden.
pub fn project(&self, p: Vec2) -> Vec2 {
self.origin + self.dir * (p - self.origin).dot(self.dir)
}
}
impl Ruler {
pub fn new(center: Vec2) -> Self {
Self { center, angle: 0.0 }
}
/// Einheitsvektor entlang der Längsachse.
pub fn axis(&self) -> Vec2 {
Vec2::from_angle(self.angle)
}
/// Einheitsvektor quer zur Längsachse (zur „unteren“ Kante, bei 0° nach unten).
pub fn normal(&self) -> Vec2 {
self.axis().perp()
}
/// Lage von `p` im Lineal-Koordinatensystem: (entlang der Achse, quer dazu).
pub fn local(&self, p: Vec2) -> Vec2 {
let d = p - self.center;
Vec2::new(d.dot(self.axis()), d.dot(self.normal()))
}
/// Ecken: oben links, oben rechts, unten rechts, unten links (bei 0°).
pub fn corners(&self) -> [Vec2; 4] {
let a = self.axis() * (RULER_LENGTH / 2.0);
let n = self.normal() * (RULER_WIDTH / 2.0);
[
self.center - a - n,
self.center + a - n,
self.center + a + n,
self.center - a + n,
]
}
/// Mittelpunkt des Dreh-Griffs (nahe dem rechten Ende).
pub fn handle_center(&self) -> Vec2 {
self.center + self.axis() * (RULER_LENGTH / 2.0 - HANDLE_RADIUS * 1.6)
}
/// Was liegt an `p`? Die Streifen entlang der Kanten gehören nicht dazu: dort beginnen
/// eingerastete Striche.
pub fn hit(&self, p: Vec2) -> Option<RulerHit> {
if (p - self.handle_center()).length() <= HANDLE_RADIUS {
return Some(RulerHit::RotateHandle);
}
let l = self.local(p);
let inside_band = l.y.abs() <= RULER_WIDTH / 2.0 - SNAP_DISTANCE;
(l.x.abs() <= RULER_LENGTH / 2.0 && inside_band).then_some(RulerHit::Body)
}
/// Kante, an der ein bei `p` beginnender Strich einrastet, verschoben um `offset` nach
/// außen (halbe Strichbreite: die Tinte liegt dann neben dem Lineal, nicht darunter).
pub fn edge_lock(&self, p: Vec2, offset: f32) -> Option<EdgeLock> {
let l = self.local(p);
if l.x.abs() > RULER_LENGTH / 2.0 + SNAP_DISTANCE {
return None;
}
let half = RULER_WIDTH / 2.0;
let side = if (l.y - half).abs() <= SNAP_DISTANCE {
1.0
} else if (l.y + half).abs() <= SNAP_DISTANCE {
-1.0
} else {
return None;
};
Some(EdgeLock {
origin: self.center + self.normal() * side * (half + offset),
dir: self.axis(),
})
}
/// Dreht das Lineal so, dass die Achse vom Mittelpunkt auf `toward` zeigt; Vielfache von
/// 15° rasten ein.
pub fn rotate_toward(&mut self, toward: Vec2) {
let d = toward - self.center;
if d.length_squared() < 1e-6 {
return;
}
self.angle = snap_angle(d.y.atan2(d.x));
}
/// Winkel für die Anzeige in Grad, 0–179 (ein Lineal hat keine Richtung).
pub fn display_degrees(&self) -> u32 {
(self.angle.to_degrees().round() as i32).rem_euclid(180) as u32
}
}
/// Rastet einen Winkel (Bogenmaß) auf das nächste Vielfache von 15° ein, wenn er nahe genug ist.
pub fn snap_angle(angle: f32) -> f32 {
let deg = angle.to_degrees();
let nearest = (deg / ANGLE_SNAP_STEP_DEG).round() * ANGLE_SNAP_STEP_DEG;
if (deg - nearest).abs() <= ANGLE_SNAP_TOLERANCE_DEG {
nearest.to_radians()
} else {
angle
}
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: Vec2, b: Vec2) -> bool {
(a - b).length() < 1e-2
}
#[test]
fn test_geometry_and_hits() {
let ruler = Ruler::new(Vec2::new(1000.0, 1000.0));
let [tl, tr, br, bl] = ruler.corners();
assert!(close(
tl,
Vec2::new(1000.0 - RULER_LENGTH / 2.0, 1000.0 - RULER_WIDTH / 2.0)
));
assert!(close(
br,
Vec2::new(1000.0 + RULER_LENGTH / 2.0, 1000.0 + RULER_WIDTH / 2.0)
));
assert!(close(
tr - tl,
bl - tl + Vec2::new(RULER_LENGTH, -RULER_WIDTH)
));
assert_eq!(ruler.hit(ruler.center), Some(RulerHit::Body));
assert_eq!(
ruler.hit(ruler.handle_center()),
Some(RulerHit::RotateHandle)
);
// Kantenstreifen und außerhalb: kein Treffer (dort wird gezeichnet)
assert_eq!(
ruler.hit(Vec2::new(1000.0, 1000.0 - RULER_WIDTH / 2.0 + 10.0)),
None
);
assert_eq!(ruler.hit(Vec2::new(1000.0, 1300.0)), None);
assert_eq!(ruler.hit(Vec2::new(1000.0 + RULER_LENGTH, 1000.0)), None);
}
#[test]
fn test_edge_lock_projects_onto_offset_edge() {
let ruler = Ruler::new(Vec2::new(1000.0, 1000.0));
let edge_y = 1000.0 - RULER_WIDTH / 2.0;
// Knapp oberhalb der oberen Kante: rastet ein, Linie liegt um den Versatz außerhalb
let lock = ruler
.edge_lock(Vec2::new(900.0, edge_y - 20.0), 3.0)
.unwrap();
let p = lock.project(Vec2::new(1300.0, edge_y - 55.0));
assert!(close(p, Vec2::new(1300.0, edge_y - 3.0)));
// Knapp innerhalb der unteren Kante
let lock = ruler
.edge_lock(Vec2::new(900.0, 1000.0 + RULER_WIDTH / 2.0 - 20.0), 0.0)
.unwrap();
assert!(close(
lock.project(Vec2::new(0.0, 0.0)),
Vec2::new(0.0, 1000.0 + RULER_WIDTH / 2.0)
));
// Mitte oder weit weg: kein Einrasten
assert!(ruler.edge_lock(ruler.center, 0.0).is_none());
assert!(ruler.edge_lock(Vec2::new(1000.0, 500.0), 0.0).is_none());
assert!(ruler
.edge_lock(Vec2::new(1000.0 + RULER_LENGTH, edge_y), 0.0)
.is_none());
// Gedrehtes Lineal: die Linie folgt der Drehung
let mut tilted = ruler;
tilted.angle = 30f32.to_radians();
let near_edge = tilted.center - tilted.normal() * (RULER_WIDTH / 2.0 + 10.0);
let lock = tilted.edge_lock(near_edge, 0.0).unwrap();
let a = lock.project(near_edge);
let b = lock.project(near_edge + Vec2::new(300.0, 0.0));
let dir = (b - a).normalize();
assert!((dir.y.atan2(dir.x).to_degrees() - 30.0).abs() < 0.01);
}
#[test]
fn test_rotation_snaps_to_15_degrees() {
let mut ruler = Ruler::new(Vec2::ZERO);
ruler.rotate_toward(Vec2::from_angle(44.0f32.to_radians()) * 100.0);
assert_eq!(ruler.display_degrees(), 45);
ruler.rotate_toward(Vec2::from_angle(37.0f32.to_radians()) * 100.0);
assert_eq!(ruler.display_degrees(), 37);
// Gegenrichtung zeigt denselben Winkel
ruler.rotate_toward(Vec2::from_angle(-150.0f32.to_radians()) * 100.0);
assert_eq!(ruler.display_degrees(), 30);
assert_eq!(snap_angle(1.0f32.to_radians()), 0.0);
}
}
+80 -15
View File
@@ -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,
};
@@ -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],
@@ -199,13 +203,19 @@ impl SelectionState {
pub fn paste_from_clipboard(
&mut self,
page: &mut Page,
at: Option<Vec2>,
next_id_gen: &mut impl FnMut() -> u64,
) -> Vec<u64> {
if self.clipboard.is_empty() {
return Vec::new();
}
let offset = Vec2::new(30.0, 30.0); // ca. 3 mm Versatz nach rechts-unten
// Mit `at` (lange gedrückt → Einfügen) landet die Mitte der Objekte dort, sonst
// ca. 3 mm nach rechts unten versetzt
let offset = match (at, Page::selection_aabb(&self.clipboard)) {
(Some(at), Some(aabb)) => at - (aabb.min + aabb.max) / 2.0,
_ => Vec2::new(30.0, 30.0),
};
let mut new_strokes = Vec::with_capacity(self.clipboard.len());
let mut new_ids = Vec::with_capacity(self.clipboard.len());
@@ -239,7 +249,12 @@ impl SelectionState {
if self.selected_ids.is_empty() {
return false;
}
let mut strokes = page.get_strokes(&self.selected_ids);
// Bilder behalten ihre Farben
let mut strokes: Vec<_> = page
.get_strokes(&self.selected_ids)
.into_iter()
.filter(|s| !s.is_image())
.collect();
if strokes.is_empty() {
return false;
}
@@ -273,6 +288,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,6 +308,8 @@ 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 {
@@ -290,8 +317,8 @@ pub fn render_selection_overlay(
current_page,
} = selection.drag_mode
{
let p1 = canvas.page_to_screen(start_page);
let p2 = canvas.page_to_screen(current_page);
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]);
@@ -303,14 +330,14 @@ 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::Color32::from_rgba_unmultiplied(80, 160, 255, 220),
),
);
return;
@@ -321,8 +348,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]));
@@ -338,7 +365,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],
@@ -436,7 +463,7 @@ mod tests {
// Paste
let mut next_id = 100;
let new_ids = sel.paste_from_clipboard(&mut page, &mut || {
let new_ids = sel.paste_from_clipboard(&mut page, None, &mut || {
next_id += 1;
next_id
});
@@ -444,6 +471,18 @@ mod tests {
assert_eq!(page.strokes.len(), 3);
assert_eq!(sel.selected_ids, new_ids);
// An einer Stelle einfügen: die Mitte der Kopie liegt dort
let target = Vec2::new(1000.0, 1500.0);
let placed = sel.paste_from_clipboard(&mut page, Some(target), &mut || {
next_id += 1;
next_id
});
let aabb = sel.get_selection_aabb(&page).unwrap();
assert_eq!(sel.selected_ids, placed);
assert!(((aabb.min + aabb.max) / 2.0 - target).length() < 0.01);
sel.delete_selected(&mut page);
sel.selected_ids = new_ids.clone();
// Delete
let deleted = sel.delete_selected(&mut page);
assert_eq!(deleted.len(), 1);
@@ -483,4 +522,30 @@ mod tests {
let restored_pos = page.strokes[0].points[0].position;
assert!((orig_pos.x - restored_pos.x).abs() < 1e-4);
}
#[test]
fn test_recolor_ignores_images() {
let mut page = Page::new(1, PageBackground::Blank);
let corners = [
Vec2::new(0.0, 0.0),
Vec2::new(100.0, 0.0),
Vec2::new(100.0, 50.0),
Vec2::new(0.0, 50.0),
];
page.add_stroke(Stroke::new_image(1, "a.png".into(), corners));
let undo_depth = page.history.undo_stack.len();
let mut sel = SelectionState::new();
sel.select_single(1);
// Nur ein Bild ausgewählt: nichts umzufärben, kein Undo-Eintrag
assert!(!sel.recolor_selected(&mut page, [1.0, 0.0, 0.0, 1.0]));
assert_eq!(page.history.undo_stack.len(), undo_depth);
// Bild und Strich: nur der Strich wird umgefärbt
page.add_stroke(make_test_stroke(2, 20.0, 20.0, [0.1, 0.1, 0.1, 1.0]));
sel.select_multiple(vec![1, 2]);
assert!(sel.recolor_selected(&mut page, [1.0, 0.0, 0.0, 1.0]));
assert_eq!(page.strokes[0].color, [1.0, 1.0, 1.0, 1.0]);
assert_eq!(page.strokes[1].color, [1.0, 0.0, 0.0, 1.0]);
}
}
+393
View File
@@ -0,0 +1,393 @@
//! 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::background::{PaperTheme, PatternColor};
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,
}
/// Druckkennlinie des Stifts: passt die sehr unterschiedlichen Druckbereiche verschiedener
/// Digitizer (Wacom EMR, MPP, …) an das Schreibgefühl an.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PressureCurve {
/// Exponent der Kennlinie: < 1 weich (schon leichter Druck ergibt kräftige Linien),
/// > 1 hart (erst fester Druck ergibt kräftige Linien)
pub exponent: f32,
/// Rohdruck unterhalb dieser Schwelle zählt als kein Druck (dünnste Linie);
/// unterdrückt das Grundrauschen empfindlicher Stifte
pub threshold: f32,
}
impl PressureCurve {
pub const EXPONENT_RANGE: std::ops::RangeInclusive<f32> = 0.3..=3.0;
pub const THRESHOLD_RANGE: std::ops::RangeInclusive<f32> = 0.0..=0.5;
/// Rechnet den Rohdruck des Stifts (0–1) in den Druck für die Strichbreite um.
pub fn apply(&self, raw: f32) -> f32 {
let curve = self.sanitized();
if !raw.is_finite() {
return 0.0;
}
let normalized = ((raw - curve.threshold) / (1.0 - curve.threshold)).clamp(0.0, 1.0);
normalized.powf(curve.exponent)
}
/// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei auf den
/// gültigen Bereich.
pub fn sanitized(&self) -> Self {
let default = Self::default();
let clamp = |v: f32, range: &std::ops::RangeInclusive<f32>, fallback: f32| {
if v.is_finite() {
v.clamp(*range.start(), *range.end())
} else {
fallback
}
};
Self {
exponent: clamp(self.exponent, &Self::EXPONENT_RANGE, default.exponent),
threshold: clamp(self.threshold, &Self::THRESHOLD_RANGE, default.threshold),
}
}
}
impl Default for PressureCurve {
fn default() -> Self {
Self {
exponent: 1.0,
threshold: 0.0,
}
}
}
/// Stift-Vorlage: Farbe und Stärke eines der Stifte in der Werkzeugleiste. Ändert man Farbe
/// oder Stärke, während ein Stift gewählt ist, merkt sich die Vorlage die neuen Werte.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct PenPreset {
pub color: [f32; 4],
pub width_mm: f32,
}
impl PenPreset {
pub const WIDTH_RANGE: std::ops::RangeInclusive<f32> = 0.15..=5.0;
/// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei; Stifte sind
/// immer deckend.
pub fn sanitized(&self, fallback: PenPreset) -> Self {
let color = sanitized_color(self.color, [0.0, 0.0, 0.0, 1.0]);
let width_mm = if self.width_mm.is_finite() {
self.width_mm
.clamp(*Self::WIDTH_RANGE.start(), *Self::WIDTH_RANGE.end())
} else {
fallback.width_mm
};
Self { color, width_mm }
}
}
/// Anzahl der Stifte in der Werkzeugleiste.
pub const PEN_PRESET_COUNT: usize = 4;
/// Standardstifte: Schwarz, Blau und Rot fein, Grün etwas kräftiger.
pub const DEFAULT_PEN_PRESETS: [PenPreset; PEN_PRESET_COUNT] = [
PenPreset {
color: [0.12, 0.12, 0.14, 1.0],
width_mm: 0.5,
},
PenPreset {
color: [0.15, 0.35, 0.85, 1.0],
width_mm: 0.5,
},
PenPreset {
color: [0.85, 0.20, 0.20, 1.0],
width_mm: 0.5,
},
PenPreset {
color: [0.18, 0.65, 0.35, 1.0],
width_mm: 0.8,
},
];
/// Farben des Textmarkers in der Werkzeugleiste (halbtransparent).
pub const DEFAULT_HIGHLIGHTER_COLORS: [[f32; 4]; 4] = [
[1.0, 0.95, 0.1, 0.35],
[0.2, 0.9, 0.3, 0.35],
[0.25, 0.7, 1.0, 0.35],
[1.0, 0.3, 0.65, 0.35],
];
/// Farben des Text-Werkzeugs in der Werkzeugleiste.
pub const DEFAULT_TEXT_COLORS: [[f32; 4]; 5] = [
[0.12, 0.12, 0.14, 1.0],
[0.15, 0.35, 0.85, 1.0],
[0.85, 0.20, 0.20, 1.0],
[0.18, 0.65, 0.35, 1.0],
[0.45, 0.45, 0.48, 1.0],
];
/// Begrenzt eine Farbe aus der Einstellungsdatei auf 0–1; die Deckkraft kommt immer aus
/// `fallback` (Stift und Text deckend, Textmarker halbtransparent).
fn sanitized_color(color: [f32; 4], fallback: [f32; 4]) -> [f32; 4] {
let mut out = fallback;
if color.iter().all(|c| c.is_finite()) {
for (o, c) in out.iter_mut().zip(color).take(3) {
*o = c.clamp(0.0, 1.0);
}
}
out
}
/// Benutzereinstellungen der Oberfläche.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct UiSettings {
pub sidebar_side: SidebarSide,
/// Papierfarbe der Anzeige
pub paper_theme: PaperTheme,
/// Dunkle Stiftfarben auf dunklem Papier aufhellen
pub adapt_ink_colors: bool,
/// Farbe von Karos, Linien und Punkten (Anzeige und Export)
pub pattern_color: PatternColor,
/// Druckkennlinie des Stifts
pub pressure_curve: PressureCurve,
/// Seiten durchgehend untereinander anzeigen (sonst einzeln blättern)
pub continuous_scroll: bool,
/// Stifte der Werkzeugleiste und der gewählte davon
pub pen_presets: [PenPreset; PEN_PRESET_COUNT],
pub active_pen: usize,
/// Eigene Farben von Textmarker und Text-Werkzeug und die gewählte davon
pub highlighter_colors: [[f32; 4]; 4],
pub active_highlighter: usize,
pub text_colors: [[f32; 4]; 5],
pub active_text_color: usize,
}
impl Default for UiSettings {
fn default() -> Self {
Self {
sidebar_side: SidebarSide::default(),
paper_theme: PaperTheme::default(),
adapt_ink_colors: true,
pattern_color: PatternColor::default(),
pressure_curve: PressureCurve::default(),
continuous_scroll: true,
pen_presets: DEFAULT_PEN_PRESETS,
active_pen: 0,
highlighter_colors: DEFAULT_HIGHLIGHTER_COLORS,
active_highlighter: 0,
text_colors: DEFAULT_TEXT_COLORS,
active_text_color: 0,
}
}
}
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 {
let mut settings: 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(),
};
settings.pressure_curve = settings.pressure_curve.sanitized();
for (preset, fallback) in settings.pen_presets.iter_mut().zip(DEFAULT_PEN_PRESETS) {
*preset = preset.sanitized(fallback);
}
settings.active_pen = settings.active_pen.min(PEN_PRESET_COUNT - 1);
for (color, fallback) in settings
.highlighter_colors
.iter_mut()
.zip(DEFAULT_HIGHLIGHTER_COLORS)
{
*color = sanitized_color(*color, fallback);
}
settings.active_highlighter = settings
.active_highlighter
.min(settings.highlighter_colors.len() - 1);
for (color, fallback) in settings.text_colors.iter_mut().zip(DEFAULT_TEXT_COLORS) {
*color = sanitized_color(*color, fallback);
}
settings.active_text_color = settings
.active_text_color
.min(settings.text_colors.len() - 1);
settings
}
/// 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,
paper_theme: PaperTheme::Black,
adapt_ink_colors: false,
pattern_color: PatternColor::Strong,
pressure_curve: PressureCurve {
exponent: 1.8,
threshold: 0.1,
},
continuous_scroll: false,
pen_presets: [PenPreset {
color: [0.5, 0.2, 0.7, 1.0],
width_mm: 1.5,
}; PEN_PRESET_COUNT],
active_pen: 2,
highlighter_colors: [[0.9, 0.5, 0.1, 0.35]; 4],
active_highlighter: 3,
text_colors: [[0.3, 0.1, 0.6, 1.0]; 5],
active_text_color: 4,
};
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());
}
#[test]
fn test_old_settings_file_keeps_new_defaults() {
// ui.json aus v0.3.x kennt nur die Seite der Werkzeugleiste
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ui.json");
std::fs::write(&path, br#"{"sidebar_side": "Right"}"#).unwrap();
let settings = UiSettings::load_from(&path);
assert_eq!(settings.sidebar_side, SidebarSide::Right);
assert_eq!(settings.paper_theme, PaperTheme::White);
assert!(settings.adapt_ink_colors);
assert_eq!(settings.pressure_curve, PressureCurve::default());
assert!(settings.continuous_scroll);
assert_eq!(settings.pattern_color, PatternColor::Light);
// Unsinnige Werte aus einer von Hand bearbeiteten Datei werden begrenzt
std::fs::write(
&path,
br#"{"pressure_curve": {"exponent": 50.0, "threshold": -3.0}}"#,
)
.unwrap();
let curve = UiSettings::load_from(&path).pressure_curve;
assert_eq!(curve.exponent, 3.0);
assert_eq!(curve.threshold, 0.0);
// Ohne Stifte: die Standardstifte; unsinnige Stifte werden begrenzt
assert_eq!(settings.pen_presets, DEFAULT_PEN_PRESETS);
assert_eq!(settings.active_pen, 0);
std::fs::write(
&path,
br#"{"active_pen": 9, "pen_presets": [
{"color": [2.0, -1.0, 0.5, 0.2], "width_mm": 99.0},
{"color": [0.1, 0.2, 0.3, 1.0], "width_mm": 0.0},
{"color": [0.1, 0.2, 0.3, 1.0], "width_mm": 1.0},
{"color": [0.1, 0.2, 0.3, 1.0], "width_mm": 1.0}
]}"#,
)
.unwrap();
let settings = UiSettings::load_from(&path);
assert_eq!(settings.active_pen, PEN_PRESET_COUNT - 1);
assert_eq!(settings.pen_presets[0].color, [1.0, 0.0, 0.5, 1.0]);
assert_eq!(settings.pen_presets[0].width_mm, 5.0);
assert_eq!(settings.pen_presets[1].width_mm, 0.15);
// Eigene Farben: Textmarker bleibt halbtransparent, Text deckend
std::fs::write(
&path,
br#"{"highlighter_colors": [[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0],
[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0]], "active_highlighter": 7,
"text_colors": [[5.0, 0.2, 0.3, 0.1], [0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0],
[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0]]}"#,
)
.unwrap();
let settings = UiSettings::load_from(&path);
assert_eq!(settings.highlighter_colors[0], [0.1, 0.2, 0.3, 0.35]);
assert_eq!(settings.active_highlighter, 3);
assert_eq!(settings.text_colors[0], [1.0, 0.2, 0.3, 1.0]);
}
#[test]
fn test_pressure_curve() {
// Standard: unverändert
let linear = PressureCurve::default();
for p in [0.0, 0.25, 0.5, 1.0] {
assert!((linear.apply(p) - p).abs() < 1e-6);
}
// Weich: halber Druck ergibt mehr als halbe Breite, hart: weniger
let soft = PressureCurve {
exponent: 0.5,
threshold: 0.0,
};
let hard = PressureCurve {
exponent: 2.0,
threshold: 0.0,
};
assert!(soft.apply(0.5) > 0.6);
assert!(hard.apply(0.5) < 0.3);
// Endpunkte bleiben fest
for curve in [soft, hard] {
assert_eq!(curve.apply(0.0), 0.0);
assert!((curve.apply(1.0) - 1.0).abs() < 1e-6);
}
// Schwelle: darunter kein Druck, darüber auf den vollen Bereich gestreckt
let gated = PressureCurve {
exponent: 1.0,
threshold: 0.2,
};
assert_eq!(gated.apply(0.15), 0.0);
assert!((gated.apply(0.6) - 0.5).abs() < 1e-6);
assert!((gated.apply(1.0) - 1.0).abs() < 1e-6);
// Monoton steigend
let mut last = -1.0;
for i in 0..=100 {
let v = hard.apply(i as f32 / 100.0);
assert!(v >= last);
last = v;
}
// Ungültige Eingaben ergeben nie NaN
assert_eq!(linear.apply(f32::NAN), 0.0);
assert_eq!(linear.apply(7.0), 1.0);
}
}
+153 -1
View File
@@ -14,6 +14,8 @@ struct VertexOutput {
struct CameraUniforms {
transform_scale: vec2<f32>,
transform_offset: vec2<f32>,
// x: 1.0 = dunkle Tinte für dunkles Papier aufhellen (siehe `adapt_ink_color`)
ink_params: vec4<f32>,
};
@group(0) @binding(0)
@@ -28,7 +30,157 @@ fn vs_main(in: VertexInput) -> VertexOutput {
return out;
}
// Linearer Farbwert (Papier, Muster) -> sRGB: Gezeichnet wird ohne sRGB-Umrechnung der GPU
// (siehe `render_format` in canvas.rs), gemischt wird also wie im PDF in sRGB
fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
let low = c * 12.92;
let high = 1.055 * pow(c, vec3<f32>(1.0 / 2.4)) - vec3<f32>(0.055);
return select(high, low, c <= vec3<f32>(0.0031308));
}
// Papier: Farbe linear
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return in.color;
return vec4<f32>(linear_to_srgb(clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0))), in.color.a);
}
// Striche: Tintenfarben sind sRGB-Werte wie im Export und in der Oberfläche. Bei dunklem
// Papier wird die HSL-Helligkeit dunkler Farben gespiegelt (L -> 1 - L, Farbton und
// Sättigung bleiben). Entspricht `background::adapt_ink_color`.
@fragment
fn fs_ink(in: VertexOutput) -> @location(0) vec4<f32> {
var rgb = clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0));
if (camera.ink_params.x >= 0.5) {
let max_c = max(max(rgb.r, rgb.g), rgb.b);
let min_c = min(min(rgb.r, rgb.g), rgb.b);
let lift = max(1.0 - max_c - min_c, 0.0);
rgb = min(rgb + vec3<f32>(lift), vec3<f32>(1.0));
}
return vec4<f32>(rgb, in.color.a);
}
// Bildschirmfüllendes Dreieck ohne Farbe (setzt den Stencil-Puffer zurück)
@vertex
fn vs_fullscreen(@builtin(vertex_index) index: u32) -> @builtin(position) vec4<f32> {
let x = f32((index << 1u) & 2u) * 2.0 - 1.0;
let y = f32(index & 2u) * 2.0 - 1.0;
return vec4<f32>(x, y, 0.0, 1.0);
}
@fragment
fn fs_none() -> @location(0) vec4<f32> {
return vec4<f32>(0.0);
}
// Eingefügte Bilder: texturierte Vierecke unter der Tinte (Texturen enthalten sRGB-Werte
// und werden unverändert übernommen)
struct ImageVertexInput {
@location(0) position: vec2<f32>,
@location(1) uv: vec2<f32>,
};
struct ImageVertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@group(1) @binding(0)
var image_texture: texture_2d<f32>;
@group(1) @binding(1)
var image_sampler: sampler;
@vertex
fn vs_image(in: ImageVertexInput) -> ImageVertexOutput {
var out: ImageVertexOutput;
let ndc = in.position * camera.transform_scale + camera.transform_offset;
out.clip_position = vec4<f32>(ndc, 0.0, 1.0);
out.uv = in.uv;
return out;
}
@fragment
fn fs_image(in: ImageVertexOutput) -> @location(0) vec4<f32> {
return textureSample(image_texture, image_sampler, in.uv);
}
// Karo, Linien und Punkte: pro Pixel aus der Seitenkoordinate berechnet, damit die Abstände
// in jedem Zoom gleichmäßig bleiben. Linien und Punkte sind mindestens ein Pixel breit und
// kantengeglättet; werden die Abstände sehr klein, blendet das Muster aus statt zu flimmern.
struct PatternInput {
@location(0) position: vec2<f32>,
@location(1) color: vec4<f32>,
// Art (1 Karo, 2 Linien, 3 Punkte), Abstand, Linienstärke, Punktgröße
@location(2) params: vec4<f32>,
// Seitenbreite, Seitenhöhe, Rand oben/unten bei Linien
@location(3) bounds: vec4<f32>,
};
struct PatternOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) page: vec2<f32>,
@location(1) color: vec4<f32>,
@location(2) params: vec4<f32>,
@location(3) bounds: vec4<f32>,
};
@vertex
fn vs_pattern(in: PatternInput) -> PatternOutput {
var out: PatternOutput;
let ndc = in.position * camera.transform_scale + camera.transform_offset;
out.clip_position = vec4<f32>(ndc, 0.0, 1.0);
out.page = in.position;
out.color = in.color;
out.params = in.params;
out.bounds = in.bounds;
return out;
}
// Deckung einer Linie bei `line` für die Koordinate `c` (`upp`: Seiteneinheiten je Pixel).
fn line_coverage(c: f32, line: f32, half_width: f32, upp: f32) -> f32 {
let half_px = max(half_width / upp, 0.5);
let d = abs(c - line) / upp;
return clamp(half_px + 0.5 - d, 0.0, 1.0);
}
@fragment
fn fs_pattern(in: PatternOutput) -> @location(0) vec4<f32> {
let p = in.page;
let kind = in.params.x;
let spacing = in.params.y;
let upp = max(max(fwidth(p.x), fwidth(p.y)), 1e-4);
let size = in.bounds.xy;
// Unter 3 Pixel Abstand ausblenden (ganz herausgezoomt)
let fade = clamp((spacing / upp - 3.0) / 3.0, 0.0, 1.0);
var coverage = 0.0;
if (kind < 1.5) {
// Karo: senkrechte und waagerechte Linien bei k · Abstand, nicht am Blattrand
let k = round(p / spacing);
let line = k * spacing;
if (k.x >= 1.0 && line.x < size.x - 0.01) {
coverage = max(coverage, line_coverage(p.x, line.x, in.params.z * 0.5, upp));
}
if (k.y >= 1.0 && line.y < size.y - 0.01) {
coverage = max(coverage, line_coverage(p.y, line.y, in.params.z * 0.5, upp));
}
} else if (kind < 2.5) {
// Linien: waagerecht ab dem oberen Rand bis zum unteren Rand
let margin = in.bounds.z;
let k = round((p.y - margin) / spacing);
let line = margin + k * spacing;
if (k >= 0.0 && line <= size.y - margin + 0.01) {
coverage = line_coverage(p.y, line, in.params.z * 0.5, upp);
}
} else {
// Punkte: an den Kreuzungen des Karos
let k = round(p / spacing);
let point = k * spacing;
if (all(k >= vec2<f32>(1.0)) && point.x < size.x - 0.01 && point.y < size.y - 0.01) {
let radius_px = max(in.params.w * 0.5 / upp, 0.6);
let d = length(p - point) / upp;
coverage = clamp(radius_px + 0.5 - d, 0.0, 1.0);
}
}
let rgb = linear_to_srgb(clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0)));
return vec4<f32>(rgb, in.color.a * coverage * fade);
}
+82 -8
View File
@@ -17,6 +17,10 @@ pub const RDP_TOLERANCE_UNITS: f32 = 0.75;
/// Schwellenwert für Ecken-Erkennung (Richtungsänderung > 65 Grad).
pub const CORNER_ANGLE_THRESHOLD_DEG: f32 = 65.0;
/// Ecken nur zwischen Abschnitten, die nach der Ausdünnung mindestens so lang sind (0,6 mm).
/// Kleine Schleifen („o“, „a“, „e“) bestehen aus kurzen Abschnitten mit großem Knickwinkel;
/// würden sie als Ecken getrennt, würden sie zu spitzen Zacken statt Rundungen.
pub const CORNER_MIN_SEGMENT_UNITS: f32 = 6.0;
/// GPU-Vertex für Triangle-Strips (Memory-Layout kompatibel mit GPU-Buffern).
#[repr(C)]
@@ -105,6 +109,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);
@@ -258,7 +274,7 @@ pub fn generate_triangle_strip(
// Einzelner Punkt (Stift-Tippen / Dot): Erzeuge kreisförmiges Pad
let pt = control_points[0];
let radius = calculate_radius(pt.pressure, config.min_radius, config.max_radius);
let segments = 8;
let segments = 20;
let mut vertices = Vec::with_capacity((segments + 1) * 2);
for i in 0..=segments {
@@ -345,6 +361,34 @@ pub fn smooth_pressure(samples: &[StylusSample]) -> Vec<StylusSample> {
smoothed
}
/// Glättet die Positionen mit dem Fenster [0.25, 0.5, 0.25] gegen Digitizer-Rauschen.
/// Symmetrisch (ohne Verzögerung), daher bleiben auch kleine Schleifen rund. Anfang, Ende
/// und Punkte mit scharfem Knick (über [`CORNER_ANGLE_THRESHOLD_DEG`]) bleiben unverändert,
/// damit Ecken spitz bleiben.
pub fn smooth_positions(samples: &[StylusSample]) -> Vec<StylusSample> {
if samples.len() < 3 {
return samples.to_vec();
}
let cos_limit = CORNER_ANGLE_THRESHOLD_DEG.to_radians().cos();
let mut smoothed = samples.to_vec();
for i in 1..samples.len() - 1 {
let (a, b, c) = (
samples[i - 1].position,
samples[i].position,
samples[i + 1].position,
);
let (d_in, d_out) = (b - a, c - b);
if d_in.length_squared() > 1e-8 && d_out.length_squared() > 1e-8 {
let cos = d_in.normalize().dot(d_out.normalize());
if cos < cos_limit {
continue;
}
}
smoothed[i].position = 0.25 * a + 0.5 * b + 0.25 * c;
}
smoothed
}
/// Rekursives RDP-Markieren zur Ausdünnung kollinearer Zwischenpunkte.
fn rdp_mark(positions: &[glam::Vec2], start: usize, end: usize, epsilon: f32, keep: &mut [bool]) {
if end <= start + 1 {
@@ -390,7 +434,8 @@ pub fn rdp_filter_samples(samples: &[StylusSample], epsilon: f32) -> Vec<StylusS
.collect()
}
/// Berechnet die Richtungsänderung zwischen benachbarten Segmenten und findet Ecken mit Winkel > `threshold_deg`.
/// Berechnet die Richtungsänderung zwischen benachbarten Segmenten und findet Ecken mit Winkel > `threshold_deg`
/// (nur zwischen Segmenten von mindestens [`CORNER_MIN_SEGMENT_UNITS`] Länge).
pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec<usize> {
if samples.len() < 3 {
return Vec::new();
@@ -403,7 +448,7 @@ pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec<usize
let l_in = v_in.length();
let l_out = v_out.length();
if l_in > 1e-4 && l_out > 1e-4 {
if l_in >= CORNER_MIN_SEGMENT_UNITS && l_out >= CORNER_MIN_SEGMENT_UNITS {
let d_in = v_in / l_in;
let d_out = v_out / l_out;
let dot = d_in.dot(d_out).clamp(-1.0, 1.0);
@@ -554,7 +599,8 @@ pub fn generate_finalized_triangle_strip(
return Vec::new();
}
const CAP_STEPS: usize = 4;
// Runde Kappen fein genug unterteilt, dass sie auch stark vergrößert rund wirken
const CAP_STEPS: usize = 10;
let mut vertices = Vec::new();
// 3. Runde Start-Kappe vor dem ersten Run
@@ -614,7 +660,7 @@ pub fn generate_finalized_triangle_strip(
/// Finalisiert einen Strich nach dem Zwei-Stufen-Modell:
/// 1. Ecken-Erkennung (Corner Preservation bei Richtungsänderung > 65°)
/// 2. Druckglättung über gewichtetes 3-Punkt-Fenster [0.25, 0.5, 0.25]
/// 2. Druck- und Positionsglättung über gewichtetes 3-Punkt-Fenster [0.25, 0.5, 0.25]
/// 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 {
@@ -637,8 +683,8 @@ pub fn finalize_stroke_with_config(
return Stroke::new(stroke_id, Vec::new(), Vec::new(), color);
}
// 1. Druckglättung über 3-Punkt-Fenster [0.25, 0.5, 0.25]
let smoothed = smooth_pressure(raw_samples);
// 1. Druck- und Positionsglättung über 3-Punkt-Fenster [0.25, 0.5, 0.25]
let smoothed = smooth_positions(&smooth_pressure(raw_samples));
// 2. RDP-Dezimierung (ca. 0.075 mm = 0.75 Einheiten)
let decimated = rdp_filter_samples(&smoothed, RDP_TOLERANCE_UNITS);
@@ -653,7 +699,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)]
@@ -851,6 +900,31 @@ mod tests {
assert!((corner_pt.position.y - 0.0).abs() < 1e-3);
}
#[test]
fn test_small_loops_stay_round() {
// Kleines „o“ (Radius 0,8 mm) in 0,1 s mit 200 Hz, mit Vorschub wie im Wort
let (r, period, hz) = (8.0_f32, 0.1_f64, 200.0);
let center = |t: f64| glam::Vec2::new(500.0 + t as f32 * 40.0, 500.0);
let raw: Vec<StylusSample> = (0..=((period * hz) as usize))
.map(|i| {
let t = i as f64 / hz;
let a = std::f32::consts::TAU * (t / period) as f32;
StylusSample {
position: center(t) + glam::Vec2::new(r * a.sin(), -r * a.cos()),
pressure: 0.6,
timestamp: t,
}
})
.collect();
let stroke =
finalize_stroke_with_config(1, &raw, [0.0; 4], &SplineConfig::from_width_units(5.0));
for p in &stroke.points {
let d = (p.position - center(p.timestamp)).length();
assert!((d - r).abs() < 0.6, "Schleife verformt: Abstand {d}");
}
assert!(detect_corners(&stroke.points, CORNER_ANGLE_THRESHOLD_DEG).is_empty());
}
#[test]
fn test_pressure_smoothing() {
let samples = vec![
+1996 -100
View File
File diff suppressed because it is too large Load Diff
+546
View File
@@ -0,0 +1,546 @@
//! Textfelder (Text-Werkzeug): Datenmodell, Zeilenumbruch und Rasterung.
//!
//! Ein Textfeld ist ein Objekt mit vier Ecken wie ein Bild (siehe `Stroke::text`). Der Text
//! wird in einem eigenen Koordinatensystem gesetzt – Breite = Umbruchbreite, Höhe = Zeilen ×
//! Zeilenhöhe, in Seiteneinheiten – und dieses Rechteck auf die Ecken abgebildet. So wirken
//! Verschieben, Drehen und Skalieren der Auswahl ohne Sonderfälle auf Text.
//!
//! Bildschirm (Textur), PDF-Export (eingebettete Schrift, durchsuchbar) und Bearbeitung (egui)
//! verwenden dieselben Schriften aus egui und denselben Zeilenumbruch.
use std::sync::OnceLock;
use ab_glyph::{Font, FontRef, GlyphId, PxScale};
use serde::{Deserialize, Serialize};
use crate::assets::RgbaImage;
use crate::document::{Stroke, UNITS_PER_MM};
/// Schriftart eines Textfelds.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum TextFont {
/// Proportionalschrift (Ubuntu Light) für Überschriften und Notizen
#[default]
Sans,
/// Festbreitenschrift (Hack) für Code
Mono,
}
impl TextFont {
pub fn label(self) -> &'static str {
match self {
TextFont::Sans => "Normal",
TextFont::Mono => "Code",
}
}
/// Name der Schrift in den egui-Standardschriften.
fn egui_name(self) -> &'static str {
match self {
TextFont::Sans => "Ubuntu-Light",
TextFont::Mono => "Hack",
}
}
/// egui-Schriftfamilie, deren erste Schrift diese ist (für die Bearbeitung).
pub fn egui_family(self) -> egui::FontFamily {
match self {
TextFont::Sans => egui::FontFamily::Proportional,
TextFont::Mono => egui::FontFamily::Monospace,
}
}
/// TrueType-Daten der Schrift (aus den in egui eingebauten Schriften).
pub fn bytes(self) -> &'static [u8] {
static DEFINITIONS: OnceLock<egui::FontDefinitions> = OnceLock::new();
let definitions = DEFINITIONS.get_or_init(egui::FontDefinitions::default);
&definitions.font_data[self.egui_name()].font
}
pub fn face(self) -> &'static FontRef<'static> {
static SANS: OnceLock<FontRef<'static>> = OnceLock::new();
static MONO: OnceLock<FontRef<'static>> = OnceLock::new();
let cell = match self {
TextFont::Sans => &SANS,
TextFont::Mono => &MONO,
};
cell.get_or_init(|| {
FontRef::try_from_slice(self.bytes()).expect("eingebaute egui-Schrift ist gültig")
})
}
fn units_per_em(self) -> f32 {
self.face().units_per_em().unwrap_or(1000.0)
}
/// Höhe von Ober- bis Unterlänge je Em: ab_glyph skaliert auf diese Höhe, PDF, egui und
/// dieses Modul messen die Schriftgröße als Em.
pub fn height_per_em(self) -> f32 {
let face = self.face();
(face.ascent_unscaled() - face.descent_unscaled()) / self.units_per_em()
}
}
/// Seiteneinheiten je typografischem Punkt.
pub const UNITS_PER_PT: f32 = 25.4 / 72.0 * UNITS_PER_MM;
/// Wählbare Schriftgrößen in Punkt.
pub const TEXT_SIZES_PT: [f32; 7] = [8.0, 10.0, 12.0, 14.0, 18.0, 24.0, 36.0];
pub const DEFAULT_TEXT_SIZE_PT: f32 = 12.0;
/// Kleinste Umbruchbreite eines neuen Textfelds (3 cm).
pub const MIN_WRAP_WIDTH: f32 = 300.0;
/// Abstand neuer Textfelder zum rechten Blattrand (1 cm).
pub const RIGHT_MARGIN: f32 = 100.0;
/// Ein Tabulator wird durch so viele Leerzeichen ersetzt.
const TAB_WIDTH: usize = 4;
/// Inhalt und Satz eines Textfelds.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TextBox {
pub content: String,
/// Schriftgröße (Em) in Seiteneinheiten
pub size: f32,
#[serde(default)]
pub font: TextFont,
/// Umbruchbreite in Seiteneinheiten (Breite des ungedrehten, unskalierten Felds)
pub wrap_width: f32,
}
/// Eine gesetzte Zeile: Zeichen (ohne Zeichen, die die Schrift nicht kennt) und ihre
/// Glyphen mit x-Position.
#[derive(Debug, Clone, PartialEq)]
pub struct LaidLine {
pub text: String,
pub glyphs: Vec<(GlyphId, f32)>,
pub width: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextLayout {
pub lines: Vec<LaidLine>,
/// Oberlänge (Abstand Zeilenoberkante → Grundlinie)
pub ascent: f32,
pub line_height: f32,
}
impl TextLayout {
/// Höhe des Felds; ein leeres Feld ist eine Zeile hoch.
pub fn height(&self) -> f32 {
self.lines.len().max(1) as f32 * self.line_height
}
/// Grundlinie der Zeile `i` (von der Oberkante des Felds).
pub fn baseline(&self, i: usize) -> f32 {
i as f32 * self.line_height + self.ascent
}
}
impl TextBox {
pub fn new(content: String, size: f32, font: TextFont, wrap_width: f32) -> Self {
Self {
content,
size,
font,
wrap_width,
}
}
/// Größe des ungedrehten, unskalierten Felds in Seiteneinheiten.
pub fn box_size(&self) -> glam::Vec2 {
glam::Vec2::new(self.wrap_width.max(self.size), self.layout().height())
}
/// Setzt den Text mit Zeilenumbruch an Leerzeichen; zu lange Wörter werden zeichenweise
/// umbrochen, Zeilenumbrüche im Text bleiben erhalten.
pub fn layout(&self) -> TextLayout {
let face = self.font.face();
let em = self.size / self.font.units_per_em();
let line_height =
(face.ascent_unscaled() - face.descent_unscaled() + face.line_gap_unscaled()) * em;
let width = self.wrap_width.max(self.size);
let mut lines = Vec::new();
for paragraph in self.content.split('\n') {
let mut chars: Vec<(char, GlyphId, f32)> = paragraph
.chars()
.flat_map(|c| {
let (c, n) = if c == '\t' { (' ', TAB_WIDTH) } else { (c, 1) };
std::iter::repeat_n(c, n)
})
.filter_map(|c| {
let id = face.glyph_id(c);
(id.0 != 0).then(|| (c, id, face.h_advance_unscaled(id) * em))
})
.collect();
// Unterschneidung (wie egui) dem vorderen Zeichen des Paars zurechnen
for i in 1..chars.len() {
chars[i - 1].2 += face.kern_unscaled(chars[i - 1].1, chars[i].1) * em;
}
let mut start = 0;
let mut x = 0.0;
// Letztes Leerzeichen der Zeile (möglicher Umbruch)
let mut last_space: Option<usize> = None;
for i in 0..chars.len() {
let (c, _, advance) = chars[i];
if c != ' ' && x + advance > width && i > start {
let end = match last_space {
Some(space) if space > start => space,
_ => i,
};
lines.push(laid_line(&chars[start..end]));
start = end;
while start < i && chars[start].0 == ' ' {
start += 1;
}
x = chars[start..i].iter().map(|g| g.2).sum();
last_space = None;
}
x += advance;
if c == ' ' {
last_space = Some(i);
}
}
lines.push(laid_line(&chars[start..]));
}
// Leere Zeilen am Ende zählen mit (Platz für den Cursor), ein leerer Text ist eine Zeile
TextLayout {
lines,
ascent: face.ascent_unscaled() * em,
line_height,
}
}
}
fn laid_line(chars: &[(char, GlyphId, f32)]) -> LaidLine {
let mut x = 0.0;
let mut glyphs = Vec::with_capacity(chars.len());
let mut width: f32 = 0.0;
for &(c, id, advance) in chars {
glyphs.push((id, x));
x += advance;
if c != ' ' {
width = x;
}
}
LaidLine {
text: chars.iter().map(|g| g.0).collect(),
glyphs,
width,
}
}
/// Ecken eines Felds der Größe `size` mit Ursprung (oben links) `origin` und den Achsen
/// `axes` (Seitenvektor je Einheit nach rechts bzw. unten): oben links, oben rechts,
/// unten rechts, unten links.
pub fn frame_corners(
origin: glam::Vec2,
axes: [glam::Vec2; 2],
size: glam::Vec2,
) -> [glam::Vec2; 4] {
let right = axes[0] * size.x;
let down = axes[1] * size.y;
[origin, origin + right, origin + right + down, origin + down]
}
/// Ursprung und Achsen (je Einheit) eines Felds der Größe `size` mit den Ecken `corners`.
pub fn frame_axes(corners: &[glam::Vec2; 4], size: glam::Vec2) -> (glam::Vec2, [glam::Vec2; 2]) {
let [tl, tr, _, bl] = *corners;
(
tl,
[(tr - tl) / size.x.max(1e-3), (bl - tl) / size.y.max(1e-3)],
)
}
/// Laufende Bearbeitung eines Textfelds (neu oder vorhanden).
#[derive(Debug, Clone, PartialEq)]
pub struct TextEditing {
/// Seite, auf der das Feld liegt
pub page_id: u64,
/// Bearbeitetes vorhandenes Feld im Zustand vor der Bearbeitung; `None` bei einem neuen
pub original: Option<Stroke>,
/// Ecke oben links und Achsen (Seitenvektor je Einheit nach rechts bzw. unten)
pub origin: glam::Vec2,
pub axes: [glam::Vec2; 2],
pub text: TextBox,
pub color: [f32; 4],
/// Beim nächsten Bild den Tastaturfokus auf das Feld setzen
pub focus: bool,
/// Bearbeitung beenden (Esc)
pub finish: bool,
}
impl TextEditing {
/// Neues, leeres Feld: die erste Zeile mittig auf `pos`, umbrochen 1 cm vor dem rechten
/// Rand eines Blatts der Breite `page_width` (mindestens 3 cm breit).
pub fn new_at(
page_id: u64,
pos: glam::Vec2,
page_width: f32,
size: f32,
font: TextFont,
color: [f32; 4],
) -> Self {
let right = page_width - RIGHT_MARGIN;
let x = pos.x.min(right - MIN_WRAP_WIDTH).max(0.0);
let text = TextBox::new(String::new(), size, font, (right - x).max(MIN_WRAP_WIDTH));
let line_height = text.layout().line_height;
Self {
page_id,
original: None,
origin: glam::Vec2::new(x, (pos.y - line_height / 2.0).max(0.0)),
axes: [glam::Vec2::X, glam::Vec2::Y],
text,
color,
focus: true,
finish: false,
}
}
/// Bearbeitung des vorhandenen Textfelds `stroke`.
pub fn of_stroke(page_id: u64, stroke: &Stroke) -> Option<Self> {
let text = stroke.text.clone()?;
let corners = stroke.object_corners()?;
let (origin, axes) = frame_axes(&corners, text.box_size());
Some(Self {
page_id,
original: Some(stroke.clone()),
origin,
axes,
text,
color: stroke.color,
focus: true,
finish: false,
})
}
/// Wurde gegenüber dem Ausgangszustand etwas geändert?
pub fn is_changed(&self) -> bool {
match &self.original {
Some(original) => {
original.text.as_ref() != Some(&self.text) || original.color != self.color
}
None => !self.is_empty(),
}
}
/// Enthält das Feld nur Leerraum?
pub fn is_empty(&self) -> bool {
self.text.content.trim().is_empty()
}
/// Ergebnis als Textfeld mit der ID `id`; die Höhe passt sich dem Text an, Lage,
/// Drehung und Skalierung bleiben. `None` für ein leeres Feld.
pub fn to_stroke(&self, id: u64) -> Option<Stroke> {
if self.is_empty() {
return None;
}
let corners = frame_corners(self.origin, self.axes, self.text.box_size());
Some(Stroke::new_text(id, self.text.clone(), corners, self.color))
}
}
/// Exponent für die Kantendeckung der Glyphen (wie egui, damit Bearbeitung und Anzeige
/// gleich kräftig wirken).
const COVERAGE_GAMMA: f32 = 0.55;
/// Größte Kantenlänge einer Texttextur in Pixeln.
const MAX_TEXTURE_SIDE: f32 = 4096.0;
/// Größte Pixelzahl einer Texttextur (64 MB RGBA).
const MAX_TEXTURE_PIXELS: f32 = 16.0 * 1024.0 * 1024.0;
/// Rastert ein Textfeld mit `px_per_unit` Pixeln je Seiteneinheit (begrenzt auf eine
/// handhabbare Texturgröße). `color` ist sRGB wie die Tinte, ebenso die Pixel.
pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage {
let layout = text.layout();
let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height());
let scale = px_per_unit
.min(MAX_TEXTURE_SIDE / size.x)
.min(MAX_TEXTURE_SIDE / size.y)
.min((MAX_TEXTURE_PIXELS / (size.x * size.y)).sqrt())
.max(1e-3);
let width = ((size.x * scale).ceil() as u32).max(1);
let height = ((size.y * scale).ceil() as u32).max(1);
let face = text.font.face();
// ab_glyph misst die Skalierung als Höhe Ober- bis Unterlänge
let px_scale = PxScale::from(text.size * scale * text.font.height_per_em());
let mut coverage = vec![0.0_f32; (width * height) as usize];
for (i, line) in layout.lines.iter().enumerate() {
let baseline = layout.baseline(i) * scale;
for &(id, x) in &line.glyphs {
let glyph = id.with_scale_and_position(px_scale, ab_glyph::point(x * scale, baseline));
let Some(outlined) = face.outline_glyph(glyph) else {
continue;
};
let bounds = outlined.px_bounds();
outlined.draw(|gx, gy, c| {
let px = bounds.min.x as i64 + gx as i64;
let py = bounds.min.y as i64 + gy as i64;
if px >= 0 && py >= 0 && (px as u32) < width && (py as u32) < height {
let cell = &mut coverage[(py as u32 * width + px as u32) as usize];
*cell = (*cell + c).min(1.0);
}
});
}
}
let rgb = [0, 1, 2].map(|i| (color[i].clamp(0.0, 1.0) * 255.0).round() as u8);
let alpha = color[3].clamp(0.0, 1.0);
let mut pixels = Vec::with_capacity(coverage.len() * 4);
for c in coverage {
pixels.extend_from_slice(&rgb);
// Wie egui: Deckung anheben, sonst wirkt die dünne Schrift verkleinert blass
pixels.push((c.powf(COVERAGE_GAMMA) * alpha * 255.0).round() as u8);
}
RgbaImage {
width,
height,
pixels,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::PAGE_WIDTH;
fn sample(content: &str, wrap_width: f32) -> TextBox {
TextBox::new(content.to_string(), 50.0, TextFont::Sans, wrap_width)
}
#[test]
fn test_fonts_load() {
for font in [TextFont::Sans, TextFont::Mono] {
assert!(font.face().glyph_id('ä').0 != 0);
let factor = font.height_per_em();
assert!((1.0..1.4).contains(&factor), "{factor}");
}
}
#[test]
fn test_layout_wraps_at_spaces() {
let text = sample("eins zwei drei vier fünf sechs", 400.0);
let layout = text.layout();
assert!(layout.lines.len() > 1);
for line in &layout.lines {
assert!(line.width <= 400.0 + 1e-3, "{line:?}");
assert!(!line.text.starts_with(' ') && !line.text.ends_with(' '));
}
let words: Vec<String> = layout.lines.iter().map(|l| l.text.clone()).collect();
assert_eq!(words.join(" "), "eins zwei drei vier fünf sechs");
}
#[test]
fn test_layout_breaks_long_words_and_keeps_newlines() {
let text = sample("Donaudampfschifffahrtsgesellschaft\n\nende", 300.0);
let layout = text.layout();
assert!(layout.lines.iter().all(|l| l.width <= 300.0 + 1e-3));
let joined: String = layout.lines.iter().map(|l| l.text.as_str()).collect();
assert_eq!(joined, "Donaudampfschifffahrtsgesellschaftende");
// Leere Zeile bleibt erhalten
assert!(layout.lines.iter().any(|l| l.text.is_empty()));
assert_eq!(layout.lines.last().unwrap().text, "ende");
}
#[test]
fn test_empty_text_is_one_line_high() {
let layout = sample("", 500.0).layout();
assert!((layout.height() - layout.line_height).abs() < 1e-3);
assert!(layout.line_height > 50.0);
}
#[test]
fn test_tabs_become_spaces_and_indent_is_kept() {
let text = TextBox::new("\tx".into(), 50.0, TextFont::Mono, 2000.0);
let line = &text.layout().lines[0];
assert_eq!(line.text, " x");
// Festbreitenschrift: x steht vier Zeichenbreiten weiter
let advance = line.glyphs[1].1;
assert!((line.glyphs[4].1 - 4.0 * advance).abs() < 1e-3);
}
#[test]
fn test_frame_round_trip() {
let size = glam::Vec2::new(400.0, 120.0);
let axes = [glam::Vec2::new(0.0, 2.0), glam::Vec2::new(-2.0, 0.0)];
let corners = frame_corners(glam::Vec2::new(100.0, 50.0), axes, size);
let (origin, back) = frame_axes(&corners, size);
assert_eq!(origin, glam::Vec2::new(100.0, 50.0));
assert!((back[0] - axes[0]).length() < 1e-4 && (back[1] - axes[1]).length() < 1e-4);
}
#[test]
fn test_rasterize_draws_ink_in_color() {
let text = sample("Hallo", 400.0);
let image = rasterize(&text, [0.85, 0.2, 0.2, 1.0], 1.0);
assert_eq!(image.width, 400);
assert_eq!(image.height as f32, text.layout().height().ceil());
let opaque = image.pixels.chunks(4).filter(|p| p[3] > 200).count();
assert!(opaque > 100, "{opaque}");
// Die Farbe ist schon sRGB und wird unverändert übernommen
assert!(image.pixels.chunks(4).all(|p| p[..3] == [217, 51, 51]));
}
#[test]
fn test_editing_new_text_box() {
let edit = TextEditing::new_at(
1,
glam::Vec2::new(500.0, 800.0),
PAGE_WIDTH,
50.0,
TextFont::Sans,
[0.0; 4],
);
assert!((edit.text.wrap_width - (PAGE_WIDTH - RIGHT_MARGIN - 500.0)).abs() < 1e-3);
assert!(edit.origin.y < 800.0);
assert!(!edit.is_changed());
assert!(edit.to_stroke(9).is_none(), "leeres Feld wird verworfen");
// Ganz rechts: Feld wird nach links versetzt, damit es mindestens 3 cm breit ist
let edge = TextEditing::new_at(
1,
glam::Vec2::new(2050.0, 100.0),
PAGE_WIDTH,
50.0,
TextFont::Sans,
[0.0; 4],
);
assert!((edge.text.wrap_width - MIN_WRAP_WIDTH).abs() < 1e-3);
assert!(edge.origin.x + MIN_WRAP_WIDTH <= PAGE_WIDTH);
}
#[test]
fn test_editing_keeps_transform_and_grows_with_text() {
let mut edit = TextEditing::new_at(
1,
glam::Vec2::new(400.0, 400.0),
PAGE_WIDTH,
50.0,
TextFont::Sans,
[0.0; 4],
);
edit.text.content = "eine Zeile".into();
let mut stroke = edit.to_stroke(5).unwrap();
assert!(stroke.is_text() && stroke.is_object());
stroke.rotate(glam::Vec2::new(400.0, 400.0), 0.5);
stroke.scale(glam::Vec2::new(400.0, 400.0), glam::Vec2::new(2.0, 2.0));
let before = stroke.object_corners().unwrap();
let mut edit = TextEditing::of_stroke(1, &stroke).unwrap();
assert!(!edit.is_changed());
edit.text.content = "eine Zeile\nzwei Zeilen".into();
assert!(edit.is_changed());
let after = edit.to_stroke(5).unwrap().object_corners().unwrap();
// Obere Kante bleibt, die Seitenkanten werden in ihrer Richtung doppelt so lang
assert!((after[0] - before[0]).length() < 1e-2 && (after[1] - before[1]).length() < 1e-2);
let (old_side, new_side) = (before[3] - before[0], after[3] - after[0]);
assert!((new_side.length() / old_side.length() - 2.0).abs() < 1e-3);
assert!(old_side.normalize().dot(new_side.normalize()) > 0.9999);
}
#[test]
fn test_rasterize_limits_texture_size() {
let text = sample("x", 2000.0);
let image = rasterize(&text, [0.0, 0.0, 0.0, 1.0], 100.0);
assert!(image.width as f32 <= MAX_TEXTURE_SIDE);
}
}
+4093 -1008
View File
File diff suppressed because it is too large Load Diff
+195 -59
View File
@@ -7,7 +7,7 @@
//! - Sichere Asset-Namensbereinigung (Path Traversal Schutz)
//! - Isolierter Benutzer-Cache-Pfad (`$XDG_CACHE_HOME/ink-notes/updates` mit 0700 Rechten)
//! - Integritätsprüfung via SHA-256 (`SHA256SUMS.txt`) mit automatischem Löschen ungültiger Dateien
//! - Erweiterbare Schnittstelle für optionale Signaturprüfung (.sig / .minisig)
//! - Pflicht-Signaturprüfung der `SHA256SUMS.txt` via Minisign (eingebauter öffentlicher Schlüssel)
//! - Atomares AppImage-Self-Update via `.new`-Zwischendatei, Backup-Pflicht und atomarem `fs::rename`
//! - Echtes Fortschritts-Streaming (~10 Updates/s)
//! - Korrekte SemVer-Prüfung via `semver` Crate (Prerelease-Schutz standardmäßig aktiv)
@@ -313,22 +313,78 @@ pub fn verify_checksum(
Ok(())
}
/// Status einer Signaturverifikation (.sig / .minisig).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SignatureVerificationStatus {
NotPresent,
Verified { signer: String },
Failed(String),
/// Öffentlicher Minisign-Schlüssel (Base64), mit dem die `SHA256SUMS.txt` jedes Releases
/// signiert sein muss. Ist er leer, lehnt diese Version alle In-App-Updates ab.
/// Erzeugen: `minisign -G -W -p ink-notes.pub -s ink-notes.key`, dann die zweite Zeile
/// aus `ink-notes.pub` hier eintragen (siehe README, Abschnitt Release-Signatur).
pub const UPDATE_SIGNING_PUBLIC_KEY: &str =
"RWTZqQ0ZDCn+PHTpqgI8NwjtgtkJk5QyJ0FYHWZlCZtABsXBcdPDv9WI";
/// Name des Prüfsummen-Assets eines Releases.
pub const CHECKSUMS_ASSET_NAME: &str = "SHA256SUMS.txt";
/// Name der Minisign-Signatur zu [`CHECKSUMS_ASSET_NAME`].
pub const CHECKSUMS_SIGNATURE_ASSET_NAME: &str = "SHA256SUMS.txt.minisig";
/// Prüft die Minisign-Signatur der Prüfsummendatei. Nur aktuelle (prehashed) Signaturen
/// werden akzeptiert; ohne hinterlegten Schlüssel schlägt die Prüfung immer fehl.
pub fn verify_checksums_signature(
checksums: &[u8],
signature_text: &str,
public_key_b64: &str,
) -> Result<(), String> {
let key = public_key_b64.trim();
if key.is_empty() {
return Err("In dieser Version ist kein Signaturschlüssel für Updates hinterlegt. Bitte das Update manuell installieren.".to_string());
}
let public_key = minisign_verify::PublicKey::from_base64(key)
.map_err(|e| format!("Ungültiger Update-Signaturschlüssel: {e}."))?;
let signature = minisign_verify::Signature::decode(signature_text)
.map_err(|e| format!("{CHECKSUMS_SIGNATURE_ASSET_NAME} ist ungültig: {e}."))?;
public_key
.verify(checksums, &signature, false)
.map_err(|e| format!("Signaturprüfung von {CHECKSUMS_ASSET_NAME} fehlgeschlagen: {e}."))
}
/// Erweiterbare Schnittstelle für kryptografische Signaturprüfung.
pub fn verify_signature_if_present(
_asset_file: &Path,
_sig_bytes: Option<&[u8]>,
) -> Result<SignatureVerificationStatus, String> {
// Erweiterungspunkt: Sobald ein Signaturschlüssel / Minisign-Public-Key
// im Projekt konfiguriert ist, kann hier die kryptografische Verifikation erfolgen.
Ok(SignatureVerificationStatus::NotPresent)
/// Lädt einen Text-Asset des Releases über den abgesicherten HTTP-Client.
fn fetch_release_text(
agent: &ureq::Agent,
release: &GiteaRelease,
name: &str,
config: &UpdaterConfig,
) -> Result<String, String> {
let asset = release
.assets
.iter()
.find(|a| a.name == name)
.ok_or_else(|| format!("Release enthält keine {name}."))?;
http_get_with_auth_guard(
agent,
&asset.browser_download_url,
config.auth_token.as_deref(),
5,
config.allow_insecure_http,
)
.map_err(|e| format!("Konnte {name} nicht laden: {e}."))?
.into_string()
.map_err(|e| format!("Konnte {name} nicht dekodieren: {e}."))
}
/// Lädt `SHA256SUMS.txt` samt Minisign-Signatur und gibt den Inhalt nur bei gültiger
/// Signatur zurück.
fn fetch_verified_checksums(
agent: &ureq::Agent,
release: &GiteaRelease,
config: &UpdaterConfig,
public_key_b64: &str,
) -> Result<String, String> {
// Ohne Schlüssel gar nicht erst Netzwerkanfragen stellen
if public_key_b64.trim().is_empty() {
return verify_checksums_signature(b"", "", public_key_b64).map(|()| String::new());
}
let checksums = fetch_release_text(agent, release, CHECKSUMS_ASSET_NAME, config)?;
let signature = fetch_release_text(agent, release, CHECKSUMS_SIGNATURE_ASSET_NAME, config)?;
verify_checksums_signature(checksums.as_bytes(), &signature, public_key_b64)?;
Ok(checksums)
}
/// Event-Typen für den asynchronen Worker.
@@ -368,6 +424,17 @@ pub enum UpdaterStatus {
Error(String),
}
/// Lehnt jede URL ohne HTTPS ab, sofern unsicheres HTTP nicht ausdrücklich erlaubt ist.
fn ensure_secure_scheme(url: &url::Url, allow_insecure_http: bool) -> Result<(), String> {
match url.scheme() {
"https" => Ok(()),
"http" if allow_insecure_http => Ok(()),
scheme => Err(format!(
"Unsicheres Protokoll '{scheme}' für '{url}' abgewiesen. Es wird zwingend HTTPS verlangt."
)),
}
}
/// Erstellt einen ureq Agent mit sauberen Timeouts.
fn create_http_agent() -> ureq::Agent {
ureq::AgentBuilder::new()
@@ -384,12 +451,15 @@ fn http_get_with_auth_guard(
initial_url_str: &str,
token: Option<&str>,
max_redirects: usize,
allow_insecure_http: bool,
) -> Result<ureq::Response, String> {
let mut current_url = url::Url::parse(initial_url_str)
.map_err(|e| format!("Ungültige URL '{initial_url_str}': {e}"))?;
let initial_origin = current_url.origin().ascii_serialization();
for _ in 0..=max_redirects {
// Jede Anfrage prüfen – auch Download-Links aus der API und Weiterleitungsziele
ensure_secure_scheme(&current_url, allow_insecure_http)?;
let mut req = agent.get(current_url.as_str());
// Token NUR anhängen, wenn Origin (Scheme, Host, Port) identisch ist
@@ -500,7 +570,13 @@ impl UpdaterController {
);
let agent = create_http_agent();
match http_get_with_auth_guard(&agent, &api_url, config.auth_token.as_deref(), 5) {
match http_get_with_auth_guard(
&agent,
&api_url,
config.auth_token.as_deref(),
5,
config.allow_insecure_http,
) {
Ok(resp) => match resp.into_json::<GiteaRelease>() {
Ok(release) => {
if release.prerelease && !config.include_prereleases {
@@ -563,12 +639,37 @@ impl UpdaterController {
let target_file = cache_dir.join(&safe_asset_name);
let agent = create_http_agent();
// 1. Download des Release-Assets mit Fortschritts-Streaming
// 1. Signierte Prüfsummen laden und verifizieren, BEVOR das Paket geladen wird
let checksums_content = match active_release.as_ref() {
Some(release) => match fetch_verified_checksums(
&agent,
release,
&config,
UPDATE_SIGNING_PUBLIC_KEY,
) {
Ok(text) => text,
Err(e) => {
let _ = tx.send(UpdaterEvent::DownloadError(format!(
"{e} Download abgelehnt."
)));
return;
}
},
None => {
let _ = tx.send(UpdaterEvent::DownloadError(
"Kein Release ausgewählt. Download abgelehnt.".to_string(),
));
return;
}
};
// 2. Download des Release-Assets mit Fortschritts-Streaming
let resp = match http_get_with_auth_guard(
&agent,
&asset.browser_download_url,
config.auth_token.as_deref(),
5,
config.allow_insecure_http,
) {
Ok(r) => r,
Err(e) => {
@@ -646,53 +747,13 @@ impl UpdaterController {
drop(file);
// 2. Integritätsprüfung gegen SHA256SUMS.txt
let checksum_asset = active_release
.as_ref()
.and_then(|r| r.assets.iter().find(|a| a.name == "SHA256SUMS.txt"));
let checksums_content = match checksum_asset {
Some(ca) => {
match http_get_with_auth_guard(
&agent,
&ca.browser_download_url,
config.auth_token.as_deref(),
5,
) {
Ok(c_resp) => match c_resp.into_string() {
Ok(text) => text,
Err(e) => {
let _ = std::fs::remove_file(&target_file);
let _ = tx.send(UpdaterEvent::DownloadError(format!(
"Konnte SHA256SUMS.txt nicht dekodieren: {e}. Datei gelöscht."
)));
return;
}
},
Err(e) => {
let _ = std::fs::remove_file(&target_file);
let _ = tx.send(UpdaterEvent::DownloadError(format!(
"Konnte SHA256SUMS.txt nicht laden: {e}. Datei aus Sicherheitsgründen gelöscht."
)));
return;
}
}
}
None => {
let _ = std::fs::remove_file(&target_file);
let _ = tx.send(UpdaterEvent::DownloadError(
"Release enthält keine SHA256SUMS.txt-Prüfsummen. Download abgelehnt und gelöscht.".to_string(),
));
return;
}
};
// 3. Integritätsprüfung des Pakets gegen die signierte SHA256SUMS.txt
if let Err(err) = verify_checksum(&target_file, &safe_asset_name, &checksums_content) {
let _ = tx.send(UpdaterEvent::DownloadError(err));
return;
}
// 3. Wenn es ein AppImage ist, Executable-Bit auf 0755 setzen
// 4. Wenn es ein AppImage ist, Executable-Bit auf 0755 setzen
if safe_asset_name.ends_with(".AppImage") {
#[cfg(unix)]
{
@@ -996,6 +1057,81 @@ dcdeb7ac87d9e6cb8022e5a71bc7f2c56d779b4fce4efdd5bae5be3b0214884a ink-notes_0.1.
assert!(allowed_insecure.validate_url().is_ok());
}
/// Nur für Tests erzeugtes Minisign-Schlüsselpaar (der geheime Schlüssel wurde verworfen).
const TEST_PUBLIC_KEY: &str = "RWQr5S8CVgsjS4UA3uY0xqTDua4uRSSzKWpFfJKLCypjgP3NGt9Tmdbh";
const TEST_CHECKSUMS: &str = "abc123 ink-notes_9.9.9_amd64.deb\n";
const TEST_SIGNATURE: &str = "untrusted comment: signature from rsign secret key
RUQr5S8CVgsjS5BYMUx1ICe1E356aP7wesQXw+oKF6Rd8WsvdF1dmCb1ffonXgJ2GhWE5iAWPXQaT8Y/Y4zLIpyS1PA8JCe8eAM=
trusted comment: ink-notes test
iw0NOedyAQg3emO36fHjyfn+AbnI7Idyl+NwFTDJX1YceLM1jSfjIfeIc0D49IQXyGTpM/QnkZJkRO/eI8ZKBQ==
";
#[test]
fn test_checksums_signature_verification() {
// Gültige Signatur
assert!(verify_checksums_signature(
TEST_CHECKSUMS.as_bytes(),
TEST_SIGNATURE,
TEST_PUBLIC_KEY
)
.is_ok());
// Manipulierte Prüfsummen (z. B. ausgetauschter Hash)
let tampered = TEST_CHECKSUMS.replace("abc123", "abc124");
assert!(
verify_checksums_signature(tampered.as_bytes(), TEST_SIGNATURE, TEST_PUBLIC_KEY)
.is_err()
);
// Fremder Schlüssel
let other_key = "RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3";
assert!(
verify_checksums_signature(TEST_CHECKSUMS.as_bytes(), TEST_SIGNATURE, other_key)
.is_err()
);
// Kein Schlüssel hinterlegt -> strikt ablehnen
let err =
verify_checksums_signature(TEST_CHECKSUMS.as_bytes(), TEST_SIGNATURE, "").unwrap_err();
assert!(err.contains("kein Signaturschlüssel"), "{err}");
// Fehlende / kaputte Signatur
assert!(
verify_checksums_signature(TEST_CHECKSUMS.as_bytes(), "", TEST_PUBLIC_KEY).is_err()
);
// Altes (nicht prehashed) Signaturformat wird nicht akzeptiert
let legacy_sig = "untrusted comment: signature from minisign secret key
RWQf6LRCGA9i59SLOFxz6NxvASXDJeRtuZykwQepbDEGt87ig1BNpWaVWuNrm73YiIiJbq71Wi+dP9eKL8OC351vwIasSSbXxwA=
trusted comment: timestamp:1555779966\tfile:test
QtKMXWyYcwdpZAlPF7tE2ENJkRd1ujvKjlj1m9RtHTBnZPa5WKU5uWRs5GoP5M/VqE81QFuMKI5k/SfNQUaOAA==";
assert!(verify_checksums_signature(b"test", legacy_sig, other_key).is_err());
}
#[test]
fn test_https_enforced_for_every_request() {
let https = url::Url::parse("https://gitea.pansi.eu/a.deb").unwrap();
let http = url::Url::parse("http://cdn.example.com/a.deb").unwrap();
let ftp = url::Url::parse("ftp://example.com/a.deb").unwrap();
assert!(ensure_secure_scheme(&https, false).is_ok());
assert!(ensure_secure_scheme(&http, false).is_err());
assert!(ensure_secure_scheme(&http, true).is_ok());
assert!(ensure_secure_scheme(&ftp, true).is_err());
// Ein http-Download-Link aus der Release-API wird abgewiesen, bevor eine
// Verbindung aufgebaut wird
let agent = create_http_agent();
let err = http_get_with_auth_guard(
&agent,
"http://127.0.0.1:9/ink-notes.AppImage",
Some("geheim"),
5,
false,
)
.unwrap_err();
assert!(err.contains("HTTPS"), "{err}");
}
#[test]
fn test_appimage_atomic_self_update() {
let temp_dir = tempfile::tempdir().unwrap();
+232 -9
View File
@@ -3,12 +3,97 @@
//! 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 crate::document::{PageViewport, PAGE_HEIGHT, PAGE_WIDTH};
use crate::document::PageViewport;
use glam::{Mat4, Vec2, Vec4};
/// Minimaler und maximaler Zoomfaktor.
pub const MIN_ZOOM: f32 = 0.25;
pub const MAX_ZOOM: f32 = 5.0;
pub const MAX_ZOOM: f32 = 10.0;
/// Höchstens so viele Nachbarseiten werden gleichzeitig dargestellt.
pub const MAX_NEIGHBOR_PAGES: usize = 12;
/// Durchgehende Ansicht: Die Seiten hängen lückenlos untereinander (die Grenze markiert eine
/// Linie der Oberfläche) und sind waagerecht zueinander zentriert. Lage jeder Seite (Ecke oben links) in Koordinaten der
/// aktuellen Seite.
#[derive(Debug, Clone, PartialEq)]
pub struct PageStack {
pub origins: Vec<Vec2>,
pub sizes: Vec<Vec2>,
pub current: usize,
}
impl PageStack {
/// Stapel aus den Seitengrößen; `current` ist die Bezugsseite.
pub fn new(sizes: Vec<Vec2>, current: usize) -> Self {
let current = current.min(sizes.len().saturating_sub(1));
let width = sizes.get(current).map_or(0.0, |s| s.x);
let mut origins = vec![Vec2::ZERO; sizes.len()];
let x = |i: usize| (width - sizes[i].x) / 2.0;
for i in current + 1..sizes.len() {
let y = origins[i - 1].y + sizes[i - 1].y;
origins[i] = Vec2::new(x(i), y);
}
for i in (0..current).rev() {
let y = origins[i + 1].y - sizes[i].y;
origins[i] = Vec2::new(x(i), y);
}
Self {
origins,
sizes,
current,
}
}
pub fn len(&self) -> usize {
self.sizes.len()
}
pub fn is_empty(&self) -> bool {
self.sizes.is_empty()
}
/// Seite, auf deren Höhe `y` liegt (an der Grenze die untere); `None` außerhalb.
pub fn page_at(&self, y: f32) -> Option<usize> {
// Seiten liegen nach Index sortiert untereinander
let i = self.origins.partition_point(|o| o.y <= y).checked_sub(1)?;
(y <= self.origins[i].y + self.sizes[i].y).then_some(i)
}
/// Seiten (außer der aktuellen), die zwischen den Höhen `top` und `bottom` sichtbar sind;
/// sehr weit herausgezoomt nur die [`MAX_NEIGHBOR_PAGES`] nächstgelegenen.
pub fn visible_neighbors(&self, top: f32, bottom: f32) -> Vec<usize> {
if !(top.is_finite() && bottom.is_finite()) || bottom < top {
return Vec::new();
}
let first = self
.origins
.iter()
.zip(&self.sizes)
.position(|(o, s)| o.y + s.y >= top);
let Some(first) = first else {
return Vec::new();
};
let mut pages: Vec<usize> = (first..self.len())
.take_while(|&i| self.origins[i].y <= bottom)
.filter(|&i| i != self.current)
.collect();
if pages.len() > MAX_NEIGHBOR_PAGES {
pages.sort_by_key(|&i| i.abs_diff(self.current));
pages.truncate(MAX_NEIGHBOR_PAGES);
pages.sort_unstable();
}
pages
}
/// Kleinstes Rechteck um alle Seiten (min, max).
pub fn bounds(&self) -> (Vec2, Vec2) {
self.origins.iter().zip(&self.sizes).fold(
(Vec2::splat(f32::INFINITY), Vec2::splat(f32::NEG_INFINITY)),
|(min, max), (&o, &s)| (min.min(o), max.max(o + s)),
)
}
}
/// Verwaltet den Viewport-Kamerazustand für stufenlosen Zoom und Pan.
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -39,6 +124,39 @@ impl ViewportState {
self.pan_offset = [0.0, 0.0];
}
/// Durchgehende Ansicht: Die Seite mit der Ecke `origin` (Koordinaten der bisherigen
/// Bezugsseite) wird zur Bezugsseite, ohne dass sich der Bildausschnitt bewegt. Waagerecht
/// zentriert [`PageViewport`] die neue Seite bereits (ungezoomt); der Pan gleicht nur den
/// Zoomanteil aus.
pub fn shift_reference_page(&mut self, origin: Vec2) {
self.pan_offset[0] += origin.x * (1.0 - 1.0 / self.zoom.max(1e-6));
self.pan_offset[1] += origin.y;
}
/// Durchgehende Ansicht: Verschiebt den Ausschnitt so, dass der Bildschirmpunkt `center`
/// über dem Notizbuch liegt (zwischen Oberkante der ersten und Unterkante der letzten
/// Seite, waagerecht innerhalb der Seitenbreite). `true`, wenn korrigiert wurde.
pub fn clamp_continuous_center(
&mut self,
center: [f32; 2],
stack: &PageStack,
page_viewport: &PageViewport,
) -> bool {
if stack.is_empty() {
return false;
}
let c = self.screen_to_page_unclamped(center[0], center[1], page_viewport);
let (min, max) = stack.bounds();
let clamped = c.clamp(min, max.max(min));
if (clamped - c).length() < 1e-3 {
return false;
}
// Bildschirm = Viewport + (Seite + Pan) · Maßstab: der Pan gleicht die Differenz aus
self.pan_offset[0] += c.x - clamped.x;
self.pan_offset[1] += c.y - clamped.y;
true
}
/// Passt den Pan-Offset um eine Bildschirmverschiebung (in Fensterpixeln) an.
pub fn pan_by_screen_delta(
&mut self,
@@ -46,7 +164,7 @@ impl ViewportState {
delta_y: f32,
page_viewport: &PageViewport,
) {
let base_scale = page_viewport.page_width / PAGE_WIDTH;
let base_scale = page_viewport.scale;
if base_scale * self.zoom > 1e-6 {
self.pan_offset[0] += delta_x / (base_scale * self.zoom);
self.pan_offset[1] += delta_y / (base_scale * self.zoom);
@@ -68,7 +186,7 @@ impl ViewportState {
return;
}
let base_scale = page_viewport.page_width / PAGE_WIDTH;
let base_scale = page_viewport.scale;
// Bildschirmposition des Seitenursprungs (0, 0)
let old_offset_x = page_viewport.page_x + self.pan_offset[0] * base_scale * old_zoom;
@@ -90,7 +208,7 @@ impl ViewportState {
screen_y: f32,
page_viewport: &PageViewport,
) -> Vec2 {
let base_scale = page_viewport.page_width / PAGE_WIDTH;
let base_scale = page_viewport.scale;
let effective_scale = base_scale * self.zoom;
if effective_scale < 1e-6 {
return Vec2::ZERO;
@@ -112,7 +230,8 @@ impl ViewportState {
page_viewport: &PageViewport,
) -> Option<Vec2> {
let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport);
if (0.0..=PAGE_WIDTH).contains(&pt.x) && (0.0..=PAGE_HEIGHT).contains(&pt.y) {
let size = page_viewport.page_size;
if (0.0..=size.x).contains(&pt.x) && (0.0..=size.y).contains(&pt.y) {
Some(pt)
} else {
None
@@ -127,7 +246,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))
pt.clamp(Vec2::ZERO, page_viewport.page_size)
}
/// Transformiert einen DIN-A4-Seitenpunkt in Fenster-Pixelkoordinaten.
@@ -142,7 +261,7 @@ impl ViewportState {
page_y: f32,
page_viewport: &PageViewport,
) -> [f32; 2] {
let base_scale = page_viewport.page_width / PAGE_WIDTH;
let base_scale = page_viewport.scale;
let effective_scale = base_scale * self.zoom;
let screen_x = page_viewport.page_x + (page_x + self.pan_offset[0]) * effective_scale;
let screen_y = page_viewport.page_y + (page_y + self.pan_offset[1]) * effective_scale;
@@ -152,7 +271,7 @@ impl ViewportState {
/// Berechnet die Shader-Transformationsparameter (Scale, Offset) für den Vertex-Shader,
/// um DIN-A4-Seitenkoordinaten direkt in den GPU-NDC-Bereich [-1.0, 1.0] abzubilden.
pub fn ndc_transform(&self, page_viewport: &PageViewport) -> ([f32; 2], [f32; 2]) {
let base_scale = page_viewport.page_width / PAGE_WIDTH;
let base_scale = page_viewport.scale;
let effective_scale = base_scale * self.zoom;
// ndc_x = (screen_x / window_width) * 2.0 - 1.0
@@ -190,6 +309,110 @@ impl ViewportState {
mod tests {
use super::*;
const A4: Vec2 = crate::document::A4_SIZE;
const PITCH: f32 = A4.y;
fn a4_stack(count: usize, current: usize) -> PageStack {
PageStack::new(vec![A4; count], current)
}
#[test]
fn test_continuous_layout() {
let stack = a4_stack(5, 1);
assert_eq!(stack.page_at(100.0), Some(1));
// Lückenlos: direkt unter dem Seitenende beginnt die nächste Seite
assert_eq!(stack.page_at(A4.y - 1.0), Some(1));
assert_eq!(stack.page_at(A4.y + 1.0), Some(2));
assert_eq!(stack.page_at(PITCH + 5.0), Some(2));
assert_eq!(stack.page_at(-5.0), Some(0));
assert_eq!(stack.page_at(-PITCH - 5.0), None, "vor der ersten Seite");
// Ende von Seite 2 und Anfang von Seite 3 sichtbar (aktuell: Seite 2 von 5)
assert_eq!(
stack.visible_neighbors(A4.y - 500.0, PITCH + 500.0),
vec![2]
);
// Ganz oben im Notizbuch: keine Seite vor der ersten
assert!(a4_stack(5, 0)
.visible_neighbors(-PITCH * 3.0, 500.0)
.is_empty());
// Hinter der letzten Seite: keine weiteren
assert_eq!(
a4_stack(5, 3).visible_neighbors(-PITCH + 1.0, PITCH * 4.0),
vec![2, 4]
);
// Weit herausgezoomt: auf die nächstgelegenen Seiten begrenzt
let many = a4_stack(200, 50).visible_neighbors(-PITCH * 50.0, PITCH * 50.0);
assert_eq!(many.len(), MAX_NEIGHBOR_PAGES);
assert!(many.iter().all(|i| i.abs_diff(50) <= 6));
}
#[test]
fn test_stack_of_different_page_sizes() {
let slide = Vec2::new(2970.0, 1671.0);
let stack = PageStack::new(vec![slide, A4, slide], 1);
// Folien waagerecht zur A4-Seite zentriert, direkt darüber und darunter
assert_eq!(stack.origins[0], Vec2::new(-435.0, -slide.y));
assert_eq!(stack.origins[2], Vec2::new(-435.0, A4.y));
assert_eq!(stack.page_at(A4.y + 10.0), Some(2));
let (min, max) = stack.bounds();
assert_eq!(min, Vec2::new(-435.0, -slide.y));
assert_eq!(max, Vec2::new(2535.0, A4.y + slide.y));
}
#[test]
fn test_shift_reference_page_keeps_view() {
let mut vp = ViewportState::new();
let page_vp = PageViewport::calculate(1920.0, 1080.0, 24.0);
vp.pan_by_screen_delta(0.0, -900.0, &page_vp);
// Punkt oben auf der nächsten Seite, in Koordinaten der aktuellen Seite
let before = vp.page_to_screen(300.0, PITCH + 200.0, &page_vp);
vp.shift_reference_page(a4_stack(2, 0).origins[1]);
let after = vp.page_to_screen(300.0, 200.0, &page_vp);
assert!((before[0] - after[0]).abs() < 0.01 && (before[1] - after[1]).abs() < 0.01);
}
#[test]
fn test_shift_to_page_of_other_size_keeps_view_when_zoomed() {
let slide = Vec2::new(2970.0, 1671.0);
let fit = Vec2::new(2970.0, 2970.0);
let view_a4 = PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), fit, A4);
let view_slide = PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), fit, slide);
let stack = PageStack::new(vec![A4, slide], 0);
let mut vp = ViewportState::new();
vp.zoom_at_cursor([500.0, 300.0], 2.5, &view_a4);
vp.pan_by_screen_delta(-40.0, -700.0, &view_a4);
let point = Vec2::new(900.0, 300.0);
let before = vp.page_to_screen_vec2(stack.origins[1] + point, &view_a4);
vp.shift_reference_page(stack.origins[1]);
let after = vp.page_to_screen_vec2(point, &view_slide);
assert!((before[0] - after[0]).abs() < 0.01 && (before[1] - after[1]).abs() < 0.01);
}
#[test]
fn test_continuous_scroll_stays_on_the_notebook() {
let page_vp = PageViewport::calculate(1280.0, 850.0, 24.0);
let center = [640.0, 425.0];
let stack = a4_stack(3, 1);
let mut vp = ViewportState::new();
// Mitte der Seite: nichts zu korrigieren
assert!(!vp.clamp_continuous_center(center, &stack, &page_vp));
// Weit über die erste Seite hinaus nach oben gescrollt (aktuell: Seite 2 von 3)
vp.pan_by_screen_delta(0.0, 5000.0, &page_vp);
assert!(vp.clamp_continuous_center(center, &stack, &page_vp));
let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp);
assert!(
(c.y + PITCH).abs() < 0.1,
"Oberkante der ersten Seite: {c:?}"
);
// Weit nach unten: Unterkante der letzten Seite
vp.pan_by_screen_delta(-3000.0, -20000.0, &page_vp);
assert!(vp.clamp_continuous_center(center, &stack, &page_vp));
let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp);
assert!((c.y - (PITCH + A4.y)).abs() < 0.1, "{c:?}");
assert!((c.x - A4.x).abs() < 0.1, "{c:?}");
}
#[test]
fn test_viewport_zoom_limits() {
let mut vp = ViewportState::new();
+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
}