Compare commits

..
87 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 76968f9e58 chore(release): bump version to 0.30.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m18s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:16:57 +02:00
haraldandClaude Opus 5.5 b75858b43a feat: Schneller durch lange Notizbücher scrollen (Issue #28), neue Notizbücher durchgehend (Issue #30)
- Nachgleiten: Nach schnellem Wischen mit dem Finger gleitet das Blatt
  weiter und bremst ab; eine neue Berührung, Klick oder Mausrad hält an
- Bildlaufleiste in der durchgehenden Ansicht gegenüber der
  Werkzeugleiste: Griff ziehen oder auf die Leiste tippen springt durch
  das Notizbuch, beim Ziehen steht die Seitenzahl daneben
- Mausrad scrollt doppelt so weit je Raste
- Neue Notizbücher (auch PDF-Importe) sind durchgehend; beim Anlegen
  stehen nur noch Durchgehend und Endlos zur Wahl, feste Seiten gibt es
  weiter im ☰-Menü

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:12:51 +02:00
haraldandClaude Opus 5.5 5fafff5eaf style: cargo fmt
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:19:10 +02:00
haraldandClaude Opus 5.5 d21fa91315 chore(release): bump version to 0.29.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 1m47s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:28:51 +02:00
haraldandClaude Opus 5.5 b00767b457 perf: Große Auswahlen flüssig verschieben, drehen und skalieren
Seit 0.27 kopierte die Prüfung des Seitenverhältnis-Schlosses bei jedem
Bild alle ausgewählten Striche und suchte jeden davon quadratisch in der
Seite. Zusammen mit dem Kopieren beim Ziehen lastete das bei großen
Auswahlen den Prozessor aus, das Verschieben kam kaum noch hinterher.

- Schloss und Auswahlrahmen ohne Kopien bestimmen
- Striche beim Ziehen an ihrer Stelle aus dem Ausgangszustand umformen
  statt die Auswahl zweimal zu kopieren; Suche über eine ID-Tabelle
- Stiftbewegungen der Auswahl nur einmal je Bild anwenden
- Endlose Fläche: Mausrad scrollt (Strg+Mausrad zoomt)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:28:51 +02:00
haraldandClaude Opus 5.5 63ab6a3e5b chore(release): bump version to 0.29.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:17:17 +02:00
haraldandClaude Opus 5.5 3c793fe659 feat: Endlose Fläche als Seitenanordnung (Issue #30, Schritt 2)
Ein endloses Notizbuch hat eine einzige Seite von 10 × 10 m; Eingabe,
Treffertests, Undo und Speichern bleiben dadurch unverändert. Die
Ansicht zeigt bei 100 % einen DIN-A4-Rahmen in der Mitte, Bilder und
Textfelder richten sich nach A4. Wählbar beim Anlegen und im ☰-Menü,
umschalten nur bei einer Seite ohne PDF bzw. zurück, solange alles im
Startausschnitt liegt. Seiten-Knöpfe sind auf der endlosen Fläche aus.

Der Export enthält nur den beschriebenen Bereich mit 10 mm Rand, auf
das Musterraster gelegt; Bilder einer Auswahl rastern nur noch den
Ausschnitt statt der ganzen Seite. Formatversion 9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:14:45 +02:00
haraldandClaude Opus 5.5 8bc29faddc chore(release): bump version to 0.28.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:01:18 +02:00
haraldandClaude Opus 5.5 8ea20aa783 feat: Seitenanordnung je Notizbuch (Issue #30, Schritt 1)
Ob ein Notizbuch Seite für Seite oder durchgehend angezeigt wird, steht
jetzt im Manifest (`layout`). Beim Anlegen ist „Feste Seiten“
vorgewählt; im ☰-Menü lässt sich die Anordnung des offenen Notizbuchs
umschalten. Notizbücher ohne Angabe behalten die frühere globale
Einstellung, ältere App-Versionen ignorieren das Feld.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:58:55 +02:00
haraldandClaude Opus 5.5 1b30f2bd26 chore(release): bump version to 0.27.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 13m58s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:48:20 +02:00
haraldandClaude Opus 5.5 657a5ff16e feat: Stifte der Werkzeugleiste in ihrer Farbe (Issue #31)
Statt eines dünnen Strichs auf grauem Papier zeigt jeder Stift jetzt
einen schräg liegenden Stift, dessen Schaft und Mine in seiner Farbe
gefüllt sind. Eine helle Kontur hält dunkle Farben auf der dunklen
Leiste sichtbar; die Stärke steht weiter im Tooltip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:44:51 +02:00
haraldandClaude Opus 5.5 8fddd9e2c3 chore(release): bump version to 0.27.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 21m31s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:18:34 +02:00
haraldandClaude Opus 5.5 1a98c9b8bb feat: Knopf für das Seitenverhältnis-Schloss der Auswahl (Issue #29)
Im Tablet-Betrieb gibt es keine Umschalttaste. Beim Auswahlwerkzeug
steht unter den Stiftfarben jetzt ein Schloss: 🔒 skaliert gleichmäßig,
🔓 frei. Vorgabe ist gesperrt, sobald ein Bild ausgewählt ist, sonst
frei. Das Umschalten gilt für die aktuelle Auswahl; eine neue Auswahl
beginnt wieder mit der Vorgabe. Shift sperrt am Rechner zusätzlich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:13:15 +02:00
haraldandClaude Opus 5.5 98b78d29e6 feat: Bilder beim Skalieren nicht verzerren (Issue #29)
Enthält die Auswahl ein Bild, skaliert der Eckgriff gleichmäßig, das
Seitenverhältnis bleibt erhalten. Mit gedrückter Umschalttaste gilt das
auch für Striche und Formen. Der Faktor ist die Projektion der
Zugposition auf die Diagonale der Auswahl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:02:56 +02:00
haraldandClaude Opus 5.5 81ccd93438 docs: Code of Conduct, Mitmach-Anleitung, Sicherheitsrichtlinie und Vorlagen
Für die Community-Standards auf GitHub (Spiegel) und für Gitea:

- CODE_OF_CONDUCT.md: Contributor Covenant 2.1, Kontakt harald@pansi.eu
- CONTRIBUTING.md (Deutsch/Englisch): Gitea als Hauptrepository,
  Prüfungen wie in der CI, Tests, Dateiformat, Übersetzungen
- SECURITY.md: unterstützt wird die neueste Version, Meldungen
  vertraulich per E-Mail
- Issue-Vorlagen für Fehler und Ideen, Kontakt-Links (auf GitHub mit
  Verweis auf Gitea), Pull-Request-Vorlage
- README: Abschnitt „Mitmachen“

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:40:36 +02:00
haraldandClaude Opus 5.5 cbf699b680 chore(release): bump version to 0.26.2
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m41s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:26:42 +02:00
haraldandClaude Opus 5.5 ade20ab3dc perf: Veraltete PDF-Render-Aufträge beim Scrollen verwerfen (Issue #28)
Beim schnellen Scrollen durch ein großes PDF hat der Render-Thread jede
vorbeigezogene Seite der Reihe nach gerendert; die gerade sichtbare
Seite musste warten.

- Gemeinsame Warteschlange (Mutex + Condvar) statt Kanal: Der
  Render-Thread nimmt den wichtigsten wartenden Auftrag zuerst.
- Je Bild meldet der Event-Loop zwischen begin_frame und end_frame die
  benötigten Seiten an (die aktuelle zuerst); end_frame verwirft
  wartende Aufträge und fertige Ergebnisse für Seiten, die nicht mehr
  gebraucht werden. Die vorab gerenderte nächste Seite bleibt erhalten.

Gemessen (Xvfb, Debug-Build, PDF mit 60 Seiten, 400 Mausrad-Schritte):
die letzte Seite erscheint nach 0,12–0,16 s statt 0,63–1,56 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:48:31 +02:00
haraldandClaude Opus 5.5 f24e7750e6 perf: Mipmaps importierter PDF-Seiten im Render-Thread (Issue #28)
Beim Scrollen durch große PDFs stockte die Anzeige bei jeder neu
gerenderten Seite: Der Haupt-Thread erzeugte die Mipmaps, bei 2560 px
etwa 100–200 ms, beim Hineinzoomen (5120 px) bis zu 1 s.

- Der PDF-Render-Thread liefert die fertigen Mipmap-Stufen mit; der
  Haupt-Thread lädt sie nur noch hoch (TextureSource::ReadyMips).
- mip_chain halbiert mit einem 2×2-Mittelwert statt imageops::resize:
  etwa 7-mal schneller (2560 px: ~15 ms statt ~100–200 ms).
- Belegter Grafikspeicher wird aus den tatsächlichen Stufen berechnet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:22:21 +02:00
haraldandClaude Opus 5.5 097933882c chore(flatpak): ideas.txt und issue.txt nicht mehr überspringen
Die Dateien gibt es nicht mehr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:28:35 +02:00
haraldandClaude Opus 5.5 b3578175c2 chore(release): bump version to 0.26.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m25s
Screenshot-URLs im Metainfo zeigen auf das Release-Tag statt auf main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:29:56 +02:00
haraldandClaude Opus 5.5 1cb02bf8e9 docs: Screenshot einer beschrifteten PDF-Folie für Flathub
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:20:31 +02:00
haraldandClaude Opus 5.5 26c4191e0f docs: Screenshots für Flathub
- packaging/screenshots/notes.png: Notizen mit Textmarker, Handschrift
  und Textfeldern
- packaging/screenshots/shapes.png: erkannte Formen (Achsenkreuz,
  Dreieck, Kreis, Oktaeder, Sechseck, Ellipse)
- Metainfo: Screenshots mit englischer und deutscher Beschriftung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:16:01 +02:00
haraldandClaude Opus 5.5 7d683d6e67 chore(release): bump version to 0.26.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m16s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:20:31 +02:00
haraldandClaude Opus 5.5 e399c357d8 feat: Flatpak-Paket (Vorbereitung für Flathub)
- packaging/flatpak/eu.pansi.ink-notes.yml: Runtime freedesktop 26.08,
  Rust aus rust-stable, Abhängigkeiten offline aus cargo-sources.json.
  Rechte: Wayland/X11, GPU, Lesen von Bildern/Downloads/Schreibtisch,
  Schreiben in den Dokumente-Ordner (kein Zugriff auf den ganzen
  Home-Ordner).
- Im Flatpak sucht und installiert ink-notes keine Updates selbst
  (Vorgabe von Flathub); der Update-Dialog verweist auf die
  Software-Verwaltung.
- Export-Ordner aus user-dirs.dirs (z. B. ~/Dokumente) statt fest
  ~/Documents; nur dieser Ordner ist im Flatpak beschreibbar.
- Metainfo und Desktop-Datei auf Englisch mit deutscher Übersetzung,
  Release-Einträge, unterstützte Eingabegeräte.
- Test: cargo-sources.json deckt Cargo.lock ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:18:37 +02:00
haraldandClaude Opus 5.5 dd702524ca fix: Auswählen, Text und Platz schaffen mit dem Finger (Issue #26)
Ohne „Mit dem Finger zeichnen“ verschob ein Finger immer das Blatt, auch
im Auswahlwerkzeug – Objekte ließen sich mit dem Finger weder auswählen
noch verschieben. Jetzt nehmen Text und Platz schaffen den Finger immer,
das Auswahlwerkzeug, wenn der Finger ein Objekt oder die Auswahl trifft;
auf leerer Fläche verschiebt er weiter das Blatt. Halten öffnet dabei
kein Kontextmenü.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:12:25 +02:00
haraldandClaude Opus 5.5 20792ed34f chore(release): bump version to 0.25.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m56s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:54:06 +02:00
haraldandClaude Opus 5.5 b8d0260607 fix: Halten zum Einrasten verträgt Zittern des Stifts (Issue #27)
Beim Halten am Strichende durfte sich die Position nur um 3 physische
Pixel ändern, sonst begann die Wartezeit neu. Ruhende Stifte zittern um
einige Pixel, auf HiDPI-Bildschirmen umso mehr; jetzt sind 6 logische
Pixel erlaubt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:53:40 +02:00
haraldandClaude Opus 5.5 6c0db643c4 feat: Englische Oberfläche
Alle Oberflächentexte und Fehlermeldungen stehen in t!("…"); der deutsche
Text ist zugleich der Schlüssel für die englische Fassung in
src/i18n/en.rs (430 Einträge). Platzhalter heißen {name}. ink-notes folgt
der Systemsprache (Deutsch bei de…, sonst Englisch); im ☰-Menü lässt sie
sich unter „Sprache / Language“ auf System, Deutsch oder English
festlegen, gespeichert in ui.json.

Ein Test prüft, dass jeder markierte Text übersetzt ist, keine
Übersetzung verwaist und die Platzhalter übereinstimmen.
Konsolenausgaben bleiben deutsch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:37:41 +02:00
haraldandClaude Opus 5.5 ec6a5259d5 chore(release): bump version to 0.24.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-09 12:46:34 +02:00
haraldandClaude Opus 5.5 c92bf16cbf feat: Achsenkreuz aus zwei Strichen (Issue #20)
Kreuzt eine eingerastete Gerade (Halten oder Formen-Werkzeug) den
vorigen Strich, und ist dieser ebenfalls eine Gerade (eingerastet oder
freihändig gerade gezogen), werden beide zum Achsenkreuz mit Pfeilspitzen
an allen vier Enden. Eine Gerade muss waagerecht, die andere senkrecht
sein (je höchstens 25° schief), und jeder Arm reicht mindestens ein
Fünftel seiner Achse über den Schnittpunkt hinaus. Beim Halten zeigt die
Vorschau schon das Achsenkreuz; Rückgängig stellt die erste Gerade wieder
her. Ein T oder L bleibt zwei Geraden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:45:58 +02:00
haraldandClaude Opus 5.5 57028dfbc6 chore(release): bump version to 0.23.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:21:29 +02:00
haraldandClaude Opus 5.5 6fb190cf79 fix: Bildschirmtastatur unter GNOME/Wayland (Issue #21)
GNOME blendet die Bildschirmtastatur erst ein, wenn ein bereits aktives
Textfeld zwp_text_input_v3.enable erneut meldet (wie GTK beim Antippen).
winit meldet enable nur einmal. Ein eigenes text-input-Objekt auf
derselben Wayland-Verbindung schickt das zweite enable, wenn ein Textfeld
den Fokus bekommt oder erneut angetippt wird.

- Zeichen der Bildschirmtastatur kommen als IME-Text ohne Vorschau; egui
  0.27 fügte davon nur das erste ein. Sie werden jetzt als normaler Text
  übergeben.
- Die Rücktaste löscht über delete_surrounding_text; dafür meldet das
  eigene Objekt einen Platzhalter-Umgebungstext und übersetzt das Löschen
  in Tastendrücke.
- Ein Fingertipp ging verloren, wenn GNOME beim Antippen den Mauszeiger
  ausblendet (CursorLeft bei aufliegendem Finger); das Ereignis erreicht
  egui in dem Fall nicht mehr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:11:45 +02:00
haraldandClaude Opus 5.5 1148851585 chore(release): bump version to 0.22.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m32s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:51:17 +02:00
haraldandClaude Opus 5.5 eef4cc6b2b feat: App-Symbol für das laufende Fenster
- Fenster meldet die App-Kennung eu.pansi.ink-notes (Wayland: app_id, X11:
  WM_CLASS), passend zur .desktop-Datei (StartupWMClass): Dock und Taskleiste
  zeigen das ink-notes-Symbol statt eines allgemeinen Symbols
- Unter X11 zusätzlich das eingebaute Symbol als Fenstersymbol (64 × 64)
- Fenstertitel ohne Werbezusatz

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:33:13 +02:00
haraldandClaude Opus 5.5 dc067af31e chore(release): bump version to 0.22.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:31:28 +02:00
haraldandClaude Opus 5.5 693f62c2e7 feat: Werkzeug „Platz schaffen“ (Issue #22)
- Neues Werkzeug ↕: aufs Blatt tippen und ziehen; alles unterhalb der
  Ansatzhöhe rutscht mit, auch der Inhalt der folgenden Seiten (bis vor die
  nächste PDF-Seite)
- Was über ein Seitenende rutschen würde, beginnt auf der nächsten Seite
  (10 mm Abstand), alles danach rutscht weiter; fehlende Seiten werden angehängt
- Nach oben wird nur leerer Platz geschlossen
- Vorschau beim Ziehen (Band mit Millimeterangabe); mit Maus, Stift und Finger
- Ein Schritt für Rückgängig/Wiederholen über alle beteiligten Seiten
- Nachbarseiten werden neu gezeichnet, wenn sich ihr Inhalt bei gleicher
  Strichzahl ändert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:15:18 +02:00
haraldandClaude Opus 5.5 f212b2e23f chore(release): bump version to 0.21.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:42:48 +02:00
haraldandClaude Opus 5.5 61c0c903a4 feat: Achsenkreuz aus einem „+“ in einem Zug (Issue #20)
- Formerkennung (Halten oder Formen-Werkzeug): Ein „+“ in einem Strich wird zum
  Achsenkreuz – waagerechte und senkrechte Achse, Pfeilspitzen an allen vier
  Enden; die Arme dürfen unterschiedlich lang sein
- Gespeichert als Koordinatensystem mit dem neuen optionalen Feld
  negative_ends: ältere Versionen zeigen es als L-Koordinatensystem, kein neues
  Dateiformat nötig
- Auch im PDF-, PNG- und SVG-Export mit vier Pfeilspitzen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:42:03 +02:00
haraldandClaude Opus 5.5 680fa96f32 chore(release): bump version to 0.20.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m32s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:25:26 +02:00
haraldandClaude Opus 5.5 eb0a801921 fix: Erstes Antippen mit dem Stift trifft (Issues #24, #25)
octotablet meldet das Aufsetzen des Stifts vor der Position desselben Frames.
Bis dahin galt die alte Position – nähert man den Stift und tippt sofort,
lag sie bei der vorherigen Stelle: Der Druck ging an den alten Ort, beim
Loslassen hatte sich der Zeiger „bewegt“, egui wertete das nicht als Klick
und zeigte nur den Tooltip. Erst das zweite Tippen traf. Auf dem Blatt
konnte ein Strich so mit einer Linie von der alten Stelle beginnen.

Jetzt wird das Aufsetzen erst nach der Position seines Frames verarbeitet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:24:12 +02:00
haraldandClaude Opus 5.5 76ecdac081 chore(release): bump version to 0.20.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:05:48 +02:00
haraldandClaude Opus 5.5 ea9ee6c0d7 feat: Tasten am Stift belegen (Issue #23)
- Im ☰-Menü unter Eingabe → „Tasten am Stift“: je Taste Radieren (Standard),
  Auswählen, Textmarker, Stift oder Nichts; gilt, solange die Taste gedrückt ist
- Tasten werden beim ersten Drücken gelernt und in ui.json gemerkt; die gerade
  gedrückte leuchtet im Menü grün
- Mit einer Werkzeug-Taste geschriebene Striche benutzen dieses Werkzeug, danach
  ist wieder das gewählte Werkzeug aktiv

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:05:09 +02:00
haraldandClaude Opus 5.5 d1f0b46fac chore(release): bump version to 0.19.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m40s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:47:28 +02:00
haraldandClaude Opus 5.5 82a297f395 feat: Ordner in andere Ordner verschieben (Issue #18)
- 📂 auch bei Ordnern: verschiebt den Ordner samt Unterordnern und
  Notizbüchern in einen anderen Ordner oder auf die oberste Ebene
- Nicht in sich selbst oder einen eigenen Unterordner; gibt es am Ziel schon
  einen gleichnamigen Ordner, kommt eine Meldung statt einer Zusammenlegung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:46:51 +02:00
haraldandClaude Opus 5.5 e33062fe0a chore(release): bump version to 0.18.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:42:38 +02:00
haraldandClaude Opus 5.5 36a1a3cd2e feat: Ordner für Notizbücher (Issue #18)
- Beliebig verschachtelte Ordner (z. B. 3. Semester › Mathe) im
  Dokumenten-Manager: Pfadleiste zum Navigieren, Ordner anlegen, umbenennen,
  löschen (Inhalt rückt eine Ebene nach oben) und Notizbücher mit 📂 verschieben
- Neue Notizbücher landen im angezeigten Ordner, importierte PDFs neben dem
  geöffneten Notizbuch; der Manager öffnet im Ordner des aktuellen Notizbuchs
- Ordner sind logisch (library.json im Notizbuch-Basisordner), Notizbuch-
  Dateien werden nie verschoben; fehlt die Datei, liegt alles oben
- Die ganze Titelfläche einer Zeile öffnet Notizbuch bzw. Ordner
- Ein ganz neues Standard-Notizbuch wird sofort gespeichert (erschien sonst
  bis zur ersten Änderung als „beschädigt“)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:41:05 +02:00
haraldandClaude Opus 5.5 70317caeb5 chore(release): bump version to 0.17.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m34s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:10:14 +02:00
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
57 changed files with 25132 additions and 2109 deletions
+7
View File
@@ -0,0 +1,7 @@
# Gitea liest die Vorlagen aus .github/ISSUE_TEMPLATE; diese Datei ersetzt dort nur die
# Kontakt-Links (der Verweis auf Gitea selbst entfällt).
blank_issues_enabled: true
contact_links:
- name: "Sicherheitslücke melden / Report a security issue"
url: https://gitea.pansi.eu/harald/ink-notes/src/branch/main/SECURITY.md
about: "Bitte vertraulich per E-Mail, nicht als öffentliches Issue. / Please report privately by e-mail, not as a public issue."
+28
View File
@@ -0,0 +1,28 @@
---
name: "Fehler melden / Bug report"
about: "Etwas funktioniert nicht wie erwartet / Something does not work as expected"
title: ""
---
<!-- Sicherheitslücken bitte nicht hier melden, siehe SECURITY.md. / Please report security issues privately, see SECURITY.md. -->
**Was passiert? / What happens?**
**Was hättest du erwartet? / What did you expect?**
**Schritte zum Nachstellen / Steps to reproduce**
1.
2.
3.
**Umgebung / Environment**
- ink-notes-Version / version (☰ → Updates):
- Installiert als / installed as (.deb, AppImage, Flatpak, selbst gebaut / built from source):
- Distribution:
- Wayland oder / or X11:
- Eingabegerät / input device (Stift / pen, Finger / touch, Maus / mouse, Touchpad):
**Bildschirmfoto, Aufnahme oder Ausgabe im Terminal / Screenshot, recording or terminal output**
<!-- Wenn möglich: ink-notes im Terminal starten und die Ausgabe hier einfügen. / If possible, start ink-notes in a terminal and paste its output here. -->
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: "Issues auf Gitea (Hauptrepository) / Issues on Gitea (main repository)"
url: https://gitea.pansi.eu/harald/ink-notes/issues
about: "GitHub ist ein Spiegel; Issues werden bevorzugt auf Gitea bearbeitet. / GitHub is a mirror; issues are preferably handled on Gitea."
- name: "Sicherheitslücke melden / Report a security issue"
url: https://gitea.pansi.eu/harald/ink-notes/src/branch/main/SECURITY.md
about: "Bitte vertraulich per E-Mail, nicht als öffentliches Issue. / Please report privately by e-mail, not as a public issue."
+14
View File
@@ -0,0 +1,14 @@
---
name: "Idee vorschlagen / Feature request"
about: "Eine neue Funktion oder Verbesserung / A new feature or improvement"
title: ""
---
**Was möchtest du tun können? / What would you like to be able to do?**
**Wie machst du es bisher, und was stört dich daran? / How do you do it today, and what bothers you about it?**
**Wie könnte es aussehen? / How could it work?**
<!-- Skizzen, Bildschirmfotos oder Beispiele aus anderen Apps helfen. / Sketches, screenshots or examples from other apps help. -->
+13
View File
@@ -0,0 +1,13 @@
<!-- GitHub ist ein Spiegel von https://gitea.pansi.eu/harald/ink-notes; Pull Requests werden dort von Hand übernommen. / GitHub is a mirror; pull requests are applied on Gitea by hand. -->
**Was ändert sich und warum? / What changes and why?**
**Zugehöriges Issue / Related issue:** #
**Checkliste / Checklist**
- [ ] `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings` und / and `cargo test` laufen durch / pass
- [ ] Tests für neue Funktionen oder behobene Fehler / tests for new features or fixes
- [ ] Neue Texte der Oberfläche auch auf Englisch (`src/i18n/`) / new UI texts translated to English
- [ ] Änderungen am Dateiformat in `docs/FILE_FORMAT.md` beschrieben / file format changes documented (falls nötig / if needed)
- [ ] Ich habe CONTRIBUTING.md gelesen / I have read CONTRIBUTING.md
+85
View File
@@ -0,0 +1,85 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at harald@pansi.eu. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of actions.
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+43
View File
@@ -0,0 +1,43 @@
# Mitmachen bei ink-notes
Danke, dass du ink-notes verbessern willst! *(English below.)*
## Wo?
Das Hauptrepository liegt auf **Gitea**: <https://gitea.pansi.eu/harald/ink-notes>.
[GitHub](https://github.com/haraldpansi-gif/ink-notes) ist ein Spiegel davon – Issues und Pull Requests sind dort auch willkommen, Pull Requests werden aber von Hand nach Gitea übernommen.
## Fehler melden und Ideen vorschlagen
- Bitte zuerst nachsehen, ob es schon ein [Issue](https://gitea.pansi.eu/harald/ink-notes/issues) dazu gibt.
- Für Fehler die Vorlage „Fehler melden“ verwenden: Version (☰ → Updates), Distribution, Wayland oder X11, Eingabegerät (Stift, Finger, Maus, Touchpad) und die Schritte, mit denen der Fehler auftritt. Ein Bildschirmfoto oder eine kurze Aufnahme hilft oft sehr.
- Deutsch und Englisch sind beide in Ordnung.
- **Sicherheitslücken bitte nicht öffentlich melden**, sondern wie in [SECURITY.md](SECURITY.md) beschrieben.
## Code beitragen
1. Größere Änderungen am besten vorher in einem Issue absprechen.
2. Bauen und testen, wie im [README](README.md#entwicklung) beschrieben. Vor dem Einreichen müssen diese Prüfungen durchlaufen (die CI prüft sie ebenfalls):
```bash
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
```
3. Neue Funktionen und behobene Fehler bekommen einen Test, wo das sinnvoll geht.
4. Änderungen am Dateiformat bitte in [docs/FILE_FORMAT.md](docs/FILE_FORMAT.md) nachtragen; ältere Notizbücher müssen weiter lesbar bleiben.
5. Commit-Nachrichten im Stil von [Conventional Commits](https://www.conventionalcommits.org/), z. B. `fix: …`, `feat: …`, `perf: …`, `docs: …`; ein Bezug auf ein Issue als `(Issue #28)`.
6. Texte der Oberfläche gibt es auf Deutsch und Englisch: Neue Texte mit `t!("…")` schreiben und die englische Übersetzung unter `src/i18n/` ergänzen.
Mit dem Einreichen stimmst du zu, dass dein Beitrag unter der [MIT-Lizenz](LICENSE) des Projekts steht. Für den Umgang miteinander gilt der [Code of Conduct](CODE_OF_CONDUCT.md).
---
## Contributing (English)
The main repository is on **Gitea**: <https://gitea.pansi.eu/harald/ink-notes>. [GitHub](https://github.com/haraldpansi-gif/ink-notes) is a mirror; issues and pull requests are welcome there too, but pull requests are applied to Gitea by hand.
- **Bugs and ideas:** check the [existing issues](https://gitea.pansi.eu/harald/ink-notes/issues) first, then use the bug report or feature request template. Please include the version (☰ → Updates), distribution, Wayland or X11, the input device and the steps to reproduce. German and English are both fine.
- **Security issues:** please do not report them publicly; see [SECURITY.md](SECURITY.md).
- **Code:** discuss larger changes in an issue first. Before submitting, `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings` and `cargo test` must pass. Add tests where it makes sense, document file format changes in `docs/FILE_FORMAT.md`, use Conventional Commits, and add English translations for new UI texts (`t!("…")`, see `src/i18n/`).
By contributing you agree that your contribution is licensed under the project's [MIT License](LICENSE). Everyone taking part is expected to follow the [Code of Conduct](CODE_OF_CONDUCT.md).
Generated
+715 -19
View File
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -1,12 +1,12 @@
[package] [package]
name = "ink-notes" name = "ink-notes"
version = "0.3.6" version = "0.30.0"
edition = "2021" 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" repository = "https://gitea.pansi.eu/harald/ink-notes"
license = "MIT" license = "MIT"
readme = "README.md" readme = "README.md"
rust-version = "1.75" rust-version = "1.92"
[dependencies] [dependencies]
# Fenster- & Event-Handling (Wayland/X11, Stylus) # Fenster- & Event-Handling (Wayland/X11, Stylus)
@@ -39,8 +39,22 @@ sha2 = "0.10"
ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] } ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] }
url = "2.5" url = "2.5"
octotablet = "0.1.0" 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" minisign-verify = "0.3.0"
fs2 = "0.4" fs2 = "0.4"
hayro = "0.8"
hayro-svg = "0.8"
# Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui)
ab_glyph = "0.2"
# Bildschirmtastatur unter Wayland anfordern (eigenes text-input-Objekt, siehe src/osk.rs)
wayland-client = "0.31"
wayland-backend = { version = "0.3", features = ["client_system"] }
wayland-protocols = { version = "0.31", features = ["client", "unstable"] }
[profile.dev] [profile.dev]
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
@@ -53,3 +67,5 @@ strip = true
[dev-dependencies] [dev-dependencies]
tempfile = "3.27.0" 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"] }
+206 -236
View File
@@ -1,281 +1,251 @@
# 🖋️ 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**. <p align="center">
<strong>Eine moderne Notizbuch-App für Linux zum Schreiben mit Stift, Touch und Maus</strong><br>
`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. 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 Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
- **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) Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemsprache, umstellen lässt sie sich im ☰-Menü unter „Sprache / Language“.
- **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`.
--- ---
## 📦 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). ### Schreiben und Zeichnen
- **Portables .AppImage**: Eigenständiges x86_64 Bundle. - **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.
- **In-App Updater (`src/updater.rs`)**: Automatischer Prüflauf gegen Gitea Releases, SemVer-Filterung, Minisign-Signaturprüfung, SHA-256-Prüfung und atomarer In-Place Austausch. - **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).
- **Tasten am Stift** im ☰-Menü belegen: Radieren (Standard), Auswählen, Textmarker, Stift oder nichts – gilt, solange die Taste gedrückt ist. Jede Taste erscheint dort nach dem ersten Drücken.
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Die Stiftrückseite radiert ebenfalls.
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
- **Platz schaffen** (↕ in der Werkzeugleiste): aufs Blatt tippen und nach unten ziehen – alles darunter rutscht mit, auch auf den folgenden Seiten. Was über ein Seitenende rutschen würde, beginnt auf der nächsten Seite; fehlende Seiten werden angehängt. Nach oben ziehen schließt leeren Platz. Ein Schritt für Rückgängig.
### 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 (L-Form), Achsenkreuze (ein „+“ in einem Zug oder zwei gekreuzte Geraden, Pfeilspitzen an allen vier Enden) 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, endlose Fläche oder feste Seiten, je Notizbuch:** Beim Anlegen wählbar (Vorgabe: durchgehend; oder endlos), im ☰-Menü umschaltbar – dort auch auf feste Seiten (Seite für Seite blättern mit Pfeilen in der Werkzeugleiste). Durchgehend hängen die Seiten lückenlos untereinander, eine dunkle Linie markiert die Grenze; schnelles Wischen gleitet nach, und mit der Bildlaufleiste am Rand gegenüber der Werkzeugleiste springt man samt Seitenzahl durch lange PDFs; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Endlos ist eine einzige Fläche ohne Rand, auf der man in alle Richtungen schreibt; der PDF-Export enthält nur den beschriebenen Bereich.
- **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).
- **Ordner** für Notizbücher, beliebig verschachtelt (z. B. 3. Semester › Mathe): anlegen, umbenennen, löschen (der Inhalt rückt eine Ebene nach oben) und Notizbücher wie Ordner (samt Inhalt) mit 📂 verschieben. Neue Notizbücher landen im gerade geöffneten Ordner.
- **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).
- Als Flatpak sucht ink-notes nicht selbst nach Updates; die kommen dann über die Software-Verwaltung (`flatpak update`).
---
## 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 ```bash
# Baut .deb, .AppImage und berechnet SHA256SUMS.txt in target/ # Debian-Paket (Zorin OS 18 / Ubuntu 24.04)
./scripts/build-release.sh 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/`: Hinweise:
- `ink-notes_0.1.0_amd64.deb` - Benötigt `glibc >= 2.39` (Ubuntu 24.04, Zorin OS 18) und einen Grafiktreiber mit Vulkan oder OpenGL.
- `ink-notes_0.1.0_x86_64.AppImage` - Meldet das AppImage einen Fehler zu `libfuse.so.2`, entweder `sudo apt install libfuse2t64` installieren oder mit `--appimage-extract-and-run` starten.
- `SHA256SUMS.txt`
- `SHA256SUMS.txt.minisig` (nur mit gesetztem `MINISIGN_SECRET_KEY`, siehe unten) ---
## Tastenkürzel
| 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 |
---
## Entwicklung
### 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`
### 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
```
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
├── library.rs # Ordner der Notizbücher (library.json)
├── 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`.
### Flatpak
Das Manifest liegt in [`packaging/flatpak/`](packaging/flatpak/) (Runtime `org.freedesktop.Platform` 26.08, Rust aus der SDK-Erweiterung `rust-stable`). Lokal bauen und installieren:
```bash
flatpak-builder --user --install --force-clean target/flatpak-build packaging/flatpak/eu.pansi.ink-notes.yml
flatpak run eu.pansi.ink-notes
```
Nach jeder Änderung an `Cargo.lock` muss `packaging/flatpak/cargo-sources.json` mit dem [flatpak-cargo-generator](https://github.com/flatpak/flatpak-builder-tools/tree/master/cargo) neu erzeugt werden (`python3 flatpak-cargo-generator.py Cargo.lock -o packaging/flatpak/cargo-sources.json`). In der Sandbox darf ink-notes Bilder, Downloads und Schreibtisch lesen und in den Dokumente-Ordner exportieren; die Notizbücher liegen unter `~/.var/app/eu.pansi.ink-notes/`.
### Release-Signatur einrichten (einmalig) ### Release-Signatur einrichten (einmalig)
In-App-Updates werden nur mit gültiger Minisign-Signatur installiert. Signiert wird mit [rsign2](https://crates.io/crates/rsign2), einer Minisign-kompatiblen Implementierung (`cargo install rsign2 --locked`). Einmalig ein Schlüsselpaar erzeugen (ohne Passwort, da es nur als CI-Secret verwendet wird): 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 ```bash
rsign generate -W -p ink-notes.pub -s ink-notes.key rsign generate -W -p ink-notes.pub -s ink-notes.key
``` ```
> **Wichtig:** Schlüssel und Signierwerkzeug müssen zusammenpassen. `rsign generate -W` verschlüsselt den Schlüssel mit einem leeren Passwort; das `minisign`-CLI (z. B. Ubuntu-Paket 0.11) kann ihn ohne Terminal nicht laden. `build-release.sh` und die CI signieren deshalb mit `rsign`. 1. Die zweite Zeile 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.
1. Die zweite Zeile aus `ink-notes.pub` (beginnt mit `RW`) in `src/updater.rs` als `UPDATE_SIGNING_PUBLIC_KEY` eintragen und committen. `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.
2. Den kompletten Inhalt von `ink-notes.key` in Gitea unter *Repository → Einstellungen → Actions → Secrets* als `MINISIGN_SECRET_KEY` anlegen.
3. `ink-notes.key` sicher offline aufbewahren und vom Rechner löschen. Geht der Schlüssel verloren, können bestehende Installationen keine Updates mehr prüfen.
Lokal signiert `build-release.sh` nur, wenn `MINISIGN_SECRET_KEY` gesetzt und `rsign` installiert ist. In der CI ist die Signatur Pflicht (`INK_NOTES_REQUIRE_SIGNATURE=1`), und das Skript prüft, dass der geheime Schlüssel zum eingebauten öffentlichen Schlüssel passt. ### CI-Runner (act_runner)
### 2. Installation auf Zorin OS 18 / Ubuntu 24.04 Der Workflow läuft auf dem Label `ubuntu-latest`. Im `config.yaml` des Runners muss es zugeordnet sein, z. B.:
```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
```
---
## 🛡️ Updater-Sicherheit
Der In-App-Updater (`src/updater.rs`) folgt strengen Sicherheitsrichtlinien:
1. **Minisign-Signatur (Pflicht):**
- Jedes Release enthält `SHA256SUMS.txt.minisig`, eine Minisign-Signatur der Prüfsummendatei.
- Der Updater lädt `SHA256SUMS.txt` und die Signatur **vor** dem Paket und prüft sie gegen den in die App eingebauten öffentlichen Schlüssel (`UPDATE_SIGNING_PUBLIC_KEY` in `src/updater.rs`, Crate `minisign-verify`).
- Fehlt die Signatur, ist sie ungültig oder ist kein Schlüssel eingebaut, wird das Update abgelehnt. Ein kompromittierter Release-Server kann so keine manipulierten Pakete ausliefern.
2. **SHA-256-Integritätsprüfung:**
- Der SHA-256-Hash des heruntergeladenen Pakets wird mit dem Eintrag in der signierten `SHA256SUMS.txt` verglichen (`sha2`).
- Weicht die Prüfsumme ab oder fehlt ein Eintrag, wird die Datei sofort gelöscht und der Installationsvorgang mit Fehlermeldung abgebrochen.
3. **Redirect-Schutz & HTTPS:**
- Jede Anfrage erzwingt HTTPS – auch Download-Links aus der Release-API und jedes Weiterleitungsziel.
- Bei HTTP-Redirects werden sensible Autorisierungs-Header (`Authorization: token ...`) verworfen, sobald die Weiterleitung auf eine andere Origin oder ein externes CDN verweist.
4. **Isolierter Download-Pfad & Path-Traversal-Schutz:**
- Downloads werden in einem isolierten Benutzerverzeichnis abgelegt (`$XDG_CACHE_HOME/ink-notes/updates` bzw. `~/.cache/ink-notes/updates`, Berechtigung `0700`).
- Asset-Namen werden sanitisiert: Pfadtrenner (`/`, `\`), Pfadüberquerungen (`..`), Steuerzeichen und leere Dateinamen werden strikt abgewiesen.
5. **Atomarer AppImage-Tausch:**
- Kein direktes Überschreiben laufender Programmdateien per `fs::copy` (verhindert den Linux-Kernel-Fehler *"Text file busy"*).
- Die neue Datei wird temporär im selben Verzeichnis angelegt (`<name>.new.<pid>`), mit Ausführungsrechten (`0755`) versehen, das bestehende AppImage als `<name>.bak` gesichert (dessen Fehler nicht ignoriert werden) und anschließend per atomarem `fs::rename` ausgetauscht.
---
## ⚙️ CI-Runner einrichten (Gitea Actions / act_runner)
Der Veröffentlichungs-Workflow liegt unter [`.gitea/workflows/release.yaml`](.gitea/workflows/release.yaml).
### Runner-Label konfigurieren
Der Workflow nutzt standardmäßig das Runner-Label:
```yaml
runs-on: ubuntu-latest
```
In der Konfigurationsdatei Ihres Gitea `act_runner` (`config.yaml`) muss dieses Label auf ein passendes Container-Image oder den Host gemappt sein:
```yaml ```yaml
runner: runner:
labels: labels:
- "ubuntu-latest:docker://catthehacker/ubuntu:act-latest" - "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
# oder für native Host-Ausführung:
# - "ubuntu-latest:host"
``` ```
### Diagnose & Fehlerbehebung 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.
Bleibt ein Release-Job auf **"Waiting to run"** hängen:
1. **Runner online?** Prüfen Sie in Gitea unter *Einstellungen -> Aktionen -> Runner*, ob der `act_runner` aktiv und mit einem grünen Punkt als *Online* markiert ist.
2. **Label-Übereinstimmung:** Stimmen die im Runner registrierten Labels exakt mit dem `runs-on`-Feld in `.gitea/workflows/release.yaml` überein?
3. **Rechte:** Der Workflow prüft `if [ "$(id -u)" -ne 0 ]` und setzt `sudo` nur ein, wenn der Runner nicht als `root` läuft. Das verhindert Fehler sowohl in unprivilegierten Host-Umgebungen als auch in Root-Containern.
--- ---
## 🚀 Schnellstart (Entwicklung) ## Sicherheit der Updates
### Voraussetzungen 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.
- **Rust & Cargo:** Version 1.75 oder neuer (`rustup update stable`) 2. **Prüfsumme:** Der SHA-256-Wert des heruntergeladenen Pakets muss zum Eintrag in der signierten Liste passen, sonst wird die Datei gelöscht.
- **System-Bibliotheken (Linux):** 3. **Nur HTTPS:** Auch Download-Links und Weiterleitungen müssen HTTPS sein. Bei Weiterleitungen auf einen anderen Server wird der `Authorization`-Header nicht mitgeschickt.
- Vulkan-Treiber (z. B. `mesa-vulkan-drivers` oder proprietäre Treiber) 4. **Eigener Download-Ordner:** `~/.cache/ink-notes/updates` (Rechte `0700`); Dateinamen mit Pfadtrennern, `..` oder Steuerzeichen werden abgelehnt.
- X11 / Wayland Bibliotheken (`libx11-dev`, `libxcursor-dev`, `libxrandr-dev`, `libxi-dev`, `libxkbcommon-dev`) 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.
### 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
```
--- ---
## ⌨️ Tastatur- & Maus-Shortcuts ## Mitmachen
| Aktion | Tastenkürzel / Mausgeste | Fehlermeldungen, Ideen und Beiträge sind willkommen – am liebsten als [Issue auf Gitea](https://gitea.pansi.eu/harald/ink-notes/issues); [GitHub](https://github.com/haraldpansi-gif/ink-notes) ist ein Spiegel. Details in [CONTRIBUTING.md](CONTRIBUTING.md), Sicherheitslücken bitte vertraulich wie in [SECURITY.md](SECURITY.md) beschrieben. Es gilt der [Code of Conduct](CODE_OF_CONDUCT.md).
| :--- | :--- |
| **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 ## Lizenz
```text [MIT](LICENSE), Copyright © 2026 Harald Pansi.
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).
+26
View File
@@ -0,0 +1,26 @@
# Sicherheit / Security Policy
## Unterstützte Versionen / Supported versions
Sicherheitskorrekturen gibt es nur für die jeweils **neueste Version** (siehe [Releases](https://gitea.pansi.eu/harald/ink-notes/releases)). Die App bietet Updates selbst an (☰ → Updates); als Flatpak kommen sie über die Software-Verwaltung.
Security fixes are only provided for the **latest release**. The app offers updates itself (☰ → Updates); the Flatpak is updated through the software manager.
## Sicherheitslücke melden / Reporting a vulnerability
Bitte Sicherheitslücken **nicht** in einem öffentlichen Issue melden, sondern per E-Mail an **harald@pansi.eu** – möglichst mit:
- betroffener Version und Installationsart (.deb, AppImage, Flatpak, selbst gebaut),
- einer Beschreibung, was passieren kann,
- den Schritten oder einer Datei (z. B. ein präpariertes Notizbuch oder PDF), mit denen es sich nachstellen lässt.
Du bekommst innerhalb einer Woche eine Antwort. Ist die Lücke bestätigt, erscheint die Korrektur als neue Version; auf Wunsch wirst du in den Release-Notes genannt.
Please do **not** report vulnerabilities in a public issue. Send an e-mail to **harald@pansi.eu** instead, ideally with the affected version and installation type, a description of the impact and steps or a file (e.g. a crafted notebook or PDF) to reproduce it. You will get an answer within a week. Confirmed issues are fixed in a new release; on request you will be credited in the release notes.
## Besonders interessant / Areas of interest
- **Updates:** Prüfung der Minisign-Signatur und der SHA-256-Prüfsummen, Download und Austausch der Pakete (siehe „Sicherheit der Updates“ im [README](README.md#sicherheit-der-updates)).
- **Dateien öffnen:** importierte PDFs und Bilder, Notizbuch-Dateien ([Dateiformat](docs/FILE_FORMAT.md)) – z. B. Abstürze oder Schreiben außerhalb des Datenordners durch präparierte Dateien.
- **Updates:** verification of the Minisign signature and SHA-256 checksums, downloading and replacing packages.
- **Opening files:** imported PDFs and images, notebook files – e.g. crashes or writes outside the data folder caused by crafted files.
+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

+166 -117
View File
@@ -1,153 +1,202 @@
# 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] [Stift / Touch / Maus]
│ │ Bildschirmposition, Druck, Zeit
▼ ▼
[winit Event-Loop] [Bildschirm → Seitenkoordinaten] (viewport.rs)
│ (tx, ty, pressure, timestamp) │
▼ ▼
[Viewport Invertierung] (screen_to_page: Umrechnung in DIN-A4-Einheiten) [Druckkurve] (settings.rs, vom Nutzer einstellbar)
│ │
▼ ▼
[1€-Filter] (Jitter-Beseitigung bei langsamen Bewegungen) [1€-Filter] (filter.rs)
│ │
▼ ▼
[Centripetal Catmull-Rom] (C1-stetige Spline-Interpolation, alpha = 0.5) [Catmull-Rom-Spline + Dreiecksstreifen] (spline.rs) → Live-Puffer auf der GPU
│ │ Stift abgehoben
▼ ▼
[Mesh-Tessellierung] (Drucksensitive Dreiecksstreifen via Normalen) [Nachbearbeitung] (spline.rs: finalize_stroke)
│ │
▼ ▼
[wgpu GPU-Buffer] (Vertex- & Index-Buffer im VRAM) [Strich auf der Seite + Undo-Eintrag] (document.rs, history.rs)
``` ```
### 1.1. 1€-Filter (One-Euro-Filter) ### 2.1 1€-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). 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}|$$ $$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 Bei langsamer Bewegung glättet er stark, bei schneller kaum.
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.
### 1.3. Drucksensitive Dreiecksstreifen (Tessellierung) ### 2.2 Zentripetaler Catmull-Rom-Spline
Aus den interpolierten Spline-Punkten mit Radius $r_i = f(\text{pressure}_i)$ wird ein Triangle-Strip erzeugt: Die gefilterten Punkte werden mit einem zentripetalen Catmull-Rom-Spline ($\alpha = 0{,}5$) interpoliert:
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$$
### 1.4. Zwei-Stufen-Modell (Live-Glättung vs. akausale Post-Stroke-Finalisierung) $$t_{i+1} = t_i + \lVert P_{i+1} - P_i \rVert^{\alpha}$$
Um minimale Latenz beim Zeichnen mit maximaler Präzision und Ecken-Erhaltung zu verbinden, arbeitet `ink-notes` mit einem strikten Zwei-Stufen-Modell:
1. **Live-Phase (Kausal, Zero-Latency):** Diese Parametrisierung erzeugt bei engen Kurven keine Schlaufen oder Spitzen und ist an den Stützstellen $C^1$-stetig.
- 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.3 Dreiecksstreifen
2. **Commit-Phase (Akausal, `finalize_stroke`):** Aus den Spline-Punkten mit Radius $r_i$ (aus Druck und Strichstärke) entsteht ein Dreiecksstreifen:
- 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. $$V_{i,\text{links}} = P_i + N_i \cdot r_i, \qquad V_{i,\text{rechts}} = P_i - N_i \cdot r_i$$
- **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. $N_i$ ist die Normale der Laufrichtung; an inneren Punkten wird über benachbarte Segmente gemittelt.
- **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. ### 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 ### 3.1 Seitenkoordinaten
Die Anwendung rechnet intern in virtuellen DIN-A4-Koordinaten: 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.
- Breite: **2100 Einheiten** (210 mm)
- Höhe: **2970 Einheiten** (297 mm)
- 1 mm entspricht exakt **10 Einheiten**.
### 2.2. Viewport & Projektionsmatrix (`viewport.rs`) ### 3.2 Maßstab und Kamera
Der Vertex-Shader transformiert DIN-A4-Koordinaten über eine Orthogonal-Projektion kombiniert mit Pan und Zoom in Normalized Device Coordinates (NDC, $[-1, 1]$): - `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}}}$$ $$\vec{p}_{\text{neu}} = \vec{c} - (\vec{c} - \vec{p}_{\text{alt}}) \cdot \frac{z_{\text{neu}}}{z_{\text{alt}}}$$
### 2.4. Render-Passes - Im Shader wird jeder Punkt mit `transform_scale` und `transform_offset` (aus `CameraUniforms`) direkt in Gerätekoordinaten umgerechnet.
In jedem Frame werden zwei Hauptpässe durchlaufen:
1. **Pass 1 (Canvas):** ### 3.3 Durchgehende Ansicht
- Hintergrund-Quad und Linien/Gitter (Instanced oder Batch Vertex-Buffer). Ob ein Notizbuch Seite für Seite, durchgehend oder als endlose Fläche angezeigt wird, steht in seinem Manifest (`layout`, siehe [`FILE_FORMAT.md`](FILE_FORMAT.md)).
- Gespeicherte Striche und Geometrie-Meshes.
- Aktiver Live-Strich (Ephemeral Buffer). 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).
- Auswahl-Overlay & Transformationsgriffe.
2. **Pass 2 (egui UI):** 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.
- Floating Glassmorphism-Toolbar.
- Farb- und Werkzeug-Popovers.
- Modal-Dokumentenmanager.
--- ---
## 3. Geometrieerkennung & Shape-Fitting (`geometry.rs`) ### 3.4 Endlose Fläche
Ein endloses Notizbuch hat eine einzige Seite von 10 × 10 m (`ENDLESS_PAGE_SIZE`). Damit bleibt alles wie bei jeder anderen Seite – Eingabe, Treffertests, Undo, Speichern –, nur der Rand ist praktisch unerreichbar. `PageViewport::calculate_endless` wählt Maßstab und Lage so, als läge eine DIN-A4-Seite am Startausschnitt (`ENDLESS_HOME`, ein A4-Rahmen in der Mitte); 100 % Zoom und „Ansicht zurücksetzen“ zeigen also diesen Rahmen. Bilder und Textfelder richten ihre Größe bzw. ihren Umbruch an DIN A4 aus (`Page::reference_size`, `Page::text_right_edge`).
Die Erkennung erfolgt rein analytisch und deterministisch ohne schwere ML-Frameworks: Beim Wechsel von und zu Seiten (`Notebook::make_endless`/`make_paged`) wird der Inhalt um `ENDLESS_HOME` verschoben und der Verlauf der Seite geleert. Zurück zu Seiten geht nur, solange alles im Startausschnitt liegt. Export und Bilder einer Auswahl rastern bzw. exportieren nur einen Ausschnitt (`Page::cropped`), beim Export auf das Musterraster gelegt (`Page::endless_export_bounds`).
### 3.1. Ramer-Douglas-Peucker (RDP) ## 4. Rendering
Reduziert den Rohpfad auf signifikante Eckpunkte mit konfigurierbarem Toleranzabstand $\epsilon$.
### 3.2. Erkennungsregeln: Ein Frame besteht aus einem Render-Durchgang auf die Fensterfläche (`canvas.rs`):
- **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.
### 3.3. Draw-and-Hold Erkennung 1. Für jede sichtbare Nachbarseite: Papier und Muster, Bilder/PDF-Seite/Text, Tinte.
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. 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.
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.
### 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.
**Platz schaffen** (`Notebook::make_space`) betrifft mehrere Seiten: Die Seite, auf der angesetzt wurde, und die folgenden bis vor die nächste PDF-Seite gelten als durchgehende Spalte. Alles unterhalb der Ansatzhöhe rutscht um denselben Betrag; ein Objekt, das über ein Seitenende ragen würde, beginnt 10 mm unter dem Anfang der nächsten Seite, und alles danach rutscht entsprechend weiter. Fehlende Seiten werden eingefügt. Der Schritt (`SpaceShift`: jeder verschobene Strich vorher/nachher mit Seite) landet im Verlauf der Seite, auf der angesetzt wurde; `Notebook::undo`/`redo` buchen ihn über alle Seiten zurück (später geänderte Striche bleiben unberührt) und entfernen dafür angelegte, leer gebliebene Seiten wieder.
---
## 6. Formerkennung
`geometry.rs` erkennt Formen rein geometrisch, ohne Machine Learning. Zuerst wird auf ein Achsenkreuz geprüft (alle Teilstücke liegen auf einer waagerechten und einer senkrechten Linie, beide reichen auf beiden Seiten über den Schnittpunkt hinaus). 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.
- **Achsenkreuz:** ein „+“ in einem Zug; die Achsen werden waagerecht und senkrecht ausgerichtet und bekommen an allen vier Enden Pfeilspitzen.
- **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] [Änderung]
│ │ mark_page_dirty / mark_manifest_dirty
▼ ▼
[mark_page_dirty] [AutosaveController] ── 1,5 s nach der letzten Änderung, spätestens nach 10 s ──► [Autosave-Thread]
│ │
▼ ▼
[AutosaveController] ──(1.5s Debounce)──► [Hintergrund-Thread (MPSC)] [temporäre Datei schreiben + fsync]
│ │
▼ ▼
[Temp-Datei *.tmp schreiben] [rename über die alte Datei, Ordner-fsync]
│
▼
[Atomares std::fs::rename]
│
▼
[manifest.json / page_<id>.json]
``` ```
- **Keine UI-Blockierung:** Disk-I/O erfolgt vollständig in einem dedizierten Worker-Thread. - Der Haupt-Thread schreibt nie selbst; er übergibt fertige Seitendaten an den Autosave-Thread.
- **Atomarität:** Daten werden stets zuerst in `.json.tmp` geschrieben und via `fs::rename` atomar ersetzt. - Schlägt ein Schreibvorgang fehl, wird die Seite mit wachsendem Abstand (1 s bis 30 s) erneut versucht; der Status wird rot.
- **Verlustfreier Notizbuchwechsel:** Bei `switch_notebook_dir` wird synchron `flush()` aufgerufen, um alle ungespeicherten Daten des aktuellen Dokuments sicherzustellen, bevor der Pfad gewechselt wird. - `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“.
+161 -101
View File
@@ -1,137 +1,197 @@
# Dateiformat & Speicherstruktur von ink-notes # Dateiformat von ink-notes
Dieses Dokument beschreibt die Speicherstruktur, die JSON-Dateiformate und die Versionshistorie, 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 9**.
--- ---
## 1. Verzeichnisstruktur ## 1. Ordner
Notizbücher werden standardmäßig unter dem XDG-Datenpfad gespeichert:
```text ```text
~/.local/share/ink-notes/ ~/.local/share/ink-notes/ ($XDG_DATA_HOME/ink-notes)
└── notebooks/ ├── notebooks/
├── <notebook_id_1>/ │ ├── library.json Ordner der Notizbücher (siehe unten)
│ ├── manifest.json │ ├── <notizbuch-id>/
│ ├── manifest.json.v0.bak (optional, Migrations-Backup) │ │ ├── manifest.json Metadaten und Seitenreihenfolge
│ ├── page_1.json │ │ ├── manifest.json.bak Kopie des vorherigen Manifests
│ ├── page_1.json.corrupt-1775460000 (optional, Quarantäne bei Parsefehlern) │ │ ├── page_1.json eine Datei je Seite
│ ├── page_2.json │ │ ├── page_2.json
│ └── ... │ │ ├── page_7.json.corrupt-<ts> beiseitegelegte beschädigte Seite (falls vorgekommen)
└── <notebook_id_2>/ │ │ ├── assets/ eingefügte Bilder und importierte PDFs
├── manifest.json │ │ │ ├── <sha256>.png
└── page_1.json │ │ │ └── <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`) ### Ordner (`library.json`)
Ordner wie „3. Semester › Mathe“ sind rein logisch: Die Notizbücher bleiben in ihren Verzeichnissen, `library.json` hält nur fest, welcher Ordner welches Notizbuch enthält. Umbenennen, Verschieben (samt Inhalt) und Löschen von Ordnern ändern deshalb nie eine Notizbuch-Datei.
Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge und IDs aller Seiten:
```json ```json
{ {
"format_version": 2, "folders": ["3. Semester", "3. Semester/Mathe", "4. Semester"],
"id": "notebook-a1b2c3d4", "notebooks": { "analysis_1775460000": "3. Semester/Mathe" }
"title": "Mathematik Vorlesung",
"pages": [
{
"id": 1,
"background": "Grid5mm"
},
{
"id": 2,
"background": "DotGrid"
}
],
"page_ids": [1, 2],
"current_page_index": 0,
"next_page_id": 3,
"next_stroke_id": 42,
"created_at": 1775460000,
"modified_at": 1775463600
} }
``` ```
### Felder: - Ordner sind Pfade mit `/` als Trenner; Namen enthalten selbst kein `/` und höchstens 80 Zeichen.
- `format_version`: Format-Versionsnummer (aktuell `2`, bei unversionierten Legacy-Dateien implizit `0`). - Notizbücher ohne Eintrag liegen auf der obersten Ebene.
- `id`: Eindeutige ID des Notizbuchs (z. B. UUID oder `"default"`). - Wird ein Ordner gelöscht, rücken seine Notizbücher und Unterordner eine Ebene nach oben.
- `title`: Vom Nutzer vergebener Anzeigename des Notizbuchs. - Fehlt die Datei oder ist sie unlesbar, liegen alle Notizbücher auf der obersten Ebene; fehlende übergeordnete Ordner werden beim Laden ergänzt.
- `pages`: Geordnete Liste von Seitenmetadaten (`id`, optional `background`).
- `page_ids`: Abwärtskompatible Liste der Seiten-IDs.
- `current_page_index`: Index der zuletzt aktiven Seite für Session-Restore.
- `next_page_id`: Inkrementierender ID-Zähler für neue Seiten.
- `next_stroke_id`: Globaler ID-Zähler für neue Striche.
- `created_at` / `modified_at`: UNIX-Zeitstempel in Sekunden.
--- ---
## 3. Seitendatei (`page_<id>.json`) ## 2. Manifest (`manifest.json`)
Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelles inkrementelles Speichern zu ermöglichen.
Die Speicherung erfolgt ab Version 2 als kompaktes JSON (`serde_json::to_vec`), wodurch gegenüber Pretty-Printed JSON über 50 % Speicherplatz eingespart wird.
```json ```json
{"format_version":2,"id":1,"background":"Grid5mm","strokes":[{"id":1,"color":[0.12,0.12,0.14,1.0],"samples":[{"x":350.2,"y":420.5,"p":0.65,"t":12.345},{"x":352.1,"y":425.8,"p":0.70,"t":12.355}],"shape":null,"tool":"pen","width":5.0,"finalized":true},{"id":2,"color":[0.15,0.35,0.85,1.0],"samples":[],"shape":{"Rectangle":{"top_left":{"x":500.0,"y":600.0},"width":300.0,"height":200.0}},"tool":"shape","width":20.0,"finalized":true}]} {
"format_version": 9,
"id": "vorlesung_1775460000",
"title": "Vorlesung",
"created_at": 1775460000,
"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,
"layout": "Paged"
}
``` ```
### Felder: | Feld | Bedeutung |
- `format_version`: Format-Versionsnummer (`2`). | :--- | :--- |
- `id`: Eindeutige ID der Seite. | `format_version` | Formatversion (siehe Abschnitt 5) |
- `background`: Papiermuster (`Blank`, `Grid5mm`, `Lined`, `DotGrid`). | `id`, `title` | ID (= Ordnername) und Anzeigename |
- `strokes`: Liste der auf der Seite gezeichneten Striche und Vektorformen. | `created_at`, `modified_at` | UNIX-Zeit in Sekunden |
- `id`: Eindeutige ID des Strichs. | `current_page_index` | zuletzt geöffnete Seite |
- `color`: RGBA-Farbwerte im Bereich `[0.0, 1.0]`. | `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 |
- `samples`: Rohabtastpunkte entlang des Pfads (`x`, `y` in DIN-A4-Einheiten, `p` Druckwert in `[0.0, 1.0]`, `t` Zeitstempel in Sekunden). | `page_ids` | dieselbe Reihenfolge als reine ID-Liste (für ältere Versionen) |
- `shape`: Optionales Enum für geometrische Vektorformen: | `next_stroke_id`, `next_page_id` | nächste freie IDs |
- `Line { start, end }` | `layout` | Anordnung der Seiten: `Paged` (Seite für Seite), `Continuous` (durchgehend untereinander) oder `Endless` (endlose Fläche, siehe unten). Seit 0.28; fehlt das Feld, gilt die frühere globale Einstellung `continuous_scroll` aus `ui.json` |
- `Rectangle { top_left, width, height }`
- `Circle { center, radius }` **Endlose Notizbücher** (Version 9) haben genau eine Seite der Größe `[100000.0, 100000.0]` (10 × 10 m). Der Startausschnitt ist ein DIN-A4-Rahmen in ihrer Mitte (ab `[48950, 48515]`); Koordinaten bleiben positiv. Beim Export wird nur der beschriebene Bereich mit 10 mm Rand zur PDF-Seite.
- `Axes { origin, x_end, y_end, has_arrows }`
- `Triangle { p1, p2, p3 }`
- `Rhombus { center, width, height }`
- `Octahedron { center, radius }`
- `tool`: Zeichenwerkzeug (`"pen"`, `"highlighter"`, `"shape"`).
- `width`: Strichbreite in internen DIN-A4-Einheiten (10 Einheiten = 1 mm; z. B. `5.0` für 0.5 mm Stift, `60.0` für 6.0 mm Marker).
- `finalized`: Boolesches Flag für Striche, die das Zwei-Stufen-Inking (Eckenerkennung, Glättung, runde Endkappen) durchlaufen haben.
--- ---
## 4. Datensicherheit & Migration ## 3. Seite (`page_<id>.json`)
### Atomares Schreiben & Verzeichnis-Sync Jede Seite liegt in einer eigenen Datei (kompaktes JSON, hier zur Lesbarkeit umbrochen):
1. Daten werden in `<ziel>.tmp.<pid>.<nanos>` serialisiert und per `fsync` auf das Speichermedium geschrieben (Dateirechte `0600` auf Unix).
2. Mittels `std::fs::rename("<ziel>.tmp", "<ziel>")` wird die Zieldatei auf POSIX-Systemen atomar ersetzt.
3. Nach dem Rename wird das übergeordnete Verzeichnis synchronisiert (`sync_all()`), um Persistenz bei Stromausfall zu garantieren. Verzeichnisse erhalten Berechtigung `0700`.
### Quarantäne bei Parsefehlern ```json
Wird eine Seitendatei beim Laden als fehlerhaft erkannt (z. B. unvollständiges oder korruptes JSON), wird sie nicht stillschweigend gelöscht oder überschrieben, sondern nach `page_<id>.json.corrupt-<unix_ts>` verschoben. Die Ersatzseite wird als schreibgeschützt markiert. {
"format_version": 8,
"id": 1,
"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": [ … ]
}
```
### Migrations-Backups & Rolling Backups | Feld | Bedeutung |
Beim ersten Speichern einer Datei im Legacy-Format (Version 0) wird vor dem Überschreiben ein Backup `*.v0.bak` angelegt (z. B. `manifest.json.v0.bak`, `page_1.json.v0.bak`). | :--- | :--- |
Zusätzlich existiert ein Rolling-Backup `manifest.json.bak`, auf das bei beschädigtem Manifest automatisch zurückgegriffen wird. | `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]`). Höchstens 2 × 2 m, außer der endlosen Seite `[100000, 100000]` |
| `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, negative_ends? }`, `Octahedron { … }`. Punkte sind `[x, y]`. Beim Achsenkreuz enthält `negative_ends` die Enden der x- und der y-Achse auf der anderen Seite des Ursprungs; ältere Versionen ignorieren das Feld und zeigen ein L-förmiges Koordinatensystem.
**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
--- ---
## 5. Versionshistorie ## 4. Sicherheit beim Speichern
| Version | Status | Beschreibung | ### Atomares Schreiben
|---|---|---| 1. Der Inhalt wird in `<datei>.tmp.<pid>.<nanosekunden>` geschrieben und mit `fsync` auf den Datenträger gebracht.
| **0** | Legacy | Unversioniertes Format. `format_version` fehlt in JSON. Implizit als Version 0 interpretiert. | 2. `rename` ersetzt die alte Datei in einem Schritt.
| **1** | Überholt | Einführung von `format_version: u32` in `manifest.json` und `page_<id>.json`. Seiten-Hintergrund im Manifest gecached (`pages: Vec<PageMeta>`). Strenges Versionsmatching (neuere Versionen werden abgelehnt). Automatische Migration mit `*.v0.bak`. | 3. Danach wird auch der Ordner mit `fsync` gesichert.
| **2** | **Aktuell** | Stroke-Treue: Ergänzung von `tool`, `width` und `finalized` in `StrokeData` für exakte Rekonstruktion aller Vertizes und Splines. Umstellung auf kompaktes JSON (`serde_json::to_vec`, ca. 54% Platzerparnis). |
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.
--- ---
## 6. Analyse & Bewertung: Binärformat (z. B. `postcard`) ## 5. Versionen
Eine Umstellung auf ein Binärformat wie `postcard` wurde evaluiert: | Version | App | Änderung |
- **Vorteile**: | :--- | :--- | :--- |
- Weitere 20–30 % Reduktion der Dateigröße gegenüber kompaktem JSON. | 0 | – | ohne `format_version` |
- Minimal schnellere Serialisierung auf leistungsschwachen Systemen. | 1 | – | `format_version`, Hintergründe im Manifest |
- **Nachteile**: | 2 | – | `tool`, `width`, `finalized` an Strichen; kompaktes JSON |
- Verlust der Text-Transparenz (JSON lässt sich direkt inspizieren, mit `git`/Tools diffen und bei Korruption manuell reparieren). | 3 | 0.3.7 | Formen `Ellipse` und `Arrow` |
- Snapshotting und JSON-Verarbeitung liegen im Benchmark bereits bei **0,02 ms (1k Samples)** bis **0,70 ms (50k Samples)** – also weit unter dem Zielwert von 1 ms. | 4 | 0.5 | Bilder (`image`, Ordner `assets/`) |
- Kompaktes JSON spart bereits über 53 % Dateigröße ein. | 5 | 0.6.1 | importierte PDF-Seiten (`pdf_background`) |
- **Entscheidung**: | 6 | 0.7 | Seitentitel (`title` in Seite und Manifest) |
Kompaktes JSON bietet aktuell das optimale Verhältnis aus Ausfallsicherheit, Benutzerfreundlichkeit, Performance und Portabilität. Ein Binärformat wie `postcard` bleibt als Option für spätere Riesen-Notizbücher (> 500 Seiten) denkbar, wird aktuell jedoch nicht eingeführt. | 7 | 0.10 | Textfelder (`text`) |
| 8 | 0.11 | Seitengröße je Seite (`size` in Seite und Manifest) |
| 9 | 0.29 | Seitenanordnung je Notizbuch (`layout` im Manifest), endlose Notizbücher |
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.
+10 -6
View File
@@ -1,18 +1,22 @@
[Desktop Entry] [Desktop Entry]
Version=1.0 Version=1.0
Name=ink-notes Name=ink-notes
GenericName=Notizbuch- & Inking-App GenericName=Note-Taking & Inking App
Comment=Latenzoptimierte Vektor-Notizen mit Stylus, Stift & Geometrie-Erkennung GenericName[de]=Notizbuch- & Inking-App
Comment=Handwritten notes with pen, touch and mouse; annotate PDFs
Comment[de]=Handschriftliche Notizen mit Stift, Touch und Maus; PDFs beschriften
Exec=ink-notes %F Exec=ink-notes %F
Icon=eu.pansi.ink-notes Icon=eu.pansi.ink-notes
Terminal=false Terminal=false
Type=Application Type=Application
Categories=Office;Graphics;Utility; Categories=Office;Graphics;
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector; Keywords=Note;Pen;Ink;Inking;Stylus;Drawing;PDF;Vector;Text;Annotation;
StartupWMClass=ink-notes Keywords[de]=Notiz;Stift;Ink;Inking;Stylus;Zeichnen;PDF;Vektor;Text;Annotation;
StartupWMClass=eu.pansi.ink-notes
MimeType=application/x-inknotes; MimeType=application/x-inknotes;
Actions=NewWindow; Actions=NewWindow;
[Desktop Action NewWindow] [Desktop Action NewWindow]
Name=Neues Fenster Name=New Window
Name[de]=Neues Fenster
Exec=ink-notes Exec=ink-notes
+122 -12
View File
@@ -4,20 +4,34 @@
<metadata_license>CC0-1.0</metadata_license> <metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license> <project_license>MIT</project_license>
<name>ink-notes</name> <name>ink-notes</name>
<summary>Latenzoptimierte Vektor-Notizbuch- und Inking-App für Linux</summary> <summary>Handwritten notes with pen, touch and mouse</summary>
<summary xml:lang="de">Notizbuch-App für Stift, Touch und Maus</summary>
<description> <description>
<p> <p>
ink-notes ist eine hardwarebeschleunigte, hochperformante Notizbuch- und Inking-Anwendung für Linux, ink-notes is a notebook app for Linux tablets and convertibles. Handwriting is stored as
geschrieben in Rust mit wgpu. Sie bietet minimale Eingabelatenz mit Stylus, Touchpad oder Maus. vectors; write with a pen, your finger, a touchpad or a mouse.
</p> </p>
<p>Hauptmerkmale:</p> <p xml:lang="de">
ink-notes ist eine Notizbuch-App für Linux-Tablets und Convertibles. Handschrift wird als
Vektoren gespeichert; geschrieben wird mit Stift, Finger, Touchpad oder Maus.
</p>
<p>Features:</p>
<p xml:lang="de">Funktionen:</p>
<ul> <ul>
<li>Zwei-Stufen-Inking-Modell: Echtzeit-Glättung mit 1€-Filter und akausale Ecken-Erhaltung</li> <li>Pens and highlighter with pressure, smoothing while writing and sharp corners</li>
<li>Automatische Geometrie- und Formerkennung (Geraden, Rechtecke, Kreise, Koordinatensysteme, Polygone)</li> <li xml:lang="de">Stifte und Textmarker mit Druckstärke, Glättung beim Schreiben und Ecken-Erhaltung</li>
<li>Stufenloser Viewport mit cursor-zentriertem Zoom (25% bis 500%) und Panning</li> <li>Shape recognition: hold at the end of a stroke to get clean lines, arrows, circles, rectangles, triangles and coordinate systems</li>
<li>Objektauswahl, Lasso, Skalieren, stufenloses Drehen und Multicolor-Umfärbung</li> <li xml:lang="de">Formerkennung: am Strichende halten für saubere Linien, Pfeile, Kreise, Rechtecke, Dreiecke und Koordinatensysteme</li>
<li>Multi-Dokumenten- und Notizbuch-Verwaltung mit atomarem Autosave</li> <li>Eraser for whole strokes, partial strokes or highlighter only; virtual ruler</li>
<li>Vektor-PDF-Export für Druck und Weitergabe</li> <li xml:lang="de">Radierer für ganze Striche, Teilstriche oder nur Textmarker; virtuelles Lineal</li>
<li>Select, move, rotate, scale and recolor; undo for every change</li>
<li xml:lang="de">Auswählen, Verschieben, Drehen, Skalieren und Umfärben; Rückgängig für alle Änderungen</li>
<li>Text boxes, images and PDF import to annotate lecture slides and worksheets</li>
<li xml:lang="de">Textfelder, Bilder und PDF-Import zum Beschriften von Vorlesungsfolien und Übungsblättern</li>
<li>Notebooks in folders, continuous scrolling, automatic saving in the background</li>
<li xml:lang="de">Notizbücher in Ordnern, durchgehendes Scrollen, automatisches Speichern im Hintergrund</li>
<li>Export as PDF (with searchable text), PNG or SVG</li>
<li xml:lang="de">Export als PDF (mit durchsuchbarem Text), PNG oder SVG</li>
</ul> </ul>
</description> </description>
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable> <launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
@@ -27,12 +41,108 @@
<categories> <categories>
<category>Office</category> <category>Office</category>
<category>Graphics</category> <category>Graphics</category>
<category>Utility</category>
</categories> </categories>
<keywords>
<keyword>notes</keyword>
<keyword>handwriting</keyword>
<keyword>stylus</keyword>
<keyword>pen</keyword>
<keyword>PDF</keyword>
<keyword>annotate</keyword>
</keywords>
<supports>
<control>pointing</control>
<control>touch</control>
<control>tablet</control>
</supports>
<developer id="eu.pansi"> <developer id="eu.pansi">
<name>Harald</name> <name>Harald Pansi</name>
</developer> </developer>
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url> <url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url> <url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
<url type="vcs-browser">https://gitea.pansi.eu/harald/ink-notes</url>
<screenshots>
<screenshot type="default">
<caption>Notes with highlighter, handwriting and text boxes</caption>
<caption xml:lang="de">Notizen mit Textmarker, Handschrift und Textfeldern</caption>
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/notes.png</image>
</screenshot>
<screenshot>
<caption>Recognized shapes: coordinate system, triangle, circle, octahedron</caption>
<caption xml:lang="de">Erkannte Formen: Koordinatensystem, Dreieck, Kreis, Oktaeder</caption>
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/shapes.png</image>
</screenshot>
<screenshot>
<caption>Annotating imported PDF slides</caption>
<caption xml:lang="de">Importierte PDF-Folien beschriften</caption>
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/pdf.png</image>
</screenshot>
</screenshots>
<branding>
<color type="primary" scheme_preference="light">#a8c8f0</color>
<color type="primary" scheme_preference="dark">#1f3a5f</color>
</branding>
<content_rating type="oars-1.1" /> <content_rating type="oars-1.1" />
<releases>
<release version="0.30.0" date="2026-10-19">
<description>
<p>Faster scrolling through long notebooks and PDFs: a quick finger swipe keeps gliding, and a scrollbar with page numbers jumps through the notebook. The mouse wheel scrolls further per step. New notebooks scroll continuously by default.</p>
</description>
</release>
<release version="0.29.1" date="2026-10-19">
<description>
<p>Moving, rotating and scaling large selections is smooth again and no longer keeps the processor busy. On endless notebooks the mouse wheel scrolls (Ctrl+wheel zooms).</p>
</description>
</release>
<release version="0.29.0" date="2026-10-18">
<description>
<p>Endless notebooks: one surface without edges to write, pan and zoom in every direction. Choose it when creating a notebook or switch a single-page notebook in the menu; PDF export contains only the written area.</p>
</description>
</release>
<release version="0.28.0" date="2026-10-18">
<description>
<p>Each notebook remembers whether it flips page by page or scrolls continuously. New notebooks start with fixed pages; the layout can be chosen when creating a notebook and changed later in the menu.</p>
</description>
</release>
<release version="0.27.1" date="2026-10-18">
<description>
<p>The pens in the toolbar are shown as pencils in their own colour, so they are easy to tell apart.</p>
</description>
</release>
<release version="0.27.0" date="2026-10-18">
<description>
<p>Pictures keep their proportions when resized. A lock button in the selection tool switches between proportional and free resizing; pictures start locked, other objects free.</p>
</description>
</release>
<release version="0.26.2" date="2026-10-13">
<description>
<p>Smoother scrolling through large imported PDFs: pages no longer stall the display while they are prepared, and pages scrolled past quickly are skipped.</p>
</description>
</release>
<release version="0.26.1" date="2026-10-10">
<description>
<p>Screenshots in the app description for software centers. The app itself is unchanged.</p>
</description>
</release>
<release version="0.26.0" date="2026-10-10">
<description>
<p>Select and move objects with a finger; text and make-space tools also work with a finger. Exports go to the documents folder of the user directories.</p>
</description>
</release>
<release version="0.25.0" date="2026-10-09">
<description>
<p>English user interface; the language follows the system and can be changed in the menu. Holding to snap a shape tolerates a slightly trembling pen.</p>
</description>
</release>
<release version="0.24.0" date="2026-10-09">
<description>
<p>Axis cross from two crossing strokes.</p>
</description>
</release>
<release version="0.23.0" date="2026-10-09">
<description>
<p>The on-screen keyboard of GNOME appears when tapping a text field.</p>
</description>
</release>
</releases>
</component> </component>
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
# Flatpak-Manifest für ink-notes (Flathub).
#
# Lokal bauen und installieren (aus dem Repository-Wurzelverzeichnis):
# flatpak-builder --user --install --force-clean target/flatpak-build \
# packaging/flatpak/eu.pansi.ink-notes.yml
#
# cargo-sources.json nach jeder Änderung an Cargo.lock neu erzeugen:
# python3 flatpak-cargo-generator.py Cargo.lock -o packaging/flatpak/cargo-sources.json
# (flatpak-builder-tools, https://github.com/flatpak/flatpak-builder-tools/tree/master/cargo)
#
# Für die Einreichung bei Flathub die Quelle `type: dir` durch das Release-Tag ersetzen
# (type: git, url, tag, commit).
id: eu.pansi.ink-notes
runtime: org.freedesktop.Platform
runtime-version: '26.08'
sdk: org.freedesktop.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: ink-notes
finish-args:
- --socket=wayland
- --socket=fallback-x11
- --share=ipc
- --device=dri
# Die eingebaute Dateiauswahl (Bilder und PDFs einfügen) arbeitet ohne Desktop-Portal und
# bietet Bilder, Downloads und Schreibtisch an; Export (PDF, PNG, SVG) in den Dokumente-Ordner.
# Ziehen aus dem Dateimanager geht über das Dokumenten-Portal auch aus anderen Ordnern.
- --filesystem=xdg-pictures:ro
- --filesystem=xdg-download:ro
- --filesystem=xdg-desktop:ro
- --filesystem=xdg-documents
build-options:
append-path: /usr/lib/sdk/rust-stable/bin
env:
CARGO_HOME: /run/build/ink-notes/cargo
modules:
- name: ink-notes
buildsystem: simple
build-commands:
- cargo --offline build --release --locked
- install -Dm755 target/release/ink-notes /app/bin/ink-notes
- install -Dm644 packaging/eu.pansi.ink-notes.desktop /app/share/applications/eu.pansi.ink-notes.desktop
- install -Dm644 packaging/eu.pansi.ink-notes.metainfo.xml /app/share/metainfo/eu.pansi.ink-notes.metainfo.xml
- install -Dm644 assets/ink-notes.svg /app/share/icons/hicolor/scalable/apps/eu.pansi.ink-notes.svg
- install -Dm644 assets/ink-notes.png /app/share/icons/hicolor/256x256/apps/eu.pansi.ink-notes.png
sources:
- type: dir
path: ../..
skip:
- target
- .git
- .flatpak-builder
- cargo-sources.json
+10 -6
View File
@@ -1,18 +1,22 @@
[Desktop Entry] [Desktop Entry]
Version=1.0 Version=1.0
Name=ink-notes Name=ink-notes
GenericName=Notizbuch- & Inking-App GenericName=Note-Taking & Inking App
Comment=Latenzoptimierte Vektor-Notizen mit Stylus, Stift & Geometrie-Erkennung GenericName[de]=Notizbuch- & Inking-App
Comment=Handwritten notes with pen, touch and mouse; annotate PDFs
Comment[de]=Handschriftliche Notizen mit Stift, Touch und Maus; PDFs beschriften
Exec=ink-notes %F Exec=ink-notes %F
Icon=eu.pansi.ink-notes Icon=eu.pansi.ink-notes
Terminal=false Terminal=false
Type=Application Type=Application
Categories=Office;Graphics;Utility; Categories=Office;Graphics;
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector; Keywords=Note;Pen;Ink;Inking;Stylus;Drawing;PDF;Vector;Text;Annotation;
StartupWMClass=ink-notes Keywords[de]=Notiz;Stift;Ink;Inking;Stylus;Zeichnen;PDF;Vektor;Text;Annotation;
StartupWMClass=eu.pansi.ink-notes
MimeType=application/x-inknotes; MimeType=application/x-inknotes;
Actions=NewWindow; Actions=NewWindow;
[Desktop Action NewWindow] [Desktop Action NewWindow]
Name=Neues Fenster Name=New Window
Name[de]=Neues Fenster
Exec=ink-notes Exec=ink-notes
+122 -12
View File
@@ -4,20 +4,34 @@
<metadata_license>CC0-1.0</metadata_license> <metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license> <project_license>MIT</project_license>
<name>ink-notes</name> <name>ink-notes</name>
<summary>Latenzoptimierte Vektor-Notizbuch- und Inking-App für Linux</summary> <summary>Handwritten notes with pen, touch and mouse</summary>
<summary xml:lang="de">Notizbuch-App für Stift, Touch und Maus</summary>
<description> <description>
<p> <p>
ink-notes ist eine hardwarebeschleunigte, hochperformante Notizbuch- und Inking-Anwendung für Linux, ink-notes is a notebook app for Linux tablets and convertibles. Handwriting is stored as
geschrieben in Rust mit wgpu. Sie bietet minimale Eingabelatenz mit Stylus, Touchpad oder Maus. vectors; write with a pen, your finger, a touchpad or a mouse.
</p> </p>
<p>Hauptmerkmale:</p> <p xml:lang="de">
ink-notes ist eine Notizbuch-App für Linux-Tablets und Convertibles. Handschrift wird als
Vektoren gespeichert; geschrieben wird mit Stift, Finger, Touchpad oder Maus.
</p>
<p>Features:</p>
<p xml:lang="de">Funktionen:</p>
<ul> <ul>
<li>Zwei-Stufen-Inking-Modell: Echtzeit-Glättung mit 1€-Filter und akausale Ecken-Erhaltung</li> <li>Pens and highlighter with pressure, smoothing while writing and sharp corners</li>
<li>Automatische Geometrie- und Formerkennung (Geraden, Rechtecke, Kreise, Koordinatensysteme, Polygone)</li> <li xml:lang="de">Stifte und Textmarker mit Druckstärke, Glättung beim Schreiben und Ecken-Erhaltung</li>
<li>Stufenloser Viewport mit cursor-zentriertem Zoom (25% bis 500%) und Panning</li> <li>Shape recognition: hold at the end of a stroke to get clean lines, arrows, circles, rectangles, triangles and coordinate systems</li>
<li>Objektauswahl, Lasso, Skalieren, stufenloses Drehen und Multicolor-Umfärbung</li> <li xml:lang="de">Formerkennung: am Strichende halten für saubere Linien, Pfeile, Kreise, Rechtecke, Dreiecke und Koordinatensysteme</li>
<li>Multi-Dokumenten- und Notizbuch-Verwaltung mit atomarem Autosave</li> <li>Eraser for whole strokes, partial strokes or highlighter only; virtual ruler</li>
<li>Vektor-PDF-Export für Druck und Weitergabe</li> <li xml:lang="de">Radierer für ganze Striche, Teilstriche oder nur Textmarker; virtuelles Lineal</li>
<li>Select, move, rotate, scale and recolor; undo for every change</li>
<li xml:lang="de">Auswählen, Verschieben, Drehen, Skalieren und Umfärben; Rückgängig für alle Änderungen</li>
<li>Text boxes, images and PDF import to annotate lecture slides and worksheets</li>
<li xml:lang="de">Textfelder, Bilder und PDF-Import zum Beschriften von Vorlesungsfolien und Übungsblättern</li>
<li>Notebooks in folders, continuous scrolling, automatic saving in the background</li>
<li xml:lang="de">Notizbücher in Ordnern, durchgehendes Scrollen, automatisches Speichern im Hintergrund</li>
<li>Export as PDF (with searchable text), PNG or SVG</li>
<li xml:lang="de">Export als PDF (mit durchsuchbarem Text), PNG oder SVG</li>
</ul> </ul>
</description> </description>
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable> <launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
@@ -27,12 +41,108 @@
<categories> <categories>
<category>Office</category> <category>Office</category>
<category>Graphics</category> <category>Graphics</category>
<category>Utility</category>
</categories> </categories>
<keywords>
<keyword>notes</keyword>
<keyword>handwriting</keyword>
<keyword>stylus</keyword>
<keyword>pen</keyword>
<keyword>PDF</keyword>
<keyword>annotate</keyword>
</keywords>
<supports>
<control>pointing</control>
<control>touch</control>
<control>tablet</control>
</supports>
<developer id="eu.pansi"> <developer id="eu.pansi">
<name>Harald</name> <name>Harald Pansi</name>
</developer> </developer>
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url> <url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url> <url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
<url type="vcs-browser">https://gitea.pansi.eu/harald/ink-notes</url>
<screenshots>
<screenshot type="default">
<caption>Notes with highlighter, handwriting and text boxes</caption>
<caption xml:lang="de">Notizen mit Textmarker, Handschrift und Textfeldern</caption>
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/notes.png</image>
</screenshot>
<screenshot>
<caption>Recognized shapes: coordinate system, triangle, circle, octahedron</caption>
<caption xml:lang="de">Erkannte Formen: Koordinatensystem, Dreieck, Kreis, Oktaeder</caption>
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/shapes.png</image>
</screenshot>
<screenshot>
<caption>Annotating imported PDF slides</caption>
<caption xml:lang="de">Importierte PDF-Folien beschriften</caption>
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/pdf.png</image>
</screenshot>
</screenshots>
<branding>
<color type="primary" scheme_preference="light">#a8c8f0</color>
<color type="primary" scheme_preference="dark">#1f3a5f</color>
</branding>
<content_rating type="oars-1.1" /> <content_rating type="oars-1.1" />
<releases>
<release version="0.30.0" date="2026-10-19">
<description>
<p>Faster scrolling through long notebooks and PDFs: a quick finger swipe keeps gliding, and a scrollbar with page numbers jumps through the notebook. The mouse wheel scrolls further per step. New notebooks scroll continuously by default.</p>
</description>
</release>
<release version="0.29.1" date="2026-10-19">
<description>
<p>Moving, rotating and scaling large selections is smooth again and no longer keeps the processor busy. On endless notebooks the mouse wheel scrolls (Ctrl+wheel zooms).</p>
</description>
</release>
<release version="0.29.0" date="2026-10-18">
<description>
<p>Endless notebooks: one surface without edges to write, pan and zoom in every direction. Choose it when creating a notebook or switch a single-page notebook in the menu; PDF export contains only the written area.</p>
</description>
</release>
<release version="0.28.0" date="2026-10-18">
<description>
<p>Each notebook remembers whether it flips page by page or scrolls continuously. New notebooks start with fixed pages; the layout can be chosen when creating a notebook and changed later in the menu.</p>
</description>
</release>
<release version="0.27.1" date="2026-10-18">
<description>
<p>The pens in the toolbar are shown as pencils in their own colour, so they are easy to tell apart.</p>
</description>
</release>
<release version="0.27.0" date="2026-10-18">
<description>
<p>Pictures keep their proportions when resized. A lock button in the selection tool switches between proportional and free resizing; pictures start locked, other objects free.</p>
</description>
</release>
<release version="0.26.2" date="2026-10-13">
<description>
<p>Smoother scrolling through large imported PDFs: pages no longer stall the display while they are prepared, and pages scrolled past quickly are skipped.</p>
</description>
</release>
<release version="0.26.1" date="2026-10-10">
<description>
<p>Screenshots in the app description for software centers. The app itself is unchanged.</p>
</description>
</release>
<release version="0.26.0" date="2026-10-10">
<description>
<p>Select and move objects with a finger; text and make-space tools also work with a finger. Exports go to the documents folder of the user directories.</p>
</description>
</release>
<release version="0.25.0" date="2026-10-09">
<description>
<p>English user interface; the language follows the system and can be changed in the menu. Holding to snap a shape tolerates a slightly trembling pen.</p>
</description>
</release>
<release version="0.24.0" date="2026-10-09">
<description>
<p>Axis cross from two crossing strokes.</p>
</description>
</release>
<release version="0.23.0" date="2026-10-09">
<description>
<p>The on-screen keyboard of GNOME appears when tapping a text field.</p>
</description>
</release>
</releases>
</component> </component>
+50 -10
View File
@@ -1,29 +1,69 @@
## ink-notes {{TAG_NAME}} ## 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: ### Highlights:
- **Schneller scrollen**: Schnelles Wischen mit dem Finger gleitet nach; in der durchgehenden Ansicht springt eine Bildlaufleiste (gegenüber der Werkzeugleiste, mit Seitenzahl beim Ziehen) durch lange Notizbücher und PDFs; das Mausrad scrollt doppelt so weit. Neue Notizbücher sind durchgehend, feste Seiten gibt es weiter im ☰-Menü
- **Fehlerbehebung Auswahl**: Verschieben, Drehen und Skalieren großer Auswahlen ist wieder flüssig und lastet den Prozessor nicht mehr aus (seit 0.27 wurde die Auswahl bei jedem Bild mehrfach kopiert). Auf der endlosen Fläche scrollt das Mausrad (Strg+Mausrad zoomt)
- **Endlose Fläche**: Neue Seitenanordnung „♾ Endlos“ – eine Fläche ohne Rand, auf der man in alle Richtungen schreibt, verschiebt und zoomt. Wählbar beim Anlegen oder im ☰-Menü (bei Notizbüchern mit einer Seite); der PDF-Export enthält nur den beschriebenen Bereich. Neue Dateiformatversion 9: ältere App-Versionen öffnen Notizbücher dieser Version nicht mehr
- **Seitenanordnung je Notizbuch**: Beim Anlegen wählbar – „Feste Seiten“ (Vorgabe) oder „Durchgehend“ –, später im ☰-Menü umschaltbar. Bestehende Notizbücher behalten die bisherige Einstellung
- **Stifte in ihrer Farbe**: Die Stifte in der Werkzeugleiste erscheinen als Stifte in ihrer eigenen Farbe statt als dünner Strich und sind so auf einen Blick zu unterscheiden
- **Bilder ohne Verzerren skalieren**: Beim Auswahlwerkzeug schaltet ein Schloss unter den Stiftfarben zwischen gleichmäßigem (🔒) und freiem Skalieren (🔓) um. Bilder sind zu Beginn gesperrt, Striche und Formen frei; am Rechner sperrt zusätzlich die Umschalttaste
- **Flüssigeres Scrollen in großen PDFs**: Neu gerenderte PDF-Seiten halten die Anzeige nicht mehr an, und Seiten, über die man schnell hinwegscrollt, werden übersprungen statt der Reihe nach gerendert
- **Screenshots für Flathub**: Die App-Beschreibung (Metainfo) zeigt jetzt drei Screenshots; an der App selbst ändert sich nichts
- **Auswählen mit dem Finger**: Im Auswahlwerkzeug wählt und verschiebt ein Finger Objekte (auf leerer Fläche scrollt er weiter); Text und Platz schaffen gehen ebenfalls mit dem Finger
- **Flatpak**: Manifest unter packaging/flatpak (Vorbereitung für Flathub); Exporte landen im Dokumente-Ordner aus den Benutzerordnern (z. B. ~/Dokumente)
- **Englische Oberfläche / English interface**: ink-notes folgt der Systemsprache (Deutsch oder Englisch); umstellen im ☰-Menü unter „Sprache / Language“
- **Halten zum Einrasten**: verträgt jetzt das leichte Zittern eines ruhenden Stifts, damit Formen auch im normalen Stift-Modus einrasten
- **Achsenkreuz aus zwei Strichen**: Eine waagerechte und eine senkrechte Gerade über Kreuz zeichnen – die zweite mit Halten oder dem Formen-Werkzeug – und beide werden zum Achsenkreuz mit Pfeilspitzen an allen vier Enden; Rückgängig stellt die erste Gerade wieder her
- **Bildschirmtastatur**: Unter GNOME/Zorin (Wayland) erscheint die Bildschirmtastatur jetzt beim Antippen eines Namensfelds – mit Finger oder Stift; Tippen und Rücktaste funktionieren. Außerdem geht der erste Fingertipp nach Mausbenutzung nicht mehr verloren
- **App-Symbol**: Das Fenster meldet sich als eu.pansi.ink-notes – Dock und Taskleiste zeigen das ink-notes-Symbol statt eines allgemeinen Symbols (unter X11 auch als Fenstersymbol)
- **Platz schaffen**: Neues Werkzeug ↕ – aufs Blatt tippen und nach unten ziehen, alles darunter rutscht mit, auch auf den folgenden Seiten (über das Seitenende hinaus geht es auf der nächsten Seite weiter, fehlende Seiten werden angehängt); nach oben ziehen schließt leeren Platz; ein Schritt für Rückgängig
- **Achsenkreuz**: Ein „+“ in einem Zug (mit Halten oder dem Formen-Werkzeug) wird zum Achsenkreuz mit Pfeilspitzen an allen vier Enden; auch im PDF-/Bildexport
- **Fehlerbehebung Stift**: Das erste Antippen mit dem Stift trifft jetzt – Knöpfe und Notizbücher reagieren sofort statt erst beim zweiten Tippen; Striche beginnen nicht mehr mit einer Linie von der vorherigen Stiftposition
- **Tasten am Stift belegen**: Im ☰-Menü unter Eingabe jede Stifttaste mit Radieren, Auswählen, Textmarker, Stift oder Nichts belegen (gilt, solange die Taste gedrückt ist); Tasten erscheinen nach dem ersten Drücken und leuchten beim Drücken grün
- **Ordner verschieben**: Ordner lassen sich jetzt wie Notizbücher mit 📂 samt Inhalt in andere Ordner oder auf die oberste Ebene verschieben
- **Ordner für Notizbücher**: Beliebig verschachtelte Ordner (z. B. 3. Semester › Mathe) im Dokumenten-Manager anlegen, umbenennen und löschen, Notizbücher mit 📂 verschieben; neue Notizbücher landen im geöffneten Ordner. Die Notizbuch-Dateien bleiben unverändert (Zuordnung in library.json)
- **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 (wie in Rnote)
- **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 - **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` - **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) - **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 - **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 - **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 - **UI per Stift und Touch bedienbar**: Werkzeugleiste, Popover und Dialoge reagieren jetzt auch auf Stift und Finger, auch bei Display-Skalierung
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung - **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Stifteingabe über das Tablet-Protokoll von Wayland bzw. X11, mit Druckstärke
- **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus - **Touchscreen Multi-Touch**: Zoomen mit zwei Fingern, Verschieben mit einem; Zeichnen mit dem Finger zuschaltbar
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation) - **Zwei-Stufen-Inking-Modell**: Glättung während des Schreibens (1€-Filter), beim Absetzen Nachbearbeitung mit Ecken-Erhaltung
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Dreiecke, Rauten, Vielecke & Koordinatensysteme (Draw-and-Hold); Rechtecke werden zuverlässig erkannt, Freiformen bleiben unverändert - **Geometrie- und Formerkennung**: Geraden, Pfeile & Doppelpfeile, Rechtecke, Kreise, Ellipsen, Dreiecke, Rauten, Vielecke & Koordinatensysteme mit spitzen Pfeilspitzen (Draw-and-Hold); Freiformen bleiben unverändert
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave, Papierkorb und fortlaufenden Backups - **Mehrere Notizbücher**: Anlegen, umbenennen, löschen (mit Papierkorb); automatisches Speichern im Hintergrund
- **Datensicherheit**: Versioniertes Dateiformat mit Migration, Quarantäne beschädigter Seiten, Schutz vor Doppelöffnung und Crash-getestetem Speichern - **Datensicherheit**: Versioniertes Dateiformat mit Migration, beschädigte Seiten werden beiseitegelegt statt überschrieben, Schutz vor gleichzeitigem Öffnen, atomares Speichern
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases – mit Pflicht-Signaturprüfung (Minisign) und durchgängigem HTTPS - **In-App Updater**: Prüft auf neue Versionen auf Gitea und installiert sie auf Wunsch – nur mit gültiger Minisign-Signatur und über HTTPS
- **Vektor-PDF-Export**: Verlustfreie Ausgabe für Druck und Weitergabe - **PDF-Export**: Striche und Formen als Vektoren, für Druck und Weitergabe
### Installation: ### Installation:
- **Zorin OS / Ubuntu / Debian (.deb)**: - **Zorin OS / Ubuntu / Debian (.deb)**:
```bash ```bash
sudo dpkg -i ink-notes_{{VERSION}}_amd64.deb sudo dpkg -i ink-notes_{{VERSION}}_amd64.deb
``` ```
- **Portables AppImage (Alle Linux-Distributionen)**: - **AppImage (andere Distributionen mit glibc ≥ 2.39)**:
```bash ```bash
chmod +x ink-notes_{{VERSION}}_x86_64.AppImage chmod +x ink-notes_{{VERSION}}_x86_64.AppImage
./ink-notes_{{VERSION}}_x86_64.AppImage ./ink-notes_{{VERSION}}_x86_64.AppImage
Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+5 -7
View File
@@ -104,9 +104,7 @@ cat <<EOF > "${CHANGELOG_TMP}"
ink-notes (${VERSION}-1) stable; urgency=medium ink-notes (${VERSION}-1) stable; urgency=medium
* Release v${VERSION}. * Release v${VERSION}.
* Low-latency vector inking with two-stage pipeline. * See the release notes on https://gitea.pansi.eu/harald/ink-notes/releases
* Shape recognition and infinite canvas with zoom/pan.
* Multi-notebook management and PDF export.
-- Harald <harald@pansi.eu> $(date -R) -- Harald <harald@pansi.eu> $(date -R)
EOF EOF
@@ -130,10 +128,10 @@ Depends: libc6 (>= 2.38), libgcc-s1, libvulkan1, mesa-vulkan-drivers | vulkan-ic
Section: utils Section: utils
Priority: optional Priority: optional
Homepage: https://gitea.pansi.eu/harald/ink-notes Homepage: https://gitea.pansi.eu/harald/ink-notes
Description: Latenzoptimierte Vektor-Notizbuch- und Inking-App für Linux Description: Notizbuch-App für Stift, Touch und Maus
ink-notes kombiniert minimale Stylus-Latenz mit GPU-Hardwarebeschleunigung (wgpu), ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen und
stufenlosem Viewport, Geometrie- und Formerkennung, Objekt-Transformationen, kann PDFs zum Beschriften importieren sowie Bilder und Textfelder einfügen.
Multi-Dokumenten-Verwaltung und Vektor-PDF-Export. Mehrere Notizbücher, automatisches Speichern und Export als PDF, PNG oder SVG.
EOF EOF
chmod 644 "${STAGING_DIR}/DEBIAN/control" chmod 644 "${STAGING_DIR}/DEBIAN/control"
+371
View File
@@ -0,0 +1,371 @@
//! 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;
use crate::t;
/// 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) => {
f.write_str(&t!("Bild kann nicht gelesen werden: {msg}", msg = msg))
}
AssetError::TooLarge(bytes) => f.write_str(&t!(
"Bild ist zu groß ({size} MB, höchstens {max} MB)",
size = bytes / (1024 * 1024),
max = MAX_IMPORT_BYTES / (1024 * 1024)
)),
AssetError::InvalidName(name) => {
f.write_str(&t!("Ungültiger Bildverweis '{name}'", name = name))
}
AssetError::Io(e) => f.write_str(&t!("Ein-/Ausgabefehler: {e}", e = 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(t!("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")));
}
}
+18 -16
View File
@@ -16,6 +16,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::document::{Notebook, Page, PersistState}; use crate::document::{Notebook, Page, PersistState};
use crate::storage::{self, NotebookManifest, PageData, StorageError}; use crate::storage::{self, NotebookManifest, PageData, StorageError};
use crate::t;
/// Abstraktion für die Zeitmessung (für deterministische Tests ohne Thread-Sleep). /// Abstraktion für die Zeitmessung (für deterministische Tests ohne Thread-Sleep).
pub trait Clock: Send + Sync + 'static { pub trait Clock: Send + Sync + 'static {
@@ -69,12 +70,11 @@ pub struct AutosaveError {
impl fmt::Display for AutosaveError { impl fmt::Display for AutosaveError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!( f.write_str(&t!(
f, "Autosave fehlgeschlagen für Seiten {pages}: {messages}",
"Autosave fehlgeschlagen für Seiten {:?}: {}", pages = format!("{:?}", self.failed_page_ids),
self.failed_page_ids, messages = self.messages.join("; ")
self.messages.join("; ") ))
)
} }
} }
@@ -96,10 +96,10 @@ pub enum AutosaveStatus {
impl AutosaveStatus { impl AutosaveStatus {
pub fn display_text(&self) -> &'static str { pub fn display_text(&self) -> &'static str {
match self { match self {
AutosaveStatus::Saved => "Gespeichert", AutosaveStatus::Saved => t!("Gespeichert"),
AutosaveStatus::Pending => "Ungespeichert", AutosaveStatus::Pending => t!("Ungespeichert"),
AutosaveStatus::Saving => "Speichert...", AutosaveStatus::Saving => t!("Speichert..."),
AutosaveStatus::Error => "Speicherfehler!", AutosaveStatus::Error => t!("Speicherfehler!"),
} }
} }
} }
@@ -166,7 +166,7 @@ fn spawn_worker(
let _ = resp_tx.send(AutosaveResponse::PageSaved { let _ = resp_tx.send(AutosaveResponse::PageSaved {
page_id, page_id,
result: Err(( result: Err((
"Worker panic beim Speichern der Seite".to_string(), t!("Worker panic beim Speichern der Seite").to_string(),
Box::new(page_data), Box::new(page_data),
)), )),
}); });
@@ -194,7 +194,7 @@ fn spawn_worker(
Err(_panic) => { Err(_panic) => {
let _ = resp_tx.send(AutosaveResponse::ManifestSaved { let _ = resp_tx.send(AutosaveResponse::ManifestSaved {
result: Err(( result: Err((
"Worker panic beim Speichern des Manifests".to_string(), t!("Worker panic beim Speichern des Manifests").to_string(),
Box::new(manifest), Box::new(manifest),
)), )),
}); });
@@ -413,7 +413,8 @@ impl AutosaveController {
Err((err_msg, failed_page)) => { Err((err_msg, failed_page)) => {
eprintln!("[Autosave-Fehler] Seite {page_id} fehlgeschlagen: {err_msg}"); eprintln!("[Autosave-Fehler] Seite {page_id} fehlgeschlagen: {err_msg}");
self.status = AutosaveStatus::Error; self.status = AutosaveStatus::Error;
self.last_errors.push(format!("Seite {page_id}: {err_msg}")); self.last_errors
.push(t!("Seite {id}: {msg}", id = page_id, msg = err_msg));
// Wieder einreihen, wenn nicht bereits eine neuere Version vorliegt // Wieder einreihen, wenn nicht bereits eine neuere Version vorliegt
self.pending_pages.entry(page_id).or_insert(*failed_page); self.pending_pages.entry(page_id).or_insert(*failed_page);
@@ -431,7 +432,7 @@ impl AutosaveController {
if let Err((err_msg, failed_manifest)) = result { if let Err((err_msg, failed_manifest)) = result {
eprintln!("[Autosave-Fehler] Manifest fehlgeschlagen: {err_msg}"); eprintln!("[Autosave-Fehler] Manifest fehlgeschlagen: {err_msg}");
self.status = AutosaveStatus::Error; self.status = AutosaveStatus::Error;
self.last_errors.push(format!("Manifest: {err_msg}")); self.last_errors.push(t!("Manifest: {msg}", msg = err_msg));
if self.pending_manifest.is_none() { if self.pending_manifest.is_none() {
self.pending_manifest = Some(*failed_manifest); self.pending_manifest = Some(*failed_manifest);
} }
@@ -567,7 +568,8 @@ impl AutosaveController {
} }
Err((e, failed_page)) => { Err((e, failed_page)) => {
self.status = AutosaveStatus::Error; self.status = AutosaveStatus::Error;
self.last_errors.push(format!("Seite {page_id}: {e}")); self.last_errors
.push(t!("Seite {id}: {msg}", id = page_id, msg = e));
self.pending_pages.insert(page_id, *failed_page); self.pending_pages.insert(page_id, *failed_page);
let now = self.clock.now(); let now = self.clock.now();
let retry = self.retries.entry(page_id).or_insert(PageRetry { let retry = self.retries.entry(page_id).or_insert(PageRetry {
@@ -581,7 +583,7 @@ impl AutosaveController {
AutosaveResponse::ManifestSaved { result } => { AutosaveResponse::ManifestSaved { result } => {
if let Err((e, failed_manifest)) = result { if let Err((e, failed_manifest)) = result {
self.status = AutosaveStatus::Error; self.status = AutosaveStatus::Error;
self.last_errors.push(format!("Manifest: {e}")); self.last_errors.push(t!("Manifest: {msg}", msg = e));
self.pending_manifest = Some(*failed_manifest); self.pending_manifest = Some(*failed_manifest);
} }
} }
+327 -109
View File
@@ -1,149 +1,367 @@
//! Vektor-Generierung für DIN-A4-Seitenhintergründe (Kariert, Liniert, Punktraster, Blanko). //! Vektor-Generierung für DIN-A4-Seitenhintergründe (Kariert, Liniert, Punktraster, Blanko).
use serde::{Deserialize, Serialize};
use crate::canvas::InkVertex; use crate::canvas::InkVertex;
use crate::document::{PageBackground, PAGE_HEIGHT, PAGE_WIDTH}; use crate::document::PageBackground;
use crate::t;
/// 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 => t!("Hell"),
PatternColor::Medium => t!("Mittel"),
PatternColor::Strong => t!("Kräftig"),
PatternColor::Blue => t!("Blau"),
PatternColor::Green => t!("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). /// Linienstärke für Gitter- und Schreiblinien in Seiteneinheiten (1.5 = 0.15 mm).
pub const LINE_THICKNESS: f32 = 1.5; pub const LINE_THICKNESS: f32 = 1.5;
/// Punktgröße für das Punktraster in Seiteneinheiten (2.5 = 0.25 mm). /// Punktgröße für das Punktraster in Seiteneinheiten (4 = 0,4 mm, wie im PDF-Export).
pub const DOT_SIZE: f32 = 2.5; pub const DOT_SIZE: f32 = 4.0;
/// Rasterabstand 5 mm (50 Einheiten). /// Rasterabstand 5 mm (50 Einheiten).
pub const GRID_5MM_SPACING: f32 = 50.0; pub const GRID_5MM_SPACING: f32 = 50.0;
/// Zeilenabstand für liniertes Papier (7.5 mm = 75 Einheiten). /// Zeilenabstand für liniertes Papier (7.5 mm = 75 Einheiten).
pub const LINED_SPACING: f32 = 75.0; pub const LINED_SPACING: f32 = 75.0;
/// Hellgraue Farbe für Hintergrundmuster (#E0E0E0). /// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte
pub const BACKGROUND_PATTERN_COLOR: [f32; 4] = [0.88, 0.88, 0.88, 1.0]; /// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier).
/// Papierweiß für das DIN-A4-Blatt (#FFFFFF). ///
pub const PAPER_COLOR: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; /// Papier- und Musterfarben sind linear (der Shader rechnet sie in sRGB um); Tintenfarben
/// Schreibtisch-Hintergrundfarbe (Letterbox-Bereich, #28282D). /// dagegen sind sRGB-Werte wie im Export.
pub const DESK_COLOR: wgpu::Color = wgpu::Color { #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
r: 0.16, #[serde(rename_all = "lowercase")]
g: 0.16, pub enum PaperTheme {
b: 0.18, #[default]
a: 1.0, 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. impl PaperTheme {
pub fn generate_paper_quad() -> Vec<InkVertex> { pub const ALL: [PaperTheme; 4] = [
PaperTheme::White,
PaperTheme::Cream,
PaperTheme::DarkGray,
PaperTheme::Black,
];
pub fn label(self) -> &'static str {
match self {
PaperTheme::White => t!("Weiß"),
PaperTheme::Cream => t!("Creme"),
PaperTheme::DarkGray => t!("Dunkelgrau"),
PaperTheme::Black => t!("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![ vec![
InkVertex::new(0.0, 0.0, PAPER_COLOR), InkVertex::new(0.0, 0.0, color),
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR), InkVertex::new(page_width, 0.0, color),
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR), InkVertex::new(0.0, page_height, color),
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR), InkVertex::new(0.0, page_height, color),
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR), InkVertex::new(page_width, 0.0, color),
InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, PAPER_COLOR), InkVertex::new(page_width, page_height, color),
] ]
} }
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu. /// Oberer und unterer Rand ohne Linien bei liniertem Papier (15 mm).
#[inline] pub const LINED_MARGIN: f32 = 150.0;
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)); /// Ecke des Musterrechtecks: Das Muster selbst berechnet der Fragment-Shader (`fs_pattern`)
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR)); /// für jedes Bildschirmpixel. So liegen Linien und Punkte in jedem Zoom exakt gleichmäßig,
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR)); /// sind mindestens ein Pixel breit und kantengeglättet – vorher gerasterte Haarlinien
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR)); /// fielen je nach Lage auf das Pixelraster und wirkten unregelmäßig.
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR)); #[repr(C)]
vertices.push(InkVertex::new(x_max, y_max, BACKGROUND_PATTERN_COLOR)); #[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. impl PatternVertex {
pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> { const ATTRIBUTES: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
match background { 0 => Float32x2,
PageBackground::Blank => Vec::new(), 1 => Float32x4,
PageBackground::Grid5mm => { 2 => Float32x4,
let mut vertices = Vec::with_capacity(1200); 3 => Float32x4
let half_t = LINE_THICKNESS / 2.0; ];
// Vertikale Linien alle 5 mm pub fn desc() -> wgpu::VertexBufferLayout<'static> {
let mut x = GRID_5MM_SPACING; wgpu::VertexBufferLayout {
while x < PAGE_WIDTH { array_stride: std::mem::size_of::<PatternVertex>() as wgpu::BufferAddress,
push_quad(&mut vertices, x - half_t, 0.0, x + half_t, PAGE_HEIGHT); step_mode: wgpu::VertexStepMode::Vertex,
x += GRID_5MM_SPACING; attributes: &Self::ATTRIBUTES,
}
// 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
} }
} }
} }
/// 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn test_paper_quad_generation() { 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.len(), 6);
assert_eq!(quad[0].position, [0.0, 0.0]); assert_eq!(quad[0].position, [0.0, 0.0]);
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]); 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] #[test]
fn test_background_meshes() { fn test_background_mesh_uses_theme_color() {
let blank = generate_background_mesh(PageBackground::Blank); let quad = generate_paper_quad(PaperTheme::Cream.paper_color(), A4_SIZE);
assert!(blank.is_empty()); assert!(quad
.iter()
.all(|v| v.color == PaperTheme::Cream.paper_color()));
}
let grid = generate_background_mesh(PageBackground::Grid5mm); #[test]
assert!(!grid.is_empty()); fn test_adapt_ink_color() {
assert_eq!( let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5);
grid.len() % 6, // Schwarz wird Weiß, Deckkraft bleibt
0, assert!(close(
"Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)" 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); #[test]
assert!(!lined.is_empty()); fn test_paper_themes_contrast() {
assert_eq!(lined.len() % 6, 0); for theme in PaperTheme::ALL {
let paper = theme.paper_color()[0];
let dots = generate_background_mesh(PageBackground::DotGrid); let pattern = theme.pattern_color()[0];
assert!(!dots.is_empty()); assert!(
assert_eq!(dots.len() % 6, 0); (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:?}");
}
} }
} }
+884 -69
View File
File diff suppressed because it is too large Load Diff
+1775 -87
View File
File diff suppressed because it is too large Load Diff
+1125 -116
View File
File diff suppressed because it is too large Load Diff
+251
View File
@@ -0,0 +1,251 @@
//! 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;
use crate::{t, tn};
/// 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 => tn!("🖼 Bilder"),
PickerKind::Pdf => tn!("⬇ 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(t!(
"'{dir}' kann nicht geöffnet werden: {err}",
dir = dir.display(),
err = err
));
}
}
}
/// 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 Ordnern aus `~/.config/user-dirs.dirs` (z. B. „Bilder“ statt „Pictures“). Die
/// Bezeichnungen sind deutsch, angezeigt werden sie mit `i18n::tr`.
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((tn!("🖼 Bilder"), p));
}
if let Some(p) = xdg("XDG_DOWNLOAD_DIR", &["Downloads"]) {
list.push((tn!("⬇ Downloads"), p));
}
if let Some(p) = xdg("XDG_DESKTOP_DIR", &["Schreibtisch", "Desktop"]) {
list.push((tn!("🖥 Schreibtisch"), p));
}
list.push((tn!("🏠 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) /// Standard-Minimal-Grenzfrequenz in Hz (reduziert Jitter bei niedrigen Geschwindigkeiten)
pub const DEFAULT_FC_MIN: f32 = 4.5; pub const DEFAULT_FC_MIN: f32 = 4.5;
/// Standard-Geschwindigkeitskoeffizient (reduziert Latenz bei schnellen Bewegungen) /// Standard-Geschwindigkeitskoeffizient (Hz je Seiteneinheit/s): Bei 400 Einheiten/s
pub const DEFAULT_BETA: f32 = 0.05; /// (4 cm/s, zügige Handschrift) liegt die Grenzfrequenz bei rund 65 Hz, kleine Schleifen
/// Standard-Grenzfrequenz für die Ableitung in Hz /// werden kaum noch verkleinert
pub const DEFAULT_D_CUTOFF: f32 = 1.0; 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). /// 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]. /// 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)] #[derive(Debug, Clone)]
pub struct OneEuroFilter { pub struct OneEuroFilter {
pub fc_min: f32, pub fc_min: f32,
pub beta: f32, pub beta: f32,
pub d_cutoff: f32, pub d_cutoff: f32,
x_filter: OneEuroFilter1D, pos_prev: Option<glam::Vec2>,
y_filter: OneEuroFilter1D, speed_prev: f32,
t_prev: Option<f64>,
p_filter: OneEuroFilter1D, p_filter: OneEuroFilter1D,
last_output: Option<(f32, f32, f32)>, last_output: Option<(f32, f32, f32)>,
} }
@@ -175,8 +183,9 @@ impl OneEuroFilter {
fc_min, fc_min,
beta, beta,
d_cutoff, d_cutoff,
x_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff), pos_prev: None,
y_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff), speed_prev: 0.0,
t_prev: None,
p_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff), p_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
last_output: None, last_output: None,
} }
@@ -184,8 +193,9 @@ impl OneEuroFilter {
/// Setzt den Filter vollständig zurück (z. B. bei neuem Stroke). /// Setzt den Filter vollständig zurück (z. B. bei neuem Stroke).
pub fn reset(&mut self) { pub fn reset(&mut self) {
self.x_filter.reset(); self.pos_prev = None;
self.y_filter.reset(); self.speed_prev = 0.0;
self.t_prev = None;
self.p_filter.reset(); self.p_filter.reset();
self.last_output = None; self.last_output = None;
} }
@@ -202,8 +212,8 @@ impl OneEuroFilter {
// Druck immer auf [0.0, 1.0] klemmen // Druck immer auf [0.0, 1.0] klemmen
let p_in = pressure.clamp(0.0, 1.0); let p_in = pressure.clamp(0.0, 1.0);
let x_out = self.x_filter.filter(x, t); let pos = self.filter_position(glam::Vec2::new(x, y), t);
let y_out = self.y_filter.filter(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 p_out = self.p_filter.filter(p_in, t).clamp(0.0, 1.0);
let out = (x_out, y_out, p_out); let out = (x_out, y_out, p_out);
@@ -211,6 +221,51 @@ impl OneEuroFilter {
out 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`. /// 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) { 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); 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] #[test]
fn test_high_frequency_jitter() { fn test_high_frequency_jitter() {
let mut filter = OneEuroFilter::default(); let mut filter = OneEuroFilter::default();
@@ -286,7 +360,8 @@ mod tests {
let base_x = 100.0_f32; let base_x = 100.0_f32;
let base_y = 50.0_f32; let base_y = 50.0_f32;
let base_p = 0.5_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 num_samples = 120;
let dt = 1.0 / 120.0; // 120 Hz Abtastrate let dt = 1.0 / 120.0; // 120 Hz Abtastrate
@@ -331,9 +406,10 @@ mod tests {
.sum::<f32>() .sum::<f32>()
/ count; / 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!( 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})" "Gefilterte Varianz ({filtered_variance}) muss deutlich kleiner sein als Raw-Varianz ({raw_variance})"
); );
} }
+1145 -99
View File
File diff suppressed because it is too large Load Diff
+66
View File
@@ -3,6 +3,9 @@
use crate::document::Stroke; use crate::document::Stroke;
/// Eine rückgängig machbare Benutzeraktion auf einer Seite. /// Eine rückgängig machbare Benutzeraktion auf einer Seite.
// Der Verlauf hält nur wenige hundert Einträge; die Größe des Ersetzen-Eintrags (zwei Striche)
// fällt nicht ins Gewicht, eine Box würde nur die Aufrufe umständlicher machen
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum HistoryAction { pub enum HistoryAction {
/// Ein Strich wurde neu gezeichnet. /// Ein Strich wurde neu gezeichnet.
@@ -18,6 +21,16 @@ pub enum HistoryAction {
before: Vec<Stroke>, before: Vec<Stroke>,
after: Vec<Stroke>, after: Vec<Stroke>,
}, },
/// Platz geschaffen: Inhalt mehrerer Seiten wurde verschoben. Rückgängig macht das das
/// Notizbuch (`Notebook::undo`), nicht die einzelne Seite.
SpaceShift(Box<crate::document::SpaceShift>),
/// 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). /// History-Stack für Transaktionsverwaltung (Undo / Redo).
@@ -41,6 +54,40 @@ impl HistoryStack {
self.redo_stack.clear(); 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. /// Gibt zurück, ob Undo verfügbar ist.
pub fn can_undo(&self) -> bool { pub fn can_undo(&self) -> bool {
!self.undo_stack.is_empty() !self.undo_stack.is_empty()
@@ -95,6 +142,16 @@ impl HistoryStack {
self.redo_stack self.redo_stack
.push(HistoryAction::ModifyStrokes { before, after }); .push(HistoryAction::ModifyStrokes { before, after });
} }
// Betrifft mehrere Seiten: Das Notizbuch macht es rückgängig, hier nur umbuchen
HistoryAction::SpaceShift(shift) => {
self.redo_stack.push(HistoryAction::SpaceShift(shift));
}
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 true
@@ -140,6 +197,15 @@ impl HistoryStack {
self.undo_stack self.undo_stack
.push(HistoryAction::ModifyStrokes { before, after }); .push(HistoryAction::ModifyStrokes { before, after });
} }
HistoryAction::SpaceShift(shift) => {
self.undo_stack.push(HistoryAction::SpaceShift(shift));
}
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 true
+326
View File
@@ -0,0 +1,326 @@
//! Sprache der Oberfläche: Deutsch oder Englisch.
//!
//! Die Texte stehen im Quelltext auf Deutsch in `t!("…")`; dieser deutsche Text ist zugleich
//! der Schlüssel für die englische Fassung in `en.rs`. Platzhalter heißen `{name}` und werden
//! mit `t!("Seite {number}", number = n)` gefüllt (Formatangaben wie `{:.1}` vorher mit
//! `format!` anwenden). Texte in Tabellen markiert `tn!("…")`, übersetzt werden sie beim
//! Anzeigen mit `tr(…)`. Ein Test prüft, dass jeder markierte Text übersetzt ist.
use std::collections::HashMap;
use std::fmt::Display;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
mod en;
/// Sprache der Oberfläche
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Language {
German,
English,
}
impl Language {
pub const ALL: [Language; 2] = [Language::German, Language::English];
/// Name der Sprache in ihr selbst (für die Auswahl, unabhängig von der aktuellen Sprache)
pub fn native_name(self) -> &'static str {
match self {
Language::German => "Deutsch",
Language::English => "English",
}
}
/// Sprache aus den Locale-Variablen (`LC_ALL`, `LC_MESSAGES`, `LANG`, `LANGUAGE`; `C` und
/// `POSIX` zählen nicht): Deutsch bei `de…`, sonst Englisch.
pub fn from_locale(vars: &[Option<String>]) -> Language {
let locale = vars
.iter()
.flatten()
.map(|v| v.trim())
.find(|v| !v.is_empty() && *v != "C" && !v.starts_with("C.") && *v != "POSIX");
match locale {
Some(v) if v.to_ascii_lowercase().starts_with("de") => Language::German,
_ => Language::English,
}
}
/// Sprache des Systems
pub fn system() -> Language {
let var = |name| std::env::var(name).ok();
Language::from_locale(&[
var("LC_ALL"),
var("LC_MESSAGES"),
var("LANG"),
var("LANGUAGE"),
])
}
}
static CURRENT: AtomicU8 = AtomicU8::new(0);
/// Stellt die Sprache der Oberfläche um (wirkt ab dem nächsten Frame).
pub fn set_language(language: Language) {
CURRENT.store(language as u8, Ordering::Relaxed);
}
/// Aktuelle Sprache der Oberfläche
pub fn language() -> Language {
match CURRENT.load(Ordering::Relaxed) {
0 => Language::German,
_ => Language::English,
}
}
fn english() -> &'static HashMap<&'static str, &'static str> {
static TABLE: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
TABLE.get_or_init(|| en::EN.iter().copied().collect())
}
/// Text `german` in der Sprache `language`; fehlt die Übersetzung, bleibt er deutsch.
pub fn translate(language: Language, german: &'static str) -> &'static str {
match language {
Language::German => german,
Language::English => english().get(german).copied().unwrap_or(german),
}
}
/// Text in der aktuellen Sprache
pub fn tr(german: &'static str) -> &'static str {
translate(language(), german)
}
/// Setzt die Werte für die Platzhalter `{name}` ein.
pub fn fill(template: &str, args: &[(&str, &dyn Display)]) -> String {
let mut out = String::with_capacity(template.len() + 16);
let mut rest = template;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
match after.find('}') {
Some(close) => {
let name = &after[..close];
match args.iter().find(|(key, _)| *key == name) {
Some((_, value)) => out.push_str(&value.to_string()),
None => out.push_str(&rest[open..open + close + 2]),
}
rest = &after[close + 1..];
}
None => {
out.push_str(&rest[open..]);
rest = "";
}
}
}
out.push_str(rest);
out
}
/// Übersetzt einen deutschen Text: `t!("Abbrechen")` ergibt `&'static str`,
/// `t!("Seite {number}", number = n)` einen `String` mit eingesetzten Werten.
#[macro_export]
macro_rules! t {
($german:literal) => {
$crate::i18n::tr($german)
};
($german:literal, $($name:ident = $value:expr),+ $(,)?) => {
$crate::i18n::fill(
$crate::i18n::tr($german),
&[$((stringify!($name), &$value as &dyn ::std::fmt::Display)),+],
)
};
}
/// Markiert einen deutschen Text zum Übersetzen, ohne ihn schon zu übersetzen (für Tabellen;
/// angezeigt wird er mit `tr`).
#[macro_export]
macro_rules! tn {
($german:literal) => {
$german
};
}
#[cfg(test)]
mod tests {
use super::*;
/// Quelltexte mit übersetzbaren Texten
const SOURCES: &[(&str, &str)] = &[
("assets.rs", include_str!("assets.rs")),
("autosave.rs", include_str!("autosave.rs")),
("background.rs", include_str!("background.rs")),
("document.rs", include_str!("document.rs")),
("export.rs", include_str!("export.rs")),
("file_picker.rs", include_str!("file_picker.rs")),
("geometry.rs", include_str!("geometry.rs")),
("library.rs", include_str!("library.rs")),
("main.rs", include_str!("main.rs")),
("page_export.rs", include_str!("page_export.rs")),
("pdf_doc.rs", include_str!("pdf_doc.rs")),
("pdf_merge.rs", include_str!("pdf_merge.rs")),
("canvas.rs", include_str!("canvas.rs")),
("selection.rs", include_str!("selection.rs")),
("settings.rs", include_str!("settings.rs")),
("storage.rs", include_str!("storage.rs")),
("text.rs", include_str!("text.rs")),
("ui.rs", include_str!("ui.rs")),
("updater.rs", include_str!("updater.rs")),
];
/// Alle Texte in `t!("…")` und `tn!("…")` eines Quelltexts, auch über mehrere Zeilen
/// (Escapes wie `\n` aufgelöst)
fn marked_texts(src: &str) -> Vec<String> {
let chars: Vec<char> = src.chars().collect();
let mut out = Vec::new();
let mut i = 0;
while i < chars.len() {
let rest: String = chars[i..chars.len().min(i + 4)].iter().collect();
let marker_len = if rest.starts_with("t!(") {
3
} else if rest.starts_with("tn!(") {
4
} else {
i += 1;
continue;
};
// `t!(` nicht als Ende eines längeren Namens (z. B. `format!(`) zählen
if i > 0 && (chars[i - 1].is_alphanumeric() || chars[i - 1] == '_') {
i += 1;
continue;
}
i += marker_len;
while chars.get(i).is_some_and(|c| c.is_whitespace()) {
i += 1;
}
if chars.get(i) != Some(&'"') {
continue;
}
i += 1;
let mut text = String::new();
while i < chars.len() && chars[i] != '"' {
if chars[i] == '\\' {
i += 1;
match chars.get(i) {
Some('n') => text.push('\n'),
// Zeilenfortsetzung: Umbruch und führende Leerzeichen entfallen
Some('\n') => {
while chars.get(i + 1).is_some_and(|c| c.is_whitespace()) {
i += 1;
}
}
Some(other) => text.push(*other),
None => {}
}
} else {
text.push(chars[i]);
}
i += 1;
}
out.push(text);
}
out
}
fn placeholders(text: &str) -> Vec<&str> {
let mut names: Vec<&str> = text
.split('{')
.skip(1)
.filter_map(|part| part.split_once('}').map(|(name, _)| name))
.collect();
names.sort_unstable();
names
}
#[test]
fn every_marked_text_has_an_english_translation() {
let table = english();
let mut missing: Vec<String> = SOURCES
.iter()
.flat_map(|(file, src)| {
marked_texts(src)
.into_iter()
.filter(|text| !table.contains_key(text.as_str()))
.map(move |text| format!("{file}: {text:?}"))
})
.collect();
missing.sort();
missing.dedup();
assert!(
missing.is_empty(),
"Ohne Übersetzung:\n{}",
missing.join("\n")
);
}
#[test]
fn every_translation_is_used_and_keeps_its_placeholders() {
let used: std::collections::HashSet<String> = SOURCES
.iter()
.flat_map(|(_, src)| marked_texts(src))
.collect();
let mut seen = std::collections::HashSet::new();
for (german, english) in en::EN {
assert!(seen.insert(german), "Doppelt: {german:?}");
assert!(used.contains(*german), "Nicht (mehr) verwendet: {german:?}");
assert_eq!(
placeholders(german),
placeholders(english),
"Platzhalter verschieden: {german:?} / {english:?}"
);
}
}
#[test]
fn translate_falls_back_to_german() {
assert_eq!(translate(Language::German, "Abbrechen"), "Abbrechen");
assert_eq!(translate(Language::English, "Abbrechen"), "Cancel");
assert_eq!(
translate(Language::English, "kein bekannter Text"),
"kein bekannter Text"
);
}
#[test]
fn fill_replaces_named_placeholders() {
assert_eq!(
fill(
"Seite {number} von {count}",
&[("number", &3), ("count", &"7")]
),
"Seite 3 von 7"
);
assert_eq!(fill("{unbekannt} bleibt", &[]), "{unbekannt} bleibt");
assert_eq!(fill("offen {", &[]), "offen {");
}
#[test]
fn locale_selects_language() {
let s = |v: &str| Some(v.to_string());
assert_eq!(
Language::from_locale(&[None, None, s("de_AT.UTF-8")]),
Language::German
);
assert_eq!(
Language::from_locale(&[s("en_US.UTF-8"), None, s("de_DE.UTF-8")]),
Language::English
);
assert_eq!(
Language::from_locale(&[s("C"), None, s("de_DE.UTF-8")]),
Language::German
);
assert_eq!(
Language::from_locale(&[s("C.UTF-8"), None, s("de_DE.UTF-8")]),
Language::German
);
assert_eq!(
Language::from_locale(&[None, None, None]),
Language::English
);
assert_eq!(
Language::from_locale(&[None, None, s("fr_FR.UTF-8")]),
Language::English
);
}
}
+449
View File
@@ -0,0 +1,449 @@
//! Englische Fassung der Oberflächentexte: (deutscher Text im Quelltext, englischer Text).
//! Platzhalter `{name}` müssen in beiden Fassungen gleich heißen; der Test in `i18n.rs` meldet
//! fehlende, überzählige und abweichende Einträge.
pub const EN: &[(&str, &str)] = &[
("'{dir}' kann nicht geöffnet werden: {err}", "'{dir}' cannot be opened: {err}"),
("'{name}' erfolgreich heruntergeladen!", "'{name}' downloaded successfully!"),
("'{name}' nicht eingefügt: {err}", "'{name}' not inserted: {err}"),
("'{title}' gelöscht", "'{title}' deleted"),
("'{title}' importiert ({count} Seiten)", "'{title}' imported ({count} pages)"),
("(Empfohlen für dieses System)", "(Recommended for this system)"),
("1 Notizbuch", "1 notebook"),
("1 Objekt | [R] Drehen [Entf] Löschen", "1 object | [R] Rotate [Del] Delete"),
("Abbrechen", "Cancel"),
("Abfrage fehlgeschlagen:", "Request failed:"),
("Achsenkreuz", "Axis cross"),
("Achteck (Oktagon)", "Octagon"),
("Aktuelle Version:", "Current version:"),
("Alle Striche radieren", "Erase whole strokes"),
("Alle", "All"),
("Alles gespeichert", "All saved"),
("Als leere Seite bearbeiten (Schreibschutz aufheben)", "Edit as blank page (remove write protection)"),
("AppImage (Portabel)", "AppImage (portable)"),
("Asset-Name darf nicht leer sein.", "Asset name must not be empty."),
("Auch per Strg+V aus der Zwischenablage oder durch Ziehen einer Bilddatei ins Fenster", "Also with Ctrl+V from the clipboard or by dragging an image file into the window"),
("Auf Seiten mit importiertem PDF lässt sich kein Platz schaffen", "Pages with an imported PDF cannot make space"),
("Aus manifest.json.bak wiederherstellen", "Restore from manifest.json.bak"),
("Aus: ein Finger verschiebt, zwei Finger zoomen (empfohlen mit Stift).\nAn: ein Finger zeichnet.", "Off: one finger pans, two fingers zoom (recommended with a pen).\nOn: one finger draws."),
("Auswahl / Lasso (Select)", "Select / Lasso"),
("Auswahl auf Blau umgefärbt", "Selection recolored to blue"),
("Auswahl auf Schwarz umgefärbt", "Selection recolored to black"),
("Auswahl in der Farbe von Stift {number} umfärben", "Recolor selection with the color of pen {number}"),
("Auswahl um 90° gedreht", "Selection rotated by 90°"),
("Auswahl umgefärbt", "Selection recolored"),
("Auswählen", "Select"),
("Auswählen: verschieben, drehen, skalieren, kopieren (V)", "Select: move, rotate, scale, copy (V)"),
("Autosave fehlgeschlagen für Seiten {pages}: {messages}", "Autosave failed for pages {pages}: {messages}"),
("Beschädigte Seite {id}: {msg}", "Damaged page {id}: {msg}"),
("Bild einfügen", "Insert image"),
("Bild eingefügt ({width} × {height} px)", "Image inserted ({width} × {height} px)"),
("Bild ist zu groß ({size} MB, höchstens {max} MB)", "Image is too large ({size} MB, at most {max} MB)"),
("Bild kann nicht gelesen werden: {msg}", "Cannot read image: {msg}"),
("Bild mit Papier und Raster (200 dpi)", "Image with paper and pattern (200 dpi)"),
("Bild mit transparentem Hintergrund, nur Inhalt", "Image with transparent background, content only"),
("Bild nicht eingefügt: {err}", "Image not inserted: {err}"),
("Bild, Text oder kopierte Objekte aus der Zwischenablage hier einfügen", "Paste an image, text or copied objects from the clipboard here"),
("Blanko", "Blank"),
("Blau", "Blue"),
("Code", "Code"),
("Code-Schrift (feste Zeichenbreite, Tab rückt ein)", "Code font (fixed width, Tab indents)"),
("Creme", "Cream"),
("Das PDF enthält keine Seiten", "The PDF has no pages"),
("Das PDF hat {count} Seiten, höchstens {max} werden unterstützt", "The PDF has {count} pages, at most {max} are supported"),
("Das PDF ist verschlüsselt oder passwortgeschützt und kann nicht geöffnet werden", "The PDF is encrypted or password protected and cannot be opened"),
("Das PDF wird als neues Notizbuch angelegt, eine Seite je PDF-Seite. Beim Export liegen deine Notizen über den Originalseiten.", "The PDF becomes a new notebook, one page per PDF page. When exporting, your notes lie on top of the original pages."),
("Datei existiert nicht", "File does not exist"),
("Den Ordner gibt es nicht mehr", "The folder no longer exists"),
("Den Ordner „{name}“ gibt es hier schon", "A folder “{name}” already exists here"),
("Den Zielordner gibt es nicht mehr", "The target folder no longer exists"),
("Der Ordnername ist leer", "The folder name is empty"),
("Der Ordnername ist zu lang (höchstens {max} Zeichen)", "The folder name is too long (at most {max} characters)"),
("Der übergeordnete Ordner existiert nicht mehr", "The parent folder no longer exists"),
("DICKEN-PRESETS", "WIDTH PRESETS"),
("Die beschädigte Originaldatei wurde in Quarantäne gesichert:", "The damaged original file was saved to quarantine:"),
("Die Datei ist kein lesbares PDF", "The file is not a readable PDF"),
("Die einzige Seite eines Notizbuchs kann nicht gelöscht werden", "The only page of a notebook cannot be deleted"),
("Die Flatpak-Version wird über die Software-Verwaltung aktualisiert.", "The Flatpak version is updated through the software manager."),
("Die heruntergeladene Datei existiert nicht mehr.", "The downloaded file no longer exists."),
("die oberste Ebene", "the top level"),
("Die Zwischenablage ist leer", "The clipboard is empty"),
("Diese Version wird über die Software-Verwaltung aktualisiert (z. B. GNOME Software oder „flatpak update“).", "This version is updated through the software manager (e.g. GNOME Software or “flatpak update”)."),
("Dieser Ordner ist leer.", "This folder is empty."),
("Dokument '{title}' erstellt", "Document '{title}' created"),
("Dokument '{title}' geöffnet", "Document '{title}' opened"),
("Dokument in Papierkorb verschoben", "Document moved to the trash"),
("DOKUMENT", "DOCUMENT"),
("Dokumenten-Manager", "Document manager"),
("Doppelpfeil", "Double arrow"),
("Download-Verbindungsfehler: {e}", "Download connection error: {e}"),
("Dreieck", "Triangle"),
("Druckkurve des Stifts", "Pen pressure curve"),
("Druckkurve zurücksetzen", "Reset pressure curve"),
("Drücke eine Taste am Stift (über dem Bildschirm) – sie erscheint dann hier. Bis dahin radieren alle Tasten.", "Press a button on the pen (above the screen) – it will appear here. Until then all buttons erase."),
("Dunkelgrau", "Dark gray"),
("Dunkle Stiftfarben aufhellen", "Lighten dark pen colors"),
("Eigene Farbe wählen", "Choose a custom color"),
("Ein Ordner kann nicht in sich selbst verschoben werden", "A folder cannot be moved into itself"),
("Ein-/Ausgabefehler: {e}", "I/O error: {e}"),
("Eine Fläche ohne Rand: in alle Richtungen schreiben, verschieben und zoomen. Wechsel nur bei einer einzigen Seite ohne PDF bzw. zurück, solange alles im Startausschnitt liegt.", "One surface without edges: write, pan and zoom in every direction. Switching only works for a single page without a PDF, and back only while everything lies in the starting area."),
("Einfügen (Strg+V)", "Paste (Ctrl+V)"),
("EINGABE", "INPUT"),
("Einstellung nicht gespeichert: {err}", "Setting not saved: {err}"),
("Ellipse", "Ellipse"),
("Endlos geht nur bei einem Notizbuch mit einer einzigen Seite ohne PDF", "Endless only works for a notebook with a single page without a PDF"),
("Entwicklung / Lokal", "Development / local"),
("Export fehlgeschlagen: {err}", "Export failed: {err}"),
("Farbe {number} – erneut tippen, um sie anzupassen", "Color {number} – tap again to adjust it"),
("Farbe {number}", "Color {number}"),
("FARBE", "COLOR"),
("Fehler beim Beenden", "Error while quitting"),
("Fehler beim Erstellen: {err}", "Error while creating: {err}"),
("Fehler beim Lesen der Prüfsummendatei: {e}", "Error reading the checksum file: {e}"),
("Fehler beim Lesen des Download-Streams: {e}", "Error reading the download stream: {e}"),
("Fehler beim Löschen: {err}", "Error while deleting: {err}"),
("Fehler beim Schreiben auf die Festplatte: {e}", "Error writing to disk: {e}"),
("Fehler beim Umbenennen: {err}", "Error while renaming: {err}"),
("Fehler beim Wiederherstellen: {err}", "Error while restoring: {err}"),
("Fehler beim Öffnen: {err}", "Error while opening: {err}"),
("Fehler: {err}", "Error: {err}"),
("Fehlermeldung: {message}", "Error message: {message}"),
("Formen (Shape)", "Shapes"),
("Formen: Striche werden zu Linien, Rechtecken, Kreisen … (S/F)", "Shapes: strokes become lines, rectangles, circles … (S/F)"),
("Fünfeck (Pentagon)", "Pentagon"),
("Gerade", "Line"),
("Gespeichert in: {path}", "Saved in: {path}"),
("Gespeichert", "Saved"),
("Gespeichert: {path}", "Saved: {path}"),
("Gewechselt zu '{title}'", "Switched to '{title}'"),
("Gilt für alle Seiten, auch beim Export", "Applies to all pages, also when exporting"),
("Gitea Release-JSON konnte nicht verarbeitet werden: {e}", "Could not process the Gitea release JSON: {e}"),
("Gitea Server-URL:", "Gitea server URL:"),
("Gitea-Server antwortete mit HTTP {code}: {body}", "Gitea server responded with HTTP {code}: {body}"),
("Graphit-Grau", "Graphite gray"),
("Größere Schrift (jetzt {size} pt)", "Larger font (now {size} pt)"),
("Grün", "Green"),
("hart", "hard"),
("Hell", "Light"),
("Hellgrün", "Light green"),
("Heranzoomen (Strg++)", "Zoom in (Ctrl++)"),
("Herauszoomen (Strg+-)", "Zoom out (Ctrl+-)"),
("HINTERGRUND DIESER SEITE", "BACKGROUND OF THIS PAGE"),
("Hinweis: Falls das Repository privat ist, unten ein Personal Access Token eintragen.", "Note: if the repository is private, enter a personal access token below."),
("HOME-Umgebungsvariable ist nicht gesetzt.", "HOME environment variable is not set."),
("HTTP {code} Weiterleitung ohne Location-Header erhalten", "HTTP {code} redirect without Location header"),
("I/O-Fehler: {e}", "I/O error: {e}"),
("Im Zielordner gibt es schon einen Ordner „{name}“", "A folder “{name}” already exists in the target folder"),
("In dieser Version ist kein Signaturschlüssel für Updates hinterlegt. Bitte das Update manuell installieren.", "This version has no signing key for updates. Please install the update manually."),
("In einen anderen Ordner verschieben", "Move to another folder"),
("Inhalt nicht lesbar: {e}", "Content not readable: {e}"),
("ink-notes ist auf dem neuesten Stand (v{version}).", "ink-notes is up to date (v{version})."),
("ink-notes | [{notebook}] {page}{page_title} | [{status}] | {tool} | Hintergrund: {background} | [Ctrl+Z] Undo [Ctrl+Y] Redo [E] Radierer [Ctrl+E/S] PDF-Export", "ink-notes | [{notebook}] {page}{page_title} | [{status}] | {tool} | Background: {background} | [Ctrl+Z] Undo [Ctrl+Y] Redo [E] Eraser [Ctrl+E/S] PDF export"),
("Integritätsprüfung fehlgeschlagen für '{asset_name}': Erwartet {expected_hash}, berechnet {actual_hash}. Datei wurde gelöscht.", "Integrity check failed for '{asset_name}': expected {expected_hash}, computed {actual_hash}. The file was deleted."),
("Kariert (5mm)", "Grid (5mm)"),
("Kariert", "Grid"),
("KAROS, LINIEN UND PUNKTE", "GRID, LINES AND DOTS"),
("kein Asset-Ordner", "no asset folder"),
("Kein Eintrag für '{asset_name}' in SHA256SUMS.txt gefunden. Heruntergeladene Datei wurde gelöscht.", "No entry for '{asset_name}' found in SHA256SUMS.txt. The downloaded file was deleted."),
("Kein Release ausgewählt. Download abgelehnt.", "No release selected. Download refused."),
("Keine manifest.json vorhanden", "No manifest.json present"),
("Keine nutzbare Grafikschnittstelle gefunden.\nHäufigste Ursache: Der Benutzer darf nicht auf die Grafikkarte zugreifen (/dev/dri).\nPrüfen mit `ls -l /dev/dri` und `groups`; ggf. `sudo usermod -aG render,video $USER`\nund neu anmelden. Alternativ einen Software-Renderer installieren (Paket mesa-vulkan-drivers)\noder die Schnittstelle festlegen, z. B. `WGPU_BACKEND=vulkan ink-notes`.", "No usable graphics interface found.\nMost common cause: the user may not access the graphics card (/dev/dri).\nCheck with `ls -l /dev/dri` and `groups`; if needed `sudo usermod -aG render,video $USER`\nand log in again. Alternatively install a software renderer (package mesa-vulkan-drivers)\nor choose the interface, e.g. `WGPU_BACKEND=vulkan ink-notes`."),
("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)", "No folders or images (PNG, JPEG, GIF, WebP, BMP)"),
("Keine Ordner oder PDF-Dateien", "No folders or PDF files"),
("Keine Sicherungskopie (manifest.json.bak) vorhanden", "No backup copy (manifest.json.bak) present"),
("Kleinere Schrift (jetzt {size} pt)", "Smaller font (now {size} pt)"),
("Klicke auf 'Nach Updates suchen', um die neuesten Releases vom Gitea-Server abzufragen.", "Click 'Check for updates' to query the latest releases from the Gitea server."),
("Konnte AppImage nicht atomar ersetzen: {e}. Altes AppImage bleibt unverändert.", "Could not replace the AppImage atomically: {e}. The old AppImage stays unchanged."),
("Konnte Backup ({path}) nicht anlegen: {e}. Update abgebrochen, altes AppImage unverändert.", "Could not create backup ({path}): {e}. Update cancelled, old AppImage unchanged."),
("Konnte Datei '{path}' zur Prüfsummenberechnung nicht öffnen: {e}", "Could not open file '{path}' to compute its checksum: {e}"),
("Konnte nach 100 Versuchen kein eindeutiges Notizbuch-Verzeichnis finden", "Could not find a unique notebook folder after 100 attempts"),
("Konnte temporäre Update-Datei nicht schreiben: {e}", "Could not write temporary update file: {e}"),
("Konnte Update-Cache-Verzeichnis nicht anlegen ({path}): {e}", "Could not create update cache folder ({path}): {e}"),
("Konnte xdg-open nicht ausführen: {e}", "Could not run xdg-open: {e}"),
("Konnte Zieldatei '{path}' nicht erstellen: {e}", "Could not create target file '{path}': {e}"),
("Konnte {name} nicht dekodieren: {e}.", "Could not decode {name}: {e}."),
("Konnte {name} nicht laden: {e}.", "Could not load {name}: {e}."),
("Konnte übergeordnetes Verzeichnis des AppImages nicht ermitteln.", "Could not determine the parent folder of the AppImage."),
("Koordinatensystem", "Coordinate system"),
("Kopieren (Strg+C)", "Copy (Ctrl+C)"),
("Korrektur-Rot", "Correction red"),
("Kreis", "Circle"),
("Kräftig", "Strong"),
("Lade '{name}' herunter...", "Downloading '{name}'..."),
("Leere Seite direkt nach der aktuellen Seite", "Blank page right after the current page"),
("Leichterer Druck zählt als kein Druck (dünnste Linie).\nHilft bei Stiften, die schon beim Schweben Druck melden.", "Lighter pressure counts as no pressure (thinnest line).\nHelps with pens that already report pressure while hovering."),
("Leuchtet grün, solange die Taste gedrückt ist", "Lights up green while the button is pressed"),
("Liegt bereits hier", "Already here"),
("Lineal (L): Fläche ziehen = verschieben, Griff ⟳ = drehen; Striche an einer Kante werden gerade", "Ruler (L): drag the surface = move, handle ⟳ = rotate; strokes along an edge become straight"),
("Liniert (7.5mm)", "Lined (7.5mm)"),
("Liniert", "Lined"),
("Links", "Left"),
("Löschen (Entf)", "Delete (Del)"),
("Löschen", "Delete"),
("Löscht die aktuelle Seite; danach im Menü wiederherstellbar", "Deletes the current page; can be restored from the menu afterwards"),
("Manifest aus Backup wiederhergestellt", "Manifest restored from backup"),
("Manifest: {msg}", "Manifest: {msg}"),
("Manifestdatei nicht gefunden: {path}", "Manifest file not found: {path}"),
("Mindestdruck", "Minimum pressure"),
("Mit dem Finger zeichnen", "Draw with finger"),
("Mittel", "Medium"),
("Name …", "Name …"),
("Neon-Gelb", "Neon yellow"),
("Netzwerk-Transportfehler: {t}", "Network transport error: {t}"),
("Neue Seite nach dieser einfügen", "Insert a new page after this one"),
("Neue Version verfügbar: {version}", "New version available: {version}"),
("Neuen Ordner hier anlegen (z. B. Semester oder Fach)", "Create a new folder here (e.g. semester or subject)"),
("Neues Notizbuch in diesem Ordner anlegen und öffnen", "Create a new notebook in this folder and open it"),
("Nichts", "Nothing"),
("Noch keine Seite hat einen Titel. Gib oben einen Titel für die aktuelle Seite ein.", "No page has a title yet. Enter a title for the current page above."),
("Normal", "Normal"),
("Normale Schrift", "Normal font"),
("Notfall-Export fehlgeschlagen: {err}", "Emergency export failed: {err}"),
("Notfall-Export", "Emergency export"),
("Notiz-Schwarz", "Note black"),
("Notizbuch '{path}' wird bereits von einer anderen Instanz verwendet", "Notebook '{path}' is already used by another instance"),
("Notizbuch '{title}' konnte nicht geladen werden – neues Notizbuch angelegt", "Notebook '{title}' could not be loaded – created a new notebook"),
("Notizbuch enthält keine Seiten", "Notebook has no pages"),
("Notizbuch in Papierkorb verschieben", "Move notebook to the trash"),
("Notizbuch ist gesperrt: {msg}", "Notebook is locked: {msg}"),
("Notizbuch wiederhergestellt", "Notebook restored"),
("Notizbuch wurde mit neuerer Version erstellt (Formatversion {found}, unterstützt wird bis {supported})", "Notebook was created with a newer version (format version {found}, supported up to {supported})"),
("Notizbuch", "Notebook"),
("Notizbücher verwalten (aktuell: {title})", "Manage notebooks (current: {title})"),
("Nur Bilder (PNG, JPEG, GIF, WebP, BMP) und PDFs können eingefügt werden", "Only images (PNG, JPEG, GIF, WebP, BMP) and PDFs can be inserted"),
("Nur Textmarker radieren – Handschrift darunter bleibt", "Erase highlighter only – handwriting underneath stays"),
("Nächste Seite (Bild ab)", "Next page (Page Down)"),
("Oktaeder (3D-Kantenmodell)", "Octahedron (3D wireframe)"),
("Ordner löschen (die Notizbücher bleiben erhalten)", "Delete folder (the notebooks are kept)"),
("Ordner löschen", "Delete folder"),
("Ordner nicht gespeichert: {e}", "Folder not saved: {e}"),
("Ordner samt Inhalt in einen anderen Ordner verschieben", "Move folder with its contents to another folder"),
("Ordner umbenennen", "Rename folder"),
("Ordner „{name}“ angelegt", "Folder “{name}” created"),
("Ordner „{name}“ gelöscht, sein Inhalt liegt eine Ebene höher", "Folder “{name}” deleted, its contents moved up one level"),
("Ordner „{name}“ löschen? Seine Notizbücher und Unterordner rücken eine Ebene nach oben, gelöscht wird kein Notizbuch.", "Delete folder “{name}”? Its notebooks and subfolders move up one level, no notebook is deleted."),
("Ordner „{name}“ verschoben in {target}", "Folder “{name}” moved to {target}"),
("Ordner: {path}", "Folder: {path}"),
("Ordnernamen dürfen kein „/“ enthalten", "Folder names must not contain “/”"),
("PAPIERFARBE (NUR ANZEIGE)", "PAPER COLOR (DISPLAY ONLY)"),
("Papierkorb-Eintrag '{path}' existiert nicht", "Trash entry '{path}' does not exist"),
("Papierkorb-Pfad '{path}' liegt nicht im Papierkorb", "Trash path '{path}' is not in the trash"),
("Papierkorb-Verzeichnis existiert nicht", "Trash folder does not exist"),
("Pastell-Cyan", "Pastel cyan"),
("PDF importieren", "Import PDF"),
("PDF kann nicht geöffnet werden", "Cannot open PDF"),
("PDF nicht importiert: {err}", "PDF not imported: {err}"),
("PDF nicht lesbar: {e}", "PDF not readable: {e}"),
("PDF ohne Seite", "PDF without a page"),
("PDF-Export läuft…", "PDF export running…"),
("PDF-Render-Thread beendet", "PDF render thread stopped"),
("Personal Access Token (optional für private Repos):", "Personal access token (optional for private repos):"),
("Pfad '{path}' ist ein Symlink und darf nicht gelöscht werden", "Path '{path}' is a symlink and must not be deleted"),
("Pfad '{path}' liegt nicht direkt unter Basis-Verzeichnis '{base}'", "Path '{path}' is not directly inside the base folder '{base}'"),
("Pfad in Statusleiste anzeigen", "Show path in the status bar"),
("Pfeil", "Arrow"),
("Pixeldaten passen nicht zur Bildgröße", "Pixel data does not match the image size"),
("Platz schaffen", "Make space"),
("Platz schaffen: auf das Blatt tippen und nach unten ziehen – alles darunter rutscht mit, auch auf die folgenden Seiten; nach oben schließt leeren Platz", "Make space: tap the page and drag down – everything below moves along, also onto the following pages; dragging up closes empty space"),
("Polygon", "Polygon"),
("Prüfe Gitea-Releases...", "Checking Gitea releases..."),
("Punkte", "Dots"),
("Punktraster (5mm)", "Dot grid (5mm)"),
("Quell-PDF fehlt", "Source PDF missing"),
("Quell-PDF ist verschlüsselt", "Source PDF is encrypted"),
("Radieren", "Erase"),
("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich oder nur Textmarker", "Eraser (E or right mouse button) – below: whole strokes, partial stroke or highlighter only"),
("Radierer (Eraser)", "Eraser"),
("Raute (Rhombus)", "Rhombus"),
("Rechteck", "Rectangle"),
("Rechts", "Right"),
("Release enthält keine {name}.", "Release contains no {name}."),
("Release- & Update-Center", "Release & update center"),
("Rosa", "Pink"),
("Rückgängig (Strg+Z)", "Undo (Ctrl+Z)"),
("Schließen (Esc)", "Close (Esc)"),
("Schriftgröße {size} pt", "Font size {size} pt"),
("Schwarz wird Weiß, Dunkelblau wird Hellblau. Gespeichert und als PDF exportiert werden immer die Originalfarben.", "Black becomes white, dark blue becomes light blue. Saving and PDF export always use the original colors."),
("Schwarz", "Black"),
("Sechseck (Hexagon)", "Hexagon"),
("Seite als", "Page as"),
("Seite für Seite blättern", "Flip page by page"),
("Seite ist schreibgeschützt", "Page is read-only"),
("Seite konnte nicht geladen werden", "Page could not be loaded"),
("Seite wird exportiert …", "Exporting page …"),
("Seite wählen", "Choose page"),
("Seite {id} ist nicht geladen – Export abgebrochen (vorher storage::load_all_pages aufrufen)", "Page {id} is not loaded – export cancelled (call storage::load_all_pages first)"),
("Seite {id} konnte nicht fehlerfrei geladen werden!", "Page {id} could not be loaded without errors!"),
("Seite {id} nicht gefunden", "Page {id} not found"),
("Seite {id}: {msg}", "Page {id}: {msg}"),
("Seite {number} / {count}", "Page {number} / {count}"),
("Seite {number} eingefügt", "Page {number} inserted"),
("Seite {number} fehlt im PDF", "Page {number} missing in the PDF"),
("Seite {number} fehlt im Quell-PDF", "Page {number} missing in the source PDF"),
("Seite {number} gelöscht – im Menü ☰ wiederherstellbar", "Page {number} deleted – can be restored from the ☰ menu"),
("Seite {number} mit allem Inhalt löschen?", "Delete page {number} with all its content?"),
("Seite {number} wiederhergestellt", "Page {number} restored"),
("Seite {number} – {title}", "Page {number} – {title}"),
("Seite {number}", "Page {number}"),
("Seiten ({count})", "Pages ({count})"),
("SEITEN DIESES NOTIZBUCHS", "PAGES OF THIS NOTEBOOK"),
("Seiten hängen untereinander, Mausrad und Finger scrollen (Strg+Mausrad zoomt); wer unten auf der letzten Seite schreibt, bekommt automatisch eine neue Seite.", "Pages are stacked, mouse wheel and finger scroll (Ctrl+wheel zooms); writing at the bottom of the last page adds a new page automatically."),
("Seiten:", "Pages:"),
("Seitenverhältnis frei – tippen zum Sperren", "Aspect ratio free – tap to lock"),
("Seitenverhältnis gesperrt – tippen zum Freigeben", "Aspect ratio locked – tap to unlock"),
("Serialisierungsfehler: {e}", "Serialization error: {e}"),
("Server-Einstellungen gespeichert", "Server settings saved"),
("Server:", "Server:"),
("Signaturprüfung von {file} fehlgeschlagen: {e}.", "Signature check of {file} failed: {e}."),
("Speicherfehler!", "Save error!"),
("Speicherfehler: {messages}", "Save error: {messages}"),
("Speichert …", "Saving …"),
("Speichert...", "Saving..."),
("Sprache der Oberfläche", "Interface language"),
("Standard-Notizbuch erstellt", "Default notebook created"),
("Standard-Notizbuch", "Default notebook"),
("STATUS", "STATUS"),
("Stift (B/U/R für Farben) – erneut tippen für Farbe & Stärke", "Pen (B/U/R for colors) – tap again for color & width"),
("Stift (Pen)", "Pen"),
("Stift {number} ({width} mm) – erneut tippen für Farbe & Stärke; Änderungen merkt sich der Stift", "Pen {number} ({width} mm) – tap again for color & width; the pen remembers changes"),
("Stift {number} ({width} mm)", "Pen {number} ({width} mm)"),
("Stift {number}", "Pen {number}"),
("Stift", "Pen"),
("STUFENLOSE STÄRKE", "STEPLESS WIDTH"),
("Stärke auf {width} setzen", "Set width to {width}"),
("System", "System"),
("System-Paket (.deb)", "System package (.deb)"),
("Tafel-Grün", "Chalkboard green"),
("Taste {number}", "Button {number}"),
("Tasten am Stift", "Pen buttons"),
("Teilstrich radieren – nur den überstrichenen Teil entfernen (Formen ganz)", "Erase partial stroke – remove only the part you wipe over (shapes completely)"),
("Text (T)", "Text (T)"),
("Text (T): ins Blatt tippen und schreiben, vorhandenen Text antippen zum Ändern; Esc beendet", "Text (T): tap the page and write, tap existing text to edit it; Esc finishes"),
("Text eingefügt – mit dem Text-Werkzeug (T) änderbar", "Text pasted – editable with the text tool (T)"),
("Textfarbe {number}", "Text color {number}"),
("Textmarker (Highlighter)", "Highlighter"),
("Textmarker – erneut tippen für Farbe & Stärke", "Highlighter – tap again for color & width"),
("Textmarker – Farbe {number}", "Highlighter – color {number}"),
("Textmarker", "Highlighter"),
("Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen.", "Tip: paste screenshots with Ctrl+V or drag image files into the window."),
("Titel umbenennen", "Rename title"),
("TITEL VON SEITE {number}", "TITLE OF PAGE {number}"),
("Trotzdem beenden", "Quit anyway"),
("Trotzdem beenden?", "Quit anyway?"),
("Um 90° drehen (R)", "Rotate by 90° (R)"),
("Um Datenverlust zu verhindern, ist diese Seite schreibgeschützt.\nWenn Sie diese Seite bearbeiten möchten, können Sie den Schreibschutz aufheben.", "To prevent data loss, this page is read-only.\nIf you want to edit this page, you can remove the write protection."),
("Umbenannt in '{title}'", "Renamed to '{title}'"),
("Ungespeichert", "Unsaved"),
("Ungespeicherte Änderungen konnten nicht gesichert werden:", "Unsaved changes could not be saved:"),
("Ungespeicherte Änderungen", "Unsaved changes"),
("Ungültige Server-URL '{url}': {e}", "Invalid server URL '{url}': {e}"),
("Ungültige URL '{initial_url_str}': {e}", "Invalid URL '{initial_url_str}': {e}"),
("Ungültiger AppImage-Dateiname.", "Invalid AppImage file name."),
("Ungültiger Asset-Name (enthält unzulässige Pfadkomponenten): '{raw_name}'", "Invalid asset name (contains forbidden path components): '{raw_name}'"),
("Ungültiger Bildverweis '{name}'", "Invalid image reference '{name}'"),
("Ungültiger Dateiname: '{raw_name}'", "Invalid file name: '{raw_name}'"),
("Ungültiger oder versteckter Asset-Name: '{raw_name}'", "Invalid or hidden asset name: '{raw_name}'"),
("Ungültiger PDF-Verweis '{key}'", "Invalid PDF reference '{key}'"),
("Ungültiger Pfad (Path Traversal): {msg}", "Invalid path (path traversal): {msg}"),
("Ungültiger Update-Signaturschlüssel: {e}.", "Invalid update signing key: {e}."),
("Ungültiges Manifest: {msg}", "Invalid manifest: {msg}"),
("Ungültiges Weiterleitungsziel '{loc}': {e}", "Invalid redirect target '{loc}': {e}"),
("Universitäts-Blau", "University blue"),
("Unsicheres Protokoll '{scheme}' abgewiesen. Es wird zwingend HTTPS verlangt (z. B. https://gitea.pansi.eu).", "Insecure protocol '{scheme}' rejected. HTTPS is required (e.g. https://gitea.pansi.eu)."),
("Unsicheres Protokoll '{scheme}' für '{url}' abgewiesen. Es wird zwingend HTTPS verlangt.", "Insecure protocol '{scheme}' for '{url}' rejected. HTTPS is required."),
("Update gestartet / geöffnet", "Update started / opened"),
("Update-Fehler: {err}", "Update error: {err}"),
("Vektorgrafik, z. B. für Inkscape oder LaTeX", "Vector graphics, e.g. for Inkscape or LaTeX"),
("VERFÜGBARE ARTEFAKTE:", "AVAILABLE ARTIFACTS:"),
("Vergessen (erscheint beim nächsten Drücken wieder, dann mit Radieren)", "Forget (appears again on the next press, then erasing)"),
("Verschoben in {target}", "Moved to {target}"),
("Verzeichnis '{path}' enthält kein manifest.json", "Folder '{path}' contains no manifest.json"),
("Verzeichnis '{path}' existiert nicht", "Folder '{path}' does not exist"),
("Vorherige Seite (Bild auf)", "Previous page (Page Up)"),
("Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen und darauf schreiben", "Open lecture slides, worksheets or forms as a new notebook and write on them"),
("weich", "soft"),
("Weich: schon leichter Druck ergibt kräftige Linien.\nHart: erst fester Druck ergibt kräftige Linien.", "Soft: even light pressure gives bold lines.\nHard: only firm pressure gives bold lines."),
("Weitere Funktionen – Update verfügbar", "More – update available"),
("Weitere Funktionen", "More"),
("Weitere Funktionen: PDF, Hintergrund, Zoom, Einstellungen", "More: PDF, background, zoom, settings"),
("Weiß", "White"),
("WERKZEUGLEISTE", "TOOLBAR"),
("Wiederherstellung fehlgeschlagen: {err}", "Restore failed: {err}"),
("Wiederholen (Strg+Y)", "Redo (Ctrl+Y)"),
("Wiederholen", "Retry"),
("Worker panic beim Speichern der Seite", "Worker panic while saving the page"),
("Worker panic beim Speichern des Manifests", "Worker panic while saving the manifest"),
("z. B. Kapitel 2 – Integrale", "e.g. Chapter 2 – Integrals"),
("Zielseite fehlt", "Target page missing"),
("ZOOM", "ZOOM"),
("Zu viele Weiterleitungen (Redirect-Schleife abgebrochen).", "Too many redirects (redirect loop stopped)."),
("Zugriffsfehler (I/O). Die Datei existiert, kann aber nicht gelesen werden.\nDie Seite bleibt schreibgeschützt, um ein Überschreiben zu verhindern.", "Access error (I/O). The file exists but cannot be read.\nThe page stays read-only to prevent overwriting it."),
("Zurück zu Seiten geht nur, solange alles im Startausschnitt liegt", "Back to pages only works while everything lies in the starting area"),
("Zurücksetzen (Strg+0)", "Reset (Ctrl+0)"),
("{count} Objekt(e) eingefügt", "{count} object(s) pasted"),
("{count} Objekt(e) gelöscht", "{count} object(s) deleted"),
("{count} Objekt(e) kopiert – auch als Bild für andere Programme", "{count} object(s) copied – also as an image for other apps"),
("{count} Objekt(e) kopiert", "{count} object(s) copied"),
("{count} Objekte | [R] Drehen [Entf] Löschen", "{count} objects | [R] Rotate [Del] Delete"),
("{count} Seite(n)", "{count} page(s)"),
("{e} Download abgelehnt.", "{e} Download refused."),
("{file} ist ungültig: {e}.", "{file} is invalid: {e}."),
("{mm} mm Platz geschaffen (Rückgängig mit Strg+Z)", "{mm} mm of space made (undo with Ctrl+Z)"),
("{mm} mm Platz geschlossen (Rückgängig mit Strg+Z)", "{mm} mm of space closed (undo with Ctrl+Z)"),
("{n} Notizbücher", "{n} notebooks"),
("{shape_name} ausgerichtet! (Undo mit Strg+Z)", "{shape_name} straightened! (undo with Ctrl+Z)"),
("{shape} eingerastet!", "{shape} snapped!"),
("{title}_Seite_{number}{suffix}", "{title}_page_{number}{suffix}"),
("Änderungen:", "Changes:"),
("Übergeordneter Ordner", "Parent folder"),
("Übergeordnetes Verzeichnis nicht gefunden", "Parent folder not found"),
("„{title}“ verschieben nach:", "Move “{title}” to:"),
("↩ Rückgängig", "↩ Undo"),
("⏳ Update lädt … {percent} %", "⏳ Downloading update … {percent} %"),
("⏷ {mm} mm Platz", "⏷ {mm} mm space"),
("♾ Endlos", "♾ Endless"),
("⚙ Erweiterte Server-Einstellungen", "⚙ Advanced server settings"),
("⚠ Beschädigt", "⚠ Damaged"),
("⚠ Datenintegritäts-Warnung", "⚠ Data integrity warning"),
("⚠ In anderer Instanz geöffnet (Schreibschutz)", "⚠ Opened in another instance (read-only)"),
("⚠ Ungespeicherte Änderungen", "⚠ Unsaved changes"),
("⚠\u{fe0f} {name} (Beschädigt - kein Manifest)", "⚠\u{fe0f} {name} (damaged - no manifest)"),
("⚠\u{fe0f} {name} (Beschädigt)", "⚠\u{fe0f} {name} (damaged)"),
("⚫ Aktiv", "⚫ Active"),
("✔ Update bereit", "✔ Update ready"),
("➕ Neue Seite einfügen", "➕ Insert new page"),
("➕ Notizbuch", "➕ Notebook"),
("⟲ Gelöschte Seite {number} wiederherstellen", "⟲ Restore deleted page {number}"),
("⬇ Downloads", "⬇ Downloads"),
("🌐 Release auf Gitea öffnen", "🌐 Open release on Gitea"),
("🏠 Persönlicher Ordner", "🏠 Home folder"),
("💾 Einstellungen speichern", "💾 Save settings"),
("📁 Ordner", "📁 Folder"),
("📂 Im Dateimanager anzeigen", "📂 Show in file manager"),
("📂 Ordner öffnen", "📂 Open folder"),
("📄 Als PDF exportieren (Strg+E)", "📄 Export as PDF (Ctrl+E)"),
("📄 PDF importieren …", "📄 Import PDF …"),
("📄 Feste Seiten", "📄 Fixed pages"),
("📄 PDF importieren", "📄 Import PDF"),
("📋 Einfügen", "📋 Paste"),
("📑 Inhalt", "📑 Contents"),
("📚 Alle", "📚 All"),
("📚 Notizbücher", "📚 Notebooks"),
("📚 Oberste Ebene", "📚 Top level"),
("📜 Durchgehend", "📜 Continuous"),
("📦 Paket mit Software-Center öffnen / installieren", "📦 Open / install package with the software center"),
("🔄 Suche nach Updates …", "🔄 Checking for updates …"),
("🔄 Updates & Versionen", "🔄 Updates & versions"),
("🔄 Backup", "🔄 Backup"),
("🔄 Nach Updates suchen", "🔄 Check for updates"),
("🖥 Schreibtisch", "🖥 Desktop"),
("🖼 Bild aus Datei …", "🖼 Image from file …"),
("🖼 Bild einfügen … (Strg+I)", "🖼 Insert image … (Ctrl+I)"),
("🖼 Kacheln", "🖼 Tiles"),
("🖼 Bild einfügen", "🖼 Insert image"),
("🖼 Bilder", "🖼 Pictures"),
("🗑 Seite {number} löschen …", "🗑 Delete page {number} …"),
("🚀 Update {version} verfügbar", "🚀 Update {version} available"),
("🚀 AppImage aktualisieren & ausführen", "🚀 Update AppImage & restart"),
("🚀 Updates & Versionen", "🚀 Updates & versions"),
];
+516
View File
@@ -0,0 +1,516 @@
//! 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. Für eine gerenderte
/// PDF-Seite dauert das spürbar (2560 px: einige 10 ms); deshalb erzeugt sie der
/// PDF-Render-Thread und nicht der Haupt-Thread.
pub fn mip_chain(base: RgbaImage) -> Vec<RgbaImage> {
let mut levels = vec![base];
loop {
let current = &levels[levels.len() - 1];
if current.width <= 1 && current.height <= 1 {
break;
}
let next = half_size(current);
levels.push(next);
}
levels
}
/// Halbiert ein Bild: Mittelwert aus je 2 × 2 Pixeln (bei ungerader Kante entfällt die
/// letzte Spalte bzw. Zeile, bei Kantenlänge 1 wird nur in der anderen Richtung gemittelt).
fn half_size(image: &RgbaImage) -> RgbaImage {
let (w, h) = (image.width as usize, image.height as usize);
let (nw, nh) = ((w / 2).max(1), (h / 2).max(1));
let mut pixels = vec![0u8; nw * nh * 4];
if image.pixels.len() < w * h * 4 {
return RgbaImage {
width: nw as u32,
height: nh as u32,
pixels,
};
}
for y in 0..nh {
let (y0, y1) = ((2 * y).min(h - 1), (2 * y + 1).min(h - 1));
let (row0, row1) = (&image.pixels[y0 * w * 4..], &image.pixels[y1 * w * 4..]);
let out = &mut pixels[y * nw * 4..(y + 1) * nw * 4];
for x in 0..nw {
let (x0, x1) = ((2 * x).min(w - 1) * 4, (2 * x + 1).min(w - 1) * 4);
for c in 0..4 {
let sum = row0[x0 + c] as u16
+ row0[x1 + c] as u16
+ row1[x0 + c] as u16
+ row1[x1 + c] as u16;
out[x * 4 + c] = ((sum + 2) / 4) as u8;
}
}
}
RgbaImage {
width: nw as u32,
height: nh as u32,
pixels,
}
}
/// 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),
/// Fertige Mipmap-Stufen (siehe [`mip_chain`]), z. B. vom PDF-Render-Thread
ReadyMips(Vec<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, mip_chain(image))
}
TextureSource::ReadyMips(levels) if !levels.is_empty() => {
self.upload_texture(device, queue, &key, levels)
}
TextureSource::ReadyMips(_) => {
eprintln!("[Bilder] '{key}' kann nicht angezeigt werden: leeres Bild");
(self.missing_texture(device, queue), 4)
}
// 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, vec![gray]).0
}
/// Lädt die Mipmap-Stufen hoch (Stufe 0 = volle Größe); liefert die Bind Group und den
/// belegten Grafikspeicher.
fn upload_texture(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
label: &str,
levels: Vec<RgbaImage>,
) -> (wgpu::BindGroup, u64) {
let image = &levels[0];
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 bytes = levels.iter().map(|l| l.pixels.len() as u64).sum();
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let bind_group = 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),
},
],
});
(bind_group, bytes)
}
}
#[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);
}
}
#[test]
fn test_half_size_averages_two_by_two_blocks() {
// 3 × 2: links schwarz/weiß übereinander, Mitte grau; die dritte Spalte entfällt
let px = |v: u8| [v, v, v, 255];
let pixels = [px(0), px(100), px(7), px(255), px(100), px(7)].concat();
let base = RgbaImage {
width: 3,
height: 2,
pixels,
};
let half = half_size(&base);
assert_eq!((half.width, half.height), (1, 1));
// (0 + 100 + 255 + 100) / 4 = 113,75 → 114
assert_eq!(half.pixels, vec![114, 114, 114, 255]);
// Kantenlänge 1: nur in der anderen Richtung mitteln
let column = RgbaImage {
width: 1,
height: 2,
pixels: [px(10), px(20)].concat(),
};
assert_eq!(half_size(&column).pixels, vec![15, 15, 15, 255]);
}
}
+11
View File
@@ -1,18 +1,29 @@
//! ink-notes: Latenzoptimierte Inking-App für Linux. //! ink-notes: Latenzoptimierte Inking-App für Linux.
pub mod assets;
pub mod autosave; pub mod autosave;
pub mod background; pub mod background;
pub mod canvas; pub mod canvas;
pub mod document; pub mod document;
pub mod export; pub mod export;
pub mod file_picker;
pub mod filter; pub mod filter;
pub mod geometry; pub mod geometry;
pub mod history; pub mod history;
pub mod i18n;
pub mod image_layer;
pub mod library;
pub mod osk;
pub mod page_export;
pub mod pdf_doc;
pub mod pdf_merge;
pub mod platform; pub mod platform;
pub mod ruler;
pub mod selection; pub mod selection;
pub mod settings; pub mod settings;
pub mod spline; pub mod spline;
pub mod storage; pub mod storage;
pub mod text;
pub mod ui; pub mod ui;
pub mod updater; pub mod updater;
pub mod viewport; pub mod viewport;
+449
View File
@@ -0,0 +1,449 @@
//! Ordner für Notizbücher (`<Basisordner>/library.json`).
//!
//! Ordner sind rein logisch: Die Notizbücher bleiben in ihren Verzeichnissen
//! `<Basisordner>/<id>/`, `library.json` hält nur fest, welcher Ordner welches Notizbuch
//! enthält. Umbenennen und Verschieben ändern so nie Notizbuch-Dateien. Fehlt die Datei oder
//! ist sie unlesbar, liegen alle Notizbücher auf der obersten Ebene – verloren geht nichts.
//!
//! Ordner werden als Pfade mit `/` als Trenner gespeichert (z. B. `3. Semester/Mathe`);
//! die oberste Ebene ist der leere Pfad.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::storage::{self, StorageError};
use crate::t;
/// Oberste Ebene.
pub const ROOT: &str = "";
/// Längster erlaubter Ordnername (Zeichen).
const MAX_NAME_CHARS: usize = 80;
/// Ordnerbaum und Zuordnung der Notizbücher.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Library {
/// Alle Ordner als Pfade
pub folders: BTreeSet<String>,
/// Ordner je Notizbuch-Kennung; Notizbücher ohne Eintrag liegen auf oberster Ebene
pub notebooks: BTreeMap<String, String>,
}
/// Letzter Teil eines Ordnerpfads (der angezeigte Name).
pub fn folder_name(path: &str) -> &str {
path.rsplit('/').next().unwrap_or(path)
}
/// Übergeordneter Ordner (oberste Ebene für Ordner der obersten Ebene).
pub fn parent_folder(path: &str) -> &str {
path.rsplit_once('/').map_or(ROOT, |(parent, _)| parent)
}
/// Pfad des Unterordners `name` in `parent`.
fn join(parent: &str, name: &str) -> String {
if parent.is_empty() {
name.to_string()
} else {
format!("{parent}/{name}")
}
}
/// `path` liegt in `folder` oder ist `folder` selbst.
fn is_within(path: &str, folder: &str) -> bool {
path == folder
|| path
.strip_prefix(folder)
.is_some_and(|rest| rest.starts_with('/'))
}
/// Bereinigt einen Ordnernamen; Fehlermeldung für die Oberfläche bei ungültigen Namen.
pub fn validate_name(name: &str) -> Result<String, String> {
let name = name.trim();
if name.is_empty() {
return Err(t!("Der Ordnername ist leer").to_string());
}
if name.contains('/') {
return Err(t!("Ordnernamen dürfen kein „/“ enthalten").to_string());
}
if name.chars().count() > MAX_NAME_CHARS {
return Err(t!(
"Der Ordnername ist zu lang (höchstens {max} Zeichen)",
max = MAX_NAME_CHARS
));
}
Ok(name.to_string())
}
impl Library {
/// Ort von `library.json` im Basisordner der Notizbücher.
pub fn file_path(base_dir: &Path) -> PathBuf {
base_dir.join("library.json")
}
/// Lädt die Ordner; fehlt die Datei oder ist sie beschädigt, gibt es keine Ordner.
pub fn load(base_dir: &Path) -> Self {
let path = Self::file_path(base_dir);
let mut library: Self = match std::fs::read_to_string(&path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
eprintln!(
"[Ordner] '{}' ist ungültig ({e}), alle Notizbücher auf oberster Ebene.",
path.display()
);
Self::default()
}),
Err(_) => Self::default(),
};
library.repair();
library
}
/// Speichert die Ordner atomar.
pub fn save(&self, base_dir: &Path) -> Result<(), StorageError> {
storage::atomic_write_json(&Self::file_path(base_dir), self)
}
/// Macht eine (evtl. von Hand bearbeitete) Datei stimmig: leere oder ungültige Namen
/// fallen weg, jeder benutzte Ordner und seine übergeordneten Ordner existieren.
fn repair(&mut self) {
let valid = |path: &str| {
!path.is_empty()
&& path
.split('/')
.all(|name| validate_name(name).as_deref() == Ok(name))
};
self.folders.retain(|f| valid(f));
self.notebooks.retain(|_, f| f.is_empty() || valid(f));
let used: Vec<String> = self
.folders
.iter()
.chain(self.notebooks.values())
.cloned()
.collect();
for mut folder in used {
while !folder.is_empty() {
let parent = parent_folder(&folder).to_string();
self.folders.insert(folder);
folder = parent;
}
}
}
/// Ordner, in dem das Notizbuch liegt.
pub fn folder_of(&self, notebook_id: &str) -> &str {
self.notebooks.get(notebook_id).map_or(ROOT, String::as_str)
}
/// Direkte Unterordner von `parent` (vollständige Pfade, nach Name sortiert).
pub fn subfolders(&self, parent: &str) -> Vec<&str> {
let mut list: Vec<&str> = self
.folders
.iter()
.map(String::as_str)
.filter(|f| parent_folder(f) == parent)
.collect();
list.sort_by_key(|f| folder_name(f).to_lowercase());
list
}
/// Alle Ordner in Baumreihenfolge (Eltern vor Kindern, Geschwister nach Name).
pub fn all_folders(&self) -> Vec<&str> {
let mut out = Vec::with_capacity(self.folders.len());
let mut stack: Vec<&str> = self.subfolders(ROOT).into_iter().rev().collect();
while let Some(folder) = stack.pop() {
out.push(folder);
stack.extend(self.subfolders(folder).into_iter().rev());
}
out
}
/// Legt in `parent` den Ordner `name` an und gibt seinen Pfad zurück.
pub fn create_folder(&mut self, parent: &str, name: &str) -> Result<String, String> {
let name = validate_name(name)?;
if !parent.is_empty() && !self.folders.contains(parent) {
return Err(t!("Der übergeordnete Ordner existiert nicht mehr").to_string());
}
let path = join(parent, &name);
if !self.folders.insert(path.clone()) {
return Err(t!("Den Ordner „{name}“ gibt es hier schon", name = name));
}
Ok(path)
}
/// Benennt einen Ordner um (mitsamt Unterordnern und Zuordnungen); gibt den neuen Pfad
/// zurück.
pub fn rename_folder(&mut self, path: &str, new_name: &str) -> Result<String, String> {
let new_name = validate_name(new_name)?;
if !self.folders.contains(path) {
return Err(t!("Den Ordner gibt es nicht mehr").to_string());
}
let new_path = join(parent_folder(path), &new_name);
if new_path == path {
return Ok(new_path);
}
if self.folders.contains(&new_path) {
return Err(t!(
"Den Ordner „{name}“ gibt es hier schon",
name = new_name
));
}
self.move_contents(path, &new_path);
Ok(new_path)
}
/// Löscht einen Ordner. Seine Notizbücher und Unterordner rücken eine Ebene nach oben
/// (gleichnamige Unterordner werden zusammengelegt) – gelöscht wird kein Notizbuch.
pub fn delete_folder(&mut self, path: &str) {
if path.is_empty() || !self.folders.remove(path) {
return;
}
let parent = parent_folder(path).to_string();
let prefix = format!("{path}/");
let moved: Vec<String> = self
.folders
.iter()
.filter(|f| f.starts_with(&prefix))
.cloned()
.collect();
for folder in moved {
self.folders.remove(&folder);
self.folders.insert(join(&parent, &folder[prefix.len()..]));
}
for folder in self.notebooks.values_mut() {
if folder == path {
*folder = parent.clone();
} else if let Some(rest) = folder.strip_prefix(&prefix) {
*folder = join(&parent, rest);
}
}
self.notebooks.retain(|_, f| !f.is_empty());
}
/// Verschiebt einen Ordner samt Inhalt nach `new_path`.
fn move_contents(&mut self, path: &str, new_path: &str) {
let renamed = |f: &str| format!("{new_path}{}", &f[path.len()..]);
let moved: Vec<String> = self
.folders
.iter()
.filter(|f| is_within(f, path))
.cloned()
.collect();
for folder in moved {
self.folders.remove(&folder);
self.folders.insert(renamed(&folder));
}
for folder in self.notebooks.values_mut() {
if is_within(folder, path) {
*folder = renamed(folder);
}
}
}
/// Verschiebt einen Ordner samt Inhalt nach `new_parent` (oberste Ebene: [`ROOT`]) und
/// gibt seinen neuen Pfad zurück. Nicht in sich selbst oder einen eigenen Unterordner.
pub fn move_folder(&mut self, path: &str, new_parent: &str) -> Result<String, String> {
if !self.folders.contains(path) {
return Err(t!("Den Ordner gibt es nicht mehr").to_string());
}
if !new_parent.is_empty() && !self.folders.contains(new_parent) {
return Err(t!("Den Zielordner gibt es nicht mehr").to_string());
}
if is_within(new_parent, path) {
return Err(t!("Ein Ordner kann nicht in sich selbst verschoben werden").to_string());
}
let name = folder_name(path);
let new_path = join(new_parent, name);
if new_path == path {
return Ok(new_path);
}
if self.folders.contains(&new_path) {
return Err(t!(
"Im Zielordner gibt es schon einen Ordner „{name}“",
name = name
));
}
self.move_contents(path, &new_path);
Ok(new_path)
}
/// Mögliche Ziele für das Verschieben des Ordners `path`: alle Ordner außer ihm selbst und
/// seinen Unterordnern (die oberste Ebene ist immer ein Ziel).
pub fn folder_move_targets(&self, path: &str) -> Vec<&str> {
self.all_folders()
.into_iter()
.filter(|f| !is_within(f, path))
.collect()
}
/// Legt das Notizbuch in `folder` (oberste Ebene: [`ROOT`]).
pub fn move_notebook(&mut self, notebook_id: &str, folder: &str) -> Result<(), String> {
if folder.is_empty() {
self.notebooks.remove(notebook_id);
return Ok(());
}
if !self.folders.contains(folder) {
return Err(t!("Den Ordner gibt es nicht mehr").to_string());
}
self.notebooks
.insert(notebook_id.to_string(), folder.to_string());
Ok(())
}
/// Wie viele der Notizbücher `ids` in `folder` liegen, Unterordner eingeschlossen.
pub fn count_within<'a>(&self, folder: &str, ids: impl IntoIterator<Item = &'a str>) -> usize {
ids.into_iter()
.filter(|id| is_within(self.folder_of(id), folder))
.count()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn library() -> Library {
let mut lib = Library::default();
let sem3 = lib.create_folder(ROOT, "3. Semester").unwrap();
let mathe = lib.create_folder(&sem3, "Mathe").unwrap();
lib.create_folder(&sem3, "Physik").unwrap();
lib.move_notebook("analysis", &mathe).unwrap();
lib.move_notebook("uebung", &sem3).unwrap();
lib
}
#[test]
fn test_paths_and_names() {
assert_eq!(folder_name("3. Semester/Mathe"), "Mathe");
assert_eq!(folder_name("Mathe"), "Mathe");
assert_eq!(parent_folder("3. Semester/Mathe"), "3. Semester");
assert_eq!(parent_folder("Mathe"), ROOT);
assert_eq!(validate_name(" Mathe ").unwrap(), "Mathe");
assert!(validate_name(" ").is_err());
assert!(validate_name("a/b").is_err());
assert!(validate_name(&"x".repeat(81)).is_err());
}
#[test]
fn test_create_and_list_folders() {
let mut lib = library();
assert_eq!(lib.subfolders(ROOT), ["3. Semester"]);
assert_eq!(
lib.subfolders("3. Semester"),
["3. Semester/Mathe", "3. Semester/Physik"]
);
assert_eq!(
lib.all_folders(),
["3. Semester", "3. Semester/Mathe", "3. Semester/Physik"]
);
assert_eq!(lib.folder_of("analysis"), "3. Semester/Mathe");
assert_eq!(lib.folder_of("unbekannt"), ROOT);
// Doppelte Namen und fehlende Eltern werden abgelehnt
assert!(lib.create_folder("3. Semester", " Mathe ").is_err());
assert!(lib.create_folder("Gibt es nicht", "X").is_err());
// Gleicher Name in einem anderen Ordner ist erlaubt
lib.create_folder(ROOT, "4. Semester").unwrap();
assert!(lib.create_folder("4. Semester", "Mathe").is_ok());
assert_eq!(
lib.count_within("3. Semester", ["analysis", "uebung", "lose"]),
2
);
}
#[test]
fn test_rename_folder_keeps_contents() {
let mut lib = library();
let new = lib.rename_folder("3. Semester", "WS 25-26").unwrap();
assert_eq!(new, "WS 25-26");
assert_eq!(lib.folder_of("analysis"), "WS 25-26/Mathe");
assert_eq!(lib.folder_of("uebung"), "WS 25-26");
assert!(lib.folders.contains("WS 25-26/Physik"));
assert!(!lib.folders.iter().any(|f| f.starts_with("3. Semester")));
// Ein Ordner mit ähnlichem Namen bleibt unberührt
let mut lib = library();
lib.create_folder(ROOT, "3. Semester alt").unwrap();
lib.rename_folder("3. Semester", "Drei").unwrap();
assert!(lib.folders.contains("3. Semester alt"));
// Kollision
assert!(lib.rename_folder("Drei", "3. Semester alt").is_err());
}
#[test]
fn test_delete_folder_moves_contents_up() {
let mut lib = library();
lib.delete_folder("3. Semester");
assert_eq!(lib.folder_of("analysis"), "Mathe");
assert_eq!(lib.folder_of("uebung"), ROOT);
assert!(!lib.notebooks.contains_key("uebung"));
assert_eq!(lib.subfolders(ROOT), ["Mathe", "Physik"]);
}
#[test]
fn test_move_folder() {
let mut lib = library();
lib.create_folder(ROOT, "Mathe-Sammlung").unwrap();
// Ordner samt Inhalt in einen anderen Ordner schieben
let new = lib
.move_folder("3. Semester/Mathe", "Mathe-Sammlung")
.unwrap();
assert_eq!(new, "Mathe-Sammlung/Mathe");
assert_eq!(lib.folder_of("analysis"), "Mathe-Sammlung/Mathe");
assert!(!lib.folders.contains("3. Semester/Mathe"));
// Auf die oberste Ebene und zurück
assert_eq!(
lib.move_folder("Mathe-Sammlung/Mathe", ROOT).unwrap(),
"Mathe"
);
assert_eq!(
lib.move_folder("Mathe", "3. Semester").unwrap(),
"3. Semester/Mathe"
);
// Nicht in sich selbst oder in einen eigenen Unterordner
assert!(lib.move_folder("3. Semester", "3. Semester").is_err());
assert!(lib.move_folder("3. Semester", "3. Semester/Mathe").is_err());
assert_eq!(lib.folder_move_targets("3. Semester"), ["Mathe-Sammlung"]);
// Namenskollision am Ziel
lib.create_folder("Mathe-Sammlung", "Physik").unwrap();
assert!(lib
.move_folder("3. Semester/Physik", "Mathe-Sammlung")
.is_err());
assert_eq!(lib.folder_of("uebung"), "3. Semester");
}
#[test]
fn test_move_notebook() {
let mut lib = library();
lib.move_notebook("uebung", "3. Semester/Physik").unwrap();
assert_eq!(lib.folder_of("uebung"), "3. Semester/Physik");
lib.move_notebook("uebung", ROOT).unwrap();
assert_eq!(lib.folder_of("uebung"), ROOT);
assert!(lib.move_notebook("uebung", "Gibt es nicht").is_err());
}
#[test]
fn test_load_save_and_repair() {
let dir = tempfile::tempdir().unwrap();
// Fehlende Datei: keine Ordner
assert_eq!(Library::load(dir.path()), Library::default());
let lib = library();
lib.save(dir.path()).unwrap();
assert_eq!(Library::load(dir.path()), lib);
// Beschädigt: alles auf oberster Ebene statt Absturz
std::fs::write(Library::file_path(dir.path()), b"{ kaputt").unwrap();
assert_eq!(Library::load(dir.path()), Library::default());
// Von Hand bearbeitet: fehlende Elternordner werden ergänzt, Unsinn fällt weg
std::fs::write(
Library::file_path(dir.path()),
br#"{"folders": ["A/B", "", "C//D"], "notebooks": {"x": "E/F", "y": "/kaputt"}}"#,
)
.unwrap();
let lib = Library::load(dir.path());
assert_eq!(lib.all_folders(), ["A", "A/B", "E", "E/F"]);
assert_eq!(lib.folder_of("x"), "E/F");
assert_eq!(lib.folder_of("y"), ROOT);
}
}
+2107 -545
View File
File diff suppressed because it is too large Load Diff
+294
View File
@@ -0,0 +1,294 @@
//! Bildschirmtastatur unter Wayland anfordern.
//!
//! GNOME (mutter) blendet die Bildschirmtastatur nicht schon beim ersten `enable` eines
//! Texteingabefelds (`zwp_text_input_v3`) ein, sondern erst, wenn ein bereits aktives Feld
//! `enable` erneut meldet – so macht es GTK beim Antippen. winit meldet `enable` nur einmal,
//! sobald egui ein Textfeld fokussiert. Daher schickt ink-notes über ein eigenes
//! text-input-Objekt auf derselben Wayland-Verbindung ein weiteres `enable`. Die getippten
//! Zeichen kommen weiterhin über winit an.
//!
//! Die Rücktaste der GNOME-Bildschirmtastatur löscht über `delete_surrounding_text` im zuvor
//! gemeldeten Umgebungstext; ohne Umgebungstext passiert nichts, und winit wertet das Löschen
//! ohnehin nicht aus. Daher meldet das eigene Objekt einen Platzhalter als Umgebungstext und
//! übersetzt die Löschwünsche in Tastendrücke (Rücktaste/Entf) für egui.
use std::ffi::c_void;
use wayland_client::{
delegate_noop,
protocol::{wl_registry, wl_seat},
Connection, Dispatch, EventQueue, QueueHandle,
};
use wayland_protocols::wp::text_input::zv3::client::{
zwp_text_input_manager_v3::ZwpTextInputManagerV3,
zwp_text_input_v3::{self, ZwpTextInputV3},
};
/// Platzhalter-Umgebungstext: ein Zeichen vor der Schreibmarke, damit die Rücktaste der
/// Bildschirmtastatur immer etwas zu löschen hat (ein Byte = ein Zeichen)
const SURROUNDING_PLACEHOLDER: &str = " ";
/// Wandelt Texteingaben ohne Vorschau (Preedit) in normale Texteingaben um.
///
/// Die Bildschirmtastatur schickt jedes Zeichen als fertigen IME-Text, ohne vorher eine Vorschau
/// zu melden. egui 0.27 fügt solche Texte nur ein, solange die Schreibmarke dort steht, wo die
/// letzte Vorschau begann – also nur das erste Zeichen. `composing` merkt sich über Frames hinweg,
/// ob gerade eine Vorschau läuft; deren Abschluss bleibt bei egui.
pub fn plain_ime_commits_to_text(events: &mut [egui::Event], composing: &mut bool) {
for event in events {
match event {
egui::Event::CompositionStart => *composing = true,
egui::Event::CompositionEnd(text) => {
if !*composing && !text.is_empty() && !text.contains(['\n', '\r']) {
*event = egui::Event::Text(std::mem::take(text));
}
*composing = false;
}
_ => {}
}
}
}
/// Tastendrücke für egui, die `back` Zeichen vor und `forward` Zeichen nach der Schreibmarke
/// löschen
pub fn deletion_key_events(back: u32, forward: u32) -> Vec<egui::Event> {
let presses = std::iter::repeat_n(egui::Key::Backspace, back as usize)
.chain(std::iter::repeat_n(egui::Key::Delete, forward as usize));
presses
.flat_map(|key| {
[true, false].map(|pressed| egui::Event::Key {
key,
physical_key: None,
pressed,
repeat: false,
modifiers: egui::Modifiers::NONE,
})
})
.collect()
}
/// Entscheidet, ob die Bildschirmtastatur angefordert wird: sobald ein Textfeld den Fokus
/// bekommt und erneut bei jedem Antippen des fokussierten Felds (falls die Tastatur inzwischen
/// zugeklappt wurde).
pub fn should_request(was_active: bool, active: bool, pressed: bool) -> bool {
active && (!was_active || pressed)
}
#[derive(Default)]
struct State {
seat: Option<wl_seat::WlSeat>,
manager: Option<ZwpTextInputManagerV3>,
text_input: Option<ZwpTextInputV3>,
/// Von der Bildschirmtastatur gewünschtes Löschen (Zeichen vor/nach der Schreibmarke)
delete_back: u32,
delete_forward: u32,
}
/// Eigenes text-input-Objekt, mit dem ink-notes die Bildschirmtastatur anfordert
pub struct KeyboardRequester {
conn: Connection,
queue: EventQueue<State>,
state: State,
}
impl KeyboardRequester {
/// Hängt sich an die Wayland-Verbindung des Fensters.
///
/// # Safety
/// `wl_display` muss auf die `wl_display` des Fensters zeigen und gültig bleiben, solange
/// der `KeyboardRequester` lebt.
pub unsafe fn new(wl_display: *mut c_void) -> Self {
// Safety: an den Aufrufer weitergereicht
let backend =
unsafe { wayland_backend::client::Backend::from_foreign_display(wl_display.cast()) };
let conn = Connection::from_backend(backend);
let queue = conn.new_event_queue();
conn.display().get_registry(&queue.handle(), ());
let _ = conn.flush();
Self {
conn,
queue,
state: State::default(),
}
}
/// Verarbeitet eingetroffene Ereignisse; einmal pro Frame aufrufen.
pub fn pump(&mut self) {
let _ = self.queue.dispatch_pending(&mut self.state);
if self.state.text_input.is_none() {
if let (Some(manager), Some(seat)) = (&self.state.manager, &self.state.seat) {
self.state.text_input =
Some(manager.get_text_input(seat, &self.queue.handle(), ()));
}
}
}
/// Bittet den Compositor, die Bildschirmtastatur einzublenden. Muss nach dem `enable` von
/// winit kommen (also nach `handle_platform_output`).
pub fn request(&mut self) {
self.pump();
if let Some(text_input) = &self.state.text_input {
text_input.enable();
self.send_surrounding_placeholder();
}
}
/// Tastendrücke für das seit dem letzten Aufruf gewünschte Löschen (vor `begin_frame`
/// aufrufen und an die egui-Eingabe anhängen)
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
self.pump();
// Den Platzhalter hier nicht neu melden: Die Tastatur löscht bei jeder neuen Meldung
// erneut, solange die Rücktaste gedrückt ist – ein kurzes Antippen löschte sonst doppelt.
let back = std::mem::take(&mut self.state.delete_back);
let forward = std::mem::take(&mut self.state.delete_forward);
deletion_key_events(back, forward)
}
fn send_surrounding_placeholder(&self) {
if let Some(text_input) = &self.state.text_input {
let len = SURROUNDING_PLACEHOLDER.len() as i32;
text_input.set_surrounding_text(SURROUNDING_PLACEHOLDER.to_owned(), len, len);
text_input.commit();
let _ = self.conn.flush();
}
}
}
impl Dispatch<wl_registry::WlRegistry, ()> for State {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_registry::Event::Global {
name, interface, ..
} = event
{
match interface.as_str() {
"wl_seat" if state.seat.is_none() => {
state.seat = Some(registry.bind(name, 1, qh, ()));
}
"zwp_text_input_manager_v3" if state.manager.is_none() => {
state.manager = Some(registry.bind(name, 1, qh, ()));
}
_ => {}
}
}
}
}
delegate_noop!(State: ignore wl_seat::WlSeat);
delegate_noop!(State: ZwpTextInputManagerV3);
impl Dispatch<ZwpTextInputV3, ()> for State {
fn event(
state: &mut Self,
_: &ZwpTextInputV3,
event: zwp_text_input_v3::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
// Getippter Text kommt über winit; hier nur das Löschen
if let zwp_text_input_v3::Event::DeleteSurroundingText {
before_length,
after_length,
} = event
{
state.delete_back += before_length;
state.delete_forward += after_length;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn requests_when_text_field_gets_focus() {
assert!(should_request(false, true, false));
assert!(should_request(false, true, true));
}
#[test]
fn requests_again_when_focused_field_is_tapped() {
assert!(should_request(true, true, true));
assert!(!should_request(true, true, false));
}
#[test]
fn commits_without_preedit_become_text() {
let mut composing = false;
let mut events = vec![
egui::Event::CompositionEnd("h".into()),
egui::Event::CompositionEnd("i".into()),
];
plain_ime_commits_to_text(&mut events, &mut composing);
assert_eq!(
events,
vec![egui::Event::Text("h".into()), egui::Event::Text("i".into())]
);
assert!(!composing);
}
#[test]
fn commits_after_preedit_stay_with_egui() {
let mut composing = false;
let mut events = vec![
egui::Event::CompositionStart,
egui::Event::CompositionUpdate("ni".into()),
];
plain_ime_commits_to_text(&mut events, &mut composing);
assert!(composing);
// Abschluss erst im nächsten Frame
let mut events = vec![egui::Event::CompositionEnd("你".into())];
plain_ime_commits_to_text(&mut events, &mut composing);
assert_eq!(events, vec![egui::Event::CompositionEnd("你".into())]);
assert!(!composing);
}
#[test]
fn line_breaks_and_empty_commits_are_left_alone() {
let mut composing = false;
let mut events = vec![
egui::Event::CompositionEnd("\n".into()),
egui::Event::CompositionEnd(String::new()),
];
let before = events.clone();
plain_ime_commits_to_text(&mut events, &mut composing);
assert_eq!(events, before);
}
#[test]
fn deletions_become_key_presses() {
let events = deletion_key_events(2, 1);
let keys: Vec<_> = events
.iter()
.map(|e| match e {
egui::Event::Key { key, pressed, .. } => (*key, *pressed),
other => panic!("unerwartet: {other:?}"),
})
.collect();
assert_eq!(
keys,
vec![
(egui::Key::Backspace, true),
(egui::Key::Backspace, false),
(egui::Key::Backspace, true),
(egui::Key::Backspace, false),
(egui::Key::Delete, true),
(egui::Key::Delete, false),
]
);
assert!(deletion_key_events(0, 0).is_empty());
}
#[test]
fn no_request_without_focused_text_field() {
assert!(!should_request(false, false, true));
assert!(!should_request(true, false, true));
}
}
+444
View File
@@ -0,0 +1,444 @@
//! 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};
use crate::export::{export_notebook_to_bytes, ExportStrategy};
use crate::t;
/// 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| t!("PDF nicht lesbar: {e}", e = format!("{e:?}")))?;
let page = pdf.pages().first().ok_or(t!("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(t!("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| t!("PDF nicht lesbar: {e}", e = format!("{e:?}")))?;
let pdf_page = pdf.pages().first().ok_or(t!("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 size = bounds.max - bounds.min;
if size.min_element() < 1.0 {
return Ok(None);
}
// Nur den Ausschnitt rastern (auf der endlosen Seite wäre die ganze Seite riesig)
let cropped = selected.cropped(bounds.min, size);
let image = rasterize(
single_page_pdf(&cropped, assets, PatternColor::default())?,
SELECTION_PNG_DPI,
true,
)?;
Ok(Some(image))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::{Stroke, StylusSample, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH};
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());
}
}
+537
View File
@@ -0,0 +1,537 @@
//! 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, samt Mipmaps für die Anzeige, und meldet
//! fertige Seiten zurück.
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver};
use std::sync::{Arc, Condvar, Mutex};
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;
use crate::t;
/// 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(t!("Das PDF enthält keine Seiten").into());
}
if sizes.len() > MAX_IMPORT_PAGES {
return Err(t!(
"Das PDF hat {count} Seiten, höchstens {max} werden unterstützt",
count = sizes.len(),
max = MAX_IMPORT_PAGES
));
}
Ok(sizes)
}
fn open(bytes: Vec<u8>) -> Result<Pdf, String> {
Pdf::new(bytes).map_err(|e| match e {
LoadPdfError::Decryption(_) => {
t!("Das PDF ist verschlüsselt oder passwortgeschützt und kann nicht geöffnet werden")
.to_string()
}
LoadPdfError::Invalid => t!("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(|| t!("Seite {number} fehlt im PDF", number = 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,
/// Wichtigkeit: kleiner zuerst (Reihenfolge der Anfragen im aktuellen Bild)
rank: usize,
}
/// Wichtigkeit einer vorab gerenderten Seite: nach allen sichtbaren.
const PREFETCH_RANK: usize = usize::MAX;
/// Warteschlange der noch nicht begonnenen Render-Aufträge, geteilt mit dem Render-Thread.
#[derive(Default)]
struct RenderQueue {
requests: Vec<RenderRequest>,
shutdown: bool,
}
impl RenderQueue {
/// Fügt einen Auftrag hinzu oder macht einen wartenden wichtiger.
fn push(&mut self, request: RenderRequest) {
match self.requests.iter_mut().find(|r| r.key == request.key) {
Some(queued) => queued.rank = queued.rank.min(request.rank),
None => self.requests.push(request),
}
}
/// Nimmt den wichtigsten Auftrag heraus.
fn pop(&mut self) -> Option<RenderRequest> {
let (index, _) = self
.requests
.iter()
.enumerate()
.min_by_key(|(_, r)| r.rank)?;
Some(self.requests.swap_remove(index))
}
/// Verwirft Aufträge, für die `rank_of` keinen Rang mehr liefert, und ordnet die übrigen
/// neu. Liefert die Schlüssel der verworfenen.
fn retain(&mut self, rank_of: impl Fn(&str) -> Option<usize>) -> Vec<String> {
let mut dropped = Vec::new();
self.requests.retain_mut(|r| match rank_of(&r.key) {
Some(rank) => {
r.rank = rank;
true
}
None => {
dropped.push(r.key.clone());
false
}
});
dropped
}
}
type SharedQueue = Arc<(Mutex<RenderQueue>, Condvar)>;
/// Ergebnis eines Render-Auftrags: die Mipmap-Stufen der Seite (siehe
/// [`crate::image_layer::mip_chain`]), Stufe 0 in voller Größe.
pub type RenderResult = Result<Vec<RgbaImage>, String>;
/// Rendert PDF-Seiten im Hintergrund. Fertige Seiten holt der Event-Loop mit
/// [`PdfRenderService::take`] ab; `on_ready` weckt ihn dafür auf.
///
/// Je Bild meldet der Event-Loop zwischen [`Self::begin_frame`] und [`Self::end_frame`] mit
/// `take` alle Seiten an, die er gerade braucht (die wichtigste zuerst). Gerendert wird die
/// wichtigste wartende Seite zuerst; Seiten, die beim schnellen Scrollen schon wieder aus dem
/// Bild sind, verwirft `end_frame`, bevor der Render-Thread sie beginnt.
pub struct PdfRenderService {
queue: SharedQueue,
worker: std::thread::JoinHandle<()>,
results: Receiver<(String, RenderResult)>,
/// Wartende oder gerade gerenderte Seiten
pending: HashSet<String>,
ready: HashMap<String, RenderResult>,
/// Im aktuellen Bild angeforderte Seiten in ihrer Reihenfolge
wanted: Vec<String>,
/// Zuletzt vorab angeforderte Seite (bleibt erhalten, bis sie abgeholt wird)
prefetched: Option<String>,
}
impl PdfRenderService {
pub fn new(on_ready: impl Fn() + Send + 'static) -> Self {
let queue: SharedQueue = Arc::default();
let (res_tx, res_rx) = mpsc::channel();
let worker_queue = Arc::clone(&queue);
let worker = 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;
loop {
let request = {
let (lock, ready) = &*worker_queue;
let mut queue = lock.lock().unwrap_or_else(|e| e.into_inner());
loop {
if queue.shutdown {
return;
}
if let Some(request) = queue.pop() {
break request;
}
queue = ready.wait(queue).unwrap_or_else(|e| e.into_inner());
}
};
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 {
// Mipmaps hier statt im Haupt-Thread: sonst stockt das Scrollen bei
// jeder neu gerenderten Seite (Issue #28)
Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels)
.map(crate::image_layer::mip_chain),
None => Err(t!("PDF kann nicht geöffnet werden").into()),
};
if res_tx.send((request.key, result)).is_err() {
return;
}
on_ready();
}
})
.expect("PDF-Render-Thread konnte nicht gestartet werden");
Self {
queue,
worker,
results: res_rx,
pending: HashSet::new(),
ready: HashMap::new(),
wanted: Vec::new(),
prefetched: None,
}
}
/// Beginnt die Anfragen eines neuen Bildes (siehe [`Self::end_frame`]).
pub fn begin_frame(&mut self) {
self.wanted.clear();
}
/// Verwirft wartende Aufträge und fertige Ergebnisse für Seiten, die in diesem Bild nicht
/// mehr angefordert wurden (außer der vorab angeforderten), und ordnet die übrigen nach
/// der Reihenfolge der Anfragen.
pub fn end_frame(&mut self) {
let rank_of = |key: &str| {
self.wanted
.iter()
.position(|k| k == key)
.or_else(|| (self.prefetched.as_deref() == Some(key)).then_some(PREFETCH_RANK))
};
let dropped = {
let mut queue = self.queue.0.lock().unwrap_or_else(|e| e.into_inner());
queue.retain(rank_of)
};
for key in dropped {
self.pending.remove(&key);
}
self.ready.retain(|key, _| rank_of(key).is_some());
}
/// 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();
self.prefetched = Some(key.to_string());
if self.ready.contains_key(key) {
return;
}
self.request(key, path, page, DISPLAY_RENDER_PIXELS, PREFETCH_RANK);
}
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 bzw. macht einen wartenden wichtiger. `false`, wenn der
/// Render-Thread beendet ist.
fn request(
&mut self,
key: &str,
path: PathBuf,
page: usize,
max_pixels: u32,
rank: usize,
) -> bool {
if self.worker.is_finished() {
self.pending.remove(key);
return false;
}
let (lock, ready) = &*self.queue;
let mut queue = lock.lock().unwrap_or_else(|e| e.into_inner());
// Steht ein angefragter Auftrag nicht mehr in der Warteschlange, rendert ihn der
// Thread gerade: nicht doppelt erteilen
let waiting = queue.requests.iter().any(|r| r.key == key);
if self.pending.contains(key) && !waiting {
return true;
}
self.pending.insert(key.to_string());
queue.push(RenderRequest {
key: key.to_string(),
path,
page,
max_pixels,
rank,
});
ready.notify_one();
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 self.prefetched.as_deref() == Some(key) {
self.prefetched = None;
}
if let Some(result) = self.ready.remove(key) {
return Some(result);
}
let rank = self.wanted.len();
self.wanted.push(key.to_string());
if !self.request(key, path, page, max_pixels, rank) {
return Some(Err(t!("PDF-Render-Thread beendet").into()));
}
None
}
}
impl Drop for PdfRenderService {
fn drop(&mut self) {
let (lock, ready) = &*self.queue;
lock.lock().unwrap_or_else(|e| e.into_inner()).shutdown = true;
ready.notify_all();
}
}
#[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 levels = service.take("a#0", path.clone(), 0, px).unwrap().unwrap();
assert_eq!(levels[0].width, DISPLAY_RENDER_PIXELS);
assert_eq!(
(levels.last().unwrap().width, levels.last().unwrap().height),
(1, 1)
);
// 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());
}
fn request(key: &str, rank: usize) -> RenderRequest {
RenderRequest {
key: key.into(),
path: PathBuf::new(),
page: 0,
max_pixels: 1,
rank,
}
}
#[test]
fn test_render_queue_priorities_and_stale_requests() {
let mut queue = RenderQueue::default();
queue.push(request("a", 2));
queue.push(request("b", 0));
queue.push(request("c", 1));
// Erneute Anfrage macht einen wartenden Auftrag wichtiger, ohne ihn zu verdoppeln
queue.push(request("a", 0));
assert_eq!(queue.requests.len(), 3);
assert_eq!(queue.pop().map(|r| r.key), Some("a".into()));
// Nach dem Scrollen wird nur noch „c“ gebraucht: „b“ fällt weg
let dropped = queue.retain(|key| (key == "c").then_some(0));
assert_eq!(dropped, vec!["b".to_string()]);
assert_eq!(queue.pop().map(|r| r.key), Some("c".into()));
assert!(queue.pop().is_none());
}
#[test]
fn test_end_frame_drops_unwanted_results_but_keeps_prefetch() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("a.pdf");
std::fs::write(&path, sample_pdf(&[(400.0, 300.0, 0), (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;
let wait = || {
wake_rx
.recv_timeout(std::time::Duration::from_secs(20))
.unwrap()
};
// Bild 1 braucht Seite 1; vorab gerendert wird Seite 2
service.begin_frame();
assert!(service.take("a#0", path.clone(), 0, px).is_none());
service.prefetch("a#1", path.clone(), 1);
service.end_frame();
wait();
wait();
// Bild 2 braucht keine Seite mehr (weggescrollt): Seite 1 wird verworfen,
// die vorab gerenderte Seite 2 bleibt
service.begin_frame();
service.collect();
service.end_frame();
assert!(!service.ready.contains_key("a#0"));
assert!(service.ready.contains_key("a#1"));
assert!(service.pending.is_empty());
service.begin_frame();
assert!(service.take("a#1", path.clone(), 1, px).unwrap().is_ok());
// Seite 1 wird bei Bedarf neu gerendert
assert!(service.take("a#0", path.clone(), 0, px).is_none());
service.end_frame();
wait();
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);
}
}
+390
View File
@@ -0,0 +1,390 @@
//! 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};
use crate::t;
/// 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| t!("Inhalt nicht lesbar: {e}", e = 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(|| t!("Quell-PDF fehlt").to_string())?;
let mut source = Document::load_mem(bytes).map_err(|e| e.to_string())?;
if source.is_encrypted() {
return Err(t!("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(|| {
t!(
"Seite {number} fehlt im Quell-PDF",
number = underlay.source_page + 1
)
})?;
let target_page = *target_pages
.get(underlay.target_page)
.ok_or_else(|| t!("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"));
}
}
+52
View File
@@ -58,10 +58,62 @@ pub fn display_backend_from_env() -> DisplayBackend {
) )
} }
/// Bringt die Ereignisse eines Tablet-Frames in eine brauchbare Reihenfolge: octotablet meldet
/// das Aufsetzen des Stifts (`Down`) vor der Position desselben Frames (`Pose`). Bis dahin
/// gälte noch die alte Position – kam der Stift schnell heran (Annähern und Aufsetzen im selben
/// Frame), läge sie ganz woanders: Ein Tippen auf einen Knopf ginge daneben, ein Strich begänne
/// mit einer Linie von der alten Stelle. Hier folgt jedes `Down` auf die nächste `Pose`, spätestens
/// vor dem nächsten anderen Ereignis.
pub fn down_after_pose<T>(
events: impl IntoIterator<Item = T>,
is_down: impl Fn(&T) -> bool,
is_pose: impl Fn(&T) -> bool,
) -> Vec<T> {
let mut ordered = Vec::new();
let mut deferred = None;
for event in events {
if is_down(&event) {
if let Some(down) = deferred.replace(event) {
ordered.push(down);
}
continue;
}
let pose = is_pose(&event);
if !pose {
ordered.extend(deferred.take());
}
ordered.push(event);
if pose {
ordered.extend(deferred.take());
}
}
ordered.extend(deferred);
ordered
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn test_down_follows_pose_of_its_frame() {
let order = |events: &str| {
down_after_pose(events.chars(), |c| *c == 'D', |c| *c == 'P')
.into_iter()
.collect::<String>()
};
// In, Down, Pose, Button, Frame -> Down erst mit der neuen Position
assert_eq!(order("IDPBF"), "IPDBF");
// Down ohne Pose im Frame: vor dem Frame-Ende
assert_eq!(order("DF"), "DF");
assert_eq!(order("DBF"), "DBF");
// Am Ende ohne weiteres Ereignis
assert_eq!(order("PD"), "PD");
// Andere Ereignisse bleiben unverändert
assert_eq!(order("PFPFUF"), "PFPFUF");
assert_eq!(order("IDPFPFUF"), "IPDFPFUF");
}
#[test] #[test]
fn test_choose_display_backend() { fn test_choose_display_backend() {
// WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab) // WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab)
+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);
}
}
+191 -14
View File
@@ -7,6 +7,7 @@ use std::f32::consts::FRAC_PI_2;
use crate::canvas::CanvasState; use crate::canvas::CanvasState;
use crate::document::{Aabb, Page, Stroke}; use crate::document::{Aabb, Page, Stroke};
use crate::t;
/// Status des aktiven Ziehvorgangs (Drag-Operation) mit der Maus oder dem Stylus. /// Status des aktiven Ziehvorgangs (Drag-Operation) mit der Maus oder dem Stylus.
#[derive(Debug, Clone, PartialEq, Default)] #[derive(Debug, Clone, PartialEq, Default)]
@@ -35,9 +36,34 @@ pub enum DragMode {
start_pos_page: Vec2, start_pos_page: Vec2,
initial_box_size: Vec2, initial_box_size: Vec2,
initial_strokes: Vec<Stroke>, initial_strokes: Vec<Stroke>,
/// Seitenverhältnis beibehalten (Schloss der Auswahl oder Shift gedrückt)
keep_aspect: bool,
}, },
} }
/// Skalierungsfaktoren, wenn der Eckgriff von `start` (relativ zur festen Ecke `fixed`)
/// nach `current` gezogen wird. Mit `keep_aspect` wird gleichmäßig skaliert: der Faktor ist
/// die Projektion der Zugposition auf die Diagonale, damit das Seitenverhältnis bleibt.
pub fn scale_factors(fixed: Vec2, start: Vec2, current: Vec2, keep_aspect: bool) -> Vec2 {
let orig = start - fixed;
let new = current - fixed;
if keep_aspect {
let len_sq = orig.length_squared();
if len_sq <= 1.0 {
return Vec2::ONE;
}
return Vec2::splat((new.dot(orig) / len_sq).clamp(0.05, 20.0));
}
let factor = |n: f32, o: f32| {
if o.abs() > 1.0 {
(n / o).clamp(0.05, 20.0)
} else {
1.0
}
};
Vec2::new(factor(new.x, orig.x), factor(new.y, orig.y))
}
/// Ergebnis eines Hit-Tests auf Auswahl-Griffe (Handles). /// Ergebnis eines Hit-Tests auf Auswahl-Griffe (Handles).
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub enum SelectionHandleHit { pub enum SelectionHandleHit {
@@ -62,7 +88,13 @@ pub enum SelectionHandleHit {
pub struct SelectionState { pub struct SelectionState {
pub selected_ids: Vec<u64>, pub selected_ids: Vec<u64>,
pub drag_mode: DragMode, pub drag_mode: DragMode,
/// Letzte Zugposition, die noch nicht angewendet ist: Der Stift meldet sich öfter, als
/// gezeichnet wird; angewendet wird einmal je Bild (siehe `flush_selection_drag`).
pub pending_drag: Option<Vec2>,
pub clipboard: Vec<Stroke>, pub clipboard: Vec<Stroke>,
/// Von Hand umgeschaltetes Seitenverhältnis-Schloss und die Auswahl (IDs), für die es
/// gilt. Ändert sich die Auswahl, gilt wieder die Vorgabe (siehe [`Self::keeps_aspect`]).
aspect_lock: Option<(Vec<u64>, bool)>,
} }
impl SelectionState { impl SelectionState {
@@ -70,10 +102,31 @@ impl SelectionState {
Self { Self {
selected_ids: Vec::new(), selected_ids: Vec::new(),
drag_mode: DragMode::None, drag_mode: DragMode::None,
pending_drag: None,
clipboard: Vec::new(), clipboard: Vec::new(),
aspect_lock: None,
} }
} }
/// Ob Skalieren an den Eckgriffen das Seitenverhältnis der Auswahl beibehält. Vorgabe:
/// ja, sobald ein Bild ausgewählt ist (Issue #29); per Knopf für diese Auswahl umschaltbar.
pub fn keeps_aspect(&self, page: &Page) -> bool {
match &self.aspect_lock {
Some((ids, locked)) if *ids == self.selected_ids => *locked,
// Läuft bei jedem Bild: erst die (wenigen) Bilder suchen, ohne Striche zu kopieren
_ => page
.strokes
.iter()
.any(|s| s.is_image() && self.selected_ids.contains(&s.id)),
}
}
/// Schaltet das Seitenverhältnis-Schloss der aktuellen Auswahl um.
pub fn toggle_aspect_lock(&mut self, page: &Page) {
let locked = !self.keeps_aspect(page);
self.aspect_lock = Some((self.selected_ids.clone(), locked));
}
/// Prüft, ob aktuell Objekte ausgewählt sind. /// Prüft, ob aktuell Objekte ausgewählt sind.
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.selected_ids.is_empty() self.selected_ids.is_empty()
@@ -111,8 +164,13 @@ impl SelectionState {
/// Berechnet die gemeinsame AABB aller ausgewählten Objekte. /// Berechnet die gemeinsame AABB aller ausgewählten Objekte.
pub fn get_selection_aabb(&self, page: &Page) -> Option<Aabb> { pub fn get_selection_aabb(&self, page: &Page) -> Option<Aabb> {
let strokes = page.get_strokes(&self.selected_ids); // Läuft bei jedem Bild: ohne die ausgewählten Striche zu kopieren
Page::selection_aabb(&strokes) let ids: std::collections::HashSet<u64> = self.selected_ids.iter().copied().collect();
page.strokes
.iter()
.filter(|s| ids.contains(&s.id))
.map(|s| s.aabb)
.reduce(|a, b| a.union(&b))
} }
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten /// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten
@@ -203,13 +261,19 @@ impl SelectionState {
pub fn paste_from_clipboard( pub fn paste_from_clipboard(
&mut self, &mut self,
page: &mut Page, page: &mut Page,
at: Option<Vec2>,
next_id_gen: &mut impl FnMut() -> u64, next_id_gen: &mut impl FnMut() -> u64,
) -> Vec<u64> { ) -> Vec<u64> {
if self.clipboard.is_empty() { if self.clipboard.is_empty() {
return Vec::new(); 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_strokes = Vec::with_capacity(self.clipboard.len());
let mut new_ids = Vec::with_capacity(self.clipboard.len()); let mut new_ids = Vec::with_capacity(self.clipboard.len());
@@ -243,7 +307,12 @@ impl SelectionState {
if self.selected_ids.is_empty() { if self.selected_ids.is_empty() {
return false; 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() { if strokes.is_empty() {
return false; return false;
} }
@@ -393,15 +462,14 @@ pub fn render_selection_overlay(
} }
// Kleines Info-Badge an der Unterkante (z. B. "2 Objekte | [R] Drehen [Entf] Löschen") // Kleines Info-Badge an der Unterkante (z. B. "2 Objekte | [R] Drehen [Entf] Löschen")
let badge_text = format!( let badge_text = if selection.count() == 1 {
"{} {} | [R] Drehen [Entf] Löschen", t!("1 Objekt | [R] Drehen [Entf] Löschen").to_string()
selection.count(), } else {
if selection.count() == 1 { t!(
"Objekt" "{count} Objekte | [R] Drehen [Entf] Löschen",
} else { count = selection.count()
"Objekte" )
} };
);
let badge_pos = egui::pos2(rect.center().x, rect.max.y + 16.0); let badge_pos = egui::pos2(rect.center().x, rect.max.y + 16.0);
painter.text( painter.text(
badge_pos, badge_pos,
@@ -452,7 +520,7 @@ mod tests {
// Paste // Paste
let mut next_id = 100; 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 += 1;
next_id next_id
}); });
@@ -460,6 +528,18 @@ mod tests {
assert_eq!(page.strokes.len(), 3); assert_eq!(page.strokes.len(), 3);
assert_eq!(sel.selected_ids, new_ids); 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 // Delete
let deleted = sel.delete_selected(&mut page); let deleted = sel.delete_selected(&mut page);
assert_eq!(deleted.len(), 1); assert_eq!(deleted.len(), 1);
@@ -499,4 +579,101 @@ mod tests {
let restored_pos = page.strokes[0].points[0].position; let restored_pos = page.strokes[0].points[0].position;
assert!((orig_pos.x - restored_pos.x).abs() < 1e-4); assert!((orig_pos.x - restored_pos.x).abs() < 1e-4);
} }
#[test]
fn test_scale_factors_free_and_keep_aspect() {
let fixed = Vec2::new(0.0, 0.0);
let start = Vec2::new(200.0, 100.0);
// Frei: Breite und Höhe unabhängig
let free = scale_factors(fixed, start, Vec2::new(400.0, 100.0), false);
assert_eq!(free, Vec2::new(2.0, 1.0));
// Seitenverhältnis fest: gleicher Faktor in beiden Richtungen
let locked = scale_factors(fixed, start, Vec2::new(400.0, 100.0), true);
assert!((locked.x - locked.y).abs() < 1e-6);
assert!((locked.x - 1.8).abs() < 1e-4);
// Entlang der Diagonale genau verdoppelt
let diag = scale_factors(fixed, start, Vec2::new(400.0, 200.0), true);
assert!((diag - Vec2::splat(2.0)).length() < 1e-5);
// Über die feste Ecke hinaus: kein Spiegeln
let past = scale_factors(fixed, start, Vec2::new(-50.0, -50.0), true);
assert_eq!(past, Vec2::splat(0.05));
// Senkrechte Linie (Breite 0): frei bleibt x unverändert, fest skaliert beides
let line = Vec2::new(0.0, 100.0);
assert_eq!(
scale_factors(fixed, line, Vec2::new(30.0, 200.0), false),
Vec2::new(1.0, 2.0)
);
assert_eq!(
scale_factors(fixed, line, Vec2::new(30.0, 200.0), true),
Vec2::splat(2.0)
);
}
#[test]
fn test_aspect_lock_defaults_to_images_and_toggles_per_selection() {
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));
page.add_stroke(make_test_stroke(2, 200.0, 200.0, [0.1, 0.1, 0.1, 1.0]));
let mut sel = SelectionState::new();
// Vorgabe: Bilder gesperrt, Striche frei
sel.select_single(1);
assert!(sel.keeps_aspect(&page));
sel.select_single(2);
assert!(!sel.keeps_aspect(&page));
// Umschalten gilt für diese Auswahl
sel.toggle_aspect_lock(&page);
assert!(sel.keeps_aspect(&page));
sel.select_single(1);
sel.toggle_aspect_lock(&page);
assert!(!sel.keeps_aspect(&page));
sel.toggle_aspect_lock(&page);
assert!(sel.keeps_aspect(&page));
sel.toggle_aspect_lock(&page);
// Neue Auswahl: wieder die Vorgabe
sel.select_multiple(vec![1, 2]);
assert!(sel.keeps_aspect(&page));
sel.select_single(1);
assert!(
!sel.keeps_aspect(&page),
"gleiche Auswahl behält das Umschalten"
);
sel.clear();
sel.select_single(1);
assert!(!sel.keeps_aspect(&page));
}
#[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]);
}
} }
+379 -2
View File
@@ -6,7 +6,10 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::background::{PaperTheme, PatternColor};
use crate::i18n::Language;
use crate::storage::{self, StorageError}; use crate::storage::{self, StorageError};
use crate::t;
/// Fensterseite, an der die Werkzeugleiste angedockt ist. /// Fensterseite, an der die Werkzeugleiste angedockt ist.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
@@ -18,11 +21,233 @@ pub enum SidebarSide {
Right, 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
}
/// Was eine Taste am Stift bewirkt, solange sie gedrückt ist.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum StylusAction {
/// Radieren (Standard, wie bisher)
#[default]
Erase,
/// Auswählen und verschieben
Select,
/// Mit dem Textmarker schreiben
Highlighter,
/// Mit dem gewählten Stift schreiben (z. B. während der Textmarker aktiv ist)
Pen,
/// Taste nicht verwenden
Nothing,
}
impl StylusAction {
pub const ALL: [StylusAction; 5] = [
StylusAction::Erase,
StylusAction::Select,
StylusAction::Highlighter,
StylusAction::Pen,
StylusAction::Nothing,
];
pub fn label(self) -> &'static str {
match self {
StylusAction::Erase => t!("Radieren"),
StylusAction::Select => t!("Auswählen"),
StylusAction::Highlighter => t!("Textmarker"),
StylusAction::Pen => t!("Stift"),
StylusAction::Nothing => t!("Nichts"),
}
}
}
/// Eine Taste am Stift: Kennung (aus der Tablet-Schnittstelle, beim ersten Drücken gelernt)
/// und ihre Belegung.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct StylusButton {
pub id: u64,
pub action: StylusAction,
}
/// Höchstens so viele Stifttasten werden gemerkt.
pub const MAX_STYLUS_BUTTONS: usize = 4;
/// Benutzereinstellungen der Oberfläche. /// Benutzereinstellungen der Oberfläche.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct UiSettings { pub struct UiSettings {
pub sidebar_side: SidebarSide, 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,
/// Frühere globale Einstellung „Seiten durchgehend scrollen“: gilt nur noch für Notizbücher
/// ohne eigene Anordnung im Manifest (vor 0.28 angelegt), wird nicht mehr geändert
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,
/// Tasten am Stift in der Reihenfolge, in der sie zuerst gedrückt wurden
pub stylus_buttons: Vec<StylusButton>,
/// Sprache der Oberfläche; ohne Angabe die des Systems
pub language: Option<Language>,
}
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,
stylus_buttons: Vec::new(),
language: None,
}
}
} }
impl UiSettings { impl UiSettings {
@@ -37,7 +262,7 @@ impl UiSettings {
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte. /// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
pub fn load_from(path: &Path) -> Self { pub fn load_from(path: &Path) -> Self {
match std::fs::read_to_string(path) { let mut settings: Self = match std::fs::read_to_string(path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| { Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
eprintln!( eprintln!(
"[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.", "[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.",
@@ -46,7 +271,30 @@ impl UiSettings {
Self::default() Self::default()
}), }),
Err(_) => 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.stylus_buttons.truncate(MAX_STYLUS_BUTTONS);
settings
} }
/// Speichert die Einstellungen atomar. /// Speichert die Einstellungen atomar.
@@ -69,6 +317,34 @@ mod tests {
let settings = UiSettings { let settings = UiSettings {
sidebar_side: SidebarSide::Right, 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,
stylus_buttons: vec![
StylusButton {
id: 17,
action: StylusAction::Select,
},
StylusButton {
id: 42,
action: StylusAction::Nothing,
},
],
language: Some(Language::English),
}; };
settings.save_to(&path).unwrap(); settings.save_to(&path).unwrap();
assert_eq!(UiSettings::load_from(&path), settings); assert_eq!(UiSettings::load_from(&path), settings);
@@ -81,4 +357,105 @@ mod tests {
std::fs::write(&path, br#"{"zukunft": 1}"#).unwrap(); std::fs::write(&path, br#"{"zukunft": 1}"#).unwrap();
assert_eq!(UiSettings::load_from(&path), UiSettings::default()); 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 { struct CameraUniforms {
transform_scale: vec2<f32>, transform_scale: vec2<f32>,
transform_offset: 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) @group(0) @binding(0)
@@ -28,7 +30,157 @@ fn vs_main(in: VertexInput) -> VertexOutput {
return out; 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 @fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { 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);
} }
+66 -7
View File
@@ -17,6 +17,10 @@ pub const RDP_TOLERANCE_UNITS: f32 = 0.75;
/// Schwellenwert für Ecken-Erkennung (Richtungsänderung > 65 Grad). /// Schwellenwert für Ecken-Erkennung (Richtungsänderung > 65 Grad).
pub const CORNER_ANGLE_THRESHOLD_DEG: f32 = 65.0; 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). /// GPU-Vertex für Triangle-Strips (Memory-Layout kompatibel mit GPU-Buffern).
#[repr(C)] #[repr(C)]
@@ -270,7 +274,7 @@ pub fn generate_triangle_strip(
// Einzelner Punkt (Stift-Tippen / Dot): Erzeuge kreisförmiges Pad // Einzelner Punkt (Stift-Tippen / Dot): Erzeuge kreisförmiges Pad
let pt = control_points[0]; let pt = control_points[0];
let radius = calculate_radius(pt.pressure, config.min_radius, config.max_radius); 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); let mut vertices = Vec::with_capacity((segments + 1) * 2);
for i in 0..=segments { for i in 0..=segments {
@@ -357,6 +361,34 @@ pub fn smooth_pressure(samples: &[StylusSample]) -> Vec<StylusSample> {
smoothed 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. /// Rekursives RDP-Markieren zur Ausdünnung kollinearer Zwischenpunkte.
fn rdp_mark(positions: &[glam::Vec2], start: usize, end: usize, epsilon: f32, keep: &mut [bool]) { fn rdp_mark(positions: &[glam::Vec2], start: usize, end: usize, epsilon: f32, keep: &mut [bool]) {
if end <= start + 1 { if end <= start + 1 {
@@ -402,7 +434,8 @@ pub fn rdp_filter_samples(samples: &[StylusSample], epsilon: f32) -> Vec<StylusS
.collect() .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> { pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec<usize> {
if samples.len() < 3 { if samples.len() < 3 {
return Vec::new(); return Vec::new();
@@ -415,7 +448,7 @@ pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec<usize
let l_in = v_in.length(); let l_in = v_in.length();
let l_out = v_out.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_in = v_in / l_in;
let d_out = v_out / l_out; let d_out = v_out / l_out;
let dot = d_in.dot(d_out).clamp(-1.0, 1.0); let dot = d_in.dot(d_out).clamp(-1.0, 1.0);
@@ -566,7 +599,8 @@ pub fn generate_finalized_triangle_strip(
return Vec::new(); 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(); let mut vertices = Vec::new();
// 3. Runde Start-Kappe vor dem ersten Run // 3. Runde Start-Kappe vor dem ersten Run
@@ -626,7 +660,7 @@ pub fn generate_finalized_triangle_strip(
/// Finalisiert einen Strich nach dem Zwei-Stufen-Modell: /// Finalisiert einen Strich nach dem Zwei-Stufen-Modell:
/// 1. Ecken-Erkennung (Corner Preservation bei Richtungsänderung > 65°) /// 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) /// 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. /// 4. Generierung der finalen Triangle-Strips mit runden Kappen an Start und Ende.
pub fn finalize_stroke(raw_samples: &[StylusSample]) -> Stroke { pub fn finalize_stroke(raw_samples: &[StylusSample]) -> Stroke {
@@ -649,8 +683,8 @@ pub fn finalize_stroke_with_config(
return Stroke::new(stroke_id, Vec::new(), Vec::new(), color); return Stroke::new(stroke_id, Vec::new(), Vec::new(), color);
} }
// 1. Druckglättung über 3-Punkt-Fenster [0.25, 0.5, 0.25] // 1. Druck- und Positionsglättung über 3-Punkt-Fenster [0.25, 0.5, 0.25]
let smoothed = smooth_pressure(raw_samples); let smoothed = smooth_positions(&smooth_pressure(raw_samples));
// 2. RDP-Dezimierung (ca. 0.075 mm = 0.75 Einheiten) // 2. RDP-Dezimierung (ca. 0.075 mm = 0.75 Einheiten)
let decimated = rdp_filter_samples(&smoothed, RDP_TOLERANCE_UNITS); let decimated = rdp_filter_samples(&smoothed, RDP_TOLERANCE_UNITS);
@@ -866,6 +900,31 @@ mod tests {
assert!((corner_pt.position.y - 0.0).abs() < 1e-3); 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] #[test]
fn test_pressure_smoothing() { fn test_pressure_smoothing() {
let samples = vec![ let samples = vec![
+575 -61
View File
@@ -15,6 +15,7 @@ use crate::document::{
}; };
use crate::filter::OneEuroFilter; use crate::filter::OneEuroFilter;
use crate::spline::{self, ControlPoint, SplineConfig}; use crate::spline::{self, ControlPoint, SplineConfig};
use crate::t;
/// Serialisierbarer Stylus-Abtastpunkt in DIN-A4-Koordinaten. /// Serialisierbarer Stylus-Abtastpunkt in DIN-A4-Koordinaten.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
@@ -70,6 +71,12 @@ pub struct StrokeData {
pub width: f32, pub width: f32,
#[serde(default = "default_finalized")] #[serde(default = "default_finalized")]
pub finalized: bool, pub finalized: bool,
/// Eingefügtes Bild (Name im Asset-Ordner); die Samples sind dann die vier Bildecken
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
/// Textfeld; die Samples sind dann die vier Ecken des Felds
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<crate::text::TextBox>,
} }
impl From<&Stroke> for StrokeData { impl From<&Stroke> for StrokeData {
@@ -82,6 +89,8 @@ impl From<&Stroke> for StrokeData {
tool: stroke.tool, tool: stroke.tool,
width: stroke.width, width: stroke.width,
finalized: stroke.finalized, finalized: stroke.finalized,
image: stroke.image.clone(),
text: stroke.text.clone(),
} }
} }
} }
@@ -96,6 +105,25 @@ impl StrokeData {
.copied() .copied()
.map(StylusSample::from) .map(StylusSample::from)
.collect(); .collect();
if let (Some(text), [a, b, c, d, ..]) = (&self.text, stylus_samples.as_slice()) {
return Stroke::new_text(
self.id,
text.clone(),
[a.position, b.position, c.position, d.position],
self.color,
);
}
if let Some(asset) = &self.image {
if let [a, b, c, d, ..] = stylus_samples.as_slice() {
let mut stroke = Stroke::new_image(
self.id,
asset.clone(),
[a.position, b.position, c.position, d.position],
);
stroke.color = self.color;
return stroke;
}
}
let width = if self.width > 0.0 { let width = if self.width > 0.0 {
self.width self.width
} else if self.shape.is_some() { } else if self.shape.is_some() {
@@ -117,15 +145,32 @@ impl StrokeData {
}; };
let mut stroke = Stroke::new(self.id, stylus_samples, vertices, self.color); let mut stroke = Stroke::new(self.id, stylus_samples, vertices, self.color);
stroke.shape = self.shape.clone(); stroke.shape = self.shape.clone();
stroke.tool = self.tool; stroke.tool = crate::document::StrokeTool::resolve_stored(
self.tool,
self.color,
self.shape.is_some(),
);
stroke.width = width; stroke.width = width;
stroke.finalized = self.finalized; stroke.finalized = self.finalized;
stroke stroke
} }
} }
/// Aktuelle Dateiformat-Version von ink-notes (Version 2: Stroke-Treue mit tool, width, finalized). /// Aktuelle Dateiformat-Version von ink-notes.
pub const CURRENT_FORMAT_VERSION: u32 = 2; /// - Version 2: Stroke-Treue mit tool, width, finalized
/// - Version 3: neue Formen (Ellipse, Pfeil). Ältere App-Versionen kennen sie nicht und würden
/// betroffene Seiten für beschädigt halten; mit Version 3 lehnen sie das Notizbuch stattdessen
/// sauber als „neuere Version“ ab.
/// - Version 4: eingefügte Bilder (`image` am Strich, Ordner `assets/`). Ältere Versionen würden
/// das unbekannte Feld ignorieren, Bilder als Rechteckstrich anzeigen und beim Speichern
/// verlieren.
/// - Version 5: importierte PDF-Seiten als Seitenhintergrund (`pdf_background`).
/// - Version 6: Seitentitel (`title` in Seite und Manifest).
/// - Version 7: Textfelder (`text` am Strich).
/// - Version 8: Seitengröße je Seite (`size` in Seite und Manifest), Querformat für Folien.
/// - Version 9: endlose Notizbücher (`layout: "Endless"` im Manifest, eine Seite mit
/// `document::ENDLESS_PAGE_SIZE`). Ältere Versionen würden sie als riesige Seite öffnen.
pub const CURRENT_FORMAT_VERSION: u32 = 9;
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0). /// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
pub fn legacy_format_version() -> u32 { pub fn legacy_format_version() -> u32 {
@@ -139,15 +184,46 @@ pub struct PageData {
pub format_version: u32, pub format_version: u32,
pub id: u64, pub id: u64,
pub background: PageBackground, pub background: PageBackground,
/// Importierte PDF-Seite als Hintergrund
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pdf_background: Option<crate::document::PdfBackground>,
/// Seitentitel (steht zusätzlich im Manifest)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// Seitengröße in Seiteneinheiten, wenn nicht DIN A4 hoch (steht zusätzlich im Manifest)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<[f32; 2]>,
pub strokes: Vec<StrokeData>, pub strokes: Vec<StrokeData>,
} }
/// Gespeicherte Seitengröße: `None` für DIN A4 hoch.
fn stored_size(size: glam::Vec2) -> Option<[f32; 2]> {
(size != crate::document::A4_SIZE).then(|| size.to_array())
}
/// Seitengröße aus einer (evtl. von Hand bearbeiteten) Datei; unbrauchbare Werte ergeben A4.
/// Höchstens 2 × 2 m – außer der endlosen Seite.
pub fn page_size_from(stored: Option<[f32; 2]>) -> glam::Vec2 {
match stored {
Some(size) if glam::Vec2::from(size) == crate::document::ENDLESS_PAGE_SIZE => {
crate::document::ENDLESS_PAGE_SIZE
}
Some([w, h]) if w.is_finite() && h.is_finite() && w >= 100.0 && h >= 100.0 => {
glam::Vec2::new(w.min(20_000.0), h.min(20_000.0))
}
_ => crate::document::A4_SIZE,
}
}
impl From<&Page> for PageData { impl From<&Page> for PageData {
fn from(page: &Page) -> Self { fn from(page: &Page) -> Self {
Self { Self {
format_version: CURRENT_FORMAT_VERSION, format_version: CURRENT_FORMAT_VERSION,
id: page.id, id: page.id,
background: page.background, background: page.background,
pdf_background: page.pdf_background.clone(),
title: page.title.clone(),
size: stored_size(page.size),
strokes: page.strokes.iter().map(StrokeData::from).collect(), strokes: page.strokes.iter().map(StrokeData::from).collect(),
} }
} }
@@ -156,7 +232,9 @@ impl From<&Page> for PageData {
impl PageData { impl PageData {
/// Wandelt die Seitendaten in eine runtime-aktive `Page` mit initialisiertem Cache um. /// Wandelt die Seitendaten in eine runtime-aktive `Page` mit initialisiertem Cache um.
pub fn to_page(&self) -> Page { pub fn to_page(&self) -> Page {
let mut page = Page::new(self.id, self.background); let mut page = Page::new(self.id, self.background).with_size(page_size_from(self.size));
page.pdf_background = self.pdf_background.clone();
page.title = self.title.clone();
for stroke_data in &self.strokes { for stroke_data in &self.strokes {
page.strokes.push(stroke_data.to_stroke()); page.strokes.push(stroke_data.to_stroke());
} }
@@ -173,11 +251,22 @@ pub struct PageMeta {
pub id: u64, pub id: u64,
#[serde(default)] #[serde(default)]
pub background: Option<PageBackground>, pub background: Option<PageBackground>,
/// Seitentitel (Lesezeichen), damit die Seitenübersicht ihn ohne Laden der Seite kennt
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// Seitengröße, wenn nicht DIN A4 hoch (für Lage und Maßstab ungeladener Seiten)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<[f32; 2]>,
} }
impl PageMeta { impl PageMeta {
pub fn new(id: u64, background: Option<PageBackground>) -> Self { pub fn new(id: u64, background: Option<PageBackground>) -> Self {
Self { id, background } Self {
id,
background,
title: None,
size: None,
}
} }
} }
@@ -197,9 +286,34 @@ pub struct NotebookManifest {
pub page_ids: Vec<u64>, pub page_ids: Vec<u64>,
pub next_stroke_id: u64, pub next_stroke_id: u64,
pub next_page_id: u64, pub next_page_id: u64,
/// Anordnung der Seiten. Fehlt bei Notizbüchern von vor 0.28; dann gilt die frühere
/// globale Einstellung `continuous_scroll` aus `ui.json` (siehe [`Self::layout_or`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layout: Option<NotebookLayout>,
}
/// Anordnung der Seiten eines Notizbuchs (Issue #30).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum NotebookLayout {
/// Feste Seiten: Seite für Seite blättern
#[default]
Paged,
/// Seiten hängen untereinander und werden durchgehend gescrollt
Continuous,
/// Eine einzige, praktisch unbegrenzte Fläche (siehe `document::ENDLESS_PAGE_SIZE`)
Endless,
} }
impl NotebookManifest { impl NotebookManifest {
/// Anordnung des Notizbuchs; ohne eigene Angabe die frühere globale Einstellung.
pub fn layout_or(&self, legacy_continuous: bool) -> NotebookLayout {
self.layout.unwrap_or(if legacy_continuous {
NotebookLayout::Continuous
} else {
NotebookLayout::Paged
})
}
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self { pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
@@ -216,6 +330,7 @@ impl NotebookManifest {
page_ids: vec![1], page_ids: vec![1],
next_stroke_id: 1, next_stroke_id: 1,
next_page_id: 2, next_page_id: 2,
layout: Some(NotebookLayout::Continuous),
} }
} }
@@ -227,6 +342,22 @@ impl NotebookManifest {
} }
} }
pub fn title_for_page(&self, page_id: u64) -> Option<String> {
self.pages
.iter()
.find(|p| p.id == page_id)
.and_then(|p| p.title.clone())
}
pub fn size_for_page(&self, page_id: u64) -> glam::Vec2 {
page_size_from(
self.pages
.iter()
.find(|p| p.id == page_id)
.and_then(|p| p.size),
)
}
pub fn background_for_page(&self, page_id: u64) -> Option<PageBackground> { pub fn background_for_page(&self, page_id: u64) -> Option<PageBackground> {
self.pages self.pages
.iter() .iter()
@@ -242,6 +373,8 @@ impl NotebookManifest {
.map(|p| PageMeta { .map(|p| PageMeta {
id: p.id, id: p.id,
background: Some(p.background), background: Some(p.background),
title: p.title.clone(),
size: stored_size(p.size),
}) })
.collect(); .collect();
self.page_ids = self.pages.iter().map(|p| p.id).collect(); self.page_ids = self.pages.iter().map(|p| p.id).collect();
@@ -271,19 +404,30 @@ pub enum StorageError {
impl fmt::Display for StorageError { impl fmt::Display for StorageError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
StorageError::Io(e) => write!(f, "I/O-Fehler: {e}"), StorageError::Io(e) => f.write_str(&t!("I/O-Fehler: {e}", e = e)),
StorageError::Serialization(e) => write!(f, "Serialisierungsfehler: {e}"), StorageError::Serialization(e) => {
StorageError::InvalidManifest(msg) => write!(f, "Ungültiges Manifest: {msg}"), f.write_str(&t!("Serialisierungsfehler: {e}", e = e))
StorageError::CorruptedPage(id, msg) => { }
write!(f, "Beschädigte Seite {id}: {msg}") StorageError::InvalidManifest(msg) => {
f.write_str(&t!("Ungültiges Manifest: {msg}", msg = msg))
}
StorageError::CorruptedPage(id, msg) => {
f.write_str(&t!("Beschädigte Seite {id}: {msg}", id = id, msg = msg))
}
StorageError::PageNotFound(id) => {
f.write_str(&t!("Seite {id} nicht gefunden", id = id))
}
StorageError::UnsupportedVersion { found, supported } => f.write_str(&t!(
"Notizbuch wurde mit neuerer Version erstellt (Formatversion {found}, unterstützt wird bis {supported})",
found = found,
supported = supported
)),
StorageError::AlreadyLocked(msg) => {
f.write_str(&t!("Notizbuch ist gesperrt: {msg}", msg = msg))
}
StorageError::PathTraversal(msg) => {
f.write_str(&t!("Ungültiger Pfad (Path Traversal): {msg}", msg = msg))
} }
StorageError::PageNotFound(id) => write!(f, "Seite {id} nicht gefunden"),
StorageError::UnsupportedVersion { found, supported } => write!(
f,
"Notizbuch wurde mit neuerer Version erstellt (Formatversion {found}, unterstützt wird bis {supported})"
),
StorageError::AlreadyLocked(msg) => write!(f, "Notizbuch ist gesperrt: {msg}"),
StorageError::PathTraversal(msg) => write!(f, "Ungültiger Pfad (Path Traversal): {msg}"),
} }
} }
} }
@@ -390,7 +534,7 @@ pub fn atomic_write_file(path: &Path, content: &[u8]) -> Result<(), io::Error> {
let dir = path.parent().ok_or_else(|| { let dir = path.parent().ok_or_else(|| {
io::Error::new( io::Error::new(
io::ErrorKind::NotFound, io::ErrorKind::NotFound,
"Übergeordnetes Verzeichnis nicht gefunden", t!("Übergeordnetes Verzeichnis nicht gefunden"),
) )
})?; })?;
create_secure_dir_all(dir)?; create_secure_dir_all(dir)?;
@@ -495,9 +639,9 @@ pub fn load_manifest(notebook_dir: &Path) -> Result<NotebookManifest, StorageErr
eprintln!("[Storage-Warnung] manifest.json fehlt, lade aus manifest.json.bak"); eprintln!("[Storage-Warnung] manifest.json fehlt, lade aus manifest.json.bak");
return load_manifest_from_file(&bak_path); return load_manifest_from_file(&bak_path);
} }
return Err(StorageError::InvalidManifest(format!( return Err(StorageError::InvalidManifest(t!(
"Manifestdatei nicht gefunden: {}", "Manifestdatei nicht gefunden: {path}",
path.display() path = path.display()
))); )));
} }
@@ -525,7 +669,7 @@ pub fn quarantine_corrupted_page(notebook_dir: &Path, page_id: u64) -> Result<Pa
if !source_path.exists() { if !source_path.exists() {
return Err(io::Error::new( return Err(io::Error::new(
io::ErrorKind::NotFound, io::ErrorKind::NotFound,
"Datei existiert nicht", t!("Datei existiert nicht"),
)); ));
} }
let ts = std::time::SystemTime::now() let ts = std::time::SystemTime::now()
@@ -652,7 +796,7 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
quarantine_path: q_path, quarantine_path: q_path,
}, },
); );
let mut page = Page::new(id, first_bg); let mut page = Page::new(id, first_bg).with_size(manifest.size_for_page(id));
page.persist = PersistState::ReadOnlyAfterLoadError; page.persist = PersistState::ReadOnlyAfterLoadError;
page page
} }
@@ -664,17 +808,19 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
first_id, first_id,
PageLoadWarning { PageLoadWarning {
page_id: first_id, page_id: first_id,
message: format!("I/O-Fehler: {e}"), message: t!("I/O-Fehler: {e}", e = e),
quarantine_path: None, quarantine_path: None,
}, },
); );
let mut page = Page::new(first_id, first_bg); let mut page =
Page::new(first_id, first_bg).with_size(manifest.size_for_page(first_id));
page.persist = PersistState::ReadOnlyAfterLoadError; page.persist = PersistState::ReadOnlyAfterLoadError;
page page
} }
Err(e) => { Err(e) => {
eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}. Erstelle leere Ersatzseite."); eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}. Erstelle leere Ersatzseite.");
let mut page = Page::new(first_id, first_bg); let mut page =
Page::new(first_id, first_bg).with_size(manifest.size_for_page(first_id));
page.persist = PersistState::ReadOnlyAfterLoadError; page.persist = PersistState::ReadOnlyAfterLoadError;
page page
} }
@@ -686,7 +832,14 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
let bg = manifest let bg = manifest
.background_for_page(page_id) .background_for_page(page_id)
.unwrap_or(PageBackground::Grid5mm); .unwrap_or(PageBackground::Grid5mm);
pages.push(Page::new_unloaded(page_id, bg)); pages.push(Page::new_unloaded(page_id, bg).with_size(manifest.size_for_page(page_id)));
}
// Titel aus dem Manifest (Platzhalter und Ersatzseiten kennen sie sonst nicht)
for page in &mut pages {
if page.title.is_none() {
page.title = manifest.title_for_page(page.id);
}
} }
let mut notebook = Notebook::from_pages( let mut notebook = Notebook::from_pages(
@@ -726,7 +879,18 @@ pub fn ensure_page_loaded(
let page_id = notebook.pages[page_idx].id; let page_id = notebook.pages[page_idx].id;
match load_page(notebook_dir, page_id) { match load_page(notebook_dir, page_id) {
Ok(data) => { Ok(data) => {
notebook.pages[page_idx] = data.to_page(); let title = notebook.pages[page_idx].title.take();
let mut page = data.to_page();
page.title = page.title.or(title);
notebook.pages[page_idx] = page;
Ok(())
}
// Angehängt, aber nie gespeichert (noch leer): leere Seite statt Ladefehler
Err(StorageError::PageNotFound(_)) => {
let placeholder = &mut notebook.pages[page_idx];
let mut page = Page::new(page_id, placeholder.background).with_size(placeholder.size);
page.title = placeholder.title.take();
*placeholder = page;
Ok(()) Ok(())
} }
Err(StorageError::CorruptedPage(id, err_msg)) => { Err(StorageError::CorruptedPage(id, err_msg)) => {
@@ -756,7 +920,7 @@ pub fn ensure_page_loaded(
page_id, page_id,
PageLoadWarning { PageLoadWarning {
page_id, page_id,
message: format!("I/O-Fehler: {err_str}"), message: t!("I/O-Fehler: {e}", e = err_str),
quarantine_path: None, quarantine_path: None,
}, },
); );
@@ -850,9 +1014,9 @@ impl NotebookLock {
Err(e) => { Err(e) => {
let kind = e.kind(); let kind = e.kind();
if kind == io::ErrorKind::WouldBlock || e.raw_os_error() == Some(11) { if kind == io::ErrorKind::WouldBlock || e.raw_os_error() == Some(11) {
Err(StorageError::AlreadyLocked(format!( Err(StorageError::AlreadyLocked(t!(
"Notizbuch '{}' wird bereits von einer anderen Instanz verwendet", "Notizbuch '{path}' wird bereits von einer anderen Instanz verwendet",
notebook_dir.display() path = notebook_dir.display()
))) )))
} else { } else {
Err(StorageError::Io(e)) Err(StorageError::Io(e))
@@ -938,7 +1102,7 @@ pub fn list_notebooks(base_dir: &Path) -> Vec<NotebookSummary> {
list.push(NotebookSummary { list.push(NotebookSummary {
id: dir_name.to_string(), id: dir_name.to_string(),
title: format!("⚠️ {dir_name} (Beschädigt)"), title: t!("⚠️ {name} (Beschädigt)", name = dir_name),
page_count: 0, page_count: 0,
modified_at, modified_at,
status: NotebookStatus::Damaged, status: NotebookStatus::Damaged,
@@ -957,11 +1121,11 @@ pub fn list_notebooks(base_dir: &Path) -> Vec<NotebookSummary> {
list.push(NotebookSummary { list.push(NotebookSummary {
id: dir_name.to_string(), id: dir_name.to_string(),
title: format!("⚠️ {dir_name} (Beschädigt - kein Manifest)"), title: t!("⚠️ {name} (Beschädigt - kein Manifest)", name = dir_name),
page_count: 0, page_count: 0,
modified_at, modified_at,
status: NotebookStatus::Damaged, status: NotebookStatus::Damaged,
error: Some("Keine manifest.json vorhanden".to_string()), error: Some(t!("Keine manifest.json vorhanden").to_string()),
}); });
} }
} }
@@ -1015,7 +1179,7 @@ pub fn create_new_notebook(
if counter > 100 { if counter > 100 {
return Err(StorageError::Io(io::Error::new( return Err(StorageError::Io(io::Error::new(
io::ErrorKind::AlreadyExists, io::ErrorKind::AlreadyExists,
"Konnte nach 100 Versuchen kein eindeutiges Notizbuch-Verzeichnis finden", t!("Konnte nach 100 Versuchen kein eindeutiges Notizbuch-Verzeichnis finden"),
))); )));
} }
} }
@@ -1030,6 +1194,51 @@ pub fn create_new_notebook(
Ok((notebook, manifest, notebook_dir)) Ok((notebook, manifest, notebook_dir))
} }
/// Legt ein neues Notizbuch aus einem PDF an: eine Seite je PDF-Seite, die PDF-Seite als
/// Hintergrund. Das PDF wird unverändert im Asset-Ordner abgelegt; alle Seiten werden sofort
/// gespeichert.
pub fn create_notebook_from_pdf(
base_dir: &Path,
title: &str,
pdf_bytes: Vec<u8>,
) -> Result<(Notebook, NotebookManifest, PathBuf), String> {
let sizes = crate::pdf_doc::page_sizes(pdf_bytes.clone())?;
let (_, _, dir) = create_new_notebook(base_dir, title).map_err(|e| e.to_string())?;
let result = (|| {
let asset = crate::assets::AssetStore::new(&dir)
.import_pdf(&pdf_bytes)
.map_err(|e| e.to_string())?;
let pages: Vec<Page> = sizes
.iter()
.enumerate()
.map(|(i, &(width_pt, height_pt))| {
let mut page = Page::new(i as u64 + 1, PageBackground::Blank)
.with_size(crate::document::pdf_page_size(width_pt, height_pt));
page.pdf_background = Some(crate::document::PdfBackground {
asset: asset.clone(),
page: i as u32,
width_pt,
height_pt,
});
page
})
.collect();
let page_count = pages.len() as u64;
let notebook = Notebook::from_pages(pages, 0, 1, page_count + 1);
let mut manifest = load_manifest(&dir).map_err(|e| e.to_string())?;
save_notebook(&dir, &notebook, &mut manifest).map_err(|e| e.to_string())?;
Ok((notebook, manifest))
})();
match result {
Ok((notebook, manifest)) => Ok((notebook, manifest, dir)),
Err(err) => {
// Halbfertiges Notizbuch nicht liegen lassen
let _ = fs::remove_dir_all(&dir);
Err(err)
}
}
}
/// Benennt ein Notizbuch um und aktualisiert das Manifest atomar auf der Festplatte. /// Benennt ein Notizbuch um und aktualisiert das Manifest atomar auf der Festplatte.
pub fn rename_notebook( pub fn rename_notebook(
notebook_dir: &Path, notebook_dir: &Path,
@@ -1049,18 +1258,18 @@ pub fn rename_notebook(
/// Prüft, dass der Pfad direkt unter `base_dir` liegt, kein Symlink ist und `manifest.json` enthält. /// Prüft, dass der Pfad direkt unter `base_dir` liegt, kein Symlink ist und `manifest.json` enthält.
pub fn delete_notebook(notebook_dir: &Path, base_dir: &Path) -> Result<PathBuf, StorageError> { pub fn delete_notebook(notebook_dir: &Path, base_dir: &Path) -> Result<PathBuf, StorageError> {
if !notebook_dir.exists() { if !notebook_dir.exists() {
return Err(StorageError::InvalidManifest(format!( return Err(StorageError::InvalidManifest(t!(
"Verzeichnis '{}' existiert nicht", "Verzeichnis '{path}' existiert nicht",
notebook_dir.display() path = notebook_dir.display()
))); )));
} }
// Prüfen auf Symlink // Prüfen auf Symlink
let symlink_meta = fs::symlink_metadata(notebook_dir).map_err(StorageError::Io)?; let symlink_meta = fs::symlink_metadata(notebook_dir).map_err(StorageError::Io)?;
if symlink_meta.file_type().is_symlink() { if symlink_meta.file_type().is_symlink() {
return Err(StorageError::InvalidManifest(format!( return Err(StorageError::InvalidManifest(t!(
"Pfad '{}' ist ein Symlink und darf nicht gelöscht werden", "Pfad '{path}' ist ein Symlink und darf nicht gelöscht werden",
notebook_dir.display() path = notebook_dir.display()
))); )));
} }
@@ -1069,18 +1278,18 @@ pub fn delete_notebook(notebook_dir: &Path, base_dir: &Path) -> Result<PathBuf,
// Prüfen, dass der Pfad direkt unter base_dir liegt // Prüfen, dass der Pfad direkt unter base_dir liegt
if canon_target.parent() != Some(&canon_base) { if canon_target.parent() != Some(&canon_base) {
return Err(StorageError::PathTraversal(format!( return Err(StorageError::PathTraversal(t!(
"Pfad '{}' liegt nicht direkt unter Basis-Verzeichnis '{}'", "Pfad '{path}' liegt nicht direkt unter Basis-Verzeichnis '{base}'",
notebook_dir.display(), path = notebook_dir.display(),
base_dir.display() base = base_dir.display()
))); )));
} }
// Prüfen, dass manifest.json vorhanden ist // Prüfen, dass manifest.json vorhanden ist
if !canon_target.join("manifest.json").exists() { if !canon_target.join("manifest.json").exists() {
return Err(StorageError::InvalidManifest(format!( return Err(StorageError::InvalidManifest(t!(
"Verzeichnis '{}' enthält kein manifest.json", "Verzeichnis '{path}' enthält kein manifest.json",
notebook_dir.display() path = notebook_dir.display()
))); )));
} }
@@ -1111,9 +1320,9 @@ pub fn restore_notebook_from_trash(
if !trash_target.exists() { if !trash_target.exists() {
return Err(StorageError::Io(io::Error::new( return Err(StorageError::Io(io::Error::new(
io::ErrorKind::NotFound, io::ErrorKind::NotFound,
format!( t!(
"Papierkorb-Eintrag '{}' existiert nicht", "Papierkorb-Eintrag '{path}' existiert nicht",
trash_target.display() path = trash_target.display()
), ),
))); )));
} }
@@ -1124,7 +1333,7 @@ pub fn restore_notebook_from_trash(
let expected_trash_dir = canon_base.join(".trash"); let expected_trash_dir = canon_base.join(".trash");
if !expected_trash_dir.exists() { if !expected_trash_dir.exists() {
return Err(StorageError::InvalidManifest( return Err(StorageError::InvalidManifest(
"Papierkorb-Verzeichnis existiert nicht".to_string(), t!("Papierkorb-Verzeichnis existiert nicht").to_string(),
)); ));
} }
let canon_trash_base = expected_trash_dir let canon_trash_base = expected_trash_dir
@@ -1132,9 +1341,9 @@ pub fn restore_notebook_from_trash(
.map_err(StorageError::Io)?; .map_err(StorageError::Io)?;
if canon_trash.parent() != Some(&canon_trash_base) { if canon_trash.parent() != Some(&canon_trash_base) {
return Err(StorageError::PathTraversal(format!( return Err(StorageError::PathTraversal(t!(
"Papierkorb-Pfad '{}' liegt nicht im Papierkorb", "Papierkorb-Pfad '{path}' liegt nicht im Papierkorb",
trash_target.display() path = trash_target.display()
))); )));
} }
@@ -1179,7 +1388,7 @@ pub fn restore_manifest_from_backup(notebook_dir: &Path) -> Result<(), StorageEr
Ok(()) Ok(())
} else { } else {
Err(StorageError::InvalidManifest( Err(StorageError::InvalidManifest(
"Keine Sicherungskopie (manifest.json.bak) vorhanden".to_string(), t!("Keine Sicherungskopie (manifest.json.bak) vorhanden").to_string(),
)) ))
} }
} }
@@ -1222,16 +1431,20 @@ pub fn load_all_pages(notebook_dir: &Path, notebook: &mut Notebook) -> Result<()
if page.persist == crate::document::PersistState::ReadOnlyAfterLoadError { if page.persist == crate::document::PersistState::ReadOnlyAfterLoadError {
return Err(StorageError::CorruptedPage( return Err(StorageError::CorruptedPage(
page.id, page.id,
"Seite konnte nicht geladen werden".to_string(), t!("Seite konnte nicht geladen werden").to_string(),
)); ));
} }
if !page.is_loaded { if !page.is_loaded {
let title = page.title.take();
*page = match load_page(notebook_dir, page.id) { *page = match load_page(notebook_dir, page.id) {
Ok(data) => data.to_page(), Ok(data) => data.to_page(),
// Wie beim Lazy Loading: nie gespeicherte Seite = leere Seite // Wie beim Lazy Loading: nie gespeicherte Seite = leere Seite
Err(StorageError::PageNotFound(_)) => Page::new(page.id, page.background), Err(StorageError::PageNotFound(_)) => {
Page::new(page.id, page.background).with_size(page.size)
}
Err(e) => return Err(e), Err(e) => return Err(e),
}; };
page.title = page.title.take().or(title);
} }
} }
Ok(()) Ok(())
@@ -1423,6 +1636,40 @@ mod tests {
assert!(shutdown_res.is_ok()); assert!(shutdown_res.is_ok());
} }
#[test]
fn test_notebook_layout_is_saved_and_old_manifests_fall_back() {
let temp_dir = tempfile::tempdir().unwrap();
let (_nb, mut manifest, dir) = create_new_notebook(temp_dir.path(), "Layout").unwrap();
// Neue Notizbücher: durchgehend, unabhängig von der früheren globalen Einstellung
assert_eq!(manifest.layout, Some(NotebookLayout::Continuous));
assert_eq!(manifest.layout_or(false), NotebookLayout::Continuous);
manifest.layout = Some(NotebookLayout::Paged);
save_manifest(&dir, &manifest).unwrap();
let (_nb, loaded) = load_notebook(&dir).unwrap();
assert_eq!(loaded.layout, Some(NotebookLayout::Paged));
// Manifest von vor 0.28 ohne `layout`: es gilt die globale Einstellung
let mut json: serde_json::Value =
serde_json::from_str(&fs::read_to_string(dir.join("manifest.json")).unwrap()).unwrap();
json.as_object_mut().unwrap().remove("layout");
let old: NotebookManifest = serde_json::from_value(json).unwrap();
assert_eq!(old.layout, None);
assert_eq!(old.layout_or(true), NotebookLayout::Continuous);
assert_eq!(old.layout_or(false), NotebookLayout::Paged);
// … und schreibt beim Speichern auch keins dazu
assert!(!serde_json::to_string(&old).unwrap().contains("\"layout\""));
// Endlos: die endlose Seite übersteht Speichern und Laden
let (mut nb, mut manifest, dir) = create_new_notebook(temp_dir.path(), "Endlos").unwrap();
assert!(nb.make_endless());
manifest.layout = Some(NotebookLayout::Endless);
save_notebook(&dir, &nb, &mut manifest).unwrap();
let (loaded_nb, loaded) = load_notebook(&dir).unwrap();
assert_eq!(loaded.layout, Some(NotebookLayout::Endless));
assert!(loaded_nb.is_endless());
}
#[test] #[test]
fn test_notebook_management() { fn test_notebook_management() {
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen"); let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
@@ -2169,12 +2416,24 @@ mod tests {
let (mut loaded, _) = load_notebook(&dir).unwrap(); let (mut loaded, _) = load_notebook(&dir).unwrap();
assert!(!loaded.pages[1].is_loaded); assert!(!loaded.pages[1].is_loaded);
// Ohne Nachladen verweigert der Export, statt leere Seiten zu erzeugen // Ohne Nachladen verweigert der Export, statt leere Seiten zu erzeugen
assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_err()); assert!(crate::export::generate_pdf_document(
&loaded,
Default::default(),
None,
Default::default()
)
.is_err());
load_all_pages(&dir, &mut loaded).unwrap(); load_all_pages(&dir, &mut loaded).unwrap();
assert_eq!(loaded.pages[1].strokes.len(), 1); assert_eq!(loaded.pages[1].strokes.len(), 1);
assert_eq!(loaded.pages[1].background, PageBackground::Lined); assert_eq!(loaded.pages[1].background, PageBackground::Lined);
assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_ok()); assert!(crate::export::generate_pdf_document(
&loaded,
Default::default(),
None,
Default::default()
)
.is_ok());
} }
#[test] #[test]
@@ -2209,6 +2468,26 @@ mod tests {
assert_eq!(reloaded.current_page().strokes.len(), 2); assert_eq!(reloaded.current_page().strokes.len(), 2);
} }
#[test]
fn test_never_saved_page_loads_as_empty_page() {
// Angehängte, nie beschriebene Seite: steht im Manifest, hat aber keine Datei
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Angehängt").unwrap();
notebook.add_page(PageBackground::Lined);
save_notebook(&dir, &notebook, &mut manifest).unwrap();
let _ = fs::remove_file(dir.join(format!("page_{}.json", notebook.pages[1].id)));
let (mut loaded, _) = load_notebook(&dir).unwrap();
assert!(!loaded.pages[1].is_loaded);
ensure_page_loaded(&dir, &mut loaded, 1).unwrap();
let page = &loaded.pages[1];
assert!(page.is_loaded && !page.is_readonly());
assert_eq!(page.background, PageBackground::Lined);
assert!(page.strokes.is_empty());
assert!(loaded.load_warnings.is_empty());
}
#[test] #[test]
fn test_autosave_never_writes_unloaded_placeholder() { fn test_autosave_never_writes_unloaded_placeholder() {
let temp_dir = tempfile::tempdir().unwrap(); let temp_dir = tempfile::tempdir().unwrap();
@@ -2233,4 +2512,239 @@ mod tests {
"Seitendatei wurde überschrieben" "Seitendatei wurde überschrieben"
); );
} }
#[test]
fn test_new_shapes_round_trip_through_page_file() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Neue Formen").unwrap();
let shapes = [
crate::geometry::RecognizedShape::Ellipse {
center: glam::Vec2::new(500.0, 600.0),
radius_x: 250.0,
radius_y: 120.0,
rotation: 0.3,
},
crate::geometry::RecognizedShape::Arrow {
start: glam::Vec2::new(100.0, 100.0),
end: glam::Vec2::new(700.0, 300.0),
double: true,
},
];
for (i, shape) in shapes.iter().enumerate() {
notebook
.current_page_mut()
.strokes
.push(crate::geometry::shape_to_stroke(
shape.clone(),
i as u64 + 1,
[0.0, 0.0, 0.0, 1.0],
5.0,
));
}
save_notebook(&dir, &notebook, &mut manifest).unwrap();
assert_eq!(
load_manifest(&dir).unwrap().format_version,
CURRENT_FORMAT_VERSION
);
let (reloaded, _) = load_notebook(&dir).unwrap();
let loaded: Vec<_> = reloaded
.current_page()
.strokes
.iter()
.map(|s| s.shape.clone())
.collect();
assert_eq!(loaded, shapes.iter().cloned().map(Some).collect::<Vec<_>>());
}
#[test]
fn test_stroke_tool_of_old_files_is_recovered() {
// Bis v0.3.7 wurde jeder Strich als "pen" gespeichert
let json = r#"{"id": 1, "color": [1.0, 0.95, 0.1, 0.35], "tool": "pen",
"samples": [{"x": 10.0, "y": 10.0, "p": 0.5, "t": 0.0},
{"x": 90.0, "y": 10.0, "p": 0.5, "t": 0.1}]}"#;
let data: StrokeData = serde_json::from_str(json).unwrap();
let highlighter = data.to_stroke();
assert_eq!(highlighter.tool, crate::document::StrokeTool::Highlighter);
let mut pen = data.clone();
pen.color = [0.12, 0.12, 0.14, 1.0];
assert_eq!(pen.to_stroke().tool, crate::document::StrokeTool::Pen);
let mut shape = pen.clone();
shape.shape = Some(crate::geometry::RecognizedShape::Line {
start: glam::Vec2::new(10.0, 10.0),
end: glam::Vec2::new(90.0, 10.0),
});
assert_eq!(shape.to_stroke().tool, crate::document::StrokeTool::Shape);
// Ausdrücklich gespeicherte Werkzeuge bleiben unverändert
let mut explicit = data.clone();
explicit.tool = crate::document::StrokeTool::Shape;
assert_eq!(
explicit.to_stroke().tool,
crate::document::StrokeTool::Shape
);
}
#[test]
fn test_image_objects_round_trip_through_page_file() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Mit Bild").unwrap();
let assets = crate::assets::AssetStore::new(&dir);
let imported = assets.import_rgba(4, 2, vec![200; 32]).unwrap();
let corners = [
glam::Vec2::new(100.0, 100.0),
glam::Vec2::new(500.0, 120.0),
glam::Vec2::new(480.0, 320.0),
glam::Vec2::new(80.0, 300.0),
];
notebook.current_page_mut().strokes.push(Stroke::new_image(
7,
imported.asset.clone(),
corners,
));
save_notebook(&dir, &notebook, &mut manifest).unwrap();
assert_eq!(
load_manifest(&dir).unwrap().format_version,
CURRENT_FORMAT_VERSION
);
let (reloaded, _) = load_notebook(&dir).unwrap();
let image = &reloaded.current_page().strokes[0];
assert_eq!(image.image.as_deref(), Some(imported.asset.as_str()));
assert_eq!(image.image_corners(), Some(corners));
assert!(image.vertices.is_empty());
// Bilddatei liegt im Notizbuchordner
assert!(dir.join("assets").join(&imported.asset).exists());
}
#[test]
fn test_text_boxes_round_trip_through_page_file() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Mit Text").unwrap();
let text = crate::text::TextBox::new(
"Überschrift\n\tfn main() {}".into(),
42.0,
crate::text::TextFont::Mono,
900.0,
);
let corners = [
glam::Vec2::new(100.0, 100.0),
glam::Vec2::new(1000.0, 100.0),
glam::Vec2::new(1000.0, 220.0),
glam::Vec2::new(100.0, 220.0),
];
notebook.current_page_mut().strokes.push(Stroke::new_text(
8,
text.clone(),
corners,
[0.15, 0.35, 0.85, 1.0],
));
save_notebook(&dir, &notebook, &mut manifest).unwrap();
let (reloaded, _) = load_notebook(&dir).unwrap();
let stroke = &reloaded.current_page().strokes[0];
assert_eq!(stroke.text.as_ref(), Some(&text));
assert_eq!(stroke.object_corners(), Some(corners));
assert_eq!(stroke.color, [0.15, 0.35, 0.85, 1.0]);
assert!(stroke.is_text() && !stroke.is_image() && stroke.vertices.is_empty());
}
#[test]
fn test_page_size_round_trips_and_is_known_before_loading() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Folien").unwrap();
let slide = glam::Vec2::new(2970.0, 1671.0);
notebook.pages[0].size = slide;
notebook.add_page(PageBackground::Blank);
notebook.add_page(PageBackground::Blank);
notebook.pages[2].size = crate::document::A4_SIZE;
save_notebook(&dir, &notebook, &mut manifest).unwrap();
let (mut reloaded, _) = load_notebook(&dir).unwrap();
let sizes: Vec<glam::Vec2> = reloaded.pages.iter().map(|p| p.size).collect();
assert_eq!(sizes, vec![slide, slide, crate::document::A4_SIZE]);
// Ungeladene Platzhalter kennen ihre Größe aus dem Manifest
assert!(!reloaded.pages[1].is_loaded);
ensure_page_loaded(&dir, &mut reloaded, 1).unwrap();
assert_eq!(reloaded.pages[1].size, slide);
// A4-Seiten speichern keine Größe (Dateien bleiben wie bisher)
let data = load_page(&dir, reloaded.pages[2].id).unwrap();
assert_eq!(data.size, None);
// Unsinnige Werte aus einer bearbeiteten Datei ergeben A4
assert_eq!(
page_size_from(Some([f32::NAN, 5.0])),
crate::document::A4_SIZE
);
}
#[test]
fn test_create_notebook_from_pdf() {
let temp_dir = tempfile::tempdir().unwrap();
let pdf = crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (595.0, 842.0, 0)]);
let (notebook, manifest, dir) =
create_notebook_from_pdf(temp_dir.path(), "Vorlesung 3", pdf.clone()).unwrap();
assert_eq!(manifest.title, "Vorlesung 3");
assert_eq!(notebook.pages.len(), 2);
assert_eq!(notebook.next_page_id, 3);
// Nach dem Neuladen: jede Seite verweist auf ihre PDF-Seite, das PDF liegt unverändert
// im Asset-Ordner
let (mut reloaded, _) = load_notebook(&dir).unwrap();
load_all_pages(&dir, &mut reloaded).unwrap();
for (i, page) in reloaded.pages.iter().enumerate() {
let bg = page.pdf_background.as_ref().expect("PDF-Hintergrund fehlt");
assert_eq!(bg.page, i as u32);
assert_eq!(page.background, PageBackground::Blank);
}
let first = reloaded.pages[0].pdf_background.clone().unwrap();
assert_eq!((first.width_pt, first.height_pt), (842.0, 595.0));
let assets = crate::assets::AssetStore::new(&dir);
assert_eq!(assets.read_bytes(&first.asset).unwrap(), pdf);
// Kein PDF: Fehler und kein halbfertiges Notizbuch
let before = std::fs::read_dir(temp_dir.path()).unwrap().count();
assert!(
create_notebook_from_pdf(temp_dir.path(), "Kaputt", b"%PDF-kaputt".to_vec()).is_err()
);
assert_eq!(std::fs::read_dir(temp_dir.path()).unwrap().count(), before);
}
#[test]
fn test_page_titles_survive_restart_and_lazy_loading() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Mit Titeln").unwrap();
notebook.add_page(PageBackground::Lined);
notebook.add_page(PageBackground::Lined);
let ids: Vec<u64> = notebook.pages.iter().map(|p| p.id).collect();
assert!(notebook.set_page_title(ids[0], " Einleitung \n"));
assert!(notebook.set_page_title(ids[2], "Kapitel 2 – Integrale"));
assert!(!notebook.set_page_title(ids[2], "Kapitel 2 – Integrale"));
save_notebook(&dir, &notebook, &mut manifest).unwrap();
assert_eq!(manifest.pages[0].title.as_deref(), Some("Einleitung"));
// Neustart: Folgeseiten sind Platzhalter, kennen ihren Titel aber aus dem Manifest
let (mut reloaded, _) = load_notebook(&dir).unwrap();
assert!(!reloaded.pages[2].is_loaded);
assert_eq!(
reloaded.pages[2].title.as_deref(),
Some("Kapitel 2 – Integrale")
);
ensure_page_loaded(&dir, &mut reloaded, 2).unwrap();
assert_eq!(
reloaded.pages[2].title.as_deref(),
Some("Kapitel 2 – Integrale")
);
assert_eq!(reloaded.pages[1].title, None);
// Titel entfernen
assert!(reloaded.set_page_title(ids[0], " "));
assert_eq!(reloaded.pages[0].title, None);
}
} }
+548
View File
@@ -0,0 +1,548 @@
//! 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 crate::t;
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 => t!("Normal"),
TextFont::Mono => t!("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);
}
}
+3372 -358
View File
File diff suppressed because it is too large Load Diff
+154 -72
View File
@@ -23,6 +23,8 @@ use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use crate::t;
/// Konfiguration für den Gitea-Update-Server. /// Konfiguration für den Gitea-Update-Server.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UpdaterConfig { pub struct UpdaterConfig {
@@ -59,13 +61,18 @@ impl Default for UpdaterConfig {
impl UpdaterConfig { impl UpdaterConfig {
/// Validiert die Server-URL auf Protokollsicherheit (erzwingt HTTPS). /// Validiert die Server-URL auf Protokollsicherheit (erzwingt HTTPS).
pub fn validate_url(&self) -> Result<url::Url, String> { pub fn validate_url(&self) -> Result<url::Url, String> {
let parsed = url::Url::parse(&self.server_url) let parsed = url::Url::parse(&self.server_url).map_err(|e| {
.map_err(|e| format!("Ungültige Server-URL '{}': {e}", self.server_url))?; t!(
"Ungültige Server-URL '{url}': {e}",
url = self.server_url,
e = e
)
})?;
if parsed.scheme() != "https" && !self.allow_insecure_http { if parsed.scheme() != "https" && !self.allow_insecure_http {
return Err(format!( return Err(t!(
"Unsicheres Protokoll '{}' abgewiesen. Es wird zwingend HTTPS verlangt (z. B. https://gitea.pansi.eu).", "Unsicheres Protokoll '{scheme}' abgewiesen. Es wird zwingend HTTPS verlangt (z. B. https://gitea.pansi.eu).",
parsed.scheme() scheme = parsed.scheme()
)); ));
} }
Ok(parsed) Ok(parsed)
@@ -111,11 +118,20 @@ pub enum RuntimeEnvironment {
DebPackage { binary_path: PathBuf }, DebPackage { binary_path: PathBuf },
/// Aus Quellcode / Entwicklungs-Binary (z.B. target/debug oder target/release) /// Aus Quellcode / Entwicklungs-Binary (z.B. target/debug oder target/release)
PortableOrDev { binary_path: PathBuf }, PortableOrDev { binary_path: PathBuf },
/// Als Flatpak installiert: Updates kommen über die Software-Verwaltung, ink-notes lädt
/// selbst nichts herunter (Vorgabe von Flathub)
Flatpak,
} }
impl RuntimeEnvironment { impl RuntimeEnvironment {
/// Erkennt die aktuelle Laufzeitumgebung anhand der Umgebung und des Binärpfads. /// Erkennt die aktuelle Laufzeitumgebung anhand der Umgebung und des Binärpfads.
pub fn detect() -> Self { pub fn detect() -> Self {
if is_flatpak(
std::env::var("FLATPAK_ID").ok().as_deref(),
Path::new("/.flatpak-info").exists(),
) {
return RuntimeEnvironment::Flatpak;
}
let appimage_var = std::env::var("APPIMAGE").ok(); let appimage_var = std::env::var("APPIMAGE").ok();
let current_exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("ink-notes")); let current_exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("ink-notes"));
Self::detect_from(appimage_var.as_deref(), &current_exe) Self::detect_from(appimage_var.as_deref(), &current_exe)
@@ -146,33 +162,47 @@ impl RuntimeEnvironment {
pub fn display_name(&self) -> &'static str { pub fn display_name(&self) -> &'static str {
match self { match self {
RuntimeEnvironment::AppImage { .. } => "AppImage (Portabel)", RuntimeEnvironment::AppImage { .. } => t!("AppImage (Portabel)"),
RuntimeEnvironment::DebPackage { .. } => "System-Paket (.deb)", RuntimeEnvironment::DebPackage { .. } => t!("System-Paket (.deb)"),
RuntimeEnvironment::PortableOrDev { .. } => "Entwicklung / Lokal", RuntimeEnvironment::PortableOrDev { .. } => t!("Entwicklung / Lokal"),
RuntimeEnvironment::Flatpak => "Flatpak",
} }
} }
/// Ob ink-notes selbst nach Updates suchen und sie installieren darf
pub fn self_update_allowed(&self) -> bool {
!matches!(self, RuntimeEnvironment::Flatpak)
}
}
/// Läuft ink-notes in einer Flatpak-Sandbox (`FLATPAK_ID` gesetzt oder `/.flatpak-info`
/// vorhanden)?
pub fn is_flatpak(flatpak_id: Option<&str>, info_file_exists: bool) -> bool {
flatpak_id.is_some_and(|id| !id.trim().is_empty()) || info_file_exists
} }
/// Bereinigt und validiert einen Asset-Dateinamen gegen Path Traversal Angriffe. /// Bereinigt und validiert einen Asset-Dateinamen gegen Path Traversal Angriffe.
pub fn sanitize_asset_name(raw_name: &str) -> Result<String, String> { pub fn sanitize_asset_name(raw_name: &str) -> Result<String, String> {
let trimmed = raw_name.trim(); let trimmed = raw_name.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
return Err("Asset-Name darf nicht leer sein.".to_string()); return Err(t!("Asset-Name darf nicht leer sein.").to_string());
} }
if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") { if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") {
return Err(format!( return Err(t!(
"Ungültiger Asset-Name (enthält unzulässige Pfadkomponenten): '{raw_name}'" "Ungültiger Asset-Name (enthält unzulässige Pfadkomponenten): '{raw_name}'",
raw_name = raw_name
)); ));
} }
let path = Path::new(trimmed); let path = Path::new(trimmed);
let file_name = path let file_name = path
.file_name() .file_name()
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.ok_or_else(|| format!("Ungültiger Dateiname: '{raw_name}'"))?; .ok_or_else(|| t!("Ungültiger Dateiname: '{raw_name}'", raw_name = raw_name))?;
if file_name != trimmed || file_name.starts_with('.') { if file_name != trimmed || file_name.starts_with('.') {
return Err(format!( return Err(t!(
"Ungültiger oder versteckter Asset-Name: '{raw_name}'" "Ungültiger oder versteckter Asset-Name: '{raw_name}'",
raw_name = raw_name
)); ));
} }
Ok(file_name.to_string()) Ok(file_name.to_string())
@@ -193,9 +223,10 @@ pub fn get_updates_cache_dir() -> Result<PathBuf, String> {
let dir = base.join("ink-notes").join("updates"); let dir = base.join("ink-notes").join("updates");
std::fs::create_dir_all(&dir).map_err(|e| { std::fs::create_dir_all(&dir).map_err(|e| {
format!( t!(
"Konnte Update-Cache-Verzeichnis nicht anlegen ({}): {e}", "Konnte Update-Cache-Verzeichnis nicht anlegen ({path}): {e}",
dir.display() path = dir.display(),
e = e
) )
})?; })?;
@@ -210,7 +241,7 @@ pub fn get_updates_cache_dir() -> Result<PathBuf, String> {
fn default_user_cache_dir() -> Result<PathBuf, String> { fn default_user_cache_dir() -> Result<PathBuf, String> {
let home = std::env::var("HOME") let home = std::env::var("HOME")
.map_err(|_| "HOME-Umgebungsvariable ist nicht gesetzt.".to_string())?; .map_err(|_| t!("HOME-Umgebungsvariable ist nicht gesetzt.").to_string())?;
Ok(PathBuf::from(home).join(".cache")) Ok(PathBuf::from(home).join(".cache"))
} }
@@ -251,9 +282,10 @@ pub fn is_newer_version(remote_tag: &str, current_tag: &str, include_prereleases
/// Berechnet den hex-kodierten SHA-256 Hash einer Datei. /// Berechnet den hex-kodierten SHA-256 Hash einer Datei.
pub fn compute_sha256(path: &Path) -> Result<String, String> { pub fn compute_sha256(path: &Path) -> Result<String, String> {
let mut file = std::fs::File::open(path).map_err(|e| { let mut file = std::fs::File::open(path).map_err(|e| {
format!( t!(
"Konnte Datei '{}' zur Prüfsummenberechnung nicht öffnen: {e}", "Konnte Datei '{path}' zur Prüfsummenberechnung nicht öffnen: {e}",
path.display() path = path.display(),
e = e
) )
})?; })?;
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
@@ -261,7 +293,7 @@ pub fn compute_sha256(path: &Path) -> Result<String, String> {
loop { loop {
let n = file let n = file
.read(&mut buffer) .read(&mut buffer)
.map_err(|e| format!("Fehler beim Lesen der Prüfsummendatei: {e}"))?; .map_err(|e| t!("Fehler beim Lesen der Prüfsummendatei: {e}", e = e))?;
if n == 0 { if n == 0 {
break; break;
} }
@@ -300,15 +332,13 @@ pub fn verify_checksum(
let checksums = parse_checksums(checksums_content); let checksums = parse_checksums(checksums_content);
let expected_hash = checksums.get(asset_name).ok_or_else(|| { let expected_hash = checksums.get(asset_name).ok_or_else(|| {
let _ = std::fs::remove_file(downloaded_path); let _ = std::fs::remove_file(downloaded_path);
format!("Kein Eintrag für '{asset_name}' in SHA256SUMS.txt gefunden. Heruntergeladene Datei wurde gelöscht.") t!("Kein Eintrag für '{asset_name}' in SHA256SUMS.txt gefunden. Heruntergeladene Datei wurde gelöscht.", asset_name = asset_name)
})?; })?;
let actual_hash = compute_sha256(downloaded_path)?; let actual_hash = compute_sha256(downloaded_path)?;
if !actual_hash.eq_ignore_ascii_case(expected_hash) { if !actual_hash.eq_ignore_ascii_case(expected_hash) {
let _ = std::fs::remove_file(downloaded_path); let _ = std::fs::remove_file(downloaded_path);
return Err(format!( return Err(t!("Integritätsprüfung fehlgeschlagen für '{asset_name}': Erwartet {expected_hash}, berechnet {actual_hash}. Datei wurde gelöscht.", asset_name = asset_name, expected_hash = expected_hash, actual_hash = actual_hash));
"Integritätsprüfung fehlgeschlagen für '{asset_name}': Erwartet {expected_hash}, berechnet {actual_hash}. Datei wurde gelöscht."
));
} }
Ok(()) Ok(())
} }
@@ -334,15 +364,26 @@ pub fn verify_checksums_signature(
) -> Result<(), String> { ) -> Result<(), String> {
let key = public_key_b64.trim(); let key = public_key_b64.trim();
if key.is_empty() { if key.is_empty() {
return Err("In dieser Version ist kein Signaturschlüssel für Updates hinterlegt. Bitte das Update manuell installieren.".to_string()); return Err(t!("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) let public_key = minisign_verify::PublicKey::from_base64(key)
.map_err(|e| format!("Ungültiger Update-Signaturschlüssel: {e}."))?; .map_err(|e| t!("Ungültiger Update-Signaturschlüssel: {e}.", e = e))?;
let signature = minisign_verify::Signature::decode(signature_text) let signature = minisign_verify::Signature::decode(signature_text).map_err(|e| {
.map_err(|e| format!("{CHECKSUMS_SIGNATURE_ASSET_NAME} ist ungültig: {e}."))?; t!(
"{file} ist ungültig: {e}.",
file = CHECKSUMS_SIGNATURE_ASSET_NAME,
e = e
)
})?;
public_key public_key
.verify(checksums, &signature, false) .verify(checksums, &signature, false)
.map_err(|e| format!("Signaturprüfung von {CHECKSUMS_ASSET_NAME} fehlgeschlagen: {e}.")) .map_err(|e| {
t!(
"Signaturprüfung von {file} fehlgeschlagen: {e}.",
file = CHECKSUMS_ASSET_NAME,
e = e
)
})
} }
/// Lädt einen Text-Asset des Releases über den abgesicherten HTTP-Client. /// Lädt einen Text-Asset des Releases über den abgesicherten HTTP-Client.
@@ -356,7 +397,7 @@ fn fetch_release_text(
.assets .assets
.iter() .iter()
.find(|a| a.name == name) .find(|a| a.name == name)
.ok_or_else(|| format!("Release enthält keine {name}."))?; .ok_or_else(|| t!("Release enthält keine {name}.", name = name))?;
http_get_with_auth_guard( http_get_with_auth_guard(
agent, agent,
&asset.browser_download_url, &asset.browser_download_url,
@@ -364,9 +405,9 @@ fn fetch_release_text(
5, 5,
config.allow_insecure_http, config.allow_insecure_http,
) )
.map_err(|e| format!("Konnte {name} nicht laden: {e}."))? .map_err(|e| t!("Konnte {name} nicht laden: {e}.", name = name, e = e))?
.into_string() .into_string()
.map_err(|e| format!("Konnte {name} nicht dekodieren: {e}.")) .map_err(|e| t!("Konnte {name} nicht dekodieren: {e}.", name = name, e = e))
} }
/// Lädt `SHA256SUMS.txt` samt Minisign-Signatur und gibt den Inhalt nur bei gültiger /// Lädt `SHA256SUMS.txt` samt Minisign-Signatur und gibt den Inhalt nur bei gültiger
@@ -429,9 +470,7 @@ fn ensure_secure_scheme(url: &url::Url, allow_insecure_http: bool) -> Result<(),
match url.scheme() { match url.scheme() {
"https" => Ok(()), "https" => Ok(()),
"http" if allow_insecure_http => Ok(()), "http" if allow_insecure_http => Ok(()),
scheme => Err(format!( scheme => Err(t!("Unsicheres Protokoll '{scheme}' für '{url}' abgewiesen. Es wird zwingend HTTPS verlangt.", scheme = scheme, url = url)),
"Unsicheres Protokoll '{scheme}' für '{url}' abgewiesen. Es wird zwingend HTTPS verlangt."
)),
} }
} }
@@ -453,8 +492,13 @@ fn http_get_with_auth_guard(
max_redirects: usize, max_redirects: usize,
allow_insecure_http: bool, allow_insecure_http: bool,
) -> Result<ureq::Response, String> { ) -> Result<ureq::Response, String> {
let mut current_url = url::Url::parse(initial_url_str) let mut current_url = url::Url::parse(initial_url_str).map_err(|e| {
.map_err(|e| format!("Ungültige URL '{initial_url_str}': {e}"))?; t!(
"Ungültige URL '{initial_url_str}': {e}",
initial_url_str = initial_url_str,
e = e
)
})?;
let initial_origin = current_url.origin().ascii_serialization(); let initial_origin = current_url.origin().ascii_serialization();
for _ in 0..=max_redirects { for _ in 0..=max_redirects {
@@ -476,24 +520,35 @@ fn http_get_with_auth_guard(
Ok(resp) => return Ok(resp), Ok(resp) => return Ok(resp),
Err(ureq::Error::Status(code, resp)) if (301..=308).contains(&code) => { Err(ureq::Error::Status(code, resp)) if (301..=308).contains(&code) => {
let loc = resp.header("Location").ok_or_else(|| { let loc = resp.header("Location").ok_or_else(|| {
format!("HTTP {code} Weiterleitung ohne Location-Header erhalten") t!(
"HTTP {code} Weiterleitung ohne Location-Header erhalten",
code = code
)
})?;
current_url = current_url.join(loc).map_err(|e| {
t!(
"Ungültiges Weiterleitungsziel '{loc}': {e}",
loc = loc,
e = e
)
})?; })?;
current_url = current_url
.join(loc)
.map_err(|e| format!("Ungültiges Weiterleitungsziel '{loc}': {e}"))?;
continue; continue;
} }
Err(ureq::Error::Status(code, resp)) => { Err(ureq::Error::Status(code, resp)) => {
let body = resp.into_string().unwrap_or_default(); let body = resp.into_string().unwrap_or_default();
return Err(format!("Gitea-Server antwortete mit HTTP {code}: {body}")); return Err(t!(
"Gitea-Server antwortete mit HTTP {code}: {body}",
code = code,
body = body
));
} }
Err(ureq::Error::Transport(t)) => { Err(ureq::Error::Transport(t)) => {
return Err(format!("Netzwerk-Transportfehler: {t}")); return Err(t!("Netzwerk-Transportfehler: {t}", t = t));
} }
} }
} }
Err("Zu viele Weiterleitungen (Redirect-Schleife abgebrochen).".to_string()) Err(t!("Zu viele Weiterleitungen (Redirect-Schleife abgebrochen).").to_string())
} }
/// Asynchroner Controller für Update-Prüfung und Asset-Download. /// Asynchroner Controller für Update-Prüfung und Asset-Download.
@@ -544,6 +599,9 @@ impl UpdaterController {
/// Fragt im Hintergrund die Gitea-API ab. /// Fragt im Hintergrund die Gitea-API ab.
pub fn check_for_updates(&mut self) { pub fn check_for_updates(&mut self) {
if !self.runtime.self_update_allowed() {
return;
}
if matches!( if matches!(
self.status, self.status,
UpdaterStatus::Checking | UpdaterStatus::Downloading { .. } UpdaterStatus::Checking | UpdaterStatus::Downloading { .. }
@@ -592,8 +650,9 @@ impl UpdaterController {
} }
} }
Err(e) => { Err(e) => {
let _ = tx.send(UpdaterEvent::CheckError(format!( let _ = tx.send(UpdaterEvent::CheckError(t!(
"Gitea Release-JSON konnte nicht verarbeitet werden: {e}" "Gitea Release-JSON konnte nicht verarbeitet werden: {e}",
e = e
))); )));
} }
}, },
@@ -649,15 +708,16 @@ impl UpdaterController {
) { ) {
Ok(text) => text, Ok(text) => text,
Err(e) => { Err(e) => {
let _ = tx.send(UpdaterEvent::DownloadError(format!( let _ = tx.send(UpdaterEvent::DownloadError(t!(
"{e} Download abgelehnt." "{e} Download abgelehnt.",
e = e
))); )));
return; return;
} }
}, },
None => { None => {
let _ = tx.send(UpdaterEvent::DownloadError( let _ = tx.send(UpdaterEvent::DownloadError(
"Kein Release ausgewählt. Download abgelehnt.".to_string(), t!("Kein Release ausgewählt. Download abgelehnt.").to_string(),
)); ));
return; return;
} }
@@ -673,8 +733,9 @@ impl UpdaterController {
) { ) {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
let _ = tx.send(UpdaterEvent::DownloadError(format!( let _ = tx.send(UpdaterEvent::DownloadError(t!(
"Download-Verbindungsfehler: {e}" "Download-Verbindungsfehler: {e}",
e = e
))); )));
return; return;
} }
@@ -686,9 +747,10 @@ impl UpdaterController {
let mut file = match std::fs::File::create(&target_file) { let mut file = match std::fs::File::create(&target_file) {
Ok(f) => f, Ok(f) => f,
Err(e) => { Err(e) => {
let _ = tx.send(UpdaterEvent::DownloadError(format!( let _ = tx.send(UpdaterEvent::DownloadError(t!(
"Konnte Zieldatei '{}' nicht erstellen: {e}", "Konnte Zieldatei '{path}' nicht erstellen: {e}",
target_file.display() path = target_file.display(),
e = e
))); )));
return; return;
} }
@@ -705,8 +767,9 @@ impl UpdaterController {
Ok(count) => count, Ok(count) => count,
Err(e) => { Err(e) => {
let _ = std::fs::remove_file(&target_file); let _ = std::fs::remove_file(&target_file);
let _ = tx.send(UpdaterEvent::DownloadError(format!( let _ = tx.send(UpdaterEvent::DownloadError(t!(
"Fehler beim Lesen des Download-Streams: {e}" "Fehler beim Lesen des Download-Streams: {e}",
e = e
))); )));
return; return;
} }
@@ -714,8 +777,9 @@ impl UpdaterController {
if let Err(e) = file.write_all(&buffer[..n]) { if let Err(e) = file.write_all(&buffer[..n]) {
let _ = std::fs::remove_file(&target_file); let _ = std::fs::remove_file(&target_file);
let _ = tx.send(UpdaterEvent::DownloadError(format!( let _ = tx.send(UpdaterEvent::DownloadError(t!(
"Fehler beim Schreiben auf die Festplatte: {e}" "Fehler beim Schreiben auf die Festplatte: {e}",
e = e
))); )));
return; return;
} }
@@ -776,18 +840,19 @@ impl UpdaterController {
/// Startet die sichere Installation des heruntergeladenen Pakets oder öffnet es im System. /// Startet die sichere Installation des heruntergeladenen Pakets oder öffnet es im System.
pub fn install_or_open(&self, file_path: &Path) -> Result<(), String> { pub fn install_or_open(&self, file_path: &Path) -> Result<(), String> {
if !file_path.exists() { if !file_path.exists() {
return Err("Die heruntergeladene Datei existiert nicht mehr.".to_string()); return Err(t!("Die heruntergeladene Datei existiert nicht mehr.").to_string());
} }
match &self.runtime { match &self.runtime {
RuntimeEnvironment::AppImage { appimage_path } => { RuntimeEnvironment::AppImage { appimage_path } => {
let parent_dir = appimage_path.parent().ok_or_else(|| { let parent_dir = appimage_path.parent().ok_or_else(|| {
"Konnte übergeordnetes Verzeichnis des AppImages nicht ermitteln.".to_string() t!("Konnte übergeordnetes Verzeichnis des AppImages nicht ermitteln.")
.to_string()
})?; })?;
let file_name = appimage_path let file_name = appimage_path
.file_name() .file_name()
.ok_or_else(|| "Ungültiger AppImage-Dateiname.".to_string())? .ok_or_else(|| t!("Ungültiger AppImage-Dateiname.").to_string())?
.to_string_lossy(); .to_string_lossy();
let temp_new_file = let temp_new_file =
@@ -797,8 +862,9 @@ impl UpdaterController {
// 1. Neue Datei im SELBEN Verzeichnis anlegen (garantiert atomares rename) // 1. Neue Datei im SELBEN Verzeichnis anlegen (garantiert atomares rename)
if let Err(e) = std::fs::copy(file_path, &temp_new_file) { if let Err(e) = std::fs::copy(file_path, &temp_new_file) {
let _ = std::fs::remove_file(&temp_new_file); let _ = std::fs::remove_file(&temp_new_file);
return Err(format!( return Err(t!(
"Konnte temporäre Update-Datei nicht schreiben: {e}" "Konnte temporäre Update-Datei nicht schreiben: {e}",
e = e
)); ));
} }
@@ -816,18 +882,17 @@ impl UpdaterController {
// 3. Backup des alten AppImages anlegen – Fehler NIEMALS ignorieren! // 3. Backup des alten AppImages anlegen – Fehler NIEMALS ignorieren!
if let Err(e) = std::fs::copy(appimage_path, &backup_file) { if let Err(e) = std::fs::copy(appimage_path, &backup_file) {
let _ = std::fs::remove_file(&temp_new_file); let _ = std::fs::remove_file(&temp_new_file);
return Err(format!( return Err(t!(
"Konnte Backup ({}) nicht anlegen: {e}. Update abgebrochen, altes AppImage unverändert.", "Konnte Backup ({path}) nicht anlegen: {e}. Update abgebrochen, altes AppImage unverändert.",
backup_file.display() path = backup_file.display(),
e = e
)); ));
} }
// 4. Atomar ersetzen via rename // 4. Atomar ersetzen via rename
if let Err(e) = std::fs::rename(&temp_new_file, appimage_path) { if let Err(e) = std::fs::rename(&temp_new_file, appimage_path) {
let _ = std::fs::remove_file(&temp_new_file); let _ = std::fs::remove_file(&temp_new_file);
return Err(format!( return Err(t!("Konnte AppImage nicht atomar ersetzen: {e}. Altes AppImage bleibt unverändert.", e = e));
"Konnte AppImage nicht atomar ersetzen: {e}. Altes AppImage bleibt unverändert."
));
} }
// Heruntergeladene Cache-Datei aufräumen // Heruntergeladene Cache-Datei aufräumen
@@ -837,7 +902,7 @@ impl UpdaterController {
RuntimeEnvironment::DebPackage { .. } => { RuntimeEnvironment::DebPackage { .. } => {
match Command::new("xdg-open").arg(file_path).spawn() { match Command::new("xdg-open").arg(file_path).spawn() {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(e) => Err(format!("Konnte xdg-open nicht ausführen: {e}")), Err(e) => Err(t!("Konnte xdg-open nicht ausführen: {e}", e = e)),
} }
} }
RuntimeEnvironment::PortableOrDev { .. } => { RuntimeEnvironment::PortableOrDev { .. } => {
@@ -846,6 +911,10 @@ impl UpdaterController {
} }
Ok(()) Ok(())
} }
RuntimeEnvironment::Flatpak => Err(t!(
"Die Flatpak-Version wird über die Software-Verwaltung aktualisiert."
)
.to_string()),
} }
} }
@@ -1033,6 +1102,19 @@ dcdeb7ac87d9e6cb8022e5a71bc7f2c56d779b4fce4efdd5bae5be3b0214884a ink-notes_0.1.
); );
} }
#[test]
fn test_flatpak_detection_disables_self_update() {
assert!(is_flatpak(Some("eu.pansi.ink-notes"), false));
assert!(is_flatpak(None, true));
assert!(!is_flatpak(Some(" "), false));
assert!(!is_flatpak(None, false));
assert!(!RuntimeEnvironment::Flatpak.self_update_allowed());
assert!(
RuntimeEnvironment::detect_from(None, Path::new("/usr/bin/ink-notes"))
.self_update_allowed()
);
}
#[test] #[test]
fn test_https_enforcement() { fn test_https_enforcement() {
let config = UpdaterConfig { let config = UpdaterConfig {
+350 -9
View File
@@ -3,12 +3,97 @@
//! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel), //! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel),
//! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]). //! 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}; use glam::{Mat4, Vec2, Vec4};
/// Minimaler und maximaler Zoomfaktor. /// Minimaler und maximaler Zoomfaktor.
pub const MIN_ZOOM: f32 = 0.25; 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. /// Verwaltet den Viewport-Kamerazustand für stufenlosen Zoom und Pan.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
@@ -39,6 +124,39 @@ impl ViewportState {
self.pan_offset = [0.0, 0.0]; 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. /// Passt den Pan-Offset um eine Bildschirmverschiebung (in Fensterpixeln) an.
pub fn pan_by_screen_delta( pub fn pan_by_screen_delta(
&mut self, &mut self,
@@ -46,7 +164,7 @@ impl ViewportState {
delta_y: f32, delta_y: f32,
page_viewport: &PageViewport, 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 { if base_scale * self.zoom > 1e-6 {
self.pan_offset[0] += delta_x / (base_scale * self.zoom); self.pan_offset[0] += delta_x / (base_scale * self.zoom);
self.pan_offset[1] += delta_y / (base_scale * self.zoom); self.pan_offset[1] += delta_y / (base_scale * self.zoom);
@@ -68,7 +186,7 @@ impl ViewportState {
return; return;
} }
let base_scale = page_viewport.page_width / PAGE_WIDTH; let base_scale = page_viewport.scale;
// Bildschirmposition des Seitenursprungs (0, 0) // Bildschirmposition des Seitenursprungs (0, 0)
let old_offset_x = page_viewport.page_x + self.pan_offset[0] * base_scale * old_zoom; 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, screen_y: f32,
page_viewport: &PageViewport, page_viewport: &PageViewport,
) -> Vec2 { ) -> Vec2 {
let base_scale = page_viewport.page_width / PAGE_WIDTH; let base_scale = page_viewport.scale;
let effective_scale = base_scale * self.zoom; let effective_scale = base_scale * self.zoom;
if effective_scale < 1e-6 { if effective_scale < 1e-6 {
return Vec2::ZERO; return Vec2::ZERO;
@@ -112,7 +230,8 @@ impl ViewportState {
page_viewport: &PageViewport, page_viewport: &PageViewport,
) -> Option<Vec2> { ) -> Option<Vec2> {
let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport); 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) Some(pt)
} else { } else {
None None
@@ -127,7 +246,7 @@ impl ViewportState {
page_viewport: &PageViewport, page_viewport: &PageViewport,
) -> Vec2 { ) -> Vec2 {
let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport); 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. /// Transformiert einen DIN-A4-Seitenpunkt in Fenster-Pixelkoordinaten.
@@ -142,7 +261,7 @@ impl ViewportState {
page_y: f32, page_y: f32,
page_viewport: &PageViewport, 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; let effective_scale = base_scale * self.zoom;
let screen_x = page_viewport.page_x + (page_x + self.pan_offset[0]) * effective_scale; 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; 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, /// 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. /// 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]) { 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; let effective_scale = base_scale * self.zoom;
// ndc_x = (screen_x / window_width) * 2.0 - 1.0 // ndc_x = (screen_x / window_width) * 2.0 - 1.0
@@ -186,10 +305,232 @@ impl ViewportState {
} }
} }
/// Schwung beim Scrollen mit dem Finger (Issue #28): Nach dem Abheben gleitet der
/// Ausschnitt mit der zuletzt gewischten Geschwindigkeit weiter und bremst langsam ab.
/// Geschwindigkeiten in Bildschirmpixeln je Sekunde.
#[derive(Debug, Clone, Copy, Default)]
pub struct Fling {
velocity: Vec2,
/// Zeit der letzten Fingerbewegung bzw. des letzten Gleitschritts
last: Option<std::time::Instant>,
active: bool,
}
impl Fling {
/// Abbremsen je Sekunde (Anteil der Geschwindigkeit, der nach 1 s bleibt: e^-FRICTION)
const FRICTION: f32 = 2.5;
/// Langsamer wischen gleitet nicht nach
const MIN_START_SPEED: f32 = 400.0;
/// Darunter hört das Gleiten auf
const MIN_SPEED: f32 = 40.0;
/// Liegt der Finger vor dem Abheben so lange still, gleitet nichts nach
const MAX_REST: std::time::Duration = std::time::Duration::from_millis(80);
/// Finger hat sich um `delta` verschoben.
pub fn track(&mut self, delta: Vec2, now: std::time::Instant) {
self.active = false;
let dt = self
.last
.map_or(0.0, |last| now.duration_since(last).as_secs_f32());
self.last = Some(now);
if dt <= 0.0 {
return;
}
// Geglättet: einzelne ungleichmäßige Touch-Ereignisse sollen nicht zählen
let sample = delta / dt.max(0.004);
self.velocity = self.velocity.lerp(sample, 0.4);
}
/// Finger abgehoben: gleitet weiter, wenn zuletzt schnell genug gewischt wurde.
pub fn release(&mut self, now: std::time::Instant) {
let resting = self
.last
.is_none_or(|last| now.duration_since(last) > Self::MAX_REST);
self.active = !resting && self.velocity.length() >= Self::MIN_START_SPEED;
self.last = Some(now);
if !self.active {
self.stop();
}
}
/// Hält an (neue Berührung, Mausklick, Mausrad).
pub fn stop(&mut self) {
*self = Self::default();
}
pub fn is_active(&self) -> bool {
self.active
}
/// Verschiebung (Bildschirmpixel) seit dem letzten Schritt; `None`, wenn nichts gleitet.
pub fn step(&mut self, now: std::time::Instant) -> Option<Vec2> {
if !self.active {
return None;
}
let dt = self
.last
.map_or(0.0, |last| now.duration_since(last).as_secs_f32())
.min(0.1);
self.last = Some(now);
// Exakt integriert, damit die Strecke nicht von der Bildrate abhängt
let decay = (-Self::FRICTION * dt).exp();
let delta = self.velocity * (1.0 - decay) / Self::FRICTION;
self.velocity *= decay;
if self.velocity.length() < Self::MIN_SPEED {
self.stop();
}
Some(delta)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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_fling_glides_and_slows_down() {
use std::time::{Duration, Instant};
let t0 = Instant::now();
let ms = |n: u64| t0 + Duration::from_millis(n);
let mut fling = Fling::default();
// Schnell nach oben wischen: 30 px alle 10 ms = 3000 px/s
for i in 0..=10 {
fling.track(Vec2::new(0.0, -30.0), ms(i * 10));
}
fling.release(ms(105));
assert!(fling.is_active());
let first = fling.step(ms(121)).unwrap();
assert!(first.y < -20.0, "{first:?}");
let mut total = first;
let mut t = 121;
while let Some(d) = fling.step(ms(t)) {
total += d;
t += 16;
assert!(t < 10_000, "gleitet endlos");
}
// Gleitet ungefähr v / FRICTION = 1200 px weiter, dann ist Schluss
assert!(total.y < -800.0 && total.y > -1300.0, "{total:?}");
assert!(!fling.is_active());
// Langsam schieben oder vor dem Abheben stillhalten: kein Nachgleiten
let mut slow = Fling::default();
for i in 0..=10 {
slow.track(Vec2::new(0.0, -2.0), ms(i * 10));
}
slow.release(ms(105));
assert!(!slow.is_active());
let mut rested = Fling::default();
for i in 0..=10 {
rested.track(Vec2::new(0.0, -30.0), ms(i * 10));
}
rested.release(ms(400));
assert!(!rested.is_active());
}
#[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] #[test]
fn test_viewport_zoom_limits() { fn test_viewport_zoom_limits() {
let mut vp = ViewportState::new(); let mut vp = ViewportState::new();
+33
View File
@@ -0,0 +1,33 @@
//! Das Flatpak baut ohne Netz aus `packaging/flatpak/cargo-sources.json`; jede Abhängigkeit
//! aus `Cargo.lock` muss dort stehen, sonst schlägt der Flatpak-Build fehl.
#[test]
fn cargo_sources_cover_cargo_lock() {
let lock = include_str!("../Cargo.lock");
let sources = include_str!("../packaging/flatpak/cargo-sources.json");
let mut missing = Vec::new();
for package in lock.split("[[package]]").skip(1) {
let field = |key: &str| {
package.lines().find_map(|line| {
line.strip_prefix(key)
.map(|rest| rest.trim().trim_matches('"').to_string())
})
};
// Nur Pakete aus crates.io (das eigene Paket hat keine Quelle)
if field("source = ").is_none() {
continue;
}
let (Some(name), Some(version)) = (field("name = "), field("version = ")) else {
continue;
};
let crate_file = format!("/{name}-{version}.crate\"");
if !sources.contains(&crate_file) {
missing.push(format!("{name} {version}"));
}
}
assert!(
missing.is_empty(),
"cargo-sources.json ist veraltet (flatpak-cargo-generator neu ausführen), es fehlen: {}",
missing.join(", ")
);
}