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 + +