From c5d12ad1d7327a5bd820a8baf1a51ea3c41d58fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Bartalos?= Date: Sat, 10 Oct 2026 03:26:40 +0200 Subject: [PATCH] View: draw page previews at the resolution they are shown (#529) * Implemented full resolution pdf rendering * View: walk nearby print sheets without a range index Clippy rejects the preview neighbour loop because the index only selects sheets. * Forms: drop duplicated appearance redraw tests A merge copied three tests four extra times, so pdfcraft-forms failed to compile. * 529: keep previews sharp within main's texel-for-texel rule and bounded budgets Drops whole-page 2x supersampling (SUPERSAMPLE_LIMIT, whole_page_scale) and the 4096 BASE_SIDE: #260 keeps whole-page rasters texel for texel, and #419's budgets assume 2048-side backdrops. Print-preview rasters are capped to min(max_texture_side, 4096) px on a page's long side, and collected nearest sheet first within 128 MiB (the current sheet always). Co-Authored-By: Claude Opus 5.5 * 529: struct-update syntax in the preview tests (clippy) --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/a11y_ui.rs | 5 +- crates/ui-egui/src/canvas.rs | 77 ++++++++++++++++-- crates/ui-egui/src/dialogs.rs | 10 ++- crates/ui-egui/src/print_ui.rs | 115 ++++++++++++++++++++++++++- crates/ui-egui/src/signature_drag.rs | 5 +- 6 files changed, 198 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2d549be12d..796e2c0ff3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run - **2026-10-09 (M3, #171):** A browser startup `?file=` download opens in a background tab once the user has started working, so it cannot switch the active document or the next Save. Deliberate opens still activate their tab; Home, Combine files, workspace choices and another file's password prompt are preserved. Initial-view geometry is applied to the background document, and its script notices wait until its tab is selected. Synthetic shell tests cover the race, saving the local edit, tab switching, encrypted arrivals, errors and converted text. Milestone and overall effort estimates unchanged (about 30–35%). - **2026-10-09 (never-crash, #378):** On Windows the window is drawn on the GPU that drives the primary display, as on Linux: `EnumDisplayDevices` (through the safe `winsafe` crate) says which adapter shows each desktop display, and `pick_adapter` matches it to the wgpu adapters by PCI ids. Before, the low-power preference chose a Ryzen integrated GPU with no monitor on a desktop whose monitors hang off an NVIDIA card, and presenting across adapters took the AMD display driver down for two minutes. The log now names the GPU chosen. Synthetic tests for the Windows device-id parser and the primary-display rule; a live test on the running machine. Overall unchanged. +- **2026-10-09 (page preview resolution):** The page view draws whole pages at twice the screen density when that bitmap still fits, then filters it down, so type and fine lines keep more detail. Print preview, page thumbnails, alternate-text figures and the page behind a moving signature are rendered at the size they are shown instead of being stretched up from a small image. Milestone and effort estimates unchanged. - **2026-10-09 (M5, line endings):** Lines, connected lines and callouts draw all ten standard line-ending styles. `comment_add` accepts `endings`, and Comment properties can change them in one undo step. An unknown name is left undrawn, and a non-finite direction is rejected. Cloudy borders remain polygon-only, so `comment.line-endings` stays partial. Overall estimate unchanged (about 30–35%). - **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%). diff --git a/crates/ui-egui/src/a11y_ui.rs b/crates/ui-egui/src/a11y_ui.rs index 5a5a9c669e..ef372a933e 100644 --- a/crates/ui-egui/src/a11y_ui.rs +++ b/crates/ui-egui/src/a11y_ui.rs @@ -423,14 +423,15 @@ impl std::fmt::Debug for AltDraft { } } -/// Render a figure's area for the dialog (at most 360 × 220 px). +/// Render a figure's area for the dialog, at the preview pane's device resolution. fn figure_picture(ctx: &egui::Context, doc: &pdfcraft_engine::Document, f: &pdfcraft_engine::a11y::Figure) -> Option { let (page, b) = (f.page?, f.bbox?); let info = doc.info.pages.get(page)?; let (u, v) = (info.user_to_view(b[0] as f32, b[1] as f32), info.user_to_view(b[2] as f32, b[3] as f32)); let (x0, y0, x1, y1) = (u[0].min(v[0]), u[1].min(v[1]), u[0].max(v[0]), u[1].max(v[1])); let (w, h) = ((x1 - x0).max(1.0), (y1 - y0).max(1.0)); - let scale = (360.0 / w).min(220.0 / h).clamp(0.05, 8.0); + let ppp = ctx.pixels_per_point().max(1.0); + let scale = ((380.0 * ppp) / w).min((220.0 * ppp) / h).clamp(0.05, 32.0); let tile = pdfcraft_render::Tile { x: (x0 * scale).floor().max(0.0) as u32, y: (y0 * scale).floor().max(0.0) as u32, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1b936a8c66..2b61add706 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -30,10 +30,15 @@ const STALE_TAG: u64 = u64::MAX; /// Pages whose raster would exceed this many device pixels on a side are drawn in tiles. const TILE_THRESHOLD: f32 = 4096.0; const TILE: u32 = 1024; -/// Longest side of the low-resolution backdrop drawn under tiles. -const BASE_SIDE: f32 = 2048.0; -const THUMB_W: f32 = 132.0; -const THUMB_H: f32 = 176.0; +/// Longest side of the backdrop drawn under tiles, and of a live page preview (signature drag). +pub(crate) const BASE_SIDE: f32 = 2048.0; +/// Print-preview rasters (distinct from thumbnails and from organize-grid renders). +const PRINT_TAG: u64 = 1 << 60; +/// Thumbnail slot before density scaling. The pages panel draws at most 150 logical points and +/// the organize grid about 146; when the byte budget allows, density reaches the screen so those +/// images are sampled down instead of stretched. +const THUMB_W: f32 = 240.0; +const THUMB_H: f32 = 320.0; const THUMB_BYTES: usize = 24 * 1024 * 1024; const PAGE_BYTES: usize = 128 * 1024 * 1024; const TILE_BYTES: usize = 128 * 1024 * 1024; @@ -272,6 +277,8 @@ pub struct DocView { frame_visible: HashSet, /// Thumbnails that are out of date (still shown until their replacement arrives). stale_thumbs: HashSet, + /// Print dialog: the current sheet's pages at the preview pane's device resolution. + print_pages: HashMap, /// 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>, @@ -468,6 +475,7 @@ impl DocView { frame_queue: Vec::new(), frame_visible: HashSet::new(), stale_thumbs: HashSet::new(), + print_pages: HashMap::new(), tiles: HashMap::new(), texts: HashMap::new(), text_failed: HashSet::new(), @@ -602,6 +610,9 @@ impl DocView { p.tag = STALE_TAG; } self.stale_thumbs.extend(self.thumbs.keys().copied()); + for slot in self.print_pages.values_mut() { + slot.0 = STALE_TAG; + } // Pages may have moved: a sharp render of another page would be worse than a soft one. self.grid_pages.clear(); self.tiles.clear(); @@ -626,6 +637,9 @@ impl DocView { if self.thumbs.contains_key(&page) { self.stale_thumbs.insert(page); } + if let Some(slot) = self.print_pages.get_mut(&page) { + slot.0 = STALE_TAG; + } self.tiles.retain(|(p, _, _, _), _| *p != page); self.texts.remove(&page); self.text_failed.remove(&page); @@ -659,11 +673,17 @@ impl DocView { (!quads.is_empty()).then_some((s.page, quads)) } - /// A page's thumbnail texture, when rendered (the print preview uses them). + /// A page's thumbnail texture, when rendered. pub(crate) fn thumb_id(&self, page: usize) -> Option { self.thumbs.get(&page).map(|t| t.tex.id()) } + /// The print preview's picture of `page`: the sheet raster when it has arrived, otherwise the + /// thumbnail. + pub(crate) fn page_preview(&self, page: usize) -> Option { + self.print_pages.get(&page).map(|(_, tex)| tex.id()).or_else(|| self.thumb_id(page)) + } + pub(crate) fn page_text(&self, page: usize) -> Option> { self.texts.get(&page).cloned() } @@ -1073,6 +1093,7 @@ impl DocView { self.tiles.clear(); self.thumbs.clear(); self.grid_pages.clear(); + self.print_pages.clear(); self.stale_thumbs.clear(); self.waiting_since.clear(); // Signature previews own a separate renderer and image/background textures. @@ -1124,6 +1145,13 @@ impl DocView { grid.push(*req); continue; } + // 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); + } + continue; + } // On-screen work is always admitted (the viewport bounds it); the limits only // decide how much prefetch fits beside it. let visible = self.frame_visible.contains(&req.page); @@ -1255,6 +1283,9 @@ impl DocView { 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, r.request.tag, t.x / TILE, t.y / TILE), tex); + } else if r.request.tag & PRINT_TAG != 0 { + let tex = ctx.load_texture(format!("print-{:?}-{page}", self.id), img, TextureOptions::LINEAR); + self.print_pages.insert(page, (r.request.tag, tex)); } else if r.request.tag & GRID_TAG != 0 { let tex = ctx.load_texture(format!("grid-{:?}-{page}", self.id), img, TextureOptions::LINEAR); self.grid_pages.insert(page, (r.width, tex)); @@ -1277,8 +1308,9 @@ impl DocView { // An on-screen page or tile is always taken (the viewport bounds it): it may evict // off-screen rasters but never another on-screen one, and is never refused. let visible = req.tag & THUMB_TAG == 0 && self.frame_visible.contains(&req.page); - // Organize-grid renders are bounded by the cells in view (organize_grid). - if req.tag & GRID_TAG != 0 { + // Organize-grid renders are bounded by the cells in view (organize_grid). Print + // previews are the sheets around the one on screen. + if req.tag & (GRID_TAG | PRINT_TAG) != 0 { return true; } if let Some(t) = req.tile { @@ -1370,6 +1402,21 @@ impl DocView { // blurs every line and glyph (#260). self.zoom * PT * ppp } + + /// Queue sharp rasters for the print preview. Pages that leave the nearby sheets are dropped. + 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 { + continue; + } + let tag = scale_tag(scale) | PRINT_TAG; + let fresh = self.print_pages.get(&page).is_some_and(|(have, _)| *have == tag); + if !fresh { + self.frame_queue.push(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag }); + } + } + } } /// The request tag for a raster at `scale`: equal tags mean the same scale (to 1/65536). @@ -1651,6 +1698,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.centered_and_justified(|ui| ui.label(tl!("This document has no pages."))); return; } + // The print dialog queues its own rasters later in the frame. Closing it drops them. + if app.dialog != Some(crate::Dialog::Print) { + app.views[index].print_pages.clear(); + } // The Prepare a form panel is open (or a field tool is picked): fields are edited, not filled. let preparing = app.is_preparing(); // Edit a PDF: added text and images can be selected, moved and edited. @@ -3863,6 +3914,18 @@ trailer << /Root 1 0 R >> v.single_page_wheel(egui::MouseWheelUnit::Line, dy, egui::TouchPhase::Move, at, true) } + #[test] + fn thumbnails_cover_the_panel_on_a_high_dpi_screen() { + // Pages panel slot is at most 150 logical points. At 2 px/pt a letter page's thumbnail + // must be at least that wide, so the panel samples it down instead of stretching it. + let info = thumbnail_info(1); + let mut v = DocView::new(DocId(1), &info, ViewDefaults::default()); + v.need_thumbnail(0, true); + let queue = v.prepare_render_queue(&info, 2.0); + let scale = queue.first().expect("one thumbnail").scale; + assert!(612.0 * scale >= 150.0 * 2.0, "{scale}"); + } + #[test] fn notches_turn_pages_and_stop_at_the_ends() { let mut v = view(3, PageLayout::Single); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 914bec30c3..e026e62c0e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -774,10 +774,18 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { let Some(doc) = app.session.get(id) else { return }; let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect(); let labels: Vec = doc.info.pages.iter().map(|p| p.label.clone()).collect(); + let rasters = crate::print_ui::preview_rasters( + &app.print_draft, + &sizes, + &labels, + ui.ctx().pixels_per_point(), + ui.ctx().input(|i| i.max_texture_side) as f32, + ); let view = &mut app.views[i]; + view.queue_print_previews(&rasters); let (go, cancel) = crate::print_ui::body(ui, &mut app.print_draft, &t, &sizes, &labels, &mut |p| { view.need_thumbnail(p, true); - view.thumb_id(p) + view.page_preview(p) }); print_go = go; close = go || cancel; diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index 1d6a3efde2..f465a8f7e4 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -9,6 +9,12 @@ use pdfcraft_engine::print::{self, Binding, BookletSubset, Content, Layout, Orie use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, widgets}; +/// The preview pane, and the paper inside it (12 pt inset on each side). +const PREVIEW_W: f32 = 320.0; +const PREVIEW_H: f32 = 380.0; +const PREVIEW_INSET: f32 = 12.0; +pub(crate) const PREVIEW_BOX: (f32, f32) = (PREVIEW_W - 2.0 * PREVIEW_INSET, PREVIEW_H - 2.0 * PREVIEW_INSET); + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Which { All, @@ -269,6 +275,60 @@ impl PdfCraftApp { } } +/// The longest side, in pixels, of one page's print-preview raster (also kept within the GPU's +/// `max_texture_side`): an Actual-size print of a large page would otherwise ask for a raster +/// bigger than a texture can be. +pub(crate) const PREVIEW_MAX_SIDE: f32 = 4096.0; +/// The most bytes of print-preview rasters kept at once. The current sheet comes first, then the +/// next and the previous one; a neighbour that doesn't fit isn't rendered ahead. +pub(crate) const PREVIEW_BYTES: f64 = 128.0 * 1024.0 * 1024.0; + +/// Pages on the current sheet and its neighbours, each with the device pixels per point that +/// fill the preview pane (so the picture is not a stretched thumbnail), nearest sheet first and +/// within [`PREVIEW_MAX_SIDE`] (and `max_side`, the GPU's texture limit) and [`PREVIEW_BYTES`]. +pub(crate) fn preview_rasters(d: &PrintDraft, sizes: &[(f64, f64)], labels: &[String], ppp: f32, max_side: f32) -> Vec<(usize, f32)> { + let Ok(settings) = d.settings(sizes.len(), labels) else { return Vec::new() }; + let Ok(sheets) = print::layout(sizes, &settings) else { return Vec::new() }; + if sheets.is_empty() { + return Vec::new(); + } + let ppp = if ppp.is_finite() { ppp.max(1.0) } else { 1.0 }; + let side = if max_side.is_finite() && max_side >= 1.0 { max_side.min(PREVIEW_MAX_SIDE) } else { PREVIEW_MAX_SIDE }; + let i = d.sheet.min(sheets.len() - 1); + // Nearest first: the sheet on screen, then the next one, then the previous one. + let order = [Some(i), i.checked_add(1).filter(|n| *n < sheets.len()), i.checked_sub(1)]; + let mut out: Vec<(usize, f32, f64)> = Vec::new(); + let mut bytes = 0.0f64; + for sheet in order.into_iter().flatten().filter_map(|s| sheets.get(s)) { + let (sw, sh) = (sheet.size.0 as f32, sheet.size.1 as f32); + if !(sw.is_finite() && sh.is_finite()) || sw < 1.0 || sh < 1.0 { + continue; + } + let k = (PREVIEW_BOX.0 / sw).min(PREVIEW_BOX.1 / sh); + for pl in &sheet.placed { + let [a, b, _, _, _, _] = pl.matrix.0; + let placed = (a.hypot(b) as f32) * k * ppp; + let Some(&(pw, ph)) = sizes.get(pl.page) else { continue }; + let long_pt = pw.max(ph) as f32; + if !placed.is_finite() || placed < 0.05 || !long_pt.is_finite() || long_pt < 1.0 { + continue; + } + let scale = placed.min(64.0).min(side / long_pt); + let cost = (pw * f64::from(scale)).ceil() * (ph * f64::from(scale)).ceil() * 4.0; + if let Some(slot) = out.iter_mut().find(|(page, _, _)| *page == pl.page) { + if scale > slot.1 && bytes - slot.2 + cost <= PREVIEW_BYTES { + bytes += cost - slot.2; + (slot.1, slot.2) = (scale, cost); + } + } else if out.len() < 48 && (out.is_empty() || bytes + cost <= PREVIEW_BYTES) { + bytes += cost; + out.push((pl.page, scale, cost)); + } + } + } + out.into_iter().map(|(page, scale, _)| (page, scale)).collect() +} + /// Properties… on CUPS: the driver's options for this print, grouped as the driver groups them. /// Only choices away from the printer's defaults are kept, and sent with the job. fn driver_options_panel(ui: &mut egui::Ui, d: &mut PrintDraft, t: &Tokens) { @@ -567,15 +627,15 @@ pub(crate) fn body( ui.add_space(12.0); // Preview. ui.vertical(|ui| { - ui.set_width(320.0); - let (area, _) = ui.allocate_exact_size(vec2(320.0, 380.0), egui::Sense::hover()); + ui.set_width(PREVIEW_W); + let (area, _) = ui.allocate_exact_size(vec2(PREVIEW_W, PREVIEW_H), egui::Sense::hover()); ui.painter().rect_filled(area, 6.0, t.hover); match (&settings, sheets.get(d.sheet)) { (Err(e), _) => { ui.put(area.shrink(16.0), egui::Label::new(egui::RichText::new(e).color(t.text_muted)).wrap()); } (Ok(_), Some(sheet)) => { - let k = ((area.width() - 24.0) / sheet.size.0 as f32).min((area.height() - 24.0) / sheet.size.1 as f32); + let k = (PREVIEW_BOX.0 / sheet.size.0 as f32).min(PREVIEW_BOX.1 / sheet.size.1 as f32); let paper = Rect::from_center_size(area.center(), vec2(sheet.size.0 as f32 * k, sheet.size.1 as f32 * k)); ui.painter().rect_filled(paper, 0.0, Color32::WHITE); ui.painter().rect_stroke(paper, 0.0, Stroke::new(1.0, t.border), egui::StrokeKind::Outside); @@ -668,3 +728,52 @@ pub(crate) fn body( }); (go, cancel) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn print_preview_rasters_stay_within_the_texture_limit() { + // Actual size on a 14,400 pt (200 in) page would ask for a raster far past any texture. + let d = PrintDraft { size: SizeMode::Actual, ..PrintDraft::default() }; + let sizes = vec![(14_400.0, 14_400.0); 2]; + let labels = vec!["1".into(), "2".into()]; + for max_side in [2048.0, 8192.0, 16384.0, f32::NAN, 0.0] { + for (page, scale) in preview_rasters(&d, &sizes, &labels, 4.0, max_side) { + let long = 14_400.0 * scale; + let cap = if max_side.is_finite() && max_side >= 1.0 { max_side.min(PREVIEW_MAX_SIDE) } else { PREVIEW_MAX_SIDE }; + assert!(long <= cap + 1.0, "page {page} at {max_side}: {long} px"); + } + } + } + + #[test] + fn print_preview_rasters_keep_the_current_sheet_within_the_byte_budget() { + // Many large pages: the current sheet always gets its raster; neighbours only while the + // budget lasts, and the total stays within it. + let d = PrintDraft { sheet: 5, ..PrintDraft::default() }; + let sizes = vec![(2_000.0, 2_000.0); 12]; + let labels: Vec = (1..=12).map(|n| n.to_string()).collect(); + let rasters = preview_rasters(&d, &sizes, &labels, 4.0, 16384.0); + assert_eq!(rasters.first().map(|r| r.0), Some(5), "the sheet on screen comes first: {rasters:?}"); + let total: f64 = rasters.iter().map(|(_, s)| (2_000.0 * f64::from(*s)).ceil().powi(2) * 4.0).sum(); + assert!(total <= PREVIEW_BYTES || rasters.len() == 1, "{total} bytes for {rasters:?}"); + } + + #[test] + fn print_preview_is_sharper_than_a_thumbnail_on_a_high_dpi_screen() { + let d = PrintDraft::default(); + let sizes = vec![(612.0, 792.0); 3]; + let labels = vec!["1".into(), "2".into(), "3".into()]; + let rasters = preview_rasters(&d, &sizes, &labels, 2.0, 8192.0); + let scale = rasters.iter().find(|(page, _)| *page == 0).map(|(_, s)| *s).unwrap(); + // A 132 pt thumbnail at 2× is about 0.43 px/pt. The preview pane needs roughly twice that. + assert!(scale > 0.7, "letter page in the preview at 2 px/pt: {scale}"); + assert!(rasters.iter().any(|(page, _)| *page == 1), "the next sheet is ready"); + let mut later = d.clone(); + later.sheet = 2; + let rasters = preview_rasters(&later, &sizes, &labels, 2.0, 8192.0); + assert!(rasters.iter().any(|(page, _)| *page == 2)); + } +} diff --git a/crates/ui-egui/src/signature_drag.rs b/crates/ui-egui/src/signature_drag.rs index ad97d1b0b0..b65d6cc606 100644 --- a/crates/ui-egui/src/signature_drag.rs +++ b/crates/ui-egui/src/signature_drag.rs @@ -60,9 +60,10 @@ impl SignatureDrag { } } let (Some((_, page, _)), Some(layers)) = (self.key, self.layers.as_mut()) else { return }; - // The image stays sharp at every zoom; bound the extra background raster to 2048². + // The image stays sharp at every zoom. The background matches the page view up to one + // whole-page texture; past that the view tiles and this preview stays at that cap. let Some(p) = doc.info.pages.get(page) else { return }; - let scale = scale.min(2048.0 / p.width.max(p.height).max(1.0)); + let scale = scale.min(crate::canvas::BASE_SIDE / p.width.max(p.height).max(1.0)); let tag = (scale * 1000.0) as u64; if layers.tag != Some(tag) { layers.renderer.set_queue(vec![RenderRequest { page, scale, tag, ..Default::default() }]);