feat: Bilder einfügen (Datei, Zwischenablage, Drag & Drop)

- Bilder per ☰ → „Bild einfügen …“ (Strg+I, eingebaute Dateiauswahl),
  Strg+V aus der Systemzwischenablage oder durch Ziehen ins Fenster
- Bilder sind Objekte mit vier Ecken: verschieben, drehen, skalieren,
  kopieren, löschen und Undo über die bestehende Auswahl
- Gespeichert einmalig unter ihrer Prüfsumme in <Notizbuch>/assets
  (JPEG unverändert, sonst PNG); Dateiformat-Version 4
- GPU-Darstellung mit Mipmaps unter der Tinte; Radierer und Umfärben
  lassen Bilder unberührt, Tinte auf Bildern hat beim Antippen Vorrang
- PDF-Export: JPEG verlustfrei eingebettet, sonst bis 300 dpi
- Strg+V: die jüngste Kopie gewinnt (Systembild oder interne Objekte);
  Bilder werden beim Einfügen in ein anderes Notizbuch mitkopiert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 16:50:54 +02:00
co-authored by Claude Opus 5.5
parent c52e958c7f
commit b5c1da07fa
14 changed files with 2261 additions and 63 deletions
Generated
+282 -5
View File
@@ -79,6 +79,26 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "arboard"
version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
dependencies = [
"clipboard-win",
"image",
"log",
"objc2 0.6.5",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.59.0",
"x11rb",
]
[[package]] [[package]]
name = "arrayref" name = "arrayref"
version = "0.3.9" version = "0.3.9"
@@ -182,7 +202,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b55663a85f33501257357e6421bb33e769d5c9ffb5ba0921c975a123e35e68" checksum = "15b55663a85f33501257357e6421bb33e769d5c9ffb5ba0921c975a123e35e68"
dependencies = [ dependencies = [
"block-sys", "block-sys",
"objc2", "objc2 0.4.1",
] ]
[[package]] [[package]]
@@ -222,6 +242,12 @@ dependencies = [
"syn 3.0.6", "syn 3.0.6",
] ]
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.12.1" version = "1.12.1"
@@ -290,6 +316,15 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]] [[package]]
name = "codespan-reporting" name = "codespan-reporting"
version = "0.11.1" version = "0.11.1"
@@ -300,6 +335,12 @@ dependencies = [
"unicode-width", "unicode-width",
] ]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "com" name = "com"
version = "0.6.0" version = "0.6.0"
@@ -420,6 +461,12 @@ version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@@ -469,6 +516,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
[[package]]
name = "dispatch2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.13.2",
"objc2 0.6.5",
]
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.7" version = "0.2.7"
@@ -623,12 +680,33 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "error-code"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.5.0" version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fax"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.14" version = "0.1.14"
@@ -642,7 +720,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [ dependencies = [
"crc32fast", "crc32fast",
"miniz_oxide", "miniz_oxide 0.9.1",
"zlib-rs", "zlib-rs",
] ]
@@ -770,6 +848,16 @@ dependencies = [
"r-efi 6.0.0", "r-efi 6.0.0",
] ]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]] [[package]]
name = "gl_generator" name = "gl_generator"
version = "0.14.0" version = "0.14.0"
@@ -863,6 +951,17 @@ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
] ]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@@ -929,7 +1028,7 @@ checksum = "99d3aaff8a54577104bafdf686ff18565c3b6903ca5782a2026ef06e2c7aa319"
dependencies = [ dependencies = [
"block2", "block2",
"dispatch", "dispatch",
"objc2", "objc2 0.4.1",
] ]
[[package]] [[package]]
@@ -1036,6 +1135,35 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png",
"tiff",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.2" version = "2.14.2"
@@ -1050,12 +1178,14 @@ dependencies = [
name = "ink-notes" name = "ink-notes"
version = "0.4.0" version = "0.4.0"
dependencies = [ dependencies = [
"arboard",
"bytemuck", "bytemuck",
"egui", "egui",
"egui-wgpu", "egui-wgpu",
"egui-winit", "egui-winit",
"fs2", "fs2",
"glam", "glam",
"image",
"minisign-verify", "minisign-verify",
"naga", "naga",
"octotablet", "octotablet",
@@ -1312,6 +1442,16 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce" checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.9.1" version = "0.9.1"
@@ -1322,6 +1462,16 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "multiversion_no_op" name = "multiversion_no_op"
version = "1.0.0" version = "1.0.0"
@@ -1444,7 +1594,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "559c5a40fdd30eb5e344fbceacf7595a81e242529fb4e21cf5f43fb4f11ff98d" checksum = "559c5a40fdd30eb5e344fbceacf7595a81e242529fb4e21cf5f43fb4f11ff98d"
dependencies = [ dependencies = [
"objc-sys", "objc-sys",
"objc2-encode", "objc2-encode 3.0.0",
]
[[package]]
name = "objc2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [
"objc2-encode 4.1.0",
]
[[package]]
name = "objc2-app-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.2",
"objc2 0.6.5",
"objc2-core-graphics",
"objc2-foundation",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.2",
"dispatch2",
"objc2 0.6.5",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.13.2",
"dispatch2",
"objc2 0.6.5",
"objc2-core-foundation",
"objc2-io-surface",
] ]
[[package]] [[package]]
@@ -1453,6 +1648,34 @@ version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666" checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666"
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.2",
"objc2 0.6.5",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.13.2",
"objc2 0.6.5",
"objc2-core-foundation",
]
[[package]] [[package]]
name = "objc_exception" name = "objc_exception"
version = "0.1.2" version = "0.1.2"
@@ -1568,6 +1791,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.13.2",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]] [[package]]
name = "polling" name = "polling"
version = "3.11.0" version = "3.11.0"
@@ -1654,6 +1890,18 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.41.0" version = "0.41.0"
@@ -2142,6 +2390,20 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "tiff"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg",
]
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.55" version = "0.3.55"
@@ -3064,7 +3326,7 @@ dependencies = [
"memmap2", "memmap2",
"ndk", "ndk",
"ndk-sys", "ndk-sys",
"objc2", "objc2 0.4.1",
"once_cell", "once_cell",
"orbclient", "orbclient",
"percent-encoding", "percent-encoding",
@@ -3287,3 +3549,18 @@ name = "zmij"
version = "1.0.23" version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+4
View File
@@ -39,6 +39,10 @@ sha2 = "0.10"
ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] } ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] }
url = "2.5" url = "2.5"
octotablet = "0.1.0" octotablet = "0.1.0"
# Bilder einfügen: Dekodieren (PNG/JPEG/GIF/WebP/BMP) und Systemzwischenablage
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] }
arboard = { version = "3", default-features = false, features = ["image-data"] }
minisign-verify = "0.3.0" minisign-verify = "0.3.0"
fs2 = "0.4" fs2 = "0.4"
+355
View File
@@ -0,0 +1,355 @@
//! Eingebettete Bilder eines Notizbuchs (`<Notizbuch>/assets/`).
//!
//! Jedes Bild wird einmal unter dem SHA-256 seines Inhalts gespeichert (`<hash>.png` bzw.
//! `<hash>.jpg`). Seiten verweisen nur auf diesen Namen; dasselbe Bild mehrfach einzufügen
//! kostet keinen zusätzlichen Platz. Bilddateien werden nie gelöscht, damit Undo und
//! Kopien auf anderen Seiten immer funktionieren.
//!
//! JPEG wird unverändert übernommen (verlustfrei, auch im PDF-Export); alle anderen Formate
//! werden als PNG gespeichert.
use std::io::Cursor;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::storage;
/// Name des Unterordners mit den Bilddateien.
pub const ASSETS_DIR: &str = "assets";
/// Größte Kantenlänge eines eingefügten Bildes in Pixeln; größere Bilder werden verkleinert
/// (8000 px ≈ 300 dpi über die doppelte A4-Breite – mehr ist auf einer Seite nie sichtbar).
pub const MAX_IMPORT_DIMENSION: u32 = 8000;
/// Größte Kantenlänge der Bildschirmdarstellung (GPU-Textur).
pub const MAX_DISPLAY_DIMENSION: u32 = 2048;
/// Größte Dateigröße beim Einfügen.
pub const MAX_IMPORT_BYTES: usize = 64 * 1024 * 1024;
/// Fehler beim Einfügen oder Laden eines Bildes.
#[derive(Debug)]
pub enum AssetError {
/// Format nicht unterstützt oder Datei beschädigt
Decode(String),
/// Datei zu groß
TooLarge(usize),
/// Ungültiger Asset-Name (z. B. manipulierte Seitendatei)
InvalidName(String),
Io(std::io::Error),
}
impl std::fmt::Display for AssetError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AssetError::Decode(msg) => write!(f, "Bild kann nicht gelesen werden: {msg}"),
AssetError::TooLarge(bytes) => write!(
f,
"Bild ist zu groß ({} MB, höchstens {} MB)",
bytes / (1024 * 1024),
MAX_IMPORT_BYTES / (1024 * 1024)
),
AssetError::InvalidName(name) => write!(f, "Ungültiger Bildverweis '{name}'"),
AssetError::Io(e) => write!(f, "Ein-/Ausgabefehler: {e}"),
}
}
}
impl std::error::Error for AssetError {}
impl From<std::io::Error> for AssetError {
fn from(e: std::io::Error) -> Self {
AssetError::Io(e)
}
}
/// Ergebnis eines erfolgreichen Imports.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportedImage {
/// Dateiname im Asset-Ordner, z. B. `3f…a9.png`
pub asset: String,
pub width: u32,
pub height: u32,
}
/// Dekodiertes Bild in 8-Bit-RGBA (sRGB).
#[derive(Debug, Clone, PartialEq)]
pub struct RgbaImage {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
}
/// Prüft einen Asset-Namen: genau 64 Hex-Zeichen und `.png` oder `.jpg`. Verhindert, dass
/// eine manipulierte Seitendatei auf Dateien außerhalb des Asset-Ordners verweist.
pub fn is_valid_asset_name(name: &str) -> bool {
let Some((hash, ext)) = name.split_once('.') else {
return false;
};
hash.len() == 64
&& hash
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
&& matches!(ext, "png" | "jpg")
}
/// Bilddateien eines Notizbuchs.
#[derive(Debug, Clone)]
pub struct AssetStore {
dir: PathBuf,
}
impl AssetStore {
/// Asset-Ordner des Notizbuchs in `notebook_dir`.
pub fn new(notebook_dir: &Path) -> Self {
Self {
dir: notebook_dir.join(ASSETS_DIR),
}
}
pub fn dir(&self) -> &Path {
&self.dir
}
/// Pfad einer Bilddatei (nur für gültige Namen).
pub fn path_of(&self, asset: &str) -> Result<PathBuf, AssetError> {
if is_valid_asset_name(asset) {
Ok(self.dir.join(asset))
} else {
Err(AssetError::InvalidName(asset.to_string()))
}
}
/// Übernimmt eine Bilddatei (Inhalt einer PNG-, JPEG-, GIF-, WebP- oder BMP-Datei).
pub fn import_bytes(&self, bytes: &[u8]) -> Result<ImportedImage, AssetError> {
if bytes.len() > MAX_IMPORT_BYTES {
return Err(AssetError::TooLarge(bytes.len()));
}
let format = image::guess_format(bytes).map_err(|e| AssetError::Decode(e.to_string()))?;
let decoded = image::load_from_memory_with_format(bytes, format)
.map_err(|e| AssetError::Decode(e.to_string()))?;
let fits =
decoded.width() <= MAX_IMPORT_DIMENSION && decoded.height() <= MAX_IMPORT_DIMENSION;
if format == image::ImageFormat::Jpeg && fits {
// JPEG unverändert übernehmen (kein erneuter Qualitätsverlust)
return self.store(bytes, "jpg", decoded.width(), decoded.height());
}
let decoded = if fits {
decoded
} else {
decoded.resize(
MAX_IMPORT_DIMENSION,
MAX_IMPORT_DIMENSION,
image::imageops::FilterType::Triangle,
)
};
let png = encode_png(&decoded)?;
self.store(&png, "png", decoded.width(), decoded.height())
}
/// Übernimmt rohe RGBA-Pixel (z. B. aus der Zwischenablage) als PNG.
pub fn import_rgba(
&self,
width: u32,
height: u32,
pixels: Vec<u8>,
) -> Result<ImportedImage, AssetError> {
let buffer = image::RgbaImage::from_raw(width, height, pixels)
.ok_or_else(|| AssetError::Decode("Pixeldaten passen nicht zur Bildgröße".into()))?;
let mut decoded = image::DynamicImage::ImageRgba8(buffer);
if width > MAX_IMPORT_DIMENSION || height > MAX_IMPORT_DIMENSION {
decoded = decoded.resize(
MAX_IMPORT_DIMENSION,
MAX_IMPORT_DIMENSION,
image::imageops::FilterType::Triangle,
);
}
let png = encode_png(&decoded)?;
self.store(&png, "png", decoded.width(), decoded.height())
}
/// Kopiert ein Bild aus dem Asset-Ordner eines anderen Notizbuchs (Einfügen über
/// Notizbuchgrenzen hinweg). Ist es schon vorhanden, passiert nichts.
pub fn copy_from(&self, other: &AssetStore, asset: &str) -> Result<(), AssetError> {
let target = self.path_of(asset)?;
if target.exists() || other.dir == self.dir {
return Ok(());
}
let bytes = std::fs::read(other.path_of(asset)?)?;
storage::atomic_write_file(&target, &bytes)?;
Ok(())
}
/// Liest die gespeicherte Bilddatei unverändert.
pub fn read_bytes(&self, asset: &str) -> Result<Vec<u8>, AssetError> {
Ok(std::fs::read(self.path_of(asset)?)?)
}
/// Lädt ein Bild für die Bildschirmdarstellung, verkleinert auf höchstens
/// `max_dimension` Pixel Kantenlänge.
pub fn load_rgba(&self, asset: &str, max_dimension: u32) -> Result<RgbaImage, AssetError> {
let bytes = self.read_bytes(asset)?;
let mut decoded =
image::load_from_memory(&bytes).map_err(|e| AssetError::Decode(e.to_string()))?;
if decoded.width() > max_dimension || decoded.height() > max_dimension {
decoded = decoded.resize(
max_dimension,
max_dimension,
image::imageops::FilterType::Triangle,
);
}
let rgba = decoded.into_rgba8();
Ok(RgbaImage {
width: rgba.width(),
height: rgba.height(),
pixels: rgba.into_raw(),
})
}
fn store(
&self,
bytes: &[u8],
ext: &str,
width: u32,
height: u32,
) -> Result<ImportedImage, AssetError> {
let hash = Sha256::digest(bytes);
let asset = format!("{}.{ext}", hex(&hash));
let path = self.path_of(&asset)?;
if !path.exists() {
storage::atomic_write_file(&path, bytes)?;
}
Ok(ImportedImage {
asset,
width,
height,
})
}
}
fn encode_png(image: &image::DynamicImage) -> Result<Vec<u8>, AssetError> {
let mut png = Vec::new();
image
.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png)
.map_err(|e| AssetError::Decode(e.to_string()))?;
Ok(png)
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// Ist die Datei dem Namen nach ein unterstütztes Bild?
pub fn is_supported_image_path(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| {
matches!(
e.to_ascii_lowercase().as_str(),
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp"
)
})
.unwrap_or(false)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// Kleines Testbild als Dateiinhalt im gewünschten Format.
pub(crate) fn sample_image_bytes(
width: u32,
height: u32,
format: image::ImageFormat,
) -> Vec<u8> {
let img = image::RgbImage::from_fn(width, height, |x, y| {
image::Rgb([
(x * 255 / width.max(1)) as u8,
(y * 255 / height.max(1)) as u8,
90,
])
});
let mut bytes = Vec::new();
image::DynamicImage::ImageRgb8(img)
.write_to(&mut Cursor::new(&mut bytes), format)
.unwrap();
bytes
}
#[test]
fn test_asset_names_are_validated() {
let hash = "a".repeat(64);
assert!(is_valid_asset_name(&format!("{hash}.png")));
assert!(is_valid_asset_name(&format!("{hash}.jpg")));
assert!(!is_valid_asset_name(&format!("{hash}.exe")));
assert!(!is_valid_asset_name("../../etc/passwd"));
assert!(!is_valid_asset_name(&format!("../{}.png", "a".repeat(61))));
assert!(!is_valid_asset_name(&format!("{}.png", "A".repeat(64))));
assert!(!is_valid_asset_name(""));
let store = AssetStore::new(Path::new("/tmp/nb"));
assert!(store.path_of("../manifest.json").is_err());
}
#[test]
fn test_import_png_jpeg_and_deduplicate() {
let dir = tempfile::tempdir().unwrap();
let store = AssetStore::new(dir.path());
let png = sample_image_bytes(40, 20, image::ImageFormat::Png);
let a = store.import_bytes(&png).unwrap();
assert_eq!((a.width, a.height), (40, 20));
assert!(a.asset.ends_with(".png"));
assert!(store.path_of(&a.asset).unwrap().exists());
// Gleiches Bild nochmals: gleicher Name, keine zweite Datei
let b = store.import_bytes(&png).unwrap();
assert_eq!(a, b);
assert_eq!(std::fs::read_dir(store.dir()).unwrap().count(), 1);
// JPEG bleibt byte-identisch
let jpg = sample_image_bytes(30, 30, image::ImageFormat::Jpeg);
let c = store.import_bytes(&jpg).unwrap();
assert!(c.asset.ends_with(".jpg"));
assert_eq!(store.read_bytes(&c.asset).unwrap(), jpg);
// BMP wird als PNG gespeichert
let bmp = sample_image_bytes(8, 4, image::ImageFormat::Bmp);
let d = store.import_bytes(&bmp).unwrap();
assert!(d.asset.ends_with(".png"));
let rgba = store.load_rgba(&a.asset, MAX_DISPLAY_DIMENSION).unwrap();
assert_eq!((rgba.width, rgba.height), (40, 20));
assert_eq!(rgba.pixels.len(), 40 * 20 * 4);
// Verkleinerung für die Anzeige behält das Seitenverhältnis
let small = store.load_rgba(&a.asset, 10).unwrap();
assert_eq!((small.width, small.height), (10, 5));
}
#[test]
fn test_import_rejects_garbage_and_copies_between_notebooks() {
let dir = tempfile::tempdir().unwrap();
let store = AssetStore::new(&dir.path().join("a"));
assert!(matches!(
store.import_bytes(b"kein Bild"),
Err(AssetError::Decode(_))
));
assert!(store.import_rgba(4, 4, vec![0; 3]).is_err());
let imported = store.import_rgba(2, 2, vec![255; 16]).unwrap();
let other = AssetStore::new(&dir.path().join("b"));
assert!(other.read_bytes(&imported.asset).is_err());
other.copy_from(&store, &imported.asset).unwrap();
assert_eq!(
other.read_bytes(&imported.asset).unwrap(),
store.read_bytes(&imported.asset).unwrap()
);
}
#[test]
fn test_supported_image_paths() {
assert!(is_supported_image_path(Path::new("/x/Foto.JPG")));
assert!(is_supported_image_path(Path::new("a.webp")));
assert!(!is_supported_image_path(Path::new("a.pdf")));
assert!(!is_supported_image_path(Path::new("png")));
}
}
+29 -3
View File
@@ -165,6 +165,9 @@ pub struct CanvasState {
pub committed_vertex_count: u32, pub committed_vertex_count: u32,
pub committed_strokes: Vec<Range<u32>>, pub committed_strokes: Vec<Range<u32>>,
// Eingefügte Bilder der aktuellen Seite
image_layer: crate::image_layer::ImageLayer,
// egui UI-Renderer // egui UI-Renderer
pub egui_renderer: egui_wgpu::Renderer, pub egui_renderer: egui_wgpu::Renderer,
} }
@@ -407,6 +410,13 @@ impl CanvasState {
mapped_at_creation: false, mapped_at_creation: false,
}); });
let image_layer = crate::image_layer::ImageLayer::new(
&device,
&shader,
&bind_group_layout,
config.format,
);
// 8. egui Renderer für das schwebende UI initialisieren // 8. egui Renderer für das schwebende UI initialisieren
let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1); let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1);
@@ -450,6 +460,7 @@ impl CanvasState {
committed_vertex_capacity: Self::INITIAL_COMMITTED_CAPACITY, committed_vertex_capacity: Self::INITIAL_COMMITTED_CAPACITY,
committed_vertex_count: 0, committed_vertex_count: 0,
committed_strokes: Vec::with_capacity(128), committed_strokes: Vec::with_capacity(128),
image_layer,
egui_renderer, egui_renderer,
camera: crate::viewport::ViewportState::new(), camera: crate::viewport::ViewportState::new(),
}) })
@@ -655,6 +666,12 @@ impl CanvasState {
pub fn sync_page(&mut self, page: &mut Page) { pub fn sync_page(&mut self, page: &mut Page) {
page.rebuild_cache_if_dirty(); page.rebuild_cache_if_dirty();
self.set_background(page.background); self.set_background(page.background);
self.image_layer.set_quads(
page.strokes
.iter()
.filter_map(|s| Some((s.image.clone()?, s.image_corners()?)))
.collect(),
);
self.committed_vertex_count = page.vertex_cache.len() as u32; self.committed_vertex_count = page.vertex_cache.len() as u32;
self.committed_strokes = page.stroke_ranges.clone(); self.committed_strokes = page.stroke_ranges.clone();
@@ -681,6 +698,12 @@ impl CanvasState {
); );
} }
/// Lädt Bilder der aktuellen Seite, die noch nicht auf der GPU liegen. Vor jedem
/// Zeichnen aufrufen; ohne Änderungen kostet das nichts.
pub fn prepare_images(&mut self, assets: &crate::assets::AssetStore) {
self.image_layer.prepare(&self.device, &self.queue, assets);
}
/// Startet einen neuen Live-Strich in DIN-A4-Seitenkoordinaten. /// Startet einen neuen Live-Strich in DIN-A4-Seitenkoordinaten.
pub fn start_stroke(&mut self, page_x: f32, page_y: f32, pressure: f32, timestamp: f64) { pub fn start_stroke(&mut self, page_x: f32, page_y: f32, pressure: f32, timestamp: f64) {
self.filter.reset(); self.filter.reset();
@@ -880,7 +903,10 @@ impl CanvasState {
render_pass.draw(0..self.background_vertex_count, 0..1); render_pass.draw(0..self.background_vertex_count, 0..1);
} }
// 3. Abgeschlossene Striche der aktiven Seite rendern (TriangleStrip mit Vertex-Farben) // 3. Eingefügte Bilder (unter der Tinte)
self.image_layer.draw(&mut render_pass);
// 4. Abgeschlossene Striche der aktiven Seite rendern (TriangleStrip mit Vertex-Farben)
render_pass.set_pipeline(&self.strip_pipeline); render_pass.set_pipeline(&self.strip_pipeline);
if self.committed_vertex_count > 0 { if self.committed_vertex_count > 0 {
@@ -892,7 +918,7 @@ impl CanvasState {
} }
} }
// 4. Temporäre Live-Geometrie des aktuellen Strichs rendern (Zero Latency) // 5. Temporäre Live-Geometrie des aktuellen Strichs rendern (Zero Latency)
if self.live_vertex_count > 0 { if self.live_vertex_count > 0 {
render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..)); render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..));
render_pass.draw(0..self.live_vertex_count, 0..1); render_pass.draw(0..self.live_vertex_count, 0..1);
@@ -997,7 +1023,7 @@ mod tests {
.validate(&module) .validate(&module)
.unwrap_or_else(|e| panic!("Shader ungültig: {e:?}")); .unwrap_or_else(|e| panic!("Shader ungültig: {e:?}"));
for name in ["vs_main", "fs_main", "fs_ink"] { for name in ["vs_main", "fs_main", "fs_ink", "vs_image", "fs_image"] {
assert!( assert!(
module.entry_points.iter().any(|ep| ep.name == name), module.entry_points.iter().any(|ep| ep.name == name),
"Einstiegspunkt {name} fehlt" "Einstiegspunkt {name} fehlt"
+204 -9
View File
@@ -103,6 +103,52 @@ pub fn point_to_segment_distance(p: glam::Vec2, a: glam::Vec2, b: glam::Vec2) ->
(p - projection).length() (p - projection).length()
} }
/// Liegt `p` im konvexen Viereck `quad` (Ecken der Reihe nach, beliebiger Umlaufsinn)?
pub fn point_in_convex_quad(p: glam::Vec2, quad: &[glam::Vec2; 4]) -> bool {
let mut sign = 0.0_f32;
for i in 0..4 {
let a = quad[i];
let b = quad[(i + 1) % 4];
let cross = (b - a).perp_dot(p - a);
if cross.abs() < 1e-6 {
continue;
}
if sign == 0.0 {
sign = cross.signum();
} else if cross.signum() != sign {
return false;
}
}
true
}
/// Seiteneinheiten pro Bildpixel beim Einfügen (96 dpi wie am Bildschirm: 1 px ≈ 0,26 mm).
pub const IMAGE_UNITS_PER_PIXEL: f32 = UNITS_PER_MM * 25.4 / 96.0;
/// Ecken (oben links, oben rechts, unten rechts, unten links) für ein neu eingefügtes Bild
/// von `width` × `height` Pixeln um `center`: Bildschirmgröße bei 96 dpi, höchstens 80 % der
/// Seitenbreite und 60 % der Seitenhöhe, und möglichst ganz auf dem Blatt.
pub fn place_image(width: u32, height: u32, center: glam::Vec2) -> [glam::Vec2; 4] {
let natural =
glam::Vec2::new(width.max(1) as f32, height.max(1) as f32) * IMAGE_UNITS_PER_PIXEL;
let fit = (PAGE_WIDTH * 0.8 / natural.x)
.min(PAGE_HEIGHT * 0.6 / natural.y)
.min(1.0);
let size = natural * fit;
let half = size / 2.0;
let center = glam::Vec2::new(
center.x.clamp(half.x, PAGE_WIDTH - half.x),
center.y.clamp(half.y, PAGE_HEIGHT - half.y),
);
let tl = center - half;
[
tl,
tl + glam::Vec2::new(size.x, 0.0),
tl + size,
tl + glam::Vec2::new(0.0, size.y),
]
}
/// Das Werkzeug, mit dem ein Strich gezeichnet wurde. /// Das Werkzeug, mit dem ein Strich gezeichnet wurde.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -158,6 +204,10 @@ pub struct Stroke {
pub tool: StrokeTool, pub tool: StrokeTool,
pub width: f32, pub width: f32,
pub finalized: bool, pub finalized: bool,
/// Eingefügtes Bild (Name im Asset-Ordner, siehe `assets`). Die vier Punkte sind dann die
/// Bildecken oben links, oben rechts, unten rechts, unten links; Verschieben, Drehen und
/// Skalieren wirken so automatisch auch auf Bilder. Bilder liegen immer unter der Tinte.
pub image: Option<String>,
} }
impl Stroke { impl Stroke {
@@ -190,9 +240,44 @@ impl Stroke {
tool: StrokeTool::Pen, tool: StrokeTool::Pen,
width: 5.0, width: 5.0,
finalized: true, finalized: true,
image: None,
} }
} }
/// Erzeugt ein Bildobjekt mit den Ecken oben links, oben rechts, unten rechts, unten links.
pub fn new_image(id: u64, asset: String, corners: [glam::Vec2; 4]) -> Self {
let points = corners
.iter()
.map(|&position| StylusSample {
position,
pressure: 1.0,
timestamp: 0.0,
})
.collect();
let mut stroke = Self::new(id, points, Vec::new(), [1.0, 1.0, 1.0, 1.0]);
stroke.width = 0.0;
stroke.image = Some(asset);
stroke
}
/// Ist dieses Objekt ein Bild?
pub fn is_image(&self) -> bool {
self.image.is_some()
}
/// Bildecken (oben links, oben rechts, unten rechts, unten links), falls ein Bild.
pub fn image_corners(&self) -> Option<[glam::Vec2; 4]> {
if self.image.is_none() || self.points.len() < 4 {
return None;
}
Some([
self.points[0].position,
self.points[1].position,
self.points[2].position,
self.points[3].position,
])
}
/// Finalisiert rohe Stylus-Samples mit akausalem Post-Processing (Zwei-Stufen-Modell). /// Finalisiert rohe Stylus-Samples mit akausalem Post-Processing (Zwei-Stufen-Modell).
pub fn finalize( pub fn finalize(
id: u64, id: u64,
@@ -226,7 +311,16 @@ impl Stroke {
} }
/// Berechnet den minimalen euklidischen Abstand eines Punktes zu diesem Strich. /// Berechnet den minimalen euklidischen Abstand eines Punktes zu diesem Strich.
/// Bei Bildern ist der Abstand innerhalb der Bildfläche 0.
pub fn distance_to_point(&self, p: glam::Vec2) -> f32 { pub fn distance_to_point(&self, p: glam::Vec2) -> f32 {
if let Some(corners) = self.image_corners() {
if point_in_convex_quad(p, &corners) {
return 0.0;
}
return (0..4)
.map(|i| point_to_segment_distance(p, corners[i], corners[(i + 1) % 4]))
.fold(f32::INFINITY, f32::min);
}
if self.points.is_empty() { if self.points.is_empty() {
return f32::INFINITY; return f32::INFINITY;
} }
@@ -348,7 +442,11 @@ impl Stroke {
} }
/// Weist diesem Strich eine neue Farbe zu und aktualisiert alle Vertex-Farben. /// Weist diesem Strich eine neue Farbe zu und aktualisiert alle Vertex-Farben.
/// Bilder behalten ihre Farben.
pub fn set_color(&mut self, new_color: [f32; 4]) { pub fn set_color(&mut self, new_color: [f32; 4]) {
if self.is_image() {
return;
}
self.color = new_color; self.color = new_color;
for v in &mut self.vertices { for v in &mut self.vertices {
v.color = new_color; v.color = new_color;
@@ -462,20 +560,18 @@ impl Page {
} }
/// Findet den nächsten Strich an Position `pos` innerhalb eines maximalen Suchradius. /// Findet den nächsten Strich an Position `pos` innerhalb eines maximalen Suchradius.
/// Tinte hat Vorrang vor Bildern darunter, damit Notizen auf einem Bild auswählbar bleiben.
pub fn find_stroke_at(&self, pos: glam::Vec2, max_dist: f32) -> Option<&Stroke> { pub fn find_stroke_at(&self, pos: glam::Vec2, max_dist: f32) -> Option<&Stroke> {
let mut best: Option<(&Stroke, f32)> = None; let mut best: Option<(&Stroke, (bool, f32))> = None;
for s in &self.strokes { for s in &self.strokes {
if !s.aabb.expanded(max_dist).contains_point(pos) { if !s.aabb.expanded(max_dist).contains_point(pos) {
continue; continue;
} }
let d = s.distance_to_point(pos); let d = s.distance_to_point(pos);
if d <= max_dist { if d <= max_dist {
if let Some((_, best_d)) = best { let key = (s.is_image(), d);
if d < best_d { if best.map_or(true, |(_, best_key)| key < best_key) {
best = Some((s, d)); best = Some((s, key));
}
} else {
best = Some((s, d));
} }
} }
} }
@@ -516,7 +612,8 @@ impl Page {
let mut hit_indices = Vec::new(); let mut hit_indices = Vec::new();
for (i, stroke) in self.strokes.iter().enumerate() { for (i, stroke) in self.strokes.iter().enumerate() {
if mode.erases(stroke.tool) if !stroke.is_image()
&& mode.erases(stroke.tool)
&& stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius) && stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius)
{ {
hit_indices.push(i); hit_indices.push(i);
@@ -572,6 +669,8 @@ impl Page {
} }
/// Findet die IDs aller Striche, die die gegebene Auswahlbox schneiden oder darin liegen. /// Findet die IDs aller Striche, die die gegebene Auswahlbox schneiden oder darin liegen.
/// Bilder werden nur ausgewählt, wenn sie ganz in der Box liegen – sonst würde jede
/// Auswahl von Notizen auf einem Bild das Bild mitnehmen.
pub fn find_strokes_in_box(&self, box_min: glam::Vec2, box_max: glam::Vec2) -> Vec<u64> { pub fn find_strokes_in_box(&self, box_min: glam::Vec2, box_max: glam::Vec2) -> Vec<u64> {
let sel_box = Aabb::new( let sel_box = Aabb::new(
glam::Vec2::new(box_min.x.min(box_max.x), box_min.y.min(box_max.y)), glam::Vec2::new(box_min.x.min(box_max.x), box_min.y.min(box_max.y)),
@@ -579,7 +678,13 @@ impl Page {
); );
self.strokes self.strokes
.iter() .iter()
.filter(|s| s.aabb.intersects(&sel_box)) .filter(|s| {
if s.is_image() {
sel_box.contains_point(s.aabb.min) && sel_box.contains_point(s.aabb.max)
} else {
s.aabb.intersects(&sel_box)
}
})
.map(|s| s.id) .map(|s| s.id)
.collect() .collect()
} }
@@ -1145,6 +1250,96 @@ mod tests {
assert!(page.strokes.is_empty()); assert!(page.strokes.is_empty());
} }
#[test]
fn test_place_image() {
let center = glam::Vec2::new(PAGE_WIDTH / 2.0, PAGE_HEIGHT / 2.0);
// Kleines Bild: Bildschirmgröße, zentriert
let [tl, tr, br, bl] = place_image(200, 100, center);
assert!(((tr - tl).length() - 200.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
assert!(((bl - tl).length() - 100.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
assert!(((tl + br) / 2.0 - center).length() < 1e-3);
// Großer Screenshot: auf 80 % der Breite verkleinert, Seitenverhältnis bleibt
let [tl, tr, br, _] = place_image(3840, 2160, center);
let size = br - tl;
assert!((size.x - PAGE_WIDTH * 0.8).abs() < 1e-2);
assert!((size.x / size.y - 3840.0 / 2160.0).abs() < 1e-3);
assert_eq!(tr.y, tl.y);
// Am Rand eingefügt: wird aufs Blatt geschoben
let corners = place_image(400, 400, glam::Vec2::new(-50.0, PAGE_HEIGHT + 50.0));
for c in corners {
assert!(c.x >= -1e-3 && c.x <= PAGE_WIDTH + 1e-3);
assert!(c.y >= -1e-3 && c.y <= PAGE_HEIGHT + 1e-3);
}
}
#[test]
fn test_image_objects_hit_test_and_survive_erasing() {
let mut page = Page::new(1, PageBackground::Blank);
let corners = [
glam::Vec2::new(100.0, 100.0),
glam::Vec2::new(300.0, 100.0),
glam::Vec2::new(300.0, 200.0),
glam::Vec2::new(100.0, 200.0),
];
page.add_stroke(Stroke::new_image(1, "a.png".into(), corners));
assert_eq!(page.strokes[0].image_corners(), Some(corners));
// Fläche des Bildes trifft, auch weit weg vom Rand
let inside = glam::Vec2::new(200.0, 150.0);
assert_eq!(page.find_stroke_at(inside, 5.0).map(|s| s.id), Some(1));
assert!(page
.find_stroke_at(glam::Vec2::new(400.0, 150.0), 5.0)
.is_none());
// Notiz auf dem Bild hat beim Antippen Vorrang
let mut ink = Stroke::new(
2,
vec![
StylusSample {
position: glam::Vec2::new(150.0, 150.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: glam::Vec2::new(250.0, 152.0),
pressure: 0.5,
timestamp: 0.1,
},
],
Vec::new(),
[0.0, 0.0, 0.0, 1.0],
);
ink.width = 5.0;
page.add_stroke(ink);
assert_eq!(page.find_stroke_at(inside, 5.0).map(|s| s.id), Some(2));
// Radierer entfernt nur die Notiz, nie das Bild
let removed = page.erase_at(inside, 30.0, 12.0, EraserMode::All);
assert_eq!(removed.iter().map(|s| s.id).collect::<Vec<_>>(), vec![2]);
assert!(page
.erase_at(glam::Vec2::new(100.0, 100.0), 30.0, 12.0, EraserMode::All)
.is_empty());
// Auswahlrechteck: Bild nur bei vollständigem Einschluss
assert!(page
.find_strokes_in_box(glam::Vec2::new(150.0, 120.0), glam::Vec2::new(250.0, 180.0))
.is_empty());
assert_eq!(
page.find_strokes_in_box(glam::Vec2::new(90.0, 90.0), glam::Vec2::new(310.0, 210.0)),
vec![1]
);
// Umfärben ändert Bilder nicht, Drehen bewegt die Ecken mit
let mut image = page.strokes[0].clone();
image.set_color([1.0, 0.0, 0.0, 1.0]);
assert_eq!(image.color, [1.0, 1.0, 1.0, 1.0]);
image.rotate(glam::Vec2::new(200.0, 150.0), std::f32::consts::FRAC_PI_2);
let rotated = image.image_corners().unwrap();
assert!((rotated[0] - glam::Vec2::new(250.0, 50.0)).length() < 1e-3);
}
#[test] #[test]
fn test_page_isolation() { fn test_page_isolation() {
let mut nb = Notebook::new(); let mut nb = Notebook::new();
+284 -6
View File
@@ -2,7 +2,7 @@
//! //!
//! Features: //! Features:
//! - Hochformat DIN A4 (210 mm x 297 mm) //! - Hochformat DIN A4 (210 mm x 297 mm)
//! - 2 Layer pro Seite: Layer 1 ("Background") und Layer 2 ("Ink") //! - Layer pro Seite: "Background", "Images" (nur bei eingefügten Bildern) und "Ink"
//! - Y-Achsen-Spiegelung (PDF-Ursprung unten links vs. Bildschirmursprung oben links) //! - Y-Achsen-Spiegelung (PDF-Ursprung unten links vs. Bildschirmursprung oben links)
//! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko) //! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko)
//! - Zwei Export-Strategien: //! - Zwei Export-Strategien:
@@ -17,6 +17,7 @@ use std::path::{Path, PathBuf};
use printpdf::path::{PaintMode, WindingOrder}; use printpdf::path::{PaintMode, WindingOrder};
use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb}; use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb};
use crate::assets::AssetStore;
use crate::background::{GRID_5MM_SPACING, LINED_SPACING}; use crate::background::{GRID_5MM_SPACING, LINED_SPACING};
use crate::document::{ use crate::document::{
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH, Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
@@ -109,14 +110,16 @@ pub fn page_to_pdf_mm(x: f32, y: f32) -> (f32, f32) {
} }
/// Erzeugt ein PDF-Dokument aus einem Notizbuch und speichert es als Datei. /// Erzeugt ein PDF-Dokument aus einem Notizbuch und speichert es als Datei.
/// `assets`: Bilder des Notizbuchs (ohne werden Bilder als graue Fläche ausgegeben).
pub fn export_notebook_to_pdf<P: AsRef<Path>>( pub fn export_notebook_to_pdf<P: AsRef<Path>>(
notebook: &Notebook, notebook: &Notebook,
path: P, path: P,
strategy: ExportStrategy, strategy: ExportStrategy,
assets: Option<&AssetStore>,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
let file = File::create(path)?; let file = File::create(path)?;
let mut writer = BufWriter::new(file); let mut writer = BufWriter::new(file);
let doc = generate_pdf_document(notebook, strategy)?; let doc = generate_pdf_document(notebook, strategy, assets)?;
doc.save(&mut writer)?; doc.save(&mut writer)?;
Ok(()) Ok(())
} }
@@ -125,8 +128,9 @@ pub fn export_notebook_to_pdf<P: AsRef<Path>>(
pub fn export_notebook_to_bytes( pub fn export_notebook_to_bytes(
notebook: &Notebook, notebook: &Notebook,
strategy: ExportStrategy, strategy: ExportStrategy,
assets: Option<&AssetStore>,
) -> Result<Vec<u8>, Box<dyn Error>> { ) -> Result<Vec<u8>, Box<dyn Error>> {
let doc = generate_pdf_document(notebook, strategy)?; let doc = generate_pdf_document(notebook, strategy, assets)?;
let bytes = doc.save_to_bytes()?; let bytes = doc.save_to_bytes()?;
Ok(bytes) Ok(bytes)
} }
@@ -135,6 +139,7 @@ pub fn export_notebook_to_bytes(
pub fn generate_pdf_document( pub fn generate_pdf_document(
notebook: &Notebook, notebook: &Notebook,
strategy: ExportStrategy, strategy: ExportStrategy,
assets: Option<&AssetStore>,
) -> Result<PdfDocumentReference, Box<dyn Error>> { ) -> Result<PdfDocumentReference, Box<dyn Error>> {
if notebook.pages.is_empty() { if notebook.pages.is_empty() {
return Err("Notizbuch enthält keine Seiten".into()); return Err("Notizbuch enthält keine Seiten".into());
@@ -159,6 +164,9 @@ pub fn generate_pdf_document(
let page1_ref = doc.get_page(page1_idx); let page1_ref = doc.get_page(page1_idx);
let layer1_bg_ref = page1_ref.get_layer(layer1_bg); let layer1_bg_ref = page1_ref.get_layer(layer1_bg);
render_background_layer(&layer1_bg_ref, notebook.pages[0].background); render_background_layer(&layer1_bg_ref, notebook.pages[0].background);
if notebook.pages[0].strokes.iter().any(Stroke::is_image) {
render_image_layer(&page1_ref.add_layer("Images"), &notebook.pages[0], assets);
}
let layer1_ink_ref = page1_ref.add_layer("Ink"); let layer1_ink_ref = page1_ref.add_layer("Ink");
render_ink_layer(&layer1_ink_ref, &notebook.pages[0], strategy); render_ink_layer(&layer1_ink_ref, &notebook.pages[0], strategy);
@@ -169,6 +177,9 @@ pub fn generate_pdf_document(
let page_ref = doc.get_page(page_idx); let page_ref = doc.get_page(page_idx);
let layer_bg_ref = page_ref.get_layer(layer_bg); let layer_bg_ref = page_ref.get_layer(layer_bg);
render_background_layer(&layer_bg_ref, page.background); render_background_layer(&layer_bg_ref, page.background);
if page.strokes.iter().any(Stroke::is_image) {
render_image_layer(&page_ref.add_layer("Images"), page, assets);
}
let layer_ink_ref = page_ref.add_layer("Ink"); let layer_ink_ref = page_ref.add_layer("Ink");
render_ink_layer(&layer_ink_ref, page, strategy); render_ink_layer(&layer_ink_ref, page, strategy);
@@ -268,6 +279,9 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, background: Page
/// Rendert alle Striche einer Seite auf Layer 2 ("Ink"). /// Rendert alle Striche einer Seite auf Layer 2 ("Ink").
fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) { fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) {
for stroke in &page.strokes { for stroke in &page.strokes {
if stroke.is_image() {
continue;
}
if let Some(ref shape) = stroke.shape { if let Some(ref shape) = stroke.shape {
render_recognized_shape(layer, shape, stroke.color); render_recognized_shape(layer, shape, stroke.color);
} else { } else {
@@ -283,6 +297,160 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy:
} }
} }
/// Auflösung, mit der Bilder höchstens eingebettet werden (Druckqualität).
pub const PDF_IMAGE_DPI: f32 = 300.0;
/// Seitenkoordinate in PDF-Punkten (ohne Begrenzung auf das Blatt, Ursprung unten links).
fn page_to_pdf_pt(p: glam::Vec2) -> glam::Vec2 {
const PT_PER_MM: f32 = 72.0 / 25.4;
glam::Vec2::new(
p.x / PAGE_WIDTH * A4_WIDTH_MM * PT_PER_MM,
(1.0 - p.y / PAGE_HEIGHT) * A4_HEIGHT_MM * PT_PER_MM,
)
}
/// PDF-Transformationsmatrix, die das Einheitsquadrat eines Bildes (Ursprung unten links)
/// auf die Bildecken oben links, oben rechts, unten rechts, unten links abbildet.
pub fn image_matrix(corners: &[glam::Vec2; 4]) -> [f32; 6] {
let [tl, _, br, bl] = corners.map(page_to_pdf_pt);
let x_axis = br - bl;
let y_axis = tl - bl;
[x_axis.x, x_axis.y, y_axis.x, y_axis.y, bl.x, bl.y]
}
/// Anzahl der Bildkomponenten eines JPEG laut Frame-Header (1 = Graustufen, 3 = Farbe,
/// 4 = CMYK), `None` bei unlesbarem Header.
fn jpeg_components(bytes: &[u8]) -> Option<u8> {
let mut i = 2;
while i + 9 < bytes.len() {
if bytes[i] != 0xFF {
return None;
}
let marker = bytes[i + 1];
let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
// SOF0–SOF15 außer DHT (C4), JPG (C8) und DAC (CC)
if (0xC0..=0xCF).contains(&marker) && !matches!(marker, 0xC4 | 0xC8 | 0xCC) {
return bytes.get(i + 9).copied();
}
i += 2 + len;
}
None
}
/// Bilddaten für das PDF: JPEG-Dateien werden unverändert eingebettet, alles andere als
/// RGB auf weißem Grund (Transparenz wird mit dem Papier verrechnet), höchstens mit
/// `PDF_IMAGE_DPI` bezogen auf die Größe auf der Seite.
fn pdf_image_xobject(
assets: &AssetStore,
asset: &str,
corners: &[glam::Vec2; 4],
) -> Result<printpdf::ImageXObject, Box<dyn Error>> {
use printpdf::{ColorBits, ColorSpace, ImageFilter, ImageXObject, Px};
let bytes = assets.read_bytes(asset)?;
let decoded = image::load_from_memory(&bytes)?;
if asset.ends_with(".jpg") {
let color_space = match jpeg_components(&bytes) {
Some(1) => Some(ColorSpace::Greyscale),
Some(3) => Some(ColorSpace::Rgb),
_ => None,
};
if let Some(color_space) = color_space {
return Ok(ImageXObject {
width: Px(decoded.width() as usize),
height: Px(decoded.height() as usize),
color_space,
bits_per_component: ColorBits::Bit8,
interpolate: true,
image_data: bytes,
image_filter: Some(ImageFilter::DCT),
smask: None,
clipping_bbox: None,
});
}
}
// Größe auf der Seite in Zoll → benötigte Pixel
let width_in = (corners[1] - corners[0]).length() / PAGE_WIDTH * A4_WIDTH_MM / 25.4;
let height_in = (corners[3] - corners[0]).length() / PAGE_WIDTH * A4_WIDTH_MM / 25.4;
let max_w = ((width_in * PDF_IMAGE_DPI).ceil() as u32).max(1);
let max_h = ((height_in * PDF_IMAGE_DPI).ceil() as u32).max(1);
let decoded = if decoded.width() > max_w || decoded.height() > max_h {
decoded.resize(max_w, max_h, image::imageops::FilterType::Triangle)
} else {
decoded
};
let rgba = decoded.into_rgba8();
let mut rgb = Vec::with_capacity((rgba.width() * rgba.height() * 3) as usize);
for px in rgba.pixels() {
let alpha = px[3] as u32;
for c in &px.0[..3] {
rgb.push(((*c as u32 * alpha + 255 * (255 - alpha)) / 255) as u8);
}
}
Ok(ImageXObject {
width: Px(rgba.width() as usize),
height: Px(rgba.height() as usize),
color_space: ColorSpace::Rgb,
bits_per_component: ColorBits::Bit8,
interpolate: true,
image_data: rgb,
image_filter: None,
smask: None,
clipping_bbox: None,
})
}
/// Rendert die eingefügten Bilder einer Seite. Fehlt eine Bilddatei, erscheint an ihrer
/// Stelle eine graue Fläche, damit der Export nicht abbricht.
fn render_image_layer(
layer: &printpdf::PdfLayerReference,
page: &Page,
assets: Option<&AssetStore>,
) {
for stroke in &page.strokes {
let (Some(asset), Some(corners)) = (stroke.image.as_deref(), stroke.image_corners()) else {
continue;
};
let xobject = match assets.map(|a| pdf_image_xobject(a, asset, &corners)) {
Some(Ok(xobject)) => xobject,
other => {
if let Some(Err(err)) = other {
eprintln!("[PDF-Export] Bild '{asset}' fehlt oder ist beschädigt: {err}");
}
printpdf::ImageXObject {
width: printpdf::Px(1),
height: printpdf::Px(1),
color_space: printpdf::ColorSpace::Rgb,
bits_per_component: printpdf::ColorBits::Bit8,
interpolate: false,
image_data: vec![200, 200, 200],
image_filter: None,
smask: None,
clipping_bbox: None,
}
}
};
let (width, height) = (xobject.width.0 as f32, xobject.height.0 as f32);
layer.save_graphics_state();
layer.set_ctm(printpdf::CurTransMat::Raw(image_matrix(&corners)));
// Bei 72 dpi ist ein Pixel ein Punkt; die Skalierung bildet das Bild auf das
// Einheitsquadrat ab, die Matrix oben auf die Bildecken
printpdf::Image::from(xobject).add_to_layer(
layer.clone(),
printpdf::ImageTransform {
dpi: Some(72.0),
scale_x: Some(1.0 / width),
scale_y: Some(1.0 / height),
..Default::default()
},
);
layer.restore_graphics_state();
}
}
/// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem) /// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem)
/// als verlustfreien Vektorpfad im PDF. /// als verlustfreien Vektorpfad im PDF.
fn render_recognized_shape( fn render_recognized_shape(
@@ -794,7 +962,7 @@ mod tests {
let pdf_path = temp_dir.join("ink_notes_test_export.pdf"); let pdf_path = temp_dir.join("ink_notes_test_export.pdf");
let export_res = let export_res =
export_notebook_to_pdf(&notebook, &pdf_path, ExportStrategy::FilledPolygon); export_notebook_to_pdf(&notebook, &pdf_path, ExportStrategy::FilledPolygon, None);
assert!( assert!(
export_res.is_ok(), export_res.is_ok(),
"PDF-Export zu Datei muss erfolgreich sein" "PDF-Export zu Datei muss erfolgreich sein"
@@ -820,7 +988,7 @@ mod tests {
); );
// 3. Export als Bytes testen (Strategie B: BezierCurve) // 3. Export als Bytes testen (Strategie B: BezierCurve)
let bytes_res = export_notebook_to_bytes(&notebook, ExportStrategy::BezierCurve); let bytes_res = export_notebook_to_bytes(&notebook, ExportStrategy::BezierCurve, None);
assert!(bytes_res.is_ok()); assert!(bytes_res.is_ok());
let bytes = bytes_res.unwrap(); let bytes = bytes_res.unwrap();
assert!(bytes.len() > 1024); assert!(bytes.len() > 1024);
@@ -943,13 +1111,123 @@ mod tests {
); );
page.add_stroke(octahedron_stroke); page.add_stroke(octahedron_stroke);
let bytes_res = export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon); let bytes_res = export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, None);
assert!(bytes_res.is_ok(), "Shape export must succeed"); assert!(bytes_res.is_ok(), "Shape export must succeed");
let bytes = bytes_res.unwrap(); let bytes = bytes_res.unwrap();
assert!(bytes.len() > 1024); assert!(bytes.len() > 1024);
assert!(bytes.starts_with(b"%PDF-1.")); assert!(bytes.starts_with(b"%PDF-1."));
} }
#[test]
fn test_image_matrix_maps_unit_square_to_corners() {
// Bild über die volle Seitenbreite, oberes Viertel der Seite
let corners = [
glam::Vec2::new(0.0, 0.0),
glam::Vec2::new(PAGE_WIDTH, 0.0),
glam::Vec2::new(PAGE_WIDTH, PAGE_HEIGHT / 4.0),
glam::Vec2::new(0.0, PAGE_HEIGHT / 4.0),
];
let [a, b, c, d, e, f] = image_matrix(&corners);
let page_w_pt = A4_WIDTH_MM * 72.0 / 25.4;
let page_h_pt = A4_HEIGHT_MM * 72.0 / 25.4;
let map = |u: f32, v: f32| (a * u + c * v + e, b * u + d * v + f);
let close = |(x, y): (f32, f32), (ex, ey): (f32, f32)| {
(x - ex).abs() < 0.01 && (y - ey).abs() < 0.01
};
// Unten links des Bildes, oben rechts des Bildes (PDF-Ursprung unten links)
assert!(close(map(0.0, 0.0), (0.0, page_h_pt * 0.75)));
assert!(close(map(1.0, 1.0), (page_w_pt, page_h_pt)));
// Gedrehtes Bild: Achsen folgen den Ecken
let rotated = [
glam::Vec2::new(500.0, 500.0),
glam::Vec2::new(500.0, 900.0),
glam::Vec2::new(300.0, 900.0),
glam::Vec2::new(300.0, 500.0),
];
let m = image_matrix(&rotated);
assert!(
m[0].abs() < 1e-3 && m[1] < 0.0,
"x-Achse zeigt nach unten: {m:?}"
);
}
#[test]
fn test_pdf_export_embeds_images() {
let dir = tempfile::tempdir().unwrap();
let assets = AssetStore::new(dir.path());
let jpg = crate::assets::tests::sample_image_bytes(64, 48, image::ImageFormat::Jpeg);
let png = crate::assets::tests::sample_image_bytes(30, 20, image::ImageFormat::Png);
let jpg_asset = assets.import_bytes(&jpg).unwrap().asset;
let png_asset = assets.import_bytes(&png).unwrap().asset;
let mut notebook = Notebook::new();
let page = notebook.current_page_mut();
for (id, asset) in [
(1, jpg_asset),
(2, png_asset),
(3, format!("{}.png", "0".repeat(64))),
] {
let y = id as f32 * 400.0;
page.strokes.push(Stroke::new_image(
id,
asset,
[
glam::Vec2::new(100.0, y),
glam::Vec2::new(700.0, y),
glam::Vec2::new(700.0, y + 300.0),
glam::Vec2::new(100.0, y + 300.0),
],
));
}
let bytes =
export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, Some(&assets))
.expect("Export mit Bildern");
let contains = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
assert!(
contains(b"/DCTDecode"),
"JPEG wird nicht unverändert eingebettet"
);
// JPEG-Daten liegen byte-identisch im PDF
assert!(contains(&jpg[..64]));
assert!(
contains(b"/Images") || contains(b"(Images)"),
"Bild-Ebene fehlt"
);
let image_objects = bytes
.windows(b"/Subtype/Image".len())
.filter(|w| *w == b"/Subtype/Image")
.count()
+ bytes
.windows(b"/Subtype /Image".len())
.filter(|w| *w == b"/Subtype /Image")
.count();
// JPEG, PNG und Ersatzfläche für die fehlende Datei
assert_eq!(image_objects, 3);
// Ohne Asset-Ordner: Export klappt trotzdem (graue Flächen)
assert!(export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, None).is_ok());
}
#[test]
fn test_jpeg_component_count() {
let color = crate::assets::tests::sample_image_bytes(8, 8, image::ImageFormat::Jpeg);
assert_eq!(jpeg_components(&color), Some(3));
let gray = {
let img = image::GrayImage::from_pixel(8, 8, image::Luma([90]));
let mut out = Vec::new();
image::DynamicImage::ImageLuma8(img)
.write_to(
&mut std::io::Cursor::new(&mut out),
image::ImageFormat::Jpeg,
)
.unwrap();
out
};
assert_eq!(jpeg_components(&gray), Some(1));
assert_eq!(jpeg_components(b"\xFF\xD8kaputt"), None);
}
#[test] #[test]
fn test_sanitize_filename_component() { fn test_sanitize_filename_component() {
assert_eq!(sanitize_filename_component("Notizbuch 1"), "Notizbuch 1"); assert_eq!(sanitize_filename_component("Notizbuch 1"), "Notizbuch 1");
+196
View File
@@ -0,0 +1,196 @@
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder.
//!
//! 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 unterstützte Bilder des
//! aktuellen Ordners; versteckte Dateien bleiben ausgeblendet.
use std::path::{Path, PathBuf};
use crate::assets::is_supported_image_path;
/// Ein Eintrag der Dateiliste.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerEntry {
pub name: String,
pub path: PathBuf,
pub is_dir: bool,
}
/// Zustand der Dateiauswahl.
#[derive(Debug, Clone, PartialEq)]
pub struct FilePicker {
pub dir: PathBuf,
pub entries: Vec<PickerEntry>,
/// Ordner nicht lesbar
pub error: Option<String>,
}
impl FilePicker {
/// Öffnet die Auswahl in `dir`, sonst im Bilder-Ordner, sonst im Home-Ordner.
pub fn open(dir: Option<&Path>) -> Self {
let start = dir
.filter(|d| d.is_dir())
.map(Path::to_path_buf)
.or_else(|| shortcuts().into_iter().map(|(_, p)| p).find(|p| p.is_dir()))
.unwrap_or_else(|| PathBuf::from("/"));
let mut picker = Self {
dir: start.clone(),
entries: Vec::new(),
error: None,
};
picker.navigate(&start);
picker
}
/// Wechselt in `dir` und liest den Inhalt neu ein.
pub fn navigate(&mut self, dir: &Path) {
match list_dir(dir) {
Ok(entries) => {
self.dir = dir.to_path_buf();
self.entries = entries;
self.error = None;
}
Err(err) => {
self.error = Some(format!(
"'{}' kann nicht geöffnet werden: {err}",
dir.display()
));
}
}
}
/// Eine Ebene nach oben.
pub fn go_up(&mut self) {
if let Some(parent) = self.dir.parent().map(Path::to_path_buf) {
self.navigate(&parent);
}
}
}
/// Ordner und unterstützte Bilder in `dir`: erst Ordner, dann Dateien, jeweils alphabetisch
/// ohne Groß-/Kleinschreibung; versteckte Einträge (Punkt am Anfang) fehlen.
pub fn list_dir(dir: &Path) -> std::io::Result<Vec<PickerEntry>> {
let mut entries: Vec<PickerEntry> = std::fs::read_dir(dir)?
.filter_map(Result::ok)
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
return None;
}
let path = entry.path();
// Symlinks auflösen: ein Link auf einen Ordner ist ein Ordner
let is_dir = path.is_dir();
(is_dir || is_supported_image_path(&path)).then_some(PickerEntry { name, path, is_dir })
})
.collect();
entries.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
Ok(entries)
}
/// Schnellzugriffe: Bilder, Downloads, Schreibtisch und Home-Ordner (soweit vorhanden),
/// mit den Namen aus `~/.config/user-dirs.dirs` (z. B. „Bilder“ statt „Pictures“).
pub fn shortcuts() -> Vec<(&'static str, PathBuf)> {
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
return Vec::new();
};
let user_dirs =
std::fs::read_to_string(home.join(".config/user-dirs.dirs")).unwrap_or_default();
let xdg = |key: &str, fallbacks: &[&str]| -> Option<PathBuf> {
parse_user_dir(&user_dirs, key, &home)
.into_iter()
.chain(fallbacks.iter().map(|f| home.join(f)))
.find(|p| p.is_dir() && *p != home)
};
let mut list = Vec::new();
if let Some(p) = xdg("XDG_PICTURES_DIR", &["Bilder", "Pictures"]) {
list.push(("🖼 Bilder", p));
}
if let Some(p) = xdg("XDG_DOWNLOAD_DIR", &["Downloads"]) {
list.push(("⬇ Downloads", p));
}
if let Some(p) = xdg("XDG_DESKTOP_DIR", &["Schreibtisch", "Desktop"]) {
list.push(("🖥 Schreibtisch", p));
}
list.push(("🏠 Persönlicher Ordner", home));
list
}
/// Liest einen Eintrag wie `XDG_PICTURES_DIR="$HOME/Bilder"` aus `user-dirs.dirs`.
pub fn parse_user_dir(content: &str, key: &str, home: &Path) -> Option<PathBuf> {
content.lines().find_map(|line| {
let value = line
.trim()
.strip_prefix(key)?
.trim_start()
.strip_prefix('=')?;
let value = value.trim().trim_matches('"');
let path = match value.strip_prefix("$HOME") {
Some(rest) => home.join(rest.trim_start_matches('/')),
None => PathBuf::from(value),
};
path.is_absolute().then_some(path)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_list_dir_sorts_and_filters() {
let dir = tempfile::tempdir().unwrap();
for name in ["b.png", "A.jpg", "notiz.txt", ".versteckt.png", "c.webp"] {
std::fs::write(dir.path().join(name), b"x").unwrap();
}
std::fs::create_dir(dir.path().join("zOrdner")).unwrap();
std::fs::create_dir(dir.path().join("aOrdner")).unwrap();
std::fs::create_dir(dir.path().join(".cache")).unwrap();
let names: Vec<String> = list_dir(dir.path())
.unwrap()
.into_iter()
.map(|e| e.name)
.collect();
assert_eq!(names, ["aOrdner", "zOrdner", "A.jpg", "b.png", "c.webp"]);
}
#[test]
fn test_navigation_and_errors() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("Fotos");
std::fs::create_dir(&sub).unwrap();
std::fs::write(sub.join("tafel.jpg"), b"x").unwrap();
let mut picker = FilePicker::open(Some(&sub));
assert_eq!(picker.dir, sub);
assert_eq!(picker.entries.len(), 1);
picker.go_up();
assert_eq!(picker.dir, dir.path());
assert!(picker.entries.iter().any(|e| e.is_dir && e.name == "Fotos"));
// Nicht vorhandener Ordner: Fehlermeldung, aktueller Ordner bleibt
picker.navigate(&dir.path().join("gibt-es-nicht"));
assert!(picker.error.is_some());
assert_eq!(picker.dir, dir.path());
}
#[test]
fn test_parse_user_dirs() {
let home = Path::new("/home/anna");
let content = "# Kommentar\nXDG_DESKTOP_DIR=\"$HOME/Schreibtisch\"\nXDG_PICTURES_DIR=\"$HOME/Bilder\"\nXDG_MUSIC_DIR=\"/daten/Musik\"\n";
assert_eq!(
parse_user_dir(content, "XDG_PICTURES_DIR", home),
Some(PathBuf::from("/home/anna/Bilder"))
);
assert_eq!(
parse_user_dir(content, "XDG_MUSIC_DIR", home),
Some(PathBuf::from("/daten/Musik"))
);
assert_eq!(parse_user_dir(content, "XDG_DOWNLOAD_DIR", home), None);
}
}
+363
View File
@@ -0,0 +1,363 @@
//! GPU-Darstellung eingefügter Bilder: ein texturiertes Viereck pro Bild, gezeichnet nach dem
//! Papier und vor der Tinte.
//!
//! Texturen werden beim ersten Bedarf aus dem Asset-Ordner geladen (verkleinert auf
//! `assets::MAX_DISPLAY_DIMENSION`) und mit Mipmaps versehen, damit verkleinerte Bilder
//! beim Herauszoomen nicht flimmern.
use std::collections::{HashMap, HashSet};
use crate::assets::{AssetStore, RgbaImage, MAX_DISPLAY_DIMENSION};
/// Vertex eines Bildvierecks: Seitenkoordinate und Texturkoordinate.
#[repr(C)]
#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct ImageVertex {
pub position: [f32; 2],
pub uv: [f32; 2],
}
impl ImageVertex {
const ATTRIBUTES: [wgpu::VertexAttribute; 2] =
wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2];
fn desc() -> wgpu::VertexBufferLayout<'static> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<ImageVertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &Self::ATTRIBUTES,
}
}
}
/// Zwei Dreiecke für ein Bild mit den Ecken oben links, oben rechts, unten rechts, unten links.
pub fn quad_vertices(corners: &[glam::Vec2; 4]) -> [ImageVertex; 6] {
let v = |i: usize, uv: [f32; 2]| ImageVertex {
position: corners[i].to_array(),
uv,
};
let (tl, tr, br, bl) = (
v(0, [0.0, 0.0]),
v(1, [1.0, 0.0]),
v(2, [1.0, 1.0]),
v(3, [0.0, 1.0]),
);
[tl, tr, bl, bl, tr, br]
}
/// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1.
pub fn mip_chain(base: &RgbaImage) -> Vec<RgbaImage> {
let mut levels = vec![base.clone()];
let Some(mut current) =
image::RgbaImage::from_raw(base.width, base.height, base.pixels.clone())
else {
return levels;
};
while current.width() > 1 || current.height() > 1 {
let (w, h) = ((current.width() / 2).max(1), (current.height() / 2).max(1));
current = image::imageops::resize(&current, w, h, image::imageops::FilterType::Triangle);
levels.push(RgbaImage {
width: w,
height: h,
pixels: current.as_raw().clone(),
});
}
levels
}
/// Name der Ersatztextur für Bilder, deren Datei fehlt oder beschädigt ist.
const MISSING: &str = "";
/// So viele Texturen bleiben höchstens im Speicher, auch wenn sie auf der aktuellen Seite
/// nicht mehr gebraucht werden (schnelles Zurückblättern).
const TEXTURE_CACHE_LIMIT: usize = 24;
pub struct ImageLayer {
pipeline: wgpu::RenderPipeline,
texture_layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
textures: HashMap<String, wgpu::BindGroup>,
/// Bilder der aktuellen Seite: Asset-Name und Ecken
quads: Vec<(String, [glam::Vec2; 4])>,
vertex_buffer: Option<wgpu::Buffer>,
needs_upload: bool,
}
impl ImageLayer {
pub fn new(
device: &wgpu::Device,
shader: &wgpu::ShaderModule,
camera_layout: &wgpu::BindGroupLayout,
format: wgpu::TextureFormat,
) -> Self {
let texture_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Image Texture Bind Group Layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Image Pipeline Layout"),
bind_group_layouts: &[camera_layout, &texture_layout],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Image Render Pipeline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: shader,
entry_point: "vs_image",
buffers: &[ImageVertex::desc()],
},
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: "fs_image",
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Image Sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Linear,
..Default::default()
});
Self {
pipeline,
texture_layout,
sampler,
textures: HashMap::new(),
quads: Vec::new(),
vertex_buffer: None,
needs_upload: false,
}
}
/// Übernimmt die Bilder der aktuellen Seite (Asset-Name und Ecken in Seitenkoordinaten).
pub fn set_quads(&mut self, quads: Vec<(String, [glam::Vec2; 4])>) {
if quads != self.quads {
self.quads = quads;
self.needs_upload = true;
}
}
/// Lädt fehlende Texturen und den Vertex-Puffer hoch. Vor jedem Zeichnen aufrufen;
/// ohne Änderungen kostet das nichts.
pub fn prepare(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, assets: &AssetStore) {
if !self.needs_upload {
return;
}
self.needs_upload = false;
let missing: Vec<String> = self
.quads
.iter()
.map(|(asset, _)| asset)
.filter(|asset| !self.textures.contains_key(*asset))
.cloned()
.collect();
for asset in missing {
if self.textures.contains_key(&asset) {
continue;
}
let bind_group = match assets.load_rgba(&asset, MAX_DISPLAY_DIMENSION) {
Ok(image) => self.upload_texture(device, queue, &asset, &image),
Err(err) => {
eprintln!("[Bilder] '{asset}' kann nicht angezeigt werden: {err}");
self.missing_texture(device, queue)
}
};
self.textures.insert(asset, bind_group);
}
// Nicht mehr benötigte Texturen freigeben, wenn der Speicher voll wird
if self.textures.len() > TEXTURE_CACHE_LIMIT {
let used: HashSet<&String> = self.quads.iter().map(|(a, _)| a).collect();
self.textures.retain(|name, _| used.contains(name));
}
let vertices: Vec<ImageVertex> = self
.quads
.iter()
.flat_map(|(_, corners)| quad_vertices(corners))
.collect();
if vertices.is_empty() {
return;
}
let bytes: &[u8] = bytemuck::cast_slice(&vertices);
let too_small = self
.vertex_buffer
.as_ref()
.map_or(true, |b| b.size() < bytes.len() as u64);
if too_small {
self.vertex_buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Image Vertex Buffer"),
size: (bytes.len() as u64 * 2).max(1024),
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}));
}
if let Some(buffer) = &self.vertex_buffer {
queue.write_buffer(buffer, 0, bytes);
}
}
/// Zeichnet die Bilder der aktuellen Seite. Bind Group 0 (Kamera) muss gesetzt sein.
pub fn draw<'a>(&'a self, render_pass: &mut wgpu::RenderPass<'a>) {
let Some(buffer) = &self.vertex_buffer else {
return;
};
if self.quads.is_empty() || self.needs_upload {
return;
}
render_pass.set_pipeline(&self.pipeline);
render_pass.set_vertex_buffer(0, buffer.slice(..));
for (i, (asset, _)) in self.quads.iter().enumerate() {
let Some(bind_group) = self.textures.get(asset) else {
continue;
};
render_pass.set_bind_group(1, bind_group, &[]);
let start = (i * 6) as u32;
render_pass.draw(start..start + 6, 0..1);
}
}
fn missing_texture(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::BindGroup {
// Hellgraue Fläche statt eines unsichtbaren Bildes
let gray = RgbaImage {
width: 1,
height: 1,
pixels: vec![200, 200, 200, 255],
};
self.upload_texture(device, queue, MISSING, &gray)
}
fn upload_texture(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
label: &str,
image: &RgbaImage,
) -> wgpu::BindGroup {
let levels = mip_chain(image);
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: image.width,
height: image.height,
depth_or_array_layers: 1,
},
mip_level_count: levels.len() as u32,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
for (level, mip) in levels.iter().enumerate() {
queue.write_texture(
wgpu::ImageCopyTexture {
texture: &texture,
mip_level: level as u32,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&mip.pixels,
wgpu::ImageDataLayout {
offset: 0,
bytes_per_row: Some(4 * mip.width),
rows_per_image: Some(mip.height),
},
wgpu::Extent3d {
width: mip.width,
height: mip.height,
depth_or_array_layers: 1,
},
);
}
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Image Texture Bind Group"),
layout: &self.texture_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quad_vertices_map_corners_to_texture_corners() {
let corners = [
glam::Vec2::new(10.0, 10.0),
glam::Vec2::new(50.0, 10.0),
glam::Vec2::new(50.0, 30.0),
glam::Vec2::new(10.0, 30.0),
];
let quad = quad_vertices(&corners);
for v in quad {
// Texturkoordinate entspricht der relativen Lage im Rechteck
let u = (v.position[0] - 10.0) / 40.0;
let w = (v.position[1] - 10.0) / 20.0;
assert_eq!(v.uv, [u, w]);
}
// Beide Dreiecke decken alle vier Ecken ab
for c in corners {
assert!(quad.iter().any(|v| v.position == c.to_array()));
}
}
#[test]
fn test_mip_chain_halves_down_to_one_pixel() {
let base = RgbaImage {
width: 8,
height: 3,
pixels: vec![128; 8 * 3 * 4],
};
let levels = mip_chain(&base);
let sizes: Vec<(u32, u32)> = levels.iter().map(|l| (l.width, l.height)).collect();
assert_eq!(sizes, vec![(8, 3), (4, 1), (2, 1), (1, 1)]);
for l in &levels {
assert_eq!(l.pixels.len(), (l.width * l.height * 4) as usize);
}
}
}
+3
View File
@@ -1,13 +1,16 @@
//! ink-notes: Latenzoptimierte Inking-App für Linux. //! ink-notes: Latenzoptimierte Inking-App für Linux.
pub mod assets;
pub mod autosave; pub mod autosave;
pub mod background; pub mod background;
pub mod canvas; pub mod canvas;
pub mod document; pub mod document;
pub mod export; pub mod export;
pub mod file_picker;
pub mod filter; pub mod filter;
pub mod geometry; pub mod geometry;
pub mod history; pub mod history;
pub mod image_layer;
pub mod platform; pub mod platform;
pub mod selection; pub mod selection;
pub mod settings; pub mod settings;
+256 -29
View File
@@ -16,6 +16,7 @@ use winit::{
}; };
use glam::Vec2; use glam::Vec2;
use ink_notes::assets::{AssetStore, ImportedImage};
use ink_notes::autosave::{AutosaveController, AutosaveStatus}; use ink_notes::autosave::{AutosaveController, AutosaveStatus};
use ink_notes::canvas::CanvasState; use ink_notes::canvas::CanvasState;
use ink_notes::document::{Notebook, PageBackground}; use ink_notes::document::{Notebook, PageBackground};
@@ -91,7 +92,13 @@ fn start_pdf_export(
let result = storage::load_all_pages(&storage_dir, &mut notebook_clone) let result = storage::load_all_pages(&storage_dir, &mut notebook_clone)
.map_err(|err| err.to_string()) .map_err(|err| err.to_string())
.and_then(|()| { .and_then(|()| {
export_notebook_to_pdf(&notebook_clone, &export_path, ExportStrategy::FilledPolygon) let assets = AssetStore::new(&storage_dir);
export_notebook_to_pdf(
&notebook_clone,
&export_path,
ExportStrategy::FilledPolygon,
Some(&assets),
)
.map_err(|err| err.to_string()) .map_err(|err| err.to_string())
}); });
match &result { match &result {
@@ -152,6 +159,213 @@ fn commit_stroke_to_notebook(
} }
} }
/// Zwischenablage: interne Objekte (Strg+C in ink-notes) und Bilder aus der
/// Systemzwischenablage (Screenshots, aus anderen Programmen kopierte Bilder).
struct ClipboardBridge {
system: Option<arboard::Clipboard>,
/// Prüfsumme des Systembildes zum Zeitpunkt des letzten internen Kopierens: Strg+V fügt
/// das Systembild nur ein, wenn es danach kopiert wurde (die jüngste Kopie gewinnt)
image_hash_at_copy: Option<[u8; 32]>,
/// Notizbuch, aus dem kopiert wurde (für Bilder beim Einfügen in ein anderes Notizbuch)
source_dir: Option<PathBuf>,
}
impl ClipboardBridge {
fn new() -> Self {
Self {
system: None,
image_hash_at_copy: None,
source_dir: None,
}
}
/// Bild in der Systemzwischenablage samt Prüfsumme.
fn system_image(&mut self) -> Option<(arboard::ImageData<'static>, [u8; 32])> {
if self.system.is_none() {
self.system = arboard::Clipboard::new()
.map_err(|e| eprintln!("[Zwischenablage] Nicht verfügbar: {e}"))
.ok();
}
let image = self.system.as_mut()?.get_image().ok()?;
use sha2::Digest;
let mut hasher = sha2::Sha256::new();
hasher.update((image.width as u64).to_le_bytes());
hasher.update((image.height as u64).to_le_bytes());
hasher.update(&image.bytes);
let hash: [u8; 32] = hasher.finalize().into();
Some((image.to_owned_img(), hash))
}
/// Merkt sich beim internen Kopieren das aktuelle Systembild und das Quell-Notizbuch.
fn note_internal_copy(&mut self, storage_dir: &Path) {
self.image_hash_at_copy = self.system_image().map(|(_, hash)| hash);
self.source_dir = Some(storage_dir.to_path_buf());
}
}
/// Fügt ein importiertes Bild um `center` (Seitenkoordinaten) auf der aktuellen Seite ein,
/// wählt es aus und schaltet auf das Auswahlwerkzeug, damit es gleich verschoben und
/// skaliert werden kann.
#[allow(clippy::too_many_arguments)]
fn insert_image_on_page(
imported: ImportedImage,
center: Vec2,
canvas: &mut CanvasState,
notebook: &mut Notebook,
manifest: &mut NotebookManifest,
autosave: &mut AutosaveController,
selection_state: &mut SelectionState,
ui_state: &mut UiState,
) {
let id = notebook.next_stroke_id();
let corners = ink_notes::document::place_image(imported.width, imported.height, center);
let page = notebook.current_page_mut();
page.add_stroke(ink_notes::document::Stroke::new_image(
id,
imported.asset,
corners,
));
canvas.sync_page(page);
manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_dirty(notebook, manifest);
selection_state.select_single(id);
ui_state.active_tool = ActiveTool::Select;
ui_state.set_export_message(format!(
"Bild eingefügt ({} × {} px)",
imported.width, imported.height
));
}
/// Seitenmitte des sichtbaren Bereichs (Ziel für eingefügte Bilder).
fn visible_page_center(canvas: &CanvasState) -> Vec2 {
canvas.screen_to_page_clamped(
canvas.size.width as f32 / 2.0,
canvas.size.height as f32 / 2.0,
)
}
/// Strg+V bzw. „Einfügen“: ein neu kopiertes Systembild hat Vorrang, sonst werden die intern
/// kopierten Objekte eingefügt. Gibt die Meldung für die Statuszeile zurück.
#[allow(clippy::too_many_arguments)]
fn paste(
clipboard: &mut ClipboardBridge,
storage_dir: &Path,
canvas: &mut CanvasState,
notebook: &mut Notebook,
manifest: &mut NotebookManifest,
autosave: &mut AutosaveController,
selection_state: &mut SelectionState,
ui_state: &mut UiState,
) {
if notebook.current_page().is_readonly() {
return;
}
if let Some((image, hash)) = clipboard.system_image() {
// Die jüngste Kopie gewinnt: das Systembild nur, wenn es nach dem letzten internen
// Kopieren in die Zwischenablage kam
if selection_state.clipboard.is_empty() || clipboard.image_hash_at_copy != Some(hash) {
let assets = AssetStore::new(storage_dir);
match assets.import_rgba(
image.width as u32,
image.height as u32,
image.bytes.into_owned(),
) {
Ok(imported) => {
let center = visible_page_center(canvas);
insert_image_on_page(
imported,
center,
canvas,
notebook,
manifest,
autosave,
selection_state,
ui_state,
);
}
Err(err) => ui_state.set_export_message(format!("Bild nicht eingefügt: {err}")),
}
return;
}
}
// Bilder aus einem anderen Notizbuch mitnehmen
if let Some(source) = clipboard
.source_dir
.as_deref()
.filter(|d| *d != storage_dir)
{
let from = AssetStore::new(source);
let to = AssetStore::new(storage_dir);
for asset in selection_state
.clipboard
.iter()
.filter_map(|s| s.image.as_deref())
{
if let Err(err) = to.copy_from(&from, asset) {
eprintln!("[Zwischenablage] Bild '{asset}' nicht übernommen: {err}");
}
}
}
let mut next_id = notebook.next_stroke_id;
let new_ids = selection_state.paste_from_clipboard(notebook.current_page_mut(), &mut || {
let id = next_id;
next_id += 1;
id
});
notebook.next_stroke_id = next_id;
if !new_ids.is_empty() {
canvas.sync_page(notebook.current_page_mut());
manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_dirty(notebook, manifest);
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
}
}
/// Liest eine Bilddatei und fügt sie um `center` ein.
#[allow(clippy::too_many_arguments)]
fn insert_image_file(
path: &Path,
center: Vec2,
storage_dir: &Path,
canvas: &mut CanvasState,
notebook: &mut Notebook,
manifest: &mut NotebookManifest,
autosave: &mut AutosaveController,
selection_state: &mut SelectionState,
ui_state: &mut UiState,
) {
if notebook.current_page().is_readonly() {
ui_state.set_export_message("Seite ist schreibgeschützt".to_string());
return;
}
let result = std::fs::read(path)
.map_err(|e| e.to_string())
.and_then(|bytes| {
AssetStore::new(storage_dir)
.import_bytes(&bytes)
.map_err(|e| e.to_string())
});
match result {
Ok(imported) => insert_image_on_page(
imported,
center,
canvas,
notebook,
manifest,
autosave,
selection_state,
ui_state,
),
Err(err) => ui_state.set_export_message(format!(
"'{}' nicht eingefügt: {err}",
path.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default()
)),
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
// Anzeigesystem wählen: Unter WSL (WSLg) bricht Wayland ab, X11 (Xwayland) läuft stabil // Anzeigesystem wählen: Unter WSL (WSLg) bricht Wayland ab, X11 (Xwayland) läuft stabil
let mut event_loop_builder = EventLoopBuilder::new(); let mut event_loop_builder = EventLoopBuilder::new();
@@ -284,6 +498,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.set_export_message(warning); ui_state.set_export_message(warning);
} }
let mut selection_state = SelectionState::new(); let mut selection_state = SelectionState::new();
let mut clipboard = ClipboardBridge::new();
let mut _notebook_lock = match storage::NotebookLock::acquire(&storage_dir) { let mut _notebook_lock = match storage::NotebookLock::acquire(&storage_dir) {
Ok(lock) => { Ok(lock) => {
@@ -755,6 +970,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
// Bilddatei ins Fenster gezogen: an der Ablagestelle einfügen
WindowEvent::DroppedFile(path) => {
if ink_notes::assets::is_supported_image_path(&path) {
let (sx, sy) = cursor_screen_pos;
let center = canvas.screen_to_page_clamped(sx, sy);
insert_image_file(
&path, center, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
&mut autosave, &mut selection_state, &mut ui_state,
);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
} else {
ui_state.set_export_message("Nur Bilder (PNG, JPEG, GIF, WebP, BMP) können eingefügt werden".to_string());
}
window.request_redraw();
}
WindowEvent::Focused(false) => { WindowEvent::Focused(false) => {
// Bei Fokusverlust Strich abbrechen und 1€-Filter resetten // Bei Fokusverlust Strich abbrechen und 1€-Filter resetten
if is_drawing { if is_drawing {
@@ -879,6 +1110,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Key::Character(c) if c.eq_ignore_ascii_case("c") && ctrl => { Key::Character(c) if c.eq_ignore_ascii_case("c") && ctrl => {
let count = selection_state.copy_to_clipboard(notebook.current_page()); let count = selection_state.copy_to_clipboard(notebook.current_page());
if count > 0 { if count > 0 {
clipboard.note_internal_copy(&storage_dir);
ui_state.set_export_message(format!("{count} Objekt(e) kopiert")); ui_state.set_export_message(format!("{count} Objekt(e) kopiert"));
window.request_redraw(); window.request_redraw();
} }
@@ -886,23 +1118,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Einfügen: Strg + V // Einfügen: Strg + V
Key::Character(c) if c.eq_ignore_ascii_case("v") && ctrl => { Key::Character(c) if c.eq_ignore_ascii_case("v") && ctrl => {
let mut next_id = notebook.next_stroke_id; paste(
let new_ids = selection_state.paste_from_clipboard( &mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
notebook.current_page_mut(), &mut autosave, &mut selection_state, &mut ui_state,
&mut || {
let id = next_id;
next_id += 1;
id
},
); );
notebook.next_stroke_id = next_id; update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
if !new_ids.is_empty() {
canvas.sync_page(notebook.current_page_mut());
manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
window.request_redraw(); window.request_redraw();
} }
// Bild einfügen: Strg + I
Key::Character(c) if c.eq_ignore_ascii_case("i") && ctrl => {
ui_state.open_image_picker();
window.request_redraw();
} }
// Löschen: Entf oder Backspace // Löschen: Entf oder Backspace
@@ -1984,28 +2211,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
UiAction::CopySelected => { UiAction::CopySelected => {
let count = selection_state.copy_to_clipboard(notebook.current_page()); let count = selection_state.copy_to_clipboard(notebook.current_page());
if count > 0 { if count > 0 {
clipboard.note_internal_copy(&storage_dir);
ui_state.set_export_message(format!("{count} Objekt(e) kopiert")); ui_state.set_export_message(format!("{count} Objekt(e) kopiert"));
window.request_redraw(); window.request_redraw();
} }
} }
UiAction::PasteSelected => { UiAction::PasteSelected => {
let mut next_id = notebook.next_stroke_id; paste(
let new_ids = selection_state.paste_from_clipboard( &mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
notebook.current_page_mut(), &mut autosave, &mut selection_state, &mut ui_state,
&mut || {
let id = next_id;
next_id += 1;
id
},
); );
notebook.next_stroke_id = next_id; update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
if !new_ids.is_empty() {
canvas.sync_page(notebook.current_page_mut());
manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
window.request_redraw(); window.request_redraw();
} }
UiAction::InsertImageFile(path) => {
let center = visible_page_center(&canvas);
insert_image_file(
&path, center, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
&mut autosave, &mut selection_state, &mut ui_state,
);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
} }
UiAction::RotateSelected => { UiAction::RotateSelected => {
if !selection_state.is_empty() if !selection_state.is_empty()
@@ -2482,6 +2708,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
// 5. Canvas (Pass 1) und egui UI (Pass 2) in Surface-Textur rendern // 5. Canvas (Pass 1) und egui UI (Pass 2) in Surface-Textur rendern
canvas.prepare_images(&AssetStore::new(&storage_dir));
match canvas.render(&clipped_primitives, &screen_descriptor, &full_output.textures_delta) { match canvas.render(&clipped_primitives, &screen_descriptor, &full_output.textures_delta) {
Ok(_) => {} Ok(_) => {}
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
+32 -1
View File
@@ -243,7 +243,12 @@ impl SelectionState {
if self.selected_ids.is_empty() { if self.selected_ids.is_empty() {
return false; return false;
} }
let mut strokes = page.get_strokes(&self.selected_ids); // Bilder behalten ihre Farben
let mut strokes: Vec<_> = page
.get_strokes(&self.selected_ids)
.into_iter()
.filter(|s| !s.is_image())
.collect();
if strokes.is_empty() { if strokes.is_empty() {
return false; return false;
} }
@@ -499,4 +504,30 @@ mod tests {
let restored_pos = page.strokes[0].points[0].position; let restored_pos = page.strokes[0].points[0].position;
assert!((orig_pos.x - restored_pos.x).abs() < 1e-4); assert!((orig_pos.x - restored_pos.x).abs() < 1e-4);
} }
#[test]
fn test_recolor_ignores_images() {
let mut page = Page::new(1, PageBackground::Blank);
let corners = [
Vec2::new(0.0, 0.0),
Vec2::new(100.0, 0.0),
Vec2::new(100.0, 50.0),
Vec2::new(0.0, 50.0),
];
page.add_stroke(Stroke::new_image(1, "a.png".into(), corners));
let undo_depth = page.history.undo_stack.len();
let mut sel = SelectionState::new();
sel.select_single(1);
// Nur ein Bild ausgewählt: nichts umzufärben, kein Undo-Eintrag
assert!(!sel.recolor_selected(&mut page, [1.0, 0.0, 0.0, 1.0]));
assert_eq!(page.history.undo_stack.len(), undo_depth);
// Bild und Strich: nur der Strich wird umgefärbt
page.add_stroke(make_test_stroke(2, 20.0, 20.0, [0.1, 0.1, 0.1, 1.0]));
sel.select_multiple(vec![1, 2]);
assert!(sel.recolor_selected(&mut page, [1.0, 0.0, 0.0, 1.0]));
assert_eq!(page.strokes[0].color, [1.0, 1.0, 1.0, 1.0]);
assert_eq!(page.strokes[1].color, [1.0, 0.0, 0.0, 1.0]);
}
} }
+30
View File
@@ -47,3 +47,33 @@ fn fs_ink(in: VertexOutput) -> @location(0) vec4<f32> {
let lift = max(1.0 - max_c - min_c, 0.0); let lift = max(1.0 - max_c - min_c, 0.0);
return vec4<f32>(in.color.rgb + vec3<f32>(lift), in.color.a); return vec4<f32>(in.color.rgb + vec3<f32>(lift), in.color.a);
} }
// Eingefügte Bilder: texturierte Vierecke unter der Tinte
struct ImageVertexInput {
@location(0) position: vec2<f32>,
@location(1) uv: vec2<f32>,
};
struct ImageVertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@group(1) @binding(0)
var image_texture: texture_2d<f32>;
@group(1) @binding(1)
var image_sampler: sampler;
@vertex
fn vs_image(in: ImageVertexInput) -> ImageVertexOutput {
var out: ImageVertexOutput;
let ndc = in.position * camera.transform_scale + camera.transform_offset;
out.clip_position = vec4<f32>(ndc, 0.0, 1.0);
out.uv = in.uv;
return out;
}
@fragment
fn fs_image(in: ImageVertexOutput) -> @location(0) vec4<f32> {
return textureSample(image_texture, image_sampler, in.uv);
}
+51 -3
View File
@@ -70,6 +70,9 @@ pub struct StrokeData {
pub width: f32, pub width: f32,
#[serde(default = "default_finalized")] #[serde(default = "default_finalized")]
pub finalized: bool, pub finalized: bool,
/// Eingefügtes Bild (Name im Asset-Ordner); die Samples sind dann die vier Bildecken
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
} }
impl From<&Stroke> for StrokeData { impl From<&Stroke> for StrokeData {
@@ -82,6 +85,7 @@ impl From<&Stroke> for StrokeData {
tool: stroke.tool, tool: stroke.tool,
width: stroke.width, width: stroke.width,
finalized: stroke.finalized, finalized: stroke.finalized,
image: stroke.image.clone(),
} }
} }
} }
@@ -96,6 +100,17 @@ impl StrokeData {
.copied() .copied()
.map(StylusSample::from) .map(StylusSample::from)
.collect(); .collect();
if let Some(asset) = &self.image {
if let [a, b, c, d, ..] = stylus_samples.as_slice() {
let mut stroke = Stroke::new_image(
self.id,
asset.clone(),
[a.position, b.position, c.position, d.position],
);
stroke.color = self.color;
return stroke;
}
}
let width = if self.width > 0.0 { let width = if self.width > 0.0 {
self.width self.width
} else if self.shape.is_some() { } else if self.shape.is_some() {
@@ -133,7 +148,10 @@ impl StrokeData {
/// - Version 3: neue Formen (Ellipse, Pfeil). Ältere App-Versionen kennen sie nicht und würden /// - Version 3: neue Formen (Ellipse, Pfeil). Ältere App-Versionen kennen sie nicht und würden
/// betroffene Seiten für beschädigt halten; mit Version 3 lehnen sie das Notizbuch stattdessen /// betroffene Seiten für beschädigt halten; mit Version 3 lehnen sie das Notizbuch stattdessen
/// sauber als „neuere Version“ ab. /// sauber als „neuere Version“ ab.
pub const CURRENT_FORMAT_VERSION: u32 = 3; /// - Version 4: eingefügte Bilder (`image` am Strich, Ordner `assets/`). Ältere Versionen würden
/// das unbekannte Feld ignorieren, Bilder als Rechteckstrich anzeigen und beim Speichern
/// verlieren.
pub const CURRENT_FORMAT_VERSION: u32 = 4;
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0). /// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
pub fn legacy_format_version() -> u32 { pub fn legacy_format_version() -> u32 {
@@ -2177,12 +2195,12 @@ mod tests {
let (mut loaded, _) = load_notebook(&dir).unwrap(); let (mut loaded, _) = load_notebook(&dir).unwrap();
assert!(!loaded.pages[1].is_loaded); assert!(!loaded.pages[1].is_loaded);
// Ohne Nachladen verweigert der Export, statt leere Seiten zu erzeugen // Ohne Nachladen verweigert der Export, statt leere Seiten zu erzeugen
assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_err()); assert!(crate::export::generate_pdf_document(&loaded, Default::default(), None).is_err());
load_all_pages(&dir, &mut loaded).unwrap(); load_all_pages(&dir, &mut loaded).unwrap();
assert_eq!(loaded.pages[1].strokes.len(), 1); assert_eq!(loaded.pages[1].strokes.len(), 1);
assert_eq!(loaded.pages[1].background, PageBackground::Lined); assert_eq!(loaded.pages[1].background, PageBackground::Lined);
assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_ok()); assert!(crate::export::generate_pdf_document(&loaded, Default::default(), None).is_ok());
} }
#[test] #[test]
@@ -2316,4 +2334,34 @@ mod tests {
crate::document::StrokeTool::Shape crate::document::StrokeTool::Shape
); );
} }
#[test]
fn test_image_objects_round_trip_through_page_file() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Mit Bild").unwrap();
let assets = crate::assets::AssetStore::new(&dir);
let imported = assets.import_rgba(4, 2, vec![200; 32]).unwrap();
let corners = [
glam::Vec2::new(100.0, 100.0),
glam::Vec2::new(500.0, 120.0),
glam::Vec2::new(480.0, 320.0),
glam::Vec2::new(80.0, 300.0),
];
notebook.current_page_mut().strokes.push(Stroke::new_image(
7,
imported.asset.clone(),
corners,
));
save_notebook(&dir, &notebook, &mut manifest).unwrap();
assert_eq!(load_manifest(&dir).unwrap().format_version, 4);
let (reloaded, _) = load_notebook(&dir).unwrap();
let image = &reloaded.current_page().strokes[0];
assert_eq!(image.image.as_deref(), Some(imported.asset.as_str()));
assert_eq!(image.image_corners(), Some(corners));
assert!(image.vertices.is_empty());
// Bilddatei liegt im Notizbuchordner
assert!(dir.join("assets").join(&imported.asset).exists());
}
} }
+167 -2
View File
@@ -16,6 +16,7 @@ use crate::background::{adapt_ink_color, PaperTheme};
use crate::document::{ use crate::document::{
EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH, EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH,
}; };
use crate::file_picker::FilePicker;
use crate::settings::{PressureCurve, SidebarSide}; use crate::settings::{PressureCurve, SidebarSide};
use crate::storage::NotebookSummary; use crate::storage::NotebookSummary;
@@ -151,6 +152,8 @@ pub enum UiAction {
}, },
/// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`) /// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`)
SavePressureCurve(PressureCurve), SavePressureCurve(PressureCurve),
/// Bilddatei auf der aktuellen Seite einfügen
InsertImageFile(std::path::PathBuf),
// Beenden-Sicherheit bei Fehlschlag // Beenden-Sicherheit bei Fehlschlag
RetryShutdown, RetryShutdown,
ExportRecoveryAndExit, ExportRecoveryAndExit,
@@ -212,6 +215,10 @@ pub struct UiState {
pub pressure_curve: PressureCurve, pub pressure_curve: PressureCurve,
/// Zuletzt gemessener Rohdruck des Stifts (Anzeige in der Druckkurve) /// Zuletzt gemessener Rohdruck des Stifts (Anzeige in der Druckkurve)
pub last_raw_pressure: Option<f32>, pub last_raw_pressure: Option<f32>,
/// Geöffnete Dateiauswahl „Bild einfügen“
pub image_picker: Option<FilePicker>,
/// Zuletzt in der Dateiauswahl benutzter Ordner (während der Sitzung)
pub last_image_dir: Option<std::path::PathBuf>,
} }
impl Default for UiState { impl Default for UiState {
@@ -252,6 +259,8 @@ impl Default for UiState {
adapt_ink_colors: true, adapt_ink_colors: true,
pressure_curve: PressureCurve::default(), pressure_curve: PressureCurve::default(),
last_raw_pressure: None, last_raw_pressure: None,
image_picker: None,
last_image_dir: None,
} }
} }
} }
@@ -273,6 +282,14 @@ impl UiState {
self.show_page_jump_popup = false; self.show_page_jump_popup = false;
self.is_document_menu_open = false; self.is_document_menu_open = false;
self.is_update_dialog_open = false; self.is_update_dialog_open = false;
self.image_picker = None;
}
/// Öffnet die Dateiauswahl „Bild einfügen“ im zuletzt benutzten Ordner.
pub fn open_image_picker(&mut self) {
let dir = self.last_image_dir.clone();
self.close_overlays();
self.image_picker = Some(FilePicker::open(dir.as_deref()));
} }
/// Länge der längsten Farbpalette; so viel Platz hält die Leiste immer frei. /// Länge der längsten Farbpalette; so viel Platz hält die Leiste immer frei.
@@ -758,10 +775,19 @@ pub fn page_thumbnail_shapes(
let scale = rect.width() / PAGE_WIDTH; let scale = rect.width() / PAGE_WIDTH;
let to_screen = |p: glam::Vec2| rect.min + egui::vec2(p.x * scale, p.y * scale); let to_screen = |p: glam::Vec2| rect.min + egui::vec2(p.x * scale, p.y * scale);
// Bilder als Flächen unter der Tinte
for corners in page.strokes.iter().filter_map(|s| s.image_corners()) {
shapes.push(egui::Shape::convex_polygon(
corners.iter().map(|&c| to_screen(c)).collect(),
egui::Color32::from_rgb(168, 182, 200),
egui::Stroke::NONE,
));
}
let total: usize = page.strokes.iter().map(|s| s.points.len()).sum(); let total: usize = page.strokes.iter().map(|s| s.points.len()).sum();
let step = total.div_ceil(THUMBNAIL_POINT_BUDGET).max(1); let step = total.div_ceil(THUMBNAIL_POINT_BUDGET).max(1);
for stroke in &page.strokes { for stroke in page.strokes.iter().filter(|s| !s.is_image()) {
let ink = if adapt { let ink = if adapt {
adapt_ink_color(stroke.color) adapt_ink_color(stroke.color)
} else { } else {
@@ -953,6 +979,110 @@ fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option<Press
save.then_some(state.pressure_curve) save.then_some(state.pressure_curve)
} }
/// Dialog „Bild einfügen“: Schnellzugriffe, Ordnerpfad und Liste aus Ordnern und Bildern.
fn image_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Vec<UiAction>) {
let mut close = false;
let mut navigate: Option<std::path::PathBuf> = None;
let mut go_up = false;
let mut chosen: Option<std::path::PathBuf> = None;
let Some(picker) = state.image_picker.as_ref() else {
return;
};
dialog_window("Bild einfügen", state, false).show(ctx, |ui| {
ui.set_width(460.0);
touch_friendly_spacing(ui);
if dialog_header(ui, "🖼 Bild einfügen", true) {
close = true;
}
ui.horizontal_wrapped(|ui| {
for (label, path) in crate::file_picker::shortcuts() {
if ui.selectable_label(picker.dir == path, label).clicked() {
navigate = Some(path);
}
}
});
ui.add_space(4.0);
ui.horizontal(|ui| {
if ui
.add_enabled(picker.dir.parent().is_some(), egui::Button::new("⏶"))
.on_hover_text("Übergeordneter Ordner")
.clicked()
{
go_up = true;
}
ui.add(
egui::Label::new(
egui::RichText::new(picker.dir.display().to_string())
.color(egui::Color32::from_gray(190)),
)
.truncate(true),
);
});
if let Some(err) = &picker.error {
ui.colored_label(egui::Color32::from_rgb(255, 120, 120), err);
}
ui.add_space(4.0);
egui::ScrollArea::vertical()
.max_height((ctx.screen_rect().height() - 330.0).clamp(160.0, 420.0))
.show(ui, |ui| {
ui.set_width(ui.available_width());
if picker.entries.is_empty() {
ui.label(
egui::RichText::new("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)")
.color(egui::Color32::from_gray(150)),
);
}
for entry in &picker.entries {
let label = if entry.is_dir {
format!("📁 {}", entry.name)
} else {
format!("🖼 {}", entry.name)
};
let button = egui::Button::new(label)
.frame(false)
.min_size(egui::vec2(ui.available_width(), 34.0));
let response = ui.add(button);
if response.clicked() {
if entry.is_dir {
navigate = Some(entry.path.clone());
} else {
chosen = Some(entry.path.clone());
}
}
}
});
ui.add_space(6.0);
ui.label(
egui::RichText::new(
"Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen.",
)
.size(11.5)
.color(egui::Color32::from_gray(150)),
);
});
if let Some(picker) = state.image_picker.as_mut() {
if go_up {
picker.go_up();
}
if let Some(dir) = navigate {
picker.navigate(&dir);
}
}
if let Some(path) = chosen {
state.last_image_dir = state.image_picker.as_ref().map(|p| p.dir.clone());
actions.push(UiAction::InsertImageFile(path));
close = true;
}
if close {
state.image_picker = None;
}
}
/// Rahmen der Popover neben der Werkzeugleiste. /// Rahmen der Popover neben der Werkzeugleiste.
fn popover_frame() -> egui::Frame { fn popover_frame() -> egui::Frame {
egui::Frame::none() egui::Frame::none()
@@ -1838,6 +1968,13 @@ pub fn render_ui(
{ {
actions.push(UiAction::AddPage); actions.push(UiAction::AddPage);
} }
if ui
.add_sized([ui.available_width(), 30.0], egui::Button::new("🖼 Bild einfügen … (Strg+I)"))
.on_hover_text("Auch per Strg+V aus der Zwischenablage oder durch Ziehen einer Bilddatei ins Fenster")
.clicked()
{
state.open_image_picker();
}
section(ui, "HINTERGRUND DIESER SEITE"); section(ui, "HINTERGRUND DIESER SEITE");
let current_bg = notebook.current_page().background; let current_bg = notebook.current_page().background;
@@ -2298,6 +2435,11 @@ pub fn render_ui(
state.document_modal_rect = None; state.document_modal_rect = None;
} }
// 3c. Dateiauswahl „Bild einfügen“
if state.image_picker.is_some() {
image_picker_dialog(ctx, state, &mut actions);
}
// 3c. Release- & Update-Center (Gitea Releases) // 3c. Release- & Update-Center (Gitea Releases)
if state.is_update_dialog_open { if state.is_update_dialog_open {
let win = dialog_window("Release- & Update-Center", state, false) let win = dialog_window("Release- & Update-Center", state, false)
@@ -3139,10 +3281,11 @@ mod tests {
} }
/// Quelltexte, deren String-Literale in der Oberfläche erscheinen können. /// Quelltexte, deren String-Literale in der Oberfläche erscheinen können.
const UI_SOURCES: [(&str, &str); 3] = [ const UI_SOURCES: [(&str, &str); 4] = [
("ui.rs", include_str!("ui.rs")), ("ui.rs", include_str!("ui.rs")),
("selection.rs", include_str!("selection.rs")), ("selection.rs", include_str!("selection.rs")),
("main.rs", include_str!("main.rs")), ("main.rs", include_str!("main.rs")),
("file_picker.rs", include_str!("file_picker.rs")),
]; ];
/// Liefert alle Zeichen aus String-Literalen eines Quelltexts (ohne Zeilenkommentare). /// Liefert alle Zeichen aus String-Literalen eines Quelltexts (ohne Zeilenkommentare).
@@ -3421,6 +3564,28 @@ mod tests {
pts.len(), pts.len(),
THUMBNAIL_POINT_BUDGET + 200 THUMBNAIL_POINT_BUDGET + 200
); );
// Bild: gefüllte Fläche statt Umrisslinie, unter der Tinte
let mut with_image = page_with_strokes(1, 5);
with_image.strokes.insert(
0,
crate::document::Stroke::new_image(
99,
"a.png".into(),
[
glam::Vec2::new(100.0, 100.0),
glam::Vec2::new(900.0, 100.0),
glam::Vec2::new(900.0, 600.0),
glam::Vec2::new(100.0, 600.0),
],
),
);
let shapes = page_thumbnail_shapes(&with_image, rect, PaperTheme::White, true);
assert_eq!(shapes.len(), 3);
assert!(
matches!(shapes[1], egui::Shape::Path(ref p) if p.closed && p.fill != egui::Color32::TRANSPARENT)
);
assert!(matches!(shapes[2], egui::Shape::Path(ref p) if !p.closed));
} }
#[test] #[test]