Compare commits
9
Commits
v0.4.0
...
feat/lineal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e05b33ee3 | ||
|
|
587632b173 | ||
|
|
701fa03f33 | ||
|
|
91a24b9f82 | ||
|
|
64e13e1155 | ||
|
|
8a0477740e | ||
|
|
66679e51ae | ||
|
|
9a39276da4 | ||
|
|
b5c1da07fa |
Generated
+710
-19
File diff suppressed because it is too large
Load Diff
+8
-2
@@ -1,12 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.4.0"
|
version = "0.7.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
|
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
|
||||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
rust-version = "1.75"
|
rust-version = "1.92"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Fenster- & Event-Handling (Wayland/X11, Stylus)
|
# Fenster- & Event-Handling (Wayland/X11, Stylus)
|
||||||
@@ -39,8 +39,14 @@ sha2 = "0.10"
|
|||||||
ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] }
|
ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] }
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
octotablet = "0.1.0"
|
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"
|
minisign-verify = "0.3.0"
|
||||||
fs2 = "0.4"
|
fs2 = "0.4"
|
||||||
|
hayro = "0.8"
|
||||||
|
hayro-svg = "0.8"
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
|
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
|
||||||
|
|
||||||
### Highlights:
|
### Highlights:
|
||||||
|
- **Teilstrich-Radierer**: Nur den überstrichenen Teil eines Strichs entfernen – ein Radierzug ist ein Undo-Schritt
|
||||||
|
- **Export & Zwischenablage**: Aktuelle Seite als PNG (auch transparent) oder SVG; Strg+C legt die Auswahl zusätzlich als Bild in die Zwischenablage für Chat, Mail oder Office
|
||||||
|
- **Seitentitel & Inhaltsverzeichnis**: Seiten benennen, Inhaltsansicht in der Seitenübersicht, Titel werden zu PDF-Lesezeichen (Dateiformat-Version 6)
|
||||||
|
- **PDF importieren & annotieren**: Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen (☰-Menü oder Drag & Drop), darauf schreiben und markieren; beim Export liegen die Originalseiten verlustfrei als Vektor unter den Notizen (Dateiformat-Version 5: ältere Versionen öffnen solche Notizbücher nicht)
|
||||||
|
- **Bilder einfügen**: Screenshots per Strg+V, Bilddateien über die eingebaute Dateiauswahl (Strg+I) oder per Drag & Drop; verschieben, drehen, skalieren und darauf schreiben – auch im PDF-Export (Dateiformat-Version 4: ältere Versionen öffnen solche Notizbücher nicht)
|
||||||
- **Dunkelmodus & Papierfarben**: Weiß, Creme, Dunkelgrau oder Schwarz; auf dunklem Papier werden dunkle Stiftfarben automatisch aufgehellt (nur Anzeige – Dateien und PDF-Export bleiben unverändert)
|
- **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
|
- **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
|
- **Einstellbare Druckkurve**: Kennlinie weich bis hart und Mindestdruck mit Live-Vorschau, passend für jeden Digitizer
|
||||||
|
|||||||
+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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
-3
@@ -165,6 +165,14 @@ pub struct CanvasState {
|
|||||||
pub committed_vertex_count: u32,
|
pub committed_vertex_count: u32,
|
||||||
pub committed_strokes: Vec<Range<u32>>,
|
pub committed_strokes: Vec<Range<u32>>,
|
||||||
|
|
||||||
|
// Eingefügte Bilder der aktuellen Seite
|
||||||
|
image_layer: crate::image_layer::ImageLayer,
|
||||||
|
|
||||||
|
/// Eingeblendetes Lineal (Striche nahe einer Kante rasten ein)
|
||||||
|
pub ruler: Option<crate::ruler::Ruler>,
|
||||||
|
/// Kante, an der der aktuelle Live-Strich eingerastet ist
|
||||||
|
ruler_lock: Option<crate::ruler::EdgeLock>,
|
||||||
|
|
||||||
// egui UI-Renderer
|
// egui UI-Renderer
|
||||||
pub egui_renderer: egui_wgpu::Renderer,
|
pub egui_renderer: egui_wgpu::Renderer,
|
||||||
}
|
}
|
||||||
@@ -407,6 +415,13 @@ impl CanvasState {
|
|||||||
mapped_at_creation: false,
|
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
|
// 8. egui Renderer für das schwebende UI initialisieren
|
||||||
let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1);
|
let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1);
|
||||||
|
|
||||||
@@ -450,6 +465,9 @@ impl CanvasState {
|
|||||||
committed_vertex_capacity: Self::INITIAL_COMMITTED_CAPACITY,
|
committed_vertex_capacity: Self::INITIAL_COMMITTED_CAPACITY,
|
||||||
committed_vertex_count: 0,
|
committed_vertex_count: 0,
|
||||||
committed_strokes: Vec::with_capacity(128),
|
committed_strokes: Vec::with_capacity(128),
|
||||||
|
image_layer,
|
||||||
|
ruler: None,
|
||||||
|
ruler_lock: None,
|
||||||
egui_renderer,
|
egui_renderer,
|
||||||
camera: crate::viewport::ViewportState::new(),
|
camera: crate::viewport::ViewportState::new(),
|
||||||
})
|
})
|
||||||
@@ -655,6 +673,18 @@ impl CanvasState {
|
|||||||
pub fn sync_page(&mut self, page: &mut Page) {
|
pub fn sync_page(&mut self, page: &mut Page) {
|
||||||
page.rebuild_cache_if_dirty();
|
page.rebuild_cache_if_dirty();
|
||||||
self.set_background(page.background);
|
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_vertex_count = page.vertex_cache.len() as u32;
|
||||||
self.committed_strokes = page.stroke_ranges.clone();
|
self.committed_strokes = page.stroke_ranges.clone();
|
||||||
@@ -681,6 +711,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.
|
/// 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) {
|
pub fn start_stroke(&mut self, page_x: f32, page_y: f32, pressure: f32, timestamp: f64) {
|
||||||
self.filter.reset();
|
self.filter.reset();
|
||||||
@@ -689,6 +733,13 @@ impl CanvasState {
|
|||||||
self.live_vertices.clear();
|
self.live_vertices.clear();
|
||||||
self.live_vertex_count = 0;
|
self.live_vertex_count = 0;
|
||||||
|
|
||||||
|
// Beginnt der Strich an einer Linealkante, bleibt er bis zum Abheben auf ihr
|
||||||
|
let half_width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM / 2.0;
|
||||||
|
self.ruler_lock = self
|
||||||
|
.ruler
|
||||||
|
.and_then(|r| r.edge_lock(glam::Vec2::new(page_x, page_y), half_width));
|
||||||
|
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
|
||||||
|
|
||||||
// 1. Zwingend durch den 1€-Filter leiten
|
// 1. Zwingend durch den 1€-Filter leiten
|
||||||
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
||||||
let pt = ControlPoint::new(fx, fy, fp);
|
let pt = ControlPoint::new(fx, fy, fp);
|
||||||
@@ -712,6 +763,7 @@ impl CanvasState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Zwingend durch den 1€-Filter leiten
|
// 1. Zwingend durch den 1€-Filter leiten
|
||||||
|
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
|
||||||
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
||||||
let new_pt = ControlPoint::new(fx, fy, fp);
|
let new_pt = ControlPoint::new(fx, fy, fp);
|
||||||
|
|
||||||
@@ -734,6 +786,19 @@ impl CanvasState {
|
|||||||
self.update_live_mesh();
|
self.update_live_mesh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Projiziert einen Punkt auf die Linealkante, falls der Live-Strich dort eingerastet ist.
|
||||||
|
fn constrain_to_ruler(&self, x: f32, y: f32) -> (f32, f32) {
|
||||||
|
match self.ruler_lock {
|
||||||
|
Some(lock) => lock.project(glam::Vec2::new(x, y)).into(),
|
||||||
|
None => (x, y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ist der aktuelle Live-Strich an einer Linealkante eingerastet?
|
||||||
|
pub fn is_ruler_locked(&self) -> bool {
|
||||||
|
self.ruler_lock.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
/// Bricht den aktuellen Live-Strich ab und setzt Puffer sowie 1€-Filter sauber zurück.
|
/// Bricht den aktuellen Live-Strich ab und setzt Puffer sowie 1€-Filter sauber zurück.
|
||||||
pub fn cancel_stroke(&mut self) {
|
pub fn cancel_stroke(&mut self) {
|
||||||
self.live_samples.clear();
|
self.live_samples.clear();
|
||||||
@@ -880,7 +945,10 @@ impl CanvasState {
|
|||||||
render_pass.draw(0..self.background_vertex_count, 0..1);
|
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);
|
render_pass.set_pipeline(&self.strip_pipeline);
|
||||||
|
|
||||||
if self.committed_vertex_count > 0 {
|
if self.committed_vertex_count > 0 {
|
||||||
@@ -892,7 +960,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 {
|
if self.live_vertex_count > 0 {
|
||||||
render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..));
|
render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..));
|
||||||
render_pass.draw(0..self.live_vertex_count, 0..1);
|
render_pass.draw(0..self.live_vertex_count, 0..1);
|
||||||
@@ -997,7 +1065,7 @@ mod tests {
|
|||||||
.validate(&module)
|
.validate(&module)
|
||||||
.unwrap_or_else(|e| panic!("Shader ungültig: {e:?}"));
|
.unwrap_or_else(|e| panic!("Shader ungültig: {e:?}"));
|
||||||
|
|
||||||
for name in ["vs_main", "fs_main", "fs_ink"] {
|
for name in ["vs_main", "fs_main", "fs_ink", "vs_image", "fs_image"] {
|
||||||
assert!(
|
assert!(
|
||||||
module.entry_points.iter().any(|ep| ep.name == name),
|
module.entry_points.iter().any(|ep| ep.name == name),
|
||||||
"Einstiegspunkt {name} fehlt"
|
"Einstiegspunkt {name} fehlt"
|
||||||
|
|||||||
+614
-10
@@ -103,6 +103,109 @@ pub fn point_to_segment_distance(p: glam::Vec2, a: glam::Vec2, b: glam::Vec2) ->
|
|||||||
(p - projection).length()
|
(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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Größte Länge eines Seitentitels in Zeichen.
|
||||||
|
pub const MAX_PAGE_TITLE_CHARS: usize = 120;
|
||||||
|
|
||||||
|
/// Bereinigt einen Seitentitel: Zeilenumbrüche und doppelte Leerzeichen entfernen, auf
|
||||||
|
/// [`MAX_PAGE_TITLE_CHARS`] kürzen; leer bedeutet „kein Titel“.
|
||||||
|
pub fn normalize_page_title(title: &str) -> Option<String> {
|
||||||
|
let cleaned: String = title
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
.chars()
|
||||||
|
.take(MAX_PAGE_TITLE_CHARS)
|
||||||
|
.collect();
|
||||||
|
(!cleaned.is_empty()).then_some(cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verweis auf eine Seite eines importierten PDFs (Datei im Asset-Ordner).
|
||||||
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct PdfBackground {
|
||||||
|
/// 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.
|
/// Das Werkzeug, mit dem ein Strich gezeichnet wurde.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -134,18 +237,124 @@ pub enum EraserMode {
|
|||||||
All,
|
All,
|
||||||
/// Nur Textmarker – die Handschrift darunter bleibt erhalten
|
/// Nur Textmarker – die Handschrift darunter bleibt erhalten
|
||||||
HighlighterOnly,
|
HighlighterOnly,
|
||||||
|
/// Nur den überstrichenen Teil eines Strichs entfernen; der Rest bleibt in Stücken
|
||||||
|
/// erhalten. Formen werden als Ganzes radiert.
|
||||||
|
Partial,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EraserMode {
|
impl EraserMode {
|
||||||
/// Darf ein Strich dieses Werkzeugs radiert werden?
|
/// Darf ein Strich dieses Werkzeugs radiert werden?
|
||||||
pub fn erases(self, tool: StrokeTool) -> bool {
|
pub fn erases(self, tool: StrokeTool) -> bool {
|
||||||
match self {
|
match self {
|
||||||
EraserMode::All => true,
|
EraserMode::All | EraserMode::Partial => true,
|
||||||
EraserMode::HighlighterOnly => tool == StrokeTool::Highlighter,
|
EraserMode::HighlighterOnly => tool == StrokeTool::Highlighter,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Zeitfenster, in dem aufeinanderfolgende Teilradierungen zu einem Undo-Schritt
|
||||||
|
/// zusammengefasst werden (ein Radierzug besteht aus vielen kleinen Schnitten).
|
||||||
|
pub const PARTIAL_ERASE_MERGE_WINDOW: std::time::Duration = std::time::Duration::from_millis(500);
|
||||||
|
|
||||||
|
/// Zerlegt die Punktfolge eines Strichs an einem Kreis (Radierer): übrig bleiben die Stücke
|
||||||
|
/// außerhalb des Kreises, an den Kreisrand gekürzt. `None`, wenn der Kreis den Strich nicht
|
||||||
|
/// berührt. Stücke kürzer als `min_length` entfallen.
|
||||||
|
pub fn split_samples_at_circle(
|
||||||
|
samples: &[StylusSample],
|
||||||
|
center: glam::Vec2,
|
||||||
|
radius: f32,
|
||||||
|
min_length: f32,
|
||||||
|
) -> Option<Vec<Vec<StylusSample>>> {
|
||||||
|
let inside = |p: glam::Vec2| (p - center).length_squared() <= radius * radius;
|
||||||
|
let lerp = |a: &StylusSample, b: &StylusSample, t: f32| StylusSample {
|
||||||
|
position: a.position.lerp(b.position, t),
|
||||||
|
pressure: a.pressure + (b.pressure - a.pressure) * t,
|
||||||
|
timestamp: a.timestamp + (b.timestamp - a.timestamp) * t as f64,
|
||||||
|
};
|
||||||
|
// Schnittparameter t ∈ (0, 1) einer Strecke mit dem Kreisrand, aufsteigend
|
||||||
|
let crossings = |a: glam::Vec2, b: glam::Vec2| -> Vec<f32> {
|
||||||
|
let d = b - a;
|
||||||
|
let f = a - center;
|
||||||
|
let qa = d.dot(d);
|
||||||
|
if qa < 1e-12 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let qb = 2.0 * f.dot(d);
|
||||||
|
let qc = f.dot(f) - radius * radius;
|
||||||
|
let disc = qb * qb - 4.0 * qa * qc;
|
||||||
|
if disc < 0.0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let root = disc.sqrt();
|
||||||
|
[(-qb - root) / (2.0 * qa), (-qb + root) / (2.0 * qa)]
|
||||||
|
.into_iter()
|
||||||
|
.filter(|t| *t > 0.0 && *t < 1.0)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
match samples {
|
||||||
|
[] => return None,
|
||||||
|
[single] => return inside(single.position).then(Vec::new),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
let touched = samples.iter().any(|s| inside(s.position))
|
||||||
|
|| samples
|
||||||
|
.windows(2)
|
||||||
|
.any(|w| crossings(w[0].position, w[1].position).len() == 2);
|
||||||
|
if !touched {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pieces = Vec::new();
|
||||||
|
let mut current: Vec<StylusSample> = Vec::new();
|
||||||
|
if !inside(samples[0].position) {
|
||||||
|
current.push(samples[0]);
|
||||||
|
}
|
||||||
|
for w in samples.windows(2) {
|
||||||
|
let (a, b) = (&w[0], &w[1]);
|
||||||
|
let (a_in, b_in) = (inside(a.position), inside(b.position));
|
||||||
|
let ts = crossings(a.position, b.position);
|
||||||
|
match (a_in, b_in) {
|
||||||
|
(false, false) => {
|
||||||
|
if let [t_enter, t_exit] = ts[..] {
|
||||||
|
// Strecke läuft durch den Kreis
|
||||||
|
current.push(lerp(a, b, t_enter));
|
||||||
|
pieces.push(std::mem::take(&mut current));
|
||||||
|
current.push(lerp(a, b, t_exit));
|
||||||
|
}
|
||||||
|
current.push(*b);
|
||||||
|
}
|
||||||
|
(false, true) => {
|
||||||
|
if let Some(&t) = ts.first() {
|
||||||
|
current.push(lerp(a, b, t));
|
||||||
|
}
|
||||||
|
pieces.push(std::mem::take(&mut current));
|
||||||
|
}
|
||||||
|
(true, false) => {
|
||||||
|
current.clear();
|
||||||
|
if let Some(&t) = ts.last() {
|
||||||
|
current.push(lerp(a, b, t));
|
||||||
|
}
|
||||||
|
current.push(*b);
|
||||||
|
}
|
||||||
|
(true, true) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pieces.push(current);
|
||||||
|
|
||||||
|
let length = |p: &Vec<StylusSample>| -> f32 {
|
||||||
|
p.windows(2)
|
||||||
|
.map(|w| (w[1].position - w[0].position).length())
|
||||||
|
.sum()
|
||||||
|
};
|
||||||
|
Some(
|
||||||
|
pieces
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| p.len() >= 2 && length(p) >= min_length)
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Ein einzelner Vektor-Strich auf einer Seite.
|
/// Ein einzelner Vektor-Strich auf einer Seite.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct Stroke {
|
pub struct Stroke {
|
||||||
@@ -158,6 +367,10 @@ pub struct Stroke {
|
|||||||
pub tool: StrokeTool,
|
pub tool: StrokeTool,
|
||||||
pub width: f32,
|
pub width: f32,
|
||||||
pub finalized: bool,
|
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 {
|
impl Stroke {
|
||||||
@@ -190,9 +403,44 @@ impl Stroke {
|
|||||||
tool: StrokeTool::Pen,
|
tool: StrokeTool::Pen,
|
||||||
width: 5.0,
|
width: 5.0,
|
||||||
finalized: true,
|
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).
|
/// Finalisiert rohe Stylus-Samples mit akausalem Post-Processing (Zwei-Stufen-Modell).
|
||||||
pub fn finalize(
|
pub fn finalize(
|
||||||
id: u64,
|
id: u64,
|
||||||
@@ -226,7 +474,16 @@ impl Stroke {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Berechnet den minimalen euklidischen Abstand eines Punktes zu diesem Strich.
|
/// 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 {
|
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() {
|
if self.points.is_empty() {
|
||||||
return f32::INFINITY;
|
return f32::INFINITY;
|
||||||
}
|
}
|
||||||
@@ -348,7 +605,11 @@ impl Stroke {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Weist diesem Strich eine neue Farbe zu und aktualisiert alle Vertex-Farben.
|
/// 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]) {
|
pub fn set_color(&mut self, new_color: [f32; 4]) {
|
||||||
|
if self.is_image() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.color = new_color;
|
self.color = new_color;
|
||||||
for v in &mut self.vertices {
|
for v in &mut self.vertices {
|
||||||
v.color = new_color;
|
v.color = new_color;
|
||||||
@@ -391,6 +652,10 @@ pub struct Page {
|
|||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub strokes: Vec<Stroke>,
|
pub strokes: Vec<Stroke>,
|
||||||
pub background: PageBackground,
|
pub background: PageBackground,
|
||||||
|
/// Importierte PDF-Seite als Hintergrund (unter Bildern und Tinte)
|
||||||
|
pub pdf_background: Option<PdfBackground>,
|
||||||
|
/// Seitentitel (Lesezeichen); auch im Manifest, damit er ohne Laden der Seite bekannt ist
|
||||||
|
pub title: Option<String>,
|
||||||
pub vertex_cache: Vec<InkVertex>,
|
pub vertex_cache: Vec<InkVertex>,
|
||||||
pub stroke_ranges: Vec<Range<u32>>,
|
pub stroke_ranges: Vec<Range<u32>>,
|
||||||
pub is_dirty: bool,
|
pub is_dirty: bool,
|
||||||
@@ -405,6 +670,8 @@ impl Page {
|
|||||||
id,
|
id,
|
||||||
strokes: Vec::new(),
|
strokes: Vec::new(),
|
||||||
background,
|
background,
|
||||||
|
pdf_background: None,
|
||||||
|
title: None,
|
||||||
vertex_cache: Vec::new(),
|
vertex_cache: Vec::new(),
|
||||||
stroke_ranges: Vec::new(),
|
stroke_ranges: Vec::new(),
|
||||||
is_dirty: false,
|
is_dirty: false,
|
||||||
@@ -420,6 +687,8 @@ impl Page {
|
|||||||
id,
|
id,
|
||||||
strokes: Vec::new(),
|
strokes: Vec::new(),
|
||||||
background,
|
background,
|
||||||
|
pdf_background: None,
|
||||||
|
title: None,
|
||||||
vertex_cache: Vec::new(),
|
vertex_cache: Vec::new(),
|
||||||
stroke_ranges: Vec::new(),
|
stroke_ranges: Vec::new(),
|
||||||
is_dirty: false,
|
is_dirty: false,
|
||||||
@@ -462,20 +731,18 @@ impl Page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Findet den nächsten Strich an Position `pos` innerhalb eines maximalen Suchradius.
|
/// 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> {
|
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 {
|
for s in &self.strokes {
|
||||||
if !s.aabb.expanded(max_dist).contains_point(pos) {
|
if !s.aabb.expanded(max_dist).contains_point(pos) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let d = s.distance_to_point(pos);
|
let d = s.distance_to_point(pos);
|
||||||
if d <= max_dist {
|
if d <= max_dist {
|
||||||
if let Some((_, best_d)) = best {
|
let key = (s.is_image(), d);
|
||||||
if d < best_d {
|
if best.is_none_or(|(_, best_key)| key < best_key) {
|
||||||
best = Some((s, d));
|
best = Some((s, key));
|
||||||
}
|
|
||||||
} else {
|
|
||||||
best = Some((s, d));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -516,7 +783,8 @@ impl Page {
|
|||||||
let mut hit_indices = Vec::new();
|
let mut hit_indices = Vec::new();
|
||||||
|
|
||||||
for (i, stroke) in self.strokes.iter().enumerate() {
|
for (i, stroke) in self.strokes.iter().enumerate() {
|
||||||
if mode.erases(stroke.tool)
|
if !stroke.is_image()
|
||||||
|
&& mode.erases(stroke.tool)
|
||||||
&& stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius)
|
&& stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius)
|
||||||
{
|
{
|
||||||
hit_indices.push(i);
|
hit_indices.push(i);
|
||||||
@@ -541,6 +809,66 @@ impl Page {
|
|||||||
removed
|
removed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Teilradierer: entfernt den Teil aller Striche im Kreis um `eraser_pos`. Getroffene
|
||||||
|
/// Striche werden durch ihre übrig gebliebenen Stücke ersetzt (neue IDs aus `next_id`),
|
||||||
|
/// Formen als Ganzes entfernt, Bilder nie. Aufeinanderfolgende Schnitte eines Radierzugs
|
||||||
|
/// bilden einen gemeinsamen Undo-Schritt. Gibt `true` zurück, wenn sich etwas geändert hat.
|
||||||
|
pub fn erase_partial_at(
|
||||||
|
&mut self,
|
||||||
|
eraser_pos: glam::Vec2,
|
||||||
|
eraser_radius: f32,
|
||||||
|
next_id: &mut u64,
|
||||||
|
) -> bool {
|
||||||
|
let mut removed = Vec::new();
|
||||||
|
let mut added = Vec::new();
|
||||||
|
let mut result = Vec::with_capacity(self.strokes.len());
|
||||||
|
for stroke in std::mem::take(&mut self.strokes) {
|
||||||
|
let reach = eraser_radius + stroke.width / 2.0;
|
||||||
|
if stroke.is_image() || !stroke.aabb.expanded(reach).contains_point(eraser_pos) {
|
||||||
|
result.push(stroke);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if stroke.shape.is_some() {
|
||||||
|
// Formen werden ganz radiert, sobald der Radierer sie berührt
|
||||||
|
if stroke.distance_to_point(eraser_pos) <= reach {
|
||||||
|
removed.push(stroke);
|
||||||
|
} else {
|
||||||
|
result.push(stroke);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match split_samples_at_circle(&stroke.points, eraser_pos, reach, stroke.width * 0.5) {
|
||||||
|
None => result.push(stroke),
|
||||||
|
Some(pieces) => {
|
||||||
|
for samples in pieces {
|
||||||
|
let vertices = crate::storage::reconstruct_stroke_vertices(
|
||||||
|
&samples,
|
||||||
|
stroke.color,
|
||||||
|
stroke.width,
|
||||||
|
stroke.tool,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
let mut piece = Stroke::new(*next_id, samples, vertices, stroke.color);
|
||||||
|
*next_id += 1;
|
||||||
|
piece.tool = stroke.tool;
|
||||||
|
piece.width = stroke.width;
|
||||||
|
piece.finalized = true;
|
||||||
|
result.push(piece.clone());
|
||||||
|
added.push(piece);
|
||||||
|
}
|
||||||
|
removed.push(stroke);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.strokes = result;
|
||||||
|
if removed.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.history.push_split(removed, added);
|
||||||
|
self.is_dirty = true;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Baut den gecachten Vertex-Puffer der Seite neu auf, wenn `is_dirty` gesetzt ist.
|
/// Baut den gecachten Vertex-Puffer der Seite neu auf, wenn `is_dirty` gesetzt ist.
|
||||||
pub fn rebuild_cache_if_dirty(&mut self) {
|
pub fn rebuild_cache_if_dirty(&mut self) {
|
||||||
if !self.is_dirty {
|
if !self.is_dirty {
|
||||||
@@ -572,6 +900,8 @@ impl Page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Findet die IDs aller Striche, die die gegebene Auswahlbox schneiden oder darin liegen.
|
/// 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> {
|
pub fn find_strokes_in_box(&self, box_min: glam::Vec2, box_max: glam::Vec2) -> Vec<u64> {
|
||||||
let sel_box = Aabb::new(
|
let sel_box = Aabb::new(
|
||||||
glam::Vec2::new(box_min.x.min(box_max.x), box_min.y.min(box_max.y)),
|
glam::Vec2::new(box_min.x.min(box_max.x), box_min.y.min(box_max.y)),
|
||||||
@@ -579,7 +909,13 @@ impl Page {
|
|||||||
);
|
);
|
||||||
self.strokes
|
self.strokes
|
||||||
.iter()
|
.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)
|
.map(|s| s.id)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -733,6 +1069,25 @@ impl Notebook {
|
|||||||
&mut self.pages[self.current_page_index]
|
&mut self.pages[self.current_page_index]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Radiert auf der aktuellen Seite gemäß `mode` (ganze Striche, nur Textmarker oder
|
||||||
|
/// Teilstriche). Gibt `true` zurück, wenn sich etwas geändert hat.
|
||||||
|
pub fn erase_at(
|
||||||
|
&mut self,
|
||||||
|
eraser_pos: glam::Vec2,
|
||||||
|
eraser_radius: f32,
|
||||||
|
max_stroke_radius: f32,
|
||||||
|
mode: EraserMode,
|
||||||
|
) -> bool {
|
||||||
|
let page = &mut self.pages[self.current_page_index];
|
||||||
|
if mode == EraserMode::Partial {
|
||||||
|
page.erase_partial_at(eraser_pos, eraser_radius, &mut self.next_stroke_id)
|
||||||
|
} else {
|
||||||
|
!page
|
||||||
|
.erase_at(eraser_pos, eraser_radius, max_stroke_radius, mode)
|
||||||
|
.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Erzeugt eine eindeutige neue Stroke-ID.
|
/// Erzeugt eine eindeutige neue Stroke-ID.
|
||||||
pub fn next_stroke_id(&mut self) -> u64 {
|
pub fn next_stroke_id(&mut self) -> u64 {
|
||||||
let id = self.next_stroke_id;
|
let id = self.next_stroke_id;
|
||||||
@@ -779,6 +1134,20 @@ impl Notebook {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Setzt den Titel der Seite `page_id` (leer = kein Titel). `true`, wenn er sich geändert
|
||||||
|
/// hat.
|
||||||
|
pub fn set_page_title(&mut self, page_id: u64, title: &str) -> bool {
|
||||||
|
let title = normalize_page_title(title);
|
||||||
|
let Some(page) = self.pages.iter_mut().find(|p| p.id == page_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if page.title == title || !page.is_loaded || page.is_readonly() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
page.title = title;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Text für das On-Screen-Display (OSD), z. B. "Seite 3 / 12".
|
/// Text für das On-Screen-Display (OSD), z. B. "Seite 3 / 12".
|
||||||
pub fn osd_text(&self) -> String {
|
pub fn osd_text(&self) -> String {
|
||||||
format!(
|
format!(
|
||||||
@@ -1145,6 +1514,241 @@ mod tests {
|
|||||||
assert!(page.strokes.is_empty());
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn line_samples(points: &[(f32, f32)]) -> Vec<StylusSample> {
|
||||||
|
points
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &(x, y))| StylusSample {
|
||||||
|
position: glam::Vec2::new(x, y),
|
||||||
|
pressure: 0.5,
|
||||||
|
timestamp: i as f64,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_split_samples_at_circle() {
|
||||||
|
let c = glam::Vec2::new(50.0, 0.0);
|
||||||
|
// Lange Strecke mit nur zwei Punkten, Kreis in der Mitte: zwei Stücke, am Rand gekürzt
|
||||||
|
let pieces =
|
||||||
|
split_samples_at_circle(&line_samples(&[(0.0, 0.0), (100.0, 0.0)]), c, 10.0, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(pieces.len(), 2);
|
||||||
|
assert!((pieces[0].last().unwrap().position.x - 40.0).abs() < 1e-3);
|
||||||
|
assert!((pieces[1][0].position.x - 60.0).abs() < 1e-3);
|
||||||
|
// Druck und Zeit werden am Schnittpunkt interpoliert
|
||||||
|
assert!((pieces[0].last().unwrap().timestamp - 0.4).abs() < 1e-3);
|
||||||
|
|
||||||
|
// Viele Punkte, Kreis am Ende: ein Stück bleibt
|
||||||
|
let dense: Vec<(f32, f32)> = (0..=10).map(|i| (i as f32 * 10.0, 0.0)).collect();
|
||||||
|
let pieces = split_samples_at_circle(
|
||||||
|
&line_samples(&dense),
|
||||||
|
glam::Vec2::new(100.0, 0.0),
|
||||||
|
15.0,
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(pieces.len(), 1);
|
||||||
|
assert!((pieces[0].last().unwrap().position.x - 85.0).abs() < 1e-3);
|
||||||
|
|
||||||
|
// Ganz im Kreis: nichts bleibt; Kreis weit weg: unberührt
|
||||||
|
assert_eq!(
|
||||||
|
split_samples_at_circle(&line_samples(&[(48.0, 0.0), (52.0, 0.0)]), c, 10.0, 1.0),
|
||||||
|
Some(Vec::new())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
split_samples_at_circle(
|
||||||
|
&line_samples(&dense),
|
||||||
|
glam::Vec2::new(50.0, 40.0),
|
||||||
|
10.0,
|
||||||
|
1.0
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// Winziger Rest wird verworfen
|
||||||
|
let pieces = split_samples_at_circle(
|
||||||
|
&line_samples(&[(0.0, 0.0), (100.0, 0.0)]),
|
||||||
|
glam::Vec2::new(5.0, 0.0),
|
||||||
|
4.0,
|
||||||
|
2.0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(pieces.len(), 1);
|
||||||
|
assert!((pieces[0][0].position.x - 9.0).abs() < 1e-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_partial_erase_splits_and_undoes_in_one_step() {
|
||||||
|
let mut notebook = Notebook::new();
|
||||||
|
let mut stroke = Stroke::new(
|
||||||
|
1,
|
||||||
|
line_samples(&[(100.0, 100.0), (500.0, 100.0)]),
|
||||||
|
Vec::new(),
|
||||||
|
[0.0, 0.0, 0.0, 1.0],
|
||||||
|
);
|
||||||
|
stroke.width = 5.0;
|
||||||
|
notebook.next_stroke_id = 10;
|
||||||
|
let page = notebook.current_page_mut();
|
||||||
|
page.add_stroke(stroke);
|
||||||
|
page.add_stroke(Stroke::new_image(
|
||||||
|
2,
|
||||||
|
"a.png".into(),
|
||||||
|
[
|
||||||
|
glam::Vec2::new(0.0, 0.0),
|
||||||
|
glam::Vec2::new(900.0, 0.0),
|
||||||
|
glam::Vec2::new(900.0, 900.0),
|
||||||
|
glam::Vec2::new(0.0, 900.0),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
let mut shape = crate::geometry::shape_to_stroke(
|
||||||
|
crate::geometry::RecognizedShape::Line {
|
||||||
|
start: glam::Vec2::new(100.0, 300.0),
|
||||||
|
end: glam::Vec2::new(500.0, 300.0),
|
||||||
|
},
|
||||||
|
3,
|
||||||
|
[0.0, 0.0, 0.0, 1.0],
|
||||||
|
5.0,
|
||||||
|
);
|
||||||
|
shape.recompute_aabb();
|
||||||
|
page.add_stroke(shape);
|
||||||
|
let undo_depth = page.history.undo_stack.len();
|
||||||
|
|
||||||
|
// Zwei Schnitte desselben Zugs durch die Linie
|
||||||
|
assert!(notebook.erase_at(
|
||||||
|
glam::Vec2::new(200.0, 100.0),
|
||||||
|
20.0,
|
||||||
|
12.0,
|
||||||
|
EraserMode::Partial
|
||||||
|
));
|
||||||
|
assert!(notebook.erase_at(
|
||||||
|
glam::Vec2::new(400.0, 100.0),
|
||||||
|
20.0,
|
||||||
|
12.0,
|
||||||
|
EraserMode::Partial
|
||||||
|
));
|
||||||
|
// Form wird als Ganzes radiert
|
||||||
|
assert!(notebook.erase_at(
|
||||||
|
glam::Vec2::new(300.0, 300.0),
|
||||||
|
20.0,
|
||||||
|
12.0,
|
||||||
|
EraserMode::Partial
|
||||||
|
));
|
||||||
|
let page = notebook.current_page();
|
||||||
|
let ink: Vec<&Stroke> = page.strokes.iter().filter(|s| !s.is_image()).collect();
|
||||||
|
assert_eq!(ink.len(), 3, "Linie in drei Stücke zerlegt, Form entfernt");
|
||||||
|
assert!(ink.iter().all(|s| s.id >= 10 && !s.vertices.is_empty()));
|
||||||
|
assert!(page.strokes.iter().any(|s| s.id == 2), "Bild bleibt");
|
||||||
|
assert!(notebook.next_stroke_id > 10);
|
||||||
|
|
||||||
|
// Ein einziger Undo-Schritt stellt alles wieder her
|
||||||
|
let page = notebook.current_page_mut();
|
||||||
|
assert_eq!(page.history.undo_stack.len(), undo_depth + 1);
|
||||||
|
assert!(page.undo());
|
||||||
|
let mut ids: Vec<u64> = page.strokes.iter().map(|s| s.id).collect();
|
||||||
|
ids.sort();
|
||||||
|
assert_eq!(ids, vec![1, 2, 3]);
|
||||||
|
assert!(page.redo());
|
||||||
|
assert_eq!(page.strokes.iter().filter(|s| !s.is_image()).count(), 3);
|
||||||
|
|
||||||
|
// Danebenradieren ändert nichts
|
||||||
|
assert!(!notebook.erase_at(
|
||||||
|
glam::Vec2::new(800.0, 800.0),
|
||||||
|
20.0,
|
||||||
|
12.0,
|
||||||
|
EraserMode::Partial
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_page_isolation() {
|
fn test_page_isolation() {
|
||||||
let mut nb = Notebook::new();
|
let mut nb = Notebook::new();
|
||||||
|
|||||||
+584
-10
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Features:
|
//! Features:
|
||||||
//! - Hochformat DIN A4 (210 mm x 297 mm)
|
//! - 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)
|
//! - Y-Achsen-Spiegelung (PDF-Ursprung unten links vs. Bildschirmursprung oben links)
|
||||||
//! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko)
|
//! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko)
|
||||||
//! - Zwei Export-Strategien:
|
//! - Zwei Export-Strategien:
|
||||||
@@ -17,6 +17,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use printpdf::path::{PaintMode, WindingOrder};
|
use printpdf::path::{PaintMode, WindingOrder};
|
||||||
use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb};
|
use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb};
|
||||||
|
|
||||||
|
use crate::assets::AssetStore;
|
||||||
use crate::background::{GRID_5MM_SPACING, LINED_SPACING};
|
use crate::background::{GRID_5MM_SPACING, LINED_SPACING};
|
||||||
use crate::document::{
|
use crate::document::{
|
||||||
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
|
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
|
||||||
@@ -78,6 +79,20 @@ pub fn resolve_pdf_export_path_in_dir(dir: &Path, title: &str) -> PathBuf {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Kollisionsfreier Pfad `~/Documents/<name>.<ext>` (Verzeichnis wird angelegt).
|
||||||
|
pub fn resolve_export_path(name: &str, ext: &str) -> PathBuf {
|
||||||
|
let dir = default_documents_dir();
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
let base = sanitize_filename_component(name);
|
||||||
|
let mut candidate = dir.join(format!("{base}.{ext}"));
|
||||||
|
let mut counter = 1;
|
||||||
|
while candidate.exists() {
|
||||||
|
candidate = dir.join(format!("{base}_{counter}.{ext}"));
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
candidate
|
||||||
|
}
|
||||||
|
|
||||||
/// Ermittelt das Ziel für den PDF-Export unter Berücksichtigung von XDG_DOCUMENTS_DIR
|
/// Ermittelt das Ziel für den PDF-Export unter Berücksichtigung von XDG_DOCUMENTS_DIR
|
||||||
/// und automatischer Namenskollisionsvermeidung. Erstellt das Verzeichnis, falls nötig.
|
/// und automatischer Namenskollisionsvermeidung. Erstellt das Verzeichnis, falls nötig.
|
||||||
pub fn resolve_pdf_export_path(title: &str) -> PathBuf {
|
pub fn resolve_pdf_export_path(title: &str) -> PathBuf {
|
||||||
@@ -109,15 +124,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.
|
/// 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>>(
|
pub fn export_notebook_to_pdf<P: AsRef<Path>>(
|
||||||
notebook: &Notebook,
|
notebook: &Notebook,
|
||||||
path: P,
|
path: P,
|
||||||
strategy: ExportStrategy,
|
strategy: ExportStrategy,
|
||||||
|
assets: Option<&AssetStore>,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
let bytes = export_notebook_to_bytes(notebook, strategy, assets)?;
|
||||||
let file = File::create(path)?;
|
let file = File::create(path)?;
|
||||||
let mut writer = BufWriter::new(file);
|
let mut writer = BufWriter::new(file);
|
||||||
let doc = generate_pdf_document(notebook, strategy)?;
|
std::io::Write::write_all(&mut writer, &bytes)?;
|
||||||
doc.save(&mut writer)?;
|
std::io::Write::flush(&mut writer)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,16 +143,135 @@ pub fn export_notebook_to_pdf<P: AsRef<Path>>(
|
|||||||
pub fn export_notebook_to_bytes(
|
pub fn export_notebook_to_bytes(
|
||||||
notebook: &Notebook,
|
notebook: &Notebook,
|
||||||
strategy: ExportStrategy,
|
strategy: ExportStrategy,
|
||||||
|
assets: Option<&AssetStore>,
|
||||||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
) -> 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()?;
|
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
|
||||||
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 = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// printpdf schreibt Lesezeichen-Titel als rohes UTF-8, PDF-Betrachter lesen Textstrings aber
|
||||||
|
/// als PDFDocEncoding oder UTF-16BE: Umlaute und Gedankenstriche würden verstümmelt. Titel
|
||||||
|
/// mit Nicht-ASCII-Zeichen werden deshalb nach UTF-16BE (mit BOM) umgeschrieben.
|
||||||
|
fn fix_text_encoding(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||||
|
let needs_fix = notebook
|
||||||
|
.pages
|
||||||
|
.iter()
|
||||||
|
.filter_map(|p| p.title.as_deref())
|
||||||
|
.any(|t| !t.is_ascii());
|
||||||
|
if !needs_fix {
|
||||||
|
return Ok(bytes);
|
||||||
|
}
|
||||||
|
let mut doc = printpdf::lopdf::Document::load_mem(&bytes)?;
|
||||||
|
for object in doc.objects.values_mut() {
|
||||||
|
let Ok(dict) = object.as_dict_mut() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Ok(printpdf::lopdf::Object::String(raw, format)) = dict.get_mut(b"Title") {
|
||||||
|
if let Some(utf16) = utf16be_text(raw) {
|
||||||
|
*raw = utf16;
|
||||||
|
*format = printpdf::lopdf::StringFormat::Hexadecimal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
doc.save_to(&mut out)?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UTF-8-Text als PDF-Textstring in UTF-16BE mit BOM; `None` bei reinem ASCII oder
|
||||||
|
/// ungültigem UTF-8.
|
||||||
|
fn utf16be_text(raw: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
let text = std::str::from_utf8(raw).ok()?;
|
||||||
|
if text.is_ascii() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = vec![0xFE, 0xFF];
|
||||||
|
for unit in text.encode_utf16() {
|
||||||
|
out.extend_from_slice(&unit.to_be_bytes());
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unterlagen für alle Seiten mit importierter PDF-Seite.
|
||||||
|
fn pdf_underlays(notebook: &Notebook) -> Vec<crate::pdf_merge::Underlay> {
|
||||||
|
notebook
|
||||||
|
.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.
|
/// 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(
|
pub fn generate_pdf_document(
|
||||||
notebook: &Notebook,
|
notebook: &Notebook,
|
||||||
strategy: ExportStrategy,
|
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>> {
|
) -> Result<PdfDocumentReference, Box<dyn Error>> {
|
||||||
if notebook.pages.is_empty() {
|
if notebook.pages.is_empty() {
|
||||||
return Err("Notizbuch enthält keine Seiten".into());
|
return Err("Notizbuch enthält keine Seiten".into());
|
||||||
@@ -155,20 +292,40 @@ pub fn generate_pdf_document(
|
|||||||
let (doc, page1_idx, layer1_bg) =
|
let (doc, page1_idx, layer1_bg) =
|
||||||
PdfDocument::new(title, Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
|
PdfDocument::new(title, Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
|
||||||
|
|
||||||
|
// Seitentitel werden zu Lesezeichen (Inhaltsverzeichnis im PDF-Betrachter)
|
||||||
|
if let Some(title) = ¬ebook.pages[0].title {
|
||||||
|
doc.add_bookmark(title.clone(), page1_idx);
|
||||||
|
}
|
||||||
|
|
||||||
// Seite 1 befüllen
|
// Seite 1 befüllen
|
||||||
let page1_ref = doc.get_page(page1_idx);
|
let page1_ref = doc.get_page(page1_idx);
|
||||||
let layer1_bg_ref = page1_ref.get_layer(layer1_bg);
|
let layer1_bg_ref = page1_ref.get_layer(layer1_bg);
|
||||||
render_background_layer(&layer1_bg_ref, notebook.pages[0].background);
|
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");
|
let layer1_ink_ref = page1_ref.add_layer("Ink");
|
||||||
render_ink_layer(&layer1_ink_ref, ¬ebook.pages[0], strategy);
|
render_ink_layer(&layer1_ink_ref, ¬ebook.pages[0], strategy);
|
||||||
|
|
||||||
// 2. Alle nachfolgenden Seiten hinzufügen
|
// 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_idx, layer_bg) = doc.add_page(Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
|
||||||
|
if let Some(title) = &page.title {
|
||||||
|
doc.add_bookmark(title.clone(), page_idx);
|
||||||
|
}
|
||||||
let page_ref = doc.get_page(page_idx);
|
let page_ref = doc.get_page(page_idx);
|
||||||
let layer_bg_ref = page_ref.get_layer(layer_bg);
|
let layer_bg_ref = page_ref.get_layer(layer_bg);
|
||||||
render_background_layer(&layer_bg_ref, page.background);
|
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");
|
let layer_ink_ref = page_ref.add_layer("Ink");
|
||||||
render_ink_layer(&layer_ink_ref, page, strategy);
|
render_ink_layer(&layer_ink_ref, page, strategy);
|
||||||
@@ -268,6 +425,9 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, background: Page
|
|||||||
/// Rendert alle Striche einer Seite auf Layer 2 ("Ink").
|
/// Rendert alle Striche einer Seite auf Layer 2 ("Ink").
|
||||||
fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) {
|
fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) {
|
||||||
for stroke in &page.strokes {
|
for stroke in &page.strokes {
|
||||||
|
if stroke.is_image() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if let Some(ref shape) = stroke.shape {
|
if let Some(ref shape) = stroke.shape {
|
||||||
render_recognized_shape(layer, shape, stroke.color);
|
render_recognized_shape(layer, shape, stroke.color);
|
||||||
} else {
|
} else {
|
||||||
@@ -283,6 +443,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)
|
/// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem)
|
||||||
/// als verlustfreien Vektorpfad im PDF.
|
/// als verlustfreien Vektorpfad im PDF.
|
||||||
fn render_recognized_shape(
|
fn render_recognized_shape(
|
||||||
@@ -794,7 +1158,7 @@ mod tests {
|
|||||||
let pdf_path = temp_dir.join("ink_notes_test_export.pdf");
|
let pdf_path = temp_dir.join("ink_notes_test_export.pdf");
|
||||||
|
|
||||||
let export_res =
|
let export_res =
|
||||||
export_notebook_to_pdf(¬ebook, &pdf_path, ExportStrategy::FilledPolygon);
|
export_notebook_to_pdf(¬ebook, &pdf_path, ExportStrategy::FilledPolygon, None);
|
||||||
assert!(
|
assert!(
|
||||||
export_res.is_ok(),
|
export_res.is_ok(),
|
||||||
"PDF-Export zu Datei muss erfolgreich sein"
|
"PDF-Export zu Datei muss erfolgreich sein"
|
||||||
@@ -820,7 +1184,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 3. Export als Bytes testen (Strategie B: BezierCurve)
|
// 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());
|
assert!(bytes_res.is_ok());
|
||||||
let bytes = bytes_res.unwrap();
|
let bytes = bytes_res.unwrap();
|
||||||
assert!(bytes.len() > 1024);
|
assert!(bytes.len() > 1024);
|
||||||
@@ -943,13 +1307,223 @@ mod tests {
|
|||||||
);
|
);
|
||||||
page.add_stroke(octahedron_stroke);
|
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");
|
assert!(bytes_res.is_ok(), "Shape export must succeed");
|
||||||
let bytes = bytes_res.unwrap();
|
let bytes = bytes_res.unwrap();
|
||||||
assert!(bytes.len() > 1024);
|
assert!(bytes.len() > 1024);
|
||||||
assert!(bytes.starts_with(b"%PDF-1."));
|
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_page_titles_become_pdf_bookmarks() {
|
||||||
|
let mut notebook = Notebook::new();
|
||||||
|
notebook.add_page(PageBackground::Blank);
|
||||||
|
notebook.add_page(PageBackground::Blank);
|
||||||
|
let ids: Vec<u64> = notebook.pages.iter().map(|p| p.id).collect();
|
||||||
|
notebook.set_page_title(ids[0], "Einleitung");
|
||||||
|
notebook.set_page_title(ids[2], "Übung 2 – Integrale");
|
||||||
|
let bytes =
|
||||||
|
export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, None).unwrap();
|
||||||
|
let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap();
|
||||||
|
let catalog = doc.catalog().unwrap();
|
||||||
|
assert!(catalog.has(b"Outlines"), "Lesezeichen fehlen");
|
||||||
|
|
||||||
|
// Titel als PDF-Textstrings: ASCII unverändert, Umlaute als UTF-16BE mit BOM
|
||||||
|
let titles: Vec<String> = doc
|
||||||
|
.objects
|
||||||
|
.values()
|
||||||
|
.filter_map(|o| o.as_dict().ok())
|
||||||
|
.filter(|d| d.has(b"Parent"))
|
||||||
|
.filter_map(|d| match d.get(b"Title") {
|
||||||
|
Ok(printpdf::lopdf::Object::String(raw, _)) => {
|
||||||
|
Some(if raw.starts_with(&[0xFE, 0xFF]) {
|
||||||
|
let units: Vec<u16> = raw[2..]
|
||||||
|
.chunks(2)
|
||||||
|
.map(|c| u16::from_be_bytes([c[0], c[1]]))
|
||||||
|
.collect();
|
||||||
|
String::from_utf16(&units).unwrap()
|
||||||
|
} else {
|
||||||
|
String::from_utf8(raw.clone()).unwrap()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(titles.contains(&"Einleitung".to_string()), "{titles:?}");
|
||||||
|
assert!(
|
||||||
|
titles.contains(&"Übung 2 – Integrale".to_string()),
|
||||||
|
"{titles:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_jpeg_component_count() {
|
||||||
|
let color = crate::assets::tests::sample_image_bytes(8, 8, image::ImageFormat::Jpeg);
|
||||||
|
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]
|
#[test]
|
||||||
fn test_sanitize_filename_component() {
|
fn test_sanitize_filename_component() {
|
||||||
assert_eq!(sanitize_filename_component("Notizbuch 1"), "Notizbuch 1");
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,13 @@ pub enum HistoryAction {
|
|||||||
before: Vec<Stroke>,
|
before: Vec<Stroke>,
|
||||||
after: Vec<Stroke>,
|
after: Vec<Stroke>,
|
||||||
},
|
},
|
||||||
|
/// Teilradierer: Striche wurden durch ihre übrig gebliebenen Stücke ersetzt.
|
||||||
|
SplitStrokes {
|
||||||
|
removed: Vec<Stroke>,
|
||||||
|
added: Vec<Stroke>,
|
||||||
|
/// Zeitpunkt des letzten Schnitts (zum Zusammenfassen eines Radierzugs)
|
||||||
|
at: std::time::Instant,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// History-Stack für Transaktionsverwaltung (Undo / Redo).
|
/// History-Stack für Transaktionsverwaltung (Undo / Redo).
|
||||||
@@ -41,6 +48,40 @@ impl HistoryStack {
|
|||||||
self.redo_stack.clear();
|
self.redo_stack.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Registriert einen Teilradier-Schnitt. Folgt er innerhalb von
|
||||||
|
/// [`crate::document::PARTIAL_ERASE_MERGE_WINDOW`] auf einen vorherigen, werden beide zu
|
||||||
|
/// einem Undo-Schritt zusammengefasst: Stücke, die im selben Zug wieder zerschnitten
|
||||||
|
/// wurden, verschwinden aus der Bilanz.
|
||||||
|
pub fn push_split(&mut self, removed: Vec<Stroke>, added: Vec<Stroke>) {
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
if let Some(HistoryAction::SplitStrokes {
|
||||||
|
removed: prev_removed,
|
||||||
|
added: prev_added,
|
||||||
|
at,
|
||||||
|
}) = self.undo_stack.last_mut()
|
||||||
|
{
|
||||||
|
if now.duration_since(*at) <= crate::document::PARTIAL_ERASE_MERGE_WINDOW
|
||||||
|
&& self.redo_stack.is_empty()
|
||||||
|
{
|
||||||
|
for stroke in removed {
|
||||||
|
if let Some(pos) = prev_added.iter().position(|s| s.id == stroke.id) {
|
||||||
|
prev_added.remove(pos);
|
||||||
|
} else {
|
||||||
|
prev_removed.push(stroke);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev_added.extend(added);
|
||||||
|
*at = now;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.push(HistoryAction::SplitStrokes {
|
||||||
|
removed,
|
||||||
|
added,
|
||||||
|
at: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Gibt zurück, ob Undo verfügbar ist.
|
/// Gibt zurück, ob Undo verfügbar ist.
|
||||||
pub fn can_undo(&self) -> bool {
|
pub fn can_undo(&self) -> bool {
|
||||||
!self.undo_stack.is_empty()
|
!self.undo_stack.is_empty()
|
||||||
@@ -95,6 +136,12 @@ impl HistoryStack {
|
|||||||
self.redo_stack
|
self.redo_stack
|
||||||
.push(HistoryAction::ModifyStrokes { before, after });
|
.push(HistoryAction::ModifyStrokes { before, after });
|
||||||
}
|
}
|
||||||
|
HistoryAction::SplitStrokes { removed, added, at } => {
|
||||||
|
strokes.retain(|s| !added.iter().any(|a| a.id == s.id));
|
||||||
|
strokes.extend(removed.iter().cloned());
|
||||||
|
self.redo_stack
|
||||||
|
.push(HistoryAction::SplitStrokes { removed, added, at });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
true
|
true
|
||||||
@@ -140,6 +187,12 @@ impl HistoryStack {
|
|||||||
self.undo_stack
|
self.undo_stack
|
||||||
.push(HistoryAction::ModifyStrokes { before, after });
|
.push(HistoryAction::ModifyStrokes { before, after });
|
||||||
}
|
}
|
||||||
|
HistoryAction::SplitStrokes { removed, added, at } => {
|
||||||
|
strokes.retain(|s| !removed.iter().any(|r| r.id == s.id));
|
||||||
|
strokes.extend(added.iter().cloned());
|
||||||
|
self.undo_stack
|
||||||
|
.push(HistoryAction::SplitStrokes { removed, added, at });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
true
|
true
|
||||||
|
|||||||
@@ -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,14 +1,21 @@
|
|||||||
//! ink-notes: Latenzoptimierte Inking-App für Linux.
|
//! ink-notes: Latenzoptimierte Inking-App für Linux.
|
||||||
|
|
||||||
|
pub mod assets;
|
||||||
pub mod autosave;
|
pub mod autosave;
|
||||||
pub mod background;
|
pub mod background;
|
||||||
pub mod canvas;
|
pub mod canvas;
|
||||||
pub mod document;
|
pub mod document;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
|
pub mod file_picker;
|
||||||
pub mod filter;
|
pub mod filter;
|
||||||
pub mod geometry;
|
pub mod geometry;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
|
pub mod image_layer;
|
||||||
|
pub mod page_export;
|
||||||
|
pub mod pdf_doc;
|
||||||
|
pub mod pdf_merge;
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
|
pub mod ruler;
|
||||||
pub mod selection;
|
pub mod selection;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod spline;
|
pub mod spline;
|
||||||
|
|||||||
+484
-62
@@ -16,11 +16,14 @@ use winit::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use glam::Vec2;
|
use glam::Vec2;
|
||||||
|
use ink_notes::assets::{AssetStore, ImportedImage};
|
||||||
use ink_notes::autosave::{AutosaveController, AutosaveStatus};
|
use ink_notes::autosave::{AutosaveController, AutosaveStatus};
|
||||||
use ink_notes::canvas::CanvasState;
|
use ink_notes::canvas::CanvasState;
|
||||||
use ink_notes::document::{Notebook, PageBackground};
|
use ink_notes::document::{Notebook, PageBackground};
|
||||||
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy};
|
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy};
|
||||||
use ink_notes::geometry::{self, RecognizedShape};
|
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::platform::{self, DisplayBackend};
|
||||||
use ink_notes::selection::{
|
use ink_notes::selection::{
|
||||||
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
||||||
@@ -59,10 +62,16 @@ fn update_window_title(
|
|||||||
PageBackground::DotGrid => "Punktraster (5mm)",
|
PageBackground::DotGrid => "Punktraster (5mm)",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let page_title = page
|
||||||
|
.title
|
||||||
|
.as_deref()
|
||||||
|
.map(|t| format!(" – {t}"))
|
||||||
|
.unwrap_or_default();
|
||||||
let title = format!(
|
let title = format!(
|
||||||
"ink-notes | [{}] {} | [{}] | {} | Hintergrund: {} | [Ctrl+Z] Undo [Ctrl+Y] Redo [E] Radierer [Ctrl+E/S] PDF-Export",
|
"ink-notes | [{}] {}{} | [{}] | {} | Hintergrund: {} | [Ctrl+Z] Undo [Ctrl+Y] Redo [E] Radierer [Ctrl+E/S] PDF-Export",
|
||||||
manifest.title,
|
manifest.title,
|
||||||
notebook.osd_text(),
|
notebook.osd_text(),
|
||||||
|
page_title,
|
||||||
status.display_text(),
|
status.display_text(),
|
||||||
tool.name(),
|
tool.name(),
|
||||||
bg_name
|
bg_name
|
||||||
@@ -91,7 +100,13 @@ fn start_pdf_export(
|
|||||||
let result = storage::load_all_pages(&storage_dir, &mut notebook_clone)
|
let result = storage::load_all_pages(&storage_dir, &mut notebook_clone)
|
||||||
.map_err(|err| err.to_string())
|
.map_err(|err| err.to_string())
|
||||||
.and_then(|()| {
|
.and_then(|()| {
|
||||||
export_notebook_to_pdf(¬ebook_clone, &export_path, ExportStrategy::FilledPolygon)
|
let assets = AssetStore::new(&storage_dir);
|
||||||
|
export_notebook_to_pdf(
|
||||||
|
¬ebook_clone,
|
||||||
|
&export_path,
|
||||||
|
ExportStrategy::FilledPolygon,
|
||||||
|
Some(&assets),
|
||||||
|
)
|
||||||
.map_err(|err| err.to_string())
|
.map_err(|err| err.to_string())
|
||||||
});
|
});
|
||||||
match &result {
|
match &result {
|
||||||
@@ -152,6 +167,333 @@ 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internes Kopieren: die Auswahl zusätzlich als Bild in die Systemzwischenablage legen,
|
||||||
|
/// damit sie sich in Chat, Mail oder Office einfügen lässt. In ink-notes selbst fügt
|
||||||
|
/// Strg+V weiterhin die Objekte (verlustfrei) ein.
|
||||||
|
fn copy_selection(
|
||||||
|
&mut self,
|
||||||
|
page: &ink_notes::document::Page,
|
||||||
|
ids: &[u64],
|
||||||
|
storage_dir: &Path,
|
||||||
|
) -> bool {
|
||||||
|
let assets = AssetStore::new(storage_dir);
|
||||||
|
let image = match ink_notes::page_export::selection_image(page, ids, Some(&assets)) {
|
||||||
|
Ok(Some(image)) => image,
|
||||||
|
Ok(None) => return false,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("[Zwischenablage] Auswahl nicht als Bild darstellbar: {err}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if self.system.is_none() {
|
||||||
|
self.system = arboard::Clipboard::new().ok();
|
||||||
|
}
|
||||||
|
let Some(system) = self.system.as_mut() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let data = arboard::ImageData {
|
||||||
|
width: image.width as usize,
|
||||||
|
height: image.height as usize,
|
||||||
|
bytes: std::borrow::Cow::Owned(image.pixels),
|
||||||
|
};
|
||||||
|
match system.set_image(data) {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("[Zwischenablage] Bild nicht gesetzt: {err}");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fügt ein importiertes Bild um `center` (Seitenkoordinaten) auf der aktuellen Seite ein,
|
||||||
|
/// 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abbildung Seite ↔ logische Bildschirmpunkte für die Oberfläche (Lineal).
|
||||||
|
fn page_view(canvas: &CanvasState, scale_factor: f32) -> ui::PageView {
|
||||||
|
let origin = canvas.page_to_screen(Vec2::ZERO);
|
||||||
|
let unit = canvas.page_to_screen(Vec2::new(1000.0, 0.0));
|
||||||
|
ui::PageView {
|
||||||
|
origin: egui::pos2(origin[0] / scale_factor, origin[1] / scale_factor),
|
||||||
|
scale: (unit[0] - origin[0]) / 1000.0 / scale_factor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_message(count: usize, as_image: bool) -> String {
|
||||||
|
if as_image {
|
||||||
|
format!("{count} Objekt(e) kopiert – auch als Bild für andere Programme")
|
||||||
|
} else {
|
||||||
|
format!("{count} Objekt(e) kopiert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exportiert die aktuelle Seite im Hintergrund als PNG oder SVG nach ~/Documents.
|
||||||
|
fn start_page_export(
|
||||||
|
kind: ui::PageExportKind,
|
||||||
|
notebook: &Notebook,
|
||||||
|
storage_dir: &Path,
|
||||||
|
title: &str,
|
||||||
|
result_tx: Sender<Result<PathBuf, String>>,
|
||||||
|
window: Arc<Window>,
|
||||||
|
) {
|
||||||
|
use ink_notes::page_export::{page_png, page_svg, ImageBackground};
|
||||||
|
let page = notebook.current_page().clone();
|
||||||
|
let number = notebook.current_page_index + 1;
|
||||||
|
let storage_dir = storage_dir.to_path_buf();
|
||||||
|
let (suffix, ext) = match kind {
|
||||||
|
ui::PageExportKind::Png => ("", "png"),
|
||||||
|
ui::PageExportKind::PngTransparent => ("_transparent", "png"),
|
||||||
|
ui::PageExportKind::Svg => ("", "svg"),
|
||||||
|
};
|
||||||
|
let path = export::resolve_export_path(&format!("{title}_Seite_{number}{suffix}"), ext);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let assets = AssetStore::new(&storage_dir);
|
||||||
|
let bytes = match kind {
|
||||||
|
ui::PageExportKind::Png => page_png(&page, Some(&assets), ImageBackground::Paper),
|
||||||
|
ui::PageExportKind::PngTransparent => {
|
||||||
|
page_png(&page, Some(&assets), ImageBackground::Transparent)
|
||||||
|
}
|
||||||
|
ui::PageExportKind::Svg => page_svg(&page, Some(&assets)).map(String::into_bytes),
|
||||||
|
};
|
||||||
|
let result = bytes
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
.and_then(|bytes| std::fs::write(&path, bytes).map_err(|e| e.to_string()))
|
||||||
|
.map(|()| path);
|
||||||
|
let _ = result_tx.send(result);
|
||||||
|
window.request_redraw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seitenmitte des sichtbaren Bereichs (Ziel für eingefügte Bilder).
|
||||||
|
fn visible_page_center(canvas: &CanvasState) -> Vec2 {
|
||||||
|
canvas.screen_to_page_clamped(
|
||||||
|
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>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Anzeigesystem wählen: Unter WSL (WSLg) bricht Wayland ab, X11 (Xwayland) läuft stabil
|
// Anzeigesystem wählen: Unter WSL (WSLg) bricht Wayland ab, X11 (Xwayland) läuft stabil
|
||||||
let mut event_loop_builder = EventLoopBuilder::new();
|
let mut event_loop_builder = EventLoopBuilder::new();
|
||||||
@@ -284,6 +626,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
ui_state.set_export_message(warning);
|
ui_state.set_export_message(warning);
|
||||||
}
|
}
|
||||||
let mut selection_state = SelectionState::new();
|
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) {
|
let mut _notebook_lock = match storage::NotebookLock::acquire(&storage_dir) {
|
||||||
Ok(lock) => {
|
Ok(lock) => {
|
||||||
@@ -401,8 +749,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
while let Ok(result) = export_rx.try_recv() {
|
while let Ok(result) = export_rx.try_recv() {
|
||||||
match result {
|
match result {
|
||||||
Ok(path) => ui_state.set_export_message(format!("PDF gespeichert: {}", path.display())),
|
Ok(path) => ui_state.set_export_message(format!("Gespeichert: {}", path.display())),
|
||||||
Err(err) => ui_state.set_export_message(format!("PDF-Export fehlgeschlagen: {err}")),
|
Err(err) => ui_state.set_export_message(format!("Export fehlgeschlagen: {err}")),
|
||||||
}
|
}
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -513,13 +861,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
if is_erasing {
|
if is_erasing {
|
||||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||||
let page = notebook.current_page_mut();
|
if notebook.erase_at(page_pt,
|
||||||
let removed = page.erase_at(
|
|
||||||
page_pt,
|
|
||||||
eraser_radius,
|
eraser_radius,
|
||||||
stroke_max_radius, ui_state.eraser_mode);
|
stroke_max_radius, ui_state.eraser_mode) {
|
||||||
if !removed.is_empty() {
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
canvas.sync_page(page);
|
|
||||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||||
update_window_title(
|
update_window_title(
|
||||||
&window,
|
&window,
|
||||||
@@ -580,13 +925,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if should_erase {
|
if should_erase {
|
||||||
is_erasing = true;
|
is_erasing = true;
|
||||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||||
let page = notebook.current_page_mut();
|
if notebook.erase_at(page_pt,
|
||||||
let removed = page.erase_at(
|
|
||||||
page_pt,
|
|
||||||
eraser_radius,
|
eraser_radius,
|
||||||
stroke_max_radius, ui_state.eraser_mode);
|
stroke_max_radius, ui_state.eraser_mode) {
|
||||||
if !removed.is_empty() {
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
canvas.sync_page(page);
|
|
||||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||||
update_window_title(
|
update_window_title(
|
||||||
&window,
|
&window,
|
||||||
@@ -755,6 +1097,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) => {
|
WindowEvent::Focused(false) => {
|
||||||
// Bei Fokusverlust Strich abbrechen und 1€-Filter resetten
|
// Bei Fokusverlust Strich abbrechen und 1€-Filter resetten
|
||||||
if is_drawing {
|
if is_drawing {
|
||||||
@@ -879,30 +1239,34 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Key::Character(c) if c.eq_ignore_ascii_case("c") && ctrl => {
|
Key::Character(c) if c.eq_ignore_ascii_case("c") && ctrl => {
|
||||||
let count = selection_state.copy_to_clipboard(notebook.current_page());
|
let count = selection_state.copy_to_clipboard(notebook.current_page());
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
ui_state.set_export_message(format!("{count} Objekt(e) kopiert"));
|
let as_image = clipboard.copy_selection(notebook.current_page(), &selection_state.selected_ids, &storage_dir);
|
||||||
|
clipboard.note_internal_copy(&storage_dir);
|
||||||
|
ui_state.set_export_message(copy_message(count, as_image));
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Einfügen: Strg + V
|
// Einfügen: Strg + V
|
||||||
Key::Character(c) if c.eq_ignore_ascii_case("v") && ctrl => {
|
Key::Character(c) if c.eq_ignore_ascii_case("v") && ctrl => {
|
||||||
let mut next_id = notebook.next_stroke_id;
|
paste(
|
||||||
let new_ids = selection_state.paste_from_clipboard(
|
&mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
|
||||||
notebook.current_page_mut(),
|
&mut autosave, &mut selection_state, &mut ui_state,
|
||||||
&mut || {
|
|
||||||
let id = next_id;
|
|
||||||
next_id += 1;
|
|
||||||
id
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
notebook.next_stroke_id = next_id;
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
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();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lineal ein-/ausblenden: L
|
||||||
|
Key::Character(c) if c.eq_ignore_ascii_case("l") && !ctrl => {
|
||||||
|
ui_state.toggle_ruler();
|
||||||
|
canvas.ruler = ui_state.ruler;
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bild einfügen: Strg + I
|
||||||
|
Key::Character(c) if c.eq_ignore_ascii_case("i") && ctrl => {
|
||||||
|
ui_state.open_image_picker();
|
||||||
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Löschen: Entf oder Backspace
|
// Löschen: Entf oder Backspace
|
||||||
@@ -1203,10 +1567,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
is_erasing = true;
|
is_erasing = true;
|
||||||
let (sx, sy) = cursor_screen_pos;
|
let (sx, sy) = cursor_screen_pos;
|
||||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||||
let page = notebook.current_page_mut();
|
if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
|
||||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
if !removed.is_empty() {
|
|
||||||
canvas.sync_page(page);
|
|
||||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
@@ -1471,10 +1833,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
} else if is_erasing {
|
} else if is_erasing {
|
||||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||||
let page = notebook.current_page_mut();
|
if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
|
||||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
if !removed.is_empty() {
|
|
||||||
canvas.sync_page(page);
|
|
||||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
@@ -1610,10 +1970,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if is_eraser_action {
|
if is_eraser_action {
|
||||||
is_erasing = true;
|
is_erasing = true;
|
||||||
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
||||||
let page = notebook.current_page_mut();
|
if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
|
||||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
if !removed.is_empty() {
|
|
||||||
canvas.sync_page(page);
|
|
||||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
@@ -1751,10 +2109,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
} else if is_erasing {
|
} else if is_erasing {
|
||||||
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
||||||
let page = notebook.current_page_mut();
|
if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
|
||||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
if !removed.is_empty() {
|
|
||||||
canvas.sync_page(page);
|
|
||||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
@@ -1901,7 +2257,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
// 3. Toolbar & Widgets rendern
|
// 3. Toolbar & Widgets rendern
|
||||||
updater.update();
|
updater.update();
|
||||||
|
ui_state.page_view = Some(page_view(&canvas, window.scale_factor() as f32));
|
||||||
let actions = ui::render_ui(&egui_ctx, &mut ui_state, ¬ebook, autosave.status(), &updater);
|
let actions = ui::render_ui(&egui_ctx, &mut ui_state, ¬ebook, autosave.status(), &updater);
|
||||||
|
canvas.ruler = ui_state.ruler;
|
||||||
|
|
||||||
// Seite neben der Werkzeugleiste zentrieren (Leistenbreite in physischen Pixeln)
|
// Seite neben der Werkzeugleiste zentrieren (Leistenbreite in physischen Pixeln)
|
||||||
let reserved = ui_state.sidebar_reserved_width() * window.scale_factor() as f32;
|
let reserved = ui_state.sidebar_reserved_width() * window.scale_factor() as f32;
|
||||||
@@ -1984,28 +2342,28 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
UiAction::CopySelected => {
|
UiAction::CopySelected => {
|
||||||
let count = selection_state.copy_to_clipboard(notebook.current_page());
|
let count = selection_state.copy_to_clipboard(notebook.current_page());
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
ui_state.set_export_message(format!("{count} Objekt(e) kopiert"));
|
let as_image = clipboard.copy_selection(notebook.current_page(), &selection_state.selected_ids, &storage_dir);
|
||||||
|
clipboard.note_internal_copy(&storage_dir);
|
||||||
|
ui_state.set_export_message(copy_message(count, as_image));
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UiAction::PasteSelected => {
|
UiAction::PasteSelected => {
|
||||||
let mut next_id = notebook.next_stroke_id;
|
paste(
|
||||||
let new_ids = selection_state.paste_from_clipboard(
|
&mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
|
||||||
notebook.current_page_mut(),
|
&mut autosave, &mut selection_state, &mut ui_state,
|
||||||
&mut || {
|
|
||||||
let id = next_id;
|
|
||||||
next_id += 1;
|
|
||||||
id
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
notebook.next_stroke_id = next_id;
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
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();
|
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 => {
|
UiAction::RotateSelected => {
|
||||||
if !selection_state.is_empty()
|
if !selection_state.is_empty()
|
||||||
@@ -2080,6 +2438,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.map(|p| storage::PageMeta {
|
.map(|p| storage::PageMeta {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
background: Some(p.background),
|
background: Some(p.background),
|
||||||
|
title: p.title.clone(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
manifest.sync_from_notebook(¬ebook);
|
manifest.sync_from_notebook(¬ebook);
|
||||||
@@ -2088,6 +2447,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
|
UiAction::ExportPage(kind) => {
|
||||||
|
start_page_export(kind, ¬ebook, &storage_dir, &manifest.title, export_tx.clone(), window.clone());
|
||||||
|
ui_state.set_export_message("Seite wird exportiert …".to_string());
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
UiAction::SetPageTitle { page_id, title } => {
|
||||||
|
if notebook.set_page_title(page_id, &title) {
|
||||||
|
if let Some(page) = notebook.pages.iter().find(|p| p.id == page_id) {
|
||||||
|
manifest.sync_from_notebook(¬ebook);
|
||||||
|
autosave.mark_page_dirty(page, &manifest);
|
||||||
|
}
|
||||||
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
|
}
|
||||||
|
}
|
||||||
UiAction::AddPage => {
|
UiAction::AddPage => {
|
||||||
selection_state.clear();
|
selection_state.clear();
|
||||||
let bg = notebook.current_page().background;
|
let bg = notebook.current_page().background;
|
||||||
@@ -2100,6 +2473,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.map(|p| storage::PageMeta {
|
.map(|p| storage::PageMeta {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
background: Some(p.background),
|
background: Some(p.background),
|
||||||
|
title: p.title.clone(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
canvas.sync_page(notebook.current_page_mut());
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
@@ -2200,6 +2574,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) => {
|
UiAction::CreateNotebook(title) => {
|
||||||
let _ = autosave.flush();
|
let _ = autosave.flush();
|
||||||
match storage::create_new_notebook(&storage_base_dir, &title) {
|
match storage::create_new_notebook(&storage_base_dir, &title) {
|
||||||
@@ -2482,6 +2893,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Canvas (Pass 1) und egui UI (Pass 2) in Surface-Textur rendern
|
// 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) {
|
match canvas.render(&clipped_primitives, &screen_descriptor, &full_output.textures_delta) {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
|
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
//! Export einzelner Seiten als PNG oder SVG und einer Auswahl als Bild.
|
||||||
|
//!
|
||||||
|
//! Grundlage ist immer der PDF-Export: die Seite wird als einseitiges PDF erzeugt (mit
|
||||||
|
//! Bildern, importierten PDF-Seiten und Formen) und anschließend mit `hayro` gerastert bzw.
|
||||||
|
//! mit `hayro-svg` nach SVG umgewandelt. So sehen PDF, PNG und SVG gleich aus.
|
||||||
|
|
||||||
|
use std::error::Error;
|
||||||
|
|
||||||
|
use hayro::hayro_interpret::InterpreterSettings;
|
||||||
|
use hayro::hayro_syntax::Pdf;
|
||||||
|
use hayro::vello_cpu::color::palette::css::{TRANSPARENT, WHITE};
|
||||||
|
use hayro::{PixmapSettings, RenderCache, RenderSettings};
|
||||||
|
|
||||||
|
use crate::assets::{AssetStore, RgbaImage};
|
||||||
|
use crate::document::{Aabb, Notebook, Page, PageBackground, A4_WIDTH_MM, PAGE_WIDTH};
|
||||||
|
use crate::export::{export_notebook_to_bytes, ExportStrategy};
|
||||||
|
|
||||||
|
/// Auflösung des PNG-Exports einer Seite.
|
||||||
|
pub const PAGE_PNG_DPI: f32 = 200.0;
|
||||||
|
/// Auflösung einer in die Zwischenablage kopierten Auswahl.
|
||||||
|
pub const SELECTION_PNG_DPI: f32 = 150.0;
|
||||||
|
|
||||||
|
/// Hintergrund beim Bild-Export.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ImageBackground {
|
||||||
|
/// Weißes Papier mit Raster bzw. importierter PDF-Seite
|
||||||
|
Paper,
|
||||||
|
/// Transparent, nur Bilder und Tinte (ohne Raster und ohne importierte PDF-Seite)
|
||||||
|
Transparent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erzeugt ein einseitiges PDF aus `page`.
|
||||||
|
fn single_page_pdf(page: &Page, assets: Option<&AssetStore>) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||||
|
let notebook = Notebook::from_pages(vec![page.clone()], 0, 1, page.id + 1);
|
||||||
|
export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, assets)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seite ohne Raster und PDF-Hintergrund (für transparenten Export und Auswahlen).
|
||||||
|
fn content_only(page: &Page) -> Page {
|
||||||
|
let mut page = page.clone();
|
||||||
|
page.background = PageBackground::Blank;
|
||||||
|
page.pdf_background = None;
|
||||||
|
page
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rastert die erste Seite eines PDFs mit `dpi`; Farben ohne Vormultiplikation.
|
||||||
|
fn rasterize(pdf_bytes: Vec<u8>, dpi: f32, transparent: bool) -> Result<RgbaImage, Box<dyn Error>> {
|
||||||
|
let pdf = Pdf::new(pdf_bytes).map_err(|e| format!("PDF nicht lesbar: {e:?}"))?;
|
||||||
|
let page = pdf.pages().first().ok_or("PDF ohne Seite")?;
|
||||||
|
let scale = dpi / 72.0;
|
||||||
|
let pixmap = hayro::render(
|
||||||
|
page,
|
||||||
|
&RenderCache::new(),
|
||||||
|
&InterpreterSettings::default(),
|
||||||
|
&RenderSettings::default(),
|
||||||
|
&PixmapSettings {
|
||||||
|
x_scale: scale,
|
||||||
|
y_scale: scale,
|
||||||
|
bg_color: if transparent { TRANSPARENT } else { WHITE },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut pixels = pixmap.data_as_u8_slice().to_vec();
|
||||||
|
// hayro liefert vormultiplizierte Farben; PNG erwartet gerade Farben
|
||||||
|
for px in pixels.as_chunks_mut::<4>().0 {
|
||||||
|
let a = px[3] as u32;
|
||||||
|
if a > 0 && a < 255 {
|
||||||
|
for c in &mut px[..3] {
|
||||||
|
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(RgbaImage {
|
||||||
|
width: pixmap.width() as u32,
|
||||||
|
height: pixmap.height() as u32,
|
||||||
|
pixels,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kodiert ein Bild als PNG.
|
||||||
|
pub fn encode_png(image: &RgbaImage) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||||
|
let buffer = image::RgbaImage::from_raw(image.width, image.height, image.pixels.clone())
|
||||||
|
.ok_or("Pixeldaten passen nicht zur Bildgröße")?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
image::DynamicImage::ImageRgba8(buffer)
|
||||||
|
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seite als PNG (A4 mit [`PAGE_PNG_DPI`]).
|
||||||
|
pub fn page_png(
|
||||||
|
page: &Page,
|
||||||
|
assets: Option<&AssetStore>,
|
||||||
|
background: ImageBackground,
|
||||||
|
) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||||
|
let image = match background {
|
||||||
|
ImageBackground::Paper => rasterize(single_page_pdf(page, assets)?, PAGE_PNG_DPI, false)?,
|
||||||
|
ImageBackground::Transparent => rasterize(
|
||||||
|
single_page_pdf(&content_only(page), assets)?,
|
||||||
|
PAGE_PNG_DPI,
|
||||||
|
true,
|
||||||
|
)?,
|
||||||
|
};
|
||||||
|
encode_png(&image)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seite als SVG (Vektor; Schriften importierter PDFs werden zu Pfaden).
|
||||||
|
pub fn page_svg(page: &Page, assets: Option<&AssetStore>) -> Result<String, Box<dyn Error>> {
|
||||||
|
let pdf =
|
||||||
|
Pdf::new(single_page_pdf(page, assets)?).map_err(|e| format!("PDF nicht lesbar: {e:?}"))?;
|
||||||
|
let pdf_page = pdf.pages().first().ok_or("PDF ohne Seite")?;
|
||||||
|
Ok(hayro_svg::convert(
|
||||||
|
pdf_page,
|
||||||
|
&hayro_svg::RenderCache::new(),
|
||||||
|
&InterpreterSettings::default(),
|
||||||
|
&hayro_svg::SvgRenderSettings {
|
||||||
|
bg_color: [255, 255, 255, 255],
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bild der Objekte `ids` auf `page`, auf ihren Umriss zugeschnitten, mit transparentem
|
||||||
|
/// Hintergrund. `None`, wenn keines der Objekte auf der Seite liegt.
|
||||||
|
pub fn selection_image(
|
||||||
|
page: &Page,
|
||||||
|
ids: &[u64],
|
||||||
|
assets: Option<&AssetStore>,
|
||||||
|
) -> Result<Option<RgbaImage>, Box<dyn Error>> {
|
||||||
|
let mut selected = content_only(page);
|
||||||
|
selected.strokes.retain(|s| ids.contains(&s.id));
|
||||||
|
let Some(bounds) = Page::selection_aabb(&selected.strokes) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let max_width = selected.strokes.iter().map(|s| s.width).fold(0.0, f32::max);
|
||||||
|
let bounds: Aabb = bounds.expanded(max_width / 2.0 + 4.0);
|
||||||
|
|
||||||
|
let full = rasterize(single_page_pdf(&selected, assets)?, SELECTION_PNG_DPI, true)?;
|
||||||
|
// Seiteneinheiten → Pixel
|
||||||
|
let px_per_unit = SELECTION_PNG_DPI / 25.4 * A4_WIDTH_MM / PAGE_WIDTH;
|
||||||
|
let x0 = ((bounds.min.x * px_per_unit).floor().max(0.0) as u32).min(full.width);
|
||||||
|
let y0 = ((bounds.min.y * px_per_unit).floor().max(0.0) as u32).min(full.height);
|
||||||
|
let x1 = ((bounds.max.x * px_per_unit).ceil().max(0.0) as u32).min(full.width);
|
||||||
|
let y1 = ((bounds.max.y * px_per_unit).ceil().max(0.0) as u32).min(full.height);
|
||||||
|
if x1 <= x0 || y1 <= y0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let (w, h) = (x1 - x0, y1 - y0);
|
||||||
|
let mut pixels = Vec::with_capacity((w * h * 4) as usize);
|
||||||
|
for y in y0..y1 {
|
||||||
|
let start = ((y * full.width + x0) * 4) as usize;
|
||||||
|
pixels.extend_from_slice(&full.pixels[start..start + (w * 4) as usize]);
|
||||||
|
}
|
||||||
|
Ok(Some(RgbaImage {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
pixels,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::document::{Stroke, StylusSample, PAGE_HEIGHT};
|
||||||
|
|
||||||
|
fn page_with_line() -> Page {
|
||||||
|
let mut page = Page::new(1, PageBackground::Grid5mm);
|
||||||
|
let samples: Vec<StylusSample> = (0..=20)
|
||||||
|
.map(|i| StylusSample {
|
||||||
|
position: glam::Vec2::new(500.0 + i as f32 * 20.0, 1000.0),
|
||||||
|
pressure: 0.6,
|
||||||
|
timestamp: i as f64 * 0.01,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let vertices = crate::storage::reconstruct_stroke_vertices(
|
||||||
|
&samples,
|
||||||
|
[0.9, 0.1, 0.1, 1.0],
|
||||||
|
20.0,
|
||||||
|
Default::default(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
let mut stroke = Stroke::new(7, samples, vertices, [0.9, 0.1, 0.1, 1.0]);
|
||||||
|
stroke.width = 20.0;
|
||||||
|
page.strokes.push(stroke);
|
||||||
|
page
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(png: &[u8]) -> image::RgbaImage {
|
||||||
|
image::load_from_memory(png).unwrap().into_rgba8()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_page_png_paper_and_transparent() {
|
||||||
|
let page = page_with_line();
|
||||||
|
let paper = decode(&page_png(&page, None, ImageBackground::Paper).unwrap());
|
||||||
|
// A4 mit 200 dpi
|
||||||
|
assert_eq!(paper.width(), (A4_WIDTH_MM / 25.4 * PAGE_PNG_DPI) as u32);
|
||||||
|
assert_eq!(paper.get_pixel(5, 5).0[3], 255, "Papier ist deckend");
|
||||||
|
|
||||||
|
let clear = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
|
||||||
|
assert_eq!(clear.get_pixel(5, 5).0[3], 0, "Hintergrund ist transparent");
|
||||||
|
// Die Linie ist rot und deckend
|
||||||
|
let px_per_unit = clear.width() as f32 / PAGE_WIDTH;
|
||||||
|
let line = clear.get_pixel((700.0 * px_per_unit) as u32, (1000.0 * px_per_unit) as u32);
|
||||||
|
assert!(
|
||||||
|
line.0[0] > 180 && line.0[1] < 80 && line.0[3] > 200,
|
||||||
|
"{line:?}"
|
||||||
|
);
|
||||||
|
assert!(clear.height() as f32 / clear.width() as f32 - PAGE_HEIGHT / PAGE_WIDTH < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_page_svg_contains_ink() {
|
||||||
|
let svg = page_svg(&page_with_line(), None).unwrap();
|
||||||
|
assert!(svg.starts_with("<svg") || svg.contains("<svg"));
|
||||||
|
assert!(svg.contains("<path"));
|
||||||
|
assert!(svg.trim_end().ends_with("</svg>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_selection_image_is_cropped() {
|
||||||
|
let page = page_with_line();
|
||||||
|
let image = selection_image(&page, &[7], None).unwrap().unwrap();
|
||||||
|
// Linie 400 Einheiten (40 mm) lang: Bild etwas breiter als 40 mm bei 150 dpi
|
||||||
|
let expected = 40.0 / 25.4 * SELECTION_PNG_DPI;
|
||||||
|
assert!(
|
||||||
|
(image.width as f32) > expected && (image.width as f32) < expected * 1.25,
|
||||||
|
"{} px",
|
||||||
|
image.width
|
||||||
|
);
|
||||||
|
assert!(image.height < image.width / 3);
|
||||||
|
assert!(image.pixels.as_chunks::<4>().0.iter().any(|p| p[3] > 200));
|
||||||
|
assert!(selection_image(&page, &[99], None).unwrap().is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
+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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+239
@@ -0,0 +1,239 @@
|
|||||||
|
//! Virtuelles Lineal: ein verschieb- und drehbares Lineal über dem Blatt. Striche, die nahe
|
||||||
|
//! einer Längskante beginnen, rasten auf dieser Kante ein und werden zu geraden Linien.
|
||||||
|
//!
|
||||||
|
//! Alle Maße in Seiteneinheiten (10 Einheiten = 1 mm).
|
||||||
|
|
||||||
|
use glam::Vec2;
|
||||||
|
|
||||||
|
use crate::document::UNITS_PER_MM;
|
||||||
|
|
||||||
|
/// Länge des Lineals (26 cm).
|
||||||
|
pub const RULER_LENGTH: f32 = 260.0 * UNITS_PER_MM;
|
||||||
|
/// Breite des Lineals (3,2 cm).
|
||||||
|
pub const RULER_WIDTH: f32 = 32.0 * UNITS_PER_MM;
|
||||||
|
/// Abstand zu einer Längskante, innerhalb dessen ein Strich einrastet (innen wie außen).
|
||||||
|
pub const SNAP_DISTANCE: f32 = 6.0 * UNITS_PER_MM;
|
||||||
|
/// Radius des Dreh-Griffs.
|
||||||
|
pub const HANDLE_RADIUS: f32 = 8.0 * UNITS_PER_MM;
|
||||||
|
/// Winkel rasten in diesen Schritten ein …
|
||||||
|
pub const ANGLE_SNAP_STEP_DEG: f32 = 15.0;
|
||||||
|
/// … wenn sie höchstens so weit davon entfernt sind.
|
||||||
|
pub const ANGLE_SNAP_TOLERANCE_DEG: f32 = 2.0;
|
||||||
|
|
||||||
|
/// Lage des Lineals auf dem Blatt.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Ruler {
|
||||||
|
/// Mittelpunkt
|
||||||
|
pub center: Vec2,
|
||||||
|
/// Drehwinkel der Längsachse in Bogenmaß (0 = waagerecht)
|
||||||
|
pub angle: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was an einer Stelle des Lineals liegt.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RulerHit {
|
||||||
|
/// Fläche zum Verschieben
|
||||||
|
Body,
|
||||||
|
/// Griff zum Drehen
|
||||||
|
RotateHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Kante, an der ein Strich eingerastet ist: Gerade durch `origin` in Richtung `dir`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct EdgeLock {
|
||||||
|
pub origin: Vec2,
|
||||||
|
pub dir: Vec2,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EdgeLock {
|
||||||
|
/// Fußpunkt von `p` auf der Geraden.
|
||||||
|
pub fn project(&self, p: Vec2) -> Vec2 {
|
||||||
|
self.origin + self.dir * (p - self.origin).dot(self.dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ruler {
|
||||||
|
pub fn new(center: Vec2) -> Self {
|
||||||
|
Self { center, angle: 0.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einheitsvektor entlang der Längsachse.
|
||||||
|
pub fn axis(&self) -> Vec2 {
|
||||||
|
Vec2::from_angle(self.angle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einheitsvektor quer zur Längsachse (zur „unteren“ Kante, bei 0° nach unten).
|
||||||
|
pub fn normal(&self) -> Vec2 {
|
||||||
|
self.axis().perp()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lage von `p` im Lineal-Koordinatensystem: (entlang der Achse, quer dazu).
|
||||||
|
pub fn local(&self, p: Vec2) -> Vec2 {
|
||||||
|
let d = p - self.center;
|
||||||
|
Vec2::new(d.dot(self.axis()), d.dot(self.normal()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ecken: oben links, oben rechts, unten rechts, unten links (bei 0°).
|
||||||
|
pub fn corners(&self) -> [Vec2; 4] {
|
||||||
|
let a = self.axis() * (RULER_LENGTH / 2.0);
|
||||||
|
let n = self.normal() * (RULER_WIDTH / 2.0);
|
||||||
|
[
|
||||||
|
self.center - a - n,
|
||||||
|
self.center + a - n,
|
||||||
|
self.center + a + n,
|
||||||
|
self.center - a + n,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mittelpunkt des Dreh-Griffs (nahe dem rechten Ende).
|
||||||
|
pub fn handle_center(&self) -> Vec2 {
|
||||||
|
self.center + self.axis() * (RULER_LENGTH / 2.0 - HANDLE_RADIUS * 1.6)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was liegt an `p`? Die Streifen entlang der Kanten gehören nicht dazu: dort beginnen
|
||||||
|
/// eingerastete Striche.
|
||||||
|
pub fn hit(&self, p: Vec2) -> Option<RulerHit> {
|
||||||
|
if (p - self.handle_center()).length() <= HANDLE_RADIUS {
|
||||||
|
return Some(RulerHit::RotateHandle);
|
||||||
|
}
|
||||||
|
let l = self.local(p);
|
||||||
|
let inside_band = l.y.abs() <= RULER_WIDTH / 2.0 - SNAP_DISTANCE;
|
||||||
|
(l.x.abs() <= RULER_LENGTH / 2.0 && inside_band).then_some(RulerHit::Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kante, an der ein bei `p` beginnender Strich einrastet, verschoben um `offset` nach
|
||||||
|
/// außen (halbe Strichbreite: die Tinte liegt dann neben dem Lineal, nicht darunter).
|
||||||
|
pub fn edge_lock(&self, p: Vec2, offset: f32) -> Option<EdgeLock> {
|
||||||
|
let l = self.local(p);
|
||||||
|
if l.x.abs() > RULER_LENGTH / 2.0 + SNAP_DISTANCE {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let half = RULER_WIDTH / 2.0;
|
||||||
|
let side = if (l.y - half).abs() <= SNAP_DISTANCE {
|
||||||
|
1.0
|
||||||
|
} else if (l.y + half).abs() <= SNAP_DISTANCE {
|
||||||
|
-1.0
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
Some(EdgeLock {
|
||||||
|
origin: self.center + self.normal() * side * (half + offset),
|
||||||
|
dir: self.axis(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dreht das Lineal so, dass die Achse vom Mittelpunkt auf `toward` zeigt; Vielfache von
|
||||||
|
/// 15° rasten ein.
|
||||||
|
pub fn rotate_toward(&mut self, toward: Vec2) {
|
||||||
|
let d = toward - self.center;
|
||||||
|
if d.length_squared() < 1e-6 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.angle = snap_angle(d.y.atan2(d.x));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Winkel für die Anzeige in Grad, 0–179 (ein Lineal hat keine Richtung).
|
||||||
|
pub fn display_degrees(&self) -> u32 {
|
||||||
|
(self.angle.to_degrees().round() as i32).rem_euclid(180) as u32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rastet einen Winkel (Bogenmaß) auf das nächste Vielfache von 15° ein, wenn er nahe genug ist.
|
||||||
|
pub fn snap_angle(angle: f32) -> f32 {
|
||||||
|
let deg = angle.to_degrees();
|
||||||
|
let nearest = (deg / ANGLE_SNAP_STEP_DEG).round() * ANGLE_SNAP_STEP_DEG;
|
||||||
|
if (deg - nearest).abs() <= ANGLE_SNAP_TOLERANCE_DEG {
|
||||||
|
nearest.to_radians()
|
||||||
|
} else {
|
||||||
|
angle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn close(a: Vec2, b: Vec2) -> bool {
|
||||||
|
(a - b).length() < 1e-2
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_geometry_and_hits() {
|
||||||
|
let ruler = Ruler::new(Vec2::new(1000.0, 1000.0));
|
||||||
|
let [tl, tr, br, bl] = ruler.corners();
|
||||||
|
assert!(close(
|
||||||
|
tl,
|
||||||
|
Vec2::new(1000.0 - RULER_LENGTH / 2.0, 1000.0 - RULER_WIDTH / 2.0)
|
||||||
|
));
|
||||||
|
assert!(close(
|
||||||
|
br,
|
||||||
|
Vec2::new(1000.0 + RULER_LENGTH / 2.0, 1000.0 + RULER_WIDTH / 2.0)
|
||||||
|
));
|
||||||
|
assert!(close(
|
||||||
|
tr - tl,
|
||||||
|
bl - tl + Vec2::new(RULER_LENGTH, -RULER_WIDTH)
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(ruler.hit(ruler.center), Some(RulerHit::Body));
|
||||||
|
assert_eq!(
|
||||||
|
ruler.hit(ruler.handle_center()),
|
||||||
|
Some(RulerHit::RotateHandle)
|
||||||
|
);
|
||||||
|
// Kantenstreifen und außerhalb: kein Treffer (dort wird gezeichnet)
|
||||||
|
assert_eq!(
|
||||||
|
ruler.hit(Vec2::new(1000.0, 1000.0 - RULER_WIDTH / 2.0 + 10.0)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(ruler.hit(Vec2::new(1000.0, 1300.0)), None);
|
||||||
|
assert_eq!(ruler.hit(Vec2::new(1000.0 + RULER_LENGTH, 1000.0)), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edge_lock_projects_onto_offset_edge() {
|
||||||
|
let ruler = Ruler::new(Vec2::new(1000.0, 1000.0));
|
||||||
|
let edge_y = 1000.0 - RULER_WIDTH / 2.0;
|
||||||
|
// Knapp oberhalb der oberen Kante: rastet ein, Linie liegt um den Versatz außerhalb
|
||||||
|
let lock = ruler
|
||||||
|
.edge_lock(Vec2::new(900.0, edge_y - 20.0), 3.0)
|
||||||
|
.unwrap();
|
||||||
|
let p = lock.project(Vec2::new(1300.0, edge_y - 55.0));
|
||||||
|
assert!(close(p, Vec2::new(1300.0, edge_y - 3.0)));
|
||||||
|
// Knapp innerhalb der unteren Kante
|
||||||
|
let lock = ruler
|
||||||
|
.edge_lock(Vec2::new(900.0, 1000.0 + RULER_WIDTH / 2.0 - 20.0), 0.0)
|
||||||
|
.unwrap();
|
||||||
|
assert!(close(
|
||||||
|
lock.project(Vec2::new(0.0, 0.0)),
|
||||||
|
Vec2::new(0.0, 1000.0 + RULER_WIDTH / 2.0)
|
||||||
|
));
|
||||||
|
// Mitte oder weit weg: kein Einrasten
|
||||||
|
assert!(ruler.edge_lock(ruler.center, 0.0).is_none());
|
||||||
|
assert!(ruler.edge_lock(Vec2::new(1000.0, 500.0), 0.0).is_none());
|
||||||
|
assert!(ruler
|
||||||
|
.edge_lock(Vec2::new(1000.0 + RULER_LENGTH, edge_y), 0.0)
|
||||||
|
.is_none());
|
||||||
|
|
||||||
|
// Gedrehtes Lineal: die Linie folgt der Drehung
|
||||||
|
let mut tilted = ruler;
|
||||||
|
tilted.angle = 30f32.to_radians();
|
||||||
|
let near_edge = tilted.center - tilted.normal() * (RULER_WIDTH / 2.0 + 10.0);
|
||||||
|
let lock = tilted.edge_lock(near_edge, 0.0).unwrap();
|
||||||
|
let a = lock.project(near_edge);
|
||||||
|
let b = lock.project(near_edge + Vec2::new(300.0, 0.0));
|
||||||
|
let dir = (b - a).normalize();
|
||||||
|
assert!((dir.y.atan2(dir.x).to_degrees() - 30.0).abs() < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rotation_snaps_to_15_degrees() {
|
||||||
|
let mut ruler = Ruler::new(Vec2::ZERO);
|
||||||
|
ruler.rotate_toward(Vec2::from_angle(44.0f32.to_radians()) * 100.0);
|
||||||
|
assert_eq!(ruler.display_degrees(), 45);
|
||||||
|
ruler.rotate_toward(Vec2::from_angle(37.0f32.to_radians()) * 100.0);
|
||||||
|
assert_eq!(ruler.display_degrees(), 37);
|
||||||
|
// Gegenrichtung zeigt denselben Winkel
|
||||||
|
ruler.rotate_toward(Vec2::from_angle(-150.0f32.to_radians()) * 100.0);
|
||||||
|
assert_eq!(ruler.display_degrees(), 30);
|
||||||
|
assert_eq!(snap_angle(1.0f32.to_radians()), 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-1
@@ -243,7 +243,12 @@ impl SelectionState {
|
|||||||
if self.selected_ids.is_empty() {
|
if self.selected_ids.is_empty() {
|
||||||
return false;
|
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() {
|
if strokes.is_empty() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -499,4 +504,30 @@ mod tests {
|
|||||||
let restored_pos = page.strokes[0].points[0].position;
|
let restored_pos = page.strokes[0].points[0].position;
|
||||||
assert!((orig_pos.x - restored_pos.x).abs() < 1e-4);
|
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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,3 +47,33 @@ fn fs_ink(in: VertexOutput) -> @location(0) vec4<f32> {
|
|||||||
let lift = max(1.0 - max_c - min_c, 0.0);
|
let lift = max(1.0 - max_c - min_c, 0.0);
|
||||||
return vec4<f32>(in.color.rgb + vec3<f32>(lift), in.color.a);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
+204
-5
@@ -70,6 +70,9 @@ pub struct StrokeData {
|
|||||||
pub width: f32,
|
pub width: f32,
|
||||||
#[serde(default = "default_finalized")]
|
#[serde(default = "default_finalized")]
|
||||||
pub finalized: bool,
|
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 {
|
impl From<&Stroke> for StrokeData {
|
||||||
@@ -82,6 +85,7 @@ impl From<&Stroke> for StrokeData {
|
|||||||
tool: stroke.tool,
|
tool: stroke.tool,
|
||||||
width: stroke.width,
|
width: stroke.width,
|
||||||
finalized: stroke.finalized,
|
finalized: stroke.finalized,
|
||||||
|
image: stroke.image.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,6 +100,17 @@ impl StrokeData {
|
|||||||
.copied()
|
.copied()
|
||||||
.map(StylusSample::from)
|
.map(StylusSample::from)
|
||||||
.collect();
|
.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 {
|
let width = if self.width > 0.0 {
|
||||||
self.width
|
self.width
|
||||||
} else if self.shape.is_some() {
|
} else if self.shape.is_some() {
|
||||||
@@ -133,7 +148,12 @@ impl StrokeData {
|
|||||||
/// - Version 3: neue Formen (Ellipse, Pfeil). Ältere App-Versionen kennen sie nicht und würden
|
/// - 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
|
/// betroffene Seiten für beschädigt halten; mit Version 3 lehnen sie das Notizbuch stattdessen
|
||||||
/// sauber als „neuere Version“ ab.
|
/// sauber als „neuere Version“ ab.
|
||||||
pub const CURRENT_FORMAT_VERSION: u32 = 3;
|
/// - 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`).
|
||||||
|
/// - Version 6: Seitentitel (`title` in Seite und Manifest).
|
||||||
|
pub const CURRENT_FORMAT_VERSION: u32 = 6;
|
||||||
|
|
||||||
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
|
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
|
||||||
pub fn legacy_format_version() -> u32 {
|
pub fn legacy_format_version() -> u32 {
|
||||||
@@ -147,6 +167,12 @@ pub struct PageData {
|
|||||||
pub format_version: u32,
|
pub format_version: u32,
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub background: PageBackground,
|
pub background: PageBackground,
|
||||||
|
/// Importierte PDF-Seite als Hintergrund
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pdf_background: Option<crate::document::PdfBackground>,
|
||||||
|
/// Seitentitel (steht zusätzlich im Manifest)
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub title: Option<String>,
|
||||||
pub strokes: Vec<StrokeData>,
|
pub strokes: Vec<StrokeData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +182,8 @@ impl From<&Page> for PageData {
|
|||||||
format_version: CURRENT_FORMAT_VERSION,
|
format_version: CURRENT_FORMAT_VERSION,
|
||||||
id: page.id,
|
id: page.id,
|
||||||
background: page.background,
|
background: page.background,
|
||||||
|
pdf_background: page.pdf_background.clone(),
|
||||||
|
title: page.title.clone(),
|
||||||
strokes: page.strokes.iter().map(StrokeData::from).collect(),
|
strokes: page.strokes.iter().map(StrokeData::from).collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,6 +193,8 @@ impl PageData {
|
|||||||
/// Wandelt die Seitendaten in eine runtime-aktive `Page` mit initialisiertem Cache um.
|
/// Wandelt die Seitendaten in eine runtime-aktive `Page` mit initialisiertem Cache um.
|
||||||
pub fn to_page(&self) -> Page {
|
pub fn to_page(&self) -> Page {
|
||||||
let mut page = Page::new(self.id, self.background);
|
let mut page = Page::new(self.id, self.background);
|
||||||
|
page.pdf_background = self.pdf_background.clone();
|
||||||
|
page.title = self.title.clone();
|
||||||
for stroke_data in &self.strokes {
|
for stroke_data in &self.strokes {
|
||||||
page.strokes.push(stroke_data.to_stroke());
|
page.strokes.push(stroke_data.to_stroke());
|
||||||
}
|
}
|
||||||
@@ -181,11 +211,18 @@ pub struct PageMeta {
|
|||||||
pub id: u64,
|
pub id: u64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub background: Option<PageBackground>,
|
pub background: Option<PageBackground>,
|
||||||
|
/// Seitentitel (Lesezeichen), damit die Seitenübersicht ihn ohne Laden der Seite kennt
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub title: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PageMeta {
|
impl PageMeta {
|
||||||
pub fn new(id: u64, background: Option<PageBackground>) -> Self {
|
pub fn new(id: u64, background: Option<PageBackground>) -> Self {
|
||||||
Self { id, background }
|
Self {
|
||||||
|
id,
|
||||||
|
background,
|
||||||
|
title: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +272,13 @@ impl NotebookManifest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn title_for_page(&self, page_id: u64) -> Option<String> {
|
||||||
|
self.pages
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.id == page_id)
|
||||||
|
.and_then(|p| p.title.clone())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn background_for_page(&self, page_id: u64) -> Option<PageBackground> {
|
pub fn background_for_page(&self, page_id: u64) -> Option<PageBackground> {
|
||||||
self.pages
|
self.pages
|
||||||
.iter()
|
.iter()
|
||||||
@@ -250,6 +294,7 @@ impl NotebookManifest {
|
|||||||
.map(|p| PageMeta {
|
.map(|p| PageMeta {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
background: Some(p.background),
|
background: Some(p.background),
|
||||||
|
title: p.title.clone(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
self.page_ids = self.pages.iter().map(|p| p.id).collect();
|
self.page_ids = self.pages.iter().map(|p| p.id).collect();
|
||||||
@@ -697,6 +742,13 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
|
|||||||
pages.push(Page::new_unloaded(page_id, bg));
|
pages.push(Page::new_unloaded(page_id, bg));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Titel aus dem Manifest (Platzhalter und Ersatzseiten kennen sie sonst nicht)
|
||||||
|
for page in &mut pages {
|
||||||
|
if page.title.is_none() {
|
||||||
|
page.title = manifest.title_for_page(page.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut notebook = Notebook::from_pages(
|
let mut notebook = Notebook::from_pages(
|
||||||
pages,
|
pages,
|
||||||
manifest.current_page_index,
|
manifest.current_page_index,
|
||||||
@@ -734,7 +786,10 @@ pub fn ensure_page_loaded(
|
|||||||
let page_id = notebook.pages[page_idx].id;
|
let page_id = notebook.pages[page_idx].id;
|
||||||
match load_page(notebook_dir, page_id) {
|
match load_page(notebook_dir, page_id) {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
notebook.pages[page_idx] = data.to_page();
|
let title = notebook.pages[page_idx].title.take();
|
||||||
|
let mut page = data.to_page();
|
||||||
|
page.title = page.title.or(title);
|
||||||
|
notebook.pages[page_idx] = page;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(StorageError::CorruptedPage(id, err_msg)) => {
|
Err(StorageError::CorruptedPage(id, err_msg)) => {
|
||||||
@@ -1038,6 +1093,50 @@ pub fn create_new_notebook(
|
|||||||
Ok((notebook, manifest, notebook_dir))
|
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.
|
/// Benennt ein Notizbuch um und aktualisiert das Manifest atomar auf der Festplatte.
|
||||||
pub fn rename_notebook(
|
pub fn rename_notebook(
|
||||||
notebook_dir: &Path,
|
notebook_dir: &Path,
|
||||||
@@ -1234,12 +1333,14 @@ pub fn load_all_pages(notebook_dir: &Path, notebook: &mut Notebook) -> Result<()
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !page.is_loaded {
|
if !page.is_loaded {
|
||||||
|
let title = page.title.take();
|
||||||
*page = match load_page(notebook_dir, page.id) {
|
*page = match load_page(notebook_dir, page.id) {
|
||||||
Ok(data) => data.to_page(),
|
Ok(data) => data.to_page(),
|
||||||
// Wie beim Lazy Loading: nie gespeicherte Seite = leere Seite
|
// Wie beim Lazy Loading: nie gespeicherte Seite = leere Seite
|
||||||
Err(StorageError::PageNotFound(_)) => Page::new(page.id, page.background),
|
Err(StorageError::PageNotFound(_)) => Page::new(page.id, page.background),
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
};
|
};
|
||||||
|
page.title = page.title.take().or(title);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -2177,12 +2278,12 @@ mod tests {
|
|||||||
let (mut loaded, _) = load_notebook(&dir).unwrap();
|
let (mut loaded, _) = load_notebook(&dir).unwrap();
|
||||||
assert!(!loaded.pages[1].is_loaded);
|
assert!(!loaded.pages[1].is_loaded);
|
||||||
// Ohne Nachladen verweigert der Export, statt leere Seiten zu erzeugen
|
// 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();
|
load_all_pages(&dir, &mut loaded).unwrap();
|
||||||
assert_eq!(loaded.pages[1].strokes.len(), 1);
|
assert_eq!(loaded.pages[1].strokes.len(), 1);
|
||||||
assert_eq!(loaded.pages[1].background, PageBackground::Lined);
|
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]
|
#[test]
|
||||||
@@ -2316,4 +2417,102 @@ mod tests {
|
|||||||
crate::document::StrokeTool::Shape
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_page_titles_survive_restart_and_lazy_loading() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let (mut notebook, mut manifest, dir) =
|
||||||
|
create_new_notebook(temp_dir.path(), "Mit Titeln").unwrap();
|
||||||
|
notebook.add_page(PageBackground::Lined);
|
||||||
|
notebook.add_page(PageBackground::Lined);
|
||||||
|
let ids: Vec<u64> = notebook.pages.iter().map(|p| p.id).collect();
|
||||||
|
assert!(notebook.set_page_title(ids[0], " Einleitung \n"));
|
||||||
|
assert!(notebook.set_page_title(ids[2], "Kapitel 2 – Integrale"));
|
||||||
|
assert!(!notebook.set_page_title(ids[2], "Kapitel 2 – Integrale"));
|
||||||
|
save_notebook(&dir, ¬ebook, &mut manifest).unwrap();
|
||||||
|
assert_eq!(manifest.pages[0].title.as_deref(), Some("Einleitung"));
|
||||||
|
|
||||||
|
// Neustart: Folgeseiten sind Platzhalter, kennen ihren Titel aber aus dem Manifest
|
||||||
|
let (mut reloaded, _) = load_notebook(&dir).unwrap();
|
||||||
|
assert!(!reloaded.pages[2].is_loaded);
|
||||||
|
assert_eq!(
|
||||||
|
reloaded.pages[2].title.as_deref(),
|
||||||
|
Some("Kapitel 2 – Integrale")
|
||||||
|
);
|
||||||
|
ensure_page_loaded(&dir, &mut reloaded, 2).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
reloaded.pages[2].title.as_deref(),
|
||||||
|
Some("Kapitel 2 – Integrale")
|
||||||
|
);
|
||||||
|
assert_eq!(reloaded.pages[1].title, None);
|
||||||
|
|
||||||
|
// Titel entfernen
|
||||||
|
assert!(reloaded.set_page_title(ids[0], " "));
|
||||||
|
assert_eq!(reloaded.pages[0].title, None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ use crate::background::{adapt_ink_color, PaperTheme};
|
|||||||
use crate::document::{
|
use crate::document::{
|
||||||
EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH,
|
EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH,
|
||||||
};
|
};
|
||||||
|
use crate::file_picker::{FilePicker, PickerKind};
|
||||||
|
use crate::ruler::{Ruler, RulerHit};
|
||||||
use crate::settings::{PressureCurve, SidebarSide};
|
use crate::settings::{PressureCurve, SidebarSide};
|
||||||
use crate::storage::NotebookSummary;
|
use crate::storage::NotebookSummary;
|
||||||
|
|
||||||
@@ -96,6 +98,45 @@ pub const PEN_WIDTH_PRESETS: [(f32, &str); 4] = [
|
|||||||
pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] =
|
pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] =
|
||||||
[(4.0, "4.0 mm"), (6.0, "6.0 mm"), (8.0, "8.0 mm")];
|
[(4.0, "4.0 mm"), (6.0, "6.0 mm"), (8.0, "8.0 mm")];
|
||||||
|
|
||||||
|
/// Abbildung zwischen Seitenkoordinaten und logischen Bildschirmpunkten (egui), vom
|
||||||
|
/// Hauptprogramm je Bild aus Ansicht und Zoom gesetzt.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct PageView {
|
||||||
|
/// Bildschirmposition des Seitenursprungs (oben links)
|
||||||
|
pub origin: egui::Pos2,
|
||||||
|
/// Bildschirmpunkte je Seiteneinheit
|
||||||
|
pub scale: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PageView {
|
||||||
|
pub fn to_screen(&self, p: glam::Vec2) -> egui::Pos2 {
|
||||||
|
self.origin + egui::vec2(p.x, p.y) * self.scale
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_page(&self, p: egui::Pos2) -> glam::Vec2 {
|
||||||
|
let d = (p - self.origin) / self.scale.max(1e-6);
|
||||||
|
glam::Vec2::new(d.x, d.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Laufende Bedienung des Lineals.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum RulerDrag {
|
||||||
|
/// Verschieben; Abstand vom Griffpunkt zum Mittelpunkt
|
||||||
|
Move {
|
||||||
|
grab_offset: glam::Vec2,
|
||||||
|
},
|
||||||
|
Rotate,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format für den Export der aktuellen Seite.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PageExportKind {
|
||||||
|
Png,
|
||||||
|
PngTransparent,
|
||||||
|
Svg,
|
||||||
|
}
|
||||||
|
|
||||||
/// Aktionen, die von der UI an die Anwendungslogik signalisiert werden.
|
/// Aktionen, die von der UI an die Anwendungslogik signalisiert werden.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum UiAction {
|
pub enum UiAction {
|
||||||
@@ -151,6 +192,17 @@ pub enum UiAction {
|
|||||||
},
|
},
|
||||||
/// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`)
|
/// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`)
|
||||||
SavePressureCurve(PressureCurve),
|
SavePressureCurve(PressureCurve),
|
||||||
|
/// Bilddatei auf der aktuellen Seite einfügen
|
||||||
|
InsertImageFile(std::path::PathBuf),
|
||||||
|
/// PDF als neues Notizbuch importieren
|
||||||
|
ImportPdf(std::path::PathBuf),
|
||||||
|
/// Aktuelle Seite als Bild oder SVG exportieren
|
||||||
|
ExportPage(PageExportKind),
|
||||||
|
/// Titel einer Seite setzen (leer = entfernen)
|
||||||
|
SetPageTitle {
|
||||||
|
page_id: u64,
|
||||||
|
title: String,
|
||||||
|
},
|
||||||
// Beenden-Sicherheit bei Fehlschlag
|
// Beenden-Sicherheit bei Fehlschlag
|
||||||
RetryShutdown,
|
RetryShutdown,
|
||||||
ExportRecoveryAndExit,
|
ExportRecoveryAndExit,
|
||||||
@@ -212,6 +264,25 @@ pub struct UiState {
|
|||||||
pub pressure_curve: PressureCurve,
|
pub pressure_curve: PressureCurve,
|
||||||
/// Zuletzt gemessener Rohdruck des Stifts (Anzeige in der Druckkurve)
|
/// Zuletzt gemessener Rohdruck des Stifts (Anzeige in der Druckkurve)
|
||||||
pub last_raw_pressure: Option<f32>,
|
pub last_raw_pressure: Option<f32>,
|
||||||
|
/// Geöffnete Dateiauswahl („Bild einfügen“ oder „PDF importieren“)
|
||||||
|
pub file_picker: Option<FilePicker>,
|
||||||
|
/// Eingeblendetes Lineal
|
||||||
|
pub ruler: Option<Ruler>,
|
||||||
|
pub ruler_drag: Option<RulerDrag>,
|
||||||
|
/// Aktuelle Abbildung Seite ↔ Bildschirm (für Lineal und Treffertests)
|
||||||
|
pub page_view: Option<PageView>,
|
||||||
|
/// Fenstergröße in logischen Punkten (vom letzten Bild)
|
||||||
|
pub screen_size: egui::Vec2,
|
||||||
|
/// Eingabefeld „Titel dieser Seite“ in der Seitenübersicht und die Seite, zu der es gehört
|
||||||
|
pub page_title_input: String,
|
||||||
|
pub page_title_for: Option<u64>,
|
||||||
|
/// Seitenübersicht als Liste mit Titeln (Inhaltsverzeichnis) statt Kacheln
|
||||||
|
pub page_picker_list: bool,
|
||||||
|
/// Aktionen, die außerhalb der Oberfläche ausgelöst wurden (z. B. ins Fenster gezogene
|
||||||
|
/// PDF-Datei); `render_ui` gibt sie beim nächsten Bild mit aus
|
||||||
|
pub queued_actions: Vec<UiAction>,
|
||||||
|
/// Zuletzt in der Dateiauswahl benutzter Ordner (während der Sitzung)
|
||||||
|
pub last_picker_dir: Option<std::path::PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UiState {
|
impl Default for UiState {
|
||||||
@@ -252,6 +323,16 @@ impl Default for UiState {
|
|||||||
adapt_ink_colors: true,
|
adapt_ink_colors: true,
|
||||||
pressure_curve: PressureCurve::default(),
|
pressure_curve: PressureCurve::default(),
|
||||||
last_raw_pressure: None,
|
last_raw_pressure: None,
|
||||||
|
file_picker: None,
|
||||||
|
queued_actions: Vec::new(),
|
||||||
|
ruler: None,
|
||||||
|
ruler_drag: None,
|
||||||
|
page_view: None,
|
||||||
|
screen_size: egui::Vec2::ZERO,
|
||||||
|
page_title_input: String::new(),
|
||||||
|
page_title_for: None,
|
||||||
|
page_picker_list: false,
|
||||||
|
last_picker_dir: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,6 +354,56 @@ impl UiState {
|
|||||||
self.show_page_jump_popup = false;
|
self.show_page_jump_popup = false;
|
||||||
self.is_document_menu_open = false;
|
self.is_document_menu_open = false;
|
||||||
self.is_update_dialog_open = false;
|
self.is_update_dialog_open = false;
|
||||||
|
self.file_picker = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blendet das Lineal ein (waagerecht in der Mitte des sichtbaren Blattbereichs) oder aus.
|
||||||
|
pub fn toggle_ruler(&mut self) {
|
||||||
|
self.ruler_drag = None;
|
||||||
|
self.ruler = match self.ruler {
|
||||||
|
Some(_) => None,
|
||||||
|
None => {
|
||||||
|
let center = self
|
||||||
|
.page_view
|
||||||
|
.map(|v| {
|
||||||
|
let screen = self.visible_canvas_center();
|
||||||
|
v.to_page(screen)
|
||||||
|
})
|
||||||
|
.unwrap_or(glam::Vec2::new(
|
||||||
|
crate::document::PAGE_WIDTH / 2.0,
|
||||||
|
crate::document::PAGE_HEIGHT / 2.0,
|
||||||
|
));
|
||||||
|
Some(Ruler::new(center))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mitte des Bereichs neben der Werkzeugleiste (logische Punkte).
|
||||||
|
fn visible_canvas_center(&self) -> egui::Pos2 {
|
||||||
|
(self.screen_size / 2.0).to_pos2()
|
||||||
|
+ egui::vec2(
|
||||||
|
match self.sidebar_side {
|
||||||
|
SidebarSide::Left => self.sidebar_reserved_width() / 2.0,
|
||||||
|
SidebarSide::Right => -self.sidebar_reserved_width() / 2.0,
|
||||||
|
},
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Öffnet die Dateiauswahl „Bild einfügen“ im zuletzt benutzten Ordner.
|
||||||
|
pub fn open_image_picker(&mut self) {
|
||||||
|
self.open_file_picker(PickerKind::Image);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ö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.
|
/// Länge der längsten Farbpalette; so viel Platz hält die Leiste immer frei.
|
||||||
@@ -387,6 +518,15 @@ impl UiState {
|
|||||||
if self.is_point_inside_toolbar(px, py) {
|
if self.is_point_inside_toolbar(px, py) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// Lineal: Fläche und Dreh-Griff bedient die Oberfläche, die Kanten bleiben zum Zeichnen
|
||||||
|
if self.ruler_drag.is_some() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if let (Some(ruler), Some(view)) = (self.ruler, self.page_view) {
|
||||||
|
if ruler.hit(view.to_page(egui::pos2(px, py))).is_some() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(pop_rect) = self.popover_rect {
|
if let Some(pop_rect) = self.popover_rect {
|
||||||
if pop_rect.contains(egui::pos2(px, py)) {
|
if pop_rect.contains(egui::pos2(px, py)) {
|
||||||
return true;
|
return true;
|
||||||
@@ -499,6 +639,8 @@ enum SidebarIcon<'a> {
|
|||||||
Eraser,
|
Eraser,
|
||||||
/// Selbst gezeichnetes gestricheltes Auswahlrechteck
|
/// Selbst gezeichnetes gestricheltes Auswahlrechteck
|
||||||
Lasso,
|
Lasso,
|
||||||
|
/// Selbst gezeichnetes Lineal mit Skala
|
||||||
|
Ruler,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Quadratischer Symbol-Knopf der Werkzeugleiste mit optionalem Farbbalken an der Unterkante.
|
/// Quadratischer Symbol-Knopf der Werkzeugleiste mit optionalem Farbbalken an der Unterkante.
|
||||||
@@ -601,6 +743,30 @@ fn sidebar_button(
|
|||||||
stroke,
|
stroke,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
SidebarIcon::Ruler => {
|
||||||
|
let dir = egui::vec2(1.0, -1.0).normalized();
|
||||||
|
let perp = egui::vec2(dir.y, -dir.x);
|
||||||
|
let (half_len, half_w) = (12.0, 4.5);
|
||||||
|
let p = |along: f32, across: f32| c + dir * along + perp * across;
|
||||||
|
painter.add(egui::Shape::convex_polygon(
|
||||||
|
vec![
|
||||||
|
p(-half_len, -half_w),
|
||||||
|
p(half_len, -half_w),
|
||||||
|
p(half_len, half_w),
|
||||||
|
p(-half_len, half_w),
|
||||||
|
],
|
||||||
|
egui::Color32::TRANSPARENT,
|
||||||
|
egui::Stroke::new(1.5_f32, color),
|
||||||
|
));
|
||||||
|
for i in 1..6 {
|
||||||
|
let along = -half_len + i as f32 * 4.0;
|
||||||
|
let tick = if i % 2 == 0 { 4.0 } else { 2.5 };
|
||||||
|
painter.line_segment(
|
||||||
|
[p(along, -half_w), p(along, -half_w + tick)],
|
||||||
|
egui::Stroke::new(1.2_f32, color),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
SidebarIcon::Lasso => {
|
SidebarIcon::Lasso => {
|
||||||
let r = egui::Rect::from_center_size(c, egui::vec2(20.0, 16.0));
|
let r = egui::Rect::from_center_size(c, egui::vec2(20.0, 16.0));
|
||||||
let pts = [
|
let pts = [
|
||||||
@@ -758,10 +924,29 @@ pub fn page_thumbnail_shapes(
|
|||||||
let scale = rect.width() / PAGE_WIDTH;
|
let scale = rect.width() / PAGE_WIDTH;
|
||||||
let to_screen = |p: glam::Vec2| rect.min + egui::vec2(p.x * scale, p.y * scale);
|
let to_screen = |p: glam::Vec2| 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 total: usize = page.strokes.iter().map(|s| s.points.len()).sum();
|
||||||
let step = total.div_ceil(THUMBNAIL_POINT_BUDGET).max(1);
|
let step = total.div_ceil(THUMBNAIL_POINT_BUDGET).max(1);
|
||||||
|
|
||||||
for stroke in &page.strokes {
|
for stroke in page.strokes.iter().filter(|s| !s.is_image()) {
|
||||||
let ink = if adapt {
|
let ink = if adapt {
|
||||||
adapt_ink_color(stroke.color)
|
adapt_ink_color(stroke.color)
|
||||||
} else {
|
} else {
|
||||||
@@ -824,6 +1009,19 @@ fn page_thumbnail(
|
|||||||
egui::Color32::from_gray(140),
|
egui::Color32::from_gray(140),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Lesezeichen-Ecke für benannte Seiten
|
||||||
|
if page.title.is_some() {
|
||||||
|
let corner = thumb.right_top();
|
||||||
|
painter.add(egui::Shape::convex_polygon(
|
||||||
|
vec![
|
||||||
|
corner + egui::vec2(-14.0, 0.0),
|
||||||
|
corner,
|
||||||
|
corner + egui::vec2(0.0, 14.0),
|
||||||
|
],
|
||||||
|
egui::Color32::from_rgb(90, 150, 255),
|
||||||
|
egui::Stroke::NONE,
|
||||||
|
));
|
||||||
|
}
|
||||||
let border = if is_current {
|
let border = if is_current {
|
||||||
egui::Stroke::new(2.5_f32, egui::Color32::from_rgb(90, 150, 255))
|
egui::Stroke::new(2.5_f32, egui::Color32::from_rgb(90, 150, 255))
|
||||||
} else if response.hovered() {
|
} else if response.hovered() {
|
||||||
@@ -844,7 +1042,43 @@ fn page_thumbnail(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
response.on_hover_text(format!("Seite {number}"))
|
match &page.title {
|
||||||
|
Some(title) => response.on_hover_text(format!("Seite {number} – {title}")),
|
||||||
|
None => response.on_hover_text(format!("Seite {number}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inhaltsverzeichnis der Seitenübersicht: alle Seiten mit Nummer und Titel, benannte
|
||||||
|
/// Seiten hervorgehoben. Gibt die angetippte Seite zurück.
|
||||||
|
fn page_title_list(ui: &mut egui::Ui, notebook: &Notebook) -> Option<usize> {
|
||||||
|
let mut chosen = None;
|
||||||
|
if notebook.pages.iter().all(|p| p.title.is_none()) {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(
|
||||||
|
"Noch keine Seite hat einen Titel. Gib oben einen Titel für die aktuelle Seite ein.",
|
||||||
|
)
|
||||||
|
.color(egui::Color32::from_gray(150)),
|
||||||
|
);
|
||||||
|
ui.add_space(4.0);
|
||||||
|
}
|
||||||
|
for (idx, page) in notebook.pages.iter().enumerate() {
|
||||||
|
let is_current = idx == notebook.current_page_index;
|
||||||
|
let text = match &page.title {
|
||||||
|
Some(title) => egui::RichText::new(format!("{:>3} {title}", idx + 1))
|
||||||
|
.strong()
|
||||||
|
.color(egui::Color32::WHITE),
|
||||||
|
None => egui::RichText::new(format!("{:>3} –", idx + 1))
|
||||||
|
.color(egui::Color32::from_gray(130)),
|
||||||
|
};
|
||||||
|
let response = ui.add_sized(
|
||||||
|
[ui.available_width(), 32.0],
|
||||||
|
egui::SelectableLabel::new(is_current, text),
|
||||||
|
);
|
||||||
|
if response.clicked() {
|
||||||
|
chosen = Some(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chosen
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Auswahlknopf für eine Papierfarbe: Farbmuster und Name.
|
/// Auswahlknopf für eine Papierfarbe: Farbmuster und Name.
|
||||||
@@ -953,6 +1187,270 @@ fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option<Press
|
|||||||
save.then_some(state.pressure_curve)
|
save.then_some(state.pressure_curve)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Zeichnet das Lineal über dem Blatt und bedient Verschieben und Drehen.
|
||||||
|
fn ruler_overlay(ctx: &egui::Context, state: &mut UiState) {
|
||||||
|
let (Some(mut ruler), Some(view)) = (state.ruler, state.page_view) else {
|
||||||
|
state.ruler_drag = None;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bedienung: Druck auf Fläche oder Griff beginnt das Ziehen (nicht über Leiste/Popovern)
|
||||||
|
let (pressed, down, pos) = ctx.input(|i| {
|
||||||
|
(
|
||||||
|
i.pointer.primary_pressed(),
|
||||||
|
i.pointer.primary_down(),
|
||||||
|
i.pointer.interact_pos(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if pressed && !ctx.is_pointer_over_area() {
|
||||||
|
if let Some(pos) = pos {
|
||||||
|
let page = view.to_page(pos);
|
||||||
|
state.ruler_drag = match ruler.hit(page) {
|
||||||
|
Some(RulerHit::Body) => Some(RulerDrag::Move {
|
||||||
|
grab_offset: ruler.center - page,
|
||||||
|
}),
|
||||||
|
Some(RulerHit::RotateHandle) => Some(RulerDrag::Rotate),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match (state.ruler_drag, down, pos) {
|
||||||
|
(Some(RulerDrag::Move { grab_offset }), true, Some(pos)) => {
|
||||||
|
ruler.center = view.to_page(pos) + grab_offset;
|
||||||
|
}
|
||||||
|
(Some(RulerDrag::Rotate), true, Some(pos)) => ruler.rotate_toward(view.to_page(pos)),
|
||||||
|
(_, false, _) => state.ruler_drag = None,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
state.ruler = Some(ruler);
|
||||||
|
|
||||||
|
// Zeichnen (unter Leiste und Dialogen)
|
||||||
|
let painter = ctx.layer_painter(egui::LayerId::new(
|
||||||
|
egui::Order::Background,
|
||||||
|
egui::Id::new("ruler_overlay"),
|
||||||
|
));
|
||||||
|
let corners: Vec<egui::Pos2> = ruler.corners().iter().map(|&c| view.to_screen(c)).collect();
|
||||||
|
painter.add(egui::Shape::convex_polygon(
|
||||||
|
corners.clone(),
|
||||||
|
egui::Color32::from_rgba_unmultiplied(214, 222, 235, 165),
|
||||||
|
egui::Stroke::new(
|
||||||
|
1.0_f32,
|
||||||
|
egui::Color32::from_rgba_unmultiplied(60, 70, 90, 200),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
let ink = egui::Color32::from_rgb(45, 52, 66);
|
||||||
|
let axis = ruler.axis();
|
||||||
|
let normal = ruler.normal();
|
||||||
|
let mm = crate::document::UNITS_PER_MM;
|
||||||
|
let px_per_mm = view.scale * mm;
|
||||||
|
let half_len_mm = (crate::ruler::RULER_LENGTH / mm / 2.0) as i32;
|
||||||
|
let half_w = crate::ruler::RULER_WIDTH / 2.0;
|
||||||
|
// Skala an beiden Längskanten, von der Mitte aus: Millimeter nur, wenn sie nicht verschwimmen
|
||||||
|
for side in [-1.0_f32, 1.0] {
|
||||||
|
let edge = ruler.center + normal * side * half_w;
|
||||||
|
for i in -half_len_mm..=half_len_mm {
|
||||||
|
let tick_mm = if i % 10 == 0 {
|
||||||
|
4.0
|
||||||
|
} else if i % 5 == 0 {
|
||||||
|
2.5
|
||||||
|
} else if px_per_mm >= 3.0 {
|
||||||
|
1.5
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let base = edge + axis * (i as f32 * mm);
|
||||||
|
let tip = base - normal * side * tick_mm * mm;
|
||||||
|
painter.line_segment(
|
||||||
|
[view.to_screen(base), view.to_screen(tip)],
|
||||||
|
egui::Stroke::new(1.0_f32, ink),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Zentimeter-Zahlen an der oberen Kante, von der linken Ecke an gezählt
|
||||||
|
if px_per_mm >= 1.2 {
|
||||||
|
let font = egui::FontId::proportional((px_per_mm * 2.6).clamp(8.0, 13.0));
|
||||||
|
let edge = ruler.center - normal * half_w;
|
||||||
|
for cm in 0..=(half_len_mm * 2 / 10) {
|
||||||
|
let along = -crate::ruler::RULER_LENGTH / 2.0 + cm as f32 * 10.0 * mm;
|
||||||
|
let anchor = view.to_screen(edge + axis * along + normal * 5.0 * mm);
|
||||||
|
let galley = ctx.fonts(|f| f.layout_no_wrap(cm.to_string(), font.clone(), ink));
|
||||||
|
let offset = egui::vec2(galley.size().x / 2.0, 0.0);
|
||||||
|
let offset = egui::vec2(
|
||||||
|
offset.x * axis.x - offset.y * axis.y,
|
||||||
|
offset.x * axis.y + offset.y * axis.x,
|
||||||
|
);
|
||||||
|
let mut text = egui::epaint::TextShape::new(anchor - offset, galley, ink);
|
||||||
|
text.angle = ruler.angle;
|
||||||
|
painter.add(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Winkelanzeige und Dreh-Griff
|
||||||
|
let center = view.to_screen(ruler.center);
|
||||||
|
let label = format!("{}°", ruler.display_degrees());
|
||||||
|
let galley = ctx.fonts(|f| {
|
||||||
|
f.layout_no_wrap(
|
||||||
|
label,
|
||||||
|
egui::FontId::proportional(13.0),
|
||||||
|
egui::Color32::WHITE,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let pill = egui::Rect::from_center_size(center, galley.size() + egui::vec2(14.0, 6.0));
|
||||||
|
painter.rect_filled(
|
||||||
|
pill,
|
||||||
|
egui::Rounding::same(9.0),
|
||||||
|
egui::Color32::from_rgba_unmultiplied(40, 70, 130, 220),
|
||||||
|
);
|
||||||
|
painter.galley(
|
||||||
|
pill.center() - galley.size() / 2.0,
|
||||||
|
galley,
|
||||||
|
egui::Color32::WHITE,
|
||||||
|
);
|
||||||
|
|
||||||
|
let handle = view.to_screen(ruler.handle_center());
|
||||||
|
let radius = (crate::ruler::HANDLE_RADIUS * view.scale).max(10.0);
|
||||||
|
let active = state.ruler_drag == Some(RulerDrag::Rotate);
|
||||||
|
painter.circle(
|
||||||
|
handle,
|
||||||
|
radius,
|
||||||
|
if active {
|
||||||
|
egui::Color32::from_rgb(70, 120, 210)
|
||||||
|
} else {
|
||||||
|
egui::Color32::from_rgba_unmultiplied(40, 70, 130, 200)
|
||||||
|
},
|
||||||
|
egui::Stroke::new(1.0_f32, egui::Color32::WHITE),
|
||||||
|
);
|
||||||
|
painter.text(
|
||||||
|
handle,
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
"⟳",
|
||||||
|
egui::FontId::proportional((radius * 1.1).clamp(10.0, 20.0)),
|
||||||
|
egui::Color32::WHITE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dialog „Bild einfügen“ bzw. „PDF importieren“: Schnellzugriffe, Ordnerpfad und Liste aus
|
||||||
|
/// Ordnern und passenden Dateien.
|
||||||
|
fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Vec<UiAction>) {
|
||||||
|
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.
|
/// Rahmen der Popover neben der Werkzeugleiste.
|
||||||
fn popover_frame() -> egui::Frame {
|
fn popover_frame() -> egui::Frame {
|
||||||
egui::Frame::none()
|
egui::Frame::none()
|
||||||
@@ -1088,7 +1586,9 @@ pub fn render_ui(
|
|||||||
autosave_status: AutosaveStatus,
|
autosave_status: AutosaveStatus,
|
||||||
updater: &crate::updater::UpdaterController,
|
updater: &crate::updater::UpdaterController,
|
||||||
) -> Vec<UiAction> {
|
) -> Vec<UiAction> {
|
||||||
let mut actions = Vec::new();
|
let mut actions = std::mem::take(&mut state.queued_actions);
|
||||||
|
state.screen_size = ctx.screen_rect().size();
|
||||||
|
ruler_overlay(ctx, state);
|
||||||
|
|
||||||
let mut pen_btn_rect = None;
|
let mut pen_btn_rect = None;
|
||||||
let mut high_btn_rect = None;
|
let mut high_btn_rect = None;
|
||||||
@@ -1248,7 +1748,7 @@ pub fn render_ui(
|
|||||||
(
|
(
|
||||||
ActiveTool::Eraser,
|
ActiveTool::Eraser,
|
||||||
SidebarIcon::Eraser,
|
SidebarIcon::Eraser,
|
||||||
"Radierer (E oder rechte Maustaste) – darunter: alles oder nur Textmarker",
|
"Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich oder nur Textmarker",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
ActiveTool::Select,
|
ActiveTool::Select,
|
||||||
@@ -1275,6 +1775,22 @@ pub fn render_ui(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Lineal (ein-/ausblenden) ---
|
||||||
|
if sidebar_button(
|
||||||
|
ui,
|
||||||
|
btn,
|
||||||
|
SidebarIcon::Ruler,
|
||||||
|
state.ruler.is_some(),
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
"Lineal (L): Fläche ziehen = verschieben, Griff ⟳ = drehen; \
|
||||||
|
Striche an einer Kante werden gerade",
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
state.toggle_ruler();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Farben des aktiven Werkzeugs ---
|
// --- Farben des aktiven Werkzeugs ---
|
||||||
let palette: Option<(&[ColorPreset], [f32; 4])> =
|
let palette: Option<(&[ColorPreset], [f32; 4])> =
|
||||||
match state.active_tool {
|
match state.active_tool {
|
||||||
@@ -1320,6 +1836,12 @@ pub fn render_ui(
|
|||||||
None,
|
None,
|
||||||
"Alle Striche radieren",
|
"Alle Striche radieren",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
EraserMode::Partial,
|
||||||
|
SidebarIcon::Glyph("✂"),
|
||||||
|
None,
|
||||||
|
"Teilstrich radieren – nur den überstrichenen Teil entfernen (Formen ganz)",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
EraserMode::HighlighterOnly,
|
EraserMode::HighlighterOnly,
|
||||||
SidebarIcon::Glyph("🖊"),
|
SidebarIcon::Glyph("🖊"),
|
||||||
@@ -1832,12 +2354,39 @@ pub fn render_ui(
|
|||||||
actions.push(UiAction::ExportPdf);
|
actions.push(UiAction::ExportPdf);
|
||||||
state.is_more_menu_open = false;
|
state.is_more_menu_open = false;
|
||||||
}
|
}
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(egui::RichText::new("Seite als").color(egui::Color32::from_gray(190)));
|
||||||
|
for (label, kind, hint) in [
|
||||||
|
("PNG", PageExportKind::Png, "Bild mit Papier und Raster (200 dpi)"),
|
||||||
|
("PNG transp.", PageExportKind::PngTransparent, "Bild mit transparentem Hintergrund, nur Inhalt"),
|
||||||
|
("SVG", PageExportKind::Svg, "Vektorgrafik, z. B. für Inkscape oder LaTeX"),
|
||||||
|
] {
|
||||||
|
if ui.button(label).on_hover_text(hint).clicked() {
|
||||||
|
actions.push(UiAction::ExportPage(kind));
|
||||||
|
state.is_more_menu_open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
if ui
|
if ui
|
||||||
.add_sized([ui.available_width(), 30.0], egui::Button::new("➕ Neue Seite anhängen"))
|
.add_sized([ui.available_width(), 30.0], egui::Button::new("➕ Neue Seite anhängen"))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
actions.push(UiAction::AddPage);
|
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");
|
section(ui, "HINTERGRUND DIESER SEITE");
|
||||||
let current_bg = notebook.current_page().background;
|
let current_bg = notebook.current_page().background;
|
||||||
@@ -2031,18 +2580,80 @@ pub fn render_ui(
|
|||||||
if dialog_header(ui, &title, true) {
|
if dialog_header(ui, &title, true) {
|
||||||
state.show_page_jump_popup = false;
|
state.show_page_jump_popup = false;
|
||||||
}
|
}
|
||||||
|
// Titel der aktuellen Seite
|
||||||
|
let current = notebook.current_page();
|
||||||
|
if state.page_title_for != Some(current.id) {
|
||||||
|
state.page_title_for = Some(current.id);
|
||||||
|
state.page_title_input = current.title.clone().unwrap_or_default();
|
||||||
|
}
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!(
|
||||||
|
"TITEL VON SEITE {}",
|
||||||
|
notebook.current_page_index + 1
|
||||||
|
))
|
||||||
|
.size(10.5)
|
||||||
|
.strong()
|
||||||
|
.color(egui::Color32::from_gray(160)),
|
||||||
|
);
|
||||||
|
let field = ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut state.page_title_input)
|
||||||
|
.hint_text("z. B. Kapitel 2 – Integrale")
|
||||||
|
.margin(egui::vec2(8.0, 8.0))
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
|
);
|
||||||
|
// Bei jeder Änderung übernehmen: schließt sich die Übersicht, geht nichts verloren
|
||||||
|
if field.changed() {
|
||||||
|
actions.push(UiAction::SetPageTitle {
|
||||||
|
page_id: current.id,
|
||||||
|
title: state.page_title_input.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ui.add_space(2.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
for (list, label) in [(false, "🖼 Kacheln"), (true, "📑 Inhalt")]
|
||||||
|
{
|
||||||
|
if ui
|
||||||
|
.add_sized(
|
||||||
|
[140.0, 30.0],
|
||||||
|
egui::SelectableLabel::new(
|
||||||
|
state.page_picker_list == list,
|
||||||
|
label,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
state.page_picker_list = list;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(2.0);
|
||||||
// Noch nicht geladene Seiten für die Vorschau nachladen lassen
|
// Noch nicht geladene Seiten für die Vorschau nachladen lassen
|
||||||
if notebook.pages.iter().any(|p| !p.is_loaded) {
|
if !state.page_picker_list
|
||||||
|
&& notebook.pages.iter().any(|p| !p.is_loaded)
|
||||||
|
{
|
||||||
actions.push(UiAction::LoadAllPages);
|
actions.push(UiAction::LoadAllPages);
|
||||||
}
|
}
|
||||||
|
if state.page_picker_list {
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.max_height(420.0)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(ui.available_width());
|
||||||
|
if let Some(idx) = page_title_list(ui, notebook) {
|
||||||
|
actions.push(UiAction::GoToPage(idx));
|
||||||
|
state.show_page_jump_popup = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.max_height(420.0)
|
.max_height(420.0)
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
egui::Grid::new("page_picker_grid")
|
egui::Grid::new("page_picker_grid")
|
||||||
.spacing([8.0, 8.0])
|
.spacing([8.0, 8.0])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
for (idx, page) in notebook.pages.iter().enumerate() {
|
for (idx, page) in notebook.pages.iter().enumerate()
|
||||||
let is_current = idx == notebook.current_page_index;
|
{
|
||||||
|
let is_current =
|
||||||
|
idx == notebook.current_page_index;
|
||||||
if page_thumbnail(
|
if page_thumbnail(
|
||||||
ui,
|
ui,
|
||||||
state,
|
state,
|
||||||
@@ -2061,6 +2672,7 @@ pub fn render_ui(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
if ui
|
if ui
|
||||||
.add_sized(
|
.add_sized(
|
||||||
@@ -2298,6 +2910,11 @@ pub fn render_ui(
|
|||||||
state.document_modal_rect = None;
|
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)
|
// 3c. Release- & Update-Center (Gitea Releases)
|
||||||
if state.is_update_dialog_open {
|
if state.is_update_dialog_open {
|
||||||
let win = dialog_window("Release- & Update-Center", state, false)
|
let win = dialog_window("Release- & Update-Center", state, false)
|
||||||
@@ -3139,10 +3756,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Quelltexte, deren String-Literale in der Oberfläche erscheinen können.
|
/// 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")),
|
("ui.rs", include_str!("ui.rs")),
|
||||||
("selection.rs", include_str!("selection.rs")),
|
("selection.rs", include_str!("selection.rs")),
|
||||||
("main.rs", include_str!("main.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).
|
/// Liefert alle Zeichen aus String-Literalen eines Quelltexts (ohne Zeilenkommentare).
|
||||||
@@ -3170,6 +3788,74 @@ mod tests {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ruler_drag_moves_and_rotates() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let notebook = Notebook::new();
|
||||||
|
let updater = crate::updater::UpdaterController::new();
|
||||||
|
let mut state = UiState::new();
|
||||||
|
// 1 Seiteneinheit = 0,5 Punkte, Seite beginnt bei (100, 0)
|
||||||
|
let view = PageView {
|
||||||
|
origin: egui::pos2(100.0, 0.0),
|
||||||
|
scale: 0.5,
|
||||||
|
};
|
||||||
|
state.page_view = Some(view);
|
||||||
|
state.ruler = Some(Ruler::new(glam::Vec2::new(1000.0, 1000.0)));
|
||||||
|
|
||||||
|
let frame = |state: &mut UiState, events: Vec<egui::Event>| {
|
||||||
|
let input = egui::RawInput {
|
||||||
|
screen_rect: Some(egui::Rect::from_min_size(
|
||||||
|
egui::Pos2::ZERO,
|
||||||
|
egui::vec2(1600.0, 1000.0),
|
||||||
|
)),
|
||||||
|
events,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
ctx.begin_frame(input);
|
||||||
|
let _ = render_ui(&ctx, state, ¬ebook, AutosaveStatus::Saved, &updater);
|
||||||
|
let _ = ctx.end_frame();
|
||||||
|
};
|
||||||
|
let press = |pos: egui::Pos2, pressed: bool| egui::Event::PointerButton {
|
||||||
|
pos,
|
||||||
|
button: egui::PointerButton::Primary,
|
||||||
|
pressed,
|
||||||
|
modifiers: egui::Modifiers::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fläche und Griff gehören zur Oberfläche, die Kanten zum Zeichnen
|
||||||
|
let center = view.to_screen(glam::Vec2::new(1000.0, 1000.0));
|
||||||
|
assert!(state.is_point_in_ui(center.x, center.y));
|
||||||
|
let edge = view.to_screen(glam::Vec2::new(
|
||||||
|
1000.0,
|
||||||
|
1000.0 - crate::ruler::RULER_WIDTH / 2.0,
|
||||||
|
));
|
||||||
|
assert!(!state.is_point_in_ui(edge.x, edge.y));
|
||||||
|
|
||||||
|
// Verschieben: Fläche greifen und um 50 Punkte (= 100 Einheiten) nach unten ziehen
|
||||||
|
frame(&mut state, vec![egui::Event::PointerMoved(center)]);
|
||||||
|
frame(&mut state, vec![press(center, true)]);
|
||||||
|
let target = center + egui::vec2(0.0, 50.0);
|
||||||
|
frame(&mut state, vec![egui::Event::PointerMoved(target)]);
|
||||||
|
assert!(state.ruler_drag.is_some());
|
||||||
|
frame(&mut state, vec![press(target, false)]);
|
||||||
|
let moved = state.ruler.unwrap();
|
||||||
|
assert!((moved.center - glam::Vec2::new(1000.0, 1100.0)).length() < 0.5);
|
||||||
|
assert!(state.ruler_drag.is_none());
|
||||||
|
|
||||||
|
// Drehen: Griff greifen und nach unten rechts ziehen → 45° (eingerastet)
|
||||||
|
let handle = view.to_screen(moved.handle_center());
|
||||||
|
frame(&mut state, vec![egui::Event::PointerMoved(handle)]);
|
||||||
|
frame(&mut state, vec![press(handle, true)]);
|
||||||
|
let diag = view.to_screen(moved.center + glam::Vec2::new(700.0, 690.0));
|
||||||
|
frame(&mut state, vec![egui::Event::PointerMoved(diag)]);
|
||||||
|
frame(&mut state, vec![press(diag, false)]);
|
||||||
|
assert_eq!(state.ruler.unwrap().display_degrees(), 45);
|
||||||
|
|
||||||
|
// Ausblenden
|
||||||
|
state.toggle_ruler();
|
||||||
|
assert!(state.ruler.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_stroke_tool_follows_active_tool() {
|
fn test_stroke_tool_follows_active_tool() {
|
||||||
assert_eq!(ActiveTool::Pen.stroke_tool(false), StrokeTool::Pen);
|
assert_eq!(ActiveTool::Pen.stroke_tool(false), StrokeTool::Pen);
|
||||||
@@ -3421,6 +4107,28 @@ mod tests {
|
|||||||
pts.len(),
|
pts.len(),
|
||||||
THUMBNAIL_POINT_BUDGET + 200
|
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]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user