Compare commits

...
5 Commits
Author SHA1 Message Date
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
21 changed files with 2470 additions and 66 deletions

No files matched your search

Generated
+134 -3
View File
@@ -539,6 +539,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.8"
@@ -790,6 +796,12 @@ dependencies = [
"zlib-rs", "zlib-rs",
] ]
[[package]]
name = "float-cmp"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.2.0" version = "0.2.0"
@@ -1381,6 +1393,12 @@ dependencies = [
"quick-error", "quick-error",
] ]
[[package]]
name = "imagesize"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.2" version = "2.14.2"
@@ -1393,14 +1411,16 @@ dependencies = [
[[package]] [[package]]
name = "ink-notes" name = "ink-notes"
version = "0.34.2" version = "0.36.0"
dependencies = [ dependencies = [
"ab_glyph", "ab_glyph",
"arboard", "arboard",
"base64",
"bytemuck", "bytemuck",
"egui", "egui",
"egui-wgpu", "egui-wgpu",
"egui-winit", "egui-winit",
"flate2",
"fs2", "fs2",
"glam", "glam",
"hayro", "hayro",
@@ -1411,6 +1431,8 @@ dependencies = [
"octotablet", "octotablet",
"pollster", "pollster",
"printpdf", "printpdf",
"quick-xml",
"resvg",
"semver", "semver",
"serde", "serde",
"serde_json", "serde_json",
@@ -2079,6 +2101,12 @@ dependencies = [
"pxfm", "pxfm",
] ]
[[package]]
name = "pico-args"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@@ -2326,6 +2354,33 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" 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]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -2340,6 +2395,15 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "1.1.0" version = "1.1.0"
@@ -2450,7 +2514,7 @@ dependencies = [
"log", "log",
"memmap2", "memmap2",
"smithay-client-toolkit", "smithay-client-toolkit",
"tiny-skia", "tiny-skia 0.11.4",
] ]
[[package]] [[package]]
@@ -2531,6 +2595,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "simplecss"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
dependencies = [
"log",
]
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.4" version = "1.0.4"
@@ -2638,6 +2711,9 @@ name = "strict-num"
version = "0.1.1" version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
dependencies = [
"float-cmp",
]
[[package]] [[package]]
name = "strum" name = "strum"
@@ -2667,6 +2743,16 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 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]] [[package]]
name = "syn" name = "syn"
version = "1.0.109" version = "1.0.109"
@@ -2828,7 +2914,22 @@ dependencies = [
"bytemuck", "bytemuck",
"cfg-if", "cfg-if",
"log", "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]] [[package]]
@@ -2842,6 +2943,17 @@ dependencies = [
"strict-num", "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]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.4" version = "0.8.4"
@@ -2978,6 +3090,25 @@ dependencies = [
"serde", "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]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
+8 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ink-notes" name = "ink-notes"
version = "0.34.2" version = "0.36.0"
edition = "2021" edition = "2021"
description = "Notizbuch-App für Stift, Touch und Maus unter Linux" description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes" repository = "https://gitea.pansi.eu/harald/ink-notes"
@@ -48,6 +48,13 @@ fs2 = "0.4"
hayro = "0.8" hayro = "0.8"
hayro-svg = "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) # Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui)
ab_glyph = "0.2" ab_glyph = "0.2"
+7 -2
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++ und Rnote importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund.
Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11). Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
@@ -47,7 +47,9 @@ 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. - **Text-Werkzeug** (`T`): ins Blatt tippen und schreiben – normale oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8–36 pt, Zeilenumbruch am Feldrand. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren. In anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
- **Bilder** (PNG, JPEG, GIF, WebP, BMP) über `Strg + I`, aus der Zwischenablage oder per Ziehen ins Fenster einfügen. - **Bilder** (PNG, JPEG, GIF, WebP, BMP) über `Strg + I`, aus der Zwischenablage oder per Ziehen ins Fenster einfügen.
- **Einfügen per Finger:** Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle. - **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.
### Seiten und Ansicht ### Seiten und Ansicht
- **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export. - **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export.
@@ -169,6 +171,9 @@ src/
├── assets.rs # Bilder und PDFs im Notizbuchordner ├── assets.rs # Bilder und PDFs im Notizbuchordner
├── pdf_doc.rs # PDF lesen und rendern (hayro) ├── pdf_doc.rs # PDF lesen und rendern (hayro)
├── pdf_merge.rs # Importierte PDF-Seiten beim Export unterlegen ├── 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
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen ├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
├── history.rs # Rückgängig/Wiederholen ├── history.rs # Rückgängig/Wiederholen
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb ├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
+7 -2
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++ and Rnote 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). It is mainly tested on Zorin OS 18 / Ubuntu 24.04 (Wayland and X11).
@@ -47,7 +47,9 @@ 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`. - **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. - **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. - **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.
### Pages and view ### 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. - **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.
@@ -171,6 +173,9 @@ src/
├── assets.rs # Images and PDFs in the notebook folder ├── assets.rs # Images and PDFs in the notebook folder
├── pdf_doc.rs # Reading and rendering PDFs (hayro) ├── pdf_doc.rs # Reading and rendering PDFs (hayro)
├── pdf_merge.rs # Underlaying imported PDF pages on export ├── 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
├── document.rs # Data model: notebook, pages, strokes, page sizes ├── document.rs # Data model: notebook, pages, strokes, page sizes
├── history.rs # Undo/redo ├── history.rs # Undo/redo
├── storage.rs # Loading/saving (atomic), notebooks, trash ├── storage.rs # Loading/saving (atomic), notebooks, trash
+1 -1
View File
@@ -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. 2. **PDF** (nur im Notfall): importierte PDF-Seite als Bild, falls sie sich nicht als Vektor übernehmen lässt.
3. **Images:** eingefügte Bilder (höchstens 300 dpi). 3. **Images:** eingefügte Bilder (höchstens 300 dpi).
4. **Text:** Textfelder als echter Text mit eingebetteter Schrift (durchsuchbar, kopierbar). Jedes Feld liegt in einem eigenen Koordinatensystem, das eine Transformationsmatrix auf seine Ecken abbildet. 4. **Text:** Textfelder als echter Text mit eingebetteter Schrift (durchsuchbar, kopierbar). Jedes Feld liegt in einem eigenen Koordinatensystem, das eine Transformationsmatrix auf seine Ecken abbildet.
5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade. 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. Importierte PDF-Seiten werden anschließend als Form-XObject unter die Seite gelegt (`pdf_merge.rs`), bleiben also Vektorgrafik. Seitentitel werden zu Lesezeichen.
+1 -1
View File
@@ -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. 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). 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. 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. 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.
+10
View File
@@ -84,6 +84,16 @@
</branding> </branding>
<content_rating type="oars-1.1" /> <content_rating type="oars-1.1" />
<releases> <releases>
<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"> <release version="0.34.2" date="2026-10-26">
<description> <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> <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>
+156
View File
@@ -805,6 +805,19 @@
"dest": "cargo/vendor/d3d12-0.19.0", "dest": "cargo/vendor/d3d12-0.19.0",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -1156,6 +1169,19 @@
"dest": "cargo/vendor/flate2-1.1.10", "dest": "cargo/vendor/flate2-1.1.10",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -1884,6 +1910,19 @@
"dest": "cargo/vendor/image-webp-0.2.4", "dest": "cargo/vendor/image-webp-0.2.4",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -2794,6 +2833,19 @@
"dest": "cargo/vendor/pic-scale-0.7.12", "dest": "cargo/vendor/pic-scale-0.7.12",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -3197,6 +3249,32 @@
"dest": "cargo/vendor/renderdoc-sys-1.1.0", "dest": "cargo/vendor/renderdoc-sys-1.1.0",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -3210,6 +3288,19 @@
"dest": "cargo/vendor/ring-0.17.14", "dest": "cargo/vendor/ring-0.17.14",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -3483,6 +3574,19 @@
"dest": "cargo/vendor/simdutf8-0.1.5", "dest": "cargo/vendor/simdutf8-0.1.5",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -3678,6 +3782,19 @@
"dest": "cargo/vendor/subtle-2.6.1", "dest": "cargo/vendor/subtle-2.6.1",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -3873,6 +3990,19 @@
"dest": "cargo/vendor/tiny-skia-0.11.4", "dest": "cargo/vendor/tiny-skia-0.11.4",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -3886,6 +4016,19 @@
"dest": "cargo/vendor/tiny-skia-path-0.11.4", "dest": "cargo/vendor/tiny-skia-path-0.11.4",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -4094,6 +4237,19 @@
"dest": "cargo/vendor/url-2.5.8", "dest": "cargo/vendor/url-2.5.8",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
+10
View File
@@ -84,6 +84,16 @@
</branding> </branding>
<content_rating type="oars-1.1" /> <content_rating type="oars-1.1" />
<releases> <releases>
<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"> <release version="0.34.2" date="2026-10-26">
<description> <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> <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>
+2
View File
@@ -3,6 +3,8 @@
Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04). Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04).
### Highlights: ### Highlights:
- **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 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 - **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 - **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
+18 -14
View File
@@ -2216,10 +2216,10 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
// Bilddatei ins Fenster gezogen: an der Ablagestelle einfügen // Bilddatei ins Fenster gezogen: an der Ablagestelle einfügen; PDF/Xournal++/Rnote: importieren
WindowEvent::DroppedFile(path) => { WindowEvent::DroppedFile(path) => {
if crate::file_picker::is_pdf_path(&path) { if crate::import::is_importable_path(&path) {
ui_state.queued_actions.push(UiAction::ImportPdf(path)); ui_state.queued_actions.push(UiAction::ImportFile(path));
} else if crate::assets::is_supported_image_path(&path) { } else if crate::assets::is_supported_image_path(&path) {
let (sx, sy) = cursor_screen_pos; let (sx, sy) = cursor_screen_pos;
let center = canvas.screen_to_page_clamped(sx, sy); let center = canvas.screen_to_page_clamped(sx, sy);
@@ -3837,18 +3837,15 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
} }
UiAction::ImportPdf(path) => { UiAction::ImportFile(path) => {
// Neben das gerade geöffnete Notizbuch // Neben das gerade geöffnete Notizbuch
let folder = ui_state.library.folder_of(&manifest.id).to_string(); let folder = ui_state.library.folder_of(&manifest.id).to_string();
let title = path let title = path
.file_stem() .file_stem()
.map(|s| s.to_string_lossy().into_owned()) .map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "PDF".to_string()); .unwrap_or_else(|| "Import".to_string());
let result = std::fs::read(&path) match crate::import::import_file(&storage_base_dir, &title, &path) {
.map_err(|e| e.to_string()) Ok((new_nb, new_manifest, new_dir, report)) => {
.and_then(|bytes| storage::create_notebook_from_pdf(&storage_base_dir, &title, bytes));
match result {
Ok((new_nb, new_manifest, new_dir)) => {
let id = new_manifest.id.clone(); let id = new_manifest.id.clone();
change_library(&storage_base_dir, &mut ui_state, |library| { change_library(&storage_base_dir, &mut ui_state, |library| {
library.move_notebook(&id, &folder) library.move_notebook(&id, &folder)
@@ -3867,15 +3864,22 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
canvas.reset_view(); canvas.reset_view();
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
ui_state.set_export_message(t!( let mut message = t!(
"'{title}' importiert ({count} Seiten)", "'{title}' importiert ({count} Seiten)",
title = manifest.title, title = manifest.title,
count = notebook.pages.len() count = notebook.pages.len()
)); );
if let Some(pdf) = &report.missing_pdf {
message.push_str(&t!(", PDF-Hintergrund nicht gefunden: {file}", file = pdf));
}
if report.skipped > 0 {
message.push_str(&t!(", {count} Elemente übersprungen", count = report.skipped));
}
ui_state.set_export_message(message);
} }
Err(err) => { Err(err) => {
eprintln!("[PDF-Import] '{}': {err}", path.display()); eprintln!("[Import] '{}': {err}", path.display());
ui_state.set_export_message(t!("PDF nicht importiert: {err}", err = err)); ui_state.set_export_message(t!("Nicht importiert: {err}", err = err));
} }
} }
window.request_redraw(); window.request_redraw();
+30 -15
View File
@@ -1222,21 +1222,25 @@ fn render_stroke_polygon(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
// Geschlossener Pfad um den Ribbon-Rand: // Geschlossener Pfad um den Ribbon-Rand:
// Left[0] -> Left[1] -> ... -> Left[n-1] -> Right[n-1] -> Right[n-2] -> ... -> Right[0] // Left[0] -> Left[1] -> ... -> Left[n-1] -> Right[n-1] -> Right[n-2] -> ... -> Right[0]
let mut ring = Vec::with_capacity(n * 2); let outline: Vec<glam::Vec2> = (0..n)
.map(|i| stroke.vertices[i * 2].position)
// 1. Linke Hüllkurve vorwärts .chain((0..n).rev().map(|i| stroke.vertices[i * 2 + 1].position))
for i in 0..n { .map(glam::Vec2::from)
let v = stroke.vertices[i * 2]; .collect();
let (x, y) = page_to_pdf_mm(v.position[0], v.position[1]); // The mesh has about 16 points per pen sample, far more than the eye (or a printer) can
ring.push((Point::new(Mm(x), Mm(y)), false)); // tell apart; fewer points make the PDF, PNG export and copying as an image much faster
} let outline = if exact_outlines() {
outline
// 2. Rechte Hüllkurve rückwärts } else {
for i in (0..n).rev() { crate::geometry::simplify_closed_outline(&outline, OUTLINE_TOLERANCE)
let v = stroke.vertices[i * 2 + 1]; };
let (x, y) = page_to_pdf_mm(v.position[0], v.position[1]); let ring: Vec<(Point, bool)> = outline
ring.push((Point::new(Mm(x), Mm(y)), false)); .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( let fill_color = Color::Rgb(Rgb::new(
stroke.color[0], stroke.color[0],
@@ -1254,6 +1258,17 @@ fn render_stroke_polygon(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
layer.add_polygon(poly); 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. /// Strategie B: Kubische Bézier-Kurven aus den Abtastpunkten via printpdf::Line.
fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) { fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
if stroke.points.is_empty() { if stroke.points.is_empty() {
+17 -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).
//! //!
//! Bewusst ohne Systemdialog: der bräuchte GTK oder das Desktop-Portal, die unter WSL und auf //! 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 //! schlanken Systemen oft fehlen. Die Auswahl zeigt Ordner und die passenden Dateien des
@@ -22,8 +22,8 @@ pub struct PickerEntry {
pub enum PickerKind { pub enum PickerKind {
/// Bilder zum Einfügen auf der aktuellen Seite /// Bilder zum Einfügen auf der aktuellen Seite
Image, Image,
/// PDF zum Importieren als neues Notizbuch /// PDF, Xournal++ or Rnote file to import as a new notebook
Pdf, Import,
} }
impl PickerKind { impl PickerKind {
@@ -31,7 +31,7 @@ impl PickerKind {
pub fn accepts(self, path: &Path) -> bool { pub fn accepts(self, path: &Path) -> bool {
match self { match self {
PickerKind::Image => is_supported_image_path(path), 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(|| { .or_else(|| {
let preferred = match kind { let preferred = match kind {
PickerKind::Image => tn!("🖼 Bilder"), PickerKind::Image => tn!("🖼 Bilder"),
PickerKind::Pdf => tn!("⬇ Downloads"), PickerKind::Import => tn!("⬇ Downloads"),
}; };
let all = shortcuts(); let all = shortcuts();
all.iter() all.iter()
@@ -192,6 +192,8 @@ mod tests {
".versteckt.png", ".versteckt.png",
"c.webp", "c.webp",
"Skript.PDF", "Skript.PDF",
"mathe.xopp",
"skizze.rnote",
] { ] {
std::fs::write(dir.path().join(name), b"x").unwrap(); std::fs::write(dir.path().join(name), b"x").unwrap();
} }
@@ -210,7 +212,16 @@ mod tests {
names(PickerKind::Image), names(PickerKind::Image),
["aOrdner", "zOrdner", "A.jpg", "b.png", "c.webp"] ["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"
]
);
} }
#[test] #[test]
+129
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`. /// Abstand eines Punktes `p` zur Geraden durch `a` und `b`.
#[inline] #[inline]
pub fn point_to_line_dist(p: Vec2, a: Vec2, b: Vec2) -> f32 { pub fn point_to_line_dist(p: Vec2, a: Vec2, b: Vec2) -> f32 {
@@ -2327,6 +2384,78 @@ pub fn shape_to_stroke(
mod tests { mod tests {
use super::*; 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] #[test]
fn test_detect_line() { fn test_detect_line() {
// Leicht zittrige diagonale Punktfolge // Leicht zittrige diagonale Punktfolge
+3
View File
@@ -167,6 +167,9 @@ mod tests {
("text.rs", include_str!("text.rs")), ("text.rs", include_str!("text.rs")),
("ui.rs", include_str!("ui.rs")), ("ui.rs", include_str!("ui.rs")),
("updater.rs", include_str!("updater.rs")), ("updater.rs", include_str!("updater.rs")),
("import.rs", include_str!("import.rs")),
("rnote.rs", include_str!("rnote.rs")),
("xopp.rs", include_str!("xopp.rs")),
]; ];
/// Alle Texte in `t!("…")` und `tn!("…")` eines Quelltexts, auch über mehrere Zeilen /// Alle Texte in `t!("…")` und `tn!("…")` eines Quelltexts, auch über mehrere Zeilen
+15 -7
View File
@@ -52,7 +52,6 @@ pub const EN: &[(&str, &str)] = &[
("Das PDF enthält keine Seiten", "The PDF has no pages"), ("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 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 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"), ("Datei existiert nicht", "File does not exist"),
("Den Ordner gibt es nicht mehr", "The folder no longer exists"), ("Den Ordner gibt es nicht mehr", "The folder no longer exists"),
("Den Ordner „{name}“ gibt es hier schon", "A folder “{name}” already exists here"), ("Den Ordner „{name}“ gibt es hier schon", "A folder “{name}” already exists here"),
@@ -150,7 +149,6 @@ pub const EN: &[(&str, &str)] = &[
("Keine manifest.json vorhanden", "No manifest.json present"), ("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 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 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"), ("Keine Sicherungskopie (manifest.json.bak) vorhanden", "No backup copy (manifest.json.bak) present"),
("Kleinere Schrift (jetzt {size} pt)", "Smaller font (now {size} pt)"), ("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."), ("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."),
@@ -234,9 +232,7 @@ pub const EN: &[(&str, &str)] = &[
("Papierkorb-Pfad '{path}' liegt nicht im Papierkorb", "Trash path '{path}' is not in the trash"), ("Papierkorb-Pfad '{path}' liegt nicht im Papierkorb", "Trash path '{path}' is not in the trash"),
("Papierkorb-Verzeichnis existiert nicht", "Trash folder does not exist"), ("Papierkorb-Verzeichnis existiert nicht", "Trash folder does not exist"),
("Pastell-Cyan", "Pastel cyan"), ("Pastell-Cyan", "Pastel cyan"),
("PDF importieren", "Import PDF"),
("PDF kann nicht geöffnet werden", "Cannot open 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 nicht lesbar: {e}", "PDF not readable: {e}"),
("PDF ohne Seite", "PDF without a page"), ("PDF ohne Seite", "PDF without a page"),
("PDF-Export läuft…", "PDF export running…"), ("PDF-Export läuft…", "PDF export running…"),
@@ -368,7 +364,6 @@ pub const EN: &[(&str, &str)] = &[
("Verzeichnis '{path}' enthält kein manifest.json", "Folder '{path}' contains no manifest.json"), ("Verzeichnis '{path}' enthält kein manifest.json", "Folder '{path}' contains no manifest.json"),
("Verzeichnis '{path}' existiert nicht", "Folder '{path}' does not exist"), ("Verzeichnis '{path}' existiert nicht", "Folder '{path}' does not exist"),
("Vorherige Seite (Bild auf)", "Previous page (Page Up)"), ("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", "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."), ("Weich: schon leichter Druck ergibt kräftige Linien.\nHart: erst fester Druck ergibt kräftige Linien.", "Soft: even light pressure gives bold lines.\nHard: only firm pressure gives bold lines."),
("Weitere Funktionen – Update verfügbar", "More – update available"), ("Weitere Funktionen – Update verfügbar", "More – update available"),
@@ -430,9 +425,7 @@ pub const EN: &[(&str, &str)] = &[
("📂 Im Dateimanager anzeigen", "📂 Show in file manager"), ("📂 Im Dateimanager anzeigen", "📂 Show in file manager"),
("📂 Ordner öffnen", "📂 Open folder"), ("📂 Ordner öffnen", "📂 Open folder"),
("📄 Als PDF exportieren (Strg+E)", "📄 Export as PDF (Ctrl+E)"), ("📄 Als PDF exportieren (Strg+E)", "📄 Export as PDF (Ctrl+E)"),
("📄 PDF importieren …", "📄 Import PDF …"),
("📄 Feste Seiten", "📄 Fixed pages"), ("📄 Feste Seiten", "📄 Fixed pages"),
("📄 PDF importieren", "📄 Import PDF"),
("📋 Einfügen", "📋 Paste"), ("📋 Einfügen", "📋 Paste"),
("📑 Inhalt", "📑 Contents"), ("📑 Inhalt", "📑 Contents"),
("📚 Alle", "📚 All"), ("📚 Alle", "📚 All"),
@@ -454,4 +447,19 @@ pub const EN: &[(&str, &str)] = &[
("🚀 Update {version} verfügbar", "🚀 Update {version} available"), ("🚀 Update {version} verfügbar", "🚀 Update {version} available"),
("🚀 AppImage aktualisieren & ausführen", "🚀 Update AppImage & restart"), ("🚀 AppImage aktualisieren & ausführen", "🚀 Update AppImage & restart"),
("🚀 Updates & Versionen", "🚀 Updates & versions"), ("🚀 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 Ordner, PDF-, Xournal++- oder Rnote-Dateien", "No folders, PDF, Xournal++ or Rnote files"),
("Die Datei wird als neues Notizbuch angelegt. PDF: eine Seite je PDF-Seite, beim Export liegen deine Notizen über den Originalseiten. Xournal++ (.xopp, .xoj) und Rnote (.rnote): Striche, Formen, Texte und Bilder werden übernommen, bei Xournal++ 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) and Rnote (.rnote): strokes, shapes, texts and images are taken over, for Xournal++ also PDF backgrounds."),
("Vorlesungsfolien, Übungsblätter, Formulare oder Notizbücher aus Xournal++ und Rnote als neues Notizbuch öffnen und darauf schreiben", "Open lecture slides, worksheets, forms or notebooks from Xournal++ and Rnote as a new notebook and write on them"),
("📄 Importieren (PDF, Xournal++, Rnote) …", "📄 Import (PDF, Xournal++, Rnote) …"),
("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"),
]; ];
+257
View File
@@ -0,0 +1,257 @@
//! Import of other formats as a new notebook (Issue #47): PDF (`storage`), Xournal++
//! (`xopp`) and Rnote (`rnote`). 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)
}
/// Imports a PDF, Xournal++ or Rnote 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 {
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,
}
}
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);
}
}
+3
View File
@@ -13,12 +13,14 @@ pub mod geometry;
pub mod history; pub mod history;
pub mod i18n; pub mod i18n;
pub mod image_layer; pub mod image_layer;
pub mod import;
pub mod library; pub mod library;
pub mod osk; pub mod osk;
pub mod page_export; pub mod page_export;
pub mod pdf_doc; pub mod pdf_doc;
pub mod pdf_merge; pub mod pdf_merge;
pub mod platform; pub mod platform;
pub mod rnote;
pub mod ruler; pub mod ruler;
pub mod selection; pub mod selection;
pub mod settings; pub mod settings;
@@ -28,3 +30,4 @@ pub mod text;
pub mod ui; pub mod ui;
pub mod updater; pub mod updater;
pub mod viewport; pub mod viewport;
pub mod xopp;
+938
View File
@@ -0,0 +1,938 @@
//! 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;
}
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))),
)
}
/// Premultiplied RGBA (Rnote, tiny-skia) to straight RGBA.
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).
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))
}
/// 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 = 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);
}
}
+16 -14
View File
@@ -338,7 +338,7 @@ pub enum UiAction {
/// Bilddatei auf der aktuellen Seite einfügen /// Bilddatei auf der aktuellen Seite einfügen
InsertImageFile(std::path::PathBuf), InsertImageFile(std::path::PathBuf),
/// PDF als neues Notizbuch importieren /// PDF als neues Notizbuch importieren
ImportPdf(std::path::PathBuf), ImportFile(std::path::PathBuf),
/// Aktuelle Seite als Bild oder SVG exportieren /// Aktuelle Seite als Bild oder SVG exportieren
ExportPage(PageExportKind), ExportPage(PageExportKind),
/// Titel einer Seite setzen (leer = entfernen) /// Titel einer Seite setzen (leer = entfernen)
@@ -669,9 +669,9 @@ impl UiState {
self.open_file_picker(PickerKind::Image); self.open_file_picker(PickerKind::Image);
} }
/// Öffnet die Dateiauswahl „PDF importieren“ im zuletzt benutzten Ordner. /// Opens the file picker "Import" (PDF, Xournal++, Rnote) in the last used folder.
pub fn open_pdf_picker(&mut self) { pub fn open_import_picker(&mut self) {
self.open_file_picker(PickerKind::Pdf); self.open_file_picker(PickerKind::Import);
} }
fn open_file_picker(&mut self, kind: PickerKind) { fn open_file_picker(&mut self, kind: PickerKind) {
@@ -2827,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!("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)"),
t!("Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen."), t!("Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen."),
), ),
PickerKind::Pdf => ( PickerKind::Import => (
t!("PDF importieren"), t!("Importieren"),
t!("📄 PDF importieren"), t!("📄 Importieren"),
"📄", "📄",
t!("Keine Ordner oder PDF-Dateien"), t!("Keine Ordner, PDF-, Xournal++- oder Rnote-Dateien"),
t!( t!(
"Das PDF wird als neues Notizbuch angelegt, eine Seite je PDF-Seite. \ "Die Datei wird als neues Notizbuch angelegt. PDF: eine Seite je PDF-Seite, \
Beim Export liegen deine Notizen über den Originalseiten." beim Export liegen deine Notizen über den Originalseiten. \
Xournal++ (.xopp, .xoj) und Rnote (.rnote): Striche, Formen, Texte und Bilder \
werden übernommen, bei Xournal++ auch PDF-Hintergründe."
), ),
), ),
}; };
@@ -2924,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()); state.last_picker_dir = state.file_picker.as_ref().map(|p| p.dir.clone());
actions.push(match picker_kind { actions.push(match picker_kind {
PickerKind::Image => UiAction::InsertImageFile(path), PickerKind::Image => UiAction::InsertImageFile(path),
PickerKind::Pdf => UiAction::ImportPdf(path), PickerKind::Import => UiAction::ImportFile(path),
}); });
close = true; close = true;
} }
@@ -4195,11 +4197,11 @@ pub fn render_ui(
state.open_image_picker(); state.open_image_picker();
} }
if ui if ui
.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 PDF importieren …"))) .add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 Importieren (PDF, Xournal++, Rnote) …")))
.on_hover_text(t!("Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen und darauf schreiben")) .on_hover_text(t!("Vorlesungsfolien, Übungsblätter, Formulare oder Notizbücher aus Xournal++ und Rnote als neues Notizbuch öffnen und darauf schreiben"))
.clicked() .clicked()
{ {
state.open_pdf_picker(); state.open_import_picker();
} }
section(ui, t!("HINTERGRUND DIESER SEITE")); section(ui, t!("HINTERGRUND DIESER SEITE"));
+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());
}
}