Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6002eee9e8 | ||
|
|
2eab394257 | ||
|
|
6a51ca3aa4 | ||
|
|
6f2b414bb8 | ||
|
|
9dec688b84 | ||
|
|
7e05b33ee3 | ||
|
|
587632b173 | ||
|
|
701fa03f33 |
Generated
+24
-1
@@ -1179,6 +1179,21 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-svg"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7377affc9513cf5b66c4a8b5fa1e6058ae652eef3d13fdc4709004a03968488"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"hayro-interpret",
|
||||
"image",
|
||||
"kurbo",
|
||||
"rustc-hash 2.1.3",
|
||||
"siphasher",
|
||||
"xmlwriter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
version = "0.8.0"
|
||||
@@ -1378,8 +1393,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"arboard",
|
||||
"bytemuck",
|
||||
"egui",
|
||||
@@ -1388,6 +1404,7 @@ dependencies = [
|
||||
"fs2",
|
||||
"glam",
|
||||
"hayro",
|
||||
"hayro-svg",
|
||||
"image",
|
||||
"minisign-verify",
|
||||
"naga",
|
||||
@@ -3827,6 +3844,12 @@ version = "0.8.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xmlwriter"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
edition = "2021"
|
||||
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
@@ -46,6 +46,10 @@ arboard = { version = "3", default-features = false, features = ["image-data"] }
|
||||
minisign-verify = "0.3.0"
|
||||
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 500 %** 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`).
|
||||
|
||||
@@ -219,10 +221,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,12 @@
|
||||
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)
|
||||
- **PDF importieren & annotieren**: Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen (☰-Menü oder Drag & Drop), darauf schreiben und markieren; beim Export liegen die Originalseiten verlustfrei als Vektor unter den Notizen (Dateiformat-Version 5: ältere Versionen öffnen solche Notizbücher nicht)
|
||||
- **Bilder einfügen**: Screenshots per Strg+V, Bilddateien über die eingebaute Dateiauswahl (Strg+I) oder per Drag & Drop; verschieben, drehen, skalieren und darauf schreiben – auch im PDF-Export (Dateiformat-Version 4: ältere Versionen öffnen solche Notizbücher nicht)
|
||||
- **Dunkelmodus & Papierfarben**: Weiß, Creme, Dunkelgrau oder Schwarz; auf dunklem Papier werden dunkle Stiftfarben automatisch aufgehellt (nur Anzeige – Dateien und PDF-Export bleiben unverändert)
|
||||
|
||||
+367
-22
@@ -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;
|
||||
@@ -112,6 +113,44 @@ 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,
|
||||
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,
|
||||
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,
|
||||
/// Lage relativ zur aktuellen Seite (in Seiten, negativ = darüber)
|
||||
offset: i64,
|
||||
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 +187,13 @@ pub struct CanvasState {
|
||||
pub paper_theme: PaperTheme,
|
||||
/// Dunkle Tinte auf dunklem Papier aufhellen
|
||||
pub adapt_ink: bool,
|
||||
/// 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 +214,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,
|
||||
}
|
||||
@@ -444,6 +499,9 @@ impl CanvasState {
|
||||
current_background,
|
||||
paper_theme,
|
||||
adapt_ink: false,
|
||||
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 +519,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 +530,26 @@ 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(0);
|
||||
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 die Seite `page_offset` Seiten unter (negativ: über) der aktuellen.
|
||||
fn camera_uniforms(&self, page_offset: i64) -> CameraUniforms {
|
||||
let (scale, mut offset) = self.camera.ndc_transform(&self.viewport);
|
||||
offset[1] += page_offset as f32 * crate::viewport::PAGE_PITCH * scale[1];
|
||||
CameraUniforms {
|
||||
transform_scale: scale,
|
||||
transform_offset: offset,
|
||||
ink_params: [
|
||||
@@ -482,12 +562,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
|
||||
@@ -578,6 +653,63 @@ impl CanvasState {
|
||||
self.camera.page_to_screen_vec2(page_pt, &self.viewport)
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: Versatz (in Seiten) der Seite unter dem Bildschirmpunkt,
|
||||
/// `None` neben den Seiten und zwischen zwei Seiten.
|
||||
pub fn page_offset_at_screen(&self, screen_x: f32, screen_y: f32) -> Option<i64> {
|
||||
let p = self
|
||||
.camera
|
||||
.screen_to_page_unclamped(screen_x, screen_y, &self.viewport);
|
||||
if !(0.0..=crate::document::PAGE_WIDTH).contains(&p.x) {
|
||||
return None;
|
||||
}
|
||||
crate::viewport::page_offset_at(p.y)
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: Versatz (in Seiten) der Seite in der Mitte des Fensters.
|
||||
pub fn page_offset_at_view_center(&self) -> Option<i64> {
|
||||
let p = self.camera.screen_to_page_unclamped(
|
||||
0.0,
|
||||
self.size.height as f32 / 2.0,
|
||||
&self.viewport,
|
||||
);
|
||||
crate::viewport::page_offset_at(p.y)
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: Versätze der sichtbaren Nachbarseiten der aktuellen Seite.
|
||||
pub fn visible_neighbor_offsets(&self, current: usize, page_count: usize) -> Vec<i64> {
|
||||
let top = self
|
||||
.camera
|
||||
.screen_to_page_unclamped(0.0, 0.0, &self.viewport);
|
||||
let bottom =
|
||||
self.camera
|
||||
.screen_to_page_unclamped(0.0, self.size.height as f32, &self.viewport);
|
||||
crate::viewport::visible_neighbor_offsets(top.y, bottom.y, current, page_count)
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: Hält die Fenstermitte über dem Notizbuch (von der Oberkante der
|
||||
/// ersten bis zur Unterkante der letzten Seite, waagerecht innerhalb der Seitenbreite), damit
|
||||
/// man nicht ins Leere scrollt. `true`, wenn der Ausschnitt korrigiert wurde.
|
||||
pub fn clamp_scroll(&mut self, current: usize, page_count: usize) -> bool {
|
||||
let center = [
|
||||
self.viewport.page_x + self.viewport.page_width / 2.0,
|
||||
self.size.height as f32 / 2.0,
|
||||
];
|
||||
let changed =
|
||||
self.camera
|
||||
.clamp_continuous_center(center, current, page_count, &self.viewport);
|
||||
if changed {
|
||||
self.update_uniforms();
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: Die Seite `k` Seiten weiter wird zur Bezugsseite, ohne dass sich
|
||||
/// der Bildausschnitt bewegt. Die Nachbarseiten sind danach neu zu setzen.
|
||||
pub fn shift_reference_page(&mut self, k: i64) {
|
||||
self.camera.shift_reference_page(k);
|
||||
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;
|
||||
@@ -665,19 +797,11 @@ 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);
|
||||
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 +828,148 @@ 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: &[(i64, &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
|
||||
})
|
||||
};
|
||||
let background =
|
||||
generate_background_mesh(page.background, self.paper_theme.pattern_color());
|
||||
NeighborPage {
|
||||
fingerprint,
|
||||
offset: 0,
|
||||
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.page_width / crate::document::PAGE_WIDTH * self.camera.zoom,
|
||||
);
|
||||
if resolution != self.text_resolution {
|
||||
self.text_resolution = resolution;
|
||||
self.image_layer
|
||||
.forget_textures(|key| key.starts_with(TEXT_KEY_PREFIX));
|
||||
}
|
||||
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()))
|
||||
.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 +985,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 +1015,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 +1038,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 +1183,28 @@ 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);
|
||||
render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..));
|
||||
render_pass.draw(0..6, 0..1);
|
||||
if let Some(buffer) = &neighbor.background_buffer {
|
||||
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
render_pass.draw(0..neighbor.background_vertex_count, 0..1);
|
||||
}
|
||||
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 +1220,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 +1284,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 4). So wird nicht bei jedem Zoomschritt neu
|
||||
/// gerastert, und die Textur ist höchstens ein Fünftel größer als dargestellt – stärker
|
||||
/// verkleinert wirkt die dünne Schrift blass.
|
||||
fn text_resolution(screen_px_per_unit: f32) -> f32 {
|
||||
let steps = (screen_px_per_unit.max(1e-3).log2() * 4.0).ceil();
|
||||
2.0_f32.powf(steps / 4.0).clamp(0.125, 4.0)
|
||||
}
|
||||
|
||||
#[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), 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_choose_present_mode() {
|
||||
use wgpu::PresentMode::{Fifo, Immediate, Mailbox};
|
||||
|
||||
+450
-9
@@ -122,6 +122,22 @@ pub fn point_in_convex_quad(p: glam::Vec2, quad: &[glam::Vec2; 4]) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Größte Länge eines Seitentitels in Zeichen.
|
||||
pub const MAX_PAGE_TITLE_CHARS: usize = 120;
|
||||
|
||||
/// Bereinigt einen Seitentitel: Zeilenumbrüche und doppelte Leerzeichen entfernen, auf
|
||||
/// [`MAX_PAGE_TITLE_CHARS`] kürzen; leer bedeutet „kein Titel“.
|
||||
pub fn normalize_page_title(title: &str) -> Option<String> {
|
||||
let cleaned: String = title
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.chars()
|
||||
.take(MAX_PAGE_TITLE_CHARS)
|
||||
.collect();
|
||||
(!cleaned.is_empty()).then_some(cleaned)
|
||||
}
|
||||
|
||||
/// Verweis auf eine Seite eines importierten PDFs (Datei im Asset-Ordner).
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PdfBackground {
|
||||
@@ -221,18 +237,124 @@ pub enum EraserMode {
|
||||
All,
|
||||
/// Nur Textmarker – die Handschrift darunter bleibt erhalten
|
||||
HighlighterOnly,
|
||||
/// Nur den überstrichenen Teil eines Strichs entfernen; der Rest bleibt in Stücken
|
||||
/// erhalten. Formen werden als Ganzes radiert.
|
||||
Partial,
|
||||
}
|
||||
|
||||
impl EraserMode {
|
||||
/// Darf ein Strich dieses Werkzeugs radiert werden?
|
||||
pub fn erases(self, tool: StrokeTool) -> bool {
|
||||
match self {
|
||||
EraserMode::All => true,
|
||||
EraserMode::All | EraserMode::Partial => true,
|
||||
EraserMode::HighlighterOnly => tool == StrokeTool::Highlighter,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zeitfenster, in dem aufeinanderfolgende Teilradierungen zu einem Undo-Schritt
|
||||
/// zusammengefasst werden (ein Radierzug besteht aus vielen kleinen Schnitten).
|
||||
pub const PARTIAL_ERASE_MERGE_WINDOW: std::time::Duration = std::time::Duration::from_millis(500);
|
||||
|
||||
/// Zerlegt die Punktfolge eines Strichs an einem Kreis (Radierer): übrig bleiben die Stücke
|
||||
/// außerhalb des Kreises, an den Kreisrand gekürzt. `None`, wenn der Kreis den Strich nicht
|
||||
/// berührt. Stücke kürzer als `min_length` entfallen.
|
||||
pub fn split_samples_at_circle(
|
||||
samples: &[StylusSample],
|
||||
center: glam::Vec2,
|
||||
radius: f32,
|
||||
min_length: f32,
|
||||
) -> Option<Vec<Vec<StylusSample>>> {
|
||||
let inside = |p: glam::Vec2| (p - center).length_squared() <= radius * radius;
|
||||
let lerp = |a: &StylusSample, b: &StylusSample, t: f32| StylusSample {
|
||||
position: a.position.lerp(b.position, t),
|
||||
pressure: a.pressure + (b.pressure - a.pressure) * t,
|
||||
timestamp: a.timestamp + (b.timestamp - a.timestamp) * t as f64,
|
||||
};
|
||||
// Schnittparameter t ∈ (0, 1) einer Strecke mit dem Kreisrand, aufsteigend
|
||||
let crossings = |a: glam::Vec2, b: glam::Vec2| -> Vec<f32> {
|
||||
let d = b - a;
|
||||
let f = a - center;
|
||||
let qa = d.dot(d);
|
||||
if qa < 1e-12 {
|
||||
return Vec::new();
|
||||
}
|
||||
let qb = 2.0 * f.dot(d);
|
||||
let qc = f.dot(f) - radius * radius;
|
||||
let disc = qb * qb - 4.0 * qa * qc;
|
||||
if disc < 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let root = disc.sqrt();
|
||||
[(-qb - root) / (2.0 * qa), (-qb + root) / (2.0 * qa)]
|
||||
.into_iter()
|
||||
.filter(|t| *t > 0.0 && *t < 1.0)
|
||||
.collect()
|
||||
};
|
||||
|
||||
match samples {
|
||||
[] => return None,
|
||||
[single] => return inside(single.position).then(Vec::new),
|
||||
_ => {}
|
||||
}
|
||||
let touched = samples.iter().any(|s| inside(s.position))
|
||||
|| samples
|
||||
.windows(2)
|
||||
.any(|w| crossings(w[0].position, w[1].position).len() == 2);
|
||||
if !touched {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut pieces = Vec::new();
|
||||
let mut current: Vec<StylusSample> = Vec::new();
|
||||
if !inside(samples[0].position) {
|
||||
current.push(samples[0]);
|
||||
}
|
||||
for w in samples.windows(2) {
|
||||
let (a, b) = (&w[0], &w[1]);
|
||||
let (a_in, b_in) = (inside(a.position), inside(b.position));
|
||||
let ts = crossings(a.position, b.position);
|
||||
match (a_in, b_in) {
|
||||
(false, false) => {
|
||||
if let [t_enter, t_exit] = ts[..] {
|
||||
// Strecke läuft durch den Kreis
|
||||
current.push(lerp(a, b, t_enter));
|
||||
pieces.push(std::mem::take(&mut current));
|
||||
current.push(lerp(a, b, t_exit));
|
||||
}
|
||||
current.push(*b);
|
||||
}
|
||||
(false, true) => {
|
||||
if let Some(&t) = ts.first() {
|
||||
current.push(lerp(a, b, t));
|
||||
}
|
||||
pieces.push(std::mem::take(&mut current));
|
||||
}
|
||||
(true, false) => {
|
||||
current.clear();
|
||||
if let Some(&t) = ts.last() {
|
||||
current.push(lerp(a, b, t));
|
||||
}
|
||||
current.push(*b);
|
||||
}
|
||||
(true, true) => {}
|
||||
}
|
||||
}
|
||||
pieces.push(current);
|
||||
|
||||
let length = |p: &Vec<StylusSample>| -> f32 {
|
||||
p.windows(2)
|
||||
.map(|w| (w[1].position - w[0].position).length())
|
||||
.sum()
|
||||
};
|
||||
Some(
|
||||
pieces
|
||||
.into_iter()
|
||||
.filter(|p| p.len() >= 2 && length(p) >= min_length)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Ein einzelner Vektor-Strich auf einer Seite.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Stroke {
|
||||
@@ -249,6 +371,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 {
|
||||
@@ -282,6 +407,7 @@ impl Stroke {
|
||||
width: 5.0,
|
||||
finalized: true,
|
||||
image: None,
|
||||
text: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,11 +427,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 {
|
||||
@@ -352,9 +515,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;
|
||||
}
|
||||
@@ -532,6 +695,8 @@ pub struct Page {
|
||||
pub background: PageBackground,
|
||||
/// 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
|
||||
pub title: Option<String>,
|
||||
pub vertex_cache: Vec<InkVertex>,
|
||||
pub stroke_ranges: Vec<Range<u32>>,
|
||||
pub is_dirty: bool,
|
||||
@@ -547,6 +712,7 @@ impl Page {
|
||||
strokes: Vec::new(),
|
||||
background,
|
||||
pdf_background: None,
|
||||
title: None,
|
||||
vertex_cache: Vec::new(),
|
||||
stroke_ranges: Vec::new(),
|
||||
is_dirty: false,
|
||||
@@ -563,6 +729,7 @@ impl Page {
|
||||
strokes: Vec::new(),
|
||||
background,
|
||||
pdf_background: None,
|
||||
title: None,
|
||||
vertex_cache: Vec::new(),
|
||||
stroke_ranges: Vec::new(),
|
||||
is_dirty: false,
|
||||
@@ -605,7 +772,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 {
|
||||
@@ -614,7 +782,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));
|
||||
}
|
||||
@@ -657,7 +825,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)
|
||||
{
|
||||
@@ -683,6 +851,66 @@ impl Page {
|
||||
removed
|
||||
}
|
||||
|
||||
/// 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 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,
|
||||
eraser_radius: f32,
|
||||
next_id: &mut u64,
|
||||
) -> bool {
|
||||
let mut removed = Vec::new();
|
||||
let mut added = Vec::new();
|
||||
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_object() || !stroke.aabb.expanded(reach).contains_point(eraser_pos) {
|
||||
result.push(stroke);
|
||||
continue;
|
||||
}
|
||||
if stroke.shape.is_some() {
|
||||
// Formen werden ganz radiert, sobald der Radierer sie berührt
|
||||
if stroke.distance_to_point(eraser_pos) <= reach {
|
||||
removed.push(stroke);
|
||||
} else {
|
||||
result.push(stroke);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match split_samples_at_circle(&stroke.points, eraser_pos, reach, stroke.width * 0.5) {
|
||||
None => result.push(stroke),
|
||||
Some(pieces) => {
|
||||
for samples in pieces {
|
||||
let vertices = crate::storage::reconstruct_stroke_vertices(
|
||||
&samples,
|
||||
stroke.color,
|
||||
stroke.width,
|
||||
stroke.tool,
|
||||
true,
|
||||
);
|
||||
let mut piece = Stroke::new(*next_id, samples, vertices, stroke.color);
|
||||
*next_id += 1;
|
||||
piece.tool = stroke.tool;
|
||||
piece.width = stroke.width;
|
||||
piece.finalized = true;
|
||||
result.push(piece.clone());
|
||||
added.push(piece);
|
||||
}
|
||||
removed.push(stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.strokes = result;
|
||||
if removed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.history.push_split(removed, added);
|
||||
self.is_dirty = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// Baut den gecachten Vertex-Puffer der Seite neu auf, wenn `is_dirty` gesetzt ist.
|
||||
pub fn rebuild_cache_if_dirty(&mut self) {
|
||||
if !self.is_dirty {
|
||||
@@ -714,8 +942,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)),
|
||||
@@ -724,7 +952,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)
|
||||
@@ -826,6 +1054,10 @@ impl Page {
|
||||
}
|
||||
}
|
||||
|
||||
/// Höhe des Bereichs am unteren Rand der letzten Seite, in dem Schreiben in der
|
||||
/// durchgehenden Ansicht automatisch eine neue Seite anhängt.
|
||||
pub const AUTO_APPEND_ZONE: f32 = PAGE_HEIGHT * 0.2;
|
||||
|
||||
/// Das Notizbuch mit diskreter Seitenverwaltung und Navigation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Notebook {
|
||||
@@ -883,6 +1115,25 @@ impl Notebook {
|
||||
&mut self.pages[self.current_page_index]
|
||||
}
|
||||
|
||||
/// Radiert auf der aktuellen Seite gemäß `mode` (ganze Striche, nur Textmarker oder
|
||||
/// Teilstriche). Gibt `true` zurück, wenn sich etwas geändert hat.
|
||||
pub fn erase_at(
|
||||
&mut self,
|
||||
eraser_pos: glam::Vec2,
|
||||
eraser_radius: f32,
|
||||
max_stroke_radius: f32,
|
||||
mode: EraserMode,
|
||||
) -> bool {
|
||||
let page = &mut self.pages[self.current_page_index];
|
||||
if mode == EraserMode::Partial {
|
||||
page.erase_partial_at(eraser_pos, eraser_radius, &mut self.next_stroke_id)
|
||||
} else {
|
||||
!page
|
||||
.erase_at(eraser_pos, eraser_radius, max_stroke_radius, mode)
|
||||
.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Erzeugt eine eindeutige neue Stroke-ID.
|
||||
pub fn next_stroke_id(&mut self) -> u64 {
|
||||
let id = self.next_stroke_id;
|
||||
@@ -898,6 +1149,20 @@ impl Notebook {
|
||||
self.pages.len() - 1
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: Reicht ein neuer Strich auf der letzten Seite bis in deren
|
||||
/// unteres Fünftel ([`AUTO_APPEND_ZONE`]), wird darunter eine leere Seite mit gleichem
|
||||
/// Hintergrund angehängt. `true`, wenn eine Seite angehängt wurde.
|
||||
pub fn append_page_if_writing_at_end(&mut self, stroke_bottom: f32) -> bool {
|
||||
if self.current_page_index + 1 != self.pages.len()
|
||||
|| stroke_bottom < PAGE_HEIGHT - AUTO_APPEND_ZONE
|
||||
{
|
||||
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() {
|
||||
@@ -929,6 +1194,20 @@ impl Notebook {
|
||||
}
|
||||
}
|
||||
|
||||
/// Setzt den Titel der Seite `page_id` (leer = kein Titel). `true`, wenn er sich geändert
|
||||
/// hat.
|
||||
pub fn set_page_title(&mut self, page_id: u64, title: &str) -> bool {
|
||||
let title = normalize_page_title(title);
|
||||
let Some(page) = self.pages.iter_mut().find(|p| p.id == page_id) else {
|
||||
return false;
|
||||
};
|
||||
if page.title == title || !page.is_loaded || page.is_readonly() {
|
||||
return false;
|
||||
}
|
||||
page.title = title;
|
||||
true
|
||||
}
|
||||
|
||||
/// Text für das On-Screen-Display (OSD), z. B. "Seite 3 / 12".
|
||||
pub fn osd_text(&self) -> String {
|
||||
format!(
|
||||
@@ -1385,6 +1664,168 @@ mod tests {
|
||||
assert!((rotated[0] - glam::Vec2::new(250.0, 50.0)).length() < 1e-3);
|
||||
}
|
||||
|
||||
fn line_samples(points: &[(f32, f32)]) -> Vec<StylusSample> {
|
||||
points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &(x, y))| StylusSample {
|
||||
position: glam::Vec2::new(x, y),
|
||||
pressure: 0.5,
|
||||
timestamp: i as f64,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_samples_at_circle() {
|
||||
let c = glam::Vec2::new(50.0, 0.0);
|
||||
// Lange Strecke mit nur zwei Punkten, Kreis in der Mitte: zwei Stücke, am Rand gekürzt
|
||||
let pieces =
|
||||
split_samples_at_circle(&line_samples(&[(0.0, 0.0), (100.0, 0.0)]), c, 10.0, 1.0)
|
||||
.unwrap();
|
||||
assert_eq!(pieces.len(), 2);
|
||||
assert!((pieces[0].last().unwrap().position.x - 40.0).abs() < 1e-3);
|
||||
assert!((pieces[1][0].position.x - 60.0).abs() < 1e-3);
|
||||
// Druck und Zeit werden am Schnittpunkt interpoliert
|
||||
assert!((pieces[0].last().unwrap().timestamp - 0.4).abs() < 1e-3);
|
||||
|
||||
// Viele Punkte, Kreis am Ende: ein Stück bleibt
|
||||
let dense: Vec<(f32, f32)> = (0..=10).map(|i| (i as f32 * 10.0, 0.0)).collect();
|
||||
let pieces = split_samples_at_circle(
|
||||
&line_samples(&dense),
|
||||
glam::Vec2::new(100.0, 0.0),
|
||||
15.0,
|
||||
1.0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(pieces.len(), 1);
|
||||
assert!((pieces[0].last().unwrap().position.x - 85.0).abs() < 1e-3);
|
||||
|
||||
// Ganz im Kreis: nichts bleibt; Kreis weit weg: unberührt
|
||||
assert_eq!(
|
||||
split_samples_at_circle(&line_samples(&[(48.0, 0.0), (52.0, 0.0)]), c, 10.0, 1.0),
|
||||
Some(Vec::new())
|
||||
);
|
||||
assert_eq!(
|
||||
split_samples_at_circle(
|
||||
&line_samples(&dense),
|
||||
glam::Vec2::new(50.0, 40.0),
|
||||
10.0,
|
||||
1.0
|
||||
),
|
||||
None
|
||||
);
|
||||
// Winziger Rest wird verworfen
|
||||
let pieces = split_samples_at_circle(
|
||||
&line_samples(&[(0.0, 0.0), (100.0, 0.0)]),
|
||||
glam::Vec2::new(5.0, 0.0),
|
||||
4.0,
|
||||
2.0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(pieces.len(), 1);
|
||||
assert!((pieces[0][0].position.x - 9.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_partial_erase_splits_and_undoes_in_one_step() {
|
||||
let mut notebook = Notebook::new();
|
||||
let mut stroke = Stroke::new(
|
||||
1,
|
||||
line_samples(&[(100.0, 100.0), (500.0, 100.0)]),
|
||||
Vec::new(),
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
);
|
||||
stroke.width = 5.0;
|
||||
notebook.next_stroke_id = 10;
|
||||
let page = notebook.current_page_mut();
|
||||
page.add_stroke(stroke);
|
||||
page.add_stroke(Stroke::new_image(
|
||||
2,
|
||||
"a.png".into(),
|
||||
[
|
||||
glam::Vec2::new(0.0, 0.0),
|
||||
glam::Vec2::new(900.0, 0.0),
|
||||
glam::Vec2::new(900.0, 900.0),
|
||||
glam::Vec2::new(0.0, 900.0),
|
||||
],
|
||||
));
|
||||
let mut shape = crate::geometry::shape_to_stroke(
|
||||
crate::geometry::RecognizedShape::Line {
|
||||
start: glam::Vec2::new(100.0, 300.0),
|
||||
end: glam::Vec2::new(500.0, 300.0),
|
||||
},
|
||||
3,
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
5.0,
|
||||
);
|
||||
shape.recompute_aabb();
|
||||
page.add_stroke(shape);
|
||||
let undo_depth = page.history.undo_stack.len();
|
||||
|
||||
// Zwei Schnitte desselben Zugs durch die Linie
|
||||
assert!(notebook.erase_at(
|
||||
glam::Vec2::new(200.0, 100.0),
|
||||
20.0,
|
||||
12.0,
|
||||
EraserMode::Partial
|
||||
));
|
||||
assert!(notebook.erase_at(
|
||||
glam::Vec2::new(400.0, 100.0),
|
||||
20.0,
|
||||
12.0,
|
||||
EraserMode::Partial
|
||||
));
|
||||
// Form wird als Ganzes radiert
|
||||
assert!(notebook.erase_at(
|
||||
glam::Vec2::new(300.0, 300.0),
|
||||
20.0,
|
||||
12.0,
|
||||
EraserMode::Partial
|
||||
));
|
||||
let page = notebook.current_page();
|
||||
let ink: Vec<&Stroke> = page.strokes.iter().filter(|s| !s.is_image()).collect();
|
||||
assert_eq!(ink.len(), 3, "Linie in drei Stücke zerlegt, Form entfernt");
|
||||
assert!(ink.iter().all(|s| s.id >= 10 && !s.vertices.is_empty()));
|
||||
assert!(page.strokes.iter().any(|s| s.id == 2), "Bild bleibt");
|
||||
assert!(notebook.next_stroke_id > 10);
|
||||
|
||||
// Ein einziger Undo-Schritt stellt alles wieder her
|
||||
let page = notebook.current_page_mut();
|
||||
assert_eq!(page.history.undo_stack.len(), undo_depth + 1);
|
||||
assert!(page.undo());
|
||||
let mut ids: Vec<u64> = page.strokes.iter().map(|s| s.id).collect();
|
||||
ids.sort();
|
||||
assert_eq!(ids, vec![1, 2, 3]);
|
||||
assert!(page.redo());
|
||||
assert_eq!(page.strokes.iter().filter(|s| !s.is_image()).count(), 3);
|
||||
|
||||
// Danebenradieren ändert nichts
|
||||
assert!(!notebook.erase_at(
|
||||
glam::Vec2::new(800.0, 800.0),
|
||||
20.0,
|
||||
12.0,
|
||||
EraserMode::Partial
|
||||
));
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
+197
-4
@@ -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 {
|
||||
@@ -79,6 +84,20 @@ pub fn resolve_pdf_export_path_in_dir(dir: &Path, title: &str) -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// Kollisionsfreier Pfad `~/Documents/<name>.<ext>` (Verzeichnis wird angelegt).
|
||||
pub fn resolve_export_path(name: &str, ext: &str) -> PathBuf {
|
||||
let dir = default_documents_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let base = sanitize_filename_component(name);
|
||||
let mut candidate = dir.join(format!("{base}.{ext}"));
|
||||
let mut counter = 1;
|
||||
while candidate.exists() {
|
||||
candidate = dir.join(format!("{base}_{counter}.{ext}"));
|
||||
counter += 1;
|
||||
}
|
||||
candidate
|
||||
}
|
||||
|
||||
/// Ermittelt das Ziel für den PDF-Export unter Berücksichtigung von XDG_DOCUMENTS_DIR
|
||||
/// und automatischer Namenskollisionsvermeidung. Erstellt das Verzeichnis, falls nötig.
|
||||
pub fn resolve_pdf_export_path(title: &str) -> PathBuf {
|
||||
@@ -132,7 +151,7 @@ pub fn export_notebook_to_bytes(
|
||||
assets: Option<&AssetStore>,
|
||||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
let doc = generate_pdf_document(notebook, strategy, assets)?;
|
||||
let bytes = doc.save_to_bytes()?;
|
||||
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
|
||||
|
||||
// Importierte PDF-Seiten als Vektorgrafik unterlegen
|
||||
let underlays = pdf_underlays(notebook);
|
||||
@@ -158,7 +177,7 @@ pub fn export_notebook_to_bytes(
|
||||
raster.insert(underlay.target_page);
|
||||
}
|
||||
let doc = generate_pdf_document_with(notebook, strategy, assets, &raster)?;
|
||||
let bytes = doc.save_to_bytes()?;
|
||||
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
|
||||
let remaining: Vec<_> = underlays
|
||||
.into_iter()
|
||||
.filter(|u| !raster.contains(&u.target_page))
|
||||
@@ -170,6 +189,49 @@ pub fn export_notebook_to_bytes(
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
/// printpdf schreibt Lesezeichen-Titel als rohes UTF-8, PDF-Betrachter lesen Textstrings aber
|
||||
/// als PDFDocEncoding oder UTF-16BE: Umlaute und Gedankenstriche würden verstümmelt. Titel
|
||||
/// mit Nicht-ASCII-Zeichen werden deshalb nach UTF-16BE (mit BOM) umgeschrieben.
|
||||
fn fix_text_encoding(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
let needs_fix = notebook
|
||||
.pages
|
||||
.iter()
|
||||
.filter_map(|p| p.title.as_deref())
|
||||
.any(|t| !t.is_ascii());
|
||||
if !needs_fix {
|
||||
return Ok(bytes);
|
||||
}
|
||||
let mut doc = printpdf::lopdf::Document::load_mem(&bytes)?;
|
||||
for object in doc.objects.values_mut() {
|
||||
let Ok(dict) = object.as_dict_mut() else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(printpdf::lopdf::Object::String(raw, format)) = dict.get_mut(b"Title") {
|
||||
if let Some(utf16) = utf16be_text(raw) {
|
||||
*raw = utf16;
|
||||
*format = printpdf::lopdf::StringFormat::Hexadecimal;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
doc.save_to(&mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// UTF-8-Text als PDF-Textstring in UTF-16BE mit BOM; `None` bei reinem ASCII oder
|
||||
/// ungültigem UTF-8.
|
||||
fn utf16be_text(raw: &[u8]) -> Option<Vec<u8>> {
|
||||
let text = std::str::from_utf8(raw).ok()?;
|
||||
if text.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
let mut out = vec![0xFE, 0xFF];
|
||||
for unit in text.encode_utf16() {
|
||||
out.extend_from_slice(&unit.to_be_bytes());
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Unterlagen für alle Seiten mit importierter PDF-Seite.
|
||||
fn pdf_underlays(notebook: &Notebook) -> Vec<crate::pdf_merge::Underlay> {
|
||||
notebook
|
||||
@@ -234,6 +296,12 @@ fn generate_pdf_document_with(
|
||||
let title = "ink-notes Notebook";
|
||||
let (doc, page1_idx, layer1_bg) =
|
||||
PdfDocument::new(title, Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "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);
|
||||
@@ -245,6 +313,9 @@ fn generate_pdf_document_with(
|
||||
if notebook.pages[0].strokes.iter().any(Stroke::is_image) {
|
||||
render_image_layer(&page1_ref.add_layer("Images"), ¬ebook.pages[0], assets);
|
||||
}
|
||||
if notebook.pages[0].strokes.iter().any(Stroke::is_text) {
|
||||
render_text_layer(&page1_ref.add_layer("Text"), ¬ebook.pages[0], &fonts);
|
||||
}
|
||||
|
||||
let layer1_ink_ref = page1_ref.add_layer("Ink");
|
||||
render_ink_layer(&layer1_ink_ref, ¬ebook.pages[0], strategy);
|
||||
@@ -252,6 +323,9 @@ fn generate_pdf_document_with(
|
||||
// 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");
|
||||
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);
|
||||
@@ -261,6 +335,9 @@ fn generate_pdf_document_with(
|
||||
if page.strokes.iter().any(Stroke::is_image) {
|
||||
render_image_layer(&page_ref.add_layer("Images"), page, assets);
|
||||
}
|
||||
if page.strokes.iter().any(Stroke::is_text) {
|
||||
render_text_layer(&page_ref.add_layer("Text"), page, &fonts);
|
||||
}
|
||||
|
||||
let layer_ink_ref = page_ref.add_layer("Ink");
|
||||
render_ink_layer(&layer_ink_ref, page, strategy);
|
||||
@@ -360,7 +437,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 {
|
||||
@@ -498,6 +575,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 = A4_WIDTH_MM / PAGE_WIDTH * 72.0 / 25.4;
|
||||
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])
|
||||
@@ -1398,6 +1549,48 @@ mod tests {
|
||||
assert!(export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_titles_become_pdf_bookmarks() {
|
||||
let mut notebook = Notebook::new();
|
||||
notebook.add_page(PageBackground::Blank);
|
||||
notebook.add_page(PageBackground::Blank);
|
||||
let ids: Vec<u64> = notebook.pages.iter().map(|p| p.id).collect();
|
||||
notebook.set_page_title(ids[0], "Einleitung");
|
||||
notebook.set_page_title(ids[2], "Übung 2 – Integrale");
|
||||
let bytes =
|
||||
export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, None).unwrap();
|
||||
let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap();
|
||||
let catalog = doc.catalog().unwrap();
|
||||
assert!(catalog.has(b"Outlines"), "Lesezeichen fehlen");
|
||||
|
||||
// Titel als PDF-Textstrings: ASCII unverändert, Umlaute als UTF-16BE mit BOM
|
||||
let titles: Vec<String> = doc
|
||||
.objects
|
||||
.values()
|
||||
.filter_map(|o| o.as_dict().ok())
|
||||
.filter(|d| d.has(b"Parent"))
|
||||
.filter_map(|d| match d.get(b"Title") {
|
||||
Ok(printpdf::lopdf::Object::String(raw, _)) => {
|
||||
Some(if raw.starts_with(&[0xFE, 0xFF]) {
|
||||
let units: Vec<u16> = raw[2..]
|
||||
.chunks(2)
|
||||
.map(|c| u16::from_be_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
String::from_utf16(&units).unwrap()
|
||||
} else {
|
||||
String::from_utf8(raw.clone()).unwrap()
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(titles.contains(&"Einleitung".to_string()), "{titles:?}");
|
||||
assert!(
|
||||
titles.contains(&"Übung 2 – Integrale".to_string()),
|
||||
"{titles:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jpeg_component_count() {
|
||||
let color = crate::assets::tests::sample_image_bytes(8, 8, image::ImageFormat::Jpeg);
|
||||
|
||||
@@ -18,6 +18,13 @@ pub enum HistoryAction {
|
||||
before: Vec<Stroke>,
|
||||
after: Vec<Stroke>,
|
||||
},
|
||||
/// Teilradierer: Striche wurden durch ihre übrig gebliebenen Stücke ersetzt.
|
||||
SplitStrokes {
|
||||
removed: Vec<Stroke>,
|
||||
added: Vec<Stroke>,
|
||||
/// Zeitpunkt des letzten Schnitts (zum Zusammenfassen eines Radierzugs)
|
||||
at: std::time::Instant,
|
||||
},
|
||||
}
|
||||
|
||||
/// History-Stack für Transaktionsverwaltung (Undo / Redo).
|
||||
@@ -41,6 +48,40 @@ impl HistoryStack {
|
||||
self.redo_stack.clear();
|
||||
}
|
||||
|
||||
/// Registriert einen Teilradier-Schnitt. Folgt er innerhalb von
|
||||
/// [`crate::document::PARTIAL_ERASE_MERGE_WINDOW`] auf einen vorherigen, werden beide zu
|
||||
/// einem Undo-Schritt zusammengefasst: Stücke, die im selben Zug wieder zerschnitten
|
||||
/// wurden, verschwinden aus der Bilanz.
|
||||
pub fn push_split(&mut self, removed: Vec<Stroke>, added: Vec<Stroke>) {
|
||||
let now = std::time::Instant::now();
|
||||
if let Some(HistoryAction::SplitStrokes {
|
||||
removed: prev_removed,
|
||||
added: prev_added,
|
||||
at,
|
||||
}) = self.undo_stack.last_mut()
|
||||
{
|
||||
if now.duration_since(*at) <= crate::document::PARTIAL_ERASE_MERGE_WINDOW
|
||||
&& self.redo_stack.is_empty()
|
||||
{
|
||||
for stroke in removed {
|
||||
if let Some(pos) = prev_added.iter().position(|s| s.id == stroke.id) {
|
||||
prev_added.remove(pos);
|
||||
} else {
|
||||
prev_removed.push(stroke);
|
||||
}
|
||||
}
|
||||
prev_added.extend(added);
|
||||
*at = now;
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.push(HistoryAction::SplitStrokes {
|
||||
removed,
|
||||
added,
|
||||
at: now,
|
||||
});
|
||||
}
|
||||
|
||||
/// Gibt zurück, ob Undo verfügbar ist.
|
||||
pub fn can_undo(&self) -> bool {
|
||||
!self.undo_stack.is_empty()
|
||||
@@ -95,6 +136,12 @@ impl HistoryStack {
|
||||
self.redo_stack
|
||||
.push(HistoryAction::ModifyStrokes { before, after });
|
||||
}
|
||||
HistoryAction::SplitStrokes { removed, added, at } => {
|
||||
strokes.retain(|s| !added.iter().any(|a| a.id == s.id));
|
||||
strokes.extend(removed.iter().cloned());
|
||||
self.redo_stack
|
||||
.push(HistoryAction::SplitStrokes { removed, added, at });
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
@@ -140,6 +187,12 @@ impl HistoryStack {
|
||||
self.undo_stack
|
||||
.push(HistoryAction::ModifyStrokes { before, after });
|
||||
}
|
||||
HistoryAction::SplitStrokes { removed, added, at } => {
|
||||
strokes.retain(|s| !removed.iter().any(|r| r.id == s.id));
|
||||
strokes.extend(added.iter().cloned());
|
||||
self.undo_stack
|
||||
.push(HistoryAction::SplitStrokes { removed, added, at });
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
|
||||
+46
-18
@@ -86,8 +86,9 @@ 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,
|
||||
}
|
||||
@@ -166,7 +167,7 @@ impl ImageLayer {
|
||||
sampler,
|
||||
textures: HashMap::new(),
|
||||
texture_bytes: 0,
|
||||
quads: Vec::new(),
|
||||
groups: vec![Vec::new()],
|
||||
vertex_buffer: None,
|
||||
vertices_dirty: false,
|
||||
}
|
||||
@@ -174,12 +175,25 @@ impl ImageLayer {
|
||||
|
||||
/// Ü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()
|
||||
}
|
||||
|
||||
/// Lädt fehlende Texturen und den Vertex-Puffer hoch. Vor jedem Zeichnen aufrufen;
|
||||
/// ohne Änderungen kostet das nichts.
|
||||
pub fn prepare(
|
||||
@@ -189,8 +203,7 @@ impl ImageLayer {
|
||||
load: &mut dyn FnMut(&str) -> TextureSource,
|
||||
) {
|
||||
let missing: Vec<String> = self
|
||||
.quads
|
||||
.iter()
|
||||
.all_quads()
|
||||
.map(|(key, _)| key)
|
||||
.filter(|key| !self.textures.contains_key(*key))
|
||||
.cloned()
|
||||
@@ -217,7 +230,7 @@ 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();
|
||||
let used: HashSet<&String> = self.groups.iter().flatten().map(|(a, _)| a).collect();
|
||||
self.textures.retain(|name, _| used.contains(name));
|
||||
self.texture_bytes = self.textures.values().map(|(_, b)| b).sum();
|
||||
}
|
||||
@@ -227,8 +240,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 +264,45 @@ impl ImageLayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fehlen noch Texturen der aktuellen Seite (z. B. PDF-Seite im Hintergrund-Rendering)?
|
||||
/// Wird die Textur `key` von einer sichtbaren Seite verwendet?
|
||||
pub fn uses(&self, key: &str) -> bool {
|
||||
self.all_quads().any(|(k, _)| k == key)
|
||||
}
|
||||
|
||||
/// 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.quads
|
||||
.iter()
|
||||
self.all_quads()
|
||||
.any(|(key, _)| !self.textures.contains_key(key))
|
||||
}
|
||||
|
||||
/// Zeichnet die Bilder der aktuellen Seite. Bind Group 0 (Kamera) muss gesetzt sein.
|
||||
pub fn draw<'a>(&'a self, render_pass: &mut wgpu::RenderPass<'a>) {
|
||||
/// 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() {
|
||||
for (i, (asset, _)) in quads.iter().enumerate() {
|
||||
let Some((bind_group, _)) = 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,16 @@ pub mod filter;
|
||||
pub mod geometry;
|
||||
pub mod history;
|
||||
pub mod image_layer;
|
||||
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;
|
||||
|
||||
+590
-46
@@ -32,6 +32,7 @@ use ink_notes::settings::{SidebarSide, UiSettings};
|
||||
use ink_notes::storage::{self, NotebookManifest};
|
||||
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
|
||||
use ink_notes::updater::UpdaterController;
|
||||
use ink_notes::viewport::PAGE_PITCH;
|
||||
|
||||
/// Aktualisiert den Fenstertitel als minimales On-Screen-Display (OSD) inklusive Autosave-Status.
|
||||
/// Speichert die UI-Einstellungen; ein Fehler wird gemeldet, bricht aber nichts ab.
|
||||
@@ -62,10 +63,16 @@ fn update_window_title(
|
||||
PageBackground::DotGrid => "Punktraster (5mm)",
|
||||
};
|
||||
|
||||
let page_title = page
|
||||
.title
|
||||
.as_deref()
|
||||
.map(|t| format!(" – {t}"))
|
||||
.unwrap_or_default();
|
||||
let title = format!(
|
||||
"ink-notes | [{}] {} | [{}] | {} | Hintergrund: {} | [Ctrl+Z] Undo [Ctrl+Y] Redo [E] Radierer [Ctrl+E/S] PDF-Export",
|
||||
"ink-notes | [{}] {}{} | [{}] | {} | Hintergrund: {} | [Ctrl+Z] Undo [Ctrl+Y] Redo [E] Radierer [Ctrl+E/S] PDF-Export",
|
||||
manifest.title,
|
||||
notebook.osd_text(),
|
||||
page_title,
|
||||
status.display_text(),
|
||||
tool.name(),
|
||||
bg_name
|
||||
@@ -145,11 +152,19 @@ fn commit_stroke_to_notebook(
|
||||
|
||||
if let Some(mut stroke) = stroke_opt {
|
||||
stroke.tool = ui_state.active_tool.stroke_tool(stroke.shape.is_some());
|
||||
let stroke_bottom = stroke.aabb.max.y;
|
||||
let page = notebook.current_page_mut();
|
||||
page.add_stroke(stroke);
|
||||
canvas.sync_page(page);
|
||||
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||
autosave.mark_dirty(notebook, manifest);
|
||||
// Durchgehende Ansicht: unten auf der letzten Seite geht es auf einer neuen weiter
|
||||
if ui_state.continuous_scroll && notebook.append_page_if_writing_at_end(stroke_bottom) {
|
||||
manifest.sync_from_notebook(notebook);
|
||||
if let Some(new_page) = notebook.pages.last() {
|
||||
autosave.mark_page_dirty(new_page, manifest);
|
||||
}
|
||||
}
|
||||
update_window_title(
|
||||
window,
|
||||
notebook,
|
||||
@@ -161,13 +176,92 @@ fn commit_stroke_to_notebook(
|
||||
}
|
||||
}
|
||||
|
||||
/// Zwischenablage: interne Objekte (Strg+C in ink-notes) und Bilder aus der
|
||||
/// Systemzwischenablage (Screenshots, aus anderen Programmen kopierte Bilder).
|
||||
/// Macht die Seite `index` zur aktuellen: lädt sie bei Bedarf und aktualisiert GPU-Puffer,
|
||||
/// Manifest und Fenstertitel. Mit `keep_view` (durchgehende Ansicht) bleibt der Bildausschnitt
|
||||
/// samt Lineal stehen, sonst erscheint die neue Seite an der Stelle der alten.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn switch_to_page(
|
||||
index: usize,
|
||||
keep_view: bool,
|
||||
canvas: &mut CanvasState,
|
||||
notebook: &mut Notebook,
|
||||
manifest: &mut NotebookManifest,
|
||||
autosave: &mut AutosaveController,
|
||||
storage_dir: &Path,
|
||||
selection_state: &mut SelectionState,
|
||||
ui_state: &mut UiState,
|
||||
window: &Window,
|
||||
) {
|
||||
let current = notebook.current_page_index;
|
||||
if index >= notebook.pages.len() || index == current {
|
||||
return;
|
||||
}
|
||||
if keep_view {
|
||||
let k = index as i64 - current as i64;
|
||||
canvas.shift_reference_page(k);
|
||||
if let Some(ruler) = &mut ui_state.ruler {
|
||||
ruler.center.y -= k as f32 * PAGE_PITCH;
|
||||
}
|
||||
canvas.ruler = ui_state.ruler;
|
||||
}
|
||||
selection_state.clear();
|
||||
notebook.current_page_index = index;
|
||||
let _ = storage::ensure_page_loaded(storage_dir, notebook, index);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.sync_from_notebook(notebook);
|
||||
autosave.mark_manifest_dirty(manifest);
|
||||
update_window_title(
|
||||
window,
|
||||
notebook,
|
||||
manifest,
|
||||
ui_state.active_tool,
|
||||
autosave.status(),
|
||||
);
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: Index der Seite unter dem Bildschirmpunkt, falls dort eine andere
|
||||
/// als die aktuelle Seite liegt und kein Griff der Auswahl getroffen wird. Zeichnen,
|
||||
/// Radieren und Auswählen wechseln dann zuerst auf diese Seite.
|
||||
fn page_to_focus(
|
||||
canvas: &CanvasState,
|
||||
notebook: &Notebook,
|
||||
ui_state: &UiState,
|
||||
selection_state: &SelectionState,
|
||||
pixels_per_point: f32,
|
||||
(sx, sy): (f32, f32),
|
||||
) -> Option<usize> {
|
||||
if !ui_state.continuous_scroll {
|
||||
return None;
|
||||
}
|
||||
let k = canvas.page_offset_at_screen(sx, sy).filter(|&k| k != 0)?;
|
||||
let index = usize::try_from(notebook.current_page_index as i64 + k).ok()?;
|
||||
if index >= notebook.pages.len() {
|
||||
return None;
|
||||
}
|
||||
let grabs_selection = ui_state.active_tool == ActiveTool::Select
|
||||
&& !selection_state.is_empty()
|
||||
&& !matches!(
|
||||
selection_state.hit_test_handles(
|
||||
notebook.current_page(),
|
||||
canvas,
|
||||
Vec2::new(sx, sy),
|
||||
pixels_per_point
|
||||
),
|
||||
SelectionHandleHit::None
|
||||
);
|
||||
(!grabs_selection).then_some(index)
|
||||
}
|
||||
|
||||
/// Zwischenablage: interne Objekte (Strg+C in ink-notes), Bilder aus der
|
||||
/// Systemzwischenablage (Screenshots, aus anderen Programmen kopierte Bilder) und Text.
|
||||
struct ClipboardBridge {
|
||||
system: Option<arboard::Clipboard>,
|
||||
/// Prüfsumme des Systembildes zum Zeitpunkt des letzten internen Kopierens: Strg+V fügt
|
||||
/// das Systembild nur ein, wenn es danach kopiert wurde (die jüngste Kopie gewinnt)
|
||||
image_hash_at_copy: Option<[u8; 32]>,
|
||||
/// Text der Systemzwischenablage zum Zeitpunkt des letzten internen Kopierens (wie oben)
|
||||
text_at_copy: Option<String>,
|
||||
/// Notizbuch, aus dem kopiert wurde (für Bilder beim Einfügen in ein anderes Notizbuch)
|
||||
source_dir: Option<PathBuf>,
|
||||
}
|
||||
@@ -177,6 +271,7 @@ impl ClipboardBridge {
|
||||
Self {
|
||||
system: None,
|
||||
image_hash_at_copy: None,
|
||||
text_at_copy: None,
|
||||
source_dir: None,
|
||||
}
|
||||
}
|
||||
@@ -198,11 +293,60 @@ impl ClipboardBridge {
|
||||
Some((image.to_owned_img(), hash))
|
||||
}
|
||||
|
||||
/// Merkt sich beim internen Kopieren das aktuelle Systembild und das Quell-Notizbuch.
|
||||
/// Text in der Systemzwischenablage (nicht nur Leerraum).
|
||||
fn system_text(&mut self) -> Option<String> {
|
||||
if self.system.is_none() {
|
||||
self.system = arboard::Clipboard::new().ok();
|
||||
}
|
||||
let text = self.system.as_mut()?.get_text().ok()?;
|
||||
(!text.trim().is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
/// Merkt sich beim internen Kopieren das aktuelle Systembild, den Text und das
|
||||
/// Quell-Notizbuch.
|
||||
fn note_internal_copy(&mut self, storage_dir: &Path) {
|
||||
self.image_hash_at_copy = self.system_image().map(|(_, hash)| hash);
|
||||
self.text_at_copy = self.system_text();
|
||||
self.source_dir = Some(storage_dir.to_path_buf());
|
||||
}
|
||||
|
||||
/// Internes Kopieren: die Auswahl zusätzlich als Bild in die Systemzwischenablage legen,
|
||||
/// damit sie sich in Chat, Mail oder Office einfügen lässt. In ink-notes selbst fügt
|
||||
/// Strg+V weiterhin die Objekte (verlustfrei) ein.
|
||||
fn copy_selection(
|
||||
&mut self,
|
||||
page: &ink_notes::document::Page,
|
||||
ids: &[u64],
|
||||
storage_dir: &Path,
|
||||
) -> bool {
|
||||
let assets = AssetStore::new(storage_dir);
|
||||
let image = match ink_notes::page_export::selection_image(page, ids, Some(&assets)) {
|
||||
Ok(Some(image)) => image,
|
||||
Ok(None) => return false,
|
||||
Err(err) => {
|
||||
eprintln!("[Zwischenablage] Auswahl nicht als Bild darstellbar: {err}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if self.system.is_none() {
|
||||
self.system = arboard::Clipboard::new().ok();
|
||||
}
|
||||
let Some(system) = self.system.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let data = arboard::ImageData {
|
||||
width: image.width as usize,
|
||||
height: image.height as usize,
|
||||
bytes: std::borrow::Cow::Owned(image.pixels),
|
||||
};
|
||||
match system.set_image(data) {
|
||||
Ok(()) => true,
|
||||
Err(err) => {
|
||||
eprintln!("[Zwischenablage] Bild nicht gesetzt: {err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fügt ein importiertes Bild um `center` (Seitenkoordinaten) auf der aktuellen Seite ein,
|
||||
@@ -238,6 +382,124 @@ fn insert_image_on_page(
|
||||
));
|
||||
}
|
||||
|
||||
/// Fügt `content` als Textfeld (Schrift des Text-Werkzeugs) in der Mitte des sichtbaren
|
||||
/// Bereichs ein, wählt es aus und schaltet auf das Auswahlwerkzeug zum Verschieben.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn insert_text_on_page(
|
||||
content: &str,
|
||||
canvas: &mut CanvasState,
|
||||
notebook: &mut Notebook,
|
||||
manifest: &mut NotebookManifest,
|
||||
autosave: &mut AutosaveController,
|
||||
selection_state: &mut SelectionState,
|
||||
ui_state: &mut UiState,
|
||||
) {
|
||||
let center = visible_page_center(canvas);
|
||||
let mut edit = ink_notes::text::TextEditing::new_at(
|
||||
notebook.current_page().id,
|
||||
Vec2::new((center.x - 600.0).max(150.0), center.y),
|
||||
ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT,
|
||||
ui_state.text_font,
|
||||
ui_state.text_color,
|
||||
);
|
||||
edit.text.content = content.replace("\r\n", "\n").replace('\r', "\n");
|
||||
let id = notebook.next_stroke_id();
|
||||
let Some(stroke) = edit.to_stroke(id) else {
|
||||
return;
|
||||
};
|
||||
let page = notebook.current_page_mut();
|
||||
page.add_stroke(stroke);
|
||||
canvas.sync_page(page);
|
||||
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||
autosave.mark_dirty(notebook, manifest);
|
||||
selection_state.select_single(id);
|
||||
ui_state.active_tool = ActiveTool::Select;
|
||||
ui_state.set_export_message("Text eingefügt – mit dem Text-Werkzeug (T) änderbar");
|
||||
}
|
||||
|
||||
/// Beendet eine laufende Textbearbeitung: übernimmt das Feld (neu, geändert oder – wenn
|
||||
/// geleert – entfernt) als einen Undo-Schritt auf seiner Seite.
|
||||
fn finish_text_edit(
|
||||
canvas: &mut CanvasState,
|
||||
notebook: &mut Notebook,
|
||||
manifest: &mut NotebookManifest,
|
||||
autosave: &mut AutosaveController,
|
||||
ui_state: &mut UiState,
|
||||
) {
|
||||
let Some(edit) = ui_state.text_edit.take() else {
|
||||
return;
|
||||
};
|
||||
canvas.set_hidden_text(None);
|
||||
if let Some(index) = notebook.pages.iter().position(|p| p.id == edit.page_id) {
|
||||
if edit.is_changed() && !notebook.pages[index].is_readonly() {
|
||||
let id = match &edit.original {
|
||||
Some(original) => original.id,
|
||||
None => notebook.next_stroke_id(),
|
||||
};
|
||||
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||
let page = &mut notebook.pages[index];
|
||||
match (edit.original.clone(), edit.to_stroke(id)) {
|
||||
(None, Some(stroke)) => page.add_stroke(stroke),
|
||||
(Some(before), Some(after)) => {
|
||||
page.commit_modified_strokes(vec![before], vec![after])
|
||||
}
|
||||
(Some(before), None) => {
|
||||
page.remove_strokes_by_ids(&[before.id]);
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
manifest.sync_from_notebook(notebook);
|
||||
autosave.mark_page_dirty(¬ebook.pages[index], manifest);
|
||||
}
|
||||
}
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
}
|
||||
|
||||
/// Text-Werkzeug: Tippen auf das Blatt beendet eine laufende Bearbeitung und öffnet das
|
||||
/// Textfeld unter `page_pt` zum Bearbeiten – oder ein neues an dieser Stelle.
|
||||
fn text_tool_click(
|
||||
page_pt: Vec2,
|
||||
canvas: &mut CanvasState,
|
||||
notebook: &mut Notebook,
|
||||
manifest: &mut NotebookManifest,
|
||||
autosave: &mut AutosaveController,
|
||||
ui_state: &mut UiState,
|
||||
) {
|
||||
finish_text_edit(canvas, notebook, manifest, autosave, ui_state);
|
||||
let page = notebook.current_page();
|
||||
if page.is_readonly() {
|
||||
return;
|
||||
}
|
||||
let hit = page
|
||||
.strokes
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|s| s.is_text() && s.distance_to_point(page_pt) <= 0.0);
|
||||
let edit = match hit {
|
||||
Some(stroke) => {
|
||||
let edit = ink_notes::text::TextEditing::of_stroke(page.id, stroke);
|
||||
if let Some(edit) = &edit {
|
||||
// Die Schriftknöpfe zeigen die Schrift des Felds und ändern sie
|
||||
ui_state.text_size_pt =
|
||||
(edit.text.size / ink_notes::text::UNITS_PER_PT * 2.0).round() / 2.0;
|
||||
ui_state.text_font = edit.text.font;
|
||||
ui_state.text_color = edit.color;
|
||||
canvas.set_hidden_text(Some(stroke.id));
|
||||
}
|
||||
edit
|
||||
}
|
||||
None => Some(ink_notes::text::TextEditing::new_at(
|
||||
page.id,
|
||||
page_pt,
|
||||
ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT,
|
||||
ui_state.text_font,
|
||||
ui_state.text_color,
|
||||
)),
|
||||
};
|
||||
ui_state.text_edit = edit;
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
}
|
||||
|
||||
/// Textur für einen Schlüssel der Bildebene: `<pdf>#<seite>` rendert der Hintergrund-Thread,
|
||||
/// alles andere ist eine Bilddatei im Asset-Ordner.
|
||||
fn texture_for(
|
||||
@@ -265,6 +527,61 @@ fn texture_for(
|
||||
}
|
||||
}
|
||||
|
||||
/// Abbildung Seite ↔ logische Bildschirmpunkte für die Oberfläche (Lineal).
|
||||
fn page_view(canvas: &CanvasState, scale_factor: f32) -> ui::PageView {
|
||||
let origin = canvas.page_to_screen(Vec2::ZERO);
|
||||
let unit = canvas.page_to_screen(Vec2::new(1000.0, 0.0));
|
||||
ui::PageView {
|
||||
origin: egui::pos2(origin[0] / scale_factor, origin[1] / scale_factor),
|
||||
scale: (unit[0] - origin[0]) / 1000.0 / scale_factor,
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_message(count: usize, as_image: bool) -> String {
|
||||
if as_image {
|
||||
format!("{count} Objekt(e) kopiert – auch als Bild für andere Programme")
|
||||
} else {
|
||||
format!("{count} Objekt(e) kopiert")
|
||||
}
|
||||
}
|
||||
|
||||
/// Exportiert die aktuelle Seite im Hintergrund als PNG oder SVG nach ~/Documents.
|
||||
fn start_page_export(
|
||||
kind: ui::PageExportKind,
|
||||
notebook: &Notebook,
|
||||
storage_dir: &Path,
|
||||
title: &str,
|
||||
result_tx: Sender<Result<PathBuf, String>>,
|
||||
window: Arc<Window>,
|
||||
) {
|
||||
use ink_notes::page_export::{page_png, page_svg, ImageBackground};
|
||||
let page = notebook.current_page().clone();
|
||||
let number = notebook.current_page_index + 1;
|
||||
let storage_dir = storage_dir.to_path_buf();
|
||||
let (suffix, ext) = match kind {
|
||||
ui::PageExportKind::Png => ("", "png"),
|
||||
ui::PageExportKind::PngTransparent => ("_transparent", "png"),
|
||||
ui::PageExportKind::Svg => ("", "svg"),
|
||||
};
|
||||
let path = export::resolve_export_path(&format!("{title}_Seite_{number}{suffix}"), ext);
|
||||
std::thread::spawn(move || {
|
||||
let assets = AssetStore::new(&storage_dir);
|
||||
let bytes = match kind {
|
||||
ui::PageExportKind::Png => page_png(&page, Some(&assets), ImageBackground::Paper),
|
||||
ui::PageExportKind::PngTransparent => {
|
||||
page_png(&page, Some(&assets), ImageBackground::Transparent)
|
||||
}
|
||||
ui::PageExportKind::Svg => page_svg(&page, Some(&assets)).map(String::into_bytes),
|
||||
};
|
||||
let result = bytes
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|bytes| std::fs::write(&path, bytes).map_err(|e| e.to_string()))
|
||||
.map(|()| path);
|
||||
let _ = result_tx.send(result);
|
||||
window.request_redraw();
|
||||
});
|
||||
}
|
||||
|
||||
/// Seitenmitte des sichtbaren Bereichs (Ziel für eingefügte Bilder).
|
||||
fn visible_page_center(canvas: &CanvasState) -> Vec2 {
|
||||
canvas.screen_to_page_clamped(
|
||||
@@ -318,6 +635,23 @@ fn paste(
|
||||
}
|
||||
}
|
||||
|
||||
// Aus anderen Programmen kopierter Text wird ein Textfeld
|
||||
if let Some(text) = clipboard.system_text() {
|
||||
if selection_state.clipboard.is_empty() || clipboard.text_at_copy.as_deref() != Some(&text)
|
||||
{
|
||||
insert_text_on_page(
|
||||
&text,
|
||||
canvas,
|
||||
notebook,
|
||||
manifest,
|
||||
autosave,
|
||||
selection_state,
|
||||
ui_state,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Bilder aus einem anderen Notizbuch mitnehmen
|
||||
if let Some(source) = clipboard
|
||||
.source_dir
|
||||
@@ -519,6 +853,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
ui_state.paper_theme = ui_settings.paper_theme;
|
||||
ui_state.adapt_ink_colors = ui_settings.adapt_ink_colors;
|
||||
ui_state.pressure_curve = ui_settings.pressure_curve;
|
||||
ui_state.continuous_scroll = ui_settings.continuous_scroll;
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = available_notebooks;
|
||||
@@ -553,10 +888,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
};
|
||||
|
||||
// Initiale Seite und Werkzeugeinstellungen mit Canvas synchronisieren
|
||||
canvas.set_paper_theme(ui_state.paper_theme, ui_state.adapt_ink_colors);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
canvas.set_stroke_color(ui_state.pen_color);
|
||||
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
|
||||
canvas.set_paper_theme(ui_state.paper_theme, ui_state.adapt_ink_colors);
|
||||
|
||||
let start_time = Instant::now();
|
||||
let mut cursor_screen_pos = (0.0_f32, 0.0_f32);
|
||||
@@ -615,7 +950,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
println!("=== ink-notes (Multi-Color & Objektauswahl/Transformation) bereit ===");
|
||||
println!("Toolbar:");
|
||||
println!(" Stift / Textmarker / Formen (Auto-Snap) / Radierer / Auswählen (Lasso)");
|
||||
println!(" Stift / Textmarker / Formen (Auto-Snap) / Radierer / Auswählen (Lasso) / Text");
|
||||
println!(" Farbpalette (Multicolor), Strichbreite, Undo/Redo, Zoom, Navigation & PDF-Export");
|
||||
println!("Objektauswahl & Transformation:");
|
||||
println!(" V : Auswahlwerkzeug (Lasso) umschalten");
|
||||
@@ -627,8 +962,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!(" Palette-Klick : Ausgewählte Objekte direkt umfärben");
|
||||
println!(" Strg + C / V : Ausgewählte Objekte kopieren & einfügen");
|
||||
println!(" Entf/Backspace : Ausgewählte Objekte löschen (Undo mit Strg+Z)");
|
||||
println!("Text:");
|
||||
println!(" T : Text-Werkzeug umschalten");
|
||||
println!(" Klick ins Blatt: Neues Textfeld (vorhandenes Textfeld: bearbeiten)");
|
||||
println!(" Esc : Textbearbeitung beenden");
|
||||
println!("Tastatur- & Maus-Shortcuts:");
|
||||
println!(" Mausrad : Stufenloser Zoom zentriert auf Cursor");
|
||||
println!(" Mausrad : Scrollen (durchgehende Ansicht), sonst Zoom");
|
||||
println!(" Strg + Mausrad : Stufenloser Zoom zentriert auf Cursor");
|
||||
println!(" Mittlere Taste : Canvas verschieben (Pan)");
|
||||
println!(" Leertaste+Drag : Canvas verschieben (Pan)");
|
||||
println!(" Strg + 0 : Zoom zurücksetzen (100% zentriert)");
|
||||
@@ -639,6 +979,38 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!(" ESC : Dialoge / Menüs schließen oder Selektion aufheben");
|
||||
println!(" Strg + Q : Beenden (mit sicherem Disk-Flush)");
|
||||
|
||||
// Durchgehende Ansicht: Scrollposition beim letzten Abgleich der aktuellen Seite (Zoom,
|
||||
// Pan unabhängig von der Bezugsseite)
|
||||
let mut last_scroll: Option<[f32; 3]> = None;
|
||||
|
||||
// Durchgehende Ansicht: vor Zeichnen, Radieren oder Auswählen auf die Seite unter dem
|
||||
// Bildschirmpunkt wechseln
|
||||
macro_rules! focus_page_at {
|
||||
($pos:expr) => {
|
||||
if let Some(index) = page_to_focus(
|
||||
&canvas,
|
||||
¬ebook,
|
||||
&ui_state,
|
||||
&selection_state,
|
||||
egui_ctx.pixels_per_point(),
|
||||
$pos,
|
||||
) {
|
||||
switch_to_page(
|
||||
index,
|
||||
true,
|
||||
&mut canvas,
|
||||
&mut notebook,
|
||||
&mut manifest,
|
||||
&mut autosave,
|
||||
&storage_dir,
|
||||
&mut selection_state,
|
||||
&mut ui_state,
|
||||
&window,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
event_loop.run(move |event, target| {
|
||||
match event {
|
||||
Event::AboutToWait => {
|
||||
@@ -650,8 +1022,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
while let Ok(result) = export_rx.try_recv() {
|
||||
match result {
|
||||
Ok(path) => ui_state.set_export_message(format!("PDF gespeichert: {}", path.display())),
|
||||
Err(err) => ui_state.set_export_message(format!("PDF-Export fehlgeschlagen: {err}")),
|
||||
Ok(path) => ui_state.set_export_message(format!("Gespeichert: {}", path.display())),
|
||||
Err(err) => ui_state.set_export_message(format!("Export fehlgeschlagen: {err}")),
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -761,14 +1133,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
if is_erasing {
|
||||
focus_page_at!((sx, sy));
|
||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||
let page = notebook.current_page_mut();
|
||||
let removed = page.erase_at(
|
||||
page_pt,
|
||||
if notebook.erase_at(page_pt,
|
||||
eraser_radius,
|
||||
stroke_max_radius, ui_state.eraser_mode);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
stroke_max_radius, ui_state.eraser_mode) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(
|
||||
&window,
|
||||
@@ -822,20 +1192,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
continue;
|
||||
}
|
||||
|
||||
focus_page_at!((sx, sy));
|
||||
let should_erase = tool_is_hardware_eraser
|
||||
|| stylus_barrel_pressed
|
||||
|| ui_state.active_tool == ActiveTool::Eraser;
|
||||
|
||||
if should_erase {
|
||||
if !should_erase && ui_state.active_tool == ActiveTool::Text {
|
||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||
text_tool_click(page_pt, &mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
} else if should_erase {
|
||||
is_erasing = true;
|
||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||
let page = notebook.current_page_mut();
|
||||
let removed = page.erase_at(
|
||||
page_pt,
|
||||
if notebook.erase_at(page_pt,
|
||||
eraser_radius,
|
||||
stroke_max_radius, ui_state.eraser_mode);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
stroke_max_radius, ui_state.eraser_mode) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(
|
||||
&window,
|
||||
@@ -991,6 +1365,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
match event {
|
||||
WindowEvent::CloseRequested => {
|
||||
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
|
||||
println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte...");
|
||||
match autosave.shutdown() {
|
||||
Ok(()) => {
|
||||
@@ -1073,6 +1448,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Offenes Textfeld ohne Fokus (z. B. nach einem Klick in die Leiste):
|
||||
// Tasten nicht als Kürzel deuten, sondern das Feld wieder fokussieren
|
||||
if let Some(edit) = &mut ui_state.text_edit {
|
||||
if key_state == ElementState::Pressed
|
||||
&& !matches!(logical_key, Key::Named(NamedKey::Escape))
|
||||
{
|
||||
edit.focus = true;
|
||||
window.request_redraw();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Key::Named(NamedKey::Space) = &logical_key {
|
||||
is_space_pressed = key_state == ElementState::Pressed;
|
||||
if !is_space_pressed && is_panning {
|
||||
@@ -1107,8 +1494,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
ui_state.is_update_dialog_open = false;
|
||||
ui_state.show_updater_config = false;
|
||||
changed = true;
|
||||
}
|
||||
else if !selection_state.is_empty() {
|
||||
} else if let Some(edit) = &mut ui_state.text_edit {
|
||||
edit.finish = true;
|
||||
changed = true;
|
||||
} else if !selection_state.is_empty() {
|
||||
selection_state.clear();
|
||||
changed = true;
|
||||
}
|
||||
@@ -1119,6 +1508,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Undo: Strg + Z
|
||||
Key::Character(c) if c.eq_ignore_ascii_case("z") && ctrl && !shift => {
|
||||
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
|
||||
if notebook.current_page_mut().undo() {
|
||||
selection_state.clear();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
@@ -1133,6 +1523,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if (c.eq_ignore_ascii_case("y") && ctrl)
|
||||
|| (c.eq_ignore_ascii_case("z") && ctrl && shift) =>
|
||||
{
|
||||
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
|
||||
if notebook.current_page_mut().redo() {
|
||||
selection_state.clear();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
@@ -1146,8 +1537,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Key::Character(c) if c.eq_ignore_ascii_case("c") && ctrl => {
|
||||
let count = selection_state.copy_to_clipboard(notebook.current_page());
|
||||
if count > 0 {
|
||||
let as_image = clipboard.copy_selection(notebook.current_page(), &selection_state.selected_ids, &storage_dir);
|
||||
clipboard.note_internal_copy(&storage_dir);
|
||||
ui_state.set_export_message(format!("{count} Objekt(e) kopiert"));
|
||||
ui_state.set_export_message(copy_message(count, as_image));
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
@@ -1162,6 +1554,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
// Lineal ein-/ausblenden: L
|
||||
Key::Character(c) if c.eq_ignore_ascii_case("l") && !ctrl => {
|
||||
ui_state.toggle_ruler();
|
||||
canvas.ruler = ui_state.ruler;
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
// Bild einfügen: Strg + I
|
||||
Key::Character(c) if c.eq_ignore_ascii_case("i") && ctrl => {
|
||||
ui_state.open_image_picker();
|
||||
@@ -1215,7 +1614,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Radierer umschalten: E
|
||||
Key::Character(c) if c.eq_ignore_ascii_case("e") && !ctrl => {
|
||||
ui_state.active_tool = match ui_state.active_tool {
|
||||
ActiveTool::Pen | ActiveTool::Highlighter | ActiveTool::Shape | ActiveTool::Select => ActiveTool::Eraser,
|
||||
ActiveTool::Pen | ActiveTool::Highlighter | ActiveTool::Shape | ActiveTool::Select | ActiveTool::Text => ActiveTool::Eraser,
|
||||
ActiveTool::Eraser => ActiveTool::Pen,
|
||||
};
|
||||
ui_state.is_pen_menu_open = false;
|
||||
@@ -1407,6 +1806,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
// Text-Werkzeug umschalten: T
|
||||
Key::Character(c) if c.eq_ignore_ascii_case("t") && !ctrl => {
|
||||
ui_state.active_tool = if ui_state.active_tool == ActiveTool::Text {
|
||||
ActiveTool::Pen
|
||||
} else {
|
||||
ActiveTool::Text
|
||||
};
|
||||
ui_state.is_pen_menu_open = false;
|
||||
ui_state.is_highlighter_menu_open = false;
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
// Formen-Werkzeug umschalten: S (ohne Strg) oder F
|
||||
Key::Character(c) if (c.eq_ignore_ascii_case("s") || c.eq_ignore_ascii_case("f")) && !ctrl => {
|
||||
ui_state.active_tool = if ui_state.active_tool == ActiveTool::Shape {
|
||||
@@ -1420,6 +1832,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Beenden: Strg + Q
|
||||
Key::Character(c) if c.eq_ignore_ascii_case("q") && ctrl => {
|
||||
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
|
||||
println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte...");
|
||||
match autosave.shutdown() {
|
||||
Ok(()) => {
|
||||
@@ -1462,14 +1875,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
return;
|
||||
}
|
||||
|
||||
focus_page_at!(cursor_screen_pos);
|
||||
if is_eraser_action {
|
||||
is_erasing = true;
|
||||
let (sx, sy) = cursor_screen_pos;
|
||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||
let page = notebook.current_page_mut();
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
@@ -1478,6 +1890,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
} else if button == MouseButton::Left {
|
||||
let (sx, sy) = cursor_screen_pos;
|
||||
|
||||
// Text-Werkzeug: Textfeld öffnen
|
||||
if ui_state.active_tool == ActiveTool::Text {
|
||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||
text_tool_click(page_pt, &mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Objektauswahl & Transformation im Select-Modus
|
||||
if ui_state.active_tool == ActiveTool::Select {
|
||||
let screen_vec = Vec2::new(sx, sy);
|
||||
@@ -1733,11 +2155,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
} else if is_erasing {
|
||||
focus_page_at!((sx, sy));
|
||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||
let page = notebook.current_page_mut();
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
@@ -1766,6 +2187,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if is_pointer_in_ui {
|
||||
return;
|
||||
}
|
||||
// Durchgehende Ansicht: Mausrad scrollt (mit Umschalt waagerecht),
|
||||
// Strg + Mausrad zoomt
|
||||
if ui_state.continuous_scroll && !modifiers.control_key() {
|
||||
let (mut dx, mut dy) = match delta {
|
||||
MouseScrollDelta::LineDelta(x, y) => {
|
||||
let line = 60.0 * window.scale_factor() as f32;
|
||||
(x * line, y * line)
|
||||
}
|
||||
MouseScrollDelta::PixelDelta(pos) => (pos.x as f32, pos.y as f32),
|
||||
};
|
||||
if modifiers.shift_key() && dx == 0.0 {
|
||||
(dx, dy) = (dy, 0.0);
|
||||
}
|
||||
canvas.pan(dx, dy);
|
||||
window.request_redraw();
|
||||
return;
|
||||
}
|
||||
let zoom_factor = match delta {
|
||||
MouseScrollDelta::LineDelta(_x, y) => {
|
||||
if y > 0.0 {
|
||||
@@ -1870,18 +2308,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
touch_pan_last_pos = (tx, ty);
|
||||
} else {
|
||||
// Modus Finger-Zeichnen aktiv
|
||||
focus_page_at!((tx, ty));
|
||||
if is_eraser_action {
|
||||
is_erasing = true;
|
||||
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
||||
let page = notebook.current_page_mut();
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
} else if ui_state.active_tool == ActiveTool::Text {
|
||||
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
||||
text_tool_click(page_pt, &mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
} else if ui_state.active_tool == ActiveTool::Select {
|
||||
let screen_vec = Vec2::new(tx, ty);
|
||||
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point());
|
||||
@@ -2013,11 +2456,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
window.request_redraw();
|
||||
}
|
||||
} else if is_erasing {
|
||||
focus_page_at!((tx, ty));
|
||||
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
||||
let page = notebook.current_page_mut();
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
@@ -2155,6 +2597,49 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
WindowEvent::RedrawRequested => {
|
||||
// 0. Durchgehende Ansicht: Nach dem Scrollen oder Zoomen wird die Seite in
|
||||
// der Fenstermitte zur aktuellen (nicht während eines Strichs, einer
|
||||
// Auswahl oder beim Ziehen des Lineals). Wer auf einer Nachbarseite
|
||||
// schreibt, bleibt dort, bis wieder gescrollt wird (Undo betrifft sie).
|
||||
if ui_state.continuous_scroll {
|
||||
canvas.clamp_scroll(notebook.current_page_index, notebook.pages.len());
|
||||
let scroll = [
|
||||
canvas.camera.zoom,
|
||||
canvas.camera.pan_offset[0],
|
||||
canvas.camera.pan_offset[1]
|
||||
- notebook.current_page_index as f32 * PAGE_PITCH,
|
||||
];
|
||||
let scrolled = last_scroll
|
||||
.is_none_or(|last: [f32; 3]| last.iter().zip(scroll).any(|(a, b)| (a - b).abs() > 1e-3));
|
||||
let busy = is_drawing
|
||||
|| is_erasing
|
||||
|| !selection_state.is_empty()
|
||||
|| selection_state.drag_mode != DragMode::None
|
||||
|| ui_state.ruler_drag.is_some()
|
||||
|| ui_state.text_edit.is_some();
|
||||
if scrolled && !busy {
|
||||
last_scroll = Some(scroll);
|
||||
let target = canvas
|
||||
.page_offset_at_view_center()
|
||||
.filter(|&k| k != 0)
|
||||
.and_then(|k| usize::try_from(notebook.current_page_index as i64 + k).ok());
|
||||
if let Some(index) = target {
|
||||
switch_to_page(
|
||||
index,
|
||||
true,
|
||||
&mut canvas,
|
||||
&mut notebook,
|
||||
&mut manifest,
|
||||
&mut autosave,
|
||||
&storage_dir,
|
||||
&mut selection_state,
|
||||
&mut ui_state,
|
||||
&window,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. egui Eingabe erfassen und Frame starten
|
||||
let raw_input = egui_winit_state.take_egui_input(&window);
|
||||
egui_ctx.begin_frame(raw_input);
|
||||
@@ -2164,7 +2649,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// 3. Toolbar & Widgets rendern
|
||||
updater.update();
|
||||
ui_state.page_view = Some(page_view(&canvas, window.scale_factor() as f32));
|
||||
let actions = ui::render_ui(&egui_ctx, &mut ui_state, ¬ebook, autosave.status(), &updater);
|
||||
canvas.ruler = ui_state.ruler;
|
||||
|
||||
// Seite neben der Werkzeugleiste zentrieren (Leistenbreite in physischen Pixeln)
|
||||
let reserved = ui_state.sidebar_reserved_width() * window.scale_factor() as f32;
|
||||
@@ -2194,7 +2681,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
canvas.set_stroke_color(ui_state.pen_color);
|
||||
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
|
||||
}
|
||||
ActiveTool::Eraser | ActiveTool::Select => {}
|
||||
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {}
|
||||
}
|
||||
update_window_title(&window, ¬ebook, &manifest, tool, autosave.status());
|
||||
window.request_redraw();
|
||||
@@ -2215,7 +2702,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
ui_state.highlighter_color = color;
|
||||
canvas.set_stroke_color(color);
|
||||
}
|
||||
ActiveTool::Eraser | ActiveTool::Select => {}
|
||||
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {}
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -2229,7 +2716,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
ui_state.highlighter_stroke_width_mm = width;
|
||||
canvas.set_stroke_width_mm(width);
|
||||
}
|
||||
ActiveTool::Eraser | ActiveTool::Select => {}
|
||||
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {}
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -2247,8 +2734,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
UiAction::CopySelected => {
|
||||
let count = selection_state.copy_to_clipboard(notebook.current_page());
|
||||
if count > 0 {
|
||||
let as_image = clipboard.copy_selection(notebook.current_page(), &selection_state.selected_ids, &storage_dir);
|
||||
clipboard.note_internal_copy(&storage_dir);
|
||||
ui_state.set_export_message(format!("{count} Objekt(e) kopiert"));
|
||||
ui_state.set_export_message(copy_message(count, as_image));
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
@@ -2298,6 +2786,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::Undo => {
|
||||
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
|
||||
if notebook.current_page_mut().undo() {
|
||||
selection_state.clear();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
@@ -2307,6 +2796,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
UiAction::Redo => {
|
||||
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
|
||||
if notebook.current_page_mut().redo() {
|
||||
selection_state.clear();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
@@ -2342,6 +2832,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.map(|p| storage::PageMeta {
|
||||
id: p.id,
|
||||
background: Some(p.background),
|
||||
title: p.title.clone(),
|
||||
})
|
||||
.collect();
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
@@ -2350,6 +2841,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::ExportPage(kind) => {
|
||||
start_page_export(kind, ¬ebook, &storage_dir, &manifest.title, export_tx.clone(), window.clone());
|
||||
ui_state.set_export_message("Seite wird exportiert …".to_string());
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::SetPageTitle { page_id, title } => {
|
||||
if notebook.set_page_title(page_id, &title) {
|
||||
if let Some(page) = notebook.pages.iter().find(|p| p.id == page_id) {
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
autosave.mark_page_dirty(page, &manifest);
|
||||
}
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
}
|
||||
}
|
||||
UiAction::AddPage => {
|
||||
selection_state.clear();
|
||||
let bg = notebook.current_page().background;
|
||||
@@ -2362,6 +2867,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.map(|p| storage::PageMeta {
|
||||
id: p.id,
|
||||
background: Some(p.background),
|
||||
title: p.title.clone(),
|
||||
})
|
||||
.collect();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
@@ -2734,11 +3240,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
UiAction::SetPaperTheme { theme, adapt_ink } => {
|
||||
canvas.set_paper_theme(theme, adapt_ink);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
ui_settings.paper_theme = theme;
|
||||
ui_settings.adapt_ink_colors = adapt_ink;
|
||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::SetContinuousScroll(on) => {
|
||||
ui_settings.continuous_scroll = on;
|
||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::SavePressureCurve(curve) => {
|
||||
ui_settings.pressure_curve = curve;
|
||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||
@@ -2750,6 +3262,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
|
||||
// Textbearbeitung beenden: Esc, anderes Werkzeug oder andere Seite
|
||||
let finish_text = ui_state.text_edit.as_ref().is_some_and(|edit| {
|
||||
edit.finish
|
||||
|| ui_state.active_tool != ActiveTool::Text
|
||||
|| edit.page_id != notebook.current_page().id
|
||||
});
|
||||
if finish_text {
|
||||
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
// 4. egui Frame beenden & Primitives tessellieren
|
||||
let full_output = egui_ctx.end_frame();
|
||||
egui_winit_state.handle_platform_output(&window, full_output.platform_output);
|
||||
@@ -2781,6 +3305,26 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
// 5. Canvas (Pass 1) und egui UI (Pass 2) in Surface-Textur rendern
|
||||
// Durchgehende Ansicht: sichtbare Nachbarseiten laden und mitzeichnen
|
||||
if ui_state.continuous_scroll {
|
||||
let current = notebook.current_page_index;
|
||||
let visible: Vec<(i64, usize)> = canvas
|
||||
.visible_neighbor_offsets(current, notebook.pages.len())
|
||||
.into_iter()
|
||||
.map(|k| (k, (current as i64 + k) as usize))
|
||||
.collect();
|
||||
for &(_, index) in &visible {
|
||||
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, index);
|
||||
notebook.pages[index].rebuild_cache_if_dirty();
|
||||
}
|
||||
let neighbors: Vec<(i64, &ink_notes::document::Page)> = visible
|
||||
.iter()
|
||||
.map(|&(k, index)| (k, ¬ebook.pages[index]))
|
||||
.collect();
|
||||
canvas.set_neighbor_pages(&neighbors);
|
||||
} else {
|
||||
canvas.set_neighbor_pages(&[]);
|
||||
}
|
||||
let assets = AssetStore::new(&storage_dir);
|
||||
canvas.prepare_images(&mut |key| texture_for(key, &assets, &mut pdf_renderer));
|
||||
// Nächste PDF-Seite vorab rendern, damit Blättern sofort klappt
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
//! Export einzelner Seiten als PNG oder SVG und einer Auswahl als Bild.
|
||||
//!
|
||||
//! Grundlage ist immer der PDF-Export: die Seite wird als einseitiges PDF erzeugt (mit
|
||||
//! Bildern, importierten PDF-Seiten und Formen) und anschließend mit `hayro` gerastert bzw.
|
||||
//! mit `hayro-svg` nach SVG umgewandelt. So sehen PDF, PNG und SVG gleich aus.
|
||||
|
||||
use std::error::Error;
|
||||
|
||||
use hayro::hayro_interpret::InterpreterSettings;
|
||||
use hayro::hayro_syntax::Pdf;
|
||||
use hayro::vello_cpu::color::palette::css::{TRANSPARENT, WHITE};
|
||||
use hayro::{PixmapSettings, RenderCache, RenderSettings};
|
||||
|
||||
use crate::assets::{AssetStore, RgbaImage};
|
||||
use crate::document::{Aabb, Notebook, Page, PageBackground, A4_WIDTH_MM, PAGE_WIDTH};
|
||||
use crate::export::{export_notebook_to_bytes, ExportStrategy};
|
||||
|
||||
/// Auflösung des PNG-Exports einer Seite.
|
||||
pub const PAGE_PNG_DPI: f32 = 200.0;
|
||||
/// Auflösung einer in die Zwischenablage kopierten Auswahl.
|
||||
pub const SELECTION_PNG_DPI: f32 = 150.0;
|
||||
|
||||
/// Hintergrund beim Bild-Export.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ImageBackground {
|
||||
/// Weißes Papier mit Raster bzw. importierter PDF-Seite
|
||||
Paper,
|
||||
/// Transparent, nur Bilder und Tinte (ohne Raster und ohne importierte PDF-Seite)
|
||||
Transparent,
|
||||
}
|
||||
|
||||
/// Erzeugt ein einseitiges PDF aus `page`.
|
||||
fn single_page_pdf(page: &Page, assets: Option<&AssetStore>) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
let notebook = Notebook::from_pages(vec![page.clone()], 0, 1, page.id + 1);
|
||||
export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, assets)
|
||||
}
|
||||
|
||||
/// Seite ohne Raster und PDF-Hintergrund (für transparenten Export und Auswahlen).
|
||||
fn content_only(page: &Page) -> Page {
|
||||
let mut page = page.clone();
|
||||
page.background = PageBackground::Blank;
|
||||
page.pdf_background = None;
|
||||
page
|
||||
}
|
||||
|
||||
/// Rastert die erste Seite eines PDFs mit `dpi`; Farben ohne Vormultiplikation.
|
||||
fn rasterize(pdf_bytes: Vec<u8>, dpi: f32, transparent: bool) -> Result<RgbaImage, Box<dyn Error>> {
|
||||
let pdf = Pdf::new(pdf_bytes).map_err(|e| format!("PDF nicht lesbar: {e:?}"))?;
|
||||
let page = pdf.pages().first().ok_or("PDF ohne Seite")?;
|
||||
let scale = dpi / 72.0;
|
||||
let pixmap = hayro::render(
|
||||
page,
|
||||
&RenderCache::new(),
|
||||
&InterpreterSettings::default(),
|
||||
&RenderSettings::default(),
|
||||
&PixmapSettings {
|
||||
x_scale: scale,
|
||||
y_scale: scale,
|
||||
bg_color: if transparent { TRANSPARENT } else { WHITE },
|
||||
},
|
||||
);
|
||||
let mut pixels = pixmap.data_as_u8_slice().to_vec();
|
||||
// hayro liefert vormultiplizierte Farben; PNG erwartet gerade Farben
|
||||
for px in pixels.as_chunks_mut::<4>().0 {
|
||||
let a = px[3] as u32;
|
||||
if a > 0 && a < 255 {
|
||||
for c in &mut px[..3] {
|
||||
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(RgbaImage {
|
||||
width: pixmap.width() as u32,
|
||||
height: pixmap.height() as u32,
|
||||
pixels,
|
||||
})
|
||||
}
|
||||
|
||||
/// Kodiert ein Bild als PNG.
|
||||
pub fn encode_png(image: &RgbaImage) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
let buffer = image::RgbaImage::from_raw(image.width, image.height, image.pixels.clone())
|
||||
.ok_or("Pixeldaten passen nicht zur Bildgröße")?;
|
||||
let mut out = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(buffer)
|
||||
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Seite als PNG (A4 mit [`PAGE_PNG_DPI`]).
|
||||
pub fn page_png(
|
||||
page: &Page,
|
||||
assets: Option<&AssetStore>,
|
||||
background: ImageBackground,
|
||||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
let image = match background {
|
||||
ImageBackground::Paper => rasterize(single_page_pdf(page, assets)?, PAGE_PNG_DPI, false)?,
|
||||
ImageBackground::Transparent => rasterize(
|
||||
single_page_pdf(&content_only(page), assets)?,
|
||||
PAGE_PNG_DPI,
|
||||
true,
|
||||
)?,
|
||||
};
|
||||
encode_png(&image)
|
||||
}
|
||||
|
||||
/// Seite als SVG (Vektor; Schriften importierter PDFs werden zu Pfaden).
|
||||
pub fn page_svg(page: &Page, assets: Option<&AssetStore>) -> Result<String, Box<dyn Error>> {
|
||||
let pdf =
|
||||
Pdf::new(single_page_pdf(page, assets)?).map_err(|e| format!("PDF nicht lesbar: {e:?}"))?;
|
||||
let pdf_page = pdf.pages().first().ok_or("PDF ohne Seite")?;
|
||||
Ok(hayro_svg::convert(
|
||||
pdf_page,
|
||||
&hayro_svg::RenderCache::new(),
|
||||
&InterpreterSettings::default(),
|
||||
&hayro_svg::SvgRenderSettings {
|
||||
bg_color: [255, 255, 255, 255],
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Bild der Objekte `ids` auf `page`, auf ihren Umriss zugeschnitten, mit transparentem
|
||||
/// Hintergrund. `None`, wenn keines der Objekte auf der Seite liegt.
|
||||
pub fn selection_image(
|
||||
page: &Page,
|
||||
ids: &[u64],
|
||||
assets: Option<&AssetStore>,
|
||||
) -> Result<Option<RgbaImage>, Box<dyn Error>> {
|
||||
let mut selected = content_only(page);
|
||||
selected.strokes.retain(|s| ids.contains(&s.id));
|
||||
let Some(bounds) = Page::selection_aabb(&selected.strokes) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let max_width = selected.strokes.iter().map(|s| s.width).fold(0.0, f32::max);
|
||||
let bounds: Aabb = bounds.expanded(max_width / 2.0 + 4.0);
|
||||
|
||||
let full = rasterize(single_page_pdf(&selected, assets)?, SELECTION_PNG_DPI, true)?;
|
||||
// Seiteneinheiten → Pixel
|
||||
let px_per_unit = SELECTION_PNG_DPI / 25.4 * A4_WIDTH_MM / PAGE_WIDTH;
|
||||
let x0 = ((bounds.min.x * px_per_unit).floor().max(0.0) as u32).min(full.width);
|
||||
let y0 = ((bounds.min.y * px_per_unit).floor().max(0.0) as u32).min(full.height);
|
||||
let x1 = ((bounds.max.x * px_per_unit).ceil().max(0.0) as u32).min(full.width);
|
||||
let y1 = ((bounds.max.y * px_per_unit).ceil().max(0.0) as u32).min(full.height);
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let mut pixels = Vec::with_capacity((w * h * 4) as usize);
|
||||
for y in y0..y1 {
|
||||
let start = ((y * full.width + x0) * 4) as usize;
|
||||
pixels.extend_from_slice(&full.pixels[start..start + (w * 4) as usize]);
|
||||
}
|
||||
Ok(Some(RgbaImage {
|
||||
width: w,
|
||||
height: h,
|
||||
pixels,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::document::{Stroke, StylusSample, PAGE_HEIGHT};
|
||||
|
||||
fn page_with_line() -> Page {
|
||||
let mut page = Page::new(1, PageBackground::Grid5mm);
|
||||
let samples: Vec<StylusSample> = (0..=20)
|
||||
.map(|i| StylusSample {
|
||||
position: glam::Vec2::new(500.0 + i as f32 * 20.0, 1000.0),
|
||||
pressure: 0.6,
|
||||
timestamp: i as f64 * 0.01,
|
||||
})
|
||||
.collect();
|
||||
let vertices = crate::storage::reconstruct_stroke_vertices(
|
||||
&samples,
|
||||
[0.9, 0.1, 0.1, 1.0],
|
||||
20.0,
|
||||
Default::default(),
|
||||
true,
|
||||
);
|
||||
let mut stroke = Stroke::new(7, samples, vertices, [0.9, 0.1, 0.1, 1.0]);
|
||||
stroke.width = 20.0;
|
||||
page.strokes.push(stroke);
|
||||
page
|
||||
}
|
||||
|
||||
fn decode(png: &[u8]) -> image::RgbaImage {
|
||||
image::load_from_memory(png).unwrap().into_rgba8()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_png_paper_and_transparent() {
|
||||
let page = page_with_line();
|
||||
let paper = decode(&page_png(&page, None, ImageBackground::Paper).unwrap());
|
||||
// A4 mit 200 dpi
|
||||
assert_eq!(paper.width(), (A4_WIDTH_MM / 25.4 * PAGE_PNG_DPI) as u32);
|
||||
assert_eq!(paper.get_pixel(5, 5).0[3], 255, "Papier ist deckend");
|
||||
|
||||
let clear = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
|
||||
assert_eq!(clear.get_pixel(5, 5).0[3], 0, "Hintergrund ist transparent");
|
||||
// Die Linie ist rot und deckend
|
||||
let px_per_unit = clear.width() as f32 / PAGE_WIDTH;
|
||||
let line = clear.get_pixel((700.0 * px_per_unit) as u32, (1000.0 * px_per_unit) as u32);
|
||||
assert!(
|
||||
line.0[0] > 180 && line.0[1] < 80 && line.0[3] > 200,
|
||||
"{line:?}"
|
||||
);
|
||||
assert!(clear.height() as f32 / clear.width() as f32 - PAGE_HEIGHT / PAGE_WIDTH < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_svg_contains_ink() {
|
||||
let svg = page_svg(&page_with_line(), None).unwrap();
|
||||
assert!(svg.starts_with("<svg") || svg.contains("<svg"));
|
||||
assert!(svg.contains("<path"));
|
||||
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_selection_image_is_cropped() {
|
||||
let page = page_with_line();
|
||||
let image = selection_image(&page, &[7], None).unwrap().unwrap();
|
||||
// Linie 400 Einheiten (40 mm) lang: Bild etwas breiter als 40 mm bei 150 dpi
|
||||
let expected = 40.0 / 25.4 * SELECTION_PNG_DPI;
|
||||
assert!(
|
||||
(image.width as f32) > expected && (image.width as f32) < expected * 1.25,
|
||||
"{} px",
|
||||
image.width
|
||||
);
|
||||
assert!(image.height < image.width / 3);
|
||||
assert!(image.pixels.as_chunks::<4>().0.iter().any(|p| p[3] > 200));
|
||||
assert!(selection_image(&page, &[99], None).unwrap().is_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(
|
||||
|
||||
+143
-3
@@ -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(
|
||||
@@ -152,7 +164,9 @@ impl StrokeData {
|
||||
/// das unbekannte Feld ignorieren, Bilder als Rechteckstrich anzeigen und beim Speichern
|
||||
/// verlieren.
|
||||
/// - Version 5: importierte PDF-Seiten als Seitenhintergrund (`pdf_background`).
|
||||
pub const CURRENT_FORMAT_VERSION: u32 = 5;
|
||||
/// - Version 6: Seitentitel (`title` in Seite und Manifest).
|
||||
/// - Version 7: Textfelder (`text` am Strich).
|
||||
pub const CURRENT_FORMAT_VERSION: u32 = 7;
|
||||
|
||||
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
|
||||
pub fn legacy_format_version() -> u32 {
|
||||
@@ -169,6 +183,9 @@ pub struct PageData {
|
||||
/// Importierte PDF-Seite als Hintergrund
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pdf_background: Option<crate::document::PdfBackground>,
|
||||
/// Seitentitel (steht zusätzlich im Manifest)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
pub strokes: Vec<StrokeData>,
|
||||
}
|
||||
|
||||
@@ -179,6 +196,7 @@ impl From<&Page> for PageData {
|
||||
id: page.id,
|
||||
background: page.background,
|
||||
pdf_background: page.pdf_background.clone(),
|
||||
title: page.title.clone(),
|
||||
strokes: page.strokes.iter().map(StrokeData::from).collect(),
|
||||
}
|
||||
}
|
||||
@@ -189,6 +207,7 @@ impl PageData {
|
||||
pub fn to_page(&self) -> Page {
|
||||
let mut page = Page::new(self.id, self.background);
|
||||
page.pdf_background = self.pdf_background.clone();
|
||||
page.title = self.title.clone();
|
||||
for stroke_data in &self.strokes {
|
||||
page.strokes.push(stroke_data.to_stroke());
|
||||
}
|
||||
@@ -205,11 +224,18 @@ pub struct PageMeta {
|
||||
pub id: u64,
|
||||
#[serde(default)]
|
||||
pub background: Option<PageBackground>,
|
||||
/// Seitentitel (Lesezeichen), damit die Seitenübersicht ihn ohne Laden der Seite kennt
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
impl PageMeta {
|
||||
pub fn new(id: u64, background: Option<PageBackground>) -> Self {
|
||||
Self { id, background }
|
||||
Self {
|
||||
id,
|
||||
background,
|
||||
title: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +285,13 @@ impl NotebookManifest {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn title_for_page(&self, page_id: u64) -> Option<String> {
|
||||
self.pages
|
||||
.iter()
|
||||
.find(|p| p.id == page_id)
|
||||
.and_then(|p| p.title.clone())
|
||||
}
|
||||
|
||||
pub fn background_for_page(&self, page_id: u64) -> Option<PageBackground> {
|
||||
self.pages
|
||||
.iter()
|
||||
@@ -274,6 +307,7 @@ impl NotebookManifest {
|
||||
.map(|p| PageMeta {
|
||||
id: p.id,
|
||||
background: Some(p.background),
|
||||
title: p.title.clone(),
|
||||
})
|
||||
.collect();
|
||||
self.page_ids = self.pages.iter().map(|p| p.id).collect();
|
||||
@@ -721,6 +755,13 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
|
||||
pages.push(Page::new_unloaded(page_id, bg));
|
||||
}
|
||||
|
||||
// Titel aus dem Manifest (Platzhalter und Ersatzseiten kennen sie sonst nicht)
|
||||
for page in &mut pages {
|
||||
if page.title.is_none() {
|
||||
page.title = manifest.title_for_page(page.id);
|
||||
}
|
||||
}
|
||||
|
||||
let mut notebook = Notebook::from_pages(
|
||||
pages,
|
||||
manifest.current_page_index,
|
||||
@@ -758,7 +799,18 @@ pub fn ensure_page_loaded(
|
||||
let page_id = notebook.pages[page_idx].id;
|
||||
match load_page(notebook_dir, page_id) {
|
||||
Ok(data) => {
|
||||
notebook.pages[page_idx] = data.to_page();
|
||||
let title = notebook.pages[page_idx].title.take();
|
||||
let mut page = data.to_page();
|
||||
page.title = page.title.or(title);
|
||||
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);
|
||||
page.title = placeholder.title.take();
|
||||
*placeholder = page;
|
||||
Ok(())
|
||||
}
|
||||
Err(StorageError::CorruptedPage(id, err_msg)) => {
|
||||
@@ -1302,12 +1354,14 @@ pub fn load_all_pages(notebook_dir: &Path, notebook: &mut Notebook) -> Result<()
|
||||
));
|
||||
}
|
||||
if !page.is_loaded {
|
||||
let title = page.title.take();
|
||||
*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(e) => return Err(e),
|
||||
};
|
||||
page.title = page.title.take().or(title);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -2285,6 +2339,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();
|
||||
@@ -2418,6 +2492,39 @@ 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_create_notebook_from_pdf() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
@@ -2449,4 +2556,37 @@ mod tests {
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(temp_dir.path()).unwrap().count(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_titles_survive_restart_and_lazy_loading() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let (mut notebook, mut manifest, dir) =
|
||||
create_new_notebook(temp_dir.path(), "Mit Titeln").unwrap();
|
||||
notebook.add_page(PageBackground::Lined);
|
||||
notebook.add_page(PageBackground::Lined);
|
||||
let ids: Vec<u64> = notebook.pages.iter().map(|p| p.id).collect();
|
||||
assert!(notebook.set_page_title(ids[0], " Einleitung \n"));
|
||||
assert!(notebook.set_page_title(ids[2], "Kapitel 2 – Integrale"));
|
||||
assert!(!notebook.set_page_title(ids[2], "Kapitel 2 – Integrale"));
|
||||
save_notebook(&dir, ¬ebook, &mut manifest).unwrap();
|
||||
assert_eq!(manifest.pages[0].title.as_deref(), Some("Einleitung"));
|
||||
|
||||
// Neustart: Folgeseiten sind Platzhalter, kennen ihren Titel aber aus dem Manifest
|
||||
let (mut reloaded, _) = load_notebook(&dir).unwrap();
|
||||
assert!(!reloaded.pages[2].is_loaded);
|
||||
assert_eq!(
|
||||
reloaded.pages[2].title.as_deref(),
|
||||
Some("Kapitel 2 – Integrale")
|
||||
);
|
||||
ensure_page_loaded(&dir, &mut reloaded, 2).unwrap();
|
||||
assert_eq!(
|
||||
reloaded.pages[2].title.as_deref(),
|
||||
Some("Kapitel 2 – Integrale")
|
||||
);
|
||||
assert_eq!(reloaded.pages[1].title, None);
|
||||
|
||||
// Titel entfernen
|
||||
assert!(reloaded.set_page_title(ids[0], " "));
|
||||
assert_eq!(reloaded.pages[0].title, None);
|
||||
}
|
||||
}
|
||||
|
||||
+541
@@ -0,0 +1,541 @@
|
||||
//! 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, PAGE_WIDTH, 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
|
||||
/// Blattrand (mindestens 3 cm breit).
|
||||
pub fn new_at(
|
||||
page_id: u64,
|
||||
pos: glam::Vec2,
|
||||
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::*;
|
||||
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
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);
|
||||
}
|
||||
}
|
||||
+132
-1
@@ -3,13 +3,54 @@
|
||||
//! 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, PAGE_HEIGHT, PAGE_WIDTH, 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;
|
||||
|
||||
/// Abstand zwischen zwei Seiten in der durchgehenden Ansicht (8 mm).
|
||||
pub const PAGE_GAP: f32 = 8.0 * UNITS_PER_MM;
|
||||
/// Vertikaler Abstand gleicher Punkte zweier aufeinanderfolgender Seiten.
|
||||
pub const PAGE_PITCH: f32 = PAGE_HEIGHT + PAGE_GAP;
|
||||
/// Höchstens so viele Nachbarseiten werden gleichzeitig dargestellt.
|
||||
pub const MAX_NEIGHBOR_PAGES: usize = 12;
|
||||
|
||||
/// Durchgehende Ansicht: Die Seiten hängen untereinander, Koordinaten beziehen sich auf die
|
||||
/// aktuelle Seite. Liefert den Versatz (in Seiten) der Seite, auf der die Höhe `y` liegt,
|
||||
/// oder `None` im Zwischenraum zweier Seiten.
|
||||
pub fn page_offset_at(y: f32) -> Option<i64> {
|
||||
let k = (y / PAGE_PITCH).floor();
|
||||
(y - k * PAGE_PITCH <= PAGE_HEIGHT).then_some(k as i64)
|
||||
}
|
||||
|
||||
/// Versätze der Seiten, die zwischen den Höhen `top` und `bottom` (Koordinaten der aktuellen
|
||||
/// Seite) sichtbar sind, begrenzt auf die Seitenindizes `0..page_count` und ohne die aktuelle
|
||||
/// Seite selbst.
|
||||
pub fn visible_neighbor_offsets(
|
||||
top: f32,
|
||||
bottom: f32,
|
||||
current: usize,
|
||||
page_count: usize,
|
||||
) -> Vec<i64> {
|
||||
if !(top.is_finite() && bottom.is_finite()) || bottom < top {
|
||||
return Vec::new();
|
||||
}
|
||||
let first = (top / PAGE_PITCH).floor().max(-(current as f32)) as i64;
|
||||
let last = (bottom / PAGE_PITCH)
|
||||
.floor()
|
||||
.min((page_count as f32) - 1.0 - current as f32) as i64;
|
||||
let mut offsets: Vec<i64> = (first..=last).filter(|&k| k != 0).collect();
|
||||
// Sehr weit herausgezoomt: nur die nächstgelegenen Seiten
|
||||
if offsets.len() > MAX_NEIGHBOR_PAGES {
|
||||
offsets.sort_by_key(|k| k.unsigned_abs());
|
||||
offsets.truncate(MAX_NEIGHBOR_PAGES);
|
||||
offsets.sort_unstable();
|
||||
}
|
||||
offsets
|
||||
}
|
||||
|
||||
/// Verwaltet den Viewport-Kamerazustand für stufenlosen Zoom und Pan.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ViewportState {
|
||||
@@ -39,6 +80,35 @@ impl ViewportState {
|
||||
self.pan_offset = [0.0, 0.0];
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: Die Seite `k` Seiten weiter wird zur Bezugsseite, ohne dass sich
|
||||
/// der Bildausschnitt bewegt.
|
||||
pub fn shift_reference_page(&mut self, k: i64) {
|
||||
self.pan_offset[1] += k as f32 * PAGE_PITCH;
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: 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],
|
||||
current: usize,
|
||||
page_count: usize,
|
||||
page_viewport: &PageViewport,
|
||||
) -> bool {
|
||||
let c = self.screen_to_page_unclamped(center[0], center[1], page_viewport);
|
||||
let top = -(current as f32) * PAGE_PITCH;
|
||||
let bottom = page_count.saturating_sub(1 + current) as f32 * PAGE_PITCH + PAGE_HEIGHT;
|
||||
let clamped = Vec2::new(c.x.clamp(0.0, PAGE_WIDTH), c.y.clamp(top, bottom.max(top)));
|
||||
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,
|
||||
@@ -190,6 +260,67 @@ impl ViewportState {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_continuous_layout() {
|
||||
assert_eq!(page_offset_at(100.0), Some(0));
|
||||
assert_eq!(page_offset_at(PAGE_HEIGHT + PAGE_GAP / 2.0), None);
|
||||
assert_eq!(page_offset_at(PAGE_PITCH + 5.0), Some(1));
|
||||
assert_eq!(page_offset_at(-5.0), None);
|
||||
assert_eq!(page_offset_at(-PAGE_GAP - 5.0), Some(-1));
|
||||
|
||||
// Ende von Seite 2 und Anfang von Seite 3 sichtbar (aktuell: Seite 2 von 5)
|
||||
assert_eq!(
|
||||
visible_neighbor_offsets(PAGE_HEIGHT - 500.0, PAGE_PITCH + 500.0, 1, 5),
|
||||
vec![1]
|
||||
);
|
||||
// Ganz oben im Notizbuch: keine Seite vor der ersten
|
||||
assert!(visible_neighbor_offsets(-PAGE_PITCH * 3.0, 500.0, 0, 5).is_empty());
|
||||
// Hinter der letzten Seite: keine weiteren
|
||||
assert_eq!(
|
||||
visible_neighbor_offsets(-PAGE_PITCH, PAGE_PITCH * 4.0, 3, 5),
|
||||
vec![-1, 1]
|
||||
);
|
||||
// Weit herausgezoomt: auf die nächstgelegenen Seiten begrenzt
|
||||
let many = visible_neighbor_offsets(-PAGE_PITCH * 50.0, PAGE_PITCH * 50.0, 50, 200);
|
||||
assert_eq!(many.len(), MAX_NEIGHBOR_PAGES);
|
||||
assert!(many.iter().all(|k| k.unsigned_abs() <= 6));
|
||||
}
|
||||
|
||||
#[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, PAGE_PITCH + 200.0, &page_vp);
|
||||
vp.shift_reference_page(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_continuous_scroll_stays_on_the_notebook() {
|
||||
let page_vp = PageViewport::calculate(1280.0, 850.0, 24.0);
|
||||
let center = [640.0, 425.0];
|
||||
let mut vp = ViewportState::new();
|
||||
// Mitte der Seite: nichts zu korrigieren
|
||||
assert!(!vp.clamp_continuous_center(center, 1, 3, &page_vp));
|
||||
// Weit über die erste Seite hinaus nach oben gescrollt (aktuell: Seite 2 von 3)
|
||||
vp.pan_by_screen_delta(0.0, 5000.0, &page_vp);
|
||||
assert!(vp.clamp_continuous_center(center, 1, 3, &page_vp));
|
||||
let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp);
|
||||
assert!(
|
||||
(c.y + PAGE_PITCH).abs() < 0.1,
|
||||
"Oberkante der ersten Seite: {c:?}"
|
||||
);
|
||||
// Weit nach unten: Unterkante der letzten Seite
|
||||
vp.pan_by_screen_delta(-3000.0, -20000.0, &page_vp);
|
||||
assert!(vp.clamp_continuous_center(center, 1, 3, &page_vp));
|
||||
let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp);
|
||||
assert!((c.y - (PAGE_PITCH + PAGE_HEIGHT)).abs() < 0.1, "{c:?}");
|
||||
assert!((c.x - PAGE_WIDTH).abs() < 0.1, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_viewport_zoom_limits() {
|
||||
let mut vp = ViewportState::new();
|
||||
|
||||
Reference in New Issue
Block a user