ditherkit
@ditherkit/core

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 * pixelSize

snapDimensionsToPixelSize

snapDimensionsToPixelSize(
  targetWidth: number,
  targetHeight: number,
  pixelSize: number
): SnappedDimensions

Snap 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

  • pixelSize is 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 of 1×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.round for the derived dimension. The follow-up call to snapDimensionsToPixelSize will then clamp the result to a multiple of pixelSize.
  • 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
): CropRegion

Compute 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.

On this page