diff --git a/docs/ARCHITECTURE.de.md b/docs/ARCHITECTURE.de.md index 667e661..66f5850 100644 --- a/docs/ARCHITECTURE.de.md +++ b/docs/ARCHITECTURE.de.md @@ -195,7 +195,7 @@ Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ers 2. **PDF** (nur im Notfall): importierte PDF-Seite als Bild, falls sie sich nicht als Vektor übernehmen lässt. 3. **Images:** eingefügte Bilder (höchstens 300 dpi). 4. **Text:** Textfelder als echter Text mit eingebetteter Schrift (durchsuchbar, kopierbar). Jedes Feld liegt in einem eigenen Koordinatensystem, das eine Transformationsmatrix auf seine Ecken abbildet. -5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade. Halbtransparente Striche (Textmarker) bekommen einen Grafikzustand mit Deckkraft (`ca`/`CA`); printpdf kann solche Zustände nicht anlegen, deshalb trägt `add_alpha_states` sie nach dem Speichern mit `lopdf` in die Seitenressourcen ein. Jeder Strich ist ein einziger Pfad und deckt sich daher nicht selbst doppelt. +5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade. Halbtransparente Striche (Textmarker) bekommen einen Grafikzustand mit Deckkraft (`ca`/`CA`); printpdf kann solche Zustände nicht anlegen, deshalb trägt `add_alpha_states` sie nach dem Speichern mit `lopdf` in die Seitenressourcen ein. Jeder Strich ist ein einziger Pfad und deckt sich daher nicht selbst doppelt. Der Umriss eines Strichs wird vor dem Export vereinfacht (`geometry::simplify_closed_outline`, höchstens 0,01 mm vom gezeichneten entfernt): Das Netz hat etwa 16 Punkte je Stiftpunkt, und mit allen dauerte der Export einer vollen Seite Sekunden. `INK_NOTES_EXACT_OUTLINES=1` exportiert wie bisher jeden Punkt. Importierte PDF-Seiten werden anschließend als Form-XObject unter die Seite gelegt (`pdf_merge.rs`), bleiben also Vektorgrafik. Seitentitel werden zu Lesezeichen. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c1018a2..eb1b2c8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -195,7 +195,7 @@ Each page has its own history (`history.rs`): adding, removing, replacing and mo 2. **PDF** (fallback only): imported PDF page as an image, if it cannot be taken over as vectors. 3. **Images:** inserted images (at most 300 dpi). 4. **Text:** text boxes as real text with embedded font (searchable, copyable). Each box lives in its own coordinate system that a transformation matrix maps onto its corners. -5. **Ink:** strokes as filled polygons, shapes as paths. Semi-transparent strokes (highlighter) get a graphics state with opacity (`ca`/`CA`); printpdf cannot create such states, so `add_alpha_states` adds them to the page resources with `lopdf` after saving. Each stroke is a single path and therefore does not cover itself twice. +5. **Ink:** strokes as filled polygons, shapes as paths. Semi-transparent strokes (highlighter) get a graphics state with opacity (`ca`/`CA`); printpdf cannot create such states, so `add_alpha_states` adds them to the page resources with `lopdf` after saving. Each stroke is a single path and therefore does not cover itself twice. The outline of a stroke is simplified before export (`geometry::simplify_closed_outline`, at most 0.01 mm away from the drawn one): the mesh has about 16 points per pen sample, and with all of them a full page took seconds to export. `INK_NOTES_EXACT_OUTLINES=1` exports every point as before. Imported PDF pages are then placed under the page as a form XObject (`pdf_merge.rs`), so they stay vector graphics. Page titles become bookmarks. diff --git a/src/export.rs b/src/export.rs index 1e132e7..83e9846 100644 --- a/src/export.rs +++ b/src/export.rs @@ -1222,21 +1222,25 @@ fn render_stroke_polygon(layer: &printpdf::PdfLayerReference, stroke: &Stroke) { // Geschlossener Pfad um den Ribbon-Rand: // Left[0] -> Left[1] -> ... -> Left[n-1] -> Right[n-1] -> Right[n-2] -> ... -> Right[0] - let mut ring = Vec::with_capacity(n * 2); - - // 1. Linke Hüllkurve vorwärts - for i in 0..n { - let v = stroke.vertices[i * 2]; - let (x, y) = page_to_pdf_mm(v.position[0], v.position[1]); - ring.push((Point::new(Mm(x), Mm(y)), false)); - } - - // 2. Rechte Hüllkurve rückwärts - for i in (0..n).rev() { - let v = stroke.vertices[i * 2 + 1]; - let (x, y) = page_to_pdf_mm(v.position[0], v.position[1]); - ring.push((Point::new(Mm(x), Mm(y)), false)); - } + let outline: Vec = (0..n) + .map(|i| stroke.vertices[i * 2].position) + .chain((0..n).rev().map(|i| stroke.vertices[i * 2 + 1].position)) + .map(glam::Vec2::from) + .collect(); + // The mesh has about 16 points per pen sample, far more than the eye (or a printer) can + // tell apart; fewer points make the PDF, PNG export and copying as an image much faster + let outline = if exact_outlines() { + outline + } else { + crate::geometry::simplify_closed_outline(&outline, OUTLINE_TOLERANCE) + }; + let ring: Vec<(Point, bool)> = outline + .iter() + .map(|p| { + let (x, y) = page_to_pdf_mm(p.x, p.y); + (Point::new(Mm(x), Mm(y)), false) + }) + .collect(); let fill_color = Color::Rgb(Rgb::new( stroke.color[0], @@ -1254,6 +1258,17 @@ fn render_stroke_polygon(layer: &printpdf::PdfLayerReference, stroke: &Stroke) { layer.add_polygon(poly); } +/// Largest deviation of an exported stroke outline from the drawn one, in page units +/// (0.01 mm: a tenth of a pixel at 200 dpi). +pub const OUTLINE_TOLERANCE: f32 = 0.1; + +/// `INK_NOTES_EXACT_OUTLINES=1` exports every point of the stroke outlines, as before this change +/// (fallback in case the simplified outlines ever look wrong). +fn exact_outlines() -> bool { + static EXACT: std::sync::OnceLock = std::sync::OnceLock::new(); + *EXACT.get_or_init(|| std::env::var_os("INK_NOTES_EXACT_OUTLINES").is_some_and(|v| v != "0")) +} + /// Strategie B: Kubische Bézier-Kurven aus den Abtastpunkten via printpdf::Line. fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) { if stroke.points.is_empty() { diff --git a/src/geometry.rs b/src/geometry.rs index 8578bce..e7812e0 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -352,6 +352,63 @@ fn rdp_recursive(points: &[Vec2], epsilon: f32) -> Vec { } } +/// Simplifies a closed outline (the last point connects back to the first) so that no point +/// of the original lies farther than `tolerance` from the result. Unlike +/// [`ramer_douglas_peucker`], it measures the distance to the segment, not to the infinite +/// line, so stroke tips that turn back are kept. Works without recursion, so long strokes +/// cannot overflow the stack. +pub fn simplify_closed_outline(points: &[Vec2], tolerance: f32) -> Vec { + if points.len() <= 3 { + return points.to_vec(); + } + // Two chains between the first point and the point farthest from it: the start of an + // outline often lies on top of its end (round cap), which would leave no chord to measure + let far = (1..points.len()) + .max_by(|&i, &j| { + points[i] + .distance_squared(points[0]) + .total_cmp(&points[j].distance_squared(points[0])) + }) + .unwrap_or(1); + let mut keep = vec![false; points.len()]; + keep[0] = true; + keep[far] = true; + let mut stack = vec![(0, far), (far, points.len())]; + while let Some((start, end)) = stack.pop() { + // `end == points.len()` closes the ring back to the first point + let (a, b) = (points[start], points[end % points.len()]); + let mut worst = (0.0_f32, start); + for (i, &p) in points.iter().enumerate().take(end).skip(start + 1) { + let d = point_to_segment_dist(p, a, b); + if d > worst.0 { + worst = (d, i); + } + } + if worst.0 > tolerance { + keep[worst.1] = true; + stack.push((start, worst.1)); + stack.push((worst.1, end)); + } + } + points + .iter() + .zip(keep) + .filter_map(|(&p, keep)| keep.then_some(p)) + .collect() +} + +/// Abstand eines Punktes `p` zur Strecke von `a` nach `b`. +#[inline] +pub fn point_to_segment_dist(p: Vec2, a: Vec2, b: Vec2) -> f32 { + let ab = b - a; + let len_sq = ab.length_squared(); + if len_sq < 1e-12 { + return p.distance(a); + } + let t = ((p - a).dot(ab) / len_sq).clamp(0.0, 1.0); + p.distance(a + ab * t) +} + /// Abstand eines Punktes `p` zur Geraden durch `a` und `b`. #[inline] pub fn point_to_line_dist(p: Vec2, a: Vec2, b: Vec2) -> f32 { @@ -2327,6 +2384,78 @@ pub fn shape_to_stroke( mod tests { use super::*; + /// Largest distance of any point of `original` from the closed outline `simplified`. + fn outline_deviation(original: &[Vec2], simplified: &[Vec2]) -> f32 { + original + .iter() + .map(|&p| { + (0..simplified.len()) + .map(|i| { + point_to_segment_dist( + p, + simplified[i], + simplified[(i + 1) % simplified.len()], + ) + }) + .fold(f32::INFINITY, f32::min) + }) + .fold(0.0, f32::max) + } + + #[test] + fn test_simplified_stroke_outline_stays_within_tolerance() { + // A handwritten loop like a letter, as the pen delivers it + let samples: Vec = (0..30) + .map(|i| { + let t = i as f32 / 29.0 * std::f32::consts::TAU * 1.3; + StylusSample { + position: Vec2::new(500.0 + 12.0 * t.cos() + t * 4.0, 800.0 + 20.0 * t.sin()), + pressure: 0.4 + 0.3 * (t * 0.7).sin().abs(), + timestamp: i as f64 * 0.008, + } + }) + .collect(); + let vertices = crate::storage::reconstruct_stroke_vertices( + &samples, + [0.0, 0.0, 0.0, 1.0], + 5.0, + Default::default(), + true, + ); + let n = vertices.len() / 2; + let outline: Vec = (0..n) + .map(|i| vertices[i * 2].position) + .chain((0..n).rev().map(|i| vertices[i * 2 + 1].position)) + .map(Vec2::from) + .collect(); + let tolerance = 0.1; + let simplified = simplify_closed_outline(&outline, tolerance); + let deviation = outline_deviation(&outline, &simplified); + assert!(deviation <= tolerance + 1e-4, "{deviation}"); + assert!( + simplified.len() * 4 < outline.len(), + "{} -> {} points", + outline.len(), + simplified.len() + ); + } + + #[test] + fn test_simplified_outline_keeps_tips_that_turn_back() { + // Thin hairpin: out along y = 0 to x = 20, back along y = 0.05. Measured against the + // infinite line, the tip would vanish. + let mut outline: Vec = (0..=20).map(|x| Vec2::new(x as f32, 0.0)).collect(); + outline.extend((0..20).rev().map(|x| Vec2::new(x as f32, 0.05))); + let simplified = simplify_closed_outline(&outline, 0.1); + let reach = simplified.iter().map(|p| p.x).fold(f32::MIN, f32::max); + assert_eq!(reach, 20.0, "{simplified:?}"); + assert!(outline_deviation(&outline, &simplified) <= 0.1); + assert!(simplified.len() <= 4, "{simplified:?}"); + // Tiny outlines stay as they are + let dot = [Vec2::ZERO, Vec2::X, Vec2::Y]; + assert_eq!(simplify_closed_outline(&dot, 0.1), dot.to_vec()); + } + #[test] fn test_detect_line() { // Leicht zittrige diagonale Punktfolge