KMPwright

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:

BackendPixel size of a viewport capture
AndroidWebView size in device pixels × scale
iOSWeb 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:

BackendMechanismSize limit
AndroidScroll 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.
iOSOne 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

AndroidiOSJVM (CDP)
FormatsPNG, JPEG, WEBPPNG, JPEGPNG, JPEG, WEBP
omitBackgroundthrowsthrowsEmulation.setDefaultBackgroundColorOverride, reset after the capture
qualityJPEG and WEBPJPEGJPEG and WEBP
scaleclamped to 0.1..5.0any positive valuesent as the clip scale
Full pagescroll and stitchsingle snapshotcaptureBeyondViewport
Annotateyesyesyes

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.

BackendHowSupported parametersLimits
AndroidScrolls 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).
iOSWKWebView.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:

FieldMeaning
matchtrue only when no pixel differs. There is no "allowed number of different pixels"; compare mismatchPercentage yourself if you need one.
mismatchPercentagedifferentPixels / totalPixels × 100.
differentPixels, totalPixelsPixel counts.
diffImageRaw 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.
dimensionMismatchSet 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.

On this page