From b5c1da07fabf126270e34070c1df9f22bc27395d Mon Sep 17 00:00:00 2001 From: Harald Date: Wed, 7 Oct 2026 16:50:54 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Bilder=20einf=C3=BCgen=20(Datei,=20Zwis?= =?UTF-8?q?chenablage,=20Drag=20&=20Drop)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 /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 --- Cargo.lock | 287 ++++++++++++++++++++++++++++++++++- Cargo.toml | 4 + src/assets.rs | 355 ++++++++++++++++++++++++++++++++++++++++++++ src/canvas.rs | 32 +++- src/document.rs | 213 ++++++++++++++++++++++++-- src/export.rs | 290 +++++++++++++++++++++++++++++++++++- src/file_picker.rs | 196 ++++++++++++++++++++++++ src/image_layer.rs | 363 +++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 3 + src/main.rs | 295 +++++++++++++++++++++++++++++++----- src/selection.rs | 33 ++++- src/shader.wgsl | 30 ++++ src/storage.rs | 54 ++++++- src/ui.rs | 169 ++++++++++++++++++++- 14 files changed, 2261 insertions(+), 63 deletions(-) create mode 100644 src/assets.rs create mode 100644 src/file_picker.rs create mode 100644 src/image_layer.rs diff --git a/Cargo.lock b/Cargo.lock index 2b7e17e..a26cdb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,6 +79,26 @@ dependencies = [ "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]] name = "arrayref" version = "0.3.9" @@ -182,7 +202,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15b55663a85f33501257357e6421bb33e769d5c9ffb5ba0921c975a123e35e68" dependencies = [ "block-sys", - "objc2", + "objc2 0.4.1", ] [[package]] @@ -222,6 +242,12 @@ dependencies = [ "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]] name = "bytes" version = "1.12.1" @@ -290,6 +316,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "codespan-reporting" version = "0.11.1" @@ -300,6 +335,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "com" version = "0.6.0" @@ -420,6 +461,12 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -469,6 +516,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "displaydoc" version = "0.2.7" @@ -623,12 +680,33 @@ dependencies = [ "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]] name = "fastrand" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "find-msvc-tools" version = "0.1.14" @@ -642,7 +720,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", "zlib-rs", ] @@ -770,6 +848,16 @@ dependencies = [ "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]] name = "gl_generator" version = "0.14.0" @@ -863,6 +951,17 @@ dependencies = [ "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]] name = "hashbrown" version = "0.14.5" @@ -929,7 +1028,7 @@ checksum = "99d3aaff8a54577104bafdf686ff18565c3b6903ca5782a2026ef06e2c7aa319" dependencies = [ "block2", "dispatch", - "objc2", + "objc2 0.4.1", ] [[package]] @@ -1036,6 +1135,35 @@ dependencies = [ "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]] name = "indexmap" version = "2.14.2" @@ -1050,12 +1178,14 @@ dependencies = [ name = "ink-notes" version = "0.4.0" dependencies = [ + "arboard", "bytemuck", "egui", "egui-wgpu", "egui-winit", "fs2", "glam", + "image", "minisign-verify", "naga", "octotablet", @@ -1312,6 +1442,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "miniz_oxide" version = "0.9.1" @@ -1322,6 +1462,16 @@ dependencies = [ "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]] name = "multiversion_no_op" version = "1.0.0" @@ -1444,7 +1594,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "559c5a40fdd30eb5e344fbceacf7595a81e242529fb4e21cf5f43fb4f11ff98d" dependencies = [ "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]] @@ -1453,6 +1648,34 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "objc_exception" version = "0.1.2" @@ -1568,6 +1791,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "polling" version = "3.11.0" @@ -1654,6 +1890,18 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "quick-xml" version = "0.41.0" @@ -2142,6 +2390,20 @@ dependencies = [ "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]] name = "time" version = "0.3.55" @@ -3064,7 +3326,7 @@ dependencies = [ "memmap2", "ndk", "ndk-sys", - "objc2", + "objc2 0.4.1", "once_cell", "orbclient", "percent-encoding", @@ -3287,3 +3549,18 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" 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", +] diff --git a/Cargo.toml b/Cargo.toml index 024e44d..5bb9939 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,10 @@ sha2 = "0.10" ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] } url = "2.5" 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" fs2 = "0.4" diff --git a/src/assets.rs b/src/assets.rs new file mode 100644 index 0000000..50f8ed7 --- /dev/null +++ b/src/assets.rs @@ -0,0 +1,355 @@ +//! Eingebettete Bilder eines Notizbuchs (`/assets/`). +//! +//! Jedes Bild wird einmal unter dem SHA-256 seines Inhalts gespeichert (`.png` bzw. +//! `.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 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, +} + +/// 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 { + 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 { + 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, + ) -> Result { + 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, 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 { + 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 { + 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, 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 { + 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"))); + } +} diff --git a/src/canvas.rs b/src/canvas.rs index 1e6d766..09a97a9 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -165,6 +165,9 @@ pub struct CanvasState { pub committed_vertex_count: u32, pub committed_strokes: Vec>, + // Eingefügte Bilder der aktuellen Seite + image_layer: crate::image_layer::ImageLayer, + // egui UI-Renderer pub egui_renderer: egui_wgpu::Renderer, } @@ -407,6 +410,13 @@ impl CanvasState { 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 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_count: 0, committed_strokes: Vec::with_capacity(128), + image_layer, egui_renderer, camera: crate::viewport::ViewportState::new(), }) @@ -655,6 +666,12 @@ impl CanvasState { pub fn sync_page(&mut self, page: &mut Page) { page.rebuild_cache_if_dirty(); 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_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. pub fn start_stroke(&mut self, page_x: f32, page_y: f32, pressure: f32, timestamp: f64) { self.filter.reset(); @@ -880,7 +903,10 @@ impl CanvasState { 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); 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 { render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..)); render_pass.draw(0..self.live_vertex_count, 0..1); @@ -997,7 +1023,7 @@ mod tests { .validate(&module) .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!( module.entry_points.iter().any(|ep| ep.name == name), "Einstiegspunkt {name} fehlt" diff --git a/src/document.rs b/src/document.rs index 548dd3e..ad4166e 100644 --- a/src/document.rs +++ b/src/document.rs @@ -103,6 +103,52 @@ pub fn point_to_segment_distance(p: glam::Vec2, a: glam::Vec2, b: glam::Vec2) -> (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. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] @@ -158,6 +204,10 @@ pub struct Stroke { pub tool: StrokeTool, pub width: f32, 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, } impl Stroke { @@ -190,9 +240,44 @@ impl Stroke { tool: StrokeTool::Pen, width: 5.0, 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). pub fn finalize( id: u64, @@ -226,7 +311,16 @@ impl Stroke { } /// 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 { + 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() { return f32::INFINITY; } @@ -348,7 +442,11 @@ impl Stroke { } /// 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]) { + if self.is_image() { + return; + } self.color = new_color; for v in &mut self.vertices { v.color = new_color; @@ -462,20 +560,18 @@ impl Page { } /// 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> { - let mut best: Option<(&Stroke, f32)> = None; + let mut best: Option<(&Stroke, (bool, f32))> = None; for s in &self.strokes { if !s.aabb.expanded(max_dist).contains_point(pos) { continue; } let d = s.distance_to_point(pos); if d <= max_dist { - if let Some((_, best_d)) = best { - if d < best_d { - best = Some((s, d)); - } - } else { - best = Some((s, d)); + let key = (s.is_image(), d); + if best.map_or(true, |(_, best_key)| key < best_key) { + best = Some((s, key)); } } } @@ -516,7 +612,8 @@ impl Page { let mut hit_indices = Vec::new(); 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) { hit_indices.push(i); @@ -572,6 +669,8 @@ impl Page { } /// 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 { let sel_box = Aabb::new( 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 .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) .collect() } @@ -1145,6 +1250,96 @@ mod tests { 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![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] fn test_page_isolation() { let mut nb = Notebook::new(); diff --git a/src/export.rs b/src/export.rs index e49fa2d..7c8c822 100644 --- a/src/export.rs +++ b/src/export.rs @@ -2,7 +2,7 @@ //! //! Features: //! - 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) //! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko) //! - Zwei Export-Strategien: @@ -17,6 +17,7 @@ use std::path::{Path, PathBuf}; use printpdf::path::{PaintMode, WindingOrder}; use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb}; +use crate::assets::AssetStore; use crate::background::{GRID_5MM_SPACING, LINED_SPACING}; use crate::document::{ 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. +/// `assets`: Bilder des Notizbuchs (ohne werden Bilder als graue Fläche ausgegeben). pub fn export_notebook_to_pdf>( notebook: &Notebook, path: P, strategy: ExportStrategy, + assets: Option<&AssetStore>, ) -> Result<(), Box> { let file = File::create(path)?; 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)?; Ok(()) } @@ -125,8 +128,9 @@ pub fn export_notebook_to_pdf>( pub fn export_notebook_to_bytes( notebook: &Notebook, strategy: ExportStrategy, + assets: Option<&AssetStore>, ) -> Result, Box> { - let doc = generate_pdf_document(notebook, strategy)?; + let doc = generate_pdf_document(notebook, strategy, assets)?; let bytes = doc.save_to_bytes()?; Ok(bytes) } @@ -135,6 +139,7 @@ pub fn export_notebook_to_bytes( pub fn generate_pdf_document( notebook: &Notebook, strategy: ExportStrategy, + assets: Option<&AssetStore>, ) -> Result> { if notebook.pages.is_empty() { 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 layer1_bg_ref = page1_ref.get_layer(layer1_bg); 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"); 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 layer_bg_ref = page_ref.get_layer(layer_bg); 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"); 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"). fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) { for stroke in &page.strokes { + if stroke.is_image() { + continue; + } if let Some(ref shape) = stroke.shape { render_recognized_shape(layer, shape, stroke.color); } 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 { + 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> { + 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) /// als verlustfreien Vektorpfad im PDF. fn render_recognized_shape( @@ -794,7 +962,7 @@ mod tests { let pdf_path = temp_dir.join("ink_notes_test_export.pdf"); let export_res = - export_notebook_to_pdf(¬ebook, &pdf_path, ExportStrategy::FilledPolygon); + export_notebook_to_pdf(¬ebook, &pdf_path, ExportStrategy::FilledPolygon, None); assert!( export_res.is_ok(), "PDF-Export zu Datei muss erfolgreich sein" @@ -820,7 +988,7 @@ mod tests { ); // 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()); let bytes = bytes_res.unwrap(); assert!(bytes.len() > 1024); @@ -943,13 +1111,123 @@ mod tests { ); 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"); let bytes = bytes_res.unwrap(); assert!(bytes.len() > 1024); 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] fn test_sanitize_filename_component() { assert_eq!(sanitize_filename_component("Notizbuch 1"), "Notizbuch 1"); diff --git a/src/file_picker.rs b/src/file_picker.rs new file mode 100644 index 0000000..c1c3bc3 --- /dev/null +++ b/src/file_picker.rs @@ -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, + /// Ordner nicht lesbar + pub error: Option, +} + +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> { + let mut entries: Vec = 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 { + 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 { + 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 = 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); + } +} diff --git a/src/image_layer.rs b/src/image_layer.rs new file mode 100644 index 0000000..0c38415 --- /dev/null +++ b/src/image_layer.rs @@ -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::() 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 { + 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, + /// Bilder der aktuellen Seite: Asset-Name und Ecken + quads: Vec<(String, [glam::Vec2; 4])>, + vertex_buffer: Option, + 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 = 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 = 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); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index f802215..2d5a89a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,13 +1,16 @@ //! ink-notes: Latenzoptimierte Inking-App für Linux. +pub mod assets; pub mod autosave; pub mod background; pub mod canvas; pub mod document; pub mod export; +pub mod file_picker; pub mod filter; pub mod geometry; pub mod history; +pub mod image_layer; pub mod platform; pub mod selection; pub mod settings; diff --git a/src/main.rs b/src/main.rs index 3a8bdf9..c92eb8d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ use winit::{ }; use glam::Vec2; +use ink_notes::assets::{AssetStore, ImportedImage}; use ink_notes::autosave::{AutosaveController, AutosaveStatus}; use ink_notes::canvas::CanvasState; use ink_notes::document::{Notebook, PageBackground}; @@ -91,8 +92,14 @@ fn start_pdf_export( let result = storage::load_all_pages(&storage_dir, &mut notebook_clone) .map_err(|err| err.to_string()) .and_then(|()| { - export_notebook_to_pdf(¬ebook_clone, &export_path, ExportStrategy::FilledPolygon) - .map_err(|err| err.to_string()) + let assets = AssetStore::new(&storage_dir); + export_notebook_to_pdf( + ¬ebook_clone, + &export_path, + ExportStrategy::FilledPolygon, + Some(&assets), + ) + .map_err(|err| err.to_string()) }); match &result { Ok(()) => println!( @@ -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, + /// 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, +} + +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> { // Anzeigesystem wählen: Unter WSL (WSLg) bricht Wayland ab, X11 (Xwayland) läuft stabil let mut event_loop_builder = EventLoopBuilder::new(); @@ -284,6 +498,7 @@ fn main() -> Result<(), Box> { ui_state.set_export_message(warning); } let mut selection_state = SelectionState::new(); + let mut clipboard = ClipboardBridge::new(); let mut _notebook_lock = match storage::NotebookLock::acquire(&storage_dir) { Ok(lock) => { @@ -755,6 +970,22 @@ fn main() -> Result<(), Box> { } } + // 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) => { // Bei Fokusverlust Strich abbrechen und 1€-Filter resetten if is_drawing { @@ -879,6 +1110,7 @@ fn main() -> Result<(), Box> { Key::Character(c) if c.eq_ignore_ascii_case("c") && ctrl => { let count = selection_state.copy_to_clipboard(notebook.current_page()); if count > 0 { + clipboard.note_internal_copy(&storage_dir); ui_state.set_export_message(format!("{count} Objekt(e) kopiert")); window.request_redraw(); } @@ -886,23 +1118,18 @@ fn main() -> Result<(), Box> { // Einfügen: Strg + V Key::Character(c) if c.eq_ignore_ascii_case("v") && ctrl => { - 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 - }, + paste( + &mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest, + &mut autosave, &mut selection_state, &mut ui_state, ); - 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(¬ebook, &mut manifest); - ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len())); - window.request_redraw(); - } + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + 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 @@ -1984,28 +2211,27 @@ fn main() -> Result<(), Box> { UiAction::CopySelected => { let count = selection_state.copy_to_clipboard(notebook.current_page()); if count > 0 { + clipboard.note_internal_copy(&storage_dir); ui_state.set_export_message(format!("{count} Objekt(e) kopiert")); window.request_redraw(); } } UiAction::PasteSelected => { - 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 - }, + paste( + &mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest, + &mut autosave, &mut selection_state, &mut ui_state, ); - 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(¬ebook, &mut manifest); - ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len())); - window.request_redraw(); - } + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + 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 => { if !selection_state.is_empty() @@ -2482,6 +2708,7 @@ fn main() -> Result<(), Box> { } // 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) { Ok(_) => {} Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { diff --git a/src/selection.rs b/src/selection.rs index 14cb87f..c9c931b 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -243,7 +243,12 @@ impl SelectionState { if self.selected_ids.is_empty() { 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() { return false; } @@ -499,4 +504,30 @@ mod tests { let restored_pos = page.strokes[0].points[0].position; 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]); + } } diff --git a/src/shader.wgsl b/src/shader.wgsl index 5274d02..4be0cb2 100644 --- a/src/shader.wgsl +++ b/src/shader.wgsl @@ -47,3 +47,33 @@ fn fs_ink(in: VertexOutput) -> @location(0) vec4 { let lift = max(1.0 - max_c - min_c, 0.0); return vec4(in.color.rgb + vec3(lift), in.color.a); } + +// Eingefügte Bilder: texturierte Vierecke unter der Tinte +struct ImageVertexInput { + @location(0) position: vec2, + @location(1) uv: vec2, +}; + +struct ImageVertexOutput { + @builtin(position) clip_position: vec4, + @location(0) uv: vec2, +}; + +@group(1) @binding(0) +var image_texture: texture_2d; +@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(ndc, 0.0, 1.0); + out.uv = in.uv; + return out; +} + +@fragment +fn fs_image(in: ImageVertexOutput) -> @location(0) vec4 { + return textureSample(image_texture, image_sampler, in.uv); +} diff --git a/src/storage.rs b/src/storage.rs index 661aab1..fdce6f6 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -70,6 +70,9 @@ pub struct StrokeData { pub width: f32, #[serde(default = "default_finalized")] 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, } impl From<&Stroke> for StrokeData { @@ -82,6 +85,7 @@ impl From<&Stroke> for StrokeData { tool: stroke.tool, width: stroke.width, finalized: stroke.finalized, + image: stroke.image.clone(), } } } @@ -96,6 +100,17 @@ impl StrokeData { .copied() .map(StylusSample::from) .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 { self.width } 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 /// betroffene Seiten für beschädigt halten; mit Version 3 lehnen sie das Notizbuch stattdessen /// 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). pub fn legacy_format_version() -> u32 { @@ -2177,12 +2195,12 @@ mod tests { let (mut loaded, _) = load_notebook(&dir).unwrap(); assert!(!loaded.pages[1].is_loaded); // 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(); assert_eq!(loaded.pages[1].strokes.len(), 1); 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] @@ -2316,4 +2334,34 @@ mod tests { 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()); + } } diff --git a/src/ui.rs b/src/ui.rs index 937cf25..b5c413f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -16,6 +16,7 @@ use crate::background::{adapt_ink_color, PaperTheme}; use crate::document::{ EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH, }; +use crate::file_picker::FilePicker; use crate::settings::{PressureCurve, SidebarSide}; use crate::storage::NotebookSummary; @@ -151,6 +152,8 @@ pub enum UiAction { }, /// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`) SavePressureCurve(PressureCurve), + /// Bilddatei auf der aktuellen Seite einfügen + InsertImageFile(std::path::PathBuf), // Beenden-Sicherheit bei Fehlschlag RetryShutdown, ExportRecoveryAndExit, @@ -212,6 +215,10 @@ pub struct UiState { pub pressure_curve: PressureCurve, /// Zuletzt gemessener Rohdruck des Stifts (Anzeige in der Druckkurve) pub last_raw_pressure: Option, + /// Geöffnete Dateiauswahl „Bild einfügen“ + pub image_picker: Option, + /// Zuletzt in der Dateiauswahl benutzter Ordner (während der Sitzung) + pub last_image_dir: Option, } impl Default for UiState { @@ -252,6 +259,8 @@ impl Default for UiState { adapt_ink_colors: true, pressure_curve: PressureCurve::default(), last_raw_pressure: None, + image_picker: None, + last_image_dir: None, } } } @@ -273,6 +282,14 @@ impl UiState { self.show_page_jump_popup = false; self.is_document_menu_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. @@ -758,10 +775,19 @@ pub fn page_thumbnail_shapes( let scale = rect.width() / PAGE_WIDTH; 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 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 { adapt_ink_color(stroke.color) } else { @@ -953,6 +979,110 @@ fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option) { + let mut close = false; + let mut navigate: Option = None; + let mut go_up = false; + let mut chosen: Option = 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. fn popover_frame() -> egui::Frame { egui::Frame::none() @@ -1838,6 +1968,13 @@ pub fn render_ui( { 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"); let current_bg = notebook.current_page().background; @@ -2298,6 +2435,11 @@ pub fn render_ui( 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) if state.is_update_dialog_open { 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. - const UI_SOURCES: [(&str, &str); 3] = [ + const UI_SOURCES: [(&str, &str); 4] = [ ("ui.rs", include_str!("ui.rs")), ("selection.rs", include_str!("selection.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). @@ -3421,6 +3564,28 @@ mod tests { pts.len(), 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]