perf: simplify stroke outlines before PDF export
Each stroke mesh has about 16 points per pen sample, and every one of them went into the PDF as a polygon point: a full handwritten page (1650 strokes) took 2.9 s to export, and PNG export and copying a selection as an image took as long, because both go through the PDF. The outline is now simplified with a closed Douglas-Peucker that measures against segments, at most 0.01 mm from the drawn outline. The same page now takes 0.44 s (PNG 0.55 s), the PDF shrinks from 10.7 MB to 1.4 MB, and the rendered PNG differs only in edge anti-aliasing (max 21/255). INK_NOTES_EXACT_OUTLINES=1 exports every point as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
330c29193f
commit
955eaa4095
4 files changed
+161
-17
No files matched your search
@@ -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.
|
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).
|
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.
|
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.
|
Importierte PDF-Seiten werden anschließend als Form-XObject unter die Seite gelegt (`pdf_merge.rs`), bleiben also Vektorgrafik. Seitentitel werden zu Lesezeichen.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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).
|
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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+30
-15
@@ -1222,21 +1222,25 @@ fn render_stroke_polygon(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
|||||||
|
|
||||||
// Geschlossener Pfad um den Ribbon-Rand:
|
// Geschlossener Pfad um den Ribbon-Rand:
|
||||||
// Left[0] -> Left[1] -> ... -> Left[n-1] -> Right[n-1] -> Right[n-2] -> ... -> Right[0]
|
// Left[0] -> Left[1] -> ... -> Left[n-1] -> Right[n-1] -> Right[n-2] -> ... -> Right[0]
|
||||||
let mut ring = Vec::with_capacity(n * 2);
|
let outline: Vec<glam::Vec2> = (0..n)
|
||||||
|
.map(|i| stroke.vertices[i * 2].position)
|
||||||
// 1. Linke Hüllkurve vorwärts
|
.chain((0..n).rev().map(|i| stroke.vertices[i * 2 + 1].position))
|
||||||
for i in 0..n {
|
.map(glam::Vec2::from)
|
||||||
let v = stroke.vertices[i * 2];
|
.collect();
|
||||||
let (x, y) = page_to_pdf_mm(v.position[0], v.position[1]);
|
// The mesh has about 16 points per pen sample, far more than the eye (or a printer) can
|
||||||
ring.push((Point::new(Mm(x), Mm(y)), false));
|
// tell apart; fewer points make the PDF, PNG export and copying as an image much faster
|
||||||
}
|
let outline = if exact_outlines() {
|
||||||
|
outline
|
||||||
// 2. Rechte Hüllkurve rückwärts
|
} else {
|
||||||
for i in (0..n).rev() {
|
crate::geometry::simplify_closed_outline(&outline, OUTLINE_TOLERANCE)
|
||||||
let v = stroke.vertices[i * 2 + 1];
|
};
|
||||||
let (x, y) = page_to_pdf_mm(v.position[0], v.position[1]);
|
let ring: Vec<(Point, bool)> = outline
|
||||||
ring.push((Point::new(Mm(x), Mm(y)), false));
|
.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(
|
let fill_color = Color::Rgb(Rgb::new(
|
||||||
stroke.color[0],
|
stroke.color[0],
|
||||||
@@ -1254,6 +1258,17 @@ fn render_stroke_polygon(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
|||||||
layer.add_polygon(poly);
|
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<bool> = 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.
|
/// Strategie B: Kubische Bézier-Kurven aus den Abtastpunkten via printpdf::Line.
|
||||||
fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
||||||
if stroke.points.is_empty() {
|
if stroke.points.is_empty() {
|
||||||
|
|||||||
+129
@@ -352,6 +352,63 @@ fn rdp_recursive(points: &[Vec2], epsilon: f32) -> Vec<Vec2> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<Vec2> {
|
||||||
|
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`.
|
/// Abstand eines Punktes `p` zur Geraden durch `a` und `b`.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn point_to_line_dist(p: Vec2, a: Vec2, b: Vec2) -> f32 {
|
pub fn point_to_line_dist(p: Vec2, a: Vec2, b: Vec2) -> f32 {
|
||||||
@@ -2327,6 +2384,78 @@ pub fn shape_to_stroke(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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<StylusSample> = (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<Vec2> = (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<Vec2> = (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]
|
#[test]
|
||||||
fn test_detect_line() {
|
fn test_detect_line() {
|
||||||
// Leicht zittrige diagonale Punktfolge
|
// Leicht zittrige diagonale Punktfolge
|
||||||
|
|||||||
Reference in new issue
Block a user