diff --git a/ROADMAP.md b/ROADMAP.md index 036eb6521a..2823b980cb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -133,6 +133,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run - **2026-10-09 (M6, field rotation):** Regenerated form appearances honour `/MK /R` (0, 90, 180, 270). Filling or restyling a rotated field keeps the text, check mark and button caption turned, and Field Properties ▸ Position and `form_set_props` `rotation` set it, swapping width and height around the field's center when the axis changes. `form.appearance-regeneration` stays partial (Helvetica metrics are still approximate). Overall estimate unchanged (about 30–35%). - **2026-10-09 (M2, JPX sample normalization):** JPEG 2000 rescaling uses bounded integer arithmetic for supported 1–32-bit samples, including the 32-bit boundary that previously shifted past the integer width. Zero-sized rows are refused before scaling. Small contributor-original codestreams exercise decoding and rendering; endpoint and standard-depth regressions cover the same helper. Overall estimate unchanged (about 30–35%). +- **2026-10-09 (M14, #577):** The desktop GPU setup enables the selected adapter's supported 2D texture extent instead of egui-wgpu's fixed 8192-pixel extent. This removes the artificial surface ceiling behind a mixed-DPI Windows startup crash when opening Outlook PDF attachments. Synthetic regressions cover the observed 8600 × 3423 surface, downlevel adapters and preservation of other device limits. Hardware resolution limits still apply; milestone and overall effort estimates are unchanged. + - **2026-10-09 (M5.7, #434 partial):** Natural-size image stamps placed at a click or through `stamp_custom` remain upright on rotated pages, with a displayed-space-sized rectangle and a counterrotated standard appearance matrix. Move, resize, restyle and undo/redo preserve that orientation. Native and external Poppler save/reopen rendering cover all four quarter-turns; UI tests verify the displayed bounds. Explicit rectangle placement, image signatures, PDF-page stamps and image-file orientation metadata remain outside this repair. #434 stays open; milestone table and effort estimates remain unchanged. - **2026-10-09 (M1, raw-stream decoding limits):** COS checks unfiltered decoded data against the caller and per-stream limits before copying it, including empty filter arrays and streams whose Crypt filter was applied on load. Strict decoding uses the same raw-data guard. Tiny synthetic regressions cover exceeded, exact and zero-byte limits while retaining the filtered decoder behavior. Milestone and overall effort estimates unchanged (about 30–35%). diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 5d567b8195..ae295990f3 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -390,6 +390,16 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()> /// its window appeared. D3D12 is the native, best-supported backend there. fn configure_gpu(native: &mut eframe::NativeOptions) { let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut native.wgpu_options.wgpu_setup else { return }; + // egui-wgpu requests only 8192 pixels even when the adapter can render larger surfaces. + // A restored 3440-point window at 250% DPI requests 8600 pixels and otherwise panics in + // Surface::configure before the app starts. Keep the renderer's other device requirements, + // but enable the adapter's actual 2D texture extent (also for smaller/downlevel adapters). + let device_descriptor = std::sync::Arc::clone(&setup.device_descriptor); + setup.device_descriptor = std::sync::Arc::new(move |adapter| { + let mut descriptor = device_descriptor(adapter); + descriptor.required_limits = surface_texture_limits(descriptor.required_limits, &adapter.limits()); + descriptor + }); if std::env::var_os("WGPU_POWER_PREF").is_none() { setup.power_preference = eframe::wgpu::PowerPreference::LowPower; #[cfg(target_os = "linux")] @@ -419,6 +429,13 @@ fn configure_gpu(native: &mut eframe::NativeOptions) { } } +/// Surface textures must fit the device's enabled limits, not just the physical GPU's limits. +/// Changing only the 2D extent preserves egui-wgpu's backend-specific downlevel requirements. +fn surface_texture_limits(mut required: eframe::wgpu::Limits, supported: &eframe::wgpu::Limits) -> eframe::wgpu::Limits { + required.max_texture_dimension_2d = supported.max_texture_dimension_2d; + required +} + /// A GPU with a connected monitor. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[cfg_attr(not(target_os = "linux"), allow(dead_code))] @@ -555,6 +572,32 @@ mod tests { } } + #[test] + fn restored_hidpi_window_fits_the_adapters_surface_limit() { + // The Outlook launch crash: 3440 x 1369 saved points at 250% DPI requested this surface. + let (width, height) = (8600, 3423); + let required = eframe::wgpu::Limits::default(); + assert!(width > required.max_texture_dimension_2d); + let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 16384, ..required.clone() }; + let enabled = super::surface_texture_limits(required, &supported); + assert!(width <= enabled.max_texture_dimension_2d); + assert!(height <= enabled.max_texture_dimension_2d); + } + + #[test] + fn surface_limits_preserve_other_requirements_and_never_overrequest() { + for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] { + for extent in [4096, 8192, 16384] { + let supported = eframe::wgpu::Limits { max_texture_dimension_2d: extent, ..required.clone() }; + let enabled = super::surface_texture_limits(required.clone(), &supported); + let mut expected = required.clone(); + expected.max_texture_dimension_2d = extent; + assert_eq!(enabled, expected); + assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d); + } + } + } + #[test] fn winit_carries_the_windows_11_monitor_scale_fix() { // Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f985925e84..11bc1cb1ec 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -222,8 +222,9 @@ pub struct DocView { thumbs: HashMap, /// Thumbnails that are out of date (still shown until their replacement arrives). stale_thumbs: HashSet, - /// Sharp tiles of large pages: (page, tile x, tile y) → (scale tag, texture). - tiles: HashMap<(usize, u32, u32), (u64, TextureHandle)>, + /// Sharp tiles of large pages: (page, scale tag, tile x, tile y) → texture. Tiles of an + /// earlier zoom stay, drawn stretched, until those of the current one cover the page. + tiles: HashMap<(usize, u64, u32, u32), TextureHandle>, /// Text layers, extracted in the background on demand (selection, find, copy). pub(crate) texts: HashMap>, pub(crate) text_failed: HashSet, @@ -520,7 +521,7 @@ impl DocView { if self.thumbs.contains_key(&page) { self.stale_thumbs.insert(page); } - self.tiles.retain(|(p, _, _), _| *p != page); + self.tiles.retain(|(p, _, _, _), _| *p != page); self.texts.remove(&page); self.text_failed.remove(&page); self.errors.remove(&page); @@ -954,7 +955,7 @@ impl DocView { let page = r.request.page; if let Some(t) = r.request.tile { let tex = ctx.load_texture(format!("tile-{:?}-{page}-{}-{}", self.id, t.x, t.y), img, TextureOptions::LINEAR); - self.tiles.insert((page, t.x / TILE, t.y / TILE), (r.request.tag, tex)); + self.tiles.insert((page, r.request.tag, t.x / TILE, t.y / TILE), tex); continue; } if r.request.tag & THUMB_TAG != 0 { @@ -1046,6 +1047,14 @@ fn scale_tag(scale: f32) -> u64 { (f64::from(scale) * 65536.0).round() as u64 } +/// The scale of a raster tagged by [`scale_tag`]. +fn tag_scale(tag: u64) -> f32 { + (tag as f64 / 65536.0) as f32 +} + +/// Tiles of earlier zooms kept for a page while its current tiles are rendered. +const OLD_TILES: usize = 64; + /// `r` moved so its corner lies on a whole physical pixel, so that a raster drawn from there /// maps texel for texel onto the screen. fn snap_to_pixels(r: Rect, ppp: f32) -> Rect { @@ -1590,7 +1599,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // A backdrop, or a raster at an older scale until the new one arrives. xf.paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0); } - if p.tag != want_tag { + // Under tiles, a whole-page raster sharper than the backdrop (from before + // the page grew past the tiling size) is a better backdrop: keep it. + let sharper = tiled && p.tag != STALE_TAG && tag_scale(p.tag) >= want_scale; + if p.tag != want_tag && !sharper { wanted.push((i, want_scale, want_tag, None)); } } @@ -1613,6 +1625,21 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let (v0, v1) = corners.iter().fold((1.0f32, 0.0f32), |(a, b), c| (a.min(c.1), b.max(c.1))); let (vx0, vy0) = ((u0.max(0.0) * dw as f32) as u32, (v0.max(0.0) * dh as f32) as u32); 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, tx, ty), tex)| (tag_scale(*t), *tx, *ty, tex)) + .collect(); + older.sort_by(|a, b| (b.0 / scale).ln().abs().total_cmp(&(a.0 / scale).ln().abs())); + for (s, tx, ty, 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) = ((tx * TILE) as f32, (ty * TILE) as f32); + xf.paint_image(painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh); + } for ty in vy0 / TILE..=(vy1.saturating_sub(1)) / TILE { for tx in vx0 / TILE..=(vx1.saturating_sub(1)) / TILE { let (x, y) = (tx * TILE, ty * TILE); @@ -1620,8 +1647,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if w == 0 || h == 0 { continue; } - match view.tiles.get(&(i, tx, ty)) { - Some((ttag, tex)) if *ttag == tag => { + match view.tiles.get(&(i, tag, tx, ty)) { + Some(tex) => { let (fw, fh) = (dw as f32, dh as f32); xf.texel_aligned([dw as usize, dh as usize], ppp).paint_image( painter, @@ -2056,8 +2083,18 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let cur = view.current; // Nearest pages first; for each page, the backdrop before its tiles. wanted.sort_by_key(|w| ((w.0 as isize - cur as isize).unsigned_abs(), w.3.is_some())); - // Tiles for other zoom levels or far-away pages are useless: free them. - view.tiles.retain(|(p, _, _), (t, _)| *t == tag && p.abs_diff(cur) <= 2); + // Tiles of far-away pages are useless: free them. Tiles of other zooms stay only while a + // current tile of their page is still missing, at most `OLD_TILES` of them, nearest scale first. + let missing: HashSet = wanted.iter().filter(|w| w.3.is_some()).map(|w| w.0).collect(); + view.tiles.retain(|(p, t, _, _), _| p.abs_diff(cur) <= 2 && (*t == tag || missing.contains(p))); + let mut older: Vec<(f32, (usize, u64, u32, u32))> = + view.tiles.keys().filter(|k| k.1 != tag).map(|k| ((tag_scale(k.1) / scale).ln().abs(), *k)).collect(); + if older.len() > OLD_TILES { + older.sort_by(|a, b| a.0.total_cmp(&b.0)); + for (_, k) in older.drain(OLD_TILES..) { + view.tiles.remove(&k); + } + } let mut queue: Vec = wanted.iter().map(|&(page, scale, tag, tile)| RenderRequest { page, kind: RequestKind::Pixels, tile, scale, tag }).collect(); // Text layers: visible pages for selection, every page while a search is active. diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index a85daa4893..479c58b7ef 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -818,3 +818,61 @@ fn compare_page_with_raster(h: &mut Harness<'static, PdfCraftApp>, pdf: &[u8], z } (sum as f64 / n as f64, worst) } + +#[test] +fn zooming_a_tiled_page_shows_its_earlier_tiles_until_new_ones_arrive() { + let _gpu = gpu(); + // A 1200 pt square page of fine lines (0.5 pt every 1.7 pt): tiled at 300 %. + let mut content = String::from("0 g\n"); + for i in 0..670 { + let v = 30.0 + 1.7 * i as f32; + content.push_str(&format!("{v} 30 0.5 1140 re 30 {v} 1140 0.5 re\n")); + } + content.push_str("f\n"); + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 1200 1200] /Contents 4 0 R >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ) + .into_bytes(); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.open_bytes("grid.pdf", None, pdf.clone()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app.set_option("zoom", "300").unwrap(); + app + }); + for _ in 0..400 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + // Detail in the middle of the window: the summed difference between neighbouring pixels. + let detail = |h: &mut Harness<'static, PdfCraftApp>| { + let img = h.render().expect("renders"); + let (cx, cy) = (img.width() / 2, img.height() / 2); + let mut sum = 0u64; + for y in cy - 100..cy + 100 { + for x in cx - 150..cx + 150 { + sum += u64::from(img.get_pixel(x, y)[0].abs_diff(img.get_pixel(x + 1, y)[0])); + } + } + sum + }; + let sharp = detail(&mut h); + // The first frame at a new zoom has no tiles for it yet (they are requested at its end). + h.state_mut().set_option("zoom", "310").unwrap(); + h.step(); + let first = detail(&mut h); + assert!(first * 10 > sharp * 7, "the first frame after zooming lost its detail: {first} against {sharp}"); +} diff --git a/docs/development.md b/docs/development.md index aa46e0cd14..a6b2c48871 100644 --- a/docs/development.md +++ b/docs/development.md @@ -24,6 +24,14 @@ By default PdfCraft's own crates (`pdfcraft*`) log at `info` and everything else covers every target starting with it (`pdfcraft*=debug`). The logger is `apps/pdfcraft/src/logging.rs`. It never records the control-channel token or document passwords. +## GPU surface limits + +The desktop renderer enables the selected adapter's supported 2D texture extent, while keeping +egui-wgpu's other device requirements. Restored windows on mixed-DPI monitors can exceed its +default 8192-pixel extent even when the GPU supports a larger surface (#577). The enabled extent +never exceeds the adapter's capability; this does not make surfaces larger than the hardware +limit renderable. + ## Environment variables | Variable | Effect |