feat: Seitenformat folgt dem PDF (Querformat für Folien)
Seiten haben jetzt eine eigene Größe. 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 (Issue #4). Eingefügte und angehängte Seiten übernehmen das Format der aktuellen Seite. Anzeige: Maßstab aus dem Rahmen aller Seiten, die durchgehende Ansicht stapelt verschieden große Seiten zentriert untereinander. Export (PDF, PNG, SVG) in der jeweiligen Seitengröße. Dateiformat-Version 8 (Größe in Seite und Manifest; A4-Seiten speichern keine Größe). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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ü.
|
||||
|
||||
|
||||
+39
-25
@@ -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<InkVertex> {
|
||||
/// 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<InkVertex> {
|
||||
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<InkVertex> {
|
||||
/// 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<InkVertex> {
|
||||
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()));
|
||||
|
||||
+86
-50
@@ -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<wgpu::Pres
|
||||
struct NeighborFingerprint {
|
||||
page_id: u64,
|
||||
background: PageBackground,
|
||||
size: [u32; 2],
|
||||
paper_theme: PaperTheme,
|
||||
vertex_count: usize,
|
||||
stroke_count: usize,
|
||||
@@ -130,6 +132,7 @@ impl NeighborFingerprint {
|
||||
Self {
|
||||
page_id: page.id,
|
||||
background: page.background,
|
||||
size: page.size.to_array().map(f32::to_bits),
|
||||
paper_theme,
|
||||
vertex_count: page.vertex_cache.len(),
|
||||
stroke_count: page.strokes.len(),
|
||||
@@ -141,8 +144,8 @@ impl NeighborFingerprint {
|
||||
/// GPU-Daten einer in der durchgehenden Ansicht sichtbaren Nachbarseite.
|
||||
struct NeighborPage {
|
||||
fingerprint: NeighborFingerprint,
|
||||
/// Lage relativ zur aktuellen Seite (in Seiten, negativ = darüber)
|
||||
offset: i64,
|
||||
/// Ecke oben links in Koordinaten der aktuellen Seite
|
||||
offset: glam::Vec2,
|
||||
camera_buffer: wgpu::Buffer,
|
||||
camera_bind_group: wgpu::BindGroup,
|
||||
background_buffer: Option<wgpu::Buffer>,
|
||||
@@ -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<String, TextSpec>,
|
||||
/// 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::<InkVertex>()) 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::<InkVertex>()) 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<i64> {
|
||||
/// 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<usize> {
|
||||
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<i64> {
|
||||
/// Durchgehende Ansicht: Seite auf Höhe der Fenstermitte.
|
||||
pub fn page_at_view_center(&self, stack: &PageStack) -> Option<usize> {
|
||||
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<i64> {
|
||||
/// Durchgehende Ansicht: sichtbare Nachbarseiten der aktuellen Seite.
|
||||
pub fn visible_neighbors(&self, stack: &PageStack) -> Vec<usize> {
|
||||
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);
|
||||
|
||||
+209
-67
@@ -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<Stroke>,
|
||||
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<PdfBackground>,
|
||||
/// 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<glam::Vec2> {
|
||||
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);
|
||||
|
||||
+78
-61
@@ -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<P: AsRef<Path>>(
|
||||
@@ -240,9 +266,11 @@ fn pdf_underlays(notebook: &Notebook) -> Vec<crate::pdf_merge::Underlay> {
|
||||
.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
|
||||
|
||||
+26
-34
@@ -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<dyn std::error::Error>> {
|
||||
// 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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
// 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 {
|
||||
|
||||
@@ -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("<path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selection_image_is_cropped() {
|
||||
let page = page_with_line();
|
||||
|
||||
+77
-9
@@ -166,7 +166,8 @@ impl StrokeData {
|
||||
/// - Version 5: importierte PDF-Seiten als Seitenhintergrund (`pdf_background`).
|
||||
/// - Version 6: Seitentitel (`title` in Seite und Manifest).
|
||||
/// - Version 7: Textfelder (`text` am Strich).
|
||||
pub const CURRENT_FORMAT_VERSION: u32 = 7;
|
||||
/// - Version 8: Seitengröße je Seite (`size` in Seite und Manifest), Querformat für Folien.
|
||||
pub const CURRENT_FORMAT_VERSION: u32 = 8;
|
||||
|
||||
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
|
||||
pub fn legacy_format_version() -> 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<String>,
|
||||
/// 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<StrokeData>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<PageBackground> {
|
||||
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<glam::Vec2> = 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();
|
||||
|
||||
+8
-3
@@ -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],
|
||||
|
||||
@@ -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<egui::Shape> {
|
||||
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<egui::Pos2> = pdf.corners().iter().map(|&c| to_screen(c)).collect();
|
||||
let points: Vec<egui::Pos2> = 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],
|
||||
|
||||
+160
-67
@@ -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<i64> {
|
||||
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<Vec2>,
|
||||
pub sizes: Vec<Vec2>,
|
||||
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<i64> {
|
||||
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<Vec2>, 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<i64> = (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<usize> {
|
||||
// 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<usize> {
|
||||
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<usize> = (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<Vec2> {
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user