What artefacts appear when you stitch viewport screenshots into one long page image, and how do you reduce them?
answer
- The loop is yours, not Selenium's
- Something is painted into every frame
- The bottom of the page misbehaves
- Slices are moments, not one moment
- Read the scroll offset back before pasting
basics
~20 sFixed toolbars repeat in every slice, the last slice duplicates a band because scrolling clamps at the maximum offset, and lazy content differs between slices. Hide fixed chrome, pre-scroll, freeze motion, and paste at the real offset.
solid answer
~40 sStitching means scrolling by one viewport height, calling `getScreenshotAs` each time, and pasting the slices into a tall canvas — Selenium ships none of that, so every artefact comes from your loop. The classic seams are **repeated fixed or sticky chrome** painted into every frame, a **duplicated band at the bottom** because the last scroll clamps at maximum offset, **lazy-loaded content** that resolved partway through, **scroll-triggered animations** caught mid-transition, and **sheared slices** when offsets are computed in CSS pixels against a device-pixel image. Reduce them by hiding fixed elements with a `JavascriptExecutor` style change, pre-scrolling to the bottom and back so content materialises, disabling animations and transitions, reading `window.scrollY` back after each scroll and drawing at that real offset, and scaling offsets by the image-width-to-`window.innerWidth` ratio.
code
java · 51 linesimport java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File;
import javax.imageio.ImageIO;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
public class StitchAgreement {
public static void main(String[] args) throws Exception {
WebDriver driver = new ChromeDriver();
JavascriptExecutor js = (JavascriptExecutor) driver;
try {
driver.get("https://redactor.example/matters/8842/agreement");
js.executeScript(
"document.querySelectorAll('.redaction-toolbar')"
+ ".forEach(e => e.style.visibility = 'hidden');");
long total = num(js, "return document.documentElement.scrollHeight;");
long step = num(js, "return window.innerHeight;");
BufferedImage first = slice(driver);
double ratio = first.getWidth() / (double) num(js, "return window.innerWidth;");
BufferedImage page =
new BufferedImage(
first.getWidth(), (int) Math.round(total * ratio), BufferedImage.TYPE_INT_RGB);
Graphics2D g = page.createGraphics();
for (long y = 0; y < total; y += step) {
js.executeScript("window.scrollTo(0, arguments[0]);", y);
long settled = num(js, "return window.scrollY;");
g.drawImage(slice(driver), 0, (int) Math.round(settled * ratio), null);
}
g.dispose();
ImageIO.write(page, "png", new File("matter-8842-full.png"));
} finally {
driver.quit();
}
}
private static long num(JavascriptExecutor js, String script) {
return ((Number) js.executeScript(script)).longValue();
}
private static BufferedImage slice(WebDriver driver) throws Exception {
byte[] png = ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES);
return ImageIO.read(new ByteArrayInputStream(png));
}
}go deeper
Know that Selenium has no built-in stitching and that joining viewport shots is your own code. Recognising a repeated header in a long image as a stitching artefact is enough at this stage.
Explain why each seam happens: fixed elements repaint per frame, the final scroll clamps at maximum offset, and lazy content resolves between slices. Name the fix for each.
Show judgment about whether to stitch at all. Weigh N round trips and misleading composites against a targeted viewport shot, and say what you would record so a reviewer does not read a seam as a rendering bug.
Own the cost and the trust question across the suite: how much wall-clock capture the pipeline can afford, and what standard makes a composite image admissible as evidence rather than something engineers argue about.
## What stitching is, and why teams do it Because the W3C **Take Screenshot** command clamps its output to the visual viewport, a suite that wants a whole 40-page agreement out of a legal-document redaction tool has to build the image itself. The loop is always the same shape: read the document height, scroll by one viewport height, call `((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES)`, decode, paste the slice into a tall canvas, repeat. Selenium ships nothing that does this for you; the loop is application code, and every artefact below comes from the loop, not from the driver. ## The seams, and what causes each | seam you see | cause | |---|---| | a toolbar repeated every screen height | fixed or sticky chrome is repainted in each frame | | a duplicated band at the bottom | the final scroll clamps at maximum offset and overlaps | | grey placeholders in early slices | lazy-loaded thumbnails had not resolved yet | | a half-faded clause | a scroll-triggered entrance transition caught mid-flight | | horizontally sheared slices | offsets computed in CSS pixels against a device-pixel image | | a scrollbar running down every slice | the framebuffer includes browser-painted scrollbars | The two that dominate real bug reports are the first and the second. - **Repeated fixed chrome.** A redaction toolbar pinned with `position: fixed`, or a `position: sticky` clause-table header, is painted into every single frame. Stitch ten slices and the reviewer sees the toolbar ten times, with a stripe of the actual document between each copy. - **The duplicated tail.** Document height is rarely an exact multiple of viewport height. The last scroll request asks for an offset past the maximum, the browser clamps it, and the final capture overlaps the previous one — so a band of clauses appears twice. ## The time dimension A stitched image is not a picture of one moment. It is a composite of N moments spread over however long the loop ran, which introduces failures a single-shot capture cannot have: - **Lazy loading.** Page-thumbnail rails and virtualised clause lists render on scroll. Slices captured before the content resolved show skeletons; slices captured after show text, in the same image. - **Animation and transitions.** Reveal-on-scroll effects mean a clause is at 40% opacity in the slice that caught it. - **Live values.** A "last saved 3 seconds ago" badge or an autosave spinner differs between the top and the bottom of the same image. - **Virtualised scrolling.** If the document pane recycles DOM nodes, scrolling back does not necessarily restore what was there, and the stitched result may contain content that never coexisted on screen. ## Reducing the artefacts In rough order of value: 1. **Neutralise fixed chrome before the loop.** Hide the toolbar and sticky headers with a `JavascriptExecutor` style change, capture, then restore. This removes the single most visible seam. 2. **Pre-warm the page.** Scroll to the bottom, wait for the lazy content to settle, then scroll back to the top before starting the capture loop, so every slice is taken against a fully materialised document. 3. **Suppress motion.** Inject a stylesheet that sets `animation: none` and `transition: none` on everything, so no slice catches a half-played effect. 4. **Paste at the browser's real scroll offset, not your requested one.** After each `window.scrollTo`, read `window.scrollY` back and draw the slice at that position. Clamping then overwrites the duplicate band instead of appending it. 5. **Work in device pixels.** The returned PNG's width is the framebuffer width, which on a high-DPI display or a zoomed browser is a multiple of `window.innerWidth`. Compute that ratio once from the first slice and scale every vertical offset by it, or the slices shear. 6. **Prefer a single-shot command where one exists.** Firefox's `getFullPageScreenshotAs(OutputType)` produces one image with none of these seams, which is why it is worth branching on browser rather than stitching everywhere. ## When not to stitch at all Stitching is expensive — N round trips plus decode and composite per capture — and every one of the artefacts above is a way for the image to mislead the person reading it. A screenshot in a Selenium suite is **evidence of a failure**, so ask what the evidence has to prove first. - If the failure is one clause not being redacted, a **viewport shot taken with that clause on screen** is smaller, faster and more honest than a stitched page. - If the argument genuinely needs the whole document — "the export dropped pages 30 to 40" — stitch, and record in the report that the image is a composite so nobody reads a seam as a rendering bug.
- Why does reading window.scrollY back after each scroll fix the duplicated tail?Because the browser clamps a scroll request past the maximum offset. If you paste at the offset you asked for, the clamped final slice lands below where its pixels actually belong and appends a duplicate band. Pasting at the offset the browser reports overwrites the overlap with identical content, so the seam disappears.
- What breaks stitching on a virtualised document viewer that recycles DOM nodes?The composite can contain content that never existed together on screen, and scrolling back may not restore what was captured earlier. Node recycling also changes heights as you scroll, so the total height you measured at the start no longer matches the canvas you sized. On such pages a single-shot full-page command or targeted viewport evidence is safer.
- How does device pixel ratio corrupt a stitch?The returned PNG's width is the framebuffer width, which on a high-DPI display or a zoomed browser is a multiple of window.innerWidth. Offsets computed in CSS pixels then place each slice too high, so the slices overlap and shear. Compute the ratio once from the first slice's width and scale every vertical offset by it.
saying these in an interview costs you the question
- Assuming Selenium stitches viewport shots into a full page itself
- Believing a stitched image is a single moment in time
- Ignoring fixed and sticky chrome before running the capture loop
- Pasting slices at the requested offset instead of the real one
- Treating image width as equal to window.innerWidth in offset maths