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)
- **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`).
+12 -3
View File
@@ -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]
+30 -6
View File
@@ -91,6 +91,9 @@ pub struct ImageLayer {
groups: Vec<Vec<(String, [glam::Vec2; 4])>>,
vertex_buffer: Option<wgpu::Buffer>,
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 {
@@ -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<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;
/// ohne Änderungen kostet das nichts.
pub fn prepare(
@@ -204,9 +221,8 @@ impl ImageLayer {
) {
let missing: Vec<String> = 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<String> = 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, &[]);
+7 -5
View File
@@ -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::<usize>()) 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()),
};
+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).
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.
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<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();
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);
}
}
+1 -1
View File
@@ -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;