Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/odd-bananas-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sweetcorn": patch
---

Improves `sweetcorn` compatibility with more Node environments
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: CI
permissions: {}

on:
push:
branches:
- main
pull_request:

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false

- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0

- name: Setup Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version: 24.12.0
cache: pnpm

- name: Install dependencies
run: pnpm install

- name: Run tests
run: pnpm -r test
4 changes: 3 additions & 1 deletion packages/sweetcorn/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
},
"scripts": {
"build": "tsc",
"prepublishOnly": "pnpm build"
"prepublishOnly": "pnpm build",
"pretest": "pnpm build",
"test": "node --test"
},
"devDependencies": {
"sharp": "^0.34.5",
Expand Down
85 changes: 13 additions & 72 deletions packages/sweetcorn/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { Sharp } from 'sharp';
import thresholdMaps from './threshold-maps.json' with { type: 'json' };;
import diffusionKernels from './diffusion-kernels';
import type { SweetcornOptions } from './types';
import { applyDiffusionKernel, applyThresholdMap } from './processors.js';
import diffusionKernels from './diffusion-kernels.js';
import thresholdMaps from './threshold-maps.json' with { type: 'json' };
import type { SweetcornOptions } from './types.js';

export type { DitheringAlgorithm } from './types';
export type { DitheringAlgorithm } from './types.js';

let sharp: typeof import('sharp');
async function loadSharp(): Promise<typeof import('sharp')> {
Expand Down Expand Up @@ -44,9 +45,14 @@ export default async function sweetcorn(image: Sharp, options: SweetcornOptions)
options.diffusionKernel || diffusionKernels[algorithm as keyof typeof diffusionKernels];

if (thresholdMap) {
applyThresholdMap(rawPixels, thresholdMap);
applyThresholdMap(rawPixels.data, rawPixels.info.width, thresholdMap);
} else if (diffusionKernel) {
applyDiffusionKernel(rawPixels, diffusionKernel);
applyDiffusionKernel(
rawPixels.data,
rawPixels.info.width,
rawPixels.info.height,
diffusionKernel
);
} else if (algorithm === 'white-noise') {
// White noise dithering (pretty rough and ugly)
for (let index = 0; index < rawPixels.data.length; index++) {
Expand All @@ -61,72 +67,7 @@ export default async function sweetcorn(image: Sharp, options: SweetcornOptions)
}
}

// Astro supports outputting different formats, but dithered images like this respond quite
// predictably to different compression methods. PNG and lossless WebP outperform lossy
// formats for this type of image, with lossless WebP producing slightly smaller images, so we
// use that here.
// Convert raw pixel data back into a Sharp image.
const outputImage = sharp(rawPixels.data, { raw: rawPixels.info });

return outputImage;
}

function applyDiffusionKernel(
rawPixels: { data: Buffer<ArrayBufferLike>; info: import('sharp').OutputInfo },
kernel: number[][]
): void {
const kernelWidth = kernel[0]!.length;
const kernelHeight = kernel.length;
const kernelRadius = Math.floor((kernelWidth - 1) / 2);

for (let index = 0; index < rawPixels.data.length; index++) {
const original = rawPixels.data[index]!;
const quantized = original < 128 ? 0 : 255;
rawPixels.data[index] = quantized;
const error = original - quantized;

const [x, y] = [index % rawPixels.info.width, Math.floor(index / rawPixels.info.width)];

const width = rawPixels.info.width;
const height = rawPixels.info.height;

for (let diffX = 0; diffX < kernelWidth; diffX++) {
for (let diffY = 0; diffY < kernelHeight; diffY++) {
const diffusionWeight = kernel[diffY]![diffX]!;
if (diffusionWeight === 0) continue;

const offsetX = diffX - kernelRadius;
const offsetY = diffY;

const neighborX = x + offsetX;
const neighborY = y + offsetY;

// Ensure we don't go out of bounds
if (neighborX >= 0 && neighborY >= 0 && neighborX < width && neighborY < height) {
const neighborIndex = neighborY * width + neighborX;
rawPixels.data[neighborIndex] = clamp(
rawPixels.data[neighborIndex]! + error * diffusionWeight
);
}
}
}
}
}

function clamp(value: number, min = 0, max = 255): number {
return Math.min(Math.max(value, min), max);
}

/** Applies a threshold map to the raw pixel data for ordered dithering. */
function applyThresholdMap(
rawPixels: { data: Buffer<ArrayBufferLike>; info: import('sharp').OutputInfo },
thresholdMap: number[][]
): void {
const mapWidth = thresholdMap[0]!.length;
const mapHeight = thresholdMap.length;
for (let index = 0; index < rawPixels.data.length; index++) {
const pixelValue = rawPixels.data[index]!;
const [x, y] = [index % rawPixels.info.width, Math.floor(index / rawPixels.info.width)];
const threshold = thresholdMap[y % mapHeight]![x % mapWidth]!;
rawPixels.data[index] = pixelValue < threshold ? 0 : 255;
}
}
65 changes: 65 additions & 0 deletions packages/sweetcorn/src/processors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/** Any array-like structure indexed by numbers and with a `length`, e.g. an `Array` or `Buffer`. */
interface ArrayOrBuffer<T> {
length: number;
[n: number]: T;
}

/** Applies a threshold map to raw pixel data for ordered dithering. */
export function applyThresholdMap(
pixels: ArrayOrBuffer<number>,
width: number,
map: number[][]
): void {
const mapWidth = map[0]!.length;
const mapHeight = map.length;
for (let index = 0; index < pixels.length; index++) {
const pixelValue = pixels[index]!;
const [x, y] = [index % width, Math.floor(index / width)];
const threshold = map[y % mapHeight]![x % mapWidth]!;
pixels[index] = pixelValue < threshold ? 0 : 255;
}
}

/** Applies an error diffusion kernel to raw pixel data. */
export function applyDiffusionKernel(
pixels: ArrayOrBuffer<number>,
width: number,
height: number,
kernel: number[][]
): void {
const kernelWidth = kernel[0]!.length;
const kernelHeight = kernel.length;
const kernelRadius = Math.floor((kernelWidth - 1) / 2);

for (let index = 0; index < pixels.length; index++) {
const original = pixels[index]!;
const quantized = original < 128 ? 0 : 255;
pixels[index] = quantized;
const error = original - quantized;

// (x, y) co-ordinates of the current pixel in the image.
const [x, y] = [index % width, Math.floor(index / width)];

// Distribute the error to neighbouring pixels based on the kernel.
for (let diffX = 0; diffX < kernelWidth; diffX++) {
for (let diffY = 0; diffY < kernelHeight; diffY++) {
const diffusionWeight = kernel[diffY]![diffX]!;
if (diffusionWeight === 0) continue;

const neighbourX = x + diffX - kernelRadius;
const neighbourY = y + diffY;

// Ensure we don't go out of bounds
if (neighbourX >= 0 && neighbourY >= 0 && neighbourX < width && neighbourY < height) {
const neighbourIndex = neighbourY * width + neighbourX;
pixels[neighbourIndex] = eightBitClamp(pixels[neighbourIndex]! + error * diffusionWeight);
}
}
}
}
}

/** Clamps a number between `0` and `255`. */
function eightBitClamp(value: number): number {
return Math.min(Math.max(value, 0), 255);
}
2 changes: 1 addition & 1 deletion packages/sweetcorn/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type diffusionKernels from './diffusion-kernels';
import type diffusionKernels from './diffusion-kernels.js';
import type thresholdMaps from './threshold-maps.json';

export type DitheringAlgorithm =
Expand Down
96 changes: 96 additions & 0 deletions packages/sweetcorn/test/sweetcorn.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import assert from 'node:assert';
import { describe, it } from 'node:test';
import sharp from 'sharp';
import sweetcorn from 'sweetcorn';
import diffusionKernels from '../dist/diffusion-kernels.js';
import { applyDiffusionKernel, applyThresholdMap } from '../dist/processors.js';
import thresholdMaps from '../dist/threshold-maps.json' with { type: 'json' };

describe('algorithms', () => {
describe('applyThresholdMapToPixels()', () => {
it('mutates a pixel array', () => {
const pixels = [127, 127, 127, 127];
applyThresholdMap(pixels, 2, [[64, 184]]);
assert.deepEqual(pixels, [255, 0, 255, 0]);
});

it('it sets all values to 0 or 255', () => {
const pixels = [60, 120, 180, 240];
applyThresholdMap(pixels, 2, [[64, 184]]);
assert(pixels.every((pixel) => pixel === 0 || pixel === 255));
});

it('supports maps with multiple rows', () => {
const pixels = [60, 120, 180, 240];
applyThresholdMap(pixels, 2, [
[64, 184],
[92, 255],
]);
assert.deepEqual(pixels, [0, 0, 255, 0]);
});

it('dithers using the built-in bayer-2 threshold map', () => {
const pixels = [127, 127, 127, 127];
applyThresholdMap(pixels, 2, thresholdMaps['bayer-2']);
assert.deepEqual(pixels, [255, 0, 0, 255]);
});
});

describe('applyDiffusionKernel()', () => {
it('mutates a pixel array', () => {
const pixels = [127, 127, 127, 127];
applyDiffusionKernel(pixels, 2, 2, diffusionKernels['simple-diffusion']);
assert.deepEqual(pixels, [0, 255, 255, 0]);
});

it('sets all values to 0 or 255', () => {
const pixels = [60, 120, 180, 250];
applyDiffusionKernel(pixels, 2, 2, diffusionKernels['simple-diffusion']);
assert(pixels.every((pixel) => pixel === 0 || pixel === 255));
});
});
});

describe('sweetcorn()', () => {
it('dithers an image using a built-in threshold map', async () => {
const image = sharp(Buffer.from([127, 127, 127, 127]), {
raw: { width: 2, height: 2, channels: 1 },
});
const dithered = await sweetcorn(image, { algorithm: 'bayer-2' });
const data = await dithered.toColourspace('b-w').raw().toBuffer();
// Note that this result differs from the direct applyThresholdMap() test above.
// This is because we gamma correct Sharp images from srgb to linear before processing.
// Our input pixel value of 127 is only 37 after this correction.
assert.deepEqual(Array.from(data), [255, 0, 0, 0]);
});

it('dithers an image using a built-in diffusion kernel', async () => {
const image = sharp(Buffer.from([60, 120, 180, 250]), {
raw: { width: 2, height: 2, channels: 1 },
});
const dithered = await sweetcorn(image, { algorithm: 'simple-diffusion' });
const data = await dithered.toColourspace('b-w').raw().toBuffer();
// See note above about gamma correction.
assert.deepEqual(Array.from(data), [0, 0, 0, 255]);
});

it('dithers an image using a custom threshold map', async () => {
const customMap = [[0]];
const image = sharp(Buffer.from([60, 120, 180, 240]), {
raw: { width: 2, height: 2, channels: 1 },
});
const dithered = await sweetcorn(image, { thresholdMap: customMap });
const data = await dithered.toColourspace('b-w').raw().toBuffer();
assert.deepEqual(Array.from(data), [255, 255, 255, 255]);
});

it('dithers an image using a custom diffusion kernel', async () => {
const customKernel = [[0, 255]];
const image = sharp(Buffer.from([60, 120, 180, 240]), {
raw: { width: 2, height: 2, channels: 1 },
});
const dithered = await sweetcorn(image, { diffusionKernel: customKernel });
const data = await dithered.toColourspace('b-w').raw().toBuffer();
assert.deepEqual(Array.from(data), [0, 255, 0, 255]);
});
});
5 changes: 3 additions & 2 deletions packages/sweetcorn/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"resolveJsonModule": true,
"erasableSyntaxOnly": true,
"outDir": "dist",
"strict": true,
"noUnusedLocals": true,
Expand Down
Loading