diff --git a/Cargo.lock b/Cargo.lock index 4583d24..34f55c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,21 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -61,7 +76,7 @@ dependencies = [ "ndk-context", "ndk-sys", "num_enum", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -205,6 +220,27 @@ dependencies = [ "objc2 0.4.1", ] +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bstr" version = "1.13.1" @@ -265,7 +301,7 @@ dependencies = [ "polling", "rustix 0.38.44", "slab", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -335,6 +371,15 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -593,7 +638,7 @@ dependencies = [ "egui", "epaint", "log", - "thiserror", + "thiserror 1.0.69", "type-map", "web-time", "wgpu", @@ -686,6 +731,15 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -707,6 +761,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fearless_simd" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4beca3cb2444e3304ac30843cc091f44ed58932353cd492ce740067bfce6b12" + +[[package]] +name = "fearless_simd" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611" + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -724,6 +790,21 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +dependencies = [ + "bytemuck", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -878,6 +959,23 @@ dependencies = [ "serde", ] +[[package]] +name = "glifo" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1218dedd5348c1b166ec3b8b3cbca49d2f527bd43b3fa072ea5123c523ed3f" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "png", + "skrifa 0.44.0", + "smallvec", + "vello_common", +] + [[package]] name = "glow" version = "0.13.1" @@ -926,7 +1024,7 @@ checksum = "6f56f6318968d03c18e1bcf4857ff88c61157e9da8e47c5f29055d60e1228884" dependencies = [ "log", "presser", - "thiserror", + "thiserror 1.0.69", "winapi", "windows 0.52.0", ] @@ -951,6 +1049,15 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + [[package]] name = "half" version = "2.7.1" @@ -977,6 +1084,9 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] [[package]] name = "hassle-rs" @@ -988,11 +1098,103 @@ dependencies = [ "com", "libc", "libloading 0.8.9", - "thiserror", + "thiserror 1.0.69", "widestring", "winapi", ] +[[package]] +name = "hayro" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4675ff5acd7ca41c0c34ccd2a3a154f5c86d8b5ec92bef0a114bfb194969e2af" +dependencies = [ + "bytemuck", + "fearless_simd 1.1.0", + "hayro-interpret", + "image", + "kurbo", + "pic-scale", + "rustc-hash 2.1.3", + "vello_cpu", +] + +[[package]] +name = "hayro-ccitt" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2866f0d976e76497480b853d5e258dab6e4b89ecfe991124e08f15dfa8fd03ad" + +[[package]] +name = "hayro-cmap" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6" +dependencies = [ + "brotli", + "hayro-postscript", +] + +[[package]] +name = "hayro-interpret" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9df074ff163a63864cec9b0bf640ccbff8abf4e44481df7369ce0ac172ffbe" +dependencies = [ + "bitflags 2.13.2", + "hayro-cmap", + "hayro-syntax", + "kurbo", + "moxcms", + "phf", + "rustc-hash 2.1.3", + "siphasher", + "skrifa 0.46.2", + "smallvec", + "yoke", +] + +[[package]] +name = "hayro-jbig2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9" +dependencies = [ + "fearless_simd 1.1.0", + "hayro-ccitt", +] + +[[package]] +name = "hayro-jpeg2000" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122c5f834c37a2260b9c851bb8be685cf539b2a8b94b704a931ef7d2525d5835" +dependencies = [ + "fearless_simd 1.1.0", +] + +[[package]] +name = "hayro-postscript" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" + +[[package]] +name = "hayro-syntax" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cdc818d8d265dcb42081d488f7599df4f73fb79e9d347ecedcd3c23809d2026" +dependencies = [ + "flate2", + "hayro-ccitt", + "hayro-jbig2", + "hayro-jpeg2000", + "memchr", + "rustc-hash 2.1.3", + "smallvec", + "zune-jpeg", +] + [[package]] name = "heck" version = "0.5.0" @@ -1185,6 +1387,7 @@ dependencies = [ "egui-winit", "fs2", "glam", + "hayro", "image", "minisign-verify", "naga", @@ -1220,7 +1423,7 @@ dependencies = [ "combine", "jni-sys 0.3.1", "log", - "thiserror", + "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", ] @@ -1291,6 +1494,18 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "libc" version = "0.2.190" @@ -1329,6 +1544,12 @@ dependencies = [ "redox_syscall 0.9.4", ] +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -1494,7 +1715,7 @@ dependencies = [ "rustc-hash 1.1.0", "spirv", "termcolor", - "thiserror", + "thiserror 1.0.69", "unicode-xid", ] @@ -1510,7 +1731,7 @@ dependencies = [ "ndk-sys", "num_enum", "raw-window-handle 0.6.2", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1697,7 +1918,7 @@ dependencies = [ "raw-window-handle 0.6.2", "smallvec", "strum", - "thiserror", + "thiserror 1.0.69", "wayland-backend", "wayland-client", "wayland-protocols", @@ -1767,12 +1988,77 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pic-scale" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb0ff645d35accaec252de081d55984a0d6c952cb1e5dc2b9afa7c78f07288" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1824,6 +2110,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "pom" version = "3.4.0" @@ -1950,6 +2245,28 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "read-fonts" +version = "0.43.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + [[package]] name = "redox_syscall" version = "0.3.5" @@ -2194,6 +2511,32 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[package]] +name = "skrifa" +version = "0.46.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4febebda507fb889c545a37a135517769d1391941013561292897961d1887d6" +dependencies = [ + "bytemuck", + "read-fonts 0.43.3", +] + [[package]] name = "slab" version = "0.4.12" @@ -2229,7 +2572,7 @@ dependencies = [ "log", "memmap2", "rustix 0.38.44", - "thiserror", + "thiserror 1.0.69", "wayland-backend", "wayland-client", "wayland-csd-frame", @@ -2376,7 +2719,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", ] [[package]] @@ -2390,6 +2742,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "tiff" version = "0.11.3" @@ -2601,6 +2964,35 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "vello_common" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73c2fbe53548bcd25c8ff42f75a93770a8611a16b37bee56d7ef7b44e36027f1" +dependencies = [ + "bytemuck", + "fearless_simd 0.7.0", + "guillotiere", + "log", + "peniko", + "png", + "smallvec", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_cpu" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed1fec424fbba6acf6cbc78bb526447eb77590d95f40419d598870fe37535212" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "png", + "vello_common", +] + [[package]] name = "version_check" version = "0.9.5" @@ -2903,7 +3295,7 @@ dependencies = [ "raw-window-handle 0.6.2", "rustc-hash 1.1.0", "smallvec", - "thiserror", + "thiserror 1.0.69", "web-sys", "wgpu-hal", "wgpu-types", @@ -2947,7 +3339,7 @@ dependencies = [ "renderdoc-sys", "rustc-hash 1.1.0", "smallvec", - "thiserror", + "thiserror 1.0.69", "wasm-bindgen", "web-sys", "wgpu-types", diff --git a/Cargo.toml b/Cargo.toml index 6fa8768..15cfd57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linu repository = "https://gitea.pansi.eu/harald/ink-notes" license = "MIT" readme = "README.md" -rust-version = "1.75" +rust-version = "1.92" [dependencies] # Fenster- & Event-Handling (Wayland/X11, Stylus) @@ -45,6 +45,7 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg", arboard = { version = "3", default-features = false, features = ["image-data"] } minisign-verify = "0.3.0" fs2 = "0.4" +hayro = "0.8" [profile.dev] opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus diff --git a/src/assets.rs b/src/assets.rs index 50f8ed7..fe4ea9c 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -78,7 +78,7 @@ pub struct RgbaImage { pub pixels: Vec, } -/// Prüft einen Asset-Namen: genau 64 Hex-Zeichen und `.png` oder `.jpg`. Verhindert, dass +/// Prüft einen Asset-Namen: genau 64 Hex-Zeichen und `.png`, `.jpg` oder `.pdf`. 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 { @@ -88,7 +88,7 @@ pub fn is_valid_asset_name(name: &str) -> bool { && hash .bytes() .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) - && matches!(ext, "png" | "jpg") + && matches!(ext, "png" | "jpg" | "pdf") } /// Bilddateien eines Notizbuchs. @@ -146,6 +146,17 @@ impl AssetStore { self.store(&png, "png", decoded.width(), decoded.height()) } + /// Übernimmt eine PDF-Datei unverändert (der Inhalt muss vorher geprüft sein). + pub fn import_pdf(&self, bytes: &[u8]) -> Result { + let hash = Sha256::digest(bytes); + let asset = format!("{}.pdf", hex(&hash)); + let path = self.path_of(&asset)?; + if !path.exists() { + storage::atomic_write_file(&path, bytes)?; + } + Ok(asset) + } + /// Übernimmt rohe RGBA-Pixel (z. B. aus der Zwischenablage) als PNG. pub fn import_rgba( &self, diff --git a/src/canvas.rs b/src/canvas.rs index 09a97a9..eaddbee 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -666,10 +666,16 @@ impl CanvasState { pub fn sync_page(&mut self, page: &mut Page) { page.rebuild_cache_if_dirty(); self.set_background(page.background); + // PDF-Seite zuerst, darüber die eingefügten Bilder self.image_layer.set_quads( - page.strokes + page.pdf_background .iter() - .filter_map(|s| Some((s.image.clone()?, s.image_corners()?))) + .map(|pdf| (pdf.key(), pdf.corners())) + .chain( + page.strokes + .iter() + .filter_map(|s| Some((s.image.clone()?, s.image_corners()?))), + ) .collect(), ); @@ -698,10 +704,18 @@ 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); + /// Lädt Bilder und PDF-Seiten der aktuellen Seite, die noch nicht auf der GPU liegen. + /// Vor jedem Zeichnen aufrufen; ohne Änderungen kostet das fast nichts. + pub fn prepare_images( + &mut self, + load: &mut dyn FnMut(&str) -> crate::image_layer::TextureSource, + ) { + self.image_layer.prepare(&self.device, &self.queue, load); + } + + /// Wird noch eine PDF-Seite im Hintergrund gerendert? + pub fn is_waiting_for_images(&self) -> bool { + self.image_layer.is_waiting() } /// Startet einen neuen Live-Strich in DIN-A4-Seitenkoordinaten. diff --git a/src/document.rs b/src/document.rs index ad4166e..056f37a 100644 --- a/src/document.rs +++ b/src/document.rs @@ -122,6 +122,47 @@ pub fn point_in_convex_quad(p: glam::Vec2, quad: &[glam::Vec2; 4]) -> bool { true } +/// Verweis auf eine Seite eines importierten PDFs (Datei im Asset-Ordner). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct PdfBackground { + /// Name der PDF-Datei im Asset-Ordner + pub asset: String, + /// Seitennummer im PDF, ab 0 + pub page: u32, + /// Angezeigte Größe der PDF-Seite in Punkt (Drehung berücksichtigt) + pub width_pt: f32, + pub height_pt: f32, +} + +impl PdfBackground { + /// Schlüssel für Texturen und Render-Aufträge. + pub fn key(&self) -> String { + format!("{}#{}", self.asset, self.page) + } + + /// Ecken (oben links, oben rechts, unten rechts, unten links) der PDF-Seite auf dem + /// A4-Blatt, siehe [`pdf_placement`]. + pub fn corners(&self) -> [glam::Vec2; 4] { + pdf_placement(self.width_pt, self.height_pt) + } +} + +/// Lage einer PDF-Seite von `width_pt` × `height_pt` auf dem A4-Blatt: so groß wie möglich, +/// waagerecht zentriert und oben bündig. Querformat-Folien liegen so oben auf der Seite, +/// darunter bleibt Platz für Notizen. +pub fn pdf_placement(width_pt: f32, height_pt: f32) -> [glam::Vec2; 4] { + let (w, h) = (width_pt.max(1.0), height_pt.max(1.0)); + let scale = (PAGE_WIDTH / w).min(PAGE_HEIGHT / h); + let size = glam::Vec2::new(w * scale, h * scale); + let tl = glam::Vec2::new((PAGE_WIDTH - size.x) / 2.0, 0.0); + [ + tl, + tl + glam::Vec2::new(size.x, 0.0), + tl + size, + tl + glam::Vec2::new(0.0, size.y), + ] +} + /// 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; @@ -489,6 +530,8 @@ pub struct Page { pub id: u64, pub strokes: Vec, pub background: PageBackground, + /// Importierte PDF-Seite als Hintergrund (unter Bildern und Tinte) + pub pdf_background: Option, pub vertex_cache: Vec, pub stroke_ranges: Vec>, pub is_dirty: bool, @@ -503,6 +546,7 @@ impl Page { id, strokes: Vec::new(), background, + pdf_background: None, vertex_cache: Vec::new(), stroke_ranges: Vec::new(), is_dirty: false, @@ -518,6 +562,7 @@ impl Page { id, strokes: Vec::new(), background, + pdf_background: None, vertex_cache: Vec::new(), stroke_ranges: Vec::new(), is_dirty: false, diff --git a/src/export.rs b/src/export.rs index 7c8c822..af82258 100644 --- a/src/export.rs +++ b/src/export.rs @@ -117,10 +117,11 @@ pub fn export_notebook_to_pdf>( strategy: ExportStrategy, assets: Option<&AssetStore>, ) -> Result<(), Box> { + let bytes = export_notebook_to_bytes(notebook, strategy, assets)?; let file = File::create(path)?; let mut writer = BufWriter::new(file); - let doc = generate_pdf_document(notebook, strategy, assets)?; - doc.save(&mut writer)?; + std::io::Write::write_all(&mut writer, &bytes)?; + std::io::Write::flush(&mut writer)?; Ok(()) } @@ -132,14 +133,88 @@ pub fn export_notebook_to_bytes( ) -> Result, Box> { let doc = generate_pdf_document(notebook, strategy, assets)?; let bytes = doc.save_to_bytes()?; - Ok(bytes) + + // Importierte PDF-Seiten als Vektorgrafik unterlegen + let underlays = pdf_underlays(notebook); + if underlays.is_empty() { + return Ok(bytes); + } + let sources: std::collections::HashMap> = underlays + .iter() + .filter_map(|u| Some((u.source.clone(), assets?.read_bytes(&u.source).ok()?))) + .collect(); + let (merged, failed) = crate::pdf_merge::apply_underlays(&bytes, &underlays, &sources)?; + if failed.is_empty() { + return Ok(merged); + } + + // Nicht übernehmbare Seiten (z. B. verschlüsselte PDFs) als Bild einbetten + let mut raster = std::collections::HashSet::new(); + for (underlay, err) in &failed { + eprintln!( + "[PDF-Export] Seite {} des PDFs als Bild statt als Vektor: {err}", + underlay.source_page + 1 + ); + raster.insert(underlay.target_page); + } + let doc = generate_pdf_document_with(notebook, strategy, assets, &raster)?; + let bytes = doc.save_to_bytes()?; + let remaining: Vec<_> = underlays + .into_iter() + .filter(|u| !raster.contains(&u.target_page)) + .collect(); + if remaining.is_empty() { + return Ok(bytes); + } + let (merged, _) = crate::pdf_merge::apply_underlays(&bytes, &remaining, &sources)?; + Ok(merged) +} + +/// Unterlagen für alle Seiten mit importierter PDF-Seite. +fn pdf_underlays(notebook: &Notebook) -> Vec { + notebook + .pages + .iter() + .enumerate() + .filter_map(|(i, page)| { + let pdf = page.pdf_background.as_ref()?; + let corners = pdf.corners(); + let top_right = page_to_pdf_pt(corners[1]); + let bottom_left = page_to_pdf_pt(corners[3]); + let size = top_right - bottom_left; + Some(crate::pdf_merge::Underlay { + target_page: i, + source: pdf.asset.clone(), + source_page: pdf.page as usize, + rect: [bottom_left.x, bottom_left.y, size.x, size.y], + }) + }) + .collect() } /// Generiert das `printpdf::PdfDocumentReference` für alle Seiten des Notizbuchs. +/// Importierte PDF-Seiten sind darin noch nicht enthalten; die legt +/// [`export_notebook_to_bytes`] anschließend als Vektorgrafik darunter. pub fn generate_pdf_document( notebook: &Notebook, strategy: ExportStrategy, assets: Option<&AssetStore>, +) -> Result> { + generate_pdf_document_with( + notebook, + strategy, + assets, + &std::collections::HashSet::new(), + ) +} + +/// Wie [`generate_pdf_document`]; die PDF-Seiten der Seiten in `raster_pdf_pages` werden +/// gerendert und als Bild eingebettet. +fn generate_pdf_document_with( + notebook: &Notebook, + strategy: ExportStrategy, + assets: Option<&AssetStore>, + raster_pdf_pages: &std::collections::HashSet, ) -> Result> { if notebook.pages.is_empty() { return Err("Notizbuch enthält keine Seiten".into()); @@ -164,6 +239,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 raster_pdf_pages.contains(&0) { + render_pdf_raster(&page1_ref.add_layer("PDF"), ¬ebook.pages[0], assets); + } if notebook.pages[0].strokes.iter().any(Stroke::is_image) { render_image_layer(&page1_ref.add_layer("Images"), ¬ebook.pages[0], assets); } @@ -172,11 +250,14 @@ pub fn generate_pdf_document( render_ink_layer(&layer1_ink_ref, ¬ebook.pages[0], strategy); // 2. Alle nachfolgenden Seiten hinzufügen - for page in ¬ebook.pages[1..] { + for (i, page) in notebook.pages.iter().enumerate().skip(1) { let (page_idx, layer_bg) = doc.add_page(Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background"); 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 raster_pdf_pages.contains(&i) { + render_pdf_raster(&page_ref.add_layer("PDF"), page, assets); + } if page.strokes.iter().any(Stroke::is_image) { render_image_layer(&page_ref.add_layer("Images"), page, assets); } @@ -390,17 +471,7 @@ fn pdf_image_xobject( 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, - }) + Ok(rgb_xobject(rgba.width(), rgba.height(), rgb)) } /// Rendert die eingefügten Bilder einer Seite. Fehlt eine Bilddatei, erscheint an ihrer @@ -420,37 +491,95 @@ fn render_image_layer( 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, - } + placeholder_xobject() } }; - 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(); + draw_image_xobject(layer, xobject, &corners); } } +/// Graue 1×1-Fläche für fehlende Bilder. +fn placeholder_xobject() -> printpdf::ImageXObject { + rgb_xobject(1, 1, vec![200, 200, 200]) +} + +fn rgb_xobject(width: u32, height: u32, rgb: Vec) -> printpdf::ImageXObject { + printpdf::ImageXObject { + width: printpdf::Px(width as usize), + height: printpdf::Px(height as usize), + color_space: printpdf::ColorSpace::Rgb, + bits_per_component: printpdf::ColorBits::Bit8, + interpolate: true, + image_data: rgb, + image_filter: None, + smask: None, + clipping_bbox: None, + } +} + +/// Zeichnet ein Bild so, dass es die Ecken oben links, oben rechts, unten rechts, +/// unten links ausfüllt. +fn draw_image_xobject( + layer: &printpdf::PdfLayerReference, + xobject: printpdf::ImageXObject, + corners: &[glam::Vec2; 4], +) { + 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(); +} + +/// Auflösung, mit der PDF-Seiten gerendert werden, die nicht als Vektor übernommen +/// werden können. +const PDF_RASTER_DPI: f32 = 200.0; + +/// Bettet die importierte PDF-Seite als gerendertes Bild ein (Ausweichlösung). +fn render_pdf_raster( + layer: &printpdf::PdfLayerReference, + page: &Page, + assets: Option<&AssetStore>, +) { + let Some(pdf) = &page.pdf_background else { + return; + }; + let longest_pt = pdf.width_pt.max(pdf.height_pt); + let max_pixels = (longest_pt / 72.0 * PDF_RASTER_DPI).ceil() as u32; + let rendered = assets + .ok_or_else(|| "kein Asset-Ordner".to_string()) + .and_then(|a| a.path_of(&pdf.asset).map_err(|e| e.to_string())) + .and_then(|path| crate::pdf_doc::render_file_page(&path, pdf.page as usize, max_pixels)); + let xobject = match rendered { + Ok(image) => { + let rgb = image + .pixels + .chunks_exact(4) + .flat_map(|px| [px[0], px[1], px[2]]) + .collect(); + rgb_xobject(image.width, image.height, rgb) + } + Err(err) => { + eprintln!( + "[PDF-Export] PDF-Seite {} nicht darstellbar: {err}", + pdf.page + 1 + ); + placeholder_xobject() + } + }; + draw_image_xobject(layer, xobject, &pdf.corners()); +} + /// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem) /// als verlustfreien Vektorpfad im PDF. fn render_recognized_shape( @@ -1209,6 +1338,64 @@ mod tests { assert!(export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, None).is_ok()); } + #[test] + fn test_pdf_export_underlays_imported_pages() { + let temp_dir = tempfile::tempdir().unwrap(); + let source = crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (595.0, 842.0, 90)]); + let (mut notebook, _, dir) = + crate::storage::create_notebook_from_pdf(temp_dir.path(), "Folien", source).unwrap(); + crate::storage::load_all_pages(&dir, &mut notebook).unwrap(); + let assets = AssetStore::new(&dir); + + let bytes = + export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, Some(&assets)) + .unwrap(); + let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap(); + assert_eq!(doc.get_pages().len(), 2); + for page_id in doc.get_pages().into_values() { + let content = doc.get_page_content(page_id).unwrap(); + assert!( + String::from_utf8_lossy(&content).starts_with("q "), + "Originalseite fehlt als Unterlage" + ); + } + + // Exportiertes PDF lässt sich wieder rendern: Folie oben, rotes Quadrat oben links + let pdf = hayro::hayro_syntax::Pdf::new(bytes).unwrap(); + let image = crate::pdf_doc::render_page(&pdf, 0, 600).unwrap(); + let px = |x: u32, y: u32| { + let i = ((y * image.width + x) * 4) as usize; + image.pixels[i..i + 3].to_vec() + }; + assert_eq!(px(5, 5), vec![255, 0, 0], "Quadrat der Folie oben links"); + assert_eq!( + px(200, 500), + vec![255, 255, 255], + "unter der Folie ist Platz" + ); + + // Gedrehte Seite (/Rotate 90) erscheint wie im PDF-Betrachter: das Quadrat liegt + // dort oben rechts, in der Quelle wie im Export + let source_pdf = hayro::hayro_syntax::Pdf::new( + assets + .read_bytes(¬ebook.pages[1].pdf_background.as_ref().unwrap().asset) + .unwrap(), + ) + .unwrap(); + let shown = crate::pdf_doc::render_page(&source_pdf, 1, 600).unwrap(); + let red_at = |img: &crate::assets::RgbaImage, x: u32, y: u32| { + let i = ((y * img.width + x) * 4) as usize; + img.pixels[i..i + 3] == [255, 0, 0] + }; + assert!(red_at(&shown, shown.width - 5, 5)); + let exported = crate::pdf_doc::render_page(&pdf, 1, 600).unwrap(); + assert!(red_at(&exported, exported.width - 5, 5)); + assert!(!red_at(&exported, 5, 5)); + + // Ohne Zugriff auf das PDF: Export klappt trotzdem (graue Ersatzfläche) + 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); diff --git a/src/file_picker.rs b/src/file_picker.rs index c1c3bc3..a751796 100644 --- a/src/file_picker.rs +++ b/src/file_picker.rs @@ -1,7 +1,7 @@ -//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder. +//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder und PDFs. //! //! 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 +//! schlanken Systemen oft fehlen. Die Auswahl zeigt Ordner und die passenden Dateien des //! aktuellen Ordners; versteckte Dateien bleiben ausgeblendet. use std::path::{Path, PathBuf}; @@ -16,9 +16,36 @@ pub struct PickerEntry { pub is_dir: bool, } +/// Welche Dateien die Auswahl anbietet. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PickerKind { + /// Bilder zum Einfügen auf der aktuellen Seite + Image, + /// PDF zum Importieren als neues Notizbuch + Pdf, +} + +impl PickerKind { + /// Passt die Datei zu dieser Auswahl? + pub fn accepts(self, path: &Path) -> bool { + match self { + PickerKind::Image => is_supported_image_path(path), + PickerKind::Pdf => is_pdf_path(path), + } + } +} + +/// Ist die Datei dem Namen nach ein PDF? +pub fn is_pdf_path(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("pdf")) +} + /// Zustand der Dateiauswahl. #[derive(Debug, Clone, PartialEq)] pub struct FilePicker { + pub kind: PickerKind, pub dir: PathBuf, pub entries: Vec, /// Ordner nicht lesbar @@ -26,14 +53,26 @@ pub struct FilePicker { } impl FilePicker { - /// Öffnet die Auswahl in `dir`, sonst im Bilder-Ordner, sonst im Home-Ordner. - pub fn open(dir: Option<&Path>) -> Self { + /// Öffnet die Auswahl in `dir`, sonst im Bilder-Ordner (Bilder) bzw. Downloads (PDF), + /// sonst im Home-Ordner. + pub fn open(kind: PickerKind, 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())) + .or_else(|| { + let preferred = match kind { + PickerKind::Image => "🖼 Bilder", + PickerKind::Pdf => "⬇ Downloads", + }; + let all = shortcuts(); + all.iter() + .find(|(label, _)| *label == preferred) + .or_else(|| all.last()) + .map(|(_, p)| p.clone()) + }) .unwrap_or_else(|| PathBuf::from("/")); let mut picker = Self { + kind, dir: start.clone(), entries: Vec::new(), error: None, @@ -44,7 +83,7 @@ impl FilePicker { /// Wechselt in `dir` und liest den Inhalt neu ein. pub fn navigate(&mut self, dir: &Path) { - match list_dir(dir) { + match list_dir(dir, self.kind) { Ok(entries) => { self.dir = dir.to_path_buf(); self.entries = entries; @@ -67,9 +106,9 @@ impl FilePicker { } } -/// Ordner und unterstützte Bilder in `dir`: erst Ordner, dann Dateien, jeweils alphabetisch +/// Ordner und passende Dateien 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> { +pub fn list_dir(dir: &Path, kind: PickerKind) -> std::io::Result> { let mut entries: Vec = std::fs::read_dir(dir)? .filter_map(Result::ok) .filter_map(|entry| { @@ -80,7 +119,7 @@ pub fn list_dir(dir: &Path) -> std::io::Result> { 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 }) + (is_dir || kind.accepts(&path)).then_some(PickerEntry { name, path, is_dir }) }) .collect(); entries.sort_by(|a, b| { @@ -143,19 +182,32 @@ mod tests { #[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"] { + for name in [ + "b.png", + "A.jpg", + "notiz.txt", + ".versteckt.png", + "c.webp", + "Skript.PDF", + ] { 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"]); + let names = |kind| -> Vec { + list_dir(dir.path(), kind) + .unwrap() + .into_iter() + .map(|e| e.name) + .collect() + }; + assert_eq!( + names(PickerKind::Image), + ["aOrdner", "zOrdner", "A.jpg", "b.png", "c.webp"] + ); + assert_eq!(names(PickerKind::Pdf), ["aOrdner", "zOrdner", "Skript.PDF"]); } #[test] @@ -165,7 +217,7 @@ mod tests { std::fs::create_dir(&sub).unwrap(); std::fs::write(sub.join("tafel.jpg"), b"x").unwrap(); - let mut picker = FilePicker::open(Some(&sub)); + let mut picker = FilePicker::open(PickerKind::Image, Some(&sub)); assert_eq!(picker.dir, sub); assert_eq!(picker.entries.len(), 1); diff --git a/src/image_layer.rs b/src/image_layer.rs index 0c38415..5a86c9f 100644 --- a/src/image_layer.rs +++ b/src/image_layer.rs @@ -1,13 +1,13 @@ //! 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. +//! Texturen liefert beim ersten Bedarf eine Texturquelle (Bilddatei oder gerenderte +//! PDF-Seite); sie werden mit Mipmaps versehen, damit verkleinerte Bilder beim +//! Herauszoomen nicht flimmern. use std::collections::{HashMap, HashSet}; -use crate::assets::{AssetStore, RgbaImage, MAX_DISPLAY_DIMENSION}; +use crate::assets::RgbaImage; /// Vertex eines Bildvierecks: Seitenkoordinate und Texturkoordinate. #[repr(C)] @@ -67,19 +67,29 @@ pub fn mip_chain(base: &RgbaImage) -> Vec { /// 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; +/// Bis zu dieser Grafikspeichermenge bleiben Texturen anderer Seiten erhalten (schnelles +/// Zurückblättern); darüber werden alle nicht mehr sichtbaren freigegeben. +const TEXTURE_CACHE_BYTES: u64 = 384 * 1024 * 1024; + +/// Antwort einer Texturquelle für einen Schlüssel. +pub enum TextureSource { + Ready(RgbaImage), + /// Wird gerade erzeugt; später erneut fragen + Pending, + Failed(String), +} pub struct ImageLayer { pipeline: wgpu::RenderPipeline, texture_layout: wgpu::BindGroupLayout, sampler: wgpu::Sampler, - textures: HashMap, + /// Texturen nach Schlüssel (Asset-Name bzw. `#`) mit belegtem Speicher + textures: HashMap, + texture_bytes: u64, /// Bilder der aktuellen Seite: Asset-Name und Ecken quads: Vec<(String, [glam::Vec2; 4])>, vertex_buffer: Option, - needs_upload: bool, + vertices_dirty: bool, } impl ImageLayer { @@ -155,9 +165,10 @@ impl ImageLayer { texture_layout, sampler, textures: HashMap::new(), + texture_bytes: 0, quads: Vec::new(), vertex_buffer: None, - needs_upload: false, + vertices_dirty: false, } } @@ -165,45 +176,56 @@ impl ImageLayer { pub fn set_quads(&mut self, quads: Vec<(String, [glam::Vec2; 4])>) { if quads != self.quads { self.quads = quads; - self.needs_upload = true; + self.vertices_dirty = 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; - + pub fn prepare( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + load: &mut dyn FnMut(&str) -> TextureSource, + ) { let missing: Vec = self .quads .iter() - .map(|(asset, _)| asset) - .filter(|asset| !self.textures.contains_key(*asset)) + .map(|(key, _)| key) + .filter(|key| !self.textures.contains_key(*key)) .cloned() .collect(); - for asset in missing { - if self.textures.contains_key(&asset) { + for key in missing { + if self.textures.contains_key(&key) { 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) + let (bind_group, bytes) = match load(&key) { + TextureSource::Ready(image) => ( + self.upload_texture(device, queue, &key, &image), + image.pixels.len() as u64 * 4 / 3, + ), + // Noch in Arbeit (PDF-Seite wird gerendert): beim nächsten Bild erneut fragen + TextureSource::Pending => continue, + TextureSource::Failed(err) => { + eprintln!("[Bilder] '{key}' kann nicht angezeigt werden: {err}"); + (self.missing_texture(device, queue), 4) } }; - self.textures.insert(asset, bind_group); + self.texture_bytes += bytes; + self.textures.insert(key, (bind_group, bytes)); } - // Nicht mehr benötigte Texturen freigeben, wenn der Speicher voll wird - if self.textures.len() > TEXTURE_CACHE_LIMIT { + // Nicht mehr benötigte Texturen freigeben, wenn der Grafikspeicher knapp wird + if self.texture_bytes > TEXTURE_CACHE_BYTES { let used: HashSet<&String> = self.quads.iter().map(|(a, _)| a).collect(); self.textures.retain(|name, _| used.contains(name)); + self.texture_bytes = self.textures.values().map(|(_, b)| b).sum(); } + if !self.vertices_dirty { + return; + } + self.vertices_dirty = false; let vertices: Vec = self .quads .iter() @@ -230,18 +252,25 @@ impl ImageLayer { } } + /// Fehlen noch Texturen der aktuellen Seite (z. B. PDF-Seite im Hintergrund-Rendering)? + pub fn is_waiting(&self) -> bool { + self.quads + .iter() + .any(|(key, _)| !self.textures.contains_key(key)) + } + /// 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 { + if self.quads.is_empty() || self.vertices_dirty { 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 { + let Some((bind_group, _)) = self.textures.get(asset) else { continue; }; render_pass.set_bind_group(1, bind_group, &[]); diff --git a/src/lib.rs b/src/lib.rs index 2d5a89a..0ee3fe7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,8 @@ pub mod filter; pub mod geometry; pub mod history; pub mod image_layer; +pub mod pdf_doc; +pub mod pdf_merge; pub mod platform; pub mod selection; pub mod settings; diff --git a/src/main.rs b/src/main.rs index c92eb8d..c3fe9a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,8 @@ use ink_notes::canvas::CanvasState; use ink_notes::document::{Notebook, PageBackground}; use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy}; use ink_notes::geometry::{self, RecognizedShape}; +use ink_notes::image_layer::TextureSource; +use ink_notes::pdf_doc::PdfRenderService; use ink_notes::platform::{self, DisplayBackend}; use ink_notes::selection::{ render_selection_overlay, DragMode, SelectionHandleHit, SelectionState, @@ -236,6 +238,33 @@ fn insert_image_on_page( )); } +/// Textur für einen Schlüssel der Bildebene: `#` rendert der Hintergrund-Thread, +/// alles andere ist eine Bilddatei im Asset-Ordner. +fn texture_for( + key: &str, + assets: &AssetStore, + pdf_renderer: &mut PdfRenderService, +) -> TextureSource { + let result = match key.split_once('#') { + Some((asset, page)) => { + let (Ok(path), Ok(page)) = (assets.path_of(asset), page.parse::()) else { + return TextureSource::Failed(format!("Ungültiger PDF-Verweis '{key}'")); + }; + match pdf_renderer.take(key, path, page) { + Some(result) => result, + None => return TextureSource::Pending, + } + } + None => assets + .load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION) + .map_err(|e| e.to_string()), + }; + match result { + Ok(image) => TextureSource::Ready(image), + Err(err) => TextureSource::Failed(err), + } +} + /// Seitenmitte des sichtbaren Bereichs (Ziel für eingefügte Bilder). fn visible_page_center(canvas: &CanvasState) -> Vec2 { canvas.screen_to_page_clamped( @@ -499,6 +528,11 @@ fn main() -> Result<(), Box> { } let mut selection_state = SelectionState::new(); let mut clipboard = ClipboardBridge::new(); + // PDF-Seiten werden im Hintergrund gerendert und wecken danach den Event-Loop + let mut pdf_renderer = { + let window = window.clone(); + PdfRenderService::new(move || window.request_redraw()) + }; let mut _notebook_lock = match storage::NotebookLock::acquire(&storage_dir) { Ok(lock) => { @@ -972,7 +1006,9 @@ 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) { + if ink_notes::file_picker::is_pdf_path(&path) { + ui_state.queued_actions.push(UiAction::ImportPdf(path)); + } else 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( @@ -981,7 +1017,7 @@ fn main() -> Result<(), Box> { ); 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()); + ui_state.set_export_message("Nur Bilder (PNG, JPEG, GIF, WebP, BMP) und PDFs können eingefügt werden".to_string()); } window.request_redraw(); } @@ -2426,6 +2462,43 @@ fn main() -> Result<(), Box> { } } } + UiAction::ImportPdf(path) => { + let title = path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "PDF".to_string()); + let result = std::fs::read(&path) + .map_err(|e| e.to_string()) + .and_then(|bytes| storage::create_notebook_from_pdf(&storage_base_dir, &title, bytes)); + match result { + Ok((new_nb, new_manifest, new_dir)) => { + let _ = autosave.flush(); + _notebook_lock = storage::NotebookLock::acquire(&new_dir).ok(); + autosave.set_read_only(_notebook_lock.is_none()); + autosave.switch_notebook_dir(new_dir.clone()); + notebook = new_nb; + manifest = new_manifest; + storage_dir = new_dir; + selection_state.clear(); + ui_state.current_notebook_id = manifest.id.clone(); + ui_state.document_title = manifest.title.clone(); + ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + canvas.reset_view(); + canvas.sync_page(notebook.current_page_mut()); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + ui_state.set_export_message(format!( + "'{}' importiert ({} Seiten)", + manifest.title, + notebook.pages.len() + )); + } + Err(err) => { + eprintln!("[PDF-Import] '{}': {err}", path.display()); + ui_state.set_export_message(format!("PDF nicht importiert: {err}")); + } + } + window.request_redraw(); + } UiAction::CreateNotebook(title) => { let _ = autosave.flush(); match storage::create_new_notebook(&storage_base_dir, &title) { @@ -2708,7 +2781,17 @@ fn main() -> Result<(), Box> { } // 5. Canvas (Pass 1) und egui UI (Pass 2) in Surface-Textur rendern - canvas.prepare_images(&AssetStore::new(&storage_dir)); + let assets = AssetStore::new(&storage_dir); + canvas.prepare_images(&mut |key| texture_for(key, &assets, &mut pdf_renderer)); + // Nächste PDF-Seite vorab rendern, damit Blättern sofort klappt + if !canvas.is_waiting_for_images() { + let next = notebook.pages.get(notebook.current_page_index + 1); + if let Some(pdf) = next.and_then(|p| p.pdf_background.as_ref()) { + if let Ok(path) = assets.path_of(&pdf.asset) { + pdf_renderer.prefetch(&pdf.key(), path, pdf.page as usize); + } + } + } match canvas.render(&clipped_primitives, &screen_descriptor, &full_output.textures_delta) { Ok(_) => {} Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { diff --git a/src/pdf_doc.rs b/src/pdf_doc.rs new file mode 100644 index 0000000..9e1cd1a --- /dev/null +++ b/src/pdf_doc.rs @@ -0,0 +1,291 @@ +//! Lesen und Rendern importierter PDF-Dateien mit `hayro` (reines Rust, nur CPU). +//! +//! Das Rendern einer Seite kann einige hundert Millisekunden dauern; [`PdfRenderService`] +//! erledigt es deshalb in einem Hintergrund-Thread und meldet fertige Seiten zurück. + +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::mpsc::{self, Receiver, Sender}; + +use hayro::hayro_interpret::InterpreterSettings; +use hayro::hayro_syntax::{LoadPdfError, Pdf}; +use hayro::vello_cpu::color::palette::css::WHITE; +use hayro::{PixmapSettings, RenderCache, RenderSettings}; + +use crate::assets::RgbaImage; + +/// Längste Kante einer gerenderten Seite für die Anzeige in Pixeln (A4 ≈ 220 dpi). +pub const DISPLAY_RENDER_PIXELS: u32 = 2560; +/// Größte Seitenzahl, die importiert wird. +pub const MAX_IMPORT_PAGES: usize = 2000; + +/// Öffnet ein PDF und liefert die angezeigte Größe jeder Seite in Punkt (Drehung +/// berücksichtigt). +pub fn page_sizes(bytes: Vec) -> Result, String> { + let pdf = open(bytes)?; + let sizes: Vec<(f32, f32)> = pdf.pages().iter().map(|p| p.render_dimensions()).collect(); + if sizes.is_empty() { + return Err("Das PDF enthält keine Seiten".into()); + } + if sizes.len() > MAX_IMPORT_PAGES { + return Err(format!( + "Das PDF hat {} Seiten, höchstens {MAX_IMPORT_PAGES} werden unterstützt", + sizes.len() + )); + } + Ok(sizes) +} + +fn open(bytes: Vec) -> Result { + Pdf::new(bytes).map_err(|e| match e { + LoadPdfError::Decryption(_) => { + "Das PDF ist verschlüsselt oder passwortgeschützt und kann nicht geöffnet werden" + .to_string() + } + LoadPdfError::Invalid => "Die Datei ist kein lesbares PDF".to_string(), + }) +} + +/// Rendert Seite `index` so, dass die längste Kante höchstens `max_pixels` lang ist, +/// auf weißem Grund. +pub fn render_page(pdf: &Pdf, index: usize, max_pixels: u32) -> Result { + let page = pdf + .pages() + .get(index) + .ok_or_else(|| format!("Seite {} fehlt im PDF", index + 1))?; + let (w, h) = page.render_dimensions(); + let scale = (max_pixels as f32 / w.max(h).max(1.0)).min(16.0); + let pixmap = hayro::render( + page, + &RenderCache::new(), + &InterpreterSettings::default(), + &RenderSettings::default(), + &PixmapSettings { + x_scale: scale, + y_scale: scale, + bg_color: WHITE, + }, + ); + // Auf weißem Grund ist jedes Pixel deckend: vormultipliziert = gerade Farbe + Ok(RgbaImage { + width: pixmap.width() as u32, + height: pixmap.height() as u32, + pixels: pixmap.data_as_u8_slice().to_vec(), + }) +} + +/// Öffnet die Datei und rendert eine Seite (für den PDF-Export als Ersatzbild). +pub fn render_file_page( + path: &std::path::Path, + index: usize, + max_pixels: u32, +) -> Result { + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + render_page(&open(bytes)?, index, max_pixels) +} + +struct RenderRequest { + key: String, + path: PathBuf, + page: usize, +} + +/// Ergebnis eines Render-Auftrags. +pub type RenderResult = Result; + +/// Rendert PDF-Seiten im Hintergrund. Fertige Seiten holt der Event-Loop mit +/// [`PdfRenderService::take`] ab; `on_ready` weckt ihn dafür auf. +pub struct PdfRenderService { + requests: Sender, + results: Receiver<(String, RenderResult)>, + pending: HashSet, + ready: HashMap, +} + +impl PdfRenderService { + pub fn new(on_ready: impl Fn() + Send + 'static) -> Self { + let (req_tx, req_rx) = mpsc::channel::(); + let (res_tx, res_rx) = mpsc::channel(); + std::thread::Builder::new() + .name("pdf-render".into()) + .spawn(move || { + // Zuletzt geöffnetes PDF behalten: Blättern rendert meist dieselbe Datei + let mut open_pdf: Option<(PathBuf, Pdf)> = None; + while let Ok(request) = req_rx.recv() { + if open_pdf.as_ref().map(|(p, _)| p) != Some(&request.path) { + open_pdf = std::fs::read(&request.path) + .map_err(|e| e.to_string()) + .and_then(open) + .map_err(|e| eprintln!("[PDF] '{}': {e}", request.path.display())) + .ok() + .map(|pdf| (request.path.clone(), pdf)); + } + let result = match &open_pdf { + Some((_, pdf)) => render_page(pdf, request.page, DISPLAY_RENDER_PIXELS), + None => Err("PDF kann nicht geöffnet werden".into()), + }; + if res_tx.send((request.key, result)).is_err() { + break; + } + on_ready(); + } + }) + .expect("PDF-Render-Thread konnte nicht gestartet werden"); + Self { + requests: req_tx, + results: res_rx, + pending: HashSet::new(), + ready: HashMap::new(), + } + } + + /// Rendert eine Seite vorab (z. B. die nächste), ohne auf das Ergebnis zu warten; + /// [`Self::take`] liefert es später sofort. + pub fn prefetch(&mut self, key: &str, path: PathBuf, page: usize) { + self.collect(); + if self.ready.contains_key(key) || self.pending.contains(key) { + return; + } + // Vorab gerenderte Seiten nicht unbegrenzt aufheben + if self.ready.len() >= 2 { + self.ready.clear(); + } + self.request(key, path, page); + } + + fn collect(&mut self) { + while let Ok((done, result)) = self.results.try_recv() { + self.pending.remove(&done); + self.ready.insert(done, result); + } + } + + /// Erteilt den Auftrag, falls noch keiner läuft. `false`, wenn der Thread beendet ist. + fn request(&mut self, key: &str, path: PathBuf, page: usize) -> bool { + if !self.pending.insert(key.to_string()) { + return true; + } + let request = RenderRequest { + key: key.to_string(), + path, + page, + }; + if self.requests.send(request).is_err() { + self.pending.remove(key); + return false; + } + true + } + + /// Fertiges Ergebnis für `key` abholen; sonst den Auftrag (einmalig) erteilen und `None`. + pub fn take(&mut self, key: &str, path: PathBuf, page: usize) -> Option { + self.collect(); + if let Some(result) = self.ready.remove(key) { + return Some(result); + } + if !self.request(key, path, page) { + return Some(Err("PDF-Render-Thread beendet".into())); + } + None + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + /// Minimales gültiges PDF mit Seiten der angegebenen Größen (Punkt); jede Seite zeigt ein + /// rotes Rechteck in der linken oberen Ecke. Optional mit `/Rotate`. + pub(crate) fn sample_pdf(pages: &[(f32, f32, i32)]) -> Vec { + let mut objects: Vec = Vec::new(); + let n = pages.len(); + // 1: Katalog, 2: Seitenbaum, danach je Seite: Seite + Inhalt + objects.push("<< /Type /Catalog /Pages 2 0 R >>".into()); + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 3 + 2 * i)).collect(); + objects.push(format!( + "<< /Type /Pages /Kids [{}] /Count {n} >>", + kids.join(" ") + )); + for (i, (w, h, rotate)) in pages.iter().enumerate() { + let content_id = 4 + 2 * i; + objects.push(format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Rotate {rotate} /Contents {content_id} 0 R /Resources << >> >>" + )); + let content = format!("1 0 0 rg 0 {} 40 40 re f", h - 40.0); + objects.push(format!( + "<< /Length {} >>\nstream\n{content}\nendstream", + content.len() + )); + } + let mut out = b"%PDF-1.4\n".to_vec(); + let mut offsets = Vec::new(); + for (i, obj) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{obj}\nendobj\n", i + 1).as_bytes()); + } + let xref_pos = out.len(); + out.extend_from_slice( + format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(), + ); + for off in offsets { + out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice( + format!( + "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n", + objects.len() + 1 + ) + .as_bytes(), + ); + out + } + + #[test] + fn test_page_sizes_and_errors() { + let pdf = sample_pdf(&[(595.0, 842.0, 0), (842.0, 595.0, 0), (595.0, 842.0, 90)]); + let sizes = page_sizes(pdf).unwrap(); + assert_eq!(sizes, vec![(595.0, 842.0), (842.0, 595.0), (842.0, 595.0)]); + assert!(page_sizes(b"kein pdf".to_vec()).is_err()); + } + + #[test] + fn test_render_page_draws_content() { + let pdf = open(sample_pdf(&[(200.0, 100.0, 0)])).unwrap(); + let image = render_page(&pdf, 0, 400).unwrap(); + assert_eq!((image.width, image.height), (400, 200)); + let px = |x: u32, y: u32| { + let i = ((y * image.width + x) * 4) as usize; + &image.pixels[i..i + 4] + }; + // Rotes Quadrat oben links, sonst weiß + assert_eq!(px(20, 20), &[255, 0, 0, 255]); + assert_eq!(px(300, 150), &[255, 255, 255, 255]); + assert!(render_page(&pdf, 5, 400).is_err()); + } + + #[test] + fn test_render_service_delivers_in_background() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("a.pdf"); + std::fs::write(&path, sample_pdf(&[(400.0, 300.0, 0)])).unwrap(); + let (wake_tx, wake_rx) = mpsc::channel(); + let mut service = PdfRenderService::new(move || { + let _ = wake_tx.send(()); + }); + assert!(service.take("a#0", path.clone(), 0).is_none()); + // Zweite Anfrage erteilt keinen weiteren Auftrag + assert!(service.take("a#0", path.clone(), 0).is_none()); + wake_rx + .recv_timeout(std::time::Duration::from_secs(20)) + .unwrap(); + let image = service.take("a#0", path.clone(), 0).unwrap().unwrap(); + assert_eq!(image.width, DISPLAY_RENDER_PIXELS); + + // Vorab rendern: danach sofort verfügbar + service.prefetch("a#0", path.clone(), 0); + wake_rx + .recv_timeout(std::time::Duration::from_secs(20)) + .unwrap(); + assert!(service.take("a#0", path, 0).unwrap().is_ok()); + } +} diff --git a/src/pdf_merge.rs b/src/pdf_merge.rs new file mode 100644 index 0000000..eaf3e1a --- /dev/null +++ b/src/pdf_merge.rs @@ -0,0 +1,383 @@ +//! Legt beim PDF-Export die Originalseiten importierter PDFs als Vektorgrafik unter die +//! Notizen (Text bleibt scharf und auswählbar). +//! +//! Vorgehen: Das von printpdf erzeugte PDF wird mit `lopdf` geöffnet. Jede benötigte Seite +//! des Quell-PDFs wird als Form-XObject (Inhalt + Ressourcen der Seite) übernommen und am +//! Anfang der Zielseite gezeichnet, also unter Hintergrund, Bildern und Tinte. + +use std::collections::{BTreeMap, HashMap}; + +use printpdf::lopdf::{Dictionary, Document, Object, ObjectId, Stream}; + +/// Eine Zielseite, unter die eine Seite eines Quell-PDFs gelegt wird. +#[derive(Debug, Clone, PartialEq)] +pub struct Underlay { + /// Seitennummer im Ziel-PDF, ab 0 + pub target_page: usize, + /// Schlüssel des Quell-PDFs in `sources` + pub source: String, + /// Seitennummer im Quell-PDF, ab 0 + pub source_page: usize, + /// Zielrechteck in PDF-Punkten (links, unten, Breite, Höhe; Ursprung unten links) + pub rect: [f32; 4], +} + +/// Nicht übernommene Unterlagen samt Grund. +pub type FailedUnderlays = Vec<(Underlay, String)>; + +/// Affine PDF-Matrix `[a b c d e f]`: x' = a·x + c·y + e, y' = b·x + d·y + f. +pub type Matrix = [f32; 6]; + +/// `outer ∘ inner`: erst `inner`, dann `outer` anwenden. +pub fn compose(outer: Matrix, inner: Matrix) -> Matrix { + let [a1, b1, c1, d1, e1, f1] = outer; + let [a2, b2, c2, d2, e2, f2] = inner; + [ + a1 * a2 + c1 * b2, + b1 * a2 + d1 * b2, + a1 * c2 + c1 * d2, + b1 * c2 + d1 * d2, + a1 * e2 + c1 * f2 + e1, + b1 * e2 + d1 * f2 + f1, + ] +} + +/// Matrix, die den Seitenbereich `bbox` (links, unten, rechts, oben) einer um `rotate` Grad +/// im Uhrzeigersinn gedrehten Seite so abbildet, wie ein Betrachter sie zeigt, eingepasst +/// in `rect` (links, unten, Breite, Höhe). +pub fn placement_matrix(bbox: [f32; 4], rotate: i64, rect: [f32; 4]) -> Matrix { + let (w, h) = ((bbox[2] - bbox[0]).max(1e-3), (bbox[3] - bbox[1]).max(1e-3)); + let to_origin = [1.0, 0.0, 0.0, 1.0, -bbox[0], -bbox[1]]; + let (rotation, shown_w, shown_h): (Matrix, f32, f32) = match rotate.rem_euclid(360) { + 90 => ([0.0, -1.0, 1.0, 0.0, 0.0, w], h, w), + 180 => ([-1.0, 0.0, 0.0, -1.0, w, h], w, h), + 270 => ([0.0, 1.0, -1.0, 0.0, h, 0.0], h, w), + _ => ([1.0, 0.0, 0.0, 1.0, 0.0, 0.0], w, h), + }; + let scale = (rect[2] / shown_w).min(rect[3] / shown_h); + let place = [scale, 0.0, 0.0, scale, rect[0], rect[1]]; + compose(place, compose(rotation, to_origin)) +} + +/// Erbt ein Seitenattribut über die `Parent`-Kette. +fn inherited<'a>(doc: &'a Document, page: &'a Dictionary, key: &[u8]) -> Option<&'a Object> { + let mut node = page; + for _ in 0..64 { + if let Ok(value) = node.get(key) { + return Some(value); + } + let parent = node.get(b"Parent").and_then(Object::as_reference).ok()?; + node = doc.get_dictionary(parent).ok()?; + } + None +} + +fn resolve<'a>(doc: &'a Document, object: &'a Object) -> &'a Object { + doc.dereference(object).map(|(_, o)| o).unwrap_or(object) +} + +fn read_box(doc: &Document, object: Option<&Object>) -> Option<[f32; 4]> { + let array = resolve(doc, object?).as_array().ok()?; + let values: Vec = array + .iter() + .filter_map(|v| resolve(doc, v).as_float().ok()) + .collect(); + let [x0, y0, x1, y1] = <[f32; 4]>::try_from(values).ok()?; + Some([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)]) +} + +/// Baut aus einer Seite des (bereits umnummerierten) Quelldokuments ein Form-XObject. +/// Liefert das XObject und die Matrix für die Platzierung in `rect`. +fn page_as_form( + doc: &Document, + page_id: ObjectId, + rect: [f32; 4], +) -> Result<(Stream, Matrix), String> { + let page = doc.get_dictionary(page_id).map_err(|e| e.to_string())?; + let media = + read_box(doc, inherited(doc, page, b"MediaBox")).unwrap_or([0.0, 0.0, 595.0, 842.0]); + let bbox = match read_box(doc, inherited(doc, page, b"CropBox")) { + // Sichtbarer Bereich: Schnitt aus Crop- und Media-Box + Some(c) => [ + c[0].max(media[0]), + c[1].max(media[1]), + c[2].min(media[2]), + c[3].min(media[3]), + ], + None => media, + }; + let rotate = inherited(doc, page, b"Rotate") + .and_then(|r| resolve(doc, r).as_i64().ok()) + .unwrap_or(0); + let resources = inherited(doc, page, b"Resources") + .cloned() + .unwrap_or_else(|| Object::Dictionary(Dictionary::new())); + + // Ein einzelner Inhaltsstrom wird unverändert (samt Kompression) übernommen, + // mehrere werden entpackt und aneinandergehängt + let streams = doc.get_page_contents(page_id); + let (content, filter) = match streams.as_slice() { + [single] => { + let stream = doc + .get_object(*single) + .and_then(Object::as_stream) + .map_err(|e| e.to_string())?; + let filter = ["Filter", "DecodeParms"] + .into_iter() + .filter_map(|k| Some((k, stream.dict.get(k.as_bytes()).ok()?.clone()))) + .collect::>(); + (stream.content.clone(), filter) + } + _ => { + let mut content = Vec::new(); + for id in streams { + let stream = doc + .get_object(id) + .and_then(Object::as_stream) + .map_err(|e| e.to_string())?; + let data = stream + .decompressed_content() + .map_err(|e| format!("Inhalt nicht lesbar: {e}"))?; + content.extend_from_slice(&data); + content.push(b'\n'); + } + (content, Vec::new()) + } + }; + + let mut dict = Dictionary::new(); + dict.set("Type", Object::Name(b"XObject".to_vec())); + dict.set("Subtype", Object::Name(b"Form".to_vec())); + dict.set( + "BBox", + Object::Array(bbox.iter().map(|&v| Object::Real(v)).collect()), + ); + dict.set("Resources", resources); + for (key, value) in filter { + dict.set(key, value); + } + Ok(( + Stream::new(dict, content), + placement_matrix(bbox, rotate, rect), + )) +} + +/// Legt die Quellseiten unter die Zielseiten. `sources` enthält die Quell-PDFs als Bytes. +/// Gibt die neuen PDF-Bytes zurück und die Underlays, die nicht übernommen werden konnten +/// (z. B. verschlüsselte PDFs), samt Grund. +pub fn apply_underlays( + target_bytes: &[u8], + underlays: &[Underlay], + sources: &HashMap>, +) -> Result<(Vec, FailedUnderlays), String> { + let mut target = Document::load_mem(target_bytes).map_err(|e| e.to_string())?; + let target_pages: Vec = target.get_pages().into_values().collect(); + let mut failed = Vec::new(); + + // Quell-PDFs einmal laden und mit freien Objektnummern in das Ziel übernehmen + let mut loaded: HashMap<&str, Result, String>> = HashMap::new(); + for underlay in underlays { + if loaded.contains_key(underlay.source.as_str()) { + continue; + } + let result = (|| { + let bytes = sources + .get(&underlay.source) + .ok_or_else(|| "Quell-PDF fehlt".to_string())?; + let mut source = Document::load_mem(bytes).map_err(|e| e.to_string())?; + if source.is_encrypted() { + return Err("Quell-PDF ist verschlüsselt".to_string()); + } + source.renumber_objects_with(target.max_id + 1); + let pages = source.get_pages(); + target.max_id = target.max_id.max(source.max_id); + // Katalog und Seitenbaum der Quelle werden nicht referenziert und beim Speichern + // mit `prune_objects` entfernt + target.objects.extend(source.objects); + Ok(pages) + })(); + loaded.insert(&underlay.source, result); + } + + for underlay in underlays { + let result = (|| { + let pages = loaded[underlay.source.as_str()] + .as_ref() + .map_err(Clone::clone)?; + let source_page = *pages + .get(&(underlay.source_page as u32 + 1)) + .ok_or_else(|| format!("Seite {} fehlt im Quell-PDF", underlay.source_page + 1))?; + let target_page = *target_pages + .get(underlay.target_page) + .ok_or_else(|| "Zielseite fehlt".to_string())?; + let (form, matrix) = page_as_form(&target, source_page, underlay.rect)?; + let form_id = target.add_object(Object::Stream(form)); + let name = format!("InkPdf{}", form_id.0); + add_xobject(&mut target, target_page, &name, form_id)?; + let ops = format!( + "q {} {} {} {} {} {} cm /{name} Do Q\n", + matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5] + ); + prepend_content(&mut target, target_page, ops.into_bytes()) + })(); + if let Err(err) = result { + failed.push((underlay.clone(), err)); + } + } + + target.prune_objects(); + // Neue, noch unkomprimierte Ströme packen (bereits gefilterte bleiben unverändert) + target.compress(); + let mut out = Vec::new(); + target.save_to(&mut out).map_err(|e| e.to_string())?; + Ok((out, failed)) +} + +/// Trägt ein XObject in die Ressourcen der Seite ein. +fn add_xobject( + doc: &mut Document, + page_id: ObjectId, + name: &str, + id: ObjectId, +) -> Result<(), String> { + // Ressourcen der Seite als eigenes Wörterbuch an der Seite (geerbte werden übernommen) + let resources = { + let page = doc.get_dictionary(page_id).map_err(|e| e.to_string())?; + match inherited(doc, page, b"Resources").map(|r| resolve(doc, r)) { + Some(Object::Dictionary(d)) => d.clone(), + _ => Dictionary::new(), + } + }; + let mut xobjects = match resources.get(b"XObject").map(|x| resolve(doc, x)) { + Ok(Object::Dictionary(d)) => d.clone(), + _ => Dictionary::new(), + }; + xobjects.set(name, Object::Reference(id)); + let mut resources = resources; + resources.set("XObject", Object::Dictionary(xobjects)); + let page = doc + .get_object_mut(page_id) + .and_then(Object::as_dict_mut) + .map_err(|e| e.to_string())?; + page.set("Resources", Object::Dictionary(resources)); + Ok(()) +} + +/// Stellt einen Inhaltsstrom an den Anfang der Seite (zeichnet also zuunterst). +fn prepend_content(doc: &mut Document, page_id: ObjectId, content: Vec) -> Result<(), String> { + let existing: Vec = doc + .get_page_contents(page_id) + .into_iter() + .map(Object::Reference) + .collect(); + let id = doc.add_object(Object::Stream(Stream::new(Dictionary::new(), content))); + let mut contents = vec![Object::Reference(id)]; + contents.extend(existing); + let page = doc + .get_object_mut(page_id) + .and_then(Object::as_dict_mut) + .map_err(|e| e.to_string())?; + page.set("Contents", Object::Array(contents)); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn apply(m: Matrix, x: f32, y: f32) -> (f32, f32) { + (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]) + } + + fn close(a: (f32, f32), b: (f32, f32)) -> bool { + (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3 + } + + #[test] + fn test_placement_matrix() { + // Querformat-Folie 800 × 600 ohne Drehung in ein 400 × 300-Rechteck bei (10, 20) + let m = placement_matrix([0.0, 0.0, 800.0, 600.0], 0, [10.0, 20.0, 400.0, 300.0]); + assert!(close(apply(m, 0.0, 0.0), (10.0, 20.0))); + assert!(close(apply(m, 800.0, 600.0), (410.0, 320.0))); + + // Verschobene MediaBox + let m = placement_matrix([100.0, 50.0, 300.0, 150.0], 0, [0.0, 0.0, 200.0, 100.0]); + assert!(close(apply(m, 100.0, 50.0), (0.0, 0.0))); + + // Um 90° gedrehte Hochformatseite (200 × 400) erscheint quer (400 × 200): + // die linke untere Ecke landet links oben + let m = placement_matrix([0.0, 0.0, 200.0, 400.0], 90, [0.0, 0.0, 400.0, 200.0]); + assert!(close(apply(m, 0.0, 0.0), (0.0, 200.0))); + assert!(close(apply(m, 200.0, 0.0), (0.0, 0.0))); + assert!(close(apply(m, 0.0, 400.0), (400.0, 200.0))); + + // 180° und 270° (= -90°) + let m = placement_matrix([0.0, 0.0, 200.0, 400.0], 180, [0.0, 0.0, 200.0, 400.0]); + assert!(close(apply(m, 0.0, 0.0), (200.0, 400.0))); + let m = placement_matrix([0.0, 0.0, 200.0, 400.0], -90, [0.0, 0.0, 400.0, 200.0]); + assert!(close(apply(m, 0.0, 0.0), (400.0, 0.0))); + } + + #[test] + fn test_apply_underlays_embeds_source_pages() { + let source = crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (595.0, 842.0, 0)]); + let target = crate::pdf_doc::tests::sample_pdf(&[(595.0, 842.0, 0), (595.0, 842.0, 0)]); + let sources = HashMap::from([("folien".to_string(), source)]); + let underlays = vec![ + Underlay { + target_page: 0, + source: "folien".into(), + source_page: 0, + rect: [0.0, 421.0, 595.0, 421.0], + }, + Underlay { + target_page: 1, + source: "folien".into(), + source_page: 5, + rect: [0.0, 0.0, 595.0, 842.0], + }, + Underlay { + target_page: 1, + source: "fehlt".into(), + source_page: 0, + rect: [0.0, 0.0, 595.0, 842.0], + }, + ]; + let (bytes, failed) = apply_underlays(&target, &underlays, &sources).unwrap(); + // Fehlende Quellseite und fehlendes PDF werden gemeldet, der Rest klappt + assert_eq!(failed.len(), 2); + + let doc = Document::load_mem(&bytes).unwrap(); + let pages: Vec = doc.get_pages().into_values().collect(); + assert_eq!( + pages.len(), + 2, + "Quellseiten dürfen nicht als eigene Seiten auftauchen" + ); + let first = doc.get_page_content(pages[0]).unwrap(); + let text = String::from_utf8_lossy(&first); + assert!( + text.starts_with("q "), + "Unterlage muss zuerst gezeichnet werden: {text}" + ); + assert!(text.contains("Do Q")); + // Ursprünglicher Seiteninhalt bleibt erhalten + assert!(text.contains("re f")); + + // Form-XObject mit dem Inhalt der Folie ist in den Ressourcen eingetragen + let page = doc.get_dictionary(pages[0]).unwrap(); + let resources = page.get(b"Resources").unwrap().as_dict().unwrap(); + let xobjects = resources.get(b"XObject").unwrap().as_dict().unwrap(); + let (_, form_ref) = xobjects.iter().next().unwrap(); + let form = doc + .get_object(form_ref.as_reference().unwrap()) + .unwrap() + .as_stream() + .unwrap(); + assert_eq!( + form.dict.get(b"Subtype").unwrap().as_name().unwrap(), + b"Form" + ); + assert!(String::from_utf8_lossy(&form.content).contains("1 0 0 rg")); + } +} diff --git a/src/storage.rs b/src/storage.rs index fdce6f6..c1850e4 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -151,7 +151,8 @@ impl StrokeData { /// - 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; +/// - Version 5: importierte PDF-Seiten als Seitenhintergrund (`pdf_background`). +pub const CURRENT_FORMAT_VERSION: u32 = 5; /// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0). pub fn legacy_format_version() -> u32 { @@ -165,6 +166,9 @@ pub struct PageData { pub format_version: u32, pub id: u64, pub background: PageBackground, + /// Importierte PDF-Seite als Hintergrund + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pdf_background: Option, pub strokes: Vec, } @@ -174,6 +178,7 @@ impl From<&Page> for PageData { format_version: CURRENT_FORMAT_VERSION, id: page.id, background: page.background, + pdf_background: page.pdf_background.clone(), strokes: page.strokes.iter().map(StrokeData::from).collect(), } } @@ -183,6 +188,7 @@ impl PageData { /// Wandelt die Seitendaten in eine runtime-aktive `Page` mit initialisiertem Cache um. pub fn to_page(&self) -> Page { let mut page = Page::new(self.id, self.background); + page.pdf_background = self.pdf_background.clone(); for stroke_data in &self.strokes { page.strokes.push(stroke_data.to_stroke()); } @@ -1056,6 +1062,50 @@ pub fn create_new_notebook( Ok((notebook, manifest, notebook_dir)) } +/// Legt ein neues Notizbuch aus einem PDF an: eine Seite je PDF-Seite, die PDF-Seite als +/// Hintergrund. Das PDF wird unverändert im Asset-Ordner abgelegt; alle Seiten werden sofort +/// gespeichert. +pub fn create_notebook_from_pdf( + base_dir: &Path, + title: &str, + pdf_bytes: Vec, +) -> Result<(Notebook, NotebookManifest, PathBuf), String> { + let sizes = crate::pdf_doc::page_sizes(pdf_bytes.clone())?; + let (_, _, dir) = create_new_notebook(base_dir, title).map_err(|e| e.to_string())?; + let result = (|| { + let asset = crate::assets::AssetStore::new(&dir) + .import_pdf(&pdf_bytes) + .map_err(|e| e.to_string())?; + let pages: Vec = sizes + .iter() + .enumerate() + .map(|(i, &(width_pt, height_pt))| { + let mut page = Page::new(i as u64 + 1, PageBackground::Blank); + page.pdf_background = Some(crate::document::PdfBackground { + asset: asset.clone(), + page: i as u32, + width_pt, + height_pt, + }); + page + }) + .collect(); + let page_count = pages.len() as u64; + let notebook = Notebook::from_pages(pages, 0, 1, page_count + 1); + let mut manifest = load_manifest(&dir).map_err(|e| e.to_string())?; + save_notebook(&dir, ¬ebook, &mut manifest).map_err(|e| e.to_string())?; + Ok((notebook, manifest)) + })(); + match result { + Ok((notebook, manifest)) => Ok((notebook, manifest, dir)), + Err(err) => { + // Halbfertiges Notizbuch nicht liegen lassen + let _ = fs::remove_dir_all(&dir); + Err(err) + } + } +} + /// Benennt ein Notizbuch um und aktualisiert das Manifest atomar auf der Festplatte. pub fn rename_notebook( notebook_dir: &Path, @@ -2354,7 +2404,10 @@ mod tests { corners, )); save_notebook(&dir, ¬ebook, &mut manifest).unwrap(); - assert_eq!(load_manifest(&dir).unwrap().format_version, 4); + assert_eq!( + load_manifest(&dir).unwrap().format_version, + CURRENT_FORMAT_VERSION + ); let (reloaded, _) = load_notebook(&dir).unwrap(); let image = &reloaded.current_page().strokes[0]; @@ -2364,4 +2417,36 @@ mod tests { // Bilddatei liegt im Notizbuchordner assert!(dir.join("assets").join(&imported.asset).exists()); } + + #[test] + fn test_create_notebook_from_pdf() { + let temp_dir = tempfile::tempdir().unwrap(); + let pdf = crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (595.0, 842.0, 0)]); + let (notebook, manifest, dir) = + create_notebook_from_pdf(temp_dir.path(), "Vorlesung 3", pdf.clone()).unwrap(); + assert_eq!(manifest.title, "Vorlesung 3"); + assert_eq!(notebook.pages.len(), 2); + assert_eq!(notebook.next_page_id, 3); + + // Nach dem Neuladen: jede Seite verweist auf ihre PDF-Seite, das PDF liegt unverändert + // im Asset-Ordner + let (mut reloaded, _) = load_notebook(&dir).unwrap(); + load_all_pages(&dir, &mut reloaded).unwrap(); + for (i, page) in reloaded.pages.iter().enumerate() { + let bg = page.pdf_background.as_ref().expect("PDF-Hintergrund fehlt"); + assert_eq!(bg.page, i as u32); + assert_eq!(page.background, PageBackground::Blank); + } + let first = reloaded.pages[0].pdf_background.clone().unwrap(); + assert_eq!((first.width_pt, first.height_pt), (842.0, 595.0)); + let assets = crate::assets::AssetStore::new(&dir); + assert_eq!(assets.read_bytes(&first.asset).unwrap(), pdf); + + // Kein PDF: Fehler und kein halbfertiges Notizbuch + let before = std::fs::read_dir(temp_dir.path()).unwrap().count(); + assert!( + create_notebook_from_pdf(temp_dir.path(), "Kaputt", b"%PDF-kaputt".to_vec()).is_err() + ); + assert_eq!(std::fs::read_dir(temp_dir.path()).unwrap().count(), before); + } } diff --git a/src/ui.rs b/src/ui.rs index b5c413f..9e3587b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -16,7 +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::file_picker::{FilePicker, PickerKind}; use crate::settings::{PressureCurve, SidebarSide}; use crate::storage::NotebookSummary; @@ -154,6 +154,8 @@ pub enum UiAction { SavePressureCurve(PressureCurve), /// Bilddatei auf der aktuellen Seite einfügen InsertImageFile(std::path::PathBuf), + /// PDF als neues Notizbuch importieren + ImportPdf(std::path::PathBuf), // Beenden-Sicherheit bei Fehlschlag RetryShutdown, ExportRecoveryAndExit, @@ -215,10 +217,13 @@ 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, + /// Geöffnete Dateiauswahl („Bild einfügen“ oder „PDF importieren“) + pub file_picker: Option, + /// Aktionen, die außerhalb der Oberfläche ausgelöst wurden (z. B. ins Fenster gezogene + /// PDF-Datei); `render_ui` gibt sie beim nächsten Bild mit aus + pub queued_actions: Vec, /// Zuletzt in der Dateiauswahl benutzter Ordner (während der Sitzung) - pub last_image_dir: Option, + pub last_picker_dir: Option, } impl Default for UiState { @@ -259,8 +264,9 @@ impl Default for UiState { adapt_ink_colors: true, pressure_curve: PressureCurve::default(), last_raw_pressure: None, - image_picker: None, - last_image_dir: None, + file_picker: None, + queued_actions: Vec::new(), + last_picker_dir: None, } } } @@ -282,14 +288,23 @@ impl UiState { self.show_page_jump_popup = false; self.is_document_menu_open = false; self.is_update_dialog_open = false; - self.image_picker = None; + self.file_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.open_file_picker(PickerKind::Image); + } + + /// Öffnet die Dateiauswahl „PDF importieren“ im zuletzt benutzten Ordner. + pub fn open_pdf_picker(&mut self) { + self.open_file_picker(PickerKind::Pdf); + } + + fn open_file_picker(&mut self, kind: PickerKind) { + let dir = self.last_picker_dir.clone(); self.close_overlays(); - self.image_picker = Some(FilePicker::open(dir.as_deref())); + self.file_picker = Some(FilePicker::open(kind, dir.as_deref())); } /// Länge der längsten Farbpalette; so viel Platz hält die Leiste immer frei. @@ -775,6 +790,16 @@ 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); + // Importierte PDF-Seite als helle Fläche mit Rand + if let Some(pdf) = &page.pdf_background { + let points: Vec = pdf.corners().iter().map(|&c| to_screen(c)).collect(); + shapes.push(egui::Shape::convex_polygon( + points, + egui::Color32::from_rgb(236, 238, 243), + egui::Stroke::new(0.8_f32, egui::Color32::from_gray(150)), + )); + } + // 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( @@ -979,20 +1004,39 @@ fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option) { +/// Dialog „Bild einfügen“ bzw. „PDF importieren“: Schnellzugriffe, Ordnerpfad und Liste aus +/// Ordnern und passenden Dateien. +fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Vec) { 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 { + let Some(picker) = state.file_picker.as_ref() else { return; }; - dialog_window("Bild einfügen", state, false).show(ctx, |ui| { + let picker_kind = picker.kind; + let (title, header, file_icon, empty_hint, tip) = match picker.kind { + PickerKind::Image => ( + "Bild einfügen", + "🖼 Bild einfügen", + "🖼", + "Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)", + "Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen.", + ), + PickerKind::Pdf => ( + "PDF importieren", + "📄 PDF importieren", + "📄", + "Keine Ordner oder PDF-Dateien", + "Das PDF wird als neues Notizbuch angelegt, eine Seite je PDF-Seite. \ + Beim Export liegen deine Notizen über den Originalseiten.", + ), + }; + dialog_window(title, state, false).show(ctx, |ui| { ui.set_width(460.0); touch_friendly_spacing(ui); - if dialog_header(ui, "🖼 Bild einfügen", true) { + if dialog_header(ui, header, true) { close = true; } @@ -1030,16 +1074,13 @@ fn image_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut V .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)), - ); + ui.label(egui::RichText::new(empty_hint).color(egui::Color32::from_gray(150))); } for entry in &picker.entries { let label = if entry.is_dir { format!("📁 {}", entry.name) } else { - format!("🖼 {}", entry.name) + format!("{file_icon} {}", entry.name) }; let button = egui::Button::new(label) .frame(false) @@ -1057,15 +1098,13 @@ fn image_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut V 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)), + egui::RichText::new(tip) + .size(11.5) + .color(egui::Color32::from_gray(150)), ); }); - if let Some(picker) = state.image_picker.as_mut() { + if let Some(picker) = state.file_picker.as_mut() { if go_up { picker.go_up(); } @@ -1074,12 +1113,15 @@ fn image_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut V } } if let Some(path) = chosen { - state.last_image_dir = state.image_picker.as_ref().map(|p| p.dir.clone()); - actions.push(UiAction::InsertImageFile(path)); + state.last_picker_dir = state.file_picker.as_ref().map(|p| p.dir.clone()); + actions.push(match picker_kind { + PickerKind::Image => UiAction::InsertImageFile(path), + PickerKind::Pdf => UiAction::ImportPdf(path), + }); close = true; } if close { - state.image_picker = None; + state.file_picker = None; } } @@ -1218,7 +1260,7 @@ pub fn render_ui( autosave_status: AutosaveStatus, updater: &crate::updater::UpdaterController, ) -> Vec { - let mut actions = Vec::new(); + let mut actions = std::mem::take(&mut state.queued_actions); let mut pen_btn_rect = None; let mut high_btn_rect = None; @@ -1975,6 +2017,13 @@ pub fn render_ui( { state.open_image_picker(); } + if ui + .add_sized([ui.available_width(), 30.0], egui::Button::new("📄 PDF importieren …")) + .on_hover_text("Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen und darauf schreiben") + .clicked() + { + state.open_pdf_picker(); + } section(ui, "HINTERGRUND DIESER SEITE"); let current_bg = notebook.current_page().background; @@ -2436,8 +2485,8 @@ pub fn render_ui( } // 3c. Dateiauswahl „Bild einfügen“ - if state.image_picker.is_some() { - image_picker_dialog(ctx, state, &mut actions); + if state.file_picker.is_some() { + file_picker_dialog(ctx, state, &mut actions); } // 3c. Release- & Update-Center (Gitea Releases)