diff --git a/README.md b/README.md index 124ea59..c7e743f 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fĂŒ ### 🔍 Stufenloser Zoom & Pan (Kamera) - **Viewport-Transformation:** 2D-Projektionsmatrix im WGSL-Vertex-Shader. - **Durchgehendes Scrollen:** Seiten hĂ€ngen untereinander und werden mit Mausrad, Finger oder Touchpad fließend gescrollt; wer im unteren FĂŒnftel der letzten Seite schreibt, bekommt automatisch eine neue Seite angehĂ€ngt. Abschaltbar im ☰-MenĂŒ unter „Ansicht“ (dann Seite fĂŒr Seite blĂ€ttern). -- **Cursor-zentrierter Zoom:** Stufenloses Zoomen von **25 % bis 500 %** zentriert auf die Cursor-Position via `Strg + Mausrad` (ohne durchgehende Ansicht: Mausrad), Trackpad-Pinch oder TastenkĂŒrzel (`Strg +` / `Strg -` / `Strg 0`). +- **Cursor-zentrierter Zoom:** Stufenloses Zoomen von **25 % bis 1000 %** (importierte PDF-Seiten werden beim Hineinzoomen schĂ€rfer nachgerendert) zentriert auf die Cursor-Position via `Strg + Mausrad` (ohne durchgehende Ansicht: Mausrad), Trackpad-Pinch oder TastenkĂŒrzel (`Strg +` / `Strg -` / `Strg 0`). - **Pan:** Verschieben des Canvas per mittlerer Maustaste, `Leertaste + Drag` oder Trackpad-Pan. - **Inverse Koordinatentransformation:** PrĂ€zise Umrechnung von Bildschirm- in DIN-A4-Dokumentkoordinaten (`screen_to_page`). diff --git a/src/canvas.rs b/src/canvas.rs index d3c6a7d..5a3222f 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -911,6 +911,15 @@ impl CanvasState { self.image_layer .forget_textures(|key| key.starts_with(TEXT_KEY_PREFIX)); } + // Importierte PDF-Seiten schĂ€rfer rendern, sobald die Standardauflösung vergrĂ¶ĂŸert wĂŒrde + let page_long_edge = crate::document::PAGE_HEIGHT.max(crate::document::PAGE_WIDTH) + * self.viewport.page_width + / crate::document::PAGE_WIDTH + * self.camera.zoom; + self.image_layer.set_pdf_pixels( + (page_long_edge > crate::pdf_doc::DISPLAY_RENDER_PIXELS as f32 * 1.25) + .then_some(crate::pdf_doc::ZOOMED_RENDER_PIXELS), + ); let specs = &self.text_specs; self.image_layer .prepare(&self.device, &self.queue, &mut |key| match specs.get(key) { @@ -1309,12 +1318,12 @@ fn text_key(text: &crate::text::TextBox, color: [f32; 4]) -> String { } /// Auflösung der Texttexturen fĂŒr `screen_px_per_unit` Bildschirmpixel je Seiteneinheit: -/// auf Viertel-Zweierpotenzen aufgerundet (⅛ bis 4). So wird nicht bei jedem Zoomschritt neu +/// auf Viertel-Zweierpotenzen aufgerundet (⅛ bis 8). So wird nicht bei jedem Zoomschritt neu /// gerastert, und die Textur ist höchstens ein FĂŒnftel grĂ¶ĂŸer als dargestellt – stĂ€rker /// verkleinert wirkt die dĂŒnne Schrift blass. fn text_resolution(screen_px_per_unit: f32) -> f32 { let steps = (screen_px_per_unit.max(1e-3).log2() * 4.0).ceil(); - 2.0_f32.powf(steps / 4.0).clamp(0.125, 4.0) + 2.0_f32.powf(steps / 4.0).clamp(0.125, 8.0) } #[cfg(test)] @@ -1328,7 +1337,7 @@ mod tests { assert!(r >= px - 1e-4 && r < px * 1.2 + 1e-4, "{px} → {r}"); } assert_eq!(text_resolution(0.27), text_resolution(0.28)); - assert_eq!(text_resolution(100.0), 4.0); + assert_eq!(text_resolution(100.0), 8.0); } #[test] diff --git a/src/image_layer.rs b/src/image_layer.rs index 8cd863d..f7532fc 100644 --- a/src/image_layer.rs +++ b/src/image_layer.rs @@ -91,6 +91,9 @@ pub struct ImageLayer { groups: Vec>, vertex_buffer: Option, vertices_dirty: bool, + /// Importierte PDF-Seiten in höherer Auflösung (lĂ€ngste Kante in Pixeln) anzeigen; + /// bis sie gerendert ist, bleibt die Standardauflösung sichtbar + pdf_pixels: Option, } impl ImageLayer { @@ -170,6 +173,7 @@ impl ImageLayer { groups: vec![Vec::new()], vertex_buffer: None, vertices_dirty: false, + pdf_pixels: None, } } @@ -194,6 +198,19 @@ impl ImageLayer { self.groups.iter().flatten() } + /// Auflösung importierter PDF-Seiten (`None`: Standard), z. B. beim Hineinzoomen. + pub fn set_pdf_pixels(&mut self, pixels: Option) { + self.pdf_pixels = pixels; + } + + /// SchlĂŒssel der anzuzeigenden Textur: PDF-Seiten ggf. in höherer Auflösung. + fn texture_key(&self, key: &str) -> String { + match self.pdf_pixels { + Some(pixels) if key.contains('#') => format!("{key}@{pixels}"), + _ => key.to_string(), + } + } + /// LĂ€dt fehlende Texturen und den Vertex-Puffer hoch. Vor jedem Zeichnen aufrufen; /// ohne Änderungen kostet das nichts. pub fn prepare( @@ -204,9 +221,8 @@ impl ImageLayer { ) { let missing: Vec = self .all_quads() - .map(|(key, _)| key) - .filter(|key| !self.textures.contains_key(*key)) - .cloned() + .map(|(key, _)| self.texture_key(key)) + .filter(|key| !self.textures.contains_key(key)) .collect(); for key in missing { if self.textures.contains_key(&key) { @@ -230,7 +246,11 @@ impl ImageLayer { // Nicht mehr benötigte Texturen freigeben, wenn der Grafikspeicher knapp wird if self.texture_bytes > TEXTURE_CACHE_BYTES { - let used: HashSet<&String> = self.groups.iter().flatten().map(|(a, _)| a).collect(); + // Die Standardauflösung bleibt als Ersatz, bis die höhere fertig ist + let used: HashSet = self + .all_quads() + .flat_map(|(key, _)| [key.clone(), self.texture_key(key)]) + .collect(); self.textures.retain(|name, _| used.contains(name)); self.texture_bytes = self.textures.values().map(|(_, b)| b).sum(); } @@ -279,7 +299,7 @@ impl ImageLayer { /// Fehlen noch Texturen sichtbarer Seiten (z. B. PDF-Seite im Hintergrund-Rendering)? pub fn is_waiting(&self) -> bool { self.all_quads() - .any(|(key, _)| !self.textures.contains_key(key)) + .any(|(key, _)| !self.textures.contains_key(&self.texture_key(key))) } /// Zeichnet die Bilder einer Seite (Gruppe 0: aktuelle Seite). Bind Group 0 (Kamera der @@ -298,7 +318,11 @@ impl ImageLayer { render_pass.set_pipeline(&self.pipeline); render_pass.set_vertex_buffer(0, buffer.slice(..)); for (i, (asset, _)) in quads.iter().enumerate() { - let Some((bind_group, _)) = self.textures.get(asset) else { + let Some((bind_group, _)) = self + .textures + .get(&self.texture_key(asset)) + .or_else(|| self.textures.get(asset)) + else { continue; }; render_pass.set_bind_group(1, bind_group, &[]); diff --git a/src/main.rs b/src/main.rs index 9dca5c9..bdc8be6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -507,17 +507,19 @@ fn texture_for( 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 { + let result = match key.contains('#') { + true => { + let Some((Ok(path), page, max_pixels)) = ink_notes::pdf_doc::parse_page_key(key) + .map(|(asset, page, px)| (assets.path_of(asset), page, px)) + else { return TextureSource::Failed(format!("UngĂŒltiger PDF-Verweis '{key}'")); }; - match pdf_renderer.take(key, path, page) { + match pdf_renderer.take(key, path, page, max_pixels) { Some(result) => result, None => return TextureSource::Pending, } } - None => assets + false => assets .load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION) .map_err(|e| e.to_string()), }; diff --git a/src/pdf_doc.rs b/src/pdf_doc.rs index 9e1cd1a..5c62f8f 100644 --- a/src/pdf_doc.rs +++ b/src/pdf_doc.rs @@ -16,6 +16,19 @@ 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; +/// LĂ€ngste Kante beim starken Hineinzoomen (A4 ≈ 440 dpi). +pub const ZOOMED_RENDER_PIXELS: u32 = 5120; + +/// TexturschlĂŒssel einer PDF-Seite: `#`, gerendert mit mehr als +/// [`DISPLAY_RENDER_PIXELS`] als `#@`. Liefert Asset, Seite und Pixel. +pub fn parse_page_key(key: &str) -> Option<(&str, usize, u32)> { + let (asset, rest) = key.split_once('#')?; + let (page, pixels) = match rest.split_once('@') { + Some((page, pixels)) => (page, pixels.parse().ok()?), + None => (rest, DISPLAY_RENDER_PIXELS), + }; + Some((asset, page.parse().ok()?, pixels)) +} /// GrĂ¶ĂŸte Seitenzahl, die importiert wird. pub const MAX_IMPORT_PAGES: usize = 2000; @@ -88,6 +101,7 @@ struct RenderRequest { key: String, path: PathBuf, page: usize, + max_pixels: u32, } /// Ergebnis eines Render-Auftrags. @@ -121,7 +135,7 @@ impl PdfRenderService { .map(|pdf| (request.path.clone(), pdf)); } let result = match &open_pdf { - Some((_, pdf)) => render_page(pdf, request.page, DISPLAY_RENDER_PIXELS), + Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels), None => Err("PDF kann nicht geöffnet werden".into()), }; if res_tx.send((request.key, result)).is_err() { @@ -150,7 +164,7 @@ impl PdfRenderService { if self.ready.len() >= 2 { self.ready.clear(); } - self.request(key, path, page); + self.request(key, path, page, DISPLAY_RENDER_PIXELS); } fn collect(&mut self) { @@ -161,7 +175,7 @@ impl PdfRenderService { } /// 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 { + fn request(&mut self, key: &str, path: PathBuf, page: usize, max_pixels: u32) -> bool { if !self.pending.insert(key.to_string()) { return true; } @@ -169,6 +183,7 @@ impl PdfRenderService { key: key.to_string(), path, page, + max_pixels, }; if self.requests.send(request).is_err() { self.pending.remove(key); @@ -178,12 +193,19 @@ impl PdfRenderService { } /// 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 { + /// Die Seite wird so gerendert, dass die lĂ€ngste Kante `max_pixels` lang ist. + pub fn take( + &mut self, + key: &str, + path: PathBuf, + page: usize, + max_pixels: u32, + ) -> Option { self.collect(); if let Some(result) = self.ready.remove(key) { return Some(result); } - if !self.request(key, path, page) { + if !self.request(key, path, page, max_pixels) { return Some(Err("PDF-Render-Thread beendet".into())); } None @@ -272,13 +294,14 @@ pub(crate) mod tests { let mut service = PdfRenderService::new(move || { let _ = wake_tx.send(()); }); - assert!(service.take("a#0", path.clone(), 0).is_none()); + let px = DISPLAY_RENDER_PIXELS; + assert!(service.take("a#0", path.clone(), 0, px).is_none()); // Zweite Anfrage erteilt keinen weiteren Auftrag - assert!(service.take("a#0", path.clone(), 0).is_none()); + assert!(service.take("a#0", path.clone(), 0, px).is_none()); wake_rx .recv_timeout(std::time::Duration::from_secs(20)) .unwrap(); - let image = service.take("a#0", path.clone(), 0).unwrap().unwrap(); + let image = service.take("a#0", path.clone(), 0, px).unwrap().unwrap(); assert_eq!(image.width, DISPLAY_RENDER_PIXELS); // Vorab rendern: danach sofort verfĂŒgbar @@ -286,6 +309,17 @@ pub(crate) mod tests { wake_rx .recv_timeout(std::time::Duration::from_secs(20)) .unwrap(); - assert!(service.take("a#0", path, 0).unwrap().is_ok()); + assert!(service.take("a#0", path, 0, px).unwrap().is_ok()); + } + + #[test] + fn test_parse_page_key() { + assert_eq!( + parse_page_key("abc.pdf#3"), + Some(("abc.pdf", 3, DISPLAY_RENDER_PIXELS)) + ); + assert_eq!(parse_page_key("abc.pdf#3@5120"), Some(("abc.pdf", 3, 5120))); + assert_eq!(parse_page_key("abc.png"), None); + assert_eq!(parse_page_key("abc.pdf#x"), None); } } diff --git a/src/viewport.rs b/src/viewport.rs index 127e1f3..3f2e013 100644 --- a/src/viewport.rs +++ b/src/viewport.rs @@ -8,7 +8,7 @@ use glam::{Mat4, Vec2, Vec4}; /// Minimaler und maximaler Zoomfaktor. pub const MIN_ZOOM: f32 = 0.25; -pub const MAX_ZOOM: f32 = 5.0; +pub const MAX_ZOOM: f32 = 10.0; /// Abstand zwischen zwei Seiten in der durchgehenden Ansicht (8 mm). pub const PAGE_GAP: f32 = 8.0 * UNITS_PER_MM;