Screenshots and PDFs
Capture the viewport, the full page, a clip or one element with page.screenshot, render PDFs with page.pdf, and compare images pixel by pixel with VisualDiff.
page.goto("https://example.com")
val viewport = page.screenshot()
val full = page.screenshot(fullPage = true)
val heading = page.locator("h1").screenshot()
println("${full.width}x${full.height} px, ${full.bytes.size} bytes")
// JVM: java.io.File("full.png").writeBytes(full.bytes)page.screenshot(...) and locator.screenshot(...) are suspend functions that return a ScreenshotResult. The library never writes files: you get the encoded image as bytes and store it with your platform's file API.
Playwright: same name, and the options are named parameters (page.screenshot({ fullPage: true }) is page.screenshot(fullPage = true)). Differences: there is no path parameter, the result is a ScreenshotResult instead of a buffer, and type is called format. mask, animations, caret and style do not exist.
Parameters
Every parameter of page.screenshot has a default.
Prop
Type
Rectangle(x, y, width, height) uses Double CSS pixels.
Errors: setting both fullPage and clip throws IllegalArgumentException ("fullPage and clip are mutually exclusive"). A capture that takes longer than the timeout throws KmpwrightTimeoutException. Every other failure of page.screenshot, including a scale that is not finite and positive or a quality outside 0..100, throws KmpwrightException (non-KMPwright exceptions are wrapped as "Screenshot failed: …").
Screenshots go through the page's action policy as ActionType.Screenshot.
The result
data class ScreenshotResult(
val bytes: ByteArray, // encoded image in the requested format (PNG by default)
val width: Int, // pixel width of the encoded image
val height: Int, // pixel height of the encoded image
val annotations: List<Annotation> = emptyList(),
val truncated: Boolean = false // explicitly bounded document capture only
)width and height are the real pixel dimensions of the image, which include the device pixel ratio and scale. They are not CSS pixels:
| Backend | Pixel size of a viewport capture |
|---|---|
| Android | WebView size in device pixels × scale |
| iOS | Web view size in points × screen scale × scale |
| JVM (CDP) | Decoded from the image Chromium returns |
ScreenshotResult.toRgbaImage(): RgbaImage? decodes the bytes into straight (non-premultiplied) RGBA pixels, or returns null when the platform cannot decode the format. Use it for visual diffs or your own image processing. On the JVM it uses javax.imageio, which does not decode WEBP by default.
Annotated screenshots
val shot = page.screenshot(annotate = true)
for (a in shot.annotations) {
println("${a.ref} ${a.role} \"${a.name}\" at ${a.box}")
}
// @e1 link "Learn more" at Rectangle(x=..., y=..., width=..., height=...)
page.locator(shot.annotations.first().ref).click()annotate = true is agent-browser's screenshot --annotate. KMPwright takes an accessibility snapshot, draws a red label with each ref (e1, e2, …) over the top-left corner of its element, captures the image, then removes the labels. Every visible ref is returned as an Annotation(ref, role, name, box); box is the element's bounding box in viewport CSS pixels at capture time. Because a new snapshot is taken, the refs from the annotated screenshot replace refs from earlier snapshots.
Annotation works on every backend and combines with fullPage. locator.screenshot has no annotate parameter.
From an agent: page.runCommand("screenshot --annotate") returns the image in result.screenshot and a legend (@e1 link: Learn more) in result.output. screenshot --full (or -f) captures the full page.
Element screenshots
val card = page.getByTestId("pricing-card").screenshot()locator.screenshot(omitBackground = false, quality = 90, format = ScreenshotFormat.Png, scale = 1.0, timeoutMs = null) scrolls the element into view, reads its bounding box and captures that rectangle as a clip through the same path as page.screenshot, so format, quality and omitBackground behave identically. It has no fullPage, clip or annotate. It throws KmpwrightException when no element matches.
Clips
val banner = page.screenshot(clip = Rectangle(0.0, 0.0, 800.0, 200.0))On every backend clip is in CSS pixels relative to the top-left of the viewport, as in Playwright. A clip that extends past the viewport is trimmed to it; a clip entirely outside the viewport throws KmpwrightException ("Clipped area is either empty or outside the viewport").
fullPage and clip cannot be combined: setting both throws IllegalArgumentException on every backend, as in Playwright.
How full-page capture works
Each backend uses the native mechanism of its engine:
| Backend | Mechanism | Size limit |
|---|---|---|
| Android | Scroll and stitch. KMPwright measures the document (scrollWidth/scrollHeight), hides the WebView's scrollbars, scrolls the window tile by tile with window.scrollTo, captures each tile and draws it into one bitmap, then restores the original scroll position. | 128 MiB of ARGB bitmap (width × height × 4 bytes) per capture; larger captures throw KmpwrightException suggesting a smaller scale or a clip. |
| iOS | One WKWebView.takeSnapshot for the document CSS rectangle converted to native points. The original scroll position is restored. | 32 megapixels (after screen scale); larger captures throw KmpwrightException. |
| JVM (CDP) | Page.captureScreenshot with captureBeyondViewport: true. With scale other than 1.0 the clip is the document size from Page.getLayoutMetrics (cssContentSize). | No KMPwright limit. |
Android tile capture and stable frames
Each Android tile is read from the composited window with PixelCopy (API 26+, WebView attached to an Activity window). Chromium can composite a frame before all of its tiles are rasterized, which shows up as blank, background-colored areas. To avoid that, KMPwright waits for a WebView visual-state callback plus two vsync frames, copies again, and accepts the frame once two consecutive copies are identical at an unchanged scroll position. It tries up to 10 copies, then uses the latest one and logs a warning.
When PixelCopy is unavailable (API below 26, WebView not attached to a window, or the copy fails or times out after 500 ms), KMPwright falls back to WebView.draw into a bitmap.
Because the page really scrolls, scroll listeners run during a full-page capture, and position: fixed or sticky elements are captured in every tile they appear in.
Scrollbars on the JVM
With CdpConfig.hideScrollbars = true (the default) KMPwright hides native scrollbars with an injected style for the duration of each capture, so images are stable across platforms.
Per-backend summary
| Android | iOS | JVM (CDP) | |
|---|---|---|---|
| Formats | PNG, JPEG, WEBP | PNG, JPEG | PNG, JPEG, WEBP |
omitBackground | throws | throws | Emulation.setDefaultBackgroundColorOverride, reset after the capture |
quality | JPEG and WEBP | JPEG | JPEG and WEBP |
scale | clamped to 0.1..5.0 | any positive value | sent as the clip scale |
| Full page | scroll and stitch | single snapshot | captureBeyondViewport |
| Annotate | yes | yes | yes |
Capture an app-owned native WebView
Android and iOS expose captureWebViewDocument for an existing WebView or WKWebView. It does not navigate, replace delegates, or take ownership of the view. Prepare the page and settle lazy content before calling it; the Page action policy is not applied by this low-level API.
val shot = captureWebViewDocument(
webView,
clip = Rectangle(0.0, 6000.0, 100.0, 1800.0),
pixelsPerCssPixel = 2.0,
maxPixels = 7_000_000,
maxHeight = 10_000,
)
// The complete selected region is 200 × 3600 output pixels.The clip uses document CSS coordinates, including scroll offsets. This differs from a viewport clip passed to page.screenshot. Native element screenshots also capture the entire bounding box, including content taller than the viewport.
Selected regions are complete or fail: an out-of-bounds or oversized selection never becomes a partial successful image. For a bounded whole-page preview, omit clip and set truncateFullPage = true; ScreenshotResult.truncated then reports whether the height or pixel budget shortened it. viewportWidthOnly = true limits an unselected document to the visible CSS width.
pixelsPerCssPixel controls the output independently of device density. CSS bounds are converted using the actual WebView scale, including overview layouts. Android tiles the region; iOS snapshots the converted native rectangle. Both restore the original scroll position on success, failure, or cancellation. Android also restores its temporary root transform and scrollbar settings. A tile is rejected if the Android scroll position changes while its frame is being acquired.
An optional afterFirstTile suspending callback runs after the first viewport row on Android, allowing an app to hide pinned headers before subsequent rows. iOS takes one native snapshot and invokes the callback afterward. This hook does not settle the page or prevent a page's own animations.
PDFs
val pdf = page.pdf(format = PdfFormat.A4, margin = PdfMargin(top = "2cm", bottom = "2cm"))
// JVM: java.io.File("page.pdf").writeBytes(pdf.bytes)page.pdf(...): PdfResult returns the document as PdfResult.bytes. Its timeoutMs parameter (null uses TimeoutConfig.default) limits the call, and it is checked against the action policy as ActionType.Screenshot. The defaults work on every backend. PageCapability.Pdf is Partial on every backend, because each one supports a different subset of the parameters:
Prop
Type
Margin values are a number followed by in, cm, mm, pt or px; a bare number is pixels (96 per inch). Anything else throws IllegalArgumentException("Invalid PDF margin: …"). width and height must be positive.
Playwright: width, height and margins are strings there; here width/height are Double inches. path, preferCSSPageSize, outline and tagged do not exist.
| Backend | How | Supported parameters | Limits |
|---|---|---|---|
| Android | Scrolls through the document, captures viewport tiles and draws them into an android.graphics.pdf.PdfDocument, scaled to the printable width. Pages are images, so text is not selectable. | format (not Content), width, height, landscape, margin. Anything else throws KmpwrightUnsupportedException. | Document up to 16 megapixels, at most 128 pages, output at most 32 MiB (each throws KmpwrightException). |
| iOS | WKWebView.createPDF with the default configuration: one unpaginated page of the whole content. | Only format null or PdfFormat.Content with zero margins and every other parameter at its default. Anything else throws KmpwrightUnsupportedException. | None in KMPwright. |
| JVM (CDP) | Page.printToPDF, streamed back in chunks. | All parameters. scale outside 0.1..2.0 throws IllegalArgumentException. | 512 MiB. |
iOS only renders the content canvas: leave format null (or pass PdfFormat.Content) and keep margins at zero. An explicit paper size such as PdfFormat.A4 throws there. PdfFormat.Content throws KmpwrightUnsupportedException on Android and the JVM.
Visual diffs
val baseline = page.screenshot()
page.getByRole("button", name = "Dark mode").click()
val current = page.screenshot()
val diff = page.visualDiff(baseline, current, threshold = 0.1)
if (!diff.match) {
println("${diff.differentPixels} of ${diff.totalPixels} pixels differ (${diff.mismatchPercentage}%)")
}Page.visualDiff(expected, actual, threshold = 0.1, generateDiffImage = true) decodes both screenshots with toRgbaImage() and compares them pixel by pixel. It does not take a new screenshot; the Page receiver is only a convenience. VisualDiff.compareRgba(expected, actual, threshold, generateDiffImage) does the same for two RgbaImages you already have (for example a baseline loaded from disk).
Prop
Type
DiffResult fields:
| Field | Meaning |
|---|---|
match | true only when no pixel differs. There is no "allowed number of different pixels"; compare mismatchPercentage yourself if you need one. |
mismatchPercentage | differentPixels / totalPixels × 100. |
differentPixels, totalPixels | Pixel counts. |
diffImage | Raw RGBA bytes (not PNG) with the expected image's size: differing pixels are opaque red, the rest is the expected image in dimmed grayscale. null when disabled or when the images could not be compared. |
dimensionMismatch | Set when the sizes differ: DimensionMismatch(expectedWidth, expectedHeight, actualWidth, actualHeight). |
When the sizes differ, the result is match = false, mismatchPercentage = 100.0, totalPixels = 0 and no diff image. Page.visualDiff throws KmpwrightException when either image cannot be decoded (for example WEBP on the JVM). Compare screenshots taken with the same backend, viewport and scale: pixel sizes include the device pixel ratio.
Related
- Capabilities:
PageCapability.Pdfand the per-backend notes. - Recording: video, screencast frames and traces (JVM only).
- Page reference
Navigation and waiting
Load pages with goto, reload and history navigation, read the NavigationResult, and wait for URLs, load states, functions, text, selectors and page events without races.
Network
Mock, block and rewrite requests with page.route, wait for requests and responses, read the request history and HAR, and set headers, credentials and allowed domains.