DEV Community

Cover image for A full-page PNG is a scan of viewports, not a screenshot of the document
Keny Alba
Keny Alba

Posted on

A full-page PNG is a scan of viewports, not a screenshot of the document

Why a single screenshot cannot exist

This is the fourth post in a series about capturing scrolling pages. Live video and Cinema maps get the attention. The still image is where sticky headers and the last slice quietly ruin the file.

I used to think “full page” meant one screenshot of a tall document. In a browser it means: scroll, wait, capture the visible tab, repeat, stack the bitmaps. Every step can disagree with the last.

captureVisibleTab sees the viewport. There is no API that returns the layout tree as one PNG at device resolution. Long pages are a loop.

Chrome also rate-limits how often you may call that API. The loop is slow on purpose. Racing it produces missing frames or errors, not a faster poster.

Between two captures the page is allowed to change: lazy images, sticky classes applied by JavaScript, banners collapsing, fonts swapping. A “full page” PNG is a timeline of viewports glued together. If you do not treat it that way, you debug the glue.

The last slice always overlaps - unless you crop

Suppose the page is not an integer number of viewports tall. You scroll by one viewport each time. On the last step, the browser clamps scrollTo. You cannot place the viewport so that its top sits on “the remaining pixels.” You land on totalHeight - viewportHeight.

The last bitmap therefore contains a band you already captured in the previous frame. Stack the images and the footer duplicates. I have seen copyright lines twice and a button that appears to sit on itself.

The fix is geometric, not aesthetic. Compute the remainder - the true height of the last segment - and crop the last bitmap to that band, in captured pixels, not CSS pixels. captureVisibleTab returns a device-pixel buffer. Remainder and viewport height are CSS. The crop uses the ratio bitmapHeight / viewportHeight or you cut the wrong amount on a retina display.

If the crop fails, I keep the raw last frame. A duplicated band is worse than a failed export, but a failed export is worse than a slightly messy PNG. The crop is non-blocking.

Frames cannot sit in the service worker as one giant message. Long pages used to die on memory and IPC size. Each viewport is written into the local encoder’s filesystem as it arrives. Assembly is a vertical stack at the end. The worker never holds the whole poster.

Cinema maps use this same capture family. A map that is too tall for a single raster (browser canvas limits are on the order of tens of thousands of pixels) must be refused before a multi-minute scan. Failure after the loop is the worst moment.

Sticky chrome is a stamp, not a header

A position: fixed bar exists in every viewport. If you stack raw captures, you print that bar at every height you visited. The page looks like it has a header every screen, because it did - on screen.

position: sticky is sneakier. At the top of the document it may still be in normal flow. Your first pass “find all fixed nodes” sees nothing. After the user (or your loop) scrolls, a script adds a class and the bar becomes fixed. Frame 0 is clean. Frames 1..N are tattooed.

The procedure that works:

  1. At the top, mark nodes that are already fixed or sticky.
  2. Capture frame 0 with the nav in its legitimate place.
  3. Hide marked nodes with visibility: hidden, not display: none. Sticky elements still occupy flow until they stick. display: none reflows the layout you are measuring. visibility keeps geometry and stops the pixels.
  4. After every later scroll, scan again for nodes that became fixed, mark them, wait a paint, then capture.

Hiding starts after the first frame. The first frame is the only one where a top nav is allowed to exist in the poster.

Scrollbars must be hidden for the whole scan. A scrollbar that appears and disappears changes the content width by a few pixels. vstack then misaligns columns. That is independent of any “clean UI” feature.

CSS transitions and scroll-triggered animation are frozen for the scan. A navbar mid-fade on a random frame is not a feature. Live video wants those animations. A poster does not. Different product, different freeze.

Lazy images in the current viewport get a short wait, then we continue. An unbounded wait is a hung capture. Black bands in the poster are the failure mode of not waiting at all.

Cleanup has to run even when capture fails

Injected styles, hidden sticky flags, and a forced scroll position must come off in finally. If the loop throws on frame 12, leaving cursor: none and frozen animations on the user’s tab is a bug they will remember longer than a missing PNG.

Window resize is not part of the historical PNG path. It is part of a Cinema map: the page must already be at output width so the bitmap aspect matches the video. A tall phone layout reflows; the map gets longer. That is why the height check exists before the loop, not after.

What a PNG is for

A stitched PNG is the right artifact when someone needs a layout audit, a mockup, or a still for a ticket. It is the wrong artifact when they needed motion, hover, or a playing video. Cinema is “this PNG, then a camera.” Live is “never this PNG.”

The header ghost and the doubled footer are not random quality issues. They are the scan model asserting itself. Once I named the last-slice clamp and the delayed fixed class, the glue became boring - which is what you want from glue.

The still export in ScrollFlow is that scan, cropped and destamped as far as the page will allow. Some overlays still win. The geometry does not.

Feedback welcome

If a stitched screenshot reprinted a header on you, I want that page.

I turned this experiment into ScrollFlow, a Chrome extension for creating local auto-scroll captures of websites. It is free to try, and I’d genuinely value feedback from developers who work with difficult pages.

Top comments (0)