View: keep earlier tiles on screen while a zoom's new tiles render (#415)

* View: keep earlier tiles on screen while a zoom's new tiles render

Zooming a tiled page (larger than 4096 device pixels) dropped every tile
of the previous scale at once, so each zoom step fell back to the
2048 px backdrop until the new tiles arrived: the page went blurry on
every frame of a pinch. Tiles are now keyed by scale; those of earlier
zooms stay (nearest scale on top, at most 64) and are drawn stretched
under the current ones until those cover the page. Entering the tiled
range also keeps a whole-page raster sharper than the backdrop instead
of replacing it with the backdrop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* hayro-syntax: fix float mantissa overflow on high-precision coordinates (#199)

jsPDF outputs double-precision floating-point coordinates with 16+ decimal
places in /MediaBox. In hayro-syntax, read_inner accumulated float mantissa
digits into u64 using wrapping arithmetic. Integer mantissas exceeding
u64::MAX wrapped modulo 2^64, severely corrupting page dimensions
(e.g., 3874.96 pt became 185.61 pt, rendering as a warped ribbon).

Accumulation now uses checked arithmetic: trailing fractional digits
exceeding u64 capacity are safely ignored without incrementing decimal_shift.
This preserves full IEEE-754 f64 precision while preventing overflow for
numbers with arbitrary numbers of decimal places.

Includes:
- Unit test in hayro-syntax with reported values and a 50-digit fraction.
- Synthetic PDF regression test in pdfcraft-render verifying inspect().
- Vendored patch (6) documentation in vendor/README.md.

Fixes #199

* M2: bound page-label work and output before applying labels

Share bounded PDF Roman and repeated-letter formatters between inspection
and editing. Bound tree traversal, raw and decoded prefixes, individual
labels and aggregate label output, with checked numbering arithmetic.

Stage inspection labels before applying them and retain physical page
numbers with a warning when optional labels cannot be processed. Reject
unsupported label edits before changing the document tree.

Add synthetic limit, cycle, Unicode, numbering and atomic-edit regressions.

Co-Authored-By: OpenAI Codex <codex@openai.com>

* Never crash: mesh shadings can't exhaust memory or run for minutes

`cargo xtask fuzz` (with the dash, JBIG2 and decompression fixes in
place) found a page whose Coons patch shading (type 6), a 147-byte
stream with 1-bit coordinates decoded onto +-40000, took the renderer
past 3 GB. hayro-interpret samples mesh shadings (types 4-7) pixel by
pixel over each triangle's device-space bounding box into a map, with
nothing but the u16 cast bounding it: up to 65536 x 65536 entries for
one triangle. A synthetic single patch passed 6 GB in six seconds; ten
thousand triangles that each cover half a 1000 x 1000 page ran for
minutes. Two rounds of review of earlier versions found more: every
patch was cut into 722 triangles (about 150 KB) before anything limited
them; a lattice mesh with `/VerticesPerRow 0` pushed empty rows forever,
and one with 4294967295 read the whole stream into one row; a mesh
painted a thousand times was triangulated a thousand times; and the
first caps cut content without a word.

Vendored hayro-interpret patch (18):
- `ShadingPattern::encode_within(area)`: the vendored hayro renderer
  passes the area it will actually draw (the path's bounding box on the
  canvas, which it already computes) plus a pixel for texels at a
  fractional edge, and triangles are sampled only inside it, so the map
  holds at most that area's pixels.
- `TriangleSampler`: at most 8 visits per pixel of that area plus 4 per
  triangle in it. A triangle's bounding box is about twice its area and a
  sub-pixel triangle's is at most 2 x 2 pixels, so tiling meshes and
  small dense ones (a patch in a few pixels is 722 triangles) still
  shade completely; overlapping ones stop.
- Coons and tensor-product patches are triangulated one at a time.
- Meshes hold at most `MAX_MESH_PATCHES` (2^16; a 200 x 200 gradient
  mesh is 40 thousand) patches and `MAX_MESH_TRIANGLES` (2^20)
  triangles, and a lattice needs two (ISO 32000-2 8.7.4.5.6) to 2^19
  vertices per row.
- A page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25; the 200 x 200
  mesh is 29 million) mesh triangles in all, reset per page like the
  existing paint budget.
- Each of these cuts logs a warning.
`encode()` keeps its signature; with no area it samples within a fixed
2^24 visits.

Synthetic regression tests in render:
- `mesh_shadings_far_off_the_page_render`: a Coons patch and a triangle
  mesh decoded onto +-40000 render and the rest of the page draws; a
  patch on the page, the part of the huge one that covers it, and a
  patch filling a 4 x 4 pt rectangle as a pattern are all shaded.
- `many_overlapping_mesh_triangles_finish`: 10,000 triangles each
  covering half of a 1000 x 1000 page finish, and are drawn.
- `mesh_shadings_with_many_patches_or_no_rows_finish`: 40,000 patches
  beside the page, and a lattice with `/VerticesPerRow 0`.
Red on main (out of memory, killed at 3.9 GB). Each part is pinned by a
mutation: no visit budget stalls past 60 s; no clip leaves the covering
patch unshaded; no per-triangle allowance leaves the small patch
unshaded; triangulating all patches first runs out of memory (killed at
5.8 GB); no rows check runs out of memory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never crash: decompression bombs can't exhaust the renderer's memory

