Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91a24b9f82 | ||
|
|
64e13e1155 | ||
|
|
8a0477740e | ||
|
|
66679e51ae | ||
|
|
9a39276da4 | ||
|
|
b5c1da07fa | ||
|
|
c52e958c7f | ||
|
|
3e2b627d6a | ||
|
|
616fc57666 | ||
|
|
d150fd1883 | ||
|
|
f9ede537ed | ||
|
|
9571e2121c | ||
|
|
4a7627e461 |
Generated
+689
-19
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -1,12 +1,12 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.3.5"
|
||||
version = "0.6.1"
|
||||
edition = "2021"
|
||||
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
rust-version = "1.75"
|
||||
rust-version = "1.92"
|
||||
|
||||
[dependencies]
|
||||
# Fenster- & Event-Handling (Wayland/X11, Stylus)
|
||||
@@ -39,8 +39,13 @@ sha2 = "0.10"
|
||||
ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] }
|
||||
url = "2.5"
|
||||
octotablet = "0.1.0"
|
||||
|
||||
# Bilder einfügen: Dekodieren (PNG/JPEG/GIF/WebP/BMP) und Systemzwischenablage
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] }
|
||||
arboard = { version = "3", default-features = false, features = ["image-data"] }
|
||||
minisign-verify = "0.3.0"
|
||||
fs2 = "0.4"
|
||||
hayro = "0.8"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
||||
@@ -53,3 +58,5 @@ strip = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27.0"
|
||||
# Prüft den WGSL-Shader in den Tests (dieselbe Version wie in wgpu 0.19)
|
||||
naga = { version = "0.19", features = ["wgsl-in"] }
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **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)
|
||||
- **Nur Textmarker radieren**: Markierungen korrigieren, ohne die Handschrift darunter zu löschen – auch mit Stiftrückseite und Seitentaste
|
||||
- **Einstellbare Druckkurve**: Kennlinie weich bis hart und Mindestdruck mit Live-Vorschau, passend für jeden Digitizer
|
||||
- **Neue Formen**: Ellipsen sowie in einem Zug gezeichnete Pfeile und Doppelpfeile werden erkannt (Dateiformat-Version 3: ältere Versionen öffnen solche Notizbücher nicht)
|
||||
- **Stabiler Start**: Kein Absturz mehr, wenn die Grafikkarte nicht nutzbar ist (z. B. fehlende Rechte auf /dev/dri); Software-Renderer als Ausweichlösung und verständliche Fehlermeldung
|
||||
- **WSL (WSLg)**: Läuft unter WSL automatisch über X11 statt Wayland, da WSLg die Wayland-Verbindung abbricht; übersteuerbar mit `INK_NOTES_DISPLAY=x11|wayland`
|
||||
- **Wichtige Fehlerbehebung**: Nach einem Neustart auf einer späteren Seite konnte Weiterschreiben den bisherigen Inhalt dieser Seite überschreiben (betrifft v0.2.3 – v0.3.2)
|
||||
- **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert
|
||||
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl mit Vorschaubildern, größere Bedienelemente in allen Popovern
|
||||
@@ -11,7 +18,7 @@ Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18
|
||||
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung
|
||||
- **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus
|
||||
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
|
||||
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Dreiecke, Rauten, Vielecke & Koordinatensysteme (Draw-and-Hold); Rechtecke werden zuverlässig erkannt, Freiformen bleiben unverändert
|
||||
- **Geometrie- und Formerkennung**: Geraden, Pfeile & Doppelpfeile, Rechtecke, Kreise, Ellipsen, Dreiecke, Rauten, Vielecke & Koordinatensysteme mit spitzen Pfeilspitzen (Draw-and-Hold); Freiformen bleiben unverändert
|
||||
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave, Papierkorb und fortlaufenden Backups
|
||||
- **Datensicherheit**: Versioniertes Dateiformat mit Migration, Quarantäne beschädigter Seiten, Schutz vor Doppelöffnung und Crash-getestetem Speichern
|
||||
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases – mit Pflicht-Signaturprüfung (Minisign) und durchgängigem HTTPS
|
||||
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
//! Eingebettete Bilder eines Notizbuchs (`<Notizbuch>/assets/`).
|
||||
//!
|
||||
//! Jedes Bild wird einmal unter dem SHA-256 seines Inhalts gespeichert (`<hash>.png` bzw.
|
||||
//! `<hash>.jpg`). Seiten verweisen nur auf diesen Namen; dasselbe Bild mehrfach einzufügen
|
||||
//! kostet keinen zusätzlichen Platz. Bilddateien werden nie gelöscht, damit Undo und
|
||||
//! Kopien auf anderen Seiten immer funktionieren.
|
||||
//!
|
||||
//! JPEG wird unverändert übernommen (verlustfrei, auch im PDF-Export); alle anderen Formate
|
||||
//! werden als PNG gespeichert.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::storage;
|
||||
|
||||
/// Name des Unterordners mit den Bilddateien.
|
||||
pub const ASSETS_DIR: &str = "assets";
|
||||
/// Größte Kantenlänge eines eingefügten Bildes in Pixeln; größere Bilder werden verkleinert
|
||||
/// (8000 px ≈ 300 dpi über die doppelte A4-Breite – mehr ist auf einer Seite nie sichtbar).
|
||||
pub const MAX_IMPORT_DIMENSION: u32 = 8000;
|
||||
/// Größte Kantenlänge der Bildschirmdarstellung (GPU-Textur).
|
||||
pub const MAX_DISPLAY_DIMENSION: u32 = 2048;
|
||||
/// Größte Dateigröße beim Einfügen.
|
||||
pub const MAX_IMPORT_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// Fehler beim Einfügen oder Laden eines Bildes.
|
||||
#[derive(Debug)]
|
||||
pub enum AssetError {
|
||||
/// Format nicht unterstützt oder Datei beschädigt
|
||||
Decode(String),
|
||||
/// Datei zu groß
|
||||
TooLarge(usize),
|
||||
/// Ungültiger Asset-Name (z. B. manipulierte Seitendatei)
|
||||
InvalidName(String),
|
||||
Io(std::io::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AssetError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AssetError::Decode(msg) => write!(f, "Bild kann nicht gelesen werden: {msg}"),
|
||||
AssetError::TooLarge(bytes) => write!(
|
||||
f,
|
||||
"Bild ist zu groß ({} MB, höchstens {} MB)",
|
||||
bytes / (1024 * 1024),
|
||||
MAX_IMPORT_BYTES / (1024 * 1024)
|
||||
),
|
||||
AssetError::InvalidName(name) => write!(f, "Ungültiger Bildverweis '{name}'"),
|
||||
AssetError::Io(e) => write!(f, "Ein-/Ausgabefehler: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AssetError {}
|
||||
|
||||
impl From<std::io::Error> for AssetError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
AssetError::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ergebnis eines erfolgreichen Imports.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImportedImage {
|
||||
/// Dateiname im Asset-Ordner, z. B. `3f…a9.png`
|
||||
pub asset: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
/// Dekodiertes Bild in 8-Bit-RGBA (sRGB).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RgbaImage {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub pixels: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Prüft einen Asset-Namen: genau 64 Hex-Zeichen und `.png`, `.jpg` oder `.pdf`. Verhindert, dass
|
||||
/// eine manipulierte Seitendatei auf Dateien außerhalb des Asset-Ordners verweist.
|
||||
pub fn is_valid_asset_name(name: &str) -> bool {
|
||||
let Some((hash, ext)) = name.split_once('.') else {
|
||||
return false;
|
||||
};
|
||||
hash.len() == 64
|
||||
&& hash
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
&& matches!(ext, "png" | "jpg" | "pdf")
|
||||
}
|
||||
|
||||
/// Bilddateien eines Notizbuchs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AssetStore {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl AssetStore {
|
||||
/// Asset-Ordner des Notizbuchs in `notebook_dir`.
|
||||
pub fn new(notebook_dir: &Path) -> Self {
|
||||
Self {
|
||||
dir: notebook_dir.join(ASSETS_DIR),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// Pfad einer Bilddatei (nur für gültige Namen).
|
||||
pub fn path_of(&self, asset: &str) -> Result<PathBuf, AssetError> {
|
||||
if is_valid_asset_name(asset) {
|
||||
Ok(self.dir.join(asset))
|
||||
} else {
|
||||
Err(AssetError::InvalidName(asset.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Übernimmt eine Bilddatei (Inhalt einer PNG-, JPEG-, GIF-, WebP- oder BMP-Datei).
|
||||
pub fn import_bytes(&self, bytes: &[u8]) -> Result<ImportedImage, AssetError> {
|
||||
if bytes.len() > MAX_IMPORT_BYTES {
|
||||
return Err(AssetError::TooLarge(bytes.len()));
|
||||
}
|
||||
let format = image::guess_format(bytes).map_err(|e| AssetError::Decode(e.to_string()))?;
|
||||
let decoded = image::load_from_memory_with_format(bytes, format)
|
||||
.map_err(|e| AssetError::Decode(e.to_string()))?;
|
||||
|
||||
let fits =
|
||||
decoded.width() <= MAX_IMPORT_DIMENSION && decoded.height() <= MAX_IMPORT_DIMENSION;
|
||||
if format == image::ImageFormat::Jpeg && fits {
|
||||
// JPEG unverändert übernehmen (kein erneuter Qualitätsverlust)
|
||||
return self.store(bytes, "jpg", decoded.width(), decoded.height());
|
||||
}
|
||||
let decoded = if fits {
|
||||
decoded
|
||||
} else {
|
||||
decoded.resize(
|
||||
MAX_IMPORT_DIMENSION,
|
||||
MAX_IMPORT_DIMENSION,
|
||||
image::imageops::FilterType::Triangle,
|
||||
)
|
||||
};
|
||||
let png = encode_png(&decoded)?;
|
||||
self.store(&png, "png", decoded.width(), decoded.height())
|
||||
}
|
||||
|
||||
/// Übernimmt eine PDF-Datei unverändert (der Inhalt muss vorher geprüft sein).
|
||||
pub fn import_pdf(&self, bytes: &[u8]) -> Result<String, AssetError> {
|
||||
let hash = Sha256::digest(bytes);
|
||||
let asset = format!("{}.pdf", hex(&hash));
|
||||
let path = self.path_of(&asset)?;
|
||||
if !path.exists() {
|
||||
storage::atomic_write_file(&path, bytes)?;
|
||||
}
|
||||
Ok(asset)
|
||||
}
|
||||
|
||||
/// Übernimmt rohe RGBA-Pixel (z. B. aus der Zwischenablage) als PNG.
|
||||
pub fn import_rgba(
|
||||
&self,
|
||||
width: u32,
|
||||
height: u32,
|
||||
pixels: Vec<u8>,
|
||||
) -> Result<ImportedImage, AssetError> {
|
||||
let buffer = image::RgbaImage::from_raw(width, height, pixels)
|
||||
.ok_or_else(|| AssetError::Decode("Pixeldaten passen nicht zur Bildgröße".into()))?;
|
||||
let mut decoded = image::DynamicImage::ImageRgba8(buffer);
|
||||
if width > MAX_IMPORT_DIMENSION || height > MAX_IMPORT_DIMENSION {
|
||||
decoded = decoded.resize(
|
||||
MAX_IMPORT_DIMENSION,
|
||||
MAX_IMPORT_DIMENSION,
|
||||
image::imageops::FilterType::Triangle,
|
||||
);
|
||||
}
|
||||
let png = encode_png(&decoded)?;
|
||||
self.store(&png, "png", decoded.width(), decoded.height())
|
||||
}
|
||||
|
||||
/// Kopiert ein Bild aus dem Asset-Ordner eines anderen Notizbuchs (Einfügen über
|
||||
/// Notizbuchgrenzen hinweg). Ist es schon vorhanden, passiert nichts.
|
||||
pub fn copy_from(&self, other: &AssetStore, asset: &str) -> Result<(), AssetError> {
|
||||
let target = self.path_of(asset)?;
|
||||
if target.exists() || other.dir == self.dir {
|
||||
return Ok(());
|
||||
}
|
||||
let bytes = std::fs::read(other.path_of(asset)?)?;
|
||||
storage::atomic_write_file(&target, &bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Liest die gespeicherte Bilddatei unverändert.
|
||||
pub fn read_bytes(&self, asset: &str) -> Result<Vec<u8>, AssetError> {
|
||||
Ok(std::fs::read(self.path_of(asset)?)?)
|
||||
}
|
||||
|
||||
/// Lädt ein Bild für die Bildschirmdarstellung, verkleinert auf höchstens
|
||||
/// `max_dimension` Pixel Kantenlänge.
|
||||
pub fn load_rgba(&self, asset: &str, max_dimension: u32) -> Result<RgbaImage, AssetError> {
|
||||
let bytes = self.read_bytes(asset)?;
|
||||
let mut decoded =
|
||||
image::load_from_memory(&bytes).map_err(|e| AssetError::Decode(e.to_string()))?;
|
||||
if decoded.width() > max_dimension || decoded.height() > max_dimension {
|
||||
decoded = decoded.resize(
|
||||
max_dimension,
|
||||
max_dimension,
|
||||
image::imageops::FilterType::Triangle,
|
||||
);
|
||||
}
|
||||
let rgba = decoded.into_rgba8();
|
||||
Ok(RgbaImage {
|
||||
width: rgba.width(),
|
||||
height: rgba.height(),
|
||||
pixels: rgba.into_raw(),
|
||||
})
|
||||
}
|
||||
|
||||
fn store(
|
||||
&self,
|
||||
bytes: &[u8],
|
||||
ext: &str,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<ImportedImage, AssetError> {
|
||||
let hash = Sha256::digest(bytes);
|
||||
let asset = format!("{}.{ext}", hex(&hash));
|
||||
let path = self.path_of(&asset)?;
|
||||
if !path.exists() {
|
||||
storage::atomic_write_file(&path, bytes)?;
|
||||
}
|
||||
Ok(ImportedImage {
|
||||
asset,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_png(image: &image::DynamicImage) -> Result<Vec<u8>, AssetError> {
|
||||
let mut png = Vec::new();
|
||||
image
|
||||
.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.map_err(|e| AssetError::Decode(e.to_string()))?;
|
||||
Ok(png)
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Ist die Datei dem Namen nach ein unterstütztes Bild?
|
||||
pub fn is_supported_image_path(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| {
|
||||
matches!(
|
||||
e.to_ascii_lowercase().as_str(),
|
||||
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp"
|
||||
)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Kleines Testbild als Dateiinhalt im gewünschten Format.
|
||||
pub(crate) fn sample_image_bytes(
|
||||
width: u32,
|
||||
height: u32,
|
||||
format: image::ImageFormat,
|
||||
) -> Vec<u8> {
|
||||
let img = image::RgbImage::from_fn(width, height, |x, y| {
|
||||
image::Rgb([
|
||||
(x * 255 / width.max(1)) as u8,
|
||||
(y * 255 / height.max(1)) as u8,
|
||||
90,
|
||||
])
|
||||
});
|
||||
let mut bytes = Vec::new();
|
||||
image::DynamicImage::ImageRgb8(img)
|
||||
.write_to(&mut Cursor::new(&mut bytes), format)
|
||||
.unwrap();
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_asset_names_are_validated() {
|
||||
let hash = "a".repeat(64);
|
||||
assert!(is_valid_asset_name(&format!("{hash}.png")));
|
||||
assert!(is_valid_asset_name(&format!("{hash}.jpg")));
|
||||
assert!(!is_valid_asset_name(&format!("{hash}.exe")));
|
||||
assert!(!is_valid_asset_name("../../etc/passwd"));
|
||||
assert!(!is_valid_asset_name(&format!("../{}.png", "a".repeat(61))));
|
||||
assert!(!is_valid_asset_name(&format!("{}.png", "A".repeat(64))));
|
||||
assert!(!is_valid_asset_name(""));
|
||||
|
||||
let store = AssetStore::new(Path::new("/tmp/nb"));
|
||||
assert!(store.path_of("../manifest.json").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_png_jpeg_and_deduplicate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = AssetStore::new(dir.path());
|
||||
|
||||
let png = sample_image_bytes(40, 20, image::ImageFormat::Png);
|
||||
let a = store.import_bytes(&png).unwrap();
|
||||
assert_eq!((a.width, a.height), (40, 20));
|
||||
assert!(a.asset.ends_with(".png"));
|
||||
assert!(store.path_of(&a.asset).unwrap().exists());
|
||||
|
||||
// Gleiches Bild nochmals: gleicher Name, keine zweite Datei
|
||||
let b = store.import_bytes(&png).unwrap();
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(std::fs::read_dir(store.dir()).unwrap().count(), 1);
|
||||
|
||||
// JPEG bleibt byte-identisch
|
||||
let jpg = sample_image_bytes(30, 30, image::ImageFormat::Jpeg);
|
||||
let c = store.import_bytes(&jpg).unwrap();
|
||||
assert!(c.asset.ends_with(".jpg"));
|
||||
assert_eq!(store.read_bytes(&c.asset).unwrap(), jpg);
|
||||
|
||||
// BMP wird als PNG gespeichert
|
||||
let bmp = sample_image_bytes(8, 4, image::ImageFormat::Bmp);
|
||||
let d = store.import_bytes(&bmp).unwrap();
|
||||
assert!(d.asset.ends_with(".png"));
|
||||
|
||||
let rgba = store.load_rgba(&a.asset, MAX_DISPLAY_DIMENSION).unwrap();
|
||||
assert_eq!((rgba.width, rgba.height), (40, 20));
|
||||
assert_eq!(rgba.pixels.len(), 40 * 20 * 4);
|
||||
// Verkleinerung für die Anzeige behält das Seitenverhältnis
|
||||
let small = store.load_rgba(&a.asset, 10).unwrap();
|
||||
assert_eq!((small.width, small.height), (10, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_rejects_garbage_and_copies_between_notebooks() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = AssetStore::new(&dir.path().join("a"));
|
||||
assert!(matches!(
|
||||
store.import_bytes(b"kein Bild"),
|
||||
Err(AssetError::Decode(_))
|
||||
));
|
||||
assert!(store.import_rgba(4, 4, vec![0; 3]).is_err());
|
||||
|
||||
let imported = store.import_rgba(2, 2, vec![255; 16]).unwrap();
|
||||
let other = AssetStore::new(&dir.path().join("b"));
|
||||
assert!(other.read_bytes(&imported.asset).is_err());
|
||||
other.copy_from(&store, &imported.asset).unwrap();
|
||||
assert_eq!(
|
||||
other.read_bytes(&imported.asset).unwrap(),
|
||||
store.read_bytes(&imported.asset).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_supported_image_paths() {
|
||||
assert!(is_supported_image_path(Path::new("/x/Foto.JPG")));
|
||||
assert!(is_supported_image_path(Path::new("a.webp")));
|
||||
assert!(!is_supported_image_path(Path::new("a.pdf")));
|
||||
assert!(!is_supported_image_path(Path::new("png")));
|
||||
}
|
||||
}
|
||||
+195
-36
@@ -1,5 +1,7 @@
|
||||
//! Vektor-Generierung für DIN-A4-Seitenhintergründe (Kariert, Liniert, Punktraster, Blanko).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::canvas::InkVertex;
|
||||
use crate::document::{PageBackground, PAGE_HEIGHT, PAGE_WIDTH};
|
||||
|
||||
@@ -13,43 +15,122 @@ pub const GRID_5MM_SPACING: f32 = 50.0;
|
||||
/// Zeilenabstand für liniertes Papier (7.5 mm = 75 Einheiten).
|
||||
pub const LINED_SPACING: f32 = 75.0;
|
||||
|
||||
/// Hellgraue Farbe für Hintergrundmuster (#E0E0E0).
|
||||
pub const BACKGROUND_PATTERN_COLOR: [f32; 4] = [0.88, 0.88, 0.88, 1.0];
|
||||
/// Papierweiß für das DIN-A4-Blatt (#FFFFFF).
|
||||
pub const PAPER_COLOR: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
|
||||
/// Schreibtisch-Hintergrundfarbe (Letterbox-Bereich, #28282D).
|
||||
pub const DESK_COLOR: wgpu::Color = wgpu::Color {
|
||||
r: 0.16,
|
||||
g: 0.16,
|
||||
b: 0.18,
|
||||
a: 1.0,
|
||||
};
|
||||
/// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte
|
||||
/// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier).
|
||||
///
|
||||
/// Alle Farben sind linear (die Oberfläche ist sRGB, die Umrechnung macht die GPU).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PaperTheme {
|
||||
#[default]
|
||||
White,
|
||||
/// Cremefarben (augenschonend, wie Recyclingpapier)
|
||||
Cream,
|
||||
/// Dunkelgrau (Nachtmodus)
|
||||
DarkGray,
|
||||
/// Schwarz (OLED)
|
||||
Black,
|
||||
}
|
||||
|
||||
/// Erzeugt die 6 Vertizes (2 Dreiecke) für das weiße DIN-A4-Papierblatt.
|
||||
pub fn generate_paper_quad() -> Vec<InkVertex> {
|
||||
impl PaperTheme {
|
||||
pub const ALL: [PaperTheme; 4] = [
|
||||
PaperTheme::White,
|
||||
PaperTheme::Cream,
|
||||
PaperTheme::DarkGray,
|
||||
PaperTheme::Black,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
PaperTheme::White => "Weiß",
|
||||
PaperTheme::Cream => "Creme",
|
||||
PaperTheme::DarkGray => "Dunkelgrau",
|
||||
PaperTheme::Black => "Schwarz",
|
||||
}
|
||||
}
|
||||
|
||||
/// Dunkles Papier: dunkle Tinte wird bei Bedarf aufgehellt.
|
||||
pub fn is_dark(self) -> bool {
|
||||
matches!(self, PaperTheme::DarkGray | PaperTheme::Black)
|
||||
}
|
||||
|
||||
/// Farbe des Blatts.
|
||||
pub fn paper_color(self) -> [f32; 4] {
|
||||
match self {
|
||||
PaperTheme::White => [1.0, 1.0, 1.0, 1.0],
|
||||
// sRGB #FBF5E6
|
||||
PaperTheme::Cream => [0.965, 0.913, 0.791, 1.0],
|
||||
// sRGB #2B2B2E
|
||||
PaperTheme::DarkGray => [0.024, 0.024, 0.027, 1.0],
|
||||
PaperTheme::Black => [0.0, 0.0, 0.0, 1.0],
|
||||
}
|
||||
}
|
||||
|
||||
/// Farbe von Karos, Linien und Punkten.
|
||||
pub fn pattern_color(self) -> [f32; 4] {
|
||||
match self {
|
||||
PaperTheme::White => [0.88, 0.88, 0.88, 1.0],
|
||||
PaperTheme::Cream => [0.78, 0.72, 0.60, 1.0],
|
||||
// sRGB #48484D bzw. #38383C
|
||||
PaperTheme::DarkGray => [0.064, 0.064, 0.074, 1.0],
|
||||
PaperTheme::Black => [0.040, 0.040, 0.045, 1.0],
|
||||
}
|
||||
}
|
||||
|
||||
/// Schreibtisch-Hintergrund um das Blatt; bei dunklem Papier noch dunkler, damit sich
|
||||
/// das Blatt abhebt.
|
||||
pub fn desk_color(self) -> wgpu::Color {
|
||||
let (r, g, b) = match self {
|
||||
PaperTheme::White | PaperTheme::Cream => (0.16, 0.16, 0.18),
|
||||
PaperTheme::DarkGray => (0.004, 0.004, 0.005),
|
||||
PaperTheme::Black => (0.03, 0.03, 0.035),
|
||||
};
|
||||
wgpu::Color { r, g, b, a: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Hellt dunkle Tinte für dunkles Papier auf: die HSL-Helligkeit L wird zu 1 − L gespiegelt,
|
||||
/// Farbton und Sättigung bleiben erhalten. Farben mit L ≥ 0.5 (Rot, Blau, Textmarker)
|
||||
/// bleiben unverändert. Muss mit `fs_ink` in `shader.wgsl` übereinstimmen.
|
||||
pub fn adapt_ink_color(color: [f32; 4]) -> [f32; 4] {
|
||||
let max = color[0].max(color[1]).max(color[2]);
|
||||
let min = color[0].min(color[1]).min(color[2]);
|
||||
let lift = (1.0 - max - min).max(0.0);
|
||||
[color[0] + lift, color[1] + lift, color[2] + lift, color[3]]
|
||||
}
|
||||
|
||||
/// Erzeugt die 6 Vertizes (2 Dreiecke) für das DIN-A4-Papierblatt.
|
||||
pub fn generate_paper_quad(color: [f32; 4]) -> Vec<InkVertex> {
|
||||
vec![
|
||||
InkVertex::new(0.0, 0.0, PAPER_COLOR),
|
||||
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR),
|
||||
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR),
|
||||
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR),
|
||||
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR),
|
||||
InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, PAPER_COLOR),
|
||||
InkVertex::new(0.0, 0.0, color),
|
||||
InkVertex::new(PAGE_WIDTH, 0.0, color),
|
||||
InkVertex::new(0.0, PAGE_HEIGHT, color),
|
||||
InkVertex::new(0.0, PAGE_HEIGHT, color),
|
||||
InkVertex::new(PAGE_WIDTH, 0.0, color),
|
||||
InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, color),
|
||||
]
|
||||
}
|
||||
|
||||
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu.
|
||||
#[inline]
|
||||
fn push_quad(vertices: &mut Vec<InkVertex>, x_min: f32, y_min: f32, x_max: f32, y_max: f32) {
|
||||
vertices.push(InkVertex::new(x_min, y_min, BACKGROUND_PATTERN_COLOR));
|
||||
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR));
|
||||
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR));
|
||||
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR));
|
||||
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR));
|
||||
vertices.push(InkVertex::new(x_max, y_max, BACKGROUND_PATTERN_COLOR));
|
||||
fn push_quad(
|
||||
vertices: &mut Vec<InkVertex>,
|
||||
color: [f32; 4],
|
||||
x_min: f32,
|
||||
y_min: f32,
|
||||
x_max: f32,
|
||||
y_max: f32,
|
||||
) {
|
||||
vertices.push(InkVertex::new(x_min, y_min, color));
|
||||
vertices.push(InkVertex::new(x_max, y_min, color));
|
||||
vertices.push(InkVertex::new(x_min, y_max, color));
|
||||
vertices.push(InkVertex::new(x_min, y_max, color));
|
||||
vertices.push(InkVertex::new(x_max, y_min, color));
|
||||
vertices.push(InkVertex::new(x_max, y_max, color));
|
||||
}
|
||||
|
||||
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund.
|
||||
pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
||||
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color`.
|
||||
pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) -> Vec<InkVertex> {
|
||||
match background {
|
||||
PageBackground::Blank => Vec::new(),
|
||||
PageBackground::Grid5mm => {
|
||||
@@ -59,14 +140,28 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
||||
// Vertikale Linien alle 5 mm
|
||||
let mut x = GRID_5MM_SPACING;
|
||||
while x < PAGE_WIDTH {
|
||||
push_quad(&mut vertices, x - half_t, 0.0, x + half_t, PAGE_HEIGHT);
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
x - half_t,
|
||||
0.0,
|
||||
x + half_t,
|
||||
PAGE_HEIGHT,
|
||||
);
|
||||
x += GRID_5MM_SPACING;
|
||||
}
|
||||
|
||||
// Horizontale Linien alle 5 mm
|
||||
let mut y = GRID_5MM_SPACING;
|
||||
while y < PAGE_HEIGHT {
|
||||
push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t);
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
0.0,
|
||||
y - half_t,
|
||||
PAGE_WIDTH,
|
||||
y + half_t,
|
||||
);
|
||||
y += GRID_5MM_SPACING;
|
||||
}
|
||||
|
||||
@@ -82,7 +177,14 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
||||
|
||||
let mut y = margin_top;
|
||||
while y <= PAGE_HEIGHT - margin_bottom {
|
||||
push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t);
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
0.0,
|
||||
y - half_t,
|
||||
PAGE_WIDTH,
|
||||
y + half_t,
|
||||
);
|
||||
y += LINED_SPACING;
|
||||
}
|
||||
|
||||
@@ -98,6 +200,7 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
||||
while x < PAGE_WIDTH {
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
x - half_d,
|
||||
y - half_d,
|
||||
x + half_d,
|
||||
@@ -119,18 +222,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_paper_quad_generation() {
|
||||
let quad = generate_paper_quad();
|
||||
let quad = generate_paper_quad(PaperTheme::White.paper_color());
|
||||
assert_eq!(quad.len(), 6);
|
||||
assert_eq!(quad[0].position, [0.0, 0.0]);
|
||||
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
|
||||
}
|
||||
|
||||
const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
|
||||
|
||||
#[test]
|
||||
fn test_background_meshes() {
|
||||
let blank = generate_background_mesh(PageBackground::Blank);
|
||||
let blank = generate_background_mesh(PageBackground::Blank, PATTERN);
|
||||
assert!(blank.is_empty());
|
||||
|
||||
let grid = generate_background_mesh(PageBackground::Grid5mm);
|
||||
let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN);
|
||||
assert!(!grid.is_empty());
|
||||
assert_eq!(
|
||||
grid.len() % 6,
|
||||
@@ -138,12 +243,66 @@ mod tests {
|
||||
"Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)"
|
||||
);
|
||||
|
||||
let lined = generate_background_mesh(PageBackground::Lined);
|
||||
let lined = generate_background_mesh(PageBackground::Lined, PATTERN);
|
||||
assert!(!lined.is_empty());
|
||||
assert_eq!(lined.len() % 6, 0);
|
||||
|
||||
let dots = generate_background_mesh(PageBackground::DotGrid);
|
||||
let dots = generate_background_mesh(PageBackground::DotGrid, PATTERN);
|
||||
assert!(!dots.is_empty());
|
||||
assert_eq!(dots.len() % 6, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_background_mesh_uses_theme_color() {
|
||||
let dark = PaperTheme::Black.pattern_color();
|
||||
let mesh = generate_background_mesh(PageBackground::Lined, dark);
|
||||
assert!(mesh.iter().all(|v| v.color == dark));
|
||||
let quad = generate_paper_quad(PaperTheme::Cream.paper_color());
|
||||
assert!(quad
|
||||
.iter()
|
||||
.all(|v| v.color == PaperTheme::Cream.paper_color()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adapt_ink_color() {
|
||||
let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5);
|
||||
// Schwarz wird Weiß, Deckkraft bleibt
|
||||
assert!(close(
|
||||
adapt_ink_color([0.0, 0.0, 0.0, 0.7]),
|
||||
[1.0, 1.0, 1.0, 0.7]
|
||||
));
|
||||
// Notiz-Schwarz wird hell
|
||||
let ink = adapt_ink_color([0.12, 0.12, 0.14, 1.0]);
|
||||
assert!(ink[0] > 0.85 && ink[2] > 0.85);
|
||||
// Dunkelblau wird Hellblau: Farbton bleibt (Blau bleibt der stärkste Kanal)
|
||||
let blue = adapt_ink_color([0.0, 0.0, 0.5, 1.0]);
|
||||
assert!(close(blue, [0.5, 0.5, 1.0, 1.0]));
|
||||
// Helle und gesättigte Farben bleiben unverändert (Rot, Universitäts-Blau, Textmarker)
|
||||
for c in [
|
||||
[0.85, 0.20, 0.20, 1.0],
|
||||
[0.15, 0.35, 0.85, 1.0],
|
||||
[1.0, 0.95, 0.1, 0.35],
|
||||
[1.0, 1.0, 1.0, 1.0],
|
||||
] {
|
||||
assert!(close(adapt_ink_color(c), c), "{c:?} wurde verändert");
|
||||
}
|
||||
// Ergebnis ist nie heller als Weiß
|
||||
for c in [[0.0, 0.3, 0.1, 1.0], [0.45, 0.45, 0.48, 1.0]] {
|
||||
assert!(adapt_ink_color(c)[..3].iter().all(|&x| x <= 1.0 + 1e-6));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paper_themes_contrast() {
|
||||
for theme in PaperTheme::ALL {
|
||||
let paper = theme.paper_color()[0];
|
||||
let pattern = theme.pattern_color()[0];
|
||||
assert!(
|
||||
(paper - pattern).abs() > 0.03,
|
||||
"{theme:?}: Muster hebt sich nicht vom Papier ab"
|
||||
);
|
||||
// Helles Papier ↔ dunkles Papier
|
||||
assert_eq!(theme.is_dark(), paper < 0.5, "{theme:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+122
-16
@@ -11,7 +11,7 @@ use std::sync::Arc;
|
||||
use winit::dpi::PhysicalSize;
|
||||
use winit::window::Window;
|
||||
|
||||
use crate::background::{generate_background_mesh, generate_paper_quad, DESK_COLOR};
|
||||
use crate::background::{generate_background_mesh, generate_paper_quad, PaperTheme};
|
||||
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
|
||||
use crate::filter::OneEuroFilter;
|
||||
use crate::spline::{self, ControlPoint, SplineConfig};
|
||||
@@ -58,6 +58,8 @@ impl InkVertex {
|
||||
pub struct CameraUniforms {
|
||||
pub transform_scale: [f32; 2],
|
||||
pub transform_offset: [f32; 2],
|
||||
/// x: 1.0 = dunkle Tinte aufhellen (Shader `fs_ink`), y–w: ungenutzt (Ausrichtung)
|
||||
pub ink_params: [f32; 4],
|
||||
}
|
||||
|
||||
/// Transformiert einen Pixel-Koordinatenpunkt in den Normalized Device Coordinate (NDC) Raum [-1.0, 1.0].
|
||||
@@ -135,7 +137,6 @@ pub struct CanvasState {
|
||||
pub camera_bind_group: wgpu::BindGroup,
|
||||
pub current_ink_color: [f32; 4],
|
||||
pub current_stroke_width_mm: f32,
|
||||
pub current_tool: crate::document::StrokeTool,
|
||||
|
||||
// Statische Hintergrund-Geometrie
|
||||
pub paper_buffer: wgpu::Buffer,
|
||||
@@ -143,6 +144,10 @@ pub struct CanvasState {
|
||||
pub background_vertex_capacity: usize,
|
||||
pub background_vertex_count: u32,
|
||||
pub current_background: PageBackground,
|
||||
/// Papierfarbe der Anzeige
|
||||
pub paper_theme: PaperTheme,
|
||||
/// Dunkle Tinte auf dunklem Papier aufhellen
|
||||
pub adapt_ink: bool,
|
||||
|
||||
// Pipeline-Trennung: Live-Strich (aktiver Stroke)
|
||||
pub live_vertex_buffer: wgpu::Buffer,
|
||||
@@ -160,6 +165,9 @@ pub struct CanvasState {
|
||||
pub committed_vertex_count: u32,
|
||||
pub committed_strokes: Vec<Range<u32>>,
|
||||
|
||||
// Eingefügte Bilder der aktuellen Seite
|
||||
image_layer: crate::image_layer::ImageLayer,
|
||||
|
||||
// egui UI-Renderer
|
||||
pub egui_renderer: egui_wgpu::Renderer,
|
||||
}
|
||||
@@ -276,6 +284,7 @@ impl CanvasState {
|
||||
let camera_uniforms = CameraUniforms {
|
||||
transform_scale: scale,
|
||||
transform_offset: offset,
|
||||
ink_params: [0.0; 4],
|
||||
};
|
||||
let camera_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Camera Uniform Buffer"),
|
||||
@@ -312,7 +321,7 @@ impl CanvasState {
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: "fs_main",
|
||||
entry_point: "fs_ink",
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
@@ -366,7 +375,8 @@ impl CanvasState {
|
||||
});
|
||||
|
||||
// 6. Statische Buffer
|
||||
let paper_quad = generate_paper_quad();
|
||||
let paper_theme = PaperTheme::default();
|
||||
let paper_quad = generate_paper_quad(paper_theme.paper_color());
|
||||
let paper_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Paper Quad Buffer"),
|
||||
size: (paper_quad.len() * std::mem::size_of::<InkVertex>()) as u64,
|
||||
@@ -376,7 +386,7 @@ impl CanvasState {
|
||||
queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad));
|
||||
|
||||
let current_background = PageBackground::Grid5mm;
|
||||
let bg_mesh = generate_background_mesh(current_background);
|
||||
let bg_mesh = generate_background_mesh(current_background, paper_theme.pattern_color());
|
||||
let background_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Background Mesh Buffer"),
|
||||
size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::<InkVertex>()) as u64,
|
||||
@@ -400,6 +410,13 @@ impl CanvasState {
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
let image_layer = crate::image_layer::ImageLayer::new(
|
||||
&device,
|
||||
&shader,
|
||||
&bind_group_layout,
|
||||
config.format,
|
||||
);
|
||||
|
||||
// 8. egui Renderer für das schwebende UI initialisieren
|
||||
let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1);
|
||||
|
||||
@@ -420,12 +437,13 @@ impl CanvasState {
|
||||
camera_bind_group,
|
||||
current_ink_color: default_ink_color,
|
||||
current_stroke_width_mm: 0.5,
|
||||
current_tool: crate::document::StrokeTool::Pen,
|
||||
paper_buffer,
|
||||
background_buffer,
|
||||
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
|
||||
background_vertex_count: bg_mesh.len() as u32,
|
||||
current_background,
|
||||
paper_theme,
|
||||
adapt_ink: false,
|
||||
live_vertex_buffer,
|
||||
live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY,
|
||||
live_vertex_count: 0,
|
||||
@@ -442,6 +460,7 @@ impl CanvasState {
|
||||
committed_vertex_capacity: Self::INITIAL_COMMITTED_CAPACITY,
|
||||
committed_vertex_count: 0,
|
||||
committed_strokes: Vec::with_capacity(128),
|
||||
image_layer,
|
||||
egui_renderer,
|
||||
camera: crate::viewport::ViewportState::new(),
|
||||
})
|
||||
@@ -453,6 +472,16 @@ impl CanvasState {
|
||||
let camera_uniforms = CameraUniforms {
|
||||
transform_scale: scale,
|
||||
transform_offset: offset,
|
||||
ink_params: [
|
||||
if self.adapt_ink && self.paper_theme.is_dark() {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
],
|
||||
};
|
||||
self.queue.write_buffer(
|
||||
&self.camera_buffer,
|
||||
@@ -587,9 +616,19 @@ impl CanvasState {
|
||||
SplineConfig::from_width_units(width_mm * crate::document::UNITS_PER_MM);
|
||||
}
|
||||
|
||||
/// Setzt das aktive Zeichenwerkzeug für den Stift.
|
||||
pub fn set_tool(&mut self, tool: crate::document::StrokeTool) {
|
||||
self.current_tool = tool;
|
||||
/// Setzt die Papierfarbe und ob dunkle Tinte auf dunklem Papier aufgehellt wird.
|
||||
pub fn set_paper_theme(&mut self, theme: PaperTheme, adapt_ink: bool) {
|
||||
self.adapt_ink = adapt_ink;
|
||||
if self.paper_theme != theme {
|
||||
self.paper_theme = theme;
|
||||
self.queue.write_buffer(
|
||||
&self.paper_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&generate_paper_quad(theme.paper_color())),
|
||||
);
|
||||
self.upload_background_mesh();
|
||||
}
|
||||
self.update_uniforms();
|
||||
}
|
||||
|
||||
/// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer.
|
||||
@@ -598,7 +637,13 @@ impl CanvasState {
|
||||
return;
|
||||
}
|
||||
self.current_background = background;
|
||||
let mesh = generate_background_mesh(background);
|
||||
self.upload_background_mesh();
|
||||
}
|
||||
|
||||
/// Erzeugt das Muster für Hintergrund und Papierfarbe neu und lädt es auf die GPU.
|
||||
fn upload_background_mesh(&mut self) {
|
||||
let mesh =
|
||||
generate_background_mesh(self.current_background, self.paper_theme.pattern_color());
|
||||
self.background_vertex_count = mesh.len() as u32;
|
||||
|
||||
if mesh.len() > self.background_vertex_capacity {
|
||||
@@ -621,6 +666,18 @@ impl CanvasState {
|
||||
pub fn sync_page(&mut self, page: &mut Page) {
|
||||
page.rebuild_cache_if_dirty();
|
||||
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(),
|
||||
);
|
||||
|
||||
self.committed_vertex_count = page.vertex_cache.len() as u32;
|
||||
self.committed_strokes = page.stroke_ranges.clone();
|
||||
@@ -647,6 +704,20 @@ impl CanvasState {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// Wird noch eine PDF-Seite im Hintergrund gerendert?
|
||||
pub fn is_waiting_for_images(&self) -> bool {
|
||||
self.image_layer.is_waiting()
|
||||
}
|
||||
|
||||
/// Startet einen neuen Live-Strich in DIN-A4-Seitenkoordinaten.
|
||||
pub fn start_stroke(&mut self, page_x: f32, page_y: f32, pressure: f32, timestamp: f64) {
|
||||
self.filter.reset();
|
||||
@@ -728,7 +799,6 @@ impl CanvasState {
|
||||
&self.spline_config,
|
||||
);
|
||||
let mut stroke = stroke;
|
||||
stroke.tool = self.current_tool;
|
||||
stroke.width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM;
|
||||
stroke.finalized = true;
|
||||
|
||||
@@ -824,7 +894,7 @@ impl CanvasState {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(DESK_COLOR), // Dunkler Tischhintergrund
|
||||
load: wgpu::LoadOp::Clear(self.paper_theme.desk_color()), // Tischhintergrund
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
@@ -836,7 +906,7 @@ impl CanvasState {
|
||||
// 2D-Kamera Uniforms (Scale & Offset) binden
|
||||
render_pass.set_bind_group(0, &self.camera_bind_group, &[]);
|
||||
|
||||
// 1. Weißes DIN-A4-Blatt zeichnen (Farbe ist direkt im Paper-Quad Vertex hinterlegt)
|
||||
// 1. DIN-A4-Blatt in Papierfarbe zeichnen (Farbe ist direkt im Paper-Quad Vertex hinterlegt)
|
||||
render_pass.set_pipeline(&self.triangle_pipeline);
|
||||
render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..));
|
||||
render_pass.draw(0..6, 0..1);
|
||||
@@ -847,7 +917,10 @@ impl CanvasState {
|
||||
render_pass.draw(0..self.background_vertex_count, 0..1);
|
||||
}
|
||||
|
||||
// 3. Abgeschlossene Striche der aktiven Seite rendern (TriangleStrip mit Vertex-Farben)
|
||||
// 3. Eingefügte Bilder (unter der Tinte)
|
||||
self.image_layer.draw(&mut render_pass);
|
||||
|
||||
// 4. Abgeschlossene Striche der aktiven Seite rendern (TriangleStrip mit Vertex-Farben)
|
||||
render_pass.set_pipeline(&self.strip_pipeline);
|
||||
|
||||
if self.committed_vertex_count > 0 {
|
||||
@@ -859,7 +932,7 @@ impl CanvasState {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Temporäre Live-Geometrie des aktuellen Strichs rendern (Zero Latency)
|
||||
// 5. Temporäre Live-Geometrie des aktuellen Strichs rendern (Zero Latency)
|
||||
if self.live_vertex_count > 0 {
|
||||
render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..));
|
||||
render_pass.draw(0..self.live_vertex_count, 0..1);
|
||||
@@ -948,7 +1021,40 @@ mod tests {
|
||||
#[test]
|
||||
fn test_camera_uniforms() {
|
||||
let _u = CameraUniforms::default();
|
||||
assert_eq!(std::mem::size_of::<CameraUniforms>(), 16);
|
||||
// Muss dem WGSL-Layout entsprechen: vec2 + vec2 + vec4
|
||||
assert_eq!(std::mem::size_of::<CameraUniforms>(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shader_is_valid_and_matches_uniforms() {
|
||||
// Ein Shaderfehler fiele sonst erst beim Programmstart auf
|
||||
let module = naga::front::wgsl::parse_str(include_str!("shader.wgsl"))
|
||||
.unwrap_or_else(|e| panic!("WGSL-Fehler: {e}"));
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::empty(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("Shader ungültig: {e:?}"));
|
||||
|
||||
for name in ["vs_main", "fs_main", "fs_ink", "vs_image", "fs_image"] {
|
||||
assert!(
|
||||
module.entry_points.iter().any(|ep| ep.name == name),
|
||||
"Einstiegspunkt {name} fehlt"
|
||||
);
|
||||
}
|
||||
let camera = module
|
||||
.types
|
||||
.iter()
|
||||
.find(|(_, ty)| ty.name.as_deref() == Some("CameraUniforms"))
|
||||
.expect("CameraUniforms fehlt im Shader")
|
||||
.1;
|
||||
match camera.inner {
|
||||
naga::TypeInner::Struct { span, .. } => {
|
||||
assert_eq!(span as usize, std::mem::size_of::<CameraUniforms>())
|
||||
}
|
||||
ref other => panic!("CameraUniforms ist kein Struct: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+340
-11
@@ -103,6 +103,93 @@ pub fn point_to_segment_distance(p: glam::Vec2, a: glam::Vec2, b: glam::Vec2) ->
|
||||
(p - projection).length()
|
||||
}
|
||||
|
||||
/// Liegt `p` im konvexen Viereck `quad` (Ecken der Reihe nach, beliebiger Umlaufsinn)?
|
||||
pub fn point_in_convex_quad(p: glam::Vec2, quad: &[glam::Vec2; 4]) -> bool {
|
||||
let mut sign = 0.0_f32;
|
||||
for i in 0..4 {
|
||||
let a = quad[i];
|
||||
let b = quad[(i + 1) % 4];
|
||||
let cross = (b - a).perp_dot(p - a);
|
||||
if cross.abs() < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
if sign == 0.0 {
|
||||
sign = cross.signum();
|
||||
} else if cross.signum() != sign {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Verweis auf eine Seite eines importierten PDFs (Datei im Asset-Ordner).
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PdfBackground {
|
||||
/// Name der PDF-Datei im Asset-Ordner
|
||||
pub asset: String,
|
||||
/// Seitennummer im PDF, ab 0
|
||||
pub page: u32,
|
||||
/// Angezeigte Größe der PDF-Seite in Punkt (Drehung berücksichtigt)
|
||||
pub width_pt: f32,
|
||||
pub height_pt: f32,
|
||||
}
|
||||
|
||||
impl PdfBackground {
|
||||
/// Schlüssel für Texturen und Render-Aufträge.
|
||||
pub fn key(&self) -> String {
|
||||
format!("{}#{}", self.asset, self.page)
|
||||
}
|
||||
|
||||
/// Ecken (oben links, oben rechts, unten rechts, unten links) der PDF-Seite auf dem
|
||||
/// A4-Blatt, siehe [`pdf_placement`].
|
||||
pub fn corners(&self) -> [glam::Vec2; 4] {
|
||||
pdf_placement(self.width_pt, self.height_pt)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lage einer PDF-Seite von `width_pt` × `height_pt` auf dem A4-Blatt: so groß wie möglich,
|
||||
/// waagerecht zentriert und oben bündig. Querformat-Folien liegen so oben auf der Seite,
|
||||
/// darunter bleibt Platz für Notizen.
|
||||
pub fn pdf_placement(width_pt: f32, height_pt: f32) -> [glam::Vec2; 4] {
|
||||
let (w, h) = (width_pt.max(1.0), height_pt.max(1.0));
|
||||
let scale = (PAGE_WIDTH / w).min(PAGE_HEIGHT / h);
|
||||
let size = glam::Vec2::new(w * scale, h * scale);
|
||||
let tl = glam::Vec2::new((PAGE_WIDTH - size.x) / 2.0, 0.0);
|
||||
[
|
||||
tl,
|
||||
tl + glam::Vec2::new(size.x, 0.0),
|
||||
tl + size,
|
||||
tl + glam::Vec2::new(0.0, size.y),
|
||||
]
|
||||
}
|
||||
|
||||
/// Seiteneinheiten pro Bildpixel beim Einfügen (96 dpi wie am Bildschirm: 1 px ≈ 0,26 mm).
|
||||
pub const IMAGE_UNITS_PER_PIXEL: f32 = UNITS_PER_MM * 25.4 / 96.0;
|
||||
|
||||
/// Ecken (oben links, oben rechts, unten rechts, unten links) für ein neu eingefügtes Bild
|
||||
/// von `width` × `height` Pixeln um `center`: Bildschirmgröße bei 96 dpi, höchstens 80 % der
|
||||
/// Seitenbreite und 60 % der Seitenhöhe, und möglichst ganz auf dem Blatt.
|
||||
pub fn place_image(width: u32, height: u32, center: glam::Vec2) -> [glam::Vec2; 4] {
|
||||
let natural =
|
||||
glam::Vec2::new(width.max(1) as f32, height.max(1) as f32) * IMAGE_UNITS_PER_PIXEL;
|
||||
let fit = (PAGE_WIDTH * 0.8 / natural.x)
|
||||
.min(PAGE_HEIGHT * 0.6 / natural.y)
|
||||
.min(1.0);
|
||||
let size = natural * fit;
|
||||
let half = size / 2.0;
|
||||
let center = glam::Vec2::new(
|
||||
center.x.clamp(half.x, PAGE_WIDTH - half.x),
|
||||
center.y.clamp(half.y, PAGE_HEIGHT - half.y),
|
||||
);
|
||||
let tl = center - half;
|
||||
[
|
||||
tl,
|
||||
tl + glam::Vec2::new(size.x, 0.0),
|
||||
tl + size,
|
||||
tl + glam::Vec2::new(0.0, size.y),
|
||||
]
|
||||
}
|
||||
|
||||
/// Das Werkzeug, mit dem ein Strich gezeichnet wurde.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -113,6 +200,39 @@ pub enum StrokeTool {
|
||||
Shape,
|
||||
}
|
||||
|
||||
impl StrokeTool {
|
||||
/// Werkzeug eines gespeicherten Strichs. Bis v0.3.7 wurde das Werkzeug nicht erfasst und
|
||||
/// jeder Strich als `pen` gespeichert; Textmarker erkennt man dann an der
|
||||
/// Halbtransparenz (der Stift ist immer deckend), Formen an der erkannten Form.
|
||||
pub fn resolve_stored(stored: StrokeTool, color: [f32; 4], has_shape: bool) -> StrokeTool {
|
||||
match stored {
|
||||
StrokeTool::Pen if has_shape => StrokeTool::Shape,
|
||||
StrokeTool::Pen if color[3] < 0.99 => StrokeTool::Highlighter,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Welche Striche der Radierer entfernt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum EraserMode {
|
||||
/// Alle getroffenen Striche
|
||||
#[default]
|
||||
All,
|
||||
/// Nur Textmarker – die Handschrift darunter bleibt erhalten
|
||||
HighlighterOnly,
|
||||
}
|
||||
|
||||
impl EraserMode {
|
||||
/// Darf ein Strich dieses Werkzeugs radiert werden?
|
||||
pub fn erases(self, tool: StrokeTool) -> bool {
|
||||
match self {
|
||||
EraserMode::All => true,
|
||||
EraserMode::HighlighterOnly => tool == StrokeTool::Highlighter,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein einzelner Vektor-Strich auf einer Seite.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Stroke {
|
||||
@@ -125,6 +245,10 @@ pub struct Stroke {
|
||||
pub tool: StrokeTool,
|
||||
pub width: f32,
|
||||
pub finalized: bool,
|
||||
/// Eingefügtes Bild (Name im Asset-Ordner, siehe `assets`). Die vier Punkte sind dann die
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
impl Stroke {
|
||||
@@ -157,9 +281,44 @@ impl Stroke {
|
||||
tool: StrokeTool::Pen,
|
||||
width: 5.0,
|
||||
finalized: true,
|
||||
image: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Erzeugt ein Bildobjekt mit den Ecken oben links, oben rechts, unten rechts, unten links.
|
||||
pub fn new_image(id: u64, asset: String, corners: [glam::Vec2; 4]) -> Self {
|
||||
let points = corners
|
||||
.iter()
|
||||
.map(|&position| StylusSample {
|
||||
position,
|
||||
pressure: 1.0,
|
||||
timestamp: 0.0,
|
||||
})
|
||||
.collect();
|
||||
let mut stroke = Self::new(id, points, Vec::new(), [1.0, 1.0, 1.0, 1.0]);
|
||||
stroke.width = 0.0;
|
||||
stroke.image = Some(asset);
|
||||
stroke
|
||||
}
|
||||
|
||||
/// Ist dieses Objekt ein Bild?
|
||||
pub fn is_image(&self) -> bool {
|
||||
self.image.is_some()
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
return None;
|
||||
}
|
||||
Some([
|
||||
self.points[0].position,
|
||||
self.points[1].position,
|
||||
self.points[2].position,
|
||||
self.points[3].position,
|
||||
])
|
||||
}
|
||||
|
||||
/// Finalisiert rohe Stylus-Samples mit akausalem Post-Processing (Zwei-Stufen-Modell).
|
||||
pub fn finalize(
|
||||
id: u64,
|
||||
@@ -193,7 +352,16 @@ impl Stroke {
|
||||
}
|
||||
|
||||
/// Berechnet den minimalen euklidischen Abstand eines Punktes zu diesem Strich.
|
||||
/// Bei Bildern ist der Abstand innerhalb der Bildfläche 0.
|
||||
pub fn distance_to_point(&self, p: glam::Vec2) -> f32 {
|
||||
if let Some(corners) = self.image_corners() {
|
||||
if point_in_convex_quad(p, &corners) {
|
||||
return 0.0;
|
||||
}
|
||||
return (0..4)
|
||||
.map(|i| point_to_segment_distance(p, corners[i], corners[(i + 1) % 4]))
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
}
|
||||
if self.points.is_empty() {
|
||||
return f32::INFINITY;
|
||||
}
|
||||
@@ -315,7 +483,11 @@ impl Stroke {
|
||||
}
|
||||
|
||||
/// Weist diesem Strich eine neue Farbe zu und aktualisiert alle Vertex-Farben.
|
||||
/// Bilder behalten ihre Farben.
|
||||
pub fn set_color(&mut self, new_color: [f32; 4]) {
|
||||
if self.is_image() {
|
||||
return;
|
||||
}
|
||||
self.color = new_color;
|
||||
for v in &mut self.vertices {
|
||||
v.color = new_color;
|
||||
@@ -358,6 +530,8 @@ pub struct Page {
|
||||
pub id: u64,
|
||||
pub strokes: Vec<Stroke>,
|
||||
pub background: PageBackground,
|
||||
/// Importierte PDF-Seite als Hintergrund (unter Bildern und Tinte)
|
||||
pub pdf_background: Option<PdfBackground>,
|
||||
pub vertex_cache: Vec<InkVertex>,
|
||||
pub stroke_ranges: Vec<Range<u32>>,
|
||||
pub is_dirty: bool,
|
||||
@@ -372,6 +546,7 @@ impl Page {
|
||||
id,
|
||||
strokes: Vec::new(),
|
||||
background,
|
||||
pdf_background: None,
|
||||
vertex_cache: Vec::new(),
|
||||
stroke_ranges: Vec::new(),
|
||||
is_dirty: false,
|
||||
@@ -387,6 +562,7 @@ impl Page {
|
||||
id,
|
||||
strokes: Vec::new(),
|
||||
background,
|
||||
pdf_background: None,
|
||||
vertex_cache: Vec::new(),
|
||||
stroke_ranges: Vec::new(),
|
||||
is_dirty: false,
|
||||
@@ -429,20 +605,18 @@ 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.
|
||||
pub fn find_stroke_at(&self, pos: glam::Vec2, max_dist: f32) -> Option<&Stroke> {
|
||||
let mut best: Option<(&Stroke, f32)> = None;
|
||||
let mut best: Option<(&Stroke, (bool, f32))> = None;
|
||||
for s in &self.strokes {
|
||||
if !s.aabb.expanded(max_dist).contains_point(pos) {
|
||||
continue;
|
||||
}
|
||||
let d = s.distance_to_point(pos);
|
||||
if d <= max_dist {
|
||||
if let Some((_, best_d)) = best {
|
||||
if d < best_d {
|
||||
best = Some((s, d));
|
||||
}
|
||||
} else {
|
||||
best = Some((s, d));
|
||||
let key = (s.is_image(), d);
|
||||
if best.is_none_or(|(_, best_key)| key < best_key) {
|
||||
best = Some((s, key));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -469,7 +643,8 @@ impl Page {
|
||||
}
|
||||
}
|
||||
|
||||
/// Radiert alle Striche an der Position `eraser_pos` mit Radius `eraser_radius`.
|
||||
/// Radiert alle Striche an der Position `eraser_pos` mit Radius `eraser_radius`,
|
||||
/// die `mode` zulässt.
|
||||
///
|
||||
/// Gibt die gelöschten Striche zurück (falls welche getroffen wurden).
|
||||
pub fn erase_at(
|
||||
@@ -477,11 +652,15 @@ impl Page {
|
||||
eraser_pos: glam::Vec2,
|
||||
eraser_radius: f32,
|
||||
max_stroke_radius: f32,
|
||||
mode: EraserMode,
|
||||
) -> Vec<Stroke> {
|
||||
let mut hit_indices = Vec::new();
|
||||
|
||||
for (i, stroke) in self.strokes.iter().enumerate() {
|
||||
if stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius) {
|
||||
if !stroke.is_image()
|
||||
&& mode.erases(stroke.tool)
|
||||
&& stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius)
|
||||
{
|
||||
hit_indices.push(i);
|
||||
}
|
||||
}
|
||||
@@ -535,6 +714,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.
|
||||
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)),
|
||||
@@ -542,7 +723,13 @@ impl Page {
|
||||
);
|
||||
self.strokes
|
||||
.iter()
|
||||
.filter(|s| s.aabb.intersects(&sel_box))
|
||||
.filter(|s| {
|
||||
if s.is_image() {
|
||||
sel_box.contains_point(s.aabb.min) && sel_box.contains_point(s.aabb.max)
|
||||
} else {
|
||||
s.aabb.intersects(&sel_box)
|
||||
}
|
||||
})
|
||||
.map(|s| s.id)
|
||||
.collect()
|
||||
}
|
||||
@@ -1040,7 +1227,7 @@ mod tests {
|
||||
assert_eq!(page.strokes.len(), 2);
|
||||
|
||||
// Radieren bei (30, 12): Trifft nur s1
|
||||
let removed = page.erase_at(glam::Vec2::new(30.0, 12.0), 10.0, 2.0);
|
||||
let removed = page.erase_at(glam::Vec2::new(30.0, 12.0), 10.0, 2.0, EraserMode::All);
|
||||
assert_eq!(removed.len(), 1);
|
||||
assert_eq!(removed[0].id, 1);
|
||||
assert_eq!(page.strokes.len(), 1);
|
||||
@@ -1056,6 +1243,148 @@ mod tests {
|
||||
assert_eq!(page.strokes[0].id, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_erase_highlighter_only_keeps_handwriting() {
|
||||
let mut page = Page::new(1, PageBackground::Grid5mm);
|
||||
let line = |id: u64, tool: StrokeTool| {
|
||||
let mut stroke = Stroke::new(
|
||||
id,
|
||||
vec![
|
||||
StylusSample {
|
||||
position: glam::Vec2::new(10.0, 10.0),
|
||||
pressure: 0.5,
|
||||
timestamp: 0.0,
|
||||
},
|
||||
StylusSample {
|
||||
position: glam::Vec2::new(50.0, 10.0),
|
||||
pressure: 0.5,
|
||||
timestamp: 0.01,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
InkVertex::new(10.0, 10.0, [0.0, 0.0, 0.0, 1.0]),
|
||||
InkVertex::new(50.0, 10.0, [0.0, 0.0, 0.0, 1.0]),
|
||||
],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
);
|
||||
stroke.tool = tool;
|
||||
stroke
|
||||
};
|
||||
// Handschrift, Form und Textmarker liegen übereinander
|
||||
page.add_stroke(line(1, StrokeTool::Pen));
|
||||
page.add_stroke(line(2, StrokeTool::Highlighter));
|
||||
page.add_stroke(line(3, StrokeTool::Shape));
|
||||
|
||||
let at = glam::Vec2::new(30.0, 10.0);
|
||||
let removed = page.erase_at(at, 10.0, 2.0, EraserMode::HighlighterOnly);
|
||||
assert_eq!(removed.iter().map(|s| s.id).collect::<Vec<_>>(), vec![2]);
|
||||
assert_eq!(
|
||||
page.strokes.iter().map(|s| s.id).collect::<Vec<_>>(),
|
||||
vec![1, 3]
|
||||
);
|
||||
// Kein Textmarker mehr da: nichts wird entfernt, kein Undo-Eintrag
|
||||
assert!(page
|
||||
.erase_at(at, 10.0, 2.0, EraserMode::HighlighterOnly)
|
||||
.is_empty());
|
||||
assert!(page.undo());
|
||||
assert_eq!(page.strokes.len(), 3);
|
||||
|
||||
// Normaler Radierer entfernt alles
|
||||
let removed = page.erase_at(at, 10.0, 2.0, EraserMode::All);
|
||||
assert_eq!(removed.len(), 3);
|
||||
assert!(page.strokes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_place_image() {
|
||||
let center = glam::Vec2::new(PAGE_WIDTH / 2.0, PAGE_HEIGHT / 2.0);
|
||||
// Kleines Bild: Bildschirmgröße, zentriert
|
||||
let [tl, tr, br, bl] = place_image(200, 100, center);
|
||||
assert!(((tr - tl).length() - 200.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
|
||||
assert!(((bl - tl).length() - 100.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
|
||||
assert!(((tl + br) / 2.0 - center).length() < 1e-3);
|
||||
|
||||
// Großer Screenshot: auf 80 % der Breite verkleinert, Seitenverhältnis bleibt
|
||||
let [tl, tr, br, _] = place_image(3840, 2160, center);
|
||||
let size = br - tl;
|
||||
assert!((size.x - PAGE_WIDTH * 0.8).abs() < 1e-2);
|
||||
assert!((size.x / size.y - 3840.0 / 2160.0).abs() < 1e-3);
|
||||
assert_eq!(tr.y, tl.y);
|
||||
|
||||
// Am Rand eingefügt: wird aufs Blatt geschoben
|
||||
let corners = place_image(400, 400, glam::Vec2::new(-50.0, PAGE_HEIGHT + 50.0));
|
||||
for c in corners {
|
||||
assert!(c.x >= -1e-3 && c.x <= PAGE_WIDTH + 1e-3);
|
||||
assert!(c.y >= -1e-3 && c.y <= PAGE_HEIGHT + 1e-3);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_objects_hit_test_and_survive_erasing() {
|
||||
let mut page = Page::new(1, PageBackground::Blank);
|
||||
let corners = [
|
||||
glam::Vec2::new(100.0, 100.0),
|
||||
glam::Vec2::new(300.0, 100.0),
|
||||
glam::Vec2::new(300.0, 200.0),
|
||||
glam::Vec2::new(100.0, 200.0),
|
||||
];
|
||||
page.add_stroke(Stroke::new_image(1, "a.png".into(), corners));
|
||||
assert_eq!(page.strokes[0].image_corners(), Some(corners));
|
||||
|
||||
// Fläche des Bildes trifft, auch weit weg vom Rand
|
||||
let inside = glam::Vec2::new(200.0, 150.0);
|
||||
assert_eq!(page.find_stroke_at(inside, 5.0).map(|s| s.id), Some(1));
|
||||
assert!(page
|
||||
.find_stroke_at(glam::Vec2::new(400.0, 150.0), 5.0)
|
||||
.is_none());
|
||||
|
||||
// Notiz auf dem Bild hat beim Antippen Vorrang
|
||||
let mut ink = Stroke::new(
|
||||
2,
|
||||
vec![
|
||||
StylusSample {
|
||||
position: glam::Vec2::new(150.0, 150.0),
|
||||
pressure: 0.5,
|
||||
timestamp: 0.0,
|
||||
},
|
||||
StylusSample {
|
||||
position: glam::Vec2::new(250.0, 152.0),
|
||||
pressure: 0.5,
|
||||
timestamp: 0.1,
|
||||
},
|
||||
],
|
||||
Vec::new(),
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
);
|
||||
ink.width = 5.0;
|
||||
page.add_stroke(ink);
|
||||
assert_eq!(page.find_stroke_at(inside, 5.0).map(|s| s.id), Some(2));
|
||||
|
||||
// Radierer entfernt nur die Notiz, nie das Bild
|
||||
let removed = page.erase_at(inside, 30.0, 12.0, EraserMode::All);
|
||||
assert_eq!(removed.iter().map(|s| s.id).collect::<Vec<_>>(), vec![2]);
|
||||
assert!(page
|
||||
.erase_at(glam::Vec2::new(100.0, 100.0), 30.0, 12.0, EraserMode::All)
|
||||
.is_empty());
|
||||
|
||||
// Auswahlrechteck: Bild nur bei vollständigem Einschluss
|
||||
assert!(page
|
||||
.find_strokes_in_box(glam::Vec2::new(150.0, 120.0), glam::Vec2::new(250.0, 180.0))
|
||||
.is_empty());
|
||||
assert_eq!(
|
||||
page.find_strokes_in_box(glam::Vec2::new(90.0, 90.0), glam::Vec2::new(310.0, 210.0)),
|
||||
vec![1]
|
||||
);
|
||||
|
||||
// Umfärben ändert Bilder nicht, Drehen bewegt die Ecken mit
|
||||
let mut image = page.strokes[0].clone();
|
||||
image.set_color([1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(image.color, [1.0, 1.0, 1.0, 1.0]);
|
||||
image.rotate(glam::Vec2::new(200.0, 150.0), std::f32::consts::FRAC_PI_2);
|
||||
let rotated = image.image_corners().unwrap();
|
||||
assert!((rotated[0] - glam::Vec2::new(250.0, 50.0)).length() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_isolation() {
|
||||
let mut nb = Notebook::new();
|
||||
|
||||
+597
-74
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Features:
|
||||
//! - Hochformat DIN A4 (210 mm x 297 mm)
|
||||
//! - 2 Layer pro Seite: Layer 1 ("Background") und Layer 2 ("Ink")
|
||||
//! - Layer pro Seite: "Background", "Images" (nur bei eingefügten Bildern) und "Ink"
|
||||
//! - Y-Achsen-Spiegelung (PDF-Ursprung unten links vs. Bildschirmursprung oben links)
|
||||
//! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko)
|
||||
//! - Zwei Export-Strategien:
|
||||
@@ -17,6 +17,7 @@ use std::path::{Path, PathBuf};
|
||||
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::document::{
|
||||
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
|
||||
@@ -109,15 +110,18 @@ pub fn page_to_pdf_mm(x: f32, y: f32) -> (f32, f32) {
|
||||
}
|
||||
|
||||
/// Erzeugt ein PDF-Dokument aus einem Notizbuch und speichert es als Datei.
|
||||
/// `assets`: Bilder des Notizbuchs (ohne werden Bilder als graue Fläche ausgegeben).
|
||||
pub fn export_notebook_to_pdf<P: AsRef<Path>>(
|
||||
notebook: &Notebook,
|
||||
path: P,
|
||||
strategy: ExportStrategy,
|
||||
assets: Option<&AssetStore>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let bytes = export_notebook_to_bytes(notebook, strategy, assets)?;
|
||||
let file = File::create(path)?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
let doc = generate_pdf_document(notebook, strategy)?;
|
||||
doc.save(&mut writer)?;
|
||||
std::io::Write::write_all(&mut writer, &bytes)?;
|
||||
std::io::Write::flush(&mut writer)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -125,16 +129,92 @@ pub fn export_notebook_to_pdf<P: AsRef<Path>>(
|
||||
pub fn export_notebook_to_bytes(
|
||||
notebook: &Notebook,
|
||||
strategy: ExportStrategy,
|
||||
assets: Option<&AssetStore>,
|
||||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
let doc = generate_pdf_document(notebook, strategy)?;
|
||||
let doc = generate_pdf_document(notebook, strategy, assets)?;
|
||||
let bytes = doc.save_to_bytes()?;
|
||||
Ok(bytes)
|
||||
|
||||
// Importierte PDF-Seiten als Vektorgrafik unterlegen
|
||||
let underlays = pdf_underlays(notebook);
|
||||
if underlays.is_empty() {
|
||||
return Ok(bytes);
|
||||
}
|
||||
let sources: std::collections::HashMap<String, Vec<u8>> = underlays
|
||||
.iter()
|
||||
.filter_map(|u| Some((u.source.clone(), assets?.read_bytes(&u.source).ok()?)))
|
||||
.collect();
|
||||
let (merged, failed) = crate::pdf_merge::apply_underlays(&bytes, &underlays, &sources)?;
|
||||
if failed.is_empty() {
|
||||
return Ok(merged);
|
||||
}
|
||||
|
||||
// Nicht übernehmbare Seiten (z. B. verschlüsselte PDFs) als Bild einbetten
|
||||
let mut raster = std::collections::HashSet::new();
|
||||
for (underlay, err) in &failed {
|
||||
eprintln!(
|
||||
"[PDF-Export] Seite {} des PDFs als Bild statt als Vektor: {err}",
|
||||
underlay.source_page + 1
|
||||
);
|
||||
raster.insert(underlay.target_page);
|
||||
}
|
||||
let doc = generate_pdf_document_with(notebook, strategy, assets, &raster)?;
|
||||
let bytes = doc.save_to_bytes()?;
|
||||
let remaining: Vec<_> = underlays
|
||||
.into_iter()
|
||||
.filter(|u| !raster.contains(&u.target_page))
|
||||
.collect();
|
||||
if remaining.is_empty() {
|
||||
return Ok(bytes);
|
||||
}
|
||||
let (merged, _) = crate::pdf_merge::apply_underlays(&bytes, &remaining, &sources)?;
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
/// Unterlagen für alle Seiten mit importierter PDF-Seite.
|
||||
fn pdf_underlays(notebook: &Notebook) -> Vec<crate::pdf_merge::Underlay> {
|
||||
notebook
|
||||
.pages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, page)| {
|
||||
let pdf = page.pdf_background.as_ref()?;
|
||||
let corners = pdf.corners();
|
||||
let top_right = page_to_pdf_pt(corners[1]);
|
||||
let bottom_left = page_to_pdf_pt(corners[3]);
|
||||
let size = top_right - bottom_left;
|
||||
Some(crate::pdf_merge::Underlay {
|
||||
target_page: i,
|
||||
source: pdf.asset.clone(),
|
||||
source_page: pdf.page as usize,
|
||||
rect: [bottom_left.x, bottom_left.y, size.x, size.y],
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generiert das `printpdf::PdfDocumentReference` für alle Seiten des Notizbuchs.
|
||||
/// Importierte PDF-Seiten sind darin noch nicht enthalten; die legt
|
||||
/// [`export_notebook_to_bytes`] anschließend als Vektorgrafik darunter.
|
||||
pub fn generate_pdf_document(
|
||||
notebook: &Notebook,
|
||||
strategy: ExportStrategy,
|
||||
assets: Option<&AssetStore>,
|
||||
) -> Result<PdfDocumentReference, Box<dyn Error>> {
|
||||
generate_pdf_document_with(
|
||||
notebook,
|
||||
strategy,
|
||||
assets,
|
||||
&std::collections::HashSet::new(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Wie [`generate_pdf_document`]; die PDF-Seiten der Seiten in `raster_pdf_pages` werden
|
||||
/// gerendert und als Bild eingebettet.
|
||||
fn generate_pdf_document_with(
|
||||
notebook: &Notebook,
|
||||
strategy: ExportStrategy,
|
||||
assets: Option<&AssetStore>,
|
||||
raster_pdf_pages: &std::collections::HashSet<usize>,
|
||||
) -> Result<PdfDocumentReference, Box<dyn Error>> {
|
||||
if notebook.pages.is_empty() {
|
||||
return Err("Notizbuch enthält keine Seiten".into());
|
||||
@@ -159,16 +239,28 @@ pub fn generate_pdf_document(
|
||||
let page1_ref = doc.get_page(page1_idx);
|
||||
let layer1_bg_ref = page1_ref.get_layer(layer1_bg);
|
||||
render_background_layer(&layer1_bg_ref, notebook.pages[0].background);
|
||||
if raster_pdf_pages.contains(&0) {
|
||||
render_pdf_raster(&page1_ref.add_layer("PDF"), ¬ebook.pages[0], assets);
|
||||
}
|
||||
if notebook.pages[0].strokes.iter().any(Stroke::is_image) {
|
||||
render_image_layer(&page1_ref.add_layer("Images"), ¬ebook.pages[0], assets);
|
||||
}
|
||||
|
||||
let layer1_ink_ref = page1_ref.add_layer("Ink");
|
||||
render_ink_layer(&layer1_ink_ref, ¬ebook.pages[0], strategy);
|
||||
|
||||
// 2. Alle nachfolgenden Seiten hinzufügen
|
||||
for page in ¬ebook.pages[1..] {
|
||||
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");
|
||||
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);
|
||||
if raster_pdf_pages.contains(&i) {
|
||||
render_pdf_raster(&page_ref.add_layer("PDF"), page, assets);
|
||||
}
|
||||
if page.strokes.iter().any(Stroke::is_image) {
|
||||
render_image_layer(&page_ref.add_layer("Images"), page, assets);
|
||||
}
|
||||
|
||||
let layer_ink_ref = page_ref.add_layer("Ink");
|
||||
render_ink_layer(&layer_ink_ref, page, strategy);
|
||||
@@ -268,6 +360,9 @@ 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() {
|
||||
continue;
|
||||
}
|
||||
if let Some(ref shape) = stroke.shape {
|
||||
render_recognized_shape(layer, shape, stroke.color);
|
||||
} else {
|
||||
@@ -283,6 +378,210 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy:
|
||||
}
|
||||
}
|
||||
|
||||
/// Auflösung, mit der Bilder höchstens eingebettet werden (Druckqualität).
|
||||
pub const PDF_IMAGE_DPI: f32 = 300.0;
|
||||
|
||||
/// Seitenkoordinate in PDF-Punkten (ohne Begrenzung auf das Blatt, Ursprung unten links).
|
||||
fn page_to_pdf_pt(p: glam::Vec2) -> glam::Vec2 {
|
||||
const PT_PER_MM: f32 = 72.0 / 25.4;
|
||||
glam::Vec2::new(
|
||||
p.x / PAGE_WIDTH * A4_WIDTH_MM * PT_PER_MM,
|
||||
(1.0 - p.y / PAGE_HEIGHT) * A4_HEIGHT_MM * PT_PER_MM,
|
||||
)
|
||||
}
|
||||
|
||||
/// PDF-Transformationsmatrix, die das Einheitsquadrat eines Bildes (Ursprung unten links)
|
||||
/// auf die Bildecken oben links, oben rechts, unten rechts, unten links abbildet.
|
||||
pub fn image_matrix(corners: &[glam::Vec2; 4]) -> [f32; 6] {
|
||||
let [tl, _, br, bl] = corners.map(page_to_pdf_pt);
|
||||
let x_axis = br - bl;
|
||||
let y_axis = tl - bl;
|
||||
[x_axis.x, x_axis.y, y_axis.x, y_axis.y, bl.x, bl.y]
|
||||
}
|
||||
|
||||
/// Anzahl der Bildkomponenten eines JPEG laut Frame-Header (1 = Graustufen, 3 = Farbe,
|
||||
/// 4 = CMYK), `None` bei unlesbarem Header.
|
||||
fn jpeg_components(bytes: &[u8]) -> Option<u8> {
|
||||
let mut i = 2;
|
||||
while i + 9 < bytes.len() {
|
||||
if bytes[i] != 0xFF {
|
||||
return None;
|
||||
}
|
||||
let marker = bytes[i + 1];
|
||||
let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
|
||||
// SOF0–SOF15 außer DHT (C4), JPG (C8) und DAC (CC)
|
||||
if (0xC0..=0xCF).contains(&marker) && !matches!(marker, 0xC4 | 0xC8 | 0xCC) {
|
||||
return bytes.get(i + 9).copied();
|
||||
}
|
||||
i += 2 + len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Bilddaten für das PDF: JPEG-Dateien werden unverändert eingebettet, alles andere als
|
||||
/// RGB auf weißem Grund (Transparenz wird mit dem Papier verrechnet), höchstens mit
|
||||
/// `PDF_IMAGE_DPI` bezogen auf die Größe auf der Seite.
|
||||
fn pdf_image_xobject(
|
||||
assets: &AssetStore,
|
||||
asset: &str,
|
||||
corners: &[glam::Vec2; 4],
|
||||
) -> Result<printpdf::ImageXObject, Box<dyn Error>> {
|
||||
use printpdf::{ColorBits, ColorSpace, ImageFilter, ImageXObject, Px};
|
||||
|
||||
let bytes = assets.read_bytes(asset)?;
|
||||
let decoded = image::load_from_memory(&bytes)?;
|
||||
|
||||
if asset.ends_with(".jpg") {
|
||||
let color_space = match jpeg_components(&bytes) {
|
||||
Some(1) => Some(ColorSpace::Greyscale),
|
||||
Some(3) => Some(ColorSpace::Rgb),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(color_space) = color_space {
|
||||
return Ok(ImageXObject {
|
||||
width: Px(decoded.width() as usize),
|
||||
height: Px(decoded.height() as usize),
|
||||
color_space,
|
||||
bits_per_component: ColorBits::Bit8,
|
||||
interpolate: true,
|
||||
image_data: bytes,
|
||||
image_filter: Some(ImageFilter::DCT),
|
||||
smask: None,
|
||||
clipping_bbox: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Größe auf der Seite in Zoll → benötigte Pixel
|
||||
let width_in = (corners[1] - corners[0]).length() / PAGE_WIDTH * A4_WIDTH_MM / 25.4;
|
||||
let height_in = (corners[3] - corners[0]).length() / PAGE_WIDTH * A4_WIDTH_MM / 25.4;
|
||||
let max_w = ((width_in * PDF_IMAGE_DPI).ceil() as u32).max(1);
|
||||
let max_h = ((height_in * PDF_IMAGE_DPI).ceil() as u32).max(1);
|
||||
let decoded = if decoded.width() > max_w || decoded.height() > max_h {
|
||||
decoded.resize(max_w, max_h, image::imageops::FilterType::Triangle)
|
||||
} else {
|
||||
decoded
|
||||
};
|
||||
|
||||
let rgba = decoded.into_rgba8();
|
||||
let mut rgb = Vec::with_capacity((rgba.width() * rgba.height() * 3) as usize);
|
||||
for px in rgba.pixels() {
|
||||
let alpha = px[3] as u32;
|
||||
for c in &px.0[..3] {
|
||||
rgb.push(((*c as u32 * alpha + 255 * (255 - alpha)) / 255) as u8);
|
||||
}
|
||||
}
|
||||
Ok(rgb_xobject(rgba.width(), rgba.height(), rgb))
|
||||
}
|
||||
|
||||
/// Rendert die eingefügten Bilder einer Seite. Fehlt eine Bilddatei, erscheint an ihrer
|
||||
/// Stelle eine graue Fläche, damit der Export nicht abbricht.
|
||||
fn render_image_layer(
|
||||
layer: &printpdf::PdfLayerReference,
|
||||
page: &Page,
|
||||
assets: Option<&AssetStore>,
|
||||
) {
|
||||
for stroke in &page.strokes {
|
||||
let (Some(asset), Some(corners)) = (stroke.image.as_deref(), stroke.image_corners()) else {
|
||||
continue;
|
||||
};
|
||||
let xobject = match assets.map(|a| pdf_image_xobject(a, asset, &corners)) {
|
||||
Some(Ok(xobject)) => xobject,
|
||||
other => {
|
||||
if let Some(Err(err)) = other {
|
||||
eprintln!("[PDF-Export] Bild '{asset}' fehlt oder ist beschädigt: {err}");
|
||||
}
|
||||
placeholder_xobject()
|
||||
}
|
||||
};
|
||||
draw_image_xobject(layer, xobject, &corners);
|
||||
}
|
||||
}
|
||||
|
||||
/// Graue 1×1-Fläche für fehlende Bilder.
|
||||
fn placeholder_xobject() -> printpdf::ImageXObject {
|
||||
rgb_xobject(1, 1, vec![200, 200, 200])
|
||||
}
|
||||
|
||||
fn rgb_xobject(width: u32, height: u32, rgb: Vec<u8>) -> printpdf::ImageXObject {
|
||||
printpdf::ImageXObject {
|
||||
width: printpdf::Px(width as usize),
|
||||
height: printpdf::Px(height as usize),
|
||||
color_space: printpdf::ColorSpace::Rgb,
|
||||
bits_per_component: printpdf::ColorBits::Bit8,
|
||||
interpolate: true,
|
||||
image_data: rgb,
|
||||
image_filter: None,
|
||||
smask: None,
|
||||
clipping_bbox: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Zeichnet ein Bild so, dass es die Ecken oben links, oben rechts, unten rechts,
|
||||
/// unten links ausfüllt.
|
||||
fn draw_image_xobject(
|
||||
layer: &printpdf::PdfLayerReference,
|
||||
xobject: printpdf::ImageXObject,
|
||||
corners: &[glam::Vec2; 4],
|
||||
) {
|
||||
let (width, height) = (xobject.width.0 as f32, xobject.height.0 as f32);
|
||||
layer.save_graphics_state();
|
||||
layer.set_ctm(printpdf::CurTransMat::Raw(image_matrix(corners)));
|
||||
// Bei 72 dpi ist ein Pixel ein Punkt; die Skalierung bildet das Bild auf das
|
||||
// Einheitsquadrat ab, die Matrix oben auf die Bildecken
|
||||
printpdf::Image::from(xobject).add_to_layer(
|
||||
layer.clone(),
|
||||
printpdf::ImageTransform {
|
||||
dpi: Some(72.0),
|
||||
scale_x: Some(1.0 / width),
|
||||
scale_y: Some(1.0 / height),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
layer.restore_graphics_state();
|
||||
}
|
||||
|
||||
/// Auflösung, mit der PDF-Seiten gerendert werden, die nicht als Vektor übernommen
|
||||
/// werden können.
|
||||
const PDF_RASTER_DPI: f32 = 200.0;
|
||||
|
||||
/// Bettet die importierte PDF-Seite als gerendertes Bild ein (Ausweichlösung).
|
||||
fn render_pdf_raster(
|
||||
layer: &printpdf::PdfLayerReference,
|
||||
page: &Page,
|
||||
assets: Option<&AssetStore>,
|
||||
) {
|
||||
let Some(pdf) = &page.pdf_background else {
|
||||
return;
|
||||
};
|
||||
let longest_pt = pdf.width_pt.max(pdf.height_pt);
|
||||
let max_pixels = (longest_pt / 72.0 * PDF_RASTER_DPI).ceil() as u32;
|
||||
let rendered = assets
|
||||
.ok_or_else(|| "kein Asset-Ordner".to_string())
|
||||
.and_then(|a| a.path_of(&pdf.asset).map_err(|e| e.to_string()))
|
||||
.and_then(|path| crate::pdf_doc::render_file_page(&path, pdf.page as usize, max_pixels));
|
||||
let xobject = match rendered {
|
||||
Ok(image) => {
|
||||
let rgb = image
|
||||
.pixels
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.flat_map(|px| [px[0], px[1], px[2]])
|
||||
.collect();
|
||||
rgb_xobject(image.width, image.height, rgb)
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"[PDF-Export] PDF-Seite {} nicht darstellbar: {err}",
|
||||
pdf.page + 1
|
||||
);
|
||||
placeholder_xobject()
|
||||
}
|
||||
};
|
||||
draw_image_xobject(layer, xobject, &pdf.corners());
|
||||
}
|
||||
|
||||
/// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem)
|
||||
/// als verlustfreien Vektorpfad im PDF.
|
||||
fn render_recognized_shape(
|
||||
@@ -329,6 +628,59 @@ fn render_recognized_shape(
|
||||
layer.add_polygon(poly);
|
||||
}
|
||||
|
||||
RecognizedShape::Ellipse {
|
||||
center,
|
||||
radius_x,
|
||||
radius_y,
|
||||
rotation,
|
||||
} => {
|
||||
let ring = (0..72)
|
||||
.map(|i| {
|
||||
let t = i as f32 / 72.0 * std::f32::consts::TAU;
|
||||
let p =
|
||||
crate::geometry::ellipse_point(*center, *radius_x, *radius_y, *rotation, t);
|
||||
let (x, y) = page_to_pdf_mm(p.x, p.y);
|
||||
(Point::new(Mm(x), Mm(y)), false)
|
||||
})
|
||||
.collect();
|
||||
layer.add_polygon(Polygon {
|
||||
rings: vec![ring],
|
||||
mode: PaintMode::Stroke,
|
||||
winding_order: WindingOrder::NonZero,
|
||||
});
|
||||
}
|
||||
|
||||
RecognizedShape::Arrow { start, end, double } => {
|
||||
// Gleiche Spitzen wie auf dem Bildschirm; der Schaft endet an ihrer Basis
|
||||
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
|
||||
let shaft_len = (*end - *start).length();
|
||||
let dir = (*end - *start).normalize_or_zero();
|
||||
let end_head = crate::geometry::arrow_head(*end, dir, PDF_SHAPE_HALF_WIDTH, shaft_len);
|
||||
let start_head =
|
||||
crate::geometry::arrow_head(*start, -dir, PDF_SHAPE_HALF_WIDTH, shaft_len);
|
||||
let line_start = if *double { start_head.0 } else { *start };
|
||||
let to_pdf = |p: glam::Vec2| {
|
||||
let (x, y) = page_to_pdf_mm(p.x, p.y);
|
||||
(Point::new(Mm(x), Mm(y)), false)
|
||||
};
|
||||
layer.add_line(Line {
|
||||
points: vec![to_pdf(line_start), to_pdf(end_head.0)],
|
||||
is_closed: false,
|
||||
});
|
||||
layer.set_fill_color(stroke_color);
|
||||
let mut heads = vec![(*end, end_head)];
|
||||
if *double {
|
||||
heads.push((*start, start_head));
|
||||
}
|
||||
for (tip, (_, left, right)) in heads {
|
||||
layer.add_polygon(Polygon {
|
||||
rings: vec![vec![to_pdf(tip), to_pdf(left), to_pdf(right)]],
|
||||
mode: PaintMode::Fill,
|
||||
winding_order: WindingOrder::NonZero,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
RecognizedShape::Rectangle { corners } => {
|
||||
let mut ring = Vec::with_capacity(4);
|
||||
for c in corners {
|
||||
@@ -412,76 +764,58 @@ fn render_recognized_shape(
|
||||
y_axis_end,
|
||||
with_arrows,
|
||||
} => {
|
||||
// Gleiche Pfeilspitzen wie auf dem Bildschirm; Achsen enden an der Basis der Spitze
|
||||
// (Linienstärke 1,5 pt ≈ 5,3 Seiteneinheiten, halbe Breite ≈ 2,65)
|
||||
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
|
||||
let y_dir = (*y_axis_end - *origin).normalize_or_zero();
|
||||
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
|
||||
let y_arrow = crate::geometry::arrow_head(
|
||||
*y_axis_end,
|
||||
y_dir,
|
||||
PDF_SHAPE_HALF_WIDTH,
|
||||
(*y_axis_end - *origin).length(),
|
||||
);
|
||||
let x_arrow = crate::geometry::arrow_head(
|
||||
*x_axis_end,
|
||||
x_dir,
|
||||
PDF_SHAPE_HALF_WIDTH,
|
||||
(*x_axis_end - *origin).length(),
|
||||
);
|
||||
let (y_line_end, x_line_end) = if *with_arrows {
|
||||
(y_arrow.0, x_arrow.0)
|
||||
} else {
|
||||
(*y_axis_end, *x_axis_end)
|
||||
};
|
||||
|
||||
let (ox, oy) = page_to_pdf_mm(origin.x, origin.y);
|
||||
let (xx, xy) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y);
|
||||
let (yx, yy) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y);
|
||||
|
||||
// Achse 1 (Y)
|
||||
let line_y = Line {
|
||||
points: vec![
|
||||
(Point::new(Mm(ox), Mm(oy)), false),
|
||||
(Point::new(Mm(yx), Mm(yy)), false),
|
||||
],
|
||||
is_closed: false,
|
||||
};
|
||||
layer.add_line(line_y);
|
||||
|
||||
// Achse 2 (X)
|
||||
let line_x = Line {
|
||||
points: vec![
|
||||
(Point::new(Mm(ox), Mm(oy)), false),
|
||||
(Point::new(Mm(xx), Mm(xy)), false),
|
||||
],
|
||||
is_closed: false,
|
||||
};
|
||||
layer.add_line(line_x);
|
||||
for end in [y_line_end, x_line_end] {
|
||||
let (ex, ey) = page_to_pdf_mm(end.x, end.y);
|
||||
layer.add_line(Line {
|
||||
points: vec![
|
||||
(Point::new(Mm(ox), Mm(oy)), false),
|
||||
(Point::new(Mm(ex), Mm(ey)), false),
|
||||
],
|
||||
is_closed: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Pfeilspitzen als gefüllte Dreiecke
|
||||
if *with_arrows {
|
||||
layer.set_fill_color(stroke_color);
|
||||
|
||||
let y_dir = (*y_axis_end - *origin).normalize_or_zero();
|
||||
let arrow_len_page = 25.0;
|
||||
let arrow_w_page = 16.0;
|
||||
|
||||
let base_y = *y_axis_end - y_dir * arrow_len_page;
|
||||
let left_y = base_y + glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5);
|
||||
let right_y = base_y - glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5);
|
||||
|
||||
let (tx, ty) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y);
|
||||
let (lx, ly) = page_to_pdf_mm(left_y.x, left_y.y);
|
||||
let (rx, ry) = page_to_pdf_mm(right_y.x, right_y.y);
|
||||
|
||||
let arrow_poly_y = Polygon {
|
||||
rings: vec![vec![
|
||||
(Point::new(Mm(tx), Mm(ty)), false),
|
||||
(Point::new(Mm(lx), Mm(ly)), false),
|
||||
(Point::new(Mm(rx), Mm(ry)), false),
|
||||
]],
|
||||
mode: PaintMode::Fill,
|
||||
winding_order: WindingOrder::NonZero,
|
||||
};
|
||||
layer.add_polygon(arrow_poly_y);
|
||||
|
||||
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
|
||||
let base_x = *x_axis_end - x_dir * arrow_len_page;
|
||||
let left_x = base_x + glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5);
|
||||
let right_x = base_x - glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5);
|
||||
|
||||
let (tx_x, ty_x) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y);
|
||||
let (lx_x, ly_x) = page_to_pdf_mm(left_x.x, left_x.y);
|
||||
let (rx_x, ry_x) = page_to_pdf_mm(right_x.x, right_x.y);
|
||||
|
||||
let arrow_poly_x = Polygon {
|
||||
rings: vec![vec![
|
||||
(Point::new(Mm(tx_x), Mm(ty_x)), false),
|
||||
(Point::new(Mm(lx_x), Mm(ly_x)), false),
|
||||
(Point::new(Mm(rx_x), Mm(ry_x)), false),
|
||||
]],
|
||||
mode: PaintMode::Fill,
|
||||
winding_order: WindingOrder::NonZero,
|
||||
};
|
||||
layer.add_polygon(arrow_poly_x);
|
||||
for (tip, (_, left, right)) in [(*y_axis_end, y_arrow), (*x_axis_end, x_arrow)] {
|
||||
let ring = [tip, left, right]
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (px, py) = page_to_pdf_mm(p.x, p.y);
|
||||
(Point::new(Mm(px), Mm(py)), false)
|
||||
})
|
||||
.collect();
|
||||
layer.add_polygon(Polygon {
|
||||
rings: vec![ring],
|
||||
mode: PaintMode::Fill,
|
||||
winding_order: WindingOrder::NonZero,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -759,7 +1093,7 @@ mod tests {
|
||||
let pdf_path = temp_dir.join("ink_notes_test_export.pdf");
|
||||
|
||||
let export_res =
|
||||
export_notebook_to_pdf(¬ebook, &pdf_path, ExportStrategy::FilledPolygon);
|
||||
export_notebook_to_pdf(¬ebook, &pdf_path, ExportStrategy::FilledPolygon, None);
|
||||
assert!(
|
||||
export_res.is_ok(),
|
||||
"PDF-Export zu Datei muss erfolgreich sein"
|
||||
@@ -785,7 +1119,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// 3. Export als Bytes testen (Strategie B: BezierCurve)
|
||||
let bytes_res = export_notebook_to_bytes(¬ebook, ExportStrategy::BezierCurve);
|
||||
let bytes_res = export_notebook_to_bytes(¬ebook, ExportStrategy::BezierCurve, None);
|
||||
assert!(bytes_res.is_ok());
|
||||
let bytes = bytes_res.unwrap();
|
||||
assert!(bytes.len() > 1024);
|
||||
@@ -838,6 +1172,27 @@ mod tests {
|
||||
);
|
||||
page.add_stroke(coords_stroke);
|
||||
|
||||
let ellipse_stroke = Stroke::new(40, vec![], vec![], [0.1, 0.5, 0.5, 1.0]).with_shape(
|
||||
crate::geometry::RecognizedShape::Ellipse {
|
||||
center: glam::Vec2::new(1400.0, 600.0),
|
||||
radius_x: 300.0,
|
||||
radius_y: 150.0,
|
||||
rotation: 0.4,
|
||||
},
|
||||
);
|
||||
page.add_stroke(ellipse_stroke);
|
||||
|
||||
for (id, double) in [(41, false), (42, true)] {
|
||||
let arrow_stroke = Stroke::new(id, vec![], vec![], [0.2, 0.2, 0.2, 1.0]).with_shape(
|
||||
crate::geometry::RecognizedShape::Arrow {
|
||||
start: glam::Vec2::new(1200.0, 1800.0 + id as f32 * 40.0),
|
||||
end: glam::Vec2::new(1800.0, 1800.0 + id as f32 * 40.0),
|
||||
double,
|
||||
},
|
||||
);
|
||||
page.add_stroke(arrow_stroke);
|
||||
}
|
||||
|
||||
let tri_stroke = Stroke::new(5, vec![], vec![], [0.6, 0.2, 0.7, 1.0]).with_shape(
|
||||
crate::geometry::RecognizedShape::Triangle {
|
||||
corners: [
|
||||
@@ -887,13 +1242,181 @@ mod tests {
|
||||
);
|
||||
page.add_stroke(octahedron_stroke);
|
||||
|
||||
let bytes_res = export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon);
|
||||
let bytes_res = export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, None);
|
||||
assert!(bytes_res.is_ok(), "Shape export must succeed");
|
||||
let bytes = bytes_res.unwrap();
|
||||
assert!(bytes.len() > 1024);
|
||||
assert!(bytes.starts_with(b"%PDF-1."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_matrix_maps_unit_square_to_corners() {
|
||||
// Bild über die volle Seitenbreite, oberes Viertel der Seite
|
||||
let corners = [
|
||||
glam::Vec2::new(0.0, 0.0),
|
||||
glam::Vec2::new(PAGE_WIDTH, 0.0),
|
||||
glam::Vec2::new(PAGE_WIDTH, PAGE_HEIGHT / 4.0),
|
||||
glam::Vec2::new(0.0, PAGE_HEIGHT / 4.0),
|
||||
];
|
||||
let [a, b, c, d, e, f] = image_matrix(&corners);
|
||||
let page_w_pt = A4_WIDTH_MM * 72.0 / 25.4;
|
||||
let page_h_pt = A4_HEIGHT_MM * 72.0 / 25.4;
|
||||
let map = |u: f32, v: f32| (a * u + c * v + e, b * u + d * v + f);
|
||||
let close = |(x, y): (f32, f32), (ex, ey): (f32, f32)| {
|
||||
(x - ex).abs() < 0.01 && (y - ey).abs() < 0.01
|
||||
};
|
||||
// Unten links des Bildes, oben rechts des Bildes (PDF-Ursprung unten links)
|
||||
assert!(close(map(0.0, 0.0), (0.0, page_h_pt * 0.75)));
|
||||
assert!(close(map(1.0, 1.0), (page_w_pt, page_h_pt)));
|
||||
// Gedrehtes Bild: Achsen folgen den Ecken
|
||||
let rotated = [
|
||||
glam::Vec2::new(500.0, 500.0),
|
||||
glam::Vec2::new(500.0, 900.0),
|
||||
glam::Vec2::new(300.0, 900.0),
|
||||
glam::Vec2::new(300.0, 500.0),
|
||||
];
|
||||
let m = image_matrix(&rotated);
|
||||
assert!(
|
||||
m[0].abs() < 1e-3 && m[1] < 0.0,
|
||||
"x-Achse zeigt nach unten: {m:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pdf_export_embeds_images() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let assets = AssetStore::new(dir.path());
|
||||
let jpg = crate::assets::tests::sample_image_bytes(64, 48, image::ImageFormat::Jpeg);
|
||||
let png = crate::assets::tests::sample_image_bytes(30, 20, image::ImageFormat::Png);
|
||||
let jpg_asset = assets.import_bytes(&jpg).unwrap().asset;
|
||||
let png_asset = assets.import_bytes(&png).unwrap().asset;
|
||||
|
||||
let mut notebook = Notebook::new();
|
||||
let page = notebook.current_page_mut();
|
||||
for (id, asset) in [
|
||||
(1, jpg_asset),
|
||||
(2, png_asset),
|
||||
(3, format!("{}.png", "0".repeat(64))),
|
||||
] {
|
||||
let y = id as f32 * 400.0;
|
||||
page.strokes.push(Stroke::new_image(
|
||||
id,
|
||||
asset,
|
||||
[
|
||||
glam::Vec2::new(100.0, y),
|
||||
glam::Vec2::new(700.0, y),
|
||||
glam::Vec2::new(700.0, y + 300.0),
|
||||
glam::Vec2::new(100.0, y + 300.0),
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
let bytes =
|
||||
export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, Some(&assets))
|
||||
.expect("Export mit Bildern");
|
||||
let contains = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
|
||||
assert!(
|
||||
contains(b"/DCTDecode"),
|
||||
"JPEG wird nicht unverändert eingebettet"
|
||||
);
|
||||
// JPEG-Daten liegen byte-identisch im PDF
|
||||
assert!(contains(&jpg[..64]));
|
||||
assert!(
|
||||
contains(b"/Images") || contains(b"(Images)"),
|
||||
"Bild-Ebene fehlt"
|
||||
);
|
||||
let image_objects = bytes
|
||||
.windows(b"/Subtype/Image".len())
|
||||
.filter(|w| *w == b"/Subtype/Image")
|
||||
.count()
|
||||
+ bytes
|
||||
.windows(b"/Subtype /Image".len())
|
||||
.filter(|w| *w == b"/Subtype /Image")
|
||||
.count();
|
||||
// JPEG, PNG und Ersatzfläche für die fehlende Datei
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pdf_export_underlays_imported_pages() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let source = crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (595.0, 842.0, 90)]);
|
||||
let (mut notebook, _, dir) =
|
||||
crate::storage::create_notebook_from_pdf(temp_dir.path(), "Folien", source).unwrap();
|
||||
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 doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap();
|
||||
assert_eq!(doc.get_pages().len(), 2);
|
||||
for page_id in doc.get_pages().into_values() {
|
||||
let content = doc.get_page_content(page_id).unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(&content).starts_with("q "),
|
||||
"Originalseite fehlt als Unterlage"
|
||||
);
|
||||
}
|
||||
|
||||
// Exportiertes PDF lässt sich wieder rendern: Folie oben, rotes Quadrat oben links
|
||||
let pdf = hayro::hayro_syntax::Pdf::new(bytes).unwrap();
|
||||
let image = crate::pdf_doc::render_page(&pdf, 0, 600).unwrap();
|
||||
let px = |x: u32, y: u32| {
|
||||
let i = ((y * image.width + x) * 4) as usize;
|
||||
image.pixels[i..i + 3].to_vec()
|
||||
};
|
||||
assert_eq!(px(5, 5), vec![255, 0, 0], "Quadrat der Folie oben links");
|
||||
assert_eq!(
|
||||
px(200, 500),
|
||||
vec![255, 255, 255],
|
||||
"unter der Folie ist Platz"
|
||||
);
|
||||
|
||||
// Gedrehte Seite (/Rotate 90) erscheint wie im PDF-Betrachter: das Quadrat liegt
|
||||
// dort oben rechts, in der Quelle wie im Export
|
||||
let source_pdf = hayro::hayro_syntax::Pdf::new(
|
||||
assets
|
||||
.read_bytes(¬ebook.pages[1].pdf_background.as_ref().unwrap().asset)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let shown = crate::pdf_doc::render_page(&source_pdf, 1, 600).unwrap();
|
||||
let red_at = |img: &crate::assets::RgbaImage, x: u32, y: u32| {
|
||||
let i = ((y * img.width + x) * 4) as usize;
|
||||
img.pixels[i..i + 3] == [255, 0, 0]
|
||||
};
|
||||
assert!(red_at(&shown, shown.width - 5, 5));
|
||||
let exported = crate::pdf_doc::render_page(&pdf, 1, 600).unwrap();
|
||||
assert!(red_at(&exported, exported.width - 5, 5));
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jpeg_component_count() {
|
||||
let color = crate::assets::tests::sample_image_bytes(8, 8, image::ImageFormat::Jpeg);
|
||||
assert_eq!(jpeg_components(&color), Some(3));
|
||||
let gray = {
|
||||
let img = image::GrayImage::from_pixel(8, 8, image::Luma([90]));
|
||||
let mut out = Vec::new();
|
||||
image::DynamicImage::ImageLuma8(img)
|
||||
.write_to(
|
||||
&mut std::io::Cursor::new(&mut out),
|
||||
image::ImageFormat::Jpeg,
|
||||
)
|
||||
.unwrap();
|
||||
out
|
||||
};
|
||||
assert_eq!(jpeg_components(&gray), Some(1));
|
||||
assert_eq!(jpeg_components(b"\xFF\xD8kaputt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_filename_component() {
|
||||
assert_eq!(sanitize_filename_component("Notizbuch 1"), "Notizbuch 1");
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder und PDFs.
|
||||
//!
|
||||
//! Bewusst ohne Systemdialog: der bräuchte GTK oder das Desktop-Portal, die unter WSL und auf
|
||||
//! schlanken Systemen oft fehlen. Die Auswahl zeigt Ordner und die passenden Dateien des
|
||||
//! aktuellen Ordners; versteckte Dateien bleiben ausgeblendet.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::assets::is_supported_image_path;
|
||||
|
||||
/// Ein Eintrag der Dateiliste.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PickerEntry {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
/// Welche Dateien die Auswahl anbietet.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PickerKind {
|
||||
/// Bilder zum Einfügen auf der aktuellen Seite
|
||||
Image,
|
||||
/// PDF zum Importieren als neues Notizbuch
|
||||
Pdf,
|
||||
}
|
||||
|
||||
impl PickerKind {
|
||||
/// Passt die Datei zu dieser Auswahl?
|
||||
pub fn accepts(self, path: &Path) -> bool {
|
||||
match self {
|
||||
PickerKind::Image => is_supported_image_path(path),
|
||||
PickerKind::Pdf => is_pdf_path(path),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ist die Datei dem Namen nach ein PDF?
|
||||
pub fn is_pdf_path(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("pdf"))
|
||||
}
|
||||
|
||||
/// Zustand der Dateiauswahl.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FilePicker {
|
||||
pub kind: PickerKind,
|
||||
pub dir: PathBuf,
|
||||
pub entries: Vec<PickerEntry>,
|
||||
/// Ordner nicht lesbar
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl FilePicker {
|
||||
/// Öffnet die Auswahl in `dir`, sonst im Bilder-Ordner (Bilder) bzw. Downloads (PDF),
|
||||
/// sonst im Home-Ordner.
|
||||
pub fn open(kind: PickerKind, dir: Option<&Path>) -> Self {
|
||||
let start = dir
|
||||
.filter(|d| d.is_dir())
|
||||
.map(Path::to_path_buf)
|
||||
.or_else(|| {
|
||||
let preferred = match kind {
|
||||
PickerKind::Image => "🖼 Bilder",
|
||||
PickerKind::Pdf => "⬇ Downloads",
|
||||
};
|
||||
let all = shortcuts();
|
||||
all.iter()
|
||||
.find(|(label, _)| *label == preferred)
|
||||
.or_else(|| all.last())
|
||||
.map(|(_, p)| p.clone())
|
||||
})
|
||||
.unwrap_or_else(|| PathBuf::from("/"));
|
||||
let mut picker = Self {
|
||||
kind,
|
||||
dir: start.clone(),
|
||||
entries: Vec::new(),
|
||||
error: None,
|
||||
};
|
||||
picker.navigate(&start);
|
||||
picker
|
||||
}
|
||||
|
||||
/// Wechselt in `dir` und liest den Inhalt neu ein.
|
||||
pub fn navigate(&mut self, dir: &Path) {
|
||||
match list_dir(dir, self.kind) {
|
||||
Ok(entries) => {
|
||||
self.dir = dir.to_path_buf();
|
||||
self.entries = entries;
|
||||
self.error = None;
|
||||
}
|
||||
Err(err) => {
|
||||
self.error = Some(format!(
|
||||
"'{}' kann nicht geöffnet werden: {err}",
|
||||
dir.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Ebene nach oben.
|
||||
pub fn go_up(&mut self) {
|
||||
if let Some(parent) = self.dir.parent().map(Path::to_path_buf) {
|
||||
self.navigate(&parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ordner und passende Dateien in `dir`: erst Ordner, dann Dateien, jeweils alphabetisch
|
||||
/// ohne Groß-/Kleinschreibung; versteckte Einträge (Punkt am Anfang) fehlen.
|
||||
pub fn list_dir(dir: &Path, kind: PickerKind) -> std::io::Result<Vec<PickerEntry>> {
|
||||
let mut entries: Vec<PickerEntry> = std::fs::read_dir(dir)?
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|entry| {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if name.starts_with('.') {
|
||||
return None;
|
||||
}
|
||||
let path = entry.path();
|
||||
// Symlinks auflösen: ein Link auf einen Ordner ist ein Ordner
|
||||
let is_dir = path.is_dir();
|
||||
(is_dir || kind.accepts(&path)).then_some(PickerEntry { name, path, is_dir })
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by(|a, b| {
|
||||
b.is_dir
|
||||
.cmp(&a.is_dir)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Schnellzugriffe: Bilder, Downloads, Schreibtisch und Home-Ordner (soweit vorhanden),
|
||||
/// mit den Namen aus `~/.config/user-dirs.dirs` (z. B. „Bilder“ statt „Pictures“).
|
||||
pub fn shortcuts() -> Vec<(&'static str, PathBuf)> {
|
||||
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let user_dirs =
|
||||
std::fs::read_to_string(home.join(".config/user-dirs.dirs")).unwrap_or_default();
|
||||
let xdg = |key: &str, fallbacks: &[&str]| -> Option<PathBuf> {
|
||||
parse_user_dir(&user_dirs, key, &home)
|
||||
.into_iter()
|
||||
.chain(fallbacks.iter().map(|f| home.join(f)))
|
||||
.find(|p| p.is_dir() && *p != home)
|
||||
};
|
||||
let mut list = Vec::new();
|
||||
if let Some(p) = xdg("XDG_PICTURES_DIR", &["Bilder", "Pictures"]) {
|
||||
list.push(("🖼 Bilder", p));
|
||||
}
|
||||
if let Some(p) = xdg("XDG_DOWNLOAD_DIR", &["Downloads"]) {
|
||||
list.push(("⬇ Downloads", p));
|
||||
}
|
||||
if let Some(p) = xdg("XDG_DESKTOP_DIR", &["Schreibtisch", "Desktop"]) {
|
||||
list.push(("🖥 Schreibtisch", p));
|
||||
}
|
||||
list.push(("🏠 Persönlicher Ordner", home));
|
||||
list
|
||||
}
|
||||
|
||||
/// Liest einen Eintrag wie `XDG_PICTURES_DIR="$HOME/Bilder"` aus `user-dirs.dirs`.
|
||||
pub fn parse_user_dir(content: &str, key: &str, home: &Path) -> Option<PathBuf> {
|
||||
content.lines().find_map(|line| {
|
||||
let value = line
|
||||
.trim()
|
||||
.strip_prefix(key)?
|
||||
.trim_start()
|
||||
.strip_prefix('=')?;
|
||||
let value = value.trim().trim_matches('"');
|
||||
let path = match value.strip_prefix("$HOME") {
|
||||
Some(rest) => home.join(rest.trim_start_matches('/')),
|
||||
None => PathBuf::from(value),
|
||||
};
|
||||
path.is_absolute().then_some(path)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_list_dir_sorts_and_filters() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for name in [
|
||||
"b.png",
|
||||
"A.jpg",
|
||||
"notiz.txt",
|
||||
".versteckt.png",
|
||||
"c.webp",
|
||||
"Skript.PDF",
|
||||
] {
|
||||
std::fs::write(dir.path().join(name), b"x").unwrap();
|
||||
}
|
||||
std::fs::create_dir(dir.path().join("zOrdner")).unwrap();
|
||||
std::fs::create_dir(dir.path().join("aOrdner")).unwrap();
|
||||
std::fs::create_dir(dir.path().join(".cache")).unwrap();
|
||||
|
||||
let names = |kind| -> Vec<String> {
|
||||
list_dir(dir.path(), kind)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|e| e.name)
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
names(PickerKind::Image),
|
||||
["aOrdner", "zOrdner", "A.jpg", "b.png", "c.webp"]
|
||||
);
|
||||
assert_eq!(names(PickerKind::Pdf), ["aOrdner", "zOrdner", "Skript.PDF"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigation_and_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sub = dir.path().join("Fotos");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
std::fs::write(sub.join("tafel.jpg"), b"x").unwrap();
|
||||
|
||||
let mut picker = FilePicker::open(PickerKind::Image, Some(&sub));
|
||||
assert_eq!(picker.dir, sub);
|
||||
assert_eq!(picker.entries.len(), 1);
|
||||
|
||||
picker.go_up();
|
||||
assert_eq!(picker.dir, dir.path());
|
||||
assert!(picker.entries.iter().any(|e| e.is_dir && e.name == "Fotos"));
|
||||
|
||||
// Nicht vorhandener Ordner: Fehlermeldung, aktueller Ordner bleibt
|
||||
picker.navigate(&dir.path().join("gibt-es-nicht"));
|
||||
assert!(picker.error.is_some());
|
||||
assert_eq!(picker.dir, dir.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_user_dirs() {
|
||||
let home = Path::new("/home/anna");
|
||||
let content = "# Kommentar\nXDG_DESKTOP_DIR=\"$HOME/Schreibtisch\"\nXDG_PICTURES_DIR=\"$HOME/Bilder\"\nXDG_MUSIC_DIR=\"/daten/Musik\"\n";
|
||||
assert_eq!(
|
||||
parse_user_dir(content, "XDG_PICTURES_DIR", home),
|
||||
Some(PathBuf::from("/home/anna/Bilder"))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_user_dir(content, "XDG_MUSIC_DIR", home),
|
||||
Some(PathBuf::from("/daten/Musik"))
|
||||
);
|
||||
assert_eq!(parse_user_dir(content, "XDG_DOWNLOAD_DIR", home), None);
|
||||
}
|
||||
}
|
||||
+632
-67
@@ -15,6 +15,8 @@ use crate::document::{Aabb, Stroke, StylusSample};
|
||||
pub enum ShapeKind {
|
||||
Line,
|
||||
Circle,
|
||||
Ellipse,
|
||||
Arrow,
|
||||
Rectangle,
|
||||
CoordinateSystem,
|
||||
Triangle,
|
||||
@@ -34,6 +36,20 @@ pub enum RecognizedShape {
|
||||
center: Vec2,
|
||||
radius: f32,
|
||||
},
|
||||
/// Ellipse mit den Halbachsen `radius_x` (entlang `rotation`) und `radius_y`.
|
||||
Ellipse {
|
||||
center: Vec2,
|
||||
radius_x: f32,
|
||||
radius_y: f32,
|
||||
/// Drehwinkel der ersten Halbachse zur x-Achse (Bogenmaß)
|
||||
rotation: f32,
|
||||
},
|
||||
/// Pfeil vom Schaftende `start` zur Spitze `end`; `double` = Spitzen an beiden Enden.
|
||||
Arrow {
|
||||
start: Vec2,
|
||||
end: Vec2,
|
||||
double: bool,
|
||||
},
|
||||
Rectangle {
|
||||
/// 4 Eckpunkte in umlaufender Reihenfolge (strikt rechtwinklig).
|
||||
corners: [Vec2; 4],
|
||||
@@ -70,6 +86,8 @@ impl RecognizedShape {
|
||||
match self {
|
||||
Self::Line { .. } => ShapeKind::Line,
|
||||
Self::Circle { .. } => ShapeKind::Circle,
|
||||
Self::Ellipse { .. } => ShapeKind::Ellipse,
|
||||
Self::Arrow { .. } => ShapeKind::Arrow,
|
||||
Self::Rectangle { .. } => ShapeKind::Rectangle,
|
||||
Self::CoordinateSystem { .. } => ShapeKind::CoordinateSystem,
|
||||
Self::Triangle { .. } => ShapeKind::Triangle,
|
||||
@@ -83,6 +101,9 @@ impl RecognizedShape {
|
||||
match self {
|
||||
Self::Line { .. } => "Gerade",
|
||||
Self::Circle { .. } => "Kreis",
|
||||
Self::Ellipse { .. } => "Ellipse",
|
||||
Self::Arrow { double: false, .. } => "Pfeil",
|
||||
Self::Arrow { double: true, .. } => "Doppelpfeil",
|
||||
Self::Rectangle { .. } => "Rechteck",
|
||||
Self::CoordinateSystem { .. } => "Koordinatensystem",
|
||||
Self::Triangle { .. } => "Dreieck",
|
||||
@@ -104,9 +125,13 @@ impl RecognizedShape {
|
||||
*start += delta;
|
||||
*end += delta;
|
||||
}
|
||||
Self::Circle { center, .. } => {
|
||||
Self::Circle { center, .. } | Self::Ellipse { center, .. } => {
|
||||
*center += delta;
|
||||
}
|
||||
Self::Arrow { start, end, .. } => {
|
||||
*start += delta;
|
||||
*end += delta;
|
||||
}
|
||||
Self::Rectangle { corners } | Self::Rhombus { corners } => {
|
||||
for c in corners {
|
||||
*c += delta;
|
||||
@@ -154,6 +179,16 @@ impl RecognizedShape {
|
||||
Self::Circle { center, .. } => {
|
||||
*center = rot(*center);
|
||||
}
|
||||
Self::Ellipse {
|
||||
center, rotation, ..
|
||||
} => {
|
||||
*center = rot(*center);
|
||||
*rotation += angle_rad;
|
||||
}
|
||||
Self::Arrow { start, end, .. } => {
|
||||
*start = rot(*start);
|
||||
*end = rot(*end);
|
||||
}
|
||||
Self::Rectangle { corners } | Self::Rhombus { corners } => {
|
||||
for c in corners {
|
||||
*c = rot(*c);
|
||||
@@ -198,6 +233,26 @@ impl RecognizedShape {
|
||||
*center = sc(*center);
|
||||
*radius *= (factors.x.abs() + factors.y.abs()) * 0.5;
|
||||
}
|
||||
Self::Ellipse {
|
||||
center,
|
||||
radius_x,
|
||||
radius_y,
|
||||
rotation,
|
||||
} => {
|
||||
// Halbachsen als Vektoren skalieren (bei schräger Ellipse und ungleicher
|
||||
// Skalierung eine gute Näherung)
|
||||
let (sin, cos) = rotation.sin_cos();
|
||||
let axis_x = Vec2::new(cos, sin) * *radius_x * factors;
|
||||
let axis_y = Vec2::new(-sin, cos) * *radius_y * factors;
|
||||
*center = sc(*center);
|
||||
*radius_x = axis_x.length();
|
||||
*radius_y = axis_y.length();
|
||||
*rotation = axis_x.y.atan2(axis_x.x);
|
||||
}
|
||||
Self::Arrow { start, end, .. } => {
|
||||
*start = sc(*start);
|
||||
*end = sc(*end);
|
||||
}
|
||||
Self::Rectangle { corners } | Self::Rhombus { corners } => {
|
||||
for c in corners {
|
||||
*c = sc(*c);
|
||||
@@ -912,6 +967,221 @@ pub fn detect_polygon(points: &[Vec2]) -> Option<RecognizedShape> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Tastet einen Linienzug gleichmäßig entlang der Bogenlänge neu ab (`samples` Abschnitte).
|
||||
fn resample_path(points: &[Vec2], samples: usize) -> Vec<Vec2> {
|
||||
let total_len = path_length(points);
|
||||
if points.is_empty() || total_len < 1e-3 || samples == 0 {
|
||||
return points.to_vec();
|
||||
}
|
||||
let step = total_len / samples as f32;
|
||||
let mut resampled = vec![points[0]];
|
||||
let mut carried = 0.0;
|
||||
for w in points.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let seg = (b - a).length();
|
||||
let mut pos = step - carried;
|
||||
while pos <= seg {
|
||||
resampled.push(a + (b - a) * (pos / seg));
|
||||
pos += step;
|
||||
}
|
||||
carried = seg - (pos - step);
|
||||
}
|
||||
resampled
|
||||
}
|
||||
|
||||
/// Ellipsen, deren kürzere Halbachse mindestens diesen Anteil der längeren hat, gelten als Kreis.
|
||||
const ELLIPSE_MAX_ROUNDNESS: f32 = 0.85;
|
||||
/// Ellipsen, die höchstens so viele Grad schief liegen, werden achsenparallel ausgerichtet.
|
||||
const ELLIPSE_AXIS_SNAP_DEG: f32 = 8.0;
|
||||
|
||||
/// Ellipsenerkennung: geschlossener Pfad, Achsen aus der Hauptkomponentenanalyse der
|
||||
/// gleichmäßig abgetasteten Punkte, Halbachsen aus der Ausdehnung entlang dieser Achsen
|
||||
/// (2.–98. Perzentil, robust gegen Überstriche). Fast runde Formen bleiben Kreise.
|
||||
pub fn detect_ellipse(points: &[Vec2]) -> Option<RecognizedShape> {
|
||||
if points.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let total_len = path_length(points);
|
||||
if total_len < 40.0 {
|
||||
return None;
|
||||
}
|
||||
let chord_len = (*points.last().unwrap() - points[0]).length();
|
||||
if chord_len > 0.35 * total_len {
|
||||
return None;
|
||||
}
|
||||
|
||||
let pts = resample_path(points, 120);
|
||||
let n = pts.len() as f32;
|
||||
let mean = pts.iter().copied().sum::<Vec2>() / n;
|
||||
let (mut sxx, mut syy, mut sxy) = (0.0_f32, 0.0_f32, 0.0_f32);
|
||||
for p in &pts {
|
||||
let d = *p - mean;
|
||||
sxx += d.x * d.x;
|
||||
syy += d.y * d.y;
|
||||
sxy += d.x * d.y;
|
||||
}
|
||||
let mut rotation = 0.5 * (2.0 * sxy).atan2(sxx - syy);
|
||||
|
||||
// Achsenparallel ausrichten, wenn nur leicht schief
|
||||
let deg = rotation.to_degrees().rem_euclid(90.0);
|
||||
if deg <= ELLIPSE_AXIS_SNAP_DEG || deg >= 90.0 - ELLIPSE_AXIS_SNAP_DEG {
|
||||
rotation = (rotation.to_degrees() / 90.0).round() * 90.0_f32.to_radians();
|
||||
}
|
||||
|
||||
let (sin, cos) = rotation.sin_cos();
|
||||
let mut along: Vec<f32> = pts.iter().map(|p| p.x * cos + p.y * sin).collect();
|
||||
let mut across: Vec<f32> = pts.iter().map(|p| -p.x * sin + p.y * cos).collect();
|
||||
along.sort_by(f32::total_cmp);
|
||||
across.sort_by(f32::total_cmp);
|
||||
let lo = pts.len() * 2 / 100;
|
||||
let hi = pts.len() - 1 - lo;
|
||||
let (a0, a1, b0, b1) = (along[lo], along[hi], across[lo], across[hi]);
|
||||
let mut radius_x = (a1 - a0) * 0.5;
|
||||
let mut radius_y = (b1 - b0) * 0.5;
|
||||
let center = Vec2::new(cos, sin) * (a0 + a1) * 0.5 + Vec2::new(-sin, cos) * (b0 + b1) * 0.5;
|
||||
if radius_x < 10.0 || radius_y < 5.0 {
|
||||
return None;
|
||||
}
|
||||
// Erste Halbachse ist die längere
|
||||
if radius_y > radius_x {
|
||||
std::mem::swap(&mut radius_x, &mut radius_y);
|
||||
rotation += std::f32::consts::FRAC_PI_2;
|
||||
}
|
||||
if radius_y / radius_x >= ELLIPSE_MAX_ROUNDNESS {
|
||||
return None;
|
||||
}
|
||||
// Drehwinkel auf (−90°, 90°] normieren
|
||||
if rotation > std::f32::consts::FRAC_PI_2 {
|
||||
rotation -= std::f32::consts::PI;
|
||||
} else if rotation <= -std::f32::consts::FRAC_PI_2 {
|
||||
rotation += std::f32::consts::PI;
|
||||
}
|
||||
|
||||
Some(RecognizedShape::Ellipse {
|
||||
center,
|
||||
radius_x,
|
||||
radius_y,
|
||||
rotation,
|
||||
})
|
||||
}
|
||||
|
||||
/// Höchster Abstand der Widerhaken von der Spitze, als Anteil der Schaftlänge.
|
||||
const ARROW_HEAD_REACH: f32 = 0.4;
|
||||
/// Punkte so nahe an der Spitze gelten als Rückweg zur Spitze, nicht als Widerhaken.
|
||||
const ARROW_TIP_ZONE: f32 = 0.08;
|
||||
|
||||
/// Pfeilerkennung (auch Doppelpfeil) für einen in einem Zug gezeichneten Pfeil: langer Schaft
|
||||
/// und an mindestens einem Ende Widerhaken auf beiden Seiten, schräg nach hinten (10°–80°;
|
||||
/// ein T-Ende mit 90° ist kein Pfeil).
|
||||
pub fn detect_arrow(points: &[Vec2]) -> Option<RecognizedShape> {
|
||||
if points.len() < 6 {
|
||||
return None;
|
||||
}
|
||||
let total_len = path_length(points);
|
||||
if total_len < 40.0 {
|
||||
return None;
|
||||
}
|
||||
let epsilon = (total_len * 0.02).clamp(3.0, 15.0);
|
||||
let simplified = ramer_douglas_peucker(points, epsilon);
|
||||
if simplified.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Schaft = längstes Segment, an beiden Enden um fast gerade weiterlaufende Punkte
|
||||
// verlängert (die Vereinfachung teilt den Schaft sonst gern kurz vor der Spitze)
|
||||
let longest = (0..simplified.len() - 1).max_by(|&i, &j| {
|
||||
let li = (simplified[i + 1] - simplified[i]).length();
|
||||
let lj = (simplified[j + 1] - simplified[j]).length();
|
||||
li.total_cmp(&lj)
|
||||
})?;
|
||||
let (mut first, mut last) = (longest, longest + 1);
|
||||
// Ein Folgepunkt gehört zum Schaft, wenn er nahe der Schaftgeraden in Verlängerung liegt
|
||||
// (kein Winkelkriterium: kurze Stücke haben durch das Zittern stark schwankende Richtungen)
|
||||
let continues = |from: Vec2, to: Vec2, next: Vec2| {
|
||||
let dir = (to - from).normalize_or_zero();
|
||||
let beyond = (next - to).dot(dir) > 0.0;
|
||||
let tolerance = (2.0 * epsilon).max(0.05 * (to - from).length());
|
||||
beyond && point_to_line_dist(next, from, to) <= tolerance
|
||||
};
|
||||
while last + 1 < simplified.len()
|
||||
&& continues(simplified[first], simplified[last], simplified[last + 1])
|
||||
{
|
||||
last += 1;
|
||||
}
|
||||
while first > 0 && continues(simplified[last], simplified[first], simplified[first - 1]) {
|
||||
first -= 1;
|
||||
}
|
||||
let (a, b) = (simplified[first], simplified[last]);
|
||||
let shaft_len = (b - a).length();
|
||||
if shaft_len < 0.4 * total_len {
|
||||
return None;
|
||||
}
|
||||
let others: Vec<Vec2> = simplified
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i < first || *i > last)
|
||||
.map(|(_, p)| *p)
|
||||
.collect();
|
||||
|
||||
// Hat das Schaftende `tip` (mit Gegenende `tail`) Widerhaken auf beiden Seiten?
|
||||
// Liefert None, wenn ein zugeordneter Punkt nicht wie ein Widerhaken liegt.
|
||||
let head_at = |tip: Vec2, tail: Vec2, cluster: &[Vec2]| -> Option<bool> {
|
||||
let back = (tail - tip).normalize_or_zero();
|
||||
let (mut left, mut right) = (false, false);
|
||||
for p in cluster {
|
||||
let v = *p - tip;
|
||||
if v.length() < ARROW_TIP_ZONE * shaft_len {
|
||||
continue;
|
||||
}
|
||||
let angle = back.angle_between(v).to_degrees().abs();
|
||||
if !(10.0..=80.0).contains(&angle) {
|
||||
return None;
|
||||
}
|
||||
if back.perp_dot(v) > 0.0 {
|
||||
left = true;
|
||||
} else {
|
||||
right = true;
|
||||
}
|
||||
}
|
||||
Some(left && right)
|
||||
};
|
||||
|
||||
// Übrige Punkte dem näheren Schaftende zuordnen; sie müssen nahe an diesem liegen
|
||||
let (mut near_a, mut near_b) = (Vec::new(), Vec::new());
|
||||
for p in others {
|
||||
let (da, db) = ((p - a).length(), (p - b).length());
|
||||
if da.min(db) > ARROW_HEAD_REACH * shaft_len {
|
||||
return None;
|
||||
}
|
||||
if da < db {
|
||||
near_a.push(p);
|
||||
} else {
|
||||
near_b.push(p);
|
||||
}
|
||||
}
|
||||
let head_b = head_at(b, a, &near_b)?;
|
||||
let head_a = head_at(a, b, &near_a)?;
|
||||
|
||||
let (start, end, double) = match (head_a, head_b) {
|
||||
(true, true) => (a, b, true),
|
||||
(false, true) if near_a.is_empty() => (a, b, false),
|
||||
(true, false) if near_b.is_empty() => (b, a, false),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
// Fast waagerechte/senkrechte Pfeile (≤ 5°) gerade ausrichten
|
||||
let dir = end - start;
|
||||
let angle_deg = dir.y.atan2(dir.x).to_degrees().abs();
|
||||
let end = if angle_deg < 5.0 || (angle_deg - 180.0).abs() < 5.0 {
|
||||
Vec2::new(end.x, start.y)
|
||||
} else if (angle_deg - 90.0).abs() < 5.0 {
|
||||
Vec2::new(start.x, end.y)
|
||||
} else {
|
||||
end
|
||||
};
|
||||
Some(RecognizedShape::Arrow { start, end, double })
|
||||
}
|
||||
|
||||
/// Anteil der gesamten Richtungsänderung, der bei einem Vieleck in der Nähe der Ecken liegen muss.
|
||||
const POLYGON_TURNING_AT_CORNERS: f32 = 0.7;
|
||||
/// Umgebung einer Ecke als Anteil des Umfangs.
|
||||
@@ -930,21 +1200,7 @@ fn corner_turning_ratio(points: &[Vec2], corners: &[Vec2]) -> Option<f32> {
|
||||
if total_len < 1e-3 || corners.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Gleichmäßig neu abtasten (120 Punkte entlang der Bogenlänge)
|
||||
const SAMPLES: usize = 120;
|
||||
let step = total_len / SAMPLES as f32;
|
||||
let mut resampled = vec![points[0]];
|
||||
let mut carried = 0.0;
|
||||
for w in points.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let seg = (b - a).length();
|
||||
let mut pos = step - carried;
|
||||
while pos <= seg {
|
||||
resampled.push(a + (b - a) * (pos / seg));
|
||||
pos += step;
|
||||
}
|
||||
carried = seg - (pos - step);
|
||||
}
|
||||
let resampled = resample_path(points, 120);
|
||||
// Richtungen über einen Abstand von 3 Punkten, um das Zittern zu glätten
|
||||
const W: usize = 3;
|
||||
if resampled.len() < 2 * W + 2 {
|
||||
@@ -1177,9 +1433,10 @@ pub fn detect_shape(points: &[StylusSample]) -> Option<RecognizedShape> {
|
||||
},
|
||||
&|| best_closed.clone(),
|
||||
&|| detect_polygon(&pts),
|
||||
// Pfeil vor Koordinatensystem und Gerade: Ein Pfeil ist eine Gerade mit Widerhaken
|
||||
&|| detect_arrow(&pts),
|
||||
&|| detect_coordinate_system(&pts),
|
||||
&|| detect_line(&pts),
|
||||
&|| None,
|
||||
];
|
||||
candidates
|
||||
.iter()
|
||||
@@ -1229,8 +1486,12 @@ pub fn shape_fit_error(points: &[Vec2], shape: &RecognizedShape) -> Option<f32>
|
||||
}
|
||||
|
||||
/// Beschreibt die Form den Strich gut genug, um ihn zu ersetzen?
|
||||
/// Pfeile sind ausgenommen: Ihre Erkennung prüft die Struktur (Schaft, Widerhaken auf beiden
|
||||
/// Seiten) bereits streng, und gezeichnete Widerhaken weichen naturgemäß von der sauberen
|
||||
/// Spitze ab.
|
||||
fn shape_describes_stroke(points: &[Vec2], shape: &RecognizedShape) -> bool {
|
||||
shape_fit_error(points, shape).is_some_and(|err| err <= SHAPE_MAX_FIT_ERROR)
|
||||
matches!(shape, RecognizedShape::Arrow { .. })
|
||||
|| shape_fit_error(points, shape).is_some_and(|err| err <= SHAPE_MAX_FIT_ERROR)
|
||||
}
|
||||
|
||||
/// Größte zulässige Abweichung (RMS) eines Rechtecks vom Strich, als Anteil der Diagonale.
|
||||
@@ -1248,7 +1509,7 @@ const CLOSED_SHAPE_GOOD_FIT: f32 = 0.04;
|
||||
const RECTANGLE_WEIGHT: f32 = 1.0 / 1.5;
|
||||
const CIRCLE_WEIGHT: f32 = 1.5;
|
||||
|
||||
/// Wählt unter Kreis, Dreieck, Rechteck, Raute und Vieleck die Form, deren Kontur am besten passt,
|
||||
/// Wählt unter Kreis, Ellipse, Dreieck, Rechteck, Raute und Vieleck die Form, deren Kontur am besten passt,
|
||||
/// und liefert sie mit ihrer Abweichung (RMS).
|
||||
fn best_closed_shape(pts: &[Vec2]) -> Option<(f32, RecognizedShape)> {
|
||||
// Gewichtete Abweichung: Bei fast gleicher Passung gewinnt die wahrscheinlichere Absicht.
|
||||
@@ -1259,6 +1520,7 @@ fn best_closed_shape(pts: &[Vec2]) -> Option<(f32, RecognizedShape)> {
|
||||
};
|
||||
[
|
||||
fit(detect_circle(pts), CIRCLE_WEIGHT),
|
||||
fit(detect_ellipse(pts), 1.0),
|
||||
fit(detect_triangle(pts), 1.0),
|
||||
fit(detect_rectangle(pts), RECTANGLE_WEIGHT),
|
||||
fit(detect_rhombus(pts), 1.0),
|
||||
@@ -1269,6 +1531,45 @@ fn best_closed_shape(pts: &[Vec2]) -> Option<(f32, RecognizedShape)> {
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
}
|
||||
|
||||
/// Halber Öffnungswinkel einer Pfeilspitze als Verhältnis halbe Breite zu Länge (tan 20° ≈ 0,36,
|
||||
/// also ~40° Öffnung – schlank genug, dass die Spitze spitz wirkt).
|
||||
const ARROW_HALF_WIDTH_RATIO: f32 = 0.36;
|
||||
|
||||
/// Geometrie einer Pfeilspitze an `tip`, Richtung `dir` (Einheitsvektor), für eine Linie der
|
||||
/// halben Breite `half_w` (Seiteneinheiten) und der Länge `line_len`. Liefert (Basismitte,
|
||||
/// linke Ecke, rechte Ecke). Die Spitze ist ~4 mm lang (dicke Linien länger), höchstens ein
|
||||
/// Fünftel der Linie. Die Linie soll an der Basismitte enden – reicht sie bis zur Spitze,
|
||||
/// wirkt diese stumpf.
|
||||
pub fn arrow_head(tip: Vec2, dir: Vec2, half_w: f32, line_len: f32) -> (Vec2, Vec2, Vec2) {
|
||||
let len = (half_w * 10.0).max(40.0).min(line_len * 0.2);
|
||||
let half_width = len * ARROW_HALF_WIDTH_RATIO;
|
||||
let base = tip - dir * len;
|
||||
let normal = Vec2::new(-dir.y, dir.x);
|
||||
(base, base + normal * half_width, base - normal * half_width)
|
||||
}
|
||||
|
||||
/// Punkt auf einer Ellipse zum Parameter `t` (Bogenmaß).
|
||||
pub fn ellipse_point(center: Vec2, radius_x: f32, radius_y: f32, rotation: f32, t: f32) -> Vec2 {
|
||||
let (sin, cos) = rotation.sin_cos();
|
||||
let local = Vec2::new(t.cos() * radius_x, t.sin() * radius_y);
|
||||
center + Vec2::new(local.x * cos - local.y * sin, local.x * sin + local.y * cos)
|
||||
}
|
||||
|
||||
/// Näherungsweiser Abstand eines Punktes zum Rand einer Ellipse: Abstand zum Mittelpunkt
|
||||
/// minus Ellipsenradius in dieser Richtung (für die Formauswahl genau genug).
|
||||
fn ellipse_distance(p: Vec2, center: Vec2, radius_x: f32, radius_y: f32, rotation: f32) -> f32 {
|
||||
let d = p - center;
|
||||
let (sin, cos) = rotation.sin_cos();
|
||||
let local = Vec2::new(d.x * cos + d.y * sin, -d.x * sin + d.y * cos);
|
||||
let scaled = Vec2::new(local.x / radius_x.max(1e-3), local.y / radius_y.max(1e-3));
|
||||
let scaled_len = scaled.length();
|
||||
if scaled_len < 1e-6 {
|
||||
return radius_x.min(radius_y);
|
||||
}
|
||||
let radius_in_direction = local.length() / scaled_len;
|
||||
(local.length() - radius_in_direction).abs()
|
||||
}
|
||||
|
||||
/// Abstand eines Punktes zur Strecke `a`–`b`.
|
||||
fn segment_distance(p: Vec2, a: Vec2, b: Vec2) -> f32 {
|
||||
let ab = b - a;
|
||||
@@ -1299,6 +1600,14 @@ pub fn contour_rms(points: &[Vec2], shape: &RecognizedShape) -> Option<f32> {
|
||||
RecognizedShape::Circle { center, radius } => {
|
||||
Some(((p - *center).length() - radius).abs())
|
||||
}
|
||||
RecognizedShape::Ellipse {
|
||||
center,
|
||||
radius_x,
|
||||
radius_y,
|
||||
rotation,
|
||||
} => Some(ellipse_distance(
|
||||
p, *center, *radius_x, *radius_y, *rotation,
|
||||
)),
|
||||
RecognizedShape::Triangle { corners } => Some(polygon_distance(p, corners)),
|
||||
RecognizedShape::Rectangle { corners } | RecognizedShape::Rhombus { corners } => {
|
||||
Some(polygon_distance(p, corners))
|
||||
@@ -1442,6 +1751,70 @@ pub fn shape_to_mesh(
|
||||
(samples, vertices)
|
||||
}
|
||||
|
||||
RecognizedShape::Ellipse {
|
||||
center,
|
||||
radius_x,
|
||||
radius_y,
|
||||
rotation,
|
||||
} => {
|
||||
const SEGMENTS: usize = 96;
|
||||
let outline: Vec<Vec2> = (0..SEGMENTS)
|
||||
.map(|i| {
|
||||
let t = i as f32 / SEGMENTS as f32 * 2.0 * PI;
|
||||
ellipse_point(*center, *radius_x, *radius_y, *rotation, t)
|
||||
})
|
||||
.collect();
|
||||
closed_polygon_to_mesh(&outline, half_w, color)
|
||||
}
|
||||
|
||||
RecognizedShape::Arrow { start, end, double } => {
|
||||
let shaft_len = (*end - *start).length();
|
||||
let dir = (*end - *start).normalize_or_zero();
|
||||
let end_head = arrow_head(*end, dir, half_w, shaft_len);
|
||||
let start_head = arrow_head(*start, -dir, half_w, shaft_len);
|
||||
// Der Schaft endet an der Basis der Spitze(n), damit diese spitz bleiben
|
||||
let line_start = if *double { start_head.0 } else { *start };
|
||||
let line_end = end_head.0;
|
||||
|
||||
let norm = Vec2::new(-dir.y, dir.x) * half_w;
|
||||
let mut vertices = vec![
|
||||
InkVertex::new(line_start.x + norm.x, line_start.y + norm.y, color),
|
||||
InkVertex::new(line_start.x - norm.x, line_start.y - norm.y, color),
|
||||
InkVertex::new(line_end.x + norm.x, line_end.y + norm.y, color),
|
||||
InkVertex::new(line_end.x - norm.x, line_end.y - norm.y, color),
|
||||
];
|
||||
let mut heads = vec![(*end, end_head)];
|
||||
if *double {
|
||||
heads.push((*start, start_head));
|
||||
}
|
||||
for (tip, (_, left, right)) in &heads {
|
||||
let last_v = *vertices.last().unwrap();
|
||||
let tip_v = InkVertex::new(tip.x, tip.y, color);
|
||||
vertices.push(last_v);
|
||||
vertices.push(tip_v);
|
||||
vertices.push(tip_v);
|
||||
vertices.push(InkVertex::new(left.x, left.y, color));
|
||||
vertices.push(InkVertex::new(right.x, right.y, color));
|
||||
}
|
||||
|
||||
// Abtastpunkte als zusammenhängender Linienzug: (Spitze am Start,) Schaft, Spitze am Ende
|
||||
let sample = |p: Vec2| StylusSample {
|
||||
position: p,
|
||||
pressure: 0.5,
|
||||
timestamp: 0.0,
|
||||
};
|
||||
let mut samples = Vec::new();
|
||||
if *double {
|
||||
let (_, l, r) = start_head;
|
||||
samples.extend([*start, l, r, *start].map(sample));
|
||||
}
|
||||
let steps = ((shaft_len / 5.0).ceil() as usize).max(2);
|
||||
samples.extend((0..=steps).map(|i| sample(start.lerp(*end, i as f32 / steps as f32))));
|
||||
let (_, l, r) = end_head;
|
||||
samples.extend([l, r, *end].map(sample));
|
||||
(samples, vertices)
|
||||
}
|
||||
|
||||
RecognizedShape::Circle { center, radius } => {
|
||||
let segments = 64;
|
||||
let r_in = (radius - half_w).max(0.1);
|
||||
@@ -1539,11 +1912,20 @@ pub fn shape_to_mesh(
|
||||
let mut vertices = Vec::new();
|
||||
let mut samples = Vec::new();
|
||||
|
||||
// 1. Y-Achse (von Pfeilspitze oben zum Ursprung)
|
||||
// Achsen enden bei Pfeilen an der Basis der Spitze, sonst überdeckt das Linienband
|
||||
// die Spitze und sie wirkt stumpf
|
||||
let y_dir = (*y_axis_end - *origin).normalize_or_zero();
|
||||
let y_norm = Vec2::new(-y_dir.y, y_dir.x) * half_w;
|
||||
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
|
||||
let y_arrow = arrow_head(*y_axis_end, y_dir, half_w, (*y_axis_end - *origin).length());
|
||||
let x_arrow = arrow_head(*x_axis_end, x_dir, half_w, (*x_axis_end - *origin).length());
|
||||
let (y_line_end, x_line_end) = if *with_arrows {
|
||||
(y_arrow.0, x_arrow.0)
|
||||
} else {
|
||||
(*y_axis_end, *x_axis_end)
|
||||
};
|
||||
|
||||
// Y-Linienband
|
||||
// 1. Y-Achse (vom Ursprung zur Spitze)
|
||||
let y_norm = Vec2::new(-y_dir.y, y_dir.x) * half_w;
|
||||
vertices.push(InkVertex::new(
|
||||
origin.x + y_norm.x,
|
||||
origin.y + y_norm.y,
|
||||
@@ -1555,26 +1937,24 @@ pub fn shape_to_mesh(
|
||||
color,
|
||||
));
|
||||
vertices.push(InkVertex::new(
|
||||
y_axis_end.x + y_norm.x,
|
||||
y_axis_end.y + y_norm.y,
|
||||
y_line_end.x + y_norm.x,
|
||||
y_line_end.y + y_norm.y,
|
||||
color,
|
||||
));
|
||||
vertices.push(InkVertex::new(
|
||||
y_axis_end.x - y_norm.x,
|
||||
y_axis_end.y - y_norm.y,
|
||||
y_line_end.x - y_norm.x,
|
||||
y_line_end.y - y_norm.y,
|
||||
color,
|
||||
));
|
||||
|
||||
// Degenerate Dreiecke für Übergang zur X-Achse
|
||||
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
|
||||
// Degenerierte Dreiecke für den Übergang zur X-Achse
|
||||
let x_norm = Vec2::new(-x_dir.y, x_dir.x) * half_w;
|
||||
|
||||
let last_v = *vertices.last().unwrap();
|
||||
let next_v = InkVertex::new(origin.x + x_norm.x, origin.y + x_norm.y, color);
|
||||
vertices.push(last_v);
|
||||
vertices.push(next_v);
|
||||
|
||||
// 2. X-Linienband
|
||||
// 2. X-Achse
|
||||
vertices.push(next_v);
|
||||
vertices.push(InkVertex::new(
|
||||
origin.x - x_norm.x,
|
||||
@@ -1582,48 +1962,27 @@ pub fn shape_to_mesh(
|
||||
color,
|
||||
));
|
||||
vertices.push(InkVertex::new(
|
||||
x_axis_end.x + x_norm.x,
|
||||
x_axis_end.y + x_norm.y,
|
||||
x_line_end.x + x_norm.x,
|
||||
x_line_end.y + x_norm.y,
|
||||
color,
|
||||
));
|
||||
vertices.push(InkVertex::new(
|
||||
x_axis_end.x - x_norm.x,
|
||||
x_axis_end.y - x_norm.y,
|
||||
x_line_end.x - x_norm.x,
|
||||
x_line_end.y - x_norm.y,
|
||||
color,
|
||||
));
|
||||
|
||||
// 3. Optionale Pfeilspitzen
|
||||
// 3. Optionale Pfeilspitzen (je ein Dreieck, über degenerierte Dreiecke angehängt)
|
||||
if *with_arrows {
|
||||
let arrow_len = (half_w * 5.0).max(18.0);
|
||||
let arrow_w = (half_w * 3.5).max(12.0);
|
||||
|
||||
// Pfeilspitze Y
|
||||
let y_base = *y_axis_end - y_dir * arrow_len;
|
||||
let y_left = y_base + Vec2::new(-y_dir.y, y_dir.x) * arrow_w;
|
||||
let y_right = y_base - Vec2::new(-y_dir.y, y_dir.x) * arrow_w;
|
||||
|
||||
let last_v = *vertices.last().unwrap();
|
||||
let tip_v = InkVertex::new(y_axis_end.x, y_axis_end.y, color);
|
||||
vertices.push(last_v);
|
||||
vertices.push(tip_v);
|
||||
|
||||
vertices.push(tip_v);
|
||||
vertices.push(InkVertex::new(y_left.x, y_left.y, color));
|
||||
vertices.push(InkVertex::new(y_right.x, y_right.y, color));
|
||||
|
||||
// Pfeilspitze X
|
||||
let x_base = *x_axis_end - x_dir * arrow_len;
|
||||
let x_left = x_base + Vec2::new(-x_dir.y, x_dir.x) * arrow_w;
|
||||
let x_right = x_base - Vec2::new(-x_dir.y, x_dir.x) * arrow_w;
|
||||
|
||||
let last_v = *vertices.last().unwrap();
|
||||
let tip_x = InkVertex::new(x_axis_end.x, x_axis_end.y, color);
|
||||
vertices.push(last_v);
|
||||
vertices.push(tip_x);
|
||||
|
||||
vertices.push(tip_x);
|
||||
vertices.push(InkVertex::new(x_left.x, x_left.y, color));
|
||||
vertices.push(InkVertex::new(x_right.x, x_right.y, color));
|
||||
for (tip, (_, left, right)) in [(*y_axis_end, y_arrow), (*x_axis_end, x_arrow)] {
|
||||
let last_v = *vertices.last().unwrap();
|
||||
let tip_v = InkVertex::new(tip.x, tip.y, color);
|
||||
vertices.push(last_v);
|
||||
vertices.push(tip_v);
|
||||
vertices.push(tip_v);
|
||||
vertices.push(InkVertex::new(left.x, left.y, color));
|
||||
vertices.push(InkVertex::new(right.x, right.y, color));
|
||||
}
|
||||
}
|
||||
|
||||
// Abtastpunkte
|
||||
@@ -2084,6 +2443,9 @@ mod tests {
|
||||
match shape {
|
||||
Some(RecognizedShape::Line { .. }) => "Linie",
|
||||
Some(RecognizedShape::Circle { .. }) => "Kreis",
|
||||
Some(RecognizedShape::Ellipse { .. }) => "Ellipse",
|
||||
Some(RecognizedShape::Arrow { double: false, .. }) => "Pfeil",
|
||||
Some(RecognizedShape::Arrow { double: true, .. }) => "Doppelpfeil",
|
||||
Some(RecognizedShape::Rectangle { .. }) => "Rechteck",
|
||||
Some(RecognizedShape::Triangle { .. }) => "Dreieck",
|
||||
Some(RecognizedShape::Rhombus { .. }) => "Raute",
|
||||
@@ -2288,6 +2650,57 @@ mod tests {
|
||||
add("Oval 0.6".to_string(), kind(&detect_shape(&pts)));
|
||||
}
|
||||
}
|
||||
// Pfeile in einem Zug: Schaft, dann Widerhaken links, zurück zur Spitze, Widerhaken rechts
|
||||
for &len in &[200.0_f32, 400.0, 700.0] {
|
||||
for &angle in &[0.0_f32, 30.0, 135.0, 260.0] {
|
||||
for &barb_deg in &[25.0_f32, 40.0, 55.0] {
|
||||
for &seed in &seeds[..3] {
|
||||
let dir = Vec2::from_angle(angle.to_radians());
|
||||
let tail = c - dir * len * 0.5;
|
||||
let tip = c + dir * len * 0.5;
|
||||
let barb = |side: f32| {
|
||||
tip + Vec2::from_angle(side * barb_deg.to_radians()).rotate(-dir)
|
||||
* len
|
||||
* 0.15
|
||||
};
|
||||
let single = [tail, tip, barb(1.0), tip, barb(-1.0)];
|
||||
add(
|
||||
format!("Pfeil {barb_deg}°"),
|
||||
kind(&detect_shape(&freehand(&single, false, seed))),
|
||||
);
|
||||
let barb_tail = |side: f32| {
|
||||
tail + Vec2::from_angle(side * barb_deg.to_radians()).rotate(dir)
|
||||
* len
|
||||
* 0.15
|
||||
};
|
||||
let double = [
|
||||
barb_tail(1.0),
|
||||
tail,
|
||||
barb_tail(-1.0),
|
||||
tail,
|
||||
tip,
|
||||
barb(1.0),
|
||||
tip,
|
||||
barb(-1.0),
|
||||
];
|
||||
add(
|
||||
format!("Doppelpfeil {barb_deg}°"),
|
||||
kind(&detect_shape(&freehand(&double, false, seed))),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Gerade bleibt Gerade
|
||||
let dir = Vec2::from_angle(angle.to_radians());
|
||||
add(
|
||||
"Linie".to_string(),
|
||||
kind(&detect_shape(&freehand(
|
||||
&[c - dir * len * 0.5, c + dir * len * 0.5],
|
||||
false,
|
||||
0.3,
|
||||
))),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Freiformen, die KEIN Rechteck/Kreis werden sollen
|
||||
for &seed in &seeds {
|
||||
let star: Vec<Vec2> = (0..10)
|
||||
@@ -2349,8 +2762,19 @@ mod tests {
|
||||
for group in stats.keys() {
|
||||
let expected = if group.starts_with("Rechteck") {
|
||||
Some("Rechteck")
|
||||
} else if group == "Kreis/Ellipse 1" || group == "Kreis/Ellipse 0.8" {
|
||||
} else if group == "Kreis/Ellipse 1" {
|
||||
Some("Kreis")
|
||||
} else if group == "Kreis/Ellipse 0.8"
|
||||
|| group == "Kreis/Ellipse 0.6"
|
||||
|| group == "Oval 0.6"
|
||||
{
|
||||
Some("Ellipse")
|
||||
} else if group.starts_with("Pfeil") {
|
||||
Some("Pfeil")
|
||||
} else if group.starts_with("Doppelpfeil") {
|
||||
Some("Doppelpfeil")
|
||||
} else if group.starts_with("Linie") {
|
||||
Some("Linie")
|
||||
} else if group == "Dreieck" {
|
||||
Some("Dreieck")
|
||||
} else if group.starts_with("Vieleck") {
|
||||
@@ -2364,7 +2788,7 @@ mod tests {
|
||||
if let Some(kind) = expected {
|
||||
let (hits, total) = count(group, kind);
|
||||
// Achteck/Sechseck und leicht ovale Kreise sind von Hand kaum zu unterscheiden
|
||||
let min_share = if group.starts_with("Vieleck") || group == "Kreis/Ellipse 0.8" {
|
||||
let min_share = if group.starts_with("Vieleck") {
|
||||
0.9
|
||||
} else {
|
||||
1.0
|
||||
@@ -2400,4 +2824,145 @@ mod tests {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_arrow_head_is_slender_and_axes_end_at_its_base() {
|
||||
// Spitze schlank (~40° Öffnung) und breiter als die Linie
|
||||
let (base, left, right) = arrow_head(Vec2::new(0.0, 0.0), Vec2::new(0.0, -1.0), 2.5, 500.0);
|
||||
let len = base.length();
|
||||
let half_width = (left - base).length();
|
||||
let opening = 2.0 * (half_width / len).atan().to_degrees();
|
||||
assert!(
|
||||
(35.0..=45.0).contains(&opening),
|
||||
"Öffnungswinkel {opening}°"
|
||||
);
|
||||
assert!(
|
||||
half_width > 2.5 * 2.0,
|
||||
"Spitze muss breiter als die Linie sein"
|
||||
);
|
||||
assert!(((left + right) * 0.5 - base).length() < 1e-4);
|
||||
assert!(len >= 40.0, "Spitze mindestens 4 mm lang: {len}");
|
||||
// Bei kurzen Linien höchstens ein Fünftel der Linie
|
||||
let (short_base, ..) = arrow_head(Vec2::ZERO, Vec2::new(1.0, 0.0), 2.5, 100.0);
|
||||
assert!((short_base.length() - 20.0).abs() < 1e-3);
|
||||
|
||||
// Kein Vertex des Linienbands ragt über die Basis hinaus in die Spitze hinein
|
||||
let origin = Vec2::new(100.0, 300.0);
|
||||
let y_end = Vec2::new(100.0, 100.0);
|
||||
let x_end = Vec2::new(300.0, 300.0);
|
||||
let shape = RecognizedShape::CoordinateSystem {
|
||||
origin,
|
||||
x_axis_end: x_end,
|
||||
y_axis_end: y_end,
|
||||
with_arrows: true,
|
||||
};
|
||||
let (_, vertices) = shape_to_mesh(&shape, 5.0, [0.0, 0.0, 0.0, 1.0]);
|
||||
let (y_base, ..) = arrow_head(y_end, Vec2::new(0.0, -1.0), 2.5, 200.0);
|
||||
let (x_base, ..) = arrow_head(x_end, Vec2::new(1.0, 0.0), 2.5, 200.0);
|
||||
for v in &vertices {
|
||||
let p = Vec2::new(v.position[0], v.position[1]);
|
||||
let is_tip = (p - y_end).length() < 1e-3 || (p - x_end).length() < 1e-3;
|
||||
assert!(
|
||||
is_tip || p.y >= y_base.y - 1e-3,
|
||||
"Y-Achse ragt in die Spitze: {p:?}"
|
||||
);
|
||||
assert!(
|
||||
is_tip || p.x <= x_base.x + 1e-3,
|
||||
"X-Achse ragt in die Spitze: {p:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_arrow_negative_cases() {
|
||||
let c = Vec2::new(1000.0, 1400.0);
|
||||
// T-Ende: Querbalken im rechten Winkel ist kein Pfeil
|
||||
let t_shape = [
|
||||
c - Vec2::new(300.0, 0.0),
|
||||
c + Vec2::new(300.0, 0.0),
|
||||
c + Vec2::new(300.0, 60.0),
|
||||
c + Vec2::new(300.0, -60.0),
|
||||
];
|
||||
assert_ne!(
|
||||
kind(&detect_shape(&freehand(&t_shape, false, 0.4))),
|
||||
"Pfeil"
|
||||
);
|
||||
// Zickzack ist kein Pfeil
|
||||
let zigzag: Vec<Vec2> = (0..8)
|
||||
.map(|i| c + Vec2::new(i as f32 * 80.0, if i % 2 == 0 { 0.0 } else { 60.0 }))
|
||||
.collect();
|
||||
let got = kind(&detect_shape(&freehand(&zigzag, false, 0.4)));
|
||||
assert!(
|
||||
got != "Pfeil" && got != "Doppelpfeil",
|
||||
"Zickzack erkannt als {got}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ellipse_and_arrow_transforms_and_mesh() {
|
||||
let mut ellipse = RecognizedShape::Ellipse {
|
||||
center: Vec2::new(500.0, 500.0),
|
||||
radius_x: 200.0,
|
||||
radius_y: 100.0,
|
||||
rotation: 0.0,
|
||||
};
|
||||
ellipse.translate(Vec2::new(10.0, 20.0));
|
||||
ellipse.rotate(Vec2::new(510.0, 520.0), std::f32::consts::FRAC_PI_2);
|
||||
ellipse.scale(Vec2::new(510.0, 520.0), Vec2::new(2.0, 1.0));
|
||||
if let RecognizedShape::Ellipse {
|
||||
center,
|
||||
radius_x,
|
||||
radius_y,
|
||||
rotation,
|
||||
} = ellipse
|
||||
{
|
||||
assert!((center - Vec2::new(510.0, 520.0)).length() < 1e-3);
|
||||
// Nach 90°-Drehung liegt die lange Achse senkrecht; die x-Skalierung trifft die kurze
|
||||
assert!((radius_x - 200.0).abs() < 1e-2, "radius_x {radius_x}");
|
||||
assert!((radius_y - 200.0).abs() < 1e-2, "radius_y {radius_y}");
|
||||
assert!((rotation.abs() - std::f32::consts::FRAC_PI_2).abs() < 1e-3);
|
||||
} else {
|
||||
panic!("Ellipse erwartet");
|
||||
}
|
||||
|
||||
// Ellipsen-Mesh: alle Abtastpunkte liegen auf der Ellipse
|
||||
let shape = RecognizedShape::Ellipse {
|
||||
center: Vec2::new(0.0, 0.0),
|
||||
radius_x: 300.0,
|
||||
radius_y: 120.0,
|
||||
rotation: 0.5,
|
||||
};
|
||||
let (samples, vertices) = shape_to_mesh(&shape, 5.0, [0.0; 4]);
|
||||
assert!(!vertices.is_empty());
|
||||
let pts: Vec<Vec2> = samples.iter().map(|s| s.position).collect();
|
||||
assert!(contour_rms(&pts, &shape).unwrap() < 1.0);
|
||||
|
||||
// Pfeil: Verschieben/Skalieren bewegt beide Enden, Mesh hat Spitze(n)
|
||||
let mut arrow = RecognizedShape::Arrow {
|
||||
start: Vec2::new(0.0, 0.0),
|
||||
end: Vec2::new(100.0, 0.0),
|
||||
double: true,
|
||||
};
|
||||
arrow.translate(Vec2::new(5.0, 5.0));
|
||||
arrow.scale(Vec2::new(5.0, 5.0), Vec2::new(2.0, 2.0));
|
||||
assert_eq!(
|
||||
arrow,
|
||||
RecognizedShape::Arrow {
|
||||
start: Vec2::new(5.0, 5.0),
|
||||
end: Vec2::new(205.0, 5.0),
|
||||
double: true
|
||||
}
|
||||
);
|
||||
let (_, single_v) = shape_to_mesh(
|
||||
&RecognizedShape::Arrow {
|
||||
start: Vec2::ZERO,
|
||||
end: Vec2::new(300.0, 0.0),
|
||||
double: false,
|
||||
},
|
||||
5.0,
|
||||
[0.0; 4],
|
||||
);
|
||||
let (_, double_v) = shape_to_mesh(&arrow, 5.0, [0.0; 4]);
|
||||
assert_eq!(double_v.len(), single_v.len() + 5, "zweite Spitze fehlt");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
//! GPU-Darstellung eingefügter Bilder: ein texturiertes Viereck pro Bild, gezeichnet nach dem
|
||||
//! Papier und vor der Tinte.
|
||||
//!
|
||||
//! Texturen liefert beim ersten Bedarf eine Texturquelle (Bilddatei oder gerenderte
|
||||
//! PDF-Seite); sie werden mit Mipmaps versehen, damit verkleinerte Bilder beim
|
||||
//! Herauszoomen nicht flimmern.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::assets::RgbaImage;
|
||||
|
||||
/// Vertex eines Bildvierecks: Seitenkoordinate und Texturkoordinate.
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct ImageVertex {
|
||||
pub position: [f32; 2],
|
||||
pub uv: [f32; 2],
|
||||
}
|
||||
|
||||
impl ImageVertex {
|
||||
const ATTRIBUTES: [wgpu::VertexAttribute; 2] =
|
||||
wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2];
|
||||
|
||||
fn desc() -> wgpu::VertexBufferLayout<'static> {
|
||||
wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<ImageVertex>() as wgpu::BufferAddress,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &Self::ATTRIBUTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zwei Dreiecke für ein Bild mit den Ecken oben links, oben rechts, unten rechts, unten links.
|
||||
pub fn quad_vertices(corners: &[glam::Vec2; 4]) -> [ImageVertex; 6] {
|
||||
let v = |i: usize, uv: [f32; 2]| ImageVertex {
|
||||
position: corners[i].to_array(),
|
||||
uv,
|
||||
};
|
||||
let (tl, tr, br, bl) = (
|
||||
v(0, [0.0, 0.0]),
|
||||
v(1, [1.0, 0.0]),
|
||||
v(2, [1.0, 1.0]),
|
||||
v(3, [0.0, 1.0]),
|
||||
);
|
||||
[tl, tr, bl, bl, tr, br]
|
||||
}
|
||||
|
||||
/// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1.
|
||||
pub fn mip_chain(base: &RgbaImage) -> Vec<RgbaImage> {
|
||||
let mut levels = vec![base.clone()];
|
||||
let Some(mut current) =
|
||||
image::RgbaImage::from_raw(base.width, base.height, base.pixels.clone())
|
||||
else {
|
||||
return levels;
|
||||
};
|
||||
while current.width() > 1 || current.height() > 1 {
|
||||
let (w, h) = ((current.width() / 2).max(1), (current.height() / 2).max(1));
|
||||
current = image::imageops::resize(¤t, w, h, image::imageops::FilterType::Triangle);
|
||||
levels.push(RgbaImage {
|
||||
width: w,
|
||||
height: h,
|
||||
pixels: current.as_raw().clone(),
|
||||
});
|
||||
}
|
||||
levels
|
||||
}
|
||||
|
||||
/// Name der Ersatztextur für Bilder, deren Datei fehlt oder beschädigt ist.
|
||||
const MISSING: &str = "";
|
||||
/// Bis zu dieser Grafikspeichermenge bleiben Texturen anderer Seiten erhalten (schnelles
|
||||
/// Zurückblättern); darüber werden alle nicht mehr sichtbaren freigegeben.
|
||||
const TEXTURE_CACHE_BYTES: u64 = 384 * 1024 * 1024;
|
||||
|
||||
/// Antwort einer Texturquelle für einen Schlüssel.
|
||||
pub enum TextureSource {
|
||||
Ready(RgbaImage),
|
||||
/// Wird gerade erzeugt; später erneut fragen
|
||||
Pending,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
pub struct ImageLayer {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
texture_layout: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
/// 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])>,
|
||||
vertex_buffer: Option<wgpu::Buffer>,
|
||||
vertices_dirty: bool,
|
||||
}
|
||||
|
||||
impl ImageLayer {
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
shader: &wgpu::ShaderModule,
|
||||
camera_layout: &wgpu::BindGroupLayout,
|
||||
format: wgpu::TextureFormat,
|
||||
) -> Self {
|
||||
let texture_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Image Texture Bind Group Layout"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Image Pipeline Layout"),
|
||||
bind_group_layouts: &[camera_layout, &texture_layout],
|
||||
push_constant_ranges: &[],
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Image Render Pipeline"),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: shader,
|
||||
entry_point: "vs_image",
|
||||
buffers: &[ImageVertex::desc()],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: shader,
|
||||
entry_point: "fs_image",
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview: None,
|
||||
});
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Image Sampler"),
|
||||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
mipmap_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
Self {
|
||||
pipeline,
|
||||
texture_layout,
|
||||
sampler,
|
||||
textures: HashMap::new(),
|
||||
texture_bytes: 0,
|
||||
quads: Vec::new(),
|
||||
vertex_buffer: None,
|
||||
vertices_dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ü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;
|
||||
self.vertices_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Lädt fehlende Texturen und den Vertex-Puffer hoch. Vor jedem Zeichnen aufrufen;
|
||||
/// ohne Änderungen kostet das nichts.
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
load: &mut dyn FnMut(&str) -> TextureSource,
|
||||
) {
|
||||
let missing: Vec<String> = self
|
||||
.quads
|
||||
.iter()
|
||||
.map(|(key, _)| key)
|
||||
.filter(|key| !self.textures.contains_key(*key))
|
||||
.cloned()
|
||||
.collect();
|
||||
for key in missing {
|
||||
if self.textures.contains_key(&key) {
|
||||
continue;
|
||||
}
|
||||
let (bind_group, bytes) = match load(&key) {
|
||||
TextureSource::Ready(image) => (
|
||||
self.upload_texture(device, queue, &key, &image),
|
||||
image.pixels.len() as u64 * 4 / 3,
|
||||
),
|
||||
// Noch in Arbeit (PDF-Seite wird gerendert): beim nächsten Bild erneut fragen
|
||||
TextureSource::Pending => continue,
|
||||
TextureSource::Failed(err) => {
|
||||
eprintln!("[Bilder] '{key}' kann nicht angezeigt werden: {err}");
|
||||
(self.missing_texture(device, queue), 4)
|
||||
}
|
||||
};
|
||||
self.texture_bytes += bytes;
|
||||
self.textures.insert(key, (bind_group, bytes));
|
||||
}
|
||||
|
||||
// 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();
|
||||
self.textures.retain(|name, _| used.contains(name));
|
||||
self.texture_bytes = self.textures.values().map(|(_, b)| b).sum();
|
||||
}
|
||||
|
||||
if !self.vertices_dirty {
|
||||
return;
|
||||
}
|
||||
self.vertices_dirty = false;
|
||||
let vertices: Vec<ImageVertex> = self
|
||||
.quads
|
||||
.iter()
|
||||
.flat_map(|(_, corners)| quad_vertices(corners))
|
||||
.collect();
|
||||
if vertices.is_empty() {
|
||||
return;
|
||||
}
|
||||
let bytes: &[u8] = bytemuck::cast_slice(&vertices);
|
||||
let too_small = self
|
||||
.vertex_buffer
|
||||
.as_ref()
|
||||
.is_none_or(|b| b.size() < bytes.len() as u64);
|
||||
if too_small {
|
||||
self.vertex_buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Image Vertex Buffer"),
|
||||
size: (bytes.len() as u64 * 2).max(1024),
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}));
|
||||
}
|
||||
if let Some(buffer) = &self.vertex_buffer {
|
||||
queue.write_buffer(buffer, 0, bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fehlen noch Texturen der aktuellen Seite (z. B. PDF-Seite im Hintergrund-Rendering)?
|
||||
pub fn is_waiting(&self) -> bool {
|
||||
self.quads
|
||||
.iter()
|
||||
.any(|(key, _)| !self.textures.contains_key(key))
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
let Some(buffer) = &self.vertex_buffer else {
|
||||
return;
|
||||
};
|
||||
if self.quads.is_empty() || self.vertices_dirty {
|
||||
return;
|
||||
}
|
||||
render_pass.set_pipeline(&self.pipeline);
|
||||
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
for (i, (asset, _)) in self.quads.iter().enumerate() {
|
||||
let Some((bind_group, _)) = self.textures.get(asset) else {
|
||||
continue;
|
||||
};
|
||||
render_pass.set_bind_group(1, bind_group, &[]);
|
||||
let start = (i * 6) as u32;
|
||||
render_pass.draw(start..start + 6, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_texture(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::BindGroup {
|
||||
// Hellgraue Fläche statt eines unsichtbaren Bildes
|
||||
let gray = RgbaImage {
|
||||
width: 1,
|
||||
height: 1,
|
||||
pixels: vec![200, 200, 200, 255],
|
||||
};
|
||||
self.upload_texture(device, queue, MISSING, &gray)
|
||||
}
|
||||
|
||||
fn upload_texture(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
label: &str,
|
||||
image: &RgbaImage,
|
||||
) -> wgpu::BindGroup {
|
||||
let levels = mip_chain(image);
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: levels.len() as u32,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8UnormSrgb,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
for (level, mip) in levels.iter().enumerate() {
|
||||
queue.write_texture(
|
||||
wgpu::ImageCopyTexture {
|
||||
texture: &texture,
|
||||
mip_level: level as u32,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
&mip.pixels,
|
||||
wgpu::ImageDataLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(4 * mip.width),
|
||||
rows_per_image: Some(mip.height),
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: mip.width,
|
||||
height: mip.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Image Texture Bind Group"),
|
||||
layout: &self.texture_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_quad_vertices_map_corners_to_texture_corners() {
|
||||
let corners = [
|
||||
glam::Vec2::new(10.0, 10.0),
|
||||
glam::Vec2::new(50.0, 10.0),
|
||||
glam::Vec2::new(50.0, 30.0),
|
||||
glam::Vec2::new(10.0, 30.0),
|
||||
];
|
||||
let quad = quad_vertices(&corners);
|
||||
for v in quad {
|
||||
// Texturkoordinate entspricht der relativen Lage im Rechteck
|
||||
let u = (v.position[0] - 10.0) / 40.0;
|
||||
let w = (v.position[1] - 10.0) / 20.0;
|
||||
assert_eq!(v.uv, [u, w]);
|
||||
}
|
||||
// Beide Dreiecke decken alle vier Ecken ab
|
||||
for c in corners {
|
||||
assert!(quad.iter().any(|v| v.position == c.to_array()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mip_chain_halves_down_to_one_pixel() {
|
||||
let base = RgbaImage {
|
||||
width: 8,
|
||||
height: 3,
|
||||
pixels: vec![128; 8 * 3 * 4],
|
||||
};
|
||||
let levels = mip_chain(&base);
|
||||
let sizes: Vec<(u32, u32)> = levels.iter().map(|l| (l.width, l.height)).collect();
|
||||
assert_eq!(sizes, vec![(8, 3), (4, 1), (2, 1), (1, 1)]);
|
||||
for l in &levels {
|
||||
assert_eq!(l.pixels.len(), (l.width * l.height * 4) as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
//! ink-notes: Latenzoptimierte Inking-App für Linux.
|
||||
|
||||
pub mod assets;
|
||||
pub mod autosave;
|
||||
pub mod background;
|
||||
pub mod canvas;
|
||||
pub mod document;
|
||||
pub mod export;
|
||||
pub mod file_picker;
|
||||
pub mod filter;
|
||||
pub mod geometry;
|
||||
pub mod history;
|
||||
pub mod image_layer;
|
||||
pub mod pdf_doc;
|
||||
pub mod pdf_merge;
|
||||
pub mod platform;
|
||||
pub mod selection;
|
||||
pub mod settings;
|
||||
pub mod spline;
|
||||
|
||||
+407
-56
@@ -9,17 +9,22 @@ use winit::{
|
||||
event::{
|
||||
ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent,
|
||||
},
|
||||
event_loop::{ControlFlow, EventLoop},
|
||||
event_loop::{ControlFlow, EventLoopBuilder},
|
||||
keyboard::{Key, ModifiersState, NamedKey},
|
||||
platform::{wayland::EventLoopBuilderExtWayland, x11::EventLoopBuilderExtX11},
|
||||
window::{Window, WindowBuilder},
|
||||
};
|
||||
|
||||
use glam::Vec2;
|
||||
use ink_notes::assets::{AssetStore, ImportedImage};
|
||||
use ink_notes::autosave::{AutosaveController, AutosaveStatus};
|
||||
use ink_notes::canvas::CanvasState;
|
||||
use ink_notes::document::{Notebook, PageBackground};
|
||||
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy};
|
||||
use ink_notes::geometry::{self, RecognizedShape};
|
||||
use ink_notes::image_layer::TextureSource;
|
||||
use ink_notes::pdf_doc::PdfRenderService;
|
||||
use ink_notes::platform::{self, DisplayBackend};
|
||||
use ink_notes::selection::{
|
||||
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
||||
};
|
||||
@@ -29,6 +34,19 @@ use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
|
||||
use ink_notes::updater::UpdaterController;
|
||||
|
||||
/// Aktualisiert den Fenstertitel als minimales On-Screen-Display (OSD) inklusive Autosave-Status.
|
||||
/// Speichert die UI-Einstellungen; ein Fehler wird gemeldet, bricht aber nichts ab.
|
||||
fn save_ui_settings(settings: &UiSettings, path: Option<&std::path::Path>, ui_state: &mut UiState) {
|
||||
if let Some(path) = path {
|
||||
if let Err(err) = settings.save_to(path) {
|
||||
eprintln!(
|
||||
"[Einstellungen] Konnte '{}' nicht speichern: {err}",
|
||||
path.display()
|
||||
);
|
||||
ui_state.set_export_message(format!("Einstellung nicht gespeichert: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_window_title(
|
||||
window: &Window,
|
||||
notebook: &Notebook,
|
||||
@@ -76,8 +94,14 @@ fn start_pdf_export(
|
||||
let result = storage::load_all_pages(&storage_dir, &mut notebook_clone)
|
||||
.map_err(|err| err.to_string())
|
||||
.and_then(|()| {
|
||||
export_notebook_to_pdf(¬ebook_clone, &export_path, ExportStrategy::FilledPolygon)
|
||||
.map_err(|err| err.to_string())
|
||||
let assets = AssetStore::new(&storage_dir);
|
||||
export_notebook_to_pdf(
|
||||
¬ebook_clone,
|
||||
&export_path,
|
||||
ExportStrategy::FilledPolygon,
|
||||
Some(&assets),
|
||||
)
|
||||
.map_err(|err| err.to_string())
|
||||
});
|
||||
match &result {
|
||||
Ok(()) => println!(
|
||||
@@ -119,7 +143,8 @@ fn commit_stroke_to_notebook(
|
||||
canvas.finish_stroke(new_id)
|
||||
};
|
||||
|
||||
if let Some(stroke) = stroke_opt {
|
||||
if let Some(mut stroke) = stroke_opt {
|
||||
stroke.tool = ui_state.active_tool.stroke_tool(stroke.shape.is_some());
|
||||
let page = notebook.current_page_mut();
|
||||
page.add_stroke(stroke);
|
||||
canvas.sync_page(page);
|
||||
@@ -136,8 +161,255 @@ fn commit_stroke_to_notebook(
|
||||
}
|
||||
}
|
||||
|
||||
/// Zwischenablage: interne Objekte (Strg+C in ink-notes) und Bilder aus der
|
||||
/// Systemzwischenablage (Screenshots, aus anderen Programmen kopierte Bilder).
|
||||
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]>,
|
||||
/// Notizbuch, aus dem kopiert wurde (für Bilder beim Einfügen in ein anderes Notizbuch)
|
||||
source_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl ClipboardBridge {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
system: None,
|
||||
image_hash_at_copy: None,
|
||||
source_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bild in der Systemzwischenablage samt Prüfsumme.
|
||||
fn system_image(&mut self) -> Option<(arboard::ImageData<'static>, [u8; 32])> {
|
||||
if self.system.is_none() {
|
||||
self.system = arboard::Clipboard::new()
|
||||
.map_err(|e| eprintln!("[Zwischenablage] Nicht verfügbar: {e}"))
|
||||
.ok();
|
||||
}
|
||||
let image = self.system.as_mut()?.get_image().ok()?;
|
||||
use sha2::Digest;
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update((image.width as u64).to_le_bytes());
|
||||
hasher.update((image.height as u64).to_le_bytes());
|
||||
hasher.update(&image.bytes);
|
||||
let hash: [u8; 32] = hasher.finalize().into();
|
||||
Some((image.to_owned_img(), hash))
|
||||
}
|
||||
|
||||
/// Merkt sich beim internen Kopieren das aktuelle Systembild 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.source_dir = Some(storage_dir.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
/// Fügt ein importiertes Bild um `center` (Seitenkoordinaten) auf der aktuellen Seite ein,
|
||||
/// wählt es aus und schaltet auf das Auswahlwerkzeug, damit es gleich verschoben und
|
||||
/// skaliert werden kann.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn insert_image_on_page(
|
||||
imported: ImportedImage,
|
||||
center: Vec2,
|
||||
canvas: &mut CanvasState,
|
||||
notebook: &mut Notebook,
|
||||
manifest: &mut NotebookManifest,
|
||||
autosave: &mut AutosaveController,
|
||||
selection_state: &mut SelectionState,
|
||||
ui_state: &mut UiState,
|
||||
) {
|
||||
let id = notebook.next_stroke_id();
|
||||
let corners = ink_notes::document::place_image(imported.width, imported.height, center);
|
||||
let page = notebook.current_page_mut();
|
||||
page.add_stroke(ink_notes::document::Stroke::new_image(
|
||||
id,
|
||||
imported.asset,
|
||||
corners,
|
||||
));
|
||||
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(format!(
|
||||
"Bild eingefügt ({} × {} px)",
|
||||
imported.width, imported.height
|
||||
));
|
||||
}
|
||||
|
||||
/// 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(
|
||||
key: &str,
|
||||
assets: &AssetStore,
|
||||
pdf_renderer: &mut PdfRenderService,
|
||||
) -> TextureSource {
|
||||
let result = match key.split_once('#') {
|
||||
Some((asset, page)) => {
|
||||
let (Ok(path), Ok(page)) = (assets.path_of(asset), page.parse::<usize>()) else {
|
||||
return TextureSource::Failed(format!("Ungültiger PDF-Verweis '{key}'"));
|
||||
};
|
||||
match pdf_renderer.take(key, path, page) {
|
||||
Some(result) => result,
|
||||
None => return TextureSource::Pending,
|
||||
}
|
||||
}
|
||||
None => assets
|
||||
.load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION)
|
||||
.map_err(|e| e.to_string()),
|
||||
};
|
||||
match result {
|
||||
Ok(image) => TextureSource::Ready(image),
|
||||
Err(err) => TextureSource::Failed(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// Seitenmitte des sichtbaren Bereichs (Ziel für eingefügte Bilder).
|
||||
fn visible_page_center(canvas: &CanvasState) -> Vec2 {
|
||||
canvas.screen_to_page_clamped(
|
||||
canvas.size.width as f32 / 2.0,
|
||||
canvas.size.height as f32 / 2.0,
|
||||
)
|
||||
}
|
||||
|
||||
/// Strg+V bzw. „Einfügen“: ein neu kopiertes Systembild hat Vorrang, sonst werden die intern
|
||||
/// kopierten Objekte eingefügt. Gibt die Meldung für die Statuszeile zurück.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paste(
|
||||
clipboard: &mut ClipboardBridge,
|
||||
storage_dir: &Path,
|
||||
canvas: &mut CanvasState,
|
||||
notebook: &mut Notebook,
|
||||
manifest: &mut NotebookManifest,
|
||||
autosave: &mut AutosaveController,
|
||||
selection_state: &mut SelectionState,
|
||||
ui_state: &mut UiState,
|
||||
) {
|
||||
if notebook.current_page().is_readonly() {
|
||||
return;
|
||||
}
|
||||
if let Some((image, hash)) = clipboard.system_image() {
|
||||
// Die jüngste Kopie gewinnt: das Systembild nur, wenn es nach dem letzten internen
|
||||
// Kopieren in die Zwischenablage kam
|
||||
if selection_state.clipboard.is_empty() || clipboard.image_hash_at_copy != Some(hash) {
|
||||
let assets = AssetStore::new(storage_dir);
|
||||
match assets.import_rgba(
|
||||
image.width as u32,
|
||||
image.height as u32,
|
||||
image.bytes.into_owned(),
|
||||
) {
|
||||
Ok(imported) => {
|
||||
let center = visible_page_center(canvas);
|
||||
insert_image_on_page(
|
||||
imported,
|
||||
center,
|
||||
canvas,
|
||||
notebook,
|
||||
manifest,
|
||||
autosave,
|
||||
selection_state,
|
||||
ui_state,
|
||||
);
|
||||
}
|
||||
Err(err) => ui_state.set_export_message(format!("Bild nicht eingefügt: {err}")),
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Bilder aus einem anderen Notizbuch mitnehmen
|
||||
if let Some(source) = clipboard
|
||||
.source_dir
|
||||
.as_deref()
|
||||
.filter(|d| *d != storage_dir)
|
||||
{
|
||||
let from = AssetStore::new(source);
|
||||
let to = AssetStore::new(storage_dir);
|
||||
for asset in selection_state
|
||||
.clipboard
|
||||
.iter()
|
||||
.filter_map(|s| s.image.as_deref())
|
||||
{
|
||||
if let Err(err) = to.copy_from(&from, asset) {
|
||||
eprintln!("[Zwischenablage] Bild '{asset}' nicht übernommen: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut next_id = notebook.next_stroke_id;
|
||||
let new_ids = selection_state.paste_from_clipboard(notebook.current_page_mut(), &mut || {
|
||||
let id = next_id;
|
||||
next_id += 1;
|
||||
id
|
||||
});
|
||||
notebook.next_stroke_id = next_id;
|
||||
if !new_ids.is_empty() {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||
autosave.mark_dirty(notebook, manifest);
|
||||
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Liest eine Bilddatei und fügt sie um `center` ein.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn insert_image_file(
|
||||
path: &Path,
|
||||
center: Vec2,
|
||||
storage_dir: &Path,
|
||||
canvas: &mut CanvasState,
|
||||
notebook: &mut Notebook,
|
||||
manifest: &mut NotebookManifest,
|
||||
autosave: &mut AutosaveController,
|
||||
selection_state: &mut SelectionState,
|
||||
ui_state: &mut UiState,
|
||||
) {
|
||||
if notebook.current_page().is_readonly() {
|
||||
ui_state.set_export_message("Seite ist schreibgeschützt".to_string());
|
||||
return;
|
||||
}
|
||||
let result = std::fs::read(path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|bytes| {
|
||||
AssetStore::new(storage_dir)
|
||||
.import_bytes(&bytes)
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
match result {
|
||||
Ok(imported) => insert_image_on_page(
|
||||
imported,
|
||||
center,
|
||||
canvas,
|
||||
notebook,
|
||||
manifest,
|
||||
autosave,
|
||||
selection_state,
|
||||
ui_state,
|
||||
),
|
||||
Err(err) => ui_state.set_export_message(format!(
|
||||
"'{}' nicht eingefügt: {err}",
|
||||
path.file_name()
|
||||
.map(|n| n.to_string_lossy())
|
||||
.unwrap_or_default()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let event_loop = EventLoop::new()?;
|
||||
// Anzeigesystem wählen: Unter WSL (WSLg) bricht Wayland ab, X11 (Xwayland) läuft stabil
|
||||
let mut event_loop_builder = EventLoopBuilder::new();
|
||||
match platform::display_backend_from_env() {
|
||||
DisplayBackend::X11 => {
|
||||
eprintln!("[Anzeige] Verwende X11 (unter WSL Standard; übersteuerbar mit INK_NOTES_DISPLAY=wayland)");
|
||||
event_loop_builder.with_x11();
|
||||
}
|
||||
DisplayBackend::Wayland => {
|
||||
eprintln!("[Anzeige] Verwende Wayland (INK_NOTES_DISPLAY=wayland)");
|
||||
event_loop_builder.with_wayland();
|
||||
}
|
||||
DisplayBackend::Automatic => {}
|
||||
}
|
||||
let event_loop = event_loop_builder.build()?;
|
||||
let window = Arc::new(
|
||||
WindowBuilder::new()
|
||||
.with_title("ink-notes (Latenzoptimiertes Linux-Inking)")
|
||||
@@ -244,6 +516,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.map(UiSettings::load_from)
|
||||
.unwrap_or_default();
|
||||
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.pressure_curve = ui_settings.pressure_curve;
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = available_notebooks;
|
||||
@@ -252,6 +527,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
ui_state.set_export_message(warning);
|
||||
}
|
||||
let mut selection_state = SelectionState::new();
|
||||
let mut clipboard = ClipboardBridge::new();
|
||||
// PDF-Seiten werden im Hintergrund gerendert und wecken danach den Event-Loop
|
||||
let mut pdf_renderer = {
|
||||
let window = window.clone();
|
||||
PdfRenderService::new(move || window.request_redraw())
|
||||
};
|
||||
|
||||
let mut _notebook_lock = match storage::NotebookLock::acquire(&storage_dir) {
|
||||
Ok(lock) => {
|
||||
@@ -275,6 +556,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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);
|
||||
@@ -456,7 +738,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
cursor_screen_pos = (sx, sy);
|
||||
|
||||
if let Some(p) = pose.pressure.get() {
|
||||
current_pressure = p;
|
||||
current_pressure = ui_state.pressure_curve.apply(p);
|
||||
ui_state.last_raw_pressure = Some(p);
|
||||
}
|
||||
|
||||
// Stiftbewegung an egui weitergeben (Hover, Schieberegler ziehen),
|
||||
@@ -483,8 +766,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let removed = page.erase_at(
|
||||
page_pt,
|
||||
eraser_radius,
|
||||
stroke_max_radius,
|
||||
);
|
||||
stroke_max_radius, ui_state.eraser_mode);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
@@ -551,8 +833,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let removed = page.erase_at(
|
||||
page_pt,
|
||||
eraser_radius,
|
||||
stroke_max_radius,
|
||||
);
|
||||
stroke_max_radius, ui_state.eraser_mode);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
@@ -723,6 +1004,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
|
||||
// Bilddatei ins Fenster gezogen: an der Ablagestelle einfügen
|
||||
WindowEvent::DroppedFile(path) => {
|
||||
if ink_notes::file_picker::is_pdf_path(&path) {
|
||||
ui_state.queued_actions.push(UiAction::ImportPdf(path));
|
||||
} else if ink_notes::assets::is_supported_image_path(&path) {
|
||||
let (sx, sy) = cursor_screen_pos;
|
||||
let center = canvas.screen_to_page_clamped(sx, sy);
|
||||
insert_image_file(
|
||||
&path, center, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
|
||||
&mut autosave, &mut selection_state, &mut ui_state,
|
||||
);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
} else {
|
||||
ui_state.set_export_message("Nur Bilder (PNG, JPEG, GIF, WebP, BMP) und PDFs können eingefügt werden".to_string());
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
WindowEvent::Focused(false) => {
|
||||
// Bei Fokusverlust Strich abbrechen und 1€-Filter resetten
|
||||
if is_drawing {
|
||||
@@ -847,6 +1146,7 @@ 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 {
|
||||
clipboard.note_internal_copy(&storage_dir);
|
||||
ui_state.set_export_message(format!("{count} Objekt(e) kopiert"));
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -854,23 +1154,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Einfügen: Strg + V
|
||||
Key::Character(c) if c.eq_ignore_ascii_case("v") && ctrl => {
|
||||
let mut next_id = notebook.next_stroke_id;
|
||||
let new_ids = selection_state.paste_from_clipboard(
|
||||
notebook.current_page_mut(),
|
||||
&mut || {
|
||||
let id = next_id;
|
||||
next_id += 1;
|
||||
id
|
||||
},
|
||||
paste(
|
||||
&mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
|
||||
&mut autosave, &mut selection_state, &mut ui_state,
|
||||
);
|
||||
notebook.next_stroke_id = next_id;
|
||||
if !new_ids.is_empty() {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
|
||||
window.request_redraw();
|
||||
}
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
// Bild einfügen: Strg + I
|
||||
Key::Character(c) if c.eq_ignore_ascii_case("i") && ctrl => {
|
||||
ui_state.open_image_picker();
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
// Löschen: Entf oder Backspace
|
||||
@@ -1172,7 +1467,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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);
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
@@ -1440,7 +1735,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
} 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);
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
@@ -1501,15 +1796,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let now = start_time.elapsed().as_secs_f64();
|
||||
let tx = touch.location.x as f32;
|
||||
let ty = touch.location.y as f32;
|
||||
let pressure = match touch.force {
|
||||
Some(winit::event::Force::Normalized(f)) => f as f32,
|
||||
let raw_pressure = match touch.force {
|
||||
Some(winit::event::Force::Normalized(f)) => Some(f as f32),
|
||||
Some(winit::event::Force::Calibrated {
|
||||
force,
|
||||
max_possible_force,
|
||||
..
|
||||
}) => (force / max_possible_force).clamp(0.0, 1.0) as f32,
|
||||
None => 0.5,
|
||||
}) => Some((force / max_possible_force).clamp(0.0, 1.0) as f32),
|
||||
None => None,
|
||||
};
|
||||
// Druckkurve nur bei echtem Druck; Finger ohne Druck zeichnen mittelstark
|
||||
let pressure =
|
||||
raw_pressure.map_or(0.5, |p| ui_state.pressure_curve.apply(p));
|
||||
|
||||
let is_eraser_action = ui_state.active_tool == ActiveTool::Eraser;
|
||||
|
||||
@@ -1576,7 +1874,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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);
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
@@ -1717,7 +2015,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
} 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);
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
@@ -1949,28 +2247,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
UiAction::CopySelected => {
|
||||
let count = selection_state.copy_to_clipboard(notebook.current_page());
|
||||
if count > 0 {
|
||||
clipboard.note_internal_copy(&storage_dir);
|
||||
ui_state.set_export_message(format!("{count} Objekt(e) kopiert"));
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
UiAction::PasteSelected => {
|
||||
let mut next_id = notebook.next_stroke_id;
|
||||
let new_ids = selection_state.paste_from_clipboard(
|
||||
notebook.current_page_mut(),
|
||||
&mut || {
|
||||
let id = next_id;
|
||||
next_id += 1;
|
||||
id
|
||||
},
|
||||
paste(
|
||||
&mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
|
||||
&mut autosave, &mut selection_state, &mut ui_state,
|
||||
);
|
||||
notebook.next_stroke_id = next_id;
|
||||
if !new_ids.is_empty() {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
|
||||
window.request_redraw();
|
||||
}
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::InsertImageFile(path) => {
|
||||
let center = visible_page_center(&canvas);
|
||||
insert_image_file(
|
||||
&path, center, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
|
||||
&mut autosave, &mut selection_state, &mut ui_state,
|
||||
);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::RotateSelected => {
|
||||
if !selection_state.is_empty()
|
||||
@@ -2165,6 +2462,43 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
UiAction::ImportPdf(path) => {
|
||||
let title = path
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "PDF".to_string());
|
||||
let result = std::fs::read(&path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|bytes| storage::create_notebook_from_pdf(&storage_base_dir, &title, bytes));
|
||||
match result {
|
||||
Ok((new_nb, new_manifest, new_dir)) => {
|
||||
let _ = autosave.flush();
|
||||
_notebook_lock = storage::NotebookLock::acquire(&new_dir).ok();
|
||||
autosave.set_read_only(_notebook_lock.is_none());
|
||||
autosave.switch_notebook_dir(new_dir.clone());
|
||||
notebook = new_nb;
|
||||
manifest = new_manifest;
|
||||
storage_dir = new_dir;
|
||||
selection_state.clear();
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
canvas.reset_view();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
ui_state.set_export_message(format!(
|
||||
"'{}' importiert ({} Seiten)",
|
||||
manifest.title,
|
||||
notebook.pages.len()
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[PDF-Import] '{}': {err}", path.display());
|
||||
ui_state.set_export_message(format!("PDF nicht importiert: {err}"));
|
||||
}
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::CreateNotebook(title) => {
|
||||
let _ = autosave.flush();
|
||||
match storage::create_new_notebook(&storage_base_dir, &title) {
|
||||
@@ -2395,14 +2729,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
UiAction::SetSidebarSide(side) => {
|
||||
ui_settings.sidebar_side = side;
|
||||
if let Some(path) = ui_settings_path.as_deref() {
|
||||
if let Err(err) = ui_settings.save_to(path) {
|
||||
eprintln!("[Einstellungen] Konnte '{}' nicht speichern: {err}", path.display());
|
||||
ui_state.set_export_message(format!("Einstellung nicht gespeichert: {err}"));
|
||||
}
|
||||
}
|
||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::SetPaperTheme { theme, adapt_ink } => {
|
||||
canvas.set_paper_theme(theme, adapt_ink);
|
||||
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::SavePressureCurve(curve) => {
|
||||
ui_settings.pressure_curve = curve;
|
||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||
}
|
||||
UiAction::ForceExit => {
|
||||
println!("[Shutdown] Beenden erzwungen trotz ungespeicherter Änderungen.");
|
||||
target.exit();
|
||||
@@ -2441,6 +2781,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
// 5. Canvas (Pass 1) und egui UI (Pass 2) in Surface-Textur rendern
|
||||
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
|
||||
if !canvas.is_waiting_for_images() {
|
||||
let next = notebook.pages.get(notebook.current_page_index + 1);
|
||||
if let Some(pdf) = next.and_then(|p| p.pdf_background.as_ref()) {
|
||||
if let Ok(path) = assets.path_of(&pdf.asset) {
|
||||
pdf_renderer.prefetch(&pdf.key(), path, pdf.page as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
match canvas.render(&clipped_primitives, &screen_descriptor, &full_output.textures_delta) {
|
||||
Ok(_) => {}
|
||||
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
|
||||
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
//! Lesen und Rendern importierter PDF-Dateien mit `hayro` (reines Rust, nur CPU).
|
||||
//!
|
||||
//! Das Rendern einer Seite kann einige hundert Millisekunden dauern; [`PdfRenderService`]
|
||||
//! erledigt es deshalb in einem Hintergrund-Thread und meldet fertige Seiten zurück.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
|
||||
use hayro::hayro_interpret::InterpreterSettings;
|
||||
use hayro::hayro_syntax::{LoadPdfError, Pdf};
|
||||
use hayro::vello_cpu::color::palette::css::WHITE;
|
||||
use hayro::{PixmapSettings, RenderCache, RenderSettings};
|
||||
|
||||
use crate::assets::RgbaImage;
|
||||
|
||||
/// Längste Kante einer gerenderten Seite für die Anzeige in Pixeln (A4 ≈ 220 dpi).
|
||||
pub const DISPLAY_RENDER_PIXELS: u32 = 2560;
|
||||
/// Größte Seitenzahl, die importiert wird.
|
||||
pub const MAX_IMPORT_PAGES: usize = 2000;
|
||||
|
||||
/// Öffnet ein PDF und liefert die angezeigte Größe jeder Seite in Punkt (Drehung
|
||||
/// berücksichtigt).
|
||||
pub fn page_sizes(bytes: Vec<u8>) -> Result<Vec<(f32, f32)>, String> {
|
||||
let pdf = open(bytes)?;
|
||||
let sizes: Vec<(f32, f32)> = pdf.pages().iter().map(|p| p.render_dimensions()).collect();
|
||||
if sizes.is_empty() {
|
||||
return Err("Das PDF enthält keine Seiten".into());
|
||||
}
|
||||
if sizes.len() > MAX_IMPORT_PAGES {
|
||||
return Err(format!(
|
||||
"Das PDF hat {} Seiten, höchstens {MAX_IMPORT_PAGES} werden unterstützt",
|
||||
sizes.len()
|
||||
));
|
||||
}
|
||||
Ok(sizes)
|
||||
}
|
||||
|
||||
fn open(bytes: Vec<u8>) -> Result<Pdf, String> {
|
||||
Pdf::new(bytes).map_err(|e| match e {
|
||||
LoadPdfError::Decryption(_) => {
|
||||
"Das PDF ist verschlüsselt oder passwortgeschützt und kann nicht geöffnet werden"
|
||||
.to_string()
|
||||
}
|
||||
LoadPdfError::Invalid => "Die Datei ist kein lesbares PDF".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Rendert Seite `index` so, dass die längste Kante höchstens `max_pixels` lang ist,
|
||||
/// auf weißem Grund.
|
||||
pub fn render_page(pdf: &Pdf, index: usize, max_pixels: u32) -> Result<RgbaImage, String> {
|
||||
let page = pdf
|
||||
.pages()
|
||||
.get(index)
|
||||
.ok_or_else(|| format!("Seite {} fehlt im PDF", index + 1))?;
|
||||
let (w, h) = page.render_dimensions();
|
||||
let scale = (max_pixels as f32 / w.max(h).max(1.0)).min(16.0);
|
||||
let pixmap = hayro::render(
|
||||
page,
|
||||
&RenderCache::new(),
|
||||
&InterpreterSettings::default(),
|
||||
&RenderSettings::default(),
|
||||
&PixmapSettings {
|
||||
x_scale: scale,
|
||||
y_scale: scale,
|
||||
bg_color: WHITE,
|
||||
},
|
||||
);
|
||||
// Auf weißem Grund ist jedes Pixel deckend: vormultipliziert = gerade Farbe
|
||||
Ok(RgbaImage {
|
||||
width: pixmap.width() as u32,
|
||||
height: pixmap.height() as u32,
|
||||
pixels: pixmap.data_as_u8_slice().to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Öffnet die Datei und rendert eine Seite (für den PDF-Export als Ersatzbild).
|
||||
pub fn render_file_page(
|
||||
path: &std::path::Path,
|
||||
index: usize,
|
||||
max_pixels: u32,
|
||||
) -> Result<RgbaImage, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
render_page(&open(bytes)?, index, max_pixels)
|
||||
}
|
||||
|
||||
struct RenderRequest {
|
||||
key: String,
|
||||
path: PathBuf,
|
||||
page: usize,
|
||||
}
|
||||
|
||||
/// Ergebnis eines Render-Auftrags.
|
||||
pub type RenderResult = Result<RgbaImage, String>;
|
||||
|
||||
/// Rendert PDF-Seiten im Hintergrund. Fertige Seiten holt der Event-Loop mit
|
||||
/// [`PdfRenderService::take`] ab; `on_ready` weckt ihn dafür auf.
|
||||
pub struct PdfRenderService {
|
||||
requests: Sender<RenderRequest>,
|
||||
results: Receiver<(String, RenderResult)>,
|
||||
pending: HashSet<String>,
|
||||
ready: HashMap<String, RenderResult>,
|
||||
}
|
||||
|
||||
impl PdfRenderService {
|
||||
pub fn new(on_ready: impl Fn() + Send + 'static) -> Self {
|
||||
let (req_tx, req_rx) = mpsc::channel::<RenderRequest>();
|
||||
let (res_tx, res_rx) = mpsc::channel();
|
||||
std::thread::Builder::new()
|
||||
.name("pdf-render".into())
|
||||
.spawn(move || {
|
||||
// Zuletzt geöffnetes PDF behalten: Blättern rendert meist dieselbe Datei
|
||||
let mut open_pdf: Option<(PathBuf, Pdf)> = None;
|
||||
while let Ok(request) = req_rx.recv() {
|
||||
if open_pdf.as_ref().map(|(p, _)| p) != Some(&request.path) {
|
||||
open_pdf = std::fs::read(&request.path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(open)
|
||||
.map_err(|e| eprintln!("[PDF] '{}': {e}", request.path.display()))
|
||||
.ok()
|
||||
.map(|pdf| (request.path.clone(), pdf));
|
||||
}
|
||||
let result = match &open_pdf {
|
||||
Some((_, pdf)) => render_page(pdf, request.page, DISPLAY_RENDER_PIXELS),
|
||||
None => Err("PDF kann nicht geöffnet werden".into()),
|
||||
};
|
||||
if res_tx.send((request.key, result)).is_err() {
|
||||
break;
|
||||
}
|
||||
on_ready();
|
||||
}
|
||||
})
|
||||
.expect("PDF-Render-Thread konnte nicht gestartet werden");
|
||||
Self {
|
||||
requests: req_tx,
|
||||
results: res_rx,
|
||||
pending: HashSet::new(),
|
||||
ready: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rendert eine Seite vorab (z. B. die nächste), ohne auf das Ergebnis zu warten;
|
||||
/// [`Self::take`] liefert es später sofort.
|
||||
pub fn prefetch(&mut self, key: &str, path: PathBuf, page: usize) {
|
||||
self.collect();
|
||||
if self.ready.contains_key(key) || self.pending.contains(key) {
|
||||
return;
|
||||
}
|
||||
// Vorab gerenderte Seiten nicht unbegrenzt aufheben
|
||||
if self.ready.len() >= 2 {
|
||||
self.ready.clear();
|
||||
}
|
||||
self.request(key, path, page);
|
||||
}
|
||||
|
||||
fn collect(&mut self) {
|
||||
while let Ok((done, result)) = self.results.try_recv() {
|
||||
self.pending.remove(&done);
|
||||
self.ready.insert(done, result);
|
||||
}
|
||||
}
|
||||
|
||||
/// Erteilt den Auftrag, falls noch keiner läuft. `false`, wenn der Thread beendet ist.
|
||||
fn request(&mut self, key: &str, path: PathBuf, page: usize) -> bool {
|
||||
if !self.pending.insert(key.to_string()) {
|
||||
return true;
|
||||
}
|
||||
let request = RenderRequest {
|
||||
key: key.to_string(),
|
||||
path,
|
||||
page,
|
||||
};
|
||||
if self.requests.send(request).is_err() {
|
||||
self.pending.remove(key);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Fertiges Ergebnis für `key` abholen; sonst den Auftrag (einmalig) erteilen und `None`.
|
||||
pub fn take(&mut self, key: &str, path: PathBuf, page: usize) -> Option<RenderResult> {
|
||||
self.collect();
|
||||
if let Some(result) = self.ready.remove(key) {
|
||||
return Some(result);
|
||||
}
|
||||
if !self.request(key, path, page) {
|
||||
return Some(Err("PDF-Render-Thread beendet".into()));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Minimales gültiges PDF mit Seiten der angegebenen Größen (Punkt); jede Seite zeigt ein
|
||||
/// rotes Rechteck in der linken oberen Ecke. Optional mit `/Rotate`.
|
||||
pub(crate) fn sample_pdf(pages: &[(f32, f32, i32)]) -> Vec<u8> {
|
||||
let mut objects: Vec<String> = Vec::new();
|
||||
let n = pages.len();
|
||||
// 1: Katalog, 2: Seitenbaum, danach je Seite: Seite + Inhalt
|
||||
objects.push("<< /Type /Catalog /Pages 2 0 R >>".into());
|
||||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 3 + 2 * i)).collect();
|
||||
objects.push(format!(
|
||||
"<< /Type /Pages /Kids [{}] /Count {n} >>",
|
||||
kids.join(" ")
|
||||
));
|
||||
for (i, (w, h, rotate)) in pages.iter().enumerate() {
|
||||
let content_id = 4 + 2 * i;
|
||||
objects.push(format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Rotate {rotate} /Contents {content_id} 0 R /Resources << >> >>"
|
||||
));
|
||||
let content = format!("1 0 0 rg 0 {} 40 40 re f", h - 40.0);
|
||||
objects.push(format!(
|
||||
"<< /Length {} >>\nstream\n{content}\nendstream",
|
||||
content.len()
|
||||
));
|
||||
}
|
||||
let mut out = b"%PDF-1.4\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, obj) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{obj}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref_pos = out.len();
|
||||
out.extend_from_slice(
|
||||
format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
|
||||
);
|
||||
for off in offsets {
|
||||
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n",
|
||||
objects.len() + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_sizes_and_errors() {
|
||||
let pdf = sample_pdf(&[(595.0, 842.0, 0), (842.0, 595.0, 0), (595.0, 842.0, 90)]);
|
||||
let sizes = page_sizes(pdf).unwrap();
|
||||
assert_eq!(sizes, vec![(595.0, 842.0), (842.0, 595.0), (842.0, 595.0)]);
|
||||
assert!(page_sizes(b"kein pdf".to_vec()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_page_draws_content() {
|
||||
let pdf = open(sample_pdf(&[(200.0, 100.0, 0)])).unwrap();
|
||||
let image = render_page(&pdf, 0, 400).unwrap();
|
||||
assert_eq!((image.width, image.height), (400, 200));
|
||||
let px = |x: u32, y: u32| {
|
||||
let i = ((y * image.width + x) * 4) as usize;
|
||||
&image.pixels[i..i + 4]
|
||||
};
|
||||
// Rotes Quadrat oben links, sonst weiß
|
||||
assert_eq!(px(20, 20), &[255, 0, 0, 255]);
|
||||
assert_eq!(px(300, 150), &[255, 255, 255, 255]);
|
||||
assert!(render_page(&pdf, 5, 400).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_service_delivers_in_background() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("a.pdf");
|
||||
std::fs::write(&path, sample_pdf(&[(400.0, 300.0, 0)])).unwrap();
|
||||
let (wake_tx, wake_rx) = mpsc::channel();
|
||||
let mut service = PdfRenderService::new(move || {
|
||||
let _ = wake_tx.send(());
|
||||
});
|
||||
assert!(service.take("a#0", path.clone(), 0).is_none());
|
||||
// Zweite Anfrage erteilt keinen weiteren Auftrag
|
||||
assert!(service.take("a#0", path.clone(), 0).is_none());
|
||||
wake_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(20))
|
||||
.unwrap();
|
||||
let image = service.take("a#0", path.clone(), 0).unwrap().unwrap();
|
||||
assert_eq!(image.width, DISPLAY_RENDER_PIXELS);
|
||||
|
||||
// Vorab rendern: danach sofort verfügbar
|
||||
service.prefetch("a#0", path.clone(), 0);
|
||||
wake_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(20))
|
||||
.unwrap();
|
||||
assert!(service.take("a#0", path, 0).unwrap().is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
//! Legt beim PDF-Export die Originalseiten importierter PDFs als Vektorgrafik unter die
|
||||
//! Notizen (Text bleibt scharf und auswählbar).
|
||||
//!
|
||||
//! Vorgehen: Das von printpdf erzeugte PDF wird mit `lopdf` geöffnet. Jede benötigte Seite
|
||||
//! des Quell-PDFs wird als Form-XObject (Inhalt + Ressourcen der Seite) übernommen und am
|
||||
//! Anfang der Zielseite gezeichnet, also unter Hintergrund, Bildern und Tinte.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use printpdf::lopdf::{Dictionary, Document, Object, ObjectId, Stream};
|
||||
|
||||
/// Eine Zielseite, unter die eine Seite eines Quell-PDFs gelegt wird.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Underlay {
|
||||
/// Seitennummer im Ziel-PDF, ab 0
|
||||
pub target_page: usize,
|
||||
/// Schlüssel des Quell-PDFs in `sources`
|
||||
pub source: String,
|
||||
/// Seitennummer im Quell-PDF, ab 0
|
||||
pub source_page: usize,
|
||||
/// Zielrechteck in PDF-Punkten (links, unten, Breite, Höhe; Ursprung unten links)
|
||||
pub rect: [f32; 4],
|
||||
}
|
||||
|
||||
/// Nicht übernommene Unterlagen samt Grund.
|
||||
pub type FailedUnderlays = Vec<(Underlay, String)>;
|
||||
|
||||
/// Affine PDF-Matrix `[a b c d e f]`: x' = a·x + c·y + e, y' = b·x + d·y + f.
|
||||
pub type Matrix = [f32; 6];
|
||||
|
||||
/// `outer ∘ inner`: erst `inner`, dann `outer` anwenden.
|
||||
pub fn compose(outer: Matrix, inner: Matrix) -> Matrix {
|
||||
let [a1, b1, c1, d1, e1, f1] = outer;
|
||||
let [a2, b2, c2, d2, e2, f2] = inner;
|
||||
[
|
||||
a1 * a2 + c1 * b2,
|
||||
b1 * a2 + d1 * b2,
|
||||
a1 * c2 + c1 * d2,
|
||||
b1 * c2 + d1 * d2,
|
||||
a1 * e2 + c1 * f2 + e1,
|
||||
b1 * e2 + d1 * f2 + f1,
|
||||
]
|
||||
}
|
||||
|
||||
/// Matrix, die den Seitenbereich `bbox` (links, unten, rechts, oben) einer um `rotate` Grad
|
||||
/// im Uhrzeigersinn gedrehten Seite so abbildet, wie ein Betrachter sie zeigt, eingepasst
|
||||
/// in `rect` (links, unten, Breite, Höhe).
|
||||
pub fn placement_matrix(bbox: [f32; 4], rotate: i64, rect: [f32; 4]) -> Matrix {
|
||||
let (w, h) = ((bbox[2] - bbox[0]).max(1e-3), (bbox[3] - bbox[1]).max(1e-3));
|
||||
let to_origin = [1.0, 0.0, 0.0, 1.0, -bbox[0], -bbox[1]];
|
||||
let (rotation, shown_w, shown_h): (Matrix, f32, f32) = match rotate.rem_euclid(360) {
|
||||
90 => ([0.0, -1.0, 1.0, 0.0, 0.0, w], h, w),
|
||||
180 => ([-1.0, 0.0, 0.0, -1.0, w, h], w, h),
|
||||
270 => ([0.0, 1.0, -1.0, 0.0, h, 0.0], h, w),
|
||||
_ => ([1.0, 0.0, 0.0, 1.0, 0.0, 0.0], w, h),
|
||||
};
|
||||
let scale = (rect[2] / shown_w).min(rect[3] / shown_h);
|
||||
let place = [scale, 0.0, 0.0, scale, rect[0], rect[1]];
|
||||
compose(place, compose(rotation, to_origin))
|
||||
}
|
||||
|
||||
/// Erbt ein Seitenattribut über die `Parent`-Kette.
|
||||
fn inherited<'a>(doc: &'a Document, page: &'a Dictionary, key: &[u8]) -> Option<&'a Object> {
|
||||
let mut node = page;
|
||||
for _ in 0..64 {
|
||||
if let Ok(value) = node.get(key) {
|
||||
return Some(value);
|
||||
}
|
||||
let parent = node.get(b"Parent").and_then(Object::as_reference).ok()?;
|
||||
node = doc.get_dictionary(parent).ok()?;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve<'a>(doc: &'a Document, object: &'a Object) -> &'a Object {
|
||||
doc.dereference(object).map(|(_, o)| o).unwrap_or(object)
|
||||
}
|
||||
|
||||
fn read_box(doc: &Document, object: Option<&Object>) -> Option<[f32; 4]> {
|
||||
let array = resolve(doc, object?).as_array().ok()?;
|
||||
let values: Vec<f32> = array
|
||||
.iter()
|
||||
.filter_map(|v| resolve(doc, v).as_float().ok())
|
||||
.collect();
|
||||
let [x0, y0, x1, y1] = <[f32; 4]>::try_from(values).ok()?;
|
||||
Some([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)])
|
||||
}
|
||||
|
||||
/// Baut aus einer Seite des (bereits umnummerierten) Quelldokuments ein Form-XObject.
|
||||
/// Liefert das XObject und die Matrix für die Platzierung in `rect`.
|
||||
fn page_as_form(
|
||||
doc: &Document,
|
||||
page_id: ObjectId,
|
||||
rect: [f32; 4],
|
||||
) -> Result<(Stream, Matrix), String> {
|
||||
let page = doc.get_dictionary(page_id).map_err(|e| e.to_string())?;
|
||||
let media =
|
||||
read_box(doc, inherited(doc, page, b"MediaBox")).unwrap_or([0.0, 0.0, 595.0, 842.0]);
|
||||
let bbox = match read_box(doc, inherited(doc, page, b"CropBox")) {
|
||||
// Sichtbarer Bereich: Schnitt aus Crop- und Media-Box
|
||||
Some(c) => [
|
||||
c[0].max(media[0]),
|
||||
c[1].max(media[1]),
|
||||
c[2].min(media[2]),
|
||||
c[3].min(media[3]),
|
||||
],
|
||||
None => media,
|
||||
};
|
||||
let rotate = inherited(doc, page, b"Rotate")
|
||||
.and_then(|r| resolve(doc, r).as_i64().ok())
|
||||
.unwrap_or(0);
|
||||
let resources = inherited(doc, page, b"Resources")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Object::Dictionary(Dictionary::new()));
|
||||
|
||||
// Ein einzelner Inhaltsstrom wird unverändert (samt Kompression) übernommen,
|
||||
// mehrere werden entpackt und aneinandergehängt
|
||||
let streams = doc.get_page_contents(page_id);
|
||||
let (content, filter) = match streams.as_slice() {
|
||||
[single] => {
|
||||
let stream = doc
|
||||
.get_object(*single)
|
||||
.and_then(Object::as_stream)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let filter = ["Filter", "DecodeParms"]
|
||||
.into_iter()
|
||||
.filter_map(|k| Some((k, stream.dict.get(k.as_bytes()).ok()?.clone())))
|
||||
.collect::<Vec<_>>();
|
||||
(stream.content.clone(), filter)
|
||||
}
|
||||
_ => {
|
||||
let mut content = Vec::new();
|
||||
for id in streams {
|
||||
let stream = doc
|
||||
.get_object(id)
|
||||
.and_then(Object::as_stream)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let data = stream
|
||||
.decompressed_content()
|
||||
.map_err(|e| format!("Inhalt nicht lesbar: {e}"))?;
|
||||
content.extend_from_slice(&data);
|
||||
content.push(b'\n');
|
||||
}
|
||||
(content, Vec::new())
|
||||
}
|
||||
};
|
||||
|
||||
let mut dict = Dictionary::new();
|
||||
dict.set("Type", Object::Name(b"XObject".to_vec()));
|
||||
dict.set("Subtype", Object::Name(b"Form".to_vec()));
|
||||
dict.set(
|
||||
"BBox",
|
||||
Object::Array(bbox.iter().map(|&v| Object::Real(v)).collect()),
|
||||
);
|
||||
dict.set("Resources", resources);
|
||||
for (key, value) in filter {
|
||||
dict.set(key, value);
|
||||
}
|
||||
Ok((
|
||||
Stream::new(dict, content),
|
||||
placement_matrix(bbox, rotate, rect),
|
||||
))
|
||||
}
|
||||
|
||||
/// Legt die Quellseiten unter die Zielseiten. `sources` enthält die Quell-PDFs als Bytes.
|
||||
/// Gibt die neuen PDF-Bytes zurück und die Underlays, die nicht übernommen werden konnten
|
||||
/// (z. B. verschlüsselte PDFs), samt Grund.
|
||||
pub fn apply_underlays(
|
||||
target_bytes: &[u8],
|
||||
underlays: &[Underlay],
|
||||
sources: &HashMap<String, Vec<u8>>,
|
||||
) -> Result<(Vec<u8>, FailedUnderlays), String> {
|
||||
let mut target = Document::load_mem(target_bytes).map_err(|e| e.to_string())?;
|
||||
let target_pages: Vec<ObjectId> = target.get_pages().into_values().collect();
|
||||
let mut failed = Vec::new();
|
||||
|
||||
// Quell-PDFs einmal laden und mit freien Objektnummern in das Ziel übernehmen
|
||||
let mut loaded: HashMap<&str, Result<BTreeMap<u32, ObjectId>, String>> = HashMap::new();
|
||||
for underlay in underlays {
|
||||
if loaded.contains_key(underlay.source.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let result = (|| {
|
||||
let bytes = sources
|
||||
.get(&underlay.source)
|
||||
.ok_or_else(|| "Quell-PDF fehlt".to_string())?;
|
||||
let mut source = Document::load_mem(bytes).map_err(|e| e.to_string())?;
|
||||
if source.is_encrypted() {
|
||||
return Err("Quell-PDF ist verschlüsselt".to_string());
|
||||
}
|
||||
source.renumber_objects_with(target.max_id + 1);
|
||||
let pages = source.get_pages();
|
||||
target.max_id = target.max_id.max(source.max_id);
|
||||
// Katalog und Seitenbaum der Quelle werden nicht referenziert und beim Speichern
|
||||
// mit `prune_objects` entfernt
|
||||
target.objects.extend(source.objects);
|
||||
Ok(pages)
|
||||
})();
|
||||
loaded.insert(&underlay.source, result);
|
||||
}
|
||||
|
||||
for underlay in underlays {
|
||||
let result = (|| {
|
||||
let pages = loaded[underlay.source.as_str()]
|
||||
.as_ref()
|
||||
.map_err(Clone::clone)?;
|
||||
let source_page = *pages
|
||||
.get(&(underlay.source_page as u32 + 1))
|
||||
.ok_or_else(|| format!("Seite {} fehlt im Quell-PDF", underlay.source_page + 1))?;
|
||||
let target_page = *target_pages
|
||||
.get(underlay.target_page)
|
||||
.ok_or_else(|| "Zielseite fehlt".to_string())?;
|
||||
let (form, matrix) = page_as_form(&target, source_page, underlay.rect)?;
|
||||
let form_id = target.add_object(Object::Stream(form));
|
||||
let name = format!("InkPdf{}", form_id.0);
|
||||
add_xobject(&mut target, target_page, &name, form_id)?;
|
||||
let ops = format!(
|
||||
"q {} {} {} {} {} {} cm /{name} Do Q\n",
|
||||
matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5]
|
||||
);
|
||||
prepend_content(&mut target, target_page, ops.into_bytes())
|
||||
})();
|
||||
if let Err(err) = result {
|
||||
failed.push((underlay.clone(), err));
|
||||
}
|
||||
}
|
||||
|
||||
target.prune_objects();
|
||||
// Neue, noch unkomprimierte Ströme packen (bereits gefilterte bleiben unverändert)
|
||||
target.compress();
|
||||
let mut out = Vec::new();
|
||||
target.save_to(&mut out).map_err(|e| e.to_string())?;
|
||||
Ok((out, failed))
|
||||
}
|
||||
|
||||
/// Trägt ein XObject in die Ressourcen der Seite ein.
|
||||
fn add_xobject(
|
||||
doc: &mut Document,
|
||||
page_id: ObjectId,
|
||||
name: &str,
|
||||
id: ObjectId,
|
||||
) -> Result<(), String> {
|
||||
// Ressourcen der Seite als eigenes Wörterbuch an der Seite (geerbte werden übernommen)
|
||||
let resources = {
|
||||
let page = doc.get_dictionary(page_id).map_err(|e| e.to_string())?;
|
||||
match inherited(doc, page, b"Resources").map(|r| resolve(doc, r)) {
|
||||
Some(Object::Dictionary(d)) => d.clone(),
|
||||
_ => Dictionary::new(),
|
||||
}
|
||||
};
|
||||
let mut xobjects = match resources.get(b"XObject").map(|x| resolve(doc, x)) {
|
||||
Ok(Object::Dictionary(d)) => d.clone(),
|
||||
_ => Dictionary::new(),
|
||||
};
|
||||
xobjects.set(name, Object::Reference(id));
|
||||
let mut resources = resources;
|
||||
resources.set("XObject", Object::Dictionary(xobjects));
|
||||
let page = doc
|
||||
.get_object_mut(page_id)
|
||||
.and_then(Object::as_dict_mut)
|
||||
.map_err(|e| e.to_string())?;
|
||||
page.set("Resources", Object::Dictionary(resources));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stellt einen Inhaltsstrom an den Anfang der Seite (zeichnet also zuunterst).
|
||||
fn prepend_content(doc: &mut Document, page_id: ObjectId, content: Vec<u8>) -> Result<(), String> {
|
||||
let existing: Vec<Object> = doc
|
||||
.get_page_contents(page_id)
|
||||
.into_iter()
|
||||
.map(Object::Reference)
|
||||
.collect();
|
||||
let id = doc.add_object(Object::Stream(Stream::new(Dictionary::new(), content)));
|
||||
let mut contents = vec![Object::Reference(id)];
|
||||
contents.extend(existing);
|
||||
let page = doc
|
||||
.get_object_mut(page_id)
|
||||
.and_then(Object::as_dict_mut)
|
||||
.map_err(|e| e.to_string())?;
|
||||
page.set("Contents", Object::Array(contents));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn apply(m: Matrix, x: f32, y: f32) -> (f32, f32) {
|
||||
(m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])
|
||||
}
|
||||
|
||||
fn close(a: (f32, f32), b: (f32, f32)) -> bool {
|
||||
(a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_placement_matrix() {
|
||||
// Querformat-Folie 800 × 600 ohne Drehung in ein 400 × 300-Rechteck bei (10, 20)
|
||||
let m = placement_matrix([0.0, 0.0, 800.0, 600.0], 0, [10.0, 20.0, 400.0, 300.0]);
|
||||
assert!(close(apply(m, 0.0, 0.0), (10.0, 20.0)));
|
||||
assert!(close(apply(m, 800.0, 600.0), (410.0, 320.0)));
|
||||
|
||||
// Verschobene MediaBox
|
||||
let m = placement_matrix([100.0, 50.0, 300.0, 150.0], 0, [0.0, 0.0, 200.0, 100.0]);
|
||||
assert!(close(apply(m, 100.0, 50.0), (0.0, 0.0)));
|
||||
|
||||
// Um 90° gedrehte Hochformatseite (200 × 400) erscheint quer (400 × 200):
|
||||
// die linke untere Ecke landet links oben
|
||||
let m = placement_matrix([0.0, 0.0, 200.0, 400.0], 90, [0.0, 0.0, 400.0, 200.0]);
|
||||
assert!(close(apply(m, 0.0, 0.0), (0.0, 200.0)));
|
||||
assert!(close(apply(m, 200.0, 0.0), (0.0, 0.0)));
|
||||
assert!(close(apply(m, 0.0, 400.0), (400.0, 200.0)));
|
||||
|
||||
// 180° und 270° (= -90°)
|
||||
let m = placement_matrix([0.0, 0.0, 200.0, 400.0], 180, [0.0, 0.0, 200.0, 400.0]);
|
||||
assert!(close(apply(m, 0.0, 0.0), (200.0, 400.0)));
|
||||
let m = placement_matrix([0.0, 0.0, 200.0, 400.0], -90, [0.0, 0.0, 400.0, 200.0]);
|
||||
assert!(close(apply(m, 0.0, 0.0), (400.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_underlays_embeds_source_pages() {
|
||||
let source = crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (595.0, 842.0, 0)]);
|
||||
let target = crate::pdf_doc::tests::sample_pdf(&[(595.0, 842.0, 0), (595.0, 842.0, 0)]);
|
||||
let sources = HashMap::from([("folien".to_string(), source)]);
|
||||
let underlays = vec![
|
||||
Underlay {
|
||||
target_page: 0,
|
||||
source: "folien".into(),
|
||||
source_page: 0,
|
||||
rect: [0.0, 421.0, 595.0, 421.0],
|
||||
},
|
||||
Underlay {
|
||||
target_page: 1,
|
||||
source: "folien".into(),
|
||||
source_page: 5,
|
||||
rect: [0.0, 0.0, 595.0, 842.0],
|
||||
},
|
||||
Underlay {
|
||||
target_page: 1,
|
||||
source: "fehlt".into(),
|
||||
source_page: 0,
|
||||
rect: [0.0, 0.0, 595.0, 842.0],
|
||||
},
|
||||
];
|
||||
let (bytes, failed) = apply_underlays(&target, &underlays, &sources).unwrap();
|
||||
// Fehlende Quellseite und fehlendes PDF werden gemeldet, der Rest klappt
|
||||
assert_eq!(failed.len(), 2);
|
||||
|
||||
let doc = Document::load_mem(&bytes).unwrap();
|
||||
let pages: Vec<ObjectId> = doc.get_pages().into_values().collect();
|
||||
assert_eq!(
|
||||
pages.len(),
|
||||
2,
|
||||
"Quellseiten dürfen nicht als eigene Seiten auftauchen"
|
||||
);
|
||||
let first = doc.get_page_content(pages[0]).unwrap();
|
||||
let text = String::from_utf8_lossy(&first);
|
||||
assert!(
|
||||
text.starts_with("q "),
|
||||
"Unterlage muss zuerst gezeichnet werden: {text}"
|
||||
);
|
||||
assert!(text.contains("Do Q"));
|
||||
// Ursprünglicher Seiteninhalt bleibt erhalten
|
||||
assert!(text.contains("re f"));
|
||||
|
||||
// Form-XObject mit dem Inhalt der Folie ist in den Ressourcen eingetragen
|
||||
let page = doc.get_dictionary(pages[0]).unwrap();
|
||||
let resources = page.get(b"Resources").unwrap().as_dict().unwrap();
|
||||
let xobjects = resources.get(b"XObject").unwrap().as_dict().unwrap();
|
||||
let (_, form_ref) = xobjects.iter().next().unwrap();
|
||||
let form = doc
|
||||
.get_object(form_ref.as_reference().unwrap())
|
||||
.unwrap()
|
||||
.as_stream()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
form.dict.get(b"Subtype").unwrap().as_name().unwrap(),
|
||||
b"Form"
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&form.content).contains("1 0 0 rg"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Erkennung der Laufzeitumgebung und Wahl des Anzeigesystems (X11 oder Wayland).
|
||||
//!
|
||||
//! Unter WSL (WSLg) trennt der Wayland-Compositor die Verbindung, sobald die App ein Bild
|
||||
//! über Vulkan übergibt ("Connection reset by peer"). Über X11 (Xwayland) läuft sie stabil,
|
||||
//! daher wird unter WSL standardmäßig X11 verwendet. `INK_NOTES_DISPLAY=x11|wayland`
|
||||
//! übersteuert die Wahl.
|
||||
|
||||
/// Anzeigesystem, mit dem die Ereignisschleife erzeugt wird.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DisplayBackend {
|
||||
/// Automatische Wahl von winit (Wayland, falls verfügbar, sonst X11)
|
||||
Automatic,
|
||||
X11,
|
||||
Wayland,
|
||||
}
|
||||
|
||||
/// Wählt das Anzeigesystem.
|
||||
///
|
||||
/// - `override_value`: Inhalt von `INK_NOTES_DISPLAY` (`x11` oder `wayland`, sonst ignoriert)
|
||||
/// - `is_wsl`: läuft unter WSL
|
||||
/// - `has_x11_display`: `DISPLAY` ist gesetzt (X11/Xwayland erreichbar)
|
||||
pub fn choose_display_backend(
|
||||
override_value: Option<&str>,
|
||||
is_wsl: bool,
|
||||
has_x11_display: bool,
|
||||
) -> DisplayBackend {
|
||||
match override_value
|
||||
.map(|v| v.trim().to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("x11") => return DisplayBackend::X11,
|
||||
Some("wayland") => return DisplayBackend::Wayland,
|
||||
_ => {}
|
||||
}
|
||||
if is_wsl && has_x11_display {
|
||||
DisplayBackend::X11
|
||||
} else {
|
||||
DisplayBackend::Automatic
|
||||
}
|
||||
}
|
||||
|
||||
/// Läuft das Programm unter WSL (Windows-Subsystem für Linux)?
|
||||
pub fn is_wsl() -> bool {
|
||||
if std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() {
|
||||
return true;
|
||||
}
|
||||
std::fs::read_to_string("/proc/sys/kernel/osrelease")
|
||||
.map(|release| release.to_ascii_lowercase().contains("microsoft"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Wahl anhand der aktuellen Umgebung.
|
||||
pub fn display_backend_from_env() -> DisplayBackend {
|
||||
choose_display_backend(
|
||||
std::env::var("INK_NOTES_DISPLAY").ok().as_deref(),
|
||||
is_wsl(),
|
||||
std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_choose_display_backend() {
|
||||
// WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab)
|
||||
assert_eq!(
|
||||
choose_display_backend(None, true, true),
|
||||
DisplayBackend::X11
|
||||
);
|
||||
// WSL ohne X11-Display: winit entscheiden lassen
|
||||
assert_eq!(
|
||||
choose_display_backend(None, true, false),
|
||||
DisplayBackend::Automatic
|
||||
);
|
||||
// Natives Linux: winit entscheiden lassen
|
||||
assert_eq!(
|
||||
choose_display_backend(None, false, true),
|
||||
DisplayBackend::Automatic
|
||||
);
|
||||
// Übersteuerung
|
||||
assert_eq!(
|
||||
choose_display_backend(Some("wayland"), true, true),
|
||||
DisplayBackend::Wayland
|
||||
);
|
||||
assert_eq!(
|
||||
choose_display_backend(Some(" X11 "), false, true),
|
||||
DisplayBackend::X11
|
||||
);
|
||||
assert_eq!(
|
||||
choose_display_backend(Some("quatsch"), true, true),
|
||||
DisplayBackend::X11
|
||||
);
|
||||
}
|
||||
}
|
||||
+32
-1
@@ -243,7 +243,12 @@ impl SelectionState {
|
||||
if self.selected_ids.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut strokes = page.get_strokes(&self.selected_ids);
|
||||
// Bilder behalten ihre Farben
|
||||
let mut strokes: Vec<_> = page
|
||||
.get_strokes(&self.selected_ids)
|
||||
.into_iter()
|
||||
.filter(|s| !s.is_image())
|
||||
.collect();
|
||||
if strokes.is_empty() {
|
||||
return false;
|
||||
}
|
||||
@@ -499,4 +504,30 @@ mod tests {
|
||||
let restored_pos = page.strokes[0].points[0].position;
|
||||
assert!((orig_pos.x - restored_pos.x).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recolor_ignores_images() {
|
||||
let mut page = Page::new(1, PageBackground::Blank);
|
||||
let corners = [
|
||||
Vec2::new(0.0, 0.0),
|
||||
Vec2::new(100.0, 0.0),
|
||||
Vec2::new(100.0, 50.0),
|
||||
Vec2::new(0.0, 50.0),
|
||||
];
|
||||
page.add_stroke(Stroke::new_image(1, "a.png".into(), corners));
|
||||
let undo_depth = page.history.undo_stack.len();
|
||||
|
||||
let mut sel = SelectionState::new();
|
||||
sel.select_single(1);
|
||||
// Nur ein Bild ausgewählt: nichts umzufärben, kein Undo-Eintrag
|
||||
assert!(!sel.recolor_selected(&mut page, [1.0, 0.0, 0.0, 1.0]));
|
||||
assert_eq!(page.history.undo_stack.len(), undo_depth);
|
||||
|
||||
// Bild und Strich: nur der Strich wird umgefärbt
|
||||
page.add_stroke(make_test_stroke(2, 20.0, 20.0, [0.1, 0.1, 0.1, 1.0]));
|
||||
sel.select_multiple(vec![1, 2]);
|
||||
assert!(sel.recolor_selected(&mut page, [1.0, 0.0, 0.0, 1.0]));
|
||||
assert_eq!(page.strokes[0].color, [1.0, 1.0, 1.0, 1.0]);
|
||||
assert_eq!(page.strokes[1].color, [1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
}
|
||||
|
||||
+149
-3
@@ -6,6 +6,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::background::PaperTheme;
|
||||
use crate::storage::{self, StorageError};
|
||||
|
||||
/// Fensterseite, an der die Werkzeugleiste angedockt ist.
|
||||
@@ -18,11 +19,82 @@ pub enum SidebarSide {
|
||||
Right,
|
||||
}
|
||||
|
||||
/// Druckkennlinie des Stifts: passt die sehr unterschiedlichen Druckbereiche verschiedener
|
||||
/// Digitizer (Wacom EMR, MPP, …) an das Schreibgefühl an.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PressureCurve {
|
||||
/// Exponent der Kennlinie: < 1 weich (schon leichter Druck ergibt kräftige Linien),
|
||||
/// > 1 hart (erst fester Druck ergibt kräftige Linien)
|
||||
pub exponent: f32,
|
||||
/// Rohdruck unterhalb dieser Schwelle zählt als kein Druck (dünnste Linie);
|
||||
/// unterdrückt das Grundrauschen empfindlicher Stifte
|
||||
pub threshold: f32,
|
||||
}
|
||||
|
||||
impl PressureCurve {
|
||||
pub const EXPONENT_RANGE: std::ops::RangeInclusive<f32> = 0.3..=3.0;
|
||||
pub const THRESHOLD_RANGE: std::ops::RangeInclusive<f32> = 0.0..=0.5;
|
||||
|
||||
/// Rechnet den Rohdruck des Stifts (0–1) in den Druck für die Strichbreite um.
|
||||
pub fn apply(&self, raw: f32) -> f32 {
|
||||
let curve = self.sanitized();
|
||||
if !raw.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let normalized = ((raw - curve.threshold) / (1.0 - curve.threshold)).clamp(0.0, 1.0);
|
||||
normalized.powf(curve.exponent)
|
||||
}
|
||||
|
||||
/// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei auf den
|
||||
/// gültigen Bereich.
|
||||
pub fn sanitized(&self) -> Self {
|
||||
let default = Self::default();
|
||||
let clamp = |v: f32, range: &std::ops::RangeInclusive<f32>, fallback: f32| {
|
||||
if v.is_finite() {
|
||||
v.clamp(*range.start(), *range.end())
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
};
|
||||
Self {
|
||||
exponent: clamp(self.exponent, &Self::EXPONENT_RANGE, default.exponent),
|
||||
threshold: clamp(self.threshold, &Self::THRESHOLD_RANGE, default.threshold),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PressureCurve {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
exponent: 1.0,
|
||||
threshold: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Benutzereinstellungen der Oberfläche.
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct UiSettings {
|
||||
pub sidebar_side: SidebarSide,
|
||||
/// Papierfarbe der Anzeige
|
||||
pub paper_theme: PaperTheme,
|
||||
/// Dunkle Stiftfarben auf dunklem Papier aufhellen
|
||||
pub adapt_ink_colors: bool,
|
||||
/// Druckkennlinie des Stifts
|
||||
pub pressure_curve: PressureCurve,
|
||||
}
|
||||
|
||||
impl Default for UiSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sidebar_side: SidebarSide::default(),
|
||||
paper_theme: PaperTheme::default(),
|
||||
adapt_ink_colors: true,
|
||||
pressure_curve: PressureCurve::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UiSettings {
|
||||
@@ -37,7 +109,7 @@ impl UiSettings {
|
||||
|
||||
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
|
||||
pub fn load_from(path: &Path) -> Self {
|
||||
match std::fs::read_to_string(path) {
|
||||
let mut settings: Self = match std::fs::read_to_string(path) {
|
||||
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.",
|
||||
@@ -46,7 +118,9 @@ impl UiSettings {
|
||||
Self::default()
|
||||
}),
|
||||
Err(_) => Self::default(),
|
||||
}
|
||||
};
|
||||
settings.pressure_curve = settings.pressure_curve.sanitized();
|
||||
settings
|
||||
}
|
||||
|
||||
/// Speichert die Einstellungen atomar.
|
||||
@@ -69,6 +143,12 @@ mod tests {
|
||||
|
||||
let settings = UiSettings {
|
||||
sidebar_side: SidebarSide::Right,
|
||||
paper_theme: PaperTheme::Black,
|
||||
adapt_ink_colors: false,
|
||||
pressure_curve: PressureCurve {
|
||||
exponent: 1.8,
|
||||
threshold: 0.1,
|
||||
},
|
||||
};
|
||||
settings.save_to(&path).unwrap();
|
||||
assert_eq!(UiSettings::load_from(&path), settings);
|
||||
@@ -81,4 +161,70 @@ mod tests {
|
||||
std::fs::write(&path, br#"{"zukunft": 1}"#).unwrap();
|
||||
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_old_settings_file_keeps_new_defaults() {
|
||||
// ui.json aus v0.3.x kennt nur die Seite der Werkzeugleiste
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("ui.json");
|
||||
std::fs::write(&path, br#"{"sidebar_side": "Right"}"#).unwrap();
|
||||
let settings = UiSettings::load_from(&path);
|
||||
assert_eq!(settings.sidebar_side, SidebarSide::Right);
|
||||
assert_eq!(settings.paper_theme, PaperTheme::White);
|
||||
assert!(settings.adapt_ink_colors);
|
||||
assert_eq!(settings.pressure_curve, PressureCurve::default());
|
||||
|
||||
// Unsinnige Werte aus einer von Hand bearbeiteten Datei werden begrenzt
|
||||
std::fs::write(
|
||||
&path,
|
||||
br#"{"pressure_curve": {"exponent": 50.0, "threshold": -3.0}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let curve = UiSettings::load_from(&path).pressure_curve;
|
||||
assert_eq!(curve.exponent, 3.0);
|
||||
assert_eq!(curve.threshold, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pressure_curve() {
|
||||
// Standard: unverändert
|
||||
let linear = PressureCurve::default();
|
||||
for p in [0.0, 0.25, 0.5, 1.0] {
|
||||
assert!((linear.apply(p) - p).abs() < 1e-6);
|
||||
}
|
||||
// Weich: halber Druck ergibt mehr als halbe Breite, hart: weniger
|
||||
let soft = PressureCurve {
|
||||
exponent: 0.5,
|
||||
threshold: 0.0,
|
||||
};
|
||||
let hard = PressureCurve {
|
||||
exponent: 2.0,
|
||||
threshold: 0.0,
|
||||
};
|
||||
assert!(soft.apply(0.5) > 0.6);
|
||||
assert!(hard.apply(0.5) < 0.3);
|
||||
// Endpunkte bleiben fest
|
||||
for curve in [soft, hard] {
|
||||
assert_eq!(curve.apply(0.0), 0.0);
|
||||
assert!((curve.apply(1.0) - 1.0).abs() < 1e-6);
|
||||
}
|
||||
// Schwelle: darunter kein Druck, darüber auf den vollen Bereich gestreckt
|
||||
let gated = PressureCurve {
|
||||
exponent: 1.0,
|
||||
threshold: 0.2,
|
||||
};
|
||||
assert_eq!(gated.apply(0.15), 0.0);
|
||||
assert!((gated.apply(0.6) - 0.5).abs() < 1e-6);
|
||||
assert!((gated.apply(1.0) - 1.0).abs() < 1e-6);
|
||||
// Monoton steigend
|
||||
let mut last = -1.0;
|
||||
for i in 0..=100 {
|
||||
let v = hard.apply(i as f32 / 100.0);
|
||||
assert!(v >= last);
|
||||
last = v;
|
||||
}
|
||||
// Ungültige Eingaben ergeben nie NaN
|
||||
assert_eq!(linear.apply(f32::NAN), 0.0);
|
||||
assert_eq!(linear.apply(7.0), 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ struct VertexOutput {
|
||||
struct CameraUniforms {
|
||||
transform_scale: vec2<f32>,
|
||||
transform_offset: vec2<f32>,
|
||||
// x: 1.0 = dunkle Tinte für dunkles Papier aufhellen (siehe `adapt_ink_color`)
|
||||
ink_params: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0)
|
||||
@@ -32,3 +34,46 @@ fn vs_main(in: VertexInput) -> VertexOutput {
|
||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
return in.color;
|
||||
}
|
||||
|
||||
// Striche: bei dunklem Papier wird die HSL-Helligkeit dunkler Farben gespiegelt
|
||||
// (L -> 1 - L, Farbton und Sättigung bleiben). Entspricht `background::adapt_ink_color`.
|
||||
@fragment
|
||||
fn fs_ink(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
if (camera.ink_params.x < 0.5) {
|
||||
return in.color;
|
||||
}
|
||||
let max_c = max(max(in.color.r, in.color.g), in.color.b);
|
||||
let min_c = min(min(in.color.r, in.color.g), in.color.b);
|
||||
let lift = max(1.0 - max_c - min_c, 0.0);
|
||||
return vec4<f32>(in.color.rgb + vec3<f32>(lift), in.color.a);
|
||||
}
|
||||
|
||||
// Eingefügte Bilder: texturierte Vierecke unter der Tinte
|
||||
struct ImageVertexInput {
|
||||
@location(0) position: vec2<f32>,
|
||||
@location(1) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
struct ImageVertexOutput {
|
||||
@builtin(position) clip_position: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
@group(1) @binding(0)
|
||||
var image_texture: texture_2d<f32>;
|
||||
@group(1) @binding(1)
|
||||
var image_sampler: sampler;
|
||||
|
||||
@vertex
|
||||
fn vs_image(in: ImageVertexInput) -> ImageVertexOutput {
|
||||
var out: ImageVertexOutput;
|
||||
let ndc = in.position * camera.transform_scale + camera.transform_offset;
|
||||
out.clip_position = vec4<f32>(ndc, 0.0, 1.0);
|
||||
out.uv = in.uv;
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_image(in: ImageVertexOutput) -> @location(0) vec4<f32> {
|
||||
return textureSample(image_texture, image_sampler, in.uv);
|
||||
}
|
||||
|
||||
+221
-5
@@ -70,6 +70,9 @@ pub struct StrokeData {
|
||||
pub width: f32,
|
||||
#[serde(default = "default_finalized")]
|
||||
pub finalized: bool,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
impl From<&Stroke> for StrokeData {
|
||||
@@ -82,6 +85,7 @@ impl From<&Stroke> for StrokeData {
|
||||
tool: stroke.tool,
|
||||
width: stroke.width,
|
||||
finalized: stroke.finalized,
|
||||
image: stroke.image.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,6 +100,17 @@ impl StrokeData {
|
||||
.copied()
|
||||
.map(StylusSample::from)
|
||||
.collect();
|
||||
if let Some(asset) = &self.image {
|
||||
if let [a, b, c, d, ..] = stylus_samples.as_slice() {
|
||||
let mut stroke = Stroke::new_image(
|
||||
self.id,
|
||||
asset.clone(),
|
||||
[a.position, b.position, c.position, d.position],
|
||||
);
|
||||
stroke.color = self.color;
|
||||
return stroke;
|
||||
}
|
||||
}
|
||||
let width = if self.width > 0.0 {
|
||||
self.width
|
||||
} else if self.shape.is_some() {
|
||||
@@ -117,15 +132,27 @@ impl StrokeData {
|
||||
};
|
||||
let mut stroke = Stroke::new(self.id, stylus_samples, vertices, self.color);
|
||||
stroke.shape = self.shape.clone();
|
||||
stroke.tool = self.tool;
|
||||
stroke.tool = crate::document::StrokeTool::resolve_stored(
|
||||
self.tool,
|
||||
self.color,
|
||||
self.shape.is_some(),
|
||||
);
|
||||
stroke.width = width;
|
||||
stroke.finalized = self.finalized;
|
||||
stroke
|
||||
}
|
||||
}
|
||||
|
||||
/// Aktuelle Dateiformat-Version von ink-notes (Version 2: Stroke-Treue mit tool, width, finalized).
|
||||
pub const CURRENT_FORMAT_VERSION: u32 = 2;
|
||||
/// Aktuelle Dateiformat-Version von ink-notes.
|
||||
/// - Version 2: Stroke-Treue mit tool, width, finalized
|
||||
/// - Version 3: neue Formen (Ellipse, Pfeil). Ältere App-Versionen kennen sie nicht und würden
|
||||
/// betroffene Seiten für beschädigt halten; mit Version 3 lehnen sie das Notizbuch stattdessen
|
||||
/// sauber als „neuere Version“ ab.
|
||||
/// - Version 4: eingefügte Bilder (`image` am Strich, Ordner `assets/`). Ältere Versionen würden
|
||||
/// 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;
|
||||
|
||||
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
|
||||
pub fn legacy_format_version() -> u32 {
|
||||
@@ -139,6 +166,9 @@ pub struct PageData {
|
||||
pub format_version: u32,
|
||||
pub id: u64,
|
||||
pub background: PageBackground,
|
||||
/// Importierte PDF-Seite als Hintergrund
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pdf_background: Option<crate::document::PdfBackground>,
|
||||
pub strokes: Vec<StrokeData>,
|
||||
}
|
||||
|
||||
@@ -148,6 +178,7 @@ impl From<&Page> for PageData {
|
||||
format_version: CURRENT_FORMAT_VERSION,
|
||||
id: page.id,
|
||||
background: page.background,
|
||||
pdf_background: page.pdf_background.clone(),
|
||||
strokes: page.strokes.iter().map(StrokeData::from).collect(),
|
||||
}
|
||||
}
|
||||
@@ -157,6 +188,7 @@ impl PageData {
|
||||
/// Wandelt die Seitendaten in eine runtime-aktive `Page` mit initialisiertem Cache um.
|
||||
pub fn to_page(&self) -> Page {
|
||||
let mut page = Page::new(self.id, self.background);
|
||||
page.pdf_background = self.pdf_background.clone();
|
||||
for stroke_data in &self.strokes {
|
||||
page.strokes.push(stroke_data.to_stroke());
|
||||
}
|
||||
@@ -1030,6 +1062,50 @@ pub fn create_new_notebook(
|
||||
Ok((notebook, manifest, notebook_dir))
|
||||
}
|
||||
|
||||
/// Legt ein neues Notizbuch aus einem PDF an: eine Seite je PDF-Seite, die PDF-Seite als
|
||||
/// Hintergrund. Das PDF wird unverändert im Asset-Ordner abgelegt; alle Seiten werden sofort
|
||||
/// gespeichert.
|
||||
pub fn create_notebook_from_pdf(
|
||||
base_dir: &Path,
|
||||
title: &str,
|
||||
pdf_bytes: Vec<u8>,
|
||||
) -> Result<(Notebook, NotebookManifest, PathBuf), String> {
|
||||
let sizes = crate::pdf_doc::page_sizes(pdf_bytes.clone())?;
|
||||
let (_, _, dir) = create_new_notebook(base_dir, title).map_err(|e| e.to_string())?;
|
||||
let result = (|| {
|
||||
let asset = crate::assets::AssetStore::new(&dir)
|
||||
.import_pdf(&pdf_bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let pages: Vec<Page> = sizes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &(width_pt, height_pt))| {
|
||||
let mut page = Page::new(i as u64 + 1, PageBackground::Blank);
|
||||
page.pdf_background = Some(crate::document::PdfBackground {
|
||||
asset: asset.clone(),
|
||||
page: i as u32,
|
||||
width_pt,
|
||||
height_pt,
|
||||
});
|
||||
page
|
||||
})
|
||||
.collect();
|
||||
let page_count = pages.len() as u64;
|
||||
let notebook = Notebook::from_pages(pages, 0, 1, page_count + 1);
|
||||
let mut manifest = load_manifest(&dir).map_err(|e| e.to_string())?;
|
||||
save_notebook(&dir, ¬ebook, &mut manifest).map_err(|e| e.to_string())?;
|
||||
Ok((notebook, manifest))
|
||||
})();
|
||||
match result {
|
||||
Ok((notebook, manifest)) => Ok((notebook, manifest, dir)),
|
||||
Err(err) => {
|
||||
// Halbfertiges Notizbuch nicht liegen lassen
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Benennt ein Notizbuch um und aktualisiert das Manifest atomar auf der Festplatte.
|
||||
pub fn rename_notebook(
|
||||
notebook_dir: &Path,
|
||||
@@ -2169,12 +2245,12 @@ mod tests {
|
||||
let (mut loaded, _) = load_notebook(&dir).unwrap();
|
||||
assert!(!loaded.pages[1].is_loaded);
|
||||
// Ohne Nachladen verweigert der Export, statt leere Seiten zu erzeugen
|
||||
assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_err());
|
||||
assert!(crate::export::generate_pdf_document(&loaded, Default::default(), None).is_err());
|
||||
|
||||
load_all_pages(&dir, &mut loaded).unwrap();
|
||||
assert_eq!(loaded.pages[1].strokes.len(), 1);
|
||||
assert_eq!(loaded.pages[1].background, PageBackground::Lined);
|
||||
assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_ok());
|
||||
assert!(crate::export::generate_pdf_document(&loaded, Default::default(), None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2233,4 +2309,144 @@ mod tests {
|
||||
"Seitendatei wurde überschrieben"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_shapes_round_trip_through_page_file() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let (mut notebook, mut manifest, dir) =
|
||||
create_new_notebook(temp_dir.path(), "Neue Formen").unwrap();
|
||||
let shapes = [
|
||||
crate::geometry::RecognizedShape::Ellipse {
|
||||
center: glam::Vec2::new(500.0, 600.0),
|
||||
radius_x: 250.0,
|
||||
radius_y: 120.0,
|
||||
rotation: 0.3,
|
||||
},
|
||||
crate::geometry::RecognizedShape::Arrow {
|
||||
start: glam::Vec2::new(100.0, 100.0),
|
||||
end: glam::Vec2::new(700.0, 300.0),
|
||||
double: true,
|
||||
},
|
||||
];
|
||||
for (i, shape) in shapes.iter().enumerate() {
|
||||
notebook
|
||||
.current_page_mut()
|
||||
.strokes
|
||||
.push(crate::geometry::shape_to_stroke(
|
||||
shape.clone(),
|
||||
i as u64 + 1,
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
5.0,
|
||||
));
|
||||
}
|
||||
save_notebook(&dir, ¬ebook, &mut manifest).unwrap();
|
||||
assert_eq!(
|
||||
load_manifest(&dir).unwrap().format_version,
|
||||
CURRENT_FORMAT_VERSION
|
||||
);
|
||||
|
||||
let (reloaded, _) = load_notebook(&dir).unwrap();
|
||||
let loaded: Vec<_> = reloaded
|
||||
.current_page()
|
||||
.strokes
|
||||
.iter()
|
||||
.map(|s| s.shape.clone())
|
||||
.collect();
|
||||
assert_eq!(loaded, shapes.iter().cloned().map(Some).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stroke_tool_of_old_files_is_recovered() {
|
||||
// Bis v0.3.7 wurde jeder Strich als "pen" gespeichert
|
||||
let json = r#"{"id": 1, "color": [1.0, 0.95, 0.1, 0.35], "tool": "pen",
|
||||
"samples": [{"x": 10.0, "y": 10.0, "p": 0.5, "t": 0.0},
|
||||
{"x": 90.0, "y": 10.0, "p": 0.5, "t": 0.1}]}"#;
|
||||
let data: StrokeData = serde_json::from_str(json).unwrap();
|
||||
let highlighter = data.to_stroke();
|
||||
assert_eq!(highlighter.tool, crate::document::StrokeTool::Highlighter);
|
||||
|
||||
let mut pen = data.clone();
|
||||
pen.color = [0.12, 0.12, 0.14, 1.0];
|
||||
assert_eq!(pen.to_stroke().tool, crate::document::StrokeTool::Pen);
|
||||
|
||||
let mut shape = pen.clone();
|
||||
shape.shape = Some(crate::geometry::RecognizedShape::Line {
|
||||
start: glam::Vec2::new(10.0, 10.0),
|
||||
end: glam::Vec2::new(90.0, 10.0),
|
||||
});
|
||||
assert_eq!(shape.to_stroke().tool, crate::document::StrokeTool::Shape);
|
||||
|
||||
// Ausdrücklich gespeicherte Werkzeuge bleiben unverändert
|
||||
let mut explicit = data.clone();
|
||||
explicit.tool = crate::document::StrokeTool::Shape;
|
||||
assert_eq!(
|
||||
explicit.to_stroke().tool,
|
||||
crate::document::StrokeTool::Shape
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_objects_round_trip_through_page_file() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let (mut notebook, mut manifest, dir) =
|
||||
create_new_notebook(temp_dir.path(), "Mit Bild").unwrap();
|
||||
let assets = crate::assets::AssetStore::new(&dir);
|
||||
let imported = assets.import_rgba(4, 2, vec![200; 32]).unwrap();
|
||||
let corners = [
|
||||
glam::Vec2::new(100.0, 100.0),
|
||||
glam::Vec2::new(500.0, 120.0),
|
||||
glam::Vec2::new(480.0, 320.0),
|
||||
glam::Vec2::new(80.0, 300.0),
|
||||
];
|
||||
notebook.current_page_mut().strokes.push(Stroke::new_image(
|
||||
7,
|
||||
imported.asset.clone(),
|
||||
corners,
|
||||
));
|
||||
save_notebook(&dir, ¬ebook, &mut manifest).unwrap();
|
||||
assert_eq!(
|
||||
load_manifest(&dir).unwrap().format_version,
|
||||
CURRENT_FORMAT_VERSION
|
||||
);
|
||||
|
||||
let (reloaded, _) = load_notebook(&dir).unwrap();
|
||||
let image = &reloaded.current_page().strokes[0];
|
||||
assert_eq!(image.image.as_deref(), Some(imported.asset.as_str()));
|
||||
assert_eq!(image.image_corners(), Some(corners));
|
||||
assert!(image.vertices.is_empty());
|
||||
// Bilddatei liegt im Notizbuchordner
|
||||
assert!(dir.join("assets").join(&imported.asset).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_notebook_from_pdf() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let pdf = crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (595.0, 842.0, 0)]);
|
||||
let (notebook, manifest, dir) =
|
||||
create_notebook_from_pdf(temp_dir.path(), "Vorlesung 3", pdf.clone()).unwrap();
|
||||
assert_eq!(manifest.title, "Vorlesung 3");
|
||||
assert_eq!(notebook.pages.len(), 2);
|
||||
assert_eq!(notebook.next_page_id, 3);
|
||||
|
||||
// Nach dem Neuladen: jede Seite verweist auf ihre PDF-Seite, das PDF liegt unverändert
|
||||
// im Asset-Ordner
|
||||
let (mut reloaded, _) = load_notebook(&dir).unwrap();
|
||||
load_all_pages(&dir, &mut reloaded).unwrap();
|
||||
for (i, page) in reloaded.pages.iter().enumerate() {
|
||||
let bg = page.pdf_background.as_ref().expect("PDF-Hintergrund fehlt");
|
||||
assert_eq!(bg.page, i as u32);
|
||||
assert_eq!(page.background, PageBackground::Blank);
|
||||
}
|
||||
let first = reloaded.pages[0].pdf_background.clone().unwrap();
|
||||
assert_eq!((first.width_pt, first.height_pt), (842.0, 595.0));
|
||||
let assets = crate::assets::AssetStore::new(&dir);
|
||||
assert_eq!(assets.read_bytes(&first.asset).unwrap(), pdf);
|
||||
|
||||
// Kein PDF: Fehler und kein halbfertiges Notizbuch
|
||||
let before = std::fs::read_dir(temp_dir.path()).unwrap().count();
|
||||
assert!(
|
||||
create_notebook_from_pdf(temp_dir.path(), "Kaputt", b"%PDF-kaputt".to_vec()).is_err()
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(temp_dir.path()).unwrap().count(), before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,20 @@
|
||||
//! (links oder rechts, umschaltbar):
|
||||
//! - Symbol-Knöpfe mit 40 px Kantenlänge (stift- und fingertauglich), Tooltips mit Kürzeln
|
||||
//! - Werkzeuge, Farben des aktiven Werkzeugs, Auswahl-Aktionen, Undo/Redo, Seitennavigation
|
||||
//! - Selten genutzte Funktionen (PDF, Hintergrund, Zoom, Touch-Modus, Leistenseite,
|
||||
//! Updates, Speicherstatus) im ☰-Menü
|
||||
//! - Selten genutzte Funktionen (PDF, Hintergrund, Papierfarbe, Zoom, Touch-Modus,
|
||||
//! Druckkurve, Leistenseite, Updates, Speicherstatus) im ☰-Menü
|
||||
//! - Popover (Stift/Marker/Menü) öffnen sich zur Blattseite hin
|
||||
//! - Hit-Testing für Event-Transparenz außerhalb der Leiste und der Popover
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::autosave::AutosaveStatus;
|
||||
use crate::document::{Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH};
|
||||
use crate::settings::SidebarSide;
|
||||
use crate::background::{adapt_ink_color, PaperTheme};
|
||||
use crate::document::{
|
||||
EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH,
|
||||
};
|
||||
use crate::file_picker::{FilePicker, PickerKind};
|
||||
use crate::settings::{PressureCurve, SidebarSide};
|
||||
use crate::storage::NotebookSummary;
|
||||
|
||||
/// Aktives Werkzeug für das Zeichnen oder Bearbeiten.
|
||||
@@ -40,6 +44,15 @@ impl ActiveTool {
|
||||
pub fn is_drawing(&self) -> bool {
|
||||
matches!(self, ActiveTool::Pen | ActiveTool::Highlighter)
|
||||
}
|
||||
|
||||
/// Werkzeug, das ein mit diesem Werkzeug gezeichneter Strich erhält.
|
||||
pub fn stroke_tool(&self, has_shape: bool) -> StrokeTool {
|
||||
match self {
|
||||
_ if has_shape => StrokeTool::Shape,
|
||||
ActiveTool::Highlighter => StrokeTool::Highlighter,
|
||||
_ => StrokeTool::Pen,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Farbvorgabe: RGBA-Farbe und Bezeichnung.
|
||||
@@ -132,6 +145,17 @@ pub enum UiAction {
|
||||
UnlockCorruptedPage(u64),
|
||||
/// Werkzeugleiste an die andere Fensterseite verlegen (wird gespeichert)
|
||||
SetSidebarSide(SidebarSide),
|
||||
/// Papierfarbe der Anzeige und Aufhellen dunkler Tinte (wird gespeichert)
|
||||
SetPaperTheme {
|
||||
theme: PaperTheme,
|
||||
adapt_ink: bool,
|
||||
},
|
||||
/// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`)
|
||||
SavePressureCurve(PressureCurve),
|
||||
/// Bilddatei auf der aktuellen Seite einfügen
|
||||
InsertImageFile(std::path::PathBuf),
|
||||
/// PDF als neues Notizbuch importieren
|
||||
ImportPdf(std::path::PathBuf),
|
||||
// Beenden-Sicherheit bei Fehlschlag
|
||||
RetryShutdown,
|
||||
ExportRecoveryAndExit,
|
||||
@@ -183,6 +207,23 @@ pub struct UiState {
|
||||
pub sidebar_side: SidebarSide,
|
||||
/// Öffnungszustand des ☰-Menüs
|
||||
pub is_more_menu_open: bool,
|
||||
/// Welche Striche der Radierer entfernt (gilt auch für Stiftrückseite und Seitentaste)
|
||||
pub eraser_mode: EraserMode,
|
||||
/// Papierfarbe der Anzeige
|
||||
pub paper_theme: PaperTheme,
|
||||
/// Dunkle Tinte auf dunklem Papier aufhellen
|
||||
pub adapt_ink_colors: bool,
|
||||
/// Druckkurve des Stifts
|
||||
pub pressure_curve: PressureCurve,
|
||||
/// Zuletzt gemessener Rohdruck des Stifts (Anzeige in der Druckkurve)
|
||||
pub last_raw_pressure: Option<f32>,
|
||||
/// Geöffnete Dateiauswahl („Bild einfügen“ oder „PDF importieren“)
|
||||
pub file_picker: Option<FilePicker>,
|
||||
/// 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<UiAction>,
|
||||
/// Zuletzt in der Dateiauswahl benutzter Ordner (während der Sitzung)
|
||||
pub last_picker_dir: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
@@ -218,6 +259,14 @@ impl Default for UiState {
|
||||
shutdown_error: None,
|
||||
sidebar_side: SidebarSide::Left,
|
||||
is_more_menu_open: false,
|
||||
eraser_mode: EraserMode::All,
|
||||
paper_theme: PaperTheme::White,
|
||||
adapt_ink_colors: true,
|
||||
pressure_curve: PressureCurve::default(),
|
||||
last_raw_pressure: None,
|
||||
file_picker: None,
|
||||
queued_actions: Vec::new(),
|
||||
last_picker_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,6 +288,23 @@ impl UiState {
|
||||
self.show_page_jump_popup = false;
|
||||
self.is_document_menu_open = false;
|
||||
self.is_update_dialog_open = false;
|
||||
self.file_picker = None;
|
||||
}
|
||||
|
||||
/// Öffnet die Dateiauswahl „Bild einfügen“ im zuletzt benutzten Ordner.
|
||||
pub fn open_image_picker(&mut self) {
|
||||
self.open_file_picker(PickerKind::Image);
|
||||
}
|
||||
|
||||
/// Öffnet die Dateiauswahl „PDF importieren“ im zuletzt benutzten Ordner.
|
||||
pub fn open_pdf_picker(&mut self) {
|
||||
self.open_file_picker(PickerKind::Pdf);
|
||||
}
|
||||
|
||||
fn open_file_picker(&mut self, kind: PickerKind) {
|
||||
let dir = self.last_picker_dir.clone();
|
||||
self.close_overlays();
|
||||
self.file_picker = Some(FilePicker::open(kind, dir.as_deref()));
|
||||
}
|
||||
|
||||
/// Länge der längsten Farbpalette; so viel Platz hält die Leiste immer frei.
|
||||
@@ -706,22 +772,53 @@ const PAGE_TILE: egui::Vec2 = egui::vec2(66.0, 66.0 * PAGE_HEIGHT / PAGE_WIDTH +
|
||||
/// Höchstzahl gezeichneter Punkte pro Vorschau (dünnt volle Seiten aus).
|
||||
const THUMBNAIL_POINT_BUDGET: usize = 1500;
|
||||
|
||||
/// Zeichenbefehle für die verkleinerte Vorschau einer Seite in `rect` (A4-Format).
|
||||
/// Striche werden als Linien gezeichnet und bei vielen Punkten gleichmäßig ausgedünnt.
|
||||
pub fn page_thumbnail_shapes(page: &Page, rect: egui::Rect) -> Vec<egui::Shape> {
|
||||
/// Zeichenbefehle für die verkleinerte Vorschau einer Seite in `rect` (A4-Format) in der
|
||||
/// Papierfarbe `paper`. Striche werden als Linien gezeichnet und bei vielen Punkten
|
||||
/// gleichmäßig ausgedünnt; `adapt_ink` hellt dunkle Tinte wie auf dem Blatt auf.
|
||||
pub fn page_thumbnail_shapes(
|
||||
page: &Page,
|
||||
rect: egui::Rect,
|
||||
paper: PaperTheme,
|
||||
adapt_ink: bool,
|
||||
) -> Vec<egui::Shape> {
|
||||
let mut shapes = vec![egui::Shape::rect_filled(
|
||||
rect,
|
||||
egui::Rounding::same(3.0),
|
||||
egui::Color32::WHITE,
|
||||
linear_color32(paper.paper_color()),
|
||||
)];
|
||||
let adapt = adapt_ink && paper.is_dark();
|
||||
let scale = rect.width() / PAGE_WIDTH;
|
||||
let to_screen = |p: glam::Vec2| rect.min + egui::vec2(p.x * scale, p.y * scale);
|
||||
|
||||
// Importierte PDF-Seite als helle Fläche mit Rand
|
||||
if let Some(pdf) = &page.pdf_background {
|
||||
let points: Vec<egui::Pos2> = pdf.corners().iter().map(|&c| to_screen(c)).collect();
|
||||
shapes.push(egui::Shape::convex_polygon(
|
||||
points,
|
||||
egui::Color32::from_rgb(236, 238, 243),
|
||||
egui::Stroke::new(0.8_f32, egui::Color32::from_gray(150)),
|
||||
));
|
||||
}
|
||||
|
||||
// Bilder als Flächen unter der Tinte
|
||||
for corners in page.strokes.iter().filter_map(|s| s.image_corners()) {
|
||||
shapes.push(egui::Shape::convex_polygon(
|
||||
corners.iter().map(|&c| to_screen(c)).collect(),
|
||||
egui::Color32::from_rgb(168, 182, 200),
|
||||
egui::Stroke::NONE,
|
||||
));
|
||||
}
|
||||
|
||||
let total: usize = page.strokes.iter().map(|s| s.points.len()).sum();
|
||||
let step = total.div_ceil(THUMBNAIL_POINT_BUDGET).max(1);
|
||||
|
||||
for stroke in &page.strokes {
|
||||
let mut color = color32_from(stroke.color);
|
||||
for stroke in page.strokes.iter().filter(|s| !s.is_image()) {
|
||||
let ink = if adapt {
|
||||
adapt_ink_color(stroke.color)
|
||||
} else {
|
||||
stroke.color
|
||||
};
|
||||
let mut color = color32_from(ink);
|
||||
if stroke.tool == StrokeTool::Highlighter {
|
||||
color = color.gamma_multiply(0.45);
|
||||
}
|
||||
@@ -743,9 +840,15 @@ pub fn page_thumbnail_shapes(page: &Page, rect: egui::Rect) -> Vec<egui::Shape>
|
||||
shapes
|
||||
}
|
||||
|
||||
/// Linearer Farbwert (wie auf der GPU) als egui-Farbe (sRGB).
|
||||
fn linear_color32(color: [f32; 4]) -> egui::Color32 {
|
||||
egui::Rgba::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]).into()
|
||||
}
|
||||
|
||||
/// Kachel der Seitenauswahl: Vorschau der Seite mit Seitenzahl darunter.
|
||||
fn page_thumbnail(
|
||||
ui: &mut egui::Ui,
|
||||
state: &UiState,
|
||||
page: &Page,
|
||||
number: usize,
|
||||
is_current: bool,
|
||||
@@ -757,7 +860,12 @@ fn page_thumbnail(
|
||||
egui::vec2(PAGE_TILE.x, PAGE_TILE.x * PAGE_HEIGHT / PAGE_WIDTH),
|
||||
);
|
||||
let painter = ui.painter();
|
||||
painter.extend(page_thumbnail_shapes(page, thumb));
|
||||
painter.extend(page_thumbnail_shapes(
|
||||
page,
|
||||
thumb,
|
||||
state.paper_theme,
|
||||
state.adapt_ink_colors,
|
||||
));
|
||||
if !page.is_loaded {
|
||||
painter.text(
|
||||
thumb.center(),
|
||||
@@ -790,6 +898,233 @@ fn page_thumbnail(
|
||||
response.on_hover_text(format!("Seite {number}"))
|
||||
}
|
||||
|
||||
/// Auswahlknopf für eine Papierfarbe: Farbmuster und Name.
|
||||
fn paper_theme_button(ui: &mut egui::Ui, theme: PaperTheme, selected: bool) -> egui::Response {
|
||||
let response = ui.add_sized(
|
||||
[115.0, 28.0],
|
||||
egui::SelectableLabel::new(selected, format!(" {}", theme.label())),
|
||||
);
|
||||
let swatch = egui::Rect::from_center_size(
|
||||
egui::pos2(response.rect.min.x + 14.0, response.rect.center().y),
|
||||
egui::vec2(14.0, 14.0),
|
||||
);
|
||||
ui.painter().rect_filled(
|
||||
swatch,
|
||||
egui::Rounding::same(3.0),
|
||||
linear_color32(theme.paper_color()),
|
||||
);
|
||||
ui.painter().rect_stroke(
|
||||
swatch,
|
||||
egui::Rounding::same(3.0),
|
||||
egui::Stroke::new(1.0_f32, egui::Color32::from_gray(120)),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
/// 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).
|
||||
fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option<PressureCurve> {
|
||||
let mut save = false;
|
||||
let mut track = |response: egui::Response| {
|
||||
if response.drag_stopped() || (response.changed() && !response.dragged()) {
|
||||
save = true;
|
||||
}
|
||||
};
|
||||
|
||||
ui.add_space(4.0);
|
||||
ui.label("Druckkurve des Stifts");
|
||||
let (graph, _) =
|
||||
ui.allocate_exact_size(egui::vec2(ui.available_width(), 56.0), egui::Sense::hover());
|
||||
let painter = ui.painter_at(graph);
|
||||
painter.rect_filled(
|
||||
graph,
|
||||
egui::Rounding::same(4.0),
|
||||
egui::Color32::from_black_alpha(90),
|
||||
);
|
||||
let to_screen = |raw: f32, out: f32| {
|
||||
egui::pos2(
|
||||
graph.min.x + 6.0 + raw * (graph.width() - 12.0),
|
||||
graph.max.y - 6.0 - out * (graph.height() - 12.0),
|
||||
)
|
||||
};
|
||||
painter.line_segment(
|
||||
[to_screen(0.0, 0.0), to_screen(1.0, 1.0)],
|
||||
egui::Stroke::new(1.0_f32, egui::Color32::from_white_alpha(40)),
|
||||
);
|
||||
let curve = state.pressure_curve;
|
||||
let points: Vec<egui::Pos2> = (0..=48)
|
||||
.map(|i| {
|
||||
let raw = i as f32 / 48.0;
|
||||
to_screen(raw, curve.apply(raw))
|
||||
})
|
||||
.collect();
|
||||
painter.add(egui::Shape::line(
|
||||
points,
|
||||
egui::Stroke::new(2.0_f32, egui::Color32::from_rgb(110, 165, 255)),
|
||||
));
|
||||
if let Some(raw) = state.last_raw_pressure {
|
||||
let raw = raw.clamp(0.0, 1.0);
|
||||
painter.circle_filled(
|
||||
to_screen(raw, curve.apply(raw)),
|
||||
4.0,
|
||||
egui::Color32::from_rgb(255, 200, 80),
|
||||
);
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("weich");
|
||||
ui.spacing_mut().slider_width = (ui.available_width() - 40.0).max(60.0);
|
||||
track(ui.add(
|
||||
egui::Slider::new(&mut state.pressure_curve.exponent, PressureCurve::EXPONENT_RANGE)
|
||||
.logarithmic(true)
|
||||
.show_value(false),
|
||||
).on_hover_text("Weich: schon leichter Druck ergibt kräftige Linien.\nHart: erst fester Druck ergibt kräftige Linien."));
|
||||
ui.label("hart");
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Mindestdruck");
|
||||
ui.spacing_mut().slider_width = (ui.available_width() - 60.0).max(60.0);
|
||||
track(ui.add(
|
||||
egui::Slider::new(&mut state.pressure_curve.threshold, PressureCurve::THRESHOLD_RANGE)
|
||||
.custom_formatter(|v, _| format!("{:.0} %", v * 100.0))
|
||||
.custom_parser(|s| s.trim().trim_end_matches('%').trim().parse::<f64>().ok().map(|v| v / 100.0)),
|
||||
).on_hover_text("Leichterer Druck zählt als kein Druck (dünnste Linie).\nHilft bei Stiften, die schon beim Schweben Druck melden."));
|
||||
});
|
||||
if ui
|
||||
.add_enabled(
|
||||
state.pressure_curve != PressureCurve::default(),
|
||||
egui::Button::new("Druckkurve zurücksetzen"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
state.pressure_curve = PressureCurve::default();
|
||||
save = true;
|
||||
}
|
||||
save.then_some(state.pressure_curve)
|
||||
}
|
||||
|
||||
/// Dialog „Bild einfügen“ bzw. „PDF importieren“: Schnellzugriffe, Ordnerpfad und Liste aus
|
||||
/// Ordnern und passenden Dateien.
|
||||
fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Vec<UiAction>) {
|
||||
let mut close = false;
|
||||
let mut navigate: Option<std::path::PathBuf> = None;
|
||||
let mut go_up = false;
|
||||
let mut chosen: Option<std::path::PathBuf> = None;
|
||||
|
||||
let Some(picker) = state.file_picker.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let picker_kind = picker.kind;
|
||||
let (title, header, file_icon, empty_hint, tip) = match picker.kind {
|
||||
PickerKind::Image => (
|
||||
"Bild einfügen",
|
||||
"🖼 Bild einfügen",
|
||||
"🖼",
|
||||
"Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)",
|
||||
"Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen.",
|
||||
),
|
||||
PickerKind::Pdf => (
|
||||
"PDF importieren",
|
||||
"📄 PDF importieren",
|
||||
"📄",
|
||||
"Keine Ordner oder PDF-Dateien",
|
||||
"Das PDF wird als neues Notizbuch angelegt, eine Seite je PDF-Seite. \
|
||||
Beim Export liegen deine Notizen über den Originalseiten.",
|
||||
),
|
||||
};
|
||||
dialog_window(title, state, false).show(ctx, |ui| {
|
||||
ui.set_width(460.0);
|
||||
touch_friendly_spacing(ui);
|
||||
if dialog_header(ui, header, true) {
|
||||
close = true;
|
||||
}
|
||||
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (label, path) in crate::file_picker::shortcuts() {
|
||||
if ui.selectable_label(picker.dir == path, label).clicked() {
|
||||
navigate = Some(path);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.add_enabled(picker.dir.parent().is_some(), egui::Button::new("⏶"))
|
||||
.on_hover_text("Übergeordneter Ordner")
|
||||
.clicked()
|
||||
{
|
||||
go_up = true;
|
||||
}
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(picker.dir.display().to_string())
|
||||
.color(egui::Color32::from_gray(190)),
|
||||
)
|
||||
.truncate(true),
|
||||
);
|
||||
});
|
||||
if let Some(err) = &picker.error {
|
||||
ui.colored_label(egui::Color32::from_rgb(255, 120, 120), err);
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height((ctx.screen_rect().height() - 330.0).clamp(160.0, 420.0))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
if picker.entries.is_empty() {
|
||||
ui.label(egui::RichText::new(empty_hint).color(egui::Color32::from_gray(150)));
|
||||
}
|
||||
for entry in &picker.entries {
|
||||
let label = if entry.is_dir {
|
||||
format!("📁 {}", entry.name)
|
||||
} else {
|
||||
format!("{file_icon} {}", entry.name)
|
||||
};
|
||||
let button = egui::Button::new(label)
|
||||
.frame(false)
|
||||
.min_size(egui::vec2(ui.available_width(), 34.0));
|
||||
let response = ui.add(button);
|
||||
if response.clicked() {
|
||||
if entry.is_dir {
|
||||
navigate = Some(entry.path.clone());
|
||||
} else {
|
||||
chosen = Some(entry.path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
egui::RichText::new(tip)
|
||||
.size(11.5)
|
||||
.color(egui::Color32::from_gray(150)),
|
||||
);
|
||||
});
|
||||
|
||||
if let Some(picker) = state.file_picker.as_mut() {
|
||||
if go_up {
|
||||
picker.go_up();
|
||||
}
|
||||
if let Some(dir) = navigate {
|
||||
picker.navigate(&dir);
|
||||
}
|
||||
}
|
||||
if let Some(path) = chosen {
|
||||
state.last_picker_dir = state.file_picker.as_ref().map(|p| p.dir.clone());
|
||||
actions.push(match picker_kind {
|
||||
PickerKind::Image => UiAction::InsertImageFile(path),
|
||||
PickerKind::Pdf => UiAction::ImportPdf(path),
|
||||
});
|
||||
close = true;
|
||||
}
|
||||
if close {
|
||||
state.file_picker = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Rahmen der Popover neben der Werkzeugleiste.
|
||||
fn popover_frame() -> egui::Frame {
|
||||
egui::Frame::none()
|
||||
@@ -925,7 +1260,7 @@ pub fn render_ui(
|
||||
autosave_status: AutosaveStatus,
|
||||
updater: &crate::updater::UpdaterController,
|
||||
) -> Vec<UiAction> {
|
||||
let mut actions = Vec::new();
|
||||
let mut actions = std::mem::take(&mut state.queued_actions);
|
||||
|
||||
let mut pen_btn_rect = None;
|
||||
let mut high_btn_rect = None;
|
||||
@@ -1085,7 +1420,7 @@ pub fn render_ui(
|
||||
(
|
||||
ActiveTool::Eraser,
|
||||
SidebarIcon::Eraser,
|
||||
"Radierer (E oder rechte Maustaste)",
|
||||
"Radierer (E oder rechte Maustaste) – darunter: alles oder nur Textmarker",
|
||||
),
|
||||
(
|
||||
ActiveTool::Select,
|
||||
@@ -1146,10 +1481,44 @@ pub fn render_ui(
|
||||
}
|
||||
}
|
||||
}
|
||||
let missing = UiState::MAX_PALETTE_LEN.saturating_sub(shown);
|
||||
if missing > 0 {
|
||||
let spacing = ui.spacing().item_spacing.y;
|
||||
ui.add_space(missing as f32 * (swatch + spacing));
|
||||
let spacing = ui.spacing().item_spacing.y;
|
||||
let mut used = shown as f32 * (swatch + spacing);
|
||||
// Radierer: statt Farben die Radier-Modi im selben Platz
|
||||
if state.active_tool == ActiveTool::Eraser {
|
||||
for (mode, icon, indicator, tooltip) in [
|
||||
(
|
||||
EraserMode::All,
|
||||
SidebarIcon::Text("Alle"),
|
||||
None,
|
||||
"Alle Striche radieren",
|
||||
),
|
||||
(
|
||||
EraserMode::HighlighterOnly,
|
||||
SidebarIcon::Glyph("🖊"),
|
||||
Some(state.highlighter_color),
|
||||
"Nur Textmarker radieren – Handschrift darunter bleibt",
|
||||
),
|
||||
] {
|
||||
if sidebar_button(
|
||||
ui,
|
||||
btn,
|
||||
icon,
|
||||
state.eraser_mode == mode,
|
||||
true,
|
||||
indicator,
|
||||
tooltip,
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
state.eraser_mode = mode;
|
||||
}
|
||||
used += btn + spacing;
|
||||
}
|
||||
}
|
||||
let reserved =
|
||||
UiState::MAX_PALETTE_LEN as f32 * (swatch + spacing);
|
||||
if reserved > used {
|
||||
ui.add_space(reserved - used);
|
||||
}
|
||||
|
||||
// --- Aktionen für die Auswahl ---
|
||||
@@ -1623,6 +1992,10 @@ pub fn render_ui(
|
||||
);
|
||||
});
|
||||
|
||||
// Bei kleinen Fenstern scrollen statt abschneiden
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height((ctx.screen_rect().height() - 90.0).max(150.0))
|
||||
.show(ui, |ui| {
|
||||
section(ui, "DOKUMENT");
|
||||
if ui
|
||||
.add_sized([ui.available_width(), 30.0], egui::Button::new("📄 Als PDF exportieren (Strg+E)"))
|
||||
@@ -1637,6 +2010,20 @@ pub fn render_ui(
|
||||
{
|
||||
actions.push(UiAction::AddPage);
|
||||
}
|
||||
if ui
|
||||
.add_sized([ui.available_width(), 30.0], egui::Button::new("🖼 Bild einfügen … (Strg+I)"))
|
||||
.on_hover_text("Auch per Strg+V aus der Zwischenablage oder durch Ziehen einer Bilddatei ins Fenster")
|
||||
.clicked()
|
||||
{
|
||||
state.open_image_picker();
|
||||
}
|
||||
if ui
|
||||
.add_sized([ui.available_width(), 30.0], egui::Button::new("📄 PDF importieren …"))
|
||||
.on_hover_text("Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen und darauf schreiben")
|
||||
.clicked()
|
||||
{
|
||||
state.open_pdf_picker();
|
||||
}
|
||||
|
||||
section(ui, "HINTERGRUND DIESER SEITE");
|
||||
let current_bg = notebook.current_page().background;
|
||||
@@ -1668,6 +2055,48 @@ pub fn render_ui(
|
||||
}
|
||||
});
|
||||
|
||||
section(ui, "PAPIERFARBE (NUR ANZEIGE)");
|
||||
let mut paper_changed = false;
|
||||
egui::Grid::new("more_menu_paper")
|
||||
.num_columns(2)
|
||||
.spacing([6.0, 6.0])
|
||||
.show(ui, |ui| {
|
||||
for (i, theme) in PaperTheme::ALL.into_iter().enumerate() {
|
||||
if paper_theme_button(ui, theme, state.paper_theme == theme)
|
||||
.clicked()
|
||||
&& state.paper_theme != theme
|
||||
{
|
||||
state.paper_theme = theme;
|
||||
paper_changed = true;
|
||||
}
|
||||
if i % 2 == 1 {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
if ui
|
||||
.add_enabled(
|
||||
state.paper_theme.is_dark(),
|
||||
egui::Checkbox::new(
|
||||
&mut state.adapt_ink_colors,
|
||||
"Dunkle Stiftfarben aufhellen",
|
||||
),
|
||||
)
|
||||
.on_hover_text(
|
||||
"Schwarz wird Weiß, Dunkelblau wird Hellblau. Gespeichert und \
|
||||
als PDF exportiert werden immer die Originalfarben.",
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
paper_changed = true;
|
||||
}
|
||||
if paper_changed {
|
||||
actions.push(UiAction::SetPaperTheme {
|
||||
theme: state.paper_theme,
|
||||
adapt_ink: state.adapt_ink_colors,
|
||||
});
|
||||
}
|
||||
|
||||
section(ui, "ZOOM");
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
@@ -1702,6 +2131,9 @@ pub fn render_ui(
|
||||
.on_hover_text(
|
||||
"Aus: ein Finger verschiebt, zwei Finger zoomen (empfohlen mit Stift).\nAn: ein Finger zeichnet.",
|
||||
);
|
||||
if let Some(curve) = pressure_curve_editor(ui, state) {
|
||||
actions.push(UiAction::SavePressureCurve(curve));
|
||||
}
|
||||
|
||||
section(ui, "WERKZEUGLEISTE");
|
||||
ui.horizontal(|ui| {
|
||||
@@ -1757,6 +2189,7 @@ pub fn render_ui(
|
||||
state.close_overlays();
|
||||
state.is_update_dialog_open = true;
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.rect
|
||||
@@ -1796,8 +2229,14 @@ pub fn render_ui(
|
||||
.show(ui, |ui| {
|
||||
for (idx, page) in notebook.pages.iter().enumerate() {
|
||||
let is_current = idx == notebook.current_page_index;
|
||||
if page_thumbnail(ui, page, idx + 1, is_current)
|
||||
.clicked()
|
||||
if page_thumbnail(
|
||||
ui,
|
||||
state,
|
||||
page,
|
||||
idx + 1,
|
||||
is_current,
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
actions.push(UiAction::GoToPage(idx));
|
||||
state.show_page_jump_popup = false;
|
||||
@@ -2045,6 +2484,11 @@ pub fn render_ui(
|
||||
state.document_modal_rect = None;
|
||||
}
|
||||
|
||||
// 3c. Dateiauswahl „Bild einfügen“
|
||||
if state.file_picker.is_some() {
|
||||
file_picker_dialog(ctx, state, &mut actions);
|
||||
}
|
||||
|
||||
// 3c. Release- & Update-Center (Gitea Releases)
|
||||
if state.is_update_dialog_open {
|
||||
let win = dialog_window("Release- & Update-Center", state, false)
|
||||
@@ -2886,10 +3330,11 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Quelltexte, deren String-Literale in der Oberfläche erscheinen können.
|
||||
const UI_SOURCES: [(&str, &str); 3] = [
|
||||
const UI_SOURCES: [(&str, &str); 4] = [
|
||||
("ui.rs", include_str!("ui.rs")),
|
||||
("selection.rs", include_str!("selection.rs")),
|
||||
("main.rs", include_str!("main.rs")),
|
||||
("file_picker.rs", include_str!("file_picker.rs")),
|
||||
];
|
||||
|
||||
/// Liefert alle Zeichen aus String-Literalen eines Quelltexts (ohne Zeilenkommentare).
|
||||
@@ -2917,6 +3362,19 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stroke_tool_follows_active_tool() {
|
||||
assert_eq!(ActiveTool::Pen.stroke_tool(false), StrokeTool::Pen);
|
||||
assert_eq!(
|
||||
ActiveTool::Highlighter.stroke_tool(false),
|
||||
StrokeTool::Highlighter
|
||||
);
|
||||
assert_eq!(ActiveTool::Shape.stroke_tool(false), StrokeTool::Pen);
|
||||
// Eingerastete Form (Formwerkzeug oder Halten am Strichende)
|
||||
assert_eq!(ActiveTool::Shape.stroke_tool(true), StrokeTool::Shape);
|
||||
assert_eq!(ActiveTool::Pen.stroke_tool(true), StrokeTool::Shape);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_ui_texts_are_renderable() {
|
||||
// Zeichen ohne Glyphe in den eingebauten egui-Schriften erscheinen als leere Kästchen
|
||||
@@ -3130,11 +3588,14 @@ mod tests {
|
||||
|
||||
// Leere Seite: nur das weiße Blatt
|
||||
let empty = Page::new(1, PageBackground::Blank);
|
||||
assert_eq!(page_thumbnail_shapes(&empty, rect).len(), 1);
|
||||
assert_eq!(
|
||||
page_thumbnail_shapes(&empty, rect, PaperTheme::White, true).len(),
|
||||
1
|
||||
);
|
||||
|
||||
// Striche liegen innerhalb der Kachel
|
||||
let page = page_with_strokes(3, 20);
|
||||
let shapes = page_thumbnail_shapes(&page, rect);
|
||||
let shapes = page_thumbnail_shapes(&page, rect, PaperTheme::White, true);
|
||||
assert_eq!(shapes.len(), 1 + 3);
|
||||
let pts = shape_points(&shapes);
|
||||
assert!(!pts.is_empty());
|
||||
@@ -3145,13 +3606,35 @@ mod tests {
|
||||
|
||||
// Sehr volle Seite wird auf das Punktbudget ausgedünnt (+ Endpunkte je Strich)
|
||||
let full = page_with_strokes(100, 1000);
|
||||
let pts = shape_points(&page_thumbnail_shapes(&full, rect));
|
||||
let pts = shape_points(&page_thumbnail_shapes(&full, rect, PaperTheme::White, true));
|
||||
assert!(
|
||||
pts.len() <= THUMBNAIL_POINT_BUDGET + 2 * 100,
|
||||
"{} Punkte statt höchstens {}",
|
||||
pts.len(),
|
||||
THUMBNAIL_POINT_BUDGET + 200
|
||||
);
|
||||
|
||||
// Bild: gefüllte Fläche statt Umrisslinie, unter der Tinte
|
||||
let mut with_image = page_with_strokes(1, 5);
|
||||
with_image.strokes.insert(
|
||||
0,
|
||||
crate::document::Stroke::new_image(
|
||||
99,
|
||||
"a.png".into(),
|
||||
[
|
||||
glam::Vec2::new(100.0, 100.0),
|
||||
glam::Vec2::new(900.0, 100.0),
|
||||
glam::Vec2::new(900.0, 600.0),
|
||||
glam::Vec2::new(100.0, 600.0),
|
||||
],
|
||||
),
|
||||
);
|
||||
let shapes = page_thumbnail_shapes(&with_image, rect, PaperTheme::White, true);
|
||||
assert_eq!(shapes.len(), 3);
|
||||
assert!(
|
||||
matches!(shapes[1], egui::Shape::Path(ref p) if p.closed && p.fill != egui::Color32::TRANSPARENT)
|
||||
);
|
||||
assert!(matches!(shapes[2], egui::Shape::Path(ref p) if !p.closed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user