Compare commits

...
14 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 41ee32e1f7 chore(release): bump version to 0.37.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m51s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:27:44 +01:00
haraldandClaude Opus 5.5 397bf3bfe4 feat: import Saber notebooks (Issue #47)
The import now also accepts Saber files: .sbn2 (BSON) with its asset
files next to it, .sba archives (zip with main.sbn2 and assets) and the
old JSON .sbn, including the layout before format version 8 with
strokes outside the pages. Taken over: strokes with pen pressure and
perfect_freehand's thinning, highlighters, circle and rectangle shapes,
typed text (Quill delta as one text field), images (SVG rasterized),
background images and PDF pages as PDF backgrounds, paper patterns.

BSON and zip are read by small built-in readers, so no new
dependencies. The SVG rasterizer moved to src/import.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:25:29 +01:00
haraldandClaude Opus 5.5 ca6f64ec8e chore(release): bump version to 0.36.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 10m35s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:56:16 +01:00
haraldandClaude Opus 5.5 46026eb8e5 feat: import Rnote notebooks (Issue #47)
The import (menu entry and drag & drop) now also accepts .rnote files
from Rnote 0.5.10 on, including the transformation and document config
layouts that changed in 0.13 and 0.15. Taken over: brush strokes with
their pressure curve, shapes as outlines, texts, bitmap images and SVG
images, which are rasterized with resvg (Rnote stores imported PDF
pages as SVG). Fixed-size and continuous documents keep their pages;
infinite ones become an endless notebook.

The parts shared with the Xournal++ import moved to src/import.rs.
resvg and its dependencies were added to the Flatpak cargo sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:40:51 +01:00
haraldandClaude Opus 5.5 2bd697ef82 feat: import Xournal++ and Xournal notebooks (Issue #47)
The import menu entry (formerly "Import PDF") and drag & drop now also
accept .xopp and .xoj files. Each Xournal page becomes a page of a new
notebook: strokes with pen pressure, highlighters, texts, embedded
images, lined/squared/dotted paper, image backgrounds and PDF
backgrounds (looked up at the stored path and next to the file). All
layers are flattened in order; LaTeX formulas are skipped and counted
in the status message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:10:34 +01:00
haraldandClaude Opus 5.5 b5d9d72e79 chore(release): bump version to 0.35.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:52:15 +01:00
haraldandClaude Opus 5.5 955eaa4095 perf: simplify stroke outlines before PDF export
Each stroke mesh has about 16 points per pen sample, and every one of them
went into the PDF as a polygon point: a full handwritten page (1650 strokes)
took 2.9 s to export, and PNG export and copying a selection as an image
took as long, because both go through the PDF. The outline is now simplified
with a closed Douglas-Peucker that measures against segments, at most 0.01 mm
from the drawn outline. The same page now takes 0.44 s (PNG 0.55 s), the PDF
shrinks from 10.7 MB to 1.4 MB, and the rendered PNG differs only in edge
anti-aliasing (max 21/255). INK_NOTES_EXACT_OUTLINES=1 exports every point
as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:45:17 +01:00
haraldandClaude Opus 5.5 330c29193f chore(release): bump version to 0.34.2
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:10:23 +01:00
haraldandClaude Opus 5.5 dd515787ec fix: measure the swipe speed with the compositor's touch event times (Issue #28)
Pia's log shows the lift arriving 200-300 ms after the last move on every
swipe, so the release looked like a resting finger and nothing glided. The
vendored winit now keeps the Wayland timestamp of each touch event
(take_touch_time), and the fling measures with those times instead of when
the app got around to handling the events. The diagnostics log both gaps and
any event handling that blocks the app for more than 40 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:08:13 +01:00
haraldandClaude Opus 5.5 b11fb2d762 refactor: move the event loop into the library as app::run (prep for Issue #42)
src/main.rs is now only the desktop entry point; the app itself lives in
src/app.rs, so other entry points such as android_main can start it. The code
is unchanged apart from crate:: paths; the source scans in the i18n and UI
tests and the docs point to app.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 14:02:43 +01:00
haraldandClaude Opus 5.5 e8a7675f8d chore(release): bump version to 0.34.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m39s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:17:21 +01:00
haraldandClaude Opus 5.5 0992b9bc0e fix: keep the measured swipe when the view touches the notebook edge (Issue #28)
Clamping the continuous or endless view reset the fling while the finger was
still down, so the release measured 0 px/s. Only an active glide stops there
now. The fling diagnostics also log moves, path length and the time since
the last move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:15:32 +01:00
haraldandClaude Opus 5.5 78554c77ee chore(release): bump version to 0.34.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 11m22s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:50:58 +02:00
haraldandClaude Opus 5.5 1e34c01ad2 fix: eraser size menu, pointed shape corners, flat tiles for slides (Issues #38, #40, #41, #28)
- #38: the eraser size button opens a menu like the pens: four presets and a slider
  (2–30 mm diameter)
- #41: shape corners stay pointed down to about 14° (miter limit 8 instead of 2)
- #40: page picker tiles are as high as the tallest page needs, so slides get flat tiles;
  the frame follows the page instead of the whole tile
- #28: the fling diagnosis also logs finger starts, mouse buttons and wheel/touchpad events

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:46:49 +02:00
28 changed files with 8101 additions and 4374 deletions

No files matched your search

Generated
+134 -3
View File
@@ -539,6 +539,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "deranged"
version = "0.5.8"
@@ -790,6 +796,12 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "float-cmp"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -1381,6 +1393,12 @@ dependencies = [
"quick-error",
]
[[package]]
name = "imagesize"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a"
[[package]]
name = "indexmap"
version = "2.14.2"
@@ -1393,14 +1411,16 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.33.0"
version = "0.37.0"
dependencies = [
"ab_glyph",
"arboard",
"base64",
"bytemuck",
"egui",
"egui-wgpu",
"egui-winit",
"flate2",
"fs2",
"glam",
"hayro",
@@ -1411,6 +1431,8 @@ dependencies = [
"octotablet",
"pollster",
"printpdf",
"quick-xml",
"resvg",
"semver",
"serde",
"serde_json",
@@ -2079,6 +2101,12 @@ dependencies = [
"pxfm",
]
[[package]]
name = "pico-args"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -2326,6 +2354,33 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "resvg"
version = "0.49.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1572844b6727a1bf842190e9947641a68ac44bed83f50afb4bd1407a2564bee8"
dependencies = [
"bytemuck",
"gif",
"image-webp",
"log",
"pico-args",
"rgb",
"svgtypes",
"tiny-skia 0.12.0",
"usvg",
"zune-jpeg",
]
[[package]]
name = "rgb"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
dependencies = [
"bytemuck",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -2340,6 +2395,15 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "roxmltree"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
dependencies = [
"memchr",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -2450,7 +2514,7 @@ dependencies = [
"log",
"memmap2",
"smithay-client-toolkit",
"tiny-skia",
"tiny-skia 0.11.4",
]
[[package]]
@@ -2531,6 +2595,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "simplecss"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
dependencies = [
"log",
]
[[package]]
name = "siphasher"
version = "1.0.4"
@@ -2638,6 +2711,9 @@ name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
dependencies = [
"float-cmp",
]
[[package]]
name = "strum"
@@ -2667,6 +2743,16 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "svgtypes"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d"
dependencies = [
"kurbo",
"siphasher",
]
[[package]]
name = "syn"
version = "1.0.109"
@@ -2828,7 +2914,22 @@ dependencies = [
"bytemuck",
"cfg-if",
"log",
"tiny-skia-path",
"tiny-skia-path 0.11.4",
]
[[package]]
name = "tiny-skia"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"png",
"tiny-skia-path 0.12.0",
]
[[package]]
@@ -2842,6 +2943,17 @@ dependencies = [
"strict-num",
]
[[package]]
name = "tiny-skia-path"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]]
name = "tinystr"
version = "0.8.4"
@@ -2978,6 +3090,25 @@ dependencies = [
"serde",
]
[[package]]
name = "usvg"
version = "0.49.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d09b73ee83641d390ad7ffd9000f347b16eb128e437675b6ff277ea5c7fcf22c"
dependencies = [
"data-url",
"imagesize",
"kurbo",
"log",
"pico-args",
"roxmltree",
"simplecss",
"siphasher",
"strict-num",
"svgtypes",
"tiny-skia-path 0.12.0",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
+8 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ink-notes"
version = "0.33.0"
version = "0.37.0"
edition = "2021"
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
@@ -48,6 +48,13 @@ fs2 = "0.4"
hayro = "0.8"
hayro-svg = "0.8"
# Import von Xournal++-Notizbüchern (gzip-komprimiertes XML mit base64-Bildern)
flate2 = "1.1"
quick-xml = "0.41"
base64 = "0.22"
# Rnote-Import: SVG-Bilder (z. B. PDF-Seiten) rastern
resvg = { version = "0.49", default-features = false, features = ["raster-images"] }
# Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui)
ab_glyph = "0.2"
+13 -5
View File
@@ -11,7 +11,7 @@
---
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 sowie Notizbücher aus Xournal++, Rnote und Saber 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).
@@ -27,7 +27,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
- **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 und werden rund verbunden, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund.
- **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
- **Tasten am Stift** im ☰-Menü belegen: Radieren (Standard), Auswählen, Textmarker, Stift oder nichts – gilt, solange die Taste gedrückt ist. Jede Taste erscheint dort nach dem ersten Drücken.
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker, in vier Größen (wird gemerkt). Die Stiftrückseite radiert ebenfalls.
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker; die Größe stellt man in einem Menü wie bei den Stiften ein (wird gemerkt). Die Stiftrückseite radiert ebenfalls.
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
- **Nur ansehen** (👁 in der Werkzeugleiste): Stift, Finger und Maus scrollen und zoomen nur; die Werkzeuge verschwinden, nichts lässt sich versehentlich ändern. Erneut 👁 tippen, um zu schreiben.
- **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.
@@ -47,7 +47,10 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
- **Text-Werkzeug** (`T`): ins Blatt tippen und schreiben – normale oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8–36 pt, Zeilenumbruch am Feldrand. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren. In anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
- **Bilder** (PNG, JPEG, GIF, WebP, BMP) über `Strg + I`, aus der Zwischenablage oder per Ziehen ins Fenster einfügen.
- **Einfügen per Finger:** Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle.
- **PDF importieren** (☰-Menü): Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert.
- **Importieren** (☰-Menü oder Datei ins Fenster ziehen) – **PDF**: 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.
**Xournal++** (`.xopp`, auch `.xoj` aus Xournal): Striche mit Stiftdruck, Textmarker, Texte, Bilder, liniertes/kariertes/gepunktetes Papier und PDF-Hintergründe werden übernommen (das PDF wird am alten Ort und neben der Datei gesucht). LaTeX-Formeln werden übersprungen.
**Rnote** (`.rnote`, ab Rnote 0.5.10): Striche mit Stiftdruck, Formen, Texte und Bilder; in Rnote importierte PDF-Seiten werden zu Bildern. Seiten bleiben Seiten, unendliche Dokumente werden ein endloses Notizbuch.
**Saber** (`.sbn2` mit den Asset-Dateien `.sbn2.0`, `.sbn2.1`, … daneben, `.sba`-Archive, altes `.sbn`): Striche mit Stiftdruck, Textmarker, Formen, getippter Text, Bilder und PDF-Hintergründe.
### 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.
@@ -152,8 +155,9 @@ Die CI prüft zusätzlich `cargo fmt --check` und `cargo clippy --all-targets --
```text
src/
├── main.rs # Event-Loop (winit), Eingaben von Maus, Touch und Stift, Ablaufsteuerung
├── lib.rs # Bibliotheks-Exporte (für Tests)
├── main.rs # Einstieg für den Desktop, startet app::run
├── app.rs # Event-Loop (winit), Eingaben von Maus, Touch und Stift, Ablaufsteuerung
├── lib.rs # Bibliotheks-Exporte (App und Module, auch für Tests)
├── canvas.rs # wgpu-Rendering: Papier, Muster, Bilder, Tinte, Nachbarseiten
├── shader.wgsl # Vertex- und Fragment-Shader
├── viewport.rs # Kamera (Zoom, Verschieben), Seitenstapel der durchgehenden Ansicht
@@ -168,6 +172,10 @@ src/
├── assets.rs # Bilder und PDFs im Notizbuchordner
├── pdf_doc.rs # PDF lesen und rendern (hayro)
├── pdf_merge.rs # Importierte PDF-Seiten beim Export unterlegen
├── import.rs # Import als neues Notizbuch (Gemeinsames)
├── xopp.rs # Import von Xournal++-/Xournal-Notizbüchern
├── rnote.rs # Import von Rnote-Notizbüchern
├── saber.rs # Import von Saber-Notizbüchern
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
├── history.rs # Rückgängig/Wiederholen
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
+13 -5
View File
@@ -11,7 +11,7 @@
---
ink-notes stores handwriting as vectors, can recognize shapes, imports PDFs for annotation, inserts images and text boxes, and exports everything again as PDF, PNG or SVG. Saving happens automatically in the background.
ink-notes stores handwriting as vectors, can recognize shapes, imports PDFs for annotation as well as Xournal++, Rnote and Saber notebooks, inserts images and text boxes, and exports everything again as PDF, PNG or SVG. Saving happens automatically in the background.
It is mainly tested on Zorin OS 18 / Ubuntu 24.04 (Wayland and X11).
@@ -27,7 +27,7 @@ The interface is available in English and German; ink-notes follows the system l
- **Two-stage smoothing:** While you write, a 1€ filter removes jitter and the line appears immediately. When you lift the pen, the stroke is refined: corners (bends over 65°) are kept and drawn round, the pressure curve is smoothed, redundant points are dropped and the ends become round.
- **Pressure curve** adjustable to your pen in the ☰ menu (softer/harder, threshold against base noise).
- **Pen buttons** can be assigned in the ☰ menu: erase (default), select, highlighter, pen or nothing – active while the button is held. Each button appears there after it is pressed for the first time.
- **Eraser** with three modes: whole strokes, only the part you wipe over (partial stroke), or highlighter only, in four sizes (remembered). The back end of the pen erases too.
- **Eraser** with three modes: whole strokes, only the part you wipe over (partial stroke), or highlighter only; its size is set in a menu like the pens (remembered). The back end of the pen erases too.
- **Virtual ruler** (`L`): move it, rotate it by its handle (snaps to 15°); strokes along an edge become straight.
- **View only** (👁 in the toolbar): pen, finger and mouse only scroll and zoom; the tools disappear and nothing can be changed by accident. Tap 👁 again to write.
- **Make space** (↕ in the toolbar): tap the page and drag down – everything below moves along, also on the following pages. Whatever would slide past the end of a page continues on the next one; missing pages are appended. Dragging up closes empty space. Undo reverts it in one step.
@@ -47,7 +47,10 @@ The interface is available in English and German; ink-notes follows the system l
- **Text tool** (`T`): tap the page and type – regular or code font (fixed character width, Tab indents), 8–36 pt, line wrapping at the box edge. Tap existing text to change it, `Esc` finishes. Text boxes can be moved, rotated and scaled like images. Text copied in other programs becomes a text box with `Ctrl + V`.
- **Images** (PNG, JPEG, GIF, WebP, BMP) via `Ctrl + I`, from the clipboard, or by dragging them into the window.
- **Paste with a finger:** Pressing the page with a finger for half a second opens a "Paste" menu – a screenshot, copied text or copied objects land exactly at that spot.
- **Import PDF** (☰ menu): Every PDF page becomes a notebook page with the PDF page as background. Portrait pages become A4; landscape pages (e.g. slides) get a landscape page without an empty strip below. When you zoom in, PDF pages are re-rendered more sharply.
- **Import** (☰ menu, or drag the file into the window) – **PDF**: Every PDF page becomes a notebook page with the PDF page as background. Portrait pages become A4; landscape pages (e.g. slides) get a landscape page without an empty strip below. When you zoom in, PDF pages are re-rendered more sharply.
**Xournal++** (`.xopp`, also `.xoj` from Xournal): strokes with pen pressure, highlighters, texts, images, lined/squared/dotted paper and PDF backgrounds are taken over (the PDF is looked for at its old place and next to the file). LaTeX formulas are skipped.
**Rnote** (`.rnote`, from Rnote 0.5.10): strokes with pen pressure, shapes, texts and images; PDF pages imported in Rnote become images. Pages stay pages, infinite documents become an endless notebook.
**Saber** (`.sbn2` with its asset files `.sbn2.0`, `.sbn2.1`, … next to it, `.sba` archives, old `.sbn`): strokes with pen pressure, highlighters, shapes, typed text, images and PDF backgrounds.
### Pages and view
- **Backgrounds:** blank, squared (5 mm), ruled (7.5 mm), dot grid (5 mm). The **color of squares, lines and dots** is selectable (light, medium, strong, blue, green) and also applies to exports.
@@ -154,8 +157,9 @@ New code comments and commit messages are written in English; older code and his
```text
src/
├── main.rs # Event loop (winit), mouse, touch and pen input, control flow
├── lib.rs # Library exports (for tests)
├── main.rs # Desktop entry point, starts app::run
├── app.rs # Event loop (winit), mouse, touch and pen input, control flow
├── lib.rs # Library exports (app and modules, also for tests)
├── canvas.rs # wgpu rendering: paper, patterns, images, ink, neighboring pages
├── shader.wgsl # Vertex and fragment shaders
├── viewport.rs # Camera (zoom, pan), page stack of the continuous view, swipe momentum
@@ -170,6 +174,10 @@ src/
├── assets.rs # Images and PDFs in the notebook folder
├── pdf_doc.rs # Reading and rendering PDFs (hayro)
├── pdf_merge.rs # Underlaying imported PDF pages on export
├── import.rs # Import as a new notebook (shared parts)
├── xopp.rs # Import of Xournal++/Xournal notebooks
├── rnote.rs # Import of Rnote notebooks
├── saber.rs # Import of Saber notebooks
├── document.rs # Data model: notebook, pages, strokes, page sizes
├── history.rs # Undo/redo
├── storage.rs # Loading/saving (atomic), notebooks, trash
+2 -2
View File
@@ -15,7 +15,7 @@ ink-notes ist ein einzelnes Programm mit einem Event-Loop (`winit`) im Haupt-Thr
| Haupt-Thread | Eingaben, Datenmodell, Rendering (`wgpu`), Oberfläche (`egui`) |
| Autosave | Seiten und Manifest auf die Festplatte schreiben (`autosave.rs`) |
| PDF-Rendering | Importierte PDF-Seiten zu Texturen rendern (`pdf_doc.rs`, `hayro`) |
| Export | PDF/PNG/SVG-Export, damit die Oberfläche nicht hängt (`main.rs`) |
| Export | PDF/PNG/SVG-Export, damit die Oberfläche nicht hängt (`app.rs`) |
Stifteingaben kommen über `octotablet` (Tablet-Protokoll von Wayland bzw. X11), Maus und Touch über `winit`. Die Oberfläche (Werkzeugleiste, Menüs, Dialoge, Textbearbeitung) ist mit `egui` gebaut und wird im selben Render-Durchgang über das Blatt gezeichnet.
@@ -195,7 +195,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.
3. **Images:** eingefügte Bilder (höchstens 300 dpi).
4. **Text:** Textfelder als echter Text mit eingebetteter Schrift (durchsuchbar, kopierbar). Jedes Feld liegt in einem eigenen Koordinatensystem, das eine Transformationsmatrix auf seine Ecken abbildet.
5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade. Halbtransparente Striche (Textmarker) bekommen einen Grafikzustand mit Deckkraft (`ca`/`CA`); printpdf kann solche Zustände nicht anlegen, deshalb trägt `add_alpha_states` sie nach dem Speichern mit `lopdf` in die Seitenressourcen ein. Jeder Strich ist ein einziger Pfad und deckt sich daher nicht selbst doppelt.
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. Der Umriss eines Strichs wird vor dem Export vereinfacht (`geometry::simplify_closed_outline`, höchstens 0,01 mm vom gezeichneten entfernt): Das Netz hat etwa 16 Punkte je Stiftpunkt, und mit allen dauerte der Export einer vollen Seite Sekunden. `INK_NOTES_EXACT_OUTLINES=1` exportiert wie bisher jeden Punkt.
Importierte PDF-Seiten werden anschließend als Form-XObject unter die Seite gelegt (`pdf_merge.rs`), bleiben also Vektorgrafik. Seitentitel werden zu Lesezeichen.
+2 -2
View File
@@ -15,7 +15,7 @@ ink-notes is a single program with an event loop (`winit`) on the main thread, p
| Main thread | Input, data model, rendering (`wgpu`), interface (`egui`) |
| Autosave | Writes pages and manifest to disk (`autosave.rs`) |
| PDF rendering | Renders imported PDF pages to textures (`pdf_doc.rs`, `hayro`) |
| Export | PDF/PNG/SVG export, so the interface does not freeze (`main.rs`) |
| Export | PDF/PNG/SVG export, so the interface does not freeze (`app.rs`) |
Pen input arrives via `octotablet` (the Wayland or X11 tablet protocol), mouse and touch via `winit`. The interface (toolbar, menus, dialogs, text editing) is built with `egui` and drawn over the page in the same render pass.
@@ -195,7 +195,7 @@ Each page has its own history (`history.rs`): adding, removing, replacing and mo
2. **PDF** (fallback only): imported PDF page as an image, if it cannot be taken over as vectors.
3. **Images:** inserted images (at most 300 dpi).
4. **Text:** text boxes as real text with embedded font (searchable, copyable). Each box lives in its own coordinate system that a transformation matrix maps onto its corners.
5. **Ink:** strokes as filled polygons, shapes as paths. Semi-transparent strokes (highlighter) get a graphics state with opacity (`ca`/`CA`); printpdf cannot create such states, so `add_alpha_states` adds them to the page resources with `lopdf` after saving. Each stroke is a single path and therefore does not cover itself twice.
5. **Ink:** strokes as filled polygons, shapes as paths. Semi-transparent strokes (highlighter) get a graphics state with opacity (`ca`/`CA`); printpdf cannot create such states, so `add_alpha_states` adds them to the page resources with `lopdf` after saving. Each stroke is a single path and therefore does not cover itself twice. The outline of a stroke is simplified before export (`geometry::simplify_closed_outline`, at most 0.01 mm away from the drawn one): the mesh has about 16 points per pen sample, and with all of them a full page took seconds to export. `INK_NOTES_EXACT_OUTLINES=1` exports every point as before.
Imported PDF pages are then placed under the page as a form XObject (`pdf_merge.rs`), so they stay vector graphics. Page titles become bookmarks.
+30
View File
@@ -84,6 +84,36 @@
</branding>
<content_rating type="oars-1.1" />
<releases>
<release version="0.37.0" date="2026-10-27">
<description>
<p>Saber notebooks (.sbn2, .sba, .sbn) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, typed text, images and PDF backgrounds.</p>
</description>
</release>
<release version="0.36.0" date="2026-10-27">
<description>
<p>Notebooks from Xournal++ (.xopp, .xoj) and Rnote (.rnote) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, texts, images and backgrounds.</p>
</description>
</release>
<release version="0.35.0" date="2026-10-27">
<description>
<p>PDF and PNG export and copying a selection as an image are about six times faster, and exported PDFs are much smaller, because stroke outlines are simplified invisibly before export.</p>
</description>
</release>
<release version="0.34.2" date="2026-10-26">
<description>
<p>The swipe speed for gliding is measured with the touch screen's own event times, so a swipe still glides when the app handles the lift a little late.</p>
</description>
</release>
<release version="0.34.1" date="2026-10-25">
<description>
<p>A finger swipe is measured reliably again for gliding, also when the view touches the edge of the notebook. The fling diagnostics log more details.</p>
</description>
</release>
<release version="0.34.0" date="2026-10-23">
<description>
<p>The eraser size is set in a menu like the pens, with presets and a slider. Corners of snapped shapes stay pointed. Slides get flat tiles in the page overview.</p>
</description>
</release>
<release version="0.33.0" date="2026-10-21">
<description>
<p>The hand is ignored while the pen is near the screen, also on the title bar. The eraser comes in four sizes. The page overview shows imported PDF pages. Snapped triangles and other shapes have the same width on every side. A selection made with the pen's select button can be moved with the pen.</p>
+156
View File
@@ -805,6 +805,19 @@
"dest": "cargo/vendor/d3d12-0.19.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/data-url/data-url-0.3.2.crate",
"sha256": "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376",
"dest": "cargo/vendor/data-url-0.3.2"
},
{
"type": "inline",
"contents": "{\"package\": \"be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376\", \"files\": {}}",
"dest": "cargo/vendor/data-url-0.3.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -1156,6 +1169,19 @@
"dest": "cargo/vendor/flate2-1.1.10",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/float-cmp/float-cmp-0.9.0.crate",
"sha256": "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4",
"dest": "cargo/vendor/float-cmp-0.9.0"
},
{
"type": "inline",
"contents": "{\"package\": \"98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4\", \"files\": {}}",
"dest": "cargo/vendor/float-cmp-0.9.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -1884,6 +1910,19 @@
"dest": "cargo/vendor/image-webp-0.2.4",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/imagesize/imagesize-0.15.0.crate",
"sha256": "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a",
"dest": "cargo/vendor/imagesize-0.15.0"
},
{
"type": "inline",
"contents": "{\"package\": \"65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a\", \"files\": {}}",
"dest": "cargo/vendor/imagesize-0.15.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -2794,6 +2833,19 @@
"dest": "cargo/vendor/pic-scale-0.7.12",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/pico-args/pico-args-0.5.0.crate",
"sha256": "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315",
"dest": "cargo/vendor/pico-args-0.5.0"
},
{
"type": "inline",
"contents": "{\"package\": \"5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315\", \"files\": {}}",
"dest": "cargo/vendor/pico-args-0.5.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -3197,6 +3249,32 @@
"dest": "cargo/vendor/renderdoc-sys-1.1.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/resvg/resvg-0.49.0.crate",
"sha256": "1572844b6727a1bf842190e9947641a68ac44bed83f50afb4bd1407a2564bee8",
"dest": "cargo/vendor/resvg-0.49.0"
},
{
"type": "inline",
"contents": "{\"package\": \"1572844b6727a1bf842190e9947641a68ac44bed83f50afb4bd1407a2564bee8\", \"files\": {}}",
"dest": "cargo/vendor/resvg-0.49.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rgb/rgb-0.8.53.crate",
"sha256": "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4",
"dest": "cargo/vendor/rgb-0.8.53"
},
{
"type": "inline",
"contents": "{\"package\": \"47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4\", \"files\": {}}",
"dest": "cargo/vendor/rgb-0.8.53",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -3210,6 +3288,19 @@
"dest": "cargo/vendor/ring-0.17.14",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/roxmltree/roxmltree-0.21.2.crate",
"sha256": "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907",
"dest": "cargo/vendor/roxmltree-0.21.2"
},
{
"type": "inline",
"contents": "{\"package\": \"9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907\", \"files\": {}}",
"dest": "cargo/vendor/roxmltree-0.21.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -3483,6 +3574,19 @@
"dest": "cargo/vendor/simdutf8-0.1.5",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/simplecss/simplecss-0.2.2.crate",
"sha256": "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c",
"dest": "cargo/vendor/simplecss-0.2.2"
},
{
"type": "inline",
"contents": "{\"package\": \"7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c\", \"files\": {}}",
"dest": "cargo/vendor/simplecss-0.2.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -3678,6 +3782,19 @@
"dest": "cargo/vendor/subtle-2.6.1",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/svgtypes/svgtypes-0.16.1.crate",
"sha256": "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d",
"dest": "cargo/vendor/svgtypes-0.16.1"
},
{
"type": "inline",
"contents": "{\"package\": \"695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d\", \"files\": {}}",
"dest": "cargo/vendor/svgtypes-0.16.1",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -3873,6 +3990,19 @@
"dest": "cargo/vendor/tiny-skia-0.11.4",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tiny-skia/tiny-skia-0.12.0.crate",
"sha256": "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea",
"dest": "cargo/vendor/tiny-skia-0.12.0"
},
{
"type": "inline",
"contents": "{\"package\": \"47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea\", \"files\": {}}",
"dest": "cargo/vendor/tiny-skia-0.12.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -3886,6 +4016,19 @@
"dest": "cargo/vendor/tiny-skia-path-0.11.4",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tiny-skia-path/tiny-skia-path-0.12.0.crate",
"sha256": "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9",
"dest": "cargo/vendor/tiny-skia-path-0.12.0"
},
{
"type": "inline",
"contents": "{\"package\": \"edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9\", \"files\": {}}",
"dest": "cargo/vendor/tiny-skia-path-0.12.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -4094,6 +4237,19 @@
"dest": "cargo/vendor/url-2.5.8",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/usvg/usvg-0.49.0.crate",
"sha256": "d09b73ee83641d390ad7ffd9000f347b16eb128e437675b6ff277ea5c7fcf22c",
"dest": "cargo/vendor/usvg-0.49.0"
},
{
"type": "inline",
"contents": "{\"package\": \"d09b73ee83641d390ad7ffd9000f347b16eb128e437675b6ff277ea5c7fcf22c\", \"files\": {}}",
"dest": "cargo/vendor/usvg-0.49.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
+30
View File
@@ -84,6 +84,36 @@
</branding>
<content_rating type="oars-1.1" />
<releases>
<release version="0.37.0" date="2026-10-27">
<description>
<p>Saber notebooks (.sbn2, .sba, .sbn) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, typed text, images and PDF backgrounds.</p>
</description>
</release>
<release version="0.36.0" date="2026-10-27">
<description>
<p>Notebooks from Xournal++ (.xopp, .xoj) and Rnote (.rnote) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, texts, images and backgrounds.</p>
</description>
</release>
<release version="0.35.0" date="2026-10-27">
<description>
<p>PDF and PNG export and copying a selection as an image are about six times faster, and exported PDFs are much smaller, because stroke outlines are simplified invisibly before export.</p>
</description>
</release>
<release version="0.34.2" date="2026-10-26">
<description>
<p>The swipe speed for gliding is measured with the touch screen's own event times, so a swipe still glides when the app handles the lift a little late.</p>
</description>
</release>
<release version="0.34.1" date="2026-10-25">
<description>
<p>A finger swipe is measured reliably again for gliding, also when the view touches the edge of the notebook. The fling diagnostics log more details.</p>
</description>
</release>
<release version="0.34.0" date="2026-10-23">
<description>
<p>The eraser size is set in a menu like the pens, with presets and a slider. Corners of snapped shapes stay pointed. Slides get flat tiles in the page overview.</p>
</description>
</release>
<release version="0.33.0" date="2026-10-21">
<description>
<p>The hand is ignored while the pen is near the screen, also on the title bar. The eraser comes in four sizes. The page overview shows imported PDF pages. Snapped triangles and other shapes have the same width on every side. A selection made with the pen's select button can be moved with the pen.</p>
+6
View File
@@ -3,6 +3,12 @@
Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04).
### Highlights:
- **Import from Saber**: ☰ → Import (or dragging the file into the window) now also opens Saber notebooks (`.sbn2` with its asset files next to it, `.sba` archives, old `.sbn`) – strokes with pen pressure, highlighters, shapes, typed text, images and PDF backgrounds
- **Import from Xournal++ and Rnote**: ☰ → Import (or dragging the file into the window) now also opens Xournal++ (`.xopp`, `.xoj`) and Rnote (`.rnote`) notebooks as new notebooks – strokes with pen pressure, highlighters, shapes, texts, images, paper patterns and Xournal++ PDF backgrounds; PDF pages imported in Rnote become images, infinite Rnote documents an endless notebook
- **Faster export**: PDF and PNG export and copying a selection as an image are about six times faster and PDFs are up to eight times smaller, because stroke outlines are simplified before export (at most 0.01 mm off; `INK_NOTES_EXACT_OUTLINES=1` exports every point as before)
- **Fling timing**: The swipe speed for gliding is measured with the touch screen's own event times (Wayland), so a fast swipe glides even when the lift is handled late; the fling diagnostics also report slow event handling
- **Fling fix**: A finger swipe is no longer lost when the view touches the edge of the notebook, so the page glides on after a fast swipe; the fling diagnostics (`INK_NOTES_DEBUG_FLING=1`) log moves, distance and timing
- **Feedback round**: The eraser size is set in a menu like the pens (four presets and a slider); corners of snapped triangles and polygons stay pointed; slides (landscape PDFs) get flat tiles in the page overview without an empty strip
- **Palm rejection & more feedback**: A hand resting on the screen while the pen is near is ignored (also on the title bar, no more accidental paste menu or window moving); the eraser has four sizes (button below the eraser modes, remembered); the page overview shows imported PDF pages; snapped triangles, rectangles and polygons have the same width on every side; a selection made with the pen's select button can be moved with the bare pen
- **Feedback round**: The endless canvas starts at the top left and only grows to the right and down; new text fields start short and grow with the text; toolbar buttons are larger again on small screens; on Wayland the title bar buttons (close, minimize, maximize) and moving the window work with a finger
- **View only**: 👁 in the toolbar hides the tools – pen, finger and mouse only scroll and zoom (the pen glides on like a finger), nothing can be changed by accident; tap 👁 again to write
+4356
View File
File diff suppressed because it is too large. Load diff
+30 -15
View File
@@ -1222,21 +1222,25 @@ fn render_stroke_polygon(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
// Geschlossener Pfad um den Ribbon-Rand:
// Left[0] -> Left[1] -> ... -> Left[n-1] -> Right[n-1] -> Right[n-2] -> ... -> Right[0]
let mut ring = Vec::with_capacity(n * 2);
// 1. Linke Hüllkurve vorwärts
for i in 0..n {
let v = stroke.vertices[i * 2];
let (x, y) = page_to_pdf_mm(v.position[0], v.position[1]);
ring.push((Point::new(Mm(x), Mm(y)), false));
}
// 2. Rechte Hüllkurve rückwärts
for i in (0..n).rev() {
let v = stroke.vertices[i * 2 + 1];
let (x, y) = page_to_pdf_mm(v.position[0], v.position[1]);
ring.push((Point::new(Mm(x), Mm(y)), false));
}
let outline: Vec<glam::Vec2> = (0..n)
.map(|i| stroke.vertices[i * 2].position)
.chain((0..n).rev().map(|i| stroke.vertices[i * 2 + 1].position))
.map(glam::Vec2::from)
.collect();
// The mesh has about 16 points per pen sample, far more than the eye (or a printer) can
// tell apart; fewer points make the PDF, PNG export and copying as an image much faster
let outline = if exact_outlines() {
outline
} else {
crate::geometry::simplify_closed_outline(&outline, OUTLINE_TOLERANCE)
};
let ring: Vec<(Point, bool)> = outline
.iter()
.map(|p| {
let (x, y) = page_to_pdf_mm(p.x, p.y);
(Point::new(Mm(x), Mm(y)), false)
})
.collect();
let fill_color = Color::Rgb(Rgb::new(
stroke.color[0],
@@ -1254,6 +1258,17 @@ fn render_stroke_polygon(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
layer.add_polygon(poly);
}
/// Largest deviation of an exported stroke outline from the drawn one, in page units
/// (0.01 mm: a tenth of a pixel at 200 dpi).
pub const OUTLINE_TOLERANCE: f32 = 0.1;
/// `INK_NOTES_EXACT_OUTLINES=1` exports every point of the stroke outlines, as before this change
/// (fallback in case the simplified outlines ever look wrong).
fn exact_outlines() -> bool {
static EXACT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*EXACT.get_or_init(|| std::env::var_os("INK_NOTES_EXACT_OUTLINES").is_some_and(|v| v != "0"))
}
/// Strategie B: Kubische Bézier-Kurven aus den Abtastpunkten via printpdf::Line.
fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
if stroke.points.is_empty() {
+20 -6
View File
@@ -1,4 +1,4 @@
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder und PDFs.
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder und importierbare Dateien (PDF, Xournal++, Rnote, Saber).
//!
//! Bewusst ohne Systemdialog: der bräuchte GTK oder das Desktop-Portal, die unter WSL und auf
//! schlanken Systemen oft fehlen. Die Auswahl zeigt Ordner und die passenden Dateien des
@@ -22,8 +22,8 @@ pub struct PickerEntry {
pub enum PickerKind {
/// Bilder zum Einfügen auf der aktuellen Seite
Image,
/// PDF zum Importieren als neues Notizbuch
Pdf,
/// PDF, Xournal++, Rnote or Saber file to import as a new notebook
Import,
}
impl PickerKind {
@@ -31,7 +31,7 @@ impl PickerKind {
pub fn accepts(self, path: &Path) -> bool {
match self {
PickerKind::Image => is_supported_image_path(path),
PickerKind::Pdf => is_pdf_path(path),
PickerKind::Import => crate::import::is_importable_path(path),
}
}
}
@@ -63,7 +63,7 @@ impl FilePicker {
.or_else(|| {
let preferred = match kind {
PickerKind::Image => tn!("🖼 Bilder"),
PickerKind::Pdf => tn!("⬇ Downloads"),
PickerKind::Import => tn!("⬇ Downloads"),
};
let all = shortcuts();
all.iter()
@@ -192,6 +192,10 @@ mod tests {
".versteckt.png",
"c.webp",
"Skript.PDF",
"mathe.xopp",
"skizze.rnote",
"Uni.sbn2",
"Uni.sbn2.0",
] {
std::fs::write(dir.path().join(name), b"x").unwrap();
}
@@ -210,7 +214,17 @@ mod tests {
names(PickerKind::Image),
["aOrdner", "zOrdner", "A.jpg", "b.png", "c.webp"]
);
assert_eq!(names(PickerKind::Pdf), ["aOrdner", "zOrdner", "Skript.PDF"]);
assert_eq!(
names(PickerKind::Import),
[
"aOrdner",
"zOrdner",
"mathe.xopp",
"skizze.rnote",
"Skript.PDF",
"Uni.sbn2"
]
);
}
#[test]
+132 -2
View File
@@ -352,6 +352,63 @@ fn rdp_recursive(points: &[Vec2], epsilon: f32) -> Vec<Vec2> {
}
}
/// Simplifies a closed outline (the last point connects back to the first) so that no point
/// of the original lies farther than `tolerance` from the result. Unlike
/// [`ramer_douglas_peucker`], it measures the distance to the segment, not to the infinite
/// line, so stroke tips that turn back are kept. Works without recursion, so long strokes
/// cannot overflow the stack.
pub fn simplify_closed_outline(points: &[Vec2], tolerance: f32) -> Vec<Vec2> {
if points.len() <= 3 {
return points.to_vec();
}
// Two chains between the first point and the point farthest from it: the start of an
// outline often lies on top of its end (round cap), which would leave no chord to measure
let far = (1..points.len())
.max_by(|&i, &j| {
points[i]
.distance_squared(points[0])
.total_cmp(&points[j].distance_squared(points[0]))
})
.unwrap_or(1);
let mut keep = vec![false; points.len()];
keep[0] = true;
keep[far] = true;
let mut stack = vec![(0, far), (far, points.len())];
while let Some((start, end)) = stack.pop() {
// `end == points.len()` closes the ring back to the first point
let (a, b) = (points[start], points[end % points.len()]);
let mut worst = (0.0_f32, start);
for (i, &p) in points.iter().enumerate().take(end).skip(start + 1) {
let d = point_to_segment_dist(p, a, b);
if d > worst.0 {
worst = (d, i);
}
}
if worst.0 > tolerance {
keep[worst.1] = true;
stack.push((start, worst.1));
stack.push((worst.1, end));
}
}
points
.iter()
.zip(keep)
.filter_map(|(&p, keep)| keep.then_some(p))
.collect()
}
/// Abstand eines Punktes `p` zur Strecke von `a` nach `b`.
#[inline]
pub fn point_to_segment_dist(p: Vec2, a: Vec2, b: Vec2) -> f32 {
let ab = b - a;
let len_sq = ab.length_squared();
if len_sq < 1e-12 {
return p.distance(a);
}
let t = ((p - a).dot(ab) / len_sq).clamp(0.0, 1.0);
p.distance(a + ab * t)
}
/// Abstand eines Punktes `p` zur Geraden durch `a` und `b`.
#[inline]
pub fn point_to_line_dist(p: Vec2, a: Vec2, b: Vec2) -> f32 {
@@ -1801,8 +1858,9 @@ pub fn contour_rms(points: &[Vec2], shape: &RecognizedShape) -> Option<f32> {
Some((sum_sq / points.len() as f32).sqrt())
}
/// Longest miter of a polygon corner, in half stroke widths; sharper corners are bevelled.
const MITER_LIMIT: f32 = 2.0;
/// Longest miter of a polygon corner, in half stroke widths: corners down to about 14° stay
/// pointed, only sharper ones are bevelled (Issue #41).
const MITER_LIMIT: f32 = 8.0;
/// Vertex pairs (left, right of the outline direction) of a triangle strip at corner `curr`
/// between the edges from `prev` and to `next` with left normals `n_prev` and `n_next`. Both
@@ -2326,6 +2384,78 @@ pub fn shape_to_stroke(
mod tests {
use super::*;
/// Largest distance of any point of `original` from the closed outline `simplified`.
fn outline_deviation(original: &[Vec2], simplified: &[Vec2]) -> f32 {
original
.iter()
.map(|&p| {
(0..simplified.len())
.map(|i| {
point_to_segment_dist(
p,
simplified[i],
simplified[(i + 1) % simplified.len()],
)
})
.fold(f32::INFINITY, f32::min)
})
.fold(0.0, f32::max)
}
#[test]
fn test_simplified_stroke_outline_stays_within_tolerance() {
// A handwritten loop like a letter, as the pen delivers it
let samples: Vec<StylusSample> = (0..30)
.map(|i| {
let t = i as f32 / 29.0 * std::f32::consts::TAU * 1.3;
StylusSample {
position: Vec2::new(500.0 + 12.0 * t.cos() + t * 4.0, 800.0 + 20.0 * t.sin()),
pressure: 0.4 + 0.3 * (t * 0.7).sin().abs(),
timestamp: i as f64 * 0.008,
}
})
.collect();
let vertices = crate::storage::reconstruct_stroke_vertices(
&samples,
[0.0, 0.0, 0.0, 1.0],
5.0,
Default::default(),
true,
);
let n = vertices.len() / 2;
let outline: Vec<Vec2> = (0..n)
.map(|i| vertices[i * 2].position)
.chain((0..n).rev().map(|i| vertices[i * 2 + 1].position))
.map(Vec2::from)
.collect();
let tolerance = 0.1;
let simplified = simplify_closed_outline(&outline, tolerance);
let deviation = outline_deviation(&outline, &simplified);
assert!(deviation <= tolerance + 1e-4, "{deviation}");
assert!(
simplified.len() * 4 < outline.len(),
"{} -> {} points",
outline.len(),
simplified.len()
);
}
#[test]
fn test_simplified_outline_keeps_tips_that_turn_back() {
// Thin hairpin: out along y = 0 to x = 20, back along y = 0.05. Measured against the
// infinite line, the tip would vanish.
let mut outline: Vec<Vec2> = (0..=20).map(|x| Vec2::new(x as f32, 0.0)).collect();
outline.extend((0..20).rev().map(|x| Vec2::new(x as f32, 0.05)));
let simplified = simplify_closed_outline(&outline, 0.1);
let reach = simplified.iter().map(|p| p.x).fold(f32::MIN, f32::max);
assert_eq!(reach, 20.0, "{simplified:?}");
assert!(outline_deviation(&outline, &simplified) <= 0.1);
assert!(simplified.len() <= 4, "{simplified:?}");
// Tiny outlines stay as they are
let dot = [Vec2::ZERO, Vec2::X, Vec2::Y];
assert_eq!(simplify_closed_outline(&dot, 0.1), dot.to_vec());
}
#[test]
fn test_detect_line() {
// Leicht zittrige diagonale Punktfolge
+5 -1
View File
@@ -156,7 +156,7 @@ mod tests {
("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")),
("app.rs", include_str!("app.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")),
@@ -167,6 +167,10 @@ mod tests {
("text.rs", include_str!("text.rs")),
("ui.rs", include_str!("ui.rs")),
("updater.rs", include_str!("updater.rs")),
("import.rs", include_str!("import.rs")),
("rnote.rs", include_str!("rnote.rs")),
("saber.rs", include_str!("saber.rs")),
("xopp.rs", include_str!("xopp.rs")),
];
/// Alle Texte in `t!("…")` und `tn!("…")` eines Quelltexts, auch über mehrere Zeilen
+22 -9
View File
@@ -52,7 +52,6 @@ pub const EN: &[(&str, &str)] = &[
("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"),
@@ -120,6 +119,7 @@ pub const EN: &[(&str, &str)] = &[
("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}"),
("GRÖSSE", "SIZE"),
("Graphit-Grau", "Graphite gray"),
("Größere Schrift (jetzt {size} pt)", "Larger font (now {size} pt)"),
("Grün", "Green"),
@@ -149,7 +149,6 @@ pub const EN: &[(&str, &str)] = &[
("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."),
@@ -233,9 +232,7 @@ pub const EN: &[(&str, &str)] = &[
("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…"),
@@ -256,9 +253,10 @@ pub const EN: &[(&str, &str)] = &[
("Quell-PDF ist verschlüsselt", "Source PDF is encrypted"),
("Radieren", "Erase"),
("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich, nur Textmarker und die Größe", "Eraser (E or right mouse button) – below: whole strokes, partial stroke, highlighter only and the size"),
("Radierer", "Eraser"),
("Radierer (Eraser)", "Eraser"),
("Radiergröße {size} mm", "Eraser size {size} mm"),
("Radiergröße {size} mm – tippen für die nächste Größe", "Eraser size {size} mm – tap for the next size"),
("Radiergröße {size} mm – tippen zum Ändern", "Eraser size {size} mm – tap to change"),
("Radiergröße {size} setzen", "Set eraser size to {size}"),
("Raute (Rhombus)", "Rhombus"),
("Rechteck", "Rectangle"),
("Rechts", "Right"),
@@ -314,6 +312,7 @@ pub const EN: &[(&str, &str)] = &[
("Stift {number} ({width} mm)", "Pen {number} ({width} mm)"),
("Stift {number}", "Pen {number}"),
("Stift", "Pen"),
("STUFENLOSE GRÖSSE", "STEPLESS SIZE"),
("STUFENLOSE STÄRKE", "STEPLESS WIDTH"),
("Stärke auf {width} setzen", "Set width to {width}"),
("System", "System"),
@@ -365,7 +364,6 @@ pub const EN: &[(&str, &str)] = &[
("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"),
@@ -427,9 +425,7 @@ pub const EN: &[(&str, &str)] = &[
("📂 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"),
@@ -451,4 +447,21 @@ pub const EN: &[(&str, &str)] = &[
("🚀 Update {version} verfügbar", "🚀 Update {version} available"),
("🚀 AppImage aktualisieren & ausführen", "🚀 Update AppImage & restart"),
("🚀 Updates & Versionen", "🚀 Updates & versions"),
(", PDF-Hintergrund nicht gefunden: {file}", ", PDF background not found: {file}"),
(", {count} Elemente übersprungen", ", {count} elements skipped"),
("Nicht importiert: {err}", "Not imported: {err}"),
("Importieren", "Import"),
("📄 Importieren", "📄 Import"),
("Datei beschädigt: {err}", "File damaged: {err}"),
("Datei nicht lesbar: {err}", "File not readable: {err}"),
("Die Datei enthält keine Seiten", "The file contains no pages"),
("Keine Xournal-Datei", "Not a Xournal file"),
("Keine Rnote-Datei", "Not an Rnote file"),
("Rnote-Datei zu alt ({version}): bitte in einem aktuellen Rnote öffnen und neu speichern", "Rnote file too old ({version}): please open it in a current Rnote and save it again"),
("Keine Ordner oder importierbaren Dateien (PDF, Xournal++, Rnote, Saber)", "No folders or importable files (PDF, Xournal++, Rnote, Saber)"),
("Die Datei wird als neues Notizbuch angelegt. PDF: eine Seite je PDF-Seite, beim Export liegen deine Notizen über den Originalseiten. Xournal++ (.xopp, .xoj), Rnote (.rnote) und Saber (.sbn2, .sba): Striche, Formen, Texte und Bilder werden übernommen, bei Xournal++ und Saber auch PDF-Hintergründe.", "The file becomes a new notebook. PDF: one page per PDF page, when exporting your notes lie on top of the original pages. Xournal++ (.xopp, .xoj), Rnote (.rnote) and Saber (.sbn2, .sba): strokes, shapes, texts and images are taken over, for Xournal++ and Saber also PDF backgrounds."),
("Vorlesungsfolien, Übungsblätter, Formulare oder Notizbücher aus Xournal++, Rnote und Saber als neues Notizbuch öffnen und darauf schreiben", "Open lecture slides, worksheets, forms or notebooks from Xournal++, Rnote and Saber as a new notebook and write on them"),
("📄 Importieren (PDF, Xournal++, Rnote, Saber) …", "📄 Import (PDF, Xournal++, Rnote, Saber) …"),
("Keine Saber-Datei", "Not a Saber file"),
("Saber-Datei zu alt: bitte in einem aktuellen Saber öffnen und neu speichern", "Saber file too old: please open it in a current Saber and save it again"),
];
+291
View File
@@ -0,0 +1,291 @@
//! Import of other formats as a new notebook (Issue #47): PDF (`storage`), Xournal++
//! (`xopp`), Rnote (`rnote`) and Saber (`saber`). This module holds what the importers
//! share: building strokes, texts and images in the stored format and creating the notebook.
use std::io::Read;
use std::path::{Path, PathBuf};
use crate::document::{Notebook, Page, PageBackground, StrokeTool};
use crate::storage::{self, NotebookLayout, NotebookManifest, SampleData, StrokeData};
use crate::t;
use crate::text::{TextBox, TextFont};
/// Largest accepted file after decompression (guards against gzip bombs).
const MAX_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
/// Alpha of imported highlighter strokes that are stored opaque (as the app's highlighters).
pub const HIGHLIGHTER_ALPHA: f32 = 0.35;
/// What could not be taken over.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ImportReport {
pub skipped: usize,
/// The PDF background was referenced but not found or unreadable
pub missing_pdf: Option<String>,
}
/// Result of an importer: pages, the next free stroke id, the layout (`None`: default).
pub struct Imported {
pub pages: Vec<Page>,
pub next_stroke_id: u64,
pub layout: Option<NotebookLayout>,
pub report: ImportReport,
}
/// Can the file be imported (by its name)?
pub fn is_importable_path(path: &Path) -> bool {
crate::file_picker::is_pdf_path(path)
|| crate::xopp::is_xopp_path(path)
|| crate::rnote::is_rnote_path(path)
|| crate::saber::is_saber_path(path)
}
/// Imports a PDF, Xournal++, Rnote or Saber file as a new notebook titled `title`.
pub fn import_file(
base_dir: &Path,
title: &str,
path: &Path,
) -> Result<(Notebook, NotebookManifest, PathBuf, ImportReport), String> {
if crate::xopp::is_xopp_path(path) {
crate::xopp::create_notebook_from_xopp(base_dir, title, path)
} else if crate::rnote::is_rnote_path(path) {
crate::rnote::create_notebook_from_rnote(base_dir, title, path)
} else if crate::saber::is_saber_path(path) {
crate::saber::create_notebook_from_saber(base_dir, title, path)
} else {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
storage::create_notebook_from_pdf(base_dir, title, bytes)
.map(|(nb, manifest, dir)| (nb, manifest, dir, ImportReport::default()))
}
}
/// Decompresses gzip data; other data is returned unchanged.
pub fn gunzip(bytes: &[u8]) -> Result<Vec<u8>, String> {
if !bytes.starts_with(&[0x1f, 0x8b]) {
return Ok(bytes.to_vec());
}
let mut data = Vec::new();
flate2::read::MultiGzDecoder::new(bytes)
.take(MAX_UNCOMPRESSED_BYTES)
.read_to_end(&mut data)
.map_err(|e| t!("Datei nicht lesbar: {err}", err = e))?;
Ok(data)
}
/// Creates a new notebook and fills it with the pages `build` creates in its folder (images
/// and PDFs go to its asset folder). On failure nothing is left behind.
pub fn create_notebook(
base_dir: &Path,
title: &str,
build: impl FnOnce(&Path) -> Result<Imported, String>,
) -> Result<(Notebook, NotebookManifest, PathBuf, ImportReport), String> {
let (_, _, dir) = storage::create_new_notebook(base_dir, title).map_err(|e| e.to_string())?;
let result = (|| {
let imported = build(&dir)?;
let page_count = imported.pages.len() as u64;
let notebook =
Notebook::from_pages(imported.pages, 0, imported.next_stroke_id, page_count + 1);
let mut manifest = storage::load_manifest(&dir).map_err(|e| e.to_string())?;
if imported.layout.is_some() {
manifest.layout = imported.layout;
}
storage::save_notebook(&dir, &notebook, &mut manifest).map_err(|e| e.to_string())?;
Ok((notebook, manifest, imported.report))
})();
match result {
Ok((notebook, manifest, report)) => Ok((notebook, manifest, dir, report)),
Err(err) => {
let _ = std::fs::remove_dir_all(&dir);
Err(err)
}
}
}
/// Builds a page from stored-format strokes.
pub fn page(
id: u64,
size: glam::Vec2,
background: PageBackground,
pdf_background: Option<crate::document::PdfBackground>,
strokes: Vec<StrokeData>,
) -> Page {
storage::PageData {
format_version: storage::CURRENT_FORMAT_VERSION,
id,
background,
pdf_background,
title: None,
size: (size != crate::document::A4_SIZE).then(|| size.to_array()),
strokes,
}
.to_page()
}
/// Our stroke width and the sample pressures for absolute widths per point (page units):
/// we draw a diameter of `width · (0.5 + p)`, so the widest point gets p = 1 and thinner
/// ones less (down to a third of the widest).
pub fn fit_widths(widths: &[f32]) -> (f32, Vec<f32>) {
let widest = widths.iter().copied().fold(0.0, f32::max).max(0.01);
let width = widest / 1.5;
let pressures = widths
.iter()
.map(|w| (w / width - 0.5).clamp(0.0, 1.0))
.collect();
(width, pressures)
}
/// Pen or highlighter stroke through `points` (page units) with the given widths per point.
pub fn ink_stroke(
id: u64,
mut color: [f32; 4],
highlighter: bool,
points: &[glam::Vec2],
width: f32,
pressures: &[f32],
) -> StrokeData {
let tool = if highlighter {
if color[3] >= 0.99 {
color[3] = HIGHLIGHTER_ALPHA;
}
StrokeTool::Highlighter
} else {
// Pens are always opaque (translucent pens are read as highlighters)
color[3] = 1.0;
StrokeTool::Pen
};
let samples = points
.iter()
.zip(pressures)
.enumerate()
.map(|(i, (p, &pressure))| SampleData {
x: p.x,
y: p.y,
p: pressure,
t: i as f64 * 0.005,
})
.collect();
StrokeData {
id,
color,
samples,
shape: None,
tool,
width,
finalized: true,
image: None,
text: None,
}
}
/// Text field with its upper left corner at `origin` and the axes (page vector per unit to
/// the right and down). Without `wrap_width` it is as wide as its longest line.
#[allow(clippy::too_many_arguments)]
pub fn text_stroke(
id: u64,
content: &str,
size: f32,
font: TextFont,
origin: glam::Vec2,
axes: [glam::Vec2; 2],
wrap_width: Option<f32>,
mut color: [f32; 4],
) -> StrokeData {
let wrap_width = wrap_width.unwrap_or_else(|| {
let unwrapped = TextBox::new(content.to_string(), size, font, f32::MAX / 4.0);
let widest = unwrapped
.layout()
.lines
.iter()
.map(|line| line.width)
.fold(0.0, f32::max);
widest + size
});
let text = TextBox::new(content.to_string(), size, font, wrap_width);
let corners = crate::text::frame_corners(origin, axes, text.box_size());
color[3] = 1.0;
StrokeData {
id,
color,
samples: corner_samples(corners),
shape: None,
tool: StrokeTool::Pen,
width: 0.0,
finalized: true,
image: None,
text: Some(text),
}
}
/// Image with the corners upper left, upper right, lower right, lower left.
pub fn image_stroke(id: u64, asset: String, corners: [glam::Vec2; 4]) -> StrokeData {
StrokeData {
id,
color: [1.0, 1.0, 1.0, 1.0],
samples: corner_samples(corners),
shape: None,
tool: StrokeTool::Pen,
width: 0.0,
finalized: true,
image: Some(asset),
text: None,
}
}
/// Premultiplied RGBA (Rnote, tiny-skia) to straight RGBA.
pub fn unpremultiply(pixels: &mut [u8]) {
for px in pixels.as_chunks_mut::<4>().0 {
let a = px[3] as u32;
if a > 0 && a < 255 {
for c in &mut px[..3] {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
}
/// Rasterizes an SVG for a box of `size` page units (about 250 dpi).
pub fn rasterize_svg(svg: &str, size: glam::Vec2) -> Option<(u32, u32, Vec<u8>)> {
use resvg::{tiny_skia, usvg};
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).ok()?;
let max = crate::assets::MAX_IMPORT_DIMENSION as f32;
let px_per_unit = 250.0 / 254.0;
let width = (size.x * px_per_unit).round().clamp(1.0, max) as u32;
let height = (size.y * px_per_unit).round().clamp(1.0, max) as u32;
let mut pixmap = tiny_skia::Pixmap::new(width, height)?;
let transform = tiny_skia::Transform::from_scale(
width as f32 / tree.size().width(),
height as f32 / tree.size().height(),
);
resvg::render(&tree, transform, &mut pixmap.as_mut());
let mut pixels = pixmap.take();
unpremultiply(&mut pixels);
Some((width, height, pixels))
}
fn corner_samples(corners: [glam::Vec2; 4]) -> Vec<SampleData> {
corners
.iter()
.map(|c| SampleData {
x: c.x,
y: c.y,
p: 1.0,
t: 0.0,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fit_widths_keeps_the_drawn_widths() {
let (width, pressures) = fit_widths(&[30.0, 15.0, 10.0, 2.0]);
let drawn: Vec<f32> = pressures.iter().map(|p| width * (0.5 + p)).collect();
assert!((drawn[0] - 30.0).abs() < 1e-3);
assert!((drawn[1] - 15.0).abs() < 1e-3);
assert!((drawn[2] - 10.0).abs() < 1e-3);
// Thinner than a third of the widest point is not possible
assert!((drawn[3] - 10.0).abs() < 1e-3);
}
}
+5
View File
@@ -1,5 +1,6 @@
//! ink-notes: Latenzoptimierte Inking-App für Linux.
pub mod app;
pub mod assets;
pub mod autosave;
pub mod background;
@@ -12,13 +13,16 @@ pub mod geometry;
pub mod history;
pub mod i18n;
pub mod image_layer;
pub mod import;
pub mod library;
pub mod osk;
pub mod page_export;
pub mod pdf_doc;
pub mod pdf_merge;
pub mod platform;
pub mod rnote;
pub mod ruler;
pub mod saber;
pub mod selection;
pub mod settings;
pub mod spline;
@@ -27,3 +31,4 @@ pub mod text;
pub mod ui;
pub mod updater;
pub mod viewport;
pub mod xopp;
+3 -4252
View File
File diff suppressed because it is too large. Load diff
+907
View File
@@ -0,0 +1,907 @@
//! Import of Rnote notebooks (.rnote) (Issue #47).
//!
//! An .rnote file is gzip-compressed JSON: `{"version": "0.x.y", "data": {"engine_snapshot":
//! {...}}}`. The snapshot is Rnote's internal state without a specification, so it is read
//! leniently as JSON and only what we need is picked out; files from Rnote 0.5.10 on are
//! supported (the layout of older ones differs too much). Coordinates are pixels at the
//! document's dpi.
//!
//! Pages: fixed-size and continuous documents become one page per Rnote page (a stroke goes
//! to the page its middle lies on), infinite documents an endless notebook.
use base64::Engine;
use glam::DVec2;
use serde_json::Value;
use std::path::Path;
use crate::document::{Notebook, PageBackground};
use crate::import::{self, ImportReport, Imported};
use crate::storage::{NotebookLayout, NotebookManifest, StrokeData};
use crate::t;
use crate::text::TextFont;
/// At most this many pages are created (protects against nonsensical document sizes).
const MAX_PAGES: usize = 2000;
/// Is the file by its name an Rnote notebook?
pub fn is_rnote_path(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("rnote"))
}
/// Parsed Rnote document, still in Rnote pixels.
#[derive(Debug, Clone, PartialEq)]
pub struct RnoteDocument {
/// Page size in pixels and dpi
pub format: DVec2,
pub dpi: f64,
/// Document bounds (upper left corner and size)
pub origin: DVec2,
pub size: DVec2,
pub endless: bool,
pub background: PageBackground,
/// In drawing order
pub items: Vec<RnoteItem>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum RnoteItem {
Ink {
color: [f32; 4],
highlighter: bool,
points: Vec<DVec2>,
/// Drawn width per point
widths: Vec<f64>,
},
Text {
content: String,
size: f64,
mono: bool,
/// Upper left corner and axes
affine: [DVec2; 3],
max_width: Option<f64>,
color: [f32; 4],
},
Bitmap {
width: u32,
height: u32,
/// Straight (not premultiplied) RGBA
pixels: Vec<u8>,
corners: [DVec2; 4],
},
/// SVG image (Rnote stores imported PDF pages like this), rasterized on import
Svg { svg: String, corners: [DVec2; 4] },
/// Element without a counterpart or unreadable
Unsupported,
}
impl RnoteItem {
/// Point that decides which page the element belongs to.
fn anchor(&self) -> Option<DVec2> {
match self {
RnoteItem::Ink { points, .. } => {
let (min, max) = points.iter().fold(
(DVec2::splat(f64::MAX), DVec2::splat(f64::MIN)),
|(a, b), &p| (a.min(p), b.max(p)),
);
(!points.is_empty()).then(|| (min + max) / 2.0)
}
RnoteItem::Text { affine, .. } => Some(affine[2]),
RnoteItem::Bitmap { corners, .. } | RnoteItem::Svg { corners, .. } => {
Some((corners[0] + corners[2]) / 2.0)
}
RnoteItem::Unsupported => None,
}
}
}
fn num(value: &Value, key: &str) -> Option<f64> {
value.get(key)?.as_f64().filter(|v| v.is_finite())
}
/// Vectors are stored as `[x, y]` (older versions possibly as `{"x": .., "y": ..}`).
fn vec2(value: &Value) -> Option<DVec2> {
match value {
Value::Array(a) if a.len() == 2 => Some(DVec2::new(a[0].as_f64()?, a[1].as_f64()?)),
Value::Object(_) => Some(DVec2::new(num(value, "x")?, num(value, "y")?)),
_ => None,
}
.filter(|v| v.is_finite())
}
/// Affine transformation as x axis, y axis and translation. Since Rnote 0.15 `affine` holds
/// the six values; before, `transform.affine` held a column-major 3×3 matrix.
fn affine(value: &Value) -> Option<[DVec2; 3]> {
let values = value
.get("affine")
.or_else(|| value.get("transform")?.get("affine"))?
.as_array()?;
let v: Vec<f64> = values.iter().filter_map(Value::as_f64).collect();
let [a, b, c, d, e, f] = match v.len() {
6 => [v[0], v[1], v[2], v[3], v[4], v[5]],
9 => [v[0], v[1], v[3], v[4], v[6], v[7]],
_ => return None,
};
Some([DVec2::new(a, b), DVec2::new(c, d), DVec2::new(e, f)])
}
fn apply(affine: &[DVec2; 3], p: DVec2) -> DVec2 {
affine[0] * p.x + affine[1] * p.y + affine[2]
}
fn color(value: Option<&Value>) -> Option<[f32; 4]> {
let value = value.filter(|v| v.is_object())?;
let c = |k| num(value, k).unwrap_or(0.0).clamp(0.0, 1.0) as f32;
Some([
c("r"),
c("g"),
c("b"),
num(value, "a").map_or(1.0, |a| a.clamp(0.0, 1.0) as f32),
])
}
/// The one variant of an externally tagged enum: `{"name": {...}}`.
fn variant(value: &Value) -> Option<(&str, &Value)> {
let object = value.as_object()?;
let (name, inner) = object.iter().next()?;
Some((name.as_str(), inner))
}
/// Width for the pressure according to Rnote's pressure curve.
fn curve_width(curve: &str, width: f64, pressure: f64) -> f64 {
let p = pressure.clamp(0.0, 1.0);
match curve {
"const" => width,
"sqrt" => width * p.sqrt(),
"cbrt" => width * p.cbrt(),
"pow2" => width * p.powi(2),
"pow3" => width * p.powi(3),
_ => width * p,
}
}
/// Style options (smooth, rough or textured).
struct Style {
width: f64,
stroke_color: Option<[f32; 4]>,
fill_color: Option<[f32; 4]>,
pressure_curve: String,
}
fn style(value: Option<&Value>) -> Option<Style> {
let (kind, options) = variant(value?)?;
let curve = if kind == "rough" {
"const".to_string()
} else {
options
.get("pressure_curve")
.and_then(Value::as_str)
.unwrap_or("linear")
.to_string()
};
Some(Style {
width: num(options, "stroke_width").unwrap_or(1.0).max(0.1),
stroke_color: color(options.get("stroke_color")),
fill_color: color(options.get("fill_color")),
pressure_curve: curve,
})
}
/// Points along a Bézier curve (without its start), at most every 2 pixels; for a line
/// just its end.
fn bezier(start: DVec2, controls: &[DVec2], end: DVec2) -> Vec<DVec2> {
if controls.is_empty() {
return vec![end];
}
let length = controls
.iter()
.chain([&end])
.fold((0.0, start), |(sum, prev), &p| (sum + prev.distance(p), p))
.0;
let steps = ((length / 2.0).ceil() as usize).clamp(1, 32);
(1..=steps)
.map(|i| {
let t = i as f64 / steps as f64;
let u = 1.0 - t;
match controls {
[cp] => start * u * u + *cp * 2.0 * u * t + end * t * t,
[cp1, cp2] => {
start * u * u * u
+ *cp1 * 3.0 * u * u * t
+ *cp2 * 3.0 * u * t * t
+ end * t * t * t
}
_ => start + (end - start) * t,
}
})
.collect()
}
fn element(value: &Value) -> Option<(DVec2, f64)> {
Some((
vec2(value.get("pos")?)?,
num(value, "pressure").unwrap_or(0.5),
))
}
fn brushstroke(value: &Value, highlighter: bool) -> Option<RnoteItem> {
let style = style(value.get("style"))?;
let color = style.stroke_color?;
let path = value.get("path")?;
let (mut prev, mut prev_pressure) = element(path.get("start")?)?;
let mut points = vec![prev];
let mut pressures = vec![prev_pressure];
for segment in path.get("segments")?.as_array()? {
let Some((kind, seg)) = variant(segment) else {
continue;
};
let Some((end, end_pressure)) = seg.get("end").and_then(element) else {
continue;
};
let controls: Vec<DVec2> = match kind {
"quadbezto" | "quadbez" => seg.get("cp").and_then(vec2).into_iter().collect(),
"cubbezto" | "cubbez" => [seg.get("cp1"), seg.get("cp2")]
.into_iter()
.filter_map(|v| v.and_then(vec2))
.collect(),
_ => Vec::new(),
};
let new = bezier(prev, &controls, end);
let n = new.len() as f64;
for (i, p) in new.into_iter().enumerate() {
let t = (i + 1) as f64 / n;
points.push(p);
pressures.push(prev_pressure + (end_pressure - prev_pressure) * t);
}
prev = end;
prev_pressure = end_pressure;
}
let widths = pressures
.iter()
.map(|&p| curve_width(&style.pressure_curve, style.width, p))
.collect();
Some(RnoteItem::Ink {
highlighter: highlighter || color[3] < 0.99,
color,
points,
widths,
})
}
/// Outline of a shape as polylines.
fn shape_lines(shape: &Value) -> Option<Vec<Vec<DVec2>>> {
let (kind, s) = variant(shape)?;
let v = |k: &str| s.get(k).and_then(vec2);
let lines = match kind {
"line" => vec![vec![v("start")?, v("end")?]],
"arrow" => {
let (start, tip) = (v("start")?, v("tip")?);
let back = (start - tip).normalize_or_zero();
let length = (start.distance(tip) * 0.4).min(20.0);
let wing = |angle: f64| tip + DVec2::from_angle(angle).rotate(back) * length;
vec![vec![start, tip], vec![wing(0.5), tip, wing(-0.5)]]
}
"rect" => {
let half = vec2(s.get("cuboid")?.get("half_extents")?)?;
let affine = affine(s)?;
let corners = [
(-1.0, -1.0),
(1.0, -1.0),
(1.0, 1.0),
(-1.0, 1.0),
(-1.0, -1.0),
];
vec![corners
.iter()
.map(|&(x, y)| apply(&affine, DVec2::new(x * half.x, y * half.y)))
.collect()]
}
"ellipse" => {
let radii = v("radii")?;
let affine = affine(s)?;
vec![(0..=64)
.map(|i| {
let a = i as f64 / 64.0 * std::f64::consts::TAU;
apply(&affine, DVec2::new(a.cos() * radii.x, a.sin() * radii.y))
})
.collect()]
}
"quadbez" | "cubbez" => {
let start = v("start")?;
let controls: Vec<DVec2> = ["cp", "cp1", "cp2"].iter().filter_map(|k| v(k)).collect();
let mut points = vec![start];
points.extend(bezier(start, &controls, v("end")?));
vec![points]
}
"polyline" | "polygon" => {
let start = v("start")?;
let mut points = vec![start];
points.extend(s.get("path")?.as_array()?.iter().filter_map(vec2));
if kind == "polygon" {
points.push(start);
}
vec![points]
}
_ => return None,
};
Some(lines)
}
fn shapestroke(value: &Value, highlighter: bool) -> Vec<RnoteItem> {
let Some(Style {
width,
stroke_color,
fill_color,
..
}) = style(value.get("style"))
else {
return vec![RnoteItem::Unsupported];
};
let (Some(color), Some(lines)) = (
stroke_color.or(fill_color),
value.get("shape").and_then(shape_lines),
) else {
return vec![RnoteItem::Unsupported];
};
lines
.into_iter()
.map(|points| RnoteItem::Ink {
color,
highlighter: highlighter || color[3] < 0.99,
widths: vec![width; points.len()],
points,
})
.collect()
}
fn textstroke(value: &Value) -> Option<RnoteItem> {
let style = value.get("text_style")?;
Some(RnoteItem::Text {
content: value.get("text")?.as_str()?.to_string(),
size: num(style, "font_size").unwrap_or(32.0).max(1.0),
mono: style
.get("font_family")
.and_then(Value::as_str)
.is_some_and(|f| f.to_ascii_lowercase().contains("mono")),
affine: affine(value)?,
max_width: num(style, "max_width").filter(|w| *w > 0.0),
color: color(style.get("color")).unwrap_or([0.0, 0.0, 0.0, 1.0]),
})
}
fn bitmapimage(value: &Value) -> Option<RnoteItem> {
let image = value.get("image")?;
let width = num(image, "pixel_width")? as u32;
let height = num(image, "pixel_height")? as u32;
let mut pixels = base64::engine::general_purpose::STANDARD
.decode(image.get("data")?.as_str()?)
.ok()?;
if pixels.len() != width as usize * height as usize * 4 {
return None;
}
import::unpremultiply(&mut pixels);
Some(RnoteItem::Bitmap {
width,
height,
pixels,
corners: rectangle_corners(value.get("rectangle")?)?,
})
}
fn vectorimage(value: &Value) -> Option<RnoteItem> {
Some(RnoteItem::Svg {
svg: value.get("svg_data")?.as_str()?.to_string(),
corners: rectangle_corners(value.get("rectangle")?)?,
})
}
/// Corners (upper left, upper right, lower right, lower left) of an Rnote rectangle: a
/// box around the origin, placed by its affine transformation.
fn rectangle_corners(rectangle: &Value) -> Option<[DVec2; 4]> {
let half = vec2(rectangle.get("cuboid")?.get("half_extents")?)?;
let affine = affine(rectangle)?;
Some(
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
.map(|(x, y)| apply(&affine, DVec2::new(x * half.x, y * half.y))),
)
}
/// Rnote's version as (major, minor, patch).
fn version(text: &str) -> Option<(u64, u64, u64)> {
let v = semver::Version::parse(text).ok()?;
Some((v.major, v.minor, v.patch))
}
/// Parses the (decompressed) JSON of an .rnote file.
pub fn parse(json: &[u8]) -> Result<RnoteDocument, String> {
let not_rnote = || t!("Keine Rnote-Datei").to_string();
let wrapper: Value = serde_json::from_slice(json).map_err(|_| not_rnote())?;
let version_text = wrapper
.get("version")
.and_then(Value::as_str)
.ok_or_else(not_rnote)?;
if version(version_text).is_none_or(|v| v < (0, 5, 10)) {
return Err(t!(
"Rnote-Datei zu alt ({version}): bitte in einem aktuellen Rnote öffnen und neu speichern",
version = version_text
)
.to_string());
}
let snapshot = wrapper
.get("data")
.and_then(|d| d.get("engine_snapshot"))
.ok_or_else(not_rnote)?;
let document = snapshot.get("document").ok_or_else(not_rnote)?;
// Since Rnote 0.13 format, background and layout are in `config`
let config = document.get("config").unwrap_or(document);
let format = config.get("format").unwrap_or(&Value::Null);
let dpi = num(format, "dpi").filter(|d| *d > 0.0).unwrap_or(96.0);
let format_size = DVec2::new(
num(format, "width").unwrap_or(dpi * 210.0 / 25.4).max(1.0),
num(format, "height").unwrap_or(dpi * 297.0 / 25.4).max(1.0),
);
let background = match config
.get("background")
.and_then(|b| b.get("pattern"))
.and_then(Value::as_str)
{
Some("lines") => PageBackground::Lined,
Some("grid" | "isometric_grid") => PageBackground::Grid5mm,
Some("dots" | "isometric_dots") => PageBackground::DotGrid,
_ => PageBackground::Blank,
};
let endless = matches!(
config.get("layout").and_then(Value::as_str),
Some("infinite" | "semi_infinite")
);
let strokes = snapshot
.get("stroke_components")
.and_then(Value::as_array)
.ok_or_else(not_rnote)?;
let chrono = snapshot
.get("chrono_components")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
// Slot maps are stored as slot arrays; the chrono components share the slot index
let mut ordered: Vec<(u64, Vec<RnoteItem>)> = Vec::new();
for (index, slot) in strokes.iter().enumerate() {
let Some(stroke) = slot.get("value").filter(|v| !v.is_null()) else {
continue;
};
let chrono = chrono.get(index).and_then(|c| c.get("value"));
let t = chrono.and_then(|c| c.get("t")).and_then(Value::as_u64);
let highlighter = chrono
.and_then(|c| c.get("layer"))
.and_then(Value::as_str)
.is_some_and(|l| l.eq_ignore_ascii_case("highlighter"));
let items = match variant(stroke) {
Some(("brushstroke", v)) => {
vec![brushstroke(v, highlighter).unwrap_or(RnoteItem::Unsupported)]
}
Some(("shapestroke", v)) => shapestroke(v, highlighter),
Some(("textstroke", v)) => vec![textstroke(v).unwrap_or(RnoteItem::Unsupported)],
Some(("bitmapimage", v)) => vec![bitmapimage(v).unwrap_or(RnoteItem::Unsupported)],
Some(("vectorimage", v)) => vec![vectorimage(v).unwrap_or(RnoteItem::Unsupported)],
_ => vec![RnoteItem::Unsupported],
};
ordered.push((t.unwrap_or(index as u64), items));
}
ordered.sort_by_key(|(t, _)| *t);
Ok(RnoteDocument {
format: format_size,
dpi,
origin: DVec2::new(
num(document, "x").unwrap_or(0.0),
num(document, "y").unwrap_or(0.0),
),
size: DVec2::new(
num(document, "width").unwrap_or(format_size.x),
num(document, "height").unwrap_or(format_size.y),
),
endless,
background,
items: ordered.into_iter().flat_map(|(_, items)| items).collect(),
})
}
/// Creates a new notebook from an Rnote file.
pub fn create_notebook_from_rnote(
base_dir: &Path,
title: &str,
path: &Path,
) -> Result<(Notebook, NotebookManifest, std::path::PathBuf, ImportReport), String> {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
let doc = parse(&import::gunzip(&bytes)?)?;
import::create_notebook(base_dir, title, |dir| Ok(convert(&doc, dir)))
}
/// A target page: where its Rnote page starts, and how Rnote pixels map onto it.
struct Target {
rnote_origin: DVec2,
origin: glam::Vec2,
scale: f32,
size: glam::Vec2,
strokes: Vec<StrokeData>,
}
impl Target {
fn map(&self, p: DVec2) -> glam::Vec2 {
self.origin + (p - self.rnote_origin).as_vec2() * self.scale
}
}
fn convert(doc: &RnoteDocument, notebook_dir: &Path) -> Imported {
let assets = crate::assets::AssetStore::new(notebook_dir);
let mut report = ImportReport::default();
// Page units per Rnote pixel at the document's dpi
let units_per_px = (crate::document::UNITS_PER_MM as f64 * 25.4 / doc.dpi) as f32;
let (columns, mut targets) = if doc.endless {
let target = Target {
rnote_origin: DVec2::ZERO,
origin: crate::document::ENDLESS_HOME,
scale: units_per_px,
size: crate::document::ENDLESS_PAGE_SIZE,
strokes: Vec::new(),
};
(1, vec![target])
} else {
// Same rules as for imported PDFs: portrait pages become A4, the content keeps its
// aspect ratio, centred horizontally and aligned at the top
let size_pt = (doc.format / doc.dpi * 72.0).as_vec2();
let size = crate::document::pdf_page_size(size_pt.x, size_pt.y);
let [origin, top_right, _, bottom_left] =
crate::document::pdf_placement(size_pt.x, size_pt.y, size);
let scale_pt =
((top_right.x - origin.x) / size_pt.x).min((bottom_left.y - origin.y) / size_pt.y);
let scale = scale_pt * (72.0 / doc.dpi) as f32;
// Rnote grows documents page by page; tolerate rounding in the stored size
let count = |length: f64, page: f64| ((length / page - 0.01).ceil() as usize).max(1);
let columns = count(doc.size.x, doc.format.x);
let rows = count(doc.size.y, doc.format.y);
let rows = rows.min((MAX_PAGES / columns).max(1));
let targets = (0..rows * columns)
.map(|i| Target {
rnote_origin: doc.origin
+ DVec2::new((i % columns) as f64, (i / columns) as f64) * doc.format,
origin,
scale,
size,
strokes: Vec::new(),
})
.collect();
(columns, targets)
};
let rows = targets.len() / columns;
let mut next_id: u64 = 1;
for item in &doc.items {
let Some(anchor) = item.anchor() else {
report.skipped += 1;
continue;
};
let cell = ((anchor - doc.origin) / doc.format).floor();
let index = if doc.endless {
0
} else {
let column = (cell.x.max(0.0) as usize).min(columns - 1);
let row = (cell.y.max(0.0) as usize).min(rows - 1);
row * columns + column
};
let target = &mut targets[index];
let id = next_id;
next_id += 1;
let stroke = match item {
RnoteItem::Ink {
color,
highlighter,
points,
widths,
} => {
let points: Vec<glam::Vec2> = points.iter().map(|&p| target.map(p)).collect();
let widths: Vec<f32> = widths.iter().map(|&w| w as f32 * target.scale).collect();
let (width, pressures) = import::fit_widths(&widths);
import::ink_stroke(id, *color, *highlighter, &points, width, &pressures)
}
RnoteItem::Text {
content,
size,
mono,
affine,
max_width,
color,
} => {
let font = if *mono {
TextFont::Mono
} else {
TextFont::Sans
};
import::text_stroke(
id,
content,
*size as f32 * target.scale,
font,
target.map(affine[2]),
[affine[0].as_vec2(), affine[1].as_vec2()],
max_width.map(|w| w as f32 * target.scale),
*color,
)
}
RnoteItem::Bitmap {
width,
height,
pixels,
corners,
} => match assets.import_rgba(*width, *height, pixels.clone()) {
Ok(image) => import::image_stroke(id, image.asset, corners.map(|c| target.map(c))),
Err(_) => {
report.skipped += 1;
continue;
}
},
RnoteItem::Svg { svg, corners } => {
let corners = corners.map(|c| target.map(c));
let size = glam::Vec2::new(
corners[0].distance(corners[1]),
corners[0].distance(corners[3]),
);
let image = import::rasterize_svg(svg, size).and_then(|(width, height, pixels)| {
assets.import_rgba(width, height, pixels).ok()
});
match image {
Some(image) => import::image_stroke(id, image.asset, corners),
None => {
report.skipped += 1;
continue;
}
}
}
RnoteItem::Unsupported => unreachable!("has no anchor"),
};
target.strokes.push(stroke);
}
let pages = targets
.into_iter()
.enumerate()
.map(|(i, target)| {
import::page(
i as u64 + 1,
target.size,
doc.background,
None,
target.strokes,
)
})
.collect();
Imported {
pages,
next_stroke_id: next_id,
layout: doc.endless.then_some(NotebookLayout::Endless),
report,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::StrokeTool;
use serde_json::json;
use std::io::Write;
fn slot(value: Value) -> Value {
json!({ "value": value, "version": 1 })
}
/// A small Rnote 0.15 document: two A4 pages at 96 dpi.
fn sample(layout: &str) -> Value {
let brush = json!({ "brushstroke": {
"path": {
"start": { "pos": [100.0, 100.0], "pressure": 0.5 },
"segments": [
{ "lineto": { "end": { "pos": [120.0, 100.0], "pressure": 1.0 } } },
{ "cubbezto": { "cp1": [130.0, 110.0], "cp2": [140.0, 110.0],
"end": { "pos": [150.0, 100.0], "pressure": 0.25 } } }
]
},
"style": { "smooth": { "stroke_width": 4.0,
"stroke_color": { "r": 0.0, "g": 0.0, "b": 1.0, "a": 1.0 },
"fill_color": null, "pressure_curve": "linear" } }
}});
let rect = json!({ "shapestroke": {
"shape": { "rect": { "cuboid": { "half_extents": [50.0, 20.0] },
"affine": [1.0, 0.0, 0.0, 1.0, 300.0, 1300.0] } },
"style": { "rough": { "stroke_width": 2.0,
"stroke_color": { "r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0 } } }
}});
let text = json!({ "textstroke": {
"text": "Hallo",
"affine": [1.0, 0.0, 0.0, 1.0, 50.0, 60.0],
"text_style": { "font_family": "serif", "font_size": 32.0, "max_width": null,
"color": { "r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0 } }
}});
let pixels = base64::engine::general_purpose::STANDARD.encode([128u8, 0, 0, 128].repeat(4));
let bitmap = json!({ "bitmapimage": {
"image": { "data": pixels, "pixel_width": 2, "pixel_height": 2,
"memory_format": "R8g8b8a8Premultiplied" },
"rectangle": { "cuboid": { "half_extents": [10.0, 10.0] },
"affine": [1.0, 0.0, 0.0, 1.0, 400.0, 400.0] }
}});
let svg = json!({ "vectorimage": {
"svg_data": "<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10'><rect width='10' height='10' fill='green'/></svg>",
"intrinsic_size": [10.0, 10.0],
"rectangle": { "cuboid": { "half_extents": [40.0, 40.0] },
"affine": [1.0, 0.0, 0.0, 1.0, 400.0, 1500.0] }
}});
let marker = json!({ "brushstroke": {
"path": { "start": { "pos": [10.0, 10.0], "pressure": 1.0 },
"segments": [ { "lineto": { "end": { "pos": [90.0, 10.0], "pressure": 1.0 } } } ] },
"style": { "smooth": { "stroke_width": 12.0,
"stroke_color": { "r": 1.0, "g": 1.0, "b": 0.0, "a": 1.0 },
"fill_color": null, "pressure_curve": "const" } }
}});
let chrono = |t: u32, layer: &str| slot(json!({ "t": t, "layer": layer }));
json!({
"version": "0.15.0",
"data": { "engine_snapshot": {
"document": {
"config": {
"format": { "width": 793.701, "height": 1122.52, "dpi": 96.0 },
"background": { "pattern": "grid" },
"layout": layout
},
"x": 0.0, "y": 0.0, "width": 793.701, "height": 2245.04
},
"camera": {},
// Slot 0 is the slot map's unused sentinel; the text was drawn first
"stroke_components": [
{ "value": null, "version": 0 },
slot(brush), slot(rect), slot(text), slot(bitmap), slot(svg), slot(marker)
],
"chrono_components": [
{ "value": null, "version": 0 },
chrono(2, "user_layer"), chrono(3, "user_layer"), chrono(1, "user_layer"),
chrono(4, "image"), chrono(5, "image"), chrono(6, "highlighter")
],
"chrono_counter": 7
}}
})
}
fn write_rnote(dir: &Path, value: &Value) -> std::path::PathBuf {
let path = dir.join("Skizze.rnote");
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(value.to_string().as_bytes()).unwrap();
std::fs::write(&path, encoder.finish().unwrap()).unwrap();
path
}
#[test]
fn test_parse_rnote() {
let doc = parse(sample("fixed_size").to_string().as_bytes()).unwrap();
assert_eq!(doc.background, PageBackground::Grid5mm);
assert!(!doc.endless);
// In drawing order: the text was drawn first
assert!(matches!(doc.items[0], RnoteItem::Text { .. }));
match &doc.items[1] {
RnoteItem::Ink {
points,
widths,
color,
highlighter,
} => {
assert!(!highlighter);
assert_eq!(*color, [0.0, 0.0, 1.0, 1.0]);
assert_eq!(points.first(), Some(&DVec2::new(100.0, 100.0)));
assert_eq!(points.last(), Some(&DVec2::new(150.0, 100.0)));
assert!(points.len() > 3, "the curve is sampled");
assert_eq!(widths[0], 2.0);
assert_eq!(widths[1], 4.0);
assert_eq!(*widths.last().unwrap(), 1.0);
}
other => panic!("expected ink, got {other:?}"),
}
match &doc.items[2] {
RnoteItem::Ink { points, .. } => {
assert_eq!(points.len(), 5);
assert_eq!(points[0], DVec2::new(250.0, 1280.0));
assert_eq!(points[2], DVec2::new(350.0, 1320.0));
}
other => panic!("expected the rectangle, got {other:?}"),
}
match &doc.items[3] {
RnoteItem::Bitmap { pixels, .. } => assert_eq!(&pixels[..4], &[255, 0, 0, 128]),
other => panic!("expected a bitmap, got {other:?}"),
}
assert!(matches!(doc.items[4], RnoteItem::Svg { .. }));
assert!(matches!(
doc.items[5],
RnoteItem::Ink {
highlighter: true,
..
}
));
}
#[test]
fn test_older_formats() {
// Before 0.13: format, background and layout directly in the document; before 0.15:
// transformations as column-major 3×3 matrix
let mut value = sample("continuous_vertical");
value["version"] = json!("0.9.4");
let document = &mut value["data"]["engine_snapshot"]["document"];
let config = document["config"].take();
for key in ["format", "background", "layout"] {
document[key] = config[key].clone();
}
document.as_object_mut().unwrap().remove("config");
let text =
&mut value["data"]["engine_snapshot"]["stroke_components"][3]["value"]["textstroke"];
text.as_object_mut().unwrap().remove("affine");
text["transform"] = json!({ "affine": [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 50.0, 60.0, 1.0] });
let doc = parse(value.to_string().as_bytes()).unwrap();
assert_eq!(doc.background, PageBackground::Grid5mm);
match &doc.items[0] {
RnoteItem::Text { affine, .. } => assert_eq!(affine[2], DVec2::new(50.0, 60.0)),
other => panic!("expected a text, got {other:?}"),
}
value["version"] = json!("0.5.5");
assert!(parse(value.to_string().as_bytes()).is_err());
assert!(parse(b"{\"a\": 1}").is_err());
}
#[test]
fn test_create_notebook_from_rnote() {
let temp_dir = tempfile::tempdir().unwrap();
let source_dir = tempfile::tempdir().unwrap();
let path = write_rnote(source_dir.path(), &sample("fixed_size"));
let (notebook, manifest, dir, report) =
create_notebook_from_rnote(temp_dir.path(), "Skizze", &path).unwrap();
assert_eq!(report, ImportReport::default());
assert_eq!(manifest.layout, NotebookManifest::new("x", "x").layout);
let (mut reloaded, _) = crate::storage::load_notebook(&dir).unwrap();
crate::storage::load_all_pages(&dir, &mut reloaded).unwrap();
assert_eq!(notebook.pages.len(), 2);
let [first, second] = &reloaded.pages[..] else {
panic!("two pages expected");
};
assert_eq!(first.background, PageBackground::Grid5mm);
// Text, pen, bitmap, highlighter on page 1; rectangle and SVG on page 2
assert_eq!(first.strokes.len(), 4);
assert_eq!(second.strokes.len(), 2);
assert!(first.strokes[0].is_text());
let pen = &first.strokes[1];
assert_eq!(pen.tool, StrokeTool::Pen);
// 96 dpi: 100 px = 26.46 mm
assert!((pen.points[0].position.x - 264.6).abs() < 0.5);
assert!(first.strokes[2].is_image());
assert_eq!(first.strokes[3].tool, StrokeTool::Highlighter);
// The rectangle sits on page 2 in page coordinates: 250 px - 0 / 1280 px - 1122.52 px
let rect = &second.strokes[0];
assert!((rect.points[0].position.x - 661.5).abs() < 0.5);
assert!((rect.points[0].position.y - 416.7).abs() < 0.5);
assert!(second.strokes[1].is_image());
}
#[test]
fn test_infinite_documents_become_endless() {
let temp_dir = tempfile::tempdir().unwrap();
let source_dir = tempfile::tempdir().unwrap();
let path = write_rnote(source_dir.path(), &sample("infinite"));
let (notebook, manifest, _, _) =
create_notebook_from_rnote(temp_dir.path(), "Skizze", &path).unwrap();
assert_eq!(manifest.layout, Some(NotebookLayout::Endless));
assert_eq!(notebook.pages.len(), 1);
assert!(notebook.pages[0].is_endless());
assert_eq!(notebook.pages[0].strokes.len(), 6);
let pen = &notebook.pages[0].strokes[1];
let home = crate::document::ENDLESS_HOME;
assert!((pen.points[0].position.x - home.x - 264.6).abs() < 0.5);
}
}
+904
View File
@@ -0,0 +1,904 @@
//! Import of Saber notebooks (.sbn2, .sba, old .sbn) (Issue #47).
//!
//! A `.sbn2` file is a BSON document; images and PDFs lie next to it as `<file>.sbn2.0`,
//! `<file>.sbn2.1`, …; a `.sba` archive is a zip with `main.sbn2` and its assets, and the old
//! `.sbn` is the same document as JSON. Pages (`z`) hold strokes (`s`), images (`i`), typed
//! text as a Quill delta (`q`) and an optional background image or PDF page (`b`). Points are
//! three little-endian `f32` (x, y, pressure) since format version 13.
//!
//! Stroke widths follow perfect_freehand: the diameter is `size · (1 − thinning · (1 − 2p))`.
use std::collections::HashMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use crate::document::{Notebook, PageBackground, PdfBackground};
use crate::import::{self, ImportReport, Imported};
use crate::storage::{NotebookManifest, StrokeData};
use crate::t;
use crate::text::TextFont;
/// Saber's default page size, used when a page has none.
const DEFAULT_PAGE: glam::Vec2 = glam::Vec2::new(1000.0, 1400.0);
/// Is the file by its name a Saber notebook?
pub fn is_saber_path(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
["sbn2", "sba", "sbn"]
.iter()
.any(|x| e.eq_ignore_ascii_case(x))
})
}
/// A BSON (or JSON) value as far as Saber uses it.
#[derive(Debug, Clone, PartialEq)]
pub enum Bson {
Null,
Bool(bool),
Int(i64),
Double(f64),
Str(String),
Binary(Vec<u8>),
Array(Vec<Bson>),
Doc(Vec<(String, Bson)>),
}
impl Bson {
pub fn get(&self, key: &str) -> Option<&Bson> {
match self {
Bson::Doc(fields) => fields
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v)
.filter(|v| **v != Bson::Null),
_ => None,
}
}
pub fn as_f64(&self) -> Option<f64> {
match *self {
Bson::Double(v) if v.is_finite() => Some(v),
Bson::Int(v) => Some(v as f64),
_ => None,
}
}
pub fn as_i64(&self) -> Option<i64> {
match *self {
Bson::Int(v) => Some(v),
Bson::Double(v) if v.is_finite() => Some(v as i64),
_ => None,
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
Bson::Str(s) => Some(s),
_ => None,
}
}
pub fn as_array(&self) -> &[Bson] {
match self {
Bson::Array(items) => items,
_ => &[],
}
}
fn num(&self, key: &str) -> Option<f64> {
self.get(key)?.as_f64()
}
fn bool(&self, key: &str) -> Option<bool> {
match self.get(key)? {
Bson::Bool(b) => Some(*b),
_ => None,
}
}
fn from_json(value: serde_json::Value) -> Bson {
use serde_json::Value;
match value {
Value::Null => Bson::Null,
Value::Bool(b) => Bson::Bool(b),
Value::Number(n) => n
.as_i64()
.map(Bson::Int)
.unwrap_or_else(|| Bson::Double(n.as_f64().unwrap_or(0.0))),
Value::String(s) => Bson::Str(s),
Value::Array(items) => Bson::Array(items.into_iter().map(Bson::from_json).collect()),
Value::Object(map) => Bson::Doc(
map.into_iter()
.map(|(k, v)| (k, Bson::from_json(v)))
.collect(),
),
}
}
}
/// Reads a BSON document.
pub fn parse_bson(bytes: &[u8]) -> Result<Bson, String> {
let mut pos = 0;
document(bytes, &mut pos, 0).ok_or_else(|| t!("Keine Saber-Datei").to_string())
}
fn take<'a>(bytes: &'a [u8], pos: &mut usize, n: usize) -> Option<&'a [u8]> {
let slice = bytes.get(*pos..pos.checked_add(n)?)?;
*pos += n;
Some(slice)
}
fn int32(bytes: &[u8], pos: &mut usize) -> Option<i32> {
Some(i32::from_le_bytes(take(bytes, pos, 4)?.try_into().ok()?))
}
fn cstring(bytes: &[u8], pos: &mut usize) -> Option<String> {
let len = bytes.get(*pos..)?.iter().position(|&b| b == 0)?;
let s = String::from_utf8_lossy(take(bytes, pos, len)?).into_owned();
*pos += 1;
Some(s)
}
/// A document (or array, which is a document with the keys "0", "1", …).
fn document(bytes: &[u8], pos: &mut usize, depth: usize) -> Option<Bson> {
if depth > 64 {
return None;
}
let start = *pos;
let len = usize::try_from(int32(bytes, pos)?).ok()?;
let end = start.checked_add(len)?;
if end > bytes.len() || len < 5 {
return None;
}
let mut fields = Vec::new();
loop {
let kind = *bytes.get(*pos)?;
*pos += 1;
if kind == 0 {
break;
}
let key = cstring(bytes, pos)?;
let value = match kind {
0x01 => Bson::Double(f64::from_le_bytes(take(bytes, pos, 8)?.try_into().ok()?)),
0x02 => {
let n = usize::try_from(int32(bytes, pos)?).ok()?;
let s = take(bytes, pos, n)?;
Bson::Str(String::from_utf8_lossy(s.strip_suffix(&[0]).unwrap_or(s)).into_owned())
}
0x03 => document(bytes, pos, depth + 1)?,
0x04 => match document(bytes, pos, depth + 1)? {
Bson::Doc(items) => Bson::Array(items.into_iter().map(|(_, v)| v).collect()),
other => other,
},
0x05 => {
let n = usize::try_from(int32(bytes, pos)?).ok()?;
*pos += 1; // subtype
Bson::Binary(take(bytes, pos, n)?.to_vec())
}
0x07 => {
take(bytes, pos, 12)?;
Bson::Null
}
0x08 => Bson::Bool(*take(bytes, pos, 1)?.first()? != 0),
0x09 | 0x11 | 0x12 => {
Bson::Int(i64::from_le_bytes(take(bytes, pos, 8)?.try_into().ok()?))
}
0x0A | 0x06 | 0x7F | 0xFF => Bson::Null,
0x10 => Bson::Int(int32(bytes, pos)? as i64),
_ => return None,
};
fields.push((key, value));
}
(*pos == end).then_some(Bson::Doc(fields))
}
/// Reads the files of a zip archive (stored or deflated), at most `limit` bytes in total.
fn unzip(bytes: &[u8], limit: usize) -> Result<HashMap<String, Vec<u8>>, String> {
let broken = || t!("Datei beschädigt: {err}", err = "zip").to_string();
let u16_at = |at: usize| -> Option<usize> {
Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?) as usize)
};
let u32_at = |at: usize| -> Option<usize> {
Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?) as usize)
};
// End of central directory: the last record with its signature
let eocd = (0..bytes.len().saturating_sub(21))
.rev()
.find(|&i| bytes[i..].starts_with(&[0x50, 0x4b, 0x05, 0x06]))
.ok_or_else(broken)?;
let count = u16_at(eocd + 10).ok_or_else(broken)?;
let mut at = u32_at(eocd + 16).ok_or_else(broken)?;
let mut files = HashMap::new();
let mut total = 0;
for _ in 0..count {
let entry = (|| {
if !bytes.get(at..)?.starts_with(&[0x50, 0x4b, 0x01, 0x02]) {
return None;
}
let method = u16_at(at + 10)?;
let compressed = u32_at(at + 20)?;
let size = u32_at(at + 24)?;
let name_len = u16_at(at + 28)?;
let skip = name_len + u16_at(at + 30)? + u16_at(at + 32)?;
let local = u32_at(at + 42)?;
let name = String::from_utf8_lossy(bytes.get(at + 46..at + 46 + name_len)?);
at += 46 + skip;
let data_start = local + 30 + u16_at(local + 26)? + u16_at(local + 28)?;
let data = bytes.get(data_start..data_start.checked_add(compressed)?)?;
Some((name.into_owned(), method, size, data))
})();
let (name, method, size, data) = entry.ok_or_else(broken)?;
total += size;
if total > limit {
return Err(broken());
}
let content = match method {
0 => data.to_vec(),
8 => {
let mut out = Vec::with_capacity(size);
flate2::read::DeflateDecoder::new(data)
.take(size as u64)
.read_to_end(&mut out)
.map_err(|_| broken())?;
out
}
_ => return Err(broken()),
};
files.insert(name, content);
}
Ok(files)
}
/// The document and a way to get its assets (by index).
struct Source {
doc: Bson,
assets: Box<dyn Fn(usize) -> Option<Vec<u8>>>,
}
fn load(path: &Path) -> Result<Source, String> {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
if bytes.starts_with(&[0x50, 0x4b, 0x03, 0x04]) {
let files = unzip(&bytes, 1024 * 1024 * 1024)?;
let main = files
.iter()
.find(|(name, _)| name.ends_with(".sbn2"))
.map(|(name, data)| (name.clone(), data))
.ok_or_else(|| t!("Keine Saber-Datei").to_string())?;
let doc = parse_bson(main.1)?;
let prefix = main.0;
return Ok(Source {
doc,
assets: Box::new(move |i| files.get(&format!("{prefix}.{i}")).cloned()),
});
}
// .sbn2 is BSON, the old .sbn JSON
let doc = match parse_bson(&bytes) {
Ok(doc) => doc,
Err(err) => serde_json::from_slice(&bytes)
.map(Bson::from_json)
.map_err(|_| err)?,
};
let base = path.to_path_buf();
Ok(Source {
doc,
assets: Box::new(move |i| {
std::fs::read(PathBuf::from(format!("{}.{i}", base.display()))).ok()
}),
})
}
/// Color stored as ARGB integer.
fn argb(value: Option<&Bson>) -> [f32; 4] {
let v = value.and_then(Bson::as_i64).unwrap_or(0xff000000) as u32;
[
((v >> 16) & 0xff) as f32 / 255.0,
((v >> 8) & 0xff) as f32 / 255.0,
(v & 0xff) as f32 / 255.0,
(v >> 24) as f32 / 255.0,
]
}
/// Points of a stroke: `f32` triples (version 13 on) or `{x, y, p}` documents.
fn points(stroke: &Bson) -> Vec<(glam::Vec2, Option<f32>)> {
let offset = glam::Vec2::new(
stroke.num("ox").unwrap_or(0.0) as f32,
stroke.num("oy").unwrap_or(0.0) as f32,
);
let Some(list) = stroke.get("p") else {
return Vec::new();
};
list.as_array()
.iter()
.filter_map(|point| match point {
Bson::Binary(data) => {
let values: Vec<f32> = data
.as_chunks::<4>()
.0
.iter()
.map(|b| f32::from_le_bytes(*b))
.collect();
match values[..] {
[x, y] => Some((glam::Vec2::new(x, y), None)),
[x, y, p, ..] => Some((glam::Vec2::new(x, y), Some(p))),
_ => None,
}
}
doc @ Bson::Doc(_) => Some((
glam::Vec2::new(doc.num("x")? as f32, doc.num("y")? as f32),
doc.num("p").map(|p| p as f32),
)),
_ => None,
})
.filter(|(p, _)| p.is_finite())
.map(|(p, pressure)| (p + offset, pressure))
.collect()
}
/// Outline points of a shape stroke (circle or rectangle).
fn shape_points(stroke: &Bson, shape: &str) -> Vec<glam::Vec2> {
let n = |k| stroke.num(k).unwrap_or(0.0) as f32;
match shape {
"circle" => {
let (center, r) = (glam::Vec2::new(n("cx"), n("cy")), n("r"));
(0..=64)
.map(|i| {
let a = i as f32 / 64.0 * std::f32::consts::TAU;
center + glam::Vec2::new(a.cos(), a.sin()) * r
})
.collect()
}
"rect" => {
let (l, t, w, h) = (n("rl"), n("rt"), n("rw"), n("rh"));
[(l, t), (l + w, t), (l + w, t + h), (l, t + h), (l, t)]
.map(|(x, y)| glam::Vec2::new(x, y))
.to_vec()
}
_ => Vec::new(),
}
}
/// Typed text of a page: the inserted strings of its Quill delta.
fn quill_text(delta: &Bson) -> String {
let text: String = delta
.as_array()
.iter()
.filter_map(|op| op.get("insert")?.as_str())
.collect();
text.trim_end().to_string()
}
/// Creates a new notebook from a Saber file.
pub fn create_notebook_from_saber(
base_dir: &Path,
title: &str,
path: &Path,
) -> Result<(Notebook, NotebookManifest, PathBuf, ImportReport), String> {
let source = load(path)?;
// The very first files were only a list of strokes; versions up to 7 kept strokes and
// images outside the pages, which is read as well
if !matches!(source.doc, Bson::Doc(_)) {
return Err(t!(
"Saber-Datei zu alt: bitte in einem aktuellen Saber öffnen und neu speichern"
)
.to_string());
}
import::create_notebook(base_dir, title, |dir| Ok(convert(&source, dir)))
}
/// A PDF of the notebook in our asset folder.
struct PdfSource {
asset: String,
sizes: Vec<(f32, f32)>,
}
fn convert(source: &Source, notebook_dir: &Path) -> Imported {
let doc = &source.doc;
let assets = crate::assets::AssetStore::new(notebook_dir);
let mut report = ImportReport::default();
let mut pdfs: HashMap<usize, Option<PdfSource>> = HashMap::new();
let background = match doc.get("p").and_then(Bson::as_str) {
Some("lined" | "college" | "college-rtl" | "cornell") => PageBackground::Lined,
Some("grid") => PageBackground::Grid5mm,
Some("dots") => PageBackground::DotGrid,
_ => PageBackground::Blank,
};
let line_height = doc.num("l").unwrap_or(40.0) as f32;
let mut xpages: Vec<&Bson> = doc
.get("z")
.map(Bson::as_array)
.unwrap_or_default()
.iter()
.collect();
let empty = Bson::Doc(Vec::new());
if xpages.is_empty() {
xpages.push(&empty);
}
// Before version 8 strokes and images were stored outside the pages, with a page index
let old_items = |key: &str, index: usize| -> Vec<&Bson> {
doc.get(key)
.map(Bson::as_array)
.unwrap_or_default()
.iter()
.filter(|item| item.num("i").unwrap_or(0.0) as usize == index)
.collect()
};
let mut next_id: u64 = 1;
let mut new_id = || {
next_id += 1;
next_id - 1
};
let mut pages = Vec::with_capacity(xpages.len());
for (index, xpage) in xpages.iter().enumerate() {
// Pages stored as [width, height] in very old files
let page_size = match xpage {
Bson::Array(wh) => glam::Vec2::new(
wh.first().and_then(Bson::as_f64).unwrap_or(1000.0) as f32,
wh.get(1).and_then(Bson::as_f64).unwrap_or(1400.0) as f32,
),
_ => glam::Vec2::new(
xpage.num("w").map_or(DEFAULT_PAGE.x, |w| w as f32),
xpage.num("h").map_or(DEFAULT_PAGE.y, |h| h as f32),
),
}
.max(glam::Vec2::ONE);
// Same rules as for imported PDFs: portrait pages become A4, the content keeps its
// aspect ratio, centred horizontally and aligned at the top
let size = crate::document::pdf_page_size(page_size.x, page_size.y);
let placement = crate::document::pdf_placement(page_size.x, page_size.y, size);
let scale = (placement[1].x - placement[0].x) / page_size.x;
let map = |p: glam::Vec2| placement[0] + p * scale;
let mut strokes: Vec<StrokeData> = Vec::new();
let mut pdf_background = None;
if let Some(image) = xpage.get("b") {
let natural = glam::Vec2::new(
image.num("nw").unwrap_or(0.0) as f32,
image.num("nh").unwrap_or(0.0) as f32,
);
let corners = if natural.min_element() > 0.0 {
crate::document::pdf_placement(natural.x, natural.y, size)
} else {
placement
};
let asset = image.num("a").map(|a| a as usize);
if image.get("e").and_then(Bson::as_str) == Some(".pdf") {
let pdf = asset.and_then(|a| {
pdfs.entry(a)
.or_insert_with(|| {
let bytes = (source.assets)(a)?;
let sizes = crate::pdf_doc::page_sizes(bytes.clone()).ok()?;
let asset = assets.import_pdf(&bytes).ok()?;
Some(PdfSource { asset, sizes })
})
.as_ref()
});
let page = image.num("pdfi").unwrap_or(0.0) as u32;
match pdf.and_then(|pdf| Some((pdf, *pdf.sizes.get(page as usize)?))) {
Some((pdf, (width_pt, height_pt))) => {
pdf_background = Some(PdfBackground {
asset: pdf.asset.clone(),
page,
width_pt,
height_pt,
})
}
None => report.skipped += 1,
}
} else {
match image_asset(image, source, &assets, corners) {
Some(asset) => strokes.push(import::image_stroke(new_id(), asset, corners)),
None => report.skipped += 1,
}
}
}
let images = xpage.get("i").map(Bson::as_array).unwrap_or_default();
for image in images.iter().chain(old_items("i", index)) {
let rect = ["x", "y", "w", "h"].map(|k| image.num(k).unwrap_or(0.0) as f32);
let corners = [
glam::Vec2::new(rect[0], rect[1]),
glam::Vec2::new(rect[0] + rect[2], rect[1]),
glam::Vec2::new(rect[0] + rect[2], rect[1] + rect[3]),
glam::Vec2::new(rect[0], rect[1] + rect[3]),
]
.map(map);
match image_asset(image, source, &assets, corners) {
Some(asset) => strokes.push(import::image_stroke(new_id(), asset, corners)),
None => report.skipped += 1,
}
}
if let Some(delta) = xpage.get("q") {
let text = quill_text(delta);
if !text.is_empty() {
// Saber's text area starts 1.2 lines down, half a line in from the sides
let origin = glam::Vec2::new(line_height * 0.5, line_height * 1.2);
strokes.push(import::text_stroke(
new_id(),
&text,
line_height * 0.8 * scale,
TextFont::Sans,
map(origin),
[glam::Vec2::X, glam::Vec2::Y],
Some((page_size.x - line_height) * scale),
[0.0, 0.0, 0.0, 1.0],
));
}
}
let page_strokes = xpage.get("s").map(Bson::as_array).unwrap_or_default();
for stroke in page_strokes.iter().chain(old_items("s", index)) {
let tool = stroke
.get("ty")
.and_then(Bson::as_str)
.unwrap_or("fountainPen");
if tool == "Eraser" || tool == "LaserPointer" {
continue;
}
let size = stroke.num("s").unwrap_or(10.0) as f32;
let thinning = stroke.num("t").unwrap_or(0.5) as f32;
let pressure_enabled = stroke.bool("pe").unwrap_or(true);
let (points, pressures): (Vec<glam::Vec2>, Vec<Option<f32>>) =
match stroke.get("shape").and_then(Bson::as_str) {
Some(shape) => shape_points(stroke, shape)
.into_iter()
.map(|p| (p, None))
.unzip(),
None => points(stroke).into_iter().unzip(),
};
if points.is_empty() {
report.skipped += 1;
continue;
}
let widths: Vec<f32> = pressures
.iter()
.map(|p| {
let p = p
.filter(|_| pressure_enabled)
.unwrap_or(0.5)
.clamp(0.0, 1.0);
(size * (1.0 - thinning * (1.0 - 2.0 * p))).max(size * 0.05) * scale
})
.collect();
let (width, pressures) = import::fit_widths(&widths);
let points: Vec<glam::Vec2> = points.into_iter().map(map).collect();
strokes.push(import::ink_stroke(
new_id(),
argb(stroke.get("c")),
tool == "Highlighter",
&points,
width,
&pressures,
));
}
pages.push(import::page(
index as u64 + 1,
size,
background,
pdf_background,
strokes,
));
}
Imported {
pages,
next_stroke_id: next_id,
layout: None,
report,
}
}
/// Stores the image of `image` (asset file, inline bytes or SVG text) in our asset folder.
fn image_asset(
image: &Bson,
source: &Source,
assets: &crate::assets::AssetStore,
corners: [glam::Vec2; 4],
) -> Option<String> {
let bytes = match (image.num("a"), image.get("b")) {
(Some(a), _) => (source.assets)(a as usize)?,
(None, Some(Bson::Binary(b))) => b.clone(),
(None, Some(Bson::Array(b))) => b.iter().filter_map(|v| Some(v.as_i64()? as u8)).collect(),
(None, Some(Bson::Str(svg))) => svg.clone().into_bytes(),
_ => return None,
};
if image.get("e").and_then(Bson::as_str) == Some(".svg") {
let size = glam::Vec2::new(
corners[0].distance(corners[1]),
corners[0].distance(corners[3]),
);
let (w, h, pixels) = import::rasterize_svg(std::str::from_utf8(&bytes).ok()?, size)?;
return assets.import_rgba(w, h, pixels).ok().map(|i| i.asset);
}
assets.import_bytes(&bytes).ok().map(|i| i.asset)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::StrokeTool;
/// BSON encoder for test files.
fn encode(value: &Bson) -> Vec<u8> {
fn doc(fields: &[(String, Bson)]) -> Vec<u8> {
let mut body = Vec::new();
for (key, value) in fields {
let (kind, data): (u8, Vec<u8>) = match value {
Bson::Null => (0x0A, Vec::new()),
Bson::Bool(b) => (0x08, vec![*b as u8]),
Bson::Int(i) => (0x12, i.to_le_bytes().to_vec()),
Bson::Double(d) => (0x01, d.to_le_bytes().to_vec()),
Bson::Str(s) => {
let mut d = ((s.len() + 1) as i32).to_le_bytes().to_vec();
d.extend(s.as_bytes());
d.push(0);
(0x02, d)
}
Bson::Binary(b) => {
let mut d = (b.len() as i32).to_le_bytes().to_vec();
d.push(0);
d.extend(b);
(0x05, d)
}
Bson::Array(items) => {
let fields: Vec<(String, Bson)> = items
.iter()
.enumerate()
.map(|(i, v)| (i.to_string(), v.clone()))
.collect();
(0x04, doc(&fields))
}
Bson::Doc(fields) => (0x03, doc(fields)),
};
body.push(kind);
body.extend(key.as_bytes());
body.push(0);
body.extend(data);
}
let mut out = ((body.len() + 5) as i32).to_le_bytes().to_vec();
out.extend(body);
out.push(0);
out
}
match value {
Bson::Doc(fields) => doc(fields),
_ => panic!("only documents"),
}
}
fn d(fields: &[(&str, Bson)]) -> Bson {
Bson::Doc(
fields
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect(),
)
}
fn point(x: f32, y: f32, p: f32) -> Bson {
Bson::Binary([x, y, p].iter().flat_map(|v| v.to_le_bytes()).collect())
}
fn png() -> Vec<u8> {
let image = image::RgbaImage::from_pixel(4, 2, image::Rgba([200, 30, 30, 255]));
let mut png = Vec::new();
image
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
png
}
/// Version 19 document: page 1 with ink, highlighter, a circle, an image and text;
/// page 2 with a PDF page as background. Assets: 0 = PNG, 1 = PDF.
fn sample() -> Bson {
let pen = d(&[
(
"p",
Bson::Array(vec![point(100.0, 100.0, 1.0), point(200.0, 100.0, 0.25)]),
),
("ty", Bson::Str("fountainPen".into())),
("pe", Bson::Bool(true)),
("c", Bson::Int(0xff0000ff)),
("s", Bson::Double(10.0)),
("t", Bson::Double(0.5)),
("ox", Bson::Double(10.0)),
]);
let marker = d(&[
(
"p",
Bson::Array(vec![point(0.0, 300.0, 0.5), point(300.0, 300.0, 0.5)]),
),
("ty", Bson::Str("Highlighter".into())),
("c", Bson::Int(0x80ffff00)),
("s", Bson::Double(40.0)),
]);
let circle = d(&[
("shape", Bson::Str("circle".into())),
("cx", Bson::Double(500.0)),
("cy", Bson::Double(500.0)),
("r", Bson::Double(50.0)),
("c", Bson::Int(0xff00ff00)),
("s", Bson::Double(5.0)),
]);
let image = d(&[
("e", Bson::Str(".png".into())),
("a", Bson::Int(0)),
("x", Bson::Double(600.0)),
("y", Bson::Double(100.0)),
("w", Bson::Double(200.0)),
("h", Bson::Double(100.0)),
]);
let text = Bson::Array(vec![
d(&[("insert", Bson::Str("Hallo ".into()))]),
d(&[("insert", Bson::Str("Welt\n".into()))]),
]);
let page1 = d(&[
("w", Bson::Double(1000.0)),
("h", Bson::Double(1400.0)),
("s", Bson::Array(vec![pen, marker, circle])),
("i", Bson::Array(vec![image])),
("q", text),
]);
let pdf = d(&[
("e", Bson::Str(".pdf".into())),
("a", Bson::Int(1)),
("pdfi", Bson::Int(1)),
("nw", Bson::Double(842.0)),
("nh", Bson::Double(595.0)),
]);
let page2 = d(&[
("w", Bson::Double(1000.0)),
("h", Bson::Double(707.0)),
("b", pdf),
]);
d(&[
("v", Bson::Int(19)),
("p", Bson::Str("dots".into())),
("l", Bson::Int(40)),
("z", Bson::Array(vec![page1, page2])),
])
}
fn sample_pdf() -> Vec<u8> {
crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (842.0, 595.0, 0)])
}
fn check(notebook: &Notebook, report: &ImportReport) {
assert_eq!(*report, ImportReport::default());
assert_eq!(notebook.pages.len(), 2);
let first = &notebook.pages[0];
assert_eq!(first.background, PageBackground::DotGrid);
// Image, text, then the strokes
assert_eq!(first.strokes.len(), 5);
assert!(first.strokes[0].is_image());
assert_eq!(
first.strokes[1].text.as_ref().unwrap().content,
"Hallo Welt"
);
let pen = &first.strokes[2];
assert_eq!(pen.tool, StrokeTool::Pen);
assert_eq!(pen.color, [0.0, 0.0, 1.0, 1.0]);
// 1000 Saber units = 210 mm; the offset moves the stroke by 10
assert!((pen.points[0].position.x - 231.0).abs() < 0.1);
// perfect_freehand: size 10, thinning 0.5 gives 15 at full pressure and 7.5 at a
// quarter, scaled by 2.1
let drawn: Vec<f32> = pen
.points
.iter()
.map(|s| pen.width * (0.5 + s.pressure))
.collect();
assert!((drawn[0] - 31.5).abs() < 0.01 && (drawn[1] - 15.75).abs() < 0.01);
assert_eq!(first.strokes[3].tool, StrokeTool::Highlighter);
assert!((first.strokes[3].color[3] - 128.0 / 255.0).abs() < 1e-3);
assert_eq!(first.strokes[4].points.len(), 65);
let bg = notebook.pages[1].pdf_background.as_ref().unwrap();
assert_eq!(bg.page, 1);
assert!(notebook.pages[1].size.x > notebook.pages[1].size.y);
}
#[test]
fn test_parse_bson() {
let value = sample();
assert_eq!(parse_bson(&encode(&value)).unwrap(), value);
assert!(parse_bson(b"\x05\x00\x00").is_err());
let mut broken = encode(&value);
broken.truncate(broken.len() - 3);
assert!(parse_bson(&broken).is_err());
}
#[test]
fn test_import_sbn2_with_assets_next_to_it() {
let temp_dir = tempfile::tempdir().unwrap();
let source_dir = tempfile::tempdir().unwrap();
let path = source_dir.path().join("Mathe.sbn2");
std::fs::write(&path, encode(&sample())).unwrap();
std::fs::write(source_dir.path().join("Mathe.sbn2.0"), png()).unwrap();
std::fs::write(source_dir.path().join("Mathe.sbn2.1"), sample_pdf()).unwrap();
let (notebook, _, dir, report) =
create_notebook_from_saber(temp_dir.path(), "Mathe", &path).unwrap();
check(&notebook, &report);
// Saved and loaded again
let (mut reloaded, _) = crate::storage::load_notebook(&dir).unwrap();
crate::storage::load_all_pages(&dir, &mut reloaded).unwrap();
check(&reloaded, &report);
}
/// Zip archive with stored (uncompressed) files.
fn zip(files: &[(&str, Vec<u8>)]) -> Vec<u8> {
let mut out = Vec::new();
let mut central = Vec::new();
for (name, data) in files {
let offset = out.len() as u32;
let header = |sig: &[u8], central: bool| {
let mut h = sig.to_vec();
if central {
h.extend([20, 0]);
}
h.extend([20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
h.extend((data.len() as u32).to_le_bytes());
h.extend((data.len() as u32).to_le_bytes());
h.extend((name.len() as u16).to_le_bytes());
h.extend([0, 0]);
if central {
h.extend([0; 10]);
h.extend(offset.to_le_bytes());
}
h.extend(name.as_bytes());
h
};
out.extend(header(&[0x50, 0x4b, 0x03, 0x04], false));
out.extend(data);
central.extend(header(&[0x50, 0x4b, 0x01, 0x02], true));
}
let start = out.len() as u32;
out.extend(&central);
out.extend([0x50, 0x4b, 0x05, 0x06, 0, 0, 0, 0]);
out.extend((files.len() as u16).to_le_bytes());
out.extend((files.len() as u16).to_le_bytes());
out.extend((central.len() as u32).to_le_bytes());
out.extend(start.to_le_bytes());
out.extend([0, 0]);
out
}
#[test]
fn test_import_sba_archive() {
let temp_dir = tempfile::tempdir().unwrap();
let source_dir = tempfile::tempdir().unwrap();
let path = source_dir.path().join("Mathe.sba");
let archive = zip(&[
("main.sbn2", encode(&sample())),
("main.sbn2.0", png()),
("main.sbn2.1", sample_pdf()),
]);
std::fs::write(&path, archive).unwrap();
let (notebook, _, _, report) =
create_notebook_from_saber(temp_dir.path(), "Mathe", &path).unwrap();
check(&notebook, &report);
}
#[test]
fn test_old_json_files() {
let temp_dir = tempfile::tempdir().unwrap();
let source_dir = tempfile::tempdir().unwrap();
// Version 7: strokes outside the pages, points as {x, y, p}
let path = source_dir.path().join("Alt.sbn");
std::fs::write(
&path,
r#"{"v": 7, "z": [{"w": 1000, "h": 1400}, {"w": 1000, "h": 1400}],
"s": [{"i": 1, "p": [{"x": 0, "y": 0, "p": 0.5}, {"x": 100, "y": 0, "p": 0.5}]}]}"#,
)
.unwrap();
let (notebook, _, _, _) =
create_notebook_from_saber(temp_dir.path(), "Alt", &path).unwrap();
assert_eq!(notebook.pages[0].strokes.len(), 0);
assert_eq!(notebook.pages[1].strokes.len(), 1);
// The very first format: just a list of strokes
let path = source_dir.path().join("Uralt.sbn");
std::fs::write(&path, "[]").unwrap();
assert!(create_notebook_from_saber(temp_dir.path(), "Uralt", &path).is_err());
}
}
+1 -1
View File
@@ -2385,7 +2385,7 @@ mod tests {
create_new_notebook(temp_dir.path(), "Neue Seiten").unwrap();
let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60));
// Wie Taste N in main.rs: neue Seite anhängen, Strich darauf, speichern
// Wie Taste N in app.rs: neue Seite anhängen, Strich darauf, speichern
notebook.next_page();
let idx = notebook.current_page_index;
notebook.pages[idx] = page_with_one_stroke(notebook.pages[idx].id, PageBackground::Lined);
+253 -69
View File
@@ -110,8 +110,10 @@ pub const PEN_WIDTH_PRESETS: [(f32, &str); 4] = [
pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] =
[(4.0, "4.0 mm"), (6.0, "6.0 mm"), (8.0, "8.0 mm")];
/// Eraser radii in mm, small to large; the toolbar button cycles through them (Issue #38).
/// Eraser size presets as radius in mm, small to large (Issue #38).
pub const ERASER_RADII_MM: [f32; 4] = [1.5, 3.0, 6.0, 12.0];
/// Range of the eraser size slider (diameter in mm).
pub const ERASER_DIAMETER_RANGE_MM: (f32, f32) = (2.0, 30.0);
/// Abbildung zwischen Seitenkoordinaten und logischen Bildschirmpunkten (egui), vom
/// Hauptprogramm je Bild aus Ansicht und Zoom gesetzt.
@@ -336,7 +338,7 @@ pub enum UiAction {
/// Bilddatei auf der aktuellen Seite einfügen
InsertImageFile(std::path::PathBuf),
/// PDF als neues Notizbuch importieren
ImportPdf(std::path::PathBuf),
ImportFile(std::path::PathBuf),
/// Aktuelle Seite als Bild oder SVG exportieren
ExportPage(PageExportKind),
/// Titel einer Seite setzen (leer = entfernen)
@@ -376,6 +378,8 @@ pub struct UiState {
pub toolbar_rect: Option<egui::Rect>,
/// Öffnungszustand des Einstellungs-Popovers für den Stift
pub is_pen_menu_open: bool,
/// Eraser size menu next to the size button (Issue #38)
pub is_eraser_menu_open: bool,
/// Öffnungszustand des Einstellungs-Popovers für den Textmarker
pub is_highlighter_menu_open: bool,
/// Bildschirm-Rechteck des geöffneten Einstellungs-Popovers
@@ -514,6 +518,7 @@ impl Default for UiState {
current_zoom: 1.0,
toolbar_rect: None,
is_pen_menu_open: false,
is_eraser_menu_open: false,
is_highlighter_menu_open: false,
popover_rect: None,
is_document_menu_open: false,
@@ -595,21 +600,14 @@ impl UiState {
/// Abstand der Werkzeugleiste zum Fensterrand.
pub const SIDEBAR_MARGIN: f32 = 10.0;
/// Index of the eraser size in [`ERASER_RADII_MM`] closest to the current radius.
pub fn eraser_size_index(&self) -> usize {
(0..ERASER_RADII_MM.len())
.min_by(|&a, &b| {
let da = (ERASER_RADII_MM[a] - self.eraser_radius_mm).abs();
let db = (ERASER_RADII_MM[b] - self.eraser_radius_mm).abs();
da.total_cmp(&db)
})
.unwrap_or(1)
}
/// Sets the eraser radius to the closest available size (e.g. from the settings file).
/// Sets the eraser radius within the slider range (e.g. from the settings file).
pub fn set_eraser_radius_mm(&mut self, mm: f32) {
self.eraser_radius_mm = mm;
self.eraser_radius_mm = ERASER_RADII_MM[self.eraser_size_index()];
let (min, max) = ERASER_DIAMETER_RANGE_MM;
self.eraser_radius_mm = if mm.is_finite() {
mm.clamp(min / 2.0, max / 2.0)
} else {
ERASER_RADII_MM[1]
};
}
/// Eraser radius in page units.
@@ -624,6 +622,7 @@ impl UiState {
self.context_menu = None;
self.is_text_color_menu_open = false;
self.is_pen_menu_open = false;
self.is_eraser_menu_open = false;
self.is_highlighter_menu_open = false;
self.is_more_menu_open = false;
self.show_page_jump_popup = false;
@@ -670,9 +669,9 @@ impl UiState {
self.open_file_picker(PickerKind::Image);
}
/// Öffnet die Dateiauswahl „PDF importieren“ im zuletzt benutzten Ordner.
pub fn open_pdf_picker(&mut self) {
self.open_file_picker(PickerKind::Pdf);
/// Opens the file picker "Import" (PDF, Xournal++, Rnote, Saber) in the last used folder.
pub fn open_import_picker(&mut self) {
self.open_file_picker(PickerKind::Import);
}
fn open_file_picker(&mut self, kind: PickerKind) {
@@ -964,6 +963,61 @@ fn stroke_preview_button(
response.on_hover_text(t!("Stärke auf {width} setzen", width = label_text))
}
/// Eraser size button of the eraser menu: a circle in proportion to the size and its diameter.
fn eraser_size_button(ui: &mut egui::Ui, is_active: bool, radius_mm: f32) -> egui::Response {
let (rect, response) = ui.allocate_exact_size(egui::vec2(66.0, 40.0), egui::Sense::click());
let label = format!("{} mm", format_mm(radius_mm * 2.0));
if ui.is_rect_visible(rect) {
let painter = ui.painter();
let (bg, border) = if is_active {
(
egui::Color32::from_rgba_premultiplied(50, 85, 150, 220),
egui::Color32::from_rgba_unmultiplied(120, 175, 255, 200),
)
} else if response.hovered() {
(
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 16),
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 25),
)
} else {
(
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 8),
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 14),
)
};
painter.rect_filled(rect, egui::Rounding::same(6.0), bg);
painter.rect_stroke(
rect,
egui::Rounding::same(6.0),
egui::Stroke::new(1.0_f32, border),
);
let text_color = if is_active {
egui::Color32::WHITE
} else {
egui::Color32::from_gray(190)
};
painter.circle_stroke(
egui::pos2(rect.center().x, rect.center().y - 5.0),
(radius_mm * 0.9).clamp(1.5, 11.0),
egui::Stroke::new(1.5_f32, text_color),
);
painter.text(
egui::pos2(rect.center().x, rect.max.y - 7.5),
egui::Align2::CENTER_CENTER,
&label,
egui::FontId::proportional(10.0),
text_color,
);
}
response.on_hover_text(t!("Radiergröße {size} setzen", size = label))
}
/// Millimeters without trailing zeros: 3, 1.5, 2.25.
fn format_mm(mm: f32) -> String {
let text = format!("{:.2}", mm);
text.trim_end_matches('0').trim_end_matches('.').to_string()
}
/// Inhalt eines Symbol-Knopfs der Werkzeugleiste.
#[derive(Clone, Copy)]
enum SidebarIcon<'a> {
@@ -1379,8 +1433,36 @@ fn color_circle_swatch(
/// Spalten der Seitenauswahl.
const PAGE_PICKER_COLUMNS: usize = 4;
/// Größe einer Kachel der Seitenauswahl: A4-Vorschau plus Zeile für die Seitenzahl.
const PAGE_TILE: egui::Vec2 = egui::vec2(66.0, 66.0 * PAGE_HEIGHT / PAGE_WIDTH + 18.0);
/// Breite einer Kachel der Seitenauswahl; darunter kommt eine Zeile für die Seitenzahl.
const PAGE_TILE_WIDTH: f32 = 66.0;
const PAGE_TILE_LABEL: f32 = 18.0;
/// Size of a page picker tile: as high as the tallest page of the notebook needs, so that
/// slides (landscape PDFs) get flat tiles without an empty strip (Issue #40).
fn page_tile_size(notebook: &Notebook) -> egui::Vec2 {
let aspect = notebook
.pages
.iter()
.map(|p| p.size.y / p.size.x.max(1.0))
.fold(0.0_f32, f32::max);
let aspect = if aspect > 0.0 {
aspect.clamp(0.3, 2.0)
} else {
PAGE_HEIGHT / PAGE_WIDTH
};
egui::vec2(PAGE_TILE_WIDTH, PAGE_TILE_WIDTH * aspect + PAGE_TILE_LABEL)
}
/// Rectangle of a page of `page_size` in a thumbnail area: as large as possible, centered
/// horizontally and at the top.
fn thumbnail_sheet(page_size: glam::Vec2, rect: egui::Rect) -> egui::Rect {
let scale = (rect.width() / page_size.x).min(rect.height() / page_size.y);
let size = egui::vec2(page_size.x, page_size.y) * scale;
egui::Rect::from_min_size(
rect.min + egui::vec2((rect.width() - size.x) / 2.0, 0.0),
size,
)
}
/// Höchstzahl gezeichneter Punkte pro Vorschau (dünnt volle Seiten aus).
const THUMBNAIL_POINT_BUDGET: usize = 1500;
@@ -1396,12 +1478,8 @@ pub fn page_thumbnail_shapes(
adapt_ink: bool,
pdf_texture: Option<egui::TextureId>,
) -> Vec<egui::Shape> {
let scale = (rect.width() / page.size.x).min(rect.height() / page.size.y);
let size = egui::vec2(page.size.x, page.size.y) * scale;
let sheet = egui::Rect::from_min_size(
rect.min + egui::vec2((rect.width() - size.x) / 2.0, 0.0),
size,
);
let sheet = thumbnail_sheet(page.size, rect);
let scale = sheet.width() / page.size.x;
let mut shapes = vec![egui::Shape::rect_filled(
sheet,
egui::Rounding::same(3.0),
@@ -1632,14 +1710,13 @@ fn page_thumbnail(
page: &Page,
number: usize,
is_current: bool,
tile: egui::Vec2,
wanted_pdfs: &mut Vec<crate::document::PdfBackground>,
) -> egui::Response {
let (rect, response) = ui.allocate_exact_size(PAGE_TILE, egui::Sense::click());
let (rect, response) = ui.allocate_exact_size(tile, egui::Sense::click());
if ui.is_rect_visible(rect) {
let thumb = egui::Rect::from_min_size(
rect.min,
egui::vec2(PAGE_TILE.x, PAGE_TILE.x * PAGE_HEIGHT / PAGE_WIDTH),
);
let thumb =
egui::Rect::from_min_size(rect.min, egui::vec2(tile.x, tile.y - PAGE_TILE_LABEL));
let pdf_texture = match &page.pdf_background {
Some(pdf) => match state.pdf_thumbnails.get(&pdf.key()) {
Some(texture) => texture.as_ref().map(|t| t.0.id()),
@@ -1658,9 +1735,11 @@ fn page_thumbnail(
state.adapt_ink_colors,
pdf_texture,
));
// Frame and marks follow the page itself, not the whole tile
let sheet = thumbnail_sheet(page.size, thumb);
if !page.is_loaded {
painter.text(
thumb.center(),
sheet.center(),
egui::Align2::CENTER_CENTER,
"…",
egui::FontId::proportional(16.0),
@@ -1669,7 +1748,7 @@ fn page_thumbnail(
}
// Lesezeichen-Ecke für benannte Seiten
if page.title.is_some() {
let corner = thumb.right_top();
let corner = sheet.right_top();
painter.add(egui::Shape::convex_polygon(
vec![
corner + egui::vec2(-14.0, 0.0),
@@ -1687,7 +1766,7 @@ fn page_thumbnail(
} else {
egui::Stroke::new(1.0_f32, egui::Color32::from_white_alpha(60))
};
painter.rect_stroke(thumb, egui::Rounding::same(3.0), border);
painter.rect_stroke(sheet, egui::Rounding::same(3.0), border);
painter.text(
egui::pos2(rect.center().x, rect.max.y - 8.0),
egui::Align2::CENTER_CENTER,
@@ -2748,14 +2827,16 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve
t!("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)"),
t!("Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen."),
),
PickerKind::Pdf => (
t!("PDF importieren"),
t!("📄 PDF importieren"),
PickerKind::Import => (
t!("Importieren"),
t!("📄 Importieren"),
"📄",
t!("Keine Ordner oder PDF-Dateien"),
t!("Keine Ordner oder importierbaren Dateien (PDF, Xournal++, Rnote, Saber)"),
t!(
"Das PDF wird als neues Notizbuch angelegt, eine Seite je PDF-Seite. \
Beim Export liegen deine Notizen über den Originalseiten."
"Die Datei wird als neues Notizbuch angelegt. PDF: eine Seite je PDF-Seite, \
beim Export liegen deine Notizen über den Originalseiten. \
Xournal++ (.xopp, .xoj), Rnote (.rnote) und Saber (.sbn2, .sba): Striche, Formen, \
Texte und Bilder werden übernommen, bei Xournal++ und Saber auch PDF-Hintergründe."
),
),
};
@@ -2845,7 +2926,7 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve
state.last_picker_dir = state.file_picker.as_ref().map(|p| p.dir.clone());
actions.push(match picker_kind {
PickerKind::Image => UiAction::InsertImageFile(path),
PickerKind::Pdf => UiAction::ImportPdf(path),
PickerKind::Import => UiAction::ImportFile(path),
});
close = true;
}
@@ -3030,6 +3111,7 @@ pub fn render_ui(
context_menu(ctx, state, &mut actions);
let mut pen_btn_rect = None;
let mut eraser_btn_rect = None;
let mut high_btn_rect = None;
let mut text_color_btn_rect = None;
let mut more_btn_rect = None;
@@ -3430,31 +3512,27 @@ pub fn render_ui(
}
used += swatch + spacing;
}
// Eraser size: tapping cycles small → large (Issue #38)
let index = state.eraser_size_index();
let diameter = format!("{}", ERASER_RADII_MM[index] * 2.0);
// Eraser size: opens a menu like the pens (Issue #38)
let diameter = format_mm(state.eraser_radius_mm * 2.0);
let tooltip = t!(
"Radiergröße {size} mm – tippen für die nächste Größe",
"Radiergröße {size} mm – tippen zum Ändern",
size = diameter
);
let dot = [2.5_f32, 4.0, 6.0, 8.5][index].min(swatch * 0.45);
if sidebar_button(
let dot = (state.eraser_radius_mm * 0.7).clamp(2.0, swatch * 0.45);
let response = sidebar_button(
ui,
swatch,
SidebarIcon::Dot(dot),
false,
state.is_eraser_menu_open,
true,
None,
&tooltip,
)
.clicked()
{
let next = (index + 1) % ERASER_RADII_MM.len();
state.eraser_radius_mm = ERASER_RADII_MM[next];
state.set_export_message(t!(
"Radiergröße {size} mm",
size = format!("{}", ERASER_RADII_MM[next] * 2.0)
));
);
eraser_btn_rect = Some(response.rect);
if response.clicked() {
let open = !state.is_eraser_menu_open;
state.close_overlays();
state.is_eraser_menu_open = open;
}
used += swatch + spacing;
}
@@ -3776,6 +3854,84 @@ pub fn render_ui(
frame.response.rect
});
popover_rect = Some(pop_response.inner);
}
} else if state.is_eraser_menu_open && state.active_tool == ActiveTool::Eraser {
if let Some(btn_rect) = eraser_btn_rect {
let (popover_pos, pivot) =
popover_placement(state.sidebar_side, area_response.inner, btn_rect);
let pop_response = egui::Area::new(egui::Id::new("eraser_settings_popover"))
.fixed_pos(popover_pos)
.pivot(pivot)
.constrain(true)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
let frame = popover_frame().show(ui, |ui| {
ui.set_width(320.0);
touch_friendly_spacing(ui);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(t!("Radierer"))
.strong()
.color(egui::Color32::WHITE)
.size(12.5),
);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if close_button(ui).clicked() {
state.is_eraser_menu_open = false;
}
},
);
});
ui.add_space(8.0);
ui.label(
egui::RichText::new(t!("GRÖSSE"))
.size(10.5)
.strong()
.color(egui::Color32::from_gray(160)),
);
ui.add_space(3.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(6.0, 0.0);
for radius in ERASER_RADII_MM {
let is_sel = (state.eraser_radius_mm - radius).abs() < 0.05;
if eraser_size_button(ui, is_sel, radius).clicked() {
state.eraser_radius_mm = radius;
}
}
});
ui.add_space(10.0);
ui.label(
egui::RichText::new(t!("STUFENLOSE GRÖSSE"))
.size(10.5)
.strong()
.color(egui::Color32::from_gray(160)),
);
ui.add_space(2.0);
let mut diameter = state.eraser_radius_mm * 2.0;
if ui
.add(
egui::Slider::new(
&mut diameter,
ERASER_DIAMETER_RANGE_MM.0..=ERASER_DIAMETER_RANGE_MM.1,
)
.suffix(" mm")
.step_by(0.5),
)
.changed()
{
state.set_eraser_radius_mm(diameter / 2.0);
}
});
frame.response.rect
});
popover_rect = Some(pop_response.inner);
}
} else if state.is_highlighter_menu_open {
@@ -4041,11 +4197,11 @@ pub fn render_ui(
state.open_image_picker();
}
if ui
.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 PDF importieren …")))
.on_hover_text(t!("Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen und darauf schreiben"))
.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 Importieren (PDF, Xournal++, Rnote, Saber) …")))
.on_hover_text(t!("Vorlesungsfolien, Übungsblätter, Formulare oder Notizbücher aus Xournal++, Rnote und Saber als neues Notizbuch öffnen und darauf schreiben"))
.clicked()
{
state.open_pdf_picker();
state.open_import_picker();
}
section(ui, t!("HINTERGRUND DIESER SEITE"));
@@ -4295,7 +4451,9 @@ pub fn render_ui(
popover_frame()
.show(ui, |ui| {
// Breite = Kachelraster; sonst nimmt die Kopfzeile die ganze Fensterbreite ein
ui.set_width(PAGE_PICKER_COLUMNS as f32 * (PAGE_TILE.x + 8.0) - 8.0);
ui.set_width(
PAGE_PICKER_COLUMNS as f32 * (PAGE_TILE_WIDTH + 8.0) - 8.0,
);
touch_friendly_spacing(ui);
let title = t!("Seiten ({count})", count = notebook.pages.len());
if dialog_header(ui, &title, true) {
@@ -4370,6 +4528,7 @@ pub fn render_ui(
.max_height(420.0)
.show(ui, |ui| {
let mut wanted_pdfs = Vec::new();
let tile = page_tile_size(notebook);
egui::Grid::new("page_picker_grid")
.spacing([8.0, 8.0])
.show(ui, |ui| {
@@ -4383,6 +4542,7 @@ pub fn render_ui(
page,
idx + 1,
is_current,
tile,
&mut wanted_pdfs,
)
.clicked()
@@ -4428,6 +4588,7 @@ pub fn render_ui(
// Automatisches Schließen der Popover bei Klick außerhalb von Leiste und Popover
if (state.is_pen_menu_open
|| state.is_eraser_menu_open
|| state.is_highlighter_menu_open
|| state.is_text_color_menu_open
|| state.is_more_menu_open
@@ -4439,6 +4600,7 @@ pub fn render_ui(
let in_popover = state.popover_rect.is_some_and(|r| r.contains(pos));
if !in_toolbar && !in_popover {
state.is_pen_menu_open = false;
state.is_eraser_menu_open = false;
state.is_highlighter_menu_open = false;
state.is_text_color_menu_open = false;
state.is_more_menu_open = false;
@@ -5818,17 +5980,20 @@ mod tests {
}
#[test]
fn test_eraser_size_snaps_to_the_available_sizes() {
fn test_eraser_size_stays_in_the_slider_range() {
let mut state = UiState::new();
assert_eq!(state.eraser_radius(), 30.0);
// Values from the settings file snap to the closest size
// Values from the settings file stay within the slider range
state.set_eraser_radius_mm(7.0);
assert_eq!(state.eraser_radius_mm, 6.0);
assert_eq!(state.eraser_size_index(), 2);
assert_eq!(state.eraser_radius_mm, 7.0);
state.set_eraser_radius_mm(100.0);
assert_eq!(state.eraser_radius_mm, 12.0);
assert_eq!(state.eraser_radius_mm, ERASER_DIAMETER_RANGE_MM.1 / 2.0);
state.set_eraser_radius_mm(0.0);
assert_eq!(state.eraser_radius_mm, ERASER_DIAMETER_RANGE_MM.0 / 2.0);
state.set_eraser_radius_mm(f32::NAN);
assert!(ERASER_RADII_MM.contains(&state.eraser_radius_mm));
assert_eq!(state.eraser_radius_mm, 3.0);
assert_eq!(format_mm(3.0), "3");
assert_eq!(format_mm(2.5), "2.5");
}
#[test]
@@ -5970,7 +6135,7 @@ mod tests {
const UI_SOURCES: [(&str, &str); 4] = [
("ui.rs", include_str!("ui.rs")),
("selection.rs", include_str!("selection.rs")),
("main.rs", include_str!("main.rs")),
("app.rs", include_str!("app.rs")),
("file_picker.rs", include_str!("file_picker.rs")),
];
@@ -6504,6 +6669,25 @@ mod tests {
assert!(rect.contains_rect(shapes[1].visual_bounding_rect()));
}
#[test]
fn test_page_tiles_follow_the_page_shape() {
// Slides only: flat tiles without an empty strip below the page (Issue #40)
let mut notebook = Notebook::new();
notebook.pages[0].size = glam::Vec2::new(2970.0, 1671.0);
let flat = page_tile_size(&notebook);
assert!((flat.y - PAGE_TILE_LABEL - flat.x * 1671.0 / 2970.0).abs() < 0.01);
let thumb =
egui::Rect::from_min_size(egui::Pos2::ZERO, flat - egui::vec2(0.0, PAGE_TILE_LABEL));
let sheet = thumbnail_sheet(notebook.pages[0].size, thumb);
assert!((sheet.height() - thumb.height()).abs() < 0.01);
// A portrait page among them: tiles as high as A4, slides at the top
let index = notebook.add_page(PageBackground::Blank);
notebook.pages[index].size = crate::document::A4_SIZE;
let tall = page_tile_size(&notebook);
assert!((tall.y - PAGE_TILE_LABEL - tall.x * PAGE_HEIGHT / PAGE_WIDTH).abs() < 0.01);
}
#[test]
fn test_page_picker_requests_loading_of_unloaded_pages() {
let updater = crate::updater::UpdaterController::new();
+27
View File
@@ -389,6 +389,14 @@ impl Fling {
velocity
}
/// The glide continues from `now` (after [`Self::release`] with the time the finger was
/// lifted, which may lie a little in the past).
pub fn restart_clock(&mut self, now: std::time::Instant) {
if self.active {
self.last = Some(now);
}
}
/// Hält an (neue Berührung, Mausklick, Mausrad).
pub fn stop(&mut self) {
*self = Self::default();
@@ -430,6 +438,25 @@ mod tests {
PageStack::new(vec![A4; count], current)
}
#[test]
fn test_fling_measures_with_event_times_handled_late() {
use std::time::{Duration, Instant};
let t0 = Instant::now();
let ms = |n: u64| t0 + Duration::from_millis(n);
let mut fling = Fling::default();
// The finger moved until 100 ms and was lifted at 105 ms; the app only got to the
// lift 300 ms later, so the glide starts from there
for i in 0..=10 {
fling.track(Vec2::new(0.0, -30.0), ms(i * 10));
}
let velocity = fling.release(ms(105));
assert!(velocity.y < -2000.0, "{velocity:?}");
assert!(fling.is_active());
fling.restart_clock(ms(405));
let first = fling.step(ms(421)).unwrap();
assert!(first.y < -20.0 && first.y > -60.0, "{first:?}");
}
#[test]
fn test_fling_glides_and_slows_down() {
use std::time::{Duration, Instant};
+708
View File
@@ -0,0 +1,708 @@
//! Import of Xournal++ (.xopp) and Xournal (.xoj) notebooks (Issue #47).
//!
//! Both formats are gzip-compressed XML: `<page>` elements with a `<background>` and
//! `<layer>`s holding `<stroke>`, `<text>` and `<image>` elements; coordinates are PDF points.
//! The import creates a new notebook: one page per Xournal page, all layers flattened in
//! order. PDF backgrounds are taken over if the PDF can still be found; LaTeX formulas
//! (`<teximage>`) have no counterpart and are skipped.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use base64::Engine;
use quick_xml::events::{BytesStart, Event};
use quick_xml::XmlVersion;
use crate::document::{Notebook, PageBackground, PdfBackground};
use crate::import::{self, ImportReport, Imported};
use crate::storage::{NotebookManifest, StrokeData};
use crate::t;
use crate::text::TextFont;
/// Is the file by its name a Xournal++ or Xournal notebook?
pub fn is_xopp_path(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("xopp") || e.eq_ignore_ascii_case("xoj"))
}
/// Parsed Xournal document, still in PDF points.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct XoppDocument {
pub pages: Vec<XoppPage>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct XoppPage {
pub width: f32,
pub height: f32,
pub background: XoppBackground,
pub items: Vec<XoppItem>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub enum XoppBackground {
#[default]
Plain,
Lined,
Graph,
Dotted,
/// Page `page` (from 0) of a PDF; `file` is only given on the first PDF page
Pdf {
file: Option<String>,
page: u32,
},
/// Image file as background
Pixmap {
file: String,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum XoppItem {
Stroke {
highlighter: bool,
color: [f32; 4],
/// Nominal width, then optionally one width per point (or per segment in Xournal)
widths: Vec<f32>,
points: Vec<glam::Vec2>,
},
Text {
mono: bool,
size: f32,
pos: glam::Vec2,
color: [f32; 4],
content: String,
},
/// Embedded image (base64 PNG) with its rectangle (left, top, right, bottom)
Image { rect: [f32; 4], data: String },
/// Element without a counterpart (LaTeX formula)
Unsupported,
}
/// Reads the (usually gzip-compressed) XML of a Xournal file.
pub fn decompress(bytes: &[u8]) -> Result<String, String> {
String::from_utf8(import::gunzip(bytes)?).map_err(|_| t!("Keine Xournal-Datei").to_string())
}
fn attributes(e: &BytesStart) -> HashMap<String, String> {
e.attributes()
.flatten()
.filter_map(|a| {
let value = a.normalized_value(XmlVersion::Implicit1_0).ok()?;
Some((
String::from_utf8_lossy(a.key.as_ref()).into_owned(),
value.into_owned(),
))
})
.collect()
}
fn number(attrs: &HashMap<String, String>, key: &str) -> Option<f32> {
attrs
.get(key)?
.trim()
.parse::<f32>()
.ok()
.filter(|v| v.is_finite())
}
/// Xournal colors: `#rrggbbaa` or one of the names of the original Xournal palette.
fn parse_color(value: &str) -> [f32; 4] {
let rgba: u32 = match value.trim() {
"black" => 0x000000ff,
"blue" => 0x3333ccff,
"red" => 0xff0000ff,
"green" => 0x008000ff,
"gray" => 0x808080ff,
"lightblue" => 0x00c0ffff,
"lightgreen" => 0x00ff00ff,
"magenta" => 0xff00ffff,
"orange" => 0xff8000ff,
"yellow" => 0xffff00ff,
"white" => 0xffffffff,
hex => {
let hex = hex.trim_start_matches('#');
match (u32::from_str_radix(hex, 16), hex.len()) {
(Ok(v), 8) => v,
(Ok(v), 6) => (v << 8) | 0xff,
_ => 0x000000ff,
}
}
};
[
(rgba >> 24) as f32 / 255.0,
((rgba >> 16) & 0xff) as f32 / 255.0,
((rgba >> 8) & 0xff) as f32 / 255.0,
(rgba & 0xff) as f32 / 255.0,
]
}
fn numbers(text: &str) -> Vec<f32> {
text.split_ascii_whitespace()
.filter_map(|v| v.parse::<f32>().ok())
.filter(|v| v.is_finite())
.collect()
}
/// Element whose text content is still being collected.
enum Open {
Stroke(HashMap<String, String>),
Text(HashMap<String, String>),
Image(HashMap<String, String>),
}
/// Parses the XML of a Xournal file.
pub fn parse(xml: &str) -> Result<XoppDocument, String> {
let mut reader = quick_xml::Reader::from_str(xml);
let mut doc = XoppDocument::default();
let mut seen_root = false;
let mut open: Option<Open> = None;
let mut content = String::new();
loop {
let event = reader
.read_event()
.map_err(|e| t!("Datei beschädigt: {err}", err = e))?;
match event {
Event::Start(ref e) | Event::Empty(ref e) => {
let is_start = matches!(event, Event::Start(_));
let attrs = attributes(e);
match e.name().as_ref() {
b"xournal" => seen_root = true,
b"page" => doc.pages.push(XoppPage {
width: number(&attrs, "width").unwrap_or(595.0).max(1.0),
height: number(&attrs, "height").unwrap_or(842.0).max(1.0),
..Default::default()
}),
b"background" => {
if let Some(page) = doc.pages.last_mut() {
page.background = parse_background(&attrs);
}
}
b"stroke" if is_start => open = Some(Open::Stroke(attrs)),
b"text" if is_start => open = Some(Open::Text(attrs)),
b"image" if is_start => open = Some(Open::Image(attrs)),
b"teximage" => {
if let Some(page) = doc.pages.last_mut() {
page.items.push(XoppItem::Unsupported);
}
}
_ => {}
}
content.clear();
}
Event::Text(t) if open.is_some() => {
content.push_str(&t.decode().map_err(|e| e.to_string())?);
}
Event::CData(t) if open.is_some() => {
content.push_str(&t.decode().map_err(|e| e.to_string())?);
}
Event::GeneralRef(r) if open.is_some() => {
if let Ok(Some(c)) = r.resolve_char_ref() {
content.push(c);
} else if let Ok(name) = r.decode() {
if let Some(s) = quick_xml::escape::resolve_predefined_entity(&name) {
content.push_str(s);
}
}
}
Event::End(_) => {
if let (Some(element), Some(page)) = (open.take(), doc.pages.last_mut()) {
page.items.extend(finish_item(element, &content));
}
content.clear();
}
Event::Eof => break,
_ => {}
}
}
if !seen_root {
return Err(t!("Keine Xournal-Datei").to_string());
}
Ok(doc)
}
fn parse_background(attrs: &HashMap<String, String>) -> XoppBackground {
match attrs.get("type").map(String::as_str) {
Some("pdf") => {
// Xournal++ has written page numbers like "2ll"; only the digits count
let page = attrs
.get("pageno")
.map(|v| {
v.chars()
.take_while(char::is_ascii_digit)
.collect::<String>()
})
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(1);
XoppBackground::Pdf {
file: attrs.get("filename").filter(|f| !f.is_empty()).cloned(),
page: page.saturating_sub(1),
}
}
Some("pixmap") => match attrs.get("filename") {
Some(file) if !file.is_empty() => XoppBackground::Pixmap { file: file.clone() },
_ => XoppBackground::Plain,
},
_ => match attrs.get("style").map(String::as_str) {
Some("lined" | "ruled") => XoppBackground::Lined,
Some("graph" | "isograph") => XoppBackground::Graph,
Some("dotted" | "isodotted") => XoppBackground::Dotted,
_ => XoppBackground::Plain,
},
}
}
fn finish_item(element: Open, content: &str) -> Option<XoppItem> {
match element {
Open::Stroke(attrs) => {
let coords = numbers(content);
let points: Vec<glam::Vec2> = coords
.as_chunks::<2>()
.0
.iter()
.map(|&[x, y]| glam::Vec2::new(x, y))
.collect();
if points.is_empty() {
return None;
}
Some(XoppItem::Stroke {
highlighter: attrs.get("tool").is_some_and(|t| t == "highlighter"),
color: parse_color(attrs.get("color").map_or("black", String::as_str)),
widths: attrs.get("width").map(|w| numbers(w)).unwrap_or_default(),
points,
})
}
Open::Text(attrs) => {
if content.trim().is_empty() {
return None;
}
Some(XoppItem::Text {
mono: attrs
.get("font")
.is_some_and(|f| f.to_ascii_lowercase().contains("mono")),
size: number(&attrs, "size").unwrap_or(12.0).max(1.0),
pos: glam::Vec2::new(
number(&attrs, "x").unwrap_or(0.0),
number(&attrs, "y").unwrap_or(0.0),
),
color: parse_color(attrs.get("color").map_or("black", String::as_str)),
content: content.to_string(),
})
}
Open::Image(attrs) => {
let rect = ["left", "top", "right", "bottom"].map(|k| number(&attrs, k));
match rect {
[Some(l), Some(t), Some(r), Some(b)] => Some(XoppItem::Image {
rect: [l, t, r, b],
data: content.to_string(),
}),
_ => Some(XoppItem::Unsupported),
}
}
}
}
/// Our stroke width and sample pressures for Xournal widths (nominal width, then optionally
/// one width per point, or per segment in the original Xournal).
fn stroke_widths(widths: &[f32], count: usize, scale: f32) -> (f32, Vec<f32>) {
let nominal = widths.first().copied().unwrap_or(1.0).max(0.01) * scale;
let per_point = widths.get(1..).unwrap_or_default();
if per_point.is_empty() || per_point.len() + 1 < count {
return (nominal, vec![0.5; count]);
}
let scaled: Vec<f32> = (0..count)
.map(|i| per_point[i.min(per_point.len() - 1)] * scale)
.collect();
import::fit_widths(&scaled)
}
/// Finds a referenced file: as given, next to the Xournal file, or as Xournal++ attachment
/// (`<file>.xopp.<name>`).
fn find_file(name: &str, xopp_path: &Path) -> Option<PathBuf> {
let given = PathBuf::from(name);
let dir = xopp_path.parent().unwrap_or(Path::new("."));
let mut candidates = vec![given.clone(), dir.join(&given)];
candidates.push(PathBuf::from(format!("{}.{name}", xopp_path.display())));
if let Some(file_name) = given.file_name() {
candidates.push(dir.join(file_name));
}
candidates.into_iter().find(|p| p.is_file())
}
/// Creates a new notebook from a Xournal++/Xournal file.
pub fn create_notebook_from_xopp(
base_dir: &Path,
title: &str,
xopp_path: &Path,
) -> Result<(Notebook, NotebookManifest, PathBuf, ImportReport), String> {
let bytes = std::fs::read(xopp_path).map_err(|e| e.to_string())?;
let doc = parse(&decompress(&bytes)?)?;
if doc.pages.is_empty() {
return Err(t!("Die Datei enthält keine Seiten").to_string());
}
import::create_notebook(base_dir, title, |dir| convert(&doc, xopp_path, dir))
}
/// PDF used as background: asset name and page sizes in points.
struct PdfSource {
asset: String,
sizes: Vec<(f32, f32)>,
}
fn convert(doc: &XoppDocument, xopp_path: &Path, notebook_dir: &Path) -> Result<Imported, String> {
let assets = crate::assets::AssetStore::new(notebook_dir);
let mut report = ImportReport::default();
let mut pdfs: HashMap<String, Option<PdfSource>> = HashMap::new();
let mut current_pdf: Option<String> = None;
let mut next_id: u64 = 1;
let mut pages = Vec::with_capacity(doc.pages.len());
for (index, xpage) in doc.pages.iter().enumerate() {
// Same rules as for imported PDFs: portrait pages become A4, the content keeps its
// aspect ratio, centred horizontally and aligned at the top
let size = crate::document::pdf_page_size(xpage.width, xpage.height);
let placement = crate::document::pdf_placement(xpage.width, xpage.height, size);
let [origin, top_right, _, bottom_left] = placement;
let scale =
((top_right.x - origin.x) / xpage.width).min((bottom_left.y - origin.y) / xpage.height);
let map = |p: glam::Vec2| origin + p * scale;
let mut background = PageBackground::Blank;
let mut pdf_background = None;
let mut strokes: Vec<StrokeData> = Vec::new();
let mut new_id = || {
next_id += 1;
next_id - 1
};
match &xpage.background {
XoppBackground::Plain => {}
XoppBackground::Lined => background = PageBackground::Lined,
XoppBackground::Graph => background = PageBackground::Grid5mm,
XoppBackground::Dotted => background = PageBackground::DotGrid,
XoppBackground::Pdf { file, page } => {
if let Some(file) = file {
current_pdf = Some(file.clone());
}
if let Some(file) = &current_pdf {
let source = pdfs
.entry(file.clone())
.or_insert_with(|| load_pdf(file, xopp_path, &assets));
match source {
Some(source) => {
if let Some(&(width_pt, height_pt)) = source.sizes.get(*page as usize) {
pdf_background = Some(PdfBackground {
asset: source.asset.clone(),
page: *page,
width_pt,
height_pt,
});
}
}
None => report.missing_pdf = Some(file.clone()),
}
}
}
XoppBackground::Pixmap { file } => {
let image = find_file(file, xopp_path)
.and_then(|p| std::fs::read(p).ok())
.and_then(|b| assets.import_bytes(&b).ok());
match image {
Some(image) => {
strokes.push(import::image_stroke(new_id(), image.asset, placement))
}
None => report.skipped += 1,
}
}
}
for item in &xpage.items {
match item {
XoppItem::Stroke {
highlighter,
color,
widths,
points,
} => {
let (width, pressures) = stroke_widths(widths, points.len(), scale);
let points: Vec<glam::Vec2> = points.iter().map(|&p| map(p)).collect();
strokes.push(import::ink_stroke(
new_id(),
*color,
*highlighter,
&points,
width,
&pressures,
));
}
XoppItem::Text {
mono,
size,
pos,
color,
content,
} => {
let font = if *mono {
TextFont::Mono
} else {
TextFont::Sans
};
// Xournal doesn't wrap: as wide as the longest line
strokes.push(import::text_stroke(
new_id(),
content,
size * scale,
font,
map(*pos),
[glam::Vec2::X, glam::Vec2::Y],
None,
*color,
));
}
XoppItem::Image { rect, data } => {
let compact: String =
data.chars().filter(|c| !c.is_ascii_whitespace()).collect();
let image = base64::engine::general_purpose::STANDARD
.decode(compact)
.ok()
.and_then(|b| assets.import_bytes(&b).ok());
match image {
Some(image) => {
let [l, t, r, b] = *rect;
let corners = [
glam::Vec2::new(l, t),
glam::Vec2::new(r, t),
glam::Vec2::new(r, b),
glam::Vec2::new(l, b),
]
.map(map);
strokes.push(import::image_stroke(new_id(), image.asset, corners));
}
None => report.skipped += 1,
}
}
XoppItem::Unsupported => report.skipped += 1,
}
}
pages.push(import::page(
index as u64 + 1,
size,
background,
pdf_background,
strokes,
));
}
Ok(Imported {
pages,
next_stroke_id: next_id,
layout: None,
report,
})
}
fn load_pdf(file: &str, xopp_path: &Path, assets: &crate::assets::AssetStore) -> Option<PdfSource> {
let bytes = std::fs::read(find_file(file, xopp_path)?).ok()?;
let sizes = crate::pdf_doc::page_sizes(bytes.clone()).ok()?;
let asset = assets.import_pdf(&bytes).ok()?;
Some(PdfSource { asset, sizes })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::StrokeTool;
use crate::storage;
use std::io::Write;
fn gzip(xml: &str) -> Vec<u8> {
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(xml.as_bytes()).unwrap();
encoder.finish().unwrap()
}
fn png_base64() -> String {
let image = image::RgbaImage::from_pixel(4, 2, image::Rgba([200, 30, 30, 255]));
let mut png = Vec::new();
image
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
base64::engine::general_purpose::STANDARD.encode(png)
}
const SAMPLE: &str = r##"<?xml version="1.0" standalone="no"?>
<xournal creator="Xournal++ 1.2.3" fileversion="4">
<title>Xournal++ document</title>
<page width="595.27559" height="841.88976">
<background type="solid" color="#ffffffff" style="lined"/>
<layer>
<stroke tool="pen" ts="0" fn="" color="#3333ccff" width="1.41 1.0 2.0 1.5" capStyle="round">10 20 30 40 50 60</stroke>
<stroke tool="highlighter" color="#ffff00ff" width="8.5">0 0 100 0</stroke>
</layer>
<layer>
<text font="Sans" size="12" x="72" y="100" color="#000000ff">Hallo &amp; Welt
zweite Zeile</text>
<teximage text="x^2" left="0" top="0" right="10" bottom="10">AAAA</teximage>
</layer>
</page>
<page width="841.88976" height="595.27559">
<background type="pdf" domain="absolute" filename="/nirgends/skript.pdf" pageno="1ll"/>
<layer/>
</page>
<page width="841.88976" height="595.27559">
<background type="pdf" pageno="2ll"/>
<layer/>
</page>
</xournal>"##;
#[test]
fn test_parse_xopp() {
let doc = parse(&decompress(&gzip(SAMPLE)).unwrap()).unwrap();
assert_eq!(doc.pages.len(), 3);
let first = &doc.pages[0];
assert_eq!(first.background, XoppBackground::Lined);
assert_eq!(first.items.len(), 4);
match &first.items[0] {
XoppItem::Stroke {
highlighter,
color,
widths,
points,
} => {
assert!(!highlighter);
assert_eq!(*color, [0.2, 0.2, 0.8, 1.0]);
assert_eq!(widths, &[1.41, 1.0, 2.0, 1.5]);
assert_eq!(points.len(), 3);
assert_eq!(points[2], glam::Vec2::new(50.0, 60.0));
}
other => panic!("expected a stroke, got {other:?}"),
}
assert!(matches!(
first.items[1],
XoppItem::Stroke {
highlighter: true,
..
}
));
match &first.items[2] {
XoppItem::Text { content, size, .. } => {
assert_eq!(content, "Hallo & Welt\nzweite Zeile");
assert_eq!(*size, 12.0);
}
other => panic!("expected a text, got {other:?}"),
}
assert_eq!(first.items[3], XoppItem::Unsupported);
assert_eq!(
doc.pages[1].background,
XoppBackground::Pdf {
file: Some("/nirgends/skript.pdf".into()),
page: 0
}
);
assert_eq!(
doc.pages[2].background,
XoppBackground::Pdf {
file: None,
page: 1
}
);
}
#[test]
fn test_parse_rejects_other_files() {
assert!(parse("<svg></svg>").is_err());
assert!(decompress(&[0x1f, 0x8b, 1, 2, 3]).is_err());
}
#[test]
fn test_colors() {
assert_eq!(parse_color("#ff000080")[3], 128.0 / 255.0);
assert_eq!(parse_color("red"), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(parse_color("#00ff00"), [0.0, 1.0, 0.0, 1.0]);
assert_eq!(parse_color("Unsinn"), [0.0, 0.0, 0.0, 1.0]);
}
#[test]
fn test_pressure_widths_map_to_our_width_model() {
// Without pressure: nominal width, medium pressure (drawn exactly that wide)
assert_eq!(stroke_widths(&[2.0], 3, 10.0), (20.0, vec![0.5; 3]));
// With pressure: the widest point is drawn exactly as wide as in Xournal++
let (width, pressures) = stroke_widths(&[1.0, 3.0, 1.5], 2, 10.0);
let drawn: Vec<f32> = pressures.iter().map(|p| width * (0.5 + p)).collect();
assert!((drawn[0] - 30.0).abs() < 1e-3);
assert!((drawn[1] - 15.0).abs() < 1e-3);
// Xournal: one width per segment, the last point repeats the last one
let (_, pressures) = stroke_widths(&[1.0, 2.0, 1.0], 3, 1.0);
assert_eq!(pressures.len(), 3);
assert_eq!(pressures[1], pressures[2]);
}
#[test]
fn test_create_notebook_from_xopp() {
let temp_dir = tempfile::tempdir().unwrap();
let source_dir = tempfile::tempdir().unwrap();
let pdf = crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (842.0, 595.0, 0)]);
std::fs::write(source_dir.path().join("skript.pdf"), &pdf).unwrap();
// The PDF moved next to the notebook; its image is embedded
let xml = SAMPLE.replace("<layer/>\n</page>\n</xournal>", &format!(
"<layer><image left=\"0\" top=\"0\" right=\"40\" bottom=\"20\">{}</image></layer>\n</page>\n</xournal>",
png_base64()
));
let path = source_dir.path().join("Mathe.xopp");
std::fs::write(&path, gzip(&xml)).unwrap();
let (notebook, manifest, dir, report) =
create_notebook_from_xopp(temp_dir.path(), "Mathe", &path).unwrap();
assert_eq!(manifest.title, "Mathe");
assert_eq!(notebook.pages.len(), 3);
assert_eq!(
report,
ImportReport {
skipped: 1,
missing_pdf: None
}
);
let (mut reloaded, _) = storage::load_notebook(&dir).unwrap();
storage::load_all_pages(&dir, &mut reloaded).unwrap();
let first = &reloaded.pages[0];
assert_eq!(first.background, PageBackground::Lined);
assert_eq!(first.size, crate::document::A4_SIZE);
assert_eq!(first.strokes.len(), 3);
let pen = &first.strokes[0];
assert_eq!(pen.tool, StrokeTool::Pen);
// Points → page units (10 per mm): 10 pt = 35.3 units
assert!((pen.points[0].position.x - 35.28).abs() < 0.05);
assert!(!pen.vertices.is_empty());
let marker = &first.strokes[1];
assert_eq!(marker.tool, StrokeTool::Highlighter);
assert_eq!(marker.color[3], import::HIGHLIGHTER_ALPHA);
assert!(first.strokes[2].is_text());
for (i, page) in reloaded.pages[1..].iter().enumerate() {
let bg = page
.pdf_background
.as_ref()
.expect("PDF background missing");
assert_eq!(bg.page, i as u32);
assert!(page.size.x > page.size.y);
}
assert!(reloaded.pages[2].strokes[0].is_image());
// Stroke ids are unique and the notebook continues after them
assert!(reloaded.next_stroke_id > 4);
}
#[test]
fn test_missing_pdf_is_reported() {
let temp_dir = tempfile::tempdir().unwrap();
let source_dir = tempfile::tempdir().unwrap();
let path = source_dir.path().join("Mathe.xoj");
std::fs::write(&path, SAMPLE).unwrap();
let (notebook, _, _, report) =
create_notebook_from_xopp(temp_dir.path(), "Mathe", &path).unwrap();
assert_eq!(notebook.pages.len(), 3);
assert_eq!(report.missing_pdf.as_deref(), Some("/nirgends/skript.pdf"));
assert!(notebook.pages[1].pdf_background.is_none());
}
}
+4
View File
@@ -14,6 +14,10 @@ docs of the original crate were removed.
- `src/platform/wayland.rs`: `ignore_decoration_touches_until` lets ink-notes ignore touches on
the title bar while the pen is near the screen, so a resting palm does not move the window
(Issue #39).
- `src/platform/wayland.rs` and the touch handler: `take_touch_time` returns the compositor
timestamp of each touch event (Started, Moved, Ended), so ink-notes measures the swipe speed
for gliding with the real event times and not with the time it got around to handling them
(Issue #28).
- `Cargo.toml`: `[lints.rust] warnings = "allow"`, because a path dependency is not built with
capped lints like a crates.io dependency.
+30
View File
@@ -28,6 +28,36 @@ pub(crate) fn decoration_touches_ignored() -> bool {
.is_some_and(|until| std::time::Instant::now() < until)
}
/// ink-notes patch: compositor timestamps (ms) of the touch events not yet taken, in event order.
static TOUCH_TIMES: std::sync::Mutex<
std::collections::VecDeque<(u64, crate::event::TouchPhase, u32)>,
> = std::sync::Mutex::new(std::collections::VecDeque::new());
/// ink-notes patch: remembers the compositor time of a touch event that is about to be sent.
pub(crate) fn push_touch_time(id: u64, phase: crate::event::TouchPhase, time: u32) {
let mut times = TOUCH_TIMES.lock().unwrap_or_else(|e| e.into_inner());
// Nobody takes them (e.g. an app that never asks): keep only the latest
if times.len() >= 256 {
times.pop_front();
}
times.push_back((id, phase, time));
}
/// ink-notes patch: compositor time in milliseconds (arbitrary origin, wraps around) of the
/// touch event with this id and phase (`Started`, `Moved` or `Ended`; `Cancelled` has none),
/// for the event currently being handled. Call it once for every such `WindowEvent::Touch`.
/// `None` on X11 or if the time is unknown.
pub fn take_touch_time(id: u64, phase: crate::event::TouchPhase) -> Option<u32> {
let mut times = TOUCH_TIMES.lock().unwrap_or_else(|e| e.into_inner());
// Older entries that were never taken are skipped
while let Some((i, p, time)) = times.pop_front() {
if i == id && p == phase {
return Some(time);
}
}
None
}
/// Additional methods on [`EventLoopWindowTarget`] that are specific to Wayland.
pub trait EventLoopWindowTargetExtWayland {
/// True if the [`EventLoopWindowTarget`] uses Wayland.
@@ -83,6 +83,9 @@ impl TouchHandler for WinitState {
.touch_map
.insert(id, TouchPoint { surface, location, frame_of: None });
// ink-notes patch: keep the event time for `take_touch_time`
crate::platform::wayland::push_touch_time(id as u64, TouchPhase::Started, time);
self.events_sink.push_window_event(
WindowEvent::Touch(Touch {
device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(
@@ -138,6 +141,8 @@ impl TouchHandler for WinitState {
None => return,
};
// ink-notes patch: keep the event time for `take_touch_time`
crate::platform::wayland::push_touch_time(id as u64, TouchPhase::Ended, time);
self.events_sink.push_window_event(
WindowEvent::Touch(Touch {
device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(
@@ -157,7 +162,7 @@ impl TouchHandler for WinitState {
_: &Connection,
_: &QueueHandle<Self>,
touch: &WlTouch,
_: u32,
time: u32,
id: i32,
position: (f64, f64),
) {
@@ -192,6 +197,9 @@ impl TouchHandler for WinitState {
touch_point.location = LogicalPosition::<f64>::from(position);
// ink-notes patch: keep the event time for `take_touch_time`
crate::platform::wayland::push_touch_time(id as u64, TouchPhase::Moved, time);
self.events_sink.push_window_event(
WindowEvent::Touch(Touch {
device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(