feat: Zoom bis 1000 %, PDF-Seiten beim Hineinzoomen schärfer

Höchster Zoom 1000 % statt 500 % (Issue #1). Importierte PDF-Seiten werden
mit doppelter Auflösung nachgerendert, sobald die Standardauflösung
sichtbar vergrößert würde; bis dahin bleibt sie als Ersatz sichtbar.
Texte werden bis 8 Pixel je Einheit gerastert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 07:59:27 +02:00
co-authored by Claude Opus 5.5
parent 6002eee9e8
commit 392c22bc4a
6 changed files with 94 additions and 25 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
### 🔍 Stufenloser Zoom & Pan (Kamera) ### 🔍 Stufenloser Zoom & Pan (Kamera)
- **Viewport-Transformation:** 2D-Projektionsmatrix im WGSL-Vertex-Shader. - **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). - **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. - **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`). - **Inverse Koordinatentransformation:** Präzise Umrechnung von Bildschirm- in DIN-A4-Dokumentkoordinaten (`screen_to_page`).
+12 -3
View File
@@ -911,6 +911,15 @@ impl CanvasState {
self.image_layer self.image_layer
.forget_textures(|key| key.starts_with(TEXT_KEY_PREFIX)); .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; let specs = &self.text_specs;
self.image_layer self.image_layer
.prepare(&self.device, &self.queue, &mut |key| match specs.get(key) { .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: /// 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 /// gerastert, und die Textur ist höchstens ein Fünftel größer als dargestellt – stärker
/// verkleinert wirkt die dünne Schrift blass. /// verkleinert wirkt die dünne Schrift blass.
fn text_resolution(screen_px_per_unit: f32) -> f32 { fn text_resolution(screen_px_per_unit: f32) -> f32 {
let steps = (screen_px_per_unit.max(1e-3).log2() * 4.0).ceil(); 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)] #[cfg(test)]
@@ -1328,7 +1337,7 @@ mod tests {
assert!(r >= px - 1e-4 && r < px * 1.2 + 1e-4, "{px} → {r}"); 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(0.27), text_resolution(0.28));
assert_eq!(text_resolution(100.0), 4.0); assert_eq!(text_resolution(100.0), 8.0);
} }
#[test] #[test]
+30 -6
View File
@@ -91,6 +91,9 @@ pub struct ImageLayer {
groups: Vec<Vec<(String, [glam::Vec2; 4])>>, groups: Vec<Vec<(String, [glam::Vec2; 4])>>,
vertex_buffer: Option<wgpu::Buffer>, vertex_buffer: Option<wgpu::Buffer>,
vertices_dirty: bool, 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<u32>,
} }
impl ImageLayer { impl ImageLayer {
@@ -170,6 +173,7 @@ impl ImageLayer {
groups: vec![Vec::new()], groups: vec![Vec::new()],
vertex_buffer: None, vertex_buffer: None,
vertices_dirty: false, vertices_dirty: false,
pdf_pixels: None,
} }
} }
@@ -194,6 +198,19 @@ impl ImageLayer {
self.groups.iter().flatten() self.groups.iter().flatten()
} }
/// Auflösung importierter PDF-Seiten (`None`: Standard), z. B. beim Hineinzoomen.
pub fn set_pdf_pixels(&mut self, pixels: Option<u32>) {
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; /// Lädt fehlende Texturen und den Vertex-Puffer hoch. Vor jedem Zeichnen aufrufen;
/// ohne Änderungen kostet das nichts. /// ohne Änderungen kostet das nichts.
pub fn prepare( pub fn prepare(
@@ -204,9 +221,8 @@ impl ImageLayer {
) { ) {
let missing: Vec<String> = self let missing: Vec<String> = self
.all_quads() .all_quads()
.map(|(key, _)| key) .map(|(key, _)| self.texture_key(key))
.filter(|key| !self.textures.contains_key(*key)) .filter(|key| !self.textures.contains_key(key))
.cloned()
.collect(); .collect();
for key in missing { for key in missing {
if self.textures.contains_key(&key) { if self.textures.contains_key(&key) {
@@ -230,7 +246,11 @@ impl ImageLayer {
// Nicht mehr benötigte Texturen freigeben, wenn der Grafikspeicher knapp wird // Nicht mehr benötigte Texturen freigeben, wenn der Grafikspeicher knapp wird
if self.texture_bytes > TEXTURE_CACHE_BYTES { 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<String> = self
.all_quads()
.flat_map(|(key, _)| [key.clone(), self.texture_key(key)])
.collect();
self.textures.retain(|name, _| used.contains(name)); self.textures.retain(|name, _| used.contains(name));
self.texture_bytes = self.textures.values().map(|(_, b)| b).sum(); 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)? /// Fehlen noch Texturen sichtbarer Seiten (z. B. PDF-Seite im Hintergrund-Rendering)?
pub fn is_waiting(&self) -> bool { pub fn is_waiting(&self) -> bool {
self.all_quads() 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 /// 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_pipeline(&self.pipeline);
render_pass.set_vertex_buffer(0, buffer.slice(..)); render_pass.set_vertex_buffer(0, buffer.slice(..));
for (i, (asset, _)) in quads.iter().enumerate() { 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; continue;
}; };
render_pass.set_bind_group(1, bind_group, &[]); render_pass.set_bind_group(1, bind_group, &[]);
+7 -5
View File
@@ -507,17 +507,19 @@ fn texture_for(
assets: &AssetStore, assets: &AssetStore,
pdf_renderer: &mut PdfRenderService, pdf_renderer: &mut PdfRenderService,
) -> TextureSource { ) -> TextureSource {
let result = match key.split_once('#') { let result = match key.contains('#') {
Some((asset, page)) => { true => {
let (Ok(path), Ok(page)) = (assets.path_of(asset), page.parse::<usize>()) else { 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}'")); 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, Some(result) => result,
None => return TextureSource::Pending, None => return TextureSource::Pending,
} }
} }
None => assets false => assets
.load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION) .load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION)
.map_err(|e| e.to_string()), .map_err(|e| e.to_string()),
}; };
+43 -9
View File
@@ -16,6 +16,19 @@ use crate::assets::RgbaImage;
/// Längste Kante einer gerenderten Seite für die Anzeige in Pixeln (A4 ≈ 220 dpi). /// Längste Kante einer gerenderten Seite für die Anzeige in Pixeln (A4 ≈ 220 dpi).
pub const DISPLAY_RENDER_PIXELS: u32 = 2560; 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: `<asset>#<seite>`, gerendert mit mehr als
/// [`DISPLAY_RENDER_PIXELS`] als `<asset>#<seite>@<pixel>`. 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. /// Größte Seitenzahl, die importiert wird.
pub const MAX_IMPORT_PAGES: usize = 2000; pub const MAX_IMPORT_PAGES: usize = 2000;
@@ -88,6 +101,7 @@ struct RenderRequest {
key: String, key: String,
path: PathBuf, path: PathBuf,
page: usize, page: usize,
max_pixels: u32,
} }
/// Ergebnis eines Render-Auftrags. /// Ergebnis eines Render-Auftrags.
@@ -121,7 +135,7 @@ impl PdfRenderService {
.map(|pdf| (request.path.clone(), pdf)); .map(|pdf| (request.path.clone(), pdf));
} }
let result = match &open_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()), None => Err("PDF kann nicht geöffnet werden".into()),
}; };
if res_tx.send((request.key, result)).is_err() { if res_tx.send((request.key, result)).is_err() {
@@ -150,7 +164,7 @@ impl PdfRenderService {
if self.ready.len() >= 2 { if self.ready.len() >= 2 {
self.ready.clear(); self.ready.clear();
} }
self.request(key, path, page); self.request(key, path, page, DISPLAY_RENDER_PIXELS);
} }
fn collect(&mut self) { fn collect(&mut self) {
@@ -161,7 +175,7 @@ impl PdfRenderService {
} }
/// Erteilt den Auftrag, falls noch keiner läuft. `false`, wenn der Thread beendet ist. /// 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()) { if !self.pending.insert(key.to_string()) {
return true; return true;
} }
@@ -169,6 +183,7 @@ impl PdfRenderService {
key: key.to_string(), key: key.to_string(),
path, path,
page, page,
max_pixels,
}; };
if self.requests.send(request).is_err() { if self.requests.send(request).is_err() {
self.pending.remove(key); self.pending.remove(key);
@@ -178,12 +193,19 @@ impl PdfRenderService {
} }
/// Fertiges Ergebnis für `key` abholen; sonst den Auftrag (einmalig) erteilen und `None`. /// 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<RenderResult> { /// 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<RenderResult> {
self.collect(); self.collect();
if let Some(result) = self.ready.remove(key) { if let Some(result) = self.ready.remove(key) {
return Some(result); 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())); return Some(Err("PDF-Render-Thread beendet".into()));
} }
None None
@@ -272,13 +294,14 @@ pub(crate) mod tests {
let mut service = PdfRenderService::new(move || { let mut service = PdfRenderService::new(move || {
let _ = wake_tx.send(()); 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 // 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 wake_rx
.recv_timeout(std::time::Duration::from_secs(20)) .recv_timeout(std::time::Duration::from_secs(20))
.unwrap(); .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); assert_eq!(image.width, DISPLAY_RENDER_PIXELS);
// Vorab rendern: danach sofort verfügbar // Vorab rendern: danach sofort verfügbar
@@ -286,6 +309,17 @@ pub(crate) mod tests {
wake_rx wake_rx
.recv_timeout(std::time::Duration::from_secs(20)) .recv_timeout(std::time::Duration::from_secs(20))
.unwrap(); .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);
} }
} }
+1 -1
View File
@@ -8,7 +8,7 @@ use glam::{Mat4, Vec2, Vec4};
/// Minimaler und maximaler Zoomfaktor. /// Minimaler und maximaler Zoomfaktor.
pub const MIN_ZOOM: f32 = 0.25; 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). /// Abstand zwischen zwei Seiten in der durchgehenden Ansicht (8 mm).
pub const PAGE_GAP: f32 = 8.0 * UNITS_PER_MM; pub const PAGE_GAP: f32 = 8.0 * UNITS_PER_MM;