Dimension utilities
Pure helpers for snapping requested width/height/pixelSize to canonical bitmap dimensions, deriving missing dimensions from a source aspect ratio, and computing cover crops.
@ditherkit/core exports a small set of pure dimension helpers used by
both <DitheredImage /> and <DitheredImageSSR /> to resolve the
user-facing "I want a W×H output with N-pixel cells" model into the
canonical numbers every layer of the toolkit agrees on.
Most users never touch these directly — the React and Next components call them internally. Reach for them when you're building your own custom pipeline (a Vue wrapper, a build script, a Cloudflare Worker) and want to match the toolkit's exact sizing behaviour.
import {
snapDimensionsToPixelSize,
deriveDimensions,
computeCoverCrop,
type SnappedDimensions,
type CropRegion,
} from '@ditherkit/core'Vocabulary
The helpers use three terms consistently:
- target — what the user asked for (may not be reachable as-is)
- output — the canonical bitmap dimensions actually rendered
- process — the (smaller) dimensions the dither algorithm runs at
The relationship is always:
outputWidth = processWidth * pixelSize
outputHeight = processHeight * pixelSizesnapDimensionsToPixelSize
snapDimensionsToPixelSize(
targetWidth: number,
targetHeight: number,
pixelSize: number
): SnappedDimensionsSnap a target output dimension pair to multiples of pixelSize. The
snapped values are the canonical values: every part of the toolkit
(canvas attribute, route URL, cache key, log line) uses these and not
the original input.
Rounds down so the result never exceeds the user's target. A target
of 1000 with pixelSize 3 becomes 999 (333 cells × 3), not 1002.
import { snapDimensionsToPixelSize } from '@ditherkit/core'
snapDimensionsToPixelSize(1000, 750, 3)
// → {
// outputWidth: 999, // 333 * 3
// outputHeight: 750, // 250 * 3
// processWidth: 333,
// processHeight: 250,
// rounded: true, // outputWidth differs from targetWidth
// }
snapDimensionsToPixelSize(800, 600, 1)
// → { outputWidth: 800, outputHeight: 600, processWidth: 800, processHeight: 600, rounded: false }SnappedDimensions
interface SnappedDimensions {
/** Canonical output bitmap width. ALWAYS the value to render at. */
outputWidth: number
/** Canonical output bitmap height. */
outputHeight: number
/** Resolution the dither algorithm runs at. = output / pixelSize. */
processWidth: number
processHeight: number
/** True if either output dimension differs from the requested target. */
rounded: boolean
}The rounded flag is useful for surfacing a dev-time warning when
snapping silently changed a value the caller supplied explicitly — both
<DitheredImage /> and <DitheredImageSSR /> use it for exactly that.
Notes
pixelSizeis clamped to a minimum of 1 and floored to an integer. Sub-1 or fractional values can't return a zero-sized buffer.- Process dimensions are clamped to a minimum of 1. Even absurd
inputs like
snapDimensionsToPixelSize(2, 2, 100)return process dimensions of1×1. - Pure function, zero allocations beyond the return object. Safe to call on every render.
deriveDimensions
deriveDimensions(
sourceWidth: number,
sourceHeight: number,
targetWidth: number | undefined,
targetHeight: number | undefined
): { width: number; height: number }Derive a complete { width, height } pair from a partial spec plus
the source aspect ratio. Used when a consumer passes only one of
width / height and the other needs to be filled in from the
source's intrinsic shape.
Rules:
- both passed → returned verbatim
- only
width→ height =width * source.height / source.width - only
height→ width =height * source.width / source.height - neither → returns the source's intrinsic dimensions
import { deriveDimensions } from '@ditherkit/core'
// Source 1200×1800, caller wants width=400 → derive height
deriveDimensions(1200, 1800, 400, undefined)
// → { width: 400, height: 600 }
// Source 1200×1800, caller wants height=300 → derive width
deriveDimensions(1200, 1800, undefined, 300)
// → { width: 200, height: 300 }
// Source 1200×1800, neither passed → use source intrinsics
deriveDimensions(1200, 1800, undefined, undefined)
// → { width: 1200, height: 1800 }Notes
- Uses
Math.roundfor the derived dimension. The follow-up call tosnapDimensionsToPixelSizewill then clamp the result to a multiple ofpixelSize. - Derived dimensions are clamped to a minimum of 1, so a tiny source can't produce a zero-pixel output.
- The typical pipeline is
deriveDimensions(...)→snapDimensionsToPixelSize(...).
computeCoverCrop
computeCoverCrop(
sourceWidth: number,
sourceHeight: number,
targetWidth: number,
targetHeight: number
): CropRegionCompute a centre-anchored "cover" crop of a source image so that, when scaled to fit a target box, the source covers the box exactly with its aspect ratio preserved. Pixels along the long axis are cropped equally on both sides.
This is the runtime equivalent of CSS object-fit: cover and Sharp's
fit: 'cover', exposed as a pure helper so the React drawImage
pipeline and the Sharp server-side pipeline can share a single
definition.
import { computeCoverCrop } from '@ditherkit/core'
// Wide source (3:2) into a square target → crop the sides
computeCoverCrop(1500, 1000, 800, 800)
// → { sx: 250, sy: 0, sWidth: 1000, sHeight: 1000 }
// Tall source (2:3) into a square target → crop top and bottom
computeCoverCrop(1000, 1500, 800, 800)
// → { sx: 0, sy: 250, sWidth: 1000, sHeight: 1000 }
// Aspect ratios already match → no crop
computeCoverCrop(1000, 1000, 500, 500)
// → { sx: 0, sy: 0, sWidth: 1000, sHeight: 1000 }CropRegion
interface CropRegion {
sx: number
sy: number
sWidth: number
sHeight: number
}The four numbers map directly to the sx, sy, sWidth, sHeight
arguments of the canvas drawImage 9-argument form, and to Sharp's
extract plus resize chain.
Why crop at all?
When the caller passes width and height that don't match the
source's intrinsic aspect ratio, something has to give. Stretching
distorts faces and breaks dithering's whole point. Letterboxing leaves
dead bars in the output. Cropping preserves the subject and gives the
caller exactly the pixels they asked for.
A typical pipeline
The three helpers compose. The full sequence used by both wrappers internally:
import {
deriveDimensions,
snapDimensionsToPixelSize,
computeCoverCrop,
} from '@ditherkit/core'
// 1. Fill in any missing dimensions from the source aspect ratio.
const { width, height } = deriveDimensions(
source.width,
source.height,
userRequestedWidth,
userRequestedHeight,
)
// 2. Snap to multiples of pixelSize. This is the canonical output size.
const snapped = snapDimensionsToPixelSize(width, height, pixelSize)
// 3. Compute the source crop so the output covers the target exactly.
const crop = computeCoverCrop(
source.width,
source.height,
snapped.outputWidth,
snapped.outputHeight,
)
// Now: draw `crop` of `source` into a `snapped.processWidth × snapped.processHeight`
// buffer, dither, and upscale to `snapped.outputWidth × snapped.outputHeight`.That sequence is what <DitheredImage /> runs in the browser and what
<DitheredImageSSR /> runs through Sharp on the server — guaranteeing
the same output bitmap from either path for the same inputs.