The renderer's parser (vendored hayro-syntax) decoded Flate, LZW and
RunLength streams with no output limit. A 2 MB page whose content
stream inflates to 2 GB, or whose 8 x 8 image does, took `pdfcraft-cli
render` and `text` past 3 GB in under a second, and a few more MB of
file scale it further. On the desktop that is the first-page render of
an emailed PDF, and the allocation-failure abort takes every open
document with it. Our own layers already bound single streams (lopdf's
load-time decoding at 256 MiB, cos at 1 GiB), so `info` stays at a few
MB; the renderer path didn't. Random mutation almost never makes a
high-ratio stream, so `cargo xtask fuzz` doesn't find this; found by
hand while triaging the fuzz run.

Vendored hayro-syntax patch (6):
- `decode_limit` gives each filter an output limit, and Flate (both
  flate2 and the fallback inflater), LZW and RunLength never write past
  it (the buffer can still grow to under twice it):
  `MAX_DECODED_STREAM`, 256 MiB, lopdf's figure; or, for the last filter
  of an image whose size, bits per component and components are known,
  twice its pixel bytes (a PNG predictor tag byte per byte at most),
  capped at 1 GiB. Earlier filters in a chain produce compressed data
  and get the general limit.
- `Stream::decoded_within(max)` for callers that budget.
- A page's `/Contents` array shares one `MAX_DECODED_STREAM` (naming one
  bomb twenty times decoded 5 GB), reserving exactly what it appends.

Vendored hayro-interpret patch (18):
- The content a page holds at once (its contents, and the forms, tiling
  patterns and Type 3 glyph procedures being painted) stays within
  `MAX_PAGE_CONTENT`, 512 MiB, reset per page like the existing paint
  budget. Forms nest fifty deep and each level kept its content while
  the next ran: a form painting itself around a 256 MiB stream would
  have held over 12 GB. Each level gives its bytes back when it is
  dropped, so content painted again and again keeps drawing, and a
  stream without filters (borrowed from the file) costs nothing.
- Type 0 sampled functions read every sample into a 4-byte value and
  then a table entry: 256 MiB of 1-bit samples asked for over 8 GB. They
  now take at most 2^21 table points and 2^23 values (the largest real
  tables, DeviceN tint transforms such as 33^4 points x 3 outputs, are
  1.2 million and 3.6 million), counted with checked arithmetic, and
  read no further than `/Size` asks. `/Size 0` no longer underflows
  building the default `/Encode`.

Three rounds of independent review found the aggregate cases, buffer
doubling past the limit, a chain/predictor shape the first image limit
would have truncated, the Type 0 amplifier, and a first, cumulative
budget that would have dropped legitimately repeated content, and a
first Type 0 cap low enough to refuse real tint transforms; all fixed
here.

Measured in a release build (render; OS peak commit):
- content stream inflating to 2 GB: 389 MB, 0.3 s
- 8 x 8 image inflating to 2 GB: 4 MB, 0.2 s
- /Contents naming it twenty times: 516 MB, 0.3 s
- a form painting itself around it: 644 MB, 0.4 s

Synthetic regression tests in render (flate2 is a new dev-dependency
there, already in the workspace):
- `decompression_bombs_stop_at_the_stream_limit`: 288 MiB bombs through
  flate2, the fallback inflater (a stored block with a wrong length
  check, which flate2 refuses and the fallback accepts), LZW and
  RunLength each decode exactly 256 MiB, and at most 128 bytes as an
  8 x 8 image.
- `page_contents_and_nested_forms_share_one_budget`: three /Contents
  references decode one stream's worth; a self-painting form finishes.
- `content_painted_again_and_again_keeps_drawing`: a 2 MiB form painted
  300 times draws the 300th time.
- `sampled_functions_with_billions_of_samples_are_refused`.
Ten mutations each turn them red: no limit at all; no limit in flate2,
the fallback, LZW, RunLength or for images; an unshared /Contents limit;
no page budget; no refund on drop; no Type 0 sample cap. (Taking a form
out of the budget no longer compiles: its content is the budget guard.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M2: normalize JPEG2000 samples without overflowing

Use bounded integer sample scaling for 1-32-bit JPX images and reject empty dimensions before chunking. Cover public decoding and continued page rendering with synthetic regressions.

Co-Authored-By: OpenAI Codex <codex@openai.com>

* M14: use adapter texture limits for HiDPI surfaces (#577)

Preserve the existing egui-wgpu device descriptor while enabling the
selected adapter's supported 2D texture extent. Cover the observed
8600-pixel Outlook startup surface and downlevel limit preservation.

Co-authored-by: Codex <noreply@openai.com>

* Never crash: decompression cut at its limit is logged, not silent; vendor README renumbered after #575

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: philkwek <philkwek03@gmail.com>
Co-authored-by: Streetblock <github@streetblock.de>
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: James Locke <336218001+jcl-bit@users.noreply.github.com>
Co-authored-by: msaulc <103976208+msaulc@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
cyfung1031
2026-10-09 14:12:43 -04:00
committed by GitHub
co-authored by Codex Claude Opus 5.5 philkwek Streetblock OpenAI Codex James Locke msaulc Brandon Thomas
parent bd4736d39c
commit a70c2b4938
5 changed files with 157 additions and 9 deletions
+2
View File
@@ -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%).
+43
View File
@@ -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
+46 -9
View File
@@ -222,8 +222,9 @@ pub struct DocView {
thumbs: HashMap<usize, TextureHandle>,
/// Thumbnails that are out of date (still shown until their replacement arrives).
stale_thumbs: HashSet<usize>,
/// 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<usize, Arc<PageText>>,
pub(crate) text_failed: HashSet<usize>,
@@ -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<usize> = 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<RenderRequest> =
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.
+58
View File
@@ -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}");
}
+8
View File
@@ -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 |