Skip to content

Fix canvas stretching on HiDPI screens and window resize - #2

Merged
profradha merged 1 commit into
mainfrom
claude/canvas-image-distortion-qsmb0p
Aug 25, 2026
Merged

Fix canvas stretching on HiDPI screens and window resize#2
profradha merged 1 commit into
mainfrom
claude/canvas-image-distortion-qsmb0p

Conversation

@profradha

Copy link
Copy Markdown
Member

Summary

This PR fixes canvas rendering issues where images appeared stretched on HiDPI screens and during window resizing. The root cause was conflating CSS pixels (layout space) with device pixels (rendering resolution), and relying on CSS max-width/max-height which size axes independently and destroy aspect ratios.

Key Changes

  • Enhanced previewBox() function: Now returns both CSS dimensions and device pixel dimensions separately, with detailed documentation explaining the distinction. Also added border width to padding calculations to accurately measure the available space.

  • New fitWithin() utility: Implements aspect-ratio-preserving scaling (mirrors the engine's ops::fit_within), ensuring layout and render math agree on what "fits".

  • New layoutCanvas() function: Explicitly sets canvas display size via JavaScript instead of relying on CSS flex and max-width/max-height. This prevents the independent axis sizing that was causing stretching. Includes self-correction for window resize timing mismatches.

  • Updated canvas styling: Removed max-width, max-height, and width: auto/height: auto from #preview-canvas. Added flex: 0 0 auto and align-self: flex-start to prevent flex from independently sizing axes. Updated .canvas-frame to remove max-width/max-height constraints.

  • Integrated layout into frame rendering: layoutCanvas() is called in onFrame() after drawing and in the window resize event handler to keep the canvas properly fitted to its container.

Implementation Details

The fix addresses a critical issue where on HiDPI screens (2x pixel ratio), the canvas backing store (device pixels) was being laid out using CSS pixel constraints, causing misalignment. For example, a 4032x3024 photo on a 1920x1080 screen at 2x was being drawn 85% too wide.

By explicitly calculating and applying CSS dimensions to the canvas element, the image now maintains its aspect ratio and fits properly within the viewport, with the crop overlay correctly aligned.

https://claude.ai/code/session_01A8jTvVudtvCzWAqgG2MkJf

A 4032x3024 photo came out visibly too wide - 85% too wide on a 1920x1080
screen at 2x, and wrong by some amount on every display with a pixel ratio
above 1. The engine was never at fault: `ops::fit_within` returns an
aspect-correct frame, and the preview it hands back has the right shape. The
layer that got it wrong was the layout.

`#preview-canvas` is a flex item of `.canvas-frame`, and its two axes were
being sized by two mechanisms that never spoke to each other: the width by
flex-shrink against `max-width: 100%`, the height by the default
`align-items: stretch` against the frame's line. Nothing tied them to the
canvas's own aspect ratio. That only stayed hidden at a pixel ratio of 1,
where the backing store happens to equal the CSS box, so both axes land on
the same scale by coincidence. Above 1 the backing store is measured in
device pixels and the CSS box in CSS pixels, the coincidence breaks, and the
picture stretches.

So the canvas is now given an explicit display size instead:

- `previewBox()` reports the box in both units - CSS pixels for layout,
  device pixels for the render request - rather than conflating them. It
  also measures with `getBoundingClientRect()`, since `clientHeight` rounds
  to whole pixels while the padding it subtracts does not, which could
  overstate the box by most of a pixel.
- `layoutCanvas()` fits the frame that actually arrived into the box that
  actually exists and sets `style.width`/`style.height` from it. Fitting the
  received frame rather than the requested one is self-correcting: a resize
  mid-render costs a little sharpness, never a stretched image.
- The window `resize` handler re-fits immediately, then re-renders on the
  existing debounce to recover the resolution the new box is worth.

`.canvas-frame` loses its `max-width`/`max-height` so it wraps the canvas
exactly. The crop overlay is stretched across that box with `inset: 0`, so
slack between the two put the selection somewhere the image was not - the
crop tool was misaligned by the same amount the image was stretched.

Verified in Chromium across 70 viewport/pixel-ratio/image-size combinations:
worst aspect-ratio error drops from 100.6% to 0.21% (integer rounding of the
frame's device dimensions, sub-pixel on screen), with the overlay landing on
the image to within 0.000px and no case overflowing the viewport. Also
checked that dragging the window through a range of shapes keeps the image
proportioned with no re-render in between. Engine suite still 38/38.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8jTvVudtvCzWAqgG2MkJf
@profradha
profradha merged commit d1f9df3 into main Aug 25, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants