From ce73729822587f4800d14b2864066d1cfee884fa Mon Sep 17 00:00:00 2001 From: Aktan Azat Date: Sun, 27 Sep 2026 02:30:10 -0700 Subject: [PATCH] Keep the viewport size in full-page captures Full-page captures resized the viewport to the document height before taking the screenshot, so a 100vh section grew to fill the whole image and the rest of the page fell off the bottom. Chromiumoxide's full_page option, used for pages past the Retina limit, resizes it the same way. Clip the whole document with captureBeyondViewport instead. The clip keeps the device scale factor, so full pages stay at 2x up to the existing ~16k px limit and fall back to 1x past it. Settle every image in the document before the screenshot, which the resized viewport used to do implicitly. Assisted-by: Claude Opus 5.5 --- Cargo.lock | 81 +++++++++++++++++ Cargo.toml | 3 + src/capture.rs | 124 +++++++++++++++++++-------- tests/fixtures/full-page-layout.html | 10 +++ 4 files changed, 184 insertions(+), 34 deletions(-) create mode 100644 tests/fixtures/full-page-layout.html diff --git a/Cargo.lock b/Cargo.lock index 22b606a..54dc373 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.5" @@ -299,6 +305,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -404,12 +419,32 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + [[package]] name = "fnv" version = "1.0.7" @@ -813,6 +848,7 @@ dependencies = [ "chromiumoxide", "clap", "futures", + "png", "rmcp", "serde", "serde_json", @@ -867,6 +903,26 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -917,6 +973,19 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -1241,6 +1310,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -1876,6 +1951,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 93396eb..29a00f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,9 @@ serde_json = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "fs", "io-std", "signal", "sync"] } url = "2" +[dev-dependencies] +png = "0.18" + [profile.release] strip = true lto = true diff --git a/src/capture.rs b/src/capture.rs index c369353..0049609 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -382,7 +382,7 @@ impl Session { page.goto(url).await?; page.wait_for_navigation().await?; - self.eval(page, SETTLE_JS.into()).await?; + self.eval(page, settle_js(false)).await?; if let Some(selector) = &opts.wait_for { // Undercut the outer timeout so the descriptive selector error surfaces // instead of a generic "timed out". @@ -410,12 +410,12 @@ impl Session { .await?; // Scrolling can start image loads, IntersectionObservers, and // entrance transitions that the initial settle could not see. - self.eval(page, SETTLE_JS.into()).await?; + self.eval(page, settle_js(false)).await?; } } if opts.wait_ms > 0 { tokio::time::sleep(Duration::from_millis(opts.wait_ms)).await; - self.eval(page, SETTLE_JS.into()).await?; + self.eval(page, settle_js(false)).await?; } let screenshot_params = || { @@ -434,40 +434,40 @@ impl Session { page.screenshot(screenshot_params().build()).await?, ), CaptureMode::FullPage => { + self.eval(page, settle_js(true)).await?; let doc_h = self .eval_u32(page, DOC_HEIGHT_JS) .await .unwrap_or(v.height) .max(v.height); - if doc_h as f64 * v.scale <= 16_000.0 { - // Retina full page: grow the viewport to the whole document so the - // scale factor still applies (CDP's captureBeyondViewport renders at 1x). - page.execute( - SetDeviceMetricsOverrideParams::builder() - .width(v.width as i64) - .height(doc_h as i64) - .device_scale_factor(v.scale) - .mobile(v.mobile) - .build() - .map_err(|e| anyhow!(e))?, - ) - .await?; - self.eval(page, SETTLE_JS.into()).await?; - ( - v.width, - doc_h, - v.scale, - page.screenshot(screenshot_params().build()).await?, - ) + // Clip the whole document instead of growing the viewport to it, so + // 100vh sections keep their height. The clip keeps the Retina scale + // factor up to Chrome's ~16k px render limit, then falls back to 1x. + let scale = if doc_h as f64 * v.scale <= 16_000.0 { + v.scale } else { - ( - v.width, - doc_h, - 1.0, - page.screenshot(screenshot_params().full_page(true).build()) - .await?, + 1.0 + }; + let clip = ScreenshotViewport::builder() + .x(0.0) + .y(0.0) + .width(v.width as f64) + .height(doc_h as f64) + .scale(scale / v.scale) + .build() + .map_err(|e| anyhow!(e))?; + ( + v.width, + doc_h, + scale, + page.screenshot( + screenshot_params() + .clip(clip) + .capture_beyond_viewport(true) + .build(), ) - } + .await?, + ) } CaptureMode::Element { selector, padding } => { let value = self.eval(page, element_bounds_js(selector)).await?; @@ -642,11 +642,12 @@ fn find_chrome() -> Option { /// then any running finite animations/transitions — entrance fade-ins — allowed /// to finish (3s cap; infinite loops are skipped, they never settle). Off-screen /// images are ignored: they don't appear in the capture, and lazy-loaded ones -/// would stall the wait forever. Full-page captures grow the viewport to the -/// whole document before the final settle, so everything counts as near there. -const SETTLE_JS: &str = r#"(async () => { +/// would stall the wait forever. Full-page captures settle the whole document +/// before the screenshot, so every image counts as near there. +const SETTLE_JS: &str = r#"async (wholeDocument) => { if (document.fonts) { try { await document.fonts.ready; } catch {} } const near = (img) => { + if (wholeDocument) return true; const r = img.getBoundingClientRect(); return r.top < innerHeight * 1.5 && r.bottom > -innerHeight * 0.5; }; @@ -668,7 +669,11 @@ const SETTLE_JS: &str = r#"(async () => { new Promise(r => setTimeout(r, 3000)), ]); await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); -})()"#; +}"#; + +fn settle_js(whole_document: bool) -> String { + format!("({SETTLE_JS})({whole_document})") +} /// Step-scroll to the bottom so IntersectionObserver lazy-loading fires, then back to top. const SCROLL_JS: &str = r#"(async () => { @@ -898,6 +903,43 @@ mod tests { assert!(parse_viewport("desktop", Some(f64::NAN)).is_err()); } + #[tokio::test] + async fn full_page_capture_keeps_viewport_relative_heights() -> Result<()> { + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/full-page-layout.html") + .canonicalize()?; + let url = url::Url::from_file_path(&fixture) + .map_err(|_| anyhow!("fixture path is not a file URL"))?; + let viewport = Viewport { + width: 320, + height: 240, + scale: 2.0, + mobile: false, + }; + let session = Session::launch(None, viewport).await?; + let capture = session + .capture( + url.as_str(), + &page_opts(viewport, CaptureMode::FullPage, false), + ) + .await; + session.close().await; + let capture = capture?; + + // A 100vh header, 900px main, and 100px footer make a 1240px page. + assert_eq!( + (capture.shot.width, capture.shot.height, capture.shot.scale), + (320, 1240, 2.0) + ); + let (width, height, pixel) = decode_png(&capture.data); + assert_eq!((width, height), (640, 2480)); + // The header stays one viewport (240px, 480 device pixels) tall. + assert_eq!(pixel(320, 479), [22, 78, 154]); + assert_eq!(pixel(320, 480), [233, 138, 34]); + assert_eq!(pixel(320, 2479), [39, 133, 74]); + Ok(()) + } + #[tokio::test] async fn browser_element_capture_contract() -> Result<()> { let temp = std::env::temp_dir().join(format!("iris-browser-{}", std::process::id())); @@ -1077,6 +1119,20 @@ mod tests { Ok(image.shot) } + fn decode_png(bytes: &[u8]) -> (u32, u32, impl Fn(usize, usize) -> [u8; 3]) { + let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes)); + decoder.set_transformations(png::Transformations::normalize_to_color8()); + let mut reader = decoder.read_info().unwrap(); + let mut buf = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut buf).unwrap(); + let channels = info.color_type.samples(); + let pixel = move |x: usize, y: usize| { + let at = y * info.line_size + x * channels; + [buf[at], buf[at + 1], buf[at + 2]] + }; + (info.width, info.height, pixel) + } + fn png_dimensions(bytes: &[u8]) -> (u32, u32) { assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n"); ( diff --git a/tests/fixtures/full-page-layout.html b/tests/fixtures/full-page-layout.html new file mode 100644 index 0000000..b446cc4 --- /dev/null +++ b/tests/fixtures/full-page-layout.html @@ -0,0 +1,10 @@ + + +Viewport-relative full-page layout + +