Compare commits

..
Author SHA1 Message Date
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
45 changed files with 8360 additions and 753 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
+1 -1
View File
@@ -1393,7 +1393,7 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.22.1"
version = "0.27.0"
dependencies = [
"ab_glyph",
"arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ink-notes"
version = "0.22.1"
version = "0.27.0"
edition = "2021"
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
+21 -1
View File
@@ -13,6 +13,8 @@ ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PD
Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemsprache, umstellen lässt sie sich im ☰-Menü unter „Sprache / Language“.
---
## Funktionen
@@ -29,7 +31,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
### 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, Pfeilspitzen an allen vier Enden) und Oktaeder (Skizze als 3D-Körper).
- 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
@@ -72,6 +74,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
### 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`).
---
@@ -188,6 +191,17 @@ Mehr dazu in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md), das Speicherformat
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)
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):
@@ -226,6 +240,12 @@ Bleibt ein Job auf „Waiting to run“ stehen: Ist der Runner online (*Einstell
---
## Mitmachen
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).
---
## Lizenz
[MIT](LICENSE), 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.
+9 -5
View File
@@ -1,18 +1,22 @@
[Desktop Entry]
Version=1.0
Name=ink-notes
GenericName=Notizbuch- & Inking-App
Comment=Handschriftliche Notizen mit Stift, Touch und Maus; PDFs beschriften
GenericName=Note-Taking & Inking App
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
Icon=eu.pansi.ink-notes
Terminal=false
Type=Application
Categories=Office;Graphics;Utility;
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;Text;Annotation;
Categories=Office;Graphics;
Keywords=Note;Pen;Ink;Inking;Stylus;Drawing;PDF;Vector;Text;Annotation;
Keywords[de]=Notiz;Stift;Ink;Inking;Stylus;Zeichnen;PDF;Vektor;Text;Annotation;
StartupWMClass=eu.pansi.ink-notes
MimeType=application/x-inknotes;
Actions=NewWindow;
[Desktop Action NewWindow]
Name=Neues Fenster
Name=New Window
Name[de]=Neues Fenster
Exec=ink-notes
+96 -13
View File
@@ -4,22 +4,34 @@
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<name>ink-notes</name>
<summary>Notizbuch-App für Stift, Touch und Maus</summary>
<summary>Handwritten notes with pen, touch and mouse</summary>
<summary xml:lang="de">Notizbuch-App für Stift, Touch und Maus</summary>
<description>
<p>
ink-notes ist eine Notizbuch-App für Linux, geschrieben in Rust mit wgpu. Handschrift wird als
ink-notes is a notebook app for Linux tablets and convertibles. Handwriting is stored as
vectors; write with a pen, your finger, a touchpad or a mouse.
</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>Funktionen:</p>
<p>Features:</p>
<p xml:lang="de">Funktionen:</p>
<ul>
<li>Stift und Textmarker mit Druckstärke, Glättung beim Schreiben und Ecken-Erhaltung</li>
<li>Radierer für ganze Striche, Teilstriche oder nur Textmarker; virtuelles Lineal</li>
<li>Formerkennung (Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Koordinatensysteme)</li>
<li>Auswählen, Verschieben, Drehen, Skalieren und Umfärben; Rückgängig für alle Änderungen</li>
<li>Textfelder, Bilder und PDF-Import zum Beschriften (Querformat-Folien bekommen Querformat-Seiten)</li>
<li>Durchgehendes Scrollen, Zoom von 25 bis 1000 Prozent, Papier- und Musterfarben</li>
<li>Mehrere Notizbücher, automatisches Speichern im Hintergrund</li>
<li>Export als PDF (mit durchsuchbarem Text), PNG oder SVG</li>
<li>Pens and highlighter with pressure, smoothing while writing and sharp corners</li>
<li xml:lang="de">Stifte und Textmarker mit Druckstärke, Glättung beim Schreiben und Ecken-Erhaltung</li>
<li>Shape recognition: hold at the end of a stroke to get clean lines, arrows, circles, rectangles, triangles and coordinate systems</li>
<li xml:lang="de">Formerkennung: am Strichende halten für saubere Linien, Pfeile, Kreise, Rechtecke, Dreiecke und Koordinatensysteme</li>
<li>Eraser for whole strokes, partial strokes or highlighter only; virtual ruler</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>
</description>
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
@@ -29,12 +41,83 @@
<categories>
<category>Office</category>
<category>Graphics</category>
<category>Utility</category>
</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">
<name>Harald</name>
<name>Harald Pansi</name>
</developer>
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
<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" />
<releases>
<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>
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
+9 -5
View File
@@ -1,18 +1,22 @@
[Desktop Entry]
Version=1.0
Name=ink-notes
GenericName=Notizbuch- & Inking-App
Comment=Handschriftliche Notizen mit Stift, Touch und Maus; PDFs beschriften
GenericName=Note-Taking & Inking App
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
Icon=eu.pansi.ink-notes
Terminal=false
Type=Application
Categories=Office;Graphics;Utility;
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;Text;Annotation;
Categories=Office;Graphics;
Keywords=Note;Pen;Ink;Inking;Stylus;Drawing;PDF;Vector;Text;Annotation;
Keywords[de]=Notiz;Stift;Ink;Inking;Stylus;Zeichnen;PDF;Vektor;Text;Annotation;
StartupWMClass=eu.pansi.ink-notes
MimeType=application/x-inknotes;
Actions=NewWindow;
[Desktop Action NewWindow]
Name=Neues Fenster
Name=New Window
Name[de]=Neues Fenster
Exec=ink-notes
+96 -13
View File
@@ -4,22 +4,34 @@
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<name>ink-notes</name>
<summary>Notizbuch-App für Stift, Touch und Maus</summary>
<summary>Handwritten notes with pen, touch and mouse</summary>
<summary xml:lang="de">Notizbuch-App für Stift, Touch und Maus</summary>
<description>
<p>
ink-notes ist eine Notizbuch-App für Linux, geschrieben in Rust mit wgpu. Handschrift wird als
ink-notes is a notebook app for Linux tablets and convertibles. Handwriting is stored as
vectors; write with a pen, your finger, a touchpad or a mouse.
</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>Funktionen:</p>
<p>Features:</p>
<p xml:lang="de">Funktionen:</p>
<ul>
<li>Stift und Textmarker mit Druckstärke, Glättung beim Schreiben und Ecken-Erhaltung</li>
<li>Radierer für ganze Striche, Teilstriche oder nur Textmarker; virtuelles Lineal</li>
<li>Formerkennung (Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Koordinatensysteme)</li>
<li>Auswählen, Verschieben, Drehen, Skalieren und Umfärben; Rückgängig für alle Änderungen</li>
<li>Textfelder, Bilder und PDF-Import zum Beschriften (Querformat-Folien bekommen Querformat-Seiten)</li>
<li>Durchgehendes Scrollen, Zoom von 25 bis 1000 Prozent, Papier- und Musterfarben</li>
<li>Mehrere Notizbücher, automatisches Speichern im Hintergrund</li>
<li>Export als PDF (mit durchsuchbarem Text), PNG oder SVG</li>
<li>Pens and highlighter with pressure, smoothing while writing and sharp corners</li>
<li xml:lang="de">Stifte und Textmarker mit Druckstärke, Glättung beim Schreiben und Ecken-Erhaltung</li>
<li>Shape recognition: hold at the end of a stroke to get clean lines, arrows, circles, rectangles, triangles and coordinate systems</li>
<li xml:lang="de">Formerkennung: am Strichende halten für saubere Linien, Pfeile, Kreise, Rechtecke, Dreiecke und Koordinatensysteme</li>
<li>Eraser for whole strokes, partial strokes or highlighter only; virtual ruler</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>
</description>
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
@@ -29,12 +41,83 @@
<categories>
<category>Office</category>
<category>Graphics</category>
<category>Utility</category>
</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">
<name>Harald</name>
<name>Harald Pansi</name>
</developer>
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
<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" />
<releases>
<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>
+9
View File
@@ -3,6 +3,15 @@
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
### Highlights:
- **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
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

