npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@singleton-sd/inkads-epaper-renderer

v1.4.1

Published

Converts advertiser artwork into display-ready e-paper framebuffers for InkAds — deterministic crop, dither, and pack for Waveshare 7.5″ B/W panels.

Readme

InkAds e-paper renderer

Shared TypeScript package that converts advertiser artwork into display-ready e-paper assets for InkAds.

The same implementation is intended for:

  • browser preview (marketing demo and advertiser UI)
  • backend asset processing
  • deterministic fixtures used to validate firmware on Waveshare hardware

Image processing stays in this package / the cloud. ESP32 firmware consumes packed framebuffer bytes only.

Status

Proof of concept. The pipeline from upload to device-ready bytes is complete for the Waveshare 7.5″ B/W target: ingest → crop/resize/zoom → dither → pack, with golden fixtures pinning the output byte for byte. The public API below is settled; validation against physical hardware (#8) and colour panels (#9) remain open. Profile orientation packs rotated mounts; confirm against hardware in #8.

Published to the public npm registry as @singleton-sd/inkads-epaper-renderer. Each release on main bumps the version, tags, updates the changelog, creates a GitHub release, and publishes to npm.

Installing

pnpm add @singleton-sd/inkads-epaper-renderer
# or: npm install @singleton-sd/inkads-epaper-renderer

The browser entry point is the package root; server-only decode/encode lives at /node:

import { renderMono, waveshare75BwProfile } from '@singleton-sd/inkads-epaper-renderer';
import { ingestImageToProfile } from '@singleton-sd/inkads-epaper-renderer/node';

Pin a specific version in consumer package.json rather than a range while the API is still settling ("1.1.1" not "^1.1.1").

Requirements

  • Node.js 22.12 or newer
  • pnpm 11.22
pnpm install
pnpm format:check
pnpm lint
pnpm typecheck
pnpm test

Local hooks

After pnpm install, Husky installs commit hooks. Once per clone you can also confirm:

git config core.hooksPath .husky

Hooks enforce Conventional Commits with a GitHub #issue ticket, TypeScript filename conventions, and branch names of the form <type>/<issue>-<kebab-title>.

Releases

Semantic versions are produced by release-it from Conventional Commits.

On every push to main (except an existing chore: Release … commit), GitHub Actions runs pnpm release:ci. That bumps the version, updates CHANGELOG.md, pushes a semver tag, creates a GitHub Release (notes include the matching npmjs version link), and publishes to the public npm registry.

Distribution: public npm at @singleton-sd/inkads-epaper-renderer. Scoped packages default to private on npm; publishConfig.access: public in package.json overrides that.

Publishing auth: npm Trusted Publishing (OIDC). The release workflow requests a short-lived GitHub OIDC token; npm validates it against a trusted publisher configured on npmjs.com. No NPM_TOKEN or Azure Key Vault secret is used for publish.

One-time setup on npmjs.com (per package):

| Field | Value | | -------------------- | ---------------------------- | | Organization or user | singleton-sd | | Repository | poc-inkads-epaper-renderer | | Workflow filename | release.yml | | Allowed actions | npm publish |

Package → Settings → Trusted publishing → GitHub Actions. If the package does not exist on npm yet, a maintainer may need one interactive publish with 2FA before trusted publishing can take over.

After the first successful OIDC publish, consider Package → Settings → Publishing access → Require two-factor authentication and disallow tokens so long-lived publish tokens cannot be used.

Requires release-it ≥ 19.0.4 (Octokit logger fix for GitHub Releases) and npm CLI ≥ 11.5.1. The release workflow pins npm@11 (not unbounded @latest).

Do not set publishConfig.registry. release-it passes it as one CLI token (--registry https://…); npm 12 then fails with EUNKNOWNCONFIG / --//registry.npmjs.org. Keep only publishConfig.access: public — the public registry is already the default.

Do not pass registry-url to actions/setup-node in the release job either; that writes an auth .npmrc that short-circuits OIDC.

npm.skipChecks is required for Trusted Publishing: release-it's preflight (npm whoami) expects a static token, but OIDC credentials only exist during npm publish inside the Actions job. Locally there is no OIDC token, so pnpm release:ci must not be run on a developer machine — it would either fail at publish or (if somehow authenticated another way) cut a release outside CI. Prefer the Actions workflow (workflow_dispatch or a push to main).

Locally:

pnpm release      # dry-run only (no publish, no git push)

Bootstrap / first package creation (interactive, with 2FA):

pnpm build && npm publish --access public

Never hand-edit package.json version.

Recovery: tag exists, GitHub Release missing

If CI pushed a semver tag but failed while creating the GitHub Release, do not rerun pnpm release:ci — that can cut another empty version bump.

Create the missing Release from the existing tag instead:

# Example for tag 0.0.3
gh release create 0.0.3 --title "v0.0.3" --generate-notes --verify-tag

Verify:

gh release view 0.0.3
gh release list

The Release page should list the tag under https://github.com/singleton-sd/poc-inkads-epaper-renderer/releases.

The pipeline end to end

Four stages take an advertiser upload to bytes the panel can display. Each is a separate export, so a caller can stop early (to show a preview) or re-run one stage (to re-dither without re-decoding).

import {
  normaliseToProfile,
  packMonoBitmap,
  renderMono,
  toPreviewImage,
  waveshare75BwProfile as profile,
} from '@singleton-sd/inkads-epaper-renderer';
import { decodeImage } from '@singleton-sd/inkads-epaper-renderer/node';

const decoded = decodeImage(uploadBytes); // 1. upload  → RGB pixels
const framed = normaliseToProfile(decoded, { profile }); // 2. → 800×480
const bitmap = renderMono(framed, { mode: 'atkinson' }); // 3. → 1-bit
const packed = packMonoBitmap(bitmap, { profile }); // 4. → 48,000 bytes

packed.bytes; // send to the device
packed.metadata.checksum; // CRC-32, so firmware can verify the download
toPreviewImage(packed, profile); // what the panel will actually show

Every stage is deterministic: the same input and options always produce byte-identical output, on any platform. That is what lets a browser preview be trusted as an exact prediction of the device result, and it is enforced by golden checksums over a set of representative creatives rather than by convention.

Behaviour is driven by the profile, never by loose width/height arguments, so adding a panel cannot silently change how an existing one renders.

Display profiles

Each hardware panel is a display profile with a stable id, fixed resolution, and expected packed framebuffer size. The renderer pipeline takes a profile — not arbitrary width/height — so cloud, preview, and firmware stay aligned.

| Field | waveshare-7.5-bw | | ----------- | ------------------ | | Panel | Waveshare 7.5″ B/W | | Resolution | 800×480 (5:3) | | Depth | 1 bit per pixel | | Packed size | 48,000 bytes |

import { getDisplayProfile, listDisplayProfiles } from '@singleton-sd/inkads-epaper-renderer';
import { ingestImageToProfile } from '@singleton-sd/inkads-epaper-renderer/node';

listDisplayProfiles(); // [{ id: 'waveshare-7.5-bw', ... }, ...]
const profile = getDisplayProfile('waveshare-7.5-bw');
const rgb = ingestImageToProfile(pngOrJpegBytes, {
  profile,
  crop: { x: 0.5, y: 0.5 }, // optional cover-fit position
});
// rgb.width === 800, rgb.height === 480

Monochrome modes (threshold, floyd-steinberg, atkinson) convert profile RGB to a 1-bit bitmap (0 black / 1 white). Prefer threshold for text, logos, and QR; use Atkinson for UI/illustration contrast; Floyd–Steinberg for fullest grey simulation. Future panels (including colour, issue #9) add new ids to the registry.

Packed framebuffer and preview

import {
  packMonoBitmap,
  renderMono,
  toPreviewImage,
  waveshare75BwProfile as profile,
} from '@singleton-sd/inkads-epaper-renderer';
import { ingestImageToProfile } from '@singleton-sd/inkads-epaper-renderer/node';

const rgb = ingestImageToProfile(uploadBytes, { profile });
const bitmap = renderMono(rgb, { mode: 'atkinson' });
const packed = packMonoBitmap(bitmap, { profile });

packed.bytes.length; // 48000 — send these bytes to the device
packed.metadata; // profileId, rendererVersion, mode, byteLength, checksum, …

const preview = toPreviewImage(packed, profile);
canvasContext.putImageData(new ImageData(preview.data, preview.width), 0, 0);

The packed layout is the firmware contract: row-major, 8 pixels per byte, MSB is the leftmost pixel, and with polarity: 'normal' a set bit is a dark pixel. checksum is CRC-32 (IEEE) over the packed bytes, so firmware can verify a download with the same cheap algorithm. Preview pixels are expanded from the packed bytes, not the upload, so what you see is what the panel renders. When profile.orientation is not native, packing rotates the logical width × height artwork into the device row layout (90° / 270° swap the packed stride; byte length stays the same on 800×480). Preview returns the device layout size — 480×800 for a portrait mount — so marketing UI can show the panel chrome without a CSS transform. Metadata still records the logical profile dimensions plus orientation for firmware.

Framing: crop, zoom, and letterbox

By default the image is cover-fit: scaled by max(800 / width, 480 / height) so it fills the panel, with the overflow cropped. Aspect ratio is never distorted, but content is discarded — a tall portrait can lose most of its height. crop slides that fixed window; sourceRect sets the window itself, which is what zooming is.

Framing UIs usually only send zoom, optional pan centre, and rotation. Pass those on normaliseToProfile and the renderer builds (and clamps) the sourceRect:

const framed = normaliseToProfile(decoded, {
  profile,
  rotation: 90,
  zoom: 2, // 1 = cover-fit; >1 zooms in; <1 letterboxes
  // centerX / centerY optional — default to image mid-point after rotation
});

Helpers (defaultFraming, sourceRectFromFraming, clampFraming, framingPanRoom, …) remain available when a consumer needs the math without running the full pipeline. Gesture / button chrome stays in the consumer. clampFraming keeps a cropped window inside the artwork, and when zoomed out (letterboxed) lets the image sit anywhere as long as it stays fully inside the panel — use framingPanRoom to disable pan arrows at the edge.

Or pass sourceRect directly when you already have an explicit region:

const framed = normaliseToProfile(decoded, {
  profile,
  // Region of the source to show, in source pixels.
  sourceRect: { x: 120, y: 80, width: 900, height: 540 },
});

framed.sourceRect; // the region actually used, after aspect correction

The rectangle may extend beyond the image on any side. That is how you zoom out past cover-fit, and the uncovered area is filled with background (white by default), which is how you letterbox instead of crop:

// Fit an entire 1000×2000 portrait onto the panel, bars either side.
normaliseToProfile(decoded, {
  profile,
  sourceRect: { x: -1166, y: 0, width: 3333, height: 2000 },
  background: { r: 255, g: 255, b: 255 },
});

If the rectangle's aspect ratio does not match the panel, it is grown about its centre until it does, never squashed: everything the user framed stays visible, and the artwork is never distorted. sourceRect on the result reports the corrected region, so a framing UI can draw handles matching the real output and warn when most of an upload is being discarded.

crop and sourceRect are mutually exclusive, since a rectangle already carries its own position.

Optional rotation (0 | 90 | 180 | 270, default 0) turns the decoded source clockwise before crop / sourceRect, so a sideways upload can be framed upright. 90° and 270° swap the source width and height.

normaliseToProfile(decoded, { profile, rotation: 90, crop: { x: 0.5, y: 0.5 } });

Browser and Node

The package has two entry points, so the browser never pays for a bundled image codec:

| Entry | Runs in | Contains | | ------------------------------------------- | ---------------- | --------------------------------------------------------- | | @singleton-sd/inkads-epaper-renderer | Browser and Node | Profiles, crop/resize, dither, pack, preview | | @singleton-sd/inkads-epaper-renderer/node | Node only | decodeImage, ingestImageToProfile, encodePreviewPng |

The root entry point imports no Node built-ins, so the full crop → dither → pack → preview pipeline can re-run live in the browser as the user frames their creative. A test walks the import graph of each entry point and fails if a Node built-in or codec reaches the root one.

Only turning an uploaded file into pixels differs by platform. In the browser the platform already has a decoder, so use it and pass the canvas pixels to fromRgbaImageData:

import {
  fromRgbaImageData,
  normaliseToProfile,
  waveshare75BwProfile as profile,
} from '@singleton-sd/inkads-epaper-renderer';

const bitmap = await createImageBitmap(file);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const context = canvas.getContext('2d')!;
context.drawImage(bitmap, 0, 0);

const decoded = fromRgbaImageData(context.getImageData(0, 0, bitmap.width, bitmap.height));
const rgb = normaliseToProfile(decoded, { profile, crop: { x: 0.5, y: 0.5 } });

encodePreviewPng is server-only for the same reason: in the browser, draw the PreviewImage to a canvas and use canvas.toBlob() when you need a file.

API reference

Everything below is public and covered by semver. Anything not listed is internal and may change in a patch release; a test pins both lists, so an internal helper cannot leak out unnoticed.

Pipeline

| Export | Entry | Purpose | | -------------------------------------------------------------------------------------------------- | ------- | -------------------------------------------------------------- | | decodeImage | /node | PNG/JPEG bytes → RGB, with limits applied to untrusted uploads | | fromRgbaImageData | root | Canvas RGBA → RGB, the browser's way in | | normaliseToProfile | root | Crop, zoom, and resize to the profile | | coverWindowSize / defaultFraming / sourceRectFromFraming / clampFraming / framingPanRoom | root | Centre/zoom framing helpers → sourceRect; pan-room for UI | | rotatedImageSize / nextSourceRotation | root | Source rotation size + 90° step helper | | ingestImageToProfile | /node | decodeImage + normaliseToProfile in one call | | renderMono | root | RGB → 1-bit bitmap via threshold or dithering | | packMonoBitmap | root | Bitmap → device-ready framebuffer plus metadata | | toPreviewImage | root | Framebuffer → RGBA for a canvas | | encodePreviewPng | /node | Preview → PNG file bytes | | crc32Hex | root | The checksum firmware verifies against |

Profiles

| Export | Purpose | | --------------------------------------------------------------- | ---------------------------------- | | waveshare75BwProfile, WAVESHARE_7_5_BW_ID | The only panel currently supported | | getDisplayProfile, hasDisplayProfile, listDisplayProfiles | Registry lookup | | defineDisplayProfile | Define a custom panel | | computePackedByteLength, assertAspectRatioMatchesDimensions | Helpers for defining one |

Errors, limits, and metadata

| Export | Purpose | | ------------------------------- | ------------------------------------------- | | ImageIngestError | Decode, limit, crop, and rectangle failures | | MonoRenderError | Invalid mode, threshold, or buffer shape | | FramebufferPackError | Profile mismatch or unsupported packing | | DisplayProfileValidationError | Invalid profile definition | | DEFAULT_DECODE_LIMITS | Upload ceilings, overridable per call | | RENDERER_VERSION | Recorded in framebuffer metadata |

Each error carries a machine-readable code, so callers can branch on the cause — telling an advertiser their file is too large versus corrupt — rather than matching on message text.

Exported types mirror these: DisplayProfile, DisplayProfileId, DisplayProfileInput, AspectRatio, DisplayOrientation, DisplayPolarity, PixelPacking, DecodedImage, ProfileRgbBuffer, NormaliseToProfileOptions, CropPosition, SourceRect, RgbColour, SourceRotation, ImageSize, FramingState, FramingProfileSize, DecodeLimits, FromRgbaLimits, RgbaImageData, MonoBitmap, MonoRenderMode, MonoSource, RenderMonoOptions, PackedFramebuffer, FramebufferMetadata, PackSource, PackMonoBitmapOptions, PackErrorCode, and PreviewImage. The /node entry adds DecodeImageOptions.

Related repositories

Architecture reference

Follow repository conventions from poc-plattform-kit and Singleton SD skills (repo-init, git-conventions, isolated-worktree) where applicable.

License

Proprietary — Singleton SD. See LICENSE. The package is public on npm for installability; that does not grant an open-source license. Do not commit secrets or commercially sensitive material.