Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63ab6a3e5b | ||
|
|
3c793fe659 | ||
|
|
8bc29faddc | ||
|
|
8ea20aa783 | ||
|
|
1b30f2bd26 | ||
|
|
657a5ff16e | ||
|
|
8fddd9e2c3 | ||
|
|
1a98c9b8bb | ||
|
|
98b78d29e6 | ||
|
|
81ccd93438 | ||
|
|
cbf699b680 | ||
|
|
ade20ab3dc | ||
|
|
f24e7750e6 | ||
|
|
097933882c | ||
|
|
b3578175c2 | ||
|
|
1cb02bf8e9 | ||
|
|
26c4191e0f | ||
|
|
7d683d6e67 | ||
|
|
e399c357d8 | ||
|
|
dd702524ca | ||
|
|
20792ed34f | ||
|
|
b8d0260607 | ||
|
|
6c0db643c4 | ||
|
|
ec6a5259d5 | ||
|
|
c92bf16cbf | ||
|
|
57028dfbc6 | ||
|
|
6fb190cf79 | ||
|
|
1148851585 | ||
|
|
eef4cc6b2b | ||
|
|
dc067af31e | ||
|
|
693f62c2e7 | ||
|
|
f212b2e23f | ||
|
|
61c0c903a4 | ||
|
|
680fa96f32 | ||
|
|
eb0a801921 | ||
|
|
76ecdac081 | ||
|
|
ea9ee6c0d7 | ||
|
|
d1f0b46fac | ||
|
|
82a297f395 | ||
|
|
e33062fe0a | ||
|
|
36a1a3cd2e | ||
|
|
70317caeb5 | ||
|
|
f9a6f8524d | ||
|
|
4a4492d034 | ||
|
|
bb9dbb52d0 | ||
|
|
568714d727 | ||
|
|
c87f4102d7 | ||
|
|
3890b6f182 | ||
|
|
5115e217c2 | ||
|
|
904b36e5f7 | ||
|
|
80d319099f | ||
|
|
ee5257d744 | ||
|
|
fa1ab5ab8f |
@@ -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."
|
||||||
@@ -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. -->
|
||||||
@@ -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."
|
||||||
@@ -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. -->
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
+4
-1
@@ -1393,7 +1393,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.12.0"
|
version = "0.29.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph",
|
"ab_glyph",
|
||||||
"arboard",
|
"arboard",
|
||||||
@@ -1418,6 +1418,9 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"ureq",
|
"ureq",
|
||||||
"url",
|
"url",
|
||||||
|
"wayland-backend",
|
||||||
|
"wayland-client",
|
||||||
|
"wayland-protocols",
|
||||||
"webbrowser",
|
"webbrowser",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
"winit",
|
"winit",
|
||||||
|
|||||||
+6
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.12.0"
|
version = "0.29.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||||
@@ -51,6 +51,11 @@ hayro-svg = "0.8"
|
|||||||
# Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui)
|
# Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui)
|
||||||
ab_glyph = "0.2"
|
ab_glyph = "0.2"
|
||||||
|
|
||||||
|
# Bildschirmtastatur unter Wayland anfordern (eigenes text-input-Objekt, siehe src/osk.rs)
|
||||||
|
wayland-client = "0.31"
|
||||||
|
wayland-backend = { version = "0.3", features = ["client_system"] }
|
||||||
|
wayland-protocols = { version = "0.31", features = ["client", "unstable"] }
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,37 @@
|
|||||||
# ink-notes
|
<p align="center">
|
||||||
|
<img src="assets/ink-notes-banner.svg" alt="ink-notes Banner" width="100%">
|
||||||
|
</p>
|
||||||
|
|
||||||
Eine Notizbuch-App für Linux zum Schreiben mit Stift, Finger oder Maus – geschrieben in **Rust** mit **wgpu**, **winit** und **egui**.
|
<p align="center">
|
||||||
|
<strong>Eine moderne Notizbuch-App für Linux zum Schreiben mit Stift, Touch und Maus</strong><br>
|
||||||
|
Geschrieben in <strong>Rust</strong> mit <strong>wgpu</strong>, <strong>winit</strong> und <strong>egui</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund.
|
ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund.
|
||||||
|
|
||||||
Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
|
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
|
## Funktionen
|
||||||
|
|
||||||
### Schreiben und Zeichnen
|
### Schreiben und Zeichnen
|
||||||
- **Stift** mit Druckstärke, vier Strichstärken (0,3–1,2 mm) und fünf Farben; **Textmarker** (halbtransparent, 4–8 mm) in vier Farben.
|
- **Vier Stifte** in der Werkzeugleiste, jeder mit eigener Farbe und Stärke (0,15–5 mm, mit Druckstärke). Ändert man Farbe oder Stärke, merkt sich der gewählte Stift das – auch über einen Neustart hinweg. **Textmarker** (halbtransparent, 4–8 mm) in vier Farben; innerhalb eines Strichs wird er nicht dunkler, erst ein neuer Strich deckt stärker.
|
||||||
|
- **Eigene Farben:** Jede Farbe in der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – gewählte Farbe erneut antippen, eine Vorgabe oder mit 🎨 einen freien Farbton wählen. Die Leiste merkt sich die Farben. Am Bildschirm erscheinen sie genauso wie im PDF- und Bildexport.
|
||||||
- **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund.
|
- **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund.
|
||||||
- **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
|
- **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
|
||||||
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Stiftrückseite und Seitentaste des Stifts radieren ebenfalls.
|
- **Tasten am Stift** im ☰-Menü belegen: Radieren (Standard), Auswählen, Textmarker, Stift oder nichts – gilt, solange die Taste gedrückt ist. Jede Taste erscheint dort nach dem ersten Drücken.
|
||||||
|
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Die Stiftrückseite radiert ebenfalls.
|
||||||
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
|
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
|
||||||
|
- **Platz schaffen** (↕ in der Werkzeugleiste): aufs Blatt tippen und nach unten ziehen – alles darunter rutscht mit, auch auf den folgenden Seiten. Was über ein Seitenende rutschen würde, beginnt auf der nächsten Seite; fehlende Seiten werden angehängt. Nach oben ziehen schließt leeren Platz. Ein Schritt für Rückgängig.
|
||||||
|
|
||||||
### Formerkennung
|
### Formerkennung
|
||||||
- **Formen-Werkzeug** (`S`/`F`) oder **Halten** am Strichende (über 400 ms stillhalten): Die Skizze wird durch eine saubere Form ersetzt.
|
- **Formen-Werkzeug** (`S`/`F`) oder **Halten** am Strichende (über 400 ms stillhalten): Die Skizze wird durch eine saubere Form ersetzt.
|
||||||
- Erkannt werden Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Vielecke, Koordinatensysteme und Oktaeder (Skizze als 3D-Körper).
|
- 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.
|
- 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
|
### Auswählen und Bearbeiten
|
||||||
@@ -31,18 +43,21 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
|
|||||||
### Text, Bilder und PDFs
|
### Text, Bilder und PDFs
|
||||||
- **Text-Werkzeug** (`T`): ins Blatt tippen und schreiben – normale oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8–36 pt, Zeilenumbruch am Feldrand. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren. In anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
|
- **Text-Werkzeug** (`T`): ins Blatt tippen und schreiben – normale oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8–36 pt, Zeilenumbruch am Feldrand. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren. In anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
|
||||||
- **Bilder** (PNG, JPEG, GIF, WebP, BMP) über `Strg + I`, aus der Zwischenablage oder per Ziehen ins Fenster einfügen.
|
- **Bilder** (PNG, JPEG, GIF, WebP, BMP) über `Strg + I`, aus der Zwischenablage oder per Ziehen ins Fenster einfügen.
|
||||||
|
- **Einfügen per Finger:** Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle.
|
||||||
- **PDF importieren** (☰-Menü): Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert.
|
- **PDF importieren** (☰-Menü): Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert.
|
||||||
|
|
||||||
### Seiten und Ansicht
|
### Seiten und Ansicht
|
||||||
- **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export.
|
- **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export.
|
||||||
- **Papierfarbe** der Anzeige: weiß, creme, dunkelgrau, schwarz; auf dunklem Papier werden dunkle Stiftfarben auf Wunsch aufgehellt. Gespeichert und exportiert wird immer auf weißem Papier.
|
- **Papierfarbe** der Anzeige: weiß, creme, dunkelgrau, schwarz; auf dunklem Papier werden dunkle Stiftfarben auf Wunsch aufgehellt. Gespeichert und exportiert wird immer auf weißem Papier.
|
||||||
- **Durchgehendes Scrollen:** Seiten hängen untereinander; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Abschaltbar (dann Seite für Seite blättern).
|
- **Feste Seiten, durchgehendes Scrollen oder endlose Fläche, je Notizbuch:** Beim Anlegen wählbar (Vorgabe: feste Seiten, Seite für Seite blättern mit Pfeilen in der Werkzeugleiste), später im ☰-Menü umschaltbar. Durchgehend hängen die Seiten lückenlos untereinander, eine dunkle Linie markiert die Grenze; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Endlos ist eine einzige Fläche ohne Rand, auf der man in alle Richtungen schreibt; der PDF-Export enthält nur den beschriebenen Bereich.
|
||||||
|
- **Seiten einfügen und löschen:** ➕ in der Werkzeugleiste fügt nach der aktuellen Seite eine leere Seite ein. Löschen im ☰-Menü (mit Rückfrage); die zuletzt gelöschte Seite lässt sich dort wiederherstellen, solange die App läuft.
|
||||||
- **Zoom** von 25 % bis 1000 % (`Strg + Mausrad`, zwei Finger, `Strg + +/-/0`), Verschieben mit mittlerer Maustaste, `Leertaste + Ziehen` oder einem Finger.
|
- **Zoom** von 25 % bis 1000 % (`Strg + Mausrad`, zwei Finger, `Strg + +/-/0`), Verschieben mit mittlerer Maustaste, `Leertaste + Ziehen` oder einem Finger.
|
||||||
- **Seiten** anhängen oder direkt nach der aktuellen Seite einfügen, **Seitentitel** vergeben; die Seitenübersicht zeigt Vorschaubilder oder ein Inhaltsverzeichnis. Seitentitel werden im PDF zu Lesezeichen.
|
- **Seiten** anhängen oder direkt nach der aktuellen Seite einfügen, **Seitentitel** vergeben; die Seitenübersicht zeigt Vorschaubilder oder ein Inhaltsverzeichnis. Seitentitel werden im PDF zu Lesezeichen.
|
||||||
- **Werkzeugleiste** links oder rechts andocken (für Links- und Rechtshänder).
|
- **Werkzeugleiste** links oder rechts andocken (für Links- und Rechtshänder).
|
||||||
|
|
||||||
### Notizbücher und Speichern
|
### Notizbücher und Speichern
|
||||||
- Mehrere **Notizbücher** anlegen, umbenennen, wechseln und löschen (gelöschte landen zunächst im Papierkorb und lassen sich wiederherstellen).
|
- Mehrere **Notizbücher** anlegen, umbenennen, wechseln und löschen (gelöschte landen zunächst im Papierkorb und lassen sich wiederherstellen).
|
||||||
|
- **Ordner** für Notizbücher, beliebig verschachtelt (z. B. 3. Semester › Mathe): anlegen, umbenennen, löschen (der Inhalt rückt eine Ebene nach oben) und Notizbücher wie Ordner (samt Inhalt) mit 📂 verschieben. Neue Notizbücher landen im gerade geöffneten Ordner.
|
||||||
- **Automatisches Speichern** im Hintergrund: 1,5 Sekunden nach der letzten Änderung, bei ununterbrochenem Schreiben spätestens alle 10 Sekunden. Zeichnen wird dabei nicht unterbrochen.
|
- **Automatisches Speichern** im Hintergrund: 1,5 Sekunden nach der letzten Änderung, bei ununterbrochenem Schreiben spätestens alle 10 Sekunden. Zeichnen wird dabei nicht unterbrochen.
|
||||||
- Jede Datei wird erst vollständig in eine temporäre Datei geschrieben und dann ersetzt – ein Absturz beim Speichern hinterlässt keine halbe Datei.
|
- Jede Datei wird erst vollständig in eine temporäre Datei geschrieben und dann ersetzt – ein Absturz beim Speichern hinterlässt keine halbe Datei.
|
||||||
- **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an.
|
- **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an.
|
||||||
@@ -59,6 +74,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
|
|||||||
|
|
||||||
### Updates
|
### 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).
|
- 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`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -152,6 +168,7 @@ src/
|
|||||||
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
|
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
|
||||||
├── history.rs # Rückgängig/Wiederholen
|
├── history.rs # Rückgängig/Wiederholen
|
||||||
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
|
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
|
||||||
|
├── library.rs # Ordner der Notizbücher (library.json)
|
||||||
├── autosave.rs # Speichern im Hintergrund
|
├── autosave.rs # Speichern im Hintergrund
|
||||||
├── export.rs # PDF-Export (printpdf)
|
├── export.rs # PDF-Export (printpdf)
|
||||||
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
|
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
|
||||||
@@ -174,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`.
|
Veröffentlicht wird über Gitea Actions ([`.gitea/workflows/release.yaml`](.gitea/workflows/release.yaml)), sobald ein Tag `vX.Y.Z` gepusht wird: Tests, Pakete bauen, signieren, Release anlegen. Manuell geht es mit `GITEA_TOKEN=… ./scripts/publish-gitea-release.sh`.
|
||||||
|
|
||||||
|
### Flatpak
|
||||||
|
|
||||||
|
Das Manifest liegt in [`packaging/flatpak/`](packaging/flatpak/) (Runtime `org.freedesktop.Platform` 26.08, Rust aus der SDK-Erweiterung `rust-stable`). Lokal bauen und installieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak-builder --user --install --force-clean target/flatpak-build packaging/flatpak/eu.pansi.ink-notes.yml
|
||||||
|
flatpak run eu.pansi.ink-notes
|
||||||
|
```
|
||||||
|
|
||||||
|
Nach jeder Änderung an `Cargo.lock` muss `packaging/flatpak/cargo-sources.json` mit dem [flatpak-cargo-generator](https://github.com/flatpak/flatpak-builder-tools/tree/master/cargo) neu erzeugt werden (`python3 flatpak-cargo-generator.py Cargo.lock -o packaging/flatpak/cargo-sources.json`). In der Sandbox darf ink-notes Bilder, Downloads und Schreibtisch lesen und in den Dokumente-Ordner exportieren; die Notizbücher liegen unter `~/.var/app/eu.pansi.ink-notes/`.
|
||||||
|
|
||||||
### Release-Signatur einrichten (einmalig)
|
### Release-Signatur einrichten (einmalig)
|
||||||
|
|
||||||
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):
|
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):
|
||||||
@@ -212,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
|
## Lizenz
|
||||||
|
|
||||||
[MIT](LICENSE), Copyright © 2026 Harald Pansi.
|
[MIT](LICENSE), Copyright © 2026 Harald Pansi.
|
||||||
|
|||||||
+26
@@ -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.
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 360" width="1200" height="360">
|
||||||
|
<defs>
|
||||||
|
<!-- Haupt-Hintergrund-Verlauf -->
|
||||||
|
<linearGradient id="bannerBg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#0f1420"/>
|
||||||
|
<stop offset="40%" stop-color="#141a29"/>
|
||||||
|
<stop offset="100%" stop-color="#090b12"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Subtiles Ambient Glow -->
|
||||||
|
<radialGradient id="ambientGlow" cx="15%" cy="50%" r="45%">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.22"/>
|
||||||
|
<stop offset="70%" stop-color="#1d4ed8" stop-opacity="0.05"/>
|
||||||
|
<stop offset="100%" stop-color="#1d4ed8" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<radialGradient id="ambientGlowRight" cx="85%" cy="30%" r="40%">
|
||||||
|
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.10"/>
|
||||||
|
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<!-- Icon Hintergrund-Verlauf -->
|
||||||
|
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#1e2638"/>
|
||||||
|
<stop offset="50%" stop-color="#141824"/>
|
||||||
|
<stop offset="100%" stop-color="#0c0e17"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Stiftkörper-Verlauf -->
|
||||||
|
<linearGradient id="penBody" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6"/>
|
||||||
|
<stop offset="100%" stop-color="#1d4ed8"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Goldene Feder-Spitze -->
|
||||||
|
<linearGradient id="nibGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#fbbf24"/>
|
||||||
|
<stop offset="50%" stop-color="#f59e0b"/>
|
||||||
|
<stop offset="100%" stop-color="#d97706"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Tintenspur-Verlauf -->
|
||||||
|
<linearGradient id="inkGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#60a5fa" stop-opacity="0.95"/>
|
||||||
|
<stop offset="50%" stop-color="#3b82f6" stop-opacity="0.85"/>
|
||||||
|
<stop offset="100%" stop-color="#2563eb" stop-opacity="0.4"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Text Ink Gradient -->
|
||||||
|
<linearGradient id="textInkGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#60a5fa"/>
|
||||||
|
<stop offset="100%" stop-color="#38bdf8"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Leucht-Effekt -->
|
||||||
|
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur stdDeviation="8" result="blur" />
|
||||||
|
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
<filter id="iconShadow" x="-10%" y="-10%" width="120%" height="130%">
|
||||||
|
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#000000" flood-opacity="0.6"/>
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
<!-- Gitter-Muster für Hintergrund -->
|
||||||
|
<pattern id="gridPattern" width="30" height="30" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M 30 0 L 0 0 0 30" fill="none" stroke="#94a3b8" stroke-width="0.8" opacity="0.07"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Banner-Rahmen & Hintergrund -->
|
||||||
|
<rect width="1200" height="360" rx="20" fill="url(#bannerBg)" stroke="#27324b" stroke-width="2"/>
|
||||||
|
|
||||||
|
<!-- Hintergrund-Gitter (Notizbuch-Raster) -->
|
||||||
|
<rect width="1200" height="360" rx="20" fill="url(#gridPattern)"/>
|
||||||
|
|
||||||
|
<!-- Subtile Glows -->
|
||||||
|
<rect width="1200" height="360" rx="20" fill="url(#ambientGlow)"/>
|
||||||
|
<rect width="1200" height="360" rx="20" fill="url(#ambientGlowRight)"/>
|
||||||
|
|
||||||
|
<!-- ==================== LINKER BEREICH: APP-ICON ==================== -->
|
||||||
|
<g transform="translate(65, 55) scale(0.488)" filter="url(#iconShadow)">
|
||||||
|
<!-- App-Icon Hintergrund (Squircle) -->
|
||||||
|
<rect x="24" y="24" width="464" height="464" rx="104" ry="104" fill="url(#bgGrad)" stroke="#384260" stroke-width="4"/>
|
||||||
|
|
||||||
|
<!-- DIN-A4 Dokumenten-Gitter im Icon -->
|
||||||
|
<g opacity="0.14" stroke="#94a3b8" stroke-width="2">
|
||||||
|
<line x1="80" y1="160" x2="432" y2="160" />
|
||||||
|
<line x1="80" y1="220" x2="432" y2="220" />
|
||||||
|
<line x1="80" y1="280" x2="432" y2="280" />
|
||||||
|
<line x1="80" y1="340" x2="432" y2="340" />
|
||||||
|
<line x1="80" y1="400" x2="432" y2="400" />
|
||||||
|
|
||||||
|
<line x1="140" y1="100" x2="140" y2="430" />
|
||||||
|
<line x1="200" y1="100" x2="200" y2="430" />
|
||||||
|
<line x1="260" y1="100" x2="260" y2="430" />
|
||||||
|
<line x1="320" y1="100" x2="320" y2="430" />
|
||||||
|
<line x1="380" y1="100" x2="380" y2="430" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Tintenspur -->
|
||||||
|
<path d="M 110 390 C 180 430, 260 380, 240 310 C 220 240, 280 190, 340 220"
|
||||||
|
fill="none" stroke="url(#inkGrad)" stroke-width="26" stroke-linecap="round" filter="url(#glow)"/>
|
||||||
|
<path d="M 110 390 C 180 430, 260 380, 240 310 C 220 240, 280 190, 340 220"
|
||||||
|
fill="none" stroke="#93c5fd" stroke-width="12" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Stylus / Füllfederhalter -->
|
||||||
|
<g transform="translate(240, 110) rotate(45)">
|
||||||
|
<rect x="-24" y="-140" width="48" height="150" rx="8" fill="url(#penBody)" stroke="#60a5fa" stroke-width="3"/>
|
||||||
|
<rect x="-22" y="10" width="44" height="26" rx="4" fill="#1e293b" stroke="#cbd5e1" stroke-width="2"/>
|
||||||
|
<rect x="-25" y="6" width="50" height="6" fill="#f8fafc"/>
|
||||||
|
<path d="M -20 36 L 20 36 L 12 85 L 0 115 L -12 85 Z" fill="url(#nibGrad)" stroke="#fef08a" stroke-width="2"/>
|
||||||
|
<line x1="0" y1="46" x2="0" y2="108" stroke="#78350f" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<circle cx="0" cy="65" r="4" fill="#78350f"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Glanzpunkt -->
|
||||||
|
<circle cx="110" cy="390" r="14" fill="#60a5fa" filter="url(#glow)"/>
|
||||||
|
<circle cx="110" cy="390" r="8" fill="#ffffff"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- ==================== RECHTER BEREICH: TYPOGRAFIE & BADGES ==================== -->
|
||||||
|
<g transform="translate(360, 0)">
|
||||||
|
<!-- Version & Platform Pill -->
|
||||||
|
<g transform="translate(0, 68)">
|
||||||
|
<rect width="254" height="30" rx="15" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
|
||||||
|
<circle cx="16" cy="15" r="4" fill="#22c55e"/>
|
||||||
|
<text x="28" y="20" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#cbd5e1" letter-spacing="0.5">
|
||||||
|
v0.14.0 • LINUX • RUST & WGPU
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Haupttitel: ink-notes -->
|
||||||
|
<text x="0" y="162" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="800" font-size="64" letter-spacing="-1">
|
||||||
|
<tspan fill="url(#textInkGrad)">ink</tspan><tspan fill="#f8fafc">-notes</tspan>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<!-- Subtitle / Tagline -->
|
||||||
|
<text x="0" y="206" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="400" font-size="22" fill="#94a3b8">
|
||||||
|
Notizbuch- & PDF-Annotations-App für Stift, Touch und Maus
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<!-- Feature Pills / Highlights -->
|
||||||
|
<g transform="translate(0, 248)">
|
||||||
|
<!-- Pill 1: Niedrige Latenz -->
|
||||||
|
<g transform="translate(0, 0)">
|
||||||
|
<rect width="186" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||||
|
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
||||||
|
⚡ Schnelles wgpu-Inking
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Pill 2: Stylus mit Druck -->
|
||||||
|
<g transform="translate(198, 0)">
|
||||||
|
<rect width="190" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||||
|
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
||||||
|
🖊️ Druck & Palm Rejection
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Pill 3: Formerkennung -->
|
||||||
|
<g transform="translate(400, 0)">
|
||||||
|
<rect width="182" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||||
|
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
||||||
|
📐 Formenerkennung & Lineal
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Pill 4: 100% lokal -->
|
||||||
|
<g transform="translate(594, 0)">
|
||||||
|
<rect width="176" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||||
|
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
||||||
|
🔒 100% lokal & atomar
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.2 KiB |
+17
-5
@@ -93,19 +93,28 @@ $$\vec{p}_{\text{neu}} = \vec{c} - (\vec{c} - \vec{p}_{\text{alt}}) \cdot \frac{
|
|||||||
- Im Shader wird jeder Punkt mit `transform_scale` und `transform_offset` (aus `CameraUniforms`) direkt in Gerätekoordinaten umgerechnet.
|
- Im Shader wird jeder Punkt mit `transform_scale` und `transform_offset` (aus `CameraUniforms`) direkt in Gerätekoordinaten umgerechnet.
|
||||||
|
|
||||||
### 3.3 Durchgehende Ansicht
|
### 3.3 Durchgehende Ansicht
|
||||||
Alle Koordinaten beziehen sich auf die **aktuelle Seite**. `PageStack` (`viewport.rs`) legt die übrigen Seiten mit 8 mm Abstand darunter bzw. darüber, waagerecht zur aktuellen Seite zentriert. Jede sichtbare Nachbarseite wird mit einer eigenen Kamera gezeichnet, die um die Lage der Seite verschoben ist (höchstens 12 Nachbarseiten).
|
Ob ein Notizbuch Seite für Seite, durchgehend oder als endlose Fläche angezeigt wird, steht in seinem Manifest (`layout`, siehe [`FILE_FORMAT.md`](FILE_FORMAT.md)).
|
||||||
|
|
||||||
|
Alle Koordinaten beziehen sich auf die **aktuelle Seite**. `PageStack` (`viewport.rs`) legt die übrigen Seiten lückenlos darunter bzw. darüber (die Grenze zeichnet die Oberfläche als dunkle Linie), waagerecht zur aktuellen Seite zentriert. Jede sichtbare Nachbarseite wird mit einer eigenen Kamera gezeichnet, die um die Lage der Seite verschoben ist (höchstens 12 Nachbarseiten).
|
||||||
|
|
||||||
Nach dem Scrollen wird die Seite in der Fenstermitte zur aktuellen Seite; die Kamera wird dabei so verschoben, dass sich das Bild nicht bewegt. Während eines Strichs, einer Auswahl, beim Ziehen des Lineals oder beim Schreiben von Text wird nicht gewechselt. Wer auf einer sichtbaren Nachbarseite zu zeichnen beginnt, wechselt zuerst auf diese Seite.
|
Nach dem Scrollen wird die Seite in der Fenstermitte zur aktuellen Seite; die Kamera wird dabei so verschoben, dass sich das Bild nicht bewegt. Während eines Strichs, einer Auswahl, beim Ziehen des Lineals oder beim Schreiben von Text wird nicht gewechselt. Wer auf einer sichtbaren Nachbarseite zu zeichnen beginnt, wechselt zuerst auf diese Seite.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 3.4 Endlose Fläche
|
||||||
|
Ein endloses Notizbuch hat eine einzige Seite von 10 × 10 m (`ENDLESS_PAGE_SIZE`). Damit bleibt alles wie bei jeder anderen Seite – Eingabe, Treffertests, Undo, Speichern –, nur der Rand ist praktisch unerreichbar. `PageViewport::calculate_endless` wählt Maßstab und Lage so, als läge eine DIN-A4-Seite am Startausschnitt (`ENDLESS_HOME`, ein A4-Rahmen in der Mitte); 100 % Zoom und „Ansicht zurücksetzen“ zeigen also diesen Rahmen. Bilder und Textfelder richten ihre Größe bzw. ihren Umbruch an DIN A4 aus (`Page::reference_size`, `Page::text_right_edge`).
|
||||||
|
|
||||||
|
Beim Wechsel von und zu Seiten (`Notebook::make_endless`/`make_paged`) wird der Inhalt um `ENDLESS_HOME` verschoben und der Verlauf der Seite geleert. Zurück zu Seiten geht nur, solange alles im Startausschnitt liegt. Export und Bilder einer Auswahl rastern bzw. exportieren nur einen Ausschnitt (`Page::cropped`), beim Export auf das Musterraster gelegt (`Page::endless_export_bounds`).
|
||||||
|
|
||||||
## 4. Rendering
|
## 4. Rendering
|
||||||
|
|
||||||
Ein Frame besteht aus einem Render-Durchgang auf die Fensterfläche (`canvas.rs`):
|
Ein Frame besteht aus einem Render-Durchgang auf die Fensterfläche (`canvas.rs`):
|
||||||
|
|
||||||
1. Für jede sichtbare Nachbarseite: Papier und Muster, Bilder/PDF-Seite/Text, Tinte.
|
1. Für jede sichtbare Nachbarseite: Papier und Muster, Bilder/PDF-Seite/Text, Tinte.
|
||||||
2. Für die aktuelle Seite: Papier, Muster (Karo, Linien, Punkte), Bildebene, gespeicherte Striche, Live-Strich.
|
2. Für die aktuelle Seite: Papier, Muster (Karo, Linien, Punkte), Bildebene, gespeicherte Striche, Live-Strich.
|
||||||
3. Die `egui`-Oberfläche: Werkzeugleiste, Menüs, Auswahlrahmen und Griffe, Lineal, Textbearbeitung.
|
3. Die `egui`-Oberfläche: Werkzeugleiste, Menüs, Seitengrenzen, Auswahlrahmen und Griffe, Lineal, Textbearbeitung.
|
||||||
|
|
||||||
|
Jeder Strich ist ein eigener Draw-Call mit eigener Nummer im Stencil-Puffer: Ein Pixel, das der Strich schon gefärbt hat, wird kein zweites Mal gefärbt. So bleibt halbtransparente Tinte (Textmarker) gleichmäßig, auch wo sich der Strich selbst überlappt (Anfangskappe, Kehren, langsames Hin- und Herfahren); erst ein neuer Strich deckt stärker. Nach 255 Strichen setzt ein bildschirmfüllendes Dreieck den Stencil-Puffer zurück.
|
||||||
|
|
||||||
### 4.1 Bildebene
|
### 4.1 Bildebene
|
||||||
`image_layer.rs` zeichnet texturierte Vierecke unter der Tinte: importierte PDF-Seiten, eingefügte Bilder und Textfelder. Texturen werden beim ersten Bedarf geladen, bekommen Mipmaps und werden freigegeben, wenn mehr als 384 MB belegt sind.
|
`image_layer.rs` zeichnet texturierte Vierecke unter der Tinte: importierte PDF-Seiten, eingefügte Bilder und Textfelder. Texturen werden beim ersten Bedarf geladen, bekommen Mipmaps und werden freigegeben, wenn mehr als 384 MB belegt sind.
|
||||||
@@ -113,7 +122,7 @@ Ein Frame besteht aus einem Render-Durchgang auf die Fensterfläche (`canvas.rs`
|
|||||||
- **Textfelder** werden mit `ab_glyph` gerastert, in einer Auflösung, die dem Zoom folgt (in Vierteln einer Zweierpotenz, höchstens 8 Pixel je Einheit). Der Texturschlüssel enthält einen Hash von Inhalt, Schrift und Farbe; jede Änderung erzeugt eine neue Textur.
|
- **Textfelder** werden mit `ab_glyph` gerastert, in einer Auflösung, die dem Zoom folgt (in Vierteln einer Zweierpotenz, höchstens 8 Pixel je Einheit). Der Texturschlüssel enthält einen Hash von Inhalt, Schrift und Farbe; jede Änderung erzeugt eine neue Textur.
|
||||||
|
|
||||||
### 4.2 Papier- und Musterfarben
|
### 4.2 Papier- und Musterfarben
|
||||||
Papierfarbe (`PaperTheme`) und Musterfarbe (`PatternColor`) stehen in `background.rs`. Alle Farben sind linear; die Fensterfläche ist sRGB, die Umrechnung übernimmt die GPU. Auf dunklem Papier kann der Shader dunkle Tinte aufhellen (`ink_params` in `CameraUniforms`), gespeichert werden immer die Originalfarben.
|
Papierfarbe (`PaperTheme`) und Musterfarbe (`PatternColor`) stehen in `background.rs`; sie sind linear angegeben und werden im Shader nach sRGB umgerechnet. Tintenfarben (Striche, Text) sind sRGB-Werte – dieselben Zahlen landen im PDF und in den Farbpunkten der Oberfläche. Gezeichnet wird ohne sRGB-Umrechnung der GPU (`render_format`), Texturen sind `Rgba8Unorm`: So wird halbtransparente Tinte wie im PDF in sRGB gemischt und sieht am Bildschirm genauso aus wie im Export. Auf dunklem Papier kann der Shader dunkle Tinte aufhellen (`ink_params` in `CameraUniforms`), gespeichert werden immer die Originalfarben.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -130,16 +139,19 @@ Alles auf einer Seite ist ein `Stroke` (`document.rs`):
|
|||||||
### 5.2 Rückgängig
|
### 5.2 Rückgängig
|
||||||
Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ersetzen und Ändern von Strichen. Verschieben/Drehen/Skalieren einer Auswahl, ein Radierzug mit dem Teilstrich-Radierer und eine Textbearbeitung sind jeweils ein Schritt.
|
Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ersetzen und Ändern von Strichen. Verschieben/Drehen/Skalieren einer Auswahl, ein Radierzug mit dem Teilstrich-Radierer und eine Textbearbeitung sind jeweils ein Schritt.
|
||||||
|
|
||||||
|
**Platz schaffen** (`Notebook::make_space`) betrifft mehrere Seiten: Die Seite, auf der angesetzt wurde, und die folgenden bis vor die nächste PDF-Seite gelten als durchgehende Spalte. Alles unterhalb der Ansatzhöhe rutscht um denselben Betrag; ein Objekt, das über ein Seitenende ragen würde, beginnt 10 mm unter dem Anfang der nächsten Seite, und alles danach rutscht entsprechend weiter. Fehlende Seiten werden eingefügt. Der Schritt (`SpaceShift`: jeder verschobene Strich vorher/nachher mit Seite) landet im Verlauf der Seite, auf der angesetzt wurde; `Notebook::undo`/`redo` buchen ihn über alle Seiten zurück (später geänderte Striche bleiben unberührt) und entfernen dafür angelegte, leer gebliebene Seiten wieder.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Formerkennung
|
## 6. Formerkennung
|
||||||
|
|
||||||
`geometry.rs` erkennt Formen rein geometrisch, ohne Machine Learning. Geschlossene Formen (Kreis, Ellipse, Dreieck, Rechteck, Raute) treten gegeneinander an; gewählt wird die, deren Umriss am besten zu den Punkten passt. Danach folgen Vielecke, Pfeile, Koordinatensysteme und Linien. Übernommen wird eine Form nur, wenn der Strich im Mittel höchstens 4 % seiner Diagonale von ihr abweicht – sonst bleibt er Handschrift.
|
`geometry.rs` erkennt Formen rein geometrisch, ohne Machine Learning. Zuerst wird auf ein Achsenkreuz geprüft (alle Teilstücke liegen auf einer waagerechten und einer senkrechten Linie, beide reichen auf beiden Seiten über den Schnittpunkt hinaus). Geschlossene Formen (Kreis, Ellipse, Dreieck, Rechteck, Raute) treten gegeneinander an; gewählt wird die, deren Umriss am besten zu den Punkten passt. Danach folgen Vielecke, Pfeile, Koordinatensysteme und Linien. Übernommen wird eine Form nur, wenn der Strich im Mittel höchstens 4 % seiner Diagonale von ihr abweicht – sonst bleibt er Handschrift.
|
||||||
|
|
||||||
- **Linie:** Verhältnis von Abstand Anfang–Ende zur Strichlänge über 0,92.
|
- **Linie:** Verhältnis von Abstand Anfang–Ende zur Strichlänge über 0,92.
|
||||||
- **Kreis, Ellipse:** Kreis- bzw. Ellipsen-Fit über die Punkte.
|
- **Kreis, Ellipse:** Kreis- bzw. Ellipsen-Fit über die Punkte.
|
||||||
- **Rechteck, Dreieck, Raute, Vieleck, Pfeil:** Analyse der mit Douglas-Peucker vereinfachten Kontur (Eckenzahl, Winkel, Seitenverhältnisse).
|
- **Rechteck, Dreieck, Raute, Vieleck, Pfeil:** Analyse der mit Douglas-Peucker vereinfachten Kontur (Eckenzahl, Winkel, Seitenverhältnisse).
|
||||||
- **Koordinatensystem:** zwei etwa rechtwinklige Achsen; der Winkel wird auf 90° gesetzt.
|
- **Koordinatensystem:** zwei etwa rechtwinklige Achsen; der Winkel wird auf 90° gesetzt.
|
||||||
|
- **Achsenkreuz:** ein „+“ in einem Zug; die Achsen werden waagerecht und senkrecht ausgerichtet und bekommen an allen vier Enden Pfeilspitzen.
|
||||||
- **Oktaeder:** Skizze einer Doppelpyramide als 3D-Kantenmodell; nur, wenn kein einfacher Umriss passt.
|
- **Oktaeder:** Skizze einer Doppelpyramide als 3D-Kantenmodell; nur, wenn kein einfacher Umriss passt.
|
||||||
|
|
||||||
**Halten am Strichende:** Bleibt der Stift über 400 ms innerhalb von 3 Pixeln ruhig, wird die erkannte Form schon vor dem Absetzen angezeigt und beim Absetzen übernommen. Mit dem Formen-Werkzeug wird jeder Strich beim Absetzen geprüft.
|
**Halten am Strichende:** Bleibt der Stift über 400 ms innerhalb von 3 Pixeln ruhig, wird die erkannte Form schon vor dem Absetzen angezeigt und beim Absetzen übernommen. Mit dem Formen-Werkzeug wird jeder Strich beim Absetzen geprüft.
|
||||||
@@ -177,7 +189,7 @@ Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ers
|
|||||||
2. **PDF** (nur im Notfall): importierte PDF-Seite als Bild, falls sie sich nicht als Vektor übernehmen lässt.
|
2. **PDF** (nur im Notfall): importierte PDF-Seite als Bild, falls sie sich nicht als Vektor übernehmen lässt.
|
||||||
3. **Images:** eingefügte Bilder (höchstens 300 dpi).
|
3. **Images:** eingefügte Bilder (höchstens 300 dpi).
|
||||||
4. **Text:** Textfelder als echter Text mit eingebetteter Schrift (durchsuchbar, kopierbar). Jedes Feld liegt in einem eigenen Koordinatensystem, das eine Transformationsmatrix auf seine Ecken abbildet.
|
4. **Text:** Textfelder als echter Text mit eingebetteter Schrift (durchsuchbar, kopierbar). Jedes Feld liegt in einem eigenen Koordinatensystem, das eine Transformationsmatrix auf seine Ecken abbildet.
|
||||||
5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade.
|
5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade. Halbtransparente Striche (Textmarker) bekommen einen Grafikzustand mit Deckkraft (`ca`/`CA`); printpdf kann solche Zustände nicht anlegen, deshalb trägt `add_alpha_states` sie nach dem Speichern mit `lopdf` in die Seitenressourcen ein. Jeder Strich ist ein einziger Pfad und deckt sich daher nicht selbst doppelt.
|
||||||
|
|
||||||
Importierte PDF-Seiten werden anschließend als Form-XObject unter die Seite gelegt (`pdf_merge.rs`), bleiben also Vektorgrafik. Seitentitel werden zu Lesezeichen.
|
Importierte PDF-Seiten werden anschließend als Form-XObject unter die Seite gelegt (`pdf_merge.rs`), bleiben also Vektorgrafik. Seitentitel werden zu Lesezeichen.
|
||||||
|
|
||||||
|
|||||||
+29
-5
@@ -1,6 +1,6 @@
|
|||||||
# Dateiformat von ink-notes
|
# Dateiformat von ink-notes
|
||||||
|
|
||||||
Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die Versionen des Dateiformats. Aktuell ist **Version 8**.
|
Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die Versionen des Dateiformats. Aktuell ist **Version 9**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die
|
|||||||
```text
|
```text
|
||||||
~/.local/share/ink-notes/ ($XDG_DATA_HOME/ink-notes)
|
~/.local/share/ink-notes/ ($XDG_DATA_HOME/ink-notes)
|
||||||
├── notebooks/
|
├── notebooks/
|
||||||
|
│ ├── library.json Ordner der Notizbücher (siehe unten)
|
||||||
│ ├── <notizbuch-id>/
|
│ ├── <notizbuch-id>/
|
||||||
│ │ ├── manifest.json Metadaten und Seitenreihenfolge
|
│ │ ├── manifest.json Metadaten und Seitenreihenfolge
|
||||||
│ │ ├── manifest.json.bak Kopie des vorherigen Manifests
|
│ │ ├── manifest.json.bak Kopie des vorherigen Manifests
|
||||||
@@ -29,13 +30,28 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die
|
|||||||
- **Assets** sind nach dem SHA-256-Hash ihres Inhalts benannt; dasselbe Bild wird nur einmal gespeichert. Bilder werden als PNG oder (unverändert) JPEG abgelegt, PDFs unverändert.
|
- **Assets** sind nach dem SHA-256-Hash ihres Inhalts benannt; dasselbe Bild wird nur einmal gespeichert. Bilder werden als PNG oder (unverändert) JPEG abgelegt, PDFs unverändert.
|
||||||
- Alle Dateien werden mit Rechten `0600`, Ordner mit `0700` angelegt.
|
- Alle Dateien werden mit Rechten `0600`, Ordner mit `0700` angelegt.
|
||||||
|
|
||||||
|
### Ordner (`library.json`)
|
||||||
|
Ordner wie „3. Semester › Mathe“ sind rein logisch: Die Notizbücher bleiben in ihren Verzeichnissen, `library.json` hält nur fest, welcher Ordner welches Notizbuch enthält. Umbenennen, Verschieben (samt Inhalt) und Löschen von Ordnern ändern deshalb nie eine Notizbuch-Datei.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"folders": ["3. Semester", "3. Semester/Mathe", "4. Semester"],
|
||||||
|
"notebooks": { "analysis_1775460000": "3. Semester/Mathe" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Ordner sind Pfade mit `/` als Trenner; Namen enthalten selbst kein `/` und höchstens 80 Zeichen.
|
||||||
|
- Notizbücher ohne Eintrag liegen auf der obersten Ebene.
|
||||||
|
- Wird ein Ordner gelöscht, rücken seine Notizbücher und Unterordner eine Ebene nach oben.
|
||||||
|
- Fehlt die Datei oder ist sie unlesbar, liegen alle Notizbücher auf der obersten Ebene; fehlende übergeordnete Ordner werden beim Laden ergänzt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Manifest (`manifest.json`)
|
## 2. Manifest (`manifest.json`)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"format_version": 8,
|
"format_version": 9,
|
||||||
"id": "vorlesung_1775460000",
|
"id": "vorlesung_1775460000",
|
||||||
"title": "Vorlesung",
|
"title": "Vorlesung",
|
||||||
"created_at": 1775460000,
|
"created_at": 1775460000,
|
||||||
@@ -47,7 +63,8 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die
|
|||||||
],
|
],
|
||||||
"page_ids": [1, 2],
|
"page_ids": [1, 2],
|
||||||
"next_stroke_id": 42,
|
"next_stroke_id": 42,
|
||||||
"next_page_id": 3
|
"next_page_id": 3,
|
||||||
|
"layout": "Paged"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -60,6 +77,9 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die
|
|||||||
| `pages` | Seiten in ihrer Reihenfolge: `id`, `background`, optional `title` und `size` (siehe unten) – damit Seitenübersicht und Ansicht sie kennen, ohne jede Seite zu laden |
|
| `pages` | Seiten in ihrer Reihenfolge: `id`, `background`, optional `title` und `size` (siehe unten) – damit Seitenübersicht und Ansicht sie kennen, ohne jede Seite zu laden |
|
||||||
| `page_ids` | dieselbe Reihenfolge als reine ID-Liste (für ältere Versionen) |
|
| `page_ids` | dieselbe Reihenfolge als reine ID-Liste (für ältere Versionen) |
|
||||||
| `next_stroke_id`, `next_page_id` | nächste freie IDs |
|
| `next_stroke_id`, `next_page_id` | nächste freie IDs |
|
||||||
|
| `layout` | Anordnung der Seiten: `Paged` (Seite für Seite), `Continuous` (durchgehend untereinander) oder `Endless` (endlose Fläche, siehe unten). Seit 0.28; fehlt das Feld, gilt die frühere globale Einstellung `continuous_scroll` aus `ui.json` |
|
||||||
|
|
||||||
|
**Endlose Notizbücher** (Version 9) haben genau eine Seite der Größe `[100000.0, 100000.0]` (10 × 10 m). Der Startausschnitt ist ein DIN-A4-Rahmen in ihrer Mitte (ab `[48950, 48515]`); Koordinaten bleiben positiv. Beim Export wird nur der beschriebene Bereich mit 10 mm Rand zur PDF-Seite.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -83,7 +103,7 @@ Jede Seite liegt in einer eigenen Datei (kompaktes JSON, hier zur Lesbarkeit umb
|
|||||||
| :--- | :--- |
|
| :--- | :--- |
|
||||||
| `background` | Muster: `Blank`, `Grid5mm`, `Lined`, `DotGrid` |
|
| `background` | Muster: `Blank`, `Grid5mm`, `Lined`, `DotGrid` |
|
||||||
| `title` | Seitentitel (optional) |
|
| `title` | Seitentitel (optional) |
|
||||||
| `size` | Seitengröße `[breite, höhe]` in Seiteneinheiten (10 = 1 mm); fehlt bei DIN A4 hoch (`[2100, 2970]`) |
|
| `size` | Seitengröße `[breite, höhe]` in Seiteneinheiten (10 = 1 mm); fehlt bei DIN A4 hoch (`[2100, 2970]`). Höchstens 2 × 2 m, außer der endlosen Seite `[100000, 100000]` |
|
||||||
| `pdf_background` | importierte PDF-Seite als Hintergrund (optional): Datei, Seitennummer ab 0, Größe in Punkt |
|
| `pdf_background` | importierte PDF-Seite als Hintergrund (optional): Datei, Seitennummer ab 0, Größe in Punkt |
|
||||||
| `strokes` | alle Objekte der Seite, von unten nach oben |
|
| `strokes` | alle Objekte der Seite, von unten nach oben |
|
||||||
|
|
||||||
@@ -109,7 +129,7 @@ Jedes Objekt hat eine `id`, eine `color` (RGBA, linear, Werte 0–1) und `sample
|
|||||||
"shape": { "Rectangle": { "corners": [[500, 600], [800, 600], [800, 800], [500, 800]] } }
|
"shape": { "Rectangle": { "corners": [[500, 600], [800, 600], [800, 800], [500, 800]] } }
|
||||||
```
|
```
|
||||||
|
|
||||||
Mögliche Formen: `Line { start, end }`, `Arrow { start, end, double }`, `Circle { center, radius }`, `Ellipse { center, radius_x, radius_y, rotation }`, `Rectangle { corners }`, `Triangle { corners }`, `Rhombus { corners }`, `Polygon { vertices }`, `CoordinateSystem { origin, x_axis_end, y_axis_end, with_arrows }`, `Octahedron { … }`. Punkte sind `[x, y]`.
|
Mögliche Formen: `Line { start, end }`, `Arrow { start, end, double }`, `Circle { center, radius }`, `Ellipse { center, radius_x, radius_y, rotation }`, `Rectangle { corners }`, `Triangle { corners }`, `Rhombus { corners }`, `Polygon { vertices }`, `CoordinateSystem { origin, x_axis_end, y_axis_end, with_arrows, negative_ends? }`, `Octahedron { … }`. Punkte sind `[x, y]`. Beim Achsenkreuz enthält `negative_ends` die Enden der x- und der y-Achse auf der anderen Seite des Ursprungs; ältere Versionen ignorieren das Feld und zeigen ein L-förmiges Koordinatensystem.
|
||||||
|
|
||||||
**Bilder** haben `image` (Dateiname in `assets/`); die vier `samples` sind die Ecken oben links, oben rechts, unten rechts, unten links:
|
**Bilder** haben `image` (Dateiname in `assets/`); die vier `samples` sind die Ecken oben links, oben rechts, unten rechts, unten links:
|
||||||
|
|
||||||
@@ -142,6 +162,9 @@ Ein Absturz während des Speicherns hinterlässt deshalb entweder die alte oder
|
|||||||
- Lässt sich eine Seite nicht lesen, wird sie nach `page_<id>.json.corrupt-<zeitstempel>` verschoben (nicht gelöscht) und die Seite schreibgeschützt angezeigt.
|
- Lässt sich eine Seite nicht lesen, wird sie nach `page_<id>.json.corrupt-<zeitstempel>` verschoben (nicht gelöscht) und die Seite schreibgeschützt angezeigt.
|
||||||
- Ist `manifest.json` beschädigt oder fehlt, wird `manifest.json.bak` verwendet.
|
- Ist `manifest.json` beschädigt oder fehlt, wird `manifest.json.bak` verwendet.
|
||||||
|
|
||||||
|
### Gelöschte Seiten
|
||||||
|
Eine gelöschte Seite verschwindet aus `manifest.json`; ihre Datei `page_<id>.json` bleibt im Ordner liegen. Seitenkennungen werden nie wiederverwendet (`next_page_id`), die Datei kann also mit keiner neuen Seite kollidieren. Wird die Seite in derselben Sitzung wiederhergestellt, schreibt die App sie neu.
|
||||||
|
|
||||||
### Neuere Versionen
|
### Neuere Versionen
|
||||||
Dateien mit einer höheren `format_version` als der unterstützten werden nicht geöffnet – so kann eine ältere App-Version keine Inhalte verlieren, die sie nicht kennt.
|
Dateien mit einer höheren `format_version` als der unterstützten werden nicht geöffnet – so kann eine ältere App-Version keine Inhalte verlieren, die sie nicht kennt.
|
||||||
|
|
||||||
@@ -163,6 +186,7 @@ Beim ersten Speichern einer unversionierten Datei (Version 0) wird vorher eine K
|
|||||||
| 6 | 0.7 | Seitentitel (`title` in Seite und Manifest) |
|
| 6 | 0.7 | Seitentitel (`title` in Seite und Manifest) |
|
||||||
| 7 | 0.10 | Textfelder (`text`) |
|
| 7 | 0.10 | Textfelder (`text`) |
|
||||||
| 8 | 0.11 | Seitengröße je Seite (`size` in Seite und Manifest) |
|
| 8 | 0.11 | Seitengröße je Seite (`size` in Seite und Manifest) |
|
||||||
|
| 9 | 0.29 | Seitenanordnung je Notizbuch (`layout` im Manifest), endlose Notizbücher |
|
||||||
|
|
||||||
Jede neue Version ändert nur Ergänzungen; ältere Dateien werden ohne Umwandlung gelesen und beim nächsten Speichern mit der aktuellen Versionsnummer geschrieben.
|
Jede neue Version ändert nur Ergänzungen; ältere Dateien werden ohne Umwandlung gelesen und beim nächsten Speichern mit der aktuellen Versionsnummer geschrieben.
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Version=1.0
|
Version=1.0
|
||||||
Name=ink-notes
|
Name=ink-notes
|
||||||
GenericName=Notizbuch- & Inking-App
|
GenericName=Note-Taking & Inking App
|
||||||
Comment=Handschriftliche Notizen mit Stift, Touch und Maus; PDFs beschriften
|
GenericName[de]=Notizbuch- & Inking-App
|
||||||
|
Comment=Handwritten notes with pen, touch and mouse; annotate PDFs
|
||||||
|
Comment[de]=Handschriftliche Notizen mit Stift, Touch und Maus; PDFs beschriften
|
||||||
Exec=ink-notes %F
|
Exec=ink-notes %F
|
||||||
Icon=eu.pansi.ink-notes
|
Icon=eu.pansi.ink-notes
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Type=Application
|
Type=Application
|
||||||
Categories=Office;Graphics;Utility;
|
Categories=Office;Graphics;
|
||||||
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;Text;Annotation;
|
Keywords=Note;Pen;Ink;Inking;Stylus;Drawing;PDF;Vector;Text;Annotation;
|
||||||
StartupWMClass=ink-notes
|
Keywords[de]=Notiz;Stift;Ink;Inking;Stylus;Zeichnen;PDF;Vektor;Text;Annotation;
|
||||||
|
StartupWMClass=eu.pansi.ink-notes
|
||||||
MimeType=application/x-inknotes;
|
MimeType=application/x-inknotes;
|
||||||
Actions=NewWindow;
|
Actions=NewWindow;
|
||||||
|
|
||||||
[Desktop Action NewWindow]
|
[Desktop Action NewWindow]
|
||||||
Name=Neues Fenster
|
Name=New Window
|
||||||
|
Name[de]=Neues Fenster
|
||||||
Exec=ink-notes
|
Exec=ink-notes
|
||||||
|
|||||||
@@ -4,22 +4,34 @@
|
|||||||
<metadata_license>CC0-1.0</metadata_license>
|
<metadata_license>CC0-1.0</metadata_license>
|
||||||
<project_license>MIT</project_license>
|
<project_license>MIT</project_license>
|
||||||
<name>ink-notes</name>
|
<name>ink-notes</name>
|
||||||
<summary>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>
|
<description>
|
||||||
<p>
|
<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.
|
Vektoren gespeichert; geschrieben wird mit Stift, Finger, Touchpad oder Maus.
|
||||||
</p>
|
</p>
|
||||||
<p>Funktionen:</p>
|
<p>Features:</p>
|
||||||
|
<p xml:lang="de">Funktionen:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Stift und Textmarker mit Druckstärke, Glättung beim Schreiben und Ecken-Erhaltung</li>
|
<li>Pens and highlighter with pressure, smoothing while writing and sharp corners</li>
|
||||||
<li>Radierer für ganze Striche, Teilstriche oder nur Textmarker; virtuelles Lineal</li>
|
<li xml:lang="de">Stifte und Textmarker mit Druckstärke, Glättung beim Schreiben und Ecken-Erhaltung</li>
|
||||||
<li>Formerkennung (Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Koordinatensysteme)</li>
|
<li>Shape recognition: hold at the end of a stroke to get clean lines, arrows, circles, rectangles, triangles and coordinate systems</li>
|
||||||
<li>Auswählen, Verschieben, Drehen, Skalieren und Umfärben; Rückgängig für alle Änderungen</li>
|
<li xml:lang="de">Formerkennung: am Strichende halten für saubere Linien, Pfeile, Kreise, Rechtecke, Dreiecke und Koordinatensysteme</li>
|
||||||
<li>Textfelder, Bilder und PDF-Import zum Beschriften (Querformat-Folien bekommen Querformat-Seiten)</li>
|
<li>Eraser for whole strokes, partial strokes or highlighter only; virtual ruler</li>
|
||||||
<li>Durchgehendes Scrollen, Zoom von 25 bis 1000 Prozent, Papier- und Musterfarben</li>
|
<li xml:lang="de">Radierer für ganze Striche, Teilstriche oder nur Textmarker; virtuelles Lineal</li>
|
||||||
<li>Mehrere Notizbücher, automatisches Speichern im Hintergrund</li>
|
<li>Select, move, rotate, scale and recolor; undo for every change</li>
|
||||||
<li>Export als PDF (mit durchsuchbarem Text), PNG oder SVG</li>
|
<li xml:lang="de">Auswählen, Verschieben, Drehen, Skalieren und Umfärben; Rückgängig für alle Änderungen</li>
|
||||||
|
<li>Text boxes, images and PDF import to annotate lecture slides and worksheets</li>
|
||||||
|
<li xml:lang="de">Textfelder, Bilder und PDF-Import zum Beschriften von Vorlesungsfolien und Übungsblättern</li>
|
||||||
|
<li>Notebooks in folders, continuous scrolling, automatic saving in the background</li>
|
||||||
|
<li xml:lang="de">Notizbücher in Ordnern, durchgehendes Scrollen, automatisches Speichern im Hintergrund</li>
|
||||||
|
<li>Export as PDF (with searchable text), PNG or SVG</li>
|
||||||
|
<li xml:lang="de">Export als PDF (mit durchsuchbarem Text), PNG oder SVG</li>
|
||||||
</ul>
|
</ul>
|
||||||
</description>
|
</description>
|
||||||
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
|
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
|
||||||
@@ -29,12 +41,98 @@
|
|||||||
<categories>
|
<categories>
|
||||||
<category>Office</category>
|
<category>Office</category>
|
||||||
<category>Graphics</category>
|
<category>Graphics</category>
|
||||||
<category>Utility</category>
|
|
||||||
</categories>
|
</categories>
|
||||||
|
<keywords>
|
||||||
|
<keyword>notes</keyword>
|
||||||
|
<keyword>handwriting</keyword>
|
||||||
|
<keyword>stylus</keyword>
|
||||||
|
<keyword>pen</keyword>
|
||||||
|
<keyword>PDF</keyword>
|
||||||
|
<keyword>annotate</keyword>
|
||||||
|
</keywords>
|
||||||
|
<supports>
|
||||||
|
<control>pointing</control>
|
||||||
|
<control>touch</control>
|
||||||
|
<control>tablet</control>
|
||||||
|
</supports>
|
||||||
<developer id="eu.pansi">
|
<developer id="eu.pansi">
|
||||||
<name>Harald</name>
|
<name>Harald Pansi</name>
|
||||||
</developer>
|
</developer>
|
||||||
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
|
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||||
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
|
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
|
||||||
|
<url type="vcs-browser">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||||
|
<screenshots>
|
||||||
|
<screenshot type="default">
|
||||||
|
<caption>Notes with highlighter, handwriting and text boxes</caption>
|
||||||
|
<caption xml:lang="de">Notizen mit Textmarker, Handschrift und Textfeldern</caption>
|
||||||
|
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/notes.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Recognized shapes: coordinate system, triangle, circle, octahedron</caption>
|
||||||
|
<caption xml:lang="de">Erkannte Formen: Koordinatensystem, Dreieck, Kreis, Oktaeder</caption>
|
||||||
|
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/shapes.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Annotating imported PDF slides</caption>
|
||||||
|
<caption xml:lang="de">Importierte PDF-Folien beschriften</caption>
|
||||||
|
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/pdf.png</image>
|
||||||
|
</screenshot>
|
||||||
|
</screenshots>
|
||||||
|
<branding>
|
||||||
|
<color type="primary" scheme_preference="light">#a8c8f0</color>
|
||||||
|
<color type="primary" scheme_preference="dark">#1f3a5f</color>
|
||||||
|
</branding>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
|
<releases>
|
||||||
|
<release version="0.29.0" date="2026-10-18">
|
||||||
|
<description>
|
||||||
|
<p>Endless notebooks: one surface without edges to write, pan and zoom in every direction. Choose it when creating a notebook or switch a single-page notebook in the menu; PDF export contains only the written area.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.28.0" date="2026-10-18">
|
||||||
|
<description>
|
||||||
|
<p>Each notebook remembers whether it flips page by page or scrolls continuously. New notebooks start with fixed pages; the layout can be chosen when creating a notebook and changed later in the menu.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.27.1" date="2026-10-18">
|
||||||
|
<description>
|
||||||
|
<p>The pens in the toolbar are shown as pencils in their own colour, so they are easy to tell apart.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.27.0" date="2026-10-18">
|
||||||
|
<description>
|
||||||
|
<p>Pictures keep their proportions when resized. A lock button in the selection tool switches between proportional and free resizing; pictures start locked, other objects free.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.26.2" date="2026-10-13">
|
||||||
|
<description>
|
||||||
|
<p>Smoother scrolling through large imported PDFs: pages no longer stall the display while they are prepared, and pages scrolled past quickly are skipped.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.26.1" date="2026-10-10">
|
||||||
|
<description>
|
||||||
|
<p>Screenshots in the app description for software centers. The app itself is unchanged.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.26.0" date="2026-10-10">
|
||||||
|
<description>
|
||||||
|
<p>Select and move objects with a finger; text and make-space tools also work with a finger. Exports go to the documents folder of the user directories.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.25.0" date="2026-10-09">
|
||||||
|
<description>
|
||||||
|
<p>English user interface; the language follows the system and can be changed in the menu. Holding to snap a shape tolerates a slightly trembling pen.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.24.0" date="2026-10-09">
|
||||||
|
<description>
|
||||||
|
<p>Axis cross from two crossing strokes.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.23.0" date="2026-10-09">
|
||||||
|
<description>
|
||||||
|
<p>The on-screen keyboard of GNOME appears when tapping a text field.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
</releases>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Version=1.0
|
Version=1.0
|
||||||
Name=ink-notes
|
Name=ink-notes
|
||||||
GenericName=Notizbuch- & Inking-App
|
GenericName=Note-Taking & Inking App
|
||||||
Comment=Handschriftliche Notizen mit Stift, Touch und Maus; PDFs beschriften
|
GenericName[de]=Notizbuch- & Inking-App
|
||||||
|
Comment=Handwritten notes with pen, touch and mouse; annotate PDFs
|
||||||
|
Comment[de]=Handschriftliche Notizen mit Stift, Touch und Maus; PDFs beschriften
|
||||||
Exec=ink-notes %F
|
Exec=ink-notes %F
|
||||||
Icon=eu.pansi.ink-notes
|
Icon=eu.pansi.ink-notes
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Type=Application
|
Type=Application
|
||||||
Categories=Office;Graphics;Utility;
|
Categories=Office;Graphics;
|
||||||
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;Text;Annotation;
|
Keywords=Note;Pen;Ink;Inking;Stylus;Drawing;PDF;Vector;Text;Annotation;
|
||||||
StartupWMClass=ink-notes
|
Keywords[de]=Notiz;Stift;Ink;Inking;Stylus;Zeichnen;PDF;Vektor;Text;Annotation;
|
||||||
|
StartupWMClass=eu.pansi.ink-notes
|
||||||
MimeType=application/x-inknotes;
|
MimeType=application/x-inknotes;
|
||||||
Actions=NewWindow;
|
Actions=NewWindow;
|
||||||
|
|
||||||
[Desktop Action NewWindow]
|
[Desktop Action NewWindow]
|
||||||
Name=Neues Fenster
|
Name=New Window
|
||||||
|
Name[de]=Neues Fenster
|
||||||
Exec=ink-notes
|
Exec=ink-notes
|
||||||
|
|||||||
@@ -4,22 +4,34 @@
|
|||||||
<metadata_license>CC0-1.0</metadata_license>
|
<metadata_license>CC0-1.0</metadata_license>
|
||||||
<project_license>MIT</project_license>
|
<project_license>MIT</project_license>
|
||||||
<name>ink-notes</name>
|
<name>ink-notes</name>
|
||||||
<summary>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>
|
<description>
|
||||||
<p>
|
<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.
|
Vektoren gespeichert; geschrieben wird mit Stift, Finger, Touchpad oder Maus.
|
||||||
</p>
|
</p>
|
||||||
<p>Funktionen:</p>
|
<p>Features:</p>
|
||||||
|
<p xml:lang="de">Funktionen:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Stift und Textmarker mit Druckstärke, Glättung beim Schreiben und Ecken-Erhaltung</li>
|
<li>Pens and highlighter with pressure, smoothing while writing and sharp corners</li>
|
||||||
<li>Radierer für ganze Striche, Teilstriche oder nur Textmarker; virtuelles Lineal</li>
|
<li xml:lang="de">Stifte und Textmarker mit Druckstärke, Glättung beim Schreiben und Ecken-Erhaltung</li>
|
||||||
<li>Formerkennung (Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Koordinatensysteme)</li>
|
<li>Shape recognition: hold at the end of a stroke to get clean lines, arrows, circles, rectangles, triangles and coordinate systems</li>
|
||||||
<li>Auswählen, Verschieben, Drehen, Skalieren und Umfärben; Rückgängig für alle Änderungen</li>
|
<li xml:lang="de">Formerkennung: am Strichende halten für saubere Linien, Pfeile, Kreise, Rechtecke, Dreiecke und Koordinatensysteme</li>
|
||||||
<li>Textfelder, Bilder und PDF-Import zum Beschriften (Querformat-Folien bekommen Querformat-Seiten)</li>
|
<li>Eraser for whole strokes, partial strokes or highlighter only; virtual ruler</li>
|
||||||
<li>Durchgehendes Scrollen, Zoom von 25 bis 1000 Prozent, Papier- und Musterfarben</li>
|
<li xml:lang="de">Radierer für ganze Striche, Teilstriche oder nur Textmarker; virtuelles Lineal</li>
|
||||||
<li>Mehrere Notizbücher, automatisches Speichern im Hintergrund</li>
|
<li>Select, move, rotate, scale and recolor; undo for every change</li>
|
||||||
<li>Export als PDF (mit durchsuchbarem Text), PNG oder SVG</li>
|
<li xml:lang="de">Auswählen, Verschieben, Drehen, Skalieren und Umfärben; Rückgängig für alle Änderungen</li>
|
||||||
|
<li>Text boxes, images and PDF import to annotate lecture slides and worksheets</li>
|
||||||
|
<li xml:lang="de">Textfelder, Bilder und PDF-Import zum Beschriften von Vorlesungsfolien und Übungsblättern</li>
|
||||||
|
<li>Notebooks in folders, continuous scrolling, automatic saving in the background</li>
|
||||||
|
<li xml:lang="de">Notizbücher in Ordnern, durchgehendes Scrollen, automatisches Speichern im Hintergrund</li>
|
||||||
|
<li>Export as PDF (with searchable text), PNG or SVG</li>
|
||||||
|
<li xml:lang="de">Export als PDF (mit durchsuchbarem Text), PNG oder SVG</li>
|
||||||
</ul>
|
</ul>
|
||||||
</description>
|
</description>
|
||||||
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
|
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
|
||||||
@@ -29,12 +41,98 @@
|
|||||||
<categories>
|
<categories>
|
||||||
<category>Office</category>
|
<category>Office</category>
|
||||||
<category>Graphics</category>
|
<category>Graphics</category>
|
||||||
<category>Utility</category>
|
|
||||||
</categories>
|
</categories>
|
||||||
|
<keywords>
|
||||||
|
<keyword>notes</keyword>
|
||||||
|
<keyword>handwriting</keyword>
|
||||||
|
<keyword>stylus</keyword>
|
||||||
|
<keyword>pen</keyword>
|
||||||
|
<keyword>PDF</keyword>
|
||||||
|
<keyword>annotate</keyword>
|
||||||
|
</keywords>
|
||||||
|
<supports>
|
||||||
|
<control>pointing</control>
|
||||||
|
<control>touch</control>
|
||||||
|
<control>tablet</control>
|
||||||
|
</supports>
|
||||||
<developer id="eu.pansi">
|
<developer id="eu.pansi">
|
||||||
<name>Harald</name>
|
<name>Harald Pansi</name>
|
||||||
</developer>
|
</developer>
|
||||||
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
|
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||||
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
|
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
|
||||||
|
<url type="vcs-browser">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||||
|
<screenshots>
|
||||||
|
<screenshot type="default">
|
||||||
|
<caption>Notes with highlighter, handwriting and text boxes</caption>
|
||||||
|
<caption xml:lang="de">Notizen mit Textmarker, Handschrift und Textfeldern</caption>
|
||||||
|
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/notes.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Recognized shapes: coordinate system, triangle, circle, octahedron</caption>
|
||||||
|
<caption xml:lang="de">Erkannte Formen: Koordinatensystem, Dreieck, Kreis, Oktaeder</caption>
|
||||||
|
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/shapes.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Annotating imported PDF slides</caption>
|
||||||
|
<caption xml:lang="de">Importierte PDF-Folien beschriften</caption>
|
||||||
|
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/pdf.png</image>
|
||||||
|
</screenshot>
|
||||||
|
</screenshots>
|
||||||
|
<branding>
|
||||||
|
<color type="primary" scheme_preference="light">#a8c8f0</color>
|
||||||
|
<color type="primary" scheme_preference="dark">#1f3a5f</color>
|
||||||
|
</branding>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
|
<releases>
|
||||||
|
<release version="0.29.0" date="2026-10-18">
|
||||||
|
<description>
|
||||||
|
<p>Endless notebooks: one surface without edges to write, pan and zoom in every direction. Choose it when creating a notebook or switch a single-page notebook in the menu; PDF export contains only the written area.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.28.0" date="2026-10-18">
|
||||||
|
<description>
|
||||||
|
<p>Each notebook remembers whether it flips page by page or scrolls continuously. New notebooks start with fixed pages; the layout can be chosen when creating a notebook and changed later in the menu.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.27.1" date="2026-10-18">
|
||||||
|
<description>
|
||||||
|
<p>The pens in the toolbar are shown as pencils in their own colour, so they are easy to tell apart.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.27.0" date="2026-10-18">
|
||||||
|
<description>
|
||||||
|
<p>Pictures keep their proportions when resized. A lock button in the selection tool switches between proportional and free resizing; pictures start locked, other objects free.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.26.2" date="2026-10-13">
|
||||||
|
<description>
|
||||||
|
<p>Smoother scrolling through large imported PDFs: pages no longer stall the display while they are prepared, and pages scrolled past quickly are skipped.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.26.1" date="2026-10-10">
|
||||||
|
<description>
|
||||||
|
<p>Screenshots in the app description for software centers. The app itself is unchanged.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.26.0" date="2026-10-10">
|
||||||
|
<description>
|
||||||
|
<p>Select and move objects with a finger; text and make-space tools also work with a finger. Exports go to the documents folder of the user directories.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.25.0" date="2026-10-09">
|
||||||
|
<description>
|
||||||
|
<p>English user interface; the language follows the system and can be changed in the menu. Holding to snap a shape tolerates a slightly trembling pen.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.24.0" date="2026-10-09">
|
||||||
|
<description>
|
||||||
|
<p>Axis cross from two crossing strokes.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.23.0" date="2026-10-09">
|
||||||
|
<description>
|
||||||
|
<p>The on-screen keyboard of GNOME appears when tapping a text field.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
</releases>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -3,6 +3,30 @@
|
|||||||
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
||||||
|
|
||||||
### Highlights:
|
### Highlights:
|
||||||
|
- **Endlose Fläche**: Neue Seitenanordnung „♾ Endlos“ – eine Fläche ohne Rand, auf der man in alle Richtungen schreibt, verschiebt und zoomt. Wählbar beim Anlegen oder im ☰-Menü (bei Notizbüchern mit einer Seite); der PDF-Export enthält nur den beschriebenen Bereich. Neue Dateiformatversion 9: ältere App-Versionen öffnen Notizbücher dieser Version nicht mehr
|
||||||
|
- **Seitenanordnung je Notizbuch**: Beim Anlegen wählbar – „Feste Seiten“ (Vorgabe) oder „Durchgehend“ –, später im ☰-Menü umschaltbar. Bestehende Notizbücher behalten die bisherige Einstellung
|
||||||
|
- **Stifte in ihrer Farbe**: Die Stifte in der Werkzeugleiste erscheinen als Stifte in ihrer eigenen Farbe statt als dünner Strich und sind so auf einen Blick zu unterscheiden
|
||||||
|
- **Bilder ohne Verzerren skalieren**: Beim Auswahlwerkzeug schaltet ein Schloss unter den Stiftfarben zwischen gleichmäßigem (🔒) und freiem Skalieren (🔓) um. Bilder sind zu Beginn gesperrt, Striche und Formen frei; am Rechner sperrt zusätzlich die Umschalttaste
|
||||||
|
- **Flüssigeres Scrollen in großen PDFs**: Neu gerenderte PDF-Seiten halten die Anzeige nicht mehr an, und Seiten, über die man schnell hinwegscrollt, werden übersprungen statt der Reihe nach gerendert
|
||||||
|
- **Screenshots für Flathub**: Die App-Beschreibung (Metainfo) zeigt jetzt drei Screenshots; an der App selbst ändert sich nichts
|
||||||
|
- **Auswählen mit dem Finger**: Im Auswahlwerkzeug wählt und verschiebt ein Finger Objekte (auf leerer Fläche scrollt er weiter); Text und Platz schaffen gehen ebenfalls mit dem Finger
|
||||||
|
- **Flatpak**: Manifest unter packaging/flatpak (Vorbereitung für Flathub); Exporte landen im Dokumente-Ordner aus den Benutzerordnern (z. B. ~/Dokumente)
|
||||||
|
- **Englische Oberfläche / English interface**: ink-notes folgt der Systemsprache (Deutsch oder Englisch); umstellen im ☰-Menü unter „Sprache / Language“
|
||||||
|
- **Halten zum Einrasten**: verträgt jetzt das leichte Zittern eines ruhenden Stifts, damit Formen auch im normalen Stift-Modus einrasten
|
||||||
|
- **Achsenkreuz aus zwei Strichen**: Eine waagerechte und eine senkrechte Gerade über Kreuz zeichnen – die zweite mit Halten oder dem Formen-Werkzeug – und beide werden zum Achsenkreuz mit Pfeilspitzen an allen vier Enden; Rückgängig stellt die erste Gerade wieder her
|
||||||
|
- **Bildschirmtastatur**: Unter GNOME/Zorin (Wayland) erscheint die Bildschirmtastatur jetzt beim Antippen eines Namensfelds – mit Finger oder Stift; Tippen und Rücktaste funktionieren. Außerdem geht der erste Fingertipp nach Mausbenutzung nicht mehr verloren
|
||||||
|
- **App-Symbol**: Das Fenster meldet sich als eu.pansi.ink-notes – Dock und Taskleiste zeigen das ink-notes-Symbol statt eines allgemeinen Symbols (unter X11 auch als Fenstersymbol)
|
||||||
|
- **Platz schaffen**: Neues Werkzeug ↕ – aufs Blatt tippen und nach unten ziehen, alles darunter rutscht mit, auch auf den folgenden Seiten (über das Seitenende hinaus geht es auf der nächsten Seite weiter, fehlende Seiten werden angehängt); nach oben ziehen schließt leeren Platz; ein Schritt für Rückgängig
|
||||||
|
- **Achsenkreuz**: Ein „+“ in einem Zug (mit Halten oder dem Formen-Werkzeug) wird zum Achsenkreuz mit Pfeilspitzen an allen vier Enden; auch im PDF-/Bildexport
|
||||||
|
- **Fehlerbehebung Stift**: Das erste Antippen mit dem Stift trifft jetzt – Knöpfe und Notizbücher reagieren sofort statt erst beim zweiten Tippen; Striche beginnen nicht mehr mit einer Linie von der vorherigen Stiftposition
|
||||||
|
- **Tasten am Stift belegen**: Im ☰-Menü unter Eingabe jede Stifttaste mit Radieren, Auswählen, Textmarker, Stift oder Nichts belegen (gilt, solange die Taste gedrückt ist); Tasten erscheinen nach dem ersten Drücken und leuchten beim Drücken grün
|
||||||
|
- **Ordner verschieben**: Ordner lassen sich jetzt wie Notizbücher mit 📂 samt Inhalt in andere Ordner oder auf die oberste Ebene verschieben
|
||||||
|
- **Ordner für Notizbücher**: Beliebig verschachtelte Ordner (z. B. 3. Semester › Mathe) im Dokumenten-Manager anlegen, umbenennen und löschen, Notizbücher mit 📂 verschieben; neue Notizbücher landen im geöffneten Ordner. Die Notizbuch-Dateien bleiben unverändert (Zuordnung in library.json)
|
||||||
|
- **Einfügen per Finger**: Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle (wie in Rnote)
|
||||||
|
- **Eigene Farben**: Jede Farbe der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – erneut antippen, Vorgabe oder mit 🎨 frei wählen; die Leiste merkt sich die Farben. Farben am Bildschirm wie im PDF-/Bildexport (Schwarz ist schwarz, Textmarker mischt wie im PDF); Karos und Linien im PDF so hell wie am Bildschirm
|
||||||
|
- **Beta-Feedback II**: Vier Stifte mit eigener Farbe und Stärke, die sich Änderungen merken; Textmarker wird innerhalb eines Strichs nicht dunkler und ist jetzt auch im PDF-/PNG-/SVG-Export durchsichtig; Seiten lückenlos untereinander mit Trennlinie; neue Seite per ➕ in der Werkzeugleiste; Seite löschen im ☰-Menü (wiederherstellbar); Koordinatensystem ohne Loch am Ursprung
|
||||||
|
- **Fehlerbehebungen**: Umbenennen und Löschen in der Notizbuchverwaltung reagieren wieder; Karos, Linien und Punkte werden pro Pixel berechnet und liegen in jedem Zoom gleichmäßig (mindestens ein Pixel breit, kantengeglättet)
|
||||||
|
- **Strichqualität**: Kleine Schleifen wie in „o“, „a“ oder „e“ bleiben rund (der fertige Strich entsteht aus den ungefilterten Stiftpunkten); Strichkanten werden kantengeglättet (4× Multisampling), runde Strichenden sind feiner unterteilt
|
||||||
- **Musterfarbe**: Karos, Linien und Punkte in hell, mittel, kräftig, blau oder grün (☰-Menü, gilt auch im Export); Punkte des Punktrasters am Bildschirm so groß wie im Export (0,4 mm). README und Dokumentation überarbeitet
|
- **Musterfarbe**: Karos, Linien und Punkte in hell, mittel, kräftig, blau oder grün (☰-Menü, gilt auch im Export); Punkte des Punktrasters am Bildschirm so groß wie im Export (0,4 mm). README und Dokumentation überarbeitet
|
||||||
- **Beta-Feedback**: Querformat-PDFs (z. B. PowerPoint-Folien) bekommen Querformat-Seiten ohne leeren Streifen (Dateiformat-Version 8); neue Seiten direkt nach der aktuellen einfügen; Auswahlwerkzeug funktioniert mit dem Stift; Zoom bis 1000 % mit schärfer nachgerenderten PDF-Seiten
|
- **Beta-Feedback**: Querformat-PDFs (z. B. PowerPoint-Folien) bekommen Querformat-Seiten ohne leeren Streifen (Dateiformat-Version 8); neue Seiten direkt nach der aktuellen einfügen; Auswahlwerkzeug funktioniert mit dem Stift; Zoom bis 1000 % mit schärfer nachgerenderten PDF-Seiten
|
||||||
- **Text-Werkzeug**: Mit T ins Blatt tippen und schreiben – normale oder Code-Schrift, 8–36 pt, Zeilenumbruch am Feldrand; Textfelder verschieben, drehen und skalieren wie Bilder; im PDF echter, durchsuchbarer Text; kopierter Text wird mit Strg+V zum Textfeld (Dateiformat-Version 7)
|
- **Text-Werkzeug**: Mit T ins Blatt tippen und schreiben – normale oder Code-Schrift, 8–36 pt, Zeilenumbruch am Feldrand; Textfelder verschieben, drehen und skalieren wie Bilder; im PDF echter, durchsuchbarer Text; kopierter Text wird mit Strg+V zum Textfeld (Dateiformat-Version 7)
|
||||||
|
|||||||
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
@@ -14,6 +14,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::storage;
|
use crate::storage;
|
||||||
|
use crate::t;
|
||||||
|
|
||||||
/// Name des Unterordners mit den Bilddateien.
|
/// Name des Unterordners mit den Bilddateien.
|
||||||
pub const ASSETS_DIR: &str = "assets";
|
pub const ASSETS_DIR: &str = "assets";
|
||||||
@@ -40,15 +41,18 @@ pub enum AssetError {
|
|||||||
impl std::fmt::Display for AssetError {
|
impl std::fmt::Display for AssetError {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
AssetError::Decode(msg) => write!(f, "Bild kann nicht gelesen werden: {msg}"),
|
AssetError::Decode(msg) => {
|
||||||
AssetError::TooLarge(bytes) => write!(
|
f.write_str(&t!("Bild kann nicht gelesen werden: {msg}", msg = msg))
|
||||||
f,
|
}
|
||||||
"Bild ist zu groß ({} MB, höchstens {} MB)",
|
AssetError::TooLarge(bytes) => f.write_str(&t!(
|
||||||
bytes / (1024 * 1024),
|
"Bild ist zu groß ({size} MB, höchstens {max} MB)",
|
||||||
MAX_IMPORT_BYTES / (1024 * 1024)
|
size = bytes / (1024 * 1024),
|
||||||
),
|
max = MAX_IMPORT_BYTES / (1024 * 1024)
|
||||||
AssetError::InvalidName(name) => write!(f, "Ungültiger Bildverweis '{name}'"),
|
)),
|
||||||
AssetError::Io(e) => write!(f, "Ein-/Ausgabefehler: {e}"),
|
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,
|
height: u32,
|
||||||
pixels: Vec<u8>,
|
pixels: Vec<u8>,
|
||||||
) -> Result<ImportedImage, AssetError> {
|
) -> Result<ImportedImage, AssetError> {
|
||||||
let buffer = image::RgbaImage::from_raw(width, height, pixels)
|
let buffer = image::RgbaImage::from_raw(width, height, pixels).ok_or_else(|| {
|
||||||
.ok_or_else(|| AssetError::Decode("Pixeldaten passen nicht zur Bildgröße".into()))?;
|
AssetError::Decode(t!("Pixeldaten passen nicht zur Bildgröße").into())
|
||||||
|
})?;
|
||||||
let mut decoded = image::DynamicImage::ImageRgba8(buffer);
|
let mut decoded = image::DynamicImage::ImageRgba8(buffer);
|
||||||
if width > MAX_IMPORT_DIMENSION || height > MAX_IMPORT_DIMENSION {
|
if width > MAX_IMPORT_DIMENSION || height > MAX_IMPORT_DIMENSION {
|
||||||
decoded = decoded.resize(
|
decoded = decoded.resize(
|
||||||
|
|||||||
+18
-16
@@ -16,6 +16,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|||||||
|
|
||||||
use crate::document::{Notebook, Page, PersistState};
|
use crate::document::{Notebook, Page, PersistState};
|
||||||
use crate::storage::{self, NotebookManifest, PageData, StorageError};
|
use crate::storage::{self, NotebookManifest, PageData, StorageError};
|
||||||
|
use crate::t;
|
||||||
|
|
||||||
/// Abstraktion für die Zeitmessung (für deterministische Tests ohne Thread-Sleep).
|
/// Abstraktion für die Zeitmessung (für deterministische Tests ohne Thread-Sleep).
|
||||||
pub trait Clock: Send + Sync + 'static {
|
pub trait Clock: Send + Sync + 'static {
|
||||||
@@ -69,12 +70,11 @@ pub struct AutosaveError {
|
|||||||
|
|
||||||
impl fmt::Display for AutosaveError {
|
impl fmt::Display for AutosaveError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(
|
f.write_str(&t!(
|
||||||
f,
|
"Autosave fehlgeschlagen für Seiten {pages}: {messages}",
|
||||||
"Autosave fehlgeschlagen für Seiten {:?}: {}",
|
pages = format!("{:?}", self.failed_page_ids),
|
||||||
self.failed_page_ids,
|
messages = self.messages.join("; ")
|
||||||
self.messages.join("; ")
|
))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,10 +96,10 @@ pub enum AutosaveStatus {
|
|||||||
impl AutosaveStatus {
|
impl AutosaveStatus {
|
||||||
pub fn display_text(&self) -> &'static str {
|
pub fn display_text(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
AutosaveStatus::Saved => "Gespeichert",
|
AutosaveStatus::Saved => t!("Gespeichert"),
|
||||||
AutosaveStatus::Pending => "Ungespeichert",
|
AutosaveStatus::Pending => t!("Ungespeichert"),
|
||||||
AutosaveStatus::Saving => "Speichert...",
|
AutosaveStatus::Saving => t!("Speichert..."),
|
||||||
AutosaveStatus::Error => "Speicherfehler!",
|
AutosaveStatus::Error => t!("Speicherfehler!"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,7 +166,7 @@ fn spawn_worker(
|
|||||||
let _ = resp_tx.send(AutosaveResponse::PageSaved {
|
let _ = resp_tx.send(AutosaveResponse::PageSaved {
|
||||||
page_id,
|
page_id,
|
||||||
result: Err((
|
result: Err((
|
||||||
"Worker panic beim Speichern der Seite".to_string(),
|
t!("Worker panic beim Speichern der Seite").to_string(),
|
||||||
Box::new(page_data),
|
Box::new(page_data),
|
||||||
)),
|
)),
|
||||||
});
|
});
|
||||||
@@ -194,7 +194,7 @@ fn spawn_worker(
|
|||||||
Err(_panic) => {
|
Err(_panic) => {
|
||||||
let _ = resp_tx.send(AutosaveResponse::ManifestSaved {
|
let _ = resp_tx.send(AutosaveResponse::ManifestSaved {
|
||||||
result: Err((
|
result: Err((
|
||||||
"Worker panic beim Speichern des Manifests".to_string(),
|
t!("Worker panic beim Speichern des Manifests").to_string(),
|
||||||
Box::new(manifest),
|
Box::new(manifest),
|
||||||
)),
|
)),
|
||||||
});
|
});
|
||||||
@@ -413,7 +413,8 @@ impl AutosaveController {
|
|||||||
Err((err_msg, failed_page)) => {
|
Err((err_msg, failed_page)) => {
|
||||||
eprintln!("[Autosave-Fehler] Seite {page_id} fehlgeschlagen: {err_msg}");
|
eprintln!("[Autosave-Fehler] Seite {page_id} fehlgeschlagen: {err_msg}");
|
||||||
self.status = AutosaveStatus::Error;
|
self.status = AutosaveStatus::Error;
|
||||||
self.last_errors.push(format!("Seite {page_id}: {err_msg}"));
|
self.last_errors
|
||||||
|
.push(t!("Seite {id}: {msg}", id = page_id, msg = err_msg));
|
||||||
|
|
||||||
// Wieder einreihen, wenn nicht bereits eine neuere Version vorliegt
|
// Wieder einreihen, wenn nicht bereits eine neuere Version vorliegt
|
||||||
self.pending_pages.entry(page_id).or_insert(*failed_page);
|
self.pending_pages.entry(page_id).or_insert(*failed_page);
|
||||||
@@ -431,7 +432,7 @@ impl AutosaveController {
|
|||||||
if let Err((err_msg, failed_manifest)) = result {
|
if let Err((err_msg, failed_manifest)) = result {
|
||||||
eprintln!("[Autosave-Fehler] Manifest fehlgeschlagen: {err_msg}");
|
eprintln!("[Autosave-Fehler] Manifest fehlgeschlagen: {err_msg}");
|
||||||
self.status = AutosaveStatus::Error;
|
self.status = AutosaveStatus::Error;
|
||||||
self.last_errors.push(format!("Manifest: {err_msg}"));
|
self.last_errors.push(t!("Manifest: {msg}", msg = err_msg));
|
||||||
if self.pending_manifest.is_none() {
|
if self.pending_manifest.is_none() {
|
||||||
self.pending_manifest = Some(*failed_manifest);
|
self.pending_manifest = Some(*failed_manifest);
|
||||||
}
|
}
|
||||||
@@ -567,7 +568,8 @@ impl AutosaveController {
|
|||||||
}
|
}
|
||||||
Err((e, failed_page)) => {
|
Err((e, failed_page)) => {
|
||||||
self.status = AutosaveStatus::Error;
|
self.status = AutosaveStatus::Error;
|
||||||
self.last_errors.push(format!("Seite {page_id}: {e}"));
|
self.last_errors
|
||||||
|
.push(t!("Seite {id}: {msg}", id = page_id, msg = e));
|
||||||
self.pending_pages.insert(page_id, *failed_page);
|
self.pending_pages.insert(page_id, *failed_page);
|
||||||
let now = self.clock.now();
|
let now = self.clock.now();
|
||||||
let retry = self.retries.entry(page_id).or_insert(PageRetry {
|
let retry = self.retries.entry(page_id).or_insert(PageRetry {
|
||||||
@@ -581,7 +583,7 @@ impl AutosaveController {
|
|||||||
AutosaveResponse::ManifestSaved { result } => {
|
AutosaveResponse::ManifestSaved { result } => {
|
||||||
if let Err((e, failed_manifest)) = result {
|
if let Err((e, failed_manifest)) = result {
|
||||||
self.status = AutosaveStatus::Error;
|
self.status = AutosaveStatus::Error;
|
||||||
self.last_errors.push(format!("Manifest: {e}"));
|
self.last_errors.push(t!("Manifest: {msg}", msg = e));
|
||||||
self.pending_manifest = Some(*failed_manifest);
|
self.pending_manifest = Some(*failed_manifest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-137
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::canvas::InkVertex;
|
use crate::canvas::InkVertex;
|
||||||
use crate::document::PageBackground;
|
use crate::document::PageBackground;
|
||||||
|
use crate::t;
|
||||||
|
|
||||||
/// Farbe von Karos, Linien und Punkten (wird gespeichert und gilt auch für den Export).
|
/// Farbe von Karos, Linien und Punkten (wird gespeichert und gilt auch für den Export).
|
||||||
/// `Light` ist die zurückhaltende Standardfarbe des jeweiligen Papiers.
|
/// `Light` ist die zurückhaltende Standardfarbe des jeweiligen Papiers.
|
||||||
@@ -29,11 +30,11 @@ impl PatternColor {
|
|||||||
|
|
||||||
pub fn label(self) -> &'static str {
|
pub fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
PatternColor::Light => "Hell",
|
PatternColor::Light => t!("Hell"),
|
||||||
PatternColor::Medium => "Mittel",
|
PatternColor::Medium => t!("Mittel"),
|
||||||
PatternColor::Strong => "Kräftig",
|
PatternColor::Strong => t!("Kräftig"),
|
||||||
PatternColor::Blue => "Blau",
|
PatternColor::Blue => t!("Blau"),
|
||||||
PatternColor::Green => "Grün",
|
PatternColor::Green => t!("Grün"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +72,8 @@ pub const LINED_SPACING: f32 = 75.0;
|
|||||||
/// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte
|
/// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte
|
||||||
/// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier).
|
/// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier).
|
||||||
///
|
///
|
||||||
/// Alle Farben sind linear (die Oberfläche ist sRGB, die Umrechnung macht die GPU).
|
/// Papier- und Musterfarben sind linear (der Shader rechnet sie in sRGB um); Tintenfarben
|
||||||
|
/// dagegen sind sRGB-Werte wie im Export.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum PaperTheme {
|
pub enum PaperTheme {
|
||||||
@@ -95,10 +97,10 @@ impl PaperTheme {
|
|||||||
|
|
||||||
pub fn label(self) -> &'static str {
|
pub fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
PaperTheme::White => "Weiß",
|
PaperTheme::White => t!("Weiß"),
|
||||||
PaperTheme::Cream => "Creme",
|
PaperTheme::Cream => t!("Creme"),
|
||||||
PaperTheme::DarkGray => "Dunkelgrau",
|
PaperTheme::DarkGray => t!("Dunkelgrau"),
|
||||||
PaperTheme::Black => "Schwarz",
|
PaperTheme::Black => t!("Schwarz"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +140,24 @@ impl PaperTheme {
|
|||||||
PaperTheme::DarkGray => (0.004, 0.004, 0.005),
|
PaperTheme::DarkGray => (0.004, 0.004, 0.005),
|
||||||
PaperTheme::Black => (0.03, 0.03, 0.035),
|
PaperTheme::Black => (0.03, 0.03, 0.035),
|
||||||
};
|
};
|
||||||
wgpu::Color { r, g, b, a: 1.0 }
|
// Linear angegeben; gezeichnet wird ohne sRGB-Umrechnung
|
||||||
|
let srgb = |c: f64| f64::from(linear_to_srgb(c as f32));
|
||||||
|
wgpu::Color {
|
||||||
|
r: srgb(r),
|
||||||
|
g: srgb(g),
|
||||||
|
b: srgb(b),
|
||||||
|
a: 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linearer Farbanteil (Papier, Muster) als sRGB-Wert (Tinte, Export).
|
||||||
|
pub fn linear_to_srgb(c: f32) -> f32 {
|
||||||
|
let c = c.clamp(0.0, 1.0);
|
||||||
|
if c <= 0.003_130_8 {
|
||||||
|
c * 12.92
|
||||||
|
} else {
|
||||||
|
1.055 * c.powf(1.0 / 2.4) - 0.055
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,115 +184,70 @@ pub fn generate_paper_quad(color: [f32; 4], size: glam::Vec2) -> Vec<InkVertex>
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu.
|
/// Oberer und unterer Rand ohne Linien bei liniertem Papier (15 mm).
|
||||||
#[inline]
|
pub const LINED_MARGIN: f32 = 150.0;
|
||||||
fn push_quad(
|
|
||||||
vertices: &mut Vec<InkVertex>,
|
/// Ecke des Musterrechtecks: Das Muster selbst berechnet der Fragment-Shader (`fs_pattern`)
|
||||||
color: [f32; 4],
|
/// für jedes Bildschirmpixel. So liegen Linien und Punkte in jedem Zoom exakt gleichmäßig,
|
||||||
x_min: f32,
|
/// sind mindestens ein Pixel breit und kantengeglättet – vorher gerasterte Haarlinien
|
||||||
y_min: f32,
|
/// fielen je nach Lage auf das Pixelraster und wirkten unregelmäßig.
|
||||||
x_max: f32,
|
#[repr(C)]
|
||||||
y_max: f32,
|
#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
) {
|
pub struct PatternVertex {
|
||||||
vertices.push(InkVertex::new(x_min, y_min, color));
|
/// Seitenkoordinate
|
||||||
vertices.push(InkVertex::new(x_max, y_min, color));
|
pub position: [f32; 2],
|
||||||
vertices.push(InkVertex::new(x_min, y_max, color));
|
pub color: [f32; 4],
|
||||||
vertices.push(InkVertex::new(x_min, y_max, color));
|
/// Art (1 = Karo, 2 = Linien, 3 = Punkte), Abstand, Linienstärke, Punktgröße
|
||||||
vertices.push(InkVertex::new(x_max, y_min, color));
|
pub params: [f32; 4],
|
||||||
vertices.push(InkVertex::new(x_max, y_max, color));
|
/// Seitenbreite, Seitenhöhe, Rand oben/unten bei Linien, ungenutzt
|
||||||
|
pub bounds: [f32; 4],
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color` auf
|
impl PatternVertex {
|
||||||
/// einem Blatt der Größe `size`.
|
const ATTRIBUTES: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
|
||||||
pub fn generate_background_mesh(
|
0 => Float32x2,
|
||||||
|
1 => Float32x4,
|
||||||
|
2 => Float32x4,
|
||||||
|
3 => Float32x4
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn desc() -> wgpu::VertexBufferLayout<'static> {
|
||||||
|
wgpu::VertexBufferLayout {
|
||||||
|
array_stride: std::mem::size_of::<PatternVertex>() as wgpu::BufferAddress,
|
||||||
|
step_mode: wgpu::VertexStepMode::Vertex,
|
||||||
|
attributes: &Self::ATTRIBUTES,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rechteck über das ganze Blatt der Größe `size`, auf dem der Shader das Muster
|
||||||
|
/// `background` in `color` zeichnet; leer bei blankem Papier.
|
||||||
|
pub fn generate_pattern_quad(
|
||||||
background: PageBackground,
|
background: PageBackground,
|
||||||
color: [f32; 4],
|
color: [f32; 4],
|
||||||
size: glam::Vec2,
|
size: glam::Vec2,
|
||||||
) -> Vec<InkVertex> {
|
) -> Vec<PatternVertex> {
|
||||||
let (page_width, page_height) = (size.x, size.y);
|
let params = match background {
|
||||||
match background {
|
PageBackground::Blank => return Vec::new(),
|
||||||
PageBackground::Blank => Vec::new(),
|
PageBackground::Grid5mm => [1.0, GRID_5MM_SPACING, LINE_THICKNESS, DOT_SIZE],
|
||||||
PageBackground::Grid5mm => {
|
PageBackground::Lined => [2.0, LINED_SPACING, LINE_THICKNESS, DOT_SIZE],
|
||||||
let mut vertices = Vec::with_capacity(1200);
|
PageBackground::DotGrid => [3.0, GRID_5MM_SPACING, LINE_THICKNESS, DOT_SIZE],
|
||||||
let half_t = LINE_THICKNESS / 2.0;
|
};
|
||||||
|
let bounds = [size.x, size.y, LINED_MARGIN, 0.0];
|
||||||
// Vertikale Linien alle 5 mm
|
let corner = |x: f32, y: f32| PatternVertex {
|
||||||
let mut x = GRID_5MM_SPACING;
|
position: [x, y],
|
||||||
while x < page_width {
|
color,
|
||||||
push_quad(
|
params,
|
||||||
&mut vertices,
|
bounds,
|
||||||
color,
|
};
|
||||||
x - half_t,
|
vec![
|
||||||
0.0,
|
corner(0.0, 0.0),
|
||||||
x + half_t,
|
corner(size.x, 0.0),
|
||||||
page_height,
|
corner(0.0, size.y),
|
||||||
);
|
corner(0.0, size.y),
|
||||||
x += GRID_5MM_SPACING;
|
corner(size.x, 0.0),
|
||||||
}
|
corner(size.x, size.y),
|
||||||
|
]
|
||||||
// Horizontale Linien alle 5 mm
|
|
||||||
let mut y = GRID_5MM_SPACING;
|
|
||||||
while y < page_height {
|
|
||||||
push_quad(
|
|
||||||
&mut vertices,
|
|
||||||
color,
|
|
||||||
0.0,
|
|
||||||
y - half_t,
|
|
||||||
page_width,
|
|
||||||
y + half_t,
|
|
||||||
);
|
|
||||||
y += GRID_5MM_SPACING;
|
|
||||||
}
|
|
||||||
|
|
||||||
vertices
|
|
||||||
}
|
|
||||||
PageBackground::Lined => {
|
|
||||||
let mut vertices = Vec::with_capacity(600);
|
|
||||||
let half_t = LINE_THICKNESS / 2.0;
|
|
||||||
|
|
||||||
// Oberer und unterer Randabstand (z. B. 15 mm)
|
|
||||||
let margin_top = 150.0;
|
|
||||||
let margin_bottom = 150.0;
|
|
||||||
|
|
||||||
let mut y = margin_top;
|
|
||||||
while y <= page_height - margin_bottom {
|
|
||||||
push_quad(
|
|
||||||
&mut vertices,
|
|
||||||
color,
|
|
||||||
0.0,
|
|
||||||
y - half_t,
|
|
||||||
page_width,
|
|
||||||
y + half_t,
|
|
||||||
);
|
|
||||||
y += LINED_SPACING;
|
|
||||||
}
|
|
||||||
|
|
||||||
vertices
|
|
||||||
}
|
|
||||||
PageBackground::DotGrid => {
|
|
||||||
let mut vertices = Vec::with_capacity(15000);
|
|
||||||
let half_d = DOT_SIZE / 2.0;
|
|
||||||
|
|
||||||
let mut y = GRID_5MM_SPACING;
|
|
||||||
while y < page_height {
|
|
||||||
let mut x = GRID_5MM_SPACING;
|
|
||||||
while x < page_width {
|
|
||||||
push_quad(
|
|
||||||
&mut vertices,
|
|
||||||
color,
|
|
||||||
x - half_d,
|
|
||||||
y - half_d,
|
|
||||||
x + half_d,
|
|
||||||
y + half_d,
|
|
||||||
);
|
|
||||||
x += GRID_5MM_SPACING;
|
|
||||||
}
|
|
||||||
y += GRID_5MM_SPACING;
|
|
||||||
}
|
|
||||||
|
|
||||||
vertices
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -323,32 +297,25 @@ mod tests {
|
|||||||
const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
|
const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_background_meshes() {
|
fn test_pattern_quads() {
|
||||||
let blank = generate_background_mesh(PageBackground::Blank, PATTERN, A4_SIZE);
|
assert!(generate_pattern_quad(PageBackground::Blank, PATTERN, A4_SIZE).is_empty());
|
||||||
assert!(blank.is_empty());
|
for (background, kind) in [
|
||||||
|
(PageBackground::Grid5mm, 1.0),
|
||||||
let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN, A4_SIZE);
|
(PageBackground::Lined, 2.0),
|
||||||
assert!(!grid.is_empty());
|
(PageBackground::DotGrid, 3.0),
|
||||||
assert_eq!(
|
] {
|
||||||
grid.len() % 6,
|
let quad = generate_pattern_quad(background, PATTERN, A4_SIZE);
|
||||||
0,
|
assert_eq!(quad.len(), 6);
|
||||||
"Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)"
|
assert!(quad
|
||||||
);
|
.iter()
|
||||||
|
.all(|v| v.params[0] == kind && v.color == PATTERN));
|
||||||
let lined = generate_background_mesh(PageBackground::Lined, PATTERN, A4_SIZE);
|
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
|
||||||
assert!(!lined.is_empty());
|
assert_eq!(quad[0].bounds[..2], [PAGE_WIDTH, PAGE_HEIGHT]);
|
||||||
assert_eq!(lined.len() % 6, 0);
|
}
|
||||||
|
|
||||||
let dots = generate_background_mesh(PageBackground::DotGrid, PATTERN, A4_SIZE);
|
|
||||||
assert!(!dots.is_empty());
|
|
||||||
assert_eq!(dots.len() % 6, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_background_mesh_uses_theme_color() {
|
fn test_background_mesh_uses_theme_color() {
|
||||||
let dark = PaperTheme::Black.pattern_color();
|
|
||||||
let mesh = generate_background_mesh(PageBackground::Lined, dark, A4_SIZE);
|
|
||||||
assert!(mesh.iter().all(|v| v.color == dark));
|
|
||||||
let quad = generate_paper_quad(PaperTheme::Cream.paper_color(), A4_SIZE);
|
let quad = generate_paper_quad(PaperTheme::Cream.paper_color(), A4_SIZE);
|
||||||
assert!(quad
|
assert!(quad
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
+258
-57
@@ -12,10 +12,13 @@ use std::sync::Arc;
|
|||||||
use winit::dpi::PhysicalSize;
|
use winit::dpi::PhysicalSize;
|
||||||
use winit::window::Window;
|
use winit::window::Window;
|
||||||
|
|
||||||
use crate::background::{generate_background_mesh, generate_paper_quad, PaperTheme, PatternColor};
|
use crate::background::{
|
||||||
|
generate_paper_quad, generate_pattern_quad, PaperTheme, PatternColor, PatternVertex,
|
||||||
|
};
|
||||||
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
|
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
|
||||||
use crate::filter::OneEuroFilter;
|
use crate::filter::OneEuroFilter;
|
||||||
use crate::spline::{self, ControlPoint, SplineConfig};
|
use crate::spline::{self, ControlPoint, SplineConfig};
|
||||||
|
use crate::tn;
|
||||||
use crate::viewport::PageStack;
|
use crate::viewport::PageStack;
|
||||||
|
|
||||||
/// 2D-Vertex für Inking-Meshes mit Positions- und Farbattributen (Multi-Color).
|
/// 2D-Vertex für Inking-Meshes mit Positions- und Farbattributen (Multi-Color).
|
||||||
@@ -91,12 +94,13 @@ pub fn spline_vertices_to_ndc(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Verwaltet den GPU-Zustand, Surface, Pipelines und Buffer für das Inking.
|
/// Verwaltet den GPU-Zustand, Surface, Pipelines und Buffer für das Inking.
|
||||||
/// Meldung, wenn kein Grafikadapter das Fenster darstellen kann.
|
/// Meldung, wenn kein Grafikadapter das Fenster darstellen kann (deutsch, anzeigen mit
|
||||||
pub const NO_USABLE_GPU_MESSAGE: &str = "Keine nutzbare Grafikschnittstelle gefunden.\n\
|
/// `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\
|
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\
|
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\
|
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
|
/// Wählt den Darstellungsmodus: Mailbox (geringste Latenz), sonst Fifo, sonst den ersten
|
||||||
/// angebotenen. `None`, wenn keiner angeboten wird. `INK_FORCE_FIFO` erzwingt Fifo.
|
/// angebotenen. `None`, wenn keiner angeboten wird. `INK_FORCE_FIFO` erzwingt Fifo.
|
||||||
@@ -126,6 +130,8 @@ struct NeighborFingerprint {
|
|||||||
vertex_count: usize,
|
vertex_count: usize,
|
||||||
stroke_count: usize,
|
stroke_count: usize,
|
||||||
last_stroke_id: Option<u64>,
|
last_stroke_id: Option<u64>,
|
||||||
|
/// Neuaufbau des Caches (z. B. verschobener Inhalt bei gleicher Strichzahl)
|
||||||
|
cache_generation: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NeighborFingerprint {
|
impl NeighborFingerprint {
|
||||||
@@ -139,6 +145,7 @@ impl NeighborFingerprint {
|
|||||||
vertex_count: page.vertex_cache.len(),
|
vertex_count: page.vertex_cache.len(),
|
||||||
stroke_count: page.strokes.len(),
|
stroke_count: page.strokes.len(),
|
||||||
last_stroke_id: page.strokes.last().map(|s| s.id),
|
last_stroke_id: page.strokes.last().map(|s| s.id),
|
||||||
|
cache_generation: page.cache_generation,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,8 +157,9 @@ struct NeighborPage {
|
|||||||
offset: glam::Vec2,
|
offset: glam::Vec2,
|
||||||
camera_buffer: wgpu::Buffer,
|
camera_buffer: wgpu::Buffer,
|
||||||
camera_bind_group: wgpu::BindGroup,
|
camera_bind_group: wgpu::BindGroup,
|
||||||
background_buffer: Option<wgpu::Buffer>,
|
/// Papier (Dreiecke) und Muster (Rechteck für `fs_pattern`)
|
||||||
background_vertex_count: u32,
|
paper_buffer: wgpu::Buffer,
|
||||||
|
pattern_buffer: Option<wgpu::Buffer>,
|
||||||
vertex_buffer: Option<wgpu::Buffer>,
|
vertex_buffer: Option<wgpu::Buffer>,
|
||||||
stroke_ranges: Vec<Range<u32>>,
|
stroke_ranges: Vec<Range<u32>>,
|
||||||
}
|
}
|
||||||
@@ -163,6 +171,10 @@ pub struct CanvasState {
|
|||||||
/// Multisampling der Zeichenfläche (1 = aus) und das zugehörige Zwischenbild
|
/// Multisampling der Zeichenfläche (1 = aus) und das zugehörige Zwischenbild
|
||||||
msaa_samples: u32,
|
msaa_samples: u32,
|
||||||
msaa_view: Option<wgpu::TextureView>,
|
msaa_view: Option<wgpu::TextureView>,
|
||||||
|
/// Stencil-Puffer: Jeder Strich deckt ein Pixel nur einmal, damit sich halbtransparente
|
||||||
|
/// Tinte (Textmarker) dort, wo der Strich sich selbst überlappt, nicht verdunkelt
|
||||||
|
stencil_view: wgpu::TextureView,
|
||||||
|
stencil_reset_pipeline: wgpu::RenderPipeline,
|
||||||
/// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben
|
/// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben
|
||||||
presentation_warned: bool,
|
presentation_warned: bool,
|
||||||
pub device: wgpu::Device,
|
pub device: wgpu::Device,
|
||||||
@@ -187,9 +199,10 @@ pub struct CanvasState {
|
|||||||
|
|
||||||
// Statische Hintergrund-Geometrie
|
// Statische Hintergrund-Geometrie
|
||||||
pub paper_buffer: wgpu::Buffer,
|
pub paper_buffer: wgpu::Buffer,
|
||||||
|
/// Musterrechteck der aktuellen Seite (6 Ecken, leer bei blankem Papier)
|
||||||
pub background_buffer: wgpu::Buffer,
|
pub background_buffer: wgpu::Buffer,
|
||||||
pub background_vertex_capacity: usize,
|
|
||||||
pub background_vertex_count: u32,
|
pub background_vertex_count: u32,
|
||||||
|
pattern_pipeline: wgpu::RenderPipeline,
|
||||||
pub current_background: PageBackground,
|
pub current_background: PageBackground,
|
||||||
/// Papierfarbe der Anzeige
|
/// Papierfarbe der Anzeige
|
||||||
pub paper_theme: PaperTheme,
|
pub paper_theme: PaperTheme,
|
||||||
@@ -246,7 +259,6 @@ pub struct CanvasState {
|
|||||||
impl CanvasState {
|
impl CanvasState {
|
||||||
const INITIAL_LIVE_CAPACITY: usize = 4096;
|
const INITIAL_LIVE_CAPACITY: usize = 4096;
|
||||||
const INITIAL_COMMITTED_CAPACITY: usize = 65536;
|
const INITIAL_COMMITTED_CAPACITY: usize = 65536;
|
||||||
const INITIAL_BACKGROUND_CAPACITY: usize = 16384;
|
|
||||||
pub const VIEWPORT_MARGIN: f32 = 24.0;
|
pub const VIEWPORT_MARGIN: f32 = 24.0;
|
||||||
|
|
||||||
/// Initialisiert den CanvasState für das übergebene Winit-Fenster.
|
/// Initialisiert den CanvasState für das übergebene Winit-Fenster.
|
||||||
@@ -295,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
|
// 3. Device & Queue erstellen
|
||||||
let (device, queue) = adapter
|
let (device, queue) = adapter
|
||||||
@@ -309,13 +322,20 @@ impl CanvasState {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 4. Geringe Bildverzögerung (Mailbox, sonst Fifo) & Frame-Latency 1
|
// 4. Geringe Bildverzögerung (Mailbox, sonst Fifo) & Frame-Latency 1.
|
||||||
|
// Gezeichnet wird ohne sRGB-Umrechnung (siehe `render_format`); bietet die Fläche
|
||||||
|
// nur sRGB-Formate an, wird eine Ansicht ohne sRGB verwendet.
|
||||||
let surface_format = surface_caps
|
let surface_format = surface_caps
|
||||||
.formats
|
.formats
|
||||||
.iter()
|
.iter()
|
||||||
.copied()
|
.copied()
|
||||||
.find(|f| f.is_srgb())
|
.find(|f| !f.is_srgb())
|
||||||
.unwrap_or(surface_caps.formats[0]);
|
.unwrap_or(surface_caps.formats[0]);
|
||||||
|
let view_formats = if surface_format.is_srgb() {
|
||||||
|
vec![surface_format.remove_srgb_suffix()]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
|
||||||
let config = wgpu::SurfaceConfiguration {
|
let config = wgpu::SurfaceConfiguration {
|
||||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||||
@@ -324,14 +344,14 @@ impl CanvasState {
|
|||||||
height: size.height.max(1),
|
height: size.height.max(1),
|
||||||
present_mode,
|
present_mode,
|
||||||
alpha_mode: surface_caps.alpha_modes[0],
|
alpha_mode: surface_caps.alpha_modes[0],
|
||||||
view_formats: vec![],
|
view_formats,
|
||||||
desired_maximum_frame_latency: 1, // Minimiert Eingabelatenz auf 1 Frame
|
desired_maximum_frame_latency: 1, // Minimiert Eingabelatenz auf 1 Frame
|
||||||
};
|
};
|
||||||
surface.configure(&device, &config);
|
surface.configure(&device, &config);
|
||||||
|
|
||||||
// Kantenglättung der Zeichenfläche (4× Multisampling), falls das Format es erlaubt
|
// Kantenglättung der Zeichenfläche (4× Multisampling), falls das Format es erlaubt
|
||||||
let msaa_samples = if adapter
|
let msaa_samples = if adapter
|
||||||
.get_texture_format_features(config.format)
|
.get_texture_format_features(render_format(&config))
|
||||||
.flags
|
.flags
|
||||||
.sample_count_supported(MSAA_SAMPLES)
|
.sample_count_supported(MSAA_SAMPLES)
|
||||||
{
|
{
|
||||||
@@ -340,6 +360,7 @@ impl CanvasState {
|
|||||||
1
|
1
|
||||||
};
|
};
|
||||||
let msaa_view = create_msaa_view(&device, &config, msaa_samples);
|
let msaa_view = create_msaa_view(&device, &config, msaa_samples);
|
||||||
|
let stencil_view = create_stencil_view(&device, &config, msaa_samples);
|
||||||
|
|
||||||
// 5. Shader & Bind Group Layout
|
// 5. Shader & Bind Group Layout
|
||||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
@@ -406,7 +427,7 @@ impl CanvasState {
|
|||||||
module: &shader,
|
module: &shader,
|
||||||
entry_point: "fs_ink",
|
entry_point: "fs_ink",
|
||||||
targets: &[Some(wgpu::ColorTargetState {
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
format: config.format,
|
format: render_format(&config),
|
||||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||||
write_mask: wgpu::ColorWrites::ALL,
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
})],
|
})],
|
||||||
@@ -420,7 +441,7 @@ impl CanvasState {
|
|||||||
polygon_mode: wgpu::PolygonMode::Fill,
|
polygon_mode: wgpu::PolygonMode::Fill,
|
||||||
conservative: false,
|
conservative: false,
|
||||||
},
|
},
|
||||||
depth_stencil: None,
|
depth_stencil: Some(stroke_stencil()),
|
||||||
multisample: wgpu::MultisampleState {
|
multisample: wgpu::MultisampleState {
|
||||||
count: msaa_samples,
|
count: msaa_samples,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -428,6 +449,34 @@ impl CanvasState {
|
|||||||
multiview: None,
|
multiview: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Setzt den Stencil-Puffer zurück, wenn die Strichnummern überlaufen
|
||||||
|
let stencil_reset_pipeline =
|
||||||
|
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("Stencil Reset Pipeline"),
|
||||||
|
layout: Some(&pipeline_layout),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: "vs_fullscreen",
|
||||||
|
buffers: &[],
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: "fs_none",
|
||||||
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
|
format: render_format(&config),
|
||||||
|
blend: None,
|
||||||
|
write_mask: wgpu::ColorWrites::empty(),
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState::default(),
|
||||||
|
depth_stencil: Some(stencil_reset()),
|
||||||
|
multisample: wgpu::MultisampleState {
|
||||||
|
count: msaa_samples,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
multiview: None,
|
||||||
|
});
|
||||||
|
|
||||||
// TriangleList-Pipeline (für Hintergrundpapier und Muster)
|
// TriangleList-Pipeline (für Hintergrundpapier und Muster)
|
||||||
let triangle_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
let triangle_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
label: Some("Background TriangleList Pipeline"),
|
label: Some("Background TriangleList Pipeline"),
|
||||||
@@ -441,7 +490,7 @@ impl CanvasState {
|
|||||||
module: &shader,
|
module: &shader,
|
||||||
entry_point: "fs_main",
|
entry_point: "fs_main",
|
||||||
targets: &[Some(wgpu::ColorTargetState {
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
format: config.format,
|
format: render_format(&config),
|
||||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||||
write_mask: wgpu::ColorWrites::ALL,
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
})],
|
})],
|
||||||
@@ -455,7 +504,7 @@ impl CanvasState {
|
|||||||
polygon_mode: wgpu::PolygonMode::Fill,
|
polygon_mode: wgpu::PolygonMode::Fill,
|
||||||
conservative: false,
|
conservative: false,
|
||||||
},
|
},
|
||||||
depth_stencil: None,
|
depth_stencil: Some(passive_stencil()),
|
||||||
multisample: wgpu::MultisampleState {
|
multisample: wgpu::MultisampleState {
|
||||||
count: msaa_samples,
|
count: msaa_samples,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -475,19 +524,46 @@ impl CanvasState {
|
|||||||
queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad));
|
queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad));
|
||||||
|
|
||||||
let current_background = PageBackground::Grid5mm;
|
let current_background = PageBackground::Grid5mm;
|
||||||
let bg_mesh = generate_background_mesh(
|
let bg_mesh = generate_pattern_quad(
|
||||||
current_background,
|
current_background,
|
||||||
paper_theme.pattern_color(),
|
paper_theme.pattern_color(),
|
||||||
crate::document::A4_SIZE,
|
crate::document::A4_SIZE,
|
||||||
);
|
);
|
||||||
let background_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
let background_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("Background Mesh Buffer"),
|
label: Some("Pattern Quad Buffer"),
|
||||||
size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::<InkVertex>()) as u64,
|
size: (6 * std::mem::size_of::<PatternVertex>()) as u64,
|
||||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||||
mapped_at_creation: false,
|
mapped_at_creation: false,
|
||||||
});
|
});
|
||||||
queue.write_buffer(&background_buffer, 0, bytemuck::cast_slice(&bg_mesh));
|
queue.write_buffer(&background_buffer, 0, bytemuck::cast_slice(&bg_mesh));
|
||||||
|
|
||||||
|
// Karo, Linien und Punkte: pro Pixel im Shader berechnet
|
||||||
|
let pattern_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("Pattern Pipeline"),
|
||||||
|
layout: Some(&pipeline_layout),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: "vs_pattern",
|
||||||
|
buffers: &[PatternVertex::desc()],
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: "fs_pattern",
|
||||||
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
|
format: render_format(&config),
|
||||||
|
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||||
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState::default(),
|
||||||
|
depth_stencil: Some(passive_stencil()),
|
||||||
|
multisample: wgpu::MultisampleState {
|
||||||
|
count: msaa_samples,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
multiview: None,
|
||||||
|
});
|
||||||
|
|
||||||
// 7. GPU-Puffer für Live- und Committed-Geometrie
|
// 7. GPU-Puffer für Live- und Committed-Geometrie
|
||||||
let live_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
let live_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("Live Stroke Vertex Buffer"),
|
label: Some("Live Stroke Vertex Buffer"),
|
||||||
@@ -507,18 +583,20 @@ impl CanvasState {
|
|||||||
&device,
|
&device,
|
||||||
&shader,
|
&shader,
|
||||||
&bind_group_layout,
|
&bind_group_layout,
|
||||||
config.format,
|
render_format(&config),
|
||||||
msaa_samples,
|
msaa_samples,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 8. egui Renderer für das schwebende UI initialisieren
|
// 8. egui Renderer für das schwebende UI initialisieren
|
||||||
let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1);
|
let egui_renderer = egui_wgpu::Renderer::new(&device, render_format(&config), None, 1);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
surface,
|
surface,
|
||||||
adapter,
|
adapter,
|
||||||
msaa_samples,
|
msaa_samples,
|
||||||
msaa_view,
|
msaa_view,
|
||||||
|
stencil_view,
|
||||||
|
stencil_reset_pipeline,
|
||||||
presentation_warned: false,
|
presentation_warned: false,
|
||||||
device,
|
device,
|
||||||
queue,
|
queue,
|
||||||
@@ -535,8 +613,8 @@ impl CanvasState {
|
|||||||
current_stroke_width_mm: 0.5,
|
current_stroke_width_mm: 0.5,
|
||||||
paper_buffer,
|
paper_buffer,
|
||||||
background_buffer,
|
background_buffer,
|
||||||
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
|
|
||||||
background_vertex_count: bg_mesh.len() as u32,
|
background_vertex_count: bg_mesh.len() as u32,
|
||||||
|
pattern_pipeline,
|
||||||
current_background,
|
current_background,
|
||||||
paper_theme,
|
paper_theme,
|
||||||
adapt_ink: false,
|
adapt_ink: false,
|
||||||
@@ -632,14 +710,20 @@ impl CanvasState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn recalculate_viewport(&mut self) {
|
fn recalculate_viewport(&mut self) {
|
||||||
self.viewport = PageViewport::calculate_for(
|
let insets = (self.ui_inset_left, self.ui_inset_right);
|
||||||
self.size.width as f32,
|
let (width, height) = (self.size.width as f32, self.size.height as f32);
|
||||||
self.size.height as f32,
|
self.viewport = if self.page_size == crate::document::ENDLESS_PAGE_SIZE {
|
||||||
Self::VIEWPORT_MARGIN,
|
PageViewport::calculate_endless(width, height, Self::VIEWPORT_MARGIN, insets)
|
||||||
(self.ui_inset_left, self.ui_inset_right),
|
} else {
|
||||||
self.fit_box,
|
PageViewport::calculate_for(
|
||||||
self.page_size,
|
width,
|
||||||
);
|
height,
|
||||||
|
Self::VIEWPORT_MARGIN,
|
||||||
|
insets,
|
||||||
|
self.fit_box,
|
||||||
|
self.page_size,
|
||||||
|
)
|
||||||
|
};
|
||||||
self.update_uniforms();
|
self.update_uniforms();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,7 +742,7 @@ impl CanvasState {
|
|||||||
if !self.presentation_warned {
|
if !self.presentation_warned {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[Grafik] Fenster kann derzeit nicht dargestellt werden (keine Darstellungsmodi). {}",
|
"[Grafik] Fenster kann derzeit nicht dargestellt werden (keine Darstellungsmodi). {}",
|
||||||
NO_USABLE_GPU_MESSAGE
|
crate::i18n::tr(NO_USABLE_GPU_MESSAGE)
|
||||||
);
|
);
|
||||||
self.presentation_warned = true;
|
self.presentation_warned = true;
|
||||||
}
|
}
|
||||||
@@ -668,6 +752,7 @@ impl CanvasState {
|
|||||||
}
|
}
|
||||||
self.surface.configure(&self.device, &self.config);
|
self.surface.configure(&self.device, &self.config);
|
||||||
self.msaa_view = create_msaa_view(&self.device, &self.config, self.msaa_samples);
|
self.msaa_view = create_msaa_view(&self.device, &self.config, self.msaa_samples);
|
||||||
|
self.stencil_view = create_stencil_view(&self.device, &self.config, self.msaa_samples);
|
||||||
self.recalculate_viewport();
|
self.recalculate_viewport();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -838,23 +923,12 @@ impl CanvasState {
|
|||||||
|
|
||||||
/// Erzeugt das Muster für Hintergrund und Papierfarbe neu und lädt es auf die GPU.
|
/// Erzeugt das Muster für Hintergrund und Papierfarbe neu und lädt es auf die GPU.
|
||||||
fn upload_background_mesh(&mut self) {
|
fn upload_background_mesh(&mut self) {
|
||||||
let mesh = generate_background_mesh(
|
let mesh = generate_pattern_quad(
|
||||||
self.current_background,
|
self.current_background,
|
||||||
self.pattern.color(self.paper_theme),
|
self.pattern.color(self.paper_theme),
|
||||||
self.page_size,
|
self.page_size,
|
||||||
);
|
);
|
||||||
self.background_vertex_count = mesh.len() as u32;
|
self.background_vertex_count = mesh.len() as u32;
|
||||||
|
|
||||||
if mesh.len() > self.background_vertex_capacity {
|
|
||||||
self.background_vertex_capacity = mesh.len() * 2;
|
|
||||||
self.background_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
|
||||||
label: Some("Background Mesh Buffer (Reallocated)"),
|
|
||||||
size: (self.background_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
|
|
||||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
|
||||||
mapped_at_creation: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if !mesh.is_empty() {
|
if !mesh.is_empty() {
|
||||||
self.queue
|
self.queue
|
||||||
.write_buffer(&self.background_buffer, 0, bytemuck::cast_slice(&mesh));
|
.write_buffer(&self.background_buffer, 0, bytemuck::cast_slice(&mesh));
|
||||||
@@ -962,20 +1036,39 @@ impl CanvasState {
|
|||||||
buffer
|
buffer
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
// Papier und Muster in einem Puffer (Nachbarseiten können anders groß sein)
|
// Eigenes Papier und Muster (Nachbarseiten können anders groß sein)
|
||||||
let mut background = generate_paper_quad(self.paper_theme.paper_color(), page.size);
|
let paper = generate_paper_quad(self.paper_theme.paper_color(), page.size);
|
||||||
background.extend(generate_background_mesh(
|
let pattern = generate_pattern_quad(
|
||||||
page.background,
|
page.background,
|
||||||
self.pattern.color(self.paper_theme),
|
self.pattern.color(self.paper_theme),
|
||||||
page.size,
|
page.size,
|
||||||
));
|
);
|
||||||
|
let paper_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("Neighbor Paper Buffer"),
|
||||||
|
size: std::mem::size_of_val(paper.as_slice()) as u64,
|
||||||
|
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
self.queue
|
||||||
|
.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper));
|
||||||
|
let pattern_buffer = (!pattern.is_empty()).then(|| {
|
||||||
|
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("Neighbor Pattern Buffer"),
|
||||||
|
size: std::mem::size_of_val(pattern.as_slice()) as u64,
|
||||||
|
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
self.queue
|
||||||
|
.write_buffer(&buffer, 0, bytemuck::cast_slice(&pattern));
|
||||||
|
buffer
|
||||||
|
});
|
||||||
NeighborPage {
|
NeighborPage {
|
||||||
fingerprint,
|
fingerprint,
|
||||||
offset: glam::Vec2::ZERO,
|
offset: glam::Vec2::ZERO,
|
||||||
camera_buffer,
|
camera_buffer,
|
||||||
camera_bind_group,
|
camera_bind_group,
|
||||||
background_buffer: vertex_buffer_with("Neighbor Background Buffer", &background),
|
paper_buffer,
|
||||||
background_vertex_count: background.len() as u32,
|
pattern_buffer,
|
||||||
vertex_buffer: vertex_buffer_with("Neighbor Strokes Buffer", &page.vertex_cache),
|
vertex_buffer: vertex_buffer_with("Neighbor Strokes Buffer", &page.vertex_cache),
|
||||||
stroke_ranges: page.stroke_ranges.clone(),
|
stroke_ranges: page.stroke_ranges.clone(),
|
||||||
}
|
}
|
||||||
@@ -1266,9 +1359,10 @@ impl CanvasState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let output = self.surface.get_current_texture()?;
|
let output = self.surface.get_current_texture()?;
|
||||||
let view = output
|
let view = output.texture.create_view(&wgpu::TextureViewDescriptor {
|
||||||
.texture
|
format: Some(render_format(&self.config)),
|
||||||
.create_view(&wgpu::TextureViewDescriptor::default());
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
let mut encoder = self
|
let mut encoder = self
|
||||||
.device
|
.device
|
||||||
@@ -1302,18 +1396,29 @@ impl CanvasState {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})],
|
})],
|
||||||
depth_stencil_attachment: None,
|
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||||
|
view: &self.stencil_view,
|
||||||
|
depth_ops: None,
|
||||||
|
stencil_ops: Some(wgpu::Operations {
|
||||||
|
load: wgpu::LoadOp::Clear(0),
|
||||||
|
store: wgpu::StoreOp::Discard,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
timestamp_writes: None,
|
timestamp_writes: None,
|
||||||
occlusion_query_set: None,
|
occlusion_query_set: None,
|
||||||
});
|
});
|
||||||
|
let mut stencil_ref = 0;
|
||||||
|
|
||||||
// 0. Nachbarseiten der durchgehenden Ansicht (je mit eigener Kamera)
|
// 0. Nachbarseiten der durchgehenden Ansicht (je mit eigener Kamera)
|
||||||
for (i, neighbor) in self.neighbors.iter().enumerate() {
|
for (i, neighbor) in self.neighbors.iter().enumerate() {
|
||||||
render_pass.set_bind_group(0, &neighbor.camera_bind_group, &[]);
|
render_pass.set_bind_group(0, &neighbor.camera_bind_group, &[]);
|
||||||
render_pass.set_pipeline(&self.triangle_pipeline);
|
render_pass.set_pipeline(&self.triangle_pipeline);
|
||||||
if let Some(buffer) = &neighbor.background_buffer {
|
render_pass.set_vertex_buffer(0, neighbor.paper_buffer.slice(..));
|
||||||
|
render_pass.draw(0..6, 0..1);
|
||||||
|
if let Some(buffer) = &neighbor.pattern_buffer {
|
||||||
|
render_pass.set_pipeline(&self.pattern_pipeline);
|
||||||
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
||||||
render_pass.draw(0..neighbor.background_vertex_count, 0..1);
|
render_pass.draw(0..6, 0..1);
|
||||||
}
|
}
|
||||||
self.image_layer.draw(&mut render_pass, i + 1);
|
self.image_layer.draw(&mut render_pass, i + 1);
|
||||||
if let Some(buffer) = &neighbor.vertex_buffer {
|
if let Some(buffer) = &neighbor.vertex_buffer {
|
||||||
@@ -1321,6 +1426,7 @@ impl CanvasState {
|
|||||||
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
||||||
for range in &neighbor.stroke_ranges {
|
for range in &neighbor.stroke_ranges {
|
||||||
if range.end > range.start {
|
if range.end > range.start {
|
||||||
|
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
|
||||||
render_pass.draw(range.clone(), 0..1);
|
render_pass.draw(range.clone(), 0..1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1337,6 +1443,7 @@ impl CanvasState {
|
|||||||
|
|
||||||
// 2. Hintergrundmuster (5mm-Gitter, liniert oder Punktraster mit Vertex-Farbe)
|
// 2. Hintergrundmuster (5mm-Gitter, liniert oder Punktraster mit Vertex-Farbe)
|
||||||
if self.background_vertex_count > 0 {
|
if self.background_vertex_count > 0 {
|
||||||
|
render_pass.set_pipeline(&self.pattern_pipeline);
|
||||||
render_pass.set_vertex_buffer(0, self.background_buffer.slice(..));
|
render_pass.set_vertex_buffer(0, self.background_buffer.slice(..));
|
||||||
render_pass.draw(0..self.background_vertex_count, 0..1);
|
render_pass.draw(0..self.background_vertex_count, 0..1);
|
||||||
}
|
}
|
||||||
@@ -1351,6 +1458,7 @@ impl CanvasState {
|
|||||||
render_pass.set_vertex_buffer(0, self.committed_vertex_buffer.slice(..));
|
render_pass.set_vertex_buffer(0, self.committed_vertex_buffer.slice(..));
|
||||||
for range in &self.committed_strokes {
|
for range in &self.committed_strokes {
|
||||||
if range.end > range.start {
|
if range.end > range.start {
|
||||||
|
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
|
||||||
render_pass.draw(range.clone(), 0..1);
|
render_pass.draw(range.clone(), 0..1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1359,6 +1467,7 @@ impl CanvasState {
|
|||||||
// 5. Temporäre Live-Geometrie des aktuellen Strichs rendern (Zero Latency)
|
// 5. Temporäre Live-Geometrie des aktuellen Strichs rendern (Zero Latency)
|
||||||
if self.live_vertex_count > 0 {
|
if self.live_vertex_count > 0 {
|
||||||
render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..));
|
render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..));
|
||||||
|
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
|
||||||
render_pass.draw(0..self.live_vertex_count, 0..1);
|
render_pass.draw(0..self.live_vertex_count, 0..1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1395,6 +1504,20 @@ impl CanvasState {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gibt dem nächsten Strich eine eigene Stencil-Nummer: Die Strich-Pipeline zeichnet nur
|
||||||
|
/// Pixel, die noch nicht diese Nummer tragen. Nach 255 Strichen wird der Stencil-Puffer
|
||||||
|
/// zurückgesetzt, damit kein späterer Strich die Nummer eines früheren wiederfindet.
|
||||||
|
fn next_stroke_stencil<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>, stencil_ref: &mut u32) {
|
||||||
|
if *stencil_ref == u32::from(u8::MAX) {
|
||||||
|
pass.set_pipeline(&self.stencil_reset_pipeline);
|
||||||
|
pass.draw(0..3, 0..1);
|
||||||
|
pass.set_pipeline(&self.strip_pipeline);
|
||||||
|
*stencil_ref = 0;
|
||||||
|
}
|
||||||
|
*stencil_ref += 1;
|
||||||
|
pass.set_stencil_reference(*stencil_ref);
|
||||||
|
}
|
||||||
|
|
||||||
/// Rendert nur die Canvas-Fläche ohne UI (z. B. für Headless-Tests).
|
/// Rendert nur die Canvas-Fläche ohne UI (z. B. für Headless-Tests).
|
||||||
pub fn render_canvas_only(&mut self) -> Result<(), wgpu::SurfaceError> {
|
pub fn render_canvas_only(&mut self) -> Result<(), wgpu::SurfaceError> {
|
||||||
let dummy_delta = egui::TexturesDelta::default();
|
let dummy_delta = egui::TexturesDelta::default();
|
||||||
@@ -1406,6 +1529,14 @@ impl CanvasState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Format, in das gezeichnet wird: ohne sRGB-Umrechnung. Die Farben werden also in sRGB
|
||||||
|
/// gemischt – wie im PDF-Export, damit halbtransparente Tinte (Textmarker) am Bildschirm
|
||||||
|
/// genauso aussieht. Tintenfarben sind sRGB-Werte, Papier- und Musterfarben rechnet der
|
||||||
|
/// Shader aus linearen Werten um.
|
||||||
|
fn render_format(config: &wgpu::SurfaceConfiguration) -> wgpu::TextureFormat {
|
||||||
|
config.format.remove_srgb_suffix()
|
||||||
|
}
|
||||||
|
|
||||||
/// Multisampling der Zeichenfläche gegen gezackte Strichkanten.
|
/// Multisampling der Zeichenfläche gegen gezackte Strichkanten.
|
||||||
const MSAA_SAMPLES: u32 = 4;
|
const MSAA_SAMPLES: u32 = 4;
|
||||||
|
|
||||||
@@ -1427,7 +1558,7 @@ fn create_msaa_view(
|
|||||||
mip_level_count: 1,
|
mip_level_count: 1,
|
||||||
sample_count: samples,
|
sample_count: samples,
|
||||||
dimension: wgpu::TextureDimension::D2,
|
dimension: wgpu::TextureDimension::D2,
|
||||||
format: config.format,
|
format: render_format(config),
|
||||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||||
view_formats: &[],
|
view_formats: &[],
|
||||||
})
|
})
|
||||||
@@ -1435,6 +1566,76 @@ fn create_msaa_view(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Format des Stencil-Puffers der Zeichenfläche.
|
||||||
|
const STENCIL_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth24PlusStencil8;
|
||||||
|
|
||||||
|
/// Stencil-Puffer in Fenstergröße (mit derselben Abtastzahl wie die Zeichenfläche).
|
||||||
|
fn create_stencil_view(
|
||||||
|
device: &wgpu::Device,
|
||||||
|
config: &wgpu::SurfaceConfiguration,
|
||||||
|
samples: u32,
|
||||||
|
) -> wgpu::TextureView {
|
||||||
|
device
|
||||||
|
.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some("Stroke Stencil Buffer"),
|
||||||
|
size: wgpu::Extent3d {
|
||||||
|
width: config.width.max(1),
|
||||||
|
height: config.height.max(1),
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: samples,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: STENCIL_FORMAT,
|
||||||
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||||
|
view_formats: &[],
|
||||||
|
})
|
||||||
|
.create_view(&wgpu::TextureViewDescriptor::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stencil-Zustand mit gleicher Prüfung und Aktion für Vorder- und Rückseiten.
|
||||||
|
fn stencil_state(
|
||||||
|
compare: wgpu::CompareFunction,
|
||||||
|
pass_op: wgpu::StencilOperation,
|
||||||
|
) -> wgpu::DepthStencilState {
|
||||||
|
let face = wgpu::StencilFaceState {
|
||||||
|
compare,
|
||||||
|
fail_op: wgpu::StencilOperation::Keep,
|
||||||
|
depth_fail_op: wgpu::StencilOperation::Keep,
|
||||||
|
pass_op,
|
||||||
|
};
|
||||||
|
wgpu::DepthStencilState {
|
||||||
|
format: STENCIL_FORMAT,
|
||||||
|
depth_write_enabled: false,
|
||||||
|
depth_compare: wgpu::CompareFunction::Always,
|
||||||
|
stencil: wgpu::StencilState {
|
||||||
|
front: face,
|
||||||
|
back: face,
|
||||||
|
read_mask: 0xff,
|
||||||
|
write_mask: 0xff,
|
||||||
|
},
|
||||||
|
bias: wgpu::DepthBiasState::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pipelines ohne Strichnummer (Papier, Muster, Bilder) lassen den Stencil-Puffer unberührt.
|
||||||
|
pub(crate) fn passive_stencil() -> wgpu::DepthStencilState {
|
||||||
|
stencil_state(wgpu::CompareFunction::Always, wgpu::StencilOperation::Keep)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Striche: nur Pixel, die die eigene Nummer noch nicht tragen, und diese dann markieren.
|
||||||
|
fn stroke_stencil() -> wgpu::DepthStencilState {
|
||||||
|
stencil_state(
|
||||||
|
wgpu::CompareFunction::NotEqual,
|
||||||
|
wgpu::StencilOperation::Replace,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zurücksetzen: überall 0 eintragen.
|
||||||
|
fn stencil_reset() -> wgpu::DepthStencilState {
|
||||||
|
stencil_state(wgpu::CompareFunction::Always, wgpu::StencilOperation::Zero)
|
||||||
|
}
|
||||||
|
|
||||||
/// Zu rasterndes Textfeld.
|
/// Zu rasterndes Textfeld.
|
||||||
struct TextSpec {
|
struct TextSpec {
|
||||||
text: crate::text::TextBox,
|
text: crate::text::TextBox,
|
||||||
|
|||||||
+713
-10
@@ -5,6 +5,7 @@ use std::ops::Range;
|
|||||||
use crate::canvas::InkVertex;
|
use crate::canvas::InkVertex;
|
||||||
use crate::history::{HistoryAction, HistoryStack};
|
use crate::history::{HistoryAction, HistoryStack};
|
||||||
pub use crate::spline::{finalize_stroke, finalize_stroke_with_config};
|
pub use crate::spline::{finalize_stroke, finalize_stroke_with_config};
|
||||||
|
use crate::t;
|
||||||
|
|
||||||
/// Standardmaße DIN A4 in Millimetern.
|
/// Standardmaße DIN A4 in Millimetern.
|
||||||
pub const A4_WIDTH_MM: f32 = 210.0;
|
pub const A4_WIDTH_MM: f32 = 210.0;
|
||||||
@@ -19,6 +20,21 @@ pub const PAGE_HEIGHT: f32 = 2970.0;
|
|||||||
pub const A4_SIZE: glam::Vec2 = glam::Vec2::new(PAGE_WIDTH, PAGE_HEIGHT);
|
pub const A4_SIZE: glam::Vec2 = glam::Vec2::new(PAGE_WIDTH, PAGE_HEIGHT);
|
||||||
pub const UNITS_PER_MM: f32 = 10.0;
|
pub const UNITS_PER_MM: f32 = 10.0;
|
||||||
|
|
||||||
|
/// Größe der Seite eines endlosen Notizbuchs (10 × 10 m, Issue #30): praktisch unbegrenzt,
|
||||||
|
/// aber mit festen, positiven Koordinaten – Ansicht, Speichern und Rückgängig funktionieren
|
||||||
|
/// so wie bei jeder anderen Seite.
|
||||||
|
pub const ENDLESS_PAGE_SIZE: glam::Vec2 = glam::Vec2::new(100_000.0, 100_000.0);
|
||||||
|
/// Linke obere Ecke des Startausschnitts (ein DIN-A4-Rahmen in der Mitte der endlosen Seite).
|
||||||
|
pub const ENDLESS_HOME: glam::Vec2 = glam::Vec2::new(
|
||||||
|
(ENDLESS_PAGE_SIZE.x - PAGE_WIDTH) / 2.0,
|
||||||
|
(ENDLESS_PAGE_SIZE.y - PAGE_HEIGHT) / 2.0,
|
||||||
|
);
|
||||||
|
/// Rand um den Inhalt beim Export einer endlosen Seite (10 mm).
|
||||||
|
const ENDLESS_EXPORT_MARGIN: f32 = 100.0;
|
||||||
|
/// Raster, auf das der Exportausschnitt einer endlosen Seite fällt: Vielfaches der Abstände von
|
||||||
|
/// Karos/Punkten (5 mm) und Linien (7 mm), damit das Muster wie am Bildschirm liegt.
|
||||||
|
const ENDLESS_EXPORT_SNAP: f32 = 350.0;
|
||||||
|
|
||||||
/// Ein gefilterter Stylus-Abtastpunkt in DIN-A4-Seitenkoordinaten.
|
/// Ein gefilterter Stylus-Abtastpunkt in DIN-A4-Seitenkoordinaten.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub struct StylusSample {
|
pub struct StylusSample {
|
||||||
@@ -197,18 +213,19 @@ pub const IMAGE_UNITS_PER_PIXEL: f32 = UNITS_PER_MM * 25.4 / 96.0;
|
|||||||
|
|
||||||
/// Ecken (oben links, oben rechts, unten rechts, unten links) für ein neu eingefügtes Bild
|
/// Ecken (oben links, oben rechts, unten rechts, unten links) für ein neu eingefügtes Bild
|
||||||
/// von `width` × `height` Pixeln um `center`: Bildschirmgröße bei 96 dpi, höchstens 80 % der
|
/// von `width` × `height` Pixeln um `center`: Bildschirmgröße bei 96 dpi, höchstens 80 % der
|
||||||
/// Seitenbreite und 60 % der Seitenhöhe eines Blatts der Größe `page_size`, und möglichst
|
/// Breite und 60 % der Höhe von `reference` (meist die Seitengröße, auf der endlosen Seite
|
||||||
/// ganz auf dem Blatt.
|
/// DIN A4), und möglichst ganz auf dem Blatt der Größe `page_size`.
|
||||||
pub fn place_image(
|
pub fn place_image(
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
center: glam::Vec2,
|
center: glam::Vec2,
|
||||||
page_size: glam::Vec2,
|
page_size: glam::Vec2,
|
||||||
|
reference: glam::Vec2,
|
||||||
) -> [glam::Vec2; 4] {
|
) -> [glam::Vec2; 4] {
|
||||||
let natural =
|
let natural =
|
||||||
glam::Vec2::new(width.max(1) as f32, height.max(1) as f32) * IMAGE_UNITS_PER_PIXEL;
|
glam::Vec2::new(width.max(1) as f32, height.max(1) as f32) * IMAGE_UNITS_PER_PIXEL;
|
||||||
let fit = (page_size.x * 0.8 / natural.x)
|
let fit = (reference.x * 0.8 / natural.x)
|
||||||
.min(page_size.y * 0.6 / natural.y)
|
.min(reference.y * 0.6 / natural.y)
|
||||||
.min(1.0);
|
.min(1.0);
|
||||||
let size = natural * fit;
|
let size = natural * fit;
|
||||||
let half = size / 2.0;
|
let half = size / 2.0;
|
||||||
@@ -722,6 +739,8 @@ pub struct Page {
|
|||||||
pub vertex_cache: Vec<InkVertex>,
|
pub vertex_cache: Vec<InkVertex>,
|
||||||
pub stroke_ranges: Vec<Range<u32>>,
|
pub stroke_ranges: Vec<Range<u32>>,
|
||||||
pub is_dirty: bool,
|
pub is_dirty: bool,
|
||||||
|
/// Zählt die Neuaufbauten des Vertex-Caches (erkennt geänderte Nachbarseiten)
|
||||||
|
pub cache_generation: u64,
|
||||||
pub is_loaded: bool,
|
pub is_loaded: bool,
|
||||||
pub persist: PersistState,
|
pub persist: PersistState,
|
||||||
pub history: HistoryStack,
|
pub history: HistoryStack,
|
||||||
@@ -742,6 +761,7 @@ impl Page {
|
|||||||
is_loaded: true,
|
is_loaded: true,
|
||||||
persist: PersistState::Normal,
|
persist: PersistState::Normal,
|
||||||
history: HistoryStack::new(),
|
history: HistoryStack::new(),
|
||||||
|
cache_generation: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -760,6 +780,7 @@ impl Page {
|
|||||||
is_loaded: false,
|
is_loaded: false,
|
||||||
persist: PersistState::Normal,
|
persist: PersistState::Normal,
|
||||||
history: HistoryStack::new(),
|
history: HistoryStack::new(),
|
||||||
|
cache_generation: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,6 +790,56 @@ impl Page {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Seite eines endlosen Notizbuchs (siehe [`ENDLESS_PAGE_SIZE`]).
|
||||||
|
pub fn is_endless(&self) -> bool {
|
||||||
|
self.size == ENDLESS_PAGE_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bezugsgröße für Einfügen und Textumbruch: DIN A4 auf der endlosen Seite, sonst die Seite.
|
||||||
|
pub fn reference_size(&self) -> glam::Vec2 {
|
||||||
|
if self.is_endless() {
|
||||||
|
A4_SIZE
|
||||||
|
} else {
|
||||||
|
self.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rechter Rand für ein neues Textfeld bei `x`: auf der endlosen Seite eine A4-Breite
|
||||||
|
/// weiter, sonst der Seitenrand.
|
||||||
|
pub fn text_right_edge(&self, x: f32) -> f32 {
|
||||||
|
if self.is_endless() {
|
||||||
|
x + PAGE_WIDTH
|
||||||
|
} else {
|
||||||
|
self.size.x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kopie der Seite als Ausschnitt ab `origin` mit Größe `size`: Alle Objekte werden um
|
||||||
|
/// `-origin` verschoben (für Export und Bilder einer Auswahl).
|
||||||
|
pub fn cropped(&self, origin: glam::Vec2, size: glam::Vec2) -> Page {
|
||||||
|
let mut page = self.clone();
|
||||||
|
for stroke in &mut page.strokes {
|
||||||
|
stroke.translate(-origin);
|
||||||
|
}
|
||||||
|
page.size = size;
|
||||||
|
page.is_dirty = true;
|
||||||
|
page
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exportausschnitt einer endlosen Seite: der Inhalt mit 10 mm Rand (leer: der
|
||||||
|
/// Startausschnitt). Die linke obere Ecke liegt auf dem Musterraster.
|
||||||
|
pub fn endless_export_bounds(&self) -> (glam::Vec2, glam::Vec2) {
|
||||||
|
let Some(content) = Self::selection_aabb(&self.strokes) else {
|
||||||
|
return (ENDLESS_HOME, A4_SIZE);
|
||||||
|
};
|
||||||
|
let max_width = self.strokes.iter().map(|s| s.width).fold(0.0, f32::max);
|
||||||
|
let content = content.expanded(max_width / 2.0 + ENDLESS_EXPORT_MARGIN);
|
||||||
|
let origin = (content.min / ENDLESS_EXPORT_SNAP).floor() * ENDLESS_EXPORT_SNAP;
|
||||||
|
let origin = origin.max(glam::Vec2::ZERO);
|
||||||
|
let end = content.max.min(self.size);
|
||||||
|
(origin, (end - origin).max(glam::Vec2::splat(100.0)))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_readonly(&self) -> bool {
|
pub fn is_readonly(&self) -> bool {
|
||||||
self.persist == PersistState::ReadOnlyAfterLoadError
|
self.persist == PersistState::ReadOnlyAfterLoadError
|
||||||
}
|
}
|
||||||
@@ -958,6 +1029,7 @@ impl Page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.is_dirty = false;
|
self.is_dirty = false;
|
||||||
|
self.cache_generation += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Berechnet die kombinierte AABB einer Menge von Strichen.
|
/// Berechnet die kombinierte AABB einer Menge von Strichen.
|
||||||
@@ -1188,6 +1260,61 @@ impl Notebook {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Endloses Notizbuch: eine einzige, praktisch unbegrenzte Seite (Issue #30).
|
||||||
|
pub fn is_endless(&self) -> bool {
|
||||||
|
self.pages.len() == 1 && self.pages[0].is_endless()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Macht aus einem Notizbuch mit einer einzigen Seite (ohne importiertes PDF) ein
|
||||||
|
/// endloses: Die Seite wird zur endlosen Fläche, ihr Inhalt landet im Startausschnitt.
|
||||||
|
/// Der Verlauf der Seite wird geleert (er kennt nur die alten Koordinaten).
|
||||||
|
pub fn make_endless(&mut self) -> bool {
|
||||||
|
if !self.can_make_endless() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let page = &mut self.pages[0];
|
||||||
|
for stroke in &mut page.strokes {
|
||||||
|
stroke.translate(ENDLESS_HOME);
|
||||||
|
}
|
||||||
|
page.size = ENDLESS_PAGE_SIZE;
|
||||||
|
page.history.clear();
|
||||||
|
page.is_dirty = true;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Siehe [`Self::make_endless`].
|
||||||
|
pub fn can_make_endless(&self) -> bool {
|
||||||
|
matches!(self.pages.as_slice(), [page] if page.is_loaded
|
||||||
|
&& !page.is_endless()
|
||||||
|
&& page.pdf_background.is_none())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Siehe [`Self::make_paged`].
|
||||||
|
pub fn can_make_paged(&self) -> bool {
|
||||||
|
if !self.is_endless() || !self.pages[0].is_loaded {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Page::selection_aabb(&self.pages[0].strokes).is_none_or(|content| {
|
||||||
|
content.min.cmpge(ENDLESS_HOME).all() && content.max.cmple(ENDLESS_HOME + A4_SIZE).all()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Macht aus einem endlosen Notizbuch wieder eine DIN-A4-Seite – nur, wenn der Inhalt im
|
||||||
|
/// Startausschnitt liegt (sonst ginge er über den Seitenrand). Leert den Verlauf der Seite.
|
||||||
|
pub fn make_paged(&mut self) -> bool {
|
||||||
|
if !self.can_make_paged() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let page = &mut self.pages[0];
|
||||||
|
for stroke in &mut page.strokes {
|
||||||
|
stroke.translate(-ENDLESS_HOME);
|
||||||
|
}
|
||||||
|
page.size = A4_SIZE;
|
||||||
|
page.history.clear();
|
||||||
|
page.is_dirty = true;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Hängt eine neue Seite mit dem angegebenen Hintergrund und der Größe der aktuellen
|
/// Hängt eine neue Seite mit dem angegebenen Hintergrund und der Größe der aktuellen
|
||||||
/// Seite an.
|
/// Seite an.
|
||||||
pub fn add_page(&mut self, background: PageBackground) -> usize {
|
pub fn add_page(&mut self, background: PageBackground) -> usize {
|
||||||
@@ -1217,6 +1344,29 @@ impl Notebook {
|
|||||||
index
|
index
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Entfernt die aktuelle Seite und gibt sie zurück; danach ist die folgende Seite aktuell
|
||||||
|
/// (bzw. die vorige, wenn es die letzte war). Die einzige Seite eines Notizbuchs bleibt
|
||||||
|
/// stehen (`None`).
|
||||||
|
pub fn remove_current_page(&mut self) -> Option<Page> {
|
||||||
|
if self.pages.len() < 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let page = self.pages.remove(self.current_page_index);
|
||||||
|
self.load_warnings.remove(&page.id);
|
||||||
|
self.current_page_index = self.current_page_index.min(self.pages.len() - 1);
|
||||||
|
Some(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setzt eine mit [`Self::remove_current_page`] entfernte Seite wieder an `index` ein
|
||||||
|
/// (höchstens ans Ende) und macht sie zur aktuellen Seite; gibt ihren Index zurück.
|
||||||
|
pub fn restore_page(&mut self, index: usize, page: Page) -> usize {
|
||||||
|
let index = index.min(self.pages.len());
|
||||||
|
self.next_page_id = self.next_page_id.max(page.id + 1);
|
||||||
|
self.pages.insert(index, page);
|
||||||
|
self.current_page_index = index;
|
||||||
|
index
|
||||||
|
}
|
||||||
|
|
||||||
/// Durchgehende Ansicht: Reicht ein neuer Strich auf der letzten Seite bis in deren
|
/// Durchgehende Ansicht: Reicht ein neuer Strich auf der letzten Seite bis in deren
|
||||||
/// unteres Fünftel ([`AUTO_APPEND_FRACTION`]), wird darunter eine leere Seite mit gleichem
|
/// unteres Fünftel ([`AUTO_APPEND_FRACTION`]), wird darunter eine leere Seite mit gleichem
|
||||||
/// Hintergrund angehängt. `true`, wenn eine Seite angehängt wurde.
|
/// Hintergrund angehängt. `true`, wenn eine Seite angehängt wurde.
|
||||||
@@ -1278,10 +1428,10 @@ impl Notebook {
|
|||||||
|
|
||||||
/// Text für das On-Screen-Display (OSD), z. B. "Seite 3 / 12".
|
/// Text für das On-Screen-Display (OSD), z. B. "Seite 3 / 12".
|
||||||
pub fn osd_text(&self) -> String {
|
pub fn osd_text(&self) -> String {
|
||||||
format!(
|
t!(
|
||||||
"Seite {} / {}",
|
"Seite {number} / {count}",
|
||||||
self.current_page_index + 1,
|
number = self.current_page_index + 1,
|
||||||
self.pages.len()
|
count = self.pages.len()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1292,6 +1442,308 @@ impl Default for Notebook {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Abstand zum oberen Rand, mit dem ein Objekt auf der nächsten Seite landet, wenn es beim
|
||||||
|
/// Platzschaffen über das Seitenende rutschen würde (10 mm).
|
||||||
|
pub const SPACE_PAGE_BREAK_MARGIN: f32 = 10.0 * UNITS_PER_MM;
|
||||||
|
|
||||||
|
/// Ein beim Platzschaffen verschobener Strich: vorher und nachher, jeweils mit Seite.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct StrokeMove {
|
||||||
|
pub from_page: u64,
|
||||||
|
pub before: Stroke,
|
||||||
|
pub to_page: u64,
|
||||||
|
pub after: Stroke,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Für das Platzschaffen neu eingefügte Seite.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AddedPage {
|
||||||
|
pub id: u64,
|
||||||
|
/// Seite, hinter der sie liegt
|
||||||
|
pub after_page: u64,
|
||||||
|
pub size: glam::Vec2,
|
||||||
|
pub background: PageBackground,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Platzschaffen-Schritt (für Rückgängig und Wiederholen).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SpaceShift {
|
||||||
|
pub moves: Vec<StrokeMove>,
|
||||||
|
pub added_pages: Vec<AddedPage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Notebook {
|
||||||
|
/// Seiten, die beim Platzschaffen auf Seite `page_index` mitrutschen: sie selbst und die
|
||||||
|
/// folgenden bis vor die nächste Seite mit importiertem PDF (deren Notizen gehören zur
|
||||||
|
/// PDF-Seite). `None` auf einer PDF-Seite.
|
||||||
|
pub fn space_run(&self, page_index: usize) -> Option<std::ops::Range<usize>> {
|
||||||
|
let page = self.pages.get(page_index)?;
|
||||||
|
if page.pdf_background.is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let end = (page_index + 1..self.pages.len())
|
||||||
|
.find(|&i| self.pages[i].pdf_background.is_some())
|
||||||
|
.unwrap_or(self.pages.len());
|
||||||
|
Some(page_index..end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie weit sich der Inhalt unter `y` auf Seite `page_index` höchstens nach oben schieben
|
||||||
|
/// lässt: bis an das unterste Objekt darüber (bzw. den oberen Seitenrand).
|
||||||
|
pub fn space_up_limit(&self, page_index: usize, y: f32) -> f32 {
|
||||||
|
let Some(page) = self.pages.get(page_index) else {
|
||||||
|
return 0.0;
|
||||||
|
};
|
||||||
|
let bottom = page
|
||||||
|
.strokes
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.aabb.min.y < y)
|
||||||
|
.map(|s| s.aabb.max.y)
|
||||||
|
.fold(0.0_f32, f32::max);
|
||||||
|
(y - bottom).max(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Platz schaffen: Alles, was auf Seite `page_index` unterhalb von `y` beginnt, und der
|
||||||
|
/// ganze Inhalt der folgenden Seiten (siehe [`Self::space_run`]) rutscht um `dy` nach unten
|
||||||
|
/// (negativ: nach oben, höchstens bis [`Self::space_up_limit`]). Die Seiten gelten dabei
|
||||||
|
/// als durchgehende Spalte: Was über ein Seitenende hinausragen würde, beginnt auf der
|
||||||
|
/// nächsten Seite (mit [`SPACE_PAGE_BREAK_MARGIN`] Abstand), alles danach rutscht
|
||||||
|
/// entsprechend weiter; fehlende Seiten werden angehängt. Der Schritt landet im Verlauf
|
||||||
|
/// der Seite und gibt die Kennungen der geänderten Seiten zurück (`None`: nichts zu tun).
|
||||||
|
pub fn make_space(&mut self, page_index: usize, y: f32, dy: f32) -> Option<Vec<u64>> {
|
||||||
|
let run = self.space_run(page_index)?;
|
||||||
|
if self.pages[run.clone()].iter().any(|p| !p.is_loaded) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let dy = if dy < 0.0 {
|
||||||
|
dy.max(-self.space_up_limit(page_index, y))
|
||||||
|
} else {
|
||||||
|
dy
|
||||||
|
};
|
||||||
|
if dy.abs() < 1.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Endlose Seite: keine weiteren Seiten anlegen, nichts über den Rand schieben
|
||||||
|
let page = &self.pages[page_index];
|
||||||
|
if page.is_endless()
|
||||||
|
&& page
|
||||||
|
.strokes
|
||||||
|
.iter()
|
||||||
|
.any(|s| s.aabb.min.y >= y && s.aabb.max.y + dy > page.size.y)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seiten als durchgehende Spalte: Lage jeder Seite (auch noch anzulegender)
|
||||||
|
let last_size = self.pages[run.end - 1].size;
|
||||||
|
let mut tops = Vec::new();
|
||||||
|
let mut heights = Vec::new();
|
||||||
|
let mut top = 0.0;
|
||||||
|
for page in &self.pages[run.clone()] {
|
||||||
|
tops.push(top);
|
||||||
|
heights.push(page.size.y);
|
||||||
|
top += page.size.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verschoben wird alles unterhalb von y, in der Reihenfolge von oben nach unten
|
||||||
|
let mut movable: Vec<(usize, f32, Stroke)> = Vec::new();
|
||||||
|
for (k, page) in self.pages[run.clone()].iter().enumerate() {
|
||||||
|
for stroke in &page.strokes {
|
||||||
|
if k > 0 || stroke.aabb.min.y >= y {
|
||||||
|
movable.push((k, tops[k] + stroke.aabb.min.y, stroke.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if movable.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
movable.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||||
|
|
||||||
|
let mut extra = 0.0_f32;
|
||||||
|
let mut targets = Vec::with_capacity(movable.len());
|
||||||
|
for (_, global_top, stroke) in &movable {
|
||||||
|
let height = stroke.aabb.max.y - stroke.aabb.min.y;
|
||||||
|
let mut new_top = global_top + dy + extra;
|
||||||
|
let mut target = 0;
|
||||||
|
for _ in 0..2 {
|
||||||
|
// Seite, auf der das Objekt beginnt (bei Bedarf weitere Seiten)
|
||||||
|
target = 0;
|
||||||
|
while target + 1 < tops.len() && tops[target + 1] <= new_top {
|
||||||
|
target += 1;
|
||||||
|
}
|
||||||
|
while tops[target] + heights[target] <= new_top {
|
||||||
|
tops.push(tops[target] + heights[target]);
|
||||||
|
heights.push(last_size.y);
|
||||||
|
target += 1;
|
||||||
|
}
|
||||||
|
let page_end = tops[target] + heights[target];
|
||||||
|
let fits_on_a_page = height <= heights[target] - 2.0 * SPACE_PAGE_BREAK_MARGIN;
|
||||||
|
if new_top + height <= page_end || !fits_on_a_page {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Ragt über das Seitenende: auf die nächste Seite, alles Folgende rutscht mit
|
||||||
|
let margin = if dy > 0.0 {
|
||||||
|
SPACE_PAGE_BREAK_MARGIN
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let bump = page_end + margin - new_top;
|
||||||
|
extra += bump;
|
||||||
|
new_top += bump;
|
||||||
|
}
|
||||||
|
targets.push((target, new_top));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fehlende Seiten hinter dem Abschnitt einfügen
|
||||||
|
let needed = targets.iter().map(|(t, _)| t + 1).max().unwrap_or(0);
|
||||||
|
let mut added_pages = Vec::new();
|
||||||
|
let background = self.pages[run.end - 1].background;
|
||||||
|
for k in run.len()..needed {
|
||||||
|
let after_page = self.pages[run.start + k - 1].id;
|
||||||
|
let id = self.next_page_id;
|
||||||
|
self.next_page_id += 1;
|
||||||
|
self.pages.insert(
|
||||||
|
run.start + k,
|
||||||
|
Page::new(id, background).with_size(last_size),
|
||||||
|
);
|
||||||
|
added_pages.push(AddedPage {
|
||||||
|
id,
|
||||||
|
after_page,
|
||||||
|
size: last_size,
|
||||||
|
background,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Umbuchen
|
||||||
|
let mut moves = Vec::with_capacity(movable.len());
|
||||||
|
for ((k, _, before), (target, new_top)) in movable.into_iter().zip(targets) {
|
||||||
|
let mut after = before.clone();
|
||||||
|
after.translate(glam::Vec2::new(
|
||||||
|
0.0,
|
||||||
|
new_top - tops[target] - before.aabb.min.y,
|
||||||
|
));
|
||||||
|
moves.push(StrokeMove {
|
||||||
|
from_page: self.pages[run.start + k].id,
|
||||||
|
before,
|
||||||
|
to_page: self.pages[run.start + target].id,
|
||||||
|
after,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let shift = SpaceShift { moves, added_pages };
|
||||||
|
let changed = self.apply_space_moves(&shift, false);
|
||||||
|
self.pages[page_index]
|
||||||
|
.history
|
||||||
|
.push(HistoryAction::SpaceShift(Box::new(shift)));
|
||||||
|
Some(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bucht die Striche eines Platzschaffen-Schritts um (`undo`: zurück) und gibt die
|
||||||
|
/// geänderten Seiten zurück. Später geänderte oder gelöschte Striche bleiben unberührt.
|
||||||
|
fn apply_space_moves(&mut self, shift: &SpaceShift, undo: bool) -> Vec<u64> {
|
||||||
|
let mut changed = Vec::new();
|
||||||
|
let index_of = |pages: &[Page], id: u64| pages.iter().position(|p| p.id == id);
|
||||||
|
for m in &shift.moves {
|
||||||
|
let (from, old, to, new) = if undo {
|
||||||
|
(m.to_page, &m.after, m.from_page, &m.before)
|
||||||
|
} else {
|
||||||
|
(m.from_page, &m.before, m.to_page, &m.after)
|
||||||
|
};
|
||||||
|
let (Some(from_i), Some(to_i)) =
|
||||||
|
(index_of(&self.pages, from), index_of(&self.pages, to))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(pos) = self.pages[from_i]
|
||||||
|
.strokes
|
||||||
|
.iter()
|
||||||
|
.position(|s| s.id == old.id)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
self.pages[from_i].strokes.remove(pos);
|
||||||
|
self.pages[to_i].strokes.push(new.clone());
|
||||||
|
for i in [from_i, to_i] {
|
||||||
|
self.pages[i].is_dirty = true;
|
||||||
|
if !changed.contains(&self.pages[i].id) {
|
||||||
|
changed.push(self.pages[i].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Macht die letzte Aktion der aktuellen Seite rückgängig; ein Platzschaffen-Schritt
|
||||||
|
/// betrifft dabei alle beteiligten Seiten. Gibt die geänderten Seiten zurück.
|
||||||
|
pub fn undo(&mut self) -> Option<Vec<u64>> {
|
||||||
|
let index = self.current_page_index;
|
||||||
|
if let Some(HistoryAction::SpaceShift(_)) = self.pages[index].history.undo_stack.last() {
|
||||||
|
let Some(HistoryAction::SpaceShift(shift)) = self.pages[index].history.undo_stack.pop()
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let mut changed = self.apply_space_moves(&shift, true);
|
||||||
|
// Dafür angelegte Seiten wieder entfernen, sofern sie leer geblieben sind
|
||||||
|
for added in shift.added_pages.iter().rev() {
|
||||||
|
if let Some(i) = self.pages.iter().position(|p| p.id == added.id) {
|
||||||
|
if self.pages[i].strokes.is_empty() && self.pages.len() > 1 {
|
||||||
|
self.pages.remove(i);
|
||||||
|
if self.current_page_index >= i && self.current_page_index > 0 {
|
||||||
|
self.current_page_index -= 1;
|
||||||
|
}
|
||||||
|
changed.retain(|&id| id != added.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Der Schritt gehört weiter zur aktuellen Seite (sie selbst wird nie entfernt)
|
||||||
|
let owner = self.current_page_index;
|
||||||
|
self.pages[owner]
|
||||||
|
.history
|
||||||
|
.redo_stack
|
||||||
|
.push(HistoryAction::SpaceShift(shift));
|
||||||
|
return Some(changed);
|
||||||
|
}
|
||||||
|
let page = &mut self.pages[index];
|
||||||
|
page.undo().then(|| vec![page.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stellt die zuletzt rückgängig gemachte Aktion der aktuellen Seite wieder her.
|
||||||
|
pub fn redo(&mut self) -> Option<Vec<u64>> {
|
||||||
|
let index = self.current_page_index;
|
||||||
|
if let Some(HistoryAction::SpaceShift(_)) = self.pages[index].history.redo_stack.last() {
|
||||||
|
let Some(HistoryAction::SpaceShift(shift)) = self.pages[index].history.redo_stack.pop()
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
// Entfernte Seiten wieder einfügen
|
||||||
|
for added in &shift.added_pages {
|
||||||
|
if self.pages.iter().any(|p| p.id == added.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let at = self
|
||||||
|
.pages
|
||||||
|
.iter()
|
||||||
|
.position(|p| p.id == added.after_page)
|
||||||
|
.map_or(self.pages.len(), |i| i + 1);
|
||||||
|
self.pages.insert(
|
||||||
|
at,
|
||||||
|
Page::new(added.id, added.background).with_size(added.size),
|
||||||
|
);
|
||||||
|
if at <= self.current_page_index {
|
||||||
|
self.current_page_index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let changed = self.apply_space_moves(&shift, false);
|
||||||
|
let owner = self.current_page_index;
|
||||||
|
self.pages[owner]
|
||||||
|
.history
|
||||||
|
.undo_stack
|
||||||
|
.push(HistoryAction::SpaceShift(shift));
|
||||||
|
return Some(changed);
|
||||||
|
}
|
||||||
|
let page = &mut self.pages[index];
|
||||||
|
page.redo().then(|| vec![page.id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Lage der aktuellen Seite im Fenster bei Zoom 100 % ohne Verschiebung: Ein Rahmen der
|
/// Lage der aktuellen Seite im Fenster bei Zoom 100 % ohne Verschiebung: Ein Rahmen der
|
||||||
/// Größe `fit_box` (alle Seiten des Notizbuchs passen hinein) wird ins Fenster eingepasst
|
/// Größe `fit_box` (alle Seiten des Notizbuchs passen hinein) wird ins Fenster eingepasst
|
||||||
/// (Letterboxing/Pillarboxing); die aktuelle Seite liegt darin waagerecht zentriert und oben.
|
/// (Letterboxing/Pillarboxing); die aktuelle Seite liegt darin waagerecht zentriert und oben.
|
||||||
@@ -1370,6 +1822,32 @@ impl PageViewport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ansicht der endlosen Seite: Maßstab und Lage wie bei einer DIN-A4-Seite, die am
|
||||||
|
/// Startausschnitt ([`ENDLESS_HOME`]) der endlosen Seite liegt.
|
||||||
|
pub fn calculate_endless(
|
||||||
|
window_width: f32,
|
||||||
|
window_height: f32,
|
||||||
|
margin: f32,
|
||||||
|
insets: (f32, f32),
|
||||||
|
) -> Self {
|
||||||
|
let a4 = Self::calculate_for(
|
||||||
|
window_width,
|
||||||
|
window_height,
|
||||||
|
margin,
|
||||||
|
insets,
|
||||||
|
A4_SIZE,
|
||||||
|
A4_SIZE,
|
||||||
|
);
|
||||||
|
Self {
|
||||||
|
page_x: a4.page_x - ENDLESS_HOME.x * a4.scale,
|
||||||
|
page_y: a4.page_y - ENDLESS_HOME.y * a4.scale,
|
||||||
|
page_width: ENDLESS_PAGE_SIZE.x * a4.scale,
|
||||||
|
page_height: ENDLESS_PAGE_SIZE.y * a4.scale,
|
||||||
|
page_size: ENDLESS_PAGE_SIZE,
|
||||||
|
..a4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Transformiert einen Fenster-Pixelpunkt in Seitenkoordinaten.
|
/// Transformiert einen Fenster-Pixelpunkt in Seitenkoordinaten.
|
||||||
///
|
///
|
||||||
/// Liefert `None`, falls der Punkt außerhalb der Seite liegt.
|
/// Liefert `None`, falls der Punkt außerhalb der Seite liegt.
|
||||||
@@ -1725,13 +2203,13 @@ mod tests {
|
|||||||
fn test_place_image() {
|
fn test_place_image() {
|
||||||
let center = glam::Vec2::new(PAGE_WIDTH / 2.0, PAGE_HEIGHT / 2.0);
|
let center = glam::Vec2::new(PAGE_WIDTH / 2.0, PAGE_HEIGHT / 2.0);
|
||||||
// Kleines Bild: Bildschirmgröße, zentriert
|
// Kleines Bild: Bildschirmgröße, zentriert
|
||||||
let [tl, tr, br, bl] = place_image(200, 100, center, A4_SIZE);
|
let [tl, tr, br, bl] = place_image(200, 100, center, A4_SIZE, A4_SIZE);
|
||||||
assert!(((tr - tl).length() - 200.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
|
assert!(((tr - tl).length() - 200.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
|
||||||
assert!(((bl - tl).length() - 100.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
|
assert!(((bl - tl).length() - 100.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
|
||||||
assert!(((tl + br) / 2.0 - center).length() < 1e-3);
|
assert!(((tl + br) / 2.0 - center).length() < 1e-3);
|
||||||
|
|
||||||
// Großer Screenshot: auf 80 % der Breite verkleinert, Seitenverhältnis bleibt
|
// Großer Screenshot: auf 80 % der Breite verkleinert, Seitenverhältnis bleibt
|
||||||
let [tl, tr, br, _] = place_image(3840, 2160, center, A4_SIZE);
|
let [tl, tr, br, _] = place_image(3840, 2160, center, A4_SIZE, A4_SIZE);
|
||||||
let size = br - tl;
|
let size = br - tl;
|
||||||
assert!((size.x - PAGE_WIDTH * 0.8).abs() < 1e-2);
|
assert!((size.x - PAGE_WIDTH * 0.8).abs() < 1e-2);
|
||||||
assert!((size.x / size.y - 3840.0 / 2160.0).abs() < 1e-3);
|
assert!((size.x / size.y - 3840.0 / 2160.0).abs() < 1e-3);
|
||||||
@@ -1743,6 +2221,7 @@ mod tests {
|
|||||||
400,
|
400,
|
||||||
glam::Vec2::new(-50.0, PAGE_HEIGHT + 50.0),
|
glam::Vec2::new(-50.0, PAGE_HEIGHT + 50.0),
|
||||||
A4_SIZE,
|
A4_SIZE,
|
||||||
|
A4_SIZE,
|
||||||
);
|
);
|
||||||
for c in corners {
|
for c in corners {
|
||||||
assert!(c.x >= -1e-3 && c.x <= PAGE_WIDTH + 1e-3);
|
assert!(c.x >= -1e-3 && c.x <= PAGE_WIDTH + 1e-3);
|
||||||
@@ -1982,6 +2461,144 @@ mod tests {
|
|||||||
assert!(new.pdf_background.is_none());
|
assert!(new.pdf_background.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Senkrechter Strich bei x = 300 von `y` bis `y + h`.
|
||||||
|
fn block(id: u64, y: f32, h: f32) -> Stroke {
|
||||||
|
let samples = [y, y + h]
|
||||||
|
.map(|y| StylusSample {
|
||||||
|
position: glam::Vec2::new(300.0, y),
|
||||||
|
pressure: 0.5,
|
||||||
|
timestamp: 0.0,
|
||||||
|
})
|
||||||
|
.to_vec();
|
||||||
|
Stroke::new(id, samples, Vec::new(), [0.0, 0.0, 0.0, 1.0])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn top_of(nb: &Notebook, page: usize, id: u64) -> Option<f32> {
|
||||||
|
nb.pages[page]
|
||||||
|
.strokes
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.id == id)
|
||||||
|
.map(|s| s.aabb.min.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_make_space_moves_content_below_and_following_pages() {
|
||||||
|
let mut nb = Notebook::new();
|
||||||
|
nb.add_page(PageBackground::Lined);
|
||||||
|
let a4 = A4_SIZE.y;
|
||||||
|
nb.pages[0].strokes = vec![block(1, 200.0, 100.0), block(2, 1000.0, 100.0)];
|
||||||
|
nb.pages[1].strokes = vec![block(3, 500.0, 100.0)];
|
||||||
|
|
||||||
|
// 30 mm Platz bei y = 800 auf Seite 1
|
||||||
|
let changed = nb.make_space(0, 800.0, 300.0).unwrap();
|
||||||
|
assert_eq!(top_of(&nb, 0, 1), Some(200.0), "darüber bleibt stehen");
|
||||||
|
assert_eq!(top_of(&nb, 0, 2), Some(1300.0));
|
||||||
|
assert_eq!(
|
||||||
|
top_of(&nb, 1, 3),
|
||||||
|
Some(800.0),
|
||||||
|
"die nächste Seite rutscht mit"
|
||||||
|
);
|
||||||
|
assert_eq!(changed.len(), 2);
|
||||||
|
assert_eq!(nb.pages.len(), 2);
|
||||||
|
|
||||||
|
// Rückgängig und wiederholen über beide Seiten
|
||||||
|
nb.current_page_index = 0;
|
||||||
|
assert!(nb.undo().is_some());
|
||||||
|
assert_eq!(top_of(&nb, 0, 2), Some(1000.0));
|
||||||
|
assert_eq!(top_of(&nb, 1, 3), Some(500.0));
|
||||||
|
assert!(nb.redo().is_some());
|
||||||
|
assert_eq!(top_of(&nb, 0, 2), Some(1300.0));
|
||||||
|
assert_eq!(top_of(&nb, 1, 3), Some(800.0));
|
||||||
|
|
||||||
|
// Über das Seitenende: auf die nächste Seite mit Abstand, das Folgende rutscht weiter
|
||||||
|
let mut nb = Notebook::new();
|
||||||
|
nb.pages[0].strokes = vec![block(1, a4 - 400.0, 200.0), block(2, a4 - 150.0, 100.0)];
|
||||||
|
nb.make_space(0, a4 - 500.0, 300.0).unwrap();
|
||||||
|
assert_eq!(nb.pages.len(), 2, "eine Seite wurde angehängt");
|
||||||
|
assert_eq!(nb.pages[1].background, nb.pages[0].background);
|
||||||
|
assert!(nb.pages[0].strokes.is_empty());
|
||||||
|
let first = top_of(&nb, 1, 1).unwrap();
|
||||||
|
assert!((first - SPACE_PAGE_BREAK_MARGIN).abs() < 1e-3, "{first}");
|
||||||
|
// Abstand zwischen beiden bleibt (50 Einheiten unter dem Ende des ersten)
|
||||||
|
assert!((top_of(&nb, 1, 2).unwrap() - (first + 250.0)).abs() < 1e-3);
|
||||||
|
// Rückgängig entfernt die angehängte leere Seite
|
||||||
|
assert!(nb.undo().is_some());
|
||||||
|
assert_eq!(nb.pages.len(), 1);
|
||||||
|
assert_eq!(top_of(&nb, 0, 2), Some(a4 - 150.0));
|
||||||
|
assert!(nb.redo().is_some());
|
||||||
|
assert_eq!(nb.pages.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_make_space_upwards_only_closes_empty_space() {
|
||||||
|
let mut nb = Notebook::new();
|
||||||
|
nb.pages[0].strokes = vec![block(1, 200.0, 100.0), block(2, 1000.0, 100.0)];
|
||||||
|
// Nach oben höchstens bis an das Objekt darüber (Unterkante 300)
|
||||||
|
assert_eq!(nb.space_up_limit(0, 900.0), 600.0);
|
||||||
|
nb.make_space(0, 900.0, -2000.0).unwrap();
|
||||||
|
assert_eq!(top_of(&nb, 0, 2), Some(400.0));
|
||||||
|
assert_eq!(top_of(&nb, 0, 1), Some(200.0));
|
||||||
|
// Kein Platz mehr: nichts zu tun
|
||||||
|
assert!(nb.make_space(0, 300.5, -50.0).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_make_space_stops_before_pdf_pages() {
|
||||||
|
let mut nb = Notebook::new();
|
||||||
|
nb.add_page(PageBackground::Blank);
|
||||||
|
nb.add_page(PageBackground::Blank);
|
||||||
|
nb.pages[1].pdf_background = Some(PdfBackground {
|
||||||
|
asset: "x.pdf".to_string(),
|
||||||
|
page: 0,
|
||||||
|
width_pt: 595.0,
|
||||||
|
height_pt: 842.0,
|
||||||
|
});
|
||||||
|
nb.pages[0].strokes = vec![block(1, 1000.0, 100.0)];
|
||||||
|
nb.pages[2].strokes = vec![block(2, 500.0, 100.0)];
|
||||||
|
assert!(nb.space_run(1).is_none(), "nicht auf PDF-Seiten");
|
||||||
|
assert_eq!(nb.space_run(0), Some(0..1));
|
||||||
|
nb.make_space(0, 900.0, 300.0).unwrap();
|
||||||
|
assert_eq!(top_of(&nb, 0, 1), Some(1300.0));
|
||||||
|
assert_eq!(
|
||||||
|
top_of(&nb, 2, 2),
|
||||||
|
Some(500.0),
|
||||||
|
"hinter der PDF-Seite bleibt alles"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remove_and_restore_current_page() {
|
||||||
|
let mut nb = Notebook::new();
|
||||||
|
// Die einzige Seite bleibt
|
||||||
|
assert!(nb.remove_current_page().is_none());
|
||||||
|
nb.add_page(PageBackground::Lined);
|
||||||
|
nb.add_page(PageBackground::DotGrid);
|
||||||
|
let ids: Vec<u64> = nb.pages.iter().map(|p| p.id).collect();
|
||||||
|
|
||||||
|
// Mittlere Seite: danach ist die folgende aktuell
|
||||||
|
nb.current_page_index = 1;
|
||||||
|
let removed = nb.remove_current_page().unwrap();
|
||||||
|
assert_eq!(removed.id, ids[1]);
|
||||||
|
assert_eq!(
|
||||||
|
nb.pages.iter().map(|p| p.id).collect::<Vec<_>>(),
|
||||||
|
[ids[0], ids[2]]
|
||||||
|
);
|
||||||
|
assert_eq!(nb.current_page_index, 1);
|
||||||
|
|
||||||
|
// Wiederherstellen an der alten Stelle
|
||||||
|
assert_eq!(nb.restore_page(1, removed), 1);
|
||||||
|
assert_eq!(nb.pages.iter().map(|p| p.id).collect::<Vec<_>>(), ids);
|
||||||
|
assert_eq!(nb.current_page_index, 1);
|
||||||
|
|
||||||
|
// Letzte Seite: danach ist die vorige aktuell
|
||||||
|
nb.current_page_index = 2;
|
||||||
|
let last = nb.remove_current_page().unwrap();
|
||||||
|
assert_eq!(last.id, ids[2]);
|
||||||
|
assert_eq!(nb.current_page_index, 1);
|
||||||
|
// Neue Seiten bekommen keine schon vergebene Kennung
|
||||||
|
assert!(nb.next_page_id > ids[2]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_append_page_when_writing_at_bottom_of_last_page() {
|
fn test_append_page_when_writing_at_bottom_of_last_page() {
|
||||||
let mut nb = Notebook::new();
|
let mut nb = Notebook::new();
|
||||||
@@ -2075,4 +2692,90 @@ mod tests {
|
|||||||
glam::Vec2::new(30.0, 40.0)
|
glam::Vec2::new(30.0, 40.0)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn line(id: u64, from: glam::Vec2, to: glam::Vec2) -> Stroke {
|
||||||
|
let sample = |position, timestamp| StylusSample {
|
||||||
|
position,
|
||||||
|
pressure: 0.5,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
Stroke::new(
|
||||||
|
id,
|
||||||
|
vec![sample(from, 0.0), sample(to, 0.01)],
|
||||||
|
Vec::new(),
|
||||||
|
[0.0, 0.0, 0.0, 1.0],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_endless_notebook_round_trip() {
|
||||||
|
let mut nb = Notebook::new();
|
||||||
|
nb.pages[0].add_stroke(line(
|
||||||
|
1,
|
||||||
|
glam::Vec2::new(100.0, 100.0),
|
||||||
|
glam::Vec2::new(500.0, 300.0),
|
||||||
|
));
|
||||||
|
assert!(!nb.is_endless());
|
||||||
|
assert!(nb.make_endless());
|
||||||
|
assert!(nb.is_endless());
|
||||||
|
// Inhalt liegt im Startausschnitt, der Verlauf ist leer
|
||||||
|
let p = nb.pages[0].strokes[0].points[0].position;
|
||||||
|
assert!((p - (ENDLESS_HOME + glam::Vec2::new(100.0, 100.0))).length() < 1e-3);
|
||||||
|
assert!(!nb.pages[0].history.can_undo());
|
||||||
|
assert!(!nb.make_endless(), "schon endlos");
|
||||||
|
|
||||||
|
// Zurück zu Seiten: Inhalt wieder an der alten Stelle
|
||||||
|
assert!(nb.make_paged());
|
||||||
|
assert_eq!(nb.pages[0].size, A4_SIZE);
|
||||||
|
let p = nb.pages[0].strokes[0].points[0].position;
|
||||||
|
assert!((p - glam::Vec2::new(100.0, 100.0)).length() < 1e-2);
|
||||||
|
|
||||||
|
// Inhalt außerhalb des Startausschnitts: kein Zurück
|
||||||
|
assert!(nb.make_endless());
|
||||||
|
nb.pages[0].add_stroke(line(2, ENDLESS_HOME - 500.0, ENDLESS_HOME - 400.0));
|
||||||
|
assert!(!nb.can_make_paged());
|
||||||
|
assert!(!nb.make_paged());
|
||||||
|
assert!(nb.is_endless());
|
||||||
|
|
||||||
|
// Mehrere Seiten oder PDF-Seite: nicht endlos
|
||||||
|
let mut two = Notebook::new();
|
||||||
|
two.add_page(PageBackground::Blank);
|
||||||
|
assert!(!two.make_endless());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_endless_viewport_shows_home_like_a4() {
|
||||||
|
let a4 = PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), A4_SIZE, A4_SIZE);
|
||||||
|
let endless = PageViewport::calculate_endless(1600.0, 900.0, 20.0, (0.0, 0.0));
|
||||||
|
assert_eq!(endless.page_size, ENDLESS_PAGE_SIZE);
|
||||||
|
assert!((endless.scale - a4.scale).abs() < 1e-6);
|
||||||
|
// Die Ecke des Startausschnitts liegt dort, wo sonst die A4-Seite beginnt
|
||||||
|
let home = endless
|
||||||
|
.screen_to_page(a4.page_x + 1.0, a4.page_y + 1.0)
|
||||||
|
.unwrap();
|
||||||
|
assert!((home - ENDLESS_HOME - glam::Vec2::splat(1.0 / a4.scale)).length() < 0.05);
|
||||||
|
// Weit neben dem Startausschnitt lässt sich weiter schreiben
|
||||||
|
assert!(endless.screen_to_page(-5000.0, -5000.0).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_endless_export_bounds_follow_content() {
|
||||||
|
let mut page = Page::new(1, PageBackground::Grid5mm).with_size(ENDLESS_PAGE_SIZE);
|
||||||
|
// Leer: der Startausschnitt
|
||||||
|
assert_eq!(page.endless_export_bounds(), (ENDLESS_HOME, A4_SIZE));
|
||||||
|
|
||||||
|
let from = glam::Vec2::new(30_123.0, 40_456.0);
|
||||||
|
let to = glam::Vec2::new(36_000.0, 41_000.0);
|
||||||
|
page.add_stroke(line(1, from, to));
|
||||||
|
let (origin, size) = page.endless_export_bounds();
|
||||||
|
// Ecke auf dem Musterraster, Inhalt mit Rand vollständig enthalten
|
||||||
|
assert_eq!(origin % ENDLESS_EXPORT_SNAP, glam::Vec2::ZERO);
|
||||||
|
assert!(origin.cmple(from - ENDLESS_EXPORT_MARGIN).all());
|
||||||
|
assert!((origin + size).cmpge(to + ENDLESS_EXPORT_MARGIN).all());
|
||||||
|
assert!(size.x < 7_000.0 && size.y < 2_000.0, "{size:?}");
|
||||||
|
|
||||||
|
let cropped = page.cropped(origin, size);
|
||||||
|
assert_eq!(cropped.size, size);
|
||||||
|
assert!((cropped.strokes[0].points[0].position - (from - origin)).length() < 1e-2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+246
-22
@@ -24,6 +24,7 @@ use crate::background::{PatternColor, DOT_SIZE, GRID_5MM_SPACING, LINED_SPACING}
|
|||||||
use crate::document::{
|
use crate::document::{
|
||||||
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
|
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
|
||||||
};
|
};
|
||||||
|
use crate::t;
|
||||||
use ab_glyph::Font as _;
|
use ab_glyph::Font as _;
|
||||||
|
|
||||||
use crate::text::TextFont;
|
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 {
|
pub fn default_documents_dir() -> PathBuf {
|
||||||
if let Ok(dir) = std::env::var("XDG_DOCUMENTS_DIR") {
|
if let Ok(dir) = std::env::var("XDG_DOCUMENTS_DIR") {
|
||||||
if !dir.trim().is_empty() {
|
if !dir.trim().is_empty() {
|
||||||
@@ -55,7 +58,16 @@ pub fn default_documents_dir() -> PathBuf {
|
|||||||
|
|
||||||
if let Ok(home) = std::env::var("HOME") {
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
if !home.trim().is_empty() {
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,8 +191,30 @@ pub fn export_notebook_to_bytes(
|
|||||||
assets: Option<&AssetStore>,
|
assets: Option<&AssetStore>,
|
||||||
pattern: PatternColor,
|
pattern: PatternColor,
|
||||||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||||
|
// Endlose Seite: nur der beschriebene Ausschnitt wird zur PDF-Seite
|
||||||
|
if notebook.pages.iter().any(|p| p.is_endless()) {
|
||||||
|
let pages = notebook
|
||||||
|
.pages
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
if p.is_endless() {
|
||||||
|
let (origin, size) = p.endless_export_bounds();
|
||||||
|
p.cropped(origin, size)
|
||||||
|
} else {
|
||||||
|
p.clone()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let cropped = Notebook::from_pages(
|
||||||
|
pages,
|
||||||
|
notebook.current_page_index,
|
||||||
|
notebook.next_stroke_id,
|
||||||
|
notebook.next_page_id,
|
||||||
|
);
|
||||||
|
return export_notebook_to_bytes(&cropped, strategy, assets, pattern);
|
||||||
|
}
|
||||||
let doc = generate_pdf_document(notebook, strategy, assets, pattern)?;
|
let doc = generate_pdf_document(notebook, strategy, assets, pattern)?;
|
||||||
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
|
let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?;
|
||||||
|
|
||||||
// Importierte PDF-Seiten als Vektorgrafik unterlegen
|
// Importierte PDF-Seiten als Vektorgrafik unterlegen
|
||||||
let underlays = pdf_underlays(notebook);
|
let underlays = pdf_underlays(notebook);
|
||||||
@@ -206,7 +240,7 @@ pub fn export_notebook_to_bytes(
|
|||||||
raster.insert(underlay.target_page);
|
raster.insert(underlay.target_page);
|
||||||
}
|
}
|
||||||
let doc = generate_pdf_document_with(notebook, strategy, assets, pattern, &raster)?;
|
let doc = generate_pdf_document_with(notebook, strategy, assets, pattern, &raster)?;
|
||||||
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
|
let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?;
|
||||||
let remaining: Vec<_> = underlays
|
let remaining: Vec<_> = underlays
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|u| !raster.contains(&u.target_page))
|
.filter(|u| !raster.contains(&u.target_page))
|
||||||
@@ -247,6 +281,77 @@ fn fix_text_encoding(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Name des Grafikzustands für halbtransparente Tinte mit Deckkraft `alpha` (auf Prozent
|
||||||
|
/// gerundet); `None` für deckende Farben.
|
||||||
|
fn alpha_state_name(alpha: f32) -> Option<String> {
|
||||||
|
let percent = (alpha.clamp(0.0, 1.0) * 100.0).round() as u32;
|
||||||
|
(percent < 100).then(|| format!("InkAlpha{percent}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// printpdf kann keine Grafikzustände mit Deckkraft anlegen: Die Tintenebene verweist mit
|
||||||
|
/// `gs` auf die Namen aus [`alpha_state_name`], die Zustände selbst (`ca`/`CA`) werden hier
|
||||||
|
/// in die Ressourcen jeder Seite eingetragen. Ohne halbtransparente Striche bleibt das PDF
|
||||||
|
/// unverändert.
|
||||||
|
fn add_alpha_states(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||||
|
use printpdf::lopdf::{Dictionary, Object};
|
||||||
|
|
||||||
|
let mut states: Vec<(String, f32)> = notebook
|
||||||
|
.pages
|
||||||
|
.iter()
|
||||||
|
.flat_map(|p| &p.strokes)
|
||||||
|
.filter(|s| !s.is_object())
|
||||||
|
.filter_map(|s| alpha_state_name(s.color[3]).map(|name| (name, s.color[3])))
|
||||||
|
.collect();
|
||||||
|
if states.is_empty() {
|
||||||
|
return Ok(bytes);
|
||||||
|
}
|
||||||
|
states.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
states.dedup_by(|a, b| a.0 == b.0);
|
||||||
|
|
||||||
|
let mut doc = printpdf::lopdf::Document::load_mem(&bytes)?;
|
||||||
|
let page_ids: Vec<_> = doc.get_pages().into_values().collect();
|
||||||
|
for page_id in page_ids {
|
||||||
|
let resources = dict_object_id(&mut doc, page_id, b"Resources")?;
|
||||||
|
let ext_g_state = dict_object_id(&mut doc, resources, b"ExtGState")?;
|
||||||
|
let dict = doc.get_object_mut(ext_g_state)?.as_dict_mut()?;
|
||||||
|
for (name, alpha) in &states {
|
||||||
|
let alpha = (alpha.clamp(0.0, 1.0) * 100.0).round() / 100.0;
|
||||||
|
let mut state = Dictionary::new();
|
||||||
|
state.set("Type", Object::Name(b"ExtGState".to_vec()));
|
||||||
|
state.set("ca", Object::Real(alpha));
|
||||||
|
state.set("CA", Object::Real(alpha));
|
||||||
|
dict.set(name.as_bytes().to_vec(), Object::Dictionary(state));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
doc.save_to(&mut out)?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kennung des Wörterbuchs unter `key` im Objekt `owner`; ein eingebettetes oder fehlendes
|
||||||
|
/// Wörterbuch wird dafür zu einem eigenen Objekt.
|
||||||
|
fn dict_object_id(
|
||||||
|
doc: &mut printpdf::lopdf::Document,
|
||||||
|
owner: printpdf::lopdf::ObjectId,
|
||||||
|
key: &[u8],
|
||||||
|
) -> Result<printpdf::lopdf::ObjectId, Box<dyn Error>> {
|
||||||
|
use printpdf::lopdf::{Dictionary, Object};
|
||||||
|
|
||||||
|
let entry = doc.get_object(owner)?.as_dict()?.get(key).ok().cloned();
|
||||||
|
if let Some(Object::Reference(id)) = entry {
|
||||||
|
return Ok(id);
|
||||||
|
}
|
||||||
|
let dict = match entry {
|
||||||
|
Some(Object::Dictionary(dict)) => dict,
|
||||||
|
_ => Dictionary::new(),
|
||||||
|
};
|
||||||
|
let id = doc.add_object(dict);
|
||||||
|
doc.get_object_mut(owner)?
|
||||||
|
.as_dict_mut()?
|
||||||
|
.set(key.to_vec(), Object::Reference(id));
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
/// UTF-8-Text als PDF-Textstring in UTF-16BE mit BOM; `None` bei reinem ASCII oder
|
/// UTF-8-Text als PDF-Textstring in UTF-16BE mit BOM; `None` bei reinem ASCII oder
|
||||||
/// ungültigem UTF-8.
|
/// ungültigem UTF-8.
|
||||||
fn utf16be_text(raw: &[u8]) -> Option<Vec<u8>> {
|
fn utf16be_text(raw: &[u8]) -> Option<Vec<u8>> {
|
||||||
@@ -313,15 +418,15 @@ fn generate_pdf_document_with(
|
|||||||
raster_pdf_pages: &std::collections::HashSet<usize>,
|
raster_pdf_pages: &std::collections::HashSet<usize>,
|
||||||
) -> Result<PdfDocumentReference, Box<dyn Error>> {
|
) -> Result<PdfDocumentReference, Box<dyn Error>> {
|
||||||
if notebook.pages.is_empty() {
|
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
|
// Nicht geladene Seiten würden sonst stillschweigend als leere Seiten exportiert
|
||||||
if let Some(page) = notebook.pages.iter().find(|p| {
|
if let Some(page) = notebook.pages.iter().find(|p| {
|
||||||
!p.is_loaded || p.persist == crate::document::PersistState::ReadOnlyAfterLoadError
|
!p.is_loaded || p.persist == crate::document::PersistState::ReadOnlyAfterLoadError
|
||||||
}) {
|
}) {
|
||||||
return Err(format!(
|
return Err(t!(
|
||||||
"Seite {} ist nicht geladen – Export abgebrochen (vorher storage::load_all_pages aufrufen)",
|
"Seite {id} ist nicht geladen – Export abgebrochen (vorher storage::load_all_pages aufrufen)",
|
||||||
page.id
|
id = page.id
|
||||||
)
|
)
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
@@ -374,8 +479,10 @@ fn render_background_layer(
|
|||||||
) {
|
) {
|
||||||
let background = page.background;
|
let background = page.background;
|
||||||
let (Mm(page_width_mm), Mm(page_height_mm)) = pdf_page_mm(page);
|
let (Mm(page_width_mm), Mm(page_height_mm)) = pdf_page_mm(page);
|
||||||
// Exportiert wird immer auf weißem Papier
|
// Exportiert wird immer auf weißem Papier; die Musterfarbe ist linear, PDF erwartet sRGB
|
||||||
let [r, g, b, _] = pattern.color(crate::background::PaperTheme::White);
|
let [r, g, b, _] = pattern
|
||||||
|
.color(crate::background::PaperTheme::White)
|
||||||
|
.map(crate::background::linear_to_srgb);
|
||||||
let grid_color = Color::Rgb(Rgb::new(r, g, b, None));
|
let grid_color = Color::Rgb(Rgb::new(r, g, b, None));
|
||||||
layer.set_outline_color(grid_color.clone());
|
layer.set_outline_color(grid_color.clone());
|
||||||
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
|
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
|
||||||
@@ -468,6 +575,17 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy:
|
|||||||
if stroke.is_object() {
|
if stroke.is_object() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Halbtransparente Tinte (Textmarker): Deckkraft über einen Grafikzustand, den
|
||||||
|
// `add_alpha_states` anlegt. Jeder Strich ist ein einziger Pfad und deckt daher jede
|
||||||
|
// Stelle nur einmal, auch wo er sich selbst überlappt.
|
||||||
|
let alpha_state = alpha_state_name(stroke.color[3]);
|
||||||
|
if let Some(name) = &alpha_state {
|
||||||
|
layer.save_graphics_state();
|
||||||
|
layer.add_operation(printpdf::lopdf::content::Operation::new(
|
||||||
|
"gs",
|
||||||
|
vec![printpdf::lopdf::Object::Name(name.as_bytes().to_vec())],
|
||||||
|
));
|
||||||
|
}
|
||||||
if let Some(ref shape) = stroke.shape {
|
if let Some(ref shape) = stroke.shape {
|
||||||
render_recognized_shape(layer, shape, stroke.color);
|
render_recognized_shape(layer, shape, stroke.color);
|
||||||
} else {
|
} else {
|
||||||
@@ -480,6 +598,9 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if alpha_state.is_some() {
|
||||||
|
layer.restore_graphics_state();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,7 +857,7 @@ fn render_pdf_raster(
|
|||||||
let longest_pt = pdf.width_pt.max(pdf.height_pt);
|
let longest_pt = pdf.width_pt.max(pdf.height_pt);
|
||||||
let max_pixels = (longest_pt / 72.0 * PDF_RASTER_DPI).ceil() as u32;
|
let max_pixels = (longest_pt / 72.0 * PDF_RASTER_DPI).ceil() as u32;
|
||||||
let rendered = assets
|
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(|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));
|
.and_then(|path| crate::pdf_doc::render_file_page(&path, pdf.page as usize, max_pixels));
|
||||||
let xobject = match rendered {
|
let xobject = match rendered {
|
||||||
@@ -773,6 +894,10 @@ fn render_recognized_shape(
|
|||||||
let stroke_color = Color::Rgb(Rgb::new(color[0], color[1], color[2], None));
|
let stroke_color = Color::Rgb(Rgb::new(color[0], color[1], color[2], None));
|
||||||
layer.set_outline_color(stroke_color.clone());
|
layer.set_outline_color(stroke_color.clone());
|
||||||
layer.set_outline_thickness(1.5); // ca. 0.5 mm Standard-Liniendicke
|
layer.set_outline_thickness(1.5); // ca. 0.5 mm Standard-Liniendicke
|
||||||
|
// Wie auf dem Bildschirm: spitze Ecken und stumpfe Enden (der Grafikzustand kann noch
|
||||||
|
// runde Verbindungen von einem vorherigen Freihandstrich enthalten)
|
||||||
|
layer.set_line_cap_style(printpdf::LineCapStyle::Butt);
|
||||||
|
layer.set_line_join_style(printpdf::LineJoinStyle::Miter);
|
||||||
|
|
||||||
match shape {
|
match shape {
|
||||||
RecognizedShape::Line { start, end } => {
|
RecognizedShape::Line { start, end } => {
|
||||||
@@ -942,6 +1067,59 @@ fn render_recognized_shape(
|
|||||||
x_axis_end,
|
x_axis_end,
|
||||||
y_axis_end,
|
y_axis_end,
|
||||||
with_arrows,
|
with_arrows,
|
||||||
|
negative_ends: Some([x_axis_start, y_axis_start]),
|
||||||
|
} => {
|
||||||
|
// Achsenkreuz: zwei durchgehende Achsen, Pfeilspitzen an allen vier Enden
|
||||||
|
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
|
||||||
|
let to_pdf = |p: &glam::Vec2| {
|
||||||
|
let (px, py) = page_to_pdf_mm(p.x, p.y);
|
||||||
|
(Point::new(Mm(px), Mm(py)), false)
|
||||||
|
};
|
||||||
|
let mut heads = Vec::new();
|
||||||
|
for (neg, pos) in [(*x_axis_start, *x_axis_end), (*y_axis_start, *y_axis_end)] {
|
||||||
|
let dir = (pos - neg).normalize_or_zero();
|
||||||
|
let pos_head = crate::geometry::arrow_head(
|
||||||
|
pos,
|
||||||
|
dir,
|
||||||
|
PDF_SHAPE_HALF_WIDTH,
|
||||||
|
(pos - *origin).length(),
|
||||||
|
);
|
||||||
|
let neg_head = crate::geometry::arrow_head(
|
||||||
|
neg,
|
||||||
|
-dir,
|
||||||
|
PDF_SHAPE_HALF_WIDTH,
|
||||||
|
(neg - *origin).length(),
|
||||||
|
);
|
||||||
|
let (from, to) = if *with_arrows {
|
||||||
|
(neg_head.0, pos_head.0)
|
||||||
|
} else {
|
||||||
|
(neg, pos)
|
||||||
|
};
|
||||||
|
layer.add_line(Line {
|
||||||
|
points: [from, to].iter().map(to_pdf).collect(),
|
||||||
|
is_closed: false,
|
||||||
|
});
|
||||||
|
heads.push((pos, pos_head));
|
||||||
|
heads.push((neg, neg_head));
|
||||||
|
}
|
||||||
|
if *with_arrows {
|
||||||
|
layer.set_fill_color(stroke_color);
|
||||||
|
for (tip, (_, left, right)) in heads {
|
||||||
|
layer.add_polygon(Polygon {
|
||||||
|
rings: vec![[tip, left, right].iter().map(to_pdf).collect()],
|
||||||
|
mode: PaintMode::Fill,
|
||||||
|
winding_order: WindingOrder::NonZero,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RecognizedShape::CoordinateSystem {
|
||||||
|
origin,
|
||||||
|
x_axis_end,
|
||||||
|
y_axis_end,
|
||||||
|
with_arrows,
|
||||||
|
negative_ends: None,
|
||||||
} => {
|
} => {
|
||||||
// Gleiche Pfeilspitzen wie auf dem Bildschirm; Achsen enden an der Basis der Spitze
|
// Gleiche Pfeilspitzen wie auf dem Bildschirm; Achsen enden an der Basis der Spitze
|
||||||
// (Linienstärke 1,5 pt ≈ 5,3 Seiteneinheiten, halbe Breite ≈ 2,65)
|
// (Linienstärke 1,5 pt ≈ 5,3 Seiteneinheiten, halbe Breite ≈ 2,65)
|
||||||
@@ -966,17 +1144,19 @@ fn render_recognized_shape(
|
|||||||
(*y_axis_end, *x_axis_end)
|
(*y_axis_end, *x_axis_end)
|
||||||
};
|
};
|
||||||
|
|
||||||
let (ox, oy) = page_to_pdf_mm(origin.x, origin.y);
|
// Ein einziger Linienzug, damit die Ecke am Ursprung als Linienverbindung
|
||||||
for end in [y_line_end, x_line_end] {
|
// geschlossen gezeichnet wird statt als zwei stumpfe Enden
|
||||||
let (ex, ey) = page_to_pdf_mm(end.x, end.y);
|
let points = [y_line_end, *origin, x_line_end]
|
||||||
layer.add_line(Line {
|
.iter()
|
||||||
points: vec![
|
.map(|p| {
|
||||||
(Point::new(Mm(ox), Mm(oy)), false),
|
let (px, py) = page_to_pdf_mm(p.x, p.y);
|
||||||
(Point::new(Mm(ex), Mm(ey)), false),
|
(Point::new(Mm(px), Mm(py)), false)
|
||||||
],
|
})
|
||||||
is_closed: false,
|
.collect();
|
||||||
});
|
layer.add_line(Line {
|
||||||
}
|
points,
|
||||||
|
is_closed: false,
|
||||||
|
});
|
||||||
|
|
||||||
// Pfeilspitzen als gefüllte Dreiecke
|
// Pfeilspitzen als gefüllte Dreiecke
|
||||||
if *with_arrows {
|
if *with_arrows {
|
||||||
@@ -1357,6 +1537,7 @@ mod tests {
|
|||||||
x_axis_end: glam::Vec2::new(800.0, 1500.0),
|
x_axis_end: glam::Vec2::new(800.0, 1500.0),
|
||||||
y_axis_end: glam::Vec2::new(300.0, 1000.0),
|
y_axis_end: glam::Vec2::new(300.0, 1000.0),
|
||||||
with_arrows: true,
|
with_arrows: true,
|
||||||
|
negative_ends: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
page.add_stroke(coords_stroke);
|
page.add_stroke(coords_stroke);
|
||||||
@@ -1613,6 +1794,49 @@ mod tests {
|
|||||||
.is_ok());
|
.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_endless_page_exports_only_the_written_area() {
|
||||||
|
let mut notebook = Notebook::new();
|
||||||
|
assert!(notebook.make_endless());
|
||||||
|
let sample = |x: f32, y: f32| StylusSample {
|
||||||
|
position: glam::Vec2::new(x, y),
|
||||||
|
pressure: 0.5,
|
||||||
|
timestamp: 0.0,
|
||||||
|
};
|
||||||
|
let page = &mut notebook.pages[0];
|
||||||
|
page.add_stroke(Stroke::new(
|
||||||
|
1,
|
||||||
|
vec![sample(20_000.0, 30_000.0), sample(23_000.0, 31_000.0)],
|
||||||
|
vec![
|
||||||
|
InkVertex::new(20_000.0, 30_000.0, [0.0, 0.0, 0.0, 1.0]),
|
||||||
|
InkVertex::new(23_000.0, 31_000.0, [0.0, 0.0, 0.0, 1.0]),
|
||||||
|
InkVertex::new(23_000.0, 31_010.0, [0.0, 0.0, 0.0, 1.0]),
|
||||||
|
],
|
||||||
|
[0.0, 0.0, 0.0, 1.0],
|
||||||
|
));
|
||||||
|
let (_, size) = notebook.pages[0].endless_export_bounds();
|
||||||
|
let bytes = export_notebook_to_bytes(
|
||||||
|
¬ebook,
|
||||||
|
ExportStrategy::FilledPolygon,
|
||||||
|
None,
|
||||||
|
PatternColor::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let sizes = crate::pdf_doc::page_sizes(bytes).unwrap();
|
||||||
|
assert_eq!(sizes.len(), 1);
|
||||||
|
// Seiteneinheiten (10 = 1 mm) in Punkt
|
||||||
|
let pt = |units: f32| units / 10.0 / 25.4 * 72.0;
|
||||||
|
assert!(
|
||||||
|
(sizes[0].0 - pt(size.x)).abs() < 1.0,
|
||||||
|
"{sizes:?} statt {size:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(sizes[0].1 - pt(size.y)).abs() < 1.0,
|
||||||
|
"{sizes:?} statt {size:?}"
|
||||||
|
);
|
||||||
|
assert!(sizes[0].0 < pt(4_000.0), "nicht die ganze endlose Seite");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_page_titles_become_pdf_bookmarks() {
|
fn test_page_titles_become_pdf_bookmarks() {
|
||||||
let mut notebook = Notebook::new();
|
let mut notebook = Notebook::new();
|
||||||
|
|||||||
+13
-10
@@ -7,6 +7,7 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::assets::is_supported_image_path;
|
use crate::assets::is_supported_image_path;
|
||||||
|
use crate::{t, tn};
|
||||||
|
|
||||||
/// Ein Eintrag der Dateiliste.
|
/// Ein Eintrag der Dateiliste.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -61,8 +62,8 @@ impl FilePicker {
|
|||||||
.map(Path::to_path_buf)
|
.map(Path::to_path_buf)
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
let preferred = match kind {
|
let preferred = match kind {
|
||||||
PickerKind::Image => "🖼 Bilder",
|
PickerKind::Image => tn!("🖼 Bilder"),
|
||||||
PickerKind::Pdf => "⬇ Downloads",
|
PickerKind::Pdf => tn!("⬇ Downloads"),
|
||||||
};
|
};
|
||||||
let all = shortcuts();
|
let all = shortcuts();
|
||||||
all.iter()
|
all.iter()
|
||||||
@@ -90,9 +91,10 @@ impl FilePicker {
|
|||||||
self.error = None;
|
self.error = None;
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
self.error = Some(format!(
|
self.error = Some(t!(
|
||||||
"'{}' kann nicht geöffnet werden: {err}",
|
"'{dir}' kann nicht geöffnet werden: {err}",
|
||||||
dir.display()
|
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),
|
/// 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)> {
|
pub fn shortcuts() -> Vec<(&'static str, PathBuf)> {
|
||||||
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
|
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -146,15 +149,15 @@ pub fn shortcuts() -> Vec<(&'static str, PathBuf)> {
|
|||||||
};
|
};
|
||||||
let mut list = Vec::new();
|
let mut list = Vec::new();
|
||||||
if let Some(p) = xdg("XDG_PICTURES_DIR", &["Bilder", "Pictures"]) {
|
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"]) {
|
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"]) {
|
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
|
list
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+503
-22
@@ -9,6 +9,7 @@ use std::f32::consts::PI;
|
|||||||
|
|
||||||
use crate::canvas::InkVertex;
|
use crate::canvas::InkVertex;
|
||||||
use crate::document::{Aabb, Stroke, StylusSample};
|
use crate::document::{Aabb, Stroke, StylusSample};
|
||||||
|
use crate::t;
|
||||||
|
|
||||||
/// Arten der geometrischen Primitiven.
|
/// Arten der geometrischen Primitiven.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -59,6 +60,11 @@ pub enum RecognizedShape {
|
|||||||
x_axis_end: Vec2,
|
x_axis_end: Vec2,
|
||||||
y_axis_end: Vec2,
|
y_axis_end: Vec2,
|
||||||
with_arrows: bool,
|
with_arrows: bool,
|
||||||
|
/// Achsenkreuz: Enden der x- und der y-Achse auf der anderen Seite des Ursprungs
|
||||||
|
/// (Pfeilspitzen an allen vier Enden). Fehlt es, ist es das L-förmige
|
||||||
|
/// Koordinatensystem; ältere Versionen ignorieren das Feld und zeigen das L.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
negative_ends: Option<[Vec2; 2]>,
|
||||||
},
|
},
|
||||||
Triangle {
|
Triangle {
|
||||||
/// 3 Eckpunkte in umlaufender Reihenfolge.
|
/// 3 Eckpunkte in umlaufender Reihenfolge.
|
||||||
@@ -99,22 +105,26 @@ impl RecognizedShape {
|
|||||||
|
|
||||||
pub fn name(&self) -> &'static str {
|
pub fn name(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Line { .. } => "Gerade",
|
Self::Line { .. } => t!("Gerade"),
|
||||||
Self::Circle { .. } => "Kreis",
|
Self::Circle { .. } => t!("Kreis"),
|
||||||
Self::Ellipse { .. } => "Ellipse",
|
Self::Ellipse { .. } => t!("Ellipse"),
|
||||||
Self::Arrow { double: false, .. } => "Pfeil",
|
Self::Arrow { double: false, .. } => t!("Pfeil"),
|
||||||
Self::Arrow { double: true, .. } => "Doppelpfeil",
|
Self::Arrow { double: true, .. } => t!("Doppelpfeil"),
|
||||||
Self::Rectangle { .. } => "Rechteck",
|
Self::Rectangle { .. } => t!("Rechteck"),
|
||||||
Self::CoordinateSystem { .. } => "Koordinatensystem",
|
Self::CoordinateSystem {
|
||||||
Self::Triangle { .. } => "Dreieck",
|
negative_ends: Some(_),
|
||||||
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() {
|
Self::Polygon { vertices } => match vertices.len() {
|
||||||
5 => "Fünfeck (Pentagon)",
|
5 => t!("Fünfeck (Pentagon)"),
|
||||||
6 => "Sechseck (Hexagon)",
|
6 => t!("Sechseck (Hexagon)"),
|
||||||
8 => "Achteck (Oktagon)",
|
8 => t!("Achteck (Oktagon)"),
|
||||||
_ => "Polygon",
|
_ => t!("Polygon"),
|
||||||
},
|
},
|
||||||
Self::Octahedron { .. } => "Oktaeder (3D-Kantenmodell)",
|
Self::Octahedron { .. } => t!("Oktaeder (3D-Kantenmodell)"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,11 +156,15 @@ impl RecognizedShape {
|
|||||||
origin,
|
origin,
|
||||||
x_axis_end,
|
x_axis_end,
|
||||||
y_axis_end,
|
y_axis_end,
|
||||||
|
negative_ends,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
*origin += delta;
|
*origin += delta;
|
||||||
*x_axis_end += delta;
|
*x_axis_end += delta;
|
||||||
*y_axis_end += delta;
|
*y_axis_end += delta;
|
||||||
|
for end in negative_ends.iter_mut().flatten() {
|
||||||
|
*end += delta;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Self::Polygon { vertices } => {
|
Self::Polygon { vertices } => {
|
||||||
for v in vertices {
|
for v in vertices {
|
||||||
@@ -203,11 +217,15 @@ impl RecognizedShape {
|
|||||||
origin: o,
|
origin: o,
|
||||||
x_axis_end,
|
x_axis_end,
|
||||||
y_axis_end,
|
y_axis_end,
|
||||||
|
negative_ends,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
*o = rot(*o);
|
*o = rot(*o);
|
||||||
*x_axis_end = rot(*x_axis_end);
|
*x_axis_end = rot(*x_axis_end);
|
||||||
*y_axis_end = rot(*y_axis_end);
|
*y_axis_end = rot(*y_axis_end);
|
||||||
|
for end in negative_ends.iter_mut().flatten() {
|
||||||
|
*end = rot(*end);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Self::Polygon { vertices } => {
|
Self::Polygon { vertices } => {
|
||||||
for v in vertices {
|
for v in vertices {
|
||||||
@@ -267,11 +285,15 @@ impl RecognizedShape {
|
|||||||
origin: o,
|
origin: o,
|
||||||
x_axis_end,
|
x_axis_end,
|
||||||
y_axis_end,
|
y_axis_end,
|
||||||
|
negative_ends,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
*o = sc(*o);
|
*o = sc(*o);
|
||||||
*x_axis_end = sc(*x_axis_end);
|
*x_axis_end = sc(*x_axis_end);
|
||||||
*y_axis_end = sc(*y_axis_end);
|
*y_axis_end = sc(*y_axis_end);
|
||||||
|
for end in negative_ends.iter_mut().flatten() {
|
||||||
|
*end = sc(*end);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Self::Polygon { vertices } => {
|
Self::Polygon { vertices } => {
|
||||||
for v in vertices {
|
for v in vertices {
|
||||||
@@ -1394,9 +1416,161 @@ pub fn detect_coordinate_system(points: &[Vec2]) -> Option<RecognizedShape> {
|
|||||||
x_axis_end: x_end,
|
x_axis_end: x_end,
|
||||||
y_axis_end: y_end,
|
y_axis_end: y_end,
|
||||||
with_arrows: true,
|
with_arrows: true,
|
||||||
|
negative_ends: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Achsenkreuz in einem Strich („+“, z. B. waagerecht hin und zurück, dann senkrecht): Alle
|
||||||
|
/// Teilstücke liegen auf einer waagerechten und einer senkrechten Linie, und beide reichen
|
||||||
|
/// auf beiden Seiten deutlich über den Schnittpunkt hinaus. Ergebnis wie beim
|
||||||
|
/// Koordinatensystem achsenparallel, mit Pfeilspitzen an allen vier Enden.
|
||||||
|
pub fn detect_axis_cross(points: &[Vec2]) -> Option<RecognizedShape> {
|
||||||
|
if points.len() < 8 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let aabb = Aabb::from_points(points);
|
||||||
|
let diagonal = (aabb.max - aabb.min).length();
|
||||||
|
if diagonal < 60.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let simplified = ramer_douglas_peucker(points, (diagonal * 0.05).clamp(8.0, 40.0));
|
||||||
|
|
||||||
|
// Teilstücke nach Richtung: waagerecht oder senkrecht (höchstens 25° schief); kurze
|
||||||
|
// Stücke (Umkehrpunkte) zählen nicht, schräge lange Stücke schließen ein Kreuz aus
|
||||||
|
let max_slope = 25.0_f32.to_radians().tan();
|
||||||
|
let (mut horizontal, mut vertical) = ((0.0, 0.0), (0.0, 0.0));
|
||||||
|
for w in simplified.windows(2) {
|
||||||
|
let d = w[1] - w[0];
|
||||||
|
let len = d.length();
|
||||||
|
if len < diagonal * 0.08 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mid = (w[0] + w[1]) * 0.5;
|
||||||
|
if d.y.abs() <= d.x.abs() * max_slope {
|
||||||
|
horizontal = (horizontal.0 + mid.y * len, horizontal.1 + len);
|
||||||
|
} else if d.x.abs() <= d.y.abs() * max_slope {
|
||||||
|
vertical = (vertical.0 + mid.x * len, vertical.1 + len);
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if horizontal.1 == 0.0 || vertical.1 == 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let origin = Vec2::new(vertical.0 / vertical.1, horizontal.0 / horizontal.1);
|
||||||
|
|
||||||
|
// Jeder Punkt liegt auf einer der beiden Linien
|
||||||
|
let tolerance = (diagonal * 0.08).max(10.0);
|
||||||
|
let (mut x_min, mut x_max) = (f32::INFINITY, f32::NEG_INFINITY);
|
||||||
|
let (mut y_min, mut y_max) = (f32::INFINITY, f32::NEG_INFINITY);
|
||||||
|
for p in points {
|
||||||
|
let on_horizontal = (p.y - origin.y).abs() <= tolerance;
|
||||||
|
let on_vertical = (p.x - origin.x).abs() <= tolerance;
|
||||||
|
if !on_horizontal && !on_vertical {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if on_horizontal {
|
||||||
|
x_min = x_min.min(p.x);
|
||||||
|
x_max = x_max.max(p.x);
|
||||||
|
}
|
||||||
|
if on_vertical {
|
||||||
|
y_min = y_min.min(p.y);
|
||||||
|
y_max = y_max.max(p.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vier Arme: jeder mindestens ein Fünftel seiner Achse und deutlich länger als die Toleranz
|
||||||
|
let arm_ok = |arm: f32, axis: f32| arm >= axis * 0.2 && arm >= tolerance * 1.5;
|
||||||
|
let (width, height) = (x_max - x_min, y_max - y_min);
|
||||||
|
if !(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)]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// Führt die automatische Erkennung für einen Strich aus.
|
||||||
pub fn detect_shape(points: &[StylusSample]) -> Option<RecognizedShape> {
|
pub fn detect_shape(points: &[StylusSample]) -> Option<RecognizedShape> {
|
||||||
if points.len() < 2 {
|
if points.len() < 2 {
|
||||||
@@ -1423,7 +1597,10 @@ pub fn detect_shape(points: &[StylusSample]) -> Option<RecognizedShape> {
|
|||||||
// Kandidaten in Prioritätsreihenfolge; 3. offene Formen (Koordinatensystem, Gerade) zuletzt.
|
// Kandidaten in Prioritätsreihenfolge; 3. offene Formen (Koordinatensystem, Gerade) zuletzt.
|
||||||
// Übernommen wird der erste, der den Strich wirklich beschreibt. Passt keiner, bleibt der
|
// Übernommen wird der erste, der den Strich wirklich beschreibt. Passt keiner, bleibt der
|
||||||
// Strich unverändert (vorher wurde fast jede geschlossene Kritzelei zu einer Form).
|
// Strich unverändert (vorher wurde fast jede geschlossene Kritzelei zu einer Form).
|
||||||
let candidates: [&dyn Fn() -> Option<RecognizedShape>; 6] = [
|
let candidates: [&dyn Fn() -> Option<RecognizedShape>; 7] = [
|
||||||
|
// Das Achsenkreuz zuerst: Seine Prüfung ist streng, und ein hin und her gezeichnetes
|
||||||
|
// „+“ könnte sonst als Vieleck durchgehen
|
||||||
|
&|| detect_axis_cross(&pts),
|
||||||
&|| {
|
&|| {
|
||||||
if closed_fits_well {
|
if closed_fits_well {
|
||||||
None
|
None
|
||||||
@@ -1908,6 +2085,51 @@ pub fn shape_to_mesh(
|
|||||||
x_axis_end,
|
x_axis_end,
|
||||||
y_axis_end,
|
y_axis_end,
|
||||||
with_arrows,
|
with_arrows,
|
||||||
|
negative_ends: Some([x_axis_start, y_axis_start]),
|
||||||
|
} => {
|
||||||
|
// Achsenkreuz: zwei durchgehende Achsen, Pfeilspitzen an allen vier Enden
|
||||||
|
let mut vertices = Vec::new();
|
||||||
|
for (neg, pos) in [(*x_axis_start, *x_axis_end), (*y_axis_start, *y_axis_end)] {
|
||||||
|
let dir = (pos - neg).normalize_or_zero();
|
||||||
|
let pos_head = arrow_head(pos, dir, half_w, (pos - *origin).length());
|
||||||
|
let neg_head = arrow_head(neg, -dir, half_w, (neg - *origin).length());
|
||||||
|
let (from, to) = if *with_arrows {
|
||||||
|
(neg_head.0, pos_head.0)
|
||||||
|
} else {
|
||||||
|
(neg, pos)
|
||||||
|
};
|
||||||
|
push_band(&mut vertices, from, to, half_w, color);
|
||||||
|
if *with_arrows {
|
||||||
|
for (tip, (_, left, right)) in [(pos, pos_head), (neg, neg_head)] {
|
||||||
|
push_triangle(&mut vertices, tip, left, right, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Abtastpunkte als Linienzug ohne Diagonalen (jeder Arm vom Ursprung aus)
|
||||||
|
let samples = [
|
||||||
|
*x_axis_start,
|
||||||
|
*origin,
|
||||||
|
*x_axis_end,
|
||||||
|
*origin,
|
||||||
|
*y_axis_end,
|
||||||
|
*origin,
|
||||||
|
*y_axis_start,
|
||||||
|
]
|
||||||
|
.map(|position| StylusSample {
|
||||||
|
position,
|
||||||
|
pressure: 0.5,
|
||||||
|
timestamp: 0.0,
|
||||||
|
})
|
||||||
|
.to_vec();
|
||||||
|
(samples, vertices)
|
||||||
|
}
|
||||||
|
|
||||||
|
RecognizedShape::CoordinateSystem {
|
||||||
|
origin,
|
||||||
|
x_axis_end,
|
||||||
|
y_axis_end,
|
||||||
|
with_arrows,
|
||||||
|
negative_ends: None,
|
||||||
} => {
|
} => {
|
||||||
let mut vertices = Vec::new();
|
let mut vertices = Vec::new();
|
||||||
let mut samples = Vec::new();
|
let mut samples = Vec::new();
|
||||||
@@ -1924,16 +2146,21 @@ pub fn shape_to_mesh(
|
|||||||
(*y_axis_end, *x_axis_end)
|
(*y_axis_end, *x_axis_end)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Beide Achsen beginnen um die halbe Linienbreite hinter dem Ursprung, damit die
|
||||||
|
// äußere Ecke geschlossen ist (sonst bleibt dort eine quadratische Kerbe)
|
||||||
|
let y_start = *origin - y_dir * half_w;
|
||||||
|
let x_start = *origin - x_dir * half_w;
|
||||||
|
|
||||||
// 1. Y-Achse (vom Ursprung zur Spitze)
|
// 1. Y-Achse (vom Ursprung zur Spitze)
|
||||||
let y_norm = Vec2::new(-y_dir.y, y_dir.x) * half_w;
|
let y_norm = Vec2::new(-y_dir.y, y_dir.x) * half_w;
|
||||||
vertices.push(InkVertex::new(
|
vertices.push(InkVertex::new(
|
||||||
origin.x + y_norm.x,
|
y_start.x + y_norm.x,
|
||||||
origin.y + y_norm.y,
|
y_start.y + y_norm.y,
|
||||||
color,
|
color,
|
||||||
));
|
));
|
||||||
vertices.push(InkVertex::new(
|
vertices.push(InkVertex::new(
|
||||||
origin.x - y_norm.x,
|
y_start.x - y_norm.x,
|
||||||
origin.y - y_norm.y,
|
y_start.y - y_norm.y,
|
||||||
color,
|
color,
|
||||||
));
|
));
|
||||||
vertices.push(InkVertex::new(
|
vertices.push(InkVertex::new(
|
||||||
@@ -1950,15 +2177,15 @@ pub fn shape_to_mesh(
|
|||||||
// Degenerierte Dreiecke für den Übergang zur X-Achse
|
// Degenerierte Dreiecke für den Übergang zur X-Achse
|
||||||
let x_norm = Vec2::new(-x_dir.y, x_dir.x) * half_w;
|
let x_norm = Vec2::new(-x_dir.y, x_dir.x) * half_w;
|
||||||
let last_v = *vertices.last().unwrap();
|
let last_v = *vertices.last().unwrap();
|
||||||
let next_v = InkVertex::new(origin.x + x_norm.x, origin.y + x_norm.y, color);
|
let next_v = InkVertex::new(x_start.x + x_norm.x, x_start.y + x_norm.y, color);
|
||||||
vertices.push(last_v);
|
vertices.push(last_v);
|
||||||
vertices.push(next_v);
|
vertices.push(next_v);
|
||||||
|
|
||||||
// 2. X-Achse
|
// 2. X-Achse
|
||||||
vertices.push(next_v);
|
vertices.push(next_v);
|
||||||
vertices.push(InkVertex::new(
|
vertices.push(InkVertex::new(
|
||||||
origin.x - x_norm.x,
|
x_start.x - x_norm.x,
|
||||||
origin.y - x_norm.y,
|
x_start.y - x_norm.y,
|
||||||
color,
|
color,
|
||||||
));
|
));
|
||||||
vertices.push(InkVertex::new(
|
vertices.push(InkVertex::new(
|
||||||
@@ -2007,6 +2234,40 @@ pub fn shape_to_mesh(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hängt ein gerades Band der halben Breite `half_w` von `a` nach `b` an einen Triangle-Strip
|
||||||
|
/// an (über degenerierte Dreiecke, falls er schon Vertizes hat).
|
||||||
|
fn push_band(vertices: &mut Vec<InkVertex>, a: Vec2, b: Vec2, half_w: f32, color: [f32; 4]) {
|
||||||
|
let dir = (b - a).normalize_or_zero();
|
||||||
|
let norm = Vec2::new(-dir.y, dir.x) * half_w;
|
||||||
|
let first = InkVertex::new(a.x + norm.x, a.y + norm.y, color);
|
||||||
|
if let Some(&last) = vertices.last() {
|
||||||
|
vertices.push(last);
|
||||||
|
vertices.push(first);
|
||||||
|
}
|
||||||
|
vertices.push(first);
|
||||||
|
vertices.push(InkVertex::new(a.x - norm.x, a.y - norm.y, color));
|
||||||
|
vertices.push(InkVertex::new(b.x + norm.x, b.y + norm.y, color));
|
||||||
|
vertices.push(InkVertex::new(b.x - norm.x, b.y - norm.y, color));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hängt ein Dreieck (Pfeilspitze) an einen Triangle-Strip an.
|
||||||
|
fn push_triangle(
|
||||||
|
vertices: &mut Vec<InkVertex>,
|
||||||
|
tip: Vec2,
|
||||||
|
left: Vec2,
|
||||||
|
right: Vec2,
|
||||||
|
color: [f32; 4],
|
||||||
|
) {
|
||||||
|
let tip_v = InkVertex::new(tip.x, tip.y, color);
|
||||||
|
if let Some(&last) = vertices.last() {
|
||||||
|
vertices.push(last);
|
||||||
|
vertices.push(tip_v);
|
||||||
|
}
|
||||||
|
vertices.push(tip_v);
|
||||||
|
vertices.push(InkVertex::new(left.x, left.y, color));
|
||||||
|
vertices.push(InkVertex::new(right.x, right.y, color));
|
||||||
|
}
|
||||||
|
|
||||||
/// Erzeugt einen neuen `Stroke` aus einer erkannten Form.
|
/// Erzeugt einen neuen `Stroke` aus einer erkannten Form.
|
||||||
pub fn shape_to_stroke(
|
pub fn shape_to_stroke(
|
||||||
shape: RecognizedShape,
|
shape: RecognizedShape,
|
||||||
@@ -2195,6 +2456,31 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_coordinate_system_corner_is_closed() {
|
||||||
|
// Die Achsen treffen sich ohne Kerbe: Die äußere Ecke des Ursprungs ist ein Eckpunkt
|
||||||
|
// der Achsenbänder (Issue #15)
|
||||||
|
let origin = Vec2::new(300.0, 900.0);
|
||||||
|
let shape = RecognizedShape::CoordinateSystem {
|
||||||
|
origin,
|
||||||
|
x_axis_end: Vec2::new(900.0, 900.0),
|
||||||
|
y_axis_end: Vec2::new(300.0, 300.0),
|
||||||
|
with_arrows: true,
|
||||||
|
negative_ends: None,
|
||||||
|
};
|
||||||
|
let width = 30.0;
|
||||||
|
let (_, vertices) = shape_to_mesh(&shape, width, [0.0, 0.0, 0.0, 1.0]);
|
||||||
|
let corner = origin + Vec2::new(-width / 2.0, width / 2.0);
|
||||||
|
let nearest = vertices
|
||||||
|
.iter()
|
||||||
|
.map(|v| (Vec2::from(v.position) - corner).length())
|
||||||
|
.fold(f32::INFINITY, f32::min);
|
||||||
|
assert!(
|
||||||
|
nearest < 1e-3,
|
||||||
|
"äußere Ecke fehlt, nächster Punkt {nearest}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_detect_triangle() {
|
fn test_detect_triangle() {
|
||||||
let mut points = Vec::new();
|
let mut points = Vec::new();
|
||||||
@@ -2450,12 +2736,206 @@ mod tests {
|
|||||||
Some(RecognizedShape::Triangle { .. }) => "Dreieck",
|
Some(RecognizedShape::Triangle { .. }) => "Dreieck",
|
||||||
Some(RecognizedShape::Rhombus { .. }) => "Raute",
|
Some(RecognizedShape::Rhombus { .. }) => "Raute",
|
||||||
Some(RecognizedShape::Polygon { .. }) => "Polygon",
|
Some(RecognizedShape::Polygon { .. }) => "Polygon",
|
||||||
|
Some(RecognizedShape::CoordinateSystem {
|
||||||
|
negative_ends: Some(_),
|
||||||
|
..
|
||||||
|
}) => "Achsenkreuz",
|
||||||
Some(RecognizedShape::CoordinateSystem { .. }) => "Koordinatensystem",
|
Some(RecognizedShape::CoordinateSystem { .. }) => "Koordinatensystem",
|
||||||
Some(RecognizedShape::Octahedron { .. }) => "Oktaeder",
|
Some(RecognizedShape::Octahedron { .. }) => "Oktaeder",
|
||||||
None => "nichts",
|
None => "nichts",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
let v = |x: f32, y: f32| c + Vec2::new(x, y);
|
||||||
|
// Verschiedene Zeichenreihenfolgen eines „+“ in einem Zug
|
||||||
|
let crosses: [(&str, Vec<Vec2>); 4] = [
|
||||||
|
(
|
||||||
|
"waagerecht hin, zurück zur Mitte, senkrecht",
|
||||||
|
vec![
|
||||||
|
v(-200.0, 0.0),
|
||||||
|
v(200.0, 0.0),
|
||||||
|
v(0.0, 0.0),
|
||||||
|
v(0.0, -150.0),
|
||||||
|
v(0.0, 150.0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"senkrecht zuerst",
|
||||||
|
vec![
|
||||||
|
v(0.0, -180.0),
|
||||||
|
v(0.0, 180.0),
|
||||||
|
v(0.0, 0.0),
|
||||||
|
v(-220.0, 0.0),
|
||||||
|
v(220.0, 0.0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Arme ungleich lang",
|
||||||
|
vec![
|
||||||
|
v(-100.0, 0.0),
|
||||||
|
v(320.0, 0.0),
|
||||||
|
v(0.0, 0.0),
|
||||||
|
v(0.0, 250.0),
|
||||||
|
v(0.0, -120.0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Mitte zuerst, alle Arme hin und zurück",
|
||||||
|
vec![
|
||||||
|
v(0.0, 0.0),
|
||||||
|
v(200.0, 0.0),
|
||||||
|
v(0.0, 0.0),
|
||||||
|
v(0.0, -200.0),
|
||||||
|
v(0.0, 0.0),
|
||||||
|
v(-200.0, 0.0),
|
||||||
|
v(0.0, 0.0),
|
||||||
|
v(0.0, 200.0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (seed, (name, outline)) in crosses.iter().enumerate() {
|
||||||
|
let samples = freehand(outline, false, seed as f32);
|
||||||
|
let shape = detect_shape(&samples);
|
||||||
|
assert_eq!(kind(&shape), "Achsenkreuz", "{name}");
|
||||||
|
let Some(RecognizedShape::CoordinateSystem {
|
||||||
|
origin,
|
||||||
|
x_axis_end,
|
||||||
|
y_axis_end,
|
||||||
|
negative_ends: Some([x_start, y_start]),
|
||||||
|
with_arrows: true,
|
||||||
|
}) = shape
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
assert!((origin - c).length() < 8.0, "{name}: Ursprung {origin}");
|
||||||
|
// Achsenparallel, x nach rechts, y nach oben
|
||||||
|
assert_eq!(x_axis_end.y, origin.y);
|
||||||
|
assert_eq!(x_start.y, origin.y);
|
||||||
|
assert_eq!(y_axis_end.x, origin.x);
|
||||||
|
assert!(x_axis_end.x > origin.x && x_start.x < origin.x, "{name}");
|
||||||
|
assert!(y_axis_end.y < origin.y && y_start.y > origin.y, "{name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keine Kreuze: L-Form, T-Form, einzelne Linie, Rechteck
|
||||||
|
let not_crosses: [(&str, Vec<Vec2>, bool); 4] = [
|
||||||
|
("L", vec![v(0.0, -200.0), v(0.0, 0.0), v(250.0, 0.0)], false),
|
||||||
|
(
|
||||||
|
"T",
|
||||||
|
vec![v(-200.0, 0.0), v(200.0, 0.0), v(0.0, 0.0), v(0.0, 200.0)],
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
("Linie", vec![v(-200.0, 0.0), v(200.0, 0.0)], false),
|
||||||
|
(
|
||||||
|
"Rechteck",
|
||||||
|
vec![
|
||||||
|
v(-200.0, -100.0),
|
||||||
|
v(200.0, -100.0),
|
||||||
|
v(200.0, 100.0),
|
||||||
|
v(-200.0, 100.0),
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (seed, (name, outline, closed)) in not_crosses.iter().enumerate() {
|
||||||
|
let samples = freehand(outline, *closed, seed as f32 + 10.0);
|
||||||
|
let points: Vec<Vec2> = samples.iter().map(|s| s.position).collect();
|
||||||
|
assert!(
|
||||||
|
detect_axis_cross(&points).is_none(),
|
||||||
|
"{name} ist kein Kreuz"
|
||||||
|
);
|
||||||
|
assert_ne!(kind(&detect_shape(&samples)), "Achsenkreuz", "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_axis_cross_mesh_and_transforms() {
|
||||||
|
let mut shape = RecognizedShape::CoordinateSystem {
|
||||||
|
origin: Vec2::new(500.0, 500.0),
|
||||||
|
x_axis_end: Vec2::new(800.0, 500.0),
|
||||||
|
y_axis_end: Vec2::new(500.0, 300.0),
|
||||||
|
with_arrows: true,
|
||||||
|
negative_ends: Some([Vec2::new(300.0, 500.0), Vec2::new(500.0, 700.0)]),
|
||||||
|
};
|
||||||
|
assert_eq!(shape.name(), "Achsenkreuz");
|
||||||
|
let (samples, vertices) = shape_to_mesh(&shape, 10.0, [0.0, 0.0, 0.0, 1.0]);
|
||||||
|
// Alle vier Pfeilspitzen sind Eckpunkte des Netzes
|
||||||
|
for tip in [
|
||||||
|
(800.0, 500.0),
|
||||||
|
(500.0, 300.0),
|
||||||
|
(300.0, 500.0),
|
||||||
|
(500.0, 700.0),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
vertices
|
||||||
|
.iter()
|
||||||
|
.any(|v| (Vec2::from(v.position) - Vec2::from(tip)).length() < 1e-3),
|
||||||
|
"Spitze {tip:?} fehlt"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Die Form passt zu sich selbst gezeichnet (Abtastpunkte ohne Diagonalen)
|
||||||
|
let points: Vec<Vec2> = samples.iter().map(|s| s.position).collect();
|
||||||
|
assert!(shape_fit_error(&points, &shape).unwrap() < 1e-3);
|
||||||
|
// Verschieben bewegt auch die negativen Enden
|
||||||
|
shape.translate(Vec2::new(10.0, 20.0));
|
||||||
|
let RecognizedShape::CoordinateSystem {
|
||||||
|
negative_ends: Some([x_start, y_start]),
|
||||||
|
..
|
||||||
|
} = shape
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
assert_eq!(x_start, Vec2::new(310.0, 520.0));
|
||||||
|
assert_eq!(y_start, Vec2::new(510.0, 720.0));
|
||||||
|
// Ältere Dateien ohne das Feld bleiben L-Koordinatensysteme; neue Dateien enthalten es
|
||||||
|
let json = serde_json::to_string(&shape).unwrap();
|
||||||
|
assert!(json.contains("negative_ends"));
|
||||||
|
let old = r#"{"CoordinateSystem":{"origin":[0.0,0.0],"x_axis_end":[1.0,0.0],"y_axis_end":[0.0,-1.0],"with_arrows":true}}"#;
|
||||||
|
let parsed: RecognizedShape = serde_json::from_str(old).unwrap();
|
||||||
|
assert_eq!(parsed.name(), "Koordinatensystem");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_detect_shape_freehand_classification() {
|
fn test_detect_shape_freehand_classification() {
|
||||||
let c = Vec2::new(1000.0, 1400.0);
|
let c = Vec2::new(1000.0, 1400.0);
|
||||||
@@ -2855,6 +3335,7 @@ mod tests {
|
|||||||
x_axis_end: x_end,
|
x_axis_end: x_end,
|
||||||
y_axis_end: y_end,
|
y_axis_end: y_end,
|
||||||
with_arrows: true,
|
with_arrows: true,
|
||||||
|
negative_ends: None,
|
||||||
};
|
};
|
||||||
let (_, vertices) = shape_to_mesh(&shape, 5.0, [0.0, 0.0, 0.0, 1.0]);
|
let (_, vertices) = shape_to_mesh(&shape, 5.0, [0.0, 0.0, 0.0, 1.0]);
|
||||||
let (y_base, ..) = arrow_head(y_end, Vec2::new(0.0, -1.0), 2.5, 200.0);
|
let (y_base, ..) = arrow_head(y_end, Vec2::new(0.0, -1.0), 2.5, 200.0);
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
use crate::document::Stroke;
|
use crate::document::Stroke;
|
||||||
|
|
||||||
/// Eine rückgängig machbare Benutzeraktion auf einer Seite.
|
/// Eine rückgängig machbare Benutzeraktion auf einer Seite.
|
||||||
|
// Der Verlauf hält nur wenige hundert Einträge; die Größe des Ersetzen-Eintrags (zwei Striche)
|
||||||
|
// fällt nicht ins Gewicht, eine Box würde nur die Aufrufe umständlicher machen
|
||||||
|
#[allow(clippy::large_enum_variant)]
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum HistoryAction {
|
pub enum HistoryAction {
|
||||||
/// Ein Strich wurde neu gezeichnet.
|
/// Ein Strich wurde neu gezeichnet.
|
||||||
@@ -18,6 +21,9 @@ pub enum HistoryAction {
|
|||||||
before: Vec<Stroke>,
|
before: Vec<Stroke>,
|
||||||
after: Vec<Stroke>,
|
after: Vec<Stroke>,
|
||||||
},
|
},
|
||||||
|
/// Platz geschaffen: Inhalt mehrerer Seiten wurde verschoben. Rückgängig macht das das
|
||||||
|
/// Notizbuch (`Notebook::undo`), nicht die einzelne Seite.
|
||||||
|
SpaceShift(Box<crate::document::SpaceShift>),
|
||||||
/// Teilradierer: Striche wurden durch ihre übrig gebliebenen Stücke ersetzt.
|
/// Teilradierer: Striche wurden durch ihre übrig gebliebenen Stücke ersetzt.
|
||||||
SplitStrokes {
|
SplitStrokes {
|
||||||
removed: Vec<Stroke>,
|
removed: Vec<Stroke>,
|
||||||
@@ -136,6 +142,10 @@ impl HistoryStack {
|
|||||||
self.redo_stack
|
self.redo_stack
|
||||||
.push(HistoryAction::ModifyStrokes { before, after });
|
.push(HistoryAction::ModifyStrokes { before, after });
|
||||||
}
|
}
|
||||||
|
// Betrifft mehrere Seiten: Das Notizbuch macht es rückgängig, hier nur umbuchen
|
||||||
|
HistoryAction::SpaceShift(shift) => {
|
||||||
|
self.redo_stack.push(HistoryAction::SpaceShift(shift));
|
||||||
|
}
|
||||||
HistoryAction::SplitStrokes { removed, added, at } => {
|
HistoryAction::SplitStrokes { removed, added, at } => {
|
||||||
strokes.retain(|s| !added.iter().any(|a| a.id == s.id));
|
strokes.retain(|s| !added.iter().any(|a| a.id == s.id));
|
||||||
strokes.extend(removed.iter().cloned());
|
strokes.extend(removed.iter().cloned());
|
||||||
@@ -187,6 +197,9 @@ impl HistoryStack {
|
|||||||
self.undo_stack
|
self.undo_stack
|
||||||
.push(HistoryAction::ModifyStrokes { before, after });
|
.push(HistoryAction::ModifyStrokes { before, after });
|
||||||
}
|
}
|
||||||
|
HistoryAction::SpaceShift(shift) => {
|
||||||
|
self.undo_stack.push(HistoryAction::SpaceShift(shift));
|
||||||
|
}
|
||||||
HistoryAction::SplitStrokes { removed, added, at } => {
|
HistoryAction::SplitStrokes { removed, added, at } => {
|
||||||
strokes.retain(|s| !removed.iter().any(|r| r.id == s.id));
|
strokes.retain(|s| !removed.iter().any(|r| r.id == s.id));
|
||||||
strokes.extend(added.iter().cloned());
|
strokes.extend(added.iter().cloned());
|
||||||
|
|||||||
+326
@@ -0,0 +1,326 @@
|
|||||||
|
//! Sprache der Oberfläche: Deutsch oder Englisch.
|
||||||
|
//!
|
||||||
|
//! Die Texte stehen im Quelltext auf Deutsch in `t!("…")`; dieser deutsche Text ist zugleich
|
||||||
|
//! der Schlüssel für die englische Fassung in `en.rs`. Platzhalter heißen `{name}` und werden
|
||||||
|
//! mit `t!("Seite {number}", number = n)` gefüllt (Formatangaben wie `{:.1}` vorher mit
|
||||||
|
//! `format!` anwenden). Texte in Tabellen markiert `tn!("…")`, übersetzt werden sie beim
|
||||||
|
//! Anzeigen mit `tr(…)`. Ein Test prüft, dass jeder markierte Text übersetzt ist.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fmt::Display;
|
||||||
|
use std::sync::atomic::{AtomicU8, Ordering};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
mod en;
|
||||||
|
|
||||||
|
/// Sprache der Oberfläche
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Language {
|
||||||
|
German,
|
||||||
|
English,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Language {
|
||||||
|
pub const ALL: [Language; 2] = [Language::German, Language::English];
|
||||||
|
|
||||||
|
/// Name der Sprache in ihr selbst (für die Auswahl, unabhängig von der aktuellen Sprache)
|
||||||
|
pub fn native_name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Language::German => "Deutsch",
|
||||||
|
Language::English => "English",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sprache aus den Locale-Variablen (`LC_ALL`, `LC_MESSAGES`, `LANG`, `LANGUAGE`; `C` und
|
||||||
|
/// `POSIX` zählen nicht): Deutsch bei `de…`, sonst Englisch.
|
||||||
|
pub fn from_locale(vars: &[Option<String>]) -> Language {
|
||||||
|
let locale = vars
|
||||||
|
.iter()
|
||||||
|
.flatten()
|
||||||
|
.map(|v| v.trim())
|
||||||
|
.find(|v| !v.is_empty() && *v != "C" && !v.starts_with("C.") && *v != "POSIX");
|
||||||
|
match locale {
|
||||||
|
Some(v) if v.to_ascii_lowercase().starts_with("de") => Language::German,
|
||||||
|
_ => Language::English,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sprache des Systems
|
||||||
|
pub fn system() -> Language {
|
||||||
|
let var = |name| std::env::var(name).ok();
|
||||||
|
Language::from_locale(&[
|
||||||
|
var("LC_ALL"),
|
||||||
|
var("LC_MESSAGES"),
|
||||||
|
var("LANG"),
|
||||||
|
var("LANGUAGE"),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static CURRENT: AtomicU8 = AtomicU8::new(0);
|
||||||
|
|
||||||
|
/// Stellt die Sprache der Oberfläche um (wirkt ab dem nächsten Frame).
|
||||||
|
pub fn set_language(language: Language) {
|
||||||
|
CURRENT.store(language as u8, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aktuelle Sprache der Oberfläche
|
||||||
|
pub fn language() -> Language {
|
||||||
|
match CURRENT.load(Ordering::Relaxed) {
|
||||||
|
0 => Language::German,
|
||||||
|
_ => Language::English,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn english() -> &'static HashMap<&'static str, &'static str> {
|
||||||
|
static TABLE: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||||
|
TABLE.get_or_init(|| en::EN.iter().copied().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text `german` in der Sprache `language`; fehlt die Übersetzung, bleibt er deutsch.
|
||||||
|
pub fn translate(language: Language, german: &'static str) -> &'static str {
|
||||||
|
match language {
|
||||||
|
Language::German => german,
|
||||||
|
Language::English => english().get(german).copied().unwrap_or(german),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text in der aktuellen Sprache
|
||||||
|
pub fn tr(german: &'static str) -> &'static str {
|
||||||
|
translate(language(), german)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setzt die Werte für die Platzhalter `{name}` ein.
|
||||||
|
pub fn fill(template: &str, args: &[(&str, &dyn Display)]) -> String {
|
||||||
|
let mut out = String::with_capacity(template.len() + 16);
|
||||||
|
let mut rest = template;
|
||||||
|
while let Some(open) = rest.find('{') {
|
||||||
|
out.push_str(&rest[..open]);
|
||||||
|
let after = &rest[open + 1..];
|
||||||
|
match after.find('}') {
|
||||||
|
Some(close) => {
|
||||||
|
let name = &after[..close];
|
||||||
|
match args.iter().find(|(key, _)| *key == name) {
|
||||||
|
Some((_, value)) => out.push_str(&value.to_string()),
|
||||||
|
None => out.push_str(&rest[open..open + close + 2]),
|
||||||
|
}
|
||||||
|
rest = &after[close + 1..];
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
out.push_str(&rest[open..]);
|
||||||
|
rest = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Übersetzt einen deutschen Text: `t!("Abbrechen")` ergibt `&'static str`,
|
||||||
|
/// `t!("Seite {number}", number = n)` einen `String` mit eingesetzten Werten.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! t {
|
||||||
|
($german:literal) => {
|
||||||
|
$crate::i18n::tr($german)
|
||||||
|
};
|
||||||
|
($german:literal, $($name:ident = $value:expr),+ $(,)?) => {
|
||||||
|
$crate::i18n::fill(
|
||||||
|
$crate::i18n::tr($german),
|
||||||
|
&[$((stringify!($name), &$value as &dyn ::std::fmt::Display)),+],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Markiert einen deutschen Text zum Übersetzen, ohne ihn schon zu übersetzen (für Tabellen;
|
||||||
|
/// angezeigt wird er mit `tr`).
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! tn {
|
||||||
|
($german:literal) => {
|
||||||
|
$german
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Quelltexte mit übersetzbaren Texten
|
||||||
|
const SOURCES: &[(&str, &str)] = &[
|
||||||
|
("assets.rs", include_str!("assets.rs")),
|
||||||
|
("autosave.rs", include_str!("autosave.rs")),
|
||||||
|
("background.rs", include_str!("background.rs")),
|
||||||
|
("document.rs", include_str!("document.rs")),
|
||||||
|
("export.rs", include_str!("export.rs")),
|
||||||
|
("file_picker.rs", include_str!("file_picker.rs")),
|
||||||
|
("geometry.rs", include_str!("geometry.rs")),
|
||||||
|
("library.rs", include_str!("library.rs")),
|
||||||
|
("main.rs", include_str!("main.rs")),
|
||||||
|
("page_export.rs", include_str!("page_export.rs")),
|
||||||
|
("pdf_doc.rs", include_str!("pdf_doc.rs")),
|
||||||
|
("pdf_merge.rs", include_str!("pdf_merge.rs")),
|
||||||
|
("canvas.rs", include_str!("canvas.rs")),
|
||||||
|
("selection.rs", include_str!("selection.rs")),
|
||||||
|
("settings.rs", include_str!("settings.rs")),
|
||||||
|
("storage.rs", include_str!("storage.rs")),
|
||||||
|
("text.rs", include_str!("text.rs")),
|
||||||
|
("ui.rs", include_str!("ui.rs")),
|
||||||
|
("updater.rs", include_str!("updater.rs")),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Alle Texte in `t!("…")` und `tn!("…")` eines Quelltexts, auch über mehrere Zeilen
|
||||||
|
/// (Escapes wie `\n` aufgelöst)
|
||||||
|
fn marked_texts(src: &str) -> Vec<String> {
|
||||||
|
let chars: Vec<char> = src.chars().collect();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < chars.len() {
|
||||||
|
let rest: String = chars[i..chars.len().min(i + 4)].iter().collect();
|
||||||
|
let marker_len = if rest.starts_with("t!(") {
|
||||||
|
3
|
||||||
|
} else if rest.starts_with("tn!(") {
|
||||||
|
4
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// `t!(` nicht als Ende eines längeren Namens (z. B. `format!(`) zählen
|
||||||
|
if i > 0 && (chars[i - 1].is_alphanumeric() || chars[i - 1] == '_') {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i += marker_len;
|
||||||
|
while chars.get(i).is_some_and(|c| c.is_whitespace()) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if chars.get(i) != Some(&'"') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
let mut text = String::new();
|
||||||
|
while i < chars.len() && chars[i] != '"' {
|
||||||
|
if chars[i] == '\\' {
|
||||||
|
i += 1;
|
||||||
|
match chars.get(i) {
|
||||||
|
Some('n') => text.push('\n'),
|
||||||
|
// Zeilenfortsetzung: Umbruch und führende Leerzeichen entfallen
|
||||||
|
Some('\n') => {
|
||||||
|
while chars.get(i + 1).is_some_and(|c| c.is_whitespace()) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(other) => text.push(*other),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
text.push(chars[i]);
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out.push(text);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn placeholders(text: &str) -> Vec<&str> {
|
||||||
|
let mut names: Vec<&str> = text
|
||||||
|
.split('{')
|
||||||
|
.skip(1)
|
||||||
|
.filter_map(|part| part.split_once('}').map(|(name, _)| name))
|
||||||
|
.collect();
|
||||||
|
names.sort_unstable();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_marked_text_has_an_english_translation() {
|
||||||
|
let table = english();
|
||||||
|
let mut missing: Vec<String> = SOURCES
|
||||||
|
.iter()
|
||||||
|
.flat_map(|(file, src)| {
|
||||||
|
marked_texts(src)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|text| !table.contains_key(text.as_str()))
|
||||||
|
.map(move |text| format!("{file}: {text:?}"))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
missing.sort();
|
||||||
|
missing.dedup();
|
||||||
|
assert!(
|
||||||
|
missing.is_empty(),
|
||||||
|
"Ohne Übersetzung:\n{}",
|
||||||
|
missing.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_translation_is_used_and_keeps_its_placeholders() {
|
||||||
|
let used: std::collections::HashSet<String> = SOURCES
|
||||||
|
.iter()
|
||||||
|
.flat_map(|(_, src)| marked_texts(src))
|
||||||
|
.collect();
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for (german, english) in en::EN {
|
||||||
|
assert!(seen.insert(german), "Doppelt: {german:?}");
|
||||||
|
assert!(used.contains(*german), "Nicht (mehr) verwendet: {german:?}");
|
||||||
|
assert_eq!(
|
||||||
|
placeholders(german),
|
||||||
|
placeholders(english),
|
||||||
|
"Platzhalter verschieden: {german:?} / {english:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn translate_falls_back_to_german() {
|
||||||
|
assert_eq!(translate(Language::German, "Abbrechen"), "Abbrechen");
|
||||||
|
assert_eq!(translate(Language::English, "Abbrechen"), "Cancel");
|
||||||
|
assert_eq!(
|
||||||
|
translate(Language::English, "kein bekannter Text"),
|
||||||
|
"kein bekannter Text"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fill_replaces_named_placeholders() {
|
||||||
|
assert_eq!(
|
||||||
|
fill(
|
||||||
|
"Seite {number} von {count}",
|
||||||
|
&[("number", &3), ("count", &"7")]
|
||||||
|
),
|
||||||
|
"Seite 3 von 7"
|
||||||
|
);
|
||||||
|
assert_eq!(fill("{unbekannt} bleibt", &[]), "{unbekannt} bleibt");
|
||||||
|
assert_eq!(fill("offen {", &[]), "offen {");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn locale_selects_language() {
|
||||||
|
let s = |v: &str| Some(v.to_string());
|
||||||
|
assert_eq!(
|
||||||
|
Language::from_locale(&[None, None, s("de_AT.UTF-8")]),
|
||||||
|
Language::German
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Language::from_locale(&[s("en_US.UTF-8"), None, s("de_DE.UTF-8")]),
|
||||||
|
Language::English
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Language::from_locale(&[s("C"), None, s("de_DE.UTF-8")]),
|
||||||
|
Language::German
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Language::from_locale(&[s("C.UTF-8"), None, s("de_DE.UTF-8")]),
|
||||||
|
Language::German
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Language::from_locale(&[None, None, None]),
|
||||||
|
Language::English
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Language::from_locale(&[None, None, s("fr_FR.UTF-8")]),
|
||||||
|
Language::English
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+449
@@ -0,0 +1,449 @@
|
|||||||
|
//! Englische Fassung der Oberflächentexte: (deutscher Text im Quelltext, englischer Text).
|
||||||
|
//! Platzhalter `{name}` müssen in beiden Fassungen gleich heißen; der Test in `i18n.rs` meldet
|
||||||
|
//! fehlende, überzählige und abweichende Einträge.
|
||||||
|
|
||||||
|
pub const EN: &[(&str, &str)] = &[
|
||||||
|
("'{dir}' kann nicht geöffnet werden: {err}", "'{dir}' cannot be opened: {err}"),
|
||||||
|
("'{name}' erfolgreich heruntergeladen!", "'{name}' downloaded successfully!"),
|
||||||
|
("'{name}' nicht eingefügt: {err}", "'{name}' not inserted: {err}"),
|
||||||
|
("'{title}' gelöscht", "'{title}' deleted"),
|
||||||
|
("'{title}' importiert ({count} Seiten)", "'{title}' imported ({count} pages)"),
|
||||||
|
("(Empfohlen für dieses System)", "(Recommended for this system)"),
|
||||||
|
("1 Notizbuch", "1 notebook"),
|
||||||
|
("1 Objekt | [R] Drehen [Entf] Löschen", "1 object | [R] Rotate [Del] Delete"),
|
||||||
|
("Abbrechen", "Cancel"),
|
||||||
|
("Abfrage fehlgeschlagen:", "Request failed:"),
|
||||||
|
("Achsenkreuz", "Axis cross"),
|
||||||
|
("Achteck (Oktagon)", "Octagon"),
|
||||||
|
("Aktuelle Version:", "Current version:"),
|
||||||
|
("Alle Striche radieren", "Erase whole strokes"),
|
||||||
|
("Alle", "All"),
|
||||||
|
("Alles gespeichert", "All saved"),
|
||||||
|
("Als leere Seite bearbeiten (Schreibschutz aufheben)", "Edit as blank page (remove write protection)"),
|
||||||
|
("AppImage (Portabel)", "AppImage (portable)"),
|
||||||
|
("Asset-Name darf nicht leer sein.", "Asset name must not be empty."),
|
||||||
|
("Auch per Strg+V aus der Zwischenablage oder durch Ziehen einer Bilddatei ins Fenster", "Also with Ctrl+V from the clipboard or by dragging an image file into the window"),
|
||||||
|
("Auf Seiten mit importiertem PDF lässt sich kein Platz schaffen", "Pages with an imported PDF cannot make space"),
|
||||||
|
("Aus manifest.json.bak wiederherstellen", "Restore from manifest.json.bak"),
|
||||||
|
("Aus: ein Finger verschiebt, zwei Finger zoomen (empfohlen mit Stift).\nAn: ein Finger zeichnet.", "Off: one finger pans, two fingers zoom (recommended with a pen).\nOn: one finger draws."),
|
||||||
|
("Auswahl / Lasso (Select)", "Select / Lasso"),
|
||||||
|
("Auswahl auf Blau umgefärbt", "Selection recolored to blue"),
|
||||||
|
("Auswahl auf Schwarz umgefärbt", "Selection recolored to black"),
|
||||||
|
("Auswahl in der Farbe von Stift {number} umfärben", "Recolor selection with the color of pen {number}"),
|
||||||
|
("Auswahl um 90° gedreht", "Selection rotated by 90°"),
|
||||||
|
("Auswahl umgefärbt", "Selection recolored"),
|
||||||
|
("Auswählen", "Select"),
|
||||||
|
("Auswählen: verschieben, drehen, skalieren, kopieren (V)", "Select: move, rotate, scale, copy (V)"),
|
||||||
|
("Autosave fehlgeschlagen für Seiten {pages}: {messages}", "Autosave failed for pages {pages}: {messages}"),
|
||||||
|
("Beschädigte Seite {id}: {msg}", "Damaged page {id}: {msg}"),
|
||||||
|
("Bild einfügen", "Insert image"),
|
||||||
|
("Bild eingefügt ({width} × {height} px)", "Image inserted ({width} × {height} px)"),
|
||||||
|
("Bild ist zu groß ({size} MB, höchstens {max} MB)", "Image is too large ({size} MB, at most {max} MB)"),
|
||||||
|
("Bild kann nicht gelesen werden: {msg}", "Cannot read image: {msg}"),
|
||||||
|
("Bild mit Papier und Raster (200 dpi)", "Image with paper and pattern (200 dpi)"),
|
||||||
|
("Bild mit transparentem Hintergrund, nur Inhalt", "Image with transparent background, content only"),
|
||||||
|
("Bild nicht eingefügt: {err}", "Image not inserted: {err}"),
|
||||||
|
("Bild, Text oder kopierte Objekte aus der Zwischenablage hier einfügen", "Paste an image, text or copied objects from the clipboard here"),
|
||||||
|
("Blanko", "Blank"),
|
||||||
|
("Blau", "Blue"),
|
||||||
|
("Code", "Code"),
|
||||||
|
("Code-Schrift (feste Zeichenbreite, Tab rückt ein)", "Code font (fixed width, Tab indents)"),
|
||||||
|
("Creme", "Cream"),
|
||||||
|
("Das PDF enthält keine Seiten", "The PDF has no pages"),
|
||||||
|
("Das PDF hat {count} Seiten, höchstens {max} werden unterstützt", "The PDF has {count} pages, at most {max} are supported"),
|
||||||
|
("Das PDF ist verschlüsselt oder passwortgeschützt und kann nicht geöffnet werden", "The PDF is encrypted or password protected and cannot be opened"),
|
||||||
|
("Das PDF wird als neues Notizbuch angelegt, eine Seite je PDF-Seite. Beim Export liegen deine Notizen über den Originalseiten.", "The PDF becomes a new notebook, one page per PDF page. When exporting, your notes lie on top of the original pages."),
|
||||||
|
("Datei existiert nicht", "File does not exist"),
|
||||||
|
("Den Ordner gibt es nicht mehr", "The folder no longer exists"),
|
||||||
|
("Den Ordner „{name}“ gibt es hier schon", "A folder “{name}” already exists here"),
|
||||||
|
("Den Zielordner gibt es nicht mehr", "The target folder no longer exists"),
|
||||||
|
("Der Ordnername ist leer", "The folder name is empty"),
|
||||||
|
("Der Ordnername ist zu lang (höchstens {max} Zeichen)", "The folder name is too long (at most {max} characters)"),
|
||||||
|
("Der übergeordnete Ordner existiert nicht mehr", "The parent folder no longer exists"),
|
||||||
|
("DICKEN-PRESETS", "WIDTH PRESETS"),
|
||||||
|
("Die beschädigte Originaldatei wurde in Quarantäne gesichert:", "The damaged original file was saved to quarantine:"),
|
||||||
|
("Die Datei ist kein lesbares PDF", "The file is not a readable PDF"),
|
||||||
|
("Die einzige Seite eines Notizbuchs kann nicht gelöscht werden", "The only page of a notebook cannot be deleted"),
|
||||||
|
("Die Flatpak-Version wird über die Software-Verwaltung aktualisiert.", "The Flatpak version is updated through the software manager."),
|
||||||
|
("Die heruntergeladene Datei existiert nicht mehr.", "The downloaded file no longer exists."),
|
||||||
|
("die oberste Ebene", "the top level"),
|
||||||
|
("Die Zwischenablage ist leer", "The clipboard is empty"),
|
||||||
|
("Diese Version wird über die Software-Verwaltung aktualisiert (z. B. GNOME Software oder „flatpak update“).", "This version is updated through the software manager (e.g. GNOME Software or “flatpak update”)."),
|
||||||
|
("Dieser Ordner ist leer.", "This folder is empty."),
|
||||||
|
("Dokument '{title}' erstellt", "Document '{title}' created"),
|
||||||
|
("Dokument '{title}' geöffnet", "Document '{title}' opened"),
|
||||||
|
("Dokument in Papierkorb verschoben", "Document moved to the trash"),
|
||||||
|
("DOKUMENT", "DOCUMENT"),
|
||||||
|
("Dokumenten-Manager", "Document manager"),
|
||||||
|
("Doppelpfeil", "Double arrow"),
|
||||||
|
("Download-Verbindungsfehler: {e}", "Download connection error: {e}"),
|
||||||
|
("Dreieck", "Triangle"),
|
||||||
|
("Druckkurve des Stifts", "Pen pressure curve"),
|
||||||
|
("Druckkurve zurücksetzen", "Reset pressure curve"),
|
||||||
|
("Drücke eine Taste am Stift (über dem Bildschirm) – sie erscheint dann hier. Bis dahin radieren alle Tasten.", "Press a button on the pen (above the screen) – it will appear here. Until then all buttons erase."),
|
||||||
|
("Dunkelgrau", "Dark gray"),
|
||||||
|
("Dunkle Stiftfarben aufhellen", "Lighten dark pen colors"),
|
||||||
|
("Eigene Farbe wählen", "Choose a custom color"),
|
||||||
|
("Ein Ordner kann nicht in sich selbst verschoben werden", "A folder cannot be moved into itself"),
|
||||||
|
("Ein-/Ausgabefehler: {e}", "I/O error: {e}"),
|
||||||
|
("Eine Fläche ohne Rand: in alle Richtungen schreiben, verschieben und zoomen. Wechsel nur bei einer einzigen Seite ohne PDF bzw. zurück, solange alles im Startausschnitt liegt.", "One surface without edges: write, pan and zoom in every direction. Switching only works for a single page without a PDF, and back only while everything lies in the starting area."),
|
||||||
|
("Einfügen (Strg+V)", "Paste (Ctrl+V)"),
|
||||||
|
("EINGABE", "INPUT"),
|
||||||
|
("Einstellung nicht gespeichert: {err}", "Setting not saved: {err}"),
|
||||||
|
("Ellipse", "Ellipse"),
|
||||||
|
("Endlos geht nur bei einem Notizbuch mit einer einzigen Seite ohne PDF", "Endless only works for a notebook with a single page without a PDF"),
|
||||||
|
("Entwicklung / Lokal", "Development / local"),
|
||||||
|
("Export fehlgeschlagen: {err}", "Export failed: {err}"),
|
||||||
|
("Farbe {number} – erneut tippen, um sie anzupassen", "Color {number} – tap again to adjust it"),
|
||||||
|
("Farbe {number}", "Color {number}"),
|
||||||
|
("FARBE", "COLOR"),
|
||||||
|
("Fehler beim Beenden", "Error while quitting"),
|
||||||
|
("Fehler beim Erstellen: {err}", "Error while creating: {err}"),
|
||||||
|
("Fehler beim Lesen der Prüfsummendatei: {e}", "Error reading the checksum file: {e}"),
|
||||||
|
("Fehler beim Lesen des Download-Streams: {e}", "Error reading the download stream: {e}"),
|
||||||
|
("Fehler beim Löschen: {err}", "Error while deleting: {err}"),
|
||||||
|
("Fehler beim Schreiben auf die Festplatte: {e}", "Error writing to disk: {e}"),
|
||||||
|
("Fehler beim Umbenennen: {err}", "Error while renaming: {err}"),
|
||||||
|
("Fehler beim Wiederherstellen: {err}", "Error while restoring: {err}"),
|
||||||
|
("Fehler beim Öffnen: {err}", "Error while opening: {err}"),
|
||||||
|
("Fehler: {err}", "Error: {err}"),
|
||||||
|
("Fehlermeldung: {message}", "Error message: {message}"),
|
||||||
|
("Formen (Shape)", "Shapes"),
|
||||||
|
("Formen: Striche werden zu Linien, Rechtecken, Kreisen … (S/F)", "Shapes: strokes become lines, rectangles, circles … (S/F)"),
|
||||||
|
("Fünfeck (Pentagon)", "Pentagon"),
|
||||||
|
("Gerade", "Line"),
|
||||||
|
("Gespeichert in: {path}", "Saved in: {path}"),
|
||||||
|
("Gespeichert", "Saved"),
|
||||||
|
("Gespeichert: {path}", "Saved: {path}"),
|
||||||
|
("Gewechselt zu '{title}'", "Switched to '{title}'"),
|
||||||
|
("Gilt für alle Seiten, auch beim Export", "Applies to all pages, also when exporting"),
|
||||||
|
("Gitea Release-JSON konnte nicht verarbeitet werden: {e}", "Could not process the Gitea release JSON: {e}"),
|
||||||
|
("Gitea Server-URL:", "Gitea server URL:"),
|
||||||
|
("Gitea-Server antwortete mit HTTP {code}: {body}", "Gitea server responded with HTTP {code}: {body}"),
|
||||||
|
("Graphit-Grau", "Graphite gray"),
|
||||||
|
("Größere Schrift (jetzt {size} pt)", "Larger font (now {size} pt)"),
|
||||||
|
("Grün", "Green"),
|
||||||
|
("hart", "hard"),
|
||||||
|
("Hell", "Light"),
|
||||||
|
("Hellgrün", "Light green"),
|
||||||
|
("Heranzoomen (Strg++)", "Zoom in (Ctrl++)"),
|
||||||
|
("Herauszoomen (Strg+-)", "Zoom out (Ctrl+-)"),
|
||||||
|
("HINTERGRUND DIESER SEITE", "BACKGROUND OF THIS PAGE"),
|
||||||
|
("Hinweis: Falls das Repository privat ist, unten ein Personal Access Token eintragen.", "Note: if the repository is private, enter a personal access token below."),
|
||||||
|
("HOME-Umgebungsvariable ist nicht gesetzt.", "HOME environment variable is not set."),
|
||||||
|
("HTTP {code} Weiterleitung ohne Location-Header erhalten", "HTTP {code} redirect without Location header"),
|
||||||
|
("I/O-Fehler: {e}", "I/O error: {e}"),
|
||||||
|
("Im Zielordner gibt es schon einen Ordner „{name}“", "A folder “{name}” already exists in the target folder"),
|
||||||
|
("In dieser Version ist kein Signaturschlüssel für Updates hinterlegt. Bitte das Update manuell installieren.", "This version has no signing key for updates. Please install the update manually."),
|
||||||
|
("In einen anderen Ordner verschieben", "Move to another folder"),
|
||||||
|
("Inhalt nicht lesbar: {e}", "Content not readable: {e}"),
|
||||||
|
("ink-notes ist auf dem neuesten Stand (v{version}).", "ink-notes is up to date (v{version})."),
|
||||||
|
("ink-notes | [{notebook}] {page}{page_title} | [{status}] | {tool} | Hintergrund: {background} | [Ctrl+Z] Undo [Ctrl+Y] Redo [E] Radierer [Ctrl+E/S] PDF-Export", "ink-notes | [{notebook}] {page}{page_title} | [{status}] | {tool} | Background: {background} | [Ctrl+Z] Undo [Ctrl+Y] Redo [E] Eraser [Ctrl+E/S] PDF export"),
|
||||||
|
("Integritätsprüfung fehlgeschlagen für '{asset_name}': Erwartet {expected_hash}, berechnet {actual_hash}. Datei wurde gelöscht.", "Integrity check failed for '{asset_name}': expected {expected_hash}, computed {actual_hash}. The file was deleted."),
|
||||||
|
("Kariert (5mm)", "Grid (5mm)"),
|
||||||
|
("Kariert", "Grid"),
|
||||||
|
("KAROS, LINIEN UND PUNKTE", "GRID, LINES AND DOTS"),
|
||||||
|
("kein Asset-Ordner", "no asset folder"),
|
||||||
|
("Kein Eintrag für '{asset_name}' in SHA256SUMS.txt gefunden. Heruntergeladene Datei wurde gelöscht.", "No entry for '{asset_name}' found in SHA256SUMS.txt. The downloaded file was deleted."),
|
||||||
|
("Kein Release ausgewählt. Download abgelehnt.", "No release selected. Download refused."),
|
||||||
|
("Keine manifest.json vorhanden", "No manifest.json present"),
|
||||||
|
("Keine nutzbare Grafikschnittstelle gefunden.\nHäufigste Ursache: Der Benutzer darf nicht auf die Grafikkarte zugreifen (/dev/dri).\nPrüfen mit `ls -l /dev/dri` und `groups`; ggf. `sudo usermod -aG render,video $USER`\nund neu anmelden. Alternativ einen Software-Renderer installieren (Paket mesa-vulkan-drivers)\noder die Schnittstelle festlegen, z. B. `WGPU_BACKEND=vulkan ink-notes`.", "No usable graphics interface found.\nMost common cause: the user may not access the graphics card (/dev/dri).\nCheck with `ls -l /dev/dri` and `groups`; if needed `sudo usermod -aG render,video $USER`\nand log in again. Alternatively install a software renderer (package mesa-vulkan-drivers)\nor choose the interface, e.g. `WGPU_BACKEND=vulkan ink-notes`."),
|
||||||
|
("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)", "No folders or images (PNG, JPEG, GIF, WebP, BMP)"),
|
||||||
|
("Keine Ordner oder PDF-Dateien", "No folders or PDF files"),
|
||||||
|
("Keine Sicherungskopie (manifest.json.bak) vorhanden", "No backup copy (manifest.json.bak) present"),
|
||||||
|
("Kleinere Schrift (jetzt {size} pt)", "Smaller font (now {size} pt)"),
|
||||||
|
("Klicke auf 'Nach Updates suchen', um die neuesten Releases vom Gitea-Server abzufragen.", "Click 'Check for updates' to query the latest releases from the Gitea server."),
|
||||||
|
("Konnte AppImage nicht atomar ersetzen: {e}. Altes AppImage bleibt unverändert.", "Could not replace the AppImage atomically: {e}. The old AppImage stays unchanged."),
|
||||||
|
("Konnte Backup ({path}) nicht anlegen: {e}. Update abgebrochen, altes AppImage unverändert.", "Could not create backup ({path}): {e}. Update cancelled, old AppImage unchanged."),
|
||||||
|
("Konnte Datei '{path}' zur Prüfsummenberechnung nicht öffnen: {e}", "Could not open file '{path}' to compute its checksum: {e}"),
|
||||||
|
("Konnte nach 100 Versuchen kein eindeutiges Notizbuch-Verzeichnis finden", "Could not find a unique notebook folder after 100 attempts"),
|
||||||
|
("Konnte temporäre Update-Datei nicht schreiben: {e}", "Could not write temporary update file: {e}"),
|
||||||
|
("Konnte Update-Cache-Verzeichnis nicht anlegen ({path}): {e}", "Could not create update cache folder ({path}): {e}"),
|
||||||
|
("Konnte xdg-open nicht ausführen: {e}", "Could not run xdg-open: {e}"),
|
||||||
|
("Konnte Zieldatei '{path}' nicht erstellen: {e}", "Could not create target file '{path}': {e}"),
|
||||||
|
("Konnte {name} nicht dekodieren: {e}.", "Could not decode {name}: {e}."),
|
||||||
|
("Konnte {name} nicht laden: {e}.", "Could not load {name}: {e}."),
|
||||||
|
("Konnte übergeordnetes Verzeichnis des AppImages nicht ermitteln.", "Could not determine the parent folder of the AppImage."),
|
||||||
|
("Koordinatensystem", "Coordinate system"),
|
||||||
|
("Kopieren (Strg+C)", "Copy (Ctrl+C)"),
|
||||||
|
("Korrektur-Rot", "Correction red"),
|
||||||
|
("Kreis", "Circle"),
|
||||||
|
("Kräftig", "Strong"),
|
||||||
|
("Lade '{name}' herunter...", "Downloading '{name}'..."),
|
||||||
|
("Leere Seite direkt nach der aktuellen Seite", "Blank page right after the current page"),
|
||||||
|
("Leichterer Druck zählt als kein Druck (dünnste Linie).\nHilft bei Stiften, die schon beim Schweben Druck melden.", "Lighter pressure counts as no pressure (thinnest line).\nHelps with pens that already report pressure while hovering."),
|
||||||
|
("Leuchtet grün, solange die Taste gedrückt ist", "Lights up green while the button is pressed"),
|
||||||
|
("Liegt bereits hier", "Already here"),
|
||||||
|
("Lineal (L): Fläche ziehen = verschieben, Griff ⟳ = drehen; Striche an einer Kante werden gerade", "Ruler (L): drag the surface = move, handle ⟳ = rotate; strokes along an edge become straight"),
|
||||||
|
("Liniert (7.5mm)", "Lined (7.5mm)"),
|
||||||
|
("Liniert", "Lined"),
|
||||||
|
("Links", "Left"),
|
||||||
|
("Löschen (Entf)", "Delete (Del)"),
|
||||||
|
("Löschen", "Delete"),
|
||||||
|
("Löscht die aktuelle Seite; danach im Menü wiederherstellbar", "Deletes the current page; can be restored from the menu afterwards"),
|
||||||
|
("Manifest aus Backup wiederhergestellt", "Manifest restored from backup"),
|
||||||
|
("Manifest: {msg}", "Manifest: {msg}"),
|
||||||
|
("Manifestdatei nicht gefunden: {path}", "Manifest file not found: {path}"),
|
||||||
|
("Mindestdruck", "Minimum pressure"),
|
||||||
|
("Mit dem Finger zeichnen", "Draw with finger"),
|
||||||
|
("Mittel", "Medium"),
|
||||||
|
("Name …", "Name …"),
|
||||||
|
("Neon-Gelb", "Neon yellow"),
|
||||||
|
("Netzwerk-Transportfehler: {t}", "Network transport error: {t}"),
|
||||||
|
("Neue Seite nach dieser einfügen", "Insert a new page after this one"),
|
||||||
|
("Neue Version verfügbar: {version}", "New version available: {version}"),
|
||||||
|
("Neuen Ordner hier anlegen (z. B. Semester oder Fach)", "Create a new folder here (e.g. semester or subject)"),
|
||||||
|
("Neues Notizbuch in diesem Ordner anlegen und öffnen", "Create a new notebook in this folder and open it"),
|
||||||
|
("Nichts", "Nothing"),
|
||||||
|
("Noch keine Seite hat einen Titel. Gib oben einen Titel für die aktuelle Seite ein.", "No page has a title yet. Enter a title for the current page above."),
|
||||||
|
("Normal", "Normal"),
|
||||||
|
("Normale Schrift", "Normal font"),
|
||||||
|
("Notfall-Export fehlgeschlagen: {err}", "Emergency export failed: {err}"),
|
||||||
|
("Notfall-Export", "Emergency export"),
|
||||||
|
("Notiz-Schwarz", "Note black"),
|
||||||
|
("Notizbuch '{path}' wird bereits von einer anderen Instanz verwendet", "Notebook '{path}' is already used by another instance"),
|
||||||
|
("Notizbuch '{title}' konnte nicht geladen werden – neues Notizbuch angelegt", "Notebook '{title}' could not be loaded – created a new notebook"),
|
||||||
|
("Notizbuch enthält keine Seiten", "Notebook has no pages"),
|
||||||
|
("Notizbuch in Papierkorb verschieben", "Move notebook to the trash"),
|
||||||
|
("Notizbuch ist gesperrt: {msg}", "Notebook is locked: {msg}"),
|
||||||
|
("Notizbuch wiederhergestellt", "Notebook restored"),
|
||||||
|
("Notizbuch wurde mit neuerer Version erstellt (Formatversion {found}, unterstützt wird bis {supported})", "Notebook was created with a newer version (format version {found}, supported up to {supported})"),
|
||||||
|
("Notizbuch", "Notebook"),
|
||||||
|
("Notizbücher verwalten (aktuell: {title})", "Manage notebooks (current: {title})"),
|
||||||
|
("Nur Bilder (PNG, JPEG, GIF, WebP, BMP) und PDFs können eingefügt werden", "Only images (PNG, JPEG, GIF, WebP, BMP) and PDFs can be inserted"),
|
||||||
|
("Nur Textmarker radieren – Handschrift darunter bleibt", "Erase highlighter only – handwriting underneath stays"),
|
||||||
|
("Nächste Seite (Bild ab)", "Next page (Page Down)"),
|
||||||
|
("Oktaeder (3D-Kantenmodell)", "Octahedron (3D wireframe)"),
|
||||||
|
("Ordner löschen (die Notizbücher bleiben erhalten)", "Delete folder (the notebooks are kept)"),
|
||||||
|
("Ordner löschen", "Delete folder"),
|
||||||
|
("Ordner nicht gespeichert: {e}", "Folder not saved: {e}"),
|
||||||
|
("Ordner samt Inhalt in einen anderen Ordner verschieben", "Move folder with its contents to another folder"),
|
||||||
|
("Ordner umbenennen", "Rename folder"),
|
||||||
|
("Ordner „{name}“ angelegt", "Folder “{name}” created"),
|
||||||
|
("Ordner „{name}“ gelöscht, sein Inhalt liegt eine Ebene höher", "Folder “{name}” deleted, its contents moved up one level"),
|
||||||
|
("Ordner „{name}“ löschen? Seine Notizbücher und Unterordner rücken eine Ebene nach oben, gelöscht wird kein Notizbuch.", "Delete folder “{name}”? Its notebooks and subfolders move up one level, no notebook is deleted."),
|
||||||
|
("Ordner „{name}“ verschoben in {target}", "Folder “{name}” moved to {target}"),
|
||||||
|
("Ordner: {path}", "Folder: {path}"),
|
||||||
|
("Ordnernamen dürfen kein „/“ enthalten", "Folder names must not contain “/”"),
|
||||||
|
("PAPIERFARBE (NUR ANZEIGE)", "PAPER COLOR (DISPLAY ONLY)"),
|
||||||
|
("Papierkorb-Eintrag '{path}' existiert nicht", "Trash entry '{path}' does not exist"),
|
||||||
|
("Papierkorb-Pfad '{path}' liegt nicht im Papierkorb", "Trash path '{path}' is not in the trash"),
|
||||||
|
("Papierkorb-Verzeichnis existiert nicht", "Trash folder does not exist"),
|
||||||
|
("Pastell-Cyan", "Pastel cyan"),
|
||||||
|
("PDF importieren", "Import PDF"),
|
||||||
|
("PDF kann nicht geöffnet werden", "Cannot open PDF"),
|
||||||
|
("PDF nicht importiert: {err}", "PDF not imported: {err}"),
|
||||||
|
("PDF nicht lesbar: {e}", "PDF not readable: {e}"),
|
||||||
|
("PDF ohne Seite", "PDF without a page"),
|
||||||
|
("PDF-Export läuft…", "PDF export running…"),
|
||||||
|
("PDF-Render-Thread beendet", "PDF render thread stopped"),
|
||||||
|
("Personal Access Token (optional für private Repos):", "Personal access token (optional for private repos):"),
|
||||||
|
("Pfad '{path}' ist ein Symlink und darf nicht gelöscht werden", "Path '{path}' is a symlink and must not be deleted"),
|
||||||
|
("Pfad '{path}' liegt nicht direkt unter Basis-Verzeichnis '{base}'", "Path '{path}' is not directly inside the base folder '{base}'"),
|
||||||
|
("Pfad in Statusleiste anzeigen", "Show path in the status bar"),
|
||||||
|
("Pfeil", "Arrow"),
|
||||||
|
("Pixeldaten passen nicht zur Bildgröße", "Pixel data does not match the image size"),
|
||||||
|
("Platz schaffen", "Make space"),
|
||||||
|
("Platz schaffen: auf das Blatt tippen und nach unten ziehen – alles darunter rutscht mit, auch auf die folgenden Seiten; nach oben schließt leeren Platz", "Make space: tap the page and drag down – everything below moves along, also onto the following pages; dragging up closes empty space"),
|
||||||
|
("Polygon", "Polygon"),
|
||||||
|
("Prüfe Gitea-Releases...", "Checking Gitea releases..."),
|
||||||
|
("Punkte", "Dots"),
|
||||||
|
("Punktraster (5mm)", "Dot grid (5mm)"),
|
||||||
|
("Quell-PDF fehlt", "Source PDF missing"),
|
||||||
|
("Quell-PDF ist verschlüsselt", "Source PDF is encrypted"),
|
||||||
|
("Radieren", "Erase"),
|
||||||
|
("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich oder nur Textmarker", "Eraser (E or right mouse button) – below: whole strokes, partial stroke or highlighter only"),
|
||||||
|
("Radierer (Eraser)", "Eraser"),
|
||||||
|
("Raute (Rhombus)", "Rhombus"),
|
||||||
|
("Rechteck", "Rectangle"),
|
||||||
|
("Rechts", "Right"),
|
||||||
|
("Release enthält keine {name}.", "Release contains no {name}."),
|
||||||
|
("Release- & Update-Center", "Release & update center"),
|
||||||
|
("Rosa", "Pink"),
|
||||||
|
("Rückgängig (Strg+Z)", "Undo (Ctrl+Z)"),
|
||||||
|
("Schließen (Esc)", "Close (Esc)"),
|
||||||
|
("Schriftgröße {size} pt", "Font size {size} pt"),
|
||||||
|
("Schwarz wird Weiß, Dunkelblau wird Hellblau. Gespeichert und als PDF exportiert werden immer die Originalfarben.", "Black becomes white, dark blue becomes light blue. Saving and PDF export always use the original colors."),
|
||||||
|
("Schwarz", "Black"),
|
||||||
|
("Sechseck (Hexagon)", "Hexagon"),
|
||||||
|
("Seite als", "Page as"),
|
||||||
|
("Seite für Seite blättern", "Flip page by page"),
|
||||||
|
("Seite ist schreibgeschützt", "Page is read-only"),
|
||||||
|
("Seite konnte nicht geladen werden", "Page could not be loaded"),
|
||||||
|
("Seite wird exportiert …", "Exporting page …"),
|
||||||
|
("Seite wählen", "Choose page"),
|
||||||
|
("Seite {id} ist nicht geladen – Export abgebrochen (vorher storage::load_all_pages aufrufen)", "Page {id} is not loaded – export cancelled (call storage::load_all_pages first)"),
|
||||||
|
("Seite {id} konnte nicht fehlerfrei geladen werden!", "Page {id} could not be loaded without errors!"),
|
||||||
|
("Seite {id} nicht gefunden", "Page {id} not found"),
|
||||||
|
("Seite {id}: {msg}", "Page {id}: {msg}"),
|
||||||
|
("Seite {number} / {count}", "Page {number} / {count}"),
|
||||||
|
("Seite {number} eingefügt", "Page {number} inserted"),
|
||||||
|
("Seite {number} fehlt im PDF", "Page {number} missing in the PDF"),
|
||||||
|
("Seite {number} fehlt im Quell-PDF", "Page {number} missing in the source PDF"),
|
||||||
|
("Seite {number} gelöscht – im Menü ☰ wiederherstellbar", "Page {number} deleted – can be restored from the ☰ menu"),
|
||||||
|
("Seite {number} mit allem Inhalt löschen?", "Delete page {number} with all its content?"),
|
||||||
|
("Seite {number} wiederhergestellt", "Page {number} restored"),
|
||||||
|
("Seite {number} – {title}", "Page {number} – {title}"),
|
||||||
|
("Seite {number}", "Page {number}"),
|
||||||
|
("Seiten ({count})", "Pages ({count})"),
|
||||||
|
("SEITEN DIESES NOTIZBUCHS", "PAGES OF THIS NOTEBOOK"),
|
||||||
|
("Seiten hängen untereinander, Mausrad und Finger scrollen (Strg+Mausrad zoomt); wer unten auf der letzten Seite schreibt, bekommt automatisch eine neue Seite.", "Pages are stacked, mouse wheel and finger scroll (Ctrl+wheel zooms); writing at the bottom of the last page adds a new page automatically."),
|
||||||
|
("Seiten:", "Pages:"),
|
||||||
|
("Seitenverhältnis frei – tippen zum Sperren", "Aspect ratio free – tap to lock"),
|
||||||
|
("Seitenverhältnis gesperrt – tippen zum Freigeben", "Aspect ratio locked – tap to unlock"),
|
||||||
|
("Serialisierungsfehler: {e}", "Serialization error: {e}"),
|
||||||
|
("Server-Einstellungen gespeichert", "Server settings saved"),
|
||||||
|
("Server:", "Server:"),
|
||||||
|
("Signaturprüfung von {file} fehlgeschlagen: {e}.", "Signature check of {file} failed: {e}."),
|
||||||
|
("Speicherfehler!", "Save error!"),
|
||||||
|
("Speicherfehler: {messages}", "Save error: {messages}"),
|
||||||
|
("Speichert …", "Saving …"),
|
||||||
|
("Speichert...", "Saving..."),
|
||||||
|
("Sprache der Oberfläche", "Interface language"),
|
||||||
|
("Standard-Notizbuch erstellt", "Default notebook created"),
|
||||||
|
("Standard-Notizbuch", "Default notebook"),
|
||||||
|
("STATUS", "STATUS"),
|
||||||
|
("Stift (B/U/R für Farben) – erneut tippen für Farbe & Stärke", "Pen (B/U/R for colors) – tap again for color & width"),
|
||||||
|
("Stift (Pen)", "Pen"),
|
||||||
|
("Stift {number} ({width} mm) – erneut tippen für Farbe & Stärke; Änderungen merkt sich der Stift", "Pen {number} ({width} mm) – tap again for color & width; the pen remembers changes"),
|
||||||
|
("Stift {number} ({width} mm)", "Pen {number} ({width} mm)"),
|
||||||
|
("Stift {number}", "Pen {number}"),
|
||||||
|
("Stift", "Pen"),
|
||||||
|
("STUFENLOSE STÄRKE", "STEPLESS WIDTH"),
|
||||||
|
("Stärke auf {width} setzen", "Set width to {width}"),
|
||||||
|
("System", "System"),
|
||||||
|
("System-Paket (.deb)", "System package (.deb)"),
|
||||||
|
("Tafel-Grün", "Chalkboard green"),
|
||||||
|
("Taste {number}", "Button {number}"),
|
||||||
|
("Tasten am Stift", "Pen buttons"),
|
||||||
|
("Teilstrich radieren – nur den überstrichenen Teil entfernen (Formen ganz)", "Erase partial stroke – remove only the part you wipe over (shapes completely)"),
|
||||||
|
("Text (T)", "Text (T)"),
|
||||||
|
("Text (T): ins Blatt tippen und schreiben, vorhandenen Text antippen zum Ändern; Esc beendet", "Text (T): tap the page and write, tap existing text to edit it; Esc finishes"),
|
||||||
|
("Text eingefügt – mit dem Text-Werkzeug (T) änderbar", "Text pasted – editable with the text tool (T)"),
|
||||||
|
("Textfarbe {number}", "Text color {number}"),
|
||||||
|
("Textmarker (Highlighter)", "Highlighter"),
|
||||||
|
("Textmarker – erneut tippen für Farbe & Stärke", "Highlighter – tap again for color & width"),
|
||||||
|
("Textmarker – Farbe {number}", "Highlighter – color {number}"),
|
||||||
|
("Textmarker", "Highlighter"),
|
||||||
|
("Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen.", "Tip: paste screenshots with Ctrl+V or drag image files into the window."),
|
||||||
|
("Titel umbenennen", "Rename title"),
|
||||||
|
("TITEL VON SEITE {number}", "TITLE OF PAGE {number}"),
|
||||||
|
("Trotzdem beenden", "Quit anyway"),
|
||||||
|
("Trotzdem beenden?", "Quit anyway?"),
|
||||||
|
("Um 90° drehen (R)", "Rotate by 90° (R)"),
|
||||||
|
("Um Datenverlust zu verhindern, ist diese Seite schreibgeschützt.\nWenn Sie diese Seite bearbeiten möchten, können Sie den Schreibschutz aufheben.", "To prevent data loss, this page is read-only.\nIf you want to edit this page, you can remove the write protection."),
|
||||||
|
("Umbenannt in '{title}'", "Renamed to '{title}'"),
|
||||||
|
("Ungespeichert", "Unsaved"),
|
||||||
|
("Ungespeicherte Änderungen konnten nicht gesichert werden:", "Unsaved changes could not be saved:"),
|
||||||
|
("Ungespeicherte Änderungen", "Unsaved changes"),
|
||||||
|
("Ungültige Server-URL '{url}': {e}", "Invalid server URL '{url}': {e}"),
|
||||||
|
("Ungültige URL '{initial_url_str}': {e}", "Invalid URL '{initial_url_str}': {e}"),
|
||||||
|
("Ungültiger AppImage-Dateiname.", "Invalid AppImage file name."),
|
||||||
|
("Ungültiger Asset-Name (enthält unzulässige Pfadkomponenten): '{raw_name}'", "Invalid asset name (contains forbidden path components): '{raw_name}'"),
|
||||||
|
("Ungültiger Bildverweis '{name}'", "Invalid image reference '{name}'"),
|
||||||
|
("Ungültiger Dateiname: '{raw_name}'", "Invalid file name: '{raw_name}'"),
|
||||||
|
("Ungültiger oder versteckter Asset-Name: '{raw_name}'", "Invalid or hidden asset name: '{raw_name}'"),
|
||||||
|
("Ungültiger PDF-Verweis '{key}'", "Invalid PDF reference '{key}'"),
|
||||||
|
("Ungültiger Pfad (Path Traversal): {msg}", "Invalid path (path traversal): {msg}"),
|
||||||
|
("Ungültiger Update-Signaturschlüssel: {e}.", "Invalid update signing key: {e}."),
|
||||||
|
("Ungültiges Manifest: {msg}", "Invalid manifest: {msg}"),
|
||||||
|
("Ungültiges Weiterleitungsziel '{loc}': {e}", "Invalid redirect target '{loc}': {e}"),
|
||||||
|
("Universitäts-Blau", "University blue"),
|
||||||
|
("Unsicheres Protokoll '{scheme}' abgewiesen. Es wird zwingend HTTPS verlangt (z. B. https://gitea.pansi.eu).", "Insecure protocol '{scheme}' rejected. HTTPS is required (e.g. https://gitea.pansi.eu)."),
|
||||||
|
("Unsicheres Protokoll '{scheme}' für '{url}' abgewiesen. Es wird zwingend HTTPS verlangt.", "Insecure protocol '{scheme}' for '{url}' rejected. HTTPS is required."),
|
||||||
|
("Update gestartet / geöffnet", "Update started / opened"),
|
||||||
|
("Update-Fehler: {err}", "Update error: {err}"),
|
||||||
|
("Vektorgrafik, z. B. für Inkscape oder LaTeX", "Vector graphics, e.g. for Inkscape or LaTeX"),
|
||||||
|
("VERFÜGBARE ARTEFAKTE:", "AVAILABLE ARTIFACTS:"),
|
||||||
|
("Vergessen (erscheint beim nächsten Drücken wieder, dann mit Radieren)", "Forget (appears again on the next press, then erasing)"),
|
||||||
|
("Verschoben in {target}", "Moved to {target}"),
|
||||||
|
("Verzeichnis '{path}' enthält kein manifest.json", "Folder '{path}' contains no manifest.json"),
|
||||||
|
("Verzeichnis '{path}' existiert nicht", "Folder '{path}' does not exist"),
|
||||||
|
("Vorherige Seite (Bild auf)", "Previous page (Page Up)"),
|
||||||
|
("Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen und darauf schreiben", "Open lecture slides, worksheets or forms as a new notebook and write on them"),
|
||||||
|
("weich", "soft"),
|
||||||
|
("Weich: schon leichter Druck ergibt kräftige Linien.\nHart: erst fester Druck ergibt kräftige Linien.", "Soft: even light pressure gives bold lines.\nHard: only firm pressure gives bold lines."),
|
||||||
|
("Weitere Funktionen – Update verfügbar", "More – update available"),
|
||||||
|
("Weitere Funktionen", "More"),
|
||||||
|
("Weitere Funktionen: PDF, Hintergrund, Zoom, Einstellungen", "More: PDF, background, zoom, settings"),
|
||||||
|
("Weiß", "White"),
|
||||||
|
("WERKZEUGLEISTE", "TOOLBAR"),
|
||||||
|
("Wiederherstellung fehlgeschlagen: {err}", "Restore failed: {err}"),
|
||||||
|
("Wiederholen (Strg+Y)", "Redo (Ctrl+Y)"),
|
||||||
|
("Wiederholen", "Retry"),
|
||||||
|
("Worker panic beim Speichern der Seite", "Worker panic while saving the page"),
|
||||||
|
("Worker panic beim Speichern des Manifests", "Worker panic while saving the manifest"),
|
||||||
|
("z. B. Kapitel 2 – Integrale", "e.g. Chapter 2 – Integrals"),
|
||||||
|
("Zielseite fehlt", "Target page missing"),
|
||||||
|
("ZOOM", "ZOOM"),
|
||||||
|
("Zu viele Weiterleitungen (Redirect-Schleife abgebrochen).", "Too many redirects (redirect loop stopped)."),
|
||||||
|
("Zugriffsfehler (I/O). Die Datei existiert, kann aber nicht gelesen werden.\nDie Seite bleibt schreibgeschützt, um ein Überschreiben zu verhindern.", "Access error (I/O). The file exists but cannot be read.\nThe page stays read-only to prevent overwriting it."),
|
||||||
|
("Zurück zu Seiten geht nur, solange alles im Startausschnitt liegt", "Back to pages only works while everything lies in the starting area"),
|
||||||
|
("Zurücksetzen (Strg+0)", "Reset (Ctrl+0)"),
|
||||||
|
("{count} Objekt(e) eingefügt", "{count} object(s) pasted"),
|
||||||
|
("{count} Objekt(e) gelöscht", "{count} object(s) deleted"),
|
||||||
|
("{count} Objekt(e) kopiert – auch als Bild für andere Programme", "{count} object(s) copied – also as an image for other apps"),
|
||||||
|
("{count} Objekt(e) kopiert", "{count} object(s) copied"),
|
||||||
|
("{count} Objekte | [R] Drehen [Entf] Löschen", "{count} objects | [R] Rotate [Del] Delete"),
|
||||||
|
("{count} Seite(n)", "{count} page(s)"),
|
||||||
|
("{e} Download abgelehnt.", "{e} Download refused."),
|
||||||
|
("{file} ist ungültig: {e}.", "{file} is invalid: {e}."),
|
||||||
|
("{mm} mm Platz geschaffen (Rückgängig mit Strg+Z)", "{mm} mm of space made (undo with Ctrl+Z)"),
|
||||||
|
("{mm} mm Platz geschlossen (Rückgängig mit Strg+Z)", "{mm} mm of space closed (undo with Ctrl+Z)"),
|
||||||
|
("{n} Notizbücher", "{n} notebooks"),
|
||||||
|
("{shape_name} ausgerichtet! (Undo mit Strg+Z)", "{shape_name} straightened! (undo with Ctrl+Z)"),
|
||||||
|
("{shape} eingerastet!", "{shape} snapped!"),
|
||||||
|
("{title}_Seite_{number}{suffix}", "{title}_page_{number}{suffix}"),
|
||||||
|
("Änderungen:", "Changes:"),
|
||||||
|
("Übergeordneter Ordner", "Parent folder"),
|
||||||
|
("Übergeordnetes Verzeichnis nicht gefunden", "Parent folder not found"),
|
||||||
|
("„{title}“ verschieben nach:", "Move “{title}” to:"),
|
||||||
|
("↩ Rückgängig", "↩ Undo"),
|
||||||
|
("⏳ Update lädt … {percent} %", "⏳ Downloading update … {percent} %"),
|
||||||
|
("⏷ {mm} mm Platz", "⏷ {mm} mm space"),
|
||||||
|
("♾ Endlos", "♾ Endless"),
|
||||||
|
("⚙ Erweiterte Server-Einstellungen", "⚙ Advanced server settings"),
|
||||||
|
("⚠ Beschädigt", "⚠ Damaged"),
|
||||||
|
("⚠ Datenintegritäts-Warnung", "⚠ Data integrity warning"),
|
||||||
|
("⚠ In anderer Instanz geöffnet (Schreibschutz)", "⚠ Opened in another instance (read-only)"),
|
||||||
|
("⚠ Ungespeicherte Änderungen", "⚠ Unsaved changes"),
|
||||||
|
("⚠\u{fe0f} {name} (Beschädigt - kein Manifest)", "⚠\u{fe0f} {name} (damaged - no manifest)"),
|
||||||
|
("⚠\u{fe0f} {name} (Beschädigt)", "⚠\u{fe0f} {name} (damaged)"),
|
||||||
|
("⚫ Aktiv", "⚫ Active"),
|
||||||
|
("✔ Update bereit", "✔ Update ready"),
|
||||||
|
("➕ Neue Seite einfügen", "➕ Insert new page"),
|
||||||
|
("➕ Notizbuch", "➕ Notebook"),
|
||||||
|
("⟲ Gelöschte Seite {number} wiederherstellen", "⟲ Restore deleted page {number}"),
|
||||||
|
("⬇ Downloads", "⬇ Downloads"),
|
||||||
|
("🌐 Release auf Gitea öffnen", "🌐 Open release on Gitea"),
|
||||||
|
("🏠 Persönlicher Ordner", "🏠 Home folder"),
|
||||||
|
("💾 Einstellungen speichern", "💾 Save settings"),
|
||||||
|
("📁 Ordner", "📁 Folder"),
|
||||||
|
("📂 Im Dateimanager anzeigen", "📂 Show in file manager"),
|
||||||
|
("📂 Ordner öffnen", "📂 Open folder"),
|
||||||
|
("📄 Als PDF exportieren (Strg+E)", "📄 Export as PDF (Ctrl+E)"),
|
||||||
|
("📄 PDF importieren …", "📄 Import PDF …"),
|
||||||
|
("📄 Feste Seiten", "📄 Fixed pages"),
|
||||||
|
("📄 PDF importieren", "📄 Import PDF"),
|
||||||
|
("📋 Einfügen", "📋 Paste"),
|
||||||
|
("📑 Inhalt", "📑 Contents"),
|
||||||
|
("📚 Alle", "📚 All"),
|
||||||
|
("📚 Notizbücher", "📚 Notebooks"),
|
||||||
|
("📚 Oberste Ebene", "📚 Top level"),
|
||||||
|
("📜 Durchgehend", "📜 Continuous"),
|
||||||
|
("📦 Paket mit Software-Center öffnen / installieren", "📦 Open / install package with the software center"),
|
||||||
|
("🔄 Suche nach Updates …", "🔄 Checking for updates …"),
|
||||||
|
("🔄 Updates & Versionen", "🔄 Updates & versions"),
|
||||||
|
("🔄 Backup", "🔄 Backup"),
|
||||||
|
("🔄 Nach Updates suchen", "🔄 Check for updates"),
|
||||||
|
("🖥 Schreibtisch", "🖥 Desktop"),
|
||||||
|
("🖼 Bild aus Datei …", "🖼 Image from file …"),
|
||||||
|
("🖼 Bild einfügen … (Strg+I)", "🖼 Insert image … (Ctrl+I)"),
|
||||||
|
("🖼 Kacheln", "🖼 Tiles"),
|
||||||
|
("🖼 Bild einfügen", "🖼 Insert image"),
|
||||||
|
("🖼 Bilder", "🖼 Pictures"),
|
||||||
|
("🗑 Seite {number} löschen …", "🗑 Delete page {number} …"),
|
||||||
|
("🚀 Update {version} verfügbar", "🚀 Update {version} available"),
|
||||||
|
("🚀 AppImage aktualisieren & ausführen", "🚀 Update AppImage & restart"),
|
||||||
|
("🚀 Updates & Versionen", "🚀 Updates & versions"),
|
||||||
|
];
|
||||||
+97
-29
@@ -45,26 +45,57 @@ pub fn quad_vertices(corners: &[glam::Vec2; 4]) -> [ImageVertex; 6] {
|
|||||||
[tl, tr, bl, bl, tr, br]
|
[tl, tr, bl, bl, tr, br]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1.
|
/// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1. Für eine gerenderte
|
||||||
pub fn mip_chain(base: &RgbaImage) -> Vec<RgbaImage> {
|
/// PDF-Seite dauert das spürbar (2560 px: einige 10 ms); deshalb erzeugt sie der
|
||||||
let mut levels = vec![base.clone()];
|
/// PDF-Render-Thread und nicht der Haupt-Thread.
|
||||||
let Some(mut current) =
|
pub fn mip_chain(base: RgbaImage) -> Vec<RgbaImage> {
|
||||||
image::RgbaImage::from_raw(base.width, base.height, base.pixels.clone())
|
let mut levels = vec![base];
|
||||||
else {
|
loop {
|
||||||
return levels;
|
let current = &levels[levels.len() - 1];
|
||||||
};
|
if current.width <= 1 && current.height <= 1 {
|
||||||
while current.width() > 1 || current.height() > 1 {
|
break;
|
||||||
let (w, h) = ((current.width() / 2).max(1), (current.height() / 2).max(1));
|
}
|
||||||
current = image::imageops::resize(¤t, w, h, image::imageops::FilterType::Triangle);
|
let next = half_size(current);
|
||||||
levels.push(RgbaImage {
|
levels.push(next);
|
||||||
width: w,
|
|
||||||
height: h,
|
|
||||||
pixels: current.as_raw().clone(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
levels
|
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.
|
/// Name der Ersatztextur für Bilder, deren Datei fehlt oder beschädigt ist.
|
||||||
const MISSING: &str = "";
|
const MISSING: &str = "";
|
||||||
/// Bis zu dieser Grafikspeichermenge bleiben Texturen anderer Seiten erhalten (schnelles
|
/// 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.
|
/// Antwort einer Texturquelle für einen Schlüssel.
|
||||||
pub enum TextureSource {
|
pub enum TextureSource {
|
||||||
Ready(RgbaImage),
|
Ready(RgbaImage),
|
||||||
|
/// Fertige Mipmap-Stufen (siehe [`mip_chain`]), z. B. vom PDF-Render-Thread
|
||||||
|
ReadyMips(Vec<RgbaImage>),
|
||||||
/// Wird gerade erzeugt; später erneut fragen
|
/// Wird gerade erzeugt; später erneut fragen
|
||||||
Pending,
|
Pending,
|
||||||
Failed(String),
|
Failed(String),
|
||||||
@@ -151,7 +184,7 @@ impl ImageLayer {
|
|||||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
depth_stencil: None,
|
depth_stencil: Some(crate::canvas::passive_stencil()),
|
||||||
multisample: wgpu::MultisampleState {
|
multisample: wgpu::MultisampleState {
|
||||||
count: sample_count,
|
count: sample_count,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -233,10 +266,16 @@ impl ImageLayer {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let (bind_group, bytes) = match load(&key) {
|
let (bind_group, bytes) = match load(&key) {
|
||||||
TextureSource::Ready(image) => (
|
TextureSource::Ready(image) => {
|
||||||
self.upload_texture(device, queue, &key, &image),
|
self.upload_texture(device, queue, &key, mip_chain(image))
|
||||||
image.pixels.len() as u64 * 4 / 3,
|
}
|
||||||
),
|
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
|
// Noch in Arbeit (PDF-Seite wird gerendert): beim nächsten Bild erneut fragen
|
||||||
TextureSource::Pending => continue,
|
TextureSource::Pending => continue,
|
||||||
TextureSource::Failed(err) => {
|
TextureSource::Failed(err) => {
|
||||||
@@ -342,17 +381,19 @@ impl ImageLayer {
|
|||||||
height: 1,
|
height: 1,
|
||||||
pixels: vec![200, 200, 200, 255],
|
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(
|
fn upload_texture(
|
||||||
&self,
|
&self,
|
||||||
device: &wgpu::Device,
|
device: &wgpu::Device,
|
||||||
queue: &wgpu::Queue,
|
queue: &wgpu::Queue,
|
||||||
label: &str,
|
label: &str,
|
||||||
image: &RgbaImage,
|
levels: Vec<RgbaImage>,
|
||||||
) -> wgpu::BindGroup {
|
) -> (wgpu::BindGroup, u64) {
|
||||||
let levels = mip_chain(image);
|
let image = &levels[0];
|
||||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
label: Some(label),
|
label: Some(label),
|
||||||
size: wgpu::Extent3d {
|
size: wgpu::Extent3d {
|
||||||
@@ -363,7 +404,8 @@ impl ImageLayer {
|
|||||||
mip_level_count: levels.len() as u32,
|
mip_level_count: levels.len() as u32,
|
||||||
sample_count: 1,
|
sample_count: 1,
|
||||||
dimension: wgpu::TextureDimension::D2,
|
dimension: wgpu::TextureDimension::D2,
|
||||||
format: wgpu::TextureFormat::Rgba8UnormSrgb,
|
// sRGB-Werte ohne Umrechnung: gezeichnet wird in sRGB (siehe canvas::render_format)
|
||||||
|
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||||
view_formats: &[],
|
view_formats: &[],
|
||||||
});
|
});
|
||||||
@@ -388,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());
|
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"),
|
label: Some("Image Texture Bind Group"),
|
||||||
layout: &self.texture_layout,
|
layout: &self.texture_layout,
|
||||||
entries: &[
|
entries: &[
|
||||||
@@ -402,7 +445,8 @@ impl ImageLayer {
|
|||||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
(bind_group, bytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,11 +482,35 @@ mod tests {
|
|||||||
height: 3,
|
height: 3,
|
||||||
pixels: vec![128; 8 * 3 * 4],
|
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();
|
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)]);
|
assert_eq!(sizes, vec![(8, 3), (4, 1), (2, 1), (1, 1)]);
|
||||||
for l in &levels {
|
for l in &levels {
|
||||||
assert_eq!(l.pixels.len(), (l.width * l.height * 4) as usize);
|
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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ pub mod file_picker;
|
|||||||
pub mod filter;
|
pub mod filter;
|
||||||
pub mod geometry;
|
pub mod geometry;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
|
pub mod i18n;
|
||||||
pub mod image_layer;
|
pub mod image_layer;
|
||||||
|
pub mod library;
|
||||||
|
pub mod osk;
|
||||||
pub mod page_export;
|
pub mod page_export;
|
||||||
pub mod pdf_doc;
|
pub mod pdf_doc;
|
||||||
pub mod pdf_merge;
|
pub mod pdf_merge;
|
||||||
|
|||||||
+449
@@ -0,0 +1,449 @@
|
|||||||
|
//! Ordner für Notizbücher (`<Basisordner>/library.json`).
|
||||||
|
//!
|
||||||
|
//! Ordner sind rein logisch: Die Notizbücher bleiben in ihren Verzeichnissen
|
||||||
|
//! `<Basisordner>/<id>/`, `library.json` hält nur fest, welcher Ordner welches Notizbuch
|
||||||
|
//! enthält. Umbenennen und Verschieben ändern so nie Notizbuch-Dateien. Fehlt die Datei oder
|
||||||
|
//! ist sie unlesbar, liegen alle Notizbücher auf der obersten Ebene – verloren geht nichts.
|
||||||
|
//!
|
||||||
|
//! Ordner werden als Pfade mit `/` als Trenner gespeichert (z. B. `3. Semester/Mathe`);
|
||||||
|
//! die oberste Ebene ist der leere Pfad.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::storage::{self, StorageError};
|
||||||
|
use crate::t;
|
||||||
|
|
||||||
|
/// Oberste Ebene.
|
||||||
|
pub const ROOT: &str = "";
|
||||||
|
|
||||||
|
/// Längster erlaubter Ordnername (Zeichen).
|
||||||
|
const MAX_NAME_CHARS: usize = 80;
|
||||||
|
|
||||||
|
/// Ordnerbaum und Zuordnung der Notizbücher.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Library {
|
||||||
|
/// Alle Ordner als Pfade
|
||||||
|
pub folders: BTreeSet<String>,
|
||||||
|
/// Ordner je Notizbuch-Kennung; Notizbücher ohne Eintrag liegen auf oberster Ebene
|
||||||
|
pub notebooks: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Letzter Teil eines Ordnerpfads (der angezeigte Name).
|
||||||
|
pub fn folder_name(path: &str) -> &str {
|
||||||
|
path.rsplit('/').next().unwrap_or(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Übergeordneter Ordner (oberste Ebene für Ordner der obersten Ebene).
|
||||||
|
pub fn parent_folder(path: &str) -> &str {
|
||||||
|
path.rsplit_once('/').map_or(ROOT, |(parent, _)| parent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pfad des Unterordners `name` in `parent`.
|
||||||
|
fn join(parent: &str, name: &str) -> String {
|
||||||
|
if parent.is_empty() {
|
||||||
|
name.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{parent}/{name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `path` liegt in `folder` oder ist `folder` selbst.
|
||||||
|
fn is_within(path: &str, folder: &str) -> bool {
|
||||||
|
path == folder
|
||||||
|
|| path
|
||||||
|
.strip_prefix(folder)
|
||||||
|
.is_some_and(|rest| rest.starts_with('/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bereinigt einen Ordnernamen; Fehlermeldung für die Oberfläche bei ungültigen Namen.
|
||||||
|
pub fn validate_name(name: &str) -> Result<String, String> {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(t!("Der Ordnername ist leer").to_string());
|
||||||
|
}
|
||||||
|
if name.contains('/') {
|
||||||
|
return Err(t!("Ordnernamen dürfen kein „/“ enthalten").to_string());
|
||||||
|
}
|
||||||
|
if name.chars().count() > MAX_NAME_CHARS {
|
||||||
|
return Err(t!(
|
||||||
|
"Der Ordnername ist zu lang (höchstens {max} Zeichen)",
|
||||||
|
max = MAX_NAME_CHARS
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Library {
|
||||||
|
/// Ort von `library.json` im Basisordner der Notizbücher.
|
||||||
|
pub fn file_path(base_dir: &Path) -> PathBuf {
|
||||||
|
base_dir.join("library.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lädt die Ordner; fehlt die Datei oder ist sie beschädigt, gibt es keine Ordner.
|
||||||
|
pub fn load(base_dir: &Path) -> Self {
|
||||||
|
let path = Self::file_path(base_dir);
|
||||||
|
let mut library: Self = match std::fs::read_to_string(&path) {
|
||||||
|
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||||
|
eprintln!(
|
||||||
|
"[Ordner] '{}' ist ungültig ({e}), alle Notizbücher auf oberster Ebene.",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
Self::default()
|
||||||
|
}),
|
||||||
|
Err(_) => Self::default(),
|
||||||
|
};
|
||||||
|
library.repair();
|
||||||
|
library
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Speichert die Ordner atomar.
|
||||||
|
pub fn save(&self, base_dir: &Path) -> Result<(), StorageError> {
|
||||||
|
storage::atomic_write_json(&Self::file_path(base_dir), self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Macht eine (evtl. von Hand bearbeitete) Datei stimmig: leere oder ungültige Namen
|
||||||
|
/// fallen weg, jeder benutzte Ordner und seine übergeordneten Ordner existieren.
|
||||||
|
fn repair(&mut self) {
|
||||||
|
let valid = |path: &str| {
|
||||||
|
!path.is_empty()
|
||||||
|
&& path
|
||||||
|
.split('/')
|
||||||
|
.all(|name| validate_name(name).as_deref() == Ok(name))
|
||||||
|
};
|
||||||
|
self.folders.retain(|f| valid(f));
|
||||||
|
self.notebooks.retain(|_, f| f.is_empty() || valid(f));
|
||||||
|
let used: Vec<String> = self
|
||||||
|
.folders
|
||||||
|
.iter()
|
||||||
|
.chain(self.notebooks.values())
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
for mut folder in used {
|
||||||
|
while !folder.is_empty() {
|
||||||
|
let parent = parent_folder(&folder).to_string();
|
||||||
|
self.folders.insert(folder);
|
||||||
|
folder = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordner, in dem das Notizbuch liegt.
|
||||||
|
pub fn folder_of(&self, notebook_id: &str) -> &str {
|
||||||
|
self.notebooks.get(notebook_id).map_or(ROOT, String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Direkte Unterordner von `parent` (vollständige Pfade, nach Name sortiert).
|
||||||
|
pub fn subfolders(&self, parent: &str) -> Vec<&str> {
|
||||||
|
let mut list: Vec<&str> = self
|
||||||
|
.folders
|
||||||
|
.iter()
|
||||||
|
.map(String::as_str)
|
||||||
|
.filter(|f| parent_folder(f) == parent)
|
||||||
|
.collect();
|
||||||
|
list.sort_by_key(|f| folder_name(f).to_lowercase());
|
||||||
|
list
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle Ordner in Baumreihenfolge (Eltern vor Kindern, Geschwister nach Name).
|
||||||
|
pub fn all_folders(&self) -> Vec<&str> {
|
||||||
|
let mut out = Vec::with_capacity(self.folders.len());
|
||||||
|
let mut stack: Vec<&str> = self.subfolders(ROOT).into_iter().rev().collect();
|
||||||
|
while let Some(folder) = stack.pop() {
|
||||||
|
out.push(folder);
|
||||||
|
stack.extend(self.subfolders(folder).into_iter().rev());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legt in `parent` den Ordner `name` an und gibt seinen Pfad zurück.
|
||||||
|
pub fn create_folder(&mut self, parent: &str, name: &str) -> Result<String, String> {
|
||||||
|
let name = validate_name(name)?;
|
||||||
|
if !parent.is_empty() && !self.folders.contains(parent) {
|
||||||
|
return Err(t!("Der übergeordnete Ordner existiert nicht mehr").to_string());
|
||||||
|
}
|
||||||
|
let path = join(parent, &name);
|
||||||
|
if !self.folders.insert(path.clone()) {
|
||||||
|
return Err(t!("Den Ordner „{name}“ gibt es hier schon", name = name));
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Benennt einen Ordner um (mitsamt Unterordnern und Zuordnungen); gibt den neuen Pfad
|
||||||
|
/// zurück.
|
||||||
|
pub fn rename_folder(&mut self, path: &str, new_name: &str) -> Result<String, String> {
|
||||||
|
let new_name = validate_name(new_name)?;
|
||||||
|
if !self.folders.contains(path) {
|
||||||
|
return Err(t!("Den Ordner gibt es nicht mehr").to_string());
|
||||||
|
}
|
||||||
|
let new_path = join(parent_folder(path), &new_name);
|
||||||
|
if new_path == path {
|
||||||
|
return Ok(new_path);
|
||||||
|
}
|
||||||
|
if self.folders.contains(&new_path) {
|
||||||
|
return Err(t!(
|
||||||
|
"Den Ordner „{name}“ gibt es hier schon",
|
||||||
|
name = new_name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.move_contents(path, &new_path);
|
||||||
|
Ok(new_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Löscht einen Ordner. Seine Notizbücher und Unterordner rücken eine Ebene nach oben
|
||||||
|
/// (gleichnamige Unterordner werden zusammengelegt) – gelöscht wird kein Notizbuch.
|
||||||
|
pub fn delete_folder(&mut self, path: &str) {
|
||||||
|
if path.is_empty() || !self.folders.remove(path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let parent = parent_folder(path).to_string();
|
||||||
|
let prefix = format!("{path}/");
|
||||||
|
let moved: Vec<String> = self
|
||||||
|
.folders
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.starts_with(&prefix))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
for folder in moved {
|
||||||
|
self.folders.remove(&folder);
|
||||||
|
self.folders.insert(join(&parent, &folder[prefix.len()..]));
|
||||||
|
}
|
||||||
|
for folder in self.notebooks.values_mut() {
|
||||||
|
if folder == path {
|
||||||
|
*folder = parent.clone();
|
||||||
|
} else if let Some(rest) = folder.strip_prefix(&prefix) {
|
||||||
|
*folder = join(&parent, rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.notebooks.retain(|_, f| !f.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verschiebt einen Ordner samt Inhalt nach `new_path`.
|
||||||
|
fn move_contents(&mut self, path: &str, new_path: &str) {
|
||||||
|
let renamed = |f: &str| format!("{new_path}{}", &f[path.len()..]);
|
||||||
|
let moved: Vec<String> = self
|
||||||
|
.folders
|
||||||
|
.iter()
|
||||||
|
.filter(|f| is_within(f, path))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
for folder in moved {
|
||||||
|
self.folders.remove(&folder);
|
||||||
|
self.folders.insert(renamed(&folder));
|
||||||
|
}
|
||||||
|
for folder in self.notebooks.values_mut() {
|
||||||
|
if is_within(folder, path) {
|
||||||
|
*folder = renamed(folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verschiebt einen Ordner samt Inhalt nach `new_parent` (oberste Ebene: [`ROOT`]) und
|
||||||
|
/// gibt seinen neuen Pfad zurück. Nicht in sich selbst oder einen eigenen Unterordner.
|
||||||
|
pub fn move_folder(&mut self, path: &str, new_parent: &str) -> Result<String, String> {
|
||||||
|
if !self.folders.contains(path) {
|
||||||
|
return Err(t!("Den Ordner gibt es nicht mehr").to_string());
|
||||||
|
}
|
||||||
|
if !new_parent.is_empty() && !self.folders.contains(new_parent) {
|
||||||
|
return Err(t!("Den Zielordner gibt es nicht mehr").to_string());
|
||||||
|
}
|
||||||
|
if is_within(new_parent, path) {
|
||||||
|
return Err(t!("Ein Ordner kann nicht in sich selbst verschoben werden").to_string());
|
||||||
|
}
|
||||||
|
let name = folder_name(path);
|
||||||
|
let new_path = join(new_parent, name);
|
||||||
|
if new_path == path {
|
||||||
|
return Ok(new_path);
|
||||||
|
}
|
||||||
|
if self.folders.contains(&new_path) {
|
||||||
|
return Err(t!(
|
||||||
|
"Im Zielordner gibt es schon einen Ordner „{name}“",
|
||||||
|
name = name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.move_contents(path, &new_path);
|
||||||
|
Ok(new_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mögliche Ziele für das Verschieben des Ordners `path`: alle Ordner außer ihm selbst und
|
||||||
|
/// seinen Unterordnern (die oberste Ebene ist immer ein Ziel).
|
||||||
|
pub fn folder_move_targets(&self, path: &str) -> Vec<&str> {
|
||||||
|
self.all_folders()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| !is_within(f, path))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legt das Notizbuch in `folder` (oberste Ebene: [`ROOT`]).
|
||||||
|
pub fn move_notebook(&mut self, notebook_id: &str, folder: &str) -> Result<(), String> {
|
||||||
|
if folder.is_empty() {
|
||||||
|
self.notebooks.remove(notebook_id);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if !self.folders.contains(folder) {
|
||||||
|
return Err(t!("Den Ordner gibt es nicht mehr").to_string());
|
||||||
|
}
|
||||||
|
self.notebooks
|
||||||
|
.insert(notebook_id.to_string(), folder.to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie viele der Notizbücher `ids` in `folder` liegen, Unterordner eingeschlossen.
|
||||||
|
pub fn count_within<'a>(&self, folder: &str, ids: impl IntoIterator<Item = &'a str>) -> usize {
|
||||||
|
ids.into_iter()
|
||||||
|
.filter(|id| is_within(self.folder_of(id), folder))
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn library() -> Library {
|
||||||
|
let mut lib = Library::default();
|
||||||
|
let sem3 = lib.create_folder(ROOT, "3. Semester").unwrap();
|
||||||
|
let mathe = lib.create_folder(&sem3, "Mathe").unwrap();
|
||||||
|
lib.create_folder(&sem3, "Physik").unwrap();
|
||||||
|
lib.move_notebook("analysis", &mathe).unwrap();
|
||||||
|
lib.move_notebook("uebung", &sem3).unwrap();
|
||||||
|
lib
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_paths_and_names() {
|
||||||
|
assert_eq!(folder_name("3. Semester/Mathe"), "Mathe");
|
||||||
|
assert_eq!(folder_name("Mathe"), "Mathe");
|
||||||
|
assert_eq!(parent_folder("3. Semester/Mathe"), "3. Semester");
|
||||||
|
assert_eq!(parent_folder("Mathe"), ROOT);
|
||||||
|
assert_eq!(validate_name(" Mathe ").unwrap(), "Mathe");
|
||||||
|
assert!(validate_name(" ").is_err());
|
||||||
|
assert!(validate_name("a/b").is_err());
|
||||||
|
assert!(validate_name(&"x".repeat(81)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_and_list_folders() {
|
||||||
|
let mut lib = library();
|
||||||
|
assert_eq!(lib.subfolders(ROOT), ["3. Semester"]);
|
||||||
|
assert_eq!(
|
||||||
|
lib.subfolders("3. Semester"),
|
||||||
|
["3. Semester/Mathe", "3. Semester/Physik"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lib.all_folders(),
|
||||||
|
["3. Semester", "3. Semester/Mathe", "3. Semester/Physik"]
|
||||||
|
);
|
||||||
|
assert_eq!(lib.folder_of("analysis"), "3. Semester/Mathe");
|
||||||
|
assert_eq!(lib.folder_of("unbekannt"), ROOT);
|
||||||
|
// Doppelte Namen und fehlende Eltern werden abgelehnt
|
||||||
|
assert!(lib.create_folder("3. Semester", " Mathe ").is_err());
|
||||||
|
assert!(lib.create_folder("Gibt es nicht", "X").is_err());
|
||||||
|
// Gleicher Name in einem anderen Ordner ist erlaubt
|
||||||
|
lib.create_folder(ROOT, "4. Semester").unwrap();
|
||||||
|
assert!(lib.create_folder("4. Semester", "Mathe").is_ok());
|
||||||
|
assert_eq!(
|
||||||
|
lib.count_within("3. Semester", ["analysis", "uebung", "lose"]),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rename_folder_keeps_contents() {
|
||||||
|
let mut lib = library();
|
||||||
|
let new = lib.rename_folder("3. Semester", "WS 25-26").unwrap();
|
||||||
|
assert_eq!(new, "WS 25-26");
|
||||||
|
assert_eq!(lib.folder_of("analysis"), "WS 25-26/Mathe");
|
||||||
|
assert_eq!(lib.folder_of("uebung"), "WS 25-26");
|
||||||
|
assert!(lib.folders.contains("WS 25-26/Physik"));
|
||||||
|
assert!(!lib.folders.iter().any(|f| f.starts_with("3. Semester")));
|
||||||
|
// Ein Ordner mit ähnlichem Namen bleibt unberührt
|
||||||
|
let mut lib = library();
|
||||||
|
lib.create_folder(ROOT, "3. Semester alt").unwrap();
|
||||||
|
lib.rename_folder("3. Semester", "Drei").unwrap();
|
||||||
|
assert!(lib.folders.contains("3. Semester alt"));
|
||||||
|
// Kollision
|
||||||
|
assert!(lib.rename_folder("Drei", "3. Semester alt").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delete_folder_moves_contents_up() {
|
||||||
|
let mut lib = library();
|
||||||
|
lib.delete_folder("3. Semester");
|
||||||
|
assert_eq!(lib.folder_of("analysis"), "Mathe");
|
||||||
|
assert_eq!(lib.folder_of("uebung"), ROOT);
|
||||||
|
assert!(!lib.notebooks.contains_key("uebung"));
|
||||||
|
assert_eq!(lib.subfolders(ROOT), ["Mathe", "Physik"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_move_folder() {
|
||||||
|
let mut lib = library();
|
||||||
|
lib.create_folder(ROOT, "Mathe-Sammlung").unwrap();
|
||||||
|
// Ordner samt Inhalt in einen anderen Ordner schieben
|
||||||
|
let new = lib
|
||||||
|
.move_folder("3. Semester/Mathe", "Mathe-Sammlung")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(new, "Mathe-Sammlung/Mathe");
|
||||||
|
assert_eq!(lib.folder_of("analysis"), "Mathe-Sammlung/Mathe");
|
||||||
|
assert!(!lib.folders.contains("3. Semester/Mathe"));
|
||||||
|
// Auf die oberste Ebene und zurück
|
||||||
|
assert_eq!(
|
||||||
|
lib.move_folder("Mathe-Sammlung/Mathe", ROOT).unwrap(),
|
||||||
|
"Mathe"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lib.move_folder("Mathe", "3. Semester").unwrap(),
|
||||||
|
"3. Semester/Mathe"
|
||||||
|
);
|
||||||
|
// Nicht in sich selbst oder in einen eigenen Unterordner
|
||||||
|
assert!(lib.move_folder("3. Semester", "3. Semester").is_err());
|
||||||
|
assert!(lib.move_folder("3. Semester", "3. Semester/Mathe").is_err());
|
||||||
|
assert_eq!(lib.folder_move_targets("3. Semester"), ["Mathe-Sammlung"]);
|
||||||
|
// Namenskollision am Ziel
|
||||||
|
lib.create_folder("Mathe-Sammlung", "Physik").unwrap();
|
||||||
|
assert!(lib
|
||||||
|
.move_folder("3. Semester/Physik", "Mathe-Sammlung")
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(lib.folder_of("uebung"), "3. Semester");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_move_notebook() {
|
||||||
|
let mut lib = library();
|
||||||
|
lib.move_notebook("uebung", "3. Semester/Physik").unwrap();
|
||||||
|
assert_eq!(lib.folder_of("uebung"), "3. Semester/Physik");
|
||||||
|
lib.move_notebook("uebung", ROOT).unwrap();
|
||||||
|
assert_eq!(lib.folder_of("uebung"), ROOT);
|
||||||
|
assert!(lib.move_notebook("uebung", "Gibt es nicht").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_save_and_repair() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
// Fehlende Datei: keine Ordner
|
||||||
|
assert_eq!(Library::load(dir.path()), Library::default());
|
||||||
|
|
||||||
|
let lib = library();
|
||||||
|
lib.save(dir.path()).unwrap();
|
||||||
|
assert_eq!(Library::load(dir.path()), lib);
|
||||||
|
|
||||||
|
// Beschädigt: alles auf oberster Ebene statt Absturz
|
||||||
|
std::fs::write(Library::file_path(dir.path()), b"{ kaputt").unwrap();
|
||||||
|
assert_eq!(Library::load(dir.path()), Library::default());
|
||||||
|
|
||||||
|
// Von Hand bearbeitet: fehlende Elternordner werden ergänzt, Unsinn fällt weg
|
||||||
|
std::fs::write(
|
||||||
|
Library::file_path(dir.path()),
|
||||||
|
br#"{"folders": ["A/B", "", "C//D"], "notebooks": {"x": "E/F", "y": "/kaputt"}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let lib = Library::load(dir.path());
|
||||||
|
assert_eq!(lib.all_folders(), ["A", "A/B", "E", "E/F"]);
|
||||||
|
assert_eq!(lib.folder_of("x"), "E/F");
|
||||||
|
assert_eq!(lib.folder_of("y"), ROOT);
|
||||||
|
}
|
||||||
|
}
|
||||||
+914
-216
File diff suppressed because it is too large
Load Diff
+294
@@ -0,0 +1,294 @@
|
|||||||
|
//! Bildschirmtastatur unter Wayland anfordern.
|
||||||
|
//!
|
||||||
|
//! GNOME (mutter) blendet die Bildschirmtastatur nicht schon beim ersten `enable` eines
|
||||||
|
//! Texteingabefelds (`zwp_text_input_v3`) ein, sondern erst, wenn ein bereits aktives Feld
|
||||||
|
//! `enable` erneut meldet – so macht es GTK beim Antippen. winit meldet `enable` nur einmal,
|
||||||
|
//! sobald egui ein Textfeld fokussiert. Daher schickt ink-notes über ein eigenes
|
||||||
|
//! text-input-Objekt auf derselben Wayland-Verbindung ein weiteres `enable`. Die getippten
|
||||||
|
//! Zeichen kommen weiterhin über winit an.
|
||||||
|
//!
|
||||||
|
//! Die Rücktaste der GNOME-Bildschirmtastatur löscht über `delete_surrounding_text` im zuvor
|
||||||
|
//! gemeldeten Umgebungstext; ohne Umgebungstext passiert nichts, und winit wertet das Löschen
|
||||||
|
//! ohnehin nicht aus. Daher meldet das eigene Objekt einen Platzhalter als Umgebungstext und
|
||||||
|
//! übersetzt die Löschwünsche in Tastendrücke (Rücktaste/Entf) für egui.
|
||||||
|
|
||||||
|
use std::ffi::c_void;
|
||||||
|
|
||||||
|
use wayland_client::{
|
||||||
|
delegate_noop,
|
||||||
|
protocol::{wl_registry, wl_seat},
|
||||||
|
Connection, Dispatch, EventQueue, QueueHandle,
|
||||||
|
};
|
||||||
|
use wayland_protocols::wp::text_input::zv3::client::{
|
||||||
|
zwp_text_input_manager_v3::ZwpTextInputManagerV3,
|
||||||
|
zwp_text_input_v3::{self, ZwpTextInputV3},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Platzhalter-Umgebungstext: ein Zeichen vor der Schreibmarke, damit die Rücktaste der
|
||||||
|
/// Bildschirmtastatur immer etwas zu löschen hat (ein Byte = ein Zeichen)
|
||||||
|
const SURROUNDING_PLACEHOLDER: &str = " ";
|
||||||
|
|
||||||
|
/// Wandelt Texteingaben ohne Vorschau (Preedit) in normale Texteingaben um.
|
||||||
|
///
|
||||||
|
/// Die Bildschirmtastatur schickt jedes Zeichen als fertigen IME-Text, ohne vorher eine Vorschau
|
||||||
|
/// zu melden. egui 0.27 fügt solche Texte nur ein, solange die Schreibmarke dort steht, wo die
|
||||||
|
/// letzte Vorschau begann – also nur das erste Zeichen. `composing` merkt sich über Frames hinweg,
|
||||||
|
/// ob gerade eine Vorschau läuft; deren Abschluss bleibt bei egui.
|
||||||
|
pub fn plain_ime_commits_to_text(events: &mut [egui::Event], composing: &mut bool) {
|
||||||
|
for event in events {
|
||||||
|
match event {
|
||||||
|
egui::Event::CompositionStart => *composing = true,
|
||||||
|
egui::Event::CompositionEnd(text) => {
|
||||||
|
if !*composing && !text.is_empty() && !text.contains(['\n', '\r']) {
|
||||||
|
*event = egui::Event::Text(std::mem::take(text));
|
||||||
|
}
|
||||||
|
*composing = false;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tastendrücke für egui, die `back` Zeichen vor und `forward` Zeichen nach der Schreibmarke
|
||||||
|
/// löschen
|
||||||
|
pub fn deletion_key_events(back: u32, forward: u32) -> Vec<egui::Event> {
|
||||||
|
let presses = std::iter::repeat_n(egui::Key::Backspace, back as usize)
|
||||||
|
.chain(std::iter::repeat_n(egui::Key::Delete, forward as usize));
|
||||||
|
presses
|
||||||
|
.flat_map(|key| {
|
||||||
|
[true, false].map(|pressed| egui::Event::Key {
|
||||||
|
key,
|
||||||
|
physical_key: None,
|
||||||
|
pressed,
|
||||||
|
repeat: false,
|
||||||
|
modifiers: egui::Modifiers::NONE,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entscheidet, ob die Bildschirmtastatur angefordert wird: sobald ein Textfeld den Fokus
|
||||||
|
/// bekommt und erneut bei jedem Antippen des fokussierten Felds (falls die Tastatur inzwischen
|
||||||
|
/// zugeklappt wurde).
|
||||||
|
pub fn should_request(was_active: bool, active: bool, pressed: bool) -> bool {
|
||||||
|
active && (!was_active || pressed)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct State {
|
||||||
|
seat: Option<wl_seat::WlSeat>,
|
||||||
|
manager: Option<ZwpTextInputManagerV3>,
|
||||||
|
text_input: Option<ZwpTextInputV3>,
|
||||||
|
/// Von der Bildschirmtastatur gewünschtes Löschen (Zeichen vor/nach der Schreibmarke)
|
||||||
|
delete_back: u32,
|
||||||
|
delete_forward: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eigenes text-input-Objekt, mit dem ink-notes die Bildschirmtastatur anfordert
|
||||||
|
pub struct KeyboardRequester {
|
||||||
|
conn: Connection,
|
||||||
|
queue: EventQueue<State>,
|
||||||
|
state: State,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyboardRequester {
|
||||||
|
/// Hängt sich an die Wayland-Verbindung des Fensters.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `wl_display` muss auf die `wl_display` des Fensters zeigen und gültig bleiben, solange
|
||||||
|
/// der `KeyboardRequester` lebt.
|
||||||
|
pub unsafe fn new(wl_display: *mut c_void) -> Self {
|
||||||
|
// Safety: an den Aufrufer weitergereicht
|
||||||
|
let backend =
|
||||||
|
unsafe { wayland_backend::client::Backend::from_foreign_display(wl_display.cast()) };
|
||||||
|
let conn = Connection::from_backend(backend);
|
||||||
|
let queue = conn.new_event_queue();
|
||||||
|
conn.display().get_registry(&queue.handle(), ());
|
||||||
|
let _ = conn.flush();
|
||||||
|
Self {
|
||||||
|
conn,
|
||||||
|
queue,
|
||||||
|
state: State::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verarbeitet eingetroffene Ereignisse; einmal pro Frame aufrufen.
|
||||||
|
pub fn pump(&mut self) {
|
||||||
|
let _ = self.queue.dispatch_pending(&mut self.state);
|
||||||
|
if self.state.text_input.is_none() {
|
||||||
|
if let (Some(manager), Some(seat)) = (&self.state.manager, &self.state.seat) {
|
||||||
|
self.state.text_input =
|
||||||
|
Some(manager.get_text_input(seat, &self.queue.handle(), ()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bittet den Compositor, die Bildschirmtastatur einzublenden. Muss nach dem `enable` von
|
||||||
|
/// winit kommen (also nach `handle_platform_output`).
|
||||||
|
pub fn request(&mut self) {
|
||||||
|
self.pump();
|
||||||
|
if let Some(text_input) = &self.state.text_input {
|
||||||
|
text_input.enable();
|
||||||
|
self.send_surrounding_placeholder();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tastendrücke für das seit dem letzten Aufruf gewünschte Löschen (vor `begin_frame`
|
||||||
|
/// aufrufen und an die egui-Eingabe anhängen)
|
||||||
|
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
||||||
|
self.pump();
|
||||||
|
// Den Platzhalter hier nicht neu melden: Die Tastatur löscht bei jeder neuen Meldung
|
||||||
|
// erneut, solange die Rücktaste gedrückt ist – ein kurzes Antippen löschte sonst doppelt.
|
||||||
|
let back = std::mem::take(&mut self.state.delete_back);
|
||||||
|
let forward = std::mem::take(&mut self.state.delete_forward);
|
||||||
|
deletion_key_events(back, forward)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_surrounding_placeholder(&self) {
|
||||||
|
if let Some(text_input) = &self.state.text_input {
|
||||||
|
let len = SURROUNDING_PLACEHOLDER.len() as i32;
|
||||||
|
text_input.set_surrounding_text(SURROUNDING_PLACEHOLDER.to_owned(), len, len);
|
||||||
|
text_input.commit();
|
||||||
|
let _ = self.conn.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<wl_registry::WlRegistry, ()> for State {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
registry: &wl_registry::WlRegistry,
|
||||||
|
event: wl_registry::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
qh: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
if let wl_registry::Event::Global {
|
||||||
|
name, interface, ..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
match interface.as_str() {
|
||||||
|
"wl_seat" if state.seat.is_none() => {
|
||||||
|
state.seat = Some(registry.bind(name, 1, qh, ()));
|
||||||
|
}
|
||||||
|
"zwp_text_input_manager_v3" if state.manager.is_none() => {
|
||||||
|
state.manager = Some(registry.bind(name, 1, qh, ()));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate_noop!(State: ignore wl_seat::WlSeat);
|
||||||
|
delegate_noop!(State: ZwpTextInputManagerV3);
|
||||||
|
impl Dispatch<ZwpTextInputV3, ()> for State {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
_: &ZwpTextInputV3,
|
||||||
|
event: zwp_text_input_v3::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
// Getippter Text kommt über winit; hier nur das Löschen
|
||||||
|
if let zwp_text_input_v3::Event::DeleteSurroundingText {
|
||||||
|
before_length,
|
||||||
|
after_length,
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
state.delete_back += before_length;
|
||||||
|
state.delete_forward += after_length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requests_when_text_field_gets_focus() {
|
||||||
|
assert!(should_request(false, true, false));
|
||||||
|
assert!(should_request(false, true, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requests_again_when_focused_field_is_tapped() {
|
||||||
|
assert!(should_request(true, true, true));
|
||||||
|
assert!(!should_request(true, true, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commits_without_preedit_become_text() {
|
||||||
|
let mut composing = false;
|
||||||
|
let mut events = vec![
|
||||||
|
egui::Event::CompositionEnd("h".into()),
|
||||||
|
egui::Event::CompositionEnd("i".into()),
|
||||||
|
];
|
||||||
|
plain_ime_commits_to_text(&mut events, &mut composing);
|
||||||
|
assert_eq!(
|
||||||
|
events,
|
||||||
|
vec![egui::Event::Text("h".into()), egui::Event::Text("i".into())]
|
||||||
|
);
|
||||||
|
assert!(!composing);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commits_after_preedit_stay_with_egui() {
|
||||||
|
let mut composing = false;
|
||||||
|
let mut events = vec![
|
||||||
|
egui::Event::CompositionStart,
|
||||||
|
egui::Event::CompositionUpdate("ni".into()),
|
||||||
|
];
|
||||||
|
plain_ime_commits_to_text(&mut events, &mut composing);
|
||||||
|
assert!(composing);
|
||||||
|
// Abschluss erst im nächsten Frame
|
||||||
|
let mut events = vec![egui::Event::CompositionEnd("你".into())];
|
||||||
|
plain_ime_commits_to_text(&mut events, &mut composing);
|
||||||
|
assert_eq!(events, vec![egui::Event::CompositionEnd("你".into())]);
|
||||||
|
assert!(!composing);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_breaks_and_empty_commits_are_left_alone() {
|
||||||
|
let mut composing = false;
|
||||||
|
let mut events = vec![
|
||||||
|
egui::Event::CompositionEnd("\n".into()),
|
||||||
|
egui::Event::CompositionEnd(String::new()),
|
||||||
|
];
|
||||||
|
let before = events.clone();
|
||||||
|
plain_ime_commits_to_text(&mut events, &mut composing);
|
||||||
|
assert_eq!(events, before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deletions_become_key_presses() {
|
||||||
|
let events = deletion_key_events(2, 1);
|
||||||
|
let keys: Vec<_> = events
|
||||||
|
.iter()
|
||||||
|
.map(|e| match e {
|
||||||
|
egui::Event::Key { key, pressed, .. } => (*key, *pressed),
|
||||||
|
other => panic!("unerwartet: {other:?}"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
keys,
|
||||||
|
vec![
|
||||||
|
(egui::Key::Backspace, true),
|
||||||
|
(egui::Key::Backspace, false),
|
||||||
|
(egui::Key::Backspace, true),
|
||||||
|
(egui::Key::Backspace, false),
|
||||||
|
(egui::Key::Delete, true),
|
||||||
|
(egui::Key::Delete, false),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(deletion_key_events(0, 0).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_request_without_focused_text_field() {
|
||||||
|
assert!(!should_request(false, false, true));
|
||||||
|
assert!(!should_request(true, false, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
-30
@@ -13,8 +13,9 @@ use hayro::{PixmapSettings, RenderCache, RenderSettings};
|
|||||||
|
|
||||||
use crate::assets::{AssetStore, RgbaImage};
|
use crate::assets::{AssetStore, RgbaImage};
|
||||||
use crate::background::PatternColor;
|
use crate::background::PatternColor;
|
||||||
use crate::document::{Aabb, Notebook, Page, PageBackground, A4_WIDTH_MM, PAGE_WIDTH};
|
use crate::document::{Aabb, Notebook, Page, PageBackground};
|
||||||
use crate::export::{export_notebook_to_bytes, ExportStrategy};
|
use crate::export::{export_notebook_to_bytes, ExportStrategy};
|
||||||
|
use crate::t;
|
||||||
|
|
||||||
/// Auflösung des PNG-Exports einer Seite.
|
/// Auflösung des PNG-Exports einer Seite.
|
||||||
pub const PAGE_PNG_DPI: f32 = 200.0;
|
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.
|
/// 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>> {
|
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 pdf = Pdf::new(pdf_bytes).map_err(|e| t!("PDF nicht lesbar: {e}", e = format!("{e:?}")))?;
|
||||||
let page = pdf.pages().first().ok_or("PDF ohne Seite")?;
|
let page = pdf.pages().first().ok_or(t!("PDF ohne Seite"))?;
|
||||||
let scale = dpi / 72.0;
|
let scale = dpi / 72.0;
|
||||||
let pixmap = hayro::render(
|
let pixmap = hayro::render(
|
||||||
page,
|
page,
|
||||||
@@ -84,7 +85,7 @@ fn rasterize(pdf_bytes: Vec<u8>, dpi: f32, transparent: bool) -> Result<RgbaImag
|
|||||||
/// Kodiert ein Bild als PNG.
|
/// Kodiert ein Bild als PNG.
|
||||||
pub fn encode_png(image: &RgbaImage) -> Result<Vec<u8>, Box<dyn Error>> {
|
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())
|
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();
|
let mut out = Vec::new();
|
||||||
image::DynamicImage::ImageRgba8(buffer)
|
image::DynamicImage::ImageRgba8(buffer)
|
||||||
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)?;
|
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)?;
|
||||||
@@ -118,8 +119,8 @@ pub fn page_svg(
|
|||||||
pattern: PatternColor,
|
pattern: PatternColor,
|
||||||
) -> Result<String, Box<dyn Error>> {
|
) -> Result<String, Box<dyn Error>> {
|
||||||
let pdf = Pdf::new(single_page_pdf(page, assets, pattern)?)
|
let pdf = Pdf::new(single_page_pdf(page, assets, pattern)?)
|
||||||
.map_err(|e| format!("PDF nicht lesbar: {e:?}"))?;
|
.map_err(|e| t!("PDF nicht lesbar: {e}", e = format!("{e:?}")))?;
|
||||||
let pdf_page = pdf.pages().first().ok_or("PDF ohne Seite")?;
|
let pdf_page = pdf.pages().first().ok_or(t!("PDF ohne Seite"))?;
|
||||||
Ok(hayro_svg::convert(
|
Ok(hayro_svg::convert(
|
||||||
pdf_page,
|
pdf_page,
|
||||||
&hayro_svg::RenderCache::new(),
|
&hayro_svg::RenderCache::new(),
|
||||||
@@ -144,38 +145,24 @@ pub fn selection_image(
|
|||||||
};
|
};
|
||||||
let max_width = selected.strokes.iter().map(|s| s.width).fold(0.0, f32::max);
|
let max_width = selected.strokes.iter().map(|s| s.width).fold(0.0, f32::max);
|
||||||
let bounds: Aabb = bounds.expanded(max_width / 2.0 + 4.0);
|
let bounds: Aabb = bounds.expanded(max_width / 2.0 + 4.0);
|
||||||
|
let size = bounds.max - bounds.min;
|
||||||
let full = rasterize(
|
if size.min_element() < 1.0 {
|
||||||
single_page_pdf(&selected, assets, PatternColor::default())?,
|
return Ok(None);
|
||||||
|
}
|
||||||
|
// Nur den Ausschnitt rastern (auf der endlosen Seite wäre die ganze Seite riesig)
|
||||||
|
let cropped = selected.cropped(bounds.min, size);
|
||||||
|
let image = rasterize(
|
||||||
|
single_page_pdf(&cropped, assets, PatternColor::default())?,
|
||||||
SELECTION_PNG_DPI,
|
SELECTION_PNG_DPI,
|
||||||
true,
|
true,
|
||||||
)?;
|
)?;
|
||||||
// Seiteneinheiten → Pixel
|
Ok(Some(image))
|
||||||
let px_per_unit = SELECTION_PNG_DPI / 25.4 * A4_WIDTH_MM / PAGE_WIDTH;
|
|
||||||
let x0 = ((bounds.min.x * px_per_unit).floor().max(0.0) as u32).min(full.width);
|
|
||||||
let y0 = ((bounds.min.y * px_per_unit).floor().max(0.0) as u32).min(full.height);
|
|
||||||
let x1 = ((bounds.max.x * px_per_unit).ceil().max(0.0) as u32).min(full.width);
|
|
||||||
let y1 = ((bounds.max.y * px_per_unit).ceil().max(0.0) as u32).min(full.height);
|
|
||||||
if x1 <= x0 || y1 <= y0 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let (w, h) = (x1 - x0, y1 - y0);
|
|
||||||
let mut pixels = Vec::with_capacity((w * h * 4) as usize);
|
|
||||||
for y in y0..y1 {
|
|
||||||
let start = ((y * full.width + x0) * 4) as usize;
|
|
||||||
pixels.extend_from_slice(&full.pixels[start..start + (w * 4) as usize]);
|
|
||||||
}
|
|
||||||
Ok(Some(RgbaImage {
|
|
||||||
width: w,
|
|
||||||
height: h,
|
|
||||||
pixels,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::document::{Stroke, StylusSample, PAGE_HEIGHT};
|
use crate::document::{Stroke, StylusSample, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH};
|
||||||
|
|
||||||
fn page_with_line() -> Page {
|
fn page_with_line() -> Page {
|
||||||
let mut page = Page::new(1, PageBackground::Grid5mm);
|
let mut page = Page::new(1, PageBackground::Grid5mm);
|
||||||
@@ -233,6 +220,58 @@ mod tests {
|
|||||||
assert!(clear.height() as f32 / clear.width() as f32 - PAGE_HEIGHT / PAGE_WIDTH < 0.01);
|
assert!(clear.height() as f32 / clear.width() as f32 - PAGE_HEIGHT / PAGE_WIDTH < 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_highlighter_is_translucent_in_export() {
|
||||||
|
// Schwarze Linie, darüber ein gelber Textmarker, der hin und zurück läuft
|
||||||
|
let mut page = page_with_line();
|
||||||
|
page.strokes[0].color = [0.0, 0.0, 0.0, 1.0];
|
||||||
|
for v in &mut page.strokes[0].vertices {
|
||||||
|
v.color = [0.0, 0.0, 0.0, 1.0];
|
||||||
|
}
|
||||||
|
let color = [1.0, 0.95, 0.1, 0.35];
|
||||||
|
let samples: Vec<StylusSample> = (0..=40)
|
||||||
|
.map(|i| {
|
||||||
|
let x = if i <= 20 { i } else { 40 - i } as f32;
|
||||||
|
StylusSample {
|
||||||
|
position: glam::Vec2::new(500.0 + x * 20.0, 1000.0 + (i as f32 * 0.5)),
|
||||||
|
pressure: 0.6,
|
||||||
|
timestamp: i as f64 * 0.01,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let vertices = crate::storage::reconstruct_stroke_vertices(
|
||||||
|
&samples,
|
||||||
|
color,
|
||||||
|
60.0,
|
||||||
|
Default::default(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
let mut stroke = Stroke::new(8, samples, vertices, color);
|
||||||
|
stroke.width = 60.0;
|
||||||
|
stroke.tool = crate::document::StrokeTool::Highlighter;
|
||||||
|
page.strokes.push(stroke);
|
||||||
|
|
||||||
|
let png = decode(
|
||||||
|
&page_png(&page, None, ImageBackground::Paper, PatternColor::default()).unwrap(),
|
||||||
|
);
|
||||||
|
let px_per_unit = png.width() as f32 / PAGE_WIDTH;
|
||||||
|
let at = |x: f32, y: f32| {
|
||||||
|
png.get_pixel((x * px_per_unit) as u32, (y * px_per_unit) as u32)
|
||||||
|
.0
|
||||||
|
};
|
||||||
|
// Die schwarze Linie bleibt unter dem Textmarker sichtbar
|
||||||
|
let line = at(700.0, 1000.0);
|
||||||
|
assert!(line[0] < 110 && line[1] < 110, "Linie verdeckt: {line:?}");
|
||||||
|
// Neben der Linie: einmal gelb getönt, auch wo der Strich sich selbst überlappt
|
||||||
|
let tint = at(700.0, 1020.0);
|
||||||
|
assert!(tint[2] < 200 && tint[0] > 200, "kein Textmarker: {tint:?}");
|
||||||
|
let expected_blue = 255.0 * (1.0 - 0.35) + 0.1 * 255.0 * 0.35;
|
||||||
|
assert!(
|
||||||
|
(tint[2] as f32 - expected_blue).abs() < 25.0,
|
||||||
|
"Textmarker mehrfach gedeckt: {tint:?}, erwartet Blau ≈ {expected_blue}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_export_uses_pattern_color() {
|
fn test_export_uses_pattern_color() {
|
||||||
// Kariertes Blatt: das kräftige Muster ist im Export dunkler als das helle
|
// Kariertes Blatt: das kräftige Muster ist im Export dunkler als das helle
|
||||||
|
|||||||
+250
-38
@@ -1,11 +1,13 @@
|
|||||||
//! Lesen und Rendern importierter PDF-Dateien mit `hayro` (reines Rust, nur CPU).
|
//! Lesen und Rendern importierter PDF-Dateien mit `hayro` (reines Rust, nur CPU).
|
||||||
//!
|
//!
|
||||||
//! Das Rendern einer Seite kann einige hundert Millisekunden dauern; [`PdfRenderService`]
|
//! 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::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
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_interpret::InterpreterSettings;
|
||||||
use hayro::hayro_syntax::{LoadPdfError, Pdf};
|
use hayro::hayro_syntax::{LoadPdfError, Pdf};
|
||||||
@@ -13,6 +15,7 @@ use hayro::vello_cpu::color::palette::css::WHITE;
|
|||||||
use hayro::{PixmapSettings, RenderCache, RenderSettings};
|
use hayro::{PixmapSettings, RenderCache, RenderSettings};
|
||||||
|
|
||||||
use crate::assets::RgbaImage;
|
use crate::assets::RgbaImage;
|
||||||
|
use crate::t;
|
||||||
|
|
||||||
/// Längste Kante einer gerenderten Seite für die Anzeige in Pixeln (A4 ≈ 220 dpi).
|
/// Längste Kante einer gerenderten Seite für die Anzeige in Pixeln (A4 ≈ 220 dpi).
|
||||||
pub const DISPLAY_RENDER_PIXELS: u32 = 2560;
|
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 pdf = open(bytes)?;
|
||||||
let sizes: Vec<(f32, f32)> = pdf.pages().iter().map(|p| p.render_dimensions()).collect();
|
let sizes: Vec<(f32, f32)> = pdf.pages().iter().map(|p| p.render_dimensions()).collect();
|
||||||
if sizes.is_empty() {
|
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 {
|
if sizes.len() > MAX_IMPORT_PAGES {
|
||||||
return Err(format!(
|
return Err(t!(
|
||||||
"Das PDF hat {} Seiten, höchstens {MAX_IMPORT_PAGES} werden unterstützt",
|
"Das PDF hat {count} Seiten, höchstens {max} werden unterstützt",
|
||||||
sizes.len()
|
count = sizes.len(),
|
||||||
|
max = MAX_IMPORT_PAGES
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(sizes)
|
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> {
|
fn open(bytes: Vec<u8>) -> Result<Pdf, String> {
|
||||||
Pdf::new(bytes).map_err(|e| match e {
|
Pdf::new(bytes).map_err(|e| match e {
|
||||||
LoadPdfError::Decryption(_) => {
|
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()
|
.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
|
let page = pdf
|
||||||
.pages()
|
.pages()
|
||||||
.get(index)
|
.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 (w, h) = page.render_dimensions();
|
||||||
let scale = (max_pixels as f32 / w.max(h).max(1.0)).min(16.0);
|
let scale = (max_pixels as f32 / w.max(h).max(1.0)).min(16.0);
|
||||||
let pixmap = hayro::render(
|
let pixmap = hayro::render(
|
||||||
@@ -102,30 +106,107 @@ struct RenderRequest {
|
|||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
page: usize,
|
page: usize,
|
||||||
max_pixels: u32,
|
max_pixels: u32,
|
||||||
|
/// Wichtigkeit: kleiner zuerst (Reihenfolge der Anfragen im aktuellen Bild)
|
||||||
|
rank: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ergebnis eines Render-Auftrags.
|
/// Wichtigkeit einer vorab gerenderten Seite: nach allen sichtbaren.
|
||||||
pub type RenderResult = Result<RgbaImage, String>;
|
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
|
/// Rendert PDF-Seiten im Hintergrund. Fertige Seiten holt der Event-Loop mit
|
||||||
/// [`PdfRenderService::take`] ab; `on_ready` weckt ihn dafür auf.
|
/// [`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 {
|
pub struct PdfRenderService {
|
||||||
requests: Sender<RenderRequest>,
|
queue: SharedQueue,
|
||||||
|
worker: std::thread::JoinHandle<()>,
|
||||||
results: Receiver<(String, RenderResult)>,
|
results: Receiver<(String, RenderResult)>,
|
||||||
|
/// Wartende oder gerade gerenderte Seiten
|
||||||
pending: HashSet<String>,
|
pending: HashSet<String>,
|
||||||
ready: HashMap<String, RenderResult>,
|
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 {
|
impl PdfRenderService {
|
||||||
pub fn new(on_ready: impl Fn() + Send + 'static) -> Self {
|
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();
|
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())
|
.name("pdf-render".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
// Zuletzt geöffnetes PDF behalten: Blättern rendert meist dieselbe Datei
|
// Zuletzt geöffnetes PDF behalten: Blättern rendert meist dieselbe Datei
|
||||||
let mut open_pdf: Option<(PathBuf, Pdf)> = None;
|
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) {
|
if open_pdf.as_ref().map(|(p, _)| p) != Some(&request.path) {
|
||||||
open_pdf = std::fs::read(&request.path)
|
open_pdf = std::fs::read(&request.path)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
@@ -135,36 +216,64 @@ impl PdfRenderService {
|
|||||||
.map(|pdf| (request.path.clone(), pdf));
|
.map(|pdf| (request.path.clone(), pdf));
|
||||||
}
|
}
|
||||||
let result = match &open_pdf {
|
let result = match &open_pdf {
|
||||||
Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels),
|
// Mipmaps hier statt im Haupt-Thread: sonst stockt das Scrollen bei
|
||||||
None => Err("PDF kann nicht geöffnet werden".into()),
|
// 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() {
|
if res_tx.send((request.key, result)).is_err() {
|
||||||
break;
|
return;
|
||||||
}
|
}
|
||||||
on_ready();
|
on_ready();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.expect("PDF-Render-Thread konnte nicht gestartet werden");
|
.expect("PDF-Render-Thread konnte nicht gestartet werden");
|
||||||
Self {
|
Self {
|
||||||
requests: req_tx,
|
queue,
|
||||||
|
worker,
|
||||||
results: res_rx,
|
results: res_rx,
|
||||||
pending: HashSet::new(),
|
pending: HashSet::new(),
|
||||||
ready: HashMap::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;
|
/// Rendert eine Seite vorab (z. B. die nächste), ohne auf das Ergebnis zu warten;
|
||||||
/// [`Self::take`] liefert es später sofort.
|
/// [`Self::take`] liefert es später sofort.
|
||||||
pub fn prefetch(&mut self, key: &str, path: PathBuf, page: usize) {
|
pub fn prefetch(&mut self, key: &str, path: PathBuf, page: usize) {
|
||||||
self.collect();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// Vorab gerenderte Seiten nicht unbegrenzt aufheben
|
self.request(key, path, page, DISPLAY_RENDER_PIXELS, PREFETCH_RANK);
|
||||||
if self.ready.len() >= 2 {
|
|
||||||
self.ready.clear();
|
|
||||||
}
|
|
||||||
self.request(key, path, page, DISPLAY_RENDER_PIXELS);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect(&mut self) {
|
fn collect(&mut self) {
|
||||||
@@ -174,21 +283,37 @@ impl PdfRenderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Erteilt den Auftrag, falls noch keiner läuft. `false`, wenn der Thread beendet ist.
|
/// Erteilt den Auftrag bzw. macht einen wartenden wichtiger. `false`, wenn der
|
||||||
fn request(&mut self, key: &str, path: PathBuf, page: usize, max_pixels: u32) -> bool {
|
/// Render-Thread beendet ist.
|
||||||
if !self.pending.insert(key.to_string()) {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
let request = RenderRequest {
|
self.pending.insert(key.to_string());
|
||||||
|
queue.push(RenderRequest {
|
||||||
key: key.to_string(),
|
key: key.to_string(),
|
||||||
path,
|
path,
|
||||||
page,
|
page,
|
||||||
max_pixels,
|
max_pixels,
|
||||||
};
|
rank,
|
||||||
if self.requests.send(request).is_err() {
|
});
|
||||||
self.pending.remove(key);
|
ready.notify_one();
|
||||||
return false;
|
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,16 +327,29 @@ impl PdfRenderService {
|
|||||||
max_pixels: u32,
|
max_pixels: u32,
|
||||||
) -> Option<RenderResult> {
|
) -> Option<RenderResult> {
|
||||||
self.collect();
|
self.collect();
|
||||||
|
if self.prefetched.as_deref() == Some(key) {
|
||||||
|
self.prefetched = None;
|
||||||
|
}
|
||||||
if let Some(result) = self.ready.remove(key) {
|
if let Some(result) = self.ready.remove(key) {
|
||||||
return Some(result);
|
return Some(result);
|
||||||
}
|
}
|
||||||
if !self.request(key, path, page, max_pixels) {
|
let rank = self.wanted.len();
|
||||||
return Some(Err("PDF-Render-Thread beendet".into()));
|
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
|
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)]
|
#[cfg(test)]
|
||||||
pub(crate) mod tests {
|
pub(crate) mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -301,8 +439,12 @@ pub(crate) mod tests {
|
|||||||
wake_rx
|
wake_rx
|
||||||
.recv_timeout(std::time::Duration::from_secs(20))
|
.recv_timeout(std::time::Duration::from_secs(20))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let image = service.take("a#0", path.clone(), 0, px).unwrap().unwrap();
|
let levels = service.take("a#0", path.clone(), 0, px).unwrap().unwrap();
|
||||||
assert_eq!(image.width, DISPLAY_RENDER_PIXELS);
|
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
|
// Vorab rendern: danach sofort verfügbar
|
||||||
service.prefetch("a#0", path.clone(), 0);
|
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());
|
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]
|
#[test]
|
||||||
fn test_parse_page_key() {
|
fn test_parse_page_key() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+12
-5
@@ -9,6 +9,8 @@ use std::collections::{BTreeMap, HashMap};
|
|||||||
|
|
||||||
use printpdf::lopdf::{Dictionary, Document, Object, ObjectId, Stream};
|
use printpdf::lopdf::{Dictionary, Document, Object, ObjectId, Stream};
|
||||||
|
|
||||||
|
use crate::t;
|
||||||
|
|
||||||
/// Eine Zielseite, unter die eine Seite eines Quell-PDFs gelegt wird.
|
/// Eine Zielseite, unter die eine Seite eines Quell-PDFs gelegt wird.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct Underlay {
|
pub struct Underlay {
|
||||||
@@ -137,7 +139,7 @@ fn page_as_form(
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let data = stream
|
let data = stream
|
||||||
.decompressed_content()
|
.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.extend_from_slice(&data);
|
||||||
content.push(b'\n');
|
content.push(b'\n');
|
||||||
}
|
}
|
||||||
@@ -183,10 +185,10 @@ pub fn apply_underlays(
|
|||||||
let result = (|| {
|
let result = (|| {
|
||||||
let bytes = sources
|
let bytes = sources
|
||||||
.get(&underlay.source)
|
.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())?;
|
let mut source = Document::load_mem(bytes).map_err(|e| e.to_string())?;
|
||||||
if source.is_encrypted() {
|
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);
|
source.renumber_objects_with(target.max_id + 1);
|
||||||
let pages = source.get_pages();
|
let pages = source.get_pages();
|
||||||
@@ -206,10 +208,15 @@ pub fn apply_underlays(
|
|||||||
.map_err(Clone::clone)?;
|
.map_err(Clone::clone)?;
|
||||||
let source_page = *pages
|
let source_page = *pages
|
||||||
.get(&(underlay.source_page as u32 + 1))
|
.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
|
let target_page = *target_pages
|
||||||
.get(underlay.target_page)
|
.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, matrix) = page_as_form(&target, source_page, underlay.rect)?;
|
||||||
let form_id = target.add_object(Object::Stream(form));
|
let form_id = target.add_object(Object::Stream(form));
|
||||||
let name = format!("InkPdf{}", form_id.0);
|
let name = format!("InkPdf{}", form_id.0);
|
||||||
|
|||||||
@@ -58,10 +58,62 @@ pub fn display_backend_from_env() -> DisplayBackend {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bringt die Ereignisse eines Tablet-Frames in eine brauchbare Reihenfolge: octotablet meldet
|
||||||
|
/// das Aufsetzen des Stifts (`Down`) vor der Position desselben Frames (`Pose`). Bis dahin
|
||||||
|
/// gälte noch die alte Position – kam der Stift schnell heran (Annähern und Aufsetzen im selben
|
||||||
|
/// Frame), läge sie ganz woanders: Ein Tippen auf einen Knopf ginge daneben, ein Strich begänne
|
||||||
|
/// mit einer Linie von der alten Stelle. Hier folgt jedes `Down` auf die nächste `Pose`, spätestens
|
||||||
|
/// vor dem nächsten anderen Ereignis.
|
||||||
|
pub fn down_after_pose<T>(
|
||||||
|
events: impl IntoIterator<Item = T>,
|
||||||
|
is_down: impl Fn(&T) -> bool,
|
||||||
|
is_pose: impl Fn(&T) -> bool,
|
||||||
|
) -> Vec<T> {
|
||||||
|
let mut ordered = Vec::new();
|
||||||
|
let mut deferred = None;
|
||||||
|
for event in events {
|
||||||
|
if is_down(&event) {
|
||||||
|
if let Some(down) = deferred.replace(event) {
|
||||||
|
ordered.push(down);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let pose = is_pose(&event);
|
||||||
|
if !pose {
|
||||||
|
ordered.extend(deferred.take());
|
||||||
|
}
|
||||||
|
ordered.push(event);
|
||||||
|
if pose {
|
||||||
|
ordered.extend(deferred.take());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ordered.extend(deferred);
|
||||||
|
ordered
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_down_follows_pose_of_its_frame() {
|
||||||
|
let order = |events: &str| {
|
||||||
|
down_after_pose(events.chars(), |c| *c == 'D', |c| *c == 'P')
|
||||||
|
.into_iter()
|
||||||
|
.collect::<String>()
|
||||||
|
};
|
||||||
|
// In, Down, Pose, Button, Frame -> Down erst mit der neuen Position
|
||||||
|
assert_eq!(order("IDPBF"), "IPDBF");
|
||||||
|
// Down ohne Pose im Frame: vor dem Frame-Ende
|
||||||
|
assert_eq!(order("DF"), "DF");
|
||||||
|
assert_eq!(order("DBF"), "DBF");
|
||||||
|
// Am Ende ohne weiteres Ereignis
|
||||||
|
assert_eq!(order("PD"), "PD");
|
||||||
|
// Andere Ereignisse bleiben unverändert
|
||||||
|
assert_eq!(order("PFPFUF"), "PFPFUF");
|
||||||
|
assert_eq!(order("IDPFPFUF"), "IPDFPFUF");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_choose_display_backend() {
|
fn test_choose_display_backend() {
|
||||||
// WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab)
|
// WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab)
|
||||||
|
|||||||
+147
-11
@@ -7,6 +7,7 @@ use std::f32::consts::FRAC_PI_2;
|
|||||||
|
|
||||||
use crate::canvas::CanvasState;
|
use crate::canvas::CanvasState;
|
||||||
use crate::document::{Aabb, Page, Stroke};
|
use crate::document::{Aabb, Page, Stroke};
|
||||||
|
use crate::t;
|
||||||
|
|
||||||
/// Status des aktiven Ziehvorgangs (Drag-Operation) mit der Maus oder dem Stylus.
|
/// Status des aktiven Ziehvorgangs (Drag-Operation) mit der Maus oder dem Stylus.
|
||||||
#[derive(Debug, Clone, PartialEq, Default)]
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
@@ -35,9 +36,34 @@ pub enum DragMode {
|
|||||||
start_pos_page: Vec2,
|
start_pos_page: Vec2,
|
||||||
initial_box_size: Vec2,
|
initial_box_size: Vec2,
|
||||||
initial_strokes: Vec<Stroke>,
|
initial_strokes: Vec<Stroke>,
|
||||||
|
/// Seitenverhältnis beibehalten (Schloss der Auswahl oder Shift gedrückt)
|
||||||
|
keep_aspect: bool,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Skalierungsfaktoren, wenn der Eckgriff von `start` (relativ zur festen Ecke `fixed`)
|
||||||
|
/// nach `current` gezogen wird. Mit `keep_aspect` wird gleichmäßig skaliert: der Faktor ist
|
||||||
|
/// die Projektion der Zugposition auf die Diagonale, damit das Seitenverhältnis bleibt.
|
||||||
|
pub fn scale_factors(fixed: Vec2, start: Vec2, current: Vec2, keep_aspect: bool) -> Vec2 {
|
||||||
|
let orig = start - fixed;
|
||||||
|
let new = current - fixed;
|
||||||
|
if keep_aspect {
|
||||||
|
let len_sq = orig.length_squared();
|
||||||
|
if len_sq <= 1.0 {
|
||||||
|
return Vec2::ONE;
|
||||||
|
}
|
||||||
|
return Vec2::splat((new.dot(orig) / len_sq).clamp(0.05, 20.0));
|
||||||
|
}
|
||||||
|
let factor = |n: f32, o: f32| {
|
||||||
|
if o.abs() > 1.0 {
|
||||||
|
(n / o).clamp(0.05, 20.0)
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Vec2::new(factor(new.x, orig.x), factor(new.y, orig.y))
|
||||||
|
}
|
||||||
|
|
||||||
/// Ergebnis eines Hit-Tests auf Auswahl-Griffe (Handles).
|
/// Ergebnis eines Hit-Tests auf Auswahl-Griffe (Handles).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub enum SelectionHandleHit {
|
pub enum SelectionHandleHit {
|
||||||
@@ -63,6 +89,9 @@ pub struct SelectionState {
|
|||||||
pub selected_ids: Vec<u64>,
|
pub selected_ids: Vec<u64>,
|
||||||
pub drag_mode: DragMode,
|
pub drag_mode: DragMode,
|
||||||
pub clipboard: Vec<Stroke>,
|
pub clipboard: Vec<Stroke>,
|
||||||
|
/// Von Hand umgeschaltetes Seitenverhältnis-Schloss und die Auswahl (IDs), für die es
|
||||||
|
/// gilt. Ändert sich die Auswahl, gilt wieder die Vorgabe (siehe [`Self::keeps_aspect`]).
|
||||||
|
aspect_lock: Option<(Vec<u64>, bool)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SelectionState {
|
impl SelectionState {
|
||||||
@@ -71,9 +100,28 @@ impl SelectionState {
|
|||||||
selected_ids: Vec::new(),
|
selected_ids: Vec::new(),
|
||||||
drag_mode: DragMode::None,
|
drag_mode: DragMode::None,
|
||||||
clipboard: Vec::new(),
|
clipboard: Vec::new(),
|
||||||
|
aspect_lock: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ob Skalieren an den Eckgriffen das Seitenverhältnis der Auswahl beibehält. Vorgabe:
|
||||||
|
/// ja, sobald ein Bild ausgewählt ist (Issue #29); per Knopf für diese Auswahl umschaltbar.
|
||||||
|
pub fn keeps_aspect(&self, page: &Page) -> bool {
|
||||||
|
match &self.aspect_lock {
|
||||||
|
Some((ids, locked)) if *ids == self.selected_ids => *locked,
|
||||||
|
_ => 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.
|
/// Prüft, ob aktuell Objekte ausgewählt sind.
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.selected_ids.is_empty()
|
self.selected_ids.is_empty()
|
||||||
@@ -203,13 +251,19 @@ impl SelectionState {
|
|||||||
pub fn paste_from_clipboard(
|
pub fn paste_from_clipboard(
|
||||||
&mut self,
|
&mut self,
|
||||||
page: &mut Page,
|
page: &mut Page,
|
||||||
|
at: Option<Vec2>,
|
||||||
next_id_gen: &mut impl FnMut() -> u64,
|
next_id_gen: &mut impl FnMut() -> u64,
|
||||||
) -> Vec<u64> {
|
) -> Vec<u64> {
|
||||||
if self.clipboard.is_empty() {
|
if self.clipboard.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
let offset = Vec2::new(30.0, 30.0); // ca. 3 mm Versatz nach rechts-unten
|
// Mit `at` (lange gedrückt → Einfügen) landet die Mitte der Objekte dort, sonst
|
||||||
|
// ca. 3 mm nach rechts unten versetzt
|
||||||
|
let offset = match (at, Page::selection_aabb(&self.clipboard)) {
|
||||||
|
(Some(at), Some(aabb)) => at - (aabb.min + aabb.max) / 2.0,
|
||||||
|
_ => Vec2::new(30.0, 30.0),
|
||||||
|
};
|
||||||
let mut new_strokes = Vec::with_capacity(self.clipboard.len());
|
let mut new_strokes = Vec::with_capacity(self.clipboard.len());
|
||||||
let mut new_ids = Vec::with_capacity(self.clipboard.len());
|
let mut new_ids = Vec::with_capacity(self.clipboard.len());
|
||||||
|
|
||||||
@@ -398,15 +452,14 @@ pub fn render_selection_overlay(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Kleines Info-Badge an der Unterkante (z. B. "2 Objekte | [R] Drehen [Entf] Löschen")
|
// Kleines Info-Badge an der Unterkante (z. B. "2 Objekte | [R] Drehen [Entf] Löschen")
|
||||||
let badge_text = format!(
|
let badge_text = if selection.count() == 1 {
|
||||||
"{} {} | [R] Drehen [Entf] Löschen",
|
t!("1 Objekt | [R] Drehen [Entf] Löschen").to_string()
|
||||||
selection.count(),
|
} else {
|
||||||
if selection.count() == 1 {
|
t!(
|
||||||
"Objekt"
|
"{count} Objekte | [R] Drehen [Entf] Löschen",
|
||||||
} else {
|
count = selection.count()
|
||||||
"Objekte"
|
)
|
||||||
}
|
};
|
||||||
);
|
|
||||||
let badge_pos = egui::pos2(rect.center().x, rect.max.y + 16.0);
|
let badge_pos = egui::pos2(rect.center().x, rect.max.y + 16.0);
|
||||||
painter.text(
|
painter.text(
|
||||||
badge_pos,
|
badge_pos,
|
||||||
@@ -457,7 +510,7 @@ mod tests {
|
|||||||
|
|
||||||
// Paste
|
// Paste
|
||||||
let mut next_id = 100;
|
let mut next_id = 100;
|
||||||
let new_ids = sel.paste_from_clipboard(&mut page, &mut || {
|
let new_ids = sel.paste_from_clipboard(&mut page, None, &mut || {
|
||||||
next_id += 1;
|
next_id += 1;
|
||||||
next_id
|
next_id
|
||||||
});
|
});
|
||||||
@@ -465,6 +518,18 @@ mod tests {
|
|||||||
assert_eq!(page.strokes.len(), 3);
|
assert_eq!(page.strokes.len(), 3);
|
||||||
assert_eq!(sel.selected_ids, new_ids);
|
assert_eq!(sel.selected_ids, new_ids);
|
||||||
|
|
||||||
|
// An einer Stelle einfügen: die Mitte der Kopie liegt dort
|
||||||
|
let target = Vec2::new(1000.0, 1500.0);
|
||||||
|
let placed = sel.paste_from_clipboard(&mut page, Some(target), &mut || {
|
||||||
|
next_id += 1;
|
||||||
|
next_id
|
||||||
|
});
|
||||||
|
let aabb = sel.get_selection_aabb(&page).unwrap();
|
||||||
|
assert_eq!(sel.selected_ids, placed);
|
||||||
|
assert!(((aabb.min + aabb.max) / 2.0 - target).length() < 0.01);
|
||||||
|
sel.delete_selected(&mut page);
|
||||||
|
sel.selected_ids = new_ids.clone();
|
||||||
|
|
||||||
// Delete
|
// Delete
|
||||||
let deleted = sel.delete_selected(&mut page);
|
let deleted = sel.delete_selected(&mut page);
|
||||||
assert_eq!(deleted.len(), 1);
|
assert_eq!(deleted.len(), 1);
|
||||||
@@ -505,6 +570,77 @@ mod tests {
|
|||||||
assert!((orig_pos.x - restored_pos.x).abs() < 1e-4);
|
assert!((orig_pos.x - restored_pos.x).abs() < 1e-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scale_factors_free_and_keep_aspect() {
|
||||||
|
let fixed = Vec2::new(0.0, 0.0);
|
||||||
|
let start = Vec2::new(200.0, 100.0);
|
||||||
|
// Frei: Breite und Höhe unabhängig
|
||||||
|
let free = scale_factors(fixed, start, Vec2::new(400.0, 100.0), false);
|
||||||
|
assert_eq!(free, Vec2::new(2.0, 1.0));
|
||||||
|
// Seitenverhältnis fest: gleicher Faktor in beiden Richtungen
|
||||||
|
let locked = scale_factors(fixed, start, Vec2::new(400.0, 100.0), true);
|
||||||
|
assert!((locked.x - locked.y).abs() < 1e-6);
|
||||||
|
assert!((locked.x - 1.8).abs() < 1e-4);
|
||||||
|
// Entlang der Diagonale genau verdoppelt
|
||||||
|
let diag = scale_factors(fixed, start, Vec2::new(400.0, 200.0), true);
|
||||||
|
assert!((diag - Vec2::splat(2.0)).length() < 1e-5);
|
||||||
|
// Über die feste Ecke hinaus: kein Spiegeln
|
||||||
|
let past = scale_factors(fixed, start, Vec2::new(-50.0, -50.0), true);
|
||||||
|
assert_eq!(past, Vec2::splat(0.05));
|
||||||
|
// Senkrechte Linie (Breite 0): frei bleibt x unverändert, fest skaliert beides
|
||||||
|
let line = Vec2::new(0.0, 100.0);
|
||||||
|
assert_eq!(
|
||||||
|
scale_factors(fixed, line, Vec2::new(30.0, 200.0), false),
|
||||||
|
Vec2::new(1.0, 2.0)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
scale_factors(fixed, line, Vec2::new(30.0, 200.0), true),
|
||||||
|
Vec2::splat(2.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_aspect_lock_defaults_to_images_and_toggles_per_selection() {
|
||||||
|
let mut page = Page::new(1, PageBackground::Blank);
|
||||||
|
let corners = [
|
||||||
|
Vec2::new(0.0, 0.0),
|
||||||
|
Vec2::new(100.0, 0.0),
|
||||||
|
Vec2::new(100.0, 50.0),
|
||||||
|
Vec2::new(0.0, 50.0),
|
||||||
|
];
|
||||||
|
page.add_stroke(Stroke::new_image(1, "a.png".into(), corners));
|
||||||
|
page.add_stroke(make_test_stroke(2, 200.0, 200.0, [0.1, 0.1, 0.1, 1.0]));
|
||||||
|
let mut sel = SelectionState::new();
|
||||||
|
|
||||||
|
// Vorgabe: Bilder gesperrt, Striche frei
|
||||||
|
sel.select_single(1);
|
||||||
|
assert!(sel.keeps_aspect(&page));
|
||||||
|
sel.select_single(2);
|
||||||
|
assert!(!sel.keeps_aspect(&page));
|
||||||
|
|
||||||
|
// Umschalten gilt für diese Auswahl
|
||||||
|
sel.toggle_aspect_lock(&page);
|
||||||
|
assert!(sel.keeps_aspect(&page));
|
||||||
|
sel.select_single(1);
|
||||||
|
sel.toggle_aspect_lock(&page);
|
||||||
|
assert!(!sel.keeps_aspect(&page));
|
||||||
|
sel.toggle_aspect_lock(&page);
|
||||||
|
assert!(sel.keeps_aspect(&page));
|
||||||
|
sel.toggle_aspect_lock(&page);
|
||||||
|
|
||||||
|
// Neue Auswahl: wieder die Vorgabe
|
||||||
|
sel.select_multiple(vec![1, 2]);
|
||||||
|
assert!(sel.keeps_aspect(&page));
|
||||||
|
sel.select_single(1);
|
||||||
|
assert!(
|
||||||
|
!sel.keeps_aspect(&page),
|
||||||
|
"gleiche Auswahl behält das Umschalten"
|
||||||
|
);
|
||||||
|
sel.clear();
|
||||||
|
sel.select_single(1);
|
||||||
|
assert!(!sel.keeps_aspect(&page));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_recolor_ignores_images() {
|
fn test_recolor_ignores_images() {
|
||||||
let mut page = Page::new(1, PageBackground::Blank);
|
let mut page = Page::new(1, PageBackground::Blank);
|
||||||
|
|||||||
+222
-1
@@ -7,7 +7,9 @@ use std::path::{Path, PathBuf};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::background::{PaperTheme, PatternColor};
|
use crate::background::{PaperTheme, PatternColor};
|
||||||
|
use crate::i18n::Language;
|
||||||
use crate::storage::{self, StorageError};
|
use crate::storage::{self, StorageError};
|
||||||
|
use crate::t;
|
||||||
|
|
||||||
/// Fensterseite, an der die Werkzeugleiste angedockt ist.
|
/// Fensterseite, an der die Werkzeugleiste angedockt ist.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
@@ -73,6 +75,130 @@ impl Default for PressureCurve {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stift-Vorlage: Farbe und Stärke eines der Stifte in der Werkzeugleiste. Ändert man Farbe
|
||||||
|
/// oder Stärke, während ein Stift gewählt ist, merkt sich die Vorlage die neuen Werte.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct PenPreset {
|
||||||
|
pub color: [f32; 4],
|
||||||
|
pub width_mm: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PenPreset {
|
||||||
|
pub const WIDTH_RANGE: std::ops::RangeInclusive<f32> = 0.15..=5.0;
|
||||||
|
|
||||||
|
/// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei; Stifte sind
|
||||||
|
/// immer deckend.
|
||||||
|
pub fn sanitized(&self, fallback: PenPreset) -> Self {
|
||||||
|
let color = sanitized_color(self.color, [0.0, 0.0, 0.0, 1.0]);
|
||||||
|
let width_mm = if self.width_mm.is_finite() {
|
||||||
|
self.width_mm
|
||||||
|
.clamp(*Self::WIDTH_RANGE.start(), *Self::WIDTH_RANGE.end())
|
||||||
|
} else {
|
||||||
|
fallback.width_mm
|
||||||
|
};
|
||||||
|
Self { color, width_mm }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anzahl der Stifte in der Werkzeugleiste.
|
||||||
|
pub const PEN_PRESET_COUNT: usize = 4;
|
||||||
|
|
||||||
|
/// Standardstifte: Schwarz, Blau und Rot fein, Grün etwas kräftiger.
|
||||||
|
pub const DEFAULT_PEN_PRESETS: [PenPreset; PEN_PRESET_COUNT] = [
|
||||||
|
PenPreset {
|
||||||
|
color: [0.12, 0.12, 0.14, 1.0],
|
||||||
|
width_mm: 0.5,
|
||||||
|
},
|
||||||
|
PenPreset {
|
||||||
|
color: [0.15, 0.35, 0.85, 1.0],
|
||||||
|
width_mm: 0.5,
|
||||||
|
},
|
||||||
|
PenPreset {
|
||||||
|
color: [0.85, 0.20, 0.20, 1.0],
|
||||||
|
width_mm: 0.5,
|
||||||
|
},
|
||||||
|
PenPreset {
|
||||||
|
color: [0.18, 0.65, 0.35, 1.0],
|
||||||
|
width_mm: 0.8,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Farben des Textmarkers in der Werkzeugleiste (halbtransparent).
|
||||||
|
pub const DEFAULT_HIGHLIGHTER_COLORS: [[f32; 4]; 4] = [
|
||||||
|
[1.0, 0.95, 0.1, 0.35],
|
||||||
|
[0.2, 0.9, 0.3, 0.35],
|
||||||
|
[0.25, 0.7, 1.0, 0.35],
|
||||||
|
[1.0, 0.3, 0.65, 0.35],
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Farben des Text-Werkzeugs in der Werkzeugleiste.
|
||||||
|
pub const DEFAULT_TEXT_COLORS: [[f32; 4]; 5] = [
|
||||||
|
[0.12, 0.12, 0.14, 1.0],
|
||||||
|
[0.15, 0.35, 0.85, 1.0],
|
||||||
|
[0.85, 0.20, 0.20, 1.0],
|
||||||
|
[0.18, 0.65, 0.35, 1.0],
|
||||||
|
[0.45, 0.45, 0.48, 1.0],
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Begrenzt eine Farbe aus der Einstellungsdatei auf 0–1; die Deckkraft kommt immer aus
|
||||||
|
/// `fallback` (Stift und Text deckend, Textmarker halbtransparent).
|
||||||
|
fn sanitized_color(color: [f32; 4], fallback: [f32; 4]) -> [f32; 4] {
|
||||||
|
let mut out = fallback;
|
||||||
|
if color.iter().all(|c| c.is_finite()) {
|
||||||
|
for (o, c) in out.iter_mut().zip(color).take(3) {
|
||||||
|
*o = c.clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was eine Taste am Stift bewirkt, solange sie gedrückt ist.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub enum StylusAction {
|
||||||
|
/// Radieren (Standard, wie bisher)
|
||||||
|
#[default]
|
||||||
|
Erase,
|
||||||
|
/// Auswählen und verschieben
|
||||||
|
Select,
|
||||||
|
/// Mit dem Textmarker schreiben
|
||||||
|
Highlighter,
|
||||||
|
/// Mit dem gewählten Stift schreiben (z. B. während der Textmarker aktiv ist)
|
||||||
|
Pen,
|
||||||
|
/// Taste nicht verwenden
|
||||||
|
Nothing,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StylusAction {
|
||||||
|
pub const ALL: [StylusAction; 5] = [
|
||||||
|
StylusAction::Erase,
|
||||||
|
StylusAction::Select,
|
||||||
|
StylusAction::Highlighter,
|
||||||
|
StylusAction::Pen,
|
||||||
|
StylusAction::Nothing,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
StylusAction::Erase => t!("Radieren"),
|
||||||
|
StylusAction::Select => t!("Auswählen"),
|
||||||
|
StylusAction::Highlighter => t!("Textmarker"),
|
||||||
|
StylusAction::Pen => t!("Stift"),
|
||||||
|
StylusAction::Nothing => t!("Nichts"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Taste am Stift: Kennung (aus der Tablet-Schnittstelle, beim ersten Drücken gelernt)
|
||||||
|
/// und ihre Belegung.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct StylusButton {
|
||||||
|
pub id: u64,
|
||||||
|
pub action: StylusAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Höchstens so viele Stifttasten werden gemerkt.
|
||||||
|
pub const MAX_STYLUS_BUTTONS: usize = 4;
|
||||||
|
|
||||||
/// Benutzereinstellungen der Oberfläche.
|
/// Benutzereinstellungen der Oberfläche.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -86,8 +212,21 @@ pub struct UiSettings {
|
|||||||
pub pattern_color: PatternColor,
|
pub pattern_color: PatternColor,
|
||||||
/// Druckkennlinie des Stifts
|
/// Druckkennlinie des Stifts
|
||||||
pub pressure_curve: PressureCurve,
|
pub pressure_curve: PressureCurve,
|
||||||
/// Seiten durchgehend untereinander anzeigen (sonst einzeln blättern)
|
/// Frühere globale Einstellung „Seiten durchgehend scrollen“: gilt nur noch für Notizbücher
|
||||||
|
/// ohne eigene Anordnung im Manifest (vor 0.28 angelegt), wird nicht mehr geändert
|
||||||
pub continuous_scroll: bool,
|
pub continuous_scroll: bool,
|
||||||
|
/// Stifte der Werkzeugleiste und der gewählte davon
|
||||||
|
pub pen_presets: [PenPreset; PEN_PRESET_COUNT],
|
||||||
|
pub active_pen: usize,
|
||||||
|
/// Eigene Farben von Textmarker und Text-Werkzeug und die gewählte davon
|
||||||
|
pub highlighter_colors: [[f32; 4]; 4],
|
||||||
|
pub active_highlighter: usize,
|
||||||
|
pub text_colors: [[f32; 4]; 5],
|
||||||
|
pub active_text_color: usize,
|
||||||
|
/// Tasten am Stift in der Reihenfolge, in der sie zuerst gedrückt wurden
|
||||||
|
pub stylus_buttons: Vec<StylusButton>,
|
||||||
|
/// Sprache der Oberfläche; ohne Angabe die des Systems
|
||||||
|
pub language: Option<Language>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UiSettings {
|
impl Default for UiSettings {
|
||||||
@@ -99,6 +238,14 @@ impl Default for UiSettings {
|
|||||||
pattern_color: PatternColor::default(),
|
pattern_color: PatternColor::default(),
|
||||||
pressure_curve: PressureCurve::default(),
|
pressure_curve: PressureCurve::default(),
|
||||||
continuous_scroll: true,
|
continuous_scroll: true,
|
||||||
|
pen_presets: DEFAULT_PEN_PRESETS,
|
||||||
|
active_pen: 0,
|
||||||
|
highlighter_colors: DEFAULT_HIGHLIGHTER_COLORS,
|
||||||
|
active_highlighter: 0,
|
||||||
|
text_colors: DEFAULT_TEXT_COLORS,
|
||||||
|
active_text_color: 0,
|
||||||
|
stylus_buttons: Vec::new(),
|
||||||
|
language: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,6 +273,27 @@ impl UiSettings {
|
|||||||
Err(_) => Self::default(),
|
Err(_) => Self::default(),
|
||||||
};
|
};
|
||||||
settings.pressure_curve = settings.pressure_curve.sanitized();
|
settings.pressure_curve = settings.pressure_curve.sanitized();
|
||||||
|
for (preset, fallback) in settings.pen_presets.iter_mut().zip(DEFAULT_PEN_PRESETS) {
|
||||||
|
*preset = preset.sanitized(fallback);
|
||||||
|
}
|
||||||
|
settings.active_pen = settings.active_pen.min(PEN_PRESET_COUNT - 1);
|
||||||
|
for (color, fallback) in settings
|
||||||
|
.highlighter_colors
|
||||||
|
.iter_mut()
|
||||||
|
.zip(DEFAULT_HIGHLIGHTER_COLORS)
|
||||||
|
{
|
||||||
|
*color = sanitized_color(*color, fallback);
|
||||||
|
}
|
||||||
|
settings.active_highlighter = settings
|
||||||
|
.active_highlighter
|
||||||
|
.min(settings.highlighter_colors.len() - 1);
|
||||||
|
for (color, fallback) in settings.text_colors.iter_mut().zip(DEFAULT_TEXT_COLORS) {
|
||||||
|
*color = sanitized_color(*color, fallback);
|
||||||
|
}
|
||||||
|
settings.active_text_color = settings
|
||||||
|
.active_text_color
|
||||||
|
.min(settings.text_colors.len() - 1);
|
||||||
|
settings.stylus_buttons.truncate(MAX_STYLUS_BUTTONS);
|
||||||
settings
|
settings
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +325,26 @@ mod tests {
|
|||||||
threshold: 0.1,
|
threshold: 0.1,
|
||||||
},
|
},
|
||||||
continuous_scroll: false,
|
continuous_scroll: false,
|
||||||
|
pen_presets: [PenPreset {
|
||||||
|
color: [0.5, 0.2, 0.7, 1.0],
|
||||||
|
width_mm: 1.5,
|
||||||
|
}; PEN_PRESET_COUNT],
|
||||||
|
active_pen: 2,
|
||||||
|
highlighter_colors: [[0.9, 0.5, 0.1, 0.35]; 4],
|
||||||
|
active_highlighter: 3,
|
||||||
|
text_colors: [[0.3, 0.1, 0.6, 1.0]; 5],
|
||||||
|
active_text_color: 4,
|
||||||
|
stylus_buttons: vec![
|
||||||
|
StylusButton {
|
||||||
|
id: 17,
|
||||||
|
action: StylusAction::Select,
|
||||||
|
},
|
||||||
|
StylusButton {
|
||||||
|
id: 42,
|
||||||
|
action: StylusAction::Nothing,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
language: Some(Language::English),
|
||||||
};
|
};
|
||||||
settings.save_to(&path).unwrap();
|
settings.save_to(&path).unwrap();
|
||||||
assert_eq!(UiSettings::load_from(&path), settings);
|
assert_eq!(UiSettings::load_from(&path), settings);
|
||||||
@@ -193,6 +381,39 @@ mod tests {
|
|||||||
let curve = UiSettings::load_from(&path).pressure_curve;
|
let curve = UiSettings::load_from(&path).pressure_curve;
|
||||||
assert_eq!(curve.exponent, 3.0);
|
assert_eq!(curve.exponent, 3.0);
|
||||||
assert_eq!(curve.threshold, 0.0);
|
assert_eq!(curve.threshold, 0.0);
|
||||||
|
|
||||||
|
// Ohne Stifte: die Standardstifte; unsinnige Stifte werden begrenzt
|
||||||
|
assert_eq!(settings.pen_presets, DEFAULT_PEN_PRESETS);
|
||||||
|
assert_eq!(settings.active_pen, 0);
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
br#"{"active_pen": 9, "pen_presets": [
|
||||||
|
{"color": [2.0, -1.0, 0.5, 0.2], "width_mm": 99.0},
|
||||||
|
{"color": [0.1, 0.2, 0.3, 1.0], "width_mm": 0.0},
|
||||||
|
{"color": [0.1, 0.2, 0.3, 1.0], "width_mm": 1.0},
|
||||||
|
{"color": [0.1, 0.2, 0.3, 1.0], "width_mm": 1.0}
|
||||||
|
]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let settings = UiSettings::load_from(&path);
|
||||||
|
assert_eq!(settings.active_pen, PEN_PRESET_COUNT - 1);
|
||||||
|
assert_eq!(settings.pen_presets[0].color, [1.0, 0.0, 0.5, 1.0]);
|
||||||
|
assert_eq!(settings.pen_presets[0].width_mm, 5.0);
|
||||||
|
assert_eq!(settings.pen_presets[1].width_mm, 0.15);
|
||||||
|
|
||||||
|
// Eigene Farben: Textmarker bleibt halbtransparent, Text deckend
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
br#"{"highlighter_colors": [[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0],
|
||||||
|
[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0]], "active_highlighter": 7,
|
||||||
|
"text_colors": [[5.0, 0.2, 0.3, 0.1], [0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0],
|
||||||
|
[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0]]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let settings = UiSettings::load_from(&path);
|
||||||
|
assert_eq!(settings.highlighter_colors[0], [0.1, 0.2, 0.3, 0.35]);
|
||||||
|
assert_eq!(settings.active_highlighter, 3);
|
||||||
|
assert_eq!(settings.text_colors[0], [1.0, 0.2, 0.3, 1.0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+117
-10
@@ -30,25 +30,50 @@ fn vs_main(in: VertexInput) -> VertexOutput {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Linearer Farbwert (Papier, Muster) -> sRGB: Gezeichnet wird ohne sRGB-Umrechnung der GPU
|
||||||
|
// (siehe `render_format` in canvas.rs), gemischt wird also wie im PDF in sRGB
|
||||||
|
fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
|
||||||
|
let low = c * 12.92;
|
||||||
|
let high = 1.055 * pow(c, vec3<f32>(1.0 / 2.4)) - vec3<f32>(0.055);
|
||||||
|
return select(high, low, c <= vec3<f32>(0.0031308));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Papier: Farbe linear
|
||||||
@fragment
|
@fragment
|
||||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
return in.color;
|
return vec4<f32>(linear_to_srgb(clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0))), in.color.a);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Striche: bei dunklem Papier wird die HSL-Helligkeit dunkler Farben gespiegelt
|
// Striche: Tintenfarben sind sRGB-Werte wie im Export und in der Oberfläche. Bei dunklem
|
||||||
// (L -> 1 - L, Farbton und Sättigung bleiben). Entspricht `background::adapt_ink_color`.
|
// Papier wird die HSL-Helligkeit dunkler Farben gespiegelt (L -> 1 - L, Farbton und
|
||||||
|
// Sättigung bleiben). Entspricht `background::adapt_ink_color`.
|
||||||
@fragment
|
@fragment
|
||||||
fn fs_ink(in: VertexOutput) -> @location(0) vec4<f32> {
|
fn fs_ink(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
if (camera.ink_params.x < 0.5) {
|
var rgb = clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||||
return in.color;
|
if (camera.ink_params.x >= 0.5) {
|
||||||
|
let max_c = max(max(rgb.r, rgb.g), rgb.b);
|
||||||
|
let min_c = min(min(rgb.r, rgb.g), rgb.b);
|
||||||
|
let lift = max(1.0 - max_c - min_c, 0.0);
|
||||||
|
rgb = min(rgb + vec3<f32>(lift), vec3<f32>(1.0));
|
||||||
}
|
}
|
||||||
let max_c = max(max(in.color.r, in.color.g), in.color.b);
|
return vec4<f32>(rgb, in.color.a);
|
||||||
let min_c = min(min(in.color.r, in.color.g), in.color.b);
|
|
||||||
let lift = max(1.0 - max_c - min_c, 0.0);
|
|
||||||
return vec4<f32>(in.color.rgb + vec3<f32>(lift), in.color.a);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eingefügte Bilder: texturierte Vierecke unter der Tinte
|
// Bildschirmfüllendes Dreieck ohne Farbe (setzt den Stencil-Puffer zurück)
|
||||||
|
@vertex
|
||||||
|
fn vs_fullscreen(@builtin(vertex_index) index: u32) -> @builtin(position) vec4<f32> {
|
||||||
|
let x = f32((index << 1u) & 2u) * 2.0 - 1.0;
|
||||||
|
let y = f32(index & 2u) * 2.0 - 1.0;
|
||||||
|
return vec4<f32>(x, y, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_none() -> @location(0) vec4<f32> {
|
||||||
|
return vec4<f32>(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eingefügte Bilder: texturierte Vierecke unter der Tinte (Texturen enthalten sRGB-Werte
|
||||||
|
// und werden unverändert übernommen)
|
||||||
struct ImageVertexInput {
|
struct ImageVertexInput {
|
||||||
@location(0) position: vec2<f32>,
|
@location(0) position: vec2<f32>,
|
||||||
@location(1) uv: vec2<f32>,
|
@location(1) uv: vec2<f32>,
|
||||||
@@ -77,3 +102,85 @@ fn vs_image(in: ImageVertexInput) -> ImageVertexOutput {
|
|||||||
fn fs_image(in: ImageVertexOutput) -> @location(0) vec4<f32> {
|
fn fs_image(in: ImageVertexOutput) -> @location(0) vec4<f32> {
|
||||||
return textureSample(image_texture, image_sampler, in.uv);
|
return textureSample(image_texture, image_sampler, in.uv);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Karo, Linien und Punkte: pro Pixel aus der Seitenkoordinate berechnet, damit die Abstände
|
||||||
|
// in jedem Zoom gleichmäßig bleiben. Linien und Punkte sind mindestens ein Pixel breit und
|
||||||
|
// kantengeglättet; werden die Abstände sehr klein, blendet das Muster aus statt zu flimmern.
|
||||||
|
struct PatternInput {
|
||||||
|
@location(0) position: vec2<f32>,
|
||||||
|
@location(1) color: vec4<f32>,
|
||||||
|
// Art (1 Karo, 2 Linien, 3 Punkte), Abstand, Linienstärke, Punktgröße
|
||||||
|
@location(2) params: vec4<f32>,
|
||||||
|
// Seitenbreite, Seitenhöhe, Rand oben/unten bei Linien
|
||||||
|
@location(3) bounds: vec4<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PatternOutput {
|
||||||
|
@builtin(position) clip_position: vec4<f32>,
|
||||||
|
@location(0) page: vec2<f32>,
|
||||||
|
@location(1) color: vec4<f32>,
|
||||||
|
@location(2) params: vec4<f32>,
|
||||||
|
@location(3) bounds: vec4<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_pattern(in: PatternInput) -> PatternOutput {
|
||||||
|
var out: PatternOutput;
|
||||||
|
let ndc = in.position * camera.transform_scale + camera.transform_offset;
|
||||||
|
out.clip_position = vec4<f32>(ndc, 0.0, 1.0);
|
||||||
|
out.page = in.position;
|
||||||
|
out.color = in.color;
|
||||||
|
out.params = in.params;
|
||||||
|
out.bounds = in.bounds;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deckung einer Linie bei `line` für die Koordinate `c` (`upp`: Seiteneinheiten je Pixel).
|
||||||
|
fn line_coverage(c: f32, line: f32, half_width: f32, upp: f32) -> f32 {
|
||||||
|
let half_px = max(half_width / upp, 0.5);
|
||||||
|
let d = abs(c - line) / upp;
|
||||||
|
return clamp(half_px + 0.5 - d, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_pattern(in: PatternOutput) -> @location(0) vec4<f32> {
|
||||||
|
let p = in.page;
|
||||||
|
let kind = in.params.x;
|
||||||
|
let spacing = in.params.y;
|
||||||
|
let upp = max(max(fwidth(p.x), fwidth(p.y)), 1e-4);
|
||||||
|
let size = in.bounds.xy;
|
||||||
|
// Unter 3 Pixel Abstand ausblenden (ganz herausgezoomt)
|
||||||
|
let fade = clamp((spacing / upp - 3.0) / 3.0, 0.0, 1.0);
|
||||||
|
|
||||||
|
var coverage = 0.0;
|
||||||
|
if (kind < 1.5) {
|
||||||
|
// Karo: senkrechte und waagerechte Linien bei k · Abstand, nicht am Blattrand
|
||||||
|
let k = round(p / spacing);
|
||||||
|
let line = k * spacing;
|
||||||
|
if (k.x >= 1.0 && line.x < size.x - 0.01) {
|
||||||
|
coverage = max(coverage, line_coverage(p.x, line.x, in.params.z * 0.5, upp));
|
||||||
|
}
|
||||||
|
if (k.y >= 1.0 && line.y < size.y - 0.01) {
|
||||||
|
coverage = max(coverage, line_coverage(p.y, line.y, in.params.z * 0.5, upp));
|
||||||
|
}
|
||||||
|
} else if (kind < 2.5) {
|
||||||
|
// Linien: waagerecht ab dem oberen Rand bis zum unteren Rand
|
||||||
|
let margin = in.bounds.z;
|
||||||
|
let k = round((p.y - margin) / spacing);
|
||||||
|
let line = margin + k * spacing;
|
||||||
|
if (k >= 0.0 && line <= size.y - margin + 0.01) {
|
||||||
|
coverage = line_coverage(p.y, line, in.params.z * 0.5, upp);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Punkte: an den Kreuzungen des Karos
|
||||||
|
let k = round(p / spacing);
|
||||||
|
let point = k * spacing;
|
||||||
|
if (all(k >= vec2<f32>(1.0)) && point.x < size.x - 0.01 && point.y < size.y - 0.01) {
|
||||||
|
let radius_px = max(in.params.w * 0.5 / upp, 0.6);
|
||||||
|
let d = length(p - point) / upp;
|
||||||
|
coverage = clamp(radius_px + 0.5 - d, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let rgb = linear_to_srgb(clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0)));
|
||||||
|
return vec4<f32>(rgb, in.color.a * coverage * fade);
|
||||||
|
}
|
||||||
|
|||||||
+127
-49
@@ -15,6 +15,7 @@ use crate::document::{
|
|||||||
};
|
};
|
||||||
use crate::filter::OneEuroFilter;
|
use crate::filter::OneEuroFilter;
|
||||||
use crate::spline::{self, ControlPoint, SplineConfig};
|
use crate::spline::{self, ControlPoint, SplineConfig};
|
||||||
|
use crate::t;
|
||||||
|
|
||||||
/// Serialisierbarer Stylus-Abtastpunkt in DIN-A4-Koordinaten.
|
/// Serialisierbarer Stylus-Abtastpunkt in DIN-A4-Koordinaten.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -167,7 +168,9 @@ impl StrokeData {
|
|||||||
/// - Version 6: Seitentitel (`title` in Seite und Manifest).
|
/// - Version 6: Seitentitel (`title` in Seite und Manifest).
|
||||||
/// - Version 7: Textfelder (`text` am Strich).
|
/// - Version 7: Textfelder (`text` am Strich).
|
||||||
/// - Version 8: Seitengröße je Seite (`size` in Seite und Manifest), Querformat für Folien.
|
/// - Version 8: Seitengröße je Seite (`size` in Seite und Manifest), Querformat für Folien.
|
||||||
pub const CURRENT_FORMAT_VERSION: u32 = 8;
|
/// - Version 9: endlose Notizbücher (`layout: "Endless"` im Manifest, eine Seite mit
|
||||||
|
/// `document::ENDLESS_PAGE_SIZE`). Ältere Versionen würden sie als riesige Seite öffnen.
|
||||||
|
pub const CURRENT_FORMAT_VERSION: u32 = 9;
|
||||||
|
|
||||||
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
|
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
|
||||||
pub fn legacy_format_version() -> u32 {
|
pub fn legacy_format_version() -> u32 {
|
||||||
@@ -199,8 +202,12 @@ fn stored_size(size: glam::Vec2) -> Option<[f32; 2]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Seitengröße aus einer (evtl. von Hand bearbeiteten) Datei; unbrauchbare Werte ergeben A4.
|
/// Seitengröße aus einer (evtl. von Hand bearbeiteten) Datei; unbrauchbare Werte ergeben A4.
|
||||||
|
/// Höchstens 2 × 2 m – außer der endlosen Seite.
|
||||||
pub fn page_size_from(stored: Option<[f32; 2]>) -> glam::Vec2 {
|
pub fn page_size_from(stored: Option<[f32; 2]>) -> glam::Vec2 {
|
||||||
match stored {
|
match stored {
|
||||||
|
Some(size) if glam::Vec2::from(size) == crate::document::ENDLESS_PAGE_SIZE => {
|
||||||
|
crate::document::ENDLESS_PAGE_SIZE
|
||||||
|
}
|
||||||
Some([w, h]) if w.is_finite() && h.is_finite() && w >= 100.0 && h >= 100.0 => {
|
Some([w, h]) if w.is_finite() && h.is_finite() && w >= 100.0 && h >= 100.0 => {
|
||||||
glam::Vec2::new(w.min(20_000.0), h.min(20_000.0))
|
glam::Vec2::new(w.min(20_000.0), h.min(20_000.0))
|
||||||
}
|
}
|
||||||
@@ -279,9 +286,34 @@ pub struct NotebookManifest {
|
|||||||
pub page_ids: Vec<u64>,
|
pub page_ids: Vec<u64>,
|
||||||
pub next_stroke_id: u64,
|
pub next_stroke_id: u64,
|
||||||
pub next_page_id: u64,
|
pub next_page_id: u64,
|
||||||
|
/// Anordnung der Seiten. Fehlt bei Notizbüchern von vor 0.28; dann gilt die frühere
|
||||||
|
/// globale Einstellung `continuous_scroll` aus `ui.json` (siehe [`Self::layout_or`]).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub layout: Option<NotebookLayout>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anordnung der Seiten eines Notizbuchs (Issue #30).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub enum NotebookLayout {
|
||||||
|
/// Feste Seiten: Seite für Seite blättern
|
||||||
|
#[default]
|
||||||
|
Paged,
|
||||||
|
/// Seiten hängen untereinander und werden durchgehend gescrollt
|
||||||
|
Continuous,
|
||||||
|
/// Eine einzige, praktisch unbegrenzte Fläche (siehe `document::ENDLESS_PAGE_SIZE`)
|
||||||
|
Endless,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NotebookManifest {
|
impl NotebookManifest {
|
||||||
|
/// Anordnung des Notizbuchs; ohne eigene Angabe die frühere globale Einstellung.
|
||||||
|
pub fn layout_or(&self, legacy_continuous: bool) -> NotebookLayout {
|
||||||
|
self.layout.unwrap_or(if legacy_continuous {
|
||||||
|
NotebookLayout::Continuous
|
||||||
|
} else {
|
||||||
|
NotebookLayout::Paged
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
|
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
|
||||||
let now = std::time::SystemTime::now()
|
let now = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
@@ -298,6 +330,7 @@ impl NotebookManifest {
|
|||||||
page_ids: vec![1],
|
page_ids: vec![1],
|
||||||
next_stroke_id: 1,
|
next_stroke_id: 1,
|
||||||
next_page_id: 2,
|
next_page_id: 2,
|
||||||
|
layout: Some(NotebookLayout::Paged),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,19 +404,30 @@ pub enum StorageError {
|
|||||||
impl fmt::Display for StorageError {
|
impl fmt::Display for StorageError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
StorageError::Io(e) => write!(f, "I/O-Fehler: {e}"),
|
StorageError::Io(e) => f.write_str(&t!("I/O-Fehler: {e}", e = e)),
|
||||||
StorageError::Serialization(e) => write!(f, "Serialisierungsfehler: {e}"),
|
StorageError::Serialization(e) => {
|
||||||
StorageError::InvalidManifest(msg) => write!(f, "Ungültiges Manifest: {msg}"),
|
f.write_str(&t!("Serialisierungsfehler: {e}", e = e))
|
||||||
StorageError::CorruptedPage(id, msg) => {
|
}
|
||||||
write!(f, "Beschädigte Seite {id}: {msg}")
|
StorageError::InvalidManifest(msg) => {
|
||||||
|
f.write_str(&t!("Ungültiges Manifest: {msg}", msg = msg))
|
||||||
|
}
|
||||||
|
StorageError::CorruptedPage(id, msg) => {
|
||||||
|
f.write_str(&t!("Beschädigte Seite {id}: {msg}", id = id, msg = msg))
|
||||||
|
}
|
||||||
|
StorageError::PageNotFound(id) => {
|
||||||
|
f.write_str(&t!("Seite {id} nicht gefunden", id = id))
|
||||||
|
}
|
||||||
|
StorageError::UnsupportedVersion { found, supported } => f.write_str(&t!(
|
||||||
|
"Notizbuch wurde mit neuerer Version erstellt (Formatversion {found}, unterstützt wird bis {supported})",
|
||||||
|
found = found,
|
||||||
|
supported = supported
|
||||||
|
)),
|
||||||
|
StorageError::AlreadyLocked(msg) => {
|
||||||
|
f.write_str(&t!("Notizbuch ist gesperrt: {msg}", msg = msg))
|
||||||
|
}
|
||||||
|
StorageError::PathTraversal(msg) => {
|
||||||
|
f.write_str(&t!("Ungültiger Pfad (Path Traversal): {msg}", msg = msg))
|
||||||
}
|
}
|
||||||
StorageError::PageNotFound(id) => write!(f, "Seite {id} nicht gefunden"),
|
|
||||||
StorageError::UnsupportedVersion { found, supported } => write!(
|
|
||||||
f,
|
|
||||||
"Notizbuch wurde mit neuerer Version erstellt (Formatversion {found}, unterstützt wird bis {supported})"
|
|
||||||
),
|
|
||||||
StorageError::AlreadyLocked(msg) => write!(f, "Notizbuch ist gesperrt: {msg}"),
|
|
||||||
StorageError::PathTraversal(msg) => write!(f, "Ungültiger Pfad (Path Traversal): {msg}"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -490,7 +534,7 @@ pub fn atomic_write_file(path: &Path, content: &[u8]) -> Result<(), io::Error> {
|
|||||||
let dir = path.parent().ok_or_else(|| {
|
let dir = path.parent().ok_or_else(|| {
|
||||||
io::Error::new(
|
io::Error::new(
|
||||||
io::ErrorKind::NotFound,
|
io::ErrorKind::NotFound,
|
||||||
"Übergeordnetes Verzeichnis nicht gefunden",
|
t!("Übergeordnetes Verzeichnis nicht gefunden"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
create_secure_dir_all(dir)?;
|
create_secure_dir_all(dir)?;
|
||||||
@@ -595,9 +639,9 @@ pub fn load_manifest(notebook_dir: &Path) -> Result<NotebookManifest, StorageErr
|
|||||||
eprintln!("[Storage-Warnung] manifest.json fehlt, lade aus manifest.json.bak");
|
eprintln!("[Storage-Warnung] manifest.json fehlt, lade aus manifest.json.bak");
|
||||||
return load_manifest_from_file(&bak_path);
|
return load_manifest_from_file(&bak_path);
|
||||||
}
|
}
|
||||||
return Err(StorageError::InvalidManifest(format!(
|
return Err(StorageError::InvalidManifest(t!(
|
||||||
"Manifestdatei nicht gefunden: {}",
|
"Manifestdatei nicht gefunden: {path}",
|
||||||
path.display()
|
path = path.display()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,7 +669,7 @@ pub fn quarantine_corrupted_page(notebook_dir: &Path, page_id: u64) -> Result<Pa
|
|||||||
if !source_path.exists() {
|
if !source_path.exists() {
|
||||||
return Err(io::Error::new(
|
return Err(io::Error::new(
|
||||||
io::ErrorKind::NotFound,
|
io::ErrorKind::NotFound,
|
||||||
"Datei existiert nicht",
|
t!("Datei existiert nicht"),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let ts = std::time::SystemTime::now()
|
let ts = std::time::SystemTime::now()
|
||||||
@@ -764,7 +808,7 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
|
|||||||
first_id,
|
first_id,
|
||||||
PageLoadWarning {
|
PageLoadWarning {
|
||||||
page_id: first_id,
|
page_id: first_id,
|
||||||
message: format!("I/O-Fehler: {e}"),
|
message: t!("I/O-Fehler: {e}", e = e),
|
||||||
quarantine_path: None,
|
quarantine_path: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -876,7 +920,7 @@ pub fn ensure_page_loaded(
|
|||||||
page_id,
|
page_id,
|
||||||
PageLoadWarning {
|
PageLoadWarning {
|
||||||
page_id,
|
page_id,
|
||||||
message: format!("I/O-Fehler: {err_str}"),
|
message: t!("I/O-Fehler: {e}", e = err_str),
|
||||||
quarantine_path: None,
|
quarantine_path: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -970,9 +1014,9 @@ impl NotebookLock {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
let kind = e.kind();
|
let kind = e.kind();
|
||||||
if kind == io::ErrorKind::WouldBlock || e.raw_os_error() == Some(11) {
|
if kind == io::ErrorKind::WouldBlock || e.raw_os_error() == Some(11) {
|
||||||
Err(StorageError::AlreadyLocked(format!(
|
Err(StorageError::AlreadyLocked(t!(
|
||||||
"Notizbuch '{}' wird bereits von einer anderen Instanz verwendet",
|
"Notizbuch '{path}' wird bereits von einer anderen Instanz verwendet",
|
||||||
notebook_dir.display()
|
path = notebook_dir.display()
|
||||||
)))
|
)))
|
||||||
} else {
|
} else {
|
||||||
Err(StorageError::Io(e))
|
Err(StorageError::Io(e))
|
||||||
@@ -1058,7 +1102,7 @@ pub fn list_notebooks(base_dir: &Path) -> Vec<NotebookSummary> {
|
|||||||
|
|
||||||
list.push(NotebookSummary {
|
list.push(NotebookSummary {
|
||||||
id: dir_name.to_string(),
|
id: dir_name.to_string(),
|
||||||
title: format!("⚠️ {dir_name} (Beschädigt)"),
|
title: t!("⚠️ {name} (Beschädigt)", name = dir_name),
|
||||||
page_count: 0,
|
page_count: 0,
|
||||||
modified_at,
|
modified_at,
|
||||||
status: NotebookStatus::Damaged,
|
status: NotebookStatus::Damaged,
|
||||||
@@ -1077,11 +1121,11 @@ pub fn list_notebooks(base_dir: &Path) -> Vec<NotebookSummary> {
|
|||||||
|
|
||||||
list.push(NotebookSummary {
|
list.push(NotebookSummary {
|
||||||
id: dir_name.to_string(),
|
id: dir_name.to_string(),
|
||||||
title: format!("⚠️ {dir_name} (Beschädigt - kein Manifest)"),
|
title: t!("⚠️ {name} (Beschädigt - kein Manifest)", name = dir_name),
|
||||||
page_count: 0,
|
page_count: 0,
|
||||||
modified_at,
|
modified_at,
|
||||||
status: NotebookStatus::Damaged,
|
status: NotebookStatus::Damaged,
|
||||||
error: Some("Keine manifest.json vorhanden".to_string()),
|
error: Some(t!("Keine manifest.json vorhanden").to_string()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1135,7 +1179,7 @@ pub fn create_new_notebook(
|
|||||||
if counter > 100 {
|
if counter > 100 {
|
||||||
return Err(StorageError::Io(io::Error::new(
|
return Err(StorageError::Io(io::Error::new(
|
||||||
io::ErrorKind::AlreadyExists,
|
io::ErrorKind::AlreadyExists,
|
||||||
"Konnte nach 100 Versuchen kein eindeutiges Notizbuch-Verzeichnis finden",
|
t!("Konnte nach 100 Versuchen kein eindeutiges Notizbuch-Verzeichnis finden"),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1214,18 +1258,18 @@ pub fn rename_notebook(
|
|||||||
/// Prüft, dass der Pfad direkt unter `base_dir` liegt, kein Symlink ist und `manifest.json` enthält.
|
/// Prüft, dass der Pfad direkt unter `base_dir` liegt, kein Symlink ist und `manifest.json` enthält.
|
||||||
pub fn delete_notebook(notebook_dir: &Path, base_dir: &Path) -> Result<PathBuf, StorageError> {
|
pub fn delete_notebook(notebook_dir: &Path, base_dir: &Path) -> Result<PathBuf, StorageError> {
|
||||||
if !notebook_dir.exists() {
|
if !notebook_dir.exists() {
|
||||||
return Err(StorageError::InvalidManifest(format!(
|
return Err(StorageError::InvalidManifest(t!(
|
||||||
"Verzeichnis '{}' existiert nicht",
|
"Verzeichnis '{path}' existiert nicht",
|
||||||
notebook_dir.display()
|
path = notebook_dir.display()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfen auf Symlink
|
// Prüfen auf Symlink
|
||||||
let symlink_meta = fs::symlink_metadata(notebook_dir).map_err(StorageError::Io)?;
|
let symlink_meta = fs::symlink_metadata(notebook_dir).map_err(StorageError::Io)?;
|
||||||
if symlink_meta.file_type().is_symlink() {
|
if symlink_meta.file_type().is_symlink() {
|
||||||
return Err(StorageError::InvalidManifest(format!(
|
return Err(StorageError::InvalidManifest(t!(
|
||||||
"Pfad '{}' ist ein Symlink und darf nicht gelöscht werden",
|
"Pfad '{path}' ist ein Symlink und darf nicht gelöscht werden",
|
||||||
notebook_dir.display()
|
path = notebook_dir.display()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1234,18 +1278,18 @@ pub fn delete_notebook(notebook_dir: &Path, base_dir: &Path) -> Result<PathBuf,
|
|||||||
|
|
||||||
// Prüfen, dass der Pfad direkt unter base_dir liegt
|
// Prüfen, dass der Pfad direkt unter base_dir liegt
|
||||||
if canon_target.parent() != Some(&canon_base) {
|
if canon_target.parent() != Some(&canon_base) {
|
||||||
return Err(StorageError::PathTraversal(format!(
|
return Err(StorageError::PathTraversal(t!(
|
||||||
"Pfad '{}' liegt nicht direkt unter Basis-Verzeichnis '{}'",
|
"Pfad '{path}' liegt nicht direkt unter Basis-Verzeichnis '{base}'",
|
||||||
notebook_dir.display(),
|
path = notebook_dir.display(),
|
||||||
base_dir.display()
|
base = base_dir.display()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfen, dass manifest.json vorhanden ist
|
// Prüfen, dass manifest.json vorhanden ist
|
||||||
if !canon_target.join("manifest.json").exists() {
|
if !canon_target.join("manifest.json").exists() {
|
||||||
return Err(StorageError::InvalidManifest(format!(
|
return Err(StorageError::InvalidManifest(t!(
|
||||||
"Verzeichnis '{}' enthält kein manifest.json",
|
"Verzeichnis '{path}' enthält kein manifest.json",
|
||||||
notebook_dir.display()
|
path = notebook_dir.display()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1276,9 +1320,9 @@ pub fn restore_notebook_from_trash(
|
|||||||
if !trash_target.exists() {
|
if !trash_target.exists() {
|
||||||
return Err(StorageError::Io(io::Error::new(
|
return Err(StorageError::Io(io::Error::new(
|
||||||
io::ErrorKind::NotFound,
|
io::ErrorKind::NotFound,
|
||||||
format!(
|
t!(
|
||||||
"Papierkorb-Eintrag '{}' existiert nicht",
|
"Papierkorb-Eintrag '{path}' existiert nicht",
|
||||||
trash_target.display()
|
path = trash_target.display()
|
||||||
),
|
),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -1289,7 +1333,7 @@ pub fn restore_notebook_from_trash(
|
|||||||
let expected_trash_dir = canon_base.join(".trash");
|
let expected_trash_dir = canon_base.join(".trash");
|
||||||
if !expected_trash_dir.exists() {
|
if !expected_trash_dir.exists() {
|
||||||
return Err(StorageError::InvalidManifest(
|
return Err(StorageError::InvalidManifest(
|
||||||
"Papierkorb-Verzeichnis existiert nicht".to_string(),
|
t!("Papierkorb-Verzeichnis existiert nicht").to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let canon_trash_base = expected_trash_dir
|
let canon_trash_base = expected_trash_dir
|
||||||
@@ -1297,9 +1341,9 @@ pub fn restore_notebook_from_trash(
|
|||||||
.map_err(StorageError::Io)?;
|
.map_err(StorageError::Io)?;
|
||||||
|
|
||||||
if canon_trash.parent() != Some(&canon_trash_base) {
|
if canon_trash.parent() != Some(&canon_trash_base) {
|
||||||
return Err(StorageError::PathTraversal(format!(
|
return Err(StorageError::PathTraversal(t!(
|
||||||
"Papierkorb-Pfad '{}' liegt nicht im Papierkorb",
|
"Papierkorb-Pfad '{path}' liegt nicht im Papierkorb",
|
||||||
trash_target.display()
|
path = trash_target.display()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1344,7 +1388,7 @@ pub fn restore_manifest_from_backup(notebook_dir: &Path) -> Result<(), StorageEr
|
|||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(StorageError::InvalidManifest(
|
Err(StorageError::InvalidManifest(
|
||||||
"Keine Sicherungskopie (manifest.json.bak) vorhanden".to_string(),
|
t!("Keine Sicherungskopie (manifest.json.bak) vorhanden").to_string(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1387,7 +1431,7 @@ pub fn load_all_pages(notebook_dir: &Path, notebook: &mut Notebook) -> Result<()
|
|||||||
if page.persist == crate::document::PersistState::ReadOnlyAfterLoadError {
|
if page.persist == crate::document::PersistState::ReadOnlyAfterLoadError {
|
||||||
return Err(StorageError::CorruptedPage(
|
return Err(StorageError::CorruptedPage(
|
||||||
page.id,
|
page.id,
|
||||||
"Seite konnte nicht geladen werden".to_string(),
|
t!("Seite konnte nicht geladen werden").to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !page.is_loaded {
|
if !page.is_loaded {
|
||||||
@@ -1592,6 +1636,40 @@ mod tests {
|
|||||||
assert!(shutdown_res.is_ok());
|
assert!(shutdown_res.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_notebook_layout_is_saved_and_old_manifests_fall_back() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let (_nb, mut manifest, dir) = create_new_notebook(temp_dir.path(), "Layout").unwrap();
|
||||||
|
// Neue Notizbücher: feste Seiten, unabhängig von der früheren globalen Einstellung
|
||||||
|
assert_eq!(manifest.layout, Some(NotebookLayout::Paged));
|
||||||
|
assert_eq!(manifest.layout_or(true), NotebookLayout::Paged);
|
||||||
|
|
||||||
|
manifest.layout = Some(NotebookLayout::Continuous);
|
||||||
|
save_manifest(&dir, &manifest).unwrap();
|
||||||
|
let (_nb, loaded) = load_notebook(&dir).unwrap();
|
||||||
|
assert_eq!(loaded.layout, Some(NotebookLayout::Continuous));
|
||||||
|
|
||||||
|
// Manifest von vor 0.28 ohne `layout`: es gilt die globale Einstellung
|
||||||
|
let mut json: serde_json::Value =
|
||||||
|
serde_json::from_str(&fs::read_to_string(dir.join("manifest.json")).unwrap()).unwrap();
|
||||||
|
json.as_object_mut().unwrap().remove("layout");
|
||||||
|
let old: NotebookManifest = serde_json::from_value(json).unwrap();
|
||||||
|
assert_eq!(old.layout, None);
|
||||||
|
assert_eq!(old.layout_or(true), NotebookLayout::Continuous);
|
||||||
|
assert_eq!(old.layout_or(false), NotebookLayout::Paged);
|
||||||
|
// … und schreibt beim Speichern auch keins dazu
|
||||||
|
assert!(!serde_json::to_string(&old).unwrap().contains("\"layout\""));
|
||||||
|
|
||||||
|
// Endlos: die endlose Seite übersteht Speichern und Laden
|
||||||
|
let (mut nb, mut manifest, dir) = create_new_notebook(temp_dir.path(), "Endlos").unwrap();
|
||||||
|
assert!(nb.make_endless());
|
||||||
|
manifest.layout = Some(NotebookLayout::Endless);
|
||||||
|
save_notebook(&dir, &nb, &mut manifest).unwrap();
|
||||||
|
let (loaded_nb, loaded) = load_notebook(&dir).unwrap();
|
||||||
|
assert_eq!(loaded.layout, Some(NotebookLayout::Endless));
|
||||||
|
assert!(loaded_nb.is_endless());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_notebook_management() {
|
fn test_notebook_management() {
|
||||||
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
|
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
|
||||||
|
|||||||
+8
-6
@@ -10,6 +10,8 @@
|
|||||||
|
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use crate::t;
|
||||||
|
|
||||||
use ab_glyph::{Font, FontRef, GlyphId, PxScale};
|
use ab_glyph::{Font, FontRef, GlyphId, PxScale};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -29,8 +31,8 @@ pub enum TextFont {
|
|||||||
impl TextFont {
|
impl TextFont {
|
||||||
pub fn label(self) -> &'static str {
|
pub fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
TextFont::Sans => "Normal",
|
TextFont::Sans => t!("Normal"),
|
||||||
TextFont::Mono => "Code",
|
TextFont::Mono => t!("Code"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +350,7 @@ const MAX_TEXTURE_SIDE: f32 = 4096.0;
|
|||||||
const MAX_TEXTURE_PIXELS: f32 = 16.0 * 1024.0 * 1024.0;
|
const MAX_TEXTURE_PIXELS: f32 = 16.0 * 1024.0 * 1024.0;
|
||||||
|
|
||||||
/// Rastert ein Textfeld mit `px_per_unit` Pixeln je Seiteneinheit (begrenzt auf eine
|
/// Rastert ein Textfeld mit `px_per_unit` Pixeln je Seiteneinheit (begrenzt auf eine
|
||||||
/// handhabbare Texturgröße). `color` ist linear wie die Tinte; die Pixel sind sRGB.
|
/// handhabbare Texturgröße). `color` ist sRGB wie die Tinte, ebenso die Pixel.
|
||||||
pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage {
|
pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage {
|
||||||
let layout = text.layout();
|
let layout = text.layout();
|
||||||
let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height());
|
let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height());
|
||||||
@@ -383,7 +385,7 @@ pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let rgb = [0, 1, 2].map(|i| egui::ecolor::gamma_u8_from_linear_f32(color[i]));
|
let rgb = [0, 1, 2].map(|i| (color[i].clamp(0.0, 1.0) * 255.0).round() as u8);
|
||||||
let alpha = color[3].clamp(0.0, 1.0);
|
let alpha = color[3].clamp(0.0, 1.0);
|
||||||
let mut pixels = Vec::with_capacity(coverage.len() * 4);
|
let mut pixels = Vec::with_capacity(coverage.len() * 4);
|
||||||
for c in coverage {
|
for c in coverage {
|
||||||
@@ -476,8 +478,8 @@ mod tests {
|
|||||||
assert_eq!(image.height as f32, text.layout().height().ceil());
|
assert_eq!(image.height as f32, text.layout().height().ceil());
|
||||||
let opaque = image.pixels.chunks(4).filter(|p| p[3] > 200).count();
|
let opaque = image.pixels.chunks(4).filter(|p| p[3] > 200).count();
|
||||||
assert!(opaque > 100, "{opaque}");
|
assert!(opaque > 100, "{opaque}");
|
||||||
// Farbe ist sRGB-kodiert
|
// Die Farbe ist schon sRGB und wird unverändert übernommen
|
||||||
assert!(image.pixels.chunks(4).all(|p| p[0] > 230 && p[1] < 130));
|
assert!(image.pixels.chunks(4).all(|p| p[..3] == [217, 51, 51]));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+154
-72
@@ -23,6 +23,8 @@ use std::time::{Duration, Instant};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::t;
|
||||||
|
|
||||||
/// Konfiguration für den Gitea-Update-Server.
|
/// Konfiguration für den Gitea-Update-Server.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct UpdaterConfig {
|
pub struct UpdaterConfig {
|
||||||
@@ -59,13 +61,18 @@ impl Default for UpdaterConfig {
|
|||||||
impl UpdaterConfig {
|
impl UpdaterConfig {
|
||||||
/// Validiert die Server-URL auf Protokollsicherheit (erzwingt HTTPS).
|
/// Validiert die Server-URL auf Protokollsicherheit (erzwingt HTTPS).
|
||||||
pub fn validate_url(&self) -> Result<url::Url, String> {
|
pub fn validate_url(&self) -> Result<url::Url, String> {
|
||||||
let parsed = url::Url::parse(&self.server_url)
|
let parsed = url::Url::parse(&self.server_url).map_err(|e| {
|
||||||
.map_err(|e| format!("Ungültige Server-URL '{}': {e}", self.server_url))?;
|
t!(
|
||||||
|
"Ungültige Server-URL '{url}': {e}",
|
||||||
|
url = self.server_url,
|
||||||
|
e = e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
if parsed.scheme() != "https" && !self.allow_insecure_http {
|
if parsed.scheme() != "https" && !self.allow_insecure_http {
|
||||||
return Err(format!(
|
return Err(t!(
|
||||||
"Unsicheres Protokoll '{}' abgewiesen. Es wird zwingend HTTPS verlangt (z. B. https://gitea.pansi.eu).",
|
"Unsicheres Protokoll '{scheme}' abgewiesen. Es wird zwingend HTTPS verlangt (z. B. https://gitea.pansi.eu).",
|
||||||
parsed.scheme()
|
scheme = parsed.scheme()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(parsed)
|
Ok(parsed)
|
||||||
@@ -111,11 +118,20 @@ pub enum RuntimeEnvironment {
|
|||||||
DebPackage { binary_path: PathBuf },
|
DebPackage { binary_path: PathBuf },
|
||||||
/// Aus Quellcode / Entwicklungs-Binary (z.B. target/debug oder target/release)
|
/// Aus Quellcode / Entwicklungs-Binary (z.B. target/debug oder target/release)
|
||||||
PortableOrDev { binary_path: PathBuf },
|
PortableOrDev { binary_path: PathBuf },
|
||||||
|
/// Als Flatpak installiert: Updates kommen über die Software-Verwaltung, ink-notes lädt
|
||||||
|
/// selbst nichts herunter (Vorgabe von Flathub)
|
||||||
|
Flatpak,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RuntimeEnvironment {
|
impl RuntimeEnvironment {
|
||||||
/// Erkennt die aktuelle Laufzeitumgebung anhand der Umgebung und des Binärpfads.
|
/// Erkennt die aktuelle Laufzeitumgebung anhand der Umgebung und des Binärpfads.
|
||||||
pub fn detect() -> Self {
|
pub fn detect() -> Self {
|
||||||
|
if is_flatpak(
|
||||||
|
std::env::var("FLATPAK_ID").ok().as_deref(),
|
||||||
|
Path::new("/.flatpak-info").exists(),
|
||||||
|
) {
|
||||||
|
return RuntimeEnvironment::Flatpak;
|
||||||
|
}
|
||||||
let appimage_var = std::env::var("APPIMAGE").ok();
|
let appimage_var = std::env::var("APPIMAGE").ok();
|
||||||
let current_exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("ink-notes"));
|
let current_exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("ink-notes"));
|
||||||
Self::detect_from(appimage_var.as_deref(), ¤t_exe)
|
Self::detect_from(appimage_var.as_deref(), ¤t_exe)
|
||||||
@@ -146,33 +162,47 @@ impl RuntimeEnvironment {
|
|||||||
|
|
||||||
pub fn display_name(&self) -> &'static str {
|
pub fn display_name(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
RuntimeEnvironment::AppImage { .. } => "AppImage (Portabel)",
|
RuntimeEnvironment::AppImage { .. } => t!("AppImage (Portabel)"),
|
||||||
RuntimeEnvironment::DebPackage { .. } => "System-Paket (.deb)",
|
RuntimeEnvironment::DebPackage { .. } => t!("System-Paket (.deb)"),
|
||||||
RuntimeEnvironment::PortableOrDev { .. } => "Entwicklung / Lokal",
|
RuntimeEnvironment::PortableOrDev { .. } => t!("Entwicklung / Lokal"),
|
||||||
|
RuntimeEnvironment::Flatpak => "Flatpak",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ob ink-notes selbst nach Updates suchen und sie installieren darf
|
||||||
|
pub fn self_update_allowed(&self) -> bool {
|
||||||
|
!matches!(self, RuntimeEnvironment::Flatpak)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Läuft ink-notes in einer Flatpak-Sandbox (`FLATPAK_ID` gesetzt oder `/.flatpak-info`
|
||||||
|
/// vorhanden)?
|
||||||
|
pub fn is_flatpak(flatpak_id: Option<&str>, info_file_exists: bool) -> bool {
|
||||||
|
flatpak_id.is_some_and(|id| !id.trim().is_empty()) || info_file_exists
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bereinigt und validiert einen Asset-Dateinamen gegen Path Traversal Angriffe.
|
/// Bereinigt und validiert einen Asset-Dateinamen gegen Path Traversal Angriffe.
|
||||||
pub fn sanitize_asset_name(raw_name: &str) -> Result<String, String> {
|
pub fn sanitize_asset_name(raw_name: &str) -> Result<String, String> {
|
||||||
let trimmed = raw_name.trim();
|
let trimmed = raw_name.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return Err("Asset-Name darf nicht leer sein.".to_string());
|
return Err(t!("Asset-Name darf nicht leer sein.").to_string());
|
||||||
}
|
}
|
||||||
if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") {
|
if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") {
|
||||||
return Err(format!(
|
return Err(t!(
|
||||||
"Ungültiger Asset-Name (enthält unzulässige Pfadkomponenten): '{raw_name}'"
|
"Ungültiger Asset-Name (enthält unzulässige Pfadkomponenten): '{raw_name}'",
|
||||||
|
raw_name = raw_name
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let path = Path::new(trimmed);
|
let path = Path::new(trimmed);
|
||||||
let file_name = path
|
let file_name = path
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
.ok_or_else(|| format!("Ungültiger Dateiname: '{raw_name}'"))?;
|
.ok_or_else(|| t!("Ungültiger Dateiname: '{raw_name}'", raw_name = raw_name))?;
|
||||||
|
|
||||||
if file_name != trimmed || file_name.starts_with('.') {
|
if file_name != trimmed || file_name.starts_with('.') {
|
||||||
return Err(format!(
|
return Err(t!(
|
||||||
"Ungültiger oder versteckter Asset-Name: '{raw_name}'"
|
"Ungültiger oder versteckter Asset-Name: '{raw_name}'",
|
||||||
|
raw_name = raw_name
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(file_name.to_string())
|
Ok(file_name.to_string())
|
||||||
@@ -193,9 +223,10 @@ pub fn get_updates_cache_dir() -> Result<PathBuf, String> {
|
|||||||
|
|
||||||
let dir = base.join("ink-notes").join("updates");
|
let dir = base.join("ink-notes").join("updates");
|
||||||
std::fs::create_dir_all(&dir).map_err(|e| {
|
std::fs::create_dir_all(&dir).map_err(|e| {
|
||||||
format!(
|
t!(
|
||||||
"Konnte Update-Cache-Verzeichnis nicht anlegen ({}): {e}",
|
"Konnte Update-Cache-Verzeichnis nicht anlegen ({path}): {e}",
|
||||||
dir.display()
|
path = dir.display(),
|
||||||
|
e = e
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -210,7 +241,7 @@ pub fn get_updates_cache_dir() -> Result<PathBuf, String> {
|
|||||||
|
|
||||||
fn default_user_cache_dir() -> Result<PathBuf, String> {
|
fn default_user_cache_dir() -> Result<PathBuf, String> {
|
||||||
let home = std::env::var("HOME")
|
let home = std::env::var("HOME")
|
||||||
.map_err(|_| "HOME-Umgebungsvariable ist nicht gesetzt.".to_string())?;
|
.map_err(|_| t!("HOME-Umgebungsvariable ist nicht gesetzt.").to_string())?;
|
||||||
Ok(PathBuf::from(home).join(".cache"))
|
Ok(PathBuf::from(home).join(".cache"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,9 +282,10 @@ pub fn is_newer_version(remote_tag: &str, current_tag: &str, include_prereleases
|
|||||||
/// Berechnet den hex-kodierten SHA-256 Hash einer Datei.
|
/// Berechnet den hex-kodierten SHA-256 Hash einer Datei.
|
||||||
pub fn compute_sha256(path: &Path) -> Result<String, String> {
|
pub fn compute_sha256(path: &Path) -> Result<String, String> {
|
||||||
let mut file = std::fs::File::open(path).map_err(|e| {
|
let mut file = std::fs::File::open(path).map_err(|e| {
|
||||||
format!(
|
t!(
|
||||||
"Konnte Datei '{}' zur Prüfsummenberechnung nicht öffnen: {e}",
|
"Konnte Datei '{path}' zur Prüfsummenberechnung nicht öffnen: {e}",
|
||||||
path.display()
|
path = path.display(),
|
||||||
|
e = e
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
@@ -261,7 +293,7 @@ pub fn compute_sha256(path: &Path) -> Result<String, String> {
|
|||||||
loop {
|
loop {
|
||||||
let n = file
|
let n = file
|
||||||
.read(&mut buffer)
|
.read(&mut buffer)
|
||||||
.map_err(|e| format!("Fehler beim Lesen der Prüfsummendatei: {e}"))?;
|
.map_err(|e| t!("Fehler beim Lesen der Prüfsummendatei: {e}", e = e))?;
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -300,15 +332,13 @@ pub fn verify_checksum(
|
|||||||
let checksums = parse_checksums(checksums_content);
|
let checksums = parse_checksums(checksums_content);
|
||||||
let expected_hash = checksums.get(asset_name).ok_or_else(|| {
|
let expected_hash = checksums.get(asset_name).ok_or_else(|| {
|
||||||
let _ = std::fs::remove_file(downloaded_path);
|
let _ = std::fs::remove_file(downloaded_path);
|
||||||
format!("Kein Eintrag für '{asset_name}' in SHA256SUMS.txt gefunden. Heruntergeladene Datei wurde gelöscht.")
|
t!("Kein Eintrag für '{asset_name}' in SHA256SUMS.txt gefunden. Heruntergeladene Datei wurde gelöscht.", asset_name = asset_name)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let actual_hash = compute_sha256(downloaded_path)?;
|
let actual_hash = compute_sha256(downloaded_path)?;
|
||||||
if !actual_hash.eq_ignore_ascii_case(expected_hash) {
|
if !actual_hash.eq_ignore_ascii_case(expected_hash) {
|
||||||
let _ = std::fs::remove_file(downloaded_path);
|
let _ = std::fs::remove_file(downloaded_path);
|
||||||
return Err(format!(
|
return Err(t!("Integritätsprüfung fehlgeschlagen für '{asset_name}': Erwartet {expected_hash}, berechnet {actual_hash}. Datei wurde gelöscht.", asset_name = asset_name, expected_hash = expected_hash, actual_hash = actual_hash));
|
||||||
"Integritätsprüfung fehlgeschlagen für '{asset_name}': Erwartet {expected_hash}, berechnet {actual_hash}. Datei wurde gelöscht."
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -334,15 +364,26 @@ pub fn verify_checksums_signature(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let key = public_key_b64.trim();
|
let key = public_key_b64.trim();
|
||||||
if key.is_empty() {
|
if key.is_empty() {
|
||||||
return Err("In dieser Version ist kein Signaturschlüssel für Updates hinterlegt. Bitte das Update manuell installieren.".to_string());
|
return Err(t!("In dieser Version ist kein Signaturschlüssel für Updates hinterlegt. Bitte das Update manuell installieren.").to_string());
|
||||||
}
|
}
|
||||||
let public_key = minisign_verify::PublicKey::from_base64(key)
|
let public_key = minisign_verify::PublicKey::from_base64(key)
|
||||||
.map_err(|e| format!("Ungültiger Update-Signaturschlüssel: {e}."))?;
|
.map_err(|e| t!("Ungültiger Update-Signaturschlüssel: {e}.", e = e))?;
|
||||||
let signature = minisign_verify::Signature::decode(signature_text)
|
let signature = minisign_verify::Signature::decode(signature_text).map_err(|e| {
|
||||||
.map_err(|e| format!("{CHECKSUMS_SIGNATURE_ASSET_NAME} ist ungültig: {e}."))?;
|
t!(
|
||||||
|
"{file} ist ungültig: {e}.",
|
||||||
|
file = CHECKSUMS_SIGNATURE_ASSET_NAME,
|
||||||
|
e = e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
public_key
|
public_key
|
||||||
.verify(checksums, &signature, false)
|
.verify(checksums, &signature, false)
|
||||||
.map_err(|e| format!("Signaturprüfung von {CHECKSUMS_ASSET_NAME} fehlgeschlagen: {e}."))
|
.map_err(|e| {
|
||||||
|
t!(
|
||||||
|
"Signaturprüfung von {file} fehlgeschlagen: {e}.",
|
||||||
|
file = CHECKSUMS_ASSET_NAME,
|
||||||
|
e = e
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lädt einen Text-Asset des Releases über den abgesicherten HTTP-Client.
|
/// Lädt einen Text-Asset des Releases über den abgesicherten HTTP-Client.
|
||||||
@@ -356,7 +397,7 @@ fn fetch_release_text(
|
|||||||
.assets
|
.assets
|
||||||
.iter()
|
.iter()
|
||||||
.find(|a| a.name == name)
|
.find(|a| a.name == name)
|
||||||
.ok_or_else(|| format!("Release enthält keine {name}."))?;
|
.ok_or_else(|| t!("Release enthält keine {name}.", name = name))?;
|
||||||
http_get_with_auth_guard(
|
http_get_with_auth_guard(
|
||||||
agent,
|
agent,
|
||||||
&asset.browser_download_url,
|
&asset.browser_download_url,
|
||||||
@@ -364,9 +405,9 @@ fn fetch_release_text(
|
|||||||
5,
|
5,
|
||||||
config.allow_insecure_http,
|
config.allow_insecure_http,
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("Konnte {name} nicht laden: {e}."))?
|
.map_err(|e| t!("Konnte {name} nicht laden: {e}.", name = name, e = e))?
|
||||||
.into_string()
|
.into_string()
|
||||||
.map_err(|e| format!("Konnte {name} nicht dekodieren: {e}."))
|
.map_err(|e| t!("Konnte {name} nicht dekodieren: {e}.", name = name, e = e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lädt `SHA256SUMS.txt` samt Minisign-Signatur und gibt den Inhalt nur bei gültiger
|
/// Lädt `SHA256SUMS.txt` samt Minisign-Signatur und gibt den Inhalt nur bei gültiger
|
||||||
@@ -429,9 +470,7 @@ fn ensure_secure_scheme(url: &url::Url, allow_insecure_http: bool) -> Result<(),
|
|||||||
match url.scheme() {
|
match url.scheme() {
|
||||||
"https" => Ok(()),
|
"https" => Ok(()),
|
||||||
"http" if allow_insecure_http => Ok(()),
|
"http" if allow_insecure_http => Ok(()),
|
||||||
scheme => Err(format!(
|
scheme => Err(t!("Unsicheres Protokoll '{scheme}' für '{url}' abgewiesen. Es wird zwingend HTTPS verlangt.", scheme = scheme, url = url)),
|
||||||
"Unsicheres Protokoll '{scheme}' für '{url}' abgewiesen. Es wird zwingend HTTPS verlangt."
|
|
||||||
)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,8 +492,13 @@ fn http_get_with_auth_guard(
|
|||||||
max_redirects: usize,
|
max_redirects: usize,
|
||||||
allow_insecure_http: bool,
|
allow_insecure_http: bool,
|
||||||
) -> Result<ureq::Response, String> {
|
) -> Result<ureq::Response, String> {
|
||||||
let mut current_url = url::Url::parse(initial_url_str)
|
let mut current_url = url::Url::parse(initial_url_str).map_err(|e| {
|
||||||
.map_err(|e| format!("Ungültige URL '{initial_url_str}': {e}"))?;
|
t!(
|
||||||
|
"Ungültige URL '{initial_url_str}': {e}",
|
||||||
|
initial_url_str = initial_url_str,
|
||||||
|
e = e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
let initial_origin = current_url.origin().ascii_serialization();
|
let initial_origin = current_url.origin().ascii_serialization();
|
||||||
|
|
||||||
for _ in 0..=max_redirects {
|
for _ in 0..=max_redirects {
|
||||||
@@ -476,24 +520,35 @@ fn http_get_with_auth_guard(
|
|||||||
Ok(resp) => return Ok(resp),
|
Ok(resp) => return Ok(resp),
|
||||||
Err(ureq::Error::Status(code, resp)) if (301..=308).contains(&code) => {
|
Err(ureq::Error::Status(code, resp)) if (301..=308).contains(&code) => {
|
||||||
let loc = resp.header("Location").ok_or_else(|| {
|
let loc = resp.header("Location").ok_or_else(|| {
|
||||||
format!("HTTP {code} Weiterleitung ohne Location-Header erhalten")
|
t!(
|
||||||
|
"HTTP {code} Weiterleitung ohne Location-Header erhalten",
|
||||||
|
code = code
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
current_url = current_url.join(loc).map_err(|e| {
|
||||||
|
t!(
|
||||||
|
"Ungültiges Weiterleitungsziel '{loc}': {e}",
|
||||||
|
loc = loc,
|
||||||
|
e = e
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
current_url = current_url
|
|
||||||
.join(loc)
|
|
||||||
.map_err(|e| format!("Ungültiges Weiterleitungsziel '{loc}': {e}"))?;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Err(ureq::Error::Status(code, resp)) => {
|
Err(ureq::Error::Status(code, resp)) => {
|
||||||
let body = resp.into_string().unwrap_or_default();
|
let body = resp.into_string().unwrap_or_default();
|
||||||
return Err(format!("Gitea-Server antwortete mit HTTP {code}: {body}"));
|
return Err(t!(
|
||||||
|
"Gitea-Server antwortete mit HTTP {code}: {body}",
|
||||||
|
code = code,
|
||||||
|
body = body
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Err(ureq::Error::Transport(t)) => {
|
Err(ureq::Error::Transport(t)) => {
|
||||||
return Err(format!("Netzwerk-Transportfehler: {t}"));
|
return Err(t!("Netzwerk-Transportfehler: {t}", t = t));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err("Zu viele Weiterleitungen (Redirect-Schleife abgebrochen).".to_string())
|
Err(t!("Zu viele Weiterleitungen (Redirect-Schleife abgebrochen).").to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Asynchroner Controller für Update-Prüfung und Asset-Download.
|
/// Asynchroner Controller für Update-Prüfung und Asset-Download.
|
||||||
@@ -544,6 +599,9 @@ impl UpdaterController {
|
|||||||
|
|
||||||
/// Fragt im Hintergrund die Gitea-API ab.
|
/// Fragt im Hintergrund die Gitea-API ab.
|
||||||
pub fn check_for_updates(&mut self) {
|
pub fn check_for_updates(&mut self) {
|
||||||
|
if !self.runtime.self_update_allowed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if matches!(
|
if matches!(
|
||||||
self.status,
|
self.status,
|
||||||
UpdaterStatus::Checking | UpdaterStatus::Downloading { .. }
|
UpdaterStatus::Checking | UpdaterStatus::Downloading { .. }
|
||||||
@@ -592,8 +650,9 @@ impl UpdaterController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx.send(UpdaterEvent::CheckError(format!(
|
let _ = tx.send(UpdaterEvent::CheckError(t!(
|
||||||
"Gitea Release-JSON konnte nicht verarbeitet werden: {e}"
|
"Gitea Release-JSON konnte nicht verarbeitet werden: {e}",
|
||||||
|
e = e
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -649,15 +708,16 @@ impl UpdaterController {
|
|||||||
) {
|
) {
|
||||||
Ok(text) => text,
|
Ok(text) => text,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx.send(UpdaterEvent::DownloadError(format!(
|
let _ = tx.send(UpdaterEvent::DownloadError(t!(
|
||||||
"{e} Download abgelehnt."
|
"{e} Download abgelehnt.",
|
||||||
|
e = e
|
||||||
)));
|
)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
None => {
|
None => {
|
||||||
let _ = tx.send(UpdaterEvent::DownloadError(
|
let _ = tx.send(UpdaterEvent::DownloadError(
|
||||||
"Kein Release ausgewählt. Download abgelehnt.".to_string(),
|
t!("Kein Release ausgewählt. Download abgelehnt.").to_string(),
|
||||||
));
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -673,8 +733,9 @@ impl UpdaterController {
|
|||||||
) {
|
) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx.send(UpdaterEvent::DownloadError(format!(
|
let _ = tx.send(UpdaterEvent::DownloadError(t!(
|
||||||
"Download-Verbindungsfehler: {e}"
|
"Download-Verbindungsfehler: {e}",
|
||||||
|
e = e
|
||||||
)));
|
)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -686,9 +747,10 @@ impl UpdaterController {
|
|||||||
let mut file = match std::fs::File::create(&target_file) {
|
let mut file = match std::fs::File::create(&target_file) {
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx.send(UpdaterEvent::DownloadError(format!(
|
let _ = tx.send(UpdaterEvent::DownloadError(t!(
|
||||||
"Konnte Zieldatei '{}' nicht erstellen: {e}",
|
"Konnte Zieldatei '{path}' nicht erstellen: {e}",
|
||||||
target_file.display()
|
path = target_file.display(),
|
||||||
|
e = e
|
||||||
)));
|
)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -705,8 +767,9 @@ impl UpdaterController {
|
|||||||
Ok(count) => count,
|
Ok(count) => count,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = std::fs::remove_file(&target_file);
|
let _ = std::fs::remove_file(&target_file);
|
||||||
let _ = tx.send(UpdaterEvent::DownloadError(format!(
|
let _ = tx.send(UpdaterEvent::DownloadError(t!(
|
||||||
"Fehler beim Lesen des Download-Streams: {e}"
|
"Fehler beim Lesen des Download-Streams: {e}",
|
||||||
|
e = e
|
||||||
)));
|
)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -714,8 +777,9 @@ impl UpdaterController {
|
|||||||
|
|
||||||
if let Err(e) = file.write_all(&buffer[..n]) {
|
if let Err(e) = file.write_all(&buffer[..n]) {
|
||||||
let _ = std::fs::remove_file(&target_file);
|
let _ = std::fs::remove_file(&target_file);
|
||||||
let _ = tx.send(UpdaterEvent::DownloadError(format!(
|
let _ = tx.send(UpdaterEvent::DownloadError(t!(
|
||||||
"Fehler beim Schreiben auf die Festplatte: {e}"
|
"Fehler beim Schreiben auf die Festplatte: {e}",
|
||||||
|
e = e
|
||||||
)));
|
)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -776,18 +840,19 @@ impl UpdaterController {
|
|||||||
/// Startet die sichere Installation des heruntergeladenen Pakets oder öffnet es im System.
|
/// Startet die sichere Installation des heruntergeladenen Pakets oder öffnet es im System.
|
||||||
pub fn install_or_open(&self, file_path: &Path) -> Result<(), String> {
|
pub fn install_or_open(&self, file_path: &Path) -> Result<(), String> {
|
||||||
if !file_path.exists() {
|
if !file_path.exists() {
|
||||||
return Err("Die heruntergeladene Datei existiert nicht mehr.".to_string());
|
return Err(t!("Die heruntergeladene Datei existiert nicht mehr.").to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
match &self.runtime {
|
match &self.runtime {
|
||||||
RuntimeEnvironment::AppImage { appimage_path } => {
|
RuntimeEnvironment::AppImage { appimage_path } => {
|
||||||
let parent_dir = appimage_path.parent().ok_or_else(|| {
|
let parent_dir = appimage_path.parent().ok_or_else(|| {
|
||||||
"Konnte übergeordnetes Verzeichnis des AppImages nicht ermitteln.".to_string()
|
t!("Konnte übergeordnetes Verzeichnis des AppImages nicht ermitteln.")
|
||||||
|
.to_string()
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let file_name = appimage_path
|
let file_name = appimage_path
|
||||||
.file_name()
|
.file_name()
|
||||||
.ok_or_else(|| "Ungültiger AppImage-Dateiname.".to_string())?
|
.ok_or_else(|| t!("Ungültiger AppImage-Dateiname.").to_string())?
|
||||||
.to_string_lossy();
|
.to_string_lossy();
|
||||||
|
|
||||||
let temp_new_file =
|
let temp_new_file =
|
||||||
@@ -797,8 +862,9 @@ impl UpdaterController {
|
|||||||
// 1. Neue Datei im SELBEN Verzeichnis anlegen (garantiert atomares rename)
|
// 1. Neue Datei im SELBEN Verzeichnis anlegen (garantiert atomares rename)
|
||||||
if let Err(e) = std::fs::copy(file_path, &temp_new_file) {
|
if let Err(e) = std::fs::copy(file_path, &temp_new_file) {
|
||||||
let _ = std::fs::remove_file(&temp_new_file);
|
let _ = std::fs::remove_file(&temp_new_file);
|
||||||
return Err(format!(
|
return Err(t!(
|
||||||
"Konnte temporäre Update-Datei nicht schreiben: {e}"
|
"Konnte temporäre Update-Datei nicht schreiben: {e}",
|
||||||
|
e = e
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,18 +882,17 @@ impl UpdaterController {
|
|||||||
// 3. Backup des alten AppImages anlegen – Fehler NIEMALS ignorieren!
|
// 3. Backup des alten AppImages anlegen – Fehler NIEMALS ignorieren!
|
||||||
if let Err(e) = std::fs::copy(appimage_path, &backup_file) {
|
if let Err(e) = std::fs::copy(appimage_path, &backup_file) {
|
||||||
let _ = std::fs::remove_file(&temp_new_file);
|
let _ = std::fs::remove_file(&temp_new_file);
|
||||||
return Err(format!(
|
return Err(t!(
|
||||||
"Konnte Backup ({}) nicht anlegen: {e}. Update abgebrochen, altes AppImage unverändert.",
|
"Konnte Backup ({path}) nicht anlegen: {e}. Update abgebrochen, altes AppImage unverändert.",
|
||||||
backup_file.display()
|
path = backup_file.display(),
|
||||||
|
e = e
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Atomar ersetzen via rename
|
// 4. Atomar ersetzen via rename
|
||||||
if let Err(e) = std::fs::rename(&temp_new_file, appimage_path) {
|
if let Err(e) = std::fs::rename(&temp_new_file, appimage_path) {
|
||||||
let _ = std::fs::remove_file(&temp_new_file);
|
let _ = std::fs::remove_file(&temp_new_file);
|
||||||
return Err(format!(
|
return Err(t!("Konnte AppImage nicht atomar ersetzen: {e}. Altes AppImage bleibt unverändert.", e = e));
|
||||||
"Konnte AppImage nicht atomar ersetzen: {e}. Altes AppImage bleibt unverändert."
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heruntergeladene Cache-Datei aufräumen
|
// Heruntergeladene Cache-Datei aufräumen
|
||||||
@@ -837,7 +902,7 @@ impl UpdaterController {
|
|||||||
RuntimeEnvironment::DebPackage { .. } => {
|
RuntimeEnvironment::DebPackage { .. } => {
|
||||||
match Command::new("xdg-open").arg(file_path).spawn() {
|
match Command::new("xdg-open").arg(file_path).spawn() {
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
Err(e) => Err(format!("Konnte xdg-open nicht ausführen: {e}")),
|
Err(e) => Err(t!("Konnte xdg-open nicht ausführen: {e}", e = e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RuntimeEnvironment::PortableOrDev { .. } => {
|
RuntimeEnvironment::PortableOrDev { .. } => {
|
||||||
@@ -846,6 +911,10 @@ impl UpdaterController {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
RuntimeEnvironment::Flatpak => Err(t!(
|
||||||
|
"Die Flatpak-Version wird über die Software-Verwaltung aktualisiert."
|
||||||
|
)
|
||||||
|
.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1033,6 +1102,19 @@ dcdeb7ac87d9e6cb8022e5a71bc7f2c56d779b4fce4efdd5bae5be3b0214884a ink-notes_0.1.
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_flatpak_detection_disables_self_update() {
|
||||||
|
assert!(is_flatpak(Some("eu.pansi.ink-notes"), false));
|
||||||
|
assert!(is_flatpak(None, true));
|
||||||
|
assert!(!is_flatpak(Some(" "), false));
|
||||||
|
assert!(!is_flatpak(None, false));
|
||||||
|
assert!(!RuntimeEnvironment::Flatpak.self_update_allowed());
|
||||||
|
assert!(
|
||||||
|
RuntimeEnvironment::detect_from(None, Path::new("/usr/bin/ink-notes"))
|
||||||
|
.self_update_allowed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_https_enforcement() {
|
fn test_https_enforcement() {
|
||||||
let config = UpdaterConfig {
|
let config = UpdaterConfig {
|
||||||
|
|||||||
+18
-19
@@ -3,20 +3,18 @@
|
|||||||
//! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel),
|
//! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel),
|
||||||
//! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]).
|
//! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]).
|
||||||
|
|
||||||
use crate::document::{PageViewport, UNITS_PER_MM};
|
use crate::document::PageViewport;
|
||||||
use glam::{Mat4, Vec2, Vec4};
|
use glam::{Mat4, Vec2, Vec4};
|
||||||
|
|
||||||
/// Minimaler und maximaler Zoomfaktor.
|
/// Minimaler und maximaler Zoomfaktor.
|
||||||
pub const MIN_ZOOM: f32 = 0.25;
|
pub const MIN_ZOOM: f32 = 0.25;
|
||||||
pub const MAX_ZOOM: f32 = 10.0;
|
pub const MAX_ZOOM: f32 = 10.0;
|
||||||
|
|
||||||
/// Abstand zwischen zwei Seiten in der durchgehenden Ansicht (8 mm).
|
|
||||||
pub const PAGE_GAP: f32 = 8.0 * UNITS_PER_MM;
|
|
||||||
/// Höchstens so viele Nachbarseiten werden gleichzeitig dargestellt.
|
/// Höchstens so viele Nachbarseiten werden gleichzeitig dargestellt.
|
||||||
pub const MAX_NEIGHBOR_PAGES: usize = 12;
|
pub const MAX_NEIGHBOR_PAGES: usize = 12;
|
||||||
|
|
||||||
/// Durchgehende Ansicht: Die Seiten hängen mit [`PAGE_GAP`] Abstand untereinander und sind
|
/// Durchgehende Ansicht: Die Seiten hängen lückenlos untereinander (die Grenze markiert eine
|
||||||
/// waagerecht zueinander zentriert. Lage jeder Seite (Ecke oben links) in Koordinaten der
|
/// Linie der Oberfläche) und sind waagerecht zueinander zentriert. Lage jeder Seite (Ecke oben links) in Koordinaten der
|
||||||
/// aktuellen Seite.
|
/// aktuellen Seite.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct PageStack {
|
pub struct PageStack {
|
||||||
@@ -33,11 +31,11 @@ impl PageStack {
|
|||||||
let mut origins = vec![Vec2::ZERO; sizes.len()];
|
let mut origins = vec![Vec2::ZERO; sizes.len()];
|
||||||
let x = |i: usize| (width - sizes[i].x) / 2.0;
|
let x = |i: usize| (width - sizes[i].x) / 2.0;
|
||||||
for i in current + 1..sizes.len() {
|
for i in current + 1..sizes.len() {
|
||||||
let y = origins[i - 1].y + sizes[i - 1].y + PAGE_GAP;
|
let y = origins[i - 1].y + sizes[i - 1].y;
|
||||||
origins[i] = Vec2::new(x(i), y);
|
origins[i] = Vec2::new(x(i), y);
|
||||||
}
|
}
|
||||||
for i in (0..current).rev() {
|
for i in (0..current).rev() {
|
||||||
let y = origins[i + 1].y - PAGE_GAP - sizes[i].y;
|
let y = origins[i + 1].y - sizes[i].y;
|
||||||
origins[i] = Vec2::new(x(i), y);
|
origins[i] = Vec2::new(x(i), y);
|
||||||
}
|
}
|
||||||
Self {
|
Self {
|
||||||
@@ -55,7 +53,7 @@ impl PageStack {
|
|||||||
self.sizes.is_empty()
|
self.sizes.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seite, auf deren Höhe `y` liegt; `None` im Zwischenraum oder außerhalb.
|
/// Seite, auf deren Höhe `y` liegt (an der Grenze die untere); `None` außerhalb.
|
||||||
pub fn page_at(&self, y: f32) -> Option<usize> {
|
pub fn page_at(&self, y: f32) -> Option<usize> {
|
||||||
// Seiten liegen nach Index sortiert untereinander
|
// Seiten liegen nach Index sortiert untereinander
|
||||||
let i = self.origins.partition_point(|o| o.y <= y).checked_sub(1)?;
|
let i = self.origins.partition_point(|o| o.y <= y).checked_sub(1)?;
|
||||||
@@ -312,7 +310,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
const A4: Vec2 = crate::document::A4_SIZE;
|
const A4: Vec2 = crate::document::A4_SIZE;
|
||||||
const PITCH: f32 = A4.y + PAGE_GAP;
|
const PITCH: f32 = A4.y;
|
||||||
|
|
||||||
fn a4_stack(count: usize, current: usize) -> PageStack {
|
fn a4_stack(count: usize, current: usize) -> PageStack {
|
||||||
PageStack::new(vec![A4; count], current)
|
PageStack::new(vec![A4; count], current)
|
||||||
@@ -322,10 +320,11 @@ mod tests {
|
|||||||
fn test_continuous_layout() {
|
fn test_continuous_layout() {
|
||||||
let stack = a4_stack(5, 1);
|
let stack = a4_stack(5, 1);
|
||||||
assert_eq!(stack.page_at(100.0), Some(1));
|
assert_eq!(stack.page_at(100.0), Some(1));
|
||||||
assert_eq!(stack.page_at(A4.y + PAGE_GAP / 2.0), None);
|
// Lückenlos: direkt unter dem Seitenende beginnt die nächste Seite
|
||||||
|
assert_eq!(stack.page_at(A4.y - 1.0), Some(1));
|
||||||
|
assert_eq!(stack.page_at(A4.y + 1.0), Some(2));
|
||||||
assert_eq!(stack.page_at(PITCH + 5.0), Some(2));
|
assert_eq!(stack.page_at(PITCH + 5.0), Some(2));
|
||||||
assert_eq!(stack.page_at(-5.0), None);
|
assert_eq!(stack.page_at(-5.0), Some(0));
|
||||||
assert_eq!(stack.page_at(-PAGE_GAP - 5.0), Some(0));
|
|
||||||
assert_eq!(stack.page_at(-PITCH - 5.0), None, "vor der ersten Seite");
|
assert_eq!(stack.page_at(-PITCH - 5.0), None, "vor der ersten Seite");
|
||||||
|
|
||||||
// Ende von Seite 2 und Anfang von Seite 3 sichtbar (aktuell: Seite 2 von 5)
|
// Ende von Seite 2 und Anfang von Seite 3 sichtbar (aktuell: Seite 2 von 5)
|
||||||
@@ -339,7 +338,7 @@ mod tests {
|
|||||||
.is_empty());
|
.is_empty());
|
||||||
// Hinter der letzten Seite: keine weiteren
|
// Hinter der letzten Seite: keine weiteren
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
a4_stack(5, 3).visible_neighbors(-PITCH, PITCH * 4.0),
|
a4_stack(5, 3).visible_neighbors(-PITCH + 1.0, PITCH * 4.0),
|
||||||
vec![2, 4]
|
vec![2, 4]
|
||||||
);
|
);
|
||||||
// Weit herausgezoomt: auf die nächstgelegenen Seiten begrenzt
|
// Weit herausgezoomt: auf die nächstgelegenen Seiten begrenzt
|
||||||
@@ -352,13 +351,13 @@ mod tests {
|
|||||||
fn test_stack_of_different_page_sizes() {
|
fn test_stack_of_different_page_sizes() {
|
||||||
let slide = Vec2::new(2970.0, 1671.0);
|
let slide = Vec2::new(2970.0, 1671.0);
|
||||||
let stack = PageStack::new(vec![slide, A4, slide], 1);
|
let stack = PageStack::new(vec![slide, A4, slide], 1);
|
||||||
// Folien waagerecht zur A4-Seite zentriert, mit Abstand darüber und darunter
|
// Folien waagerecht zur A4-Seite zentriert, direkt darüber und darunter
|
||||||
assert_eq!(stack.origins[0], Vec2::new(-435.0, -PAGE_GAP - slide.y));
|
assert_eq!(stack.origins[0], Vec2::new(-435.0, -slide.y));
|
||||||
assert_eq!(stack.origins[2], Vec2::new(-435.0, A4.y + PAGE_GAP));
|
assert_eq!(stack.origins[2], Vec2::new(-435.0, A4.y));
|
||||||
assert_eq!(stack.page_at(A4.y + PAGE_GAP + 10.0), Some(2));
|
assert_eq!(stack.page_at(A4.y + 10.0), Some(2));
|
||||||
let (min, max) = stack.bounds();
|
let (min, max) = stack.bounds();
|
||||||
assert_eq!(min, Vec2::new(-435.0, -PAGE_GAP - slide.y));
|
assert_eq!(min, Vec2::new(-435.0, -slide.y));
|
||||||
assert_eq!(max, Vec2::new(2535.0, A4.y + PAGE_GAP + slide.y));
|
assert_eq!(max, Vec2::new(2535.0, A4.y + slide.y));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user