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:
Generated
+282
-5
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"), ¬ebook.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, ¬ebook.pages[0], strategy);
|
render_ink_layer(&layer1_ink_ref, ¬ebook.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(¬ebook, &pdf_path, ExportStrategy::FilledPolygon);
|
export_notebook_to_pdf(¬ebook, &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(¬ebook, ExportStrategy::BezierCurve);
|
let bytes_res = export_notebook_to_bytes(¬ebook, 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(¬ebook, ExportStrategy::FilledPolygon);
|
let bytes_res = export_notebook_to_bytes(¬ebook, 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(¬ebook, 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(¬ebook, 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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¤t, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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(¬ebook_clone, &export_path, ExportStrategy::FilledPolygon)
|
let assets = AssetStore::new(&storage_dir);
|
||||||
|
export_notebook_to_pdf(
|
||||||
|
¬ebook_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, ¬ebook, &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, ¬ebook, &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(¬ebook, &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, ¬ebook, &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(¬ebook, &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, ¬ebook, &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
@@ -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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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, ¬ebook, &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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user