From 701fa03f338493a9b5729a69019a4e129db3a87c Mon Sep 17 00:00:00 2001 From: Harald Date: Wed, 7 Oct 2026 19:19:13 +0200 Subject: [PATCH] feat: Teilstrich-Radierer, Seiten-Export (PNG/SVG), Auswahl als Bild, Seitentitel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Radierer-Modus „✂ Teilstrich“: entfernt nur den überstrichenen Teil, der Rest bleibt in Stücken; ein Radierzug ist ein Undo-Schritt, Formen werden ganz radiert - ☰ → „Seite als PNG / PNG transparent / SVG“ nach ~/Documents (über den PDF-Export gerendert: Bilder und importierte PDF-Seiten inklusive) - Strg+C legt die Auswahl zusätzlich als Bild in die Systemzwischenablage (für Chat, Mail, Office); in ink-notes fügt Strg+V weiter die Objekte ein - Seitentitel in der Seitenübersicht, Ansicht „Inhalt“ als Inhaltsverzeichnis, Lesezeichen-Ecke an benannten Seiten, Titel im Fenstertitel und als PDF-Lesezeichen (Umlaute als UTF-16) - Dateiformat-Version 6 (Seitentitel) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 22 +++ Cargo.toml | 1 + src/document.rs | 366 ++++++++++++++++++++++++++++++++++++++++++++- src/export.rs | 111 +++++++++++++- src/history.rs | 53 +++++++ src/lib.rs | 1 + src/main.rs | 159 ++++++++++++++++---- src/page_export.rs | 233 +++++++++++++++++++++++++++++ src/storage.rs | 72 ++++++++- src/ui.rs | 208 ++++++++++++++++++++++---- 10 files changed, 1160 insertions(+), 66 deletions(-) create mode 100644 src/page_export.rs diff --git a/Cargo.lock b/Cargo.lock index 2742bfc..364bd38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" @@ -1388,6 +1403,7 @@ dependencies = [ "fs2", "glam", "hayro", + "hayro-svg", "image", "minisign-verify", "naga", @@ -3827,6 +3843,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" diff --git a/Cargo.toml b/Cargo.toml index 039f765..ee07264 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ arboard = { version = "3", default-features = false, features = ["image-data"] } minisign-verify = "0.3.0" fs2 = "0.4" hayro = "0.8" +hayro-svg = "0.8" [profile.dev] opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus diff --git a/src/document.rs b/src/document.rs index 508d348..b6bbfc4 100644 --- a/src/document.rs +++ b/src/document.rs @@ -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 { + let cleaned: String = title + .split_whitespace() + .collect::>() + .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>> { + 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 { + 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 = 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| -> 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 { @@ -532,6 +654,8 @@ pub struct Page { pub background: PageBackground, /// Importierte PDF-Seite als Hintergrund (unter Bildern und Tinte) pub pdf_background: Option, + /// Seitentitel (Lesezeichen); auch im Manifest, damit er ohne Laden der Seite bekannt ist + pub title: Option, pub vertex_cache: Vec, pub stroke_ranges: Vec>, pub is_dirty: bool, @@ -547,6 +671,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 +688,7 @@ impl Page { strokes: Vec::new(), background, pdf_background: None, + title: None, vertex_cache: Vec::new(), stroke_ranges: Vec::new(), is_dirty: false, @@ -683,6 +809,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 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_image() || !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 { @@ -883,6 +1069,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; @@ -929,6 +1134,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 +1604,151 @@ mod tests { assert!((rotated[0] - glam::Vec2::new(250.0, 50.0)).length() < 1e-3); } + fn line_samples(points: &[(f32, f32)]) -> Vec { + 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 = 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_page_isolation() { let mut nb = Notebook::new(); diff --git a/src/export.rs b/src/export.rs index 8439e6e..78ea9b5 100644 --- a/src/export.rs +++ b/src/export.rs @@ -79,6 +79,20 @@ pub fn resolve_pdf_export_path_in_dir(dir: &Path, title: &str) -> PathBuf { } } +/// Kollisionsfreier Pfad `~/Documents/.` (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 +146,7 @@ pub fn export_notebook_to_bytes( assets: Option<&AssetStore>, ) -> Result, Box> { 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 +172,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 +184,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, notebook: &Notebook) -> Result, Box> { + 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> { + 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 { notebook @@ -235,6 +292,11 @@ fn generate_pdf_document_with( let (doc, page1_idx, layer1_bg) = PdfDocument::new(title, Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background"); + // Seitentitel werden zu Lesezeichen (Inhaltsverzeichnis im PDF-Betrachter) + if let Some(title) = ¬ebook.pages[0].title { + doc.add_bookmark(title.clone(), page1_idx); + } + // Seite 1 befüllen let page1_ref = doc.get_page(page1_idx); let layer1_bg_ref = page1_ref.get_layer(layer1_bg); @@ -252,6 +314,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); @@ -1398,6 +1463,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 = 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 = 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 = 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); diff --git a/src/history.rs b/src/history.rs index 1db5e73..eddca05 100644 --- a/src/history.rs +++ b/src/history.rs @@ -18,6 +18,13 @@ pub enum HistoryAction { before: Vec, after: Vec, }, + /// Teilradierer: Striche wurden durch ihre übrig gebliebenen Stücke ersetzt. + SplitStrokes { + removed: Vec, + added: Vec, + /// 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, added: Vec) { + 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 diff --git a/src/lib.rs b/src/lib.rs index 0ee3fe7..d795f45 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ 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; diff --git a/src/main.rs b/src/main.rs index c3fe9a2..46b2a0a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,10 +62,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 @@ -203,6 +209,44 @@ impl ClipboardBridge { self.image_hash_at_copy = self.system_image().map(|(_, hash)| hash); 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, @@ -265,6 +309,51 @@ fn texture_for( } } +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>, + window: Arc, +) { + 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( @@ -650,8 +739,8 @@ fn main() -> Result<(), Box> { 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(); } @@ -762,13 +851,10 @@ fn main() -> Result<(), Box> { if is_erasing { 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, @@ -829,13 +915,10 @@ fn main() -> Result<(), Box> { 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, @@ -1146,8 +1229,9 @@ fn main() -> Result<(), Box> { 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(); } } @@ -1466,10 +1550,8 @@ fn main() -> Result<(), Box> { 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(); @@ -1734,10 +1816,8 @@ fn main() -> Result<(), Box> { } } else if is_erasing { 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(); @@ -1873,10 +1953,8 @@ fn main() -> Result<(), Box> { 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(); @@ -2014,10 +2092,8 @@ fn main() -> Result<(), Box> { } } else if is_erasing { 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(); @@ -2247,8 +2323,9 @@ fn main() -> Result<(), Box> { 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(); } } @@ -2342,6 +2419,7 @@ fn main() -> Result<(), Box> { .map(|p| storage::PageMeta { id: p.id, background: Some(p.background), + title: p.title.clone(), }) .collect(); manifest.sync_from_notebook(¬ebook); @@ -2350,6 +2428,20 @@ fn main() -> Result<(), Box> { 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 +2454,7 @@ fn main() -> Result<(), Box> { .map(|p| storage::PageMeta { id: p.id, background: Some(p.background), + title: p.title.clone(), }) .collect(); canvas.sync_page(notebook.current_page_mut()); diff --git a/src/page_export.rs b/src/page_export.rs new file mode 100644 index 0000000..9cd96ea --- /dev/null +++ b/src/page_export.rs @@ -0,0 +1,233 @@ +//! 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, Box> { + 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, dpi: f32, transparent: bool) -> Result> { + 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, Box> { + 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, Box> { + 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> { + 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, Box> { + 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 = (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("")); + } + + #[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()); + } +} diff --git a/src/storage.rs b/src/storage.rs index c1850e4..0f761b7 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -152,7 +152,8 @@ 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). +pub const CURRENT_FORMAT_VERSION: u32 = 6; /// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0). pub fn legacy_format_version() -> u32 { @@ -169,6 +170,9 @@ pub struct PageData { /// Importierte PDF-Seite als Hintergrund #[serde(default, skip_serializing_if = "Option::is_none")] pub pdf_background: Option, + /// Seitentitel (steht zusätzlich im Manifest) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, pub strokes: Vec, } @@ -179,6 +183,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 +194,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 +211,18 @@ pub struct PageMeta { pub id: u64, #[serde(default)] pub background: Option, + /// Seitentitel (Lesezeichen), damit die Seitenübersicht ihn ohne Laden der Seite kennt + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, } impl PageMeta { pub fn new(id: u64, background: Option) -> Self { - Self { id, background } + Self { + id, + background, + title: None, + } } } @@ -259,6 +272,13 @@ impl NotebookManifest { } } + pub fn title_for_page(&self, page_id: u64) -> Option { + 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 { self.pages .iter() @@ -274,6 +294,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 +742,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 +786,10 @@ 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(()) } Err(StorageError::CorruptedPage(id, err_msg)) => { @@ -1302,12 +1333,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(()) @@ -2449,4 +2482,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 = 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); + } } diff --git a/src/ui.rs b/src/ui.rs index 9e3587b..640a832 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -97,6 +97,14 @@ pub const PEN_WIDTH_PRESETS: [(f32, &str); 4] = [ pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] = [(4.0, "4.0 mm"), (6.0, "6.0 mm"), (8.0, "8.0 mm")]; +/// Format für den Export der aktuellen Seite. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PageExportKind { + Png, + PngTransparent, + Svg, +} + /// Aktionen, die von der UI an die Anwendungslogik signalisiert werden. #[derive(Debug, Clone, PartialEq)] pub enum UiAction { @@ -156,6 +164,13 @@ pub enum UiAction { InsertImageFile(std::path::PathBuf), /// PDF als neues Notizbuch importieren ImportPdf(std::path::PathBuf), + /// Aktuelle Seite als Bild oder SVG exportieren + ExportPage(PageExportKind), + /// Titel einer Seite setzen (leer = entfernen) + SetPageTitle { + page_id: u64, + title: String, + }, // Beenden-Sicherheit bei Fehlschlag RetryShutdown, ExportRecoveryAndExit, @@ -219,6 +234,11 @@ pub struct UiState { pub last_raw_pressure: Option, /// Geöffnete Dateiauswahl („Bild einfügen“ oder „PDF importieren“) pub file_picker: Option, + /// Eingabefeld „Titel dieser Seite“ in der Seitenübersicht und die Seite, zu der es gehört + pub page_title_input: String, + pub page_title_for: Option, + /// Seitenübersicht als Liste mit Titeln (Inhaltsverzeichnis) statt Kacheln + pub page_picker_list: bool, /// Aktionen, die außerhalb der Oberfläche ausgelöst wurden (z. B. ins Fenster gezogene /// PDF-Datei); `render_ui` gibt sie beim nächsten Bild mit aus pub queued_actions: Vec, @@ -266,6 +286,9 @@ impl Default for UiState { last_raw_pressure: None, file_picker: None, queued_actions: Vec::new(), + page_title_input: String::new(), + page_title_for: None, + page_picker_list: false, last_picker_dir: None, } } @@ -875,6 +898,19 @@ fn page_thumbnail( egui::Color32::from_gray(140), ); } + // Lesezeichen-Ecke für benannte Seiten + if page.title.is_some() { + let corner = thumb.right_top(); + painter.add(egui::Shape::convex_polygon( + vec![ + corner + egui::vec2(-14.0, 0.0), + corner, + corner + egui::vec2(0.0, 14.0), + ], + egui::Color32::from_rgb(90, 150, 255), + egui::Stroke::NONE, + )); + } let border = if is_current { egui::Stroke::new(2.5_f32, egui::Color32::from_rgb(90, 150, 255)) } else if response.hovered() { @@ -895,7 +931,43 @@ fn page_thumbnail( }, ); } - response.on_hover_text(format!("Seite {number}")) + match &page.title { + Some(title) => response.on_hover_text(format!("Seite {number} – {title}")), + None => response.on_hover_text(format!("Seite {number}")), + } +} + +/// Inhaltsverzeichnis der Seitenübersicht: alle Seiten mit Nummer und Titel, benannte +/// Seiten hervorgehoben. Gibt die angetippte Seite zurück. +fn page_title_list(ui: &mut egui::Ui, notebook: &Notebook) -> Option { + let mut chosen = None; + if notebook.pages.iter().all(|p| p.title.is_none()) { + ui.label( + egui::RichText::new( + "Noch keine Seite hat einen Titel. Gib oben einen Titel für die aktuelle Seite ein.", + ) + .color(egui::Color32::from_gray(150)), + ); + ui.add_space(4.0); + } + for (idx, page) in notebook.pages.iter().enumerate() { + let is_current = idx == notebook.current_page_index; + let text = match &page.title { + Some(title) => egui::RichText::new(format!("{:>3} {title}", idx + 1)) + .strong() + .color(egui::Color32::WHITE), + None => egui::RichText::new(format!("{:>3} –", idx + 1)) + .color(egui::Color32::from_gray(130)), + }; + let response = ui.add_sized( + [ui.available_width(), 32.0], + egui::SelectableLabel::new(is_current, text), + ); + if response.clicked() { + chosen = Some(idx); + } + } + chosen } /// Auswahlknopf für eine Papierfarbe: Farbmuster und Name. @@ -1420,7 +1492,7 @@ pub fn render_ui( ( ActiveTool::Eraser, SidebarIcon::Eraser, - "Radierer (E oder rechte Maustaste) – darunter: alles oder nur Textmarker", + "Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich oder nur Textmarker", ), ( ActiveTool::Select, @@ -1492,6 +1564,12 @@ pub fn render_ui( None, "Alle Striche radieren", ), + ( + EraserMode::Partial, + SidebarIcon::Glyph("✂"), + None, + "Teilstrich radieren – nur den überstrichenen Teil entfernen (Formen ganz)", + ), ( EraserMode::HighlighterOnly, SidebarIcon::Glyph("🖊"), @@ -2004,6 +2082,19 @@ pub fn render_ui( actions.push(UiAction::ExportPdf); state.is_more_menu_open = false; } + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Seite als").color(egui::Color32::from_gray(190))); + for (label, kind, hint) in [ + ("PNG", PageExportKind::Png, "Bild mit Papier und Raster (200 dpi)"), + ("PNG transp.", PageExportKind::PngTransparent, "Bild mit transparentem Hintergrund, nur Inhalt"), + ("SVG", PageExportKind::Svg, "Vektorgrafik, z. B. für Inkscape oder LaTeX"), + ] { + if ui.button(label).on_hover_text(hint).clicked() { + actions.push(UiAction::ExportPage(kind)); + state.is_more_menu_open = false; + } + } + }); if ui .add_sized([ui.available_width(), 30.0], egui::Button::new("➕ Neue Seite anhängen")) .clicked() @@ -2217,36 +2308,99 @@ pub fn render_ui( if dialog_header(ui, &title, true) { state.show_page_jump_popup = false; } + // Titel der aktuellen Seite + let current = notebook.current_page(); + if state.page_title_for != Some(current.id) { + state.page_title_for = Some(current.id); + state.page_title_input = current.title.clone().unwrap_or_default(); + } + ui.label( + egui::RichText::new(format!( + "TITEL VON SEITE {}", + notebook.current_page_index + 1 + )) + .size(10.5) + .strong() + .color(egui::Color32::from_gray(160)), + ); + let field = ui.add( + egui::TextEdit::singleline(&mut state.page_title_input) + .hint_text("z. B. Kapitel 2 – Integrale") + .margin(egui::vec2(8.0, 8.0)) + .desired_width(f32::INFINITY), + ); + // Bei jeder Änderung übernehmen: schließt sich die Übersicht, geht nichts verloren + if field.changed() { + actions.push(UiAction::SetPageTitle { + page_id: current.id, + title: state.page_title_input.clone(), + }); + } + ui.add_space(2.0); + ui.horizontal(|ui| { + for (list, label) in [(false, "🖼 Kacheln"), (true, "📑 Inhalt")] + { + if ui + .add_sized( + [140.0, 30.0], + egui::SelectableLabel::new( + state.page_picker_list == list, + label, + ), + ) + .clicked() + { + state.page_picker_list = list; + } + } + }); + ui.add_space(2.0); // Noch nicht geladene Seiten für die Vorschau nachladen lassen - if notebook.pages.iter().any(|p| !p.is_loaded) { + if !state.page_picker_list + && notebook.pages.iter().any(|p| !p.is_loaded) + { actions.push(UiAction::LoadAllPages); } - egui::ScrollArea::vertical() - .max_height(420.0) - .show(ui, |ui| { - egui::Grid::new("page_picker_grid") - .spacing([8.0, 8.0]) - .show(ui, |ui| { - for (idx, page) in notebook.pages.iter().enumerate() { - let is_current = idx == notebook.current_page_index; - if page_thumbnail( - ui, - state, - page, - idx + 1, - is_current, - ) - .clicked() + if state.page_picker_list { + egui::ScrollArea::vertical() + .max_height(420.0) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + if let Some(idx) = page_title_list(ui, notebook) { + actions.push(UiAction::GoToPage(idx)); + state.show_page_jump_popup = false; + } + }); + } else { + egui::ScrollArea::vertical() + .max_height(420.0) + .show(ui, |ui| { + egui::Grid::new("page_picker_grid") + .spacing([8.0, 8.0]) + .show(ui, |ui| { + for (idx, page) in notebook.pages.iter().enumerate() { - actions.push(UiAction::GoToPage(idx)); - state.show_page_jump_popup = false; + let is_current = + idx == notebook.current_page_index; + if page_thumbnail( + ui, + state, + page, + idx + 1, + is_current, + ) + .clicked() + { + actions.push(UiAction::GoToPage(idx)); + state.show_page_jump_popup = false; + } + if (idx + 1) % PAGE_PICKER_COLUMNS == 0 { + ui.end_row(); + } } - if (idx + 1) % PAGE_PICKER_COLUMNS == 0 { - ui.end_row(); - } - } - }); - }); + }); + }); + } ui.add_space(4.0); if ui .add_sized(