From c5d152208cbb48715dcdc69be2c9911cacdfa448 Mon Sep 17 00:00:00 2001 From: Random <328444012+ishveet@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:10:30 -0400 Subject: [PATCH] M2: preserve rendered tiles after partial failures (#623 item 4) --- ROADMAP.md | 3 + crates/ui-egui/src/canvas.rs | 107 +++++- crates/ui-egui/src/canvas/cache.rs | 148 +++++++- crates/ui-egui/src/canvas/organize.rs | 5 +- crates/ui-egui/src/canvas/tests.rs | 499 +++++++++++++++++++++++++- docs/roadmap.md | 5 +- 6 files changed, 731 insertions(+), 36 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f5de8044e13..534c2ffb65a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M3, #623 item 4 viewer candidate: isolate main-canvas diagnostics and keep saturated visible work progressing; estimates unchanged) > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) @@ -200,6 +201,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-28 (M3, #623 item 4 targeted follow-up):** Preview failures remain inspectable without marking a successfully rendered main canvas as failed. Failure history can hold all current demand, then shrinks back after navigation or suspension; an oversized HiDPI window on a downlevel backend no longer starves an untried visible tile after 64 failures. Deterministic UI and scheduling regressions cover both corrections. Estimates unchanged. +- **2026-10-28 (M3, #623 item 4 viewer candidate):** A failed render request leaves successful visible page tiles and backdrops visible with an error notice. Other pending tiles and independent thumbnail, grid and print requests can finish. Current failures stay suppressed in bounded request history; successful new zooms hide obsolete diagnostics, and a valid complete raster supersedes older main-page failures. Synthetic viewer regressions cover ordering, zoom/pan, bounded retention, preview scheduling, offscreen coverage, recovery, invalidation and stale results; watchdog budgets and renderer behavior are unchanged. Estimates unchanged. - **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence. - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 91d2c41367f..b8b8fc1e99a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -322,8 +322,10 @@ pub struct DocView { /// shows one page or spread at a time, fitted to the screen. `None` outside full screen. presentation: Option, pages: HashMap, - /// Pages the renderer could not draw, with the reason (never re-requested). - errors: HashMap, + /// Bounded retry history, grouped by page. Only current demand contributes diagnostics. + errors: HashMap>, + /// Last committed pixel demand, including cached and failed requests, before queue filtering. + error_demand: Option>, /// When each still-missing page was first shown, to flag unusually slow renders. waiting_since: HashMap, thumbs: HashMap, @@ -629,6 +631,7 @@ impl DocView { presentation: None, pages: HashMap::new(), errors: HashMap::new(), + error_demand: None, waiting_since: HashMap::new(), thumbs: HashMap::new(), thumb_demand: HashMap::new(), @@ -943,7 +946,9 @@ fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFra painter.add(egui::epaint::Shadow { offset: [0, 3], blur: 14, spread: 0, color: t.page_shadow }.as_shape(r, CornerRadius::ZERO)); } painter.rect_filled(r, CornerRadius::ZERO, paper); - if let Some(err) = view.errors.get(&i) { + let has_error = view.main_page_error(i).is_some(); + let has_content = has_error && has_visible_page_pixels(view, frame, slot); + if let Some(err) = view.main_page_error(i).filter(|_| !has_content) { painter.rect_filled(r, CornerRadius::ZERO, Color32::from_rgb(0xFB, 0xF4, 0xF4)); icons::paint( ui, @@ -955,8 +960,11 @@ fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFra let message = crate::i18n::fmt(tl!("This page couldn't be displayed.\n{e}"), &[("e", err)]); let msg = ui.fonts_mut(|f| f.layout(message, theme::regular(12.5), Color32::from_rgb(0x6A, 0x2A, 0x2A), (r.width() - 40.0).max(80.0))); painter.galley(pos2(r.center().x - msg.size().x / 2.0, r.center().y), msg, Color32::BLACK); - } else { - let (pw_pt, ph_pt) = (info.pages[i].width.max(1.0), info.pages[i].height.max(1.0)); + } + // Keep collecting demand after a failure: another in-flight tile can still succeed. + { + let Some(page) = info.pages.get(i) else { return }; + let (pw_pt, ph_pt) = (page.width.max(1.0), page.height.max(1.0)); // A GPU may take smaller textures than these (OpenGL drivers report as little as // 2048 pixels, and uploading a bigger one aborts), so both stay within its limit. let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32; @@ -983,17 +991,74 @@ fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFra xf.paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0); } } - None => { + None if !has_error => { let now = ui.input(|inp| inp.time); let since = *view.waiting_since.entry(i).or_insert(now); let msg = if now - since > 6.0 { "Still rendering — this page is unusually complex…" } else { "Rendering…" }; painter.text(r.center(), Align2::CENTER_CENTER, tl!(msg), theme::regular(12.0), t.text_faint); } + None => {} } if tiled && r.intersects(visible) { paint_page_tiles(ui, view, frame, slot, (pw_pt, ph_pt, max_side)); + } else if has_content && view.pages.get(&i).is_none_or(|p| p.tag != want_tag) { + // A failed zoom-out may have only the older tiles to show below the tiling threshold. + paint_cached_tiles(view, frame, slot, painter, None); } } + if has_content && let Some(err) = view.main_page_error(i) { + paint_partial_page_error(ui, r.intersect(visible), i, err, t); + } +} + +/// Cached pixels count only where they intersect the page's visible part, including view rotation. +fn has_visible_page_pixels(view: &DocView, frame: &PageFrame<'_>, slot: &PageSlot) -> bool { + let PageSlot { i, r, xf } = *slot; + let shown = r.intersect(frame.visible); + if !shown.is_finite() || !shown.is_positive() { + return false; + } + if view.pages.contains_key(&i) { + return true; + } + let Some(page) = frame.env.info.pages.get(i) else { return false }; + view.tiles.iter().any(|(&(p, tag, x, y), tex)| { + if p != i { + return false; + } + let scale = tag_scale(tag); + let (w, h) = (device_pixels(page.width.max(1.0), scale) as f32, device_pixels(page.height.max(1.0), scale) as f32); + let [tw, th] = tex.size(); + let coverage = Rect::from_two_pos( + xf.norm_to_screen(x as f32 / w, y as f32 / h), + xf.norm_to_screen((x as f32 + tw as f32) / w, (y as f32 + th as f32) / h), + ); + coverage.intersect(shown).is_positive() + }) +} + +/// Keep the failure visible without replacing the page's useful pixels or claiming none were drawn. +fn paint_partial_page_error(ui: &egui::Ui, shown: Rect, page: usize, err: &str, t: &Tokens) { + if !shown.is_finite() || !shown.is_positive() { + return; + } + let available = shown.shrink(8.0); + if available.width() < 24.0 || available.height() < 24.0 { + // Even a tiny page or a sliver at the viewport edge must still indicate the failure. + ui.painter().rect_stroke(shown, CornerRadius::ZERO, Stroke::new(1.0, Color32::from_rgb(0xC8, 0x3A, 0x3A)), egui::StrokeKind::Inside); + ui.interact(shown, ui.scope_id().with(("page-render-error", page)), Sense::hover()).on_hover_text(err); + return; + } + let message = format!("{}\n{err}", tl!("Error")); + let text = ui.fonts_mut(|f| f.layout(message, theme::regular(12.0), t.text, (available.width() - 40.0).clamp(40.0, 320.0))); + let size = vec2((text.size().x + 40.0).min(available.width()), (text.size().y + 12.0).min(available.height()).min(160.0)); + let note = Rect::from_min_size(available.right_bottom() - size, size); + let painter = ui.painter().with_clip_rect(note); + painter.rect_filled(note, CornerRadius::same(6), t.card); + painter.rect_stroke(note, CornerRadius::same(6), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + icons::paint(ui, Rect::from_min_size(note.min + vec2(6.0, 6.0), vec2(18.0, 18.0)), "triangle-alert", 16.0, Color32::from_rgb(0xC8, 0x3A, 0x3A)); + painter.galley(note.min + vec2(30.0, 6.0), text, t.text); + ui.interact(note, ui.scope_id().with(("page-render-error", page)), Sense::hover()).on_hover_text(err); } /// Paints the tiles of a page too big to draw whole: the tiles of earlier zooms first, stretched, @@ -1020,15 +1085,7 @@ fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_> let (vx1, vy1) = (((u1.min(1.0) * dw as f32).ceil() as u32).min(dw), ((v1.min(1.0) * dh as f32).ceil() as u32).min(dh)); // Tiles of earlier zooms first, stretched, the nearest scale last (on top): // until the current tiles arrive, a zoom shows them rather than the backdrop. - let mut older: Vec<(f32, u32, u32, &TextureHandle)> = - view.tiles.iter().filter(|((p, t, _, _), _)| *p == i && *t != tag).map(|((_, t, ox, oy), tex)| (tag_scale(*t), *ox, *oy, tex)).collect(); - older.sort_by(|a, b| (b.0 / scale).ln().abs().total_cmp(&(a.0 / scale).ln().abs())); - for (s, ox, oy, tex) in older { - let (ow, oh) = (device_pixels(pw_pt, s) as f32, device_pixels(ph_pt, s) as f32); - let [w, h] = tex.size(); - let (x, y) = (ox as f32, oy as f32); - xf.paint_image(painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh); - } + paint_cached_tiles(view, frame, slot, painter, Some(tag)); // Visible tiles, nearest the viewport centre first: each tile costs a full // page interpret, so the workers sharpen what is looked at before the edges. let (vcx, vcy) = ((vx0 + vx1) as f32 / 2.0, (vy0 + vy1) as f32 / 2.0); @@ -1064,6 +1121,24 @@ fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_> } } +fn paint_cached_tiles(view: &DocView, frame: &PageFrame<'_>, slot: &PageSlot, painter: &egui::Painter, skip_tag: Option) { + let PageSlot { i, xf, .. } = *slot; + let Some(page) = frame.env.info.pages.get(i) else { return }; + let scale = frame.env.scale; + let mut tiles: Vec<(f32, u32, u32, &TextureHandle)> = view + .tiles + .iter() + .filter(|((p, t, _, _), _)| *p == i && Some(*t) != skip_tag) + .map(|((_, t, x, y), tex)| (tag_scale(*t), *x, *y, tex)) + .collect(); + tiles.sort_by(|a, b| (b.0 / scale).ln().abs().total_cmp(&(a.0 / scale).ln().abs())); + for (s, x, y, tex) in tiles { + let (w, h) = (device_pixels(page.width.max(1.0), s) as f32, device_pixels(page.height.max(1.0), s) as f32); + let [tw, th] = tex.size(); + xf.paint_image(painter, tex.id(), x as f32 / w, y as f32 / h, (x as f32 + tw as f32) / w, (y as f32 + th as f32) / h); + } +} + /// What the page's tools did with the pointer this frame. struct PageInput { /// A tool, a field or a comment took the pointer: text selection must leave it alone. @@ -2240,7 +2315,7 @@ fn plan_renders(ui: &egui::Ui, view: &mut DocView, info: &DocInfo, renders: Page let n = if view.layout == PageLayout::Single { 1 } else { 2 }; let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32; for i in (last.saturating_add(1)..=last.saturating_add(n)).chain(first.saturating_sub(n)..first) { - let Some(p) = info.pages.get(i).filter(|_| !view.errors.contains_key(&i)) else { continue }; + let Some(p) = info.pages.get(i) else { continue }; // As drawn: the whole page at this scale, or the backdrop of a tiled page. let side = p.width.max(p.height).max(1.0); wanted.push(if side * scale > TILE_THRESHOLD.min(max_side) { diff --git a/crates/ui-egui/src/canvas/cache.rs b/crates/ui-egui/src/canvas/cache.rs index a0ad8fb3359..423c5007324 100644 --- a/crates/ui-egui/src/canvas/cache.rs +++ b/crates/ui-egui/src/canvas/cache.rs @@ -47,6 +47,20 @@ const UPLOAD_BYTES_PER_FRAME: usize = 16 * 1024 * 1024; const RESULTS_PER_FRAME: usize = 8; +/// Per page and consumer (main, thumbnail, grid, print). Larger current demand temporarily +/// raises this allowance so every visible request can progress; it shrinks when demand ends. +const RECENT_REQUEST_FAILURES: usize = 64; + +fn failure_domain(request: RenderRequest) -> u64 { + if request.tag & PRINT_TAG != 0 { + PRINT_TAG + } else if request.tag & GRID_TAG != 0 { + GRID_TAG + } else { + request.tag & THUMB_TAG + } +} + /// A defensive ceiling for unusually large viewports / n-up previews. pub(super) const MAX_THUMB_DEMAND: usize = 256; @@ -119,13 +133,101 @@ impl DocView { } } - /// Pages that could not be rendered, with the reason (for automation; 0-based pages). + /// Pages with current pixel failures, including pages still showing useful content (0-based). pub fn page_errors(&self) -> Vec<(usize, &str)> { - let mut v: Vec<(usize, &str)> = self.errors.iter().map(|(p, e)| (*p, e.as_str())).collect(); + let mut v: Vec<(usize, &str)> = self.errors.keys().filter_map(|&p| self.page_error(p).map(|e| (p, e))).collect(); v.sort_unstable_by_key(|(p, _)| *p); v } + pub(super) fn page_error(&self, page: usize) -> Option<&str> { + self.request_error(page, false) + } + + /// The main canvas must not present another consumer's failed preview as a page failure. + pub(super) fn main_page_error(&self, page: usize) -> Option<&str> { + self.request_error(page, true) + } + + fn request_error(&self, page: usize, main_only: bool) -> Option<&str> { + let errors = self.errors.get(&page)?; + let relevant = |r: &RenderRequest| !main_only || failure_domain(*r) == 0; + let Some(demand) = &self.error_demand else { return errors.iter().rev().find(|(r, _)| relevant(r)).map(|(_, e)| e.as_str()) }; + if let Some((_, reason)) = errors.iter().rev().find(|(r, _)| relevant(r) && demand.contains(r)) { + return Some(reason); + } + // Keep the old reason during recovery, until the new sharp rendering really arrives. + // Once all current sharp requests succeed, an old zoom's failure is history alone. + let pending = demand.iter().any(|r| r.page == page && failure_domain(*r) == 0 && !r.preview && !self.main_request_ready(*r)); + pending.then(|| errors.iter().rev().find(|(r, _)| failure_domain(*r) == 0).map(|(_, e)| e.as_str())).flatten() + } + + fn main_request_ready(&self, request: RenderRequest) -> bool { + if let Some(tile) = request.tile { + let key = (request.page, request.tag, tile.x, tile.y); + self.tiles.contains_key(&key) && !self.stale_tiles.contains(&key) + } else { + self.pages.get(&request.page).is_some_and(|p| p.tag == request.tag) + } + } + + fn request_failed(&self, request: RenderRequest) -> bool { + self.errors.get(&request.page).is_some_and(|errors| errors.iter().any(|(r, _)| *r == request)) + } + + fn failure_limit(&self, page: usize, domain: u64) -> usize { + let demand = self.error_demand.as_deref().unwrap_or(&self.last_queue); + RECENT_REQUEST_FAILURES + .max(demand.iter().filter(|r| r.page == page && r.kind == RequestKind::Pixels && failure_domain(**r) == domain).count()) + } + + /// Bound history by current demand, rather than by the largest viewport ever visited. + fn set_error_demand(&mut self, demand: Vec) { + let previous = self.error_demand.replace(demand); + let pages: HashSet<_> = previous.as_deref().unwrap_or(&self.last_queue).iter().map(|r| r.page).collect(); + for page in pages { + if self.errors.get(&page).is_none_or(|errors| errors.len() <= RECENT_REQUEST_FAILURES) { + continue; + } + let limits = [0, THUMB_TAG, GRID_TAG, PRINT_TAG].map(|domain| (domain, self.failure_limit(page, domain))); + let wanted = self.error_demand.as_deref().unwrap_or_default(); + if let Some(errors) = self.errors.get_mut(&page) { + for (domain, limit) in limits { + let mut excess = errors.iter().filter(|(r, _)| failure_domain(*r) == domain).count().saturating_sub(limit); + errors.retain(|(r, _)| { + if excess > 0 && failure_domain(*r) == domain && !wanted.contains(r) { + excess -= 1; + false + } else { + true + } + }); + } + } + } + } + + /// History can hold every current request, so failures never block unrelated wanted work. + fn suppress_failed_requests(&self, queue: &mut Vec) { + queue.retain(|r| r.kind != RequestKind::Pixels || !self.request_failed(*r)); + } + + fn record_request_failure(&mut self, request: RenderRequest, reason: String) { + let demand = self.error_demand.as_deref().unwrap_or(&self.last_queue); + let domain = failure_domain(request); + let limit = self.failure_limit(request.page, domain); + let errors = self.errors.entry(request.page).or_default(); + errors.retain(|(r, _)| *r != request); + if errors.iter().filter(|(r, _)| failure_domain(*r) == domain).count() >= limit { + let Some(oldest) = errors.iter().position(|(r, _)| failure_domain(*r) == domain && !demand.contains(r)) else { + // Current demand has a slot for each request; only an obsolete/injected result can get here. + return; + }; + errors.remove(oldest); + } + errors.push((request, reason)); + } + pub fn render_pending(&self) -> bool { !self.last_queue.is_empty() || self.text_background.pending() || self.move_background.pending() } @@ -158,6 +260,7 @@ impl DocView { self.texts.clear(); self.text_failed.clear(); self.errors.clear(); + self.error_demand = None; self.slow_pages.clear(); self.waiting_since.clear(); self.last_queue.clear(); @@ -201,6 +304,9 @@ impl DocView { self.texts.remove(&page); self.text_failed.remove(&page); self.errors.remove(&page); + if let Some(demand) = &mut self.error_demand { + demand.retain(|r| r.page != page); + } self.last_queue.clear(); if self.selection.is_some_and(|s| s.page == page) { self.selection = None; @@ -276,6 +382,7 @@ impl DocView { /// state, but do not each retain a separate full application texture allowance. pub(crate) fn suspend_rendering(&mut self, pool: &RenderPool) { self.begin_render_frame(); + self.set_error_demand(Vec::new()); self.pages.clear(); self.tiles.clear(); self.thumbs.clear(); @@ -329,6 +436,10 @@ impl DocView { /// the allowance, so an evicted page can be requested again without a permanent done set. pub(super) fn prepare_render_queue(&mut self, info: &DocInfo, ppp: f32) -> Vec { let requests = std::mem::take(&mut self.frame_queue); + let thumbs = self.thumbnail_requests(info, ppp); + self.set_error_demand( + requests.iter().chain(&thumbs).filter(|r| r.kind == RequestKind::Pixels && r.page < info.pages.len()).copied().collect(), + ); let mut queue = Vec::new(); let mut pages = HashSet::new(); let mut tiles = HashSet::new(); @@ -344,9 +455,7 @@ impl DocView { } // Print-preview rasters: a few sheets at the pane's size (the dialog bounds them). if req.tag & PRINT_TAG != 0 { - if !self.errors.contains_key(&req.page) { - queue.push(*req); - } + queue.push(*req); continue; } // On-screen work is always admitted (the viewport bounds it); the limits only @@ -400,28 +509,26 @@ impl DocView { self.tiles.retain(|key, _| tiles.contains(key) || !tile_tags.contains(&key.1) || self.frame_visible.contains(&key.0)); self.stale_tiles.retain(|key| self.tiles.contains_key(key)); queue.extend(grid); + // Keep other pending tiles alive, but never resubmit a request that already failed. + // Do this after retention: a failure must not evict the page's successful surfaces. + self.suppress_failed_requests(&mut queue); // Opening (or turning to) a page queues its pixels alongside thumbnails and // text extraction. The workers would otherwise spend the first seconds on // thumbnails and search text while the visible page stays blank: hold those - // until no visible main-page raster is still missing. Errored pages never - // block: they are never re-requested. + // until no visible main-page raster is still missing. Failed requests were removed above. let visible_mains_pending = queue.iter().any(|r| { - r.kind == RequestKind::Pixels - && r.tag & (GRID_TAG | PRINT_TAG | THUMB_TAG | TEXT_TAG) == 0 - && self.frame_visible.contains(&r.page) - && !self.errors.contains_key(&r.page) + r.kind == RequestKind::Pixels && r.tag & (GRID_TAG | PRINT_TAG | THUMB_TAG | TEXT_TAG) == 0 && self.frame_visible.contains(&r.page) }); if visible_mains_pending { // Caches are kept; demand is re-evaluated next frame once pixels arrive. return queue; } - let thumbs = self.thumbnail_requests(info, ppp); let mut thumb_bytes = 0usize; let mut admitted = HashSet::new(); for req in thumbs { let Some(p) = info.pages.get(req.page) else { continue }; let bytes = rgba_bytes(device_pixels(p.width, req.scale) as usize, device_pixels(p.height, req.scale) as usize); - if thumb_bytes.saturating_add(bytes) > THUMB_BYTES || self.errors.contains_key(&req.page) { + if thumb_bytes.saturating_add(bytes) > THUMB_BYTES { continue; } thumb_bytes += bytes; @@ -435,6 +542,7 @@ impl DocView { self.retain_text_layers(); // Visible main pages and thumbnails take precedence over background search work. queue.extend(requests.into_iter().filter(|r| r.kind == RequestKind::Text)); + self.suppress_failed_requests(&mut queue); queue } @@ -470,6 +578,7 @@ impl DocView { // Dialogs and edits can replace pages after the canvas collected demand. Its // scales and indexes belong to the old pages, not to the new renderer (#3149). self.begin_render_frame(); + self.set_error_demand(Vec::new()); self.last_queue.clear(); pool.set_queue(Vec::new()); ctx.request_repaint(); @@ -543,7 +652,7 @@ impl DocView { } if let Some(e) = r.error { log::warn!("page {}: {e}", r.request.page + 1); - self.errors.insert(r.request.page, e); + self.record_request_failure(r.request, e); return 0; } // Even current demand may have been sized with obsolete geometry or a different @@ -562,6 +671,15 @@ impl DocView { if bytes != r.rgba.len() || !self.admit_texture(r.request, bytes) { return 0; } + // Only an admitted success can supersede a failure. A complete main-page raster also + // replaces failures of its older main-page tiles; a low-detail backdrop cannot do that. + let complete = r.request.tile.is_none() && !r.request.preview && r.request.tag & (THUMB_TAG | GRID_TAG | PRINT_TAG) == 0; + if let Some(errors) = self.errors.get_mut(&page) { + errors.retain(|(request, _)| *request != r.request && !(complete && request.tag & (THUMB_TAG | GRID_TAG | PRINT_TAG) == 0)); + if errors.is_empty() { + self.errors.remove(&page); + } + } // The length was checked above, so the renderer's buffer becomes the texture data as is. let img = texture_image([r.width as usize, r.height as usize], r.rgba); if let Some(t) = r.request.tile { @@ -662,7 +780,7 @@ impl DocView { pub(crate) fn queue_print_previews(&mut self, pages: &[(usize, f32)]) { self.print_pages.retain(|page, _| pages.iter().any(|(p, _)| p == page)); for &(page, scale) in pages { - if self.errors.contains_key(&page) || !scale.is_finite() || scale <= 0.0 { + if !scale.is_finite() || scale <= 0.0 { continue; } let tag = scale_tag(scale) | PRINT_TAG | if self.grayscale { GRAYSCALE_TAG } else { 0 }; diff --git a/crates/ui-egui/src/canvas/organize.rs b/crates/ui-egui/src/canvas/organize.rs index 7f333b3e468..8f2d340da84 100644 --- a/crates/ui-egui/src/canvas/organize.rs +++ b/crates/ui-egui/src/canvas/organize.rs @@ -245,7 +245,6 @@ fn drag_pages(view: &mut DocView, resp: &egui::Response, page: usize, drop: &mut /// A page drawn larger than its thumbnail gets a render at the size drawn, while it is in view; /// until that arrives the thumbnail stands in. fn request_sharper( - view: &DocView, page: usize, want: f32, page_width: f32, @@ -254,7 +253,7 @@ fn request_sharper( sharper: &mut Vec, ) { in_view.push((page, want)); - if !drawn_width.is_some_and(|w| sharp_enough(w, want)) && !view.errors.contains_key(&page) { + if !drawn_width.is_some_and(|w| sharp_enough(w, want)) { sharper.push(RenderRequest { page, kind: RequestKind::Pixels, @@ -338,7 +337,7 @@ pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, let thumb_ok = thumb.is_some_and(|t| t.size()[0] as f32 >= want * GRID_SHARP.start()); let sharp = view.grid_pages.get(&i).filter(|_| !thumb_ok); if !thumb_ok && ui.is_rect_visible(c) { - request_sharper(view, i, want, p.width, sharp.map(|(w, _)| *w), &mut in_view, &mut sharper); + request_sharper(i, want, p.width, sharp.map(|(w, _)| *w), &mut in_view, &mut sharper); } if let Some(tex) = sharp.map(|(_, t)| t).or(thumb) { ui.painter().image(tex.id(), pr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 634a8851117..376e6964889 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1076,6 +1076,502 @@ fn solid_result(request: RenderRequest, w: u32, h: u32) -> pdfcraft_render::Rend } } +fn tile_error(request: RenderRequest) -> pdfcraft_render::RenderedPage { + pdfcraft_render::RenderedPage { error: Some("synthetic tile failure".into()), ..solid_result(request, 0, 0) } +} + +fn tile_error_harness() -> egui_kittest::Harness<'static, PdfCraftApp> { + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 1000.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + let bytes = app.session.create_blank(300.0, 400.0, 1).unwrap(); + app.open_bytes("tile-errors.pdf", None, bytes.as_ref().clone()).unwrap(); + app.set_option("language", "en").unwrap(); + app.set_option("zoom", "2000").unwrap(); + app.views[0].hold_rasters = true; + app + }); + h.run_steps(4); + h +} + +/// #623 item 4: an error must not replace pixels from another successful request. +#[test] +fn tile_error_keeps_successful_pixels_in_both_result_orders() { + for failure_first in [false, true] { + let mut h = tile_error_harness(); + // Both requests must cover the viewport; the former adjacent 4096px tile was offscreen. + h.set_size(vec2(2600.0, 1600.0)); + h.input_mut().max_texture_side = Some(1024); + h.run_steps(3); + let ctx = h.ctx.clone(); + let requests = h.state().views[0].last_queue.clone(); + let good = *requests.iter().find(|r| r.tile.is_some()).unwrap(); + let tile = good.tile.unwrap(); + let bad = *requests.iter().find(|r| r.tile.is_some() && **r != good).unwrap_or_else(|| panic!("two visible tiles required: {requests:?}")); + let success = || { + let mut result = solid_result(good, tile.w, tile.h); + result.rgba = pdfcraft_render::Pixels::from(vec![u32::from_ne_bytes([210, 30, 30, 255]); (tile.w * tile.h) as usize]); + result + }; + if failure_first { + h.state_mut().views[0].receive_result(&ctx, tile_error(bad)); + h.run_steps(2); + assert!(h.state().views[0].last_queue.contains(&good), "another pending tile must remain wanted after a failure"); + h.state_mut().views[0].receive_result(&ctx, success()); + } else { + h.state_mut().views[0].receive_result(&ctx, success()); + h.state_mut().views[0].receive_result(&ctx, tile_error(bad)); + } + let texture = h.state().views[0].tiles.get(&(0, good.tag, tile.x, tile.y)).unwrap().id(); + h.run_steps(3); + assert_eq!(h.state().views[0].tiles.get(&(0, good.tag, tile.x, tile.y)).unwrap().id(), texture, "the successful texture stays owned"); + let painted = h.ctx.tessellate(h.output().shapes.clone(), h.ctx.pixels_per_point()); + assert!( + painted.iter().any(|p| matches!(&p.primitive, egui::epaint::Primitive::Mesh(m) if m.texture_id == texture)), + "the retained tile must still be painted, failure_first={failure_first}" + ); + let pixels = h.render().unwrap(); + if let Ok(dir) = std::env::var("PDFCRAFT_TILE_ERROR_SHOTS") { + pixels.save(std::path::Path::new(&dir).join(format!("tile-error-{failure_first}.png"))).unwrap(); + } + assert!( + pixels.pixels().filter(|p| matches!(p.0, [r, g, b, _] if r > 205 && g < 40 && b < 40)).count() > 100, + "successful tile remains visible, failure_first={failure_first}" + ); + assert_eq!(h.state().views[0].page_errors(), vec![(0, "synthetic tile failure")]); + assert!( + h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.job.text == "Error\nsynthetic tile failure")), + "the error indication stays visible beside the useful pixels" + ); + h.state_mut().set_option("zoom", "100").unwrap(); + h.run_steps(3); + let painted = h.ctx.tessellate(h.output().shapes.clone(), h.ctx.pixels_per_point()); + assert!( + painted.iter().any(|p| matches!(&p.primitive, egui::epaint::Primitive::Mesh(m) if m.texture_id == texture)), + "zooming below the tiling threshold still shows the retained tile while the new raster is pending" + ); + } +} + +#[test] +fn tile_followup_preview_failures_do_not_mark_the_main_canvas() { + for main_fails in [false, true] { + let mut h = tile_error_harness(); + h.state_mut().set_option("zoom", "100").unwrap(); + h.state_mut().set_option("panel", "pages").unwrap(); + h.run_steps(3); + let ctx = h.ctx.clone(); + let main = + *h.state().views[0].last_queue.iter().find(|r| r.kind == RequestKind::Pixels && r.tag & (THUMB_TAG | GRID_TAG | PRINT_TAG) == 0).unwrap(); + if main_fails { + h.state_mut().views[0].receive_result(&ctx, tile_error(main)); + } else { + assert!(h.state_mut().views[0].receive_result(&ctx, solid_result(main, 8, 8)) > 0, "the main raster must actually be admitted"); + } + h.run_steps(3); + let thumb = *h.state().views[0].last_queue.iter().find(|r| r.tag & THUMB_TAG != 0).unwrap(); + if main_fails { + assert!( + h.state_mut().views[0].receive_result(&ctx, solid_result(thumb, 8, 8)) > 0, + "the independent thumbnail must actually be admitted" + ); + } else { + h.state_mut().views[0] + .receive_result(&ctx, pdfcraft_render::RenderedPage { error: Some("synthetic thumbnail failure".into()), ..tile_error(thumb) }); + } + h.run_steps(3); + assert_eq!( + h.state().views[0].page_errors(), + vec![(0, if main_fails { "synthetic tile failure" } else { "synthetic thumbnail failure" })], + "aggregate diagnostics remain inspectable" + ); + let main_error_shown = h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.job.text.contains("synthetic") && (t.galley.job.text.starts_with("Error\n") || t.galley.job.text.contains("couldn't be displayed")))); + assert_eq!(main_error_shown, main_fails, "only a genuine main request failure belongs on the main canvas"); + if let Ok(dir) = std::env::var("PDFCRAFT_TILE_ERROR_SHOTS") { + h.render().unwrap().save(std::path::Path::new(&dir).join(format!("preview-isolation-main-fails-{main_fails}.png"))).unwrap(); + } + } +} + +#[test] +fn tile_followup_saturated_visible_failures_do_not_starve_a_healthy_tile() { + // ui.resize supports 8192 logical points. The downlevel backend fits its surface to + // 2048px, but egui still lays out this 2x-DPI window in its full logical coordinates. + let mut h = egui_kittest::Harness::builder().with_size(vec2(8192.0, 8192.0)).with_pixels_per_point(2.0).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + let bytes = app.session.create_blank(1000.0, 1000.0, 1).unwrap(); + app.open_bytes("saturation.pdf", None, bytes.as_ref().clone()).unwrap(); + app.set_option("language", "en").unwrap(); + app.set_option("zoom", "2000").unwrap(); + app.views[0].hold_rasters = true; + app + }); + h.input_mut().max_texture_side = Some(2048); + h.state_mut().views[0].goto_point = Some((0, Some(0.1), Some(0.1), Vec2::ZERO)); + h.run_steps(4); + let ctx = h.ctx.clone(); + let wanted = h.state().views[0].error_demand.clone().unwrap(); + let tile_count = wanted.iter().filter(|r| r.tile.is_some()).count(); + eprintln!( + "saturation geometry: viewport={:?}, ppp={}, max_texture_side={}, visible_tiles={tile_count}, pixel_demand={}", + h.state().views[0].viewport_rect(), + ctx.pixels_per_point(), + ctx.input(|i| i.max_texture_side), + wanted.len() + ); + assert!(tile_count > 64, "real canvas geometry must exercise saturation: {tile_count}"); + let healthy = *wanted.iter().rev().find(|r| r.tile.is_some()).unwrap(); + let failures: Vec<_> = + h.state().views[0].last_queue.iter().filter(|r| r.kind == RequestKind::Pixels && **r != healthy).take(64).copied().collect(); + assert_eq!(failures.len(), 64); + for req in &failures { + h.state_mut().views[0].receive_result(&ctx, tile_error(*req)); + } + h.run_steps(3); + assert!(h.state().views[0].last_queue.contains(&healthy), "64 current failures must not permanently starve an untried healthy visible tile"); + let tile = healthy.tile.unwrap(); + h.state_mut().views[0].receive_result(&ctx, solid_result(healthy, tile.w, tile.h)); + let pending = h.state().views[0].last_queue.clone(); + for req in pending.into_iter().filter(|r| *r != healthy && r.kind == RequestKind::Pixels) { + h.state_mut().views[0].receive_result(&ctx, tile_error(req)); + } + for _ in 0..4 { + h.run_steps(1); + assert!( + !h.state().views[0].last_queue.iter().any(|r| r.kind == RequestKind::Pixels), + "all visible failures stay suppressed after saturation" + ); + assert!(h.state().views[0].tiles.contains_key(&(healthy.page, healthy.tag, healthy.tile.unwrap().x, healthy.tile.unwrap().y))); + assert!(h.state().views[0].errors.get(&0).unwrap().len() <= wanted.len().max(64)); + } + let pool = RenderPool::new_inline(Arc::new(Vec::new()), Default::default()); + h.state_mut().views[0].suspend_rendering(&pool); + assert!(h.state().views[0].errors.get(&0).unwrap().len() <= 64, "oversized active history shrinks when demand ends"); + assert_eq!(h.state_mut().views[0].receive_result(&ctx, tile_error(healthy)), 0, "suspension still rejects stale results"); +} + +#[test] +fn tile_review_new_tiled_zoom_hides_history_but_keeps_current_failures() { + let ctx = egui::Context::default(); + let info = thumbnail_info(1); + let mut view = DocView::new(DocId(1), &info, ViewDefaults::default()); + let old = RenderRequest { scale: 8.0, tag: scale_tag(8.0), tile: Some(Tile { x: 0, y: 0, w: 8, h: 8 }), ..Default::default() }; + view.frame_visible.insert(0); + view.frame_queue = vec![old]; + view.last_queue = view.prepare_render_queue(&info, 1.0); + view.receive_result(&ctx, tile_error(old)); + let a = RenderRequest { scale: 16.0, tag: scale_tag(16.0), ..old }; + let b = RenderRequest { tile: Some(Tile { x: 8, y: 0, w: 8, h: 8 }), ..a }; + view.frame_queue = vec![a, b]; + view.last_queue = view.prepare_render_queue(&info, 1.0); + view.receive_result(&ctx, solid_result(a, 8, 8)); + view.receive_result(&ctx, solid_result(b, 8, 8)); + assert!(view.page_errors().is_empty(), "a fully successful tiled zoom must not report the older zoom's error"); + assert!(view.errors.get(&0).unwrap().iter().any(|(r, _)| *r == old), "history still suppresses a return to the failed request"); + view.frame_queue = vec![old]; + view.last_queue = view.prepare_render_queue(&info, 1.0); + assert!(view.last_queue.is_empty()); + assert!(!view.page_errors().is_empty(), "returning to the failed zoom reports its outstanding failure"); + let c = RenderRequest { tile: Some(Tile { x: 16, y: 0, w: 8, h: 8 }), ..a }; + view.frame_queue = vec![a, b, c]; + view.last_queue = view.prepare_render_queue(&info, 1.0); + view.receive_result(&ctx, tile_error(c)); + assert!(!view.page_errors().is_empty(), "the current zoom's actual failure stays visible"); + view.frame_queue = vec![a, b]; + view.last_queue = view.prepare_render_queue(&info, 1.0); + assert!(view.page_errors().is_empty(), "panning to successful tiles hides the offscreen failure at the same zoom"); + view.frame_queue = vec![c]; + view.last_queue = view.prepare_render_queue(&info, 1.0); + assert!(view.last_queue.is_empty() && !view.page_errors().is_empty(), "panning back reports the failure without retrying it"); + view.page_changed(0); + assert!(view.page_errors().is_empty()); + assert_eq!(view.receive_result(&ctx, tile_error(c)), 0); + assert!(view.page_errors().is_empty(), "invalidated demand cannot revive diagnostics"); +} + +#[test] +fn tile_review_main_failure_does_not_block_organize() { + let mut h = tile_error_harness(); + let ctx = h.ctx.clone(); + let main = *h.state().views[0].last_queue.iter().find(|r| r.tile.is_some()).unwrap(); + h.state_mut().views[0].receive_result(&ctx, tile_error(main)); + h.state_mut().views[0].organize = true; + h.state_mut().set_option("grid-zoom", "200").unwrap(); + h.run_steps(3); + let grid = *h.state().views[0].last_queue.iter().find(|r| r.tag & GRID_TAG != 0).expect("organize is independent of a failed main tile"); + h.state_mut().views[0].receive_result(&ctx, tile_error(grid)); + h.run_steps(3); + assert!(!h.state().views[0].last_queue.contains(&grid), "the identical failed grid request stays suppressed"); + assert!(!h.state().views[0].page_errors().is_empty()); + h.state_mut().set_option("grid-zoom", "300").unwrap(); + h.run_steps(3); + let larger = *h.state().views[0].last_queue.iter().find(|r| r.tag & GRID_TAG != 0).expect("a different grid size can succeed"); + assert_ne!(larger, grid); + h.state_mut().views[0].receive_result(&ctx, solid_result(larger, 512, 512)); + assert_eq!(h.state().views[0].grid_pages.len(), 1); +} + +#[test] +fn tile_review_main_failure_does_not_block_thumbnail_or_print() { + let ctx = egui::Context::default(); + let info = thumbnail_info(1); + let mut view = DocView::new(DocId(1), &info, ViewDefaults::default()); + let main = RenderRequest { scale: 8.0, tag: scale_tag(8.0), tile: Some(Tile { x: 0, y: 0, w: 8, h: 8 }), ..Default::default() }; + view.last_queue = vec![main]; + view.receive_result(&ctx, tile_error(main)); + view.begin_render_frame(); + view.need_thumbnail(0, true); + view.queue_print_previews(&[(0, 0.4)]); + view.last_queue = view.prepare_render_queue(&info, 1.0); + let thumb = *view.last_queue.iter().find(|r| r.tag & THUMB_TAG != 0).expect("an unrelated thumbnail must be admitted"); + let print = *view.last_queue.iter().find(|r| r.tag & PRINT_TAG != 0).expect("an unrelated print preview must be admitted"); + view.receive_result(&ctx, solid_result(thumb, 8, 8)); + view.receive_result(&ctx, solid_result(print, 8, 8)); + assert_eq!(view.raster_memory().thumbnail_count, 1); + assert_eq!(view.print_pages.len(), 1); + // Invalidate only these preview surfaces, then fail their exact requests. + view.thumbs.clear(); + view.print_pages.clear(); + view.last_queue = vec![thumb, print]; + view.receive_result(&ctx, tile_error(thumb)); + view.receive_result(&ctx, tile_error(print)); + for _ in 0..4 { + view.begin_render_frame(); + view.need_thumbnail(0, true); + view.queue_print_previews(&[(0, 0.4)]); + view.last_queue = view.prepare_render_queue(&info, 1.0); + assert!(view.last_queue.is_empty(), "identical failed previews are not repeatedly submitted"); + assert!(!view.page_errors().is_empty(), "preview failures remain inspectable"); + } + view.begin_render_frame(); + view.queue_print_previews(&[(0, 0.5)]); + view.last_queue = view.prepare_render_queue(&info, 1.0); + assert_eq!(view.last_queue.len(), 1, "a different print request is independent of the failures"); + view.receive_result(&ctx, solid_result(view.last_queue[0], 8, 8)); + assert_eq!(view.print_pages.len(), 1); +} + +#[test] +fn tile_review_failure_history_is_bounded_without_retrying_active_requests() { + let ctx = egui::Context::default(); + let info = thumbnail_info(1); + let mut view = DocView::new(DocId(1), &info, ViewDefaults::default()); + let anchor = RenderRequest { scale: 16.0, tag: scale_tag(16.0), tile: Some(Tile { x: 0, y: 0, w: 8, h: 8 }), ..Default::default() }; + view.frame_visible.insert(0); + for x in 0..512 { + let request = RenderRequest { tile: Some(Tile { x: x * 8, y: 0, w: 8, h: 8 }), ..anchor }; + view.frame_queue = vec![anchor, request]; + view.last_queue = view.prepare_render_queue(&info, 1.0); + for r in view.last_queue.clone() { + view.receive_result(&ctx, tile_error(r)); + } + assert!(view.errors.get(&0).unwrap().len() <= 64, "main failure history has an explicit count ceiling"); + view.frame_queue = vec![anchor, request]; + view.last_queue = view.prepare_render_queue(&info, 1.0); + assert!(view.last_queue.is_empty(), "neither current failure can be evicted and immediately retried"); + } + assert!(view.errors.get(&0).unwrap().iter().any(|(r, _)| *r == anchor)); + let revisit = RenderRequest { tile: Some(Tile { x: 8, y: 0, w: 8, h: 8 }), ..anchor }; + for _ in 0..4 { + view.frame_queue = vec![anchor, revisit]; + view.last_queue = view.prepare_render_queue(&info, 1.0); + for r in view.last_queue.clone() { + view.receive_result(&ctx, tile_error(r)); + } + view.frame_queue = vec![anchor, revisit]; + view.last_queue = view.prepare_render_queue(&info, 1.0); + assert!(view.last_queue.is_empty(), "an evicted historical request can fail once on revisit, then stays suppressed"); + } + let failures: Vec<_> = view.errors.get(&0).unwrap().iter().map(|(r, _)| *r).collect(); + let extra = RenderRequest { tile: Some(Tile { x: 5000, y: 0, w: 8, h: 8 }), ..anchor }; + view.frame_queue = failures.iter().copied().chain([extra]).collect(); + view.last_queue = view.prepare_render_queue(&info, 1.0); + assert_eq!(view.last_queue, vec![extra], "larger current demand reserves space for untried work without evicting active failures"); + view.receive_result(&ctx, tile_error(extra)); + view.frame_queue = failures.iter().copied().chain([extra]).collect(); + view.last_queue = view.prepare_render_queue(&info, 1.0); + assert!(view.last_queue.is_empty(), "the expanded active set stays suppressed without a retry loop"); + view.begin_render_frame(); + view.queue_print_previews(&[(0, 0.5)]); + view.last_queue = view.prepare_render_queue(&info, 1.0); + assert_eq!(view.last_queue.len(), 1, "main-history saturation leaves a slot for independent preview work"); + assert!(view.errors.get(&0).unwrap().len() <= 64, "obsolete expanded history shrinks to the ordinary allowance"); + view.invalidate_content(); + assert!(view.errors.is_empty() && view.last_queue.is_empty()); +} + +#[test] +fn tile_review_offscreen_tile_does_not_hide_the_no_content_error() { + for rotation in [0, 90, 180, 270] { + let mut h = tile_error_harness(); + h.state_mut().set_option("rotate", &rotation.to_string()).unwrap(); + h.run_steps(3); + let ctx = h.ctx.clone(); + let requests = h.state().views[0].last_queue.clone(); + let main = *requests.iter().find(|r| r.tile.is_some()).unwrap(); + let view = &h.state().views[0]; + let xf = view.page_xform(0).unwrap(); + let (u, v) = xf.screen_to_norm(view.viewport_rect().intersect(xf.rect).center()); + let x = if u > 0.5 { 0 } else { device_pixels(300.0, main.scale).saturating_sub(8) }; + let y = if v > 0.5 { 0 } else { device_pixels(400.0, main.scale).saturating_sub(8) }; + let offscreen = RenderRequest { tile: Some(Tile { x, y, w: 8, h: 8 }), ..main }; + h.state_mut().views[0].last_queue.push(offscreen); + h.state_mut().views[0].receive_result(&ctx, solid_result(offscreen, 8, 8)); + for r in requests.into_iter().filter(|r| r.kind == RequestKind::Pixels) { + h.state_mut().views[0].receive_result(&ctx, tile_error(r)); + } + h.run_steps(3); + assert!(!h.state().views[0].tiles.is_empty(), "offscreen pixels remain cached"); + let pixels = h.render().unwrap(); + if let Ok(dir) = std::env::var("PDFCRAFT_TILE_ERROR_SHOTS") { + pixels.save(std::path::Path::new(&dir).join(format!("offscreen-tile-error-{rotation}.png"))).unwrap(); + } + assert!( + pixels.pixels().filter(|p| p.0 == [0xFB, 0xF4, 0xF4, 0xFF]).count() > 100, + "no visible pixels means the existing full-page error remains meaningful" + ); + } +} + +#[test] +fn tile_error_all_failures_keep_the_full_page_error_without_retrying() { + let mut h = tile_error_harness(); + let ctx = h.ctx.clone(); + let requests = h.state().views[0].last_queue.clone(); + for req in requests.into_iter().filter(|r| r.kind == RequestKind::Pixels) { + h.state_mut().views[0].receive_result(&ctx, tile_error(req)); + } + for _ in 0..4 { + h.run_steps(1); + assert!(!h.state().views[0].last_queue.iter().any(|r| r.kind == RequestKind::Pixels), "failed requests stay suppressed"); + } + assert!(!h.state().views[0].page_errors().is_empty()); + assert!(h.state().views[0].pages.is_empty() && h.state().views[0].tiles.is_empty()); + let pixels = h.render().unwrap(); + assert!(pixels.pixels().filter(|p| p.0 == [0xFB, 0xF4, 0xF4, 0xFF]).count() > 100, "the existing error page is still painted"); +} + +#[test] +fn tile_error_invalidation_rejects_obsolete_successes_and_failures() { + let ctx = egui::Context::default(); + let info = thumbnail_info(2); + let mut view = DocView::new(DocId(1), &info, ViewDefaults::default()); + let req = RenderRequest { scale: 1.0, tag: scale_tag(1.0), tile: Some(Tile { x: 0, y: 0, w: 8, h: 8 }), ..Default::default() }; + let other = RenderRequest { page: 1, ..req }; + view.last_queue = vec![req, other]; + view.receive_result(&ctx, tile_error(req)); + view.receive_result(&ctx, solid_result(other, 8, 8)); + view.page_changed(0); + assert!(view.page_errors().is_empty()); + assert_eq!(view.receive_result(&ctx, tile_error(req)), 0); + assert_eq!(view.receive_result(&ctx, solid_result(req, 8, 8)), 0); + assert!(view.page_errors().is_empty()); + assert_eq!(view.tiles.len(), 1, "another page's successful pixels are untouched"); + view.frame_queue = vec![req]; + view.last_queue = view.prepare_render_queue(&info, 1.0); + assert_eq!(view.last_queue, vec![req], "the edited page can render again"); + view.receive_result(&ctx, tile_error(req)); + view.document_changed(&info); + assert!(view.page_errors().is_empty()); + assert!(view.tiles.is_empty()); + view.receive_result(&ctx, tile_error(req)); + view.receive_result(&ctx, solid_result(req, 8, 8)); + assert!(view.page_errors().is_empty() && view.tiles.is_empty(), "old results cannot repopulate an invalidated document"); + + view.last_queue = vec![req]; + view.receive_result(&ctx, tile_error(req)); + view.document_replaced(&thumbnail_info(1)); + view.receive_result(&ctx, tile_error(req)); + view.receive_result(&ctx, solid_result(req, 8, 8)); + assert!(view.page_errors().is_empty() && view.tiles.is_empty() && view.last_queue.is_empty(), "replacement closes the old result gate"); + assert_eq!(view.page_count, 1); + + // A new document has its own view and pool, even when its page/scale/tile are identical. + let replacement = DocView::new(DocId(2), &info, ViewDefaults::default()); + assert!(replacement.page_errors().is_empty() && replacement.tiles.is_empty()); + view.last_queue = vec![req]; + view.receive_result(&ctx, tile_error(req)); + assert!(replacement.page_errors().is_empty(), "the old view's error does not belong to the replacement"); +} + +#[test] +fn tile_error_a_new_complete_raster_recovers_and_old_zoom_results_stay_rejected() { + let ctx = egui::Context::default(); + let info = thumbnail_info(1); + let mut view = DocView::new(DocId(1), &info, ViewDefaults::default()); + let old = RenderRequest { scale: 8.0, tag: scale_tag(8.0), tile: Some(Tile { x: 0, y: 0, w: 8, h: 8 }), ..Default::default() }; + view.last_queue = vec![old]; + view.receive_result(&ctx, tile_error(old)); + let new = RenderRequest { scale: 1.0, tag: scale_tag(1.0), tile: None, ..old }; + view.frame_queue = vec![new]; + view.last_queue = view.prepare_render_queue(&info, 1.0); + assert_eq!(view.last_queue, vec![new]); + // A malformed success is not recovery and must not hide the original error. + assert_eq!(view.receive_result(&ctx, solid_result(new, 0, 0)), 0); + assert!(!view.page_errors().is_empty()); + assert!(view.receive_result(&ctx, solid_result(new, 8, 8)) > 0); + assert!(view.page_errors().is_empty(), "a complete, current page raster supersedes the older tile failure"); + view.receive_result(&ctx, tile_error(old)); + view.receive_result(&ctx, solid_result(old, 8, 8)); + assert!(view.page_errors().is_empty() && view.tiles.is_empty(), "late errors and pixels from the old zoom are ignored"); + let pool = RenderPool::new_inline(Arc::new(Vec::new()), Default::default()); + view.suspend_rendering(&pool); + view.receive_result(&ctx, tile_error(new)); + assert!(view.page_errors().is_empty() && view.pages.is_empty(), "suspending the view also closes the result gate"); +} + +#[test] +fn tile_error_keeps_a_successful_backdrop_and_reports_the_failure() { + let mut h = tile_error_harness(); + let ctx = h.ctx.clone(); + let requests = h.state().views[0].last_queue.clone(); + let backdrop = *requests.iter().find(|r| r.kind == RequestKind::Pixels && r.tile.is_none()).unwrap(); + let bad = *requests.iter().find(|r| r.tile.is_some()).unwrap(); + let mut result = solid_result(backdrop, 8, 8); + result.rgba = pdfcraft_render::Pixels::from(vec![u32::from_ne_bytes([210, 30, 30, 255]); 64]); + h.state_mut().views[0].receive_result(&ctx, result); + h.state_mut().views[0].receive_result(&ctx, tile_error(bad)); + h.run_steps(3); + let pixels = h.render().unwrap(); + assert!(pixels.pixels().filter(|p| matches!(p.0, [r, g, b, _] if r > 205 && g < 40 && b < 40)).count() > 100); + assert_eq!(h.state().views[0].page_errors(), vec![(0, "synthetic tile failure")]); + if let Ok(dir) = std::env::var("PDFCRAFT_TILE_ERROR_SHOTS") { + pixels.save(std::path::Path::new(&dir).join("backdrop-error.png")).unwrap(); + } +} + +#[test] +fn tile_error_success_only_clears_the_failure_it_supersedes() { + let ctx = egui::Context::default(); + let mut view = DocView::new(DocId(1), &thumbnail_info(1), ViewDefaults::default()); + let first = RenderRequest { tile: Some(Tile { x: 0, y: 0, w: 8, h: 8 }), ..Default::default() }; + let second = RenderRequest { tile: Some(Tile { x: 8, y: 0, w: 8, h: 8 }), ..first }; + view.last_queue = vec![first, second]; + view.receive_result(&ctx, tile_error(first)); + view.receive_result(&ctx, pdfcraft_render::RenderedPage { error: Some("second failure".into()), ..solid_result(second, 0, 0) }); + view.receive_result(&ctx, solid_result(second, 8, 8)); + assert_eq!(view.page_errors(), vec![(0, "synthetic tile failure")], "the other failure still describes the page"); + view.receive_result(&ctx, solid_result(first, 8, 8)); + assert!(view.page_errors().is_empty(), "both requests eventually succeeded"); +} + +#[test] +fn tile_error_notice_remains_visible_when_the_card_cannot_fit() { + let ctx = egui::Context::default(); + let shown = Rect::from_min_size(pos2(20.0, 20.0), vec2(12.0, 16.0)); + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + paint_partial_page_error(ui, shown, 0, "synthetic tile failure", &Tokens::get(ui.ctx())); + }); + assert!( + output + .shapes + .iter() + .any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.rect == shown && r.stroke.color == Color32::from_rgb(0xC8, 0x3A, 0x3A))), + "small visible pages still get an error outline without covering their pixels" + ); + output.drop_without_applying_deltas(); +} + /// #3149: the replacement dialog commits after the canvas collected this frame's demand. #[test] fn replacing_a_page_after_layout_never_uploads_an_oversized_texture() { @@ -1355,7 +1851,8 @@ fn visible_pages_render_before_thumbnails_and_text() { assert!(queue.iter().any(|r| r.tag & THUMB_TAG != 0), "thumbnails admitted after pixels"); assert!(!queue.iter().any(|r| r.kind == RequestKind::Pixels && r.tag & THUMB_TAG == 0), "no main pixels re-requested"); // An errored visible page never holds background work back. - v.errors.insert(0, "gone".to_string()); + v.last_queue = vec![main]; + v.receive_result(&ctx, tile_error(main)); v.begin_render_frame(); v.frame_visible = [0].into(); v.frame_queue = vec![main, text]; diff --git a/docs/roadmap.md b/docs/roadmap.md index 25205e92c13..7eb3e683b94 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M3, #623 item 4 viewer candidate: isolate main-canvas diagnostics and preserve saturated visible-work progress; estimates unchanged) > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) @@ -163,7 +164,7 @@ total matches the parity estimate. | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. Radial luminosity fades have synthetic coverage for the existing #2132 mask guard, including per-pixel references, antialiased clips, previews, scales and tiles; no additional production fix was needed. Dotted TOC leaders no longer inflate letter tracking or hide word spaces (#3086); phrase search and selection are covered. Short tracked ellipses retain their gap samples and remain searchable. Missing spacing glyphs in non-embedded Japanese CID substitutes render blank without changing PDF advances or Unicode (#3601). | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. Repeated window resizes by an edge and moves by the tab strip on GNOME/Wayland work without a click in between. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. Repeated window resizes by an edge and moves by the tab strip on GNOME/Wayland work without a click in between. #623 item 4 viewer candidate: useful visible pixels stay with an error notice after another request fails. Bounded request history suppresses active failures independently for main, thumbnail, grid and print rendering; obsolete zoom/pan diagnostics clear after successful current rendering. Synthetic regressions cover ordering, viewport coverage, scheduling, stress, invalidation and recovery. Follow-up: preview failures remain inspectable without a false main-canvas error; history grows only to current demand and shrinks afterwards, allowing untried tiles to progress past 64 active failures in oversized downlevel HiDPI windows. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | @@ -255,6 +256,8 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-28 | minor | M3, #623 item 4 follow-up: isolate main-canvas error selection from aggregate preview diagnostics. Bound failure retention by current demand with a 64-record history floor; trim on demand changes so saturated active failures do not starve healthy tiles. New deterministic UI and oversized downlevel-window regressions; estimates unchanged. | +| 2026-10-28 | minor | M3, #623 item 4 viewer candidate: successful tiles and backdrops remain visible after another request fails; bounded request-specific failure history preserves pending successes and suppresses active retries independently for main, thumbnail, grid and print. Current-demand diagnostics and intersecting cached coverage avoid obsolete zoom/pan errors and misleading partial-error cards. Synthetic viewer tests cover ordering, all failures, preview admission, retention stress, rotation, replacement, stale results and recovery. Watchdog budgets and estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. |