+16 -11
View File
@@ -14,6 +14,7 @@ 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";
@@ -40,15 +41,18 @@ pub enum AssetError {
impl std::fmt::Display for AssetError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AssetError::Decode(msg) => write!(f, "Bild kann nicht gelesen werden: {msg}"),
AssetError::TooLarge(bytes) => write!(
f,
"Bild ist zu groß ({} MB, höchstens {} MB)",
bytes / (1024 * 1024),
MAX_IMPORT_BYTES / (1024 * 1024)
),
AssetError::InvalidName(name) => write!(f, "Ungültiger Bildverweis '{name}'"),
AssetError::Io(e) => write!(f, "Ein-/Ausgabefehler: {e}"),
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)),
}
}
}
@@ -164,8 +168,9 @@ impl AssetStore {
height: u32,
pixels: Vec<u8>,
) -> Result<ImportedImage, AssetError> {
let buffer = image::RgbaImage::from_raw(width, height, pixels)
.ok_or_else(|| AssetError::Decode("Pixeldaten passen nicht zur Bildgröße".into()))?;
let 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(
+18 -16
View File
@@ -16,6 +16,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::document::{Notebook, Page, PersistState};
use crate::storage::{self, NotebookManifest, PageData, StorageError};
use crate::t;
/// Abstraktion für die Zeitmessung (für deterministische Tests ohne Thread-Sleep).
pub trait Clock: Send + Sync + 'static {
@@ -69,12 +70,11 @@ pub struct AutosaveError {
impl fmt::Display for AutosaveError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Autosave fehlgeschlagen für Seiten {:?}: {}",
self.failed_page_ids,
self.messages.join("; ")
)
f.write_str(&t!(
"Autosave fehlgeschlagen für Seiten {pages}: {messages}",
pages = format!("{:?}", self.failed_page_ids),
messages = self.messages.join("; ")
))
}
}
@@ -96,10 +96,10 @@ pub enum AutosaveStatus {
impl AutosaveStatus {
pub fn display_text(&self) -> &'static str {
match self {
AutosaveStatus::Saved => "Gespeichert",
AutosaveStatus::Pending => "Ungespeichert",
AutosaveStatus::Saving => "Speichert...",
AutosaveStatus::Error => "Speicherfehler!",
AutosaveStatus::Saved => t!("Gespeichert"),
AutosaveStatus::Pending => t!("Ungespeichert"),
AutosaveStatus::Saving => t!("Speichert..."),
AutosaveStatus::Error => t!("Speicherfehler!"),
}
}
}
@@ -166,7 +166,7 @@ fn spawn_worker(
let _ = resp_tx.send(AutosaveResponse::PageSaved {
page_id,
result: Err((
"Worker panic beim Speichern der Seite".to_string(),
t!("Worker panic beim Speichern der Seite").to_string(),
Box::new(page_data),
)),
});
@@ -194,7 +194,7 @@ fn spawn_worker(
Err(_panic) => {
let _ = resp_tx.send(AutosaveResponse::ManifestSaved {
result: Err((
"Worker panic beim Speichern des Manifests".to_string(),
t!("Worker panic beim Speichern des Manifests").to_string(),
Box::new(manifest),
)),
});
@@ -413,7 +413,8 @@ impl AutosaveController {
Err((err_msg, failed_page)) => {
eprintln!("[Autosave-Fehler] Seite {page_id} fehlgeschlagen: {err_msg}");
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
self.pending_pages.entry(page_id).or_insert(*failed_page);
@@ -431,7 +432,7 @@ impl AutosaveController {
if let Err((err_msg, failed_manifest)) = result {
eprintln!("[Autosave-Fehler] Manifest fehlgeschlagen: {err_msg}");
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() {
self.pending_manifest = Some(*failed_manifest);
}
@@ -567,7 +568,8 @@ impl AutosaveController {
}
Err((e, failed_page)) => {
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);
let now = self.clock.now();
let retry = self.retries.entry(page_id).or_insert(PageRetry {
@@ -581,7 +583,7 @@ impl AutosaveController {
AutosaveResponse::ManifestSaved { result } => {
if let Err((e, failed_manifest)) = result {
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);
}
}
+10 -9
View File
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use crate::canvas::InkVertex;
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.
@@ -29,11 +30,11 @@ impl PatternColor {
pub fn label(self) -> &'static str {
match self {
PatternColor::Light => "Hell",
PatternColor::Medium => "Mittel",
PatternColor::Strong => "Kräftig",
PatternColor::Blue => "Blau",
PatternColor::Green => "Grün",
PatternColor::Light => t!("Hell"),
PatternColor::Medium => t!("Mittel"),
PatternColor::Strong => t!("Kräftig"),
PatternColor::Blue => t!("Blau"),
PatternColor::Green => t!("Grün"),
}
}
@@ -96,10 +97,10 @@ impl PaperTheme {
pub fn label(self) -> &'static str {
match self {
PaperTheme::White => "Weiß",
PaperTheme::Cream => "Creme",
PaperTheme::DarkGray => "Dunkelgrau",
PaperTheme::Black => "Schwarz",
PaperTheme::White => t!("Weiß"),
PaperTheme::Cream => t!("Creme"),
PaperTheme::DarkGray => t!("Dunkelgrau"),
PaperTheme::Black => t!("Schwarz"),
}
}
+8 -5
View File
@@ -18,6 +18,7 @@ use crate::background::{
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
use crate::filter::OneEuroFilter;
use crate::spline::{self, ControlPoint, SplineConfig};
use crate::tn;
use crate::viewport::PageStack;
/// 2D-Vertex für Inking-Meshes mit Positions- und Farbattributen (Multi-Color).
@@ -93,12 +94,13 @@ pub fn spline_vertices_to_ndc(
}
/// Verwaltet den GPU-Zustand, Surface, Pipelines und Buffer für das Inking.
/// Meldung, wenn kein Grafikadapter das Fenster darstellen kann.
pub const NO_USABLE_GPU_MESSAGE: &str = "Keine nutzbare Grafikschnittstelle gefunden.\n\
/// Meldung, wenn kein Grafikadapter das Fenster darstellen kann (deutsch, anzeigen mit
/// `i18n::tr`).
pub const NO_USABLE_GPU_MESSAGE: &str = tn!("Keine nutzbare Grafikschnittstelle gefunden.\n\
Häufigste Ursache: Der Benutzer darf nicht auf die Grafikkarte zugreifen (/dev/dri).\n\
Prüfen mit `ls -l /dev/dri` und `groups`; ggf. `sudo usermod -aG render,video $USER`\n\
und neu anmelden. Alternativ einen Software-Renderer installieren (Paket mesa-vulkan-drivers)\n\
oder die Schnittstelle festlegen, z. B. `WGPU_BACKEND=vulkan ink-notes`.";
oder die Schnittstelle festlegen, z. B. `WGPU_BACKEND=vulkan ink-notes`.");
/// Wählt den Darstellungsmodus: Mailbox (geringste Latenz), sonst Fifo, sonst den ersten
/// angebotenen. `None`, wenn keiner angeboten wird. `INK_FORCE_FIFO` erzwingt Fifo.
@@ -305,7 +307,8 @@ impl CanvasState {
),
}
}
let (adapter, surface_caps, present_mode) = chosen.ok_or(NO_USABLE_GPU_MESSAGE)?;
let (adapter, surface_caps, present_mode) =
chosen.ok_or(crate::i18n::tr(NO_USABLE_GPU_MESSAGE))?;
// 3. Device & Queue erstellen
let (device, queue) = adapter
@@ -733,7 +736,7 @@ impl CanvasState {
if !self.presentation_warned {
eprintln!(
"[Grafik] Fenster kann derzeit nicht dargestellt werden (keine Darstellungsmodi). {}",
NO_USABLE_GPU_MESSAGE
crate::i18n::tr(NO_USABLE_GPU_MESSAGE)
);
self.presentation_warned = true;
}
+5 -4
View File
@@ -5,6 +5,7 @@ use std::ops::Range;
use crate::canvas::InkVertex;
use crate::history::{HistoryAction, HistoryStack};
pub use crate::spline::{finalize_stroke, finalize_stroke_with_config};
use crate::t;
/// Standardmaße DIN A4 in Millimetern.
pub const A4_WIDTH_MM: f32 = 210.0;
@@ -1306,10 +1307,10 @@ impl Notebook {
/// Text für das On-Screen-Display (OSD), z. B. "Seite 3 / 12".
pub fn osd_text(&self) -> String {
format!(
"Seite {} / {}",
self.current_page_index + 1,
self.pages.len()
t!(
"Seite {number} / {count}",
number = self.current_page_index + 1,
count = self.pages.len()
)
}
}
+19 -7
View File
@@ -24,6 +24,7 @@ use crate::background::{PatternColor, DOT_SIZE, GRID_5MM_SPACING, LINED_SPACING}
use crate::document::{
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
};
use crate::t;
use ab_glyph::Font as _;
use crate::text::TextFont;
@@ -45,7 +46,9 @@ pub fn sanitize_filename_component(title: &str) -> String {
}
}
/// Ermittelt das Standard-Dokumentenverzeichnis ($XDG_DOCUMENTS_DIR mit Fallback ~/Documents).
/// Ermittelt das Standard-Dokumentenverzeichnis: `$XDG_DOCUMENTS_DIR`, sonst der Eintrag in
/// `~/.config/user-dirs.dirs` (z. B. „Dokumente“), sonst `~/Documents`. Im Flatpak ist nur
/// dieser Ordner beschreibbar, der Fallback allein träfe bei deutschen Systemen daneben.
pub fn default_documents_dir() -> PathBuf {
if let Ok(dir) = std::env::var("XDG_DOCUMENTS_DIR") {
if !dir.trim().is_empty() {
@@ -55,7 +58,16 @@ pub fn default_documents_dir() -> PathBuf {
if let Ok(home) = std::env::var("HOME") {
if !home.trim().is_empty() {
return PathBuf::from(home).join("Documents");
let home = PathBuf::from(home);
let user_dirs =
std::fs::read_to_string(home.join(".config/user-dirs.dirs")).unwrap_or_default();
if let Some(dir) =
crate::file_picker::parse_user_dir(&user_dirs, "XDG_DOCUMENTS_DIR", &home)
.filter(|dir| *dir != home)
{
return dir;
}
return home.join("Documents");
}
}
@@ -384,15 +396,15 @@ fn generate_pdf_document_with(
raster_pdf_pages: &std::collections::HashSet<usize>,
) -> Result<PdfDocumentReference, Box<dyn Error>> {
if notebook.pages.is_empty() {
return Err("Notizbuch enthält keine Seiten".into());
return Err(t!("Notizbuch enthält keine Seiten").into());
}
// Nicht geladene Seiten würden sonst stillschweigend als leere Seiten exportiert
if let Some(page) = notebook.pages.iter().find(|p| {
!p.is_loaded || p.persist == crate::document::PersistState::ReadOnlyAfterLoadError
}) {
return Err(format!(
"Seite {} ist nicht geladen – Export abgebrochen (vorher storage::load_all_pages aufrufen)",
page.id
return Err(t!(
"Seite {id} ist nicht geladen – Export abgebrochen (vorher storage::load_all_pages aufrufen)",
id = page.id
)
.into());
}
@@ -823,7 +835,7 @@ fn render_pdf_raster(
let longest_pt = pdf.width_pt.max(pdf.height_pt);
let max_pixels = (longest_pt / 72.0 * PDF_RASTER_DPI).ceil() as u32;
let rendered = assets
.ok_or_else(|| "kein Asset-Ordner".to_string())
.ok_or_else(|| t!("kein Asset-Ordner").to_string())
.and_then(|a| a.path_of(&pdf.asset).map_err(|e| e.to_string()))
.and_then(|path| crate::pdf_doc::render_file_page(&path, pdf.page as usize, max_pixels));
let xobject = match rendered {
+13 -10
View File
@@ -7,6 +7,7 @@
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)]
@@ -61,8 +62,8 @@ impl FilePicker {
.map(Path::to_path_buf)
.or_else(|| {
let preferred = match kind {
PickerKind::Image => "🖼 Bilder",
PickerKind::Pdf => "⬇ Downloads",
PickerKind::Image => tn!("🖼 Bilder"),
PickerKind::Pdf => tn!("⬇ Downloads"),
};
let all = shortcuts();
all.iter()
@@ -90,9 +91,10 @@ impl FilePicker {
self.error = None;
}
Err(err) => {
self.error = Some(format!(
"'{}' kann nicht geöffnet werden: {err}",
dir.display()
self.error = Some(t!(
"'{dir}' kann nicht geöffnet werden: {err}",
dir = dir.display(),
err = err
));
}
}
@@ -131,7 +133,8 @@ pub fn list_dir(dir: &Path, kind: PickerKind) -> std::io::Result<Vec<PickerEntry
}
/// Schnellzugriffe: Bilder, Downloads, Schreibtisch und Home-Ordner (soweit vorhanden),
/// mit den Namen aus `~/.config/user-dirs.dirs` (z. B. „Bilder“ statt „Pictures“).
/// 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();
@@ -146,15 +149,15 @@ pub fn shortcuts() -> Vec<(&'static str, PathBuf)> {
};
let mut list = Vec::new();
if let Some(p) = xdg("XDG_PICTURES_DIR", &["Bilder", "Pictures"]) {
list.push(("🖼 Bilder", p));
list.push((tn!("🖼 Bilder"), p));
}
if let Some(p) = xdg("XDG_DOWNLOAD_DIR", &["Downloads"]) {
list.push(("⬇ Downloads", p));
list.push((tn!("⬇ Downloads"), p));
}
if let Some(p) = xdg("XDG_DESKTOP_DIR", &["Schreibtisch", "Desktop"]) {
list.push(("🖥 Schreibtisch", p));
list.push((tn!("🖥 Schreibtisch"), p));
}
list.push(("🏠 Persönlicher Ordner", home));
list.push((tn!("🏠 Persönlicher Ordner"), home));
list
}
+126 -15
View File
@@ -9,6 +9,7 @@ use std::f32::consts::PI;
use crate::canvas::InkVertex;
use crate::document::{Aabb, Stroke, StylusSample};
use crate::t;
/// Arten der geometrischen Primitiven.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -104,26 +105,26 @@ impl RecognizedShape {
pub fn name(&self) -> &'static str {
match self {
Self::Line { .. } => "Gerade",
Self::Circle { .. } => "Kreis",
Self::Ellipse { .. } => "Ellipse",
Self::Arrow { double: false, .. } => "Pfeil",
Self::Arrow { double: true, .. } => "Doppelpfeil",
Self::Rectangle { .. } => "Rechteck",
Self::Line { .. } => t!("Gerade"),
Self::Circle { .. } => t!("Kreis"),
Self::Ellipse { .. } => t!("Ellipse"),
Self::Arrow { double: false, .. } => t!("Pfeil"),
Self::Arrow { double: true, .. } => t!("Doppelpfeil"),
Self::Rectangle { .. } => t!("Rechteck"),
Self::CoordinateSystem {
negative_ends: Some(_),
..
} => "Achsenkreuz",
Self::CoordinateSystem { .. } => "Koordinatensystem",
Self::Triangle { .. } => "Dreieck",
Self::Rhombus { .. } => "Raute (Rhombus)",
} => t!("Achsenkreuz"),
Self::CoordinateSystem { .. } => t!("Koordinatensystem"),
Self::Triangle { .. } => t!("Dreieck"),
Self::Rhombus { .. } => t!("Raute (Rhombus)"),
Self::Polygon { vertices } => match vertices.len() {
5 => "Fünfeck (Pentagon)",
6 => "Sechseck (Hexagon)",
8 => "Achteck (Oktagon)",
_ => "Polygon",
5 => t!("Fünfeck (Pentagon)"),
6 => t!("Sechseck (Hexagon)"),
8 => t!("Achteck (Oktagon)"),
_ => t!("Polygon"),
},
Self::Octahedron { .. } => "Oktaeder (3D-Kantenmodell)",
Self::Octahedron { .. } => t!("Oktaeder (3D-Kantenmodell)"),
}
}
@@ -1499,6 +1500,77 @@ pub fn detect_axis_cross(points: &[Vec2]) -> Option<RecognizedShape> {
})
}
/// Achsenkreuz aus zwei Strichen: eine waagerechte und eine senkrechte Gerade (je höchstens
/// 25° schief, Reihenfolge egal), die sich kreuzen und auf beiden Seiten des Schnittpunkts
/// deutlich weiterlaufen. Ergebnis wie bei `detect_axis_cross`.
pub fn axis_cross_from_lines(a: (Vec2, Vec2), b: (Vec2, Vec2)) -> Option<RecognizedShape> {
let max_slope = 25.0_f32.to_radians().tan();
let is_horizontal = |(p, q): (Vec2, Vec2)| (q.y - p.y).abs() <= (q.x - p.x).abs() * max_slope;
let is_vertical = |(p, q): (Vec2, Vec2)| (q.x - p.x).abs() <= (q.y - p.y).abs() * max_slope;
let (horizontal, vertical) = if is_horizontal(a) && is_vertical(b) {
(a, b)
} else if is_vertical(a) && is_horizontal(b) {
(b, a)
} else {
return None;
};
let (x_min, x_max) = (
horizontal.0.x.min(horizontal.1.x),
horizontal.0.x.max(horizontal.1.x),
);
let (y_min, y_max) = (
vertical.0.y.min(vertical.1.y),
vertical.0.y.max(vertical.1.y),
);
let origin = Vec2::new(
(vertical.0.x + vertical.1.x) * 0.5,
(horizontal.0.y + horizontal.1.y) * 0.5,
);
// Vier Arme: jeder mindestens ein Fünftel seiner Achse, die Achsen nicht winzig
let (width, height) = (x_max - x_min, y_max - y_min);
let arm_ok = |arm: f32, axis: f32| arm >= axis * 0.2;
if width < 30.0
|| height < 30.0
|| !(arm_ok(origin.x - x_min, width)
&& arm_ok(x_max - origin.x, width)
&& arm_ok(origin.y - y_min, height)
&& arm_ok(y_max - origin.y, height))
{
return None;
}
// x nach rechts, y nach oben (auf dem Blatt: kleineres y)
Some(RecognizedShape::CoordinateSystem {
origin,
x_axis_end: Vec2::new(x_max, origin.y),
y_axis_end: Vec2::new(origin.x, y_min),
with_arrows: true,
negative_ends: Some([Vec2::new(x_min, origin.y), Vec2::new(origin.x, y_max)]),
})
}
/// Gerade, die ein Strich darstellt: eine als Gerade eingerastete Form oder ein freihändiger
/// Strich, den die Formerkennung als Gerade erkennt. Bilder, Textfelder und Textmarker zählen
/// nicht.
pub fn stroke_as_line(stroke: &Stroke) -> Option<(Vec2, Vec2)> {
if stroke.image.is_some()
|| stroke.text.is_some()
|| stroke.tool == crate::document::StrokeTool::Highlighter
{
return None;
}
let shape = match &stroke.shape {
Some(shape) => shape.clone(),
None => detect_shape(&stroke.points)?,
};
match shape {
RecognizedShape::Line { start, end } => Some((start, end)),
_ => None,
}
}
/// Führt die automatische Erkennung für einen Strich aus.
pub fn detect_shape(points: &[StylusSample]) -> Option<RecognizedShape> {
if points.len() < 2 {
@@ -2674,6 +2746,45 @@ mod tests {
}
}
#[test]
fn test_axis_cross_from_two_lines() {
let horizontal = (Vec2::new(100.0, 305.0), Vec2::new(400.0, 300.0));
let vertical = (Vec2::new(252.0, 450.0), Vec2::new(248.0, 150.0));
for (a, b) in [(horizontal, vertical), (vertical, horizontal)] {
let Some(RecognizedShape::CoordinateSystem {
origin,
x_axis_end,
y_axis_end,
with_arrows: true,
negative_ends: Some([x_start, y_start]),
}) = axis_cross_from_lines(a, b)
else {
panic!("kein Achsenkreuz");
};
assert_eq!(origin, Vec2::new(250.0, 302.5));
assert_eq!(x_axis_end, Vec2::new(400.0, 302.5));
assert_eq!(x_start, Vec2::new(100.0, 302.5));
assert_eq!(y_axis_end, Vec2::new(250.0, 150.0));
assert_eq!(y_start, Vec2::new(250.0, 450.0));
}
}
#[test]
fn test_no_axis_cross_from_lines_that_do_not_cross() {
let horizontal = (Vec2::new(100.0, 300.0), Vec2::new(400.0, 300.0));
// Senkrechte endet über der Waagerechten (L oder T statt Kreuz)
let above = (Vec2::new(250.0, 100.0), Vec2::new(250.0, 290.0));
assert!(axis_cross_from_lines(horizontal, above).is_none());
// Senkrechte schneidet ganz am Rand
let at_edge = (Vec2::new(110.0, 150.0), Vec2::new(110.0, 450.0));
assert!(axis_cross_from_lines(horizontal, at_edge).is_none());
// Zwei Waagerechte oder eine schräge Linie
let parallel = (Vec2::new(100.0, 350.0), Vec2::new(400.0, 350.0));
assert!(axis_cross_from_lines(horizontal, parallel).is_none());
let diagonal = (Vec2::new(150.0, 150.0), Vec2::new(350.0, 450.0));
assert!(axis_cross_from_lines(horizontal, diagonal).is_none());
}
#[test]
fn test_detect_axis_cross_in_one_stroke() {
let c = Vec2::new(800.0, 900.0);
+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
);
}
}
+442
View File
@@ -0,0 +1,442 @@
//! 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)"),
("An: Seiten hängen untereinander, Mausrad und Finger scrollen (Strg+Mausrad zoomt); wer unten auf der letzten Seite schreibt, bekommt automatisch eine neue Seite.\nAus: Seite für Seite blättern.", "On: pages are stacked, mouse wheel and finger scroll (Ctrl+wheel zooms); writing at the bottom of the last page adds a new page automatically.\nOff: flip page by page."),
("ANSICHT", "VIEW"),
("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}"),
("Einfügen (Strg+V)", "Paste (Ctrl+V)"),
("EINGABE", "INPUT"),
("Einstellung nicht gespeichert: {err}", "Setting not saved: {err}"),
("Ellipse", "Ellipse"),
("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 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 durchgehend scrollen", "Scroll pages continuously"),
("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ü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"),
("⚙ 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 …"),
("📄 PDF importieren", "📄 Import PDF"),
("📋 Einfügen", "📋 Paste"),
("📑 Inhalt", "📑 Contents"),
("📚 Alle", "📚 All"),
("📚 Notizbücher", "📚 Notebooks"),
("📚 Oberste Ebene", "📚 Top level"),
("📦 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"),
];
+94 -27
View File
@@ -45,26 +45,57 @@ pub fn quad_vertices(corners: &[glam::Vec2; 4]) -> [ImageVertex; 6] {
[tl, tr, bl, bl, tr, br]
}
/// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1.
pub fn mip_chain(base: &RgbaImage) -> Vec<RgbaImage> {
let mut levels = vec![base.clone()];
let Some(mut current) =
image::RgbaImage::from_raw(base.width, base.height, base.pixels.clone())
else {
return levels;
};
while current.width() > 1 || current.height() > 1 {
let (w, h) = ((current.width() / 2).max(1), (current.height() / 2).max(1));
current = image::imageops::resize(&current, w, h, image::imageops::FilterType::Triangle);
levels.push(RgbaImage {
width: w,
height: h,
pixels: current.as_raw().clone(),
});
/// 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
@@ -74,6 +105,8 @@ 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),
@@ -233,10 +266,16 @@ impl ImageLayer {
continue;
}
let (bind_group, bytes) = match load(&key) {
TextureSource::Ready(image) => (
self.upload_texture(device, queue, &key, &image),
image.pixels.len() as u64 * 4 / 3,
),
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) => {
@@ -342,17 +381,19 @@ impl ImageLayer {
height: 1,
pixels: vec![200, 200, 200, 255],
};
self.upload_texture(device, queue, MISSING, &gray)
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,
image: &RgbaImage,
) -> wgpu::BindGroup {
let levels = mip_chain(image);
levels: Vec<RgbaImage>,
) -> (wgpu::BindGroup, u64) {
let image = &levels[0];
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
@@ -389,8 +430,9 @@ impl ImageLayer {
},
);
}
let bytes = levels.iter().map(|l| l.pixels.len() as u64).sum();
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
device.create_bind_group(&wgpu::BindGroupDescriptor {
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Image Texture Bind Group"),
layout: &self.texture_layout,
entries: &[
@@ -403,7 +445,8 @@ impl ImageLayer {
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
})
});
(bind_group, bytes)
}
}
@@ -439,11 +482,35 @@ mod tests {
height: 3,
pixels: vec![128; 8 * 3 * 4],
};
let levels = mip_chain(&base);
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]);
}
}
+1
View File
@@ -10,6 +10,7 @@ pub mod file_picker;
pub mod filter;
pub mod geometry;
pub mod history;
pub mod i18n;
pub mod image_layer;
pub mod library;
pub mod osk;
+21 -13
View File
@@ -14,6 +14,7 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::storage::{self, StorageError};
use crate::t;
/// Oberste Ebene.
pub const ROOT: &str = "";
@@ -62,14 +63,15 @@ fn is_within(path: &str, folder: &str) -> bool {
pub fn validate_name(name: &str) -> Result<String, String> {
let name = name.trim();
if name.is_empty() {
return Err("Der Ordnername ist leer".to_string());
return Err(t!("Der Ordnername ist leer").to_string());
}
if name.contains('/') {
return Err("Ordnernamen dürfen kein „/“ enthalten".to_string());
return Err(t!("Ordnernamen dürfen kein „/“ enthalten").to_string());
}
if name.chars().count() > MAX_NAME_CHARS {
return Err(format!(
"Der Ordnername ist zu lang (höchstens {MAX_NAME_CHARS} Zeichen)"
return Err(t!(
"Der Ordnername ist zu lang (höchstens {max} Zeichen)",
max = MAX_NAME_CHARS
));
}
Ok(name.to_string())
@@ -161,11 +163,11 @@ impl Library {
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("Der übergeordnete Ordner existiert nicht mehr".to_string());
return Err(t!("Der übergeordnete Ordner existiert nicht mehr").to_string());
}
let path = join(parent, &name);
if !self.folders.insert(path.clone()) {
return Err(format!("Den Ordner „{name}“ gibt es hier schon"));
return Err(t!("Den Ordner „{name}“ gibt es hier schon", name = name));
}
Ok(path)
}
@@ -175,14 +177,17 @@ impl Library {
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("Den Ordner gibt es nicht mehr".to_string());
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(format!("Den Ordner „{new_name}“ gibt es hier schon"));
return Err(t!(
"Den Ordner „{name}“ gibt es hier schon",
name = new_name
));
}
self.move_contents(path, &new_path);
Ok(new_path)
@@ -240,13 +245,13 @@ impl Library {
/// 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("Den Ordner gibt es nicht mehr".to_string());
return Err(t!("Den Ordner gibt es nicht mehr").to_string());
}
if !new_parent.is_empty() && !self.folders.contains(new_parent) {
return Err("Den Zielordner gibt es nicht mehr".to_string());
return Err(t!("Den Zielordner gibt es nicht mehr").to_string());
}
if is_within(new_parent, path) {
return Err("Ein Ordner kann nicht in sich selbst verschoben werden".to_string());
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);
@@ -254,7 +259,10 @@ impl Library {
return Ok(new_path);
}
if self.folders.contains(&new_path) {
return Err(format!("Im Zielordner gibt es schon einen Ordner „{name}“"));
return Err(t!(
"Im Zielordner gibt es schon einen Ordner „{name}“",
name = name
));
}
self.move_contents(path, &new_path);
Ok(new_path)
@@ -276,7 +284,7 @@ impl Library {
return Ok(());
}
if !self.folders.contains(folder) {
return Err("Den Ordner gibt es nicht mehr".to_string());
return Err(t!("Den Ordner gibt es nicht mehr").to_string());
}
self.notebooks
.insert(notebook_id.to_string(), folder.to_string());
+278 -146
View File
@@ -24,18 +24,20 @@ use ink_notes::canvas::CanvasState;
use ink_notes::document::{Notebook, PageBackground};
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy};
use ink_notes::geometry::{self, RecognizedShape};
use ink_notes::i18n::{self, Language};
use ink_notes::image_layer::TextureSource;
use ink_notes::library::Library;
use ink_notes::osk::{self, KeyboardRequester};
use ink_notes::pdf_doc::PdfRenderService;
use ink_notes::platform::{self, DisplayBackend};
use ink_notes::selection::{
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
render_selection_overlay, scale_factors, DragMode, SelectionHandleHit, SelectionState,
};
use ink_notes::settings::{
SidebarSide, StylusAction, StylusButton, UiSettings, MAX_STYLUS_BUTTONS,
};
use ink_notes::storage::{self, NotebookManifest};
use ink_notes::t;
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
use ink_notes::updater::UpdaterController;
@@ -70,7 +72,7 @@ fn space_press(
let index = notebook.current_page_index;
if notebook.space_run(index).is_none() {
ui_state.set_export_message(
"Auf Seiten mit importiertem PDF lässt sich kein Platz schaffen".to_string(),
t!("Auf Seiten mit importiertem PDF lässt sich kein Platz schaffen").to_string(),
);
return false;
}
@@ -118,9 +120,9 @@ fn space_release(
canvas.sync_page(notebook.current_page_mut());
let mm = (dy / ink_notes::document::UNITS_PER_MM).abs().round();
ui_state.set_export_message(if dy > 0.0 {
format!("{mm} mm Platz geschaffen (Rückgängig mit Strg+Z)")
t!("{mm} mm Platz geschaffen (Rückgängig mit Strg+Z)", mm = mm)
} else {
format!("{mm} mm Platz geschlossen (Rückgängig mit Strg+Z)")
t!("{mm} mm Platz geschlossen (Rückgängig mit Strg+Z)", mm = mm)
});
}
}
@@ -183,7 +185,7 @@ fn change_library<T>(
library
.save(base_dir)
.map(|()| value)
.map_err(|e| format!("Ordner nicht gespeichert: {e}"))
.map_err(|e| t!("Ordner nicht gespeichert: {e}", e = e))
});
ui_state.library = Library::load(base_dir);
match result {
@@ -204,7 +206,7 @@ fn save_ui_settings(settings: &UiSettings, path: Option<&std::path::Path>, ui_st
"[Einstellungen] Konnte '{}' nicht speichern: {err}",
path.display()
);
ui_state.set_export_message(format!("Einstellung nicht gespeichert: {err}"));
ui_state.set_export_message(t!("Einstellung nicht gespeichert: {err}", err = err));
}
}
}
@@ -218,10 +220,10 @@ fn update_window_title(
) {
let page = notebook.current_page();
let bg_name = match page.background {
PageBackground::Blank => "Blanko",
PageBackground::Grid5mm => "Kariert (5mm)",
PageBackground::Lined => "Liniert (7.5mm)",
PageBackground::DotGrid => "Punktraster (5mm)",
PageBackground::Blank => t!("Blanko"),
PageBackground::Grid5mm => t!("Kariert (5mm)"),
PageBackground::Lined => t!("Liniert (7.5mm)"),
PageBackground::DotGrid => t!("Punktraster (5mm)"),
};
let page_title = page
@@ -229,14 +231,14 @@ fn update_window_title(
.as_deref()
.map(|t| format!(" – {t}"))
.unwrap_or_default();
let title = format!(
"ink-notes | [{}] {}{} | [{}] | {} | Hintergrund: {} | [Ctrl+Z] Undo [Ctrl+Y] Redo [E] Radierer [Ctrl+E/S] PDF-Export",
manifest.title,
notebook.osd_text(),
page_title,
status.display_text(),
tool.name(),
bg_name
let title = t!(
"ink-notes | [{notebook}] {page}{page_title} | [{status}] | {tool} | Hintergrund: {background} | [Ctrl+Z] Undo [Ctrl+Y] Redo [E] Radierer [Ctrl+E/S] PDF-Export",
notebook = manifest.title,
page = notebook.osd_text(),
page_title = page_title,
status = status.display_text(),
tool = tool.name(),
background = bg_name
);
window.set_title(&title);
}
@@ -287,6 +289,30 @@ fn start_pdf_export(
});
}
/// Halten am Strichende (Form einrasten): So weit darf der Stift dabei zittern, in physischen
/// Pixeln. Echte Stifte zittern im Stillstand um einige Pixel; mit 3 px begann die Wartezeit
/// ständig neu, und Halten rastete nur mit der Maus ein (Issue #27).
fn hold_tolerance(window: &Window) -> f32 {
6.0 * window.scale_factor() as f32
}
/// Achsenkreuz aus der eingerasteten Geraden `shape` und dem vorigen Strich der aktuellen
/// Seite, falls sich beide kreuzen (Issue #20); mit der Id des vorigen Strichs
fn axis_cross_with_previous(
notebook: &Notebook,
shape: &RecognizedShape,
) -> Option<(u64, RecognizedShape)> {
let RecognizedShape::Line { start, end } = shape else {
return None;
};
let previous = notebook.current_page().strokes.last()?;
let line = geometry::stroke_as_line(previous)?;
Some((
previous.id,
geometry::axis_cross_from_lines(line, (*start, *end))?,
))
}
/// Finalisiert einen Strich (mit Spline-Tessellierung, Ecken-Erkennung & RDP oder Auto-Snap)
/// und fügt ihn in die aktuelle Seite des Notizbuchs ein.
fn commit_stroke_to_notebook(
@@ -298,6 +324,27 @@ fn commit_stroke_to_notebook(
window: &Window,
snapped_shape: Option<RecognizedShape>,
) {
// Zweite Gerade über Kreuz mit dem vorigen Strich: beide werden zum Achsenkreuz; der
// vorige Strich wird ersetzt, ein Rückgängig stellt ihn wieder her
if let Some(shape) = &snapped_shape {
if let Some((previous_id, cross)) = axis_cross_with_previous(notebook, shape) {
let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
canvas.cancel_stroke();
let stroke = geometry::shape_to_stroke(
cross,
previous_id,
canvas.current_ink_color,
stroke_width,
);
let page = notebook.current_page_mut();
page.replace_stroke(previous_id, stroke);
canvas.sync_page(page);
autosave.mark_dirty(notebook, manifest);
window.request_redraw();
return;
}
}
let new_id = notebook.next_stroke_id();
let stroke_opt = if let Some(shape) = snapped_shape {
let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
@@ -542,12 +589,38 @@ fn insert_image_on_page(
autosave.mark_dirty(notebook, manifest);
selection_state.select_single(id);
ui_state.active_tool = ActiveTool::Select;
ui_state.set_export_message(format!(
"Bild eingefügt ({} × {} px)",
imported.width, imported.height
ui_state.set_export_message(t!(
"Bild eingefügt ({width} × {height} px)",
width = imported.width,
height = imported.height
));
}
/// Trifft ein Finger auf `screen` die Auswahl (Rahmen, Griffe) oder ein Objekt? Dann wählt bzw.
/// verschiebt er, sonst verschiebt er das Blatt.
fn finger_grabs_selection(
screen: (f32, f32),
canvas: &CanvasState,
page: &ink_notes::document::Page,
pixels_per_point: f32,
selection_state: &SelectionState,
) -> bool {
let on_selection = !selection_state.is_empty()
&& !matches!(
selection_state.hit_test_handles(
page,
canvas,
Vec2::new(screen.0, screen.1),
pixels_per_point
),
SelectionHandleHit::None
);
on_selection
|| canvas
.screen_to_page(screen.0, screen.1)
.is_some_and(|p| page.find_stroke_at(p, 25.0).is_some())
}
/// Auswahlwerkzeug: Druck auf `screen` (physische Pixel) greift einen Griff, die Auswahl
/// oder das Objekt darunter oder beginnt einen Auswahlrahmen. Mit `shift` wird die Auswahl
/// erweitert. `true`, wenn etwas begonnen wurde.
@@ -576,12 +649,18 @@ fn selection_press(
..
},
_,
) => DragMode::Scale {
fixed_corner_page,
start_pos_page,
initial_box_size,
initial_strokes: page.get_strokes(&selection_state.selected_ids),
},
) => {
let initial_strokes = page.get_strokes(&selection_state.selected_ids);
// Schloss der Auswahl (Bilder: an, Issue #29); Shift sperrt zusätzlich
let keep_aspect = shift || selection_state.keeps_aspect(page);
DragMode::Scale {
fixed_corner_page,
start_pos_page,
initial_box_size,
initial_strokes,
keep_aspect,
}
}
(SelectionHandleHit::Body, Some(p)) => DragMode::Move {
start_page: p,
initial_strokes: page.get_strokes(&selection_state.selected_ids),
@@ -656,18 +735,10 @@ fn selection_drag(
DragMode::Scale {
fixed_corner_page,
start_pos_page,
keep_aspect,
..
} => {
let orig = *start_pos_page - *fixed_corner_page;
let new = page_pt - *fixed_corner_page;
let factor = |n: f32, o: f32| {
if o.abs() > 1.0 {
(n / o).clamp(0.05, 20.0)
} else {
1.0
}
};
let factors = Vec2::new(factor(new.x, orig.x), factor(new.y, orig.y));
let factors = scale_factors(*fixed_corner_page, *start_pos_page, page_pt, *keep_aspect);
updated
.iter_mut()
.for_each(|s| s.scale(*fixed_corner_page, factors));
@@ -774,7 +845,7 @@ fn insert_text_on_page(
autosave.mark_dirty(notebook, manifest);
selection_state.select_single(id);
ui_state.active_tool = ActiveTool::Select;
ui_state.set_export_message("Text eingefügt – mit dem Text-Werkzeug (T) änderbar");
ui_state.set_export_message(t!("Text eingefügt – mit dem Text-Werkzeug (T) änderbar"));
}
/// Beendet eine laufende Textbearbeitung: übernimmt das Feld (neu, geändert oder – wenn
@@ -868,25 +939,21 @@ fn texture_for(
assets: &AssetStore,
pdf_renderer: &mut PdfRenderService,
) -> TextureSource {
let result = match key.contains('#') {
true => {
let Some((Ok(path), page, max_pixels)) = ink_notes::pdf_doc::parse_page_key(key)
.map(|(asset, page, px)| (assets.path_of(asset), page, px))
else {
return TextureSource::Failed(format!("Ungültiger PDF-Verweis '{key}'"));
};
match pdf_renderer.take(key, path, page, max_pixels) {
Some(result) => result,
None => return TextureSource::Pending,
}
}
false => assets
.load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION)
.map_err(|e| e.to_string()),
if !key.contains('#') {
return match assets.load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION) {
Ok(image) => TextureSource::Ready(image),
Err(err) => TextureSource::Failed(err.to_string()),
};
}
let Some((Ok(path), page, max_pixels)) = ink_notes::pdf_doc::parse_page_key(key)
.map(|(asset, page, px)| (assets.path_of(asset), page, px))
else {
return TextureSource::Failed(t!("Ungültiger PDF-Verweis '{key}'", key = key));
};
match result {
Ok(image) => TextureSource::Ready(image),
Err(err) => TextureSource::Failed(err),
match pdf_renderer.take(key, path, page, max_pixels) {
Some(Ok(levels)) => TextureSource::ReadyMips(levels),
Some(Err(err)) => TextureSource::Failed(err),
None => TextureSource::Pending,
}
}
@@ -902,9 +969,12 @@ fn page_view(canvas: &CanvasState, scale_factor: f32) -> ui::PageView {
fn copy_message(count: usize, as_image: bool) -> String {
if as_image {
format!("{count} Objekt(e) kopiert – auch als Bild für andere Programme")
t!(
"{count} Objekt(e) kopiert – auch als Bild für andere Programme",
count = count
)
} else {
format!("{count} Objekt(e) kopiert")
t!("{count} Objekt(e) kopiert", count = count)
}
}
@@ -927,7 +997,15 @@ fn start_page_export(
ui::PageExportKind::PngTransparent => ("_transparent", "png"),
ui::PageExportKind::Svg => ("", "svg"),
};
let path = export::resolve_export_path(&format!("{title}_Seite_{number}{suffix}"), ext);
let path = export::resolve_export_path(
&t!(
"{title}_Seite_{number}{suffix}",
title = title,
number = number,
suffix = suffix
),
ext,
);
std::thread::spawn(move || {
let assets = AssetStore::new(&storage_dir);
let bytes = match kind {
@@ -999,7 +1077,9 @@ fn paste(
ui_state,
);
}
Err(err) => ui_state.set_export_message(format!("Bild nicht eingefügt: {err}")),
Err(err) => {
ui_state.set_export_message(t!("Bild nicht eingefügt: {err}", err = err))
}
}
return;
}
@@ -1053,9 +1133,9 @@ fn paste(
canvas.sync_page(notebook.current_page_mut());
manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_dirty(notebook, manifest);
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
ui_state.set_export_message(t!("{count} Objekt(e) eingefügt", count = new_ids.len()));
} else {
ui_state.set_export_message("Die Zwischenablage ist leer".to_string());
ui_state.set_export_message(t!("Die Zwischenablage ist leer").to_string());
}
}
@@ -1073,7 +1153,7 @@ fn insert_image_file(
ui_state: &mut UiState,
) {
if notebook.current_page().is_readonly() {
ui_state.set_export_message("Seite ist schreibgeschützt".to_string());
ui_state.set_export_message(t!("Seite ist schreibgeschützt").to_string());
return;
}
let result = std::fs::read(path)
@@ -1094,16 +1174,26 @@ fn insert_image_file(
selection_state,
ui_state,
),
Err(err) => ui_state.set_export_message(format!(
"'{}' nicht eingefügt: {err}",
path.file_name()
Err(err) => ui_state.set_export_message(t!(
"'{name}' nicht eingefügt: {err}",
name = path
.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default()
.unwrap_or_default(),
err = err
)),
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Gespeicherte UI-Einstellungen; zuerst, damit alle Texte in der gewählten Sprache sind
let ui_settings_path = UiSettings::default_path();
let mut ui_settings = ui_settings_path
.as_deref()
.map(UiSettings::load_from)
.unwrap_or_default();
i18n::set_language(ui_settings.language.unwrap_or_else(Language::system));
// Anzeigesystem wählen: Unter WSL (WSLg) bricht Wayland ab, X11 (Xwayland) läuft stabil
let mut event_loop_builder = EventLoopBuilder::new();
match platform::display_backend_from_env() {
@@ -1176,7 +1266,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
{
(first.id.clone(), first.title.clone())
} else {
("default".to_string(), "Standard-Notizbuch".to_string())
("default".to_string(), t!("Standard-Notizbuch").to_string())
};
let mut storage_dir = storage_base_dir.join(&initial_id);
@@ -1192,8 +1282,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
"[Storage-Fehler] Konnte Notizbuch nicht laden ({err}). '{}' bleibt unverändert, erstelle neues Notizbuch.",
storage_dir.display()
);
startup_warning = Some(format!(
"Notizbuch '{initial_title}' konnte nicht geladen werden – neues Notizbuch angelegt"
startup_warning = Some(t!(
"Notizbuch '{title}' konnte nicht geladen werden – neues Notizbuch angelegt",
title = initial_title
));
let (nb, m, dir) = storage::create_new_notebook(&storage_base_dir, &initial_title)?;
storage_dir = dir;
@@ -1241,13 +1332,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
);
let mut ui_state = UiState::new();
// Gespeicherte UI-Einstellungen (Seite der Werkzeugleiste)
let ui_settings_path = UiSettings::default_path();
let mut ui_settings = ui_settings_path
.as_deref()
.map(UiSettings::load_from)
.unwrap_or_default();
ui_state.sidebar_side = ui_settings.sidebar_side;
ui_state.language = ui_settings.language;
ui_state.paper_theme = ui_settings.paper_theme;
ui_state.adapt_ink_colors = ui_settings.adapt_ink_colors;
ui_state.pattern_color = ui_settings.pattern_color;
@@ -1288,7 +1374,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Err(storage::StorageError::AlreadyLocked(msg)) => {
eprintln!("[Storage-Lock] {msg}");
autosave.set_read_only(true);
ui_state.set_export_message("⚠ In anderer Instanz geöffnet (Schreibschutz)");
ui_state.set_export_message(t!("⚠ In anderer Instanz geöffnet (Schreibschutz)"));
None
}
Err(e) => {
@@ -1446,8 +1532,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
while let Ok(result) = export_rx.try_recv() {
match result {
Ok(path) => ui_state.set_export_message(format!("Gespeichert: {}", path.display())),
Err(err) => ui_state.set_export_message(format!("Export fehlgeschlagen: {err}")),
Ok(path) => ui_state.set_export_message(t!("Gespeichert: {path}", path = path.display())),
Err(err) => ui_state.set_export_message(t!("Export fehlgeschlagen: {err}", err = err)),
}
window.request_redraw();
}
@@ -1623,7 +1709,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} else if is_stylus_drawing && is_drawing {
let dx = sx - stroke_hold_anchor_pos.0;
let dy = sy - stroke_hold_anchor_pos.1;
if (dx * dx + dy * dy).sqrt() > 3.0 {
if (dx * dx + dy * dy).sqrt() > hold_tolerance(&window) {
stroke_last_motion_time = Instant::now();
stroke_hold_anchor_pos = (sx, sy);
}
@@ -1813,21 +1899,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
// Draw-and-Hold Überprüfung: Stift regungslos für > 400 ms gehalten (< 3 px Distanz)
// Draw-and-Hold Überprüfung: Stift regungslos für > 400 ms gehalten (siehe hold_tolerance)
if is_drawing && !stroke_already_snapped {
let elapsed = stroke_last_motion_time.elapsed();
let dx = cursor_screen_pos.0 - stroke_hold_anchor_pos.0;
let dy = cursor_screen_pos.1 - stroke_hold_anchor_pos.1;
let dist = (dx * dx + dy * dy).sqrt();
if elapsed >= Duration::from_millis(400) && dist < 3.0 && canvas.live_samples.len() >= 4 {
if elapsed >= Duration::from_millis(400) && dist < hold_tolerance(&window) && canvas.live_samples.len() >= 4 {
if let Some(shape) = geometry::detect_shape(&canvas.live_samples) {
stroke_already_snapped = true;
stroke_snapped_shape = Some(shape.clone());
// Kreuzt die Gerade den vorigen Strich, zeigt die Vorschau schon das
// Achsenkreuz (übernommen wird es beim Absetzen)
let shape = axis_cross_with_previous(&notebook, &shape)
.map_or(shape, |(_, cross)| cross);
let width_units = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
let (samples, vertices) = geometry::shape_to_mesh(&shape, width_units, canvas.current_ink_color);
canvas.set_live_shape_mesh(samples, vertices);
ui_state.set_export_message(format!("{} eingerastet!", shape.name()));
ui_state.set_export_message(t!("{shape} eingerastet!", shape = shape.name()));
window.request_redraw();
}
}
@@ -1951,7 +2041,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
} else {
ui_state.set_export_message("Nur Bilder (PNG, JPEG, GIF, WebP, BMP) und PDFs können eingefügt werden".to_string());
ui_state.set_export_message(t!("Nur Bilder (PNG, JPEG, GIF, WebP, BMP) und PDFs können eingefügt werden").to_string());
}
window.request_redraw();
}
@@ -2134,7 +2224,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if !removed.is_empty() {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len()));
ui_state.set_export_message(t!("{count} Objekt(e) gelöscht", count = removed.len()));
window.request_redraw();
}
}
@@ -2144,12 +2234,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Key::Character(c) if c.eq_ignore_ascii_case("s") && ctrl => {
match autosave.flush() {
Ok(()) => {
ui_state.set_export_message("Gespeichert");
ui_state.set_export_message(t!("Gespeichert"));
}
Err(err) => {
ui_state.set_export_message(format!(
"Speicherfehler: {}",
err.messages.join(", ")
ui_state.set_export_message(t!(
"Speicherfehler: {messages}",
messages = err.messages.join(", ")
));
}
}
@@ -2166,7 +2256,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// PDF-Export: Strg + E
Key::Character(c) if c.eq_ignore_ascii_case("e") && ctrl => {
start_pdf_export(&notebook, &storage_dir, &manifest.title, ui_state.pattern_color, export_tx.clone(), window.clone());
ui_state.set_export_message("PDF-Export läuft…");
ui_state.set_export_message(t!("PDF-Export läuft…"));
window.request_redraw();
}
@@ -2203,7 +2293,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if selection_state.recolor_selected(notebook.current_page_mut(), color) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message("Auswahl auf Schwarz umgefärbt".to_string());
ui_state.set_export_message(t!("Auswahl auf Schwarz umgefärbt").to_string());
window.request_redraw();
}
} else {
@@ -2223,7 +2313,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if selection_state.recolor_selected(notebook.current_page_mut(), color) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message("Auswahl auf Blau umgefärbt".to_string());
ui_state.set_export_message(t!("Auswahl auf Blau umgefärbt").to_string());
window.request_redraw();
}
} else {
@@ -2242,7 +2332,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if selection_state.rotate_selected_90_deg(notebook.current_page_mut()) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message("Auswahl um 90° gedreht".to_string());
ui_state.set_export_message(t!("Auswahl um 90° gedreht").to_string());
window.request_redraw();
}
} else {
@@ -2489,7 +2579,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
notebook.current_page_mut().replace_stroke(old_id, new_stroke);
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message(format!("{shape_name} ausgerichtet! (Undo mit Strg+Z)"));
ui_state.set_export_message(t!("{shape_name} ausgerichtet! (Undo mit Strg+Z)", shape_name = shape_name));
window.request_redraw();
return;
}
@@ -2598,7 +2688,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Draw-and-Hold Bewegungsmessung: Bei Bewegung > 3 px Timer zurücksetzen
let dx = sx - stroke_hold_anchor_pos.0;
let dy = sy - stroke_hold_anchor_pos.1;
if (dx * dx + dy * dy).sqrt() > 3.0 {
if (dx * dx + dy * dy).sqrt() > hold_tolerance(&window) {
stroke_last_motion_time = Instant::now();
stroke_hold_anchor_pos = (sx, sy);
}
@@ -2749,7 +2839,29 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
cursor_screen_pos = (tx, ty);
current_pressure = pressure;
if !ui_state.touch_draw_enabled {
// Ohne Finger-Zeichnen verschiebt ein Finger das Blatt – außer bei
// Werkzeugen, die nicht zeichnen: Text und Platz schaffen nehmen den
// Finger immer, Auswählen, wenn er ein Objekt oder die Auswahl trifft
// (Issue #26)
let finger_uses_tool = match ui_state.active_tool {
ActiveTool::Text | ActiveTool::Space => true,
ActiveTool::Select => {
focus_page_at!((tx, ty));
finger_grabs_selection(
(tx, ty),
&canvas,
notebook.current_page(),
egui_ctx.pixels_per_point(),
&selection_state,
)
}
_ => false,
};
if finger_uses_tool {
// Halten soll hier kein Kontextmenü öffnen
long_press.cancel();
}
if !ui_state.touch_draw_enabled && !finger_uses_tool {
// Standard: 1 Finger scrollt / verschiebt den Canvas (Pan)
is_touch_panning = true;
touch_pan_last_pos = (tx, ty);
@@ -2835,7 +2947,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let dx = tx - stroke_hold_anchor_pos.0;
let dy = ty - stroke_hold_anchor_pos.1;
if (dx * dx + dy * dy).sqrt() > 3.0 {
if (dx * dx + dy * dy).sqrt() > hold_tolerance(&window) {
stroke_last_motion_time = Instant::now();
stroke_hold_anchor_pos = (tx, ty);
}
@@ -2989,6 +3101,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 3. Toolbar & Widgets rendern
updater.update();
ui_state.page_view = Some(page_view(&canvas, window.scale_factor() as f32));
ui_state.selection_aspect_lock = (!selection_state.is_empty())
.then(|| selection_state.keeps_aspect(notebook.current_page()));
ui_state.restorable_page = last_deleted_page
.as_ref()
.filter(|(id, _, _)| *id == manifest.id)
@@ -3019,7 +3133,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
&& selection_state.recolor_selected(notebook.current_page_mut(), color) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message("Auswahl umgefärbt".to_string());
ui_state.set_export_message(t!("Auswahl umgefärbt").to_string());
}
match ui_state.active_tool {
ActiveTool::Pen | ActiveTool::Shape => {
@@ -3054,7 +3168,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if !removed.is_empty() {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len()));
ui_state.set_export_message(t!("{count} Objekt(e) gelöscht", count = removed.len()));
window.request_redraw();
}
}
@@ -3089,12 +3203,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
UiAction::ToggleAspectLock => {
if !selection_state.is_empty() {
selection_state.toggle_aspect_lock(notebook.current_page());
window.request_redraw();
}
}
UiAction::RotateSelected => {
if !selection_state.is_empty()
&& selection_state.rotate_selected_90_deg(notebook.current_page_mut()) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message("Auswahl um 90° gedreht".to_string());
ui_state.set_export_message(t!("Auswahl um 90° gedreht").to_string());
window.request_redraw();
}
}
@@ -3166,7 +3286,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
UiAction::ExportPage(kind) => {
start_page_export(kind, &notebook, &storage_dir, &manifest.title, ui_state.pattern_color, export_tx.clone(), window.clone());
ui_state.set_export_message("Seite wird exportiert …".to_string());
ui_state.set_export_message(t!("Seite wird exportiert …").to_string());
window.request_redraw();
}
UiAction::SetPageTitle { page_id, title } => {
@@ -3196,7 +3316,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
&mut ui_state,
&window,
);
ui_state.set_export_message(format!("Seite {} eingefügt", index + 1));
ui_state.set_export_message(t!("Seite {number} eingefügt", number = index + 1));
}
UiAction::DeleteCurrentPage => {
let index = notebook.current_page_index;
@@ -3208,9 +3328,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest);
last_deleted_page = Some((manifest.id.clone(), index, page));
ui_state.set_export_message(format!(
"Seite {} gelöscht – im Menü ☰ wiederherstellbar",
index + 1
ui_state.set_export_message(t!(
"Seite {number} gelöscht – im Menü ☰ wiederherstellbar",
number = index + 1
));
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
@@ -3226,7 +3346,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Die Seitendatei neu schreiben (sie kann inzwischen fehlen)
autosave.mark_page_dirty(notebook.current_page(), &manifest);
autosave.mark_manifest_dirty(&manifest);
ui_state.set_export_message(format!("Seite {} wiederhergestellt", index + 1));
ui_state.set_export_message(t!("Seite {number} wiederhergestellt", number = index + 1));
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
} else {
@@ -3268,7 +3388,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
UiAction::ExportPdf => {
start_pdf_export(&notebook, &storage_dir, &manifest.title, ui_state.pattern_color, export_tx.clone(), window.clone());
ui_state.set_export_message("PDF-Export läuft…");
ui_state.set_export_message(t!("PDF-Export läuft…"));
window.request_redraw();
}
@@ -3276,9 +3396,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if let Some(path) = change_library(&storage_base_dir, &mut ui_state, |library| {
library.create_folder(&parent, &name)
}) {
ui_state.set_export_message(format!(
"Ordner „{}“ angelegt",
ink_notes::library::folder_name(&path)
ui_state.set_export_message(t!(
"Ordner „{name}“ angelegt",
name = ink_notes::library::folder_name(&path)
));
}
window.request_redraw();
@@ -3302,9 +3422,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
})
.is_some()
{
ui_state.set_export_message(format!(
"Ordner „{}“ gelöscht, sein Inhalt liegt eine Ebene höher",
ink_notes::library::folder_name(&path)
ui_state.set_export_message(t!(
"Ordner „{name}“ gelöscht, sein Inhalt liegt eine Ebene höher",
name = ink_notes::library::folder_name(&path)
));
}
window.request_redraw();
@@ -3319,13 +3439,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.notebook_folder = format!("{new_path}{}", &shown[path.len()..]);
}
let target = if new_parent.is_empty() {
"die oberste Ebene".to_string()
t!("die oberste Ebene").to_string()
} else {
format!("„{}“", ink_notes::library::folder_name(&new_parent))
};
ui_state.set_export_message(format!(
"Ordner „{}“ verschoben in {target}",
ink_notes::library::folder_name(&path)
ui_state.set_export_message(t!(
"Ordner „{name}“ verschoben in {target}",
name = ink_notes::library::folder_name(&path),
target = target
));
}
window.request_redraw();
@@ -3337,11 +3458,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.is_some()
{
let target = if folder.is_empty() {
"die oberste Ebene".to_string()
t!("die oberste Ebene").to_string()
} else {
format!("„{}“", ink_notes::library::folder_name(&folder))
};
ui_state.set_export_message(format!("Verschoben in {target}"));
ui_state.set_export_message(t!("Verschoben in {target}", target = target));
}
window.request_redraw();
}
@@ -3367,7 +3488,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
eprintln!("[Storage-Lock] {msg}");
_notebook_lock = None;
autosave.set_read_only(true);
ui_state.set_export_message("⚠ In anderer Instanz geöffnet (Schreibschutz)");
ui_state.set_export_message(t!("⚠ In anderer Instanz geöffnet (Schreibschutz)"));
}
Err(e) => {
eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}");
@@ -3387,13 +3508,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.sync_page(notebook.current_page_mut());
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
if _notebook_lock.is_some() {
ui_state.set_export_message(format!("Dokument '{}' geöffnet", manifest.title));
ui_state.set_export_message(t!("Dokument '{title}' geöffnet", title = manifest.title));
}
window.request_redraw();
}
Err(err) => {
eprintln!("[Dokument-Fehler] Konnte Dokument '{id}' nicht laden: {err}");
ui_state.set_export_message(format!("Fehler beim Öffnen: {err}"));
ui_state.set_export_message(t!("Fehler beim Öffnen: {err}", err = err));
}
}
}
@@ -3428,15 +3549,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.reset_view();
canvas.sync_page(notebook.current_page_mut());
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
ui_state.set_export_message(format!(
"'{}' importiert ({} Seiten)",
manifest.title,
notebook.pages.len()
ui_state.set_export_message(t!(
"'{title}' importiert ({count} Seiten)",
title = manifest.title,
count = notebook.pages.len()
));
}
Err(err) => {
eprintln!("[PDF-Import] '{}': {err}", path.display());
ui_state.set_export_message(format!("PDF nicht importiert: {err}"));
ui_state.set_export_message(t!("PDF nicht importiert: {err}", err = err));
}
}
window.request_redraw();
@@ -3462,12 +3583,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.is_document_menu_open = false;
canvas.sync_page(notebook.current_page_mut());
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
ui_state.set_export_message(format!("Dokument '{}' erstellt", manifest.title));
ui_state.set_export_message(t!("Dokument '{title}' erstellt", title = manifest.title));
window.request_redraw();
}
Err(err) => {
eprintln!("[Dokument-Fehler] Konnte Dokument '{title}' nicht erstellen: {err}");
ui_state.set_export_message(format!("Fehler beim Erstellen: {err}"));
ui_state.set_export_message(t!("Fehler beim Erstellen: {err}", err = err));
}
}
}
@@ -3481,12 +3602,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
ui_state.set_export_message(format!("Umbenannt in '{}'", new_title));
ui_state.set_export_message(t!("Umbenannt in '{title}'", title = new_title));
window.request_redraw();
}
Err(err) => {
eprintln!("[Dokument-Fehler] Konnte Dokument '{id}' nicht umbenennen: {err}");
ui_state.set_export_message(format!("Fehler beim Umbenennen: {err}"));
ui_state.set_export_message(t!("Fehler beim Umbenennen: {err}", err = err));
}
}
} else {
@@ -3495,7 +3616,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if let Ok(mut other_manifest) = serde_json::from_str::<NotebookManifest>(&content) {
let _ = storage::rename_notebook(&target_dir, &mut other_manifest, &new_title);
refresh_notebooks(&storage_base_dir, &mut ui_state);
ui_state.set_export_message(format!("Umbenannt in '{}'", new_title));
ui_state.set_export_message(t!("Umbenannt in '{title}'", title = new_title));
window.request_redraw();
}
}
@@ -3541,13 +3662,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.document_title = manifest.title.clone();
canvas.sync_page(notebook.current_page_mut());
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
ui_state.set_export_message(format!("Gewechselt zu '{}'", manifest.title));
ui_state.set_export_message(t!("Gewechselt zu '{title}'", title = manifest.title));
}
Err(err) => {
eprintln!("[Dokument-Fehler] Konnte Dokument '{}' nicht laden: {err}", first.id);
}
}
} else if let Ok((new_nb, new_manifest, new_dir)) = storage::create_new_notebook(&storage_base_dir, "Standard-Notizbuch") {
} else if let Ok((new_nb, new_manifest, new_dir)) = storage::create_new_notebook(&storage_base_dir, t!("Standard-Notizbuch")) {
_notebook_lock = storage::NotebookLock::acquire(&new_dir).ok();
autosave.set_read_only(_notebook_lock.is_none());
autosave.switch_notebook_dir(new_dir.clone());
@@ -3560,16 +3681,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
refresh_notebooks(&storage_base_dir, &mut ui_state);
canvas.sync_page(notebook.current_page_mut());
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
ui_state.set_export_message("Standard-Notizbuch erstellt".to_string());
ui_state.set_export_message(t!("Standard-Notizbuch erstellt").to_string());
}
} else {
ui_state.set_export_message("Dokument in Papierkorb verschoben".to_string());
ui_state.set_export_message(t!("Dokument in Papierkorb verschoben").to_string());
}
window.request_redraw();
}
Err(err) => {
eprintln!("[Dokument-Fehler] Konnte Dokument '{id}' nicht löschen: {err}");
ui_state.set_export_message(format!("Fehler beim Löschen: {err}"));
ui_state.set_export_message(t!("Fehler beim Löschen: {err}", err = err));
}
}
}
@@ -3578,12 +3699,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
match storage::restore_notebook_from_trash(&storage_base_dir, &trash_path) {
Ok(_restored_dir) => {
refresh_notebooks(&storage_base_dir, &mut ui_state);
ui_state.set_export_message("Notizbuch wiederhergestellt".to_string());
ui_state.set_export_message(t!("Notizbuch wiederhergestellt").to_string());
window.request_redraw();
}
Err(err) => {
eprintln!("[Dokument-Fehler] Konnte Notizbuch nicht wiederherstellen: {err}");
ui_state.set_export_message(format!("Fehler beim Wiederherstellen: {err}"));
ui_state.set_export_message(t!("Fehler beim Wiederherstellen: {err}", err = err));
}
}
}
@@ -3593,18 +3714,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
match storage::restore_manifest_from_backup(&target_dir) {
Ok(()) => {
refresh_notebooks(&storage_base_dir, &mut ui_state);
ui_state.set_export_message("Manifest aus Backup wiederhergestellt".to_string());
ui_state.set_export_message(t!("Manifest aus Backup wiederhergestellt").to_string());
window.request_redraw();
}
Err(err) => {
eprintln!("[Dokument-Fehler] Konnte Backup für '{id}' nicht wiederherstellen: {err}");
ui_state.set_export_message(format!("Wiederherstellung fehlgeschlagen: {err}"));
ui_state.set_export_message(t!("Wiederherstellung fehlgeschlagen: {err}", err = err));
}
}
}
UiAction::OpenNotebookFolder(id) => {
let target_dir = storage_base_dir.join(&id);
ui_state.set_export_message(format!("Ordner: {}", target_dir.display()));
ui_state.set_export_message(t!("Ordner: {path}", path = target_dir.display()));
window.request_redraw();
}
UiAction::CheckForUpdates => {
@@ -3617,9 +3738,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
UiAction::InstallOrOpenUpdate(path) => {
if let Err(err) = updater.install_or_open(&path) {
ui_state.set_export_message(format!("Update-Fehler: {err}"));
ui_state.set_export_message(t!("Update-Fehler: {err}", err = err));
} else {
ui_state.set_export_message("Update gestartet / geöffnet");
ui_state.set_export_message(t!("Update gestartet / geöffnet"));
}
window.request_redraw();
}
@@ -3629,7 +3750,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
UiAction::SaveUpdaterConfig { server_url, auth_token } => {
updater.set_server_url(server_url);
updater.set_auth_token(auth_token);
ui_state.set_export_message("Server-Einstellungen gespeichert");
ui_state.set_export_message(t!("Server-Einstellungen gespeichert"));
window.request_redraw();
}
UiAction::UnlockCorruptedPage(page_id) => {
@@ -3667,7 +3788,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Err(err) => {
eprintln!("[Recovery-Fehler] Konnte Notfall-Export nicht schreiben: {err}");
if let Some(ref mut se) = ui_state.shutdown_error {
se.messages.push(format!("Notfall-Export fehlgeschlagen: {err}"));
se.messages.push(t!("Notfall-Export fehlgeschlagen: {err}", err = err));
}
window.request_redraw();
}
@@ -3678,6 +3799,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
window.request_redraw();
}
UiAction::SetLanguage(language) => {
ui_settings.language = language;
i18n::set_language(language.unwrap_or_else(Language::system));
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
UiAction::SetPaperTheme { theme, adapt_ink } => {
canvas.set_paper_theme(theme, adapt_ink);
canvas.sync_page(notebook.current_page_mut());
@@ -3798,6 +3926,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.set_neighbor_pages(&[]);
}
let assets = AssetStore::new(&storage_dir);
// PDF-Seiten, die nach diesem Bild nicht mehr gebraucht werden (schnelles
// Scrollen), verwirft end_frame, bevor sie gerendert werden
pdf_renderer.begin_frame();
canvas.prepare_images(&mut |key| texture_for(key, &assets, &mut pdf_renderer));
// Nächste PDF-Seite vorab rendern, damit Blättern sofort klappt
if !canvas.is_waiting_for_images() {
@@ -3808,6 +3939,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
}
pdf_renderer.end_frame();
match canvas.render(&clipped_primitives, &screen_descriptor, &full_output.textures_delta) {
Ok(_) => {}
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
+6 -5
View File
@@ -15,6 +15,7 @@ use crate::assets::{AssetStore, RgbaImage};
use crate::background::PatternColor;
use crate::document::{Aabb, Notebook, Page, PageBackground, A4_WIDTH_MM, PAGE_WIDTH};
use crate::export::{export_notebook_to_bytes, ExportStrategy};
use crate::t;
/// Auflösung des PNG-Exports einer Seite.
pub const PAGE_PNG_DPI: f32 = 200.0;
@@ -50,8 +51,8 @@ fn content_only(page: &Page) -> Page {
/// Rastert die erste Seite eines PDFs mit `dpi`; Farben ohne Vormultiplikation.
fn rasterize(pdf_bytes: Vec<u8>, dpi: f32, transparent: bool) -> Result<RgbaImage, Box<dyn Error>> {
let pdf = Pdf::new(pdf_bytes).map_err(|e| format!("PDF nicht lesbar: {e:?}"))?;
let page = pdf.pages().first().ok_or("PDF ohne Seite")?;
let 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,
@@ -84,7 +85,7 @@ fn rasterize(pdf_bytes: Vec<u8>, dpi: f32, transparent: bool) -> Result<RgbaImag
/// Kodiert ein Bild als PNG.
pub fn encode_png(image: &RgbaImage) -> Result<Vec<u8>, Box<dyn Error>> {
let buffer = image::RgbaImage::from_raw(image.width, image.height, image.pixels.clone())
.ok_or("Pixeldaten passen nicht zur Bildgröße")?;
.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)?;
@@ -118,8 +119,8 @@ pub fn page_svg(
pattern: PatternColor,
) -> Result<String, Box<dyn Error>> {
let pdf = Pdf::new(single_page_pdf(page, assets, pattern)?)
.map_err(|e| format!("PDF nicht lesbar: {e:?}"))?;
let pdf_page = pdf.pages().first().ok_or("PDF ohne Seite")?;
.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(),
+250 -38
View File
@@ -1,11 +1,13 @@
//! Lesen und Rendern importierter PDF-Dateien mit `hayro` (reines Rust, nur CPU).
//!
//! Das Rendern einer Seite kann einige hundert Millisekunden dauern; [`PdfRenderService`]
//! erledigt es deshalb in einem Hintergrund-Thread und meldet fertige Seiten zurück.
//! 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, Sender};
use std::sync::mpsc::{self, Receiver};
use std::sync::{Arc, Condvar, Mutex};
use hayro::hayro_interpret::InterpreterSettings;
use hayro::hayro_syntax::{LoadPdfError, Pdf};
@@ -13,6 +15,7 @@ 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;
@@ -38,12 +41,13 @@ pub fn page_sizes(bytes: Vec<u8>) -> Result<Vec<(f32, f32)>, String> {
let pdf = open(bytes)?;
let sizes: Vec<(f32, f32)> = pdf.pages().iter().map(|p| p.render_dimensions()).collect();
if sizes.is_empty() {
return Err("Das PDF enthält keine Seiten".into());
return Err(t!("Das PDF enthält keine Seiten").into());
}
if sizes.len() > MAX_IMPORT_PAGES {
return Err(format!(
"Das PDF hat {} Seiten, höchstens {MAX_IMPORT_PAGES} werden unterstützt",
sizes.len()
return Err(t!(
"Das PDF hat {count} Seiten, höchstens {max} werden unterstützt",
count = sizes.len(),
max = MAX_IMPORT_PAGES
));
}
Ok(sizes)
@@ -52,10 +56,10 @@ pub fn page_sizes(bytes: Vec<u8>) -> Result<Vec<(f32, f32)>, String> {
fn open(bytes: Vec<u8>) -> Result<Pdf, String> {
Pdf::new(bytes).map_err(|e| match e {
LoadPdfError::Decryption(_) => {
"Das PDF ist verschlüsselt oder passwortgeschützt und kann nicht geöffnet werden"
t!("Das PDF ist verschlüsselt oder passwortgeschützt und kann nicht geöffnet werden")
.to_string()
}
LoadPdfError::Invalid => "Die Datei ist kein lesbares PDF".to_string(),
LoadPdfError::Invalid => t!("Die Datei ist kein lesbares PDF").to_string(),
})
}
@@ -65,7 +69,7 @@ pub fn render_page(pdf: &Pdf, index: usize, max_pixels: u32) -> Result<RgbaImage
let page = pdf
.pages()
.get(index)
.ok_or_else(|| format!("Seite {} fehlt im PDF", index + 1))?;
.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(
@@ -102,30 +106,107 @@ struct RenderRequest {
path: PathBuf,
page: usize,
max_pixels: u32,
/// Wichtigkeit: kleiner zuerst (Reihenfolge der Anfragen im aktuellen Bild)
rank: usize,
}
/// Ergebnis eines Render-Auftrags.
pub type RenderResult = Result<RgbaImage, String>;
/// 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 {
requests: Sender<RenderRequest>,
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 (req_tx, req_rx) = mpsc::channel::<RenderRequest>();
let queue: SharedQueue = Arc::default();
let (res_tx, res_rx) = mpsc::channel();
std::thread::Builder::new()
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;
while let Ok(request) = req_rx.recv() {
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())
@@ -135,36 +216,64 @@ impl PdfRenderService {
.map(|pdf| (request.path.clone(), pdf));
}
let result = match &open_pdf {
Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels),
None => Err("PDF kann nicht geöffnet werden".into()),
// 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() {
break;
return;
}
on_ready();
}
})
.expect("PDF-Render-Thread konnte nicht gestartet werden");
Self {
requests: req_tx,
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();
if self.ready.contains_key(key) || self.pending.contains(key) {
self.prefetched = Some(key.to_string());
if self.ready.contains_key(key) {
return;
}
// Vorab gerenderte Seiten nicht unbegrenzt aufheben
if self.ready.len() >= 2 {
self.ready.clear();
}
self.request(key, path, page, DISPLAY_RENDER_PIXELS);
self.request(key, path, page, DISPLAY_RENDER_PIXELS, PREFETCH_RANK);
}
fn collect(&mut self) {
@@ -174,21 +283,37 @@ impl PdfRenderService {
}
}
/// Erteilt den Auftrag, falls noch keiner läuft. `false`, wenn der Thread beendet ist.
fn request(&mut self, key: &str, path: PathBuf, page: usize, max_pixels: u32) -> bool {
if !self.pending.insert(key.to_string()) {
/// 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;
}
let request = RenderRequest {
self.pending.insert(key.to_string());
queue.push(RenderRequest {
key: key.to_string(),
path,
page,
max_pixels,
};
if self.requests.send(request).is_err() {
self.pending.remove(key);
return false;
}
rank,
});
ready.notify_one();
true
}
@@ -202,16 +327,29 @@ impl PdfRenderService {
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);
}
if !self.request(key, path, page, max_pixels) {
return Some(Err("PDF-Render-Thread beendet".into()));
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::*;
@@ -301,8 +439,12 @@ pub(crate) mod tests {
wake_rx
.recv_timeout(std::time::Duration::from_secs(20))
.unwrap();
let image = service.take("a#0", path.clone(), 0, px).unwrap().unwrap();
assert_eq!(image.width, DISPLAY_RENDER_PIXELS);
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);
@@ -312,6 +454,76 @@ pub(crate) mod tests {
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!(
+12 -5
View File
@@ -9,6 +9,8 @@ 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 {
@@ -137,7 +139,7 @@ fn page_as_form(
.map_err(|e| e.to_string())?;
let data = stream
.decompressed_content()
.map_err(|e| format!("Inhalt nicht lesbar: {e}"))?;
.map_err(|e| t!("Inhalt nicht lesbar: {e}", e = e))?;
content.extend_from_slice(&data);
content.push(b'\n');
}
@@ -183,10 +185,10 @@ pub fn apply_underlays(
let result = (|| {
let bytes = sources
.get(&underlay.source)
.ok_or_else(|| "Quell-PDF fehlt".to_string())?;
.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("Quell-PDF ist verschlüsselt".to_string());
return Err(t!("Quell-PDF ist verschlüsselt").to_string());
}
source.renumber_objects_with(target.max_id + 1);
let pages = source.get_pages();
@@ -206,10 +208,15 @@ pub fn apply_underlays(
.map_err(Clone::clone)?;
let source_page = *pages
.get(&(underlay.source_page as u32 + 1))
.ok_or_else(|| format!("Seite {} fehlt im Quell-PDF", underlay.source_page + 1))?;
.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(|| "Zielseite fehlt".to_string())?;
.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);
+127 -9
View File
@@ -7,6 +7,7 @@ use std::f32::consts::FRAC_PI_2;
use crate::canvas::CanvasState;
use crate::document::{Aabb, Page, Stroke};
use crate::t;
/// Status des aktiven Ziehvorgangs (Drag-Operation) mit der Maus oder dem Stylus.
#[derive(Debug, Clone, PartialEq, Default)]
@@ -35,9 +36,34 @@ pub enum DragMode {
start_pos_page: Vec2,
initial_box_size: Vec2,
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).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SelectionHandleHit {
@@ -63,6 +89,9 @@ pub struct SelectionState {
pub selected_ids: Vec<u64>,
pub drag_mode: DragMode,
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 {
@@ -71,9 +100,28 @@ impl SelectionState {
selected_ids: Vec::new(),
drag_mode: DragMode::None,
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,
_ => page
.get_strokes(&self.selected_ids)
.iter()
.any(|s| s.is_image()),
}
}
/// 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.
pub fn is_empty(&self) -> bool {
self.selected_ids.is_empty()
@@ -404,15 +452,14 @@ pub fn render_selection_overlay(
}
// Kleines Info-Badge an der Unterkante (z. B. "2 Objekte | [R] Drehen [Entf] Löschen")
let badge_text = format!(
"{} {} | [R] Drehen [Entf] Löschen",
selection.count(),
if selection.count() == 1 {
"Objekt"
} else {
"Objekte"
}
);
let badge_text = if selection.count() == 1 {
t!("1 Objekt | [R] Drehen [Entf] Löschen").to_string()
} else {
t!(
"{count} Objekte | [R] Drehen [Entf] Löschen",
count = selection.count()
)
};
let badge_pos = egui::pos2(rect.center().x, rect.max.y + 16.0);
painter.text(
badge_pos,
@@ -523,6 +570,77 @@ mod tests {
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);
+11 -5
View File
@@ -7,7 +7,9 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::background::{PaperTheme, PatternColor};
use crate::i18n::Language;
use crate::storage::{self, StorageError};
use crate::t;
/// Fensterseite, an der die Werkzeugleiste angedockt ist.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
@@ -177,11 +179,11 @@ impl StylusAction {
pub fn label(self) -> &'static str {
match self {
StylusAction::Erase => "Radieren",
StylusAction::Select => "Auswählen",
StylusAction::Highlighter => "Textmarker",
StylusAction::Pen => "Stift",
StylusAction::Nothing => "Nichts",
StylusAction::Erase => t!("Radieren"),
StylusAction::Select => t!("Auswählen"),
StylusAction::Highlighter => t!("Textmarker"),
StylusAction::Pen => t!("Stift"),
StylusAction::Nothing => t!("Nichts"),
}
}
}
@@ -222,6 +224,8 @@ pub struct UiSettings {
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 {
@@ -240,6 +244,7 @@ impl Default for UiSettings {
text_colors: DEFAULT_TEXT_COLORS,
active_text_color: 0,
stylus_buttons: Vec::new(),
language: None,
}
}
}
@@ -338,6 +343,7 @@ mod tests {
action: StylusAction::Nothing,
},
],
language: Some(Language::English),
};
settings.save_to(&path).unwrap();
assert_eq!(UiSettings::load_from(&path), settings);
+60 -48
View File
@@ -15,6 +15,7 @@ use crate::document::{
};
use crate::filter::OneEuroFilter;
use crate::spline::{self, ControlPoint, SplineConfig};
use crate::t;
/// Serialisierbarer Stylus-Abtastpunkt in DIN-A4-Koordinaten.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
@@ -371,19 +372,30 @@ pub enum StorageError {
impl fmt::Display for StorageError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
StorageError::Io(e) => write!(f, "I/O-Fehler: {e}"),
StorageError::Serialization(e) => write!(f, "Serialisierungsfehler: {e}"),
StorageError::InvalidManifest(msg) => write!(f, "Ungültiges Manifest: {msg}"),
StorageError::CorruptedPage(id, msg) => {
write!(f, "Beschädigte Seite {id}: {msg}")
StorageError::Io(e) => f.write_str(&t!("I/O-Fehler: {e}", e = e)),
StorageError::Serialization(e) => {
f.write_str(&t!("Serialisierungsfehler: {e}", e = e))
}
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}"),
}
}
}
@@ -490,7 +502,7 @@ pub fn atomic_write_file(path: &Path, content: &[u8]) -> Result<(), io::Error> {
let dir = path.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"Übergeordnetes Verzeichnis nicht gefunden",
t!("Übergeordnetes Verzeichnis nicht gefunden"),
)
})?;
create_secure_dir_all(dir)?;
@@ -595,9 +607,9 @@ pub fn load_manifest(notebook_dir: &Path) -> Result<NotebookManifest, StorageErr
eprintln!("[Storage-Warnung] manifest.json fehlt, lade aus manifest.json.bak");
return load_manifest_from_file(&bak_path);
}
return Err(StorageError::InvalidManifest(format!(
"Manifestdatei nicht gefunden: {}",
path.display()
return Err(StorageError::InvalidManifest(t!(
"Manifestdatei nicht gefunden: {path}",
path = path.display()
)));
}
@@ -625,7 +637,7 @@ pub fn quarantine_corrupted_page(notebook_dir: &Path, page_id: u64) -> Result<Pa
if !source_path.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"Datei existiert nicht",
t!("Datei existiert nicht"),
));
}
let ts = std::time::SystemTime::now()
@@ -764,7 +776,7 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
first_id,
PageLoadWarning {
page_id: first_id,
message: format!("I/O-Fehler: {e}"),
message: t!("I/O-Fehler: {e}", e = e),
quarantine_path: None,
},
);
@@ -876,7 +888,7 @@ pub fn ensure_page_loaded(
page_id,
PageLoadWarning {
page_id,
message: format!("I/O-Fehler: {err_str}"),
message: t!("I/O-Fehler: {e}", e = err_str),
quarantine_path: None,
},
);
@@ -970,9 +982,9 @@ impl NotebookLock {
Err(e) => {
let kind = e.kind();
if kind == io::ErrorKind::WouldBlock || e.raw_os_error() == Some(11) {
Err(StorageError::AlreadyLocked(format!(
"Notizbuch '{}' wird bereits von einer anderen Instanz verwendet",
notebook_dir.display()
Err(StorageError::AlreadyLocked(t!(
"Notizbuch '{path}' wird bereits von einer anderen Instanz verwendet",
path = notebook_dir.display()
)))
} else {
Err(StorageError::Io(e))
@@ -1058,7 +1070,7 @@ pub fn list_notebooks(base_dir: &Path) -> Vec<NotebookSummary> {
list.push(NotebookSummary {
id: dir_name.to_string(),
title: format!("⚠️ {dir_name} (Beschädigt)"),
title: t!("⚠️ {name} (Beschädigt)", name = dir_name),
page_count: 0,
modified_at,
status: NotebookStatus::Damaged,
@@ -1077,11 +1089,11 @@ pub fn list_notebooks(base_dir: &Path) -> Vec<NotebookSummary> {
list.push(NotebookSummary {
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,
modified_at,
status: NotebookStatus::Damaged,
error: Some("Keine manifest.json vorhanden".to_string()),
error: Some(t!("Keine manifest.json vorhanden").to_string()),
});
}
}
@@ -1135,7 +1147,7 @@ pub fn create_new_notebook(
if counter > 100 {
return Err(StorageError::Io(io::Error::new(
io::ErrorKind::AlreadyExists,
"Konnte nach 100 Versuchen kein eindeutiges Notizbuch-Verzeichnis finden",
t!("Konnte nach 100 Versuchen kein eindeutiges Notizbuch-Verzeichnis finden"),
)));
}
}
@@ -1214,18 +1226,18 @@ pub fn rename_notebook(
/// 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> {
if !notebook_dir.exists() {
return Err(StorageError::InvalidManifest(format!(
"Verzeichnis '{}' existiert nicht",
notebook_dir.display()
return Err(StorageError::InvalidManifest(t!(
"Verzeichnis '{path}' existiert nicht",
path = notebook_dir.display()
)));
}
// Prüfen auf Symlink
let symlink_meta = fs::symlink_metadata(notebook_dir).map_err(StorageError::Io)?;
if symlink_meta.file_type().is_symlink() {
return Err(StorageError::InvalidManifest(format!(
"Pfad '{}' ist ein Symlink und darf nicht gelöscht werden",
notebook_dir.display()
return Err(StorageError::InvalidManifest(t!(
"Pfad '{path}' ist ein Symlink und darf nicht gelöscht werden",
path = notebook_dir.display()
)));
}
@@ -1234,18 +1246,18 @@ pub fn delete_notebook(notebook_dir: &Path, base_dir: &Path) -> Result<PathBuf,
// Prüfen, dass der Pfad direkt unter base_dir liegt
if canon_target.parent() != Some(&canon_base) {
return Err(StorageError::PathTraversal(format!(
"Pfad '{}' liegt nicht direkt unter Basis-Verzeichnis '{}'",
notebook_dir.display(),
base_dir.display()
return Err(StorageError::PathTraversal(t!(
"Pfad '{path}' liegt nicht direkt unter Basis-Verzeichnis '{base}'",
path = notebook_dir.display(),
base = base_dir.display()
)));
}
// Prüfen, dass manifest.json vorhanden ist
if !canon_target.join("manifest.json").exists() {
return Err(StorageError::InvalidManifest(format!(
"Verzeichnis '{}' enthält kein manifest.json",
notebook_dir.display()
return Err(StorageError::InvalidManifest(t!(
"Verzeichnis '{path}' enthält kein manifest.json",
path = notebook_dir.display()
)));
}
@@ -1276,9 +1288,9 @@ pub fn restore_notebook_from_trash(
if !trash_target.exists() {
return Err(StorageError::Io(io::Error::new(
io::ErrorKind::NotFound,
format!(
"Papierkorb-Eintrag '{}' existiert nicht",
trash_target.display()
t!(
"Papierkorb-Eintrag '{path}' existiert nicht",
path = trash_target.display()
),
)));
}
@@ -1289,7 +1301,7 @@ pub fn restore_notebook_from_trash(
let expected_trash_dir = canon_base.join(".trash");
if !expected_trash_dir.exists() {
return Err(StorageError::InvalidManifest(
"Papierkorb-Verzeichnis existiert nicht".to_string(),
t!("Papierkorb-Verzeichnis existiert nicht").to_string(),
));
}
let canon_trash_base = expected_trash_dir
@@ -1297,9 +1309,9 @@ pub fn restore_notebook_from_trash(
.map_err(StorageError::Io)?;
if canon_trash.parent() != Some(&canon_trash_base) {
return Err(StorageError::PathTraversal(format!(
"Papierkorb-Pfad '{}' liegt nicht im Papierkorb",
trash_target.display()
return Err(StorageError::PathTraversal(t!(
"Papierkorb-Pfad '{path}' liegt nicht im Papierkorb",
path = trash_target.display()
)));
}
@@ -1344,7 +1356,7 @@ pub fn restore_manifest_from_backup(notebook_dir: &Path) -> Result<(), StorageEr
Ok(())
} else {
Err(StorageError::InvalidManifest(
"Keine Sicherungskopie (manifest.json.bak) vorhanden".to_string(),
t!("Keine Sicherungskopie (manifest.json.bak) vorhanden").to_string(),
))
}
}
@@ -1387,7 +1399,7 @@ pub fn load_all_pages(notebook_dir: &Path, notebook: &mut Notebook) -> Result<()
if page.persist == crate::document::PersistState::ReadOnlyAfterLoadError {
return Err(StorageError::CorruptedPage(
page.id,
"Seite konnte nicht geladen werden".to_string(),
t!("Seite konnte nicht geladen werden").to_string(),
));
}
if !page.is_loaded {
+4 -2
View File
@@ -10,6 +10,8 @@
use std::sync::OnceLock;
use crate::t;
use ab_glyph::{Font, FontRef, GlyphId, PxScale};
use serde::{Deserialize, Serialize};
@@ -29,8 +31,8 @@ pub enum TextFont {
impl TextFont {
pub fn label(self) -> &'static str {
match self {
TextFont::Sans => "Normal",
TextFont::Mono => "Code",
TextFont::Sans => t!("Normal"),
TextFont::Mono => t!("Code"),
}
}
+404 -267
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 sha2::{Digest, Sha256};
use crate::t;
/// Konfiguration für den Gitea-Update-Server.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UpdaterConfig {
@@ -59,13 +61,18 @@ impl Default for UpdaterConfig {
impl UpdaterConfig {
/// Validiert die Server-URL auf Protokollsicherheit (erzwingt HTTPS).
pub fn validate_url(&self) -> Result<url::Url, String> {
let parsed = url::Url::parse(&self.server_url)
.map_err(|e| format!("Ungültige Server-URL '{}': {e}", self.server_url))?;
let parsed = url::Url::parse(&self.server_url).map_err(|e| {
t!(
"Ungültige Server-URL '{url}': {e}",
url = self.server_url,
e = e
)
})?;
if parsed.scheme() != "https" && !self.allow_insecure_http {
return Err(format!(
"Unsicheres Protokoll '{}' abgewiesen. Es wird zwingend HTTPS verlangt (z. B. https://gitea.pansi.eu).",
parsed.scheme()
return Err(t!(
"Unsicheres Protokoll '{scheme}' abgewiesen. Es wird zwingend HTTPS verlangt (z. B. https://gitea.pansi.eu).",
scheme = parsed.scheme()
));
}
Ok(parsed)
@@ -111,11 +118,20 @@ pub enum RuntimeEnvironment {
DebPackage { binary_path: PathBuf },
/// Aus Quellcode / Entwicklungs-Binary (z.B. target/debug oder target/release)
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 {
/// Erkennt die aktuelle Laufzeitumgebung anhand der Umgebung und des Binärpfads.
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 current_exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("ink-notes"));
Self::detect_from(appimage_var.as_deref(), &current_exe)
@@ -146,33 +162,47 @@ impl RuntimeEnvironment {
pub fn display_name(&self) -> &'static str {
match self {
RuntimeEnvironment::AppImage { .. } => "AppImage (Portabel)",
RuntimeEnvironment::DebPackage { .. } => "System-Paket (.deb)",
RuntimeEnvironment::PortableOrDev { .. } => "Entwicklung / Lokal",
RuntimeEnvironment::AppImage { .. } => t!("AppImage (Portabel)"),
RuntimeEnvironment::DebPackage { .. } => t!("System-Paket (.deb)"),
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.
pub fn sanitize_asset_name(raw_name: &str) -> Result<String, String> {
let trimmed = raw_name.trim();
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("..") {
return Err(format!(
"Ungültiger Asset-Name (enthält unzulässige Pfadkomponenten): '{raw_name}'"
return Err(t!(
"Ungültiger Asset-Name (enthält unzulässige Pfadkomponenten): '{raw_name}'",
raw_name = raw_name
));
}
let path = Path::new(trimmed);
let file_name = path
.file_name()
.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('.') {
return Err(format!(
"Ungültiger oder versteckter Asset-Name: '{raw_name}'"
return Err(t!(
"Ungültiger oder versteckter Asset-Name: '{raw_name}'",
raw_name = raw_name
));
}
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");
std::fs::create_dir_all(&dir).map_err(|e| {
format!(
"Konnte Update-Cache-Verzeichnis nicht anlegen ({}): {e}",
dir.display()
t!(
"Konnte Update-Cache-Verzeichnis nicht anlegen ({path}): {e}",
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> {
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"))
}
@@ -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.
pub fn compute_sha256(path: &Path) -> Result<String, String> {
let mut file = std::fs::File::open(path).map_err(|e| {
format!(
"Konnte Datei '{}' zur Prüfsummenberechnung nicht öffnen: {e}",
path.display()
t!(
"Konnte Datei '{path}' zur Prüfsummenberechnung nicht öffnen: {e}",
path = path.display(),
e = e
)
})?;
let mut hasher = Sha256::new();
@@ -261,7 +293,7 @@ pub fn compute_sha256(path: &Path) -> Result<String, String> {
loop {
let n = file
.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 {
break;
}
@@ -300,15 +332,13 @@ pub fn verify_checksum(
let checksums = parse_checksums(checksums_content);
let expected_hash = checksums.get(asset_name).ok_or_else(|| {
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)?;
if !actual_hash.eq_ignore_ascii_case(expected_hash) {
let _ = std::fs::remove_file(downloaded_path);
return Err(format!(
"Integritätsprüfung fehlgeschlagen für '{asset_name}': Erwartet {expected_hash}, berechnet {actual_hash}. Datei wurde gelöscht."
));
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));
}
Ok(())
}
@@ -334,15 +364,26 @@ pub fn verify_checksums_signature(
) -> Result<(), String> {
let key = public_key_b64.trim();
if key.is_empty() {
return Err("In dieser Version ist kein Signaturschlüssel für Updates hinterlegt. Bitte das Update manuell installieren.".to_string());
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)
.map_err(|e| format!("Ungültiger Update-Signaturschlüssel: {e}."))?;
let signature = minisign_verify::Signature::decode(signature_text)
.map_err(|e| format!("{CHECKSUMS_SIGNATURE_ASSET_NAME} ist ungültig: {e}."))?;
.map_err(|e| t!("Ungültiger Update-Signaturschlüssel: {e}.", e = e))?;
let signature = minisign_verify::Signature::decode(signature_text).map_err(|e| {
t!(
"{file} ist ungültig: {e}.",
file = CHECKSUMS_SIGNATURE_ASSET_NAME,
e = e
)
})?;
public_key
.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.
@@ -356,7 +397,7 @@ fn fetch_release_text(
.assets
.iter()
.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(
agent,
&asset.browser_download_url,
@@ -364,9 +405,9 @@ fn fetch_release_text(
5,
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()
.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
@@ -429,9 +470,7 @@ fn ensure_secure_scheme(url: &url::Url, allow_insecure_http: bool) -> Result<(),
match url.scheme() {
"https" => Ok(()),
"http" if allow_insecure_http => Ok(()),
scheme => Err(format!(
"Unsicheres Protokoll '{scheme}' für '{url}' abgewiesen. Es wird zwingend HTTPS verlangt."
)),
scheme => Err(t!("Unsicheres Protokoll '{scheme}' für '{url}' abgewiesen. Es wird zwingend HTTPS verlangt.", scheme = scheme, url = url)),
}
}
@@ -453,8 +492,13 @@ fn http_get_with_auth_guard(
max_redirects: usize,
allow_insecure_http: bool,
) -> Result<ureq::Response, String> {
let mut current_url = url::Url::parse(initial_url_str)
.map_err(|e| format!("Ungültige URL '{initial_url_str}': {e}"))?;
let mut current_url = url::Url::parse(initial_url_str).map_err(|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();
for _ in 0..=max_redirects {
@@ -476,24 +520,35 @@ fn http_get_with_auth_guard(
Ok(resp) => return Ok(resp),
Err(ureq::Error::Status(code, resp)) if (301..=308).contains(&code) => {
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;
}
Err(ureq::Error::Status(code, resp)) => {
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)) => {
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.
@@ -544,6 +599,9 @@ impl UpdaterController {
/// Fragt im Hintergrund die Gitea-API ab.
pub fn check_for_updates(&mut self) {
if !self.runtime.self_update_allowed() {
return;
}
if matches!(
self.status,
UpdaterStatus::Checking | UpdaterStatus::Downloading { .. }
@@ -592,8 +650,9 @@ impl UpdaterController {
}
}
Err(e) => {
let _ = tx.send(UpdaterEvent::CheckError(format!(
"Gitea Release-JSON konnte nicht verarbeitet werden: {e}"
let _ = tx.send(UpdaterEvent::CheckError(t!(
"Gitea Release-JSON konnte nicht verarbeitet werden: {e}",
e = e
)));
}
},
@@ -649,15 +708,16 @@ impl UpdaterController {
) {
Ok(text) => text,
Err(e) => {
let _ = tx.send(UpdaterEvent::DownloadError(format!(
"{e} Download abgelehnt."
let _ = tx.send(UpdaterEvent::DownloadError(t!(
"{e} Download abgelehnt.",
e = e
)));
return;
}
},
None => {
let _ = tx.send(UpdaterEvent::DownloadError(
"Kein Release ausgewählt. Download abgelehnt.".to_string(),
t!("Kein Release ausgewählt. Download abgelehnt.").to_string(),
));
return;
}
@@ -673,8 +733,9 @@ impl UpdaterController {
) {
Ok(r) => r,
Err(e) => {
let _ = tx.send(UpdaterEvent::DownloadError(format!(
"Download-Verbindungsfehler: {e}"
let _ = tx.send(UpdaterEvent::DownloadError(t!(
"Download-Verbindungsfehler: {e}",
e = e
)));
return;
}
@@ -686,9 +747,10 @@ impl UpdaterController {
let mut file = match std::fs::File::create(&target_file) {
Ok(f) => f,
Err(e) => {
let _ = tx.send(UpdaterEvent::DownloadError(format!(
"Konnte Zieldatei '{}' nicht erstellen: {e}",
target_file.display()
let _ = tx.send(UpdaterEvent::DownloadError(t!(
"Konnte Zieldatei '{path}' nicht erstellen: {e}",
path = target_file.display(),
e = e
)));
return;
}
@@ -705,8 +767,9 @@ impl UpdaterController {
Ok(count) => count,
Err(e) => {
let _ = std::fs::remove_file(&target_file);
let _ = tx.send(UpdaterEvent::DownloadError(format!(
"Fehler beim Lesen des Download-Streams: {e}"
let _ = tx.send(UpdaterEvent::DownloadError(t!(
"Fehler beim Lesen des Download-Streams: {e}",
e = e
)));
return;
}
@@ -714,8 +777,9 @@ impl UpdaterController {
if let Err(e) = file.write_all(&buffer[..n]) {
let _ = std::fs::remove_file(&target_file);
let _ = tx.send(UpdaterEvent::DownloadError(format!(
"Fehler beim Schreiben auf die Festplatte: {e}"
let _ = tx.send(UpdaterEvent::DownloadError(t!(
"Fehler beim Schreiben auf die Festplatte: {e}",
e = e
)));
return;
}
@@ -776,18 +840,19 @@ impl UpdaterController {
/// Startet die sichere Installation des heruntergeladenen Pakets oder öffnet es im System.
pub fn install_or_open(&self, file_path: &Path) -> Result<(), String> {
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 {
RuntimeEnvironment::AppImage { appimage_path } => {
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
.file_name()
.ok_or_else(|| "Ungültiger AppImage-Dateiname.".to_string())?
.ok_or_else(|| t!("Ungültiger AppImage-Dateiname.").to_string())?
.to_string_lossy();
let temp_new_file =
@@ -797,8 +862,9 @@ impl UpdaterController {
// 1. Neue Datei im SELBEN Verzeichnis anlegen (garantiert atomares rename)
if let Err(e) = std::fs::copy(file_path, &temp_new_file) {
let _ = std::fs::remove_file(&temp_new_file);
return Err(format!(
"Konnte temporäre Update-Datei nicht schreiben: {e}"
return Err(t!(
"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!
if let Err(e) = std::fs::copy(appimage_path, &backup_file) {
let _ = std::fs::remove_file(&temp_new_file);
return Err(format!(
"Konnte Backup ({}) nicht anlegen: {e}. Update abgebrochen, altes AppImage unverändert.",
backup_file.display()
return Err(t!(
"Konnte Backup ({path}) nicht anlegen: {e}. Update abgebrochen, altes AppImage unverändert.",
path = backup_file.display(),
e = e
));
}
// 4. Atomar ersetzen via rename
if let Err(e) = std::fs::rename(&temp_new_file, appimage_path) {
let _ = std::fs::remove_file(&temp_new_file);
return Err(format!(
"Konnte AppImage nicht atomar ersetzen: {e}. Altes AppImage bleibt unverändert."
));
return Err(t!("Konnte AppImage nicht atomar ersetzen: {e}. Altes AppImage bleibt unverändert.", e = e));
}
// Heruntergeladene Cache-Datei aufräumen
@@ -837,7 +902,7 @@ impl UpdaterController {
RuntimeEnvironment::DebPackage { .. } => {
match Command::new("xdg-open").arg(file_path).spawn() {
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 { .. } => {
@@ -846,6 +911,10 @@ impl UpdaterController {
}
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]
fn test_https_enforcement() {
let config = UpdaterConfig {
+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(", ")
);
}