feat: Teilstrich-Radierer, Seiten-Export (PNG/SVG), Auswahl als Bild, Seitentitel
- Radierer-Modus „✂ Teilstrich“: entfernt nur den überstrichenen Teil, der Rest bleibt in Stücken; ein Radierzug ist ein Undo-Schritt, Formen werden ganz radiert - ☰ → „Seite als PNG / PNG transparent / SVG“ nach ~/Documents (über den PDF-Export gerendert: Bilder und importierte PDF-Seiten inklusive) - Strg+C legt die Auswahl zusätzlich als Bild in die Systemzwischenablage (für Chat, Mail, Office); in ink-notes fügt Strg+V weiter die Objekte ein - Seitentitel in der Seitenübersicht, Ansicht „Inhalt“ als Inhaltsverzeichnis, Lesezeichen-Ecke an benannten Seiten, Titel im Fenstertitel und als PDF-Lesezeichen (Umlaute als UTF-16) - Dateiformat-Version 6 (Seitentitel) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+22
@@ -1179,6 +1179,21 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
|
checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hayro-svg"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7377affc9513cf5b66c4a8b5fa1e6058ae652eef3d13fdc4709004a03968488"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"hayro-interpret",
|
||||||
|
"image",
|
||||||
|
"kurbo",
|
||||||
|
"rustc-hash 2.1.3",
|
||||||
|
"siphasher",
|
||||||
|
"xmlwriter",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hayro-syntax"
|
name = "hayro-syntax"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -1388,6 +1403,7 @@ dependencies = [
|
|||||||
"fs2",
|
"fs2",
|
||||||
"glam",
|
"glam",
|
||||||
"hayro",
|
"hayro",
|
||||||
|
"hayro-svg",
|
||||||
"image",
|
"image",
|
||||||
"minisign-verify",
|
"minisign-verify",
|
||||||
"naga",
|
"naga",
|
||||||
@@ -3827,6 +3843,12 @@ version = "0.8.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xmlwriter"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ 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 = "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
|
||||||
|
|||||||
+365
-1
@@ -122,6 +122,22 @@ pub fn point_in_convex_quad(p: glam::Vec2, quad: &[glam::Vec2; 4]) -> bool {
|
|||||||
true
|
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).
|
/// Verweis auf eine Seite eines importierten PDFs (Datei im Asset-Ordner).
|
||||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct PdfBackground {
|
pub struct PdfBackground {
|
||||||
@@ -221,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 {
|
||||||
@@ -532,6 +654,8 @@ pub struct Page {
|
|||||||
pub background: PageBackground,
|
pub background: PageBackground,
|
||||||
/// Importierte PDF-Seite als Hintergrund (unter Bildern und Tinte)
|
/// Importierte PDF-Seite als Hintergrund (unter Bildern und Tinte)
|
||||||
pub pdf_background: Option<PdfBackground>,
|
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,
|
||||||
@@ -547,6 +671,7 @@ impl Page {
|
|||||||
strokes: Vec::new(),
|
strokes: Vec::new(),
|
||||||
background,
|
background,
|
||||||
pdf_background: None,
|
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,
|
||||||
@@ -563,6 +688,7 @@ impl Page {
|
|||||||
strokes: Vec::new(),
|
strokes: Vec::new(),
|
||||||
background,
|
background,
|
||||||
pdf_background: None,
|
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,
|
||||||
@@ -683,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 {
|
||||||
@@ -883,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;
|
||||||
@@ -929,6 +1134,20 @@ impl Notebook {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Setzt den Titel der Seite `page_id` (leer = kein Titel). `true`, wenn er sich geändert
|
||||||
|
/// hat.
|
||||||
|
pub fn set_page_title(&mut self, page_id: u64, title: &str) -> bool {
|
||||||
|
let title = normalize_page_title(title);
|
||||||
|
let Some(page) = self.pages.iter_mut().find(|p| p.id == page_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if page.title == title || !page.is_loaded || page.is_readonly() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
page.title = title;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Text für das On-Screen-Display (OSD), z. B. "Seite 3 / 12".
|
/// 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!(
|
||||||
@@ -1385,6 +1604,151 @@ mod tests {
|
|||||||
assert!((rotated[0] - glam::Vec2::new(250.0, 50.0)).length() < 1e-3);
|
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();
|
||||||
|
|||||||
+109
-2
@@ -79,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 {
|
||||||
@@ -132,7 +146,7 @@ pub fn export_notebook_to_bytes(
|
|||||||
assets: Option<&AssetStore>,
|
assets: Option<&AssetStore>,
|
||||||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||||
let doc = generate_pdf_document(notebook, strategy, assets)?;
|
let doc = generate_pdf_document(notebook, strategy, assets)?;
|
||||||
let bytes = doc.save_to_bytes()?;
|
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
|
||||||
|
|
||||||
// Importierte PDF-Seiten als Vektorgrafik unterlegen
|
// Importierte PDF-Seiten als Vektorgrafik unterlegen
|
||||||
let underlays = pdf_underlays(notebook);
|
let underlays = pdf_underlays(notebook);
|
||||||
@@ -158,7 +172,7 @@ pub fn export_notebook_to_bytes(
|
|||||||
raster.insert(underlay.target_page);
|
raster.insert(underlay.target_page);
|
||||||
}
|
}
|
||||||
let doc = generate_pdf_document_with(notebook, strategy, assets, &raster)?;
|
let doc = generate_pdf_document_with(notebook, strategy, assets, &raster)?;
|
||||||
let bytes = doc.save_to_bytes()?;
|
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
|
||||||
let remaining: Vec<_> = underlays
|
let remaining: Vec<_> = underlays
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|u| !raster.contains(&u.target_page))
|
.filter(|u| !raster.contains(&u.target_page))
|
||||||
@@ -170,6 +184,49 @@ pub fn export_notebook_to_bytes(
|
|||||||
Ok(merged)
|
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.
|
/// Unterlagen für alle Seiten mit importierter PDF-Seite.
|
||||||
fn pdf_underlays(notebook: &Notebook) -> Vec<crate::pdf_merge::Underlay> {
|
fn pdf_underlays(notebook: &Notebook) -> Vec<crate::pdf_merge::Underlay> {
|
||||||
notebook
|
notebook
|
||||||
@@ -235,6 +292,11 @@ fn generate_pdf_document_with(
|
|||||||
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);
|
||||||
@@ -252,6 +314,9 @@ fn generate_pdf_document_with(
|
|||||||
// 2. Alle nachfolgenden Seiten hinzufügen
|
// 2. Alle nachfolgenden Seiten hinzufügen
|
||||||
for (i, page) in notebook.pages.iter().enumerate().skip(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);
|
||||||
@@ -1398,6 +1463,48 @@ mod tests {
|
|||||||
assert!(export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, None).is_ok());
|
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]
|
#[test]
|
||||||
fn test_jpeg_component_count() {
|
fn test_jpeg_component_count() {
|
||||||
let color = crate::assets::tests::sample_image_bytes(8, 8, image::ImageFormat::Jpeg);
|
let color = crate::assets::tests::sample_image_bytes(8, 8, image::ImageFormat::Jpeg);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod filter;
|
|||||||
pub mod geometry;
|
pub mod geometry;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod image_layer;
|
pub mod image_layer;
|
||||||
|
pub mod page_export;
|
||||||
pub mod pdf_doc;
|
pub mod pdf_doc;
|
||||||
pub mod pdf_merge;
|
pub mod pdf_merge;
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
|
|||||||
+126
-33
@@ -62,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
|
||||||
@@ -203,6 +209,44 @@ impl ClipboardBridge {
|
|||||||
self.image_hash_at_copy = self.system_image().map(|(_, hash)| hash);
|
self.image_hash_at_copy = self.system_image().map(|(_, hash)| hash);
|
||||||
self.source_dir = Some(storage_dir.to_path_buf());
|
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,
|
/// Fügt ein importiertes Bild um `center` (Seitenkoordinaten) auf der aktuellen Seite ein,
|
||||||
@@ -265,6 +309,51 @@ fn texture_for(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn copy_message(count: usize, as_image: bool) -> String {
|
||||||
|
if as_image {
|
||||||
|
format!("{count} Objekt(e) kopiert – auch als Bild für andere Programme")
|
||||||
|
} else {
|
||||||
|
format!("{count} Objekt(e) kopiert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exportiert die aktuelle Seite im Hintergrund als PNG oder SVG nach ~/Documents.
|
||||||
|
fn start_page_export(
|
||||||
|
kind: ui::PageExportKind,
|
||||||
|
notebook: &Notebook,
|
||||||
|
storage_dir: &Path,
|
||||||
|
title: &str,
|
||||||
|
result_tx: Sender<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).
|
/// Seitenmitte des sichtbaren Bereichs (Ziel für eingefügte Bilder).
|
||||||
fn visible_page_center(canvas: &CanvasState) -> Vec2 {
|
fn visible_page_center(canvas: &CanvasState) -> Vec2 {
|
||||||
canvas.screen_to_page_clamped(
|
canvas.screen_to_page_clamped(
|
||||||
@@ -650,8 +739,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();
|
||||||
}
|
}
|
||||||
@@ -762,13 +851,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,
|
||||||
@@ -829,13 +915,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,
|
||||||
@@ -1146,8 +1229,9 @@ 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 {
|
||||||
|
let as_image = clipboard.copy_selection(notebook.current_page(), &selection_state.selected_ids, &storage_dir);
|
||||||
clipboard.note_internal_copy(&storage_dir);
|
clipboard.note_internal_copy(&storage_dir);
|
||||||
ui_state.set_export_message(format!("{count} Objekt(e) kopiert"));
|
ui_state.set_export_message(copy_message(count, as_image));
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1466,10 +1550,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();
|
||||||
@@ -1734,10 +1816,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();
|
||||||
@@ -1873,10 +1953,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();
|
||||||
@@ -2014,10 +2092,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();
|
||||||
@@ -2247,8 +2323,9 @@ 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 {
|
||||||
|
let as_image = clipboard.copy_selection(notebook.current_page(), &selection_state.selected_ids, &storage_dir);
|
||||||
clipboard.note_internal_copy(&storage_dir);
|
clipboard.note_internal_copy(&storage_dir);
|
||||||
ui_state.set_export_message(format!("{count} Objekt(e) kopiert"));
|
ui_state.set_export_message(copy_message(count, as_image));
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2342,6 +2419,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);
|
||||||
@@ -2350,6 +2428,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;
|
||||||
@@ -2362,6 +2454,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());
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
-3
@@ -152,7 +152,8 @@ impl StrokeData {
|
|||||||
/// das unbekannte Feld ignorieren, Bilder als Rechteckstrich anzeigen und beim Speichern
|
/// das unbekannte Feld ignorieren, Bilder als Rechteckstrich anzeigen und beim Speichern
|
||||||
/// verlieren.
|
/// verlieren.
|
||||||
/// - Version 5: importierte PDF-Seiten als Seitenhintergrund (`pdf_background`).
|
/// - Version 5: importierte PDF-Seiten als Seitenhintergrund (`pdf_background`).
|
||||||
pub const CURRENT_FORMAT_VERSION: u32 = 5;
|
/// - Version 6: Seitentitel (`title` in Seite und Manifest).
|
||||||
|
pub const CURRENT_FORMAT_VERSION: u32 = 6;
|
||||||
|
|
||||||
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
|
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
|
||||||
pub fn legacy_format_version() -> u32 {
|
pub fn legacy_format_version() -> u32 {
|
||||||
@@ -169,6 +170,9 @@ pub struct PageData {
|
|||||||
/// Importierte PDF-Seite als Hintergrund
|
/// Importierte PDF-Seite als Hintergrund
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub pdf_background: Option<crate::document::PdfBackground>,
|
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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +183,7 @@ impl From<&Page> for PageData {
|
|||||||
id: page.id,
|
id: page.id,
|
||||||
background: page.background,
|
background: page.background,
|
||||||
pdf_background: page.pdf_background.clone(),
|
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,6 +194,7 @@ impl PageData {
|
|||||||
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.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());
|
||||||
}
|
}
|
||||||
@@ -205,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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,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()
|
||||||
@@ -274,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();
|
||||||
@@ -721,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,
|
||||||
@@ -758,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)) => {
|
||||||
@@ -1302,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(())
|
||||||
@@ -2449,4 +2482,37 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(std::fs::read_dir(temp_dir.path()).unwrap().count(), before);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,14 @@ 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")];
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
@@ -156,6 +164,13 @@ pub enum UiAction {
|
|||||||
InsertImageFile(std::path::PathBuf),
|
InsertImageFile(std::path::PathBuf),
|
||||||
/// PDF als neues Notizbuch importieren
|
/// PDF als neues Notizbuch importieren
|
||||||
ImportPdf(std::path::PathBuf),
|
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,
|
||||||
@@ -219,6 +234,11 @@ pub struct UiState {
|
|||||||
pub last_raw_pressure: Option<f32>,
|
pub last_raw_pressure: Option<f32>,
|
||||||
/// Geöffnete Dateiauswahl („Bild einfügen“ oder „PDF importieren“)
|
/// Geöffnete Dateiauswahl („Bild einfügen“ oder „PDF importieren“)
|
||||||
pub file_picker: Option<FilePicker>,
|
pub file_picker: Option<FilePicker>,
|
||||||
|
/// 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
|
/// 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
|
/// PDF-Datei); `render_ui` gibt sie beim nächsten Bild mit aus
|
||||||
pub queued_actions: Vec<UiAction>,
|
pub queued_actions: Vec<UiAction>,
|
||||||
@@ -266,6 +286,9 @@ impl Default for UiState {
|
|||||||
last_raw_pressure: None,
|
last_raw_pressure: None,
|
||||||
file_picker: None,
|
file_picker: None,
|
||||||
queued_actions: Vec::new(),
|
queued_actions: Vec::new(),
|
||||||
|
page_title_input: String::new(),
|
||||||
|
page_title_for: None,
|
||||||
|
page_picker_list: false,
|
||||||
last_picker_dir: None,
|
last_picker_dir: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -875,6 +898,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() {
|
||||||
@@ -895,7 +931,43 @@ fn page_thumbnail(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
response.on_hover_text(format!("Seite {number}"))
|
match &page.title {
|
||||||
|
Some(title) => response.on_hover_text(format!("Seite {number} – {title}")),
|
||||||
|
None => response.on_hover_text(format!("Seite {number}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inhaltsverzeichnis der Seitenübersicht: alle Seiten mit Nummer und Titel, benannte
|
||||||
|
/// Seiten hervorgehoben. Gibt die angetippte Seite zurück.
|
||||||
|
fn page_title_list(ui: &mut egui::Ui, notebook: &Notebook) -> Option<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.
|
||||||
@@ -1420,7 +1492,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,
|
||||||
@@ -1492,6 +1564,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("🖊"),
|
||||||
@@ -2004,6 +2082,19 @@ 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()
|
||||||
@@ -2217,36 +2308,99 @@ 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);
|
||||||
}
|
}
|
||||||
egui::ScrollArea::vertical()
|
if state.page_picker_list {
|
||||||
.max_height(420.0)
|
egui::ScrollArea::vertical()
|
||||||
.show(ui, |ui| {
|
.max_height(420.0)
|
||||||
egui::Grid::new("page_picker_grid")
|
.show(ui, |ui| {
|
||||||
.spacing([8.0, 8.0])
|
ui.set_width(ui.available_width());
|
||||||
.show(ui, |ui| {
|
if let Some(idx) = page_title_list(ui, notebook) {
|
||||||
for (idx, page) in notebook.pages.iter().enumerate() {
|
actions.push(UiAction::GoToPage(idx));
|
||||||
let is_current = idx == notebook.current_page_index;
|
state.show_page_jump_popup = false;
|
||||||
if page_thumbnail(
|
}
|
||||||
ui,
|
});
|
||||||
state,
|
} else {
|
||||||
page,
|
egui::ScrollArea::vertical()
|
||||||
idx + 1,
|
.max_height(420.0)
|
||||||
is_current,
|
.show(ui, |ui| {
|
||||||
)
|
egui::Grid::new("page_picker_grid")
|
||||||
.clicked()
|
.spacing([8.0, 8.0])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
for (idx, page) in notebook.pages.iter().enumerate()
|
||||||
{
|
{
|
||||||
actions.push(UiAction::GoToPage(idx));
|
let is_current =
|
||||||
state.show_page_jump_popup = false;
|
idx == notebook.current_page_index;
|
||||||
|
if page_thumbnail(
|
||||||
|
ui,
|
||||||
|
state,
|
||||||
|
page,
|
||||||
|
idx + 1,
|
||||||
|
is_current,
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(UiAction::GoToPage(idx));
|
||||||
|
state.show_page_jump_popup = false;
|
||||||
|
}
|
||||||
|
if (idx + 1) % PAGE_PICKER_COLUMNS == 0 {
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (idx + 1) % PAGE_PICKER_COLUMNS == 0 {
|
});
|
||||||
ui.end_row();
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
if ui
|
if ui
|
||||||
.add_sized(
|
.add_sized(
|
||||||
|
|||||||
Reference in New Issue
Block a user