feat: Seitenformat folgt dem PDF (Querformat für Folien)

Seiten haben jetzt eine eigene Größe. Beim PDF-Import bleiben Hochformat-
Seiten DIN A4; Querformat-Seiten wie PowerPoint-Folien bekommen eine
Querformat-Seite (297 mm breit, Höhe nach Seitenverhältnis) ohne leeren
Streifen darunter (Issue #4). Eingefügte und angehängte Seiten übernehmen
das Format der aktuellen Seite.

Anzeige: Maßstab aus dem Rahmen aller Seiten, die durchgehende Ansicht
stapelt verschieden große Seiten zentriert untereinander. Export (PDF,
PNG, SVG) in der jeweiligen Seitengröße. Dateiformat-Version 8 (Größe in
Seite und Manifest; A4-Seiten speichern keine Größe).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 08:20:44 +02:00
co-authored by Claude Opus 5.5
parent 9fde59798f
commit 6caf3f7476
11 changed files with 722 additions and 323 deletions
+1
View File
@@ -54,6 +54,7 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
### 📄 Seiten & Hintergründe
- **Virtuelles DIN-A4-Raster:** 2100 × 2970 Koordinateneinheiten (10 Einheiten = 1 mm).
- **Seitenformat aus dem PDF:** Beim PDF-Import bleiben Hochformat-Seiten DIN A4; Querformat-Seiten wie PowerPoint-Folien bekommen eine Querformat-Seite (297 mm breit, Höhe nach Seitenverhältnis) ohne leeren Streifen darunter. Eingefügte Seiten übernehmen das Format der aktuellen Seite.
- **Hintergrundmuster:** Blanko, Kariert (5 mm), Liniert (7.5 mm), Punktraster (5 mm).
- **Seitenverwaltung:** Vor- und Zurückblättern, neue Seiten anhängen oder direkt nach der aktuellen Seite einfügen (☰-Menü oder Seitenauswahl, z. B. zwischen zwei PDF-Folien), Seitensprung-Menü.
+39 -25
View File
@@ -3,7 +3,7 @@
use serde::{Deserialize, Serialize};
use crate::canvas::InkVertex;
use crate::document::{PageBackground, PAGE_HEIGHT, PAGE_WIDTH};
use crate::document::PageBackground;
/// Linienstärke für Gitter- und Schreiblinien in Seiteneinheiten (1.5 = 0.15 mm).
pub const LINE_THICKNESS: f32 = 1.5;
@@ -99,15 +99,16 @@ pub fn adapt_ink_color(color: [f32; 4]) -> [f32; 4] {
[color[0] + lift, color[1] + lift, color[2] + lift, color[3]]
}
/// Erzeugt die 6 Vertizes (2 Dreiecke) für das DIN-A4-Papierblatt.
pub fn generate_paper_quad(color: [f32; 4]) -> Vec<InkVertex> {
/// Erzeugt die 6 Vertizes (2 Dreiecke) für ein Blatt der Größe `size`.
pub fn generate_paper_quad(color: [f32; 4], size: glam::Vec2) -> Vec<InkVertex> {
let (page_width, page_height) = (size.x, size.y);
vec![
InkVertex::new(0.0, 0.0, color),
InkVertex::new(PAGE_WIDTH, 0.0, color),
InkVertex::new(0.0, PAGE_HEIGHT, color),
InkVertex::new(0.0, PAGE_HEIGHT, color),
InkVertex::new(PAGE_WIDTH, 0.0, color),
InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, color),
InkVertex::new(page_width, 0.0, color),
InkVertex::new(0.0, page_height, color),
InkVertex::new(0.0, page_height, color),
InkVertex::new(page_width, 0.0, color),
InkVertex::new(page_width, page_height, color),
]
}
@@ -129,8 +130,14 @@ fn push_quad(
vertices.push(InkVertex::new(x_max, y_max, color));
}
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color`.
pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) -> Vec<InkVertex> {
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color` auf
/// einem Blatt der Größe `size`.
pub fn generate_background_mesh(
background: PageBackground,
color: [f32; 4],
size: glam::Vec2,
) -> Vec<InkVertex> {
let (page_width, page_height) = (size.x, size.y);
match background {
PageBackground::Blank => Vec::new(),
PageBackground::Grid5mm => {
@@ -139,27 +146,27 @@ pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) ->
// Vertikale Linien alle 5 mm
let mut x = GRID_5MM_SPACING;
while x < PAGE_WIDTH {
while x < page_width {
push_quad(
&mut vertices,
color,
x - half_t,
0.0,
x + half_t,
PAGE_HEIGHT,
page_height,
);
x += GRID_5MM_SPACING;
}
// Horizontale Linien alle 5 mm
let mut y = GRID_5MM_SPACING;
while y < PAGE_HEIGHT {
while y < page_height {
push_quad(
&mut vertices,
color,
0.0,
y - half_t,
PAGE_WIDTH,
page_width,
y + half_t,
);
y += GRID_5MM_SPACING;
@@ -176,13 +183,13 @@ pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) ->
let margin_bottom = 150.0;
let mut y = margin_top;
while y <= PAGE_HEIGHT - margin_bottom {
while y <= page_height - margin_bottom {
push_quad(
&mut vertices,
color,
0.0,
y - half_t,
PAGE_WIDTH,
page_width,
y + half_t,
);
y += LINED_SPACING;
@@ -195,9 +202,9 @@ pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) ->
let half_d = DOT_SIZE / 2.0;
let mut y = GRID_5MM_SPACING;
while y < PAGE_HEIGHT {
while y < page_height {
let mut x = GRID_5MM_SPACING;
while x < PAGE_WIDTH {
while x < page_width {
push_quad(
&mut vertices,
color,
@@ -220,22 +227,29 @@ pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) ->
mod tests {
use super::*;
use crate::document::{A4_SIZE, PAGE_HEIGHT, PAGE_WIDTH};
#[test]
fn test_paper_quad_generation() {
let quad = generate_paper_quad(PaperTheme::White.paper_color());
let quad = generate_paper_quad(PaperTheme::White.paper_color(), A4_SIZE);
assert_eq!(quad.len(), 6);
assert_eq!(quad[0].position, [0.0, 0.0]);
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
let slide = generate_paper_quad(
PaperTheme::White.paper_color(),
glam::Vec2::new(2970.0, 1671.0),
);
assert_eq!(slide[5].position, [2970.0, 1671.0]);
}
const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
#[test]
fn test_background_meshes() {
let blank = generate_background_mesh(PageBackground::Blank, PATTERN);
let blank = generate_background_mesh(PageBackground::Blank, PATTERN, A4_SIZE);
assert!(blank.is_empty());
let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN);
let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN, A4_SIZE);
assert!(!grid.is_empty());
assert_eq!(
grid.len() % 6,
@@ -243,11 +257,11 @@ mod tests {
"Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)"
);
let lined = generate_background_mesh(PageBackground::Lined, PATTERN);
let lined = generate_background_mesh(PageBackground::Lined, PATTERN, A4_SIZE);
assert!(!lined.is_empty());
assert_eq!(lined.len() % 6, 0);
let dots = generate_background_mesh(PageBackground::DotGrid, PATTERN);
let dots = generate_background_mesh(PageBackground::DotGrid, PATTERN, A4_SIZE);
assert!(!dots.is_empty());
assert_eq!(dots.len() % 6, 0);
}
@@ -255,9 +269,9 @@ mod tests {
#[test]
fn test_background_mesh_uses_theme_color() {
let dark = PaperTheme::Black.pattern_color();
let mesh = generate_background_mesh(PageBackground::Lined, dark);
let mesh = generate_background_mesh(PageBackground::Lined, dark, A4_SIZE);
assert!(mesh.iter().all(|v| v.color == dark));
let quad = generate_paper_quad(PaperTheme::Cream.paper_color());
let quad = generate_paper_quad(PaperTheme::Cream.paper_color(), A4_SIZE);
assert!(quad
.iter()
.all(|v| v.color == PaperTheme::Cream.paper_color()));
+86 -50
View File
@@ -16,6 +16,7 @@ use crate::background::{generate_background_mesh, generate_paper_quad, PaperThem
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
use crate::filter::OneEuroFilter;
use crate::spline::{self, ControlPoint, SplineConfig};
use crate::viewport::PageStack;
/// 2D-Vertex für Inking-Meshes mit Positions- und Farbattributen (Multi-Color).
#[repr(C)]
@@ -119,6 +120,7 @@ pub fn choose_present_mode(available: &[wgpu::PresentMode]) -> Option<wgpu::Pres
struct NeighborFingerprint {
page_id: u64,
background: PageBackground,
size: [u32; 2],
paper_theme: PaperTheme,
vertex_count: usize,
stroke_count: usize,
@@ -130,6 +132,7 @@ impl NeighborFingerprint {
Self {
page_id: page.id,
background: page.background,
size: page.size.to_array().map(f32::to_bits),
paper_theme,
vertex_count: page.vertex_cache.len(),
stroke_count: page.strokes.len(),
@@ -141,8 +144,8 @@ impl NeighborFingerprint {
/// GPU-Daten einer in der durchgehenden Ansicht sichtbaren Nachbarseite.
struct NeighborPage {
fingerprint: NeighborFingerprint,
/// Lage relativ zur aktuellen Seite (in Seiten, negativ = darüber)
offset: i64,
/// Ecke oben links in Koordinaten der aktuellen Seite
offset: glam::Vec2,
camera_buffer: wgpu::Buffer,
camera_bind_group: wgpu::BindGroup,
background_buffer: Option<wgpu::Buffer>,
@@ -187,6 +190,9 @@ pub struct CanvasState {
pub paper_theme: PaperTheme,
/// Dunkle Tinte auf dunklem Papier aufhellen
pub adapt_ink: bool,
/// Größe der aktuellen Seite und Rahmen, in den alle Seiten des Notizbuchs passen
page_size: glam::Vec2,
fit_box: glam::Vec2,
/// Textfelder der sichtbaren Seiten nach Texturschlüssel (für die Rasterung)
text_specs: HashMap<String, TextSpec>,
/// Gerade bearbeitetes Textfeld der aktuellen Seite: nicht zeichnen (die Bearbeitung
@@ -431,7 +437,7 @@ impl CanvasState {
// 6. Statische Buffer
let paper_theme = PaperTheme::default();
let paper_quad = generate_paper_quad(paper_theme.paper_color());
let paper_quad = generate_paper_quad(paper_theme.paper_color(), crate::document::A4_SIZE);
let paper_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Paper Quad Buffer"),
size: (paper_quad.len() * std::mem::size_of::<InkVertex>()) as u64,
@@ -441,7 +447,11 @@ impl CanvasState {
queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad));
let current_background = PageBackground::Grid5mm;
let bg_mesh = generate_background_mesh(current_background, paper_theme.pattern_color());
let bg_mesh = generate_background_mesh(
current_background,
paper_theme.pattern_color(),
crate::document::A4_SIZE,
);
let background_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Background Mesh Buffer"),
size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::<InkVertex>()) as u64,
@@ -499,6 +509,8 @@ impl CanvasState {
current_background,
paper_theme,
adapt_ink: false,
page_size: crate::document::A4_SIZE,
fit_box: crate::document::A4_SIZE,
text_specs: HashMap::new(),
hidden_text: None,
text_resolution: 1.0,
@@ -530,7 +542,7 @@ impl CanvasState {
/// Aktualisiert die 2D-Kamera Uniforms (Scale & Offset im NDC-Raum) basierend auf Viewport und Kamera.
pub fn update_uniforms(&mut self) {
let camera_uniforms = self.camera_uniforms(0);
let camera_uniforms = self.camera_uniforms(glam::Vec2::ZERO);
self.queue.write_buffer(
&self.camera_buffer,
0,
@@ -545,10 +557,11 @@ impl CanvasState {
}
}
/// Kamera für die Seite `page_offset` Seiten unter (negativ: über) der aktuellen.
fn camera_uniforms(&self, page_offset: i64) -> CameraUniforms {
/// Kamera für eine Seite mit der Ecke `origin` (Koordinaten der aktuellen Seite).
fn camera_uniforms(&self, origin: glam::Vec2) -> CameraUniforms {
let (scale, mut offset) = self.camera.ndc_transform(&self.viewport);
offset[1] += page_offset as f32 * crate::viewport::PAGE_PITCH * scale[1];
offset[0] += origin.x * scale[0];
offset[1] += origin.y * scale[1];
CameraUniforms {
transform_scale: scale,
transform_offset: offset,
@@ -577,13 +590,22 @@ impl CanvasState {
true
}
/// Rahmen, in den alle Seiten des Notizbuchs passen (bestimmt den Maßstab bei 100 %).
pub fn set_fit_box(&mut self, fit_box: glam::Vec2) {
if self.fit_box != fit_box {
self.fit_box = fit_box;
self.recalculate_viewport();
}
}
fn recalculate_viewport(&mut self) {
self.viewport = PageViewport::calculate_with_insets(
self.viewport = PageViewport::calculate_for(
self.size.width as f32,
self.size.height as f32,
Self::VIEWPORT_MARGIN,
self.ui_inset_left,
self.ui_inset_right,
(self.ui_inset_left, self.ui_inset_right),
self.fit_box,
self.page_size,
);
self.update_uniforms();
}
@@ -653,60 +675,60 @@ impl CanvasState {
self.camera.page_to_screen_vec2(page_pt, &self.viewport)
}
/// Durchgehende Ansicht: Versatz (in Seiten) der Seite unter dem Bildschirmpunkt,
/// `None` neben den Seiten und zwischen zwei Seiten.
pub fn page_offset_at_screen(&self, screen_x: f32, screen_y: f32) -> Option<i64> {
/// Durchgehende Ansicht: Seite unter dem Bildschirmpunkt, `None` neben den Seiten und
/// zwischen zwei Seiten.
pub fn page_at_screen(&self, stack: &PageStack, screen_x: f32, screen_y: f32) -> Option<usize> {
let p = self
.camera
.screen_to_page_unclamped(screen_x, screen_y, &self.viewport);
if !(0.0..=crate::document::PAGE_WIDTH).contains(&p.x) {
return None;
}
crate::viewport::page_offset_at(p.y)
let index = stack.page_at(p.y)?;
let x = p.x - stack.origins[index].x;
(0.0..=stack.sizes[index].x).contains(&x).then_some(index)
}
/// Durchgehende Ansicht: Versatz (in Seiten) der Seite in der Mitte des Fensters.
pub fn page_offset_at_view_center(&self) -> Option<i64> {
/// Durchgehende Ansicht: Seite auf Höhe der Fenstermitte.
pub fn page_at_view_center(&self, stack: &PageStack) -> Option<usize> {
let p = self.camera.screen_to_page_unclamped(
0.0,
self.size.height as f32 / 2.0,
&self.viewport,
);
crate::viewport::page_offset_at(p.y)
stack.page_at(p.y)
}
/// Durchgehende Ansicht: Versätze der sichtbaren Nachbarseiten der aktuellen Seite.
pub fn visible_neighbor_offsets(&self, current: usize, page_count: usize) -> Vec<i64> {
/// Durchgehende Ansicht: sichtbare Nachbarseiten der aktuellen Seite.
pub fn visible_neighbors(&self, stack: &PageStack) -> Vec<usize> {
let top = self
.camera
.screen_to_page_unclamped(0.0, 0.0, &self.viewport);
let bottom =
self.camera
.screen_to_page_unclamped(0.0, self.size.height as f32, &self.viewport);
crate::viewport::visible_neighbor_offsets(top.y, bottom.y, current, page_count)
stack.visible_neighbors(top.y, bottom.y)
}
/// Durchgehende Ansicht: Hält die Fenstermitte über dem Notizbuch (von der Oberkante der
/// ersten bis zur Unterkante der letzten Seite, waagerecht innerhalb der Seitenbreite), damit
/// ersten bis zur Unterkante der letzten Seite, waagerecht innerhalb der Seiten), damit
/// man nicht ins Leere scrollt. `true`, wenn der Ausschnitt korrigiert wurde.
pub fn clamp_scroll(&mut self, current: usize, page_count: usize) -> bool {
pub fn clamp_scroll(&mut self, stack: &PageStack) -> bool {
let center = [
self.viewport.page_x + self.viewport.page_width / 2.0,
self.size.height as f32 / 2.0,
];
let changed =
self.camera
.clamp_continuous_center(center, current, page_count, &self.viewport);
let changed = self
.camera
.clamp_continuous_center(center, stack, &self.viewport);
if changed {
self.update_uniforms();
}
changed
}
/// Durchgehende Ansicht: Die Seite `k` Seiten weiter wird zur Bezugsseite, ohne dass sich
/// der Bildausschnitt bewegt. Die Nachbarseiten sind danach neu zu setzen.
pub fn shift_reference_page(&mut self, k: i64) {
self.camera.shift_reference_page(k);
/// Durchgehende Ansicht: Die Seite mit der Ecke `origin` (Koordinaten der aktuellen Seite)
/// wird zur Bezugsseite, ohne dass sich der Bildausschnitt bewegt. Danach ist die neue
/// Seite mit [`Self::sync_page`] zu setzen.
pub fn shift_reference_page(&mut self, origin: glam::Vec2) {
self.camera.shift_reference_page(origin);
self.update_uniforms();
}
@@ -756,7 +778,7 @@ impl CanvasState {
self.queue.write_buffer(
&self.paper_buffer,
0,
bytemuck::cast_slice(&generate_paper_quad(theme.paper_color())),
bytemuck::cast_slice(&generate_paper_quad(theme.paper_color(), self.page_size)),
);
self.upload_background_mesh();
}
@@ -774,8 +796,11 @@ impl CanvasState {
/// Erzeugt das Muster für Hintergrund und Papierfarbe neu und lädt es auf die GPU.
fn upload_background_mesh(&mut self) {
let mesh =
generate_background_mesh(self.current_background, self.paper_theme.pattern_color());
let mesh = generate_background_mesh(
self.current_background,
self.paper_theme.pattern_color(),
self.page_size,
);
self.background_vertex_count = mesh.len() as u32;
if mesh.len() > self.background_vertex_capacity {
@@ -799,6 +824,19 @@ impl CanvasState {
page.rebuild_cache_if_dirty();
// Die aktuelle Seite kann sich jetzt ändern: als Nachbarseite später neu hochladen
self.neighbors.retain(|n| n.fingerprint.page_id != page.id);
if self.page_size != page.size {
self.page_size = page.size;
self.queue.write_buffer(
&self.paper_buffer,
0,
bytemuck::cast_slice(&generate_paper_quad(
self.paper_theme.paper_color(),
self.page_size,
)),
);
self.upload_background_mesh();
self.recalculate_viewport();
}
self.set_background(page.background);
let quads = self.page_image_quads(page, self.hidden_text);
self.image_layer.set_quads(quads);
@@ -832,7 +870,7 @@ impl CanvasState {
/// aktuellen Seite (in Seiten). Die Vertex-Caches der Seiten müssen aktuell sein
/// ([`Page::rebuild_cache_if_dirty`]); unveränderte Seiten werden nicht erneut hochgeladen.
/// Eine leere Liste schaltet die Nachbarseiten ab.
pub fn set_neighbor_pages(&mut self, pages: &[(i64, &Page)]) {
pub fn set_neighbor_pages(&mut self, pages: &[(glam::Vec2, &Page)]) {
let mut old = std::mem::take(&mut self.neighbors);
let mut image_groups = Vec::with_capacity(pages.len());
for &(offset, page) in pages {
@@ -882,11 +920,16 @@ impl CanvasState {
buffer
})
};
let background =
generate_background_mesh(page.background, self.paper_theme.pattern_color());
// Papier und Muster in einem Puffer (Nachbarseiten können anders groß sein)
let mut background = generate_paper_quad(self.paper_theme.paper_color(), page.size);
background.extend(generate_background_mesh(
page.background,
self.paper_theme.pattern_color(),
page.size,
));
NeighborPage {
fingerprint,
offset: 0,
offset: glam::Vec2::ZERO,
camera_buffer,
camera_bind_group,
background_buffer: vertex_buffer_with("Neighbor Background Buffer", &background),
@@ -903,19 +946,14 @@ impl CanvasState {
load: &mut dyn FnMut(&str) -> crate::image_layer::TextureSource,
) {
// Text schärfer rastern, sobald weiter hineingezoomt wird (und gröber beim Herauszoomen)
let resolution = text_resolution(
self.viewport.page_width / crate::document::PAGE_WIDTH * self.camera.zoom,
);
let resolution = text_resolution(self.viewport.scale * self.camera.zoom);
if resolution != self.text_resolution {
self.text_resolution = resolution;
self.image_layer
.forget_textures(|key| key.starts_with(TEXT_KEY_PREFIX));
}
// Importierte PDF-Seiten schärfer rendern, sobald die Standardauflösung vergrößert würde
let page_long_edge = crate::document::PAGE_HEIGHT.max(crate::document::PAGE_WIDTH)
* self.viewport.page_width
/ crate::document::PAGE_WIDTH
* self.camera.zoom;
let page_long_edge = self.page_size.max_element() * self.viewport.scale * self.camera.zoom;
self.image_layer.set_pdf_pixels(
(page_long_edge > crate::pdf_doc::DISPLAY_RENDER_PIXELS as f32 * 1.25)
.then_some(crate::pdf_doc::ZOOMED_RENDER_PIXELS),
@@ -950,7 +988,7 @@ impl CanvasState {
let mut quads: Vec<_> = page
.pdf_background
.iter()
.map(|pdf| (pdf.key(), pdf.corners()))
.map(|pdf| (pdf.key(), pdf.corners(page.size)))
.collect();
for stroke in &page.strokes {
let Some(corners) = stroke.object_corners() else {
@@ -1196,8 +1234,6 @@ impl CanvasState {
for (i, neighbor) in self.neighbors.iter().enumerate() {
render_pass.set_bind_group(0, &neighbor.camera_bind_group, &[]);
render_pass.set_pipeline(&self.triangle_pipeline);
render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..));
render_pass.draw(0..6, 0..1);
if let Some(buffer) = &neighbor.background_buffer {
render_pass.set_vertex_buffer(0, buffer.slice(..));
render_pass.draw(0..neighbor.background_vertex_count, 0..1);
+209 -67
View File
@@ -15,6 +15,8 @@ pub const A4_ASPECT_RATIO: f32 = A4_WIDTH_MM / A4_HEIGHT_MM;
/// Virtuelle Seitenauflösung in internen Einheiten (10 Einheiten = 1 mm).
pub const PAGE_WIDTH: f32 = 2100.0;
pub const PAGE_HEIGHT: f32 = 2970.0;
/// Größe einer DIN-A4-Hochformatseite (Standard) in Seiteneinheiten.
pub const A4_SIZE: glam::Vec2 = glam::Vec2::new(PAGE_WIDTH, PAGE_HEIGHT);
pub const UNITS_PER_MM: f32 = 10.0;
/// Ein gefilterter Stylus-Abtastpunkt in DIN-A4-Seitenkoordinaten.
@@ -156,21 +158,32 @@ impl PdfBackground {
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)
/// Ecken (oben links, oben rechts, unten rechts, unten links) der PDF-Seite auf einem
/// Blatt der Größe `page_size`, siehe [`pdf_placement`].
pub fn corners(&self, page_size: glam::Vec2) -> [glam::Vec2; 4] {
pdf_placement(self.width_pt, self.height_pt, page_size)
}
}
/// 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] {
/// Seitengröße für eine importierte PDF-Seite von `width_pt` × `height_pt`: Hochformat wird
/// DIN A4 hoch (wie bisher), Querformat (z. B. Folien) eine Seite mit der Breite von A4 quer
/// (297 mm) und dem Seitenverhältnis der PDF-Seite – ohne leeren Streifen darunter.
pub fn pdf_page_size(width_pt: f32, height_pt: f32) -> glam::Vec2 {
let (w, h) = (width_pt.max(1.0), height_pt.max(1.0));
let scale = (PAGE_WIDTH / w).min(PAGE_HEIGHT / h);
if w <= h {
return A4_SIZE;
}
// Auf ganze Zehntelmillimeter runden, damit gleich große Folien gleich große Seiten geben
glam::Vec2::new(PAGE_HEIGHT, (PAGE_HEIGHT * h / w).round().max(1.0))
}
/// Lage einer PDF-Seite von `width_pt` × `height_pt` auf einem Blatt der Größe `page_size`:
/// so groß wie möglich, waagerecht zentriert und oben bündig.
pub fn pdf_placement(width_pt: f32, height_pt: f32, page_size: glam::Vec2) -> [glam::Vec2; 4] {
let (w, h) = (width_pt.max(1.0), height_pt.max(1.0));
let scale = (page_size.x / w).min(page_size.y / h);
let size = glam::Vec2::new(w * scale, h * scale);
let tl = glam::Vec2::new((PAGE_WIDTH - size.x) / 2.0, 0.0);
let tl = glam::Vec2::new((page_size.x - size.x) / 2.0, 0.0);
[
tl,
tl + glam::Vec2::new(size.x, 0.0),
@@ -184,18 +197,24 @@ 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] {
/// Seitenbreite und 60 % der Seitenhöhe eines Blatts der Größe `page_size`, und möglichst
/// ganz auf dem Blatt.
pub fn place_image(
width: u32,
height: u32,
center: glam::Vec2,
page_size: 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)
let fit = (page_size.x * 0.8 / natural.x)
.min(page_size.y * 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),
center.x.clamp(half.x, page_size.x - half.x),
center.y.clamp(half.y, page_size.y - half.y),
);
let tl = center - half;
[
@@ -693,6 +712,9 @@ pub struct Page {
pub id: u64,
pub strokes: Vec<Stroke>,
pub background: PageBackground,
/// Seitengröße in Seiteneinheiten (Standard DIN A4 hoch, Querformat für PDF-Folien); auch im
/// Manifest, damit sie ohne Laden der Seite bekannt ist
pub size: glam::Vec2,
/// 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
@@ -711,6 +733,7 @@ impl Page {
id,
strokes: Vec::new(),
background,
size: A4_SIZE,
pdf_background: None,
title: None,
vertex_cache: Vec::new(),
@@ -728,6 +751,7 @@ impl Page {
id,
strokes: Vec::new(),
background,
size: A4_SIZE,
pdf_background: None,
title: None,
vertex_cache: Vec::new(),
@@ -739,6 +763,12 @@ impl Page {
}
}
/// Dieselbe Seite mit der Größe `size`.
pub fn with_size(mut self, size: glam::Vec2) -> Self {
self.size = size;
self
}
pub fn is_readonly(&self) -> bool {
self.persist == PersistState::ReadOnlyAfterLoadError
}
@@ -1054,9 +1084,9 @@ impl Page {
}
}
/// Höhe des Bereichs am unteren Rand der letzten Seite, in dem Schreiben in der
/// Anteil der Seitenhöhe am unteren Rand der letzten Seite, in dem Schreiben in der
/// durchgehenden Ansicht automatisch eine neue Seite anhängt.
pub const AUTO_APPEND_ZONE: f32 = PAGE_HEIGHT * 0.2;
pub const AUTO_APPEND_FRACTION: f32 = 0.2;
/// Das Notizbuch mit diskreter Seitenverwaltung und Navigation.
#[derive(Debug, Clone)]
@@ -1141,30 +1171,58 @@ impl Notebook {
id
}
/// Fügt eine neue Seite mit dem angegebenen Hintergrund hinzu.
/// Rahmen, in den jede Seite des Notizbuchs passt (größte Breite und Höhe).
pub fn fit_box(&self) -> glam::Vec2 {
self.pages
.iter()
.map(|p| p.size)
.fold(glam::Vec2::ZERO, glam::Vec2::max)
.max(glam::Vec2::ONE)
}
/// Durchgehende Ansicht: Lage aller Seiten relativ zur aktuellen.
pub fn page_stack(&self) -> crate::viewport::PageStack {
crate::viewport::PageStack::new(
self.pages.iter().map(|p| p.size).collect(),
self.current_page_index,
)
}
/// Hängt eine neue Seite mit dem angegebenen Hintergrund und der Größe der aktuellen
/// Seite an.
pub fn add_page(&mut self, background: PageBackground) -> usize {
let id = self.next_page_id;
self.next_page_id += 1;
self.pages.push(Page::new(id, background));
let size = self
.pages
.get(self.current_page_index)
.map_or(A4_SIZE, |p| p.size);
self.pages.push(Page::new(id, background).with_size(size));
self.pages.len() - 1
}
/// Fügt direkt nach der aktuellen Seite eine leere Seite mit dem angegebenen
/// Hintergrund ein (die aktuelle Seite bleibt aktuell) und gibt ihren Index zurück.
/// Hintergrund und der Größe der aktuellen Seite ein (die aktuelle Seite bleibt aktuell)
/// und gibt ihren Index zurück.
pub fn insert_page_after_current(&mut self, background: PageBackground) -> usize {
let id = self.next_page_id;
self.next_page_id += 1;
let index = (self.current_page_index + 1).min(self.pages.len());
self.pages.insert(index, Page::new(id, background));
let size = self
.pages
.get(self.current_page_index)
.map_or(A4_SIZE, |p| p.size);
self.pages
.insert(index, Page::new(id, background).with_size(size));
index
}
/// Durchgehende Ansicht: Reicht ein neuer Strich auf der letzten Seite bis in deren
/// unteres Fünftel ([`AUTO_APPEND_ZONE`]), wird darunter eine leere Seite mit gleichem
/// unteres Fünftel ([`AUTO_APPEND_FRACTION`]), wird darunter eine leere Seite mit gleichem
/// Hintergrund angehängt. `true`, wenn eine Seite angehängt wurde.
pub fn append_page_if_writing_at_end(&mut self, stroke_bottom: f32) -> bool {
if self.current_page_index + 1 != self.pages.len()
|| stroke_bottom < PAGE_HEIGHT - AUTO_APPEND_ZONE
|| stroke_bottom < self.current_page().size.y * (1.0 - AUTO_APPEND_FRACTION)
{
return false;
}
@@ -1234,19 +1292,28 @@ impl Default for Notebook {
}
}
/// Berechnet die DIN-A4-Darstellung im Fenster mit Letterboxing/Pillarboxing.
/// Lage der aktuellen Seite im Fenster bei Zoom 100 % ohne Verschiebung: Ein Rahmen der
/// Größe `fit_box` (alle Seiten des Notizbuchs passen hinein) wird ins Fenster eingepasst
/// (Letterboxing/Pillarboxing); die aktuelle Seite liegt darin waagerecht zentriert und oben.
/// So bleibt der Maßstab beim Wechsel zwischen verschieden großen Seiten gleich.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PageViewport {
pub window_width: f32,
pub window_height: f32,
/// Bildschirmposition der linken oberen Ecke der aktuellen Seite
pub page_x: f32,
pub page_y: f32,
/// Größe der aktuellen Seite auf dem Bildschirm (Pixel)
pub page_width: f32,
pub page_height: f32,
/// Bildschirmpixel je Seiteneinheit
pub scale: f32,
/// Größe der aktuellen Seite in Seiteneinheiten
pub page_size: glam::Vec2,
}
impl PageViewport {
/// Berechnet die zentrierte DIN-A4-Projektionsfläche unter Beibehaltung des 210x297-Seitenverhältnisses.
/// Zentrierte DIN-A4-Seite im Fenster.
pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self {
Self::calculate_with_insets(window_width, window_height, margin, 0.0, 0.0)
}
@@ -1259,66 +1326,70 @@ impl PageViewport {
margin: f32,
inset_left: f32,
inset_right: f32,
) -> Self {
Self::calculate_for(
window_width,
window_height,
margin,
(inset_left, inset_right),
A4_SIZE,
A4_SIZE,
)
}
/// Wie [`Self::calculate_with_insets`] für eine Seite der Größe `page_size` in einem
/// Notizbuch, dessen Seiten in `fit_box` passen.
pub fn calculate_for(
window_width: f32,
window_height: f32,
margin: f32,
(inset_left, inset_right): (f32, f32),
fit_box: glam::Vec2,
page_size: glam::Vec2,
) -> Self {
let inset_left = inset_left.clamp(0.0, window_width * 0.5);
let inset_right = inset_right.clamp(0.0, window_width * 0.5);
let avail_w = (window_width - inset_left - inset_right - 2.0 * margin).max(10.0);
let avail_h = (window_height - 2.0 * margin).max(10.0);
let fit_box = fit_box.max(page_size).max(glam::Vec2::ONE);
let window_ratio = avail_w / avail_h;
let page_ratio = A4_ASPECT_RATIO;
let (page_w, page_h) = if window_ratio > page_ratio {
// Fenster ist breiter als DIN A4 -> Pillarboxing (Balken links/rechts)
let h = avail_h;
let w = h * page_ratio;
(w, h)
} else {
// Fenster ist höher als DIN A4 -> Letterboxing (Balken oben/unten)
let w = avail_w;
let h = w / page_ratio;
(w, h)
};
let page_x = inset_left + (window_width - inset_left - inset_right - page_w) / 2.0;
let page_y = (window_height - page_h) / 2.0;
let scale = (avail_w / fit_box.x).min(avail_h / fit_box.y);
let box_x =
inset_left + (window_width - inset_left - inset_right - fit_box.x * scale) / 2.0;
let box_y = (window_height - fit_box.y * scale) / 2.0;
Self {
window_width,
window_height,
page_x,
page_y,
page_width: page_w,
page_height: page_h,
page_x: box_x + (fit_box.x - page_size.x) / 2.0 * scale,
page_y: box_y,
page_width: page_size.x * scale,
page_height: page_size.y * scale,
scale,
page_size,
}
}
/// Transformiert einen Fenster-Pixelpunkt in DIN-A4-Seitenkoordinaten [0.0, PAGE_WIDTH] x [0.0, PAGE_HEIGHT].
/// Transformiert einen Fenster-Pixelpunkt in Seitenkoordinaten.
///
/// Liefert `None`, falls der Punkt außerhalb der DIN-A4-Papierfläche liegt.
/// Liefert `None`, falls der Punkt außerhalb der Seite liegt.
pub fn screen_to_page(&self, screen_x: f32, screen_y: f32) -> Option<glam::Vec2> {
let u = (screen_x - self.page_x) / self.page_width;
let v = (screen_y - self.page_y) / self.page_height;
if (0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v) {
Some(glam::Vec2::new(u * PAGE_WIDTH, v * PAGE_HEIGHT))
} else {
None
}
let p = glam::Vec2::new(screen_x - self.page_x, screen_y - self.page_y) / self.scale;
((0.0..=self.page_size.x).contains(&p.x) && (0.0..=self.page_size.y).contains(&p.y))
.then_some(p)
}
/// Transformiert einen Fenster-Pixelpunkt und klemmt ihn auf die DIN-A4-Papierfläche.
/// Transformiert einen Fenster-Pixelpunkt und klemmt ihn auf die Seite.
pub fn screen_to_page_clamped(&self, screen_x: f32, screen_y: f32) -> glam::Vec2 {
let u = ((screen_x - self.page_x) / self.page_width).clamp(0.0, 1.0);
let v = ((screen_y - self.page_y) / self.page_height).clamp(0.0, 1.0);
glam::Vec2::new(u * PAGE_WIDTH, v * PAGE_HEIGHT)
let p = glam::Vec2::new(screen_x - self.page_x, screen_y - self.page_y) / self.scale;
p.clamp(glam::Vec2::ZERO, self.page_size)
}
/// Berechnet die Shader-Transformationsparameter (Scale, Offset),
/// um DIN-A4-Seitenkoordinaten direkt im Vertex-Shader in den NDC-Bereich [-1.0, 1.0] abzubilden.
/// um Seitenkoordinaten direkt im Vertex-Shader in den NDC-Bereich [-1.0, 1.0] abzubilden.
pub fn ndc_transform(&self) -> ([f32; 2], [f32; 2]) {
let scale_x = (2.0 * self.page_width) / (self.window_width * PAGE_WIDTH);
let scale_y = (-2.0 * self.page_height) / (self.window_height * PAGE_HEIGHT);
let scale_x = (2.0 * self.scale) / self.window_width;
let scale_y = (-2.0 * self.scale) / self.window_height;
let offset_x = (2.0 * self.page_x) / self.window_width - 1.0;
let offset_y = 1.0 - (2.0 * self.page_y) / self.window_height;
([scale_x, scale_y], [offset_x, offset_y])
@@ -1584,24 +1655,95 @@ mod tests {
assert!(page.strokes.is_empty());
}
#[test]
fn test_pdf_page_size_follows_orientation() {
// Hochformat (A4, Letter) bleibt A4 hoch
assert_eq!(pdf_page_size(595.0, 842.0), A4_SIZE);
assert_eq!(pdf_page_size(612.0, 792.0), A4_SIZE);
// 16:9-Folie: 297 mm breit, Höhe nach Seitenverhältnis
let slide = pdf_page_size(960.0, 540.0);
assert_eq!(slide, glam::Vec2::new(2970.0, 1671.0));
// Die Folie füllt ihre Seite ganz aus
let [tl, _, br, _] = pdf_placement(960.0, 540.0, slide);
assert!(
tl.length() < 1e-3 && (br - slide).length() < 1.0,
"{tl} {br}"
);
}
#[test]
fn test_viewport_scale_is_shared_by_pages_of_different_size() {
let slide = glam::Vec2::new(2970.0, 1671.0);
// Nur Folien: die Folie wird ins Fenster eingepasst
let only_slides =
PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), slide, slide);
// 1560 × 860 frei: die Höhe begrenzt
assert!(
(only_slides.page_height - 860.0).abs() < 0.5,
"{only_slides:?}"
);
assert!(
(only_slides.page_width / only_slides.page_height - slide.x / slide.y).abs() < 1e-3
);
// Gemischt: gleicher Maßstab für beide Seiten, schmale Seite waagerecht zentriert
let fit = A4_SIZE.max(slide);
let a4 = PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), fit, A4_SIZE);
let wide = PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), fit, slide);
assert_eq!(a4.scale, wide.scale);
let center = |v: &PageViewport| v.page_x + v.page_width / 2.0;
assert!((center(&a4) - center(&wide)).abs() < 1e-3);
assert_eq!(a4.page_y, wide.page_y);
// Bildschirm → Seite berücksichtigt die Seitengröße
let p = wide
.screen_to_page(wide.page_x + wide.page_width - 1.0, wide.page_y + 1.0)
.unwrap();
assert!(p.x > 2900.0);
assert!(wide
.screen_to_page(wide.page_x + 5.0, wide.page_y + wide.page_height + 5.0)
.is_none());
}
#[test]
fn test_new_pages_take_size_of_current_page() {
let slide = glam::Vec2::new(2970.0, 1671.0);
let mut nb = Notebook::new();
nb.pages[0].size = slide;
nb.add_page(PageBackground::Blank);
nb.insert_page_after_current(PageBackground::Blank);
nb.next_page();
nb.current_page_index = nb.pages.len() - 1;
nb.next_page();
assert!(nb.pages.iter().all(|p| p.size == slide));
assert_eq!(nb.fit_box(), slide);
// Auto-Anhängen bezieht sich auf die Höhe der Folie
nb.current_page_index = nb.pages.len() - 1;
assert!(!nb.append_page_if_writing_at_end(slide.y * 0.7));
assert!(nb.append_page_if_writing_at_end(slide.y * 0.9));
}
#[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);
let [tl, tr, br, bl] = place_image(200, 100, center, A4_SIZE);
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 [tl, tr, br, _] = place_image(3840, 2160, center, A4_SIZE);
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));
let corners = place_image(
400,
400,
glam::Vec2::new(-50.0, PAGE_HEIGHT + 50.0),
A4_SIZE,
);
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);
+77 -60
View File
@@ -117,17 +117,43 @@ pub enum ExportStrategy {
}
/// Rechnet eine interne Seitenkoordinate (x, y) in PDF-Millimeter-Koordinaten um (mit Y-Inversion).
///
/// Bezugshöhe ist die A4-Seite; für anders hohe Seiten verschiebt [`begin_page_layer`] die
/// Ebene entsprechend.
#[inline]
pub fn page_to_pdf_mm(x: f32, y: f32) -> (f32, f32) {
let x_norm = (x / PAGE_WIDTH).clamp(0.0, 1.0);
let y_norm = (y / PAGE_HEIGHT).clamp(0.0, 1.0);
let x_mm = x_norm * A4_WIDTH_MM;
let y_mm = (1.0 - y_norm) * A4_HEIGHT_MM; // PDF-Ursprung liegt unten links
let x_mm = x / PAGE_WIDTH * A4_WIDTH_MM;
let y_mm = (1.0 - y / PAGE_HEIGHT) * A4_HEIGHT_MM; // PDF-Ursprung liegt unten links
(x_mm, y_mm)
}
/// PDF-Punkte je Seiteneinheit.
const PT_PER_UNIT: f32 = A4_WIDTH_MM / PAGE_WIDTH * 72.0 / 25.4;
/// Größe der Seite `page` im PDF.
fn pdf_page_mm(page: &Page) -> (Mm, Mm) {
let mm = page.size / crate::document::UNITS_PER_MM;
(Mm(mm.x), Mm(mm.y))
}
/// Beginnt eine Ebene: Koordinaten beziehen sich auf eine A4-hohe Seite (siehe
/// [`page_to_pdf_mm`]) und werden auf die tatsächliche Höhe von `page` verschoben.
fn begin_page_layer(layer: &printpdf::PdfLayerReference, page: &Page) {
layer.save_graphics_state();
let dy = (page.size.y - PAGE_HEIGHT) * PT_PER_UNIT;
if dy != 0.0 {
layer.set_ctm(printpdf::CurTransMat::Translate(
printpdf::Pt(0.0),
printpdf::Pt(dy),
));
}
}
/// Beendet eine mit [`begin_page_layer`] begonnene Ebene.
fn end_page_layer(layer: &printpdf::PdfLayerReference) {
layer.restore_graphics_state();
}
/// 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>>(
@@ -240,9 +266,11 @@ fn pdf_underlays(notebook: &Notebook) -> Vec<crate::pdf_merge::Underlay> {
.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 corners = pdf.corners(page.size);
// Unterlagen werden ohne die Verschiebung von `begin_page_layer` gesetzt
let shift = glam::Vec2::new(0.0, (page.size.y - PAGE_HEIGHT) * PT_PER_UNIT);
let top_right = page_to_pdf_pt(corners[1]) + shift;
let bottom_left = page_to_pdf_pt(corners[3]) + shift;
let size = top_right - bottom_left;
Some(crate::pdf_merge::Underlay {
target_page: i,
@@ -292,62 +320,50 @@ fn generate_pdf_document_with(
.into());
}
// 1. Erstes Dokument mit Seite 1 erstellen
// Erste Seite legt das Dokument an, alle weiteren werden angehängt (je in ihrer Größe)
let title = "ink-notes Notebook";
let (doc, page1_idx, layer1_bg) =
PdfDocument::new(title, Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
let (width, height) = pdf_page_mm(&notebook.pages[0]);
let (doc, page1_idx, layer1_bg) = PdfDocument::new(title, width, height, "Background");
let fonts = embed_text_fonts(&doc, notebook)?;
for (i, page) in notebook.pages.iter().enumerate() {
let (page_idx, layer_bg) = if i == 0 {
(page1_idx, layer1_bg)
} else {
let (width, height) = pdf_page_mm(page);
doc.add_page(width, height, "Background")
};
// Seitentitel werden zu Lesezeichen (Inhaltsverzeichnis im PDF-Betrachter)
if let Some(title) = &notebook.pages[0].title {
doc.add_bookmark(title.clone(), page1_idx);
}
// Seite 1 befüllen
let page1_ref = doc.get_page(page1_idx);
let layer1_bg_ref = page1_ref.get_layer(layer1_bg);
render_background_layer(&layer1_bg_ref, notebook.pages[0].background);
if raster_pdf_pages.contains(&0) {
render_pdf_raster(&page1_ref.add_layer("PDF"), &notebook.pages[0], assets);
}
if notebook.pages[0].strokes.iter().any(Stroke::is_image) {
render_image_layer(&page1_ref.add_layer("Images"), &notebook.pages[0], assets);
}
if notebook.pages[0].strokes.iter().any(Stroke::is_text) {
render_text_layer(&page1_ref.add_layer("Text"), &notebook.pages[0], &fonts);
}
let layer1_ink_ref = page1_ref.add_layer("Ink");
render_ink_layer(&layer1_ink_ref, &notebook.pages[0], strategy);
// 2. Alle nachfolgenden Seiten hinzufügen
for (i, page) in notebook.pages.iter().enumerate().skip(1) {
let (page_idx, layer_bg) = doc.add_page(Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
if let Some(title) = &page.title {
doc.add_bookmark(title.clone(), page_idx);
}
let page_ref = doc.get_page(page_idx);
let layer_bg_ref = page_ref.get_layer(layer_bg);
render_background_layer(&layer_bg_ref, page.background);
render_background_layer(&page_ref.get_layer(layer_bg), page);
let layer = |name: &str, render: &dyn Fn(&printpdf::PdfLayerReference)| {
let layer = page_ref.add_layer(name);
begin_page_layer(&layer, page);
render(&layer);
end_page_layer(&layer);
};
if raster_pdf_pages.contains(&i) {
render_pdf_raster(&page_ref.add_layer("PDF"), page, assets);
layer("PDF", &|l| render_pdf_raster(l, page, assets));
}
if page.strokes.iter().any(Stroke::is_image) {
render_image_layer(&page_ref.add_layer("Images"), page, assets);
layer("Images", &|l| render_image_layer(l, page, assets));
}
if page.strokes.iter().any(Stroke::is_text) {
render_text_layer(&page_ref.add_layer("Text"), page, &fonts);
layer("Text", &|l| render_text_layer(l, page, &fonts));
}
let layer_ink_ref = page_ref.add_layer("Ink");
render_ink_layer(&layer_ink_ref, page, strategy);
layer("Ink", &|l| render_ink_layer(l, page, strategy));
}
Ok(doc)
}
/// Rendert das Vektorraster auf Layer 1 ("Background").
fn render_background_layer(layer: &printpdf::PdfLayerReference, background: PageBackground) {
fn render_background_layer(layer: &printpdf::PdfLayerReference, page: &Page) {
let background = page.background;
let (Mm(page_width_mm), Mm(page_height_mm)) = pdf_page_mm(page);
let grid_color = Color::Rgb(Rgb::new(0.88, 0.88, 0.88, None));
layer.set_outline_color(grid_color.clone());
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
@@ -361,11 +377,11 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, background: Page
// Vertikale Linien alle 5 mm
let mut x = spacing_mm;
while x < A4_WIDTH_MM {
while x < page_width_mm {
let line = Line {
points: vec![
(Point::new(Mm(x), Mm(0.0)), false),
(Point::new(Mm(x), Mm(A4_HEIGHT_MM)), false),
(Point::new(Mm(x), Mm(page_height_mm)), false),
],
is_closed: false,
};
@@ -375,11 +391,11 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, background: Page
// Horizontale Linien alle 5 mm
let mut y = spacing_mm;
while y < A4_HEIGHT_MM {
while y < page_height_mm {
let line = Line {
points: vec![
(Point::new(Mm(0.0), Mm(y)), false),
(Point::new(Mm(A4_WIDTH_MM), Mm(y)), false),
(Point::new(Mm(page_width_mm), Mm(y)), false),
],
is_closed: false,
};
@@ -394,11 +410,11 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, background: Page
let margin_bottom_mm = 15.0;
let mut y = margin_bottom_mm;
while y <= A4_HEIGHT_MM - margin_top_mm {
while y <= page_height_mm - margin_top_mm {
let line = Line {
points: vec![
(Point::new(Mm(0.0), Mm(y)), false),
(Point::new(Mm(A4_WIDTH_MM), Mm(y)), false),
(Point::new(Mm(page_width_mm), Mm(y)), false),
],
is_closed: false,
};
@@ -412,9 +428,9 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, background: Page
let half_dot = 0.2; // 0.4 mm Kantenlänge
let mut y = spacing_mm;
while y < A4_HEIGHT_MM {
while y < page_height_mm {
let mut x = spacing_mm;
while x < A4_WIDTH_MM {
while x < page_width_mm {
let dot_poly = Polygon {
rings: vec![vec![
(Point::new(Mm(x - half_dot), Mm(y - half_dot)), false),
@@ -613,7 +629,7 @@ fn render_text_layer(
let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height());
let (origin, [right, down]) = crate::text::frame_axes(&corners, size);
// Seite → PDF-Punkte: x · k, Höhe − y · k
let k = A4_WIDTH_MM / PAGE_WIDTH * 72.0 / 25.4;
let k = PT_PER_UNIT;
let o = page_to_pdf_pt(origin);
let matrix = [right.x * k, -right.y * k, -down.x * k, down.y * k, o.x, o.y];
layer.save_graphics_state();
@@ -730,7 +746,7 @@ fn render_pdf_raster(
placeholder_xobject()
}
};
draw_image_xobject(layer, xobject, &pdf.corners());
draw_image_xobject(layer, xobject, &pdf.corners(page.size));
}
/// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem)
@@ -1513,7 +1529,8 @@ mod tests {
);
}
// Exportiertes PDF lässt sich wieder rendern: Folie oben, rotes Quadrat oben links
// Exportiertes PDF lässt sich wieder rendern: Querformat-Folie füllt ihre Seite, 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| {
@@ -1521,10 +1538,10 @@ mod tests {
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"
let ratio = image.width as f32 / image.height as f32;
assert!(
(ratio - 842.0 / 595.0).abs() < 0.01,
"Seite im Folienformat: {ratio}"
);
// Gedrehte Seite (/Rotate 90) erscheint wie im PDF-Betrachter: das Quadrat liegt
+26 -34
View File
@@ -32,7 +32,6 @@ use ink_notes::settings::{SidebarSide, UiSettings};
use ink_notes::storage::{self, NotebookManifest};
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
use ink_notes::updater::UpdaterController;
use ink_notes::viewport::PAGE_PITCH;
/// Aktualisiert den Fenstertitel als minimales On-Screen-Display (OSD) inklusive Autosave-Status.
/// Speichert die UI-Einstellungen; ein Fehler wird gemeldet, bricht aber nichts ab.
@@ -197,10 +196,10 @@ fn switch_to_page(
return;
}
if keep_view {
let k = index as i64 - current as i64;
canvas.shift_reference_page(k);
let origin = notebook.page_stack().origins[index];
canvas.shift_reference_page(origin);
if let Some(ruler) = &mut ui_state.ruler {
ruler.center.y -= k as f32 * PAGE_PITCH;
ruler.center -= origin;
}
canvas.ruler = ui_state.ruler;
}
@@ -234,11 +233,9 @@ fn page_to_focus(
if !ui_state.continuous_scroll {
return None;
}
let k = canvas.page_offset_at_screen(sx, sy).filter(|&k| k != 0)?;
let index = usize::try_from(notebook.current_page_index as i64 + k).ok()?;
if index >= notebook.pages.len() {
return None;
}
let index = canvas
.page_at_screen(&notebook.page_stack(), sx, sy)
.filter(|&i| i != notebook.current_page_index)?;
let grabs_selection = ui_state.active_tool == ActiveTool::Select
&& !selection_state.is_empty()
&& !matches!(
@@ -364,7 +361,12 @@ fn insert_image_on_page(
ui_state: &mut UiState,
) {
let id = notebook.next_stroke_id();
let corners = ink_notes::document::place_image(imported.width, imported.height, center);
let corners = ink_notes::document::place_image(
imported.width,
imported.height,
center,
notebook.current_page().size,
);
let page = notebook.current_page_mut();
page.add_stroke(ink_notes::document::Stroke::new_image(
id,
@@ -587,6 +589,7 @@ fn insert_text_on_page(
let mut edit = ink_notes::text::TextEditing::new_at(
notebook.current_page().id,
Vec2::new((center.x - 600.0).max(150.0), center.y),
notebook.current_page().size.x,
ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT,
ui_state.text_font,
ui_state.text_color,
@@ -680,6 +683,7 @@ fn text_tool_click(
None => Some(ink_notes::text::TextEditing::new_at(
page.id,
page_pt,
page.size.x,
ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT,
ui_state.text_font,
ui_state.text_color,
@@ -2545,13 +2549,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// der Fenstermitte zur aktuellen (nicht während eines Strichs, einer
// Auswahl oder beim Ziehen des Lineals). Wer auf einer Nachbarseite
// schreibt, bleibt dort, bis wieder gescrollt wird (Undo betrifft sie).
canvas.set_fit_box(notebook.fit_box());
if ui_state.continuous_scroll {
canvas.clamp_scroll(notebook.current_page_index, notebook.pages.len());
let stack = notebook.page_stack();
canvas.clamp_scroll(&stack);
// Unabhängig von der Bezugsseite: Lage bezogen auf die erste Seite
let scroll = [
canvas.camera.zoom,
canvas.camera.pan_offset[0],
canvas.camera.pan_offset[1]
- notebook.current_page_index as f32 * PAGE_PITCH,
canvas.camera.pan_offset[1] + stack.origins[0].y,
];
let scrolled = last_scroll
.is_none_or(|last: [f32; 3]| last.iter().zip(scroll).any(|(a, b)| (a - b).abs() > 1e-3));
@@ -2564,9 +2570,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if scrolled && !busy {
last_scroll = Some(scroll);
let target = canvas
.page_offset_at_view_center()
.filter(|&k| k != 0)
.and_then(|k| usize::try_from(notebook.current_page_index as i64 + k).ok());
.page_at_view_center(&stack)
.filter(|&i| i != notebook.current_page_index);
if let Some(index) = target {
switch_to_page(
index,
@@ -2770,15 +2775,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index;
manifest.next_page_id = notebook.next_page_id;
manifest.pages = notebook
.pages
.iter()
.map(|p| storage::PageMeta {
id: p.id,
background: Some(p.background),
title: p.title.clone(),
})
.collect();
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest);
autosave.mark_dirty(&notebook, &mut manifest);
@@ -3249,19 +3245,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 5. Canvas (Pass 1) und egui UI (Pass 2) in Surface-Textur rendern
// Durchgehende Ansicht: sichtbare Nachbarseiten laden und mitzeichnen
if ui_state.continuous_scroll {
let current = notebook.current_page_index;
let visible: Vec<(i64, usize)> = canvas
.visible_neighbor_offsets(current, notebook.pages.len())
.into_iter()
.map(|k| (k, (current as i64 + k) as usize))
.collect();
for &(_, index) in &visible {
let stack = notebook.page_stack();
let visible = canvas.visible_neighbors(&stack);
for &index in &visible {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, index);
notebook.pages[index].rebuild_cache_if_dirty();
}
let neighbors: Vec<(i64, &ink_notes::document::Page)> = visible
let neighbors: Vec<(Vec2, &ink_notes::document::Page)> = visible
.iter()
.map(|&(k, index)| (k, &notebook.pages[index]))
.map(|&index| (stack.origins[index], &notebook.pages[index]))
.collect();
canvas.set_neighbor_pages(&neighbors);
} else {
+20
View File
@@ -296,6 +296,26 @@ mod tests {
assert!((rotated.2 as f32) < 300.0 * px_per_unit + 2.0);
}
#[test]
fn test_landscape_page_export_keeps_positions() {
// Strich unten rechts auf einer Querformat-Seite: im PNG an derselben Stelle
let mut page = page_with_line();
page.size = glam::Vec2::new(2970.0, 1671.0);
page.strokes[0].translate(glam::Vec2::new(1900.0, 500.0)); // x 2400–2800, y 1500
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
let ratio = png.width() as f32 / png.height() as f32;
assert!((ratio - 2970.0 / 1671.0).abs() < 0.01, "{ratio}");
let px_per_unit = png.width() as f32 / 2970.0;
let at = |x: f32, y: f32| {
png.get_pixel((x * px_per_unit) as u32, (y * px_per_unit) as u32)
.0
};
assert!(at(2600.0, 1500.0)[3] > 200, "Strich liegt unten rechts");
assert!(at(2600.0, 1300.0)[3] == 0);
let svg = page_svg(&page, None).unwrap();
assert!(svg.contains("<path"));
}
#[test]
fn test_selection_image_is_cropped() {
let page = page_with_line();
+77 -9
View File
@@ -166,7 +166,8 @@ impl StrokeData {
/// - Version 5: importierte PDF-Seiten als Seitenhintergrund (`pdf_background`).
/// - Version 6: Seitentitel (`title` in Seite und Manifest).
/// - Version 7: Textfelder (`text` am Strich).
pub const CURRENT_FORMAT_VERSION: u32 = 7;
/// - Version 8: Seitengröße je Seite (`size` in Seite und Manifest), Querformat für Folien.
pub const CURRENT_FORMAT_VERSION: u32 = 8;
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
pub fn legacy_format_version() -> u32 {
@@ -186,9 +187,27 @@ pub struct PageData {
/// Seitentitel (steht zusätzlich im Manifest)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// Seitengröße in Seiteneinheiten, wenn nicht DIN A4 hoch (steht zusätzlich im Manifest)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<[f32; 2]>,
pub strokes: Vec<StrokeData>,
}
/// Gespeicherte Seitengröße: `None` für DIN A4 hoch.
fn stored_size(size: glam::Vec2) -> Option<[f32; 2]> {
(size != crate::document::A4_SIZE).then(|| size.to_array())
}
/// Seitengröße aus einer (evtl. von Hand bearbeiteten) Datei; unbrauchbare Werte ergeben A4.
pub fn page_size_from(stored: Option<[f32; 2]>) -> glam::Vec2 {
match stored {
Some([w, h]) if w.is_finite() && h.is_finite() && w >= 100.0 && h >= 100.0 => {
glam::Vec2::new(w.min(20_000.0), h.min(20_000.0))
}
_ => crate::document::A4_SIZE,
}
}
impl From<&Page> for PageData {
fn from(page: &Page) -> Self {
Self {
@@ -197,6 +216,7 @@ impl From<&Page> for PageData {
background: page.background,
pdf_background: page.pdf_background.clone(),
title: page.title.clone(),
size: stored_size(page.size),
strokes: page.strokes.iter().map(StrokeData::from).collect(),
}
}
@@ -205,7 +225,7 @@ impl From<&Page> for PageData {
impl PageData {
/// Wandelt die Seitendaten in eine runtime-aktive `Page` mit initialisiertem Cache um.
pub fn to_page(&self) -> Page {
let mut page = Page::new(self.id, self.background);
let mut page = Page::new(self.id, self.background).with_size(page_size_from(self.size));
page.pdf_background = self.pdf_background.clone();
page.title = self.title.clone();
for stroke_data in &self.strokes {
@@ -227,6 +247,9 @@ pub struct PageMeta {
/// Seitentitel (Lesezeichen), damit die Seitenübersicht ihn ohne Laden der Seite kennt
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// Seitengröße, wenn nicht DIN A4 hoch (für Lage und Maßstab ungeladener Seiten)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<[f32; 2]>,
}
impl PageMeta {
@@ -235,6 +258,7 @@ impl PageMeta {
id,
background,
title: None,
size: None,
}
}
}
@@ -292,6 +316,15 @@ impl NotebookManifest {
.and_then(|p| p.title.clone())
}
pub fn size_for_page(&self, page_id: u64) -> glam::Vec2 {
page_size_from(
self.pages
.iter()
.find(|p| p.id == page_id)
.and_then(|p| p.size),
)
}
pub fn background_for_page(&self, page_id: u64) -> Option<PageBackground> {
self.pages
.iter()
@@ -308,6 +341,7 @@ impl NotebookManifest {
id: p.id,
background: Some(p.background),
title: p.title.clone(),
size: stored_size(p.size),
})
.collect();
self.page_ids = self.pages.iter().map(|p| p.id).collect();
@@ -718,7 +752,7 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
quarantine_path: q_path,
},
);
let mut page = Page::new(id, first_bg);
let mut page = Page::new(id, first_bg).with_size(manifest.size_for_page(id));
page.persist = PersistState::ReadOnlyAfterLoadError;
page
}
@@ -734,13 +768,15 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
quarantine_path: None,
},
);
let mut page = Page::new(first_id, first_bg);
let mut page =
Page::new(first_id, first_bg).with_size(manifest.size_for_page(first_id));
page.persist = PersistState::ReadOnlyAfterLoadError;
page
}
Err(e) => {
eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}. Erstelle leere Ersatzseite.");
let mut page = Page::new(first_id, first_bg);
let mut page =
Page::new(first_id, first_bg).with_size(manifest.size_for_page(first_id));
page.persist = PersistState::ReadOnlyAfterLoadError;
page
}
@@ -752,7 +788,7 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
let bg = manifest
.background_for_page(page_id)
.unwrap_or(PageBackground::Grid5mm);
pages.push(Page::new_unloaded(page_id, bg));
pages.push(Page::new_unloaded(page_id, bg).with_size(manifest.size_for_page(page_id)));
}
// Titel aus dem Manifest (Platzhalter und Ersatzseiten kennen sie sonst nicht)
@@ -808,7 +844,7 @@ pub fn ensure_page_loaded(
// Angehängt, aber nie gespeichert (noch leer): leere Seite statt Ladefehler
Err(StorageError::PageNotFound(_)) => {
let placeholder = &mut notebook.pages[page_idx];
let mut page = Page::new(page_id, placeholder.background);
let mut page = Page::new(page_id, placeholder.background).with_size(placeholder.size);
page.title = placeholder.title.take();
*placeholder = page;
Ok(())
@@ -1132,7 +1168,8 @@ pub fn create_notebook_from_pdf(
.iter()
.enumerate()
.map(|(i, &(width_pt, height_pt))| {
let mut page = Page::new(i as u64 + 1, PageBackground::Blank);
let mut page = Page::new(i as u64 + 1, PageBackground::Blank)
.with_size(crate::document::pdf_page_size(width_pt, height_pt));
page.pdf_background = Some(crate::document::PdfBackground {
asset: asset.clone(),
page: i as u32,
@@ -1358,7 +1395,9 @@ pub fn load_all_pages(notebook_dir: &Path, notebook: &mut Notebook) -> Result<()
*page = match load_page(notebook_dir, page.id) {
Ok(data) => data.to_page(),
// Wie beim Lazy Loading: nie gespeicherte Seite = leere Seite
Err(StorageError::PageNotFound(_)) => Page::new(page.id, page.background),
Err(StorageError::PageNotFound(_)) => {
Page::new(page.id, page.background).with_size(page.size)
}
Err(e) => return Err(e),
};
page.title = page.title.take().or(title);
@@ -2525,6 +2564,35 @@ mod tests {
assert!(stroke.is_text() && !stroke.is_image() && stroke.vertices.is_empty());
}
#[test]
fn test_page_size_round_trips_and_is_known_before_loading() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Folien").unwrap();
let slide = glam::Vec2::new(2970.0, 1671.0);
notebook.pages[0].size = slide;
notebook.add_page(PageBackground::Blank);
notebook.add_page(PageBackground::Blank);
notebook.pages[2].size = crate::document::A4_SIZE;
save_notebook(&dir, &notebook, &mut manifest).unwrap();
let (mut reloaded, _) = load_notebook(&dir).unwrap();
let sizes: Vec<glam::Vec2> = reloaded.pages.iter().map(|p| p.size).collect();
assert_eq!(sizes, vec![slide, slide, crate::document::A4_SIZE]);
// Ungeladene Platzhalter kennen ihre Größe aus dem Manifest
assert!(!reloaded.pages[1].is_loaded);
ensure_page_loaded(&dir, &mut reloaded, 1).unwrap();
assert_eq!(reloaded.pages[1].size, slide);
// A4-Seiten speichern keine Größe (Dateien bleiben wie bisher)
let data = load_page(&dir, reloaded.pages[2].id).unwrap();
assert_eq!(data.size, None);
// Unsinnige Werte aus einer bearbeiteten Datei ergeben A4
assert_eq!(
page_size_from(Some([f32::NAN, 5.0])),
crate::document::A4_SIZE
);
}
#[test]
fn test_create_notebook_from_pdf() {
let temp_dir = tempfile::tempdir().unwrap();
+8 -3
View File
@@ -14,7 +14,7 @@ use ab_glyph::{Font, FontRef, GlyphId, PxScale};
use serde::{Deserialize, Serialize};
use crate::assets::RgbaImage;
use crate::document::{Stroke, PAGE_WIDTH, UNITS_PER_MM};
use crate::document::{Stroke, UNITS_PER_MM};
/// Schriftart eines Textfelds.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
@@ -270,15 +270,16 @@ pub struct TextEditing {
impl TextEditing {
/// Neues, leeres Feld: die erste Zeile mittig auf `pos`, umbrochen 1 cm vor dem rechten
/// Blattrand (mindestens 3 cm breit).
/// Rand eines Blatts der Breite `page_width` (mindestens 3 cm breit).
pub fn new_at(
page_id: u64,
pos: glam::Vec2,
page_width: f32,
size: f32,
font: TextFont,
color: [f32; 4],
) -> Self {
let right = PAGE_WIDTH - RIGHT_MARGIN;
let right = page_width - RIGHT_MARGIN;
let x = pos.x.min(right - MIN_WRAP_WIDTH).max(0.0);
let text = TextBox::new(String::new(), size, font, (right - x).max(MIN_WRAP_WIDTH));
let line_height = text.layout().line_height;
@@ -400,6 +401,7 @@ pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage
#[cfg(test)]
mod tests {
use super::*;
use crate::document::PAGE_WIDTH;
fn sample(content: &str, wrap_width: f32) -> TextBox {
TextBox::new(content.to_string(), 50.0, TextFont::Sans, wrap_width)
@@ -483,6 +485,7 @@ mod tests {
let edit = TextEditing::new_at(
1,
glam::Vec2::new(500.0, 800.0),
PAGE_WIDTH,
50.0,
TextFont::Sans,
[0.0; 4],
@@ -496,6 +499,7 @@ mod tests {
let edge = TextEditing::new_at(
1,
glam::Vec2::new(2050.0, 100.0),
PAGE_WIDTH,
50.0,
TextFont::Sans,
[0.0; 4],
@@ -509,6 +513,7 @@ mod tests {
let mut edit = TextEditing::new_at(
1,
glam::Vec2::new(400.0, 400.0),
PAGE_WIDTH,
50.0,
TextFont::Sans,
[0.0; 4],
+18 -7
View File
@@ -929,27 +929,37 @@ const PAGE_TILE: egui::Vec2 = egui::vec2(66.0, 66.0 * PAGE_HEIGHT / PAGE_WIDTH +
/// Höchstzahl gezeichneter Punkte pro Vorschau (dünnt volle Seiten aus).
const THUMBNAIL_POINT_BUDGET: usize = 1500;
/// Zeichenbefehle für die verkleinerte Vorschau einer Seite in `rect` (A4-Format) in der
/// Papierfarbe `paper`. Striche werden als Linien gezeichnet und bei vielen Punkten
/// gleichmäßig ausgedünnt; `adapt_ink` hellt dunkle Tinte wie auf dem Blatt auf.
/// Zeichenbefehle für die verkleinerte Vorschau einer Seite in `rect` in der Papierfarbe
/// `paper`: so groß wie möglich, waagerecht zentriert und oben (Querformat-Seiten sind
/// flacher). Striche werden als Linien gezeichnet und bei vielen Punkten gleichmäßig
/// ausgedünnt; `adapt_ink` hellt dunkle Tinte wie auf dem Blatt auf.
pub fn page_thumbnail_shapes(
page: &Page,
rect: egui::Rect,
paper: PaperTheme,
adapt_ink: bool,
) -> Vec<egui::Shape> {
let scale = (rect.width() / page.size.x).min(rect.height() / page.size.y);
let size = egui::vec2(page.size.x, page.size.y) * scale;
let sheet = egui::Rect::from_min_size(
rect.min + egui::vec2((rect.width() - size.x) / 2.0, 0.0),
size,
);
let mut shapes = vec![egui::Shape::rect_filled(
rect,
sheet,
egui::Rounding::same(3.0),
linear_color32(paper.paper_color()),
)];
let adapt = adapt_ink && paper.is_dark();
let scale = rect.width() / PAGE_WIDTH;
let to_screen = |p: glam::Vec2| rect.min + egui::vec2(p.x * scale, p.y * scale);
let to_screen = |p: glam::Vec2| sheet.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();
let points: Vec<egui::Pos2> = pdf
.corners(page.size)
.iter()
.map(|&c| to_screen(c))
.collect();
shapes.push(egui::Shape::convex_polygon(
points,
egui::Color32::from_rgb(236, 238, 243),
@@ -3763,6 +3773,7 @@ mod tests {
state.text_edit = Some(TextEditing::new_at(
notebook.current_page().id,
glam::Vec2::new(200.0, 300.0),
PAGE_WIDTH,
42.0,
TextFont::Sans,
[0.0, 0.0, 0.0, 1.0],
+158 -65
View File
@@ -3,7 +3,7 @@
//! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel),
//! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]).
use crate::document::{PageViewport, PAGE_HEIGHT, PAGE_WIDTH, UNITS_PER_MM};
use crate::document::{PageViewport, UNITS_PER_MM};
use glam::{Mat4, Vec2, Vec4};
/// Minimaler und maximaler Zoomfaktor.
@@ -12,43 +12,89 @@ pub const MAX_ZOOM: f32 = 10.0;
/// Abstand zwischen zwei Seiten in der durchgehenden Ansicht (8 mm).
pub const PAGE_GAP: f32 = 8.0 * UNITS_PER_MM;
/// Vertikaler Abstand gleicher Punkte zweier aufeinanderfolgender Seiten.
pub const PAGE_PITCH: f32 = PAGE_HEIGHT + PAGE_GAP;
/// Höchstens so viele Nachbarseiten werden gleichzeitig dargestellt.
pub const MAX_NEIGHBOR_PAGES: usize = 12;
/// Durchgehende Ansicht: Die Seiten hängen untereinander, Koordinaten beziehen sich auf die
/// aktuelle Seite. Liefert den Versatz (in Seiten) der Seite, auf der die Höhe `y` liegt,
/// oder `None` im Zwischenraum zweier Seiten.
pub fn page_offset_at(y: f32) -> Option<i64> {
let k = (y / PAGE_PITCH).floor();
(y - k * PAGE_PITCH <= PAGE_HEIGHT).then_some(k as i64)
/// Durchgehende Ansicht: Die Seiten hängen mit [`PAGE_GAP`] Abstand untereinander und sind
/// waagerecht zueinander zentriert. Lage jeder Seite (Ecke oben links) in Koordinaten der
/// aktuellen Seite.
#[derive(Debug, Clone, PartialEq)]
pub struct PageStack {
pub origins: Vec<Vec2>,
pub sizes: Vec<Vec2>,
pub current: usize,
}
/// Versätze der Seiten, die zwischen den Höhen `top` und `bottom` (Koordinaten der aktuellen
/// Seite) sichtbar sind, begrenzt auf die Seitenindizes `0..page_count` und ohne die aktuelle
/// Seite selbst.
pub fn visible_neighbor_offsets(
top: f32,
bottom: f32,
current: usize,
page_count: usize,
) -> Vec<i64> {
impl PageStack {
/// Stapel aus den Seitengrößen; `current` ist die Bezugsseite.
pub fn new(sizes: Vec<Vec2>, current: usize) -> Self {
let current = current.min(sizes.len().saturating_sub(1));
let width = sizes.get(current).map_or(0.0, |s| s.x);
let mut origins = vec![Vec2::ZERO; sizes.len()];
let x = |i: usize| (width - sizes[i].x) / 2.0;
for i in current + 1..sizes.len() {
let y = origins[i - 1].y + sizes[i - 1].y + PAGE_GAP;
origins[i] = Vec2::new(x(i), y);
}
for i in (0..current).rev() {
let y = origins[i + 1].y - PAGE_GAP - sizes[i].y;
origins[i] = Vec2::new(x(i), y);
}
Self {
origins,
sizes,
current,
}
}
pub fn len(&self) -> usize {
self.sizes.len()
}
pub fn is_empty(&self) -> bool {
self.sizes.is_empty()
}
/// Seite, auf deren Höhe `y` liegt; `None` im Zwischenraum oder außerhalb.
pub fn page_at(&self, y: f32) -> Option<usize> {
// Seiten liegen nach Index sortiert untereinander
let i = self.origins.partition_point(|o| o.y <= y).checked_sub(1)?;
(y <= self.origins[i].y + self.sizes[i].y).then_some(i)
}
/// Seiten (außer der aktuellen), die zwischen den Höhen `top` und `bottom` sichtbar sind;
/// sehr weit herausgezoomt nur die [`MAX_NEIGHBOR_PAGES`] nächstgelegenen.
pub fn visible_neighbors(&self, top: f32, bottom: f32) -> Vec<usize> {
if !(top.is_finite() && bottom.is_finite()) || bottom < top {
return Vec::new();
}
let first = (top / PAGE_PITCH).floor().max(-(current as f32)) as i64;
let last = (bottom / PAGE_PITCH)
.floor()
.min((page_count as f32) - 1.0 - current as f32) as i64;
let mut offsets: Vec<i64> = (first..=last).filter(|&k| k != 0).collect();
// Sehr weit herausgezoomt: nur die nächstgelegenen Seiten
if offsets.len() > MAX_NEIGHBOR_PAGES {
offsets.sort_by_key(|k| k.unsigned_abs());
offsets.truncate(MAX_NEIGHBOR_PAGES);
offsets.sort_unstable();
let first = self
.origins
.iter()
.zip(&self.sizes)
.position(|(o, s)| o.y + s.y >= top);
let Some(first) = first else {
return Vec::new();
};
let mut pages: Vec<usize> = (first..self.len())
.take_while(|&i| self.origins[i].y <= bottom)
.filter(|&i| i != self.current)
.collect();
if pages.len() > MAX_NEIGHBOR_PAGES {
pages.sort_by_key(|&i| i.abs_diff(self.current));
pages.truncate(MAX_NEIGHBOR_PAGES);
pages.sort_unstable();
}
pages
}
/// Kleinstes Rechteck um alle Seiten (min, max).
pub fn bounds(&self) -> (Vec2, Vec2) {
self.origins.iter().zip(&self.sizes).fold(
(Vec2::splat(f32::INFINITY), Vec2::splat(f32::NEG_INFINITY)),
|(min, max), (&o, &s)| (min.min(o), max.max(o + s)),
)
}
offsets
}
/// Verwaltet den Viewport-Kamerazustand für stufenlosen Zoom und Pan.
@@ -80,10 +126,13 @@ impl ViewportState {
self.pan_offset = [0.0, 0.0];
}
/// Durchgehende Ansicht: Die Seite `k` Seiten weiter wird zur Bezugsseite, ohne dass sich
/// der Bildausschnitt bewegt.
pub fn shift_reference_page(&mut self, k: i64) {
self.pan_offset[1] += k as f32 * PAGE_PITCH;
/// Durchgehende Ansicht: Die Seite mit der Ecke `origin` (Koordinaten der bisherigen
/// Bezugsseite) wird zur Bezugsseite, ohne dass sich der Bildausschnitt bewegt. Waagerecht
/// zentriert [`PageViewport`] die neue Seite bereits (ungezoomt); der Pan gleicht nur den
/// Zoomanteil aus.
pub fn shift_reference_page(&mut self, origin: Vec2) {
self.pan_offset[0] += origin.x * (1.0 - 1.0 / self.zoom.max(1e-6));
self.pan_offset[1] += origin.y;
}
/// Durchgehende Ansicht: Verschiebt den Ausschnitt so, dass der Bildschirmpunkt `center`
@@ -92,14 +141,15 @@ impl ViewportState {
pub fn clamp_continuous_center(
&mut self,
center: [f32; 2],
current: usize,
page_count: usize,
stack: &PageStack,
page_viewport: &PageViewport,
) -> bool {
if stack.is_empty() {
return false;
}
let c = self.screen_to_page_unclamped(center[0], center[1], page_viewport);
let top = -(current as f32) * PAGE_PITCH;
let bottom = page_count.saturating_sub(1 + current) as f32 * PAGE_PITCH + PAGE_HEIGHT;
let clamped = Vec2::new(c.x.clamp(0.0, PAGE_WIDTH), c.y.clamp(top, bottom.max(top)));
let (min, max) = stack.bounds();
let clamped = c.clamp(min, max.max(min));
if (clamped - c).length() < 1e-3 {
return false;
}
@@ -116,7 +166,7 @@ impl ViewportState {
delta_y: f32,
page_viewport: &PageViewport,
) {
let base_scale = page_viewport.page_width / PAGE_WIDTH;
let base_scale = page_viewport.scale;
if base_scale * self.zoom > 1e-6 {
self.pan_offset[0] += delta_x / (base_scale * self.zoom);
self.pan_offset[1] += delta_y / (base_scale * self.zoom);
@@ -138,7 +188,7 @@ impl ViewportState {
return;
}
let base_scale = page_viewport.page_width / PAGE_WIDTH;
let base_scale = page_viewport.scale;
// Bildschirmposition des Seitenursprungs (0, 0)
let old_offset_x = page_viewport.page_x + self.pan_offset[0] * base_scale * old_zoom;
@@ -160,7 +210,7 @@ impl ViewportState {
screen_y: f32,
page_viewport: &PageViewport,
) -> Vec2 {
let base_scale = page_viewport.page_width / PAGE_WIDTH;
let base_scale = page_viewport.scale;
let effective_scale = base_scale * self.zoom;
if effective_scale < 1e-6 {
return Vec2::ZERO;
@@ -182,7 +232,8 @@ impl ViewportState {
page_viewport: &PageViewport,
) -> Option<Vec2> {
let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport);
if (0.0..=PAGE_WIDTH).contains(&pt.x) && (0.0..=PAGE_HEIGHT).contains(&pt.y) {
let size = page_viewport.page_size;
if (0.0..=size.x).contains(&pt.x) && (0.0..=size.y).contains(&pt.y) {
Some(pt)
} else {
None
@@ -197,7 +248,7 @@ impl ViewportState {
page_viewport: &PageViewport,
) -> Vec2 {
let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport);
Vec2::new(pt.x.clamp(0.0, PAGE_WIDTH), pt.y.clamp(0.0, PAGE_HEIGHT))
pt.clamp(Vec2::ZERO, page_viewport.page_size)
}
/// Transformiert einen DIN-A4-Seitenpunkt in Fenster-Pixelkoordinaten.
@@ -212,7 +263,7 @@ impl ViewportState {
page_y: f32,
page_viewport: &PageViewport,
) -> [f32; 2] {
let base_scale = page_viewport.page_width / PAGE_WIDTH;
let base_scale = page_viewport.scale;
let effective_scale = base_scale * self.zoom;
let screen_x = page_viewport.page_x + (page_x + self.pan_offset[0]) * effective_scale;
let screen_y = page_viewport.page_y + (page_y + self.pan_offset[1]) * effective_scale;
@@ -222,7 +273,7 @@ impl ViewportState {
/// Berechnet die Shader-Transformationsparameter (Scale, Offset) für den Vertex-Shader,
/// um DIN-A4-Seitenkoordinaten direkt in den GPU-NDC-Bereich [-1.0, 1.0] abzubilden.
pub fn ndc_transform(&self, page_viewport: &PageViewport) -> ([f32; 2], [f32; 2]) {
let base_scale = page_viewport.page_width / PAGE_WIDTH;
let base_scale = page_viewport.scale;
let effective_scale = base_scale * self.zoom;
// ndc_x = (screen_x / window_width) * 2.0 - 1.0
@@ -260,30 +311,54 @@ impl ViewportState {
mod tests {
use super::*;
const A4: Vec2 = crate::document::A4_SIZE;
const PITCH: f32 = A4.y + PAGE_GAP;
fn a4_stack(count: usize, current: usize) -> PageStack {
PageStack::new(vec![A4; count], current)
}
#[test]
fn test_continuous_layout() {
assert_eq!(page_offset_at(100.0), Some(0));
assert_eq!(page_offset_at(PAGE_HEIGHT + PAGE_GAP / 2.0), None);
assert_eq!(page_offset_at(PAGE_PITCH + 5.0), Some(1));
assert_eq!(page_offset_at(-5.0), None);
assert_eq!(page_offset_at(-PAGE_GAP - 5.0), Some(-1));
let stack = a4_stack(5, 1);
assert_eq!(stack.page_at(100.0), Some(1));
assert_eq!(stack.page_at(A4.y + PAGE_GAP / 2.0), None);
assert_eq!(stack.page_at(PITCH + 5.0), Some(2));
assert_eq!(stack.page_at(-5.0), None);
assert_eq!(stack.page_at(-PAGE_GAP - 5.0), Some(0));
assert_eq!(stack.page_at(-PITCH - 5.0), None, "vor der ersten Seite");
// Ende von Seite 2 und Anfang von Seite 3 sichtbar (aktuell: Seite 2 von 5)
assert_eq!(
visible_neighbor_offsets(PAGE_HEIGHT - 500.0, PAGE_PITCH + 500.0, 1, 5),
vec![1]
stack.visible_neighbors(A4.y - 500.0, PITCH + 500.0),
vec![2]
);
// Ganz oben im Notizbuch: keine Seite vor der ersten
assert!(visible_neighbor_offsets(-PAGE_PITCH * 3.0, 500.0, 0, 5).is_empty());
assert!(a4_stack(5, 0)
.visible_neighbors(-PITCH * 3.0, 500.0)
.is_empty());
// Hinter der letzten Seite: keine weiteren
assert_eq!(
visible_neighbor_offsets(-PAGE_PITCH, PAGE_PITCH * 4.0, 3, 5),
vec![-1, 1]
a4_stack(5, 3).visible_neighbors(-PITCH, PITCH * 4.0),
vec![2, 4]
);
// Weit herausgezoomt: auf die nächstgelegenen Seiten begrenzt
let many = visible_neighbor_offsets(-PAGE_PITCH * 50.0, PAGE_PITCH * 50.0, 50, 200);
let many = a4_stack(200, 50).visible_neighbors(-PITCH * 50.0, PITCH * 50.0);
assert_eq!(many.len(), MAX_NEIGHBOR_PAGES);
assert!(many.iter().all(|k| k.unsigned_abs() <= 6));
assert!(many.iter().all(|i| i.abs_diff(50) <= 6));
}
#[test]
fn test_stack_of_different_page_sizes() {
let slide = Vec2::new(2970.0, 1671.0);
let stack = PageStack::new(vec![slide, A4, slide], 1);
// Folien waagerecht zur A4-Seite zentriert, mit Abstand darüber und darunter
assert_eq!(stack.origins[0], Vec2::new(-435.0, -PAGE_GAP - slide.y));
assert_eq!(stack.origins[2], Vec2::new(-435.0, A4.y + PAGE_GAP));
assert_eq!(stack.page_at(A4.y + PAGE_GAP + 10.0), Some(2));
let (min, max) = stack.bounds();
assert_eq!(min, Vec2::new(-435.0, -PAGE_GAP - slide.y));
assert_eq!(max, Vec2::new(2535.0, A4.y + PAGE_GAP + slide.y));
}
#[test]
@@ -292,33 +367,51 @@ mod tests {
let page_vp = PageViewport::calculate(1920.0, 1080.0, 24.0);
vp.pan_by_screen_delta(0.0, -900.0, &page_vp);
// Punkt oben auf der nächsten Seite, in Koordinaten der aktuellen Seite
let before = vp.page_to_screen(300.0, PAGE_PITCH + 200.0, &page_vp);
vp.shift_reference_page(1);
let before = vp.page_to_screen(300.0, PITCH + 200.0, &page_vp);
vp.shift_reference_page(a4_stack(2, 0).origins[1]);
let after = vp.page_to_screen(300.0, 200.0, &page_vp);
assert!((before[0] - after[0]).abs() < 0.01 && (before[1] - after[1]).abs() < 0.01);
}
#[test]
fn test_shift_to_page_of_other_size_keeps_view_when_zoomed() {
let slide = Vec2::new(2970.0, 1671.0);
let fit = Vec2::new(2970.0, 2970.0);
let view_a4 = PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), fit, A4);
let view_slide = PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), fit, slide);
let stack = PageStack::new(vec![A4, slide], 0);
let mut vp = ViewportState::new();
vp.zoom_at_cursor([500.0, 300.0], 2.5, &view_a4);
vp.pan_by_screen_delta(-40.0, -700.0, &view_a4);
let point = Vec2::new(900.0, 300.0);
let before = vp.page_to_screen_vec2(stack.origins[1] + point, &view_a4);
vp.shift_reference_page(stack.origins[1]);
let after = vp.page_to_screen_vec2(point, &view_slide);
assert!((before[0] - after[0]).abs() < 0.01 && (before[1] - after[1]).abs() < 0.01);
}
#[test]
fn test_continuous_scroll_stays_on_the_notebook() {
let page_vp = PageViewport::calculate(1280.0, 850.0, 24.0);
let center = [640.0, 425.0];
let stack = a4_stack(3, 1);
let mut vp = ViewportState::new();
// Mitte der Seite: nichts zu korrigieren
assert!(!vp.clamp_continuous_center(center, 1, 3, &page_vp));
assert!(!vp.clamp_continuous_center(center, &stack, &page_vp));
// Weit über die erste Seite hinaus nach oben gescrollt (aktuell: Seite 2 von 3)
vp.pan_by_screen_delta(0.0, 5000.0, &page_vp);
assert!(vp.clamp_continuous_center(center, 1, 3, &page_vp));
assert!(vp.clamp_continuous_center(center, &stack, &page_vp));
let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp);
assert!(
(c.y + PAGE_PITCH).abs() < 0.1,
(c.y + PITCH).abs() < 0.1,
"Oberkante der ersten Seite: {c:?}"
);
// Weit nach unten: Unterkante der letzten Seite
vp.pan_by_screen_delta(-3000.0, -20000.0, &page_vp);
assert!(vp.clamp_continuous_center(center, 1, 3, &page_vp));
assert!(vp.clamp_continuous_center(center, &stack, &page_vp));
let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp);
assert!((c.y - (PAGE_PITCH + PAGE_HEIGHT)).abs() < 0.1, "{c:?}");
assert!((c.x - PAGE_WIDTH).abs() < 0.1, "{c:?}");
assert!((c.y - (PITCH + A4.y)).abs() < 0.1, "{c:?}");
assert!((c.x - A4.x).abs() < 0.1, "{c:?}");
}
#[test]