Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6caf3f7476 | ||
|
|
9fde59798f | ||
|
|
438106cafd | ||
|
|
392c22bc4a | ||
|
|
6002eee9e8 | ||
|
|
2eab394257 | ||
|
|
6a51ca3aa4 | ||
|
|
6f2b414bb8 | ||
|
|
9dec688b84 | ||
|
|
7e05b33ee3 |
Generated
+2
-1
@@ -1393,8 +1393,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"arboard",
|
||||
"bytemuck",
|
||||
"egui",
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
edition = "2021"
|
||||
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
@@ -48,6 +48,9 @@ fs2 = "0.4"
|
||||
hayro = "0.8"
|
||||
hayro-svg = "0.8"
|
||||
|
||||
# Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui)
|
||||
ab_glyph = "0.2"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
||||
|
||||
|
||||
@@ -18,10 +18,12 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
|
||||
- **Stift (Pen):** Feine, variable Strichstärken (0.3 mm bis 1.2 mm) mit Notiz-Farben (Schwarz, Blau, Rot, Grün, Grau).
|
||||
- **Textmarker (Highlighter):** Breite, halbtransparente Striche (4.0 mm bis 8.0 mm) mit Multiplikations-Blending.
|
||||
- **Radierer (Eraser):** Segment- und abstandsbasierte Löschung einzelner Striche auf dem DIN-A4-Canvas.
|
||||
- **Text (`T`):** Ins Blatt tippen und mit der Tastatur schreiben – für Überschriften, Code-Schnipsel oder kopierten Text. Normale Schrift oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8 bis 36 pt, Farben der Stiftpalette; Zeilen werden am rechten Rand des Textfelds umbrochen. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren; im PDF-Export ist der Text echt (durchsuchbar und kopierbar). Aus anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
|
||||
|
||||
### 🔍 Stufenloser Zoom & Pan (Kamera)
|
||||
- **Viewport-Transformation:** 2D-Projektionsmatrix im WGSL-Vertex-Shader.
|
||||
- **Cursor-zentrierter Zoom:** Stufenloses Zoomen von **25 % bis 500 %** zentriert auf die Cursor-Position via Mausrad, Trackpad-Pinch oder Tastenkürzel (`Strg +` / `Strg -` / `Strg 0`).
|
||||
- **Durchgehendes Scrollen:** Seiten hängen untereinander und werden mit Mausrad, Finger oder Touchpad fließend gescrollt; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite angehängt. Abschaltbar im ☰-Menü unter „Ansicht“ (dann Seite für Seite blättern).
|
||||
- **Cursor-zentrierter Zoom:** Stufenloses Zoomen von **25 % bis 1000 %** (importierte PDF-Seiten werden beim Hineinzoomen schärfer nachgerendert) zentriert auf die Cursor-Position via `Strg + Mausrad` (ohne durchgehende Ansicht: Mausrad), Trackpad-Pinch oder Tastenkürzel (`Strg +` / `Strg -` / `Strg 0`).
|
||||
- **Pan:** Verschieben des Canvas per mittlerer Maustaste, `Leertaste + Drag` oder Trackpad-Pan.
|
||||
- **Inverse Koordinatentransformation:** Präzise Umrechnung von Bildschirm- in DIN-A4-Dokumentkoordinaten (`screen_to_page`).
|
||||
|
||||
@@ -52,8 +54,9 @@ 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 anfügen, Seitensprung-Menü.
|
||||
- **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ü.
|
||||
|
||||
### 💾 Autosave & Persistenz
|
||||
- **Asynchroner Autosave-Controller:** Sichert Änderungen im Hintergrund nach 1.5 s Ruhezeit (Debounce), ohne das Zeichnen zu blockieren.
|
||||
@@ -219,10 +222,12 @@ cargo run --release
|
||||
| :--- | :--- |
|
||||
| **Zeichnen** | Linke Maustaste / Stiftspitze |
|
||||
| **Canvas verschieben (Pan)** | Mittlere Maustaste **oder** `Leertaste + Drag` |
|
||||
| **Zoom zentriert auf Cursor** | Mausrad / Touchpad-Pinch |
|
||||
| **Scrollen (durchgehende Ansicht)** | Mausrad (`Umschalt` = waagerecht) / ein Finger |
|
||||
| **Zoom zentriert auf Cursor** | `Strg + Mausrad` / Touchpad-Pinch (Einzelseiten-Ansicht: Mausrad) |
|
||||
| **Zoom zurücksetzen (100 %)** | `Strg + 0` |
|
||||
| **Zoom In / Out** | `Strg + +` / `Strg + -` |
|
||||
| **Auswahlwerkzeug (Lasso)** | `V` |
|
||||
| **Text-Werkzeug** | `T`, dann ins Blatt klicken; `Esc` beendet die Eingabe |
|
||||
| **Auswahl um 90° drehen** | `R` |
|
||||
| **Kopieren & Einfügen** | `Strg + C` / `Strg + V` |
|
||||
| **Auswahl löschen** | `Entf` oder `Backspace` |
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **Text-Werkzeug**: Mit T ins Blatt tippen und schreiben – normale oder Code-Schrift, 8–36 pt, Zeilenumbruch am Feldrand; Textfelder verschieben, drehen und skalieren wie Bilder; im PDF echter, durchsuchbarer Text; kopierter Text wird mit Strg+V zum Textfeld (Dateiformat-Version 7)
|
||||
- **Durchgehendes Scrollen**: Seiten untereinander mit Mausrad, Finger oder Touchpad scrollen (Strg+Mausrad zoomt); Schreiben im unteren Fünftel der letzten Seite hängt automatisch eine neue an; abschaltbar unter ☰ → Ansicht
|
||||
- **Virtuelles Lineal**: Ein- und ausblenden mit L oder über die Werkzeugleiste; verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden exakt gerade – mit Stift, Textmarker und Formwerkzeug
|
||||
- **Teilstrich-Radierer**: Nur den überstrichenen Teil eines Strichs entfernen – ein Radierzug ist ein Undo-Schritt
|
||||
- **Export & Zwischenablage**: Aktuelle Seite als PNG (auch transparent) oder SVG; Strg+C legt die Auswahl zusätzlich als Bild in die Zwischenablage für Chat, Mail oder Office
|
||||
- **Seitentitel & Inhaltsverzeichnis**: Seiten benennen, Inhaltsansicht in der Seitenübersicht, Titel werden zu PDF-Lesezeichen (Dateiformat-Version 6)
|
||||
|
||||
+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()));
|
||||
|
||||
+420
-30
@@ -6,6 +6,7 @@
|
||||
//! - NDC-Transformation der 2D-Vektor-Meshes über Vertex-Shader-Uniforms.
|
||||
//! - Alpha-Blending (`wgpu::BlendState::ALPHA_BLENDING`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
use winit::dpi::PhysicalSize;
|
||||
@@ -15,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)]
|
||||
@@ -112,6 +114,46 @@ pub fn choose_present_mode(available: &[wgpu::PresentMode]) -> Option<wgpu::Pres
|
||||
.or_else(|| available.first().copied())
|
||||
}
|
||||
|
||||
/// Erkennt, ob die GPU-Daten einer Nachbarseite noch zur Seite passen. Änderungen an der
|
||||
/// aktuellen Seite verwerfen deren Eintrag ohnehin (siehe [`CanvasState::sync_page`]).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct NeighborFingerprint {
|
||||
page_id: u64,
|
||||
background: PageBackground,
|
||||
size: [u32; 2],
|
||||
paper_theme: PaperTheme,
|
||||
vertex_count: usize,
|
||||
stroke_count: usize,
|
||||
last_stroke_id: Option<u64>,
|
||||
}
|
||||
|
||||
impl NeighborFingerprint {
|
||||
fn of(page: &Page, paper_theme: PaperTheme) -> Self {
|
||||
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(),
|
||||
last_stroke_id: page.strokes.last().map(|s| s.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GPU-Daten einer in der durchgehenden Ansicht sichtbaren Nachbarseite.
|
||||
struct NeighborPage {
|
||||
fingerprint: NeighborFingerprint,
|
||||
/// 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>,
|
||||
background_vertex_count: u32,
|
||||
vertex_buffer: Option<wgpu::Buffer>,
|
||||
stroke_ranges: Vec<Range<u32>>,
|
||||
}
|
||||
|
||||
pub struct CanvasState {
|
||||
pub surface: wgpu::Surface<'static>,
|
||||
/// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung)
|
||||
@@ -148,6 +190,16 @@ 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
|
||||
/// zeigt es)
|
||||
hidden_text: Option<u64>,
|
||||
/// Auflösung der Texttexturen in Pixeln je Seiteneinheit
|
||||
text_resolution: f32,
|
||||
|
||||
// Pipeline-Trennung: Live-Strich (aktiver Stroke)
|
||||
pub live_vertex_buffer: wgpu::Buffer,
|
||||
@@ -168,6 +220,15 @@ pub struct CanvasState {
|
||||
// Eingefügte Bilder der aktuellen Seite
|
||||
image_layer: crate::image_layer::ImageLayer,
|
||||
|
||||
/// Durchgehende Ansicht: sichtbare Nachbarseiten der aktuellen Seite
|
||||
neighbors: Vec<NeighborPage>,
|
||||
camera_layout: wgpu::BindGroupLayout,
|
||||
|
||||
/// Eingeblendetes Lineal (Striche nahe einer Kante rasten ein)
|
||||
pub ruler: Option<crate::ruler::Ruler>,
|
||||
/// Kante, an der der aktuelle Live-Strich eingerastet ist
|
||||
ruler_lock: Option<crate::ruler::EdgeLock>,
|
||||
|
||||
// egui UI-Renderer
|
||||
pub egui_renderer: egui_wgpu::Renderer,
|
||||
}
|
||||
@@ -376,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,
|
||||
@@ -386,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,
|
||||
@@ -444,6 +509,11 @@ 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,
|
||||
live_vertex_buffer,
|
||||
live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY,
|
||||
live_vertex_count: 0,
|
||||
@@ -461,6 +531,10 @@ impl CanvasState {
|
||||
committed_vertex_count: 0,
|
||||
committed_strokes: Vec::with_capacity(128),
|
||||
image_layer,
|
||||
neighbors: Vec::new(),
|
||||
camera_layout: bind_group_layout,
|
||||
ruler: None,
|
||||
ruler_lock: None,
|
||||
egui_renderer,
|
||||
camera: crate::viewport::ViewportState::new(),
|
||||
})
|
||||
@@ -468,8 +542,27 @@ impl CanvasState {
|
||||
|
||||
/// Aktualisiert die 2D-Kamera Uniforms (Scale & Offset im NDC-Raum) basierend auf Viewport und Kamera.
|
||||
pub fn update_uniforms(&mut self) {
|
||||
let (scale, offset) = self.camera.ndc_transform(&self.viewport);
|
||||
let camera_uniforms = CameraUniforms {
|
||||
let camera_uniforms = self.camera_uniforms(glam::Vec2::ZERO);
|
||||
self.queue.write_buffer(
|
||||
&self.camera_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&[camera_uniforms]),
|
||||
);
|
||||
for neighbor in &self.neighbors {
|
||||
self.queue.write_buffer(
|
||||
&neighbor.camera_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&[self.camera_uniforms(neighbor.offset)]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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[0] += origin.x * scale[0];
|
||||
offset[1] += origin.y * scale[1];
|
||||
CameraUniforms {
|
||||
transform_scale: scale,
|
||||
transform_offset: offset,
|
||||
ink_params: [
|
||||
@@ -482,12 +575,7 @@ impl CanvasState {
|
||||
0.0,
|
||||
0.0,
|
||||
],
|
||||
};
|
||||
self.queue.write_buffer(
|
||||
&self.camera_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&[camera_uniforms]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Setzt die von seitlicher UI belegten Streifen (physische Pixel), damit die Seite
|
||||
@@ -502,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();
|
||||
}
|
||||
@@ -578,6 +675,63 @@ impl CanvasState {
|
||||
self.camera.page_to_screen_vec2(page_pt, &self.viewport)
|
||||
}
|
||||
|
||||
/// 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);
|
||||
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: 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,
|
||||
);
|
||||
stack.page_at(p.y)
|
||||
}
|
||||
|
||||
/// 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);
|
||||
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 Seiten), damit
|
||||
/// man nicht ins Leere scrollt. `true`, wenn der Ausschnitt korrigiert wurde.
|
||||
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, stack, &self.viewport);
|
||||
if changed {
|
||||
self.update_uniforms();
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
|
||||
/// Ersetzt die Live-Vertices des aktiven Strichs durch ein bereinigtes Form-Mesh (für Draw-and-Hold).
|
||||
pub fn set_live_shape_mesh(&mut self, samples: Vec<StylusSample>, vertices: Vec<InkVertex>) {
|
||||
self.live_samples = samples;
|
||||
@@ -624,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();
|
||||
}
|
||||
@@ -642,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 {
|
||||
@@ -665,19 +822,24 @@ impl CanvasState {
|
||||
/// Synchronisiert die GPU-Puffer mit den Daten der aktiven Seite.
|
||||
pub fn sync_page(&mut self, page: &mut Page) {
|
||||
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);
|
||||
// PDF-Seite zuerst, darüber die eingefügten Bilder
|
||||
self.image_layer.set_quads(
|
||||
page.pdf_background
|
||||
.iter()
|
||||
.map(|pdf| (pdf.key(), pdf.corners()))
|
||||
.chain(
|
||||
page.strokes
|
||||
.iter()
|
||||
.filter_map(|s| Some((s.image.clone()?, s.image_corners()?))),
|
||||
)
|
||||
.collect(),
|
||||
);
|
||||
let quads = self.page_image_quads(page, self.hidden_text);
|
||||
self.image_layer.set_quads(quads);
|
||||
|
||||
self.committed_vertex_count = page.vertex_cache.len() as u32;
|
||||
self.committed_strokes = page.stroke_ranges.clone();
|
||||
@@ -704,13 +866,157 @@ impl CanvasState {
|
||||
);
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: übernimmt die sichtbaren Nachbarseiten mit ihrem Versatz zur
|
||||
/// 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: &[(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 {
|
||||
let fingerprint = NeighborFingerprint::of(page, self.paper_theme);
|
||||
let mut neighbor = match old.iter().position(|n| n.fingerprint == fingerprint) {
|
||||
Some(i) => old.swap_remove(i),
|
||||
None => self.upload_neighbor(page, fingerprint),
|
||||
};
|
||||
neighbor.offset = offset;
|
||||
self.queue.write_buffer(
|
||||
&neighbor.camera_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&[self.camera_uniforms(offset)]),
|
||||
);
|
||||
self.neighbors.push(neighbor);
|
||||
image_groups.push(self.page_image_quads(page, None));
|
||||
}
|
||||
self.image_layer.set_neighbor_quads(image_groups);
|
||||
}
|
||||
|
||||
/// Lädt Raster und Striche einer Nachbarseite auf die GPU.
|
||||
fn upload_neighbor(&self, page: &Page, fingerprint: NeighborFingerprint) -> NeighborPage {
|
||||
let camera_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Neighbor Camera Uniform Buffer"),
|
||||
size: std::mem::size_of::<CameraUniforms>() as wgpu::BufferAddress,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let camera_bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Neighbor Camera Bind Group"),
|
||||
layout: &self.camera_layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: camera_buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let vertex_buffer_with = |label: &str, vertices: &[InkVertex]| {
|
||||
(!vertices.is_empty()).then(|| {
|
||||
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size: std::mem::size_of_val(vertices) as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
self.queue
|
||||
.write_buffer(&buffer, 0, bytemuck::cast_slice(vertices));
|
||||
buffer
|
||||
})
|
||||
};
|
||||
// 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: glam::Vec2::ZERO,
|
||||
camera_buffer,
|
||||
camera_bind_group,
|
||||
background_buffer: vertex_buffer_with("Neighbor Background Buffer", &background),
|
||||
background_vertex_count: background.len() as u32,
|
||||
vertex_buffer: vertex_buffer_with("Neighbor Strokes Buffer", &page.vertex_cache),
|
||||
stroke_ranges: page.stroke_ranges.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lädt Bilder und PDF-Seiten der aktuellen Seite, die noch nicht auf der GPU liegen.
|
||||
/// Vor jedem Zeichnen aufrufen; ohne Änderungen kostet das fast nichts.
|
||||
pub fn prepare_images(
|
||||
&mut self,
|
||||
load: &mut dyn FnMut(&str) -> crate::image_layer::TextureSource,
|
||||
) {
|
||||
self.image_layer.prepare(&self.device, &self.queue, load);
|
||||
// Text schärfer rastern, sobald weiter hineingezoomt wird (und gröber beim Herauszoomen)
|
||||
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 = 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),
|
||||
);
|
||||
let specs = &self.text_specs;
|
||||
self.image_layer
|
||||
.prepare(&self.device, &self.queue, &mut |key| match specs.get(key) {
|
||||
Some(spec) => crate::image_layer::TextureSource::Ready(crate::text::rasterize(
|
||||
&spec.text,
|
||||
spec.color,
|
||||
resolution * spec.scale,
|
||||
)),
|
||||
None => load(key),
|
||||
});
|
||||
let layer = &self.image_layer;
|
||||
self.text_specs.retain(|key, _| layer.uses(key));
|
||||
}
|
||||
|
||||
/// Blendet das gerade bearbeitete Textfeld aus (wirksam ab dem nächsten `sync_page`).
|
||||
pub fn set_hidden_text(&mut self, id: Option<u64>) {
|
||||
self.hidden_text = id;
|
||||
}
|
||||
|
||||
/// Bilder einer Seite für die Bildebene: zuerst die PDF-Seite, darüber die eingefügten
|
||||
/// Bilder und Textfelder (außer `hidden`) in ihrer Reihenfolge.
|
||||
fn page_image_quads(
|
||||
&mut self,
|
||||
page: &Page,
|
||||
hidden: Option<u64>,
|
||||
) -> Vec<(String, [glam::Vec2; 4])> {
|
||||
let adapt = self.adapt_ink && self.paper_theme.is_dark();
|
||||
let mut quads: Vec<_> = page
|
||||
.pdf_background
|
||||
.iter()
|
||||
.map(|pdf| (pdf.key(), pdf.corners(page.size)))
|
||||
.collect();
|
||||
for stroke in &page.strokes {
|
||||
let Some(corners) = stroke.object_corners() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(asset) = &stroke.image {
|
||||
quads.push((asset.clone(), corners));
|
||||
} else if let Some(text) = &stroke.text {
|
||||
if Some(stroke.id) == hidden {
|
||||
continue;
|
||||
}
|
||||
let color = if adapt {
|
||||
crate::background::adapt_ink_color(stroke.color)
|
||||
} else {
|
||||
stroke.color
|
||||
};
|
||||
let key = text_key(text, color);
|
||||
let scale = ((corners[1] - corners[0]).length() / text.wrap_width.max(text.size))
|
||||
.max((corners[3] - corners[0]).length() / text.layout().height());
|
||||
self.text_specs.entry(key.clone()).or_insert(TextSpec {
|
||||
text: text.clone(),
|
||||
color,
|
||||
scale,
|
||||
});
|
||||
quads.push((key, corners));
|
||||
}
|
||||
}
|
||||
quads
|
||||
}
|
||||
|
||||
/// Wird noch eine PDF-Seite im Hintergrund gerendert?
|
||||
@@ -726,6 +1032,13 @@ impl CanvasState {
|
||||
self.live_vertices.clear();
|
||||
self.live_vertex_count = 0;
|
||||
|
||||
// Beginnt der Strich an einer Linealkante, bleibt er bis zum Abheben auf ihr
|
||||
let half_width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM / 2.0;
|
||||
self.ruler_lock = self
|
||||
.ruler
|
||||
.and_then(|r| r.edge_lock(glam::Vec2::new(page_x, page_y), half_width));
|
||||
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
|
||||
|
||||
// 1. Zwingend durch den 1€-Filter leiten
|
||||
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
||||
let pt = ControlPoint::new(fx, fy, fp);
|
||||
@@ -749,6 +1062,7 @@ impl CanvasState {
|
||||
}
|
||||
|
||||
// 1. Zwingend durch den 1€-Filter leiten
|
||||
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
|
||||
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
||||
let new_pt = ControlPoint::new(fx, fy, fp);
|
||||
|
||||
@@ -771,6 +1085,19 @@ impl CanvasState {
|
||||
self.update_live_mesh();
|
||||
}
|
||||
|
||||
/// Projiziert einen Punkt auf die Linealkante, falls der Live-Strich dort eingerastet ist.
|
||||
fn constrain_to_ruler(&self, x: f32, y: f32) -> (f32, f32) {
|
||||
match self.ruler_lock {
|
||||
Some(lock) => lock.project(glam::Vec2::new(x, y)).into(),
|
||||
None => (x, y),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ist der aktuelle Live-Strich an einer Linealkante eingerastet?
|
||||
pub fn is_ruler_locked(&self) -> bool {
|
||||
self.ruler_lock.is_some()
|
||||
}
|
||||
|
||||
/// Bricht den aktuellen Live-Strich ab und setzt Puffer sowie 1€-Filter sauber zurück.
|
||||
pub fn cancel_stroke(&mut self) {
|
||||
self.live_samples.clear();
|
||||
@@ -903,6 +1230,26 @@ impl CanvasState {
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
|
||||
// 0. Nachbarseiten der durchgehenden Ansicht (je mit eigener Kamera)
|
||||
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);
|
||||
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);
|
||||
}
|
||||
self.image_layer.draw(&mut render_pass, i + 1);
|
||||
if let Some(buffer) = &neighbor.vertex_buffer {
|
||||
render_pass.set_pipeline(&self.strip_pipeline);
|
||||
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
for range in &neighbor.stroke_ranges {
|
||||
if range.end > range.start {
|
||||
render_pass.draw(range.clone(), 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2D-Kamera Uniforms (Scale & Offset) binden
|
||||
render_pass.set_bind_group(0, &self.camera_bind_group, &[]);
|
||||
|
||||
@@ -918,7 +1265,7 @@ impl CanvasState {
|
||||
}
|
||||
|
||||
// 3. Eingefügte Bilder (unter der Tinte)
|
||||
self.image_layer.draw(&mut render_pass);
|
||||
self.image_layer.draw(&mut render_pass, 0);
|
||||
|
||||
// 4. Abgeschlossene Striche der aktiven Seite rendern (TriangleStrip mit Vertex-Farben)
|
||||
render_pass.set_pipeline(&self.strip_pipeline);
|
||||
@@ -982,10 +1329,53 @@ impl CanvasState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Zu rasterndes Textfeld.
|
||||
struct TextSpec {
|
||||
text: crate::text::TextBox,
|
||||
/// Angezeigte (ggf. aufgehellte) Farbe
|
||||
color: [f32; 4],
|
||||
/// Vergrößerung des Felds durch Skalieren der Auswahl
|
||||
scale: f32,
|
||||
}
|
||||
|
||||
/// Schlüssel der Texttexturen beginnen hiermit.
|
||||
const TEXT_KEY_PREFIX: &str = "text:";
|
||||
|
||||
/// Texturschlüssel eines Textfelds: ändert sich mit Inhalt, Satz und Farbe.
|
||||
fn text_key(text: &crate::text::TextBox, color: [f32; 4]) -> String {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
text.content.hash(&mut hasher);
|
||||
text.font.hash(&mut hasher);
|
||||
for v in [text.size, text.wrap_width].into_iter().chain(color) {
|
||||
v.to_bits().hash(&mut hasher);
|
||||
}
|
||||
format!("{TEXT_KEY_PREFIX}{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
/// Auflösung der Texttexturen für `screen_px_per_unit` Bildschirmpixel je Seiteneinheit:
|
||||
/// auf Viertel-Zweierpotenzen aufgerundet (⅛ bis 8). So wird nicht bei jedem Zoomschritt neu
|
||||
/// gerastert, und die Textur ist höchstens ein Fünftel größer als dargestellt – stärker
|
||||
/// verkleinert wirkt die dünne Schrift blass.
|
||||
fn text_resolution(screen_px_per_unit: f32) -> f32 {
|
||||
let steps = (screen_px_per_unit.max(1e-3).log2() * 4.0).ceil();
|
||||
2.0_f32.powf(steps / 4.0).clamp(0.125, 8.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_text_resolution_is_quantized_and_never_below_screen() {
|
||||
for px in [0.2_f32, 0.27, 0.5, 1.3, 3.9] {
|
||||
let r = text_resolution(px);
|
||||
assert!(r >= px - 1e-4 && r < px * 1.2 + 1e-4, "{px} → {r}");
|
||||
}
|
||||
assert_eq!(text_resolution(0.27), text_resolution(0.28));
|
||||
assert_eq!(text_resolution(100.0), 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_choose_present_mode() {
|
||||
use wgpu::PresentMode::{Fifo, Immediate, Mailbox};
|
||||
|
||||
+322
-72
@@ -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;
|
||||
[
|
||||
@@ -371,6 +390,9 @@ pub struct Stroke {
|
||||
/// Bildecken oben links, oben rechts, unten rechts, unten links; Verschieben, Drehen und
|
||||
/// Skalieren wirken so automatisch auch auf Bilder. Bilder liegen immer unter der Tinte.
|
||||
pub image: Option<String>,
|
||||
/// Textfeld (Text-Werkzeug); die vier Punkte sind wie bei Bildern die Ecken des Felds.
|
||||
/// Textfelder liegen wie Bilder unter der Tinte.
|
||||
pub text: Option<crate::text::TextBox>,
|
||||
}
|
||||
|
||||
impl Stroke {
|
||||
@@ -404,6 +426,7 @@ impl Stroke {
|
||||
width: 5.0,
|
||||
finalized: true,
|
||||
image: None,
|
||||
text: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,11 +446,48 @@ impl Stroke {
|
||||
stroke
|
||||
}
|
||||
|
||||
/// Erzeugt ein Textfeld mit den Ecken oben links, oben rechts, unten rechts, unten links.
|
||||
pub fn new_text(
|
||||
id: u64,
|
||||
text: crate::text::TextBox,
|
||||
corners: [glam::Vec2; 4],
|
||||
color: [f32; 4],
|
||||
) -> Self {
|
||||
let mut stroke = Self::new_image(id, String::new(), corners);
|
||||
stroke.image = None;
|
||||
stroke.color = color;
|
||||
stroke.text = Some(text);
|
||||
stroke
|
||||
}
|
||||
|
||||
/// Ist dieses Objekt ein Bild?
|
||||
pub fn is_image(&self) -> bool {
|
||||
self.image.is_some()
|
||||
}
|
||||
|
||||
/// Ist dieses Objekt ein Textfeld?
|
||||
pub fn is_text(&self) -> bool {
|
||||
self.text.is_some()
|
||||
}
|
||||
|
||||
/// Bild oder Textfeld (Objekt mit vier Ecken statt Tinte)?
|
||||
pub fn is_object(&self) -> bool {
|
||||
self.is_image() || self.is_text()
|
||||
}
|
||||
|
||||
/// Ecken eines Bildes oder Textfelds (oben links, oben rechts, unten rechts, unten links).
|
||||
pub fn object_corners(&self) -> Option<[glam::Vec2; 4]> {
|
||||
if !self.is_object() || self.points.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
Some([
|
||||
self.points[0].position,
|
||||
self.points[1].position,
|
||||
self.points[2].position,
|
||||
self.points[3].position,
|
||||
])
|
||||
}
|
||||
|
||||
/// Bildecken (oben links, oben rechts, unten rechts, unten links), falls ein Bild.
|
||||
pub fn image_corners(&self) -> Option<[glam::Vec2; 4]> {
|
||||
if self.image.is_none() || self.points.len() < 4 {
|
||||
@@ -474,9 +534,9 @@ impl Stroke {
|
||||
}
|
||||
|
||||
/// Berechnet den minimalen euklidischen Abstand eines Punktes zu diesem Strich.
|
||||
/// Bei Bildern ist der Abstand innerhalb der Bildfläche 0.
|
||||
/// Bei Bildern und Textfeldern ist der Abstand innerhalb der Fläche 0.
|
||||
pub fn distance_to_point(&self, p: glam::Vec2) -> f32 {
|
||||
if let Some(corners) = self.image_corners() {
|
||||
if let Some(corners) = self.object_corners() {
|
||||
if point_in_convex_quad(p, &corners) {
|
||||
return 0.0;
|
||||
}
|
||||
@@ -652,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
|
||||
@@ -670,6 +733,7 @@ impl Page {
|
||||
id,
|
||||
strokes: Vec::new(),
|
||||
background,
|
||||
size: A4_SIZE,
|
||||
pdf_background: None,
|
||||
title: None,
|
||||
vertex_cache: Vec::new(),
|
||||
@@ -687,6 +751,7 @@ impl Page {
|
||||
id,
|
||||
strokes: Vec::new(),
|
||||
background,
|
||||
size: A4_SIZE,
|
||||
pdf_background: None,
|
||||
title: None,
|
||||
vertex_cache: Vec::new(),
|
||||
@@ -698,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
|
||||
}
|
||||
@@ -731,7 +802,8 @@ impl Page {
|
||||
}
|
||||
|
||||
/// Findet den nächsten Strich an Position `pos` innerhalb eines maximalen Suchradius.
|
||||
/// Tinte hat Vorrang vor Bildern darunter, damit Notizen auf einem Bild auswählbar bleiben.
|
||||
/// Tinte hat Vorrang vor Bildern und Textfeldern darunter, damit Notizen darauf
|
||||
/// auswählbar bleiben.
|
||||
pub fn find_stroke_at(&self, pos: glam::Vec2, max_dist: f32) -> Option<&Stroke> {
|
||||
let mut best: Option<(&Stroke, (bool, f32))> = None;
|
||||
for s in &self.strokes {
|
||||
@@ -740,7 +812,7 @@ impl Page {
|
||||
}
|
||||
let d = s.distance_to_point(pos);
|
||||
if d <= max_dist {
|
||||
let key = (s.is_image(), d);
|
||||
let key = (s.is_object(), d);
|
||||
if best.is_none_or(|(_, best_key)| key < best_key) {
|
||||
best = Some((s, key));
|
||||
}
|
||||
@@ -783,7 +855,7 @@ impl Page {
|
||||
let mut hit_indices = Vec::new();
|
||||
|
||||
for (i, stroke) in self.strokes.iter().enumerate() {
|
||||
if !stroke.is_image()
|
||||
if !stroke.is_object()
|
||||
&& mode.erases(stroke.tool)
|
||||
&& stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius)
|
||||
{
|
||||
@@ -811,8 +883,8 @@ impl Page {
|
||||
|
||||
/// Teilradierer: entfernt den Teil aller Striche im Kreis um `eraser_pos`. Getroffene
|
||||
/// Striche werden durch ihre übrig gebliebenen Stücke ersetzt (neue IDs aus `next_id`),
|
||||
/// Formen als Ganzes entfernt, Bilder nie. Aufeinanderfolgende Schnitte eines Radierzugs
|
||||
/// bilden einen gemeinsamen Undo-Schritt. Gibt `true` zurück, wenn sich etwas geändert hat.
|
||||
/// Formen als Ganzes entfernt, Bilder und Textfelder nie. Aufeinanderfolgende Schnitte
|
||||
/// eines Radierzugs bilden einen gemeinsamen Undo-Schritt. Gibt `true` zurück, wenn sich etwas geändert hat.
|
||||
pub fn erase_partial_at(
|
||||
&mut self,
|
||||
eraser_pos: glam::Vec2,
|
||||
@@ -824,7 +896,7 @@ impl Page {
|
||||
let mut result = Vec::with_capacity(self.strokes.len());
|
||||
for stroke in std::mem::take(&mut self.strokes) {
|
||||
let reach = eraser_radius + stroke.width / 2.0;
|
||||
if stroke.is_image() || !stroke.aabb.expanded(reach).contains_point(eraser_pos) {
|
||||
if stroke.is_object() || !stroke.aabb.expanded(reach).contains_point(eraser_pos) {
|
||||
result.push(stroke);
|
||||
continue;
|
||||
}
|
||||
@@ -900,8 +972,8 @@ impl Page {
|
||||
}
|
||||
|
||||
/// Findet die IDs aller Striche, die die gegebene Auswahlbox schneiden oder darin liegen.
|
||||
/// Bilder werden nur ausgewählt, wenn sie ganz in der Box liegen – sonst würde jede
|
||||
/// Auswahl von Notizen auf einem Bild das Bild mitnehmen.
|
||||
/// Bilder und Textfelder werden nur ausgewählt, wenn sie ganz in der Box liegen – sonst
|
||||
/// würde jede Auswahl von Notizen auf einem Bild das Bild mitnehmen.
|
||||
pub fn find_strokes_in_box(&self, box_min: glam::Vec2, box_max: glam::Vec2) -> Vec<u64> {
|
||||
let sel_box = Aabb::new(
|
||||
glam::Vec2::new(box_min.x.min(box_max.x), box_min.y.min(box_max.y)),
|
||||
@@ -910,7 +982,7 @@ impl Page {
|
||||
self.strokes
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
if s.is_image() {
|
||||
if s.is_object() {
|
||||
sel_box.contains_point(s.aabb.min) && sel_box.contains_point(s.aabb.max)
|
||||
} else {
|
||||
s.aabb.intersects(&sel_box)
|
||||
@@ -1012,6 +1084,10 @@ impl Page {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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_FRACTION: f32 = 0.2;
|
||||
|
||||
/// Das Notizbuch mit diskreter Seitenverwaltung und Navigation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Notebook {
|
||||
@@ -1095,14 +1171,66 @@ 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 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());
|
||||
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_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 < self.current_page().size.y * (1.0 - AUTO_APPEND_FRACTION)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let background = self.current_page().background;
|
||||
self.add_page(background);
|
||||
true
|
||||
}
|
||||
|
||||
/// Wechselt zur nächsten Seite (PageDown / N). Hängt automatisch eine neue Seite an, falls am Ende.
|
||||
pub fn next_page(&mut self) {
|
||||
if self.current_page_index + 1 < self.pages.len() {
|
||||
@@ -1164,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)
|
||||
}
|
||||
@@ -1189,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])
|
||||
@@ -1514,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);
|
||||
@@ -1749,6 +1961,44 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_page_after_current() {
|
||||
let mut nb = Notebook::new();
|
||||
nb.add_page(PageBackground::Lined);
|
||||
nb.add_page(PageBackground::Lined);
|
||||
let ids: Vec<u64> = nb.pages.iter().map(|p| p.id).collect();
|
||||
nb.current_page_index = 1;
|
||||
nb.pages[1].background = PageBackground::DotGrid;
|
||||
let index = nb.insert_page_after_current(PageBackground::DotGrid);
|
||||
assert_eq!(index, 2);
|
||||
assert_eq!(nb.current_page_index, 1);
|
||||
assert_eq!(nb.pages.len(), 4);
|
||||
assert_eq!(nb.pages[0].id, ids[0]);
|
||||
assert_eq!(nb.pages[1].id, ids[1]);
|
||||
assert_eq!(nb.pages[3].id, ids[2], "alte Seite 3 ist jetzt Seite 4");
|
||||
let new = &nb.pages[2];
|
||||
assert!(!ids.contains(&new.id) && new.strokes.is_empty());
|
||||
assert_eq!(new.background, PageBackground::DotGrid);
|
||||
assert!(new.pdf_background.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_page_when_writing_at_bottom_of_last_page() {
|
||||
let mut nb = Notebook::new();
|
||||
nb.current_page_mut().background = PageBackground::Lined;
|
||||
// Oben auf der Seite: nichts
|
||||
assert!(!nb.append_page_if_writing_at_end(PAGE_HEIGHT * 0.5));
|
||||
assert_eq!(nb.pages.len(), 1);
|
||||
// Unten: neue Seite mit gleichem Hintergrund, die aktuelle Seite bleibt
|
||||
assert!(nb.append_page_if_writing_at_end(PAGE_HEIGHT - 10.0));
|
||||
assert_eq!(nb.pages.len(), 2);
|
||||
assert_eq!(nb.current_page_index, 0);
|
||||
assert_eq!(nb.pages[1].background, PageBackground::Lined);
|
||||
// Nicht auf der letzten Seite: nichts
|
||||
assert!(!nb.append_page_if_writing_at_end(PAGE_HEIGHT - 10.0));
|
||||
assert_eq!(nb.pages.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_isolation() {
|
||||
let mut nb = Notebook::new();
|
||||
|
||||
+161
-58
@@ -2,13 +2,15 @@
|
||||
//!
|
||||
//! Features:
|
||||
//! - Hochformat DIN A4 (210 mm x 297 mm)
|
||||
//! - Layer pro Seite: "Background", "Images" (nur bei eingefügten Bildern) und "Ink"
|
||||
//! - Layer pro Seite: "Background", "Images" (nur bei eingefügten Bildern), "Text" (nur bei
|
||||
//! Textfeldern; echter, durchsuchbarer Text mit eingebetteter Schrift) und "Ink"
|
||||
//! - Y-Achsen-Spiegelung (PDF-Ursprung unten links vs. Bildschirmursprung oben links)
|
||||
//! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko)
|
||||
//! - Zwei Export-Strategien:
|
||||
//! - `FilledPolygon`: Glatte gefüllte Polygone aus den Triangle-Strip-Hüllen (variable Pinselstärke)
|
||||
//! - `BezierCurve`: Glatte kubische Bézier-Kurven via `printpdf::Line`
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::fs::File;
|
||||
use std::io::BufWriter;
|
||||
@@ -22,6 +24,9 @@ use crate::background::{GRID_5MM_SPACING, LINED_SPACING};
|
||||
use crate::document::{
|
||||
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
|
||||
};
|
||||
use ab_glyph::Font as _;
|
||||
|
||||
use crate::text::TextFont;
|
||||
|
||||
/// Bereinigt einen Titel für die Verwendung in Dateinamen (entfernt illegale Zeichen).
|
||||
pub fn sanitize_filename_component(title: &str) -> String {
|
||||
@@ -112,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>>(
|
||||
@@ -235,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,
|
||||
@@ -287,55 +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);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
let layer_ink_ref = page_ref.add_layer("Ink");
|
||||
render_ink_layer(&layer_ink_ref, page, strategy);
|
||||
if page.strokes.iter().any(Stroke::is_text) {
|
||||
layer("Text", &|l| render_text_layer(l, page, &fonts));
|
||||
}
|
||||
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)
|
||||
@@ -349,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,
|
||||
};
|
||||
@@ -363,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,
|
||||
};
|
||||
@@ -382,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,
|
||||
};
|
||||
@@ -400,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),
|
||||
@@ -425,7 +453,7 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, background: Page
|
||||
/// Rendert alle Striche einer Seite auf Layer 2 ("Ink").
|
||||
fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) {
|
||||
for stroke in &page.strokes {
|
||||
if stroke.is_image() {
|
||||
if stroke.is_object() {
|
||||
continue;
|
||||
}
|
||||
if let Some(ref shape) = stroke.shape {
|
||||
@@ -563,6 +591,80 @@ fn render_image_layer(
|
||||
}
|
||||
}
|
||||
|
||||
/// Bettet die von Textfeldern des Notizbuchs verwendeten Schriften ein.
|
||||
fn embed_text_fonts(
|
||||
doc: &PdfDocumentReference,
|
||||
notebook: &Notebook,
|
||||
) -> Result<HashMap<TextFont, printpdf::IndirectFontRef>, Box<dyn Error>> {
|
||||
let mut fonts = HashMap::new();
|
||||
let used = notebook
|
||||
.pages
|
||||
.iter()
|
||||
.flat_map(|p| &p.strokes)
|
||||
.filter_map(|s| s.text.as_ref().map(|t| t.font));
|
||||
for font in used {
|
||||
if let std::collections::hash_map::Entry::Vacant(entry) = fonts.entry(font) {
|
||||
entry.insert(doc.add_external_font(font.bytes())?);
|
||||
}
|
||||
}
|
||||
Ok(fonts)
|
||||
}
|
||||
|
||||
/// Rendert die Textfelder einer Seite als echten Text: Jedes Feld wird in seinem eigenen
|
||||
/// Koordinatensystem (eine Einheit = eine Seiteneinheit, y nach oben) gesetzt, das eine
|
||||
/// Transformationsmatrix auf seine Ecken abbildet – so stimmen Drehung und Skalierung.
|
||||
fn render_text_layer(
|
||||
layer: &printpdf::PdfLayerReference,
|
||||
page: &Page,
|
||||
fonts: &HashMap<TextFont, printpdf::IndirectFontRef>,
|
||||
) {
|
||||
for stroke in &page.strokes {
|
||||
let (Some(text), Some(corners)) = (&stroke.text, stroke.object_corners()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(font) = fonts.get(&text.font) else {
|
||||
continue;
|
||||
};
|
||||
let layout = text.layout();
|
||||
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 = 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();
|
||||
layer.set_ctm(printpdf::CurTransMat::Raw(matrix));
|
||||
layer.set_fill_color(Color::Rgb(Rgb::new(
|
||||
stroke.color[0],
|
||||
stroke.color[1],
|
||||
stroke.color[2],
|
||||
None,
|
||||
)));
|
||||
layer.begin_text_section();
|
||||
layer.set_font(font, text.size);
|
||||
for (i, line) in layout.lines.iter().enumerate() {
|
||||
if line.text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
layer.set_text_matrix(printpdf::TextMatrix::Translate(
|
||||
printpdf::Pt(0.0),
|
||||
printpdf::Pt(-layout.baseline(i)),
|
||||
));
|
||||
// Glyphen samt Unterschneidung wie auf dem Bildschirm (Abstände in 1/1000 Em)
|
||||
let face = text.font.face();
|
||||
let per_em = 1000.0 / face.units_per_em().unwrap_or(1000.0);
|
||||
let mut previous: Option<ab_glyph::GlyphId> = None;
|
||||
layer.write_positioned_codepoints(line.glyphs.iter().map(|&(id, _)| {
|
||||
let kern = previous.map_or(0.0, |p| face.kern_unscaled(p, id));
|
||||
previous = Some(id);
|
||||
((-kern * per_em).round() as i64, id.0)
|
||||
}));
|
||||
}
|
||||
layer.end_text_section();
|
||||
layer.restore_graphics_state();
|
||||
}
|
||||
}
|
||||
|
||||
/// Graue 1×1-Fläche für fehlende Bilder.
|
||||
fn placeholder_xobject() -> printpdf::ImageXObject {
|
||||
rgb_xobject(1, 1, vec![200, 200, 200])
|
||||
@@ -644,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)
|
||||
@@ -1427,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| {
|
||||
@@ -1435,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
|
||||
|
||||
+76
-24
@@ -86,10 +86,14 @@ pub struct ImageLayer {
|
||||
/// Texturen nach Schlüssel (Asset-Name bzw. `<pdf>#<seite>`) mit belegtem Speicher
|
||||
textures: HashMap<String, (wgpu::BindGroup, u64)>,
|
||||
texture_bytes: u64,
|
||||
/// Bilder der aktuellen Seite: Asset-Name und Ecken
|
||||
quads: Vec<(String, [glam::Vec2; 4])>,
|
||||
/// Bilder je Seite (Gruppe 0: aktuelle Seite, danach die sichtbaren Nachbarseiten):
|
||||
/// Asset-Name und Ecken in Koordinaten der jeweiligen Seite
|
||||
groups: Vec<Vec<(String, [glam::Vec2; 4])>>,
|
||||
vertex_buffer: Option<wgpu::Buffer>,
|
||||
vertices_dirty: bool,
|
||||
/// Importierte PDF-Seiten in höherer Auflösung (längste Kante in Pixeln) anzeigen;
|
||||
/// bis sie gerendert ist, bleibt die Standardauflösung sichtbar
|
||||
pdf_pixels: Option<u32>,
|
||||
}
|
||||
|
||||
impl ImageLayer {
|
||||
@@ -166,20 +170,47 @@ impl ImageLayer {
|
||||
sampler,
|
||||
textures: HashMap::new(),
|
||||
texture_bytes: 0,
|
||||
quads: Vec::new(),
|
||||
groups: vec![Vec::new()],
|
||||
vertex_buffer: None,
|
||||
vertices_dirty: false,
|
||||
pdf_pixels: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Übernimmt die Bilder der aktuellen Seite (Asset-Name und Ecken in Seitenkoordinaten).
|
||||
pub fn set_quads(&mut self, quads: Vec<(String, [glam::Vec2; 4])>) {
|
||||
if quads != self.quads {
|
||||
self.quads = quads;
|
||||
if quads != self.groups[0] {
|
||||
self.groups[0] = quads;
|
||||
self.vertices_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Übernimmt die Bilder der sichtbaren Nachbarseiten (Gruppen 1, 2, …).
|
||||
pub fn set_neighbor_quads(&mut self, neighbors: Vec<Vec<(String, [glam::Vec2; 4])>>) {
|
||||
if neighbors[..] != self.groups[1..] {
|
||||
self.groups.truncate(1);
|
||||
self.groups.extend(neighbors);
|
||||
self.vertices_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn all_quads(&self) -> impl Iterator<Item = &(String, [glam::Vec2; 4])> {
|
||||
self.groups.iter().flatten()
|
||||
}
|
||||
|
||||
/// Auflösung importierter PDF-Seiten (`None`: Standard), z. B. beim Hineinzoomen.
|
||||
pub fn set_pdf_pixels(&mut self, pixels: Option<u32>) {
|
||||
self.pdf_pixels = pixels;
|
||||
}
|
||||
|
||||
/// Schlüssel der anzuzeigenden Textur: PDF-Seiten ggf. in höherer Auflösung.
|
||||
fn texture_key(&self, key: &str) -> String {
|
||||
match self.pdf_pixels {
|
||||
Some(pixels) if key.contains('#') => format!("{key}@{pixels}"),
|
||||
_ => key.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lädt fehlende Texturen und den Vertex-Puffer hoch. Vor jedem Zeichnen aufrufen;
|
||||
/// ohne Änderungen kostet das nichts.
|
||||
pub fn prepare(
|
||||
@@ -189,11 +220,9 @@ impl ImageLayer {
|
||||
load: &mut dyn FnMut(&str) -> TextureSource,
|
||||
) {
|
||||
let missing: Vec<String> = self
|
||||
.quads
|
||||
.iter()
|
||||
.map(|(key, _)| key)
|
||||
.filter(|key| !self.textures.contains_key(*key))
|
||||
.cloned()
|
||||
.all_quads()
|
||||
.map(|(key, _)| self.texture_key(key))
|
||||
.filter(|key| !self.textures.contains_key(key))
|
||||
.collect();
|
||||
for key in missing {
|
||||
if self.textures.contains_key(&key) {
|
||||
@@ -217,7 +246,11 @@ impl ImageLayer {
|
||||
|
||||
// Nicht mehr benötigte Texturen freigeben, wenn der Grafikspeicher knapp wird
|
||||
if self.texture_bytes > TEXTURE_CACHE_BYTES {
|
||||
let used: HashSet<&String> = self.quads.iter().map(|(a, _)| a).collect();
|
||||
// Die Standardauflösung bleibt als Ersatz, bis die höhere fertig ist
|
||||
let used: HashSet<String> = self
|
||||
.all_quads()
|
||||
.flat_map(|(key, _)| [key.clone(), self.texture_key(key)])
|
||||
.collect();
|
||||
self.textures.retain(|name, _| used.contains(name));
|
||||
self.texture_bytes = self.textures.values().map(|(_, b)| b).sum();
|
||||
}
|
||||
@@ -227,8 +260,7 @@ impl ImageLayer {
|
||||
}
|
||||
self.vertices_dirty = false;
|
||||
let vertices: Vec<ImageVertex> = self
|
||||
.quads
|
||||
.iter()
|
||||
.all_quads()
|
||||
.flat_map(|(_, corners)| quad_vertices(corners))
|
||||
.collect();
|
||||
if vertices.is_empty() {
|
||||
@@ -252,29 +284,49 @@ impl ImageLayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fehlen noch Texturen der aktuellen Seite (z. B. PDF-Seite im Hintergrund-Rendering)?
|
||||
pub fn is_waiting(&self) -> bool {
|
||||
self.quads
|
||||
.iter()
|
||||
.any(|(key, _)| !self.textures.contains_key(key))
|
||||
/// Wird die Textur `key` von einer sichtbaren Seite verwendet?
|
||||
pub fn uses(&self, key: &str) -> bool {
|
||||
self.all_quads().any(|(k, _)| k == key)
|
||||
}
|
||||
|
||||
/// Zeichnet die Bilder der aktuellen Seite. Bind Group 0 (Kamera) muss gesetzt sein.
|
||||
pub fn draw<'a>(&'a self, render_pass: &mut wgpu::RenderPass<'a>) {
|
||||
/// Gibt alle Texturen frei, deren Schlüssel `matches` erfüllt (sie werden beim nächsten
|
||||
/// `prepare` neu angefordert).
|
||||
pub fn forget_textures(&mut self, mut matches: impl FnMut(&str) -> bool) {
|
||||
self.textures.retain(|key, _| !matches(key));
|
||||
self.texture_bytes = self.textures.values().map(|(_, b)| b).sum();
|
||||
}
|
||||
|
||||
/// Fehlen noch Texturen sichtbarer Seiten (z. B. PDF-Seite im Hintergrund-Rendering)?
|
||||
pub fn is_waiting(&self) -> bool {
|
||||
self.all_quads()
|
||||
.any(|(key, _)| !self.textures.contains_key(&self.texture_key(key)))
|
||||
}
|
||||
|
||||
/// Zeichnet die Bilder einer Seite (Gruppe 0: aktuelle Seite). Bind Group 0 (Kamera der
|
||||
/// Seite) muss gesetzt sein.
|
||||
pub fn draw<'a>(&'a self, render_pass: &mut wgpu::RenderPass<'a>, group: usize) {
|
||||
let Some(buffer) = &self.vertex_buffer else {
|
||||
return;
|
||||
};
|
||||
if self.quads.is_empty() || self.vertices_dirty {
|
||||
let Some(quads) = self.groups.get(group) else {
|
||||
return;
|
||||
};
|
||||
if quads.is_empty() || self.vertices_dirty {
|
||||
return;
|
||||
}
|
||||
let first: usize = self.groups[..group].iter().map(Vec::len).sum();
|
||||
render_pass.set_pipeline(&self.pipeline);
|
||||
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
for (i, (asset, _)) in self.quads.iter().enumerate() {
|
||||
let Some((bind_group, _)) = self.textures.get(asset) else {
|
||||
for (i, (asset, _)) in quads.iter().enumerate() {
|
||||
let Some((bind_group, _)) = self
|
||||
.textures
|
||||
.get(&self.texture_key(asset))
|
||||
.or_else(|| self.textures.get(asset))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
render_pass.set_bind_group(1, bind_group, &[]);
|
||||
let start = (i * 6) as u32;
|
||||
let start = ((first + i) * 6) as u32;
|
||||
render_pass.draw(start..start + 6, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,12 @@ pub mod page_export;
|
||||
pub mod pdf_doc;
|
||||
pub mod pdf_merge;
|
||||
pub mod platform;
|
||||
pub mod ruler;
|
||||
pub mod selection;
|
||||
pub mod settings;
|
||||
pub mod spline;
|
||||
pub mod storage;
|
||||
pub mod text;
|
||||
pub mod ui;
|
||||
pub mod updater;
|
||||
pub mod viewport;
|
||||
|
||||
+726
-341
File diff suppressed because it is too large
Load Diff
@@ -215,6 +215,107 @@ mod tests {
|
||||
assert!(svg.trim_end().ends_with("</svg>"));
|
||||
}
|
||||
|
||||
/// Begrenzungsrechteck (x0, y0, x1, y1) der deckenden Pixel.
|
||||
fn ink_bounds(width: u32, pixels: &[u8]) -> (u32, u32, u32, u32) {
|
||||
let mut b = (u32::MAX, u32::MAX, 0, 0);
|
||||
for (i, p) in pixels.as_chunks::<4>().0.iter().enumerate() {
|
||||
if p[3] > 128 {
|
||||
let (x, y) = (i as u32 % width, i as u32 / width);
|
||||
b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y));
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn page_with_text() -> (Page, crate::text::TextBox) {
|
||||
let text = crate::text::TextBox::new(
|
||||
"Hallo Welt\nzweite Zeile".into(),
|
||||
100.0,
|
||||
crate::text::TextFont::Sans,
|
||||
1200.0,
|
||||
);
|
||||
let size = glam::Vec2::new(1200.0, text.layout().height());
|
||||
let corners = crate::text::frame_corners(
|
||||
glam::Vec2::new(300.0, 400.0),
|
||||
[glam::Vec2::X, glam::Vec2::Y],
|
||||
size,
|
||||
);
|
||||
let mut page = Page::new(1, PageBackground::Grid5mm);
|
||||
page.strokes.push(Stroke::new_text(
|
||||
3,
|
||||
text.clone(),
|
||||
corners,
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
));
|
||||
(page, text)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exported_text_matches_screen_rendering() {
|
||||
let (page, text) = page_with_text();
|
||||
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
|
||||
let px_per_unit = png.width() as f32 / PAGE_WIDTH;
|
||||
let exported = ink_bounds(png.width(), png.as_raw());
|
||||
|
||||
// Dieselbe Stelle, wie sie die Bildschirmtextur zeigt
|
||||
let screen = crate::text::rasterize(&text, [0.0, 0.0, 0.0, 1.0], px_per_unit);
|
||||
let local = ink_bounds(screen.width, &screen.pixels);
|
||||
let (ox, oy) = ((300.0 * px_per_unit) as u32, (400.0 * px_per_unit) as u32);
|
||||
let expected = (local.0 + ox, local.1 + oy, local.2 + ox, local.3 + oy);
|
||||
for (a, b) in [
|
||||
(exported.0, expected.0),
|
||||
(exported.1, expected.1),
|
||||
(exported.2, expected.2),
|
||||
(exported.3, expected.3),
|
||||
] {
|
||||
assert!(
|
||||
a.abs_diff(b) <= 3,
|
||||
"PDF {exported:?} ≠ Bildschirm {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exported_text_follows_rotation() {
|
||||
let (mut page, _) = page_with_text();
|
||||
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
|
||||
let straight = ink_bounds(png.width(), png.as_raw());
|
||||
|
||||
let center = page.strokes[0].aabb.min;
|
||||
page.strokes[0].rotate(center, std::f32::consts::FRAC_PI_2);
|
||||
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
|
||||
let rotated = ink_bounds(png.width(), png.as_raw());
|
||||
// 90° im Uhrzeigersinn: Breite und Höhe tauschen, Text liegt links vom Drehpunkt
|
||||
let (w, h) = (straight.2 - straight.0, straight.3 - straight.1);
|
||||
let (rw, rh) = (rotated.2 - rotated.0, rotated.3 - rotated.1);
|
||||
assert!(
|
||||
rw.abs_diff(h) <= 4 && rh.abs_diff(w) <= 4,
|
||||
"{straight:?} → {rotated:?}"
|
||||
);
|
||||
let px_per_unit = png.width() as f32 / PAGE_WIDTH;
|
||||
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();
|
||||
|
||||
+43
-9
@@ -16,6 +16,19 @@ use crate::assets::RgbaImage;
|
||||
|
||||
/// Längste Kante einer gerenderten Seite für die Anzeige in Pixeln (A4 ≈ 220 dpi).
|
||||
pub const DISPLAY_RENDER_PIXELS: u32 = 2560;
|
||||
/// Längste Kante beim starken Hineinzoomen (A4 ≈ 440 dpi).
|
||||
pub const ZOOMED_RENDER_PIXELS: u32 = 5120;
|
||||
|
||||
/// Texturschlüssel einer PDF-Seite: `<asset>#<seite>`, gerendert mit mehr als
|
||||
/// [`DISPLAY_RENDER_PIXELS`] als `<asset>#<seite>@<pixel>`. Liefert Asset, Seite und Pixel.
|
||||
pub fn parse_page_key(key: &str) -> Option<(&str, usize, u32)> {
|
||||
let (asset, rest) = key.split_once('#')?;
|
||||
let (page, pixels) = match rest.split_once('@') {
|
||||
Some((page, pixels)) => (page, pixels.parse().ok()?),
|
||||
None => (rest, DISPLAY_RENDER_PIXELS),
|
||||
};
|
||||
Some((asset, page.parse().ok()?, pixels))
|
||||
}
|
||||
/// Größte Seitenzahl, die importiert wird.
|
||||
pub const MAX_IMPORT_PAGES: usize = 2000;
|
||||
|
||||
@@ -88,6 +101,7 @@ struct RenderRequest {
|
||||
key: String,
|
||||
path: PathBuf,
|
||||
page: usize,
|
||||
max_pixels: u32,
|
||||
}
|
||||
|
||||
/// Ergebnis eines Render-Auftrags.
|
||||
@@ -121,7 +135,7 @@ impl PdfRenderService {
|
||||
.map(|pdf| (request.path.clone(), pdf));
|
||||
}
|
||||
let result = match &open_pdf {
|
||||
Some((_, pdf)) => render_page(pdf, request.page, DISPLAY_RENDER_PIXELS),
|
||||
Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels),
|
||||
None => Err("PDF kann nicht geöffnet werden".into()),
|
||||
};
|
||||
if res_tx.send((request.key, result)).is_err() {
|
||||
@@ -150,7 +164,7 @@ impl PdfRenderService {
|
||||
if self.ready.len() >= 2 {
|
||||
self.ready.clear();
|
||||
}
|
||||
self.request(key, path, page);
|
||||
self.request(key, path, page, DISPLAY_RENDER_PIXELS);
|
||||
}
|
||||
|
||||
fn collect(&mut self) {
|
||||
@@ -161,7 +175,7 @@ impl PdfRenderService {
|
||||
}
|
||||
|
||||
/// Erteilt den Auftrag, falls noch keiner läuft. `false`, wenn der Thread beendet ist.
|
||||
fn request(&mut self, key: &str, path: PathBuf, page: usize) -> bool {
|
||||
fn request(&mut self, key: &str, path: PathBuf, page: usize, max_pixels: u32) -> bool {
|
||||
if !self.pending.insert(key.to_string()) {
|
||||
return true;
|
||||
}
|
||||
@@ -169,6 +183,7 @@ impl PdfRenderService {
|
||||
key: key.to_string(),
|
||||
path,
|
||||
page,
|
||||
max_pixels,
|
||||
};
|
||||
if self.requests.send(request).is_err() {
|
||||
self.pending.remove(key);
|
||||
@@ -178,12 +193,19 @@ impl PdfRenderService {
|
||||
}
|
||||
|
||||
/// Fertiges Ergebnis für `key` abholen; sonst den Auftrag (einmalig) erteilen und `None`.
|
||||
pub fn take(&mut self, key: &str, path: PathBuf, page: usize) -> Option<RenderResult> {
|
||||
/// Die Seite wird so gerendert, dass die längste Kante `max_pixels` lang ist.
|
||||
pub fn take(
|
||||
&mut self,
|
||||
key: &str,
|
||||
path: PathBuf,
|
||||
page: usize,
|
||||
max_pixels: u32,
|
||||
) -> Option<RenderResult> {
|
||||
self.collect();
|
||||
if let Some(result) = self.ready.remove(key) {
|
||||
return Some(result);
|
||||
}
|
||||
if !self.request(key, path, page) {
|
||||
if !self.request(key, path, page, max_pixels) {
|
||||
return Some(Err("PDF-Render-Thread beendet".into()));
|
||||
}
|
||||
None
|
||||
@@ -272,13 +294,14 @@ pub(crate) mod tests {
|
||||
let mut service = PdfRenderService::new(move || {
|
||||
let _ = wake_tx.send(());
|
||||
});
|
||||
assert!(service.take("a#0", path.clone(), 0).is_none());
|
||||
let px = DISPLAY_RENDER_PIXELS;
|
||||
assert!(service.take("a#0", path.clone(), 0, px).is_none());
|
||||
// Zweite Anfrage erteilt keinen weiteren Auftrag
|
||||
assert!(service.take("a#0", path.clone(), 0).is_none());
|
||||
assert!(service.take("a#0", path.clone(), 0, px).is_none());
|
||||
wake_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(20))
|
||||
.unwrap();
|
||||
let image = service.take("a#0", path.clone(), 0).unwrap().unwrap();
|
||||
let image = service.take("a#0", path.clone(), 0, px).unwrap().unwrap();
|
||||
assert_eq!(image.width, DISPLAY_RENDER_PIXELS);
|
||||
|
||||
// Vorab rendern: danach sofort verfügbar
|
||||
@@ -286,6 +309,17 @@ pub(crate) mod tests {
|
||||
wake_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(20))
|
||||
.unwrap();
|
||||
assert!(service.take("a#0", path, 0).unwrap().is_ok());
|
||||
assert!(service.take("a#0", path, 0, px).unwrap().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_page_key() {
|
||||
assert_eq!(
|
||||
parse_page_key("abc.pdf#3"),
|
||||
Some(("abc.pdf", 3, DISPLAY_RENDER_PIXELS))
|
||||
);
|
||||
assert_eq!(parse_page_key("abc.pdf#3@5120"), Some(("abc.pdf", 3, 5120)));
|
||||
assert_eq!(parse_page_key("abc.png"), None);
|
||||
assert_eq!(parse_page_key("abc.pdf#x"), None);
|
||||
}
|
||||
}
|
||||
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
//! Virtuelles Lineal: ein verschieb- und drehbares Lineal über dem Blatt. Striche, die nahe
|
||||
//! einer Längskante beginnen, rasten auf dieser Kante ein und werden zu geraden Linien.
|
||||
//!
|
||||
//! Alle Maße in Seiteneinheiten (10 Einheiten = 1 mm).
|
||||
|
||||
use glam::Vec2;
|
||||
|
||||
use crate::document::UNITS_PER_MM;
|
||||
|
||||
/// Länge des Lineals (26 cm).
|
||||
pub const RULER_LENGTH: f32 = 260.0 * UNITS_PER_MM;
|
||||
/// Breite des Lineals (3,2 cm).
|
||||
pub const RULER_WIDTH: f32 = 32.0 * UNITS_PER_MM;
|
||||
/// Abstand zu einer Längskante, innerhalb dessen ein Strich einrastet (innen wie außen).
|
||||
pub const SNAP_DISTANCE: f32 = 6.0 * UNITS_PER_MM;
|
||||
/// Radius des Dreh-Griffs.
|
||||
pub const HANDLE_RADIUS: f32 = 8.0 * UNITS_PER_MM;
|
||||
/// Winkel rasten in diesen Schritten ein …
|
||||
pub const ANGLE_SNAP_STEP_DEG: f32 = 15.0;
|
||||
/// … wenn sie höchstens so weit davon entfernt sind.
|
||||
pub const ANGLE_SNAP_TOLERANCE_DEG: f32 = 2.0;
|
||||
|
||||
/// Lage des Lineals auf dem Blatt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Ruler {
|
||||
/// Mittelpunkt
|
||||
pub center: Vec2,
|
||||
/// Drehwinkel der Längsachse in Bogenmaß (0 = waagerecht)
|
||||
pub angle: f32,
|
||||
}
|
||||
|
||||
/// Was an einer Stelle des Lineals liegt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RulerHit {
|
||||
/// Fläche zum Verschieben
|
||||
Body,
|
||||
/// Griff zum Drehen
|
||||
RotateHandle,
|
||||
}
|
||||
|
||||
/// Eine Kante, an der ein Strich eingerastet ist: Gerade durch `origin` in Richtung `dir`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct EdgeLock {
|
||||
pub origin: Vec2,
|
||||
pub dir: Vec2,
|
||||
}
|
||||
|
||||
impl EdgeLock {
|
||||
/// Fußpunkt von `p` auf der Geraden.
|
||||
pub fn project(&self, p: Vec2) -> Vec2 {
|
||||
self.origin + self.dir * (p - self.origin).dot(self.dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Ruler {
|
||||
pub fn new(center: Vec2) -> Self {
|
||||
Self { center, angle: 0.0 }
|
||||
}
|
||||
|
||||
/// Einheitsvektor entlang der Längsachse.
|
||||
pub fn axis(&self) -> Vec2 {
|
||||
Vec2::from_angle(self.angle)
|
||||
}
|
||||
|
||||
/// Einheitsvektor quer zur Längsachse (zur „unteren“ Kante, bei 0° nach unten).
|
||||
pub fn normal(&self) -> Vec2 {
|
||||
self.axis().perp()
|
||||
}
|
||||
|
||||
/// Lage von `p` im Lineal-Koordinatensystem: (entlang der Achse, quer dazu).
|
||||
pub fn local(&self, p: Vec2) -> Vec2 {
|
||||
let d = p - self.center;
|
||||
Vec2::new(d.dot(self.axis()), d.dot(self.normal()))
|
||||
}
|
||||
|
||||
/// Ecken: oben links, oben rechts, unten rechts, unten links (bei 0°).
|
||||
pub fn corners(&self) -> [Vec2; 4] {
|
||||
let a = self.axis() * (RULER_LENGTH / 2.0);
|
||||
let n = self.normal() * (RULER_WIDTH / 2.0);
|
||||
[
|
||||
self.center - a - n,
|
||||
self.center + a - n,
|
||||
self.center + a + n,
|
||||
self.center - a + n,
|
||||
]
|
||||
}
|
||||
|
||||
/// Mittelpunkt des Dreh-Griffs (nahe dem rechten Ende).
|
||||
pub fn handle_center(&self) -> Vec2 {
|
||||
self.center + self.axis() * (RULER_LENGTH / 2.0 - HANDLE_RADIUS * 1.6)
|
||||
}
|
||||
|
||||
/// Was liegt an `p`? Die Streifen entlang der Kanten gehören nicht dazu: dort beginnen
|
||||
/// eingerastete Striche.
|
||||
pub fn hit(&self, p: Vec2) -> Option<RulerHit> {
|
||||
if (p - self.handle_center()).length() <= HANDLE_RADIUS {
|
||||
return Some(RulerHit::RotateHandle);
|
||||
}
|
||||
let l = self.local(p);
|
||||
let inside_band = l.y.abs() <= RULER_WIDTH / 2.0 - SNAP_DISTANCE;
|
||||
(l.x.abs() <= RULER_LENGTH / 2.0 && inside_band).then_some(RulerHit::Body)
|
||||
}
|
||||
|
||||
/// Kante, an der ein bei `p` beginnender Strich einrastet, verschoben um `offset` nach
|
||||
/// außen (halbe Strichbreite: die Tinte liegt dann neben dem Lineal, nicht darunter).
|
||||
pub fn edge_lock(&self, p: Vec2, offset: f32) -> Option<EdgeLock> {
|
||||
let l = self.local(p);
|
||||
if l.x.abs() > RULER_LENGTH / 2.0 + SNAP_DISTANCE {
|
||||
return None;
|
||||
}
|
||||
let half = RULER_WIDTH / 2.0;
|
||||
let side = if (l.y - half).abs() <= SNAP_DISTANCE {
|
||||
1.0
|
||||
} else if (l.y + half).abs() <= SNAP_DISTANCE {
|
||||
-1.0
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(EdgeLock {
|
||||
origin: self.center + self.normal() * side * (half + offset),
|
||||
dir: self.axis(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Dreht das Lineal so, dass die Achse vom Mittelpunkt auf `toward` zeigt; Vielfache von
|
||||
/// 15° rasten ein.
|
||||
pub fn rotate_toward(&mut self, toward: Vec2) {
|
||||
let d = toward - self.center;
|
||||
if d.length_squared() < 1e-6 {
|
||||
return;
|
||||
}
|
||||
self.angle = snap_angle(d.y.atan2(d.x));
|
||||
}
|
||||
|
||||
/// Winkel für die Anzeige in Grad, 0–179 (ein Lineal hat keine Richtung).
|
||||
pub fn display_degrees(&self) -> u32 {
|
||||
(self.angle.to_degrees().round() as i32).rem_euclid(180) as u32
|
||||
}
|
||||
}
|
||||
|
||||
/// Rastet einen Winkel (Bogenmaß) auf das nächste Vielfache von 15° ein, wenn er nahe genug ist.
|
||||
pub fn snap_angle(angle: f32) -> f32 {
|
||||
let deg = angle.to_degrees();
|
||||
let nearest = (deg / ANGLE_SNAP_STEP_DEG).round() * ANGLE_SNAP_STEP_DEG;
|
||||
if (deg - nearest).abs() <= ANGLE_SNAP_TOLERANCE_DEG {
|
||||
nearest.to_radians()
|
||||
} else {
|
||||
angle
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn close(a: Vec2, b: Vec2) -> bool {
|
||||
(a - b).length() < 1e-2
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_geometry_and_hits() {
|
||||
let ruler = Ruler::new(Vec2::new(1000.0, 1000.0));
|
||||
let [tl, tr, br, bl] = ruler.corners();
|
||||
assert!(close(
|
||||
tl,
|
||||
Vec2::new(1000.0 - RULER_LENGTH / 2.0, 1000.0 - RULER_WIDTH / 2.0)
|
||||
));
|
||||
assert!(close(
|
||||
br,
|
||||
Vec2::new(1000.0 + RULER_LENGTH / 2.0, 1000.0 + RULER_WIDTH / 2.0)
|
||||
));
|
||||
assert!(close(
|
||||
tr - tl,
|
||||
bl - tl + Vec2::new(RULER_LENGTH, -RULER_WIDTH)
|
||||
));
|
||||
|
||||
assert_eq!(ruler.hit(ruler.center), Some(RulerHit::Body));
|
||||
assert_eq!(
|
||||
ruler.hit(ruler.handle_center()),
|
||||
Some(RulerHit::RotateHandle)
|
||||
);
|
||||
// Kantenstreifen und außerhalb: kein Treffer (dort wird gezeichnet)
|
||||
assert_eq!(
|
||||
ruler.hit(Vec2::new(1000.0, 1000.0 - RULER_WIDTH / 2.0 + 10.0)),
|
||||
None
|
||||
);
|
||||
assert_eq!(ruler.hit(Vec2::new(1000.0, 1300.0)), None);
|
||||
assert_eq!(ruler.hit(Vec2::new(1000.0 + RULER_LENGTH, 1000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edge_lock_projects_onto_offset_edge() {
|
||||
let ruler = Ruler::new(Vec2::new(1000.0, 1000.0));
|
||||
let edge_y = 1000.0 - RULER_WIDTH / 2.0;
|
||||
// Knapp oberhalb der oberen Kante: rastet ein, Linie liegt um den Versatz außerhalb
|
||||
let lock = ruler
|
||||
.edge_lock(Vec2::new(900.0, edge_y - 20.0), 3.0)
|
||||
.unwrap();
|
||||
let p = lock.project(Vec2::new(1300.0, edge_y - 55.0));
|
||||
assert!(close(p, Vec2::new(1300.0, edge_y - 3.0)));
|
||||
// Knapp innerhalb der unteren Kante
|
||||
let lock = ruler
|
||||
.edge_lock(Vec2::new(900.0, 1000.0 + RULER_WIDTH / 2.0 - 20.0), 0.0)
|
||||
.unwrap();
|
||||
assert!(close(
|
||||
lock.project(Vec2::new(0.0, 0.0)),
|
||||
Vec2::new(0.0, 1000.0 + RULER_WIDTH / 2.0)
|
||||
));
|
||||
// Mitte oder weit weg: kein Einrasten
|
||||
assert!(ruler.edge_lock(ruler.center, 0.0).is_none());
|
||||
assert!(ruler.edge_lock(Vec2::new(1000.0, 500.0), 0.0).is_none());
|
||||
assert!(ruler
|
||||
.edge_lock(Vec2::new(1000.0 + RULER_LENGTH, edge_y), 0.0)
|
||||
.is_none());
|
||||
|
||||
// Gedrehtes Lineal: die Linie folgt der Drehung
|
||||
let mut tilted = ruler;
|
||||
tilted.angle = 30f32.to_radians();
|
||||
let near_edge = tilted.center - tilted.normal() * (RULER_WIDTH / 2.0 + 10.0);
|
||||
let lock = tilted.edge_lock(near_edge, 0.0).unwrap();
|
||||
let a = lock.project(near_edge);
|
||||
let b = lock.project(near_edge + Vec2::new(300.0, 0.0));
|
||||
let dir = (b - a).normalize();
|
||||
assert!((dir.y.atan2(dir.x).to_degrees() - 30.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rotation_snaps_to_15_degrees() {
|
||||
let mut ruler = Ruler::new(Vec2::ZERO);
|
||||
ruler.rotate_toward(Vec2::from_angle(44.0f32.to_radians()) * 100.0);
|
||||
assert_eq!(ruler.display_degrees(), 45);
|
||||
ruler.rotate_toward(Vec2::from_angle(37.0f32.to_radians()) * 100.0);
|
||||
assert_eq!(ruler.display_degrees(), 37);
|
||||
// Gegenrichtung zeigt denselben Winkel
|
||||
ruler.rotate_toward(Vec2::from_angle(-150.0f32.to_radians()) * 100.0);
|
||||
assert_eq!(ruler.display_degrees(), 30);
|
||||
assert_eq!(snap_angle(1.0f32.to_radians()), 0.0);
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,8 @@ pub struct UiSettings {
|
||||
pub adapt_ink_colors: bool,
|
||||
/// Druckkennlinie des Stifts
|
||||
pub pressure_curve: PressureCurve,
|
||||
/// Seiten durchgehend untereinander anzeigen (sonst einzeln blättern)
|
||||
pub continuous_scroll: bool,
|
||||
}
|
||||
|
||||
impl Default for UiSettings {
|
||||
@@ -93,6 +95,7 @@ impl Default for UiSettings {
|
||||
paper_theme: PaperTheme::default(),
|
||||
adapt_ink_colors: true,
|
||||
pressure_curve: PressureCurve::default(),
|
||||
continuous_scroll: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,6 +152,7 @@ mod tests {
|
||||
exponent: 1.8,
|
||||
threshold: 0.1,
|
||||
},
|
||||
continuous_scroll: false,
|
||||
};
|
||||
settings.save_to(&path).unwrap();
|
||||
assert_eq!(UiSettings::load_from(&path), settings);
|
||||
@@ -173,6 +177,7 @@ mod tests {
|
||||
assert_eq!(settings.paper_theme, PaperTheme::White);
|
||||
assert!(settings.adapt_ink_colors);
|
||||
assert_eq!(settings.pressure_curve, PressureCurve::default());
|
||||
assert!(settings.continuous_scroll);
|
||||
|
||||
// Unsinnige Werte aus einer von Hand bearbeiteten Datei werden begrenzt
|
||||
std::fs::write(
|
||||
|
||||
+150
-8
@@ -73,6 +73,9 @@ pub struct StrokeData {
|
||||
/// Eingefügtes Bild (Name im Asset-Ordner); die Samples sind dann die vier Bildecken
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<String>,
|
||||
/// Textfeld; die Samples sind dann die vier Ecken des Felds
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<crate::text::TextBox>,
|
||||
}
|
||||
|
||||
impl From<&Stroke> for StrokeData {
|
||||
@@ -86,6 +89,7 @@ impl From<&Stroke> for StrokeData {
|
||||
width: stroke.width,
|
||||
finalized: stroke.finalized,
|
||||
image: stroke.image.clone(),
|
||||
text: stroke.text.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,6 +104,14 @@ impl StrokeData {
|
||||
.copied()
|
||||
.map(StylusSample::from)
|
||||
.collect();
|
||||
if let (Some(text), [a, b, c, d, ..]) = (&self.text, stylus_samples.as_slice()) {
|
||||
return Stroke::new_text(
|
||||
self.id,
|
||||
text.clone(),
|
||||
[a.position, b.position, c.position, d.position],
|
||||
self.color,
|
||||
);
|
||||
}
|
||||
if let Some(asset) = &self.image {
|
||||
if let [a, b, c, d, ..] = stylus_samples.as_slice() {
|
||||
let mut stroke = Stroke::new_image(
|
||||
@@ -153,7 +165,9 @@ impl StrokeData {
|
||||
/// verlieren.
|
||||
/// - Version 5: importierte PDF-Seiten als Seitenhintergrund (`pdf_background`).
|
||||
/// - Version 6: Seitentitel (`title` in Seite und Manifest).
|
||||
pub const CURRENT_FORMAT_VERSION: u32 = 6;
|
||||
/// - Version 7: Textfelder (`text` am Strich).
|
||||
/// - 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 {
|
||||
@@ -173,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 {
|
||||
@@ -184,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(),
|
||||
}
|
||||
}
|
||||
@@ -192,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 {
|
||||
@@ -214,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 {
|
||||
@@ -222,6 +258,7 @@ impl PageMeta {
|
||||
id,
|
||||
background,
|
||||
title: None,
|
||||
size: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,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()
|
||||
@@ -295,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();
|
||||
@@ -705,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
|
||||
}
|
||||
@@ -721,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
|
||||
}
|
||||
@@ -739,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)
|
||||
@@ -792,6 +841,14 @@ pub fn ensure_page_loaded(
|
||||
notebook.pages[page_idx] = page;
|
||||
Ok(())
|
||||
}
|
||||
// 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).with_size(placeholder.size);
|
||||
page.title = placeholder.title.take();
|
||||
*placeholder = page;
|
||||
Ok(())
|
||||
}
|
||||
Err(StorageError::CorruptedPage(id, err_msg)) => {
|
||||
let q_res = quarantine_corrupted_page(notebook_dir, id);
|
||||
let q_path = q_res.ok();
|
||||
@@ -1111,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,
|
||||
@@ -1337,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);
|
||||
@@ -2318,6 +2378,26 @@ mod tests {
|
||||
assert_eq!(reloaded.current_page().strokes.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_never_saved_page_loads_as_empty_page() {
|
||||
// Angehängte, nie beschriebene Seite: steht im Manifest, hat aber keine Datei
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let (mut notebook, mut manifest, dir) =
|
||||
create_new_notebook(temp_dir.path(), "Angehängt").unwrap();
|
||||
notebook.add_page(PageBackground::Lined);
|
||||
save_notebook(&dir, ¬ebook, &mut manifest).unwrap();
|
||||
let _ = fs::remove_file(dir.join(format!("page_{}.json", notebook.pages[1].id)));
|
||||
|
||||
let (mut loaded, _) = load_notebook(&dir).unwrap();
|
||||
assert!(!loaded.pages[1].is_loaded);
|
||||
ensure_page_loaded(&dir, &mut loaded, 1).unwrap();
|
||||
let page = &loaded.pages[1];
|
||||
assert!(page.is_loaded && !page.is_readonly());
|
||||
assert_eq!(page.background, PageBackground::Lined);
|
||||
assert!(page.strokes.is_empty());
|
||||
assert!(loaded.load_warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_autosave_never_writes_unloaded_placeholder() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
@@ -2451,6 +2531,68 @@ mod tests {
|
||||
assert!(dir.join("assets").join(&imported.asset).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_text_boxes_round_trip_through_page_file() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let (mut notebook, mut manifest, dir) =
|
||||
create_new_notebook(temp_dir.path(), "Mit Text").unwrap();
|
||||
let text = crate::text::TextBox::new(
|
||||
"Überschrift\n\tfn main() {}".into(),
|
||||
42.0,
|
||||
crate::text::TextFont::Mono,
|
||||
900.0,
|
||||
);
|
||||
let corners = [
|
||||
glam::Vec2::new(100.0, 100.0),
|
||||
glam::Vec2::new(1000.0, 100.0),
|
||||
glam::Vec2::new(1000.0, 220.0),
|
||||
glam::Vec2::new(100.0, 220.0),
|
||||
];
|
||||
notebook.current_page_mut().strokes.push(Stroke::new_text(
|
||||
8,
|
||||
text.clone(),
|
||||
corners,
|
||||
[0.15, 0.35, 0.85, 1.0],
|
||||
));
|
||||
save_notebook(&dir, ¬ebook, &mut manifest).unwrap();
|
||||
|
||||
let (reloaded, _) = load_notebook(&dir).unwrap();
|
||||
let stroke = &reloaded.current_page().strokes[0];
|
||||
assert_eq!(stroke.text.as_ref(), Some(&text));
|
||||
assert_eq!(stroke.object_corners(), Some(corners));
|
||||
assert_eq!(stroke.color, [0.15, 0.35, 0.85, 1.0]);
|
||||
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();
|
||||
|
||||
+546
@@ -0,0 +1,546 @@
|
||||
//! Textfelder (Text-Werkzeug): Datenmodell, Zeilenumbruch und Rasterung.
|
||||
//!
|
||||
//! Ein Textfeld ist ein Objekt mit vier Ecken wie ein Bild (siehe `Stroke::text`). Der Text
|
||||
//! wird in einem eigenen Koordinatensystem gesetzt – Breite = Umbruchbreite, Höhe = Zeilen ×
|
||||
//! Zeilenhöhe, in Seiteneinheiten – und dieses Rechteck auf die Ecken abgebildet. So wirken
|
||||
//! Verschieben, Drehen und Skalieren der Auswahl ohne Sonderfälle auf Text.
|
||||
//!
|
||||
//! Bildschirm (Textur), PDF-Export (eingebettete Schrift, durchsuchbar) und Bearbeitung (egui)
|
||||
//! verwenden dieselben Schriften aus egui und denselben Zeilenumbruch.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use ab_glyph::{Font, FontRef, GlyphId, PxScale};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::assets::RgbaImage;
|
||||
use crate::document::{Stroke, UNITS_PER_MM};
|
||||
|
||||
/// Schriftart eines Textfelds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
pub enum TextFont {
|
||||
/// Proportionalschrift (Ubuntu Light) für Überschriften und Notizen
|
||||
#[default]
|
||||
Sans,
|
||||
/// Festbreitenschrift (Hack) für Code
|
||||
Mono,
|
||||
}
|
||||
|
||||
impl TextFont {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TextFont::Sans => "Normal",
|
||||
TextFont::Mono => "Code",
|
||||
}
|
||||
}
|
||||
|
||||
/// Name der Schrift in den egui-Standardschriften.
|
||||
fn egui_name(self) -> &'static str {
|
||||
match self {
|
||||
TextFont::Sans => "Ubuntu-Light",
|
||||
TextFont::Mono => "Hack",
|
||||
}
|
||||
}
|
||||
|
||||
/// egui-Schriftfamilie, deren erste Schrift diese ist (für die Bearbeitung).
|
||||
pub fn egui_family(self) -> egui::FontFamily {
|
||||
match self {
|
||||
TextFont::Sans => egui::FontFamily::Proportional,
|
||||
TextFont::Mono => egui::FontFamily::Monospace,
|
||||
}
|
||||
}
|
||||
|
||||
/// TrueType-Daten der Schrift (aus den in egui eingebauten Schriften).
|
||||
pub fn bytes(self) -> &'static [u8] {
|
||||
static DEFINITIONS: OnceLock<egui::FontDefinitions> = OnceLock::new();
|
||||
let definitions = DEFINITIONS.get_or_init(egui::FontDefinitions::default);
|
||||
&definitions.font_data[self.egui_name()].font
|
||||
}
|
||||
|
||||
pub fn face(self) -> &'static FontRef<'static> {
|
||||
static SANS: OnceLock<FontRef<'static>> = OnceLock::new();
|
||||
static MONO: OnceLock<FontRef<'static>> = OnceLock::new();
|
||||
let cell = match self {
|
||||
TextFont::Sans => &SANS,
|
||||
TextFont::Mono => &MONO,
|
||||
};
|
||||
cell.get_or_init(|| {
|
||||
FontRef::try_from_slice(self.bytes()).expect("eingebaute egui-Schrift ist gültig")
|
||||
})
|
||||
}
|
||||
|
||||
fn units_per_em(self) -> f32 {
|
||||
self.face().units_per_em().unwrap_or(1000.0)
|
||||
}
|
||||
|
||||
/// Höhe von Ober- bis Unterlänge je Em: ab_glyph skaliert auf diese Höhe, PDF, egui und
|
||||
/// dieses Modul messen die Schriftgröße als Em.
|
||||
pub fn height_per_em(self) -> f32 {
|
||||
let face = self.face();
|
||||
(face.ascent_unscaled() - face.descent_unscaled()) / self.units_per_em()
|
||||
}
|
||||
}
|
||||
|
||||
/// Seiteneinheiten je typografischem Punkt.
|
||||
pub const UNITS_PER_PT: f32 = 25.4 / 72.0 * UNITS_PER_MM;
|
||||
/// Wählbare Schriftgrößen in Punkt.
|
||||
pub const TEXT_SIZES_PT: [f32; 7] = [8.0, 10.0, 12.0, 14.0, 18.0, 24.0, 36.0];
|
||||
pub const DEFAULT_TEXT_SIZE_PT: f32 = 12.0;
|
||||
/// Kleinste Umbruchbreite eines neuen Textfelds (3 cm).
|
||||
pub const MIN_WRAP_WIDTH: f32 = 300.0;
|
||||
/// Abstand neuer Textfelder zum rechten Blattrand (1 cm).
|
||||
pub const RIGHT_MARGIN: f32 = 100.0;
|
||||
/// Ein Tabulator wird durch so viele Leerzeichen ersetzt.
|
||||
const TAB_WIDTH: usize = 4;
|
||||
|
||||
/// Inhalt und Satz eines Textfelds.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TextBox {
|
||||
pub content: String,
|
||||
/// Schriftgröße (Em) in Seiteneinheiten
|
||||
pub size: f32,
|
||||
#[serde(default)]
|
||||
pub font: TextFont,
|
||||
/// Umbruchbreite in Seiteneinheiten (Breite des ungedrehten, unskalierten Felds)
|
||||
pub wrap_width: f32,
|
||||
}
|
||||
|
||||
/// Eine gesetzte Zeile: Zeichen (ohne Zeichen, die die Schrift nicht kennt) und ihre
|
||||
/// Glyphen mit x-Position.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LaidLine {
|
||||
pub text: String,
|
||||
pub glyphs: Vec<(GlyphId, f32)>,
|
||||
pub width: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TextLayout {
|
||||
pub lines: Vec<LaidLine>,
|
||||
/// Oberlänge (Abstand Zeilenoberkante → Grundlinie)
|
||||
pub ascent: f32,
|
||||
pub line_height: f32,
|
||||
}
|
||||
|
||||
impl TextLayout {
|
||||
/// Höhe des Felds; ein leeres Feld ist eine Zeile hoch.
|
||||
pub fn height(&self) -> f32 {
|
||||
self.lines.len().max(1) as f32 * self.line_height
|
||||
}
|
||||
|
||||
/// Grundlinie der Zeile `i` (von der Oberkante des Felds).
|
||||
pub fn baseline(&self, i: usize) -> f32 {
|
||||
i as f32 * self.line_height + self.ascent
|
||||
}
|
||||
}
|
||||
|
||||
impl TextBox {
|
||||
pub fn new(content: String, size: f32, font: TextFont, wrap_width: f32) -> Self {
|
||||
Self {
|
||||
content,
|
||||
size,
|
||||
font,
|
||||
wrap_width,
|
||||
}
|
||||
}
|
||||
|
||||
/// Größe des ungedrehten, unskalierten Felds in Seiteneinheiten.
|
||||
pub fn box_size(&self) -> glam::Vec2 {
|
||||
glam::Vec2::new(self.wrap_width.max(self.size), self.layout().height())
|
||||
}
|
||||
|
||||
/// Setzt den Text mit Zeilenumbruch an Leerzeichen; zu lange Wörter werden zeichenweise
|
||||
/// umbrochen, Zeilenumbrüche im Text bleiben erhalten.
|
||||
pub fn layout(&self) -> TextLayout {
|
||||
let face = self.font.face();
|
||||
let em = self.size / self.font.units_per_em();
|
||||
let line_height =
|
||||
(face.ascent_unscaled() - face.descent_unscaled() + face.line_gap_unscaled()) * em;
|
||||
let width = self.wrap_width.max(self.size);
|
||||
|
||||
let mut lines = Vec::new();
|
||||
for paragraph in self.content.split('\n') {
|
||||
let mut chars: Vec<(char, GlyphId, f32)> = paragraph
|
||||
.chars()
|
||||
.flat_map(|c| {
|
||||
let (c, n) = if c == '\t' { (' ', TAB_WIDTH) } else { (c, 1) };
|
||||
std::iter::repeat_n(c, n)
|
||||
})
|
||||
.filter_map(|c| {
|
||||
let id = face.glyph_id(c);
|
||||
(id.0 != 0).then(|| (c, id, face.h_advance_unscaled(id) * em))
|
||||
})
|
||||
.collect();
|
||||
// Unterschneidung (wie egui) dem vorderen Zeichen des Paars zurechnen
|
||||
for i in 1..chars.len() {
|
||||
chars[i - 1].2 += face.kern_unscaled(chars[i - 1].1, chars[i].1) * em;
|
||||
}
|
||||
let mut start = 0;
|
||||
let mut x = 0.0;
|
||||
// Letztes Leerzeichen der Zeile (möglicher Umbruch)
|
||||
let mut last_space: Option<usize> = None;
|
||||
for i in 0..chars.len() {
|
||||
let (c, _, advance) = chars[i];
|
||||
if c != ' ' && x + advance > width && i > start {
|
||||
let end = match last_space {
|
||||
Some(space) if space > start => space,
|
||||
_ => i,
|
||||
};
|
||||
lines.push(laid_line(&chars[start..end]));
|
||||
start = end;
|
||||
while start < i && chars[start].0 == ' ' {
|
||||
start += 1;
|
||||
}
|
||||
x = chars[start..i].iter().map(|g| g.2).sum();
|
||||
last_space = None;
|
||||
}
|
||||
x += advance;
|
||||
if c == ' ' {
|
||||
last_space = Some(i);
|
||||
}
|
||||
}
|
||||
lines.push(laid_line(&chars[start..]));
|
||||
}
|
||||
// Leere Zeilen am Ende zählen mit (Platz für den Cursor), ein leerer Text ist eine Zeile
|
||||
TextLayout {
|
||||
lines,
|
||||
ascent: face.ascent_unscaled() * em,
|
||||
line_height,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn laid_line(chars: &[(char, GlyphId, f32)]) -> LaidLine {
|
||||
let mut x = 0.0;
|
||||
let mut glyphs = Vec::with_capacity(chars.len());
|
||||
let mut width: f32 = 0.0;
|
||||
for &(c, id, advance) in chars {
|
||||
glyphs.push((id, x));
|
||||
x += advance;
|
||||
if c != ' ' {
|
||||
width = x;
|
||||
}
|
||||
}
|
||||
LaidLine {
|
||||
text: chars.iter().map(|g| g.0).collect(),
|
||||
glyphs,
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ecken eines Felds der Größe `size` mit Ursprung (oben links) `origin` und den Achsen
|
||||
/// `axes` (Seitenvektor je Einheit nach rechts bzw. unten): oben links, oben rechts,
|
||||
/// unten rechts, unten links.
|
||||
pub fn frame_corners(
|
||||
origin: glam::Vec2,
|
||||
axes: [glam::Vec2; 2],
|
||||
size: glam::Vec2,
|
||||
) -> [glam::Vec2; 4] {
|
||||
let right = axes[0] * size.x;
|
||||
let down = axes[1] * size.y;
|
||||
[origin, origin + right, origin + right + down, origin + down]
|
||||
}
|
||||
|
||||
/// Ursprung und Achsen (je Einheit) eines Felds der Größe `size` mit den Ecken `corners`.
|
||||
pub fn frame_axes(corners: &[glam::Vec2; 4], size: glam::Vec2) -> (glam::Vec2, [glam::Vec2; 2]) {
|
||||
let [tl, tr, _, bl] = *corners;
|
||||
(
|
||||
tl,
|
||||
[(tr - tl) / size.x.max(1e-3), (bl - tl) / size.y.max(1e-3)],
|
||||
)
|
||||
}
|
||||
|
||||
/// Laufende Bearbeitung eines Textfelds (neu oder vorhanden).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TextEditing {
|
||||
/// Seite, auf der das Feld liegt
|
||||
pub page_id: u64,
|
||||
/// Bearbeitetes vorhandenes Feld im Zustand vor der Bearbeitung; `None` bei einem neuen
|
||||
pub original: Option<Stroke>,
|
||||
/// Ecke oben links und Achsen (Seitenvektor je Einheit nach rechts bzw. unten)
|
||||
pub origin: glam::Vec2,
|
||||
pub axes: [glam::Vec2; 2],
|
||||
pub text: TextBox,
|
||||
pub color: [f32; 4],
|
||||
/// Beim nächsten Bild den Tastaturfokus auf das Feld setzen
|
||||
pub focus: bool,
|
||||
/// Bearbeitung beenden (Esc)
|
||||
pub finish: bool,
|
||||
}
|
||||
|
||||
impl TextEditing {
|
||||
/// Neues, leeres Feld: die erste Zeile mittig auf `pos`, umbrochen 1 cm vor dem rechten
|
||||
/// 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 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;
|
||||
Self {
|
||||
page_id,
|
||||
original: None,
|
||||
origin: glam::Vec2::new(x, (pos.y - line_height / 2.0).max(0.0)),
|
||||
axes: [glam::Vec2::X, glam::Vec2::Y],
|
||||
text,
|
||||
color,
|
||||
focus: true,
|
||||
finish: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bearbeitung des vorhandenen Textfelds `stroke`.
|
||||
pub fn of_stroke(page_id: u64, stroke: &Stroke) -> Option<Self> {
|
||||
let text = stroke.text.clone()?;
|
||||
let corners = stroke.object_corners()?;
|
||||
let (origin, axes) = frame_axes(&corners, text.box_size());
|
||||
Some(Self {
|
||||
page_id,
|
||||
original: Some(stroke.clone()),
|
||||
origin,
|
||||
axes,
|
||||
text,
|
||||
color: stroke.color,
|
||||
focus: true,
|
||||
finish: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Wurde gegenüber dem Ausgangszustand etwas geändert?
|
||||
pub fn is_changed(&self) -> bool {
|
||||
match &self.original {
|
||||
Some(original) => {
|
||||
original.text.as_ref() != Some(&self.text) || original.color != self.color
|
||||
}
|
||||
None => !self.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enthält das Feld nur Leerraum?
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.text.content.trim().is_empty()
|
||||
}
|
||||
|
||||
/// Ergebnis als Textfeld mit der ID `id`; die Höhe passt sich dem Text an, Lage,
|
||||
/// Drehung und Skalierung bleiben. `None` für ein leeres Feld.
|
||||
pub fn to_stroke(&self, id: u64) -> Option<Stroke> {
|
||||
if self.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let corners = frame_corners(self.origin, self.axes, self.text.box_size());
|
||||
Some(Stroke::new_text(id, self.text.clone(), corners, self.color))
|
||||
}
|
||||
}
|
||||
|
||||
/// Exponent für die Kantendeckung der Glyphen (wie egui, damit Bearbeitung und Anzeige
|
||||
/// gleich kräftig wirken).
|
||||
const COVERAGE_GAMMA: f32 = 0.55;
|
||||
|
||||
/// Größte Kantenlänge einer Texttextur in Pixeln.
|
||||
const MAX_TEXTURE_SIDE: f32 = 4096.0;
|
||||
/// Größte Pixelzahl einer Texttextur (64 MB RGBA).
|
||||
const MAX_TEXTURE_PIXELS: f32 = 16.0 * 1024.0 * 1024.0;
|
||||
|
||||
/// Rastert ein Textfeld mit `px_per_unit` Pixeln je Seiteneinheit (begrenzt auf eine
|
||||
/// handhabbare Texturgröße). `color` ist linear wie die Tinte; die Pixel sind sRGB.
|
||||
pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage {
|
||||
let layout = text.layout();
|
||||
let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height());
|
||||
let scale = px_per_unit
|
||||
.min(MAX_TEXTURE_SIDE / size.x)
|
||||
.min(MAX_TEXTURE_SIDE / size.y)
|
||||
.min((MAX_TEXTURE_PIXELS / (size.x * size.y)).sqrt())
|
||||
.max(1e-3);
|
||||
let width = ((size.x * scale).ceil() as u32).max(1);
|
||||
let height = ((size.y * scale).ceil() as u32).max(1);
|
||||
|
||||
let face = text.font.face();
|
||||
// ab_glyph misst die Skalierung als Höhe Ober- bis Unterlänge
|
||||
let px_scale = PxScale::from(text.size * scale * text.font.height_per_em());
|
||||
let mut coverage = vec![0.0_f32; (width * height) as usize];
|
||||
for (i, line) in layout.lines.iter().enumerate() {
|
||||
let baseline = layout.baseline(i) * scale;
|
||||
for &(id, x) in &line.glyphs {
|
||||
let glyph = id.with_scale_and_position(px_scale, ab_glyph::point(x * scale, baseline));
|
||||
let Some(outlined) = face.outline_glyph(glyph) else {
|
||||
continue;
|
||||
};
|
||||
let bounds = outlined.px_bounds();
|
||||
outlined.draw(|gx, gy, c| {
|
||||
let px = bounds.min.x as i64 + gx as i64;
|
||||
let py = bounds.min.y as i64 + gy as i64;
|
||||
if px >= 0 && py >= 0 && (px as u32) < width && (py as u32) < height {
|
||||
let cell = &mut coverage[(py as u32 * width + px as u32) as usize];
|
||||
*cell = (*cell + c).min(1.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let rgb = [0, 1, 2].map(|i| egui::ecolor::gamma_u8_from_linear_f32(color[i]));
|
||||
let alpha = color[3].clamp(0.0, 1.0);
|
||||
let mut pixels = Vec::with_capacity(coverage.len() * 4);
|
||||
for c in coverage {
|
||||
pixels.extend_from_slice(&rgb);
|
||||
// Wie egui: Deckung anheben, sonst wirkt die dünne Schrift verkleinert blass
|
||||
pixels.push((c.powf(COVERAGE_GAMMA) * alpha * 255.0).round() as u8);
|
||||
}
|
||||
RgbaImage {
|
||||
width,
|
||||
height,
|
||||
pixels,
|
||||
}
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fonts_load() {
|
||||
for font in [TextFont::Sans, TextFont::Mono] {
|
||||
assert!(font.face().glyph_id('ä').0 != 0);
|
||||
let factor = font.height_per_em();
|
||||
assert!((1.0..1.4).contains(&factor), "{factor}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_layout_wraps_at_spaces() {
|
||||
let text = sample("eins zwei drei vier fünf sechs", 400.0);
|
||||
let layout = text.layout();
|
||||
assert!(layout.lines.len() > 1);
|
||||
for line in &layout.lines {
|
||||
assert!(line.width <= 400.0 + 1e-3, "{line:?}");
|
||||
assert!(!line.text.starts_with(' ') && !line.text.ends_with(' '));
|
||||
}
|
||||
let words: Vec<String> = layout.lines.iter().map(|l| l.text.clone()).collect();
|
||||
assert_eq!(words.join(" "), "eins zwei drei vier fünf sechs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_layout_breaks_long_words_and_keeps_newlines() {
|
||||
let text = sample("Donaudampfschifffahrtsgesellschaft\n\nende", 300.0);
|
||||
let layout = text.layout();
|
||||
assert!(layout.lines.iter().all(|l| l.width <= 300.0 + 1e-3));
|
||||
let joined: String = layout.lines.iter().map(|l| l.text.as_str()).collect();
|
||||
assert_eq!(joined, "Donaudampfschifffahrtsgesellschaftende");
|
||||
// Leere Zeile bleibt erhalten
|
||||
assert!(layout.lines.iter().any(|l| l.text.is_empty()));
|
||||
assert_eq!(layout.lines.last().unwrap().text, "ende");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_text_is_one_line_high() {
|
||||
let layout = sample("", 500.0).layout();
|
||||
assert!((layout.height() - layout.line_height).abs() < 1e-3);
|
||||
assert!(layout.line_height > 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tabs_become_spaces_and_indent_is_kept() {
|
||||
let text = TextBox::new("\tx".into(), 50.0, TextFont::Mono, 2000.0);
|
||||
let line = &text.layout().lines[0];
|
||||
assert_eq!(line.text, " x");
|
||||
// Festbreitenschrift: x steht vier Zeichenbreiten weiter
|
||||
let advance = line.glyphs[1].1;
|
||||
assert!((line.glyphs[4].1 - 4.0 * advance).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_frame_round_trip() {
|
||||
let size = glam::Vec2::new(400.0, 120.0);
|
||||
let axes = [glam::Vec2::new(0.0, 2.0), glam::Vec2::new(-2.0, 0.0)];
|
||||
let corners = frame_corners(glam::Vec2::new(100.0, 50.0), axes, size);
|
||||
let (origin, back) = frame_axes(&corners, size);
|
||||
assert_eq!(origin, glam::Vec2::new(100.0, 50.0));
|
||||
assert!((back[0] - axes[0]).length() < 1e-4 && (back[1] - axes[1]).length() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rasterize_draws_ink_in_color() {
|
||||
let text = sample("Hallo", 400.0);
|
||||
let image = rasterize(&text, [0.85, 0.2, 0.2, 1.0], 1.0);
|
||||
assert_eq!(image.width, 400);
|
||||
assert_eq!(image.height as f32, text.layout().height().ceil());
|
||||
let opaque = image.pixels.chunks(4).filter(|p| p[3] > 200).count();
|
||||
assert!(opaque > 100, "{opaque}");
|
||||
// Farbe ist sRGB-kodiert
|
||||
assert!(image.pixels.chunks(4).all(|p| p[0] > 230 && p[1] < 130));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_editing_new_text_box() {
|
||||
let edit = TextEditing::new_at(
|
||||
1,
|
||||
glam::Vec2::new(500.0, 800.0),
|
||||
PAGE_WIDTH,
|
||||
50.0,
|
||||
TextFont::Sans,
|
||||
[0.0; 4],
|
||||
);
|
||||
assert!((edit.text.wrap_width - (PAGE_WIDTH - RIGHT_MARGIN - 500.0)).abs() < 1e-3);
|
||||
assert!(edit.origin.y < 800.0);
|
||||
assert!(!edit.is_changed());
|
||||
assert!(edit.to_stroke(9).is_none(), "leeres Feld wird verworfen");
|
||||
|
||||
// Ganz rechts: Feld wird nach links versetzt, damit es mindestens 3 cm breit ist
|
||||
let edge = TextEditing::new_at(
|
||||
1,
|
||||
glam::Vec2::new(2050.0, 100.0),
|
||||
PAGE_WIDTH,
|
||||
50.0,
|
||||
TextFont::Sans,
|
||||
[0.0; 4],
|
||||
);
|
||||
assert!((edge.text.wrap_width - MIN_WRAP_WIDTH).abs() < 1e-3);
|
||||
assert!(edge.origin.x + MIN_WRAP_WIDTH <= PAGE_WIDTH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_editing_keeps_transform_and_grows_with_text() {
|
||||
let mut edit = TextEditing::new_at(
|
||||
1,
|
||||
glam::Vec2::new(400.0, 400.0),
|
||||
PAGE_WIDTH,
|
||||
50.0,
|
||||
TextFont::Sans,
|
||||
[0.0; 4],
|
||||
);
|
||||
edit.text.content = "eine Zeile".into();
|
||||
let mut stroke = edit.to_stroke(5).unwrap();
|
||||
assert!(stroke.is_text() && stroke.is_object());
|
||||
stroke.rotate(glam::Vec2::new(400.0, 400.0), 0.5);
|
||||
stroke.scale(glam::Vec2::new(400.0, 400.0), glam::Vec2::new(2.0, 2.0));
|
||||
let before = stroke.object_corners().unwrap();
|
||||
|
||||
let mut edit = TextEditing::of_stroke(1, &stroke).unwrap();
|
||||
assert!(!edit.is_changed());
|
||||
edit.text.content = "eine Zeile\nzwei Zeilen".into();
|
||||
assert!(edit.is_changed());
|
||||
let after = edit.to_stroke(5).unwrap().object_corners().unwrap();
|
||||
// Obere Kante bleibt, die Seitenkanten werden in ihrer Richtung doppelt so lang
|
||||
assert!((after[0] - before[0]).length() < 1e-2 && (after[1] - before[1]).length() < 1e-2);
|
||||
let (old_side, new_side) = (before[3] - before[0], after[3] - after[0]);
|
||||
assert!((new_side.length() / old_side.length() - 2.0).abs() < 1e-3);
|
||||
assert!(old_side.normalize().dot(new_side.normalize()) > 0.9999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rasterize_limits_texture_size() {
|
||||
let text = sample("x", 2000.0);
|
||||
let image = rasterize(&text, [0.0, 0.0, 0.0, 1.0], 100.0);
|
||||
assert!(image.width as f32 <= MAX_TEXTURE_SIDE);
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,10 @@ use crate::document::{
|
||||
EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH,
|
||||
};
|
||||
use crate::file_picker::{FilePicker, PickerKind};
|
||||
use crate::ruler::{Ruler, RulerHit};
|
||||
use crate::settings::{PressureCurve, SidebarSide};
|
||||
use crate::storage::NotebookSummary;
|
||||
use crate::text::{TextEditing, TextFont, TEXT_SIZES_PT};
|
||||
|
||||
/// Aktives Werkzeug für das Zeichnen oder Bearbeiten.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -28,6 +30,8 @@ pub enum ActiveTool {
|
||||
Eraser,
|
||||
Shape,
|
||||
Select,
|
||||
/// Textfelder schreiben und bearbeiten
|
||||
Text,
|
||||
}
|
||||
|
||||
impl ActiveTool {
|
||||
@@ -38,6 +42,7 @@ impl ActiveTool {
|
||||
ActiveTool::Eraser => "Radierer (Eraser)",
|
||||
ActiveTool::Shape => "Formen (Shape)",
|
||||
ActiveTool::Select => "Auswahl / Lasso (Select)",
|
||||
ActiveTool::Text => "Text (T)",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +102,37 @@ pub const PEN_WIDTH_PRESETS: [(f32, &str); 4] = [
|
||||
pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] =
|
||||
[(4.0, "4.0 mm"), (6.0, "6.0 mm"), (8.0, "8.0 mm")];
|
||||
|
||||
/// Abbildung zwischen Seitenkoordinaten und logischen Bildschirmpunkten (egui), vom
|
||||
/// Hauptprogramm je Bild aus Ansicht und Zoom gesetzt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PageView {
|
||||
/// Bildschirmposition des Seitenursprungs (oben links)
|
||||
pub origin: egui::Pos2,
|
||||
/// Bildschirmpunkte je Seiteneinheit
|
||||
pub scale: f32,
|
||||
}
|
||||
|
||||
impl PageView {
|
||||
pub fn to_screen(&self, p: glam::Vec2) -> egui::Pos2 {
|
||||
self.origin + egui::vec2(p.x, p.y) * self.scale
|
||||
}
|
||||
|
||||
pub fn to_page(&self, p: egui::Pos2) -> glam::Vec2 {
|
||||
let d = (p - self.origin) / self.scale.max(1e-6);
|
||||
glam::Vec2::new(d.x, d.y)
|
||||
}
|
||||
}
|
||||
|
||||
/// Laufende Bedienung des Lineals.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum RulerDrag {
|
||||
/// Verschieben; Abstand vom Griffpunkt zum Mittelpunkt
|
||||
Move {
|
||||
grab_offset: glam::Vec2,
|
||||
},
|
||||
Rotate,
|
||||
}
|
||||
|
||||
/// Format für den Export der aktuellen Seite.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PageExportKind {
|
||||
@@ -160,6 +196,8 @@ pub enum UiAction {
|
||||
},
|
||||
/// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`)
|
||||
SavePressureCurve(PressureCurve),
|
||||
/// Seiten durchgehend untereinander statt einzeln anzeigen (wird gespeichert)
|
||||
SetContinuousScroll(bool),
|
||||
/// Bilddatei auf der aktuellen Seite einfügen
|
||||
InsertImageFile(std::path::PathBuf),
|
||||
/// PDF als neues Notizbuch importieren
|
||||
@@ -228,12 +266,27 @@ pub struct UiState {
|
||||
pub paper_theme: PaperTheme,
|
||||
/// Dunkle Tinte auf dunklem Papier aufhellen
|
||||
pub adapt_ink_colors: bool,
|
||||
/// Seiten hängen untereinander und werden durchgehend gescrollt
|
||||
pub continuous_scroll: bool,
|
||||
/// Text-Werkzeug: Farbe, Schriftgröße (Punkt) und Schriftart neuer Textfelder
|
||||
pub text_color: [f32; 4],
|
||||
pub text_size_pt: f32,
|
||||
pub text_font: TextFont,
|
||||
/// Laufende Bearbeitung eines Textfelds
|
||||
pub text_edit: Option<TextEditing>,
|
||||
/// Druckkurve des Stifts
|
||||
pub pressure_curve: PressureCurve,
|
||||
/// Zuletzt gemessener Rohdruck des Stifts (Anzeige in der Druckkurve)
|
||||
pub last_raw_pressure: Option<f32>,
|
||||
/// Geöffnete Dateiauswahl („Bild einfügen“ oder „PDF importieren“)
|
||||
pub file_picker: Option<FilePicker>,
|
||||
/// Eingeblendetes Lineal
|
||||
pub ruler: Option<Ruler>,
|
||||
pub ruler_drag: Option<RulerDrag>,
|
||||
/// Aktuelle Abbildung Seite ↔ Bildschirm (für Lineal und Treffertests)
|
||||
pub page_view: Option<PageView>,
|
||||
/// Fenstergröße in logischen Punkten (vom letzten Bild)
|
||||
pub screen_size: egui::Vec2,
|
||||
/// Eingabefeld „Titel dieser Seite“ in der Seitenübersicht und die Seite, zu der es gehört
|
||||
pub page_title_input: String,
|
||||
pub page_title_for: Option<u64>,
|
||||
@@ -282,10 +335,19 @@ impl Default for UiState {
|
||||
eraser_mode: EraserMode::All,
|
||||
paper_theme: PaperTheme::White,
|
||||
adapt_ink_colors: true,
|
||||
continuous_scroll: true,
|
||||
text_color: PEN_COLOR_PRESETS[0].0,
|
||||
text_size_pt: crate::text::DEFAULT_TEXT_SIZE_PT,
|
||||
text_font: TextFont::Sans,
|
||||
text_edit: None,
|
||||
pressure_curve: PressureCurve::default(),
|
||||
last_raw_pressure: None,
|
||||
file_picker: None,
|
||||
queued_actions: Vec::new(),
|
||||
ruler: None,
|
||||
ruler_drag: None,
|
||||
page_view: None,
|
||||
screen_size: egui::Vec2::ZERO,
|
||||
page_title_input: String::new(),
|
||||
page_title_for: None,
|
||||
page_picker_list: false,
|
||||
@@ -314,6 +376,39 @@ impl UiState {
|
||||
self.file_picker = None;
|
||||
}
|
||||
|
||||
/// Blendet das Lineal ein (waagerecht in der Mitte des sichtbaren Blattbereichs) oder aus.
|
||||
pub fn toggle_ruler(&mut self) {
|
||||
self.ruler_drag = None;
|
||||
self.ruler = match self.ruler {
|
||||
Some(_) => None,
|
||||
None => {
|
||||
let center = self
|
||||
.page_view
|
||||
.map(|v| {
|
||||
let screen = self.visible_canvas_center();
|
||||
v.to_page(screen)
|
||||
})
|
||||
.unwrap_or(glam::Vec2::new(
|
||||
crate::document::PAGE_WIDTH / 2.0,
|
||||
crate::document::PAGE_HEIGHT / 2.0,
|
||||
));
|
||||
Some(Ruler::new(center))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Mitte des Bereichs neben der Werkzeugleiste (logische Punkte).
|
||||
fn visible_canvas_center(&self) -> egui::Pos2 {
|
||||
(self.screen_size / 2.0).to_pos2()
|
||||
+ egui::vec2(
|
||||
match self.sidebar_side {
|
||||
SidebarSide::Left => self.sidebar_reserved_width() / 2.0,
|
||||
SidebarSide::Right => -self.sidebar_reserved_width() / 2.0,
|
||||
},
|
||||
0.0,
|
||||
)
|
||||
}
|
||||
|
||||
/// Öffnet die Dateiauswahl „Bild einfügen“ im zuletzt benutzten Ordner.
|
||||
pub fn open_image_picker(&mut self) {
|
||||
self.open_file_picker(PickerKind::Image);
|
||||
@@ -341,9 +436,10 @@ impl UiState {
|
||||
/// Größte Knopfgröße (40 px, schrittweise bis 28 px), bei der die Leiste mit allen Elementen
|
||||
/// des aktiven Werkzeugs in `max_height` passt.
|
||||
pub fn sidebar_button_size(tool: ActiveTool, max_height: f32) -> f32 {
|
||||
let select = tool == ActiveTool::Select;
|
||||
// Notizbücher, 5 Werkzeuge, Undo/Redo, 3× Seiten, Menü (+ 4 Auswahl-Aktionen)
|
||||
let buttons = 12 + if select { 4 } else { 0 };
|
||||
let select = matches!(tool, ActiveTool::Select | ActiveTool::Text);
|
||||
// Notizbücher, 6 Werkzeuge, Undo/Redo, 3× Seiten, Menü (+ 4 Auswahl-Aktionen bzw.
|
||||
// Schriftknöpfe)
|
||||
let buttons = 13 + if select { 4 } else { 0 };
|
||||
let dividers = 5 + usize::from(select);
|
||||
let spacing = 3.0;
|
||||
let needed = |b: f32| {
|
||||
@@ -375,6 +471,7 @@ impl UiState {
|
||||
match self.active_tool {
|
||||
ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select => self.pen_color,
|
||||
ActiveTool::Highlighter => self.highlighter_color,
|
||||
ActiveTool::Text => self.text_color,
|
||||
ActiveTool::Eraser => [0.0, 0.0, 0.0, 0.0],
|
||||
}
|
||||
}
|
||||
@@ -382,7 +479,9 @@ impl UiState {
|
||||
/// Gibt die aktuelle Strichbreite in mm zurück.
|
||||
pub fn current_stroke_width_mm(&self) -> f32 {
|
||||
match self.active_tool {
|
||||
ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select => self.pen_stroke_width_mm,
|
||||
ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select | ActiveTool::Text => {
|
||||
self.pen_stroke_width_mm
|
||||
}
|
||||
ActiveTool::Highlighter => self.highlighter_stroke_width_mm,
|
||||
ActiveTool::Eraser => 3.0,
|
||||
}
|
||||
@@ -442,6 +541,15 @@ impl UiState {
|
||||
if self.is_point_inside_toolbar(px, py) {
|
||||
return true;
|
||||
}
|
||||
// Lineal: Fläche und Dreh-Griff bedient die Oberfläche, die Kanten bleiben zum Zeichnen
|
||||
if self.ruler_drag.is_some() {
|
||||
return true;
|
||||
}
|
||||
if let (Some(ruler), Some(view)) = (self.ruler, self.page_view) {
|
||||
if ruler.hit(view.to_page(egui::pos2(px, py))).is_some() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some(pop_rect) = self.popover_rect {
|
||||
if pop_rect.contains(egui::pos2(px, py)) {
|
||||
return true;
|
||||
@@ -554,6 +662,8 @@ enum SidebarIcon<'a> {
|
||||
Eraser,
|
||||
/// Selbst gezeichnetes gestricheltes Auswahlrechteck
|
||||
Lasso,
|
||||
/// Selbst gezeichnetes Lineal mit Skala
|
||||
Ruler,
|
||||
}
|
||||
|
||||
/// Quadratischer Symbol-Knopf der Werkzeugleiste mit optionalem Farbbalken an der Unterkante.
|
||||
@@ -656,6 +766,30 @@ fn sidebar_button(
|
||||
stroke,
|
||||
));
|
||||
}
|
||||
SidebarIcon::Ruler => {
|
||||
let dir = egui::vec2(1.0, -1.0).normalized();
|
||||
let perp = egui::vec2(dir.y, -dir.x);
|
||||
let (half_len, half_w) = (12.0, 4.5);
|
||||
let p = |along: f32, across: f32| c + dir * along + perp * across;
|
||||
painter.add(egui::Shape::convex_polygon(
|
||||
vec![
|
||||
p(-half_len, -half_w),
|
||||
p(half_len, -half_w),
|
||||
p(half_len, half_w),
|
||||
p(-half_len, half_w),
|
||||
],
|
||||
egui::Color32::TRANSPARENT,
|
||||
egui::Stroke::new(1.5_f32, color),
|
||||
));
|
||||
for i in 1..6 {
|
||||
let along = -half_len + i as f32 * 4.0;
|
||||
let tick = if i % 2 == 0 { 4.0 } else { 2.5 };
|
||||
painter.line_segment(
|
||||
[p(along, -half_w), p(along, -half_w + tick)],
|
||||
egui::Stroke::new(1.2_f32, color),
|
||||
);
|
||||
}
|
||||
}
|
||||
SidebarIcon::Lasso => {
|
||||
let r = egui::Rect::from_center_size(c, egui::vec2(20.0, 16.0));
|
||||
let pts = [
|
||||
@@ -795,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),
|
||||
@@ -835,7 +979,33 @@ pub fn page_thumbnail_shapes(
|
||||
let total: usize = page.strokes.iter().map(|s| s.points.len()).sum();
|
||||
let step = total.div_ceil(THUMBNAIL_POINT_BUDGET).max(1);
|
||||
|
||||
for stroke in page.strokes.iter().filter(|s| !s.is_image()) {
|
||||
// Textfelder: je Zeile ein Strich in Textfarbe auf Höhe der Kleinbuchstaben
|
||||
for stroke in &page.strokes {
|
||||
let (Some(text), Some(corners)) = (&stroke.text, stroke.object_corners()) else {
|
||||
continue;
|
||||
};
|
||||
let ink = if adapt {
|
||||
adapt_ink_color(stroke.color)
|
||||
} else {
|
||||
stroke.color
|
||||
};
|
||||
let color = color32_from(ink).gamma_multiply(0.7);
|
||||
let layout = text.layout();
|
||||
let (origin, [right, down]) = crate::text::frame_axes(&corners, text.box_size());
|
||||
let width = (text.size * 0.45 * down.length() * scale).clamp(0.6, 6.0);
|
||||
for (i, line) in layout.lines.iter().enumerate() {
|
||||
if line.width <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let start = origin + down * (layout.baseline(i) - text.size * 0.25);
|
||||
shapes.push(egui::Shape::line_segment(
|
||||
[to_screen(start), to_screen(start + right * line.width)],
|
||||
egui::Stroke::new(width, color),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for stroke in page.strokes.iter().filter(|s| !s.is_object()) {
|
||||
let ink = if adapt {
|
||||
adapt_ink_color(stroke.color)
|
||||
} else {
|
||||
@@ -863,6 +1033,126 @@ pub fn page_thumbnail_shapes(
|
||||
shapes
|
||||
}
|
||||
|
||||
/// Knöpfe für Schriftart und -größe des Text-Werkzeugs. Änderungen gelten auch für das
|
||||
/// gerade bearbeitete Textfeld.
|
||||
fn text_style_buttons(ui: &mut egui::Ui, btn: f32, state: &mut UiState) {
|
||||
let size = state.text_size_pt;
|
||||
let smaller = TEXT_SIZES_PT
|
||||
.iter()
|
||||
.rev()
|
||||
.copied()
|
||||
.find(|&s| s < size - 0.01);
|
||||
let larger = TEXT_SIZES_PT.iter().copied().find(|&s| s > size + 0.01);
|
||||
let smaller_tip = format!("Kleinere Schrift (jetzt {size} pt)");
|
||||
let larger_tip = format!("Größere Schrift (jetzt {size} pt)");
|
||||
enum Change {
|
||||
Font(TextFont),
|
||||
/// Neue Größe; `None`: keine kleinere bzw. größere mehr
|
||||
Size(Option<f32>),
|
||||
}
|
||||
let buttons = [
|
||||
(
|
||||
SidebarIcon::Text("Aa"),
|
||||
"Normale Schrift",
|
||||
Change::Font(TextFont::Sans),
|
||||
),
|
||||
(
|
||||
SidebarIcon::Text("{ }"),
|
||||
"Code-Schrift (feste Zeichenbreite, Tab rückt ein)",
|
||||
Change::Font(TextFont::Mono),
|
||||
),
|
||||
(SidebarIcon::Text("A−"), &smaller_tip, Change::Size(smaller)),
|
||||
(SidebarIcon::Text("A+"), &larger_tip, Change::Size(larger)),
|
||||
];
|
||||
for (icon, tooltip, change) in buttons {
|
||||
let (active, enabled) = match change {
|
||||
Change::Font(font) => (state.text_font == font, true),
|
||||
Change::Size(new_size) => (false, new_size.is_some()),
|
||||
};
|
||||
if !sidebar_button(ui, btn, icon, active, enabled, None, tooltip).clicked() {
|
||||
continue;
|
||||
}
|
||||
match change {
|
||||
Change::Font(font) => state.text_font = font,
|
||||
Change::Size(Some(new_size)) => {
|
||||
state.text_size_pt = new_size;
|
||||
state.set_export_message(format!("Schriftgröße {new_size} pt"));
|
||||
}
|
||||
Change::Size(None) => {}
|
||||
}
|
||||
if let Some(edit) = &mut state.text_edit {
|
||||
edit.text.font = state.text_font;
|
||||
edit.text.size = state.text_size_pt * crate::text::UNITS_PER_PT;
|
||||
edit.focus = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bearbeitung eines Textfelds: egui-Textfeld an der Stelle und in der Größe des Felds auf
|
||||
/// dem Blatt (ungedreht), mit derselben Schrift. Esc beendet die Bearbeitung.
|
||||
fn text_edit_overlay(ctx: &egui::Context, state: &mut UiState) {
|
||||
let adapt = state.adapt_ink_colors && state.paper_theme.is_dark();
|
||||
let (Some(view), Some(edit)) = (state.page_view, state.text_edit.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
let [sx, sy] = edit.axes.map(|a| a.length());
|
||||
let font_size = (edit.text.size * sy * view.scale).clamp(2.0, 400.0);
|
||||
// egui rundet die Glyphenhöhe auf ganze Pixel: die Umbruchbreite im selben Verhältnis
|
||||
// anpassen, damit die Zeilen an denselben Stellen umbrechen wie auf dem Blatt
|
||||
let height_px = font_size * ctx.pixels_per_point() * edit.text.font.height_per_em();
|
||||
let rounding = height_px.round().max(1.0) / height_px;
|
||||
let width = (edit.text.wrap_width.max(edit.text.size) * sx * view.scale * rounding).max(8.0);
|
||||
let color = linear_color32(if adapt {
|
||||
adapt_ink_color(edit.color)
|
||||
} else {
|
||||
edit.color
|
||||
});
|
||||
egui::Area::new(egui::Id::new("text_edit_area"))
|
||||
.fixed_pos(view.to_screen(edit.origin))
|
||||
.order(egui::Order::Middle)
|
||||
.show(ctx, |ui| {
|
||||
egui::Frame::none()
|
||||
.stroke(egui::Stroke::new(
|
||||
1.0_f32,
|
||||
egui::Color32::from_rgba_unmultiplied(70, 130, 230, 200),
|
||||
))
|
||||
.show(ui, |ui| {
|
||||
let response = ui.add(
|
||||
egui::TextEdit::multiline(&mut edit.text.content)
|
||||
.id(egui::Id::new("text_edit_field"))
|
||||
.frame(false)
|
||||
.margin(egui::vec2(0.0, 0.0))
|
||||
.font(egui::FontId::new(font_size, edit.text.font.egui_family()))
|
||||
.text_color(color)
|
||||
.desired_width(width)
|
||||
.desired_rows(1)
|
||||
.lock_focus(true),
|
||||
);
|
||||
if edit.focus {
|
||||
response.request_focus();
|
||||
edit.focus = false;
|
||||
}
|
||||
// egui 0.27 zählt Enter in einem fokussierten Feld als Klick und fordert
|
||||
// den Fokus neu an – dabei geht die Tab-Sperre verloren und Tab würde
|
||||
// danach den Fokus wegnehmen statt einzurücken
|
||||
ui.memory_mut(|m| {
|
||||
m.set_focus_lock_filter(
|
||||
response.id,
|
||||
egui::EventFilter {
|
||||
tab: true,
|
||||
horizontal_arrows: true,
|
||||
vertical_arrows: true,
|
||||
escape: false,
|
||||
},
|
||||
)
|
||||
});
|
||||
});
|
||||
});
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
edit.finish = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Linearer Farbwert (wie auf der GPU) als egui-Farbe (sRGB).
|
||||
fn linear_color32(color: [f32; 4]) -> egui::Color32 {
|
||||
egui::Rgba::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]).into()
|
||||
@@ -1076,6 +1366,149 @@ fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option<Press
|
||||
save.then_some(state.pressure_curve)
|
||||
}
|
||||
|
||||
/// Zeichnet das Lineal über dem Blatt und bedient Verschieben und Drehen.
|
||||
fn ruler_overlay(ctx: &egui::Context, state: &mut UiState) {
|
||||
let (Some(mut ruler), Some(view)) = (state.ruler, state.page_view) else {
|
||||
state.ruler_drag = None;
|
||||
return;
|
||||
};
|
||||
|
||||
// Bedienung: Druck auf Fläche oder Griff beginnt das Ziehen (nicht über Leiste/Popovern)
|
||||
let (pressed, down, pos) = ctx.input(|i| {
|
||||
(
|
||||
i.pointer.primary_pressed(),
|
||||
i.pointer.primary_down(),
|
||||
i.pointer.interact_pos(),
|
||||
)
|
||||
});
|
||||
if pressed && !ctx.is_pointer_over_area() {
|
||||
if let Some(pos) = pos {
|
||||
let page = view.to_page(pos);
|
||||
state.ruler_drag = match ruler.hit(page) {
|
||||
Some(RulerHit::Body) => Some(RulerDrag::Move {
|
||||
grab_offset: ruler.center - page,
|
||||
}),
|
||||
Some(RulerHit::RotateHandle) => Some(RulerDrag::Rotate),
|
||||
None => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
match (state.ruler_drag, down, pos) {
|
||||
(Some(RulerDrag::Move { grab_offset }), true, Some(pos)) => {
|
||||
ruler.center = view.to_page(pos) + grab_offset;
|
||||
}
|
||||
(Some(RulerDrag::Rotate), true, Some(pos)) => ruler.rotate_toward(view.to_page(pos)),
|
||||
(_, false, _) => state.ruler_drag = None,
|
||||
_ => {}
|
||||
}
|
||||
state.ruler = Some(ruler);
|
||||
|
||||
// Zeichnen (unter Leiste und Dialogen)
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(
|
||||
egui::Order::Background,
|
||||
egui::Id::new("ruler_overlay"),
|
||||
));
|
||||
let corners: Vec<egui::Pos2> = ruler.corners().iter().map(|&c| view.to_screen(c)).collect();
|
||||
painter.add(egui::Shape::convex_polygon(
|
||||
corners.clone(),
|
||||
egui::Color32::from_rgba_unmultiplied(214, 222, 235, 165),
|
||||
egui::Stroke::new(
|
||||
1.0_f32,
|
||||
egui::Color32::from_rgba_unmultiplied(60, 70, 90, 200),
|
||||
),
|
||||
));
|
||||
|
||||
let ink = egui::Color32::from_rgb(45, 52, 66);
|
||||
let axis = ruler.axis();
|
||||
let normal = ruler.normal();
|
||||
let mm = crate::document::UNITS_PER_MM;
|
||||
let px_per_mm = view.scale * mm;
|
||||
let half_len_mm = (crate::ruler::RULER_LENGTH / mm / 2.0) as i32;
|
||||
let half_w = crate::ruler::RULER_WIDTH / 2.0;
|
||||
// Skala an beiden Längskanten, von der Mitte aus: Millimeter nur, wenn sie nicht verschwimmen
|
||||
for side in [-1.0_f32, 1.0] {
|
||||
let edge = ruler.center + normal * side * half_w;
|
||||
for i in -half_len_mm..=half_len_mm {
|
||||
let tick_mm = if i % 10 == 0 {
|
||||
4.0
|
||||
} else if i % 5 == 0 {
|
||||
2.5
|
||||
} else if px_per_mm >= 3.0 {
|
||||
1.5
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let base = edge + axis * (i as f32 * mm);
|
||||
let tip = base - normal * side * tick_mm * mm;
|
||||
painter.line_segment(
|
||||
[view.to_screen(base), view.to_screen(tip)],
|
||||
egui::Stroke::new(1.0_f32, ink),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Zentimeter-Zahlen an der oberen Kante, von der linken Ecke an gezählt
|
||||
if px_per_mm >= 1.2 {
|
||||
let font = egui::FontId::proportional((px_per_mm * 2.6).clamp(8.0, 13.0));
|
||||
let edge = ruler.center - normal * half_w;
|
||||
for cm in 0..=(half_len_mm * 2 / 10) {
|
||||
let along = -crate::ruler::RULER_LENGTH / 2.0 + cm as f32 * 10.0 * mm;
|
||||
let anchor = view.to_screen(edge + axis * along + normal * 5.0 * mm);
|
||||
let galley = ctx.fonts(|f| f.layout_no_wrap(cm.to_string(), font.clone(), ink));
|
||||
let offset = egui::vec2(galley.size().x / 2.0, 0.0);
|
||||
let offset = egui::vec2(
|
||||
offset.x * axis.x - offset.y * axis.y,
|
||||
offset.x * axis.y + offset.y * axis.x,
|
||||
);
|
||||
let mut text = egui::epaint::TextShape::new(anchor - offset, galley, ink);
|
||||
text.angle = ruler.angle;
|
||||
painter.add(text);
|
||||
}
|
||||
}
|
||||
|
||||
// Winkelanzeige und Dreh-Griff
|
||||
let center = view.to_screen(ruler.center);
|
||||
let label = format!("{}°", ruler.display_degrees());
|
||||
let galley = ctx.fonts(|f| {
|
||||
f.layout_no_wrap(
|
||||
label,
|
||||
egui::FontId::proportional(13.0),
|
||||
egui::Color32::WHITE,
|
||||
)
|
||||
});
|
||||
let pill = egui::Rect::from_center_size(center, galley.size() + egui::vec2(14.0, 6.0));
|
||||
painter.rect_filled(
|
||||
pill,
|
||||
egui::Rounding::same(9.0),
|
||||
egui::Color32::from_rgba_unmultiplied(40, 70, 130, 220),
|
||||
);
|
||||
painter.galley(
|
||||
pill.center() - galley.size() / 2.0,
|
||||
galley,
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
|
||||
let handle = view.to_screen(ruler.handle_center());
|
||||
let radius = (crate::ruler::HANDLE_RADIUS * view.scale).max(10.0);
|
||||
let active = state.ruler_drag == Some(RulerDrag::Rotate);
|
||||
painter.circle(
|
||||
handle,
|
||||
radius,
|
||||
if active {
|
||||
egui::Color32::from_rgb(70, 120, 210)
|
||||
} else {
|
||||
egui::Color32::from_rgba_unmultiplied(40, 70, 130, 200)
|
||||
},
|
||||
egui::Stroke::new(1.0_f32, egui::Color32::WHITE),
|
||||
);
|
||||
painter.text(
|
||||
handle,
|
||||
egui::Align2::CENTER_CENTER,
|
||||
"⟳",
|
||||
egui::FontId::proportional((radius * 1.1).clamp(10.0, 20.0)),
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
}
|
||||
|
||||
/// Dialog „Bild einfügen“ bzw. „PDF importieren“: Schnellzugriffe, Ordnerpfad und Liste aus
|
||||
/// Ordnern und passenden Dateien.
|
||||
fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Vec<UiAction>) {
|
||||
@@ -1333,6 +1766,9 @@ pub fn render_ui(
|
||||
updater: &crate::updater::UpdaterController,
|
||||
) -> Vec<UiAction> {
|
||||
let mut actions = std::mem::take(&mut state.queued_actions);
|
||||
state.screen_size = ctx.screen_rect().size();
|
||||
ruler_overlay(ctx, state);
|
||||
text_edit_overlay(ctx, state);
|
||||
|
||||
let mut pen_btn_rect = None;
|
||||
let mut high_btn_rect = None;
|
||||
@@ -1499,6 +1935,12 @@ pub fn render_ui(
|
||||
SidebarIcon::Lasso,
|
||||
"Auswählen: verschieben, drehen, skalieren, kopieren (V)",
|
||||
),
|
||||
(
|
||||
ActiveTool::Text,
|
||||
SidebarIcon::Glyph("T"),
|
||||
"Text (T): ins Blatt tippen und schreiben, vorhandenen Text \
|
||||
antippen zum Ändern; Esc beendet",
|
||||
),
|
||||
] {
|
||||
if sidebar_button(
|
||||
ui,
|
||||
@@ -1519,6 +1961,22 @@ pub fn render_ui(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lineal (ein-/ausblenden) ---
|
||||
if sidebar_button(
|
||||
ui,
|
||||
btn,
|
||||
SidebarIcon::Ruler,
|
||||
state.ruler.is_some(),
|
||||
true,
|
||||
None,
|
||||
"Lineal (L): Fläche ziehen = verschieben, Griff ⟳ = drehen; \
|
||||
Striche an einer Kante werden gerade",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
state.toggle_ruler();
|
||||
}
|
||||
|
||||
// --- Farben des aktiven Werkzeugs ---
|
||||
let palette: Option<(&[ColorPreset], [f32; 4])> =
|
||||
match state.active_tool {
|
||||
@@ -1529,6 +1987,9 @@ pub fn render_ui(
|
||||
&HIGHLIGHTER_COLOR_PRESETS[..],
|
||||
state.highlighter_color,
|
||||
)),
|
||||
ActiveTool::Text => {
|
||||
Some((&PEN_COLOR_PRESETS[..], state.text_color))
|
||||
}
|
||||
ActiveTool::Eraser => None,
|
||||
};
|
||||
// Fester Platz für die längste Palette, damit die Knöpfe darunter
|
||||
@@ -1544,10 +2005,18 @@ pub fn render_ui(
|
||||
if color_circle_swatch(ui, color, is_sel, label, swatch)
|
||||
.clicked()
|
||||
{
|
||||
if state.active_tool == ActiveTool::Highlighter {
|
||||
state.highlighter_color = color;
|
||||
} else {
|
||||
state.pen_color = color;
|
||||
match state.active_tool {
|
||||
ActiveTool::Highlighter => {
|
||||
state.highlighter_color = color
|
||||
}
|
||||
ActiveTool::Text => {
|
||||
state.text_color = color;
|
||||
if let Some(edit) = &mut state.text_edit {
|
||||
edit.color = color;
|
||||
edit.focus = true;
|
||||
}
|
||||
}
|
||||
_ => state.pen_color = color,
|
||||
}
|
||||
actions.push(UiAction::SetColor(color));
|
||||
}
|
||||
@@ -1624,6 +2093,12 @@ pub fn render_ui(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Schrift des Text-Werkzeugs ---
|
||||
if state.active_tool == ActiveTool::Text {
|
||||
sidebar_divider(ui, btn);
|
||||
text_style_buttons(ui, btn, state);
|
||||
}
|
||||
|
||||
sidebar_divider(ui, btn);
|
||||
|
||||
// --- Undo & Redo ---
|
||||
@@ -2096,7 +2571,8 @@ pub fn render_ui(
|
||||
}
|
||||
});
|
||||
if ui
|
||||
.add_sized([ui.available_width(), 30.0], egui::Button::new("➕ Neue Seite anhängen"))
|
||||
.add_sized([ui.available_width(), 30.0], egui::Button::new("➕ Neue Seite einfügen"))
|
||||
.on_hover_text("Leere Seite direkt nach der aktuellen Seite")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(UiAction::AddPage);
|
||||
@@ -2188,6 +2664,19 @@ pub fn render_ui(
|
||||
});
|
||||
}
|
||||
|
||||
section(ui, "ANSICHT");
|
||||
if ui
|
||||
.checkbox(&mut state.continuous_scroll, "Seiten durchgehend scrollen")
|
||||
.on_hover_text(
|
||||
"An: Seiten hängen untereinander, Mausrad und Finger scrollen \
|
||||
(Strg+Mausrad zoomt); wer unten auf der letzten Seite schreibt, \
|
||||
bekommt automatisch eine neue Seite.\nAus: Seite für Seite blättern.",
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
actions.push(UiAction::SetContinuousScroll(state.continuous_scroll));
|
||||
}
|
||||
|
||||
section(ui, "ZOOM");
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
@@ -2405,8 +2894,9 @@ pub fn render_ui(
|
||||
if ui
|
||||
.add_sized(
|
||||
[ui.available_width(), 36.0],
|
||||
egui::Button::new("➕ Neue Seite anhängen"),
|
||||
egui::Button::new("➕ Neue Seite einfügen"),
|
||||
)
|
||||
.on_hover_text("Leere Seite direkt nach der aktuellen Seite")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(UiAction::AddPage);
|
||||
@@ -3271,6 +3761,80 @@ mod tests {
|
||||
ctx
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_text_edit_overlay_takes_typing_and_escape() {
|
||||
let notebook = Notebook::new();
|
||||
let mut state = UiState::new();
|
||||
state.active_tool = ActiveTool::Text;
|
||||
state.page_view = Some(PageView {
|
||||
origin: egui::pos2(300.0, 50.0),
|
||||
scale: 0.3,
|
||||
});
|
||||
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],
|
||||
));
|
||||
let ctx = egui::Context::default();
|
||||
let updater = crate::updater::UpdaterController::new();
|
||||
let frame = |state: &mut UiState, events: Vec<egui::Event>| {
|
||||
ctx.begin_frame(egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(
|
||||
egui::Pos2::ZERO,
|
||||
egui::vec2(1280.0, 850.0),
|
||||
)),
|
||||
events,
|
||||
..Default::default()
|
||||
});
|
||||
let _ = render_ui(&ctx, state, ¬ebook, AutosaveStatus::Saved, &updater);
|
||||
let _ = ctx.end_frame();
|
||||
};
|
||||
frame(&mut state, Vec::new());
|
||||
assert!(ctx.wants_keyboard_input(), "Textfeld hat den Fokus");
|
||||
frame(&mut state, vec![egui::Event::Text("Hallo".into())]);
|
||||
assert_eq!(state.text_edit.as_ref().unwrap().text.content, "Hallo");
|
||||
// Zeilenumbruch und Tab (Einrücken) bleiben im Textfeld
|
||||
frame(
|
||||
&mut state,
|
||||
vec![egui::Event::Key {
|
||||
key: egui::Key::Enter,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers::default(),
|
||||
}],
|
||||
);
|
||||
frame(
|
||||
&mut state,
|
||||
vec![egui::Event::Key {
|
||||
key: egui::Key::Tab,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers::default(),
|
||||
}],
|
||||
);
|
||||
frame(&mut state, vec![egui::Event::Text("x".into())]);
|
||||
assert_eq!(state.text_edit.as_ref().unwrap().text.content, "Hallo\n\tx");
|
||||
|
||||
frame(
|
||||
&mut state,
|
||||
vec![egui::Event::Key {
|
||||
key: egui::Key::Escape,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers::default(),
|
||||
}],
|
||||
);
|
||||
let edit = state.text_edit.as_ref().unwrap();
|
||||
assert!(edit.finish);
|
||||
assert_eq!(edit.text.content, "Hallo\n\tx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sidebar_is_vertical_and_docked_to_chosen_side() {
|
||||
let notebook = Notebook::new();
|
||||
@@ -3397,6 +3961,7 @@ mod tests {
|
||||
ActiveTool::Shape,
|
||||
ActiveTool::Eraser,
|
||||
ActiveTool::Select,
|
||||
ActiveTool::Text,
|
||||
] {
|
||||
assert!(
|
||||
found.contains(&tool),
|
||||
@@ -3415,6 +3980,7 @@ mod tests {
|
||||
ActiveTool::Shape,
|
||||
ActiveTool::Eraser,
|
||||
ActiveTool::Select,
|
||||
ActiveTool::Text,
|
||||
] {
|
||||
let mut state = UiState::new();
|
||||
state.active_tool = tool;
|
||||
@@ -3516,6 +4082,74 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ruler_drag_moves_and_rotates() {
|
||||
let ctx = egui::Context::default();
|
||||
let notebook = Notebook::new();
|
||||
let updater = crate::updater::UpdaterController::new();
|
||||
let mut state = UiState::new();
|
||||
// 1 Seiteneinheit = 0,5 Punkte, Seite beginnt bei (100, 0)
|
||||
let view = PageView {
|
||||
origin: egui::pos2(100.0, 0.0),
|
||||
scale: 0.5,
|
||||
};
|
||||
state.page_view = Some(view);
|
||||
state.ruler = Some(Ruler::new(glam::Vec2::new(1000.0, 1000.0)));
|
||||
|
||||
let frame = |state: &mut UiState, events: Vec<egui::Event>| {
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(
|
||||
egui::Pos2::ZERO,
|
||||
egui::vec2(1600.0, 1000.0),
|
||||
)),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
ctx.begin_frame(input);
|
||||
let _ = render_ui(&ctx, state, ¬ebook, AutosaveStatus::Saved, &updater);
|
||||
let _ = ctx.end_frame();
|
||||
};
|
||||
let press = |pos: egui::Pos2, pressed: bool| egui::Event::PointerButton {
|
||||
pos,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed,
|
||||
modifiers: egui::Modifiers::default(),
|
||||
};
|
||||
|
||||
// Fläche und Griff gehören zur Oberfläche, die Kanten zum Zeichnen
|
||||
let center = view.to_screen(glam::Vec2::new(1000.0, 1000.0));
|
||||
assert!(state.is_point_in_ui(center.x, center.y));
|
||||
let edge = view.to_screen(glam::Vec2::new(
|
||||
1000.0,
|
||||
1000.0 - crate::ruler::RULER_WIDTH / 2.0,
|
||||
));
|
||||
assert!(!state.is_point_in_ui(edge.x, edge.y));
|
||||
|
||||
// Verschieben: Fläche greifen und um 50 Punkte (= 100 Einheiten) nach unten ziehen
|
||||
frame(&mut state, vec![egui::Event::PointerMoved(center)]);
|
||||
frame(&mut state, vec![press(center, true)]);
|
||||
let target = center + egui::vec2(0.0, 50.0);
|
||||
frame(&mut state, vec![egui::Event::PointerMoved(target)]);
|
||||
assert!(state.ruler_drag.is_some());
|
||||
frame(&mut state, vec![press(target, false)]);
|
||||
let moved = state.ruler.unwrap();
|
||||
assert!((moved.center - glam::Vec2::new(1000.0, 1100.0)).length() < 0.5);
|
||||
assert!(state.ruler_drag.is_none());
|
||||
|
||||
// Drehen: Griff greifen und nach unten rechts ziehen → 45° (eingerastet)
|
||||
let handle = view.to_screen(moved.handle_center());
|
||||
frame(&mut state, vec![egui::Event::PointerMoved(handle)]);
|
||||
frame(&mut state, vec![press(handle, true)]);
|
||||
let diag = view.to_screen(moved.center + glam::Vec2::new(700.0, 690.0));
|
||||
frame(&mut state, vec![egui::Event::PointerMoved(diag)]);
|
||||
frame(&mut state, vec![press(diag, false)]);
|
||||
assert_eq!(state.ruler.unwrap().display_degrees(), 45);
|
||||
|
||||
// Ausblenden
|
||||
state.toggle_ruler();
|
||||
assert!(state.ruler.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stroke_tool_follows_active_tool() {
|
||||
assert_eq!(ActiveTool::Pen.stroke_tool(false), StrokeTool::Pen);
|
||||
|
||||
+233
-9
@@ -3,12 +3,99 @@
|
||||
//! 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};
|
||||
use crate::document::{PageViewport, UNITS_PER_MM};
|
||||
use glam::{Mat4, Vec2, Vec4};
|
||||
|
||||
/// Minimaler und maximaler Zoomfaktor.
|
||||
pub const MIN_ZOOM: f32 = 0.25;
|
||||
pub const MAX_ZOOM: f32 = 5.0;
|
||||
pub const MAX_ZOOM: f32 = 10.0;
|
||||
|
||||
/// Abstand zwischen zwei Seiten in der durchgehenden Ansicht (8 mm).
|
||||
pub const PAGE_GAP: f32 = 8.0 * UNITS_PER_MM;
|
||||
/// Höchstens so viele Nachbarseiten werden gleichzeitig dargestellt.
|
||||
pub const MAX_NEIGHBOR_PAGES: usize = 12;
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Verwaltet den Viewport-Kamerazustand für stufenlosen Zoom und Pan.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -39,6 +126,39 @@ impl ViewportState {
|
||||
self.pan_offset = [0.0, 0.0];
|
||||
}
|
||||
|
||||
/// 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`
|
||||
/// über dem Notizbuch liegt (zwischen Oberkante der ersten und Unterkante der letzten
|
||||
/// Seite, waagerecht innerhalb der Seitenbreite). `true`, wenn korrigiert wurde.
|
||||
pub fn clamp_continuous_center(
|
||||
&mut self,
|
||||
center: [f32; 2],
|
||||
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 (min, max) = stack.bounds();
|
||||
let clamped = c.clamp(min, max.max(min));
|
||||
if (clamped - c).length() < 1e-3 {
|
||||
return false;
|
||||
}
|
||||
// Bildschirm = Viewport + (Seite + Pan) · Maßstab: der Pan gleicht die Differenz aus
|
||||
self.pan_offset[0] += c.x - clamped.x;
|
||||
self.pan_offset[1] += c.y - clamped.y;
|
||||
true
|
||||
}
|
||||
|
||||
/// Passt den Pan-Offset um eine Bildschirmverschiebung (in Fensterpixeln) an.
|
||||
pub fn pan_by_screen_delta(
|
||||
&mut self,
|
||||
@@ -46,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);
|
||||
@@ -68,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;
|
||||
@@ -90,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;
|
||||
@@ -112,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
|
||||
@@ -127,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.
|
||||
@@ -142,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;
|
||||
@@ -152,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
|
||||
@@ -190,6 +311,109 @@ 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() {
|
||||
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!(
|
||||
stack.visible_neighbors(A4.y - 500.0, PITCH + 500.0),
|
||||
vec![2]
|
||||
);
|
||||
// Ganz oben im Notizbuch: keine Seite vor der ersten
|
||||
assert!(a4_stack(5, 0)
|
||||
.visible_neighbors(-PITCH * 3.0, 500.0)
|
||||
.is_empty());
|
||||
// Hinter der letzten Seite: keine weiteren
|
||||
assert_eq!(
|
||||
a4_stack(5, 3).visible_neighbors(-PITCH, PITCH * 4.0),
|
||||
vec![2, 4]
|
||||
);
|
||||
// Weit herausgezoomt: auf die nächstgelegenen Seiten begrenzt
|
||||
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(|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]
|
||||
fn test_shift_reference_page_keeps_view() {
|
||||
let mut vp = ViewportState::new();
|
||||
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, 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, &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, &stack, &page_vp));
|
||||
let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp);
|
||||
assert!(
|
||||
(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, &stack, &page_vp));
|
||||
let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp);
|
||||
assert!((c.y - (PITCH + A4.y)).abs() < 0.1, "{c:?}");
|
||||
assert!((c.x - A4.x).abs() < 0.1, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_viewport_zoom_limits() {
|
||||
let mut vp = ViewportState::new();
|
||||
|
||||
Reference in New Issue
Block a user