fix: robustness fixes from the project review
- autosave: a manifest that failed to save is retried with backoff instead of waiting for the next edit - import: NaN and infinite numbers from broken files no longer reach the page file (they were stored as null and made the page unreadable) - import: SVG images cannot load local files, Rnote page count is capped - pdf: a panic in the PDF library fails one page or import instead of ending the render thread or the app - storage: remove the temporary file when writing it fails Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
c2652ea05b
commit
a22f081115
6 files changed
+211
-22
No files matched your search
+71
-5
@@ -228,6 +228,8 @@ pub struct AutosaveController {
|
|||||||
pending_pages: HashMap<u64, PageData>,
|
pending_pages: HashMap<u64, PageData>,
|
||||||
pending_manifest: Option<NotebookManifest>,
|
pending_manifest: Option<NotebookManifest>,
|
||||||
retries: HashMap<u64, PageRetry>,
|
retries: HashMap<u64, PageRetry>,
|
||||||
|
/// Backoff of the manifest after a failed write
|
||||||
|
manifest_retry: Option<PageRetry>,
|
||||||
last_errors: Vec<String>,
|
last_errors: Vec<String>,
|
||||||
status: AutosaveStatus,
|
status: AutosaveStatus,
|
||||||
in_flight_count: usize,
|
in_flight_count: usize,
|
||||||
@@ -281,6 +283,7 @@ impl AutosaveController {
|
|||||||
pending_pages: HashMap::new(),
|
pending_pages: HashMap::new(),
|
||||||
pending_manifest: None,
|
pending_manifest: None,
|
||||||
retries: HashMap::new(),
|
retries: HashMap::new(),
|
||||||
|
manifest_retry: None,
|
||||||
last_errors: Vec::new(),
|
last_errors: Vec::new(),
|
||||||
status: AutosaveStatus::Saved,
|
status: AutosaveStatus::Saved,
|
||||||
in_flight_count: 0,
|
in_flight_count: 0,
|
||||||
@@ -428,16 +431,25 @@ impl AutosaveController {
|
|||||||
retry.backoff = (retry.backoff * 2).min(Duration::from_secs(30));
|
retry.backoff = (retry.backoff * 2).min(Duration::from_secs(30));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
AutosaveResponse::ManifestSaved { result } => {
|
AutosaveResponse::ManifestSaved { result } => match result {
|
||||||
if let Err((err_msg, failed_manifest)) = result {
|
Ok(()) => self.manifest_retry = None,
|
||||||
|
Err((err_msg, failed_manifest)) => {
|
||||||
eprintln!("[Autosave-Fehler] Manifest fehlgeschlagen: {err_msg}");
|
eprintln!("[Autosave-Fehler] Manifest fehlgeschlagen: {err_msg}");
|
||||||
self.status = AutosaveStatus::Error;
|
self.status = AutosaveStatus::Error;
|
||||||
self.last_errors.push(t!("Manifest: {msg}", msg = err_msg));
|
self.last_errors.push(t!("Manifest: {msg}", msg = err_msg));
|
||||||
if self.pending_manifest.is_none() {
|
if self.pending_manifest.is_none() {
|
||||||
self.pending_manifest = Some(*failed_manifest);
|
self.pending_manifest = Some(*failed_manifest);
|
||||||
}
|
}
|
||||||
|
// Dispatching reset the debounce timers, so without a retry time of
|
||||||
|
// its own the manifest would wait for the next edit
|
||||||
|
let retry = self.manifest_retry.get_or_insert(PageRetry {
|
||||||
|
next_retry: now,
|
||||||
|
backoff: Duration::from_secs(1),
|
||||||
|
});
|
||||||
|
retry.next_retry = now + retry.backoff;
|
||||||
|
retry.backoff = (retry.backoff * 2).min(Duration::from_secs(30));
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.in_flight_count == 0
|
if self.in_flight_count == 0
|
||||||
@@ -468,7 +480,12 @@ impl AutosaveController {
|
|||||||
.get(id)
|
.get(id)
|
||||||
.map(|r| now >= r.next_retry)
|
.map(|r| now >= r.next_retry)
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
});
|
})
|
||||||
|
|| (self.pending_manifest.is_some()
|
||||||
|
&& self
|
||||||
|
.manifest_retry
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|r| now >= r.next_retry));
|
||||||
|
|
||||||
if max_wait_hit || debounce_hit || retry_ready {
|
if max_wait_hit || debounce_hit || retry_ready {
|
||||||
self.dispatch_pending();
|
self.dispatch_pending();
|
||||||
@@ -515,7 +532,11 @@ impl AutosaveController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(manifest) = self.pending_manifest.take() {
|
let manifest_ready = self
|
||||||
|
.manifest_retry
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|r| now >= r.next_retry);
|
||||||
|
if let Some(manifest) = self.pending_manifest.take_if(|_| manifest_ready) {
|
||||||
let cmd = AutosaveCommand::SaveManifest {
|
let cmd = AutosaveCommand::SaveManifest {
|
||||||
notebook_dir: self.notebook_dir.clone(),
|
notebook_dir: self.notebook_dir.clone(),
|
||||||
manifest: manifest.clone(),
|
manifest: manifest.clone(),
|
||||||
@@ -546,6 +567,7 @@ impl AutosaveController {
|
|||||||
}
|
}
|
||||||
// Backoff für den Flush ignorieren, damit sofort geschrieben wird
|
// Backoff für den Flush ignorieren, damit sofort geschrieben wird
|
||||||
self.retries.clear();
|
self.retries.clear();
|
||||||
|
self.manifest_retry = None;
|
||||||
|
|
||||||
if !self.pending_pages.is_empty() || self.pending_manifest.is_some() {
|
if !self.pending_pages.is_empty() || self.pending_manifest.is_some() {
|
||||||
self.dispatch_pending();
|
self.dispatch_pending();
|
||||||
@@ -650,6 +672,7 @@ impl AutosaveController {
|
|||||||
self.pending_pages.clear();
|
self.pending_pages.clear();
|
||||||
self.pending_manifest = None;
|
self.pending_manifest = None;
|
||||||
self.retries.clear();
|
self.retries.clear();
|
||||||
|
self.manifest_retry = None;
|
||||||
self.last_errors.clear();
|
self.last_errors.clear();
|
||||||
self.first_change_time = None;
|
self.first_change_time = None;
|
||||||
self.last_change_time = None;
|
self.last_change_time = None;
|
||||||
@@ -772,6 +795,7 @@ pub mod tests {
|
|||||||
/// Fake-PageStore zum Simulieren von I/O-Fehlern und Panics.
|
/// Fake-PageStore zum Simulieren von I/O-Fehlern und Panics.
|
||||||
pub struct FakePageStore {
|
pub struct FakePageStore {
|
||||||
pub fail_page_writes: Mutex<usize>,
|
pub fail_page_writes: Mutex<usize>,
|
||||||
|
pub fail_manifest_writes: Mutex<usize>,
|
||||||
pub panic_on_page: Mutex<bool>,
|
pub panic_on_page: Mutex<bool>,
|
||||||
pub page_writes: Arc<AtomicUsize>,
|
pub page_writes: Arc<AtomicUsize>,
|
||||||
pub manifest_writes: Arc<AtomicUsize>,
|
pub manifest_writes: Arc<AtomicUsize>,
|
||||||
@@ -781,6 +805,7 @@ pub mod tests {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
fail_page_writes: Mutex::new(0),
|
fail_page_writes: Mutex::new(0),
|
||||||
|
fail_manifest_writes: Mutex::new(0),
|
||||||
panic_on_page: Mutex::new(false),
|
panic_on_page: Mutex::new(false),
|
||||||
page_writes: Arc::new(AtomicUsize::new(0)),
|
page_writes: Arc::new(AtomicUsize::new(0)),
|
||||||
manifest_writes: Arc::new(AtomicUsize::new(0)),
|
manifest_writes: Arc::new(AtomicUsize::new(0)),
|
||||||
@@ -815,6 +840,13 @@ pub mod tests {
|
|||||||
_dir: &Path,
|
_dir: &Path,
|
||||||
_manifest: &NotebookManifest,
|
_manifest: &NotebookManifest,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
|
let mut fails = self.fail_manifest_writes.lock().unwrap();
|
||||||
|
if *fails > 0 {
|
||||||
|
*fails -= 1;
|
||||||
|
return Err(StorageError::InvalidManifest(
|
||||||
|
"Simulierter Store-Fehler".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
self.manifest_writes.fetch_add(1, Ordering::SeqCst);
|
self.manifest_writes.fetch_add(1, Ordering::SeqCst);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -852,6 +884,40 @@ pub mod tests {
|
|||||||
assert_eq!(controller.status(), AutosaveStatus::Saved);
|
assert_eq!(controller.status(), AutosaveStatus::Saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_failed_manifest_is_retried_without_another_edit() {
|
||||||
|
let clock = Arc::new(MockClock::new(Instant::now()));
|
||||||
|
let store = Arc::new(FakePageStore::new());
|
||||||
|
*store.fail_manifest_writes.lock().unwrap() = 1;
|
||||||
|
let mut controller = AutosaveController::with_options(
|
||||||
|
PathBuf::from("/tmp/manifest_retry"),
|
||||||
|
Duration::from_millis(100),
|
||||||
|
Duration::from_secs(10),
|
||||||
|
Arc::clone(&store) as Arc<dyn PageStore>,
|
||||||
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
|
);
|
||||||
|
let manifest = NotebookManifest::new("nb", "Retry");
|
||||||
|
controller.mark_manifest_dirty(&manifest);
|
||||||
|
|
||||||
|
clock.advance(Duration::from_millis(200));
|
||||||
|
controller.update();
|
||||||
|
controller.sync_worker();
|
||||||
|
assert_eq!(controller.update(), AutosaveStatus::Error);
|
||||||
|
assert_eq!(store.manifest_writes.load(Ordering::SeqCst), 0);
|
||||||
|
|
||||||
|
// Not before the backoff has passed
|
||||||
|
controller.update();
|
||||||
|
controller.sync_worker();
|
||||||
|
controller.update();
|
||||||
|
assert_eq!(store.manifest_writes.load(Ordering::SeqCst), 0);
|
||||||
|
|
||||||
|
clock.advance(Duration::from_secs(2));
|
||||||
|
controller.update();
|
||||||
|
controller.sync_worker();
|
||||||
|
assert_eq!(controller.update(), AutosaveStatus::Saved);
|
||||||
|
assert_eq!(store.manifest_writes.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_debounce_bundles_changes() {
|
fn test_debounce_bundles_changes() {
|
||||||
let clock = Arc::new(MockClock::new(Instant::now()));
|
let clock = Arc::new(MockClock::new(Instant::now()));
|
||||||
|
|||||||
@@ -133,6 +133,10 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Fehler beim Schreiben auf die Festplatte: {e}", "Error writing to disk: {e}"),
|
("Fehler beim Schreiben auf die Festplatte: {e}", "Error writing to disk: {e}"),
|
||||||
("Fehler beim Umbenennen: {err}", "Error while renaming: {err}"),
|
("Fehler beim Umbenennen: {err}", "Error while renaming: {err}"),
|
||||||
("Fehler beim Wiederherstellen: {err}", "Error while restoring: {err}"),
|
("Fehler beim Wiederherstellen: {err}", "Error while restoring: {err}"),
|
||||||
|
(
|
||||||
|
"Das PDF kann nicht verarbeitet werden (interner Fehler)",
|
||||||
|
"The PDF cannot be processed (internal error)",
|
||||||
|
),
|
||||||
("Fehler beim Öffnen: {err}", "Error while opening: {err}"),
|
("Fehler beim Öffnen: {err}", "Error while opening: {err}"),
|
||||||
("Fehler: {err}", "Error: {err}"),
|
("Fehler: {err}", "Error: {err}"),
|
||||||
("Fehlermeldung: {message}", "Error message: {message}"),
|
("Fehlermeldung: {message}", "Error message: {message}"),
|
||||||
|
|||||||
+74
-4
@@ -125,11 +125,24 @@ pub fn page(
|
|||||||
/// we draw a diameter of `width · (0.5 + p)`, so the widest point gets p = 1 and thinner
|
/// we draw a diameter of `width · (0.5 + p)`, so the widest point gets p = 1 and thinner
|
||||||
/// ones less (down to a third of the widest).
|
/// ones less (down to a third of the widest).
|
||||||
pub fn fit_widths(widths: &[f32]) -> (f32, Vec<f32>) {
|
pub fn fit_widths(widths: &[f32]) -> (f32, Vec<f32>) {
|
||||||
let widest = widths.iter().copied().fold(0.0, f32::max).max(0.01);
|
let widest = widths
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|w| w.is_finite())
|
||||||
|
.fold(0.0, f32::max)
|
||||||
|
.max(0.01);
|
||||||
let width = widest / 1.5;
|
let width = widest / 1.5;
|
||||||
let pressures = widths
|
let pressures = widths
|
||||||
.iter()
|
.iter()
|
||||||
.map(|w| (w / width - 0.5).clamp(0.0, 1.0))
|
.map(|w| {
|
||||||
|
let p = w / width - 0.5;
|
||||||
|
// NaN survives `clamp`, is stored as `null` and makes the page unreadable
|
||||||
|
if p.is_finite() {
|
||||||
|
p.clamp(0.0, 1.0)
|
||||||
|
} else {
|
||||||
|
0.5
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
(width, pressures)
|
(width, pressures)
|
||||||
}
|
}
|
||||||
@@ -153,17 +166,29 @@ pub fn ink_stroke(
|
|||||||
color[3] = 1.0;
|
color[3] = 1.0;
|
||||||
StrokeTool::Pen
|
StrokeTool::Pen
|
||||||
};
|
};
|
||||||
|
// Broken files may carry NaN or infinite numbers. They would be written as `null` and
|
||||||
|
// the page could not be read again, so such points are left out.
|
||||||
let samples = points
|
let samples = points
|
||||||
.iter()
|
.iter()
|
||||||
.zip(pressures)
|
.zip(pressures)
|
||||||
|
.filter(|(p, _)| p.is_finite())
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, (p, &pressure))| SampleData {
|
.map(|(i, (p, &pressure))| SampleData {
|
||||||
x: p.x,
|
x: p.x,
|
||||||
y: p.y,
|
y: p.y,
|
||||||
p: pressure,
|
p: if pressure.is_finite() {
|
||||||
|
pressure.clamp(0.0, 1.0)
|
||||||
|
} else {
|
||||||
|
0.5
|
||||||
|
},
|
||||||
t: i as f64 * 0.005,
|
t: i as f64 * 0.005,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
let width = if width.is_finite() && width > 0.0 {
|
||||||
|
width
|
||||||
|
} else {
|
||||||
|
5.0
|
||||||
|
};
|
||||||
StrokeData {
|
StrokeData {
|
||||||
id,
|
id,
|
||||||
color,
|
color,
|
||||||
@@ -246,7 +271,12 @@ pub fn unpremultiply(pixels: &mut [u8]) {
|
|||||||
/// Rasterizes an SVG for a box of `size` page units (about 250 dpi).
|
/// Rasterizes an SVG for a box of `size` page units (about 250 dpi).
|
||||||
pub fn rasterize_svg(svg: &str, size: glam::Vec2) -> Option<(u32, u32, Vec<u8>)> {
|
pub fn rasterize_svg(svg: &str, size: glam::Vec2) -> Option<(u32, u32, Vec<u8>)> {
|
||||||
use resvg::{tiny_skia, usvg};
|
use resvg::{tiny_skia, usvg};
|
||||||
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).ok()?;
|
// By default usvg loads `<image href="/some/local/file">` from disk. An imported note
|
||||||
|
// must not be able to pull files of this device into the notebook; embedded data URLs
|
||||||
|
// still work.
|
||||||
|
let mut options = usvg::Options::default();
|
||||||
|
options.image_href_resolver.resolve_string = Box::new(|_, _| None);
|
||||||
|
let tree = usvg::Tree::from_str(svg, &options).ok()?;
|
||||||
let max = crate::assets::MAX_IMPORT_DIMENSION as f32;
|
let max = crate::assets::MAX_IMPORT_DIMENSION as f32;
|
||||||
let px_per_unit = 250.0 / 254.0;
|
let px_per_unit = 250.0 / 254.0;
|
||||||
let width = (size.x * px_per_unit).round().clamp(1.0, max) as u32;
|
let width = (size.x * px_per_unit).round().clamp(1.0, max) as u32;
|
||||||
@@ -288,4 +318,44 @@ mod tests {
|
|||||||
// Thinner than a third of the widest point is not possible
|
// Thinner than a third of the widest point is not possible
|
||||||
assert!((drawn[3] - 10.0).abs() < 1e-3);
|
assert!((drawn[3] - 10.0).abs() < 1e-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_broken_numbers_never_reach_the_page_file() {
|
||||||
|
let (width, pressures) = fit_widths(&[f32::NAN, 10.0, f32::INFINITY]);
|
||||||
|
assert!(width.is_finite() && pressures.iter().all(|p| p.is_finite()));
|
||||||
|
|
||||||
|
let points = [
|
||||||
|
glam::Vec2::new(10.0, 10.0),
|
||||||
|
glam::Vec2::new(f32::NAN, 20.0),
|
||||||
|
glam::Vec2::new(30.0, 30.0),
|
||||||
|
];
|
||||||
|
let stroke = ink_stroke(
|
||||||
|
1,
|
||||||
|
[0.0, 0.0, 0.0, 1.0],
|
||||||
|
false,
|
||||||
|
&points,
|
||||||
|
f32::NAN,
|
||||||
|
&[f32::NAN, 0.5, 2.0],
|
||||||
|
);
|
||||||
|
assert_eq!(stroke.samples.len(), 2);
|
||||||
|
assert!(stroke.width.is_finite());
|
||||||
|
let json = serde_json::to_string(&stroke).unwrap();
|
||||||
|
assert!(!json.contains("null"), "{json}");
|
||||||
|
assert!(serde_json::from_str::<StrokeData>(&json).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_svg_cannot_embed_local_files() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let secret = dir.path().join("secret.png");
|
||||||
|
image::RgbaImage::from_pixel(8, 8, image::Rgba([255, 0, 0, 255]))
|
||||||
|
.save(&secret)
|
||||||
|
.unwrap();
|
||||||
|
let svg = format!(
|
||||||
|
r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="8" height="8"><image width="8" height="8" xlink:href="{}"/></svg>"#,
|
||||||
|
secret.display()
|
||||||
|
);
|
||||||
|
let (_, _, pixels) = rasterize_svg(&svg, glam::Vec2::new(80.0, 80.0)).unwrap();
|
||||||
|
assert!(pixels.iter().all(|&b| b == 0), "the local image was drawn");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+38
-10
@@ -40,8 +40,12 @@ pub const MAX_IMPORT_PAGES: usize = 2000;
|
|||||||
/// Öffnet ein PDF und liefert die angezeigte Größe jeder Seite in Punkt (Drehung
|
/// Öffnet ein PDF und liefert die angezeigte Größe jeder Seite in Punkt (Drehung
|
||||||
/// berücksichtigt).
|
/// berücksichtigt).
|
||||||
pub fn page_sizes(bytes: Vec<u8>) -> Result<Vec<(f32, f32)>, String> {
|
pub fn page_sizes(bytes: Vec<u8>) -> Result<Vec<(f32, f32)>, String> {
|
||||||
let pdf = open(bytes)?;
|
// Runs on the main thread during import: a panic in the PDF library must not take the
|
||||||
let sizes: Vec<(f32, f32)> = pdf.pages().iter().map(|p| p.render_dimensions()).collect();
|
// app down
|
||||||
|
let sizes: Vec<(f32, f32)> = guarded(|| {
|
||||||
|
let pdf = open(bytes)?;
|
||||||
|
Ok(pdf.pages().iter().map(|p| p.render_dimensions()).collect())
|
||||||
|
})?;
|
||||||
if sizes.is_empty() {
|
if sizes.is_empty() {
|
||||||
return Err(t!("Das PDF enthält keine Seiten").into());
|
return Err(t!("Das PDF enthält keine Seiten").into());
|
||||||
}
|
}
|
||||||
@@ -55,6 +59,13 @@ pub fn page_sizes(bytes: Vec<u8>) -> Result<Vec<(f32, f32)>, String> {
|
|||||||
Ok(sizes)
|
Ok(sizes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs PDF code and turns a panic of the PDF library (malformed files) into an error.
|
||||||
|
fn guarded<T>(work: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
|
||||||
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)).unwrap_or_else(|_| {
|
||||||
|
Err(t!("Das PDF kann nicht verarbeitet werden (interner Fehler)").to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn open(bytes: Vec<u8>) -> Result<Pdf, String> {
|
fn open(bytes: Vec<u8>) -> Result<Pdf, String> {
|
||||||
Pdf::new(bytes).map_err(|e| match e {
|
Pdf::new(bytes).map_err(|e| match e {
|
||||||
LoadPdfError::Decryption(_) => {
|
LoadPdfError::Decryption(_) => {
|
||||||
@@ -209,21 +220,31 @@ impl PdfRenderService {
|
|||||||
queue = ready.wait(queue).unwrap_or_else(|e| e.into_inner());
|
queue = ready.wait(queue).unwrap_or_else(|e| e.into_inner());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// A panic here would end the thread and with it every PDF page until
|
||||||
|
// the next start, so it only fails the page at hand
|
||||||
if open_pdf.as_ref().map(|(p, _)| p) != Some(&request.path) {
|
if open_pdf.as_ref().map(|(p, _)| p) != Some(&request.path) {
|
||||||
open_pdf = std::fs::read(&request.path)
|
open_pdf = guarded(|| {
|
||||||
.map_err(|e| e.to_string())
|
std::fs::read(&request.path)
|
||||||
.and_then(open)
|
.map_err(|e| e.to_string())
|
||||||
.map_err(|e| eprintln!("[PDF] '{}': {e}", request.path.display()))
|
.and_then(open)
|
||||||
.ok()
|
})
|
||||||
.map(|pdf| (request.path.clone(), pdf));
|
.map_err(|e| eprintln!("[PDF] '{}': {e}", request.path.display()))
|
||||||
|
.ok()
|
||||||
|
.map(|pdf| (request.path.clone(), pdf));
|
||||||
}
|
}
|
||||||
let result = match &open_pdf {
|
let result = match &open_pdf {
|
||||||
// Mipmaps hier statt im Haupt-Thread: sonst stockt das Scrollen bei
|
// Mipmaps hier statt im Haupt-Thread: sonst stockt das Scrollen bei
|
||||||
// jeder neu gerenderten Seite (Issue #28)
|
// jeder neu gerenderten Seite (Issue #28)
|
||||||
Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels)
|
Some((_, pdf)) => guarded(|| {
|
||||||
.map(crate::image_layer::mip_chain),
|
render_page(pdf, request.page, request.max_pixels)
|
||||||
|
.map(crate::image_layer::mip_chain)
|
||||||
|
}),
|
||||||
None => Err(t!("PDF kann nicht geöffnet werden").into()),
|
None => Err(t!("PDF kann nicht geöffnet werden").into()),
|
||||||
};
|
};
|
||||||
|
if result.is_err() {
|
||||||
|
// The document may be in a broken state after a panic
|
||||||
|
open_pdf = None;
|
||||||
|
}
|
||||||
if res_tx.send((request.key, result)).is_err() {
|
if res_tx.send((request.key, result)).is_err() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -356,6 +377,13 @@ impl Drop for PdfRenderService {
|
|||||||
pub(crate) mod tests {
|
pub(crate) mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_panic_in_pdf_code_becomes_an_error() {
|
||||||
|
let result: Result<(), String> = guarded(|| panic!("simulated panic in the PDF library"));
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(guarded(|| Ok(7)), Ok(7));
|
||||||
|
}
|
||||||
|
|
||||||
/// Minimales gültiges PDF mit Seiten der angegebenen Größen (Punkt); jede Seite zeigt ein
|
/// Minimales gültiges PDF mit Seiten der angegebenen Größen (Punkt); jede Seite zeigt ein
|
||||||
/// rotes Rechteck in der linken oberen Ecke. Optional mit `/Rotate`.
|
/// rotes Rechteck in der linken oberen Ecke. Optional mit `/Rotate`.
|
||||||
pub(crate) fn sample_pdf(pages: &[(f32, f32, i32)]) -> Vec<u8> {
|
pub(crate) fn sample_pdf(pages: &[(f32, f32, i32)]) -> Vec<u8> {
|
||||||
|
|||||||
+18
-1
@@ -561,7 +561,8 @@ fn convert(doc: &RnoteDocument, notebook_dir: &Path) -> Imported {
|
|||||||
let scale = scale_pt * (72.0 / doc.dpi) as f32;
|
let scale = scale_pt * (72.0 / doc.dpi) as f32;
|
||||||
// Rnote grows documents page by page; tolerate rounding in the stored size
|
// Rnote grows documents page by page; tolerate rounding in the stored size
|
||||||
let count = |length: f64, page: f64| ((length / page - 0.01).ceil() as usize).max(1);
|
let count = |length: f64, page: f64| ((length / page - 0.01).ceil() as usize).max(1);
|
||||||
let columns = count(doc.size.x, doc.format.x);
|
// A broken document size must not ask for millions of pages
|
||||||
|
let columns = count(doc.size.x, doc.format.x).min(MAX_PAGES);
|
||||||
let rows = count(doc.size.y, doc.format.y);
|
let rows = count(doc.size.y, doc.format.y);
|
||||||
let rows = rows.min((MAX_PAGES / columns).max(1));
|
let rows = rows.min((MAX_PAGES / columns).max(1));
|
||||||
let targets = (0..rows * columns)
|
let targets = (0..rows * columns)
|
||||||
@@ -889,6 +890,22 @@ mod tests {
|
|||||||
assert!(second.strokes[1].is_image());
|
assert!(second.strokes[1].is_image());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_broken_document_size_is_capped() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let doc = RnoteDocument {
|
||||||
|
format: DVec2::new(800.0, 1100.0),
|
||||||
|
dpi: 96.0,
|
||||||
|
origin: DVec2::ZERO,
|
||||||
|
size: DVec2::new(1e18, 1e18),
|
||||||
|
endless: false,
|
||||||
|
background: PageBackground::Blank,
|
||||||
|
items: Vec::new(),
|
||||||
|
};
|
||||||
|
let imported = convert(&doc, dir.path());
|
||||||
|
assert!(imported.pages.len() <= MAX_PAGES);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_infinite_documents_become_endless() {
|
fn test_infinite_documents_become_endless() {
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
+6
-2
@@ -555,8 +555,12 @@ pub fn atomic_write_file(path: &Path, content: &[u8]) -> Result<(), io::Error> {
|
|||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let _ = fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600));
|
let _ = fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600));
|
||||||
}
|
}
|
||||||
file.write_all(content)?;
|
// On a full disk every retry would otherwise leave another partial file behind
|
||||||
file.sync_all()?;
|
if let Err(e) = file.write_all(content).and_then(|_| file.sync_all()) {
|
||||||
|
drop(file);
|
||||||
|
let _ = fs::remove_file(&temp_path);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomares Rename
|
// Atomares Rename
|
||||||
|
|||||||
Reference in new issue
Block a user