diff --git a/src/background.rs b/src/background.rs index d8c6890..f6491f0 100644 --- a/src/background.rs +++ b/src/background.rs @@ -5,10 +5,63 @@ use serde::{Deserialize, Serialize}; use crate::canvas::InkVertex; use crate::document::PageBackground; +/// Farbe von Karos, Linien und Punkten (wird gespeichert und gilt auch für den Export). +/// `Light` ist die zurückhaltende Standardfarbe des jeweiligen Papiers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PatternColor { + #[default] + Light, + Medium, + Strong, + Blue, + Green, +} + +impl PatternColor { + pub const ALL: [PatternColor; 5] = [ + PatternColor::Light, + PatternColor::Medium, + PatternColor::Strong, + PatternColor::Blue, + PatternColor::Green, + ]; + + pub fn label(self) -> &'static str { + match self { + PatternColor::Light => "Hell", + PatternColor::Medium => "Mittel", + PatternColor::Strong => "Kräftig", + PatternColor::Blue => "Blau", + PatternColor::Green => "Grün", + } + } + + /// Linearer Farbwert des Musters auf dem Papier `paper` (für den Export: weißes Papier). + pub fn color(self, paper: PaperTheme) -> [f32; 4] { + let light = paper.pattern_color(); + let dark = paper.is_dark(); + let scaled = |f: f32| [light[0] * f, light[1] * f, light[2] * f, 1.0]; + match (self, dark) { + (PatternColor::Light, _) => light, + // Helles Papier: kräftiger = dunkler + (PatternColor::Medium, false) => scaled(0.62), + (PatternColor::Strong, false) => scaled(0.28), + // Dunkles Papier: kräftiger = heller + (PatternColor::Medium, true) => [0.16, 0.16, 0.17, 1.0], + (PatternColor::Strong, true) => [0.32, 0.32, 0.34, 1.0], + (PatternColor::Blue, false) => [0.35, 0.55, 0.95, 1.0], + (PatternColor::Blue, true) => [0.06, 0.14, 0.42, 1.0], + (PatternColor::Green, false) => [0.32, 0.72, 0.38, 1.0], + (PatternColor::Green, true) => [0.05, 0.22, 0.09, 1.0], + } + } +} + /// Linienstärke für Gitter- und Schreiblinien in Seiteneinheiten (1.5 = 0.15 mm). pub const LINE_THICKNESS: f32 = 1.5; -/// Punktgröße für das Punktraster in Seiteneinheiten (2.5 = 0.25 mm). -pub const DOT_SIZE: f32 = 2.5; +/// Punktgröße für das Punktraster in Seiteneinheiten (4 = 0,4 mm, wie im PDF-Export). +pub const DOT_SIZE: f32 = 4.0; /// Rasterabstand 5 mm (50 Einheiten). pub const GRID_5MM_SPACING: f32 = 50.0; @@ -229,6 +282,31 @@ mod tests { use crate::document::{A4_SIZE, PAGE_HEIGHT, PAGE_WIDTH}; + #[test] + fn test_pattern_colors_get_more_visible() { + // Luminanz (linear) als Abstand zum Papier + let lum = |c: [f32; 4]| 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; + for paper in PaperTheme::ALL { + let p = lum(paper.paper_color()); + let contrast = |c: PatternColor| (lum(c.color(paper)) - p).abs(); + assert_eq!(PatternColor::Light.color(paper), paper.pattern_color()); + assert!( + contrast(PatternColor::Medium) > contrast(PatternColor::Light), + "{paper:?}" + ); + assert!( + contrast(PatternColor::Strong) > contrast(PatternColor::Medium), + "{paper:?}" + ); + for c in [PatternColor::Blue, PatternColor::Green] { + assert!( + contrast(c) > contrast(PatternColor::Light), + "{c:?} auf {paper:?}" + ); + } + } + } + #[test] fn test_paper_quad_generation() { let quad = generate_paper_quad(PaperTheme::White.paper_color(), A4_SIZE); diff --git a/src/canvas.rs b/src/canvas.rs index abd74b6..4ef929f 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -1,7 +1,7 @@ //! GPU-Canvas-Renderpipeline mit wgpu und Low-Latency-Verwaltung. //! //! Features: -//! - Zero-Latency-Pufferung via `PresentMode::Mailbox` (Fallback: `Fifo`) und `desired_maximum_frame_latency: 1`. +//! - Geringe Bildverzögerung durch `PresentMode::Mailbox` (Fallback: `Fifo`) und `desired_maximum_frame_latency: 1`. //! - Strikte Pipeline-Trennung: DIN-A4-Hintergrund, permanente Striche und temporäre Live-Geometrie. //! - NDC-Transformation der 2D-Vektor-Meshes über Vertex-Shader-Uniforms. //! - Alpha-Blending (`wgpu::BlendState::ALPHA_BLENDING`). @@ -12,7 +12,7 @@ use std::sync::Arc; use winit::dpi::PhysicalSize; use winit::window::Window; -use crate::background::{generate_background_mesh, generate_paper_quad, PaperTheme}; +use crate::background::{generate_background_mesh, generate_paper_quad, PaperTheme, PatternColor}; use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample}; use crate::filter::OneEuroFilter; use crate::spline::{self, ControlPoint, SplineConfig}; @@ -122,18 +122,20 @@ struct NeighborFingerprint { background: PageBackground, size: [u32; 2], paper_theme: PaperTheme, + pattern: PatternColor, vertex_count: usize, stroke_count: usize, last_stroke_id: Option, } impl NeighborFingerprint { - fn of(page: &Page, paper_theme: PaperTheme) -> Self { + fn of(page: &Page, paper_theme: PaperTheme, pattern: PatternColor) -> Self { Self { page_id: page.id, background: page.background, size: page.size.to_array().map(f32::to_bits), paper_theme, + pattern, vertex_count: page.vertex_cache.len(), stroke_count: page.strokes.len(), last_stroke_id: page.strokes.last().map(|s| s.id), @@ -190,6 +192,8 @@ pub struct CanvasState { pub paper_theme: PaperTheme, /// Dunkle Tinte auf dunklem Papier aufhellen pub adapt_ink: bool, + /// Farbe von Karos, Linien und Punkten + pattern: PatternColor, /// Größe der aktuellen Seite und Rahmen, in den alle Seiten des Notizbuchs passen page_size: glam::Vec2, fit_box: glam::Vec2, @@ -299,7 +303,7 @@ impl CanvasState { ) .await?; - // 4. Zero-Latency-Pufferung (Mailbox, sonst Fifo) & Frame-Latency 1 + // 4. Geringe Bildverzögerung (Mailbox, sonst Fifo) & Frame-Latency 1 let surface_format = surface_caps .formats .iter() @@ -509,6 +513,7 @@ impl CanvasState { current_background, paper_theme, adapt_ink: false, + pattern: PatternColor::default(), page_size: crate::document::A4_SIZE, fit_box: crate::document::A4_SIZE, text_specs: HashMap::new(), @@ -785,6 +790,14 @@ impl CanvasState { self.update_uniforms(); } + /// Setzt die Farbe von Karos, Linien und Punkten. + pub fn set_pattern_color(&mut self, pattern: PatternColor) { + if self.pattern != pattern { + self.pattern = pattern; + self.upload_background_mesh(); + } + } + /// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer. pub fn set_background(&mut self, background: PageBackground) { if self.current_background == background { @@ -798,7 +811,7 @@ impl CanvasState { fn upload_background_mesh(&mut self) { let mesh = generate_background_mesh( self.current_background, - self.paper_theme.pattern_color(), + self.pattern.color(self.paper_theme), self.page_size, ); self.background_vertex_count = mesh.len() as u32; @@ -874,7 +887,7 @@ impl CanvasState { 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 fingerprint = NeighborFingerprint::of(page, self.paper_theme, self.pattern); let mut neighbor = match old.iter().position(|n| n.fingerprint == fingerprint) { Some(i) => old.swap_remove(i), None => self.upload_neighbor(page, fingerprint), @@ -924,7 +937,7 @@ impl CanvasState { let mut background = generate_paper_quad(self.paper_theme.paper_color(), page.size); background.extend(generate_background_mesh( page.background, - self.paper_theme.pattern_color(), + self.pattern.color(self.paper_theme), page.size, )); NeighborPage { diff --git a/src/export.rs b/src/export.rs index 88ddffc..29a6f8b 100644 --- a/src/export.rs +++ b/src/export.rs @@ -20,7 +20,7 @@ use printpdf::path::{PaintMode, WindingOrder}; use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb}; use crate::assets::AssetStore; -use crate::background::{GRID_5MM_SPACING, LINED_SPACING}; +use crate::background::{PatternColor, DOT_SIZE, GRID_5MM_SPACING, LINED_SPACING}; use crate::document::{ Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH, }; @@ -155,14 +155,16 @@ fn end_page_layer(layer: &printpdf::PdfLayerReference) { } /// Erzeugt ein PDF-Dokument aus einem Notizbuch und speichert es als Datei. -/// `assets`: Bilder des Notizbuchs (ohne werden Bilder als graue Fläche ausgegeben). +/// `assets`: Bilder des Notizbuchs (ohne werden Bilder als graue Fläche ausgegeben); +/// `pattern`: Farbe von Karos, Linien und Punkten. pub fn export_notebook_to_pdf>( notebook: &Notebook, path: P, strategy: ExportStrategy, assets: Option<&AssetStore>, + pattern: PatternColor, ) -> Result<(), Box> { - let bytes = export_notebook_to_bytes(notebook, strategy, assets)?; + let bytes = export_notebook_to_bytes(notebook, strategy, assets, pattern)?; let file = File::create(path)?; let mut writer = BufWriter::new(file); std::io::Write::write_all(&mut writer, &bytes)?; @@ -175,8 +177,9 @@ pub fn export_notebook_to_bytes( notebook: &Notebook, strategy: ExportStrategy, assets: Option<&AssetStore>, + pattern: PatternColor, ) -> Result, Box> { - let doc = generate_pdf_document(notebook, strategy, assets)?; + let doc = generate_pdf_document(notebook, strategy, assets, pattern)?; let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?; // Importierte PDF-Seiten als Vektorgrafik unterlegen @@ -202,7 +205,7 @@ pub fn export_notebook_to_bytes( ); raster.insert(underlay.target_page); } - let doc = generate_pdf_document_with(notebook, strategy, assets, &raster)?; + let doc = generate_pdf_document_with(notebook, strategy, assets, pattern, &raster)?; let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?; let remaining: Vec<_> = underlays .into_iter() @@ -289,11 +292,13 @@ pub fn generate_pdf_document( notebook: &Notebook, strategy: ExportStrategy, assets: Option<&AssetStore>, + pattern: PatternColor, ) -> Result> { generate_pdf_document_with( notebook, strategy, assets, + pattern, &std::collections::HashSet::new(), ) } @@ -304,6 +309,7 @@ fn generate_pdf_document_with( notebook: &Notebook, strategy: ExportStrategy, assets: Option<&AssetStore>, + pattern: PatternColor, raster_pdf_pages: &std::collections::HashSet, ) -> Result> { if notebook.pages.is_empty() { @@ -338,7 +344,7 @@ fn generate_pdf_document_with( doc.add_bookmark(title.clone(), page_idx); } let page_ref = doc.get_page(page_idx); - render_background_layer(&page_ref.get_layer(layer_bg), page); + render_background_layer(&page_ref.get_layer(layer_bg), page, pattern); let layer = |name: &str, render: &dyn Fn(&printpdf::PdfLayerReference)| { let layer = page_ref.add_layer(name); begin_page_layer(&layer, page); @@ -361,10 +367,16 @@ fn generate_pdf_document_with( } /// Rendert das Vektorraster auf Layer 1 ("Background"). -fn render_background_layer(layer: &printpdf::PdfLayerReference, page: &Page) { +fn render_background_layer( + layer: &printpdf::PdfLayerReference, + page: &Page, + pattern: PatternColor, +) { let background = page.background; let (Mm(page_width_mm), Mm(page_height_mm)) = pdf_page_mm(page); - let grid_color = Color::Rgb(Rgb::new(0.88, 0.88, 0.88, None)); + // Exportiert wird immer auf weißem Papier + let [r, g, b, _] = pattern.color(crate::background::PaperTheme::White); + let grid_color = Color::Rgb(Rgb::new(r, g, b, None)); layer.set_outline_color(grid_color.clone()); layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt) layer.set_fill_color(grid_color); @@ -425,7 +437,7 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, page: &Page) { PageBackground::DotGrid => { let spacing_mm = GRID_5MM_SPACING / 10.0; // 5.0 mm - let half_dot = 0.2; // 0.4 mm Kantenlänge + let half_dot = DOT_SIZE / 20.0; // Kantenlänge in mm wie am Bildschirm let mut y = spacing_mm; while y < page_height_mm { @@ -1259,8 +1271,13 @@ mod tests { let temp_dir = std::env::temp_dir(); let pdf_path = temp_dir.join("ink_notes_test_export.pdf"); - let export_res = - export_notebook_to_pdf(¬ebook, &pdf_path, ExportStrategy::FilledPolygon, None); + let export_res = export_notebook_to_pdf( + ¬ebook, + &pdf_path, + ExportStrategy::FilledPolygon, + None, + PatternColor::default(), + ); assert!( export_res.is_ok(), "PDF-Export zu Datei muss erfolgreich sein" @@ -1286,7 +1303,12 @@ mod tests { ); // 3. Export als Bytes testen (Strategie B: BezierCurve) - let bytes_res = export_notebook_to_bytes(¬ebook, ExportStrategy::BezierCurve, None); + let bytes_res = export_notebook_to_bytes( + ¬ebook, + ExportStrategy::BezierCurve, + None, + PatternColor::default(), + ); assert!(bytes_res.is_ok()); let bytes = bytes_res.unwrap(); assert!(bytes.len() > 1024); @@ -1409,7 +1431,12 @@ mod tests { ); page.add_stroke(octahedron_stroke); - let bytes_res = export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, None); + let bytes_res = export_notebook_to_bytes( + ¬ebook, + ExportStrategy::FilledPolygon, + None, + PatternColor::default(), + ); assert!(bytes_res.is_ok(), "Shape export must succeed"); let bytes = bytes_res.unwrap(); assert!(bytes.len() > 1024); @@ -1478,9 +1505,13 @@ mod tests { )); } - let bytes = - export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, Some(&assets)) - .expect("Export mit Bildern"); + let bytes = export_notebook_to_bytes( + ¬ebook, + ExportStrategy::FilledPolygon, + Some(&assets), + PatternColor::default(), + ) + .expect("Export mit Bildern"); let contains = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle); assert!( contains(b"/DCTDecode"), @@ -1504,7 +1535,13 @@ mod tests { assert_eq!(image_objects, 3); // Ohne Asset-Ordner: Export klappt trotzdem (graue Flächen) - assert!(export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, None).is_ok()); + assert!(export_notebook_to_bytes( + ¬ebook, + ExportStrategy::FilledPolygon, + None, + PatternColor::default() + ) + .is_ok()); } #[test] @@ -1516,9 +1553,13 @@ mod tests { crate::storage::load_all_pages(&dir, &mut notebook).unwrap(); let assets = AssetStore::new(&dir); - let bytes = - export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, Some(&assets)) - .unwrap(); + let bytes = export_notebook_to_bytes( + ¬ebook, + ExportStrategy::FilledPolygon, + Some(&assets), + PatternColor::default(), + ) + .unwrap(); let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap(); assert_eq!(doc.get_pages().len(), 2); for page_id in doc.get_pages().into_values() { @@ -1563,7 +1604,13 @@ mod tests { assert!(!red_at(&exported, 5, 5)); // Ohne Zugriff auf das PDF: Export klappt trotzdem (graue Ersatzfläche) - assert!(export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, None).is_ok()); + assert!(export_notebook_to_bytes( + ¬ebook, + ExportStrategy::FilledPolygon, + None, + PatternColor::default() + ) + .is_ok()); } #[test] @@ -1574,8 +1621,13 @@ mod tests { 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 bytes = export_notebook_to_bytes( + ¬ebook, + ExportStrategy::FilledPolygon, + None, + PatternColor::default(), + ) + .unwrap(); let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap(); let catalog = doc.catalog().unwrap(); assert!(catalog.has(b"Outlines"), "Lesezeichen fehlen"); diff --git a/src/main.rs b/src/main.rs index e6e8360..b228474 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ use winit::{ use glam::Vec2; use ink_notes::assets::{AssetStore, ImportedImage}; use ink_notes::autosave::{AutosaveController, AutosaveStatus}; +use ink_notes::background::PatternColor; use ink_notes::canvas::CanvasState; use ink_notes::document::{Notebook, PageBackground}; use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy}; @@ -85,6 +86,7 @@ fn start_pdf_export( notebook: &Notebook, storage_dir: &Path, title: &str, + pattern: PatternColor, result_tx: Sender>, window: Arc, ) { @@ -106,6 +108,7 @@ fn start_pdf_export( &export_path, ExportStrategy::FilledPolygon, Some(&assets), + pattern, ) .map_err(|err| err.to_string()) }); @@ -746,6 +749,7 @@ fn start_page_export( notebook: &Notebook, storage_dir: &Path, title: &str, + pattern: PatternColor, result_tx: Sender>, window: Arc, ) { @@ -762,11 +766,15 @@ fn start_page_export( 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::Png => { + page_png(&page, Some(&assets), ImageBackground::Paper, pattern) + } + ui::PageExportKind::PngTransparent => { + page_png(&page, Some(&assets), ImageBackground::Transparent, pattern) + } + ui::PageExportKind::Svg => { + page_svg(&page, Some(&assets), pattern).map(String::into_bytes) } - ui::PageExportKind::Svg => page_svg(&page, Some(&assets)).map(String::into_bytes), }; let result = bytes .map_err(|e| e.to_string()) @@ -1047,6 +1055,7 @@ fn main() -> Result<(), Box> { ui_state.sidebar_side = ui_settings.sidebar_side; ui_state.paper_theme = ui_settings.paper_theme; ui_state.adapt_ink_colors = ui_settings.adapt_ink_colors; + ui_state.pattern_color = ui_settings.pattern_color; ui_state.pressure_curve = ui_settings.pressure_curve; ui_state.continuous_scroll = ui_settings.continuous_scroll; ui_state.current_notebook_id = manifest.id.clone(); @@ -1084,6 +1093,7 @@ fn main() -> Result<(), Box> { // Initiale Seite und Werkzeugeinstellungen mit Canvas synchronisieren canvas.set_paper_theme(ui_state.paper_theme, ui_state.adapt_ink_colors); + canvas.set_pattern_color(ui_state.pattern_color); 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); @@ -1825,7 +1835,7 @@ fn main() -> Result<(), Box> { // PDF-Export: Strg + E Key::Character(c) if c.eq_ignore_ascii_case("e") && ctrl => { - start_pdf_export(¬ebook, &storage_dir, &manifest.title, export_tx.clone(), window.clone()); + start_pdf_export(¬ebook, &storage_dir, &manifest.title, ui_state.pattern_color, export_tx.clone(), window.clone()); ui_state.set_export_message("PDF-Export läuft…"); window.request_redraw(); } @@ -2782,7 +2792,7 @@ fn main() -> Result<(), Box> { window.request_redraw(); } UiAction::ExportPage(kind) => { - start_page_export(kind, ¬ebook, &storage_dir, &manifest.title, export_tx.clone(), window.clone()); + start_page_export(kind, ¬ebook, &storage_dir, &manifest.title, ui_state.pattern_color, export_tx.clone(), window.clone()); ui_state.set_export_message("Seite wird exportiert …".to_string()); window.request_redraw(); } @@ -2848,7 +2858,7 @@ fn main() -> Result<(), Box> { window.request_redraw(); } UiAction::ExportPdf => { - start_pdf_export(¬ebook, &storage_dir, &manifest.title, export_tx.clone(), window.clone()); + start_pdf_export(¬ebook, &storage_dir, &manifest.title, ui_state.pattern_color, export_tx.clone(), window.clone()); ui_state.set_export_message("PDF-Export läuft…"); window.request_redraw(); } @@ -3184,6 +3194,12 @@ fn main() -> Result<(), Box> { save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); window.request_redraw(); } + UiAction::SetPatternColor(pattern) => { + canvas.set_pattern_color(pattern); + ui_settings.pattern_color = pattern; + 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); diff --git a/src/page_export.rs b/src/page_export.rs index 7299b26..53946b1 100644 --- a/src/page_export.rs +++ b/src/page_export.rs @@ -12,6 +12,7 @@ use hayro::vello_cpu::color::palette::css::{TRANSPARENT, WHITE}; use hayro::{PixmapSettings, RenderCache, RenderSettings}; use crate::assets::{AssetStore, RgbaImage}; +use crate::background::PatternColor; use crate::document::{Aabb, Notebook, Page, PageBackground, A4_WIDTH_MM, PAGE_WIDTH}; use crate::export::{export_notebook_to_bytes, ExportStrategy}; @@ -30,9 +31,13 @@ pub enum ImageBackground { } /// Erzeugt ein einseitiges PDF aus `page`. -fn single_page_pdf(page: &Page, assets: Option<&AssetStore>) -> Result, Box> { +fn single_page_pdf( + page: &Page, + assets: Option<&AssetStore>, + pattern: PatternColor, +) -> Result, Box> { let notebook = Notebook::from_pages(vec![page.clone()], 0, 1, page.id + 1); - export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, assets) + export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, assets, pattern) } /// Seite ohne Raster und PDF-Hintergrund (für transparenten Export und Auswahlen). @@ -91,11 +96,14 @@ pub fn page_png( page: &Page, assets: Option<&AssetStore>, background: ImageBackground, + pattern: PatternColor, ) -> Result, Box> { let image = match background { - ImageBackground::Paper => rasterize(single_page_pdf(page, assets)?, PAGE_PNG_DPI, false)?, + ImageBackground::Paper => { + rasterize(single_page_pdf(page, assets, pattern)?, PAGE_PNG_DPI, false)? + } ImageBackground::Transparent => rasterize( - single_page_pdf(&content_only(page), assets)?, + single_page_pdf(&content_only(page), assets, pattern)?, PAGE_PNG_DPI, true, )?, @@ -104,9 +112,13 @@ pub fn page_png( } /// 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:?}"))?; +pub fn page_svg( + page: &Page, + assets: Option<&AssetStore>, + pattern: PatternColor, +) -> Result> { + let pdf = Pdf::new(single_page_pdf(page, assets, pattern)?) + .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, @@ -133,7 +145,11 @@ pub fn selection_image( 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)?; + let full = rasterize( + single_page_pdf(&selected, assets, PatternColor::default())?, + 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); @@ -190,12 +206,22 @@ mod tests { #[test] fn test_page_png_paper_and_transparent() { let page = page_with_line(); - let paper = decode(&page_png(&page, None, ImageBackground::Paper).unwrap()); + let paper = decode( + &page_png(&page, None, ImageBackground::Paper, PatternColor::default()).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()); + let clear = decode( + &page_png( + &page, + None, + ImageBackground::Transparent, + PatternColor::default(), + ) + .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; @@ -207,9 +233,22 @@ mod tests { assert!(clear.height() as f32 / clear.width() as f32 - PAGE_HEIGHT / PAGE_WIDTH < 0.01); } + #[test] + fn test_export_uses_pattern_color() { + // Kariertes Blatt: das kräftige Muster ist im Export dunkler als das helle + let page = Page::new(1, PageBackground::Grid5mm); + let darkest = |pattern| { + let png = decode(&page_png(&page, None, ImageBackground::Paper, pattern).unwrap()); + png.pixels().map(|p| p.0[1]).min().unwrap() + }; + let light = darkest(PatternColor::Light); + let strong = darkest(PatternColor::Strong); + assert!(strong + 60 < light, "hell {light}, kräftig {strong}"); + } + #[test] fn test_page_svg_contains_ink() { - let svg = page_svg(&page_with_line(), None).unwrap(); + let svg = page_svg(&page_with_line(), None, PatternColor::default()).unwrap(); assert!(svg.starts_with("")); @@ -253,7 +292,15 @@ mod tests { #[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 png = decode( + &page_png( + &page, + None, + ImageBackground::Transparent, + PatternColor::default(), + ) + .unwrap(), + ); let px_per_unit = png.width() as f32 / PAGE_WIDTH; let exported = ink_bounds(png.width(), png.as_raw()); @@ -278,12 +325,28 @@ mod tests { #[test] fn test_exported_text_follows_rotation() { let (mut page, _) = page_with_text(); - let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap()); + let png = decode( + &page_png( + &page, + None, + ImageBackground::Transparent, + PatternColor::default(), + ) + .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 png = decode( + &page_png( + &page, + None, + ImageBackground::Transparent, + PatternColor::default(), + ) + .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); @@ -302,7 +365,15 @@ mod tests { let mut page = page_with_line(); page.size = glam::Vec2::new(2970.0, 1671.0); page.strokes[0].translate(glam::Vec2::new(1900.0, 500.0)); // x 2400–2800, y 1500 - let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap()); + let png = decode( + &page_png( + &page, + None, + ImageBackground::Transparent, + PatternColor::default(), + ) + .unwrap(), + ); let ratio = png.width() as f32 / png.height() as f32; assert!((ratio - 2970.0 / 1671.0).abs() < 0.01, "{ratio}"); let px_per_unit = png.width() as f32 / 2970.0; @@ -312,7 +383,7 @@ mod tests { }; assert!(at(2600.0, 1500.0)[3] > 200, "Strich liegt unten rechts"); assert!(at(2600.0, 1300.0)[3] == 0); - let svg = page_svg(&page, None).unwrap(); + let svg = page_svg(&page, None, PatternColor::default()).unwrap(); assert!(svg.contains(" e response } +/// Knopf für eine Musterfarbe: Vorschau (kleines Karo auf der aktuellen Papierfarbe) und +/// Bezeichnung. +fn pattern_color_button( + ui: &mut egui::Ui, + pattern: PatternColor, + paper: PaperTheme, + selected: bool, +) -> egui::Response { + let response = ui.add_sized( + [82.0, 28.0], + egui::SelectableLabel::new(selected, format!(" {}", pattern.label())), + ); + let swatch = egui::Rect::from_center_size( + egui::pos2(response.rect.min.x + 15.0, response.rect.center().y), + egui::vec2(16.0, 16.0), + ); + let painter = ui.painter(); + painter.rect_filled( + swatch, + egui::Rounding::same(3.0), + linear_color32(paper.paper_color()), + ); + let line = egui::Stroke::new(1.5_f32, linear_color32(pattern.color(paper))); + for t in [0.33, 0.67] { + let x = swatch.min.x + swatch.width() * t; + let y = swatch.min.y + swatch.height() * t; + painter.line_segment( + [egui::pos2(x, swatch.min.y), egui::pos2(x, swatch.max.y)], + line, + ); + painter.line_segment( + [egui::pos2(swatch.min.x, y), egui::pos2(swatch.max.x, y)], + line, + ); + } + painter.rect_stroke( + swatch, + egui::Rounding::same(3.0), + egui::Stroke::new(1.0_f32, egui::Color32::from_gray(120)), + ); + response.on_hover_text("Gilt für alle Seiten, auch beim Export") +} + /// Schieberegler für die Druckkurve mit Vorschau der Kennlinie. Änderungen gelten sofort /// (`state.pressure_curve`); zurückgegeben wird die Kurve, sobald sie gespeichert werden soll /// (Regler losgelassen oder per Tastatur geändert). @@ -2664,6 +2712,25 @@ pub fn render_ui( }); } + section(ui, "KAROS, LINIEN UND PUNKTE"); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(6.0, 6.0); + for pattern in PatternColor::ALL { + if pattern_color_button( + ui, + pattern, + state.paper_theme, + state.pattern_color == pattern, + ) + .clicked() + && state.pattern_color != pattern + { + state.pattern_color = pattern; + actions.push(UiAction::SetPatternColor(pattern)); + } + } + }); + section(ui, "ANSICHT"); if ui .checkbox(&mut state.continuous_scroll, "Seiten durchgehend scrollen")