feat: PDF importieren und annotiert exportieren

- ☰ → „PDF importieren …“ oder PDF ins Fenster ziehen: neues Notizbuch
  mit einer Seite je PDF-Seite, die Originalseite als Hintergrund
- Querformat-Folien liegen oben auf dem A4-Blatt, darunter ist Platz für
  Notizen; gedrehte Seiten (/Rotate) erscheinen wie im PDF-Betrachter
- Anzeige mit hayro (reines Rust) im Hintergrund-Thread, nächste Seite
  wird vorab gerendert
- PDF-Export legt die Originalseiten als Vektorgrafik (Form-XObject via
  lopdf) unter Bilder und Tinte; nicht übernehmbare Seiten (z. B.
  verschlüsselt) werden als Bild mit 200 dpi eingebettet
- Dateiformat-Version 5 (pdf_background an der Seite)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
haraldandClaude Opus 5.5 committed 2026-10-07 17:47:49 +02:00
1 parent 9a39276da4
commit 66679e51ae
14 files changed
+1771 -147

No files matched your search

+60 -31
View File
@@ -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<RgbaImage> {
/// 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<String, wgpu::BindGroup>,
/// Texturen nach Schlüssel (Asset-Name bzw. `<pdf>#<seite>`) mit belegtem Speicher
textures: HashMap<String, (wgpu::BindGroup, u64)>,
texture_bytes: u64,
/// Bilder der aktuellen Seite: Asset-Name und Ecken
quads: Vec<(String, [glam::Vec2; 4])>,
vertex_buffer: Option<wgpu::Buffer>,
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<String> = 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<ImageVertex> = 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, &[]);