diff --git a/README.md b/README.md index c789d40..056e5dd 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü ### 📄 Seiten & Hintergründe - **Virtuelles DIN-A4-Raster:** 2100 × 2970 Koordinateneinheiten (10 Einheiten = 1 mm). +- **Seitenformat aus dem PDF:** Beim PDF-Import bleiben Hochformat-Seiten DIN A4; Querformat-Seiten wie PowerPoint-Folien bekommen eine Querformat-Seite (297 mm breit, Höhe nach Seitenverhältnis) ohne leeren Streifen darunter. Eingefügte Seiten übernehmen das Format der aktuellen Seite. - **Hintergrundmuster:** Blanko, Kariert (5 mm), Liniert (7.5 mm), Punktraster (5 mm). - **Seitenverwaltung:** Vor- und Zurückblättern, neue Seiten anhängen oder direkt nach der aktuellen Seite einfügen (☰-Menü oder Seitenauswahl, z. B. zwischen zwei PDF-Folien), Seitensprung-Menü. diff --git a/src/background.rs b/src/background.rs index bbdcb2f..d8c6890 100644 --- a/src/background.rs +++ b/src/background.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::canvas::InkVertex; -use crate::document::{PageBackground, PAGE_HEIGHT, PAGE_WIDTH}; +use crate::document::PageBackground; /// Linienstärke für Gitter- und Schreiblinien in Seiteneinheiten (1.5 = 0.15 mm). pub const LINE_THICKNESS: f32 = 1.5; @@ -99,15 +99,16 @@ pub fn adapt_ink_color(color: [f32; 4]) -> [f32; 4] { [color[0] + lift, color[1] + lift, color[2] + lift, color[3]] } -/// Erzeugt die 6 Vertizes (2 Dreiecke) für das DIN-A4-Papierblatt. -pub fn generate_paper_quad(color: [f32; 4]) -> Vec { +/// Erzeugt die 6 Vertizes (2 Dreiecke) für ein Blatt der Größe `size`. +pub fn generate_paper_quad(color: [f32; 4], size: glam::Vec2) -> Vec { + let (page_width, page_height) = (size.x, size.y); vec![ InkVertex::new(0.0, 0.0, color), - InkVertex::new(PAGE_WIDTH, 0.0, color), - InkVertex::new(0.0, PAGE_HEIGHT, color), - InkVertex::new(0.0, PAGE_HEIGHT, color), - InkVertex::new(PAGE_WIDTH, 0.0, color), - InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, color), + InkVertex::new(page_width, 0.0, color), + InkVertex::new(0.0, page_height, color), + InkVertex::new(0.0, page_height, color), + InkVertex::new(page_width, 0.0, color), + InkVertex::new(page_width, page_height, color), ] } @@ -129,8 +130,14 @@ fn push_quad( vertices.push(InkVertex::new(x_max, y_max, color)); } -/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color`. -pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) -> Vec { +/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color` auf +/// einem Blatt der Größe `size`. +pub fn generate_background_mesh( + background: PageBackground, + color: [f32; 4], + size: glam::Vec2, +) -> Vec { + let (page_width, page_height) = (size.x, size.y); match background { PageBackground::Blank => Vec::new(), PageBackground::Grid5mm => { @@ -139,27 +146,27 @@ pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) -> // Vertikale Linien alle 5 mm let mut x = GRID_5MM_SPACING; - while x < PAGE_WIDTH { + while x < page_width { push_quad( &mut vertices, color, x - half_t, 0.0, x + half_t, - PAGE_HEIGHT, + page_height, ); x += GRID_5MM_SPACING; } // Horizontale Linien alle 5 mm let mut y = GRID_5MM_SPACING; - while y < PAGE_HEIGHT { + while y < page_height { push_quad( &mut vertices, color, 0.0, y - half_t, - PAGE_WIDTH, + page_width, y + half_t, ); y += GRID_5MM_SPACING; @@ -176,13 +183,13 @@ pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) -> let margin_bottom = 150.0; let mut y = margin_top; - while y <= PAGE_HEIGHT - margin_bottom { + while y <= page_height - margin_bottom { push_quad( &mut vertices, color, 0.0, y - half_t, - PAGE_WIDTH, + page_width, y + half_t, ); y += LINED_SPACING; @@ -195,9 +202,9 @@ pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) -> let half_d = DOT_SIZE / 2.0; let mut y = GRID_5MM_SPACING; - while y < PAGE_HEIGHT { + while y < page_height { let mut x = GRID_5MM_SPACING; - while x < PAGE_WIDTH { + while x < page_width { push_quad( &mut vertices, color, @@ -220,22 +227,29 @@ pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) -> mod tests { use super::*; + use crate::document::{A4_SIZE, PAGE_HEIGHT, PAGE_WIDTH}; + #[test] fn test_paper_quad_generation() { - let quad = generate_paper_quad(PaperTheme::White.paper_color()); + let quad = generate_paper_quad(PaperTheme::White.paper_color(), A4_SIZE); assert_eq!(quad.len(), 6); assert_eq!(quad[0].position, [0.0, 0.0]); assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]); + let slide = generate_paper_quad( + PaperTheme::White.paper_color(), + glam::Vec2::new(2970.0, 1671.0), + ); + assert_eq!(slide[5].position, [2970.0, 1671.0]); } const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0]; #[test] fn test_background_meshes() { - let blank = generate_background_mesh(PageBackground::Blank, PATTERN); + let blank = generate_background_mesh(PageBackground::Blank, PATTERN, A4_SIZE); assert!(blank.is_empty()); - let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN); + let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN, A4_SIZE); assert!(!grid.is_empty()); assert_eq!( grid.len() % 6, @@ -243,11 +257,11 @@ mod tests { "Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)" ); - let lined = generate_background_mesh(PageBackground::Lined, PATTERN); + let lined = generate_background_mesh(PageBackground::Lined, PATTERN, A4_SIZE); assert!(!lined.is_empty()); assert_eq!(lined.len() % 6, 0); - let dots = generate_background_mesh(PageBackground::DotGrid, PATTERN); + let dots = generate_background_mesh(PageBackground::DotGrid, PATTERN, A4_SIZE); assert!(!dots.is_empty()); assert_eq!(dots.len() % 6, 0); } @@ -255,9 +269,9 @@ mod tests { #[test] fn test_background_mesh_uses_theme_color() { let dark = PaperTheme::Black.pattern_color(); - let mesh = generate_background_mesh(PageBackground::Lined, dark); + let mesh = generate_background_mesh(PageBackground::Lined, dark, A4_SIZE); assert!(mesh.iter().all(|v| v.color == dark)); - let quad = generate_paper_quad(PaperTheme::Cream.paper_color()); + let quad = generate_paper_quad(PaperTheme::Cream.paper_color(), A4_SIZE); assert!(quad .iter() .all(|v| v.color == PaperTheme::Cream.paper_color())); diff --git a/src/canvas.rs b/src/canvas.rs index 5a3222f..abd74b6 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -16,6 +16,7 @@ use crate::background::{generate_background_mesh, generate_paper_quad, PaperThem use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample}; use crate::filter::OneEuroFilter; use crate::spline::{self, ControlPoint, SplineConfig}; +use crate::viewport::PageStack; /// 2D-Vertex für Inking-Meshes mit Positions- und Farbattributen (Multi-Color). #[repr(C)] @@ -119,6 +120,7 @@ pub fn choose_present_mode(available: &[wgpu::PresentMode]) -> Option, @@ -187,6 +190,9 @@ pub struct CanvasState { pub paper_theme: PaperTheme, /// Dunkle Tinte auf dunklem Papier aufhellen pub adapt_ink: bool, + /// Größe der aktuellen Seite und Rahmen, in den alle Seiten des Notizbuchs passen + page_size: glam::Vec2, + fit_box: glam::Vec2, /// Textfelder der sichtbaren Seiten nach Texturschlüssel (für die Rasterung) text_specs: HashMap, /// Gerade bearbeitetes Textfeld der aktuellen Seite: nicht zeichnen (die Bearbeitung @@ -431,7 +437,7 @@ impl CanvasState { // 6. Statische Buffer let paper_theme = PaperTheme::default(); - let paper_quad = generate_paper_quad(paper_theme.paper_color()); + let paper_quad = generate_paper_quad(paper_theme.paper_color(), crate::document::A4_SIZE); let paper_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Paper Quad Buffer"), size: (paper_quad.len() * std::mem::size_of::()) as u64, @@ -441,7 +447,11 @@ impl CanvasState { queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad)); let current_background = PageBackground::Grid5mm; - let bg_mesh = generate_background_mesh(current_background, paper_theme.pattern_color()); + let bg_mesh = generate_background_mesh( + current_background, + paper_theme.pattern_color(), + crate::document::A4_SIZE, + ); let background_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Background Mesh Buffer"), size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::()) as u64, @@ -499,6 +509,8 @@ impl CanvasState { current_background, paper_theme, adapt_ink: false, + page_size: crate::document::A4_SIZE, + fit_box: crate::document::A4_SIZE, text_specs: HashMap::new(), hidden_text: None, text_resolution: 1.0, @@ -530,7 +542,7 @@ impl CanvasState { /// Aktualisiert die 2D-Kamera Uniforms (Scale & Offset im NDC-Raum) basierend auf Viewport und Kamera. pub fn update_uniforms(&mut self) { - let camera_uniforms = self.camera_uniforms(0); + let camera_uniforms = self.camera_uniforms(glam::Vec2::ZERO); self.queue.write_buffer( &self.camera_buffer, 0, @@ -545,10 +557,11 @@ impl CanvasState { } } - /// Kamera für die Seite `page_offset` Seiten unter (negativ: über) der aktuellen. - fn camera_uniforms(&self, page_offset: i64) -> CameraUniforms { + /// Kamera für eine Seite mit der Ecke `origin` (Koordinaten der aktuellen Seite). + fn camera_uniforms(&self, origin: glam::Vec2) -> CameraUniforms { let (scale, mut offset) = self.camera.ndc_transform(&self.viewport); - offset[1] += page_offset as f32 * crate::viewport::PAGE_PITCH * scale[1]; + offset[0] += origin.x * scale[0]; + offset[1] += origin.y * scale[1]; CameraUniforms { transform_scale: scale, transform_offset: offset, @@ -577,13 +590,22 @@ impl CanvasState { true } + /// Rahmen, in den alle Seiten des Notizbuchs passen (bestimmt den Maßstab bei 100 %). + pub fn set_fit_box(&mut self, fit_box: glam::Vec2) { + if self.fit_box != fit_box { + self.fit_box = fit_box; + self.recalculate_viewport(); + } + } + fn recalculate_viewport(&mut self) { - self.viewport = PageViewport::calculate_with_insets( + self.viewport = PageViewport::calculate_for( self.size.width as f32, self.size.height as f32, Self::VIEWPORT_MARGIN, - self.ui_inset_left, - self.ui_inset_right, + (self.ui_inset_left, self.ui_inset_right), + self.fit_box, + self.page_size, ); self.update_uniforms(); } @@ -653,60 +675,60 @@ impl CanvasState { self.camera.page_to_screen_vec2(page_pt, &self.viewport) } - /// Durchgehende Ansicht: Versatz (in Seiten) der Seite unter dem Bildschirmpunkt, - /// `None` neben den Seiten und zwischen zwei Seiten. - pub fn page_offset_at_screen(&self, screen_x: f32, screen_y: f32) -> Option { + /// Durchgehende Ansicht: Seite unter dem Bildschirmpunkt, `None` neben den Seiten und + /// zwischen zwei Seiten. + pub fn page_at_screen(&self, stack: &PageStack, screen_x: f32, screen_y: f32) -> Option { let p = self .camera .screen_to_page_unclamped(screen_x, screen_y, &self.viewport); - if !(0.0..=crate::document::PAGE_WIDTH).contains(&p.x) { - return None; - } - crate::viewport::page_offset_at(p.y) + let index = stack.page_at(p.y)?; + let x = p.x - stack.origins[index].x; + (0.0..=stack.sizes[index].x).contains(&x).then_some(index) } - /// Durchgehende Ansicht: Versatz (in Seiten) der Seite in der Mitte des Fensters. - pub fn page_offset_at_view_center(&self) -> Option { + /// Durchgehende Ansicht: Seite auf Höhe der Fenstermitte. + pub fn page_at_view_center(&self, stack: &PageStack) -> Option { let p = self.camera.screen_to_page_unclamped( 0.0, self.size.height as f32 / 2.0, &self.viewport, ); - crate::viewport::page_offset_at(p.y) + stack.page_at(p.y) } - /// Durchgehende Ansicht: Versätze der sichtbaren Nachbarseiten der aktuellen Seite. - pub fn visible_neighbor_offsets(&self, current: usize, page_count: usize) -> Vec { + /// Durchgehende Ansicht: sichtbare Nachbarseiten der aktuellen Seite. + pub fn visible_neighbors(&self, stack: &PageStack) -> Vec { let top = self .camera .screen_to_page_unclamped(0.0, 0.0, &self.viewport); let bottom = self.camera .screen_to_page_unclamped(0.0, self.size.height as f32, &self.viewport); - crate::viewport::visible_neighbor_offsets(top.y, bottom.y, current, page_count) + stack.visible_neighbors(top.y, bottom.y) } /// Durchgehende Ansicht: Hält die Fenstermitte über dem Notizbuch (von der Oberkante der - /// ersten bis zur Unterkante der letzten Seite, waagerecht innerhalb der Seitenbreite), damit + /// ersten bis zur Unterkante der letzten Seite, waagerecht innerhalb der Seiten), damit /// man nicht ins Leere scrollt. `true`, wenn der Ausschnitt korrigiert wurde. - pub fn clamp_scroll(&mut self, current: usize, page_count: usize) -> bool { + pub fn clamp_scroll(&mut self, stack: &PageStack) -> bool { let center = [ self.viewport.page_x + self.viewport.page_width / 2.0, self.size.height as f32 / 2.0, ]; - let changed = - self.camera - .clamp_continuous_center(center, current, page_count, &self.viewport); + let changed = self + .camera + .clamp_continuous_center(center, stack, &self.viewport); if changed { self.update_uniforms(); } changed } - /// Durchgehende Ansicht: Die Seite `k` Seiten weiter wird zur Bezugsseite, ohne dass sich - /// der Bildausschnitt bewegt. Die Nachbarseiten sind danach neu zu setzen. - pub fn shift_reference_page(&mut self, k: i64) { - self.camera.shift_reference_page(k); + /// Durchgehende Ansicht: Die Seite mit der Ecke `origin` (Koordinaten der aktuellen Seite) + /// wird zur Bezugsseite, ohne dass sich der Bildausschnitt bewegt. Danach ist die neue + /// Seite mit [`Self::sync_page`] zu setzen. + pub fn shift_reference_page(&mut self, origin: glam::Vec2) { + self.camera.shift_reference_page(origin); self.update_uniforms(); } @@ -756,7 +778,7 @@ impl CanvasState { self.queue.write_buffer( &self.paper_buffer, 0, - bytemuck::cast_slice(&generate_paper_quad(theme.paper_color())), + bytemuck::cast_slice(&generate_paper_quad(theme.paper_color(), self.page_size)), ); self.upload_background_mesh(); } @@ -774,8 +796,11 @@ impl CanvasState { /// Erzeugt das Muster für Hintergrund und Papierfarbe neu und lädt es auf die GPU. fn upload_background_mesh(&mut self) { - let mesh = - generate_background_mesh(self.current_background, self.paper_theme.pattern_color()); + let mesh = generate_background_mesh( + self.current_background, + self.paper_theme.pattern_color(), + self.page_size, + ); self.background_vertex_count = mesh.len() as u32; if mesh.len() > self.background_vertex_capacity { @@ -799,6 +824,19 @@ impl CanvasState { page.rebuild_cache_if_dirty(); // Die aktuelle Seite kann sich jetzt ändern: als Nachbarseite später neu hochladen self.neighbors.retain(|n| n.fingerprint.page_id != page.id); + if self.page_size != page.size { + self.page_size = page.size; + self.queue.write_buffer( + &self.paper_buffer, + 0, + bytemuck::cast_slice(&generate_paper_quad( + self.paper_theme.paper_color(), + self.page_size, + )), + ); + self.upload_background_mesh(); + self.recalculate_viewport(); + } self.set_background(page.background); let quads = self.page_image_quads(page, self.hidden_text); self.image_layer.set_quads(quads); @@ -832,7 +870,7 @@ impl CanvasState { /// aktuellen Seite (in Seiten). Die Vertex-Caches der Seiten müssen aktuell sein /// ([`Page::rebuild_cache_if_dirty`]); unveränderte Seiten werden nicht erneut hochgeladen. /// Eine leere Liste schaltet die Nachbarseiten ab. - pub fn set_neighbor_pages(&mut self, pages: &[(i64, &Page)]) { + pub fn set_neighbor_pages(&mut self, pages: &[(glam::Vec2, &Page)]) { let mut old = std::mem::take(&mut self.neighbors); let mut image_groups = Vec::with_capacity(pages.len()); for &(offset, page) in pages { @@ -882,11 +920,16 @@ impl CanvasState { buffer }) }; - let background = - generate_background_mesh(page.background, self.paper_theme.pattern_color()); + // Papier und Muster in einem Puffer (Nachbarseiten können anders groß sein) + let mut background = generate_paper_quad(self.paper_theme.paper_color(), page.size); + background.extend(generate_background_mesh( + page.background, + self.paper_theme.pattern_color(), + page.size, + )); NeighborPage { fingerprint, - offset: 0, + offset: glam::Vec2::ZERO, camera_buffer, camera_bind_group, background_buffer: vertex_buffer_with("Neighbor Background Buffer", &background), @@ -903,19 +946,14 @@ impl CanvasState { load: &mut dyn FnMut(&str) -> crate::image_layer::TextureSource, ) { // Text schärfer rastern, sobald weiter hineingezoomt wird (und gröber beim Herauszoomen) - let resolution = text_resolution( - self.viewport.page_width / crate::document::PAGE_WIDTH * self.camera.zoom, - ); + let resolution = text_resolution(self.viewport.scale * self.camera.zoom); if resolution != self.text_resolution { self.text_resolution = resolution; 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; + let page_long_edge = self.page_size.max_element() * self.viewport.scale * 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), @@ -950,7 +988,7 @@ impl CanvasState { let mut quads: Vec<_> = page .pdf_background .iter() - .map(|pdf| (pdf.key(), pdf.corners())) + .map(|pdf| (pdf.key(), pdf.corners(page.size))) .collect(); for stroke in &page.strokes { let Some(corners) = stroke.object_corners() else { @@ -1196,8 +1234,6 @@ impl CanvasState { for (i, neighbor) in self.neighbors.iter().enumerate() { render_pass.set_bind_group(0, &neighbor.camera_bind_group, &[]); render_pass.set_pipeline(&self.triangle_pipeline); - render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..)); - render_pass.draw(0..6, 0..1); if let Some(buffer) = &neighbor.background_buffer { render_pass.set_vertex_buffer(0, buffer.slice(..)); render_pass.draw(0..neighbor.background_vertex_count, 0..1); diff --git a/src/document.rs b/src/document.rs index 7450688..541885c 100644 --- a/src/document.rs +++ b/src/document.rs @@ -15,6 +15,8 @@ pub const A4_ASPECT_RATIO: f32 = A4_WIDTH_MM / A4_HEIGHT_MM; /// Virtuelle Seitenauflösung in internen Einheiten (10 Einheiten = 1 mm). pub const PAGE_WIDTH: f32 = 2100.0; pub const PAGE_HEIGHT: f32 = 2970.0; +/// Größe einer DIN-A4-Hochformatseite (Standard) in Seiteneinheiten. +pub const A4_SIZE: glam::Vec2 = glam::Vec2::new(PAGE_WIDTH, PAGE_HEIGHT); pub const UNITS_PER_MM: f32 = 10.0; /// Ein gefilterter Stylus-Abtastpunkt in DIN-A4-Seitenkoordinaten. @@ -156,21 +158,32 @@ impl PdfBackground { 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) + /// Ecken (oben links, oben rechts, unten rechts, unten links) der PDF-Seite auf einem + /// Blatt der Größe `page_size`, siehe [`pdf_placement`]. + pub fn corners(&self, page_size: glam::Vec2) -> [glam::Vec2; 4] { + pdf_placement(self.width_pt, self.height_pt, page_size) } } -/// 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] { +/// Seitengröße für eine importierte PDF-Seite von `width_pt` × `height_pt`: Hochformat wird +/// DIN A4 hoch (wie bisher), Querformat (z. B. Folien) eine Seite mit der Breite von A4 quer +/// (297 mm) und dem Seitenverhältnis der PDF-Seite – ohne leeren Streifen darunter. +pub fn pdf_page_size(width_pt: f32, height_pt: f32) -> glam::Vec2 { let (w, h) = (width_pt.max(1.0), height_pt.max(1.0)); - let scale = (PAGE_WIDTH / w).min(PAGE_HEIGHT / h); + if w <= h { + return A4_SIZE; + } + // Auf ganze Zehntelmillimeter runden, damit gleich große Folien gleich große Seiten geben + glam::Vec2::new(PAGE_HEIGHT, (PAGE_HEIGHT * h / w).round().max(1.0)) +} + +/// Lage einer PDF-Seite von `width_pt` × `height_pt` auf einem Blatt der Größe `page_size`: +/// so groß wie möglich, waagerecht zentriert und oben bündig. +pub fn pdf_placement(width_pt: f32, height_pt: f32, page_size: glam::Vec2) -> [glam::Vec2; 4] { + let (w, h) = (width_pt.max(1.0), height_pt.max(1.0)); + let scale = (page_size.x / w).min(page_size.y / h); let size = glam::Vec2::new(w * scale, h * scale); - let tl = glam::Vec2::new((PAGE_WIDTH - size.x) / 2.0, 0.0); + let tl = glam::Vec2::new((page_size.x - size.x) / 2.0, 0.0); [ tl, tl + glam::Vec2::new(size.x, 0.0), @@ -184,18 +197,24 @@ pub const IMAGE_UNITS_PER_PIXEL: f32 = UNITS_PER_MM * 25.4 / 96.0; /// Ecken (oben links, oben rechts, unten rechts, unten links) für ein neu eingefügtes Bild /// von `width` × `height` Pixeln um `center`: Bildschirmgröße bei 96 dpi, höchstens 80 % der -/// Seitenbreite und 60 % der Seitenhöhe, und möglichst ganz auf dem Blatt. -pub fn place_image(width: u32, height: u32, center: glam::Vec2) -> [glam::Vec2; 4] { +/// Seitenbreite und 60 % der Seitenhöhe eines Blatts der Größe `page_size`, und möglichst +/// ganz auf dem Blatt. +pub fn place_image( + width: u32, + height: u32, + center: glam::Vec2, + page_size: glam::Vec2, +) -> [glam::Vec2; 4] { let natural = glam::Vec2::new(width.max(1) as f32, height.max(1) as f32) * IMAGE_UNITS_PER_PIXEL; - let fit = (PAGE_WIDTH * 0.8 / natural.x) - .min(PAGE_HEIGHT * 0.6 / natural.y) + let fit = (page_size.x * 0.8 / natural.x) + .min(page_size.y * 0.6 / natural.y) .min(1.0); let size = natural * fit; let half = size / 2.0; let center = glam::Vec2::new( - center.x.clamp(half.x, PAGE_WIDTH - half.x), - center.y.clamp(half.y, PAGE_HEIGHT - half.y), + center.x.clamp(half.x, page_size.x - half.x), + center.y.clamp(half.y, page_size.y - half.y), ); let tl = center - half; [ @@ -693,6 +712,9 @@ pub struct Page { pub id: u64, pub strokes: Vec, pub background: PageBackground, + /// Seitengröße in Seiteneinheiten (Standard DIN A4 hoch, Querformat für PDF-Folien); auch im + /// Manifest, damit sie ohne Laden der Seite bekannt ist + pub size: glam::Vec2, /// Importierte PDF-Seite als Hintergrund (unter Bildern und Tinte) pub pdf_background: Option, /// Seitentitel (Lesezeichen); auch im Manifest, damit er ohne Laden der Seite bekannt ist @@ -711,6 +733,7 @@ impl Page { id, strokes: Vec::new(), background, + size: A4_SIZE, pdf_background: None, title: None, vertex_cache: Vec::new(), @@ -728,6 +751,7 @@ impl Page { id, strokes: Vec::new(), background, + size: A4_SIZE, pdf_background: None, title: None, vertex_cache: Vec::new(), @@ -739,6 +763,12 @@ impl Page { } } + /// Dieselbe Seite mit der Größe `size`. + pub fn with_size(mut self, size: glam::Vec2) -> Self { + self.size = size; + self + } + pub fn is_readonly(&self) -> bool { self.persist == PersistState::ReadOnlyAfterLoadError } @@ -1054,9 +1084,9 @@ impl Page { } } -/// Höhe des Bereichs am unteren Rand der letzten Seite, in dem Schreiben in der +/// Anteil der Seitenhöhe am unteren Rand der letzten Seite, in dem Schreiben in der /// durchgehenden Ansicht automatisch eine neue Seite anhängt. -pub const AUTO_APPEND_ZONE: f32 = PAGE_HEIGHT * 0.2; +pub const AUTO_APPEND_FRACTION: f32 = 0.2; /// Das Notizbuch mit diskreter Seitenverwaltung und Navigation. #[derive(Debug, Clone)] @@ -1141,30 +1171,58 @@ impl Notebook { id } - /// Fügt eine neue Seite mit dem angegebenen Hintergrund hinzu. + /// Rahmen, in den jede Seite des Notizbuchs passt (größte Breite und Höhe). + pub fn fit_box(&self) -> glam::Vec2 { + self.pages + .iter() + .map(|p| p.size) + .fold(glam::Vec2::ZERO, glam::Vec2::max) + .max(glam::Vec2::ONE) + } + + /// Durchgehende Ansicht: Lage aller Seiten relativ zur aktuellen. + pub fn page_stack(&self) -> crate::viewport::PageStack { + crate::viewport::PageStack::new( + self.pages.iter().map(|p| p.size).collect(), + self.current_page_index, + ) + } + + /// Hängt eine neue Seite mit dem angegebenen Hintergrund und der Größe der aktuellen + /// Seite an. pub fn add_page(&mut self, background: PageBackground) -> usize { let id = self.next_page_id; self.next_page_id += 1; - self.pages.push(Page::new(id, background)); + let size = self + .pages + .get(self.current_page_index) + .map_or(A4_SIZE, |p| p.size); + self.pages.push(Page::new(id, background).with_size(size)); self.pages.len() - 1 } /// Fügt direkt nach der aktuellen Seite eine leere Seite mit dem angegebenen - /// Hintergrund ein (die aktuelle Seite bleibt aktuell) und gibt ihren Index zurück. + /// Hintergrund und der Größe der aktuellen Seite ein (die aktuelle Seite bleibt aktuell) + /// und gibt ihren Index zurück. pub fn insert_page_after_current(&mut self, background: PageBackground) -> usize { let id = self.next_page_id; self.next_page_id += 1; let index = (self.current_page_index + 1).min(self.pages.len()); - self.pages.insert(index, Page::new(id, background)); + let size = self + .pages + .get(self.current_page_index) + .map_or(A4_SIZE, |p| p.size); + self.pages + .insert(index, Page::new(id, background).with_size(size)); index } /// Durchgehende Ansicht: Reicht ein neuer Strich auf der letzten Seite bis in deren - /// unteres Fünftel ([`AUTO_APPEND_ZONE`]), wird darunter eine leere Seite mit gleichem + /// unteres Fünftel ([`AUTO_APPEND_FRACTION`]), wird darunter eine leere Seite mit gleichem /// Hintergrund angehängt. `true`, wenn eine Seite angehängt wurde. pub fn append_page_if_writing_at_end(&mut self, stroke_bottom: f32) -> bool { if self.current_page_index + 1 != self.pages.len() - || stroke_bottom < PAGE_HEIGHT - AUTO_APPEND_ZONE + || stroke_bottom < self.current_page().size.y * (1.0 - AUTO_APPEND_FRACTION) { return false; } @@ -1234,19 +1292,28 @@ impl Default for Notebook { } } -/// Berechnet die DIN-A4-Darstellung im Fenster mit Letterboxing/Pillarboxing. +/// Lage der aktuellen Seite im Fenster bei Zoom 100 % ohne Verschiebung: Ein Rahmen der +/// Größe `fit_box` (alle Seiten des Notizbuchs passen hinein) wird ins Fenster eingepasst +/// (Letterboxing/Pillarboxing); die aktuelle Seite liegt darin waagerecht zentriert und oben. +/// So bleibt der Maßstab beim Wechsel zwischen verschieden großen Seiten gleich. #[derive(Debug, Clone, Copy, PartialEq)] pub struct PageViewport { pub window_width: f32, pub window_height: f32, + /// Bildschirmposition der linken oberen Ecke der aktuellen Seite pub page_x: f32, pub page_y: f32, + /// Größe der aktuellen Seite auf dem Bildschirm (Pixel) pub page_width: f32, pub page_height: f32, + /// Bildschirmpixel je Seiteneinheit + pub scale: f32, + /// Größe der aktuellen Seite in Seiteneinheiten + pub page_size: glam::Vec2, } impl PageViewport { - /// Berechnet die zentrierte DIN-A4-Projektionsfläche unter Beibehaltung des 210x297-Seitenverhältnisses. + /// Zentrierte DIN-A4-Seite im Fenster. pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self { Self::calculate_with_insets(window_width, window_height, margin, 0.0, 0.0) } @@ -1259,66 +1326,70 @@ impl PageViewport { margin: f32, inset_left: f32, inset_right: f32, + ) -> Self { + Self::calculate_for( + window_width, + window_height, + margin, + (inset_left, inset_right), + A4_SIZE, + A4_SIZE, + ) + } + + /// Wie [`Self::calculate_with_insets`] für eine Seite der Größe `page_size` in einem + /// Notizbuch, dessen Seiten in `fit_box` passen. + pub fn calculate_for( + window_width: f32, + window_height: f32, + margin: f32, + (inset_left, inset_right): (f32, f32), + fit_box: glam::Vec2, + page_size: glam::Vec2, ) -> Self { let inset_left = inset_left.clamp(0.0, window_width * 0.5); let inset_right = inset_right.clamp(0.0, window_width * 0.5); let avail_w = (window_width - inset_left - inset_right - 2.0 * margin).max(10.0); let avail_h = (window_height - 2.0 * margin).max(10.0); + let fit_box = fit_box.max(page_size).max(glam::Vec2::ONE); - let window_ratio = avail_w / avail_h; - let page_ratio = A4_ASPECT_RATIO; - - let (page_w, page_h) = if window_ratio > page_ratio { - // Fenster ist breiter als DIN A4 -> Pillarboxing (Balken links/rechts) - let h = avail_h; - let w = h * page_ratio; - (w, h) - } else { - // Fenster ist höher als DIN A4 -> Letterboxing (Balken oben/unten) - let w = avail_w; - let h = w / page_ratio; - (w, h) - }; - - let page_x = inset_left + (window_width - inset_left - inset_right - page_w) / 2.0; - let page_y = (window_height - page_h) / 2.0; + let scale = (avail_w / fit_box.x).min(avail_h / fit_box.y); + let box_x = + inset_left + (window_width - inset_left - inset_right - fit_box.x * scale) / 2.0; + let box_y = (window_height - fit_box.y * scale) / 2.0; Self { window_width, window_height, - page_x, - page_y, - page_width: page_w, - page_height: page_h, + page_x: box_x + (fit_box.x - page_size.x) / 2.0 * scale, + page_y: box_y, + page_width: page_size.x * scale, + page_height: page_size.y * scale, + scale, + page_size, } } - /// Transformiert einen Fenster-Pixelpunkt in DIN-A4-Seitenkoordinaten [0.0, PAGE_WIDTH] x [0.0, PAGE_HEIGHT]. + /// Transformiert einen Fenster-Pixelpunkt in Seitenkoordinaten. /// - /// Liefert `None`, falls der Punkt außerhalb der DIN-A4-Papierfläche liegt. + /// Liefert `None`, falls der Punkt außerhalb der Seite liegt. pub fn screen_to_page(&self, screen_x: f32, screen_y: f32) -> Option { - let u = (screen_x - self.page_x) / self.page_width; - let v = (screen_y - self.page_y) / self.page_height; - - if (0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v) { - Some(glam::Vec2::new(u * PAGE_WIDTH, v * PAGE_HEIGHT)) - } else { - None - } + let p = glam::Vec2::new(screen_x - self.page_x, screen_y - self.page_y) / self.scale; + ((0.0..=self.page_size.x).contains(&p.x) && (0.0..=self.page_size.y).contains(&p.y)) + .then_some(p) } - /// Transformiert einen Fenster-Pixelpunkt und klemmt ihn auf die DIN-A4-Papierfläche. + /// Transformiert einen Fenster-Pixelpunkt und klemmt ihn auf die Seite. pub fn screen_to_page_clamped(&self, screen_x: f32, screen_y: f32) -> glam::Vec2 { - let u = ((screen_x - self.page_x) / self.page_width).clamp(0.0, 1.0); - let v = ((screen_y - self.page_y) / self.page_height).clamp(0.0, 1.0); - glam::Vec2::new(u * PAGE_WIDTH, v * PAGE_HEIGHT) + let p = glam::Vec2::new(screen_x - self.page_x, screen_y - self.page_y) / self.scale; + p.clamp(glam::Vec2::ZERO, self.page_size) } /// Berechnet die Shader-Transformationsparameter (Scale, Offset), - /// um DIN-A4-Seitenkoordinaten direkt im Vertex-Shader in den NDC-Bereich [-1.0, 1.0] abzubilden. + /// um Seitenkoordinaten direkt im Vertex-Shader in den NDC-Bereich [-1.0, 1.0] abzubilden. pub fn ndc_transform(&self) -> ([f32; 2], [f32; 2]) { - let scale_x = (2.0 * self.page_width) / (self.window_width * PAGE_WIDTH); - let scale_y = (-2.0 * self.page_height) / (self.window_height * PAGE_HEIGHT); + let scale_x = (2.0 * self.scale) / self.window_width; + let scale_y = (-2.0 * self.scale) / self.window_height; let offset_x = (2.0 * self.page_x) / self.window_width - 1.0; let offset_y = 1.0 - (2.0 * self.page_y) / self.window_height; ([scale_x, scale_y], [offset_x, offset_y]) @@ -1584,24 +1655,95 @@ mod tests { assert!(page.strokes.is_empty()); } + #[test] + fn test_pdf_page_size_follows_orientation() { + // Hochformat (A4, Letter) bleibt A4 hoch + assert_eq!(pdf_page_size(595.0, 842.0), A4_SIZE); + assert_eq!(pdf_page_size(612.0, 792.0), A4_SIZE); + // 16:9-Folie: 297 mm breit, Höhe nach Seitenverhältnis + let slide = pdf_page_size(960.0, 540.0); + assert_eq!(slide, glam::Vec2::new(2970.0, 1671.0)); + // Die Folie füllt ihre Seite ganz aus + let [tl, _, br, _] = pdf_placement(960.0, 540.0, slide); + assert!( + tl.length() < 1e-3 && (br - slide).length() < 1.0, + "{tl} {br}" + ); + } + + #[test] + fn test_viewport_scale_is_shared_by_pages_of_different_size() { + let slide = glam::Vec2::new(2970.0, 1671.0); + // Nur Folien: die Folie wird ins Fenster eingepasst + let only_slides = + PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), slide, slide); + // 1560 × 860 frei: die Höhe begrenzt + assert!( + (only_slides.page_height - 860.0).abs() < 0.5, + "{only_slides:?}" + ); + assert!( + (only_slides.page_width / only_slides.page_height - slide.x / slide.y).abs() < 1e-3 + ); + // Gemischt: gleicher Maßstab für beide Seiten, schmale Seite waagerecht zentriert + let fit = A4_SIZE.max(slide); + let a4 = PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), fit, A4_SIZE); + let wide = PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), fit, slide); + assert_eq!(a4.scale, wide.scale); + let center = |v: &PageViewport| v.page_x + v.page_width / 2.0; + assert!((center(&a4) - center(&wide)).abs() < 1e-3); + assert_eq!(a4.page_y, wide.page_y); + // Bildschirm → Seite berücksichtigt die Seitengröße + let p = wide + .screen_to_page(wide.page_x + wide.page_width - 1.0, wide.page_y + 1.0) + .unwrap(); + assert!(p.x > 2900.0); + assert!(wide + .screen_to_page(wide.page_x + 5.0, wide.page_y + wide.page_height + 5.0) + .is_none()); + } + + #[test] + fn test_new_pages_take_size_of_current_page() { + let slide = glam::Vec2::new(2970.0, 1671.0); + let mut nb = Notebook::new(); + nb.pages[0].size = slide; + nb.add_page(PageBackground::Blank); + nb.insert_page_after_current(PageBackground::Blank); + nb.next_page(); + nb.current_page_index = nb.pages.len() - 1; + nb.next_page(); + assert!(nb.pages.iter().all(|p| p.size == slide)); + assert_eq!(nb.fit_box(), slide); + // Auto-Anhängen bezieht sich auf die Höhe der Folie + nb.current_page_index = nb.pages.len() - 1; + assert!(!nb.append_page_if_writing_at_end(slide.y * 0.7)); + assert!(nb.append_page_if_writing_at_end(slide.y * 0.9)); + } + #[test] fn test_place_image() { let center = glam::Vec2::new(PAGE_WIDTH / 2.0, PAGE_HEIGHT / 2.0); // Kleines Bild: Bildschirmgröße, zentriert - let [tl, tr, br, bl] = place_image(200, 100, center); + let [tl, tr, br, bl] = place_image(200, 100, center, A4_SIZE); assert!(((tr - tl).length() - 200.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3); assert!(((bl - tl).length() - 100.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3); assert!(((tl + br) / 2.0 - center).length() < 1e-3); // Großer Screenshot: auf 80 % der Breite verkleinert, Seitenverhältnis bleibt - let [tl, tr, br, _] = place_image(3840, 2160, center); + let [tl, tr, br, _] = place_image(3840, 2160, center, A4_SIZE); let size = br - tl; assert!((size.x - PAGE_WIDTH * 0.8).abs() < 1e-2); assert!((size.x / size.y - 3840.0 / 2160.0).abs() < 1e-3); assert_eq!(tr.y, tl.y); // Am Rand eingefügt: wird aufs Blatt geschoben - let corners = place_image(400, 400, glam::Vec2::new(-50.0, PAGE_HEIGHT + 50.0)); + let corners = place_image( + 400, + 400, + glam::Vec2::new(-50.0, PAGE_HEIGHT + 50.0), + A4_SIZE, + ); for c in corners { assert!(c.x >= -1e-3 && c.x <= PAGE_WIDTH + 1e-3); assert!(c.y >= -1e-3 && c.y <= PAGE_HEIGHT + 1e-3); diff --git a/src/export.rs b/src/export.rs index 2c5cef8..88ddffc 100644 --- a/src/export.rs +++ b/src/export.rs @@ -117,17 +117,43 @@ pub enum ExportStrategy { } /// Rechnet eine interne Seitenkoordinate (x, y) in PDF-Millimeter-Koordinaten um (mit Y-Inversion). +/// +/// Bezugshöhe ist die A4-Seite; für anders hohe Seiten verschiebt [`begin_page_layer`] die +/// Ebene entsprechend. #[inline] pub fn page_to_pdf_mm(x: f32, y: f32) -> (f32, f32) { - let x_norm = (x / PAGE_WIDTH).clamp(0.0, 1.0); - let y_norm = (y / PAGE_HEIGHT).clamp(0.0, 1.0); - - let x_mm = x_norm * A4_WIDTH_MM; - let y_mm = (1.0 - y_norm) * A4_HEIGHT_MM; // PDF-Ursprung liegt unten links - + let x_mm = x / PAGE_WIDTH * A4_WIDTH_MM; + let y_mm = (1.0 - y / PAGE_HEIGHT) * A4_HEIGHT_MM; // PDF-Ursprung liegt unten links (x_mm, y_mm) } +/// PDF-Punkte je Seiteneinheit. +const PT_PER_UNIT: f32 = A4_WIDTH_MM / PAGE_WIDTH * 72.0 / 25.4; + +/// Größe der Seite `page` im PDF. +fn pdf_page_mm(page: &Page) -> (Mm, Mm) { + let mm = page.size / crate::document::UNITS_PER_MM; + (Mm(mm.x), Mm(mm.y)) +} + +/// Beginnt eine Ebene: Koordinaten beziehen sich auf eine A4-hohe Seite (siehe +/// [`page_to_pdf_mm`]) und werden auf die tatsächliche Höhe von `page` verschoben. +fn begin_page_layer(layer: &printpdf::PdfLayerReference, page: &Page) { + layer.save_graphics_state(); + let dy = (page.size.y - PAGE_HEIGHT) * PT_PER_UNIT; + if dy != 0.0 { + layer.set_ctm(printpdf::CurTransMat::Translate( + printpdf::Pt(0.0), + printpdf::Pt(dy), + )); + } +} + +/// Beendet eine mit [`begin_page_layer`] begonnene Ebene. +fn end_page_layer(layer: &printpdf::PdfLayerReference) { + layer.restore_graphics_state(); +} + /// Erzeugt ein PDF-Dokument aus einem Notizbuch und speichert es als Datei. /// `assets`: Bilder des Notizbuchs (ohne werden Bilder als graue Fläche ausgegeben). pub fn export_notebook_to_pdf>( @@ -240,9 +266,11 @@ fn pdf_underlays(notebook: &Notebook) -> Vec { .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 corners = pdf.corners(page.size); + // Unterlagen werden ohne die Verschiebung von `begin_page_layer` gesetzt + let shift = glam::Vec2::new(0.0, (page.size.y - PAGE_HEIGHT) * PT_PER_UNIT); + let top_right = page_to_pdf_pt(corners[1]) + shift; + let bottom_left = page_to_pdf_pt(corners[3]) + shift; let size = top_right - bottom_left; Some(crate::pdf_merge::Underlay { target_page: i, @@ -292,62 +320,50 @@ fn generate_pdf_document_with( .into()); } - // 1. Erstes Dokument mit Seite 1 erstellen + // Erste Seite legt das Dokument an, alle weiteren werden angehängt (je in ihrer Größe) let title = "ink-notes Notebook"; - let (doc, page1_idx, layer1_bg) = - PdfDocument::new(title, Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background"); + let (width, height) = pdf_page_mm(¬ebook.pages[0]); + let (doc, page1_idx, layer1_bg) = PdfDocument::new(title, width, height, "Background"); let fonts = embed_text_fonts(&doc, notebook)?; - // Seitentitel werden zu Lesezeichen (Inhaltsverzeichnis im PDF-Betrachter) - if let Some(title) = ¬ebook.pages[0].title { - doc.add_bookmark(title.clone(), page1_idx); - } - - // Seite 1 befüllen - 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); - } - if notebook.pages[0].strokes.iter().any(Stroke::is_text) { - render_text_layer(&page1_ref.add_layer("Text"), ¬ebook.pages[0], &fonts); - } - - let layer1_ink_ref = page1_ref.add_layer("Ink"); - render_ink_layer(&layer1_ink_ref, ¬ebook.pages[0], strategy); - - // 2. Alle nachfolgenden Seiten hinzufügen - 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"); + for (i, page) in notebook.pages.iter().enumerate() { + let (page_idx, layer_bg) = if i == 0 { + (page1_idx, layer1_bg) + } else { + let (width, height) = pdf_page_mm(page); + doc.add_page(width, height, "Background") + }; + // Seitentitel werden zu Lesezeichen (Inhaltsverzeichnis im PDF-Betrachter) if let Some(title) = &page.title { doc.add_bookmark(title.clone(), page_idx); } 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); + render_background_layer(&page_ref.get_layer(layer_bg), page); + let layer = |name: &str, render: &dyn Fn(&printpdf::PdfLayerReference)| { + let layer = page_ref.add_layer(name); + begin_page_layer(&layer, page); + render(&layer); + end_page_layer(&layer); + }; if raster_pdf_pages.contains(&i) { - render_pdf_raster(&page_ref.add_layer("PDF"), page, assets); + layer("PDF", &|l| render_pdf_raster(l, page, assets)); } if page.strokes.iter().any(Stroke::is_image) { - render_image_layer(&page_ref.add_layer("Images"), page, assets); + layer("Images", &|l| render_image_layer(l, page, assets)); } if page.strokes.iter().any(Stroke::is_text) { - render_text_layer(&page_ref.add_layer("Text"), page, &fonts); + layer("Text", &|l| render_text_layer(l, page, &fonts)); } - - let layer_ink_ref = page_ref.add_layer("Ink"); - render_ink_layer(&layer_ink_ref, page, strategy); + layer("Ink", &|l| render_ink_layer(l, page, strategy)); } Ok(doc) } /// Rendert das Vektorraster auf Layer 1 ("Background"). -fn render_background_layer(layer: &printpdf::PdfLayerReference, background: PageBackground) { +fn render_background_layer(layer: &printpdf::PdfLayerReference, page: &Page) { + let background = page.background; + let (Mm(page_width_mm), Mm(page_height_mm)) = pdf_page_mm(page); let grid_color = Color::Rgb(Rgb::new(0.88, 0.88, 0.88, None)); layer.set_outline_color(grid_color.clone()); layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt) @@ -361,11 +377,11 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, background: Page // Vertikale Linien alle 5 mm let mut x = spacing_mm; - while x < A4_WIDTH_MM { + while x < page_width_mm { let line = Line { points: vec![ (Point::new(Mm(x), Mm(0.0)), false), - (Point::new(Mm(x), Mm(A4_HEIGHT_MM)), false), + (Point::new(Mm(x), Mm(page_height_mm)), false), ], is_closed: false, }; @@ -375,11 +391,11 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, background: Page // Horizontale Linien alle 5 mm let mut y = spacing_mm; - while y < A4_HEIGHT_MM { + while y < page_height_mm { let line = Line { points: vec![ (Point::new(Mm(0.0), Mm(y)), false), - (Point::new(Mm(A4_WIDTH_MM), Mm(y)), false), + (Point::new(Mm(page_width_mm), Mm(y)), false), ], is_closed: false, }; @@ -394,11 +410,11 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, background: Page let margin_bottom_mm = 15.0; let mut y = margin_bottom_mm; - while y <= A4_HEIGHT_MM - margin_top_mm { + while y <= page_height_mm - margin_top_mm { let line = Line { points: vec![ (Point::new(Mm(0.0), Mm(y)), false), - (Point::new(Mm(A4_WIDTH_MM), Mm(y)), false), + (Point::new(Mm(page_width_mm), Mm(y)), false), ], is_closed: false, }; @@ -412,9 +428,9 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, background: Page let half_dot = 0.2; // 0.4 mm Kantenlänge let mut y = spacing_mm; - while y < A4_HEIGHT_MM { + while y < page_height_mm { let mut x = spacing_mm; - while x < A4_WIDTH_MM { + while x < page_width_mm { let dot_poly = Polygon { rings: vec![vec![ (Point::new(Mm(x - half_dot), Mm(y - half_dot)), false), @@ -613,7 +629,7 @@ fn render_text_layer( let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height()); let (origin, [right, down]) = crate::text::frame_axes(&corners, size); // Seite → PDF-Punkte: x · k, Höhe − y · k - let k = A4_WIDTH_MM / PAGE_WIDTH * 72.0 / 25.4; + let k = PT_PER_UNIT; let o = page_to_pdf_pt(origin); let matrix = [right.x * k, -right.y * k, -down.x * k, down.y * k, o.x, o.y]; layer.save_graphics_state(); @@ -730,7 +746,7 @@ fn render_pdf_raster( placeholder_xobject() } }; - draw_image_xobject(layer, xobject, &pdf.corners()); + draw_image_xobject(layer, xobject, &pdf.corners(page.size)); } /// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem) @@ -1513,7 +1529,8 @@ mod tests { ); } - // Exportiertes PDF lässt sich wieder rendern: Folie oben, rotes Quadrat oben links + // Exportiertes PDF lässt sich wieder rendern: Querformat-Folie füllt ihre Seite, 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| { @@ -1521,10 +1538,10 @@ mod tests { 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" + let ratio = image.width as f32 / image.height as f32; + assert!( + (ratio - 842.0 / 595.0).abs() < 0.01, + "Seite im Folienformat: {ratio}" ); // Gedrehte Seite (/Rotate 90) erscheint wie im PDF-Betrachter: das Quadrat liegt diff --git a/src/main.rs b/src/main.rs index 1ae1294..e6e8360 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,7 +32,6 @@ use ink_notes::settings::{SidebarSide, UiSettings}; use ink_notes::storage::{self, NotebookManifest}; use ink_notes::ui::{self, ActiveTool, UiAction, UiState}; use ink_notes::updater::UpdaterController; -use ink_notes::viewport::PAGE_PITCH; /// Aktualisiert den Fenstertitel als minimales On-Screen-Display (OSD) inklusive Autosave-Status. /// Speichert die UI-Einstellungen; ein Fehler wird gemeldet, bricht aber nichts ab. @@ -197,10 +196,10 @@ fn switch_to_page( return; } if keep_view { - let k = index as i64 - current as i64; - canvas.shift_reference_page(k); + let origin = notebook.page_stack().origins[index]; + canvas.shift_reference_page(origin); if let Some(ruler) = &mut ui_state.ruler { - ruler.center.y -= k as f32 * PAGE_PITCH; + ruler.center -= origin; } canvas.ruler = ui_state.ruler; } @@ -234,11 +233,9 @@ fn page_to_focus( if !ui_state.continuous_scroll { return None; } - let k = canvas.page_offset_at_screen(sx, sy).filter(|&k| k != 0)?; - let index = usize::try_from(notebook.current_page_index as i64 + k).ok()?; - if index >= notebook.pages.len() { - return None; - } + let index = canvas + .page_at_screen(¬ebook.page_stack(), sx, sy) + .filter(|&i| i != notebook.current_page_index)?; let grabs_selection = ui_state.active_tool == ActiveTool::Select && !selection_state.is_empty() && !matches!( @@ -364,7 +361,12 @@ fn insert_image_on_page( ui_state: &mut UiState, ) { let id = notebook.next_stroke_id(); - let corners = ink_notes::document::place_image(imported.width, imported.height, center); + let corners = ink_notes::document::place_image( + imported.width, + imported.height, + center, + notebook.current_page().size, + ); let page = notebook.current_page_mut(); page.add_stroke(ink_notes::document::Stroke::new_image( id, @@ -587,6 +589,7 @@ fn insert_text_on_page( let mut edit = ink_notes::text::TextEditing::new_at( notebook.current_page().id, Vec2::new((center.x - 600.0).max(150.0), center.y), + notebook.current_page().size.x, ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT, ui_state.text_font, ui_state.text_color, @@ -680,6 +683,7 @@ fn text_tool_click( None => Some(ink_notes::text::TextEditing::new_at( page.id, page_pt, + page.size.x, ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT, ui_state.text_font, ui_state.text_color, @@ -2545,13 +2549,15 @@ fn main() -> Result<(), Box> { // der Fenstermitte zur aktuellen (nicht während eines Strichs, einer // Auswahl oder beim Ziehen des Lineals). Wer auf einer Nachbarseite // schreibt, bleibt dort, bis wieder gescrollt wird (Undo betrifft sie). + canvas.set_fit_box(notebook.fit_box()); if ui_state.continuous_scroll { - canvas.clamp_scroll(notebook.current_page_index, notebook.pages.len()); + let stack = notebook.page_stack(); + canvas.clamp_scroll(&stack); + // Unabhängig von der Bezugsseite: Lage bezogen auf die erste Seite let scroll = [ canvas.camera.zoom, canvas.camera.pan_offset[0], - canvas.camera.pan_offset[1] - - notebook.current_page_index as f32 * PAGE_PITCH, + canvas.camera.pan_offset[1] + stack.origins[0].y, ]; let scrolled = last_scroll .is_none_or(|last: [f32; 3]| last.iter().zip(scroll).any(|(a, b)| (a - b).abs() > 1e-3)); @@ -2564,9 +2570,8 @@ fn main() -> Result<(), Box> { if scrolled && !busy { last_scroll = Some(scroll); let target = canvas - .page_offset_at_view_center() - .filter(|&k| k != 0) - .and_then(|k| usize::try_from(notebook.current_page_index as i64 + k).ok()); + .page_at_view_center(&stack) + .filter(|&i| i != notebook.current_page_index); if let Some(index) = target { switch_to_page( index, @@ -2770,15 +2775,6 @@ fn main() -> Result<(), Box> { canvas.sync_page(notebook.current_page_mut()); manifest.current_page_index = notebook.current_page_index; manifest.next_page_id = notebook.next_page_id; - manifest.pages = notebook - .pages - .iter() - .map(|p| storage::PageMeta { - id: p.id, - background: Some(p.background), - title: p.title.clone(), - }) - .collect(); manifest.sync_from_notebook(¬ebook); autosave.mark_manifest_dirty(&manifest); autosave.mark_dirty(¬ebook, &mut manifest); @@ -3249,19 +3245,15 @@ fn main() -> Result<(), Box> { // 5. Canvas (Pass 1) und egui UI (Pass 2) in Surface-Textur rendern // Durchgehende Ansicht: sichtbare Nachbarseiten laden und mitzeichnen if ui_state.continuous_scroll { - let current = notebook.current_page_index; - let visible: Vec<(i64, usize)> = canvas - .visible_neighbor_offsets(current, notebook.pages.len()) - .into_iter() - .map(|k| (k, (current as i64 + k) as usize)) - .collect(); - for &(_, index) in &visible { + let stack = notebook.page_stack(); + let visible = canvas.visible_neighbors(&stack); + for &index in &visible { let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, index); notebook.pages[index].rebuild_cache_if_dirty(); } - let neighbors: Vec<(i64, &ink_notes::document::Page)> = visible + let neighbors: Vec<(Vec2, &ink_notes::document::Page)> = visible .iter() - .map(|&(k, index)| (k, ¬ebook.pages[index])) + .map(|&index| (stack.origins[index], ¬ebook.pages[index])) .collect(); canvas.set_neighbor_pages(&neighbors); } else { diff --git a/src/page_export.rs b/src/page_export.rs index 97f2bff..7299b26 100644 --- a/src/page_export.rs +++ b/src/page_export.rs @@ -296,6 +296,26 @@ mod tests { assert!((rotated.2 as f32) < 300.0 * px_per_unit + 2.0); } + #[test] + fn test_landscape_page_export_keeps_positions() { + // Strich unten rechts auf einer Querformat-Seite: im PNG an derselben Stelle + let mut page = page_with_line(); + page.size = glam::Vec2::new(2970.0, 1671.0); + page.strokes[0].translate(glam::Vec2::new(1900.0, 500.0)); // x 2400–2800, y 1500 + let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap()); + let ratio = png.width() as f32 / png.height() as f32; + assert!((ratio - 2970.0 / 1671.0).abs() < 0.01, "{ratio}"); + let px_per_unit = png.width() as f32 / 2970.0; + let at = |x: f32, y: f32| { + png.get_pixel((x * px_per_unit) as u32, (y * px_per_unit) as u32) + .0 + }; + assert!(at(2600.0, 1500.0)[3] > 200, "Strich liegt unten rechts"); + assert!(at(2600.0, 1300.0)[3] == 0); + let svg = page_svg(&page, None).unwrap(); + assert!(svg.contains(" u32 { @@ -186,9 +187,27 @@ pub struct PageData { /// Seitentitel (steht zusätzlich im Manifest) #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, + /// Seitengröße in Seiteneinheiten, wenn nicht DIN A4 hoch (steht zusätzlich im Manifest) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option<[f32; 2]>, pub strokes: Vec, } +/// Gespeicherte Seitengröße: `None` für DIN A4 hoch. +fn stored_size(size: glam::Vec2) -> Option<[f32; 2]> { + (size != crate::document::A4_SIZE).then(|| size.to_array()) +} + +/// Seitengröße aus einer (evtl. von Hand bearbeiteten) Datei; unbrauchbare Werte ergeben A4. +pub fn page_size_from(stored: Option<[f32; 2]>) -> glam::Vec2 { + match stored { + Some([w, h]) if w.is_finite() && h.is_finite() && w >= 100.0 && h >= 100.0 => { + glam::Vec2::new(w.min(20_000.0), h.min(20_000.0)) + } + _ => crate::document::A4_SIZE, + } +} + impl From<&Page> for PageData { fn from(page: &Page) -> Self { Self { @@ -197,6 +216,7 @@ impl From<&Page> for PageData { background: page.background, pdf_background: page.pdf_background.clone(), title: page.title.clone(), + size: stored_size(page.size), strokes: page.strokes.iter().map(StrokeData::from).collect(), } } @@ -205,7 +225,7 @@ impl From<&Page> for PageData { 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); + let mut page = Page::new(self.id, self.background).with_size(page_size_from(self.size)); page.pdf_background = self.pdf_background.clone(); page.title = self.title.clone(); for stroke_data in &self.strokes { @@ -227,6 +247,9 @@ pub struct PageMeta { /// Seitentitel (Lesezeichen), damit die Seitenübersicht ihn ohne Laden der Seite kennt #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, + /// Seitengröße, wenn nicht DIN A4 hoch (für Lage und Maßstab ungeladener Seiten) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option<[f32; 2]>, } impl PageMeta { @@ -235,6 +258,7 @@ impl PageMeta { id, background, title: None, + size: None, } } } @@ -292,6 +316,15 @@ impl NotebookManifest { .and_then(|p| p.title.clone()) } + pub fn size_for_page(&self, page_id: u64) -> glam::Vec2 { + page_size_from( + self.pages + .iter() + .find(|p| p.id == page_id) + .and_then(|p| p.size), + ) + } + pub fn background_for_page(&self, page_id: u64) -> Option { self.pages .iter() @@ -308,6 +341,7 @@ impl NotebookManifest { id: p.id, background: Some(p.background), title: p.title.clone(), + size: stored_size(p.size), }) .collect(); self.page_ids = self.pages.iter().map(|p| p.id).collect(); @@ -718,7 +752,7 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest) quarantine_path: q_path, }, ); - let mut page = Page::new(id, first_bg); + let mut page = Page::new(id, first_bg).with_size(manifest.size_for_page(id)); page.persist = PersistState::ReadOnlyAfterLoadError; page } @@ -734,13 +768,15 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest) quarantine_path: None, }, ); - let mut page = Page::new(first_id, first_bg); + let mut page = + Page::new(first_id, first_bg).with_size(manifest.size_for_page(first_id)); page.persist = PersistState::ReadOnlyAfterLoadError; page } Err(e) => { eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}. Erstelle leere Ersatzseite."); - let mut page = Page::new(first_id, first_bg); + let mut page = + Page::new(first_id, first_bg).with_size(manifest.size_for_page(first_id)); page.persist = PersistState::ReadOnlyAfterLoadError; page } @@ -752,7 +788,7 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest) let bg = manifest .background_for_page(page_id) .unwrap_or(PageBackground::Grid5mm); - pages.push(Page::new_unloaded(page_id, bg)); + pages.push(Page::new_unloaded(page_id, bg).with_size(manifest.size_for_page(page_id))); } // Titel aus dem Manifest (Platzhalter und Ersatzseiten kennen sie sonst nicht) @@ -808,7 +844,7 @@ pub fn ensure_page_loaded( // Angehängt, aber nie gespeichert (noch leer): leere Seite statt Ladefehler Err(StorageError::PageNotFound(_)) => { let placeholder = &mut notebook.pages[page_idx]; - let mut page = Page::new(page_id, placeholder.background); + let mut page = Page::new(page_id, placeholder.background).with_size(placeholder.size); page.title = placeholder.title.take(); *placeholder = page; Ok(()) @@ -1132,7 +1168,8 @@ pub fn create_notebook_from_pdf( .iter() .enumerate() .map(|(i, &(width_pt, height_pt))| { - let mut page = Page::new(i as u64 + 1, PageBackground::Blank); + let mut page = Page::new(i as u64 + 1, PageBackground::Blank) + .with_size(crate::document::pdf_page_size(width_pt, height_pt)); page.pdf_background = Some(crate::document::PdfBackground { asset: asset.clone(), page: i as u32, @@ -1358,7 +1395,9 @@ pub fn load_all_pages(notebook_dir: &Path, notebook: &mut Notebook) -> Result<() *page = match load_page(notebook_dir, page.id) { Ok(data) => data.to_page(), // Wie beim Lazy Loading: nie gespeicherte Seite = leere Seite - Err(StorageError::PageNotFound(_)) => Page::new(page.id, page.background), + Err(StorageError::PageNotFound(_)) => { + Page::new(page.id, page.background).with_size(page.size) + } Err(e) => return Err(e), }; page.title = page.title.take().or(title); @@ -2525,6 +2564,35 @@ mod tests { assert!(stroke.is_text() && !stroke.is_image() && stroke.vertices.is_empty()); } + #[test] + fn test_page_size_round_trips_and_is_known_before_loading() { + let temp_dir = tempfile::tempdir().unwrap(); + let (mut notebook, mut manifest, dir) = + create_new_notebook(temp_dir.path(), "Folien").unwrap(); + let slide = glam::Vec2::new(2970.0, 1671.0); + notebook.pages[0].size = slide; + notebook.add_page(PageBackground::Blank); + notebook.add_page(PageBackground::Blank); + notebook.pages[2].size = crate::document::A4_SIZE; + save_notebook(&dir, ¬ebook, &mut manifest).unwrap(); + + let (mut reloaded, _) = load_notebook(&dir).unwrap(); + let sizes: Vec = reloaded.pages.iter().map(|p| p.size).collect(); + assert_eq!(sizes, vec![slide, slide, crate::document::A4_SIZE]); + // Ungeladene Platzhalter kennen ihre Größe aus dem Manifest + assert!(!reloaded.pages[1].is_loaded); + ensure_page_loaded(&dir, &mut reloaded, 1).unwrap(); + assert_eq!(reloaded.pages[1].size, slide); + // A4-Seiten speichern keine Größe (Dateien bleiben wie bisher) + let data = load_page(&dir, reloaded.pages[2].id).unwrap(); + assert_eq!(data.size, None); + // Unsinnige Werte aus einer bearbeiteten Datei ergeben A4 + assert_eq!( + page_size_from(Some([f32::NAN, 5.0])), + crate::document::A4_SIZE + ); + } + #[test] fn test_create_notebook_from_pdf() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/src/text.rs b/src/text.rs index 22f2eb9..9a3f137 100644 --- a/src/text.rs +++ b/src/text.rs @@ -14,7 +14,7 @@ use ab_glyph::{Font, FontRef, GlyphId, PxScale}; use serde::{Deserialize, Serialize}; use crate::assets::RgbaImage; -use crate::document::{Stroke, PAGE_WIDTH, UNITS_PER_MM}; +use crate::document::{Stroke, UNITS_PER_MM}; /// Schriftart eines Textfelds. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] @@ -270,15 +270,16 @@ pub struct TextEditing { impl TextEditing { /// Neues, leeres Feld: die erste Zeile mittig auf `pos`, umbrochen 1 cm vor dem rechten - /// Blattrand (mindestens 3 cm breit). + /// Rand eines Blatts der Breite `page_width` (mindestens 3 cm breit). pub fn new_at( page_id: u64, pos: glam::Vec2, + page_width: f32, size: f32, font: TextFont, color: [f32; 4], ) -> Self { - let right = PAGE_WIDTH - RIGHT_MARGIN; + let right = page_width - RIGHT_MARGIN; let x = pos.x.min(right - MIN_WRAP_WIDTH).max(0.0); let text = TextBox::new(String::new(), size, font, (right - x).max(MIN_WRAP_WIDTH)); let line_height = text.layout().line_height; @@ -400,6 +401,7 @@ pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage #[cfg(test)] mod tests { use super::*; + use crate::document::PAGE_WIDTH; fn sample(content: &str, wrap_width: f32) -> TextBox { TextBox::new(content.to_string(), 50.0, TextFont::Sans, wrap_width) @@ -483,6 +485,7 @@ mod tests { let edit = TextEditing::new_at( 1, glam::Vec2::new(500.0, 800.0), + PAGE_WIDTH, 50.0, TextFont::Sans, [0.0; 4], @@ -496,6 +499,7 @@ mod tests { let edge = TextEditing::new_at( 1, glam::Vec2::new(2050.0, 100.0), + PAGE_WIDTH, 50.0, TextFont::Sans, [0.0; 4], @@ -509,6 +513,7 @@ mod tests { let mut edit = TextEditing::new_at( 1, glam::Vec2::new(400.0, 400.0), + PAGE_WIDTH, 50.0, TextFont::Sans, [0.0; 4], diff --git a/src/ui.rs b/src/ui.rs index 6724d33..3f5809e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -929,27 +929,37 @@ const PAGE_TILE: egui::Vec2 = egui::vec2(66.0, 66.0 * PAGE_HEIGHT / PAGE_WIDTH + /// Höchstzahl gezeichneter Punkte pro Vorschau (dünnt volle Seiten aus). const THUMBNAIL_POINT_BUDGET: usize = 1500; -/// Zeichenbefehle für die verkleinerte Vorschau einer Seite in `rect` (A4-Format) in der -/// Papierfarbe `paper`. Striche werden als Linien gezeichnet und bei vielen Punkten -/// gleichmäßig ausgedünnt; `adapt_ink` hellt dunkle Tinte wie auf dem Blatt auf. +/// Zeichenbefehle für die verkleinerte Vorschau einer Seite in `rect` in der Papierfarbe +/// `paper`: so groß wie möglich, waagerecht zentriert und oben (Querformat-Seiten sind +/// flacher). Striche werden als Linien gezeichnet und bei vielen Punkten gleichmäßig +/// ausgedünnt; `adapt_ink` hellt dunkle Tinte wie auf dem Blatt auf. pub fn page_thumbnail_shapes( page: &Page, rect: egui::Rect, paper: PaperTheme, adapt_ink: bool, ) -> Vec { + let scale = (rect.width() / page.size.x).min(rect.height() / page.size.y); + let size = egui::vec2(page.size.x, page.size.y) * scale; + let sheet = egui::Rect::from_min_size( + rect.min + egui::vec2((rect.width() - size.x) / 2.0, 0.0), + size, + ); let mut shapes = vec![egui::Shape::rect_filled( - rect, + sheet, egui::Rounding::same(3.0), linear_color32(paper.paper_color()), )]; let adapt = adapt_ink && paper.is_dark(); - let scale = rect.width() / PAGE_WIDTH; - let to_screen = |p: glam::Vec2| rect.min + egui::vec2(p.x * scale, p.y * scale); + let to_screen = |p: glam::Vec2| sheet.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(); + let points: Vec = pdf + .corners(page.size) + .iter() + .map(|&c| to_screen(c)) + .collect(); shapes.push(egui::Shape::convex_polygon( points, egui::Color32::from_rgb(236, 238, 243), @@ -3763,6 +3773,7 @@ mod tests { state.text_edit = Some(TextEditing::new_at( notebook.current_page().id, glam::Vec2::new(200.0, 300.0), + PAGE_WIDTH, 42.0, TextFont::Sans, [0.0, 0.0, 0.0, 1.0], diff --git a/src/viewport.rs b/src/viewport.rs index 3f2e013..2c3b67e 100644 --- a/src/viewport.rs +++ b/src/viewport.rs @@ -3,7 +3,7 @@ //! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel), //! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]). -use crate::document::{PageViewport, PAGE_HEIGHT, PAGE_WIDTH, UNITS_PER_MM}; +use crate::document::{PageViewport, UNITS_PER_MM}; use glam::{Mat4, Vec2, Vec4}; /// Minimaler und maximaler Zoomfaktor. @@ -12,43 +12,89 @@ 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; -/// Vertikaler Abstand gleicher Punkte zweier aufeinanderfolgender Seiten. -pub const PAGE_PITCH: f32 = PAGE_HEIGHT + PAGE_GAP; /// Höchstens so viele Nachbarseiten werden gleichzeitig dargestellt. pub const MAX_NEIGHBOR_PAGES: usize = 12; -/// Durchgehende Ansicht: Die Seiten hängen untereinander, Koordinaten beziehen sich auf die -/// aktuelle Seite. Liefert den Versatz (in Seiten) der Seite, auf der die Höhe `y` liegt, -/// oder `None` im Zwischenraum zweier Seiten. -pub fn page_offset_at(y: f32) -> Option { - let k = (y / PAGE_PITCH).floor(); - (y - k * PAGE_PITCH <= PAGE_HEIGHT).then_some(k as i64) +/// Durchgehende Ansicht: Die Seiten hängen mit [`PAGE_GAP`] Abstand untereinander und sind +/// waagerecht zueinander zentriert. Lage jeder Seite (Ecke oben links) in Koordinaten der +/// aktuellen Seite. +#[derive(Debug, Clone, PartialEq)] +pub struct PageStack { + pub origins: Vec, + pub sizes: Vec, + pub current: usize, } -/// Versätze der Seiten, die zwischen den Höhen `top` und `bottom` (Koordinaten der aktuellen -/// Seite) sichtbar sind, begrenzt auf die Seitenindizes `0..page_count` und ohne die aktuelle -/// Seite selbst. -pub fn visible_neighbor_offsets( - top: f32, - bottom: f32, - current: usize, - page_count: usize, -) -> Vec { - if !(top.is_finite() && bottom.is_finite()) || bottom < top { - return Vec::new(); +impl PageStack { + /// Stapel aus den Seitengrößen; `current` ist die Bezugsseite. + pub fn new(sizes: Vec, current: usize) -> Self { + let current = current.min(sizes.len().saturating_sub(1)); + let width = sizes.get(current).map_or(0.0, |s| s.x); + let mut origins = vec![Vec2::ZERO; sizes.len()]; + let x = |i: usize| (width - sizes[i].x) / 2.0; + for i in current + 1..sizes.len() { + let y = origins[i - 1].y + sizes[i - 1].y + PAGE_GAP; + origins[i] = Vec2::new(x(i), y); + } + for i in (0..current).rev() { + let y = origins[i + 1].y - PAGE_GAP - sizes[i].y; + origins[i] = Vec2::new(x(i), y); + } + Self { + origins, + sizes, + current, + } } - let first = (top / PAGE_PITCH).floor().max(-(current as f32)) as i64; - let last = (bottom / PAGE_PITCH) - .floor() - .min((page_count as f32) - 1.0 - current as f32) as i64; - let mut offsets: Vec = (first..=last).filter(|&k| k != 0).collect(); - // Sehr weit herausgezoomt: nur die nächstgelegenen Seiten - if offsets.len() > MAX_NEIGHBOR_PAGES { - offsets.sort_by_key(|k| k.unsigned_abs()); - offsets.truncate(MAX_NEIGHBOR_PAGES); - offsets.sort_unstable(); + + pub fn len(&self) -> usize { + self.sizes.len() + } + + pub fn is_empty(&self) -> bool { + self.sizes.is_empty() + } + + /// Seite, auf deren Höhe `y` liegt; `None` im Zwischenraum oder außerhalb. + pub fn page_at(&self, y: f32) -> Option { + // Seiten liegen nach Index sortiert untereinander + let i = self.origins.partition_point(|o| o.y <= y).checked_sub(1)?; + (y <= self.origins[i].y + self.sizes[i].y).then_some(i) + } + + /// Seiten (außer der aktuellen), die zwischen den Höhen `top` und `bottom` sichtbar sind; + /// sehr weit herausgezoomt nur die [`MAX_NEIGHBOR_PAGES`] nächstgelegenen. + pub fn visible_neighbors(&self, top: f32, bottom: f32) -> Vec { + if !(top.is_finite() && bottom.is_finite()) || bottom < top { + return Vec::new(); + } + let first = self + .origins + .iter() + .zip(&self.sizes) + .position(|(o, s)| o.y + s.y >= top); + let Some(first) = first else { + return Vec::new(); + }; + let mut pages: Vec = (first..self.len()) + .take_while(|&i| self.origins[i].y <= bottom) + .filter(|&i| i != self.current) + .collect(); + if pages.len() > MAX_NEIGHBOR_PAGES { + pages.sort_by_key(|&i| i.abs_diff(self.current)); + pages.truncate(MAX_NEIGHBOR_PAGES); + pages.sort_unstable(); + } + pages + } + + /// Kleinstes Rechteck um alle Seiten (min, max). + pub fn bounds(&self) -> (Vec2, Vec2) { + self.origins.iter().zip(&self.sizes).fold( + (Vec2::splat(f32::INFINITY), Vec2::splat(f32::NEG_INFINITY)), + |(min, max), (&o, &s)| (min.min(o), max.max(o + s)), + ) } - offsets } /// Verwaltet den Viewport-Kamerazustand für stufenlosen Zoom und Pan. @@ -80,10 +126,13 @@ impl ViewportState { self.pan_offset = [0.0, 0.0]; } - /// Durchgehende Ansicht: Die Seite `k` Seiten weiter wird zur Bezugsseite, ohne dass sich - /// der Bildausschnitt bewegt. - pub fn shift_reference_page(&mut self, k: i64) { - self.pan_offset[1] += k as f32 * PAGE_PITCH; + /// Durchgehende Ansicht: Die Seite mit der Ecke `origin` (Koordinaten der bisherigen + /// Bezugsseite) wird zur Bezugsseite, ohne dass sich der Bildausschnitt bewegt. Waagerecht + /// zentriert [`PageViewport`] die neue Seite bereits (ungezoomt); der Pan gleicht nur den + /// Zoomanteil aus. + pub fn shift_reference_page(&mut self, origin: Vec2) { + self.pan_offset[0] += origin.x * (1.0 - 1.0 / self.zoom.max(1e-6)); + self.pan_offset[1] += origin.y; } /// Durchgehende Ansicht: Verschiebt den Ausschnitt so, dass der Bildschirmpunkt `center` @@ -92,14 +141,15 @@ impl ViewportState { pub fn clamp_continuous_center( &mut self, center: [f32; 2], - current: usize, - page_count: usize, + stack: &PageStack, page_viewport: &PageViewport, ) -> bool { + if stack.is_empty() { + return false; + } let c = self.screen_to_page_unclamped(center[0], center[1], page_viewport); - let top = -(current as f32) * PAGE_PITCH; - let bottom = page_count.saturating_sub(1 + current) as f32 * PAGE_PITCH + PAGE_HEIGHT; - let clamped = Vec2::new(c.x.clamp(0.0, PAGE_WIDTH), c.y.clamp(top, bottom.max(top))); + let (min, max) = stack.bounds(); + let clamped = c.clamp(min, max.max(min)); if (clamped - c).length() < 1e-3 { return false; } @@ -116,7 +166,7 @@ impl ViewportState { delta_y: f32, page_viewport: &PageViewport, ) { - let base_scale = page_viewport.page_width / PAGE_WIDTH; + let base_scale = page_viewport.scale; if base_scale * self.zoom > 1e-6 { self.pan_offset[0] += delta_x / (base_scale * self.zoom); self.pan_offset[1] += delta_y / (base_scale * self.zoom); @@ -138,7 +188,7 @@ impl ViewportState { return; } - let base_scale = page_viewport.page_width / PAGE_WIDTH; + let base_scale = page_viewport.scale; // Bildschirmposition des Seitenursprungs (0, 0) let old_offset_x = page_viewport.page_x + self.pan_offset[0] * base_scale * old_zoom; @@ -160,7 +210,7 @@ impl ViewportState { screen_y: f32, page_viewport: &PageViewport, ) -> Vec2 { - let base_scale = page_viewport.page_width / PAGE_WIDTH; + let base_scale = page_viewport.scale; let effective_scale = base_scale * self.zoom; if effective_scale < 1e-6 { return Vec2::ZERO; @@ -182,7 +232,8 @@ impl ViewportState { page_viewport: &PageViewport, ) -> Option { let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport); - if (0.0..=PAGE_WIDTH).contains(&pt.x) && (0.0..=PAGE_HEIGHT).contains(&pt.y) { + let size = page_viewport.page_size; + if (0.0..=size.x).contains(&pt.x) && (0.0..=size.y).contains(&pt.y) { Some(pt) } else { None @@ -197,7 +248,7 @@ impl ViewportState { page_viewport: &PageViewport, ) -> Vec2 { let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport); - Vec2::new(pt.x.clamp(0.0, PAGE_WIDTH), pt.y.clamp(0.0, PAGE_HEIGHT)) + pt.clamp(Vec2::ZERO, page_viewport.page_size) } /// Transformiert einen DIN-A4-Seitenpunkt in Fenster-Pixelkoordinaten. @@ -212,7 +263,7 @@ impl ViewportState { page_y: f32, page_viewport: &PageViewport, ) -> [f32; 2] { - let base_scale = page_viewport.page_width / PAGE_WIDTH; + let base_scale = page_viewport.scale; let effective_scale = base_scale * self.zoom; let screen_x = page_viewport.page_x + (page_x + self.pan_offset[0]) * effective_scale; let screen_y = page_viewport.page_y + (page_y + self.pan_offset[1]) * effective_scale; @@ -222,7 +273,7 @@ impl ViewportState { /// Berechnet die Shader-Transformationsparameter (Scale, Offset) für den Vertex-Shader, /// um DIN-A4-Seitenkoordinaten direkt in den GPU-NDC-Bereich [-1.0, 1.0] abzubilden. pub fn ndc_transform(&self, page_viewport: &PageViewport) -> ([f32; 2], [f32; 2]) { - let base_scale = page_viewport.page_width / PAGE_WIDTH; + let base_scale = page_viewport.scale; let effective_scale = base_scale * self.zoom; // ndc_x = (screen_x / window_width) * 2.0 - 1.0 @@ -260,30 +311,54 @@ impl ViewportState { mod tests { use super::*; + const A4: Vec2 = crate::document::A4_SIZE; + const PITCH: f32 = A4.y + PAGE_GAP; + + fn a4_stack(count: usize, current: usize) -> PageStack { + PageStack::new(vec![A4; count], current) + } + #[test] fn test_continuous_layout() { - assert_eq!(page_offset_at(100.0), Some(0)); - assert_eq!(page_offset_at(PAGE_HEIGHT + PAGE_GAP / 2.0), None); - assert_eq!(page_offset_at(PAGE_PITCH + 5.0), Some(1)); - assert_eq!(page_offset_at(-5.0), None); - assert_eq!(page_offset_at(-PAGE_GAP - 5.0), Some(-1)); + let stack = a4_stack(5, 1); + assert_eq!(stack.page_at(100.0), Some(1)); + assert_eq!(stack.page_at(A4.y + PAGE_GAP / 2.0), None); + assert_eq!(stack.page_at(PITCH + 5.0), Some(2)); + assert_eq!(stack.page_at(-5.0), None); + assert_eq!(stack.page_at(-PAGE_GAP - 5.0), Some(0)); + assert_eq!(stack.page_at(-PITCH - 5.0), None, "vor der ersten Seite"); // Ende von Seite 2 und Anfang von Seite 3 sichtbar (aktuell: Seite 2 von 5) assert_eq!( - visible_neighbor_offsets(PAGE_HEIGHT - 500.0, PAGE_PITCH + 500.0, 1, 5), - vec![1] + stack.visible_neighbors(A4.y - 500.0, PITCH + 500.0), + vec![2] ); // Ganz oben im Notizbuch: keine Seite vor der ersten - assert!(visible_neighbor_offsets(-PAGE_PITCH * 3.0, 500.0, 0, 5).is_empty()); + assert!(a4_stack(5, 0) + .visible_neighbors(-PITCH * 3.0, 500.0) + .is_empty()); // Hinter der letzten Seite: keine weiteren assert_eq!( - visible_neighbor_offsets(-PAGE_PITCH, PAGE_PITCH * 4.0, 3, 5), - vec![-1, 1] + a4_stack(5, 3).visible_neighbors(-PITCH, PITCH * 4.0), + vec![2, 4] ); // Weit herausgezoomt: auf die nächstgelegenen Seiten begrenzt - let many = visible_neighbor_offsets(-PAGE_PITCH * 50.0, PAGE_PITCH * 50.0, 50, 200); + let many = a4_stack(200, 50).visible_neighbors(-PITCH * 50.0, PITCH * 50.0); assert_eq!(many.len(), MAX_NEIGHBOR_PAGES); - assert!(many.iter().all(|k| k.unsigned_abs() <= 6)); + assert!(many.iter().all(|i| i.abs_diff(50) <= 6)); + } + + #[test] + fn test_stack_of_different_page_sizes() { + let slide = Vec2::new(2970.0, 1671.0); + let stack = PageStack::new(vec![slide, A4, slide], 1); + // Folien waagerecht zur A4-Seite zentriert, mit Abstand darüber und darunter + assert_eq!(stack.origins[0], Vec2::new(-435.0, -PAGE_GAP - slide.y)); + assert_eq!(stack.origins[2], Vec2::new(-435.0, A4.y + PAGE_GAP)); + assert_eq!(stack.page_at(A4.y + PAGE_GAP + 10.0), Some(2)); + let (min, max) = stack.bounds(); + assert_eq!(min, Vec2::new(-435.0, -PAGE_GAP - slide.y)); + assert_eq!(max, Vec2::new(2535.0, A4.y + PAGE_GAP + slide.y)); } #[test] @@ -292,33 +367,51 @@ mod tests { let page_vp = PageViewport::calculate(1920.0, 1080.0, 24.0); vp.pan_by_screen_delta(0.0, -900.0, &page_vp); // Punkt oben auf der nächsten Seite, in Koordinaten der aktuellen Seite - let before = vp.page_to_screen(300.0, PAGE_PITCH + 200.0, &page_vp); - vp.shift_reference_page(1); + let before = vp.page_to_screen(300.0, PITCH + 200.0, &page_vp); + vp.shift_reference_page(a4_stack(2, 0).origins[1]); let after = vp.page_to_screen(300.0, 200.0, &page_vp); assert!((before[0] - after[0]).abs() < 0.01 && (before[1] - after[1]).abs() < 0.01); } + #[test] + fn test_shift_to_page_of_other_size_keeps_view_when_zoomed() { + let slide = Vec2::new(2970.0, 1671.0); + let fit = Vec2::new(2970.0, 2970.0); + let view_a4 = PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), fit, A4); + let view_slide = PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), fit, slide); + let stack = PageStack::new(vec![A4, slide], 0); + let mut vp = ViewportState::new(); + vp.zoom_at_cursor([500.0, 300.0], 2.5, &view_a4); + vp.pan_by_screen_delta(-40.0, -700.0, &view_a4); + let point = Vec2::new(900.0, 300.0); + let before = vp.page_to_screen_vec2(stack.origins[1] + point, &view_a4); + vp.shift_reference_page(stack.origins[1]); + let after = vp.page_to_screen_vec2(point, &view_slide); + assert!((before[0] - after[0]).abs() < 0.01 && (before[1] - after[1]).abs() < 0.01); + } + #[test] fn test_continuous_scroll_stays_on_the_notebook() { let page_vp = PageViewport::calculate(1280.0, 850.0, 24.0); let center = [640.0, 425.0]; + let stack = a4_stack(3, 1); let mut vp = ViewportState::new(); // Mitte der Seite: nichts zu korrigieren - assert!(!vp.clamp_continuous_center(center, 1, 3, &page_vp)); + assert!(!vp.clamp_continuous_center(center, &stack, &page_vp)); // Weit über die erste Seite hinaus nach oben gescrollt (aktuell: Seite 2 von 3) vp.pan_by_screen_delta(0.0, 5000.0, &page_vp); - assert!(vp.clamp_continuous_center(center, 1, 3, &page_vp)); + assert!(vp.clamp_continuous_center(center, &stack, &page_vp)); let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp); assert!( - (c.y + PAGE_PITCH).abs() < 0.1, + (c.y + PITCH).abs() < 0.1, "Oberkante der ersten Seite: {c:?}" ); // Weit nach unten: Unterkante der letzten Seite vp.pan_by_screen_delta(-3000.0, -20000.0, &page_vp); - assert!(vp.clamp_continuous_center(center, 1, 3, &page_vp)); + assert!(vp.clamp_continuous_center(center, &stack, &page_vp)); let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp); - assert!((c.y - (PAGE_PITCH + PAGE_HEIGHT)).abs() < 0.1, "{c:?}"); - assert!((c.x - PAGE_WIDTH).abs() < 0.1, "{c:?}"); + assert!((c.y - (PITCH + A4.y)).abs() < 0.1, "{c:?}"); + assert!((c.x - A4.x).abs() < 0.1, "{c:?}"); } #[test]