Documentation

ascii.im documentation

Architecture, algorithms and APIs: the rendering pipeline, ink measurement, script support, providers and configuration.

1ascii.im architecture overview#

ascii.im is an ASCII art generator: it converts an AI-generated image into text pixel art using the characters of a prompt as pigments. The architecture is split into four layers so that the interesting part — the conversion kernel — can be reused outside the web app, in a CLI, a worker or a test harness. The system design goal is a kernel with no framework dependency and no UI assumptions.

Layers and responsibilities
LayerPathDepends onReusable elsewhere
Kernelsrc/coreDOM for rasterising onlyYes, no framework imports
Providerssrc/providersKernel typesYes
Serversrc/serverNode runtimePartially (locale, entitlements)
App shellsrc/app, src/componentsNext.js, ReactNo
  • Only two files in the kernel touch the DOM: src/core/image/raw.ts and src/core/palette/ink.ts. Everything else is pure computation.
  • Providers implement one interface and are discovered by a registry; adding one requires no changes to routes or UI.
  • Server-side modules handle locale detection, provider access control and quota enforcement.
ASCII art
A picture whose pixels are characters: each cell of a grid is filled with a glyph whose visual weight matches the brightness of the source image.
Ink
The fraction of a fixed cell that a glyph’s strokes cover, measured by rasterising the character. It is the only quantity used to order glyphs from light to dark.
Pigment
One distinct character taken from the prompt and used as a single shade in the artwork. A prompt of five distinct characters yields five pigments.
Shade ramp
The ordered list of pigments, from lightest to darkest, that quantisation maps brightness onto.
Quantisation
Mapping a continuous brightness onto one of a small number of discrete shade indices, which is what produces the characteristic stepped look of ASCII art.
Dithering
Distributing the error introduced by quantisation across neighbouring cells, so the eye perceives intermediate tones that the palette does not actually contain.

Key takeaways

  • The kernel has no framework dependency and can run in Node or a CLI.
  • Only two kernel files touch the DOM.
  • Providers are discovered by a registry, not wired by hand.

Back to top ↑

2How does the image-to-ASCII pipeline work?#

The ASCII art pipeline has two independent halves that meet once. The pigment half turns the prompt into an ordered shade ramp; the image half turns a raster image into a brightness grid. Quantisation maps every brightness value onto a ramp index, and the renderers then draw that index grid as characters. Each of the conversion steps below is a pure function, which is why the flow is unit-testable end to end.

End-to-end data flow
prompt ──▶ grapheme split ──▶ de-duplicate ──▶ measure ink ──▶ shade ramp (light → dark)
                                                                      │
image ──▶ area-average resample ──▶ tone adjust ──▶ cutout ──┐
                                                              ├─▶ quantise + dither ──▶ index grid
                                                    ramp ─────┘
                                                              │
                                                              ▼
                                            renderers: text · SVG · canvas/PNG
  1. Extract pigment glyphs from the prompt and de-duplicate them.
  2. Measure each glyph’s ink and sort ascending to form the ramp.
  3. Resample the source image to the target column count with a two-pass separable box filter.
  4. Apply auto levels, contrast, brightness and gamma.
  5. Optionally remove the background and feather the edge.
  6. Quantise brightness onto ramp indices, with optional dithering.
  7. Render the index grid as text, SVG or a canvas snapshot.

Key takeaways

  • Pigment and image halves are independent and meet only at quantisation.
  • Quantisation works on ramp indices, not on raw ink values.
  • Renderers consume the same index grid, so output formats never diverge.

Back to top ↑

3Ink measurement: how is glyph density computed?#

Ink is the fraction of a fixed cell that a glyph’s stroke covers. The measurement algorithm draws the character into an offscreen canvas at a fixed em size, reads the alpha channel, and divides the covered pixel count by the total. The result is a scalar in roughly 0.03–0.45 that behaves like perceived weight, and it is the only quantity an ASCII art renderer needs in order to order glyphs.

Conceptual implementation
function inkOf(glyph: string, font: string, size = 64): number {
  const canvas = document.createElement('canvas');
  canvas.width = canvas.height = size;
  const ctx = canvas.getContext('2d')!;
  ctx.font = `${size}px ${font}`;
  ctx.textBaseline = 'alphabetic';
  ctx.fillText(glyph, 0, size * 0.8);

  const { data } = ctx.getImageData(0, 0, size, size);
  let covered = 0;
  for (let i = 3; i < data.length; i += 4) {
    if (data[i]! > 8) covered += 1;
  }
  return covered / (size * size);
}
  • Measurements are cached per (glyph, font) pair, so re-rendering does not re-rasterise.
  • When Canvas is unavailable the kernel falls back to a deterministic estimate and sets Palette.measured = false, which the UI surfaces as a warning.
  • Ink is used only for ordering. Quantisation distributes levels by ramp index, never by interpolating between ink values.

Key takeaways

  • Ink = covered pixels / cell area, measured by real rasterisation.
  • Results are cached; the no-Canvas fallback is explicit and flagged.
  • Only the ordering matters; levels are indexed by position.

Back to top ↑

4Script detection and multi-script support#

src/core/script.ts maps code points to a script via Unicode ranges, then picks the dominant script by weighted tally. Each script carries a profile: whether it is bicameral, its default cell aspect, a font stack, an augmentation pool and a de-duplication rule. This is what makes multi-language ASCII art work without a per-language glyph table, and it is also where grapheme clusters are kept intact.

Per-script behaviour
ScriptCase foldingCell aspectSegmentation
LatinYes (A = a)~0.5Grapheme
CyrillicYes~0.55Grapheme
GreekYes~0.55Grapheme
Han (CJK)No~1.0Grapheme
KanaNo~1.0Grapheme
HangulNo~1.0Grapheme
ArabicNo~0.6Grapheme
HebrewNo~0.6Grapheme
ThaiNo~0.6Grapheme cluster (marks)
DevanagariNo~0.7Grapheme cluster (marks)
  • Segmentation uses Intl.Segmenter when available, so Thai and Devanagari combining marks never become standalone pigments.
  • Augmentation pools are per script: a French prompt is topped up with Latin glyphs, never with Han characters.
  • Font stacks are resolved per script and merged with a CJK fallback and a system monospace tail.
Resolving the dominant script
import { detectScript, resolveFontFamily } from '@/core/script';

const script = detectScript('un café  très  noir'); // 'latin'
const font = resolveFontFamily(script);            // script stack + CJK + monospace

Key takeaways

  • Script is detected by Unicode range and weighted tally.
  • Case folding applies only to bicameral scripts.
  • Augmentation and fonts are per script, never cross-script.

Back to top ↑

5Palette extraction and shade ramp construction#

The palette builder splits the prompt into graphemes, de-duplicates by the script’s key, measures ink for every survivor, and sorts ascending. If the result is shallower than the requested minimum, the script’s augmentation pool is appended until the target depth is reached. This is how an ASCII art palette is built from arbitrary input in a single pass.

  • Augmented glyphs are measured with the same metric, so they land in correct positions rather than being appended blindly.
  • When measurement is unavailable, augmentation interpolates between the darkest known ink and 1 so ordering stays monotonic.
  • A space can be promoted to the lightest shade for cleaner highlights.
  • Manual overrides are preserved: once you drag a swatch, the automatic order stops fighting you.
Building a palette
import { createPalette } from '@/core';

const palette = createPalette('一个红苹果', { minLevels: 8, caseFold: true });
// palette.glyphs  → ['一','个','红','苹','果', …augmented]
// palette.measured→ true when ink came from real rasterisation

Key takeaways

  • De-duplication uses the script’s own key.
  • Augmentation is measured, not appended blindly.
  • Downstream consumers depend on ramp index only.

Back to top ↑

6Area-average resampling#

Downscaling an image for ASCII art sampling uses a two-pass separable box filter rather than the browser’s built-in scaling. Built-in scaling differs between engines and cannot be asserted in tests; a hand-written box filter is deterministic, and area averaging (rather than point sampling) preserves tonal information that point sampling throws away.

  • Horizontal pass then vertical pass — separable, so the cost is linear rather than quadratic in the kernel width.
  • Edge pixels are handled by clamping, so borders do not darken.
  • Alpha is premultiplied before averaging to avoid halos around semi-transparent edges.
Sampling strategies compared
StrategyDeterministicTone fidelityTestable
Point samplingYesPoor — aliases detailYes
Built-in canvas scalingNo — varies by engineGoodNo
Two-pass box filter (used)YesGoodYes

Key takeaways

  • Hand-written separable box filter for determinism.
  • Area averaging preserves tone; point sampling does not.
  • Alpha is premultiplied to avoid edge halos.

Back to top ↑

7Quantisation and dithering#

Quantisation maps a continuous brightness to one of N ramp indices using uniform bins over the level count. With small N, uniform bins produce visible banding on smooth gradients; dithering perturbs the decision so neighbouring cells alternate and the eye integrates them into intermediate tones. This is the step that decides how an ASCII art image actually reads at normal viewing distance.

Algorithms
AlgorithmError handlingCharacterCost
NoneTruncate to nearest binPoster-like, flatLowest
Floyd–SteinbergDiffuse 7/16, 3/16, 5/16, 1/16Organic, smoothOne pass
Ordered (Bayer)Threshold matrixRegular, retroLowest
Floyd–Steinberg error diffusion weights
        (x+1, y)   7/16
(x-1,y+1) 3/16   (x,y+1) 5/16   (x+1,y+1) 1/16

Key takeaways

  • Uniform bins over ramp index; dithering diffuses the error.
  • Floyd–Steinberg for smooth gradients, Bayer for a halftone look.
  • Tone adjustment must precede quantisation.

Back to top ↑

8Output renderers: text, SVG and canvas#

ASCII art rendering always starts from the same shade-index grid, so no output format can silently drift from another. Text emits a character grid, SVG emits positioned <text> elements, and the canvas renderer paints to a bitmap that a minimal PNG encoder serialises.

  • SVG emits a list of absolute x offsets per glyph rather than relying on font advance widths, so a file renders identically on machines with different fonts.
  • Consecutive identical glyphs are merged into a single <text> node with an x list, which keeps file size down.
  • Multi-code-point graphemes (emoji ZWJ sequences) repeat the same x so their components overlap correctly.
  • PNG export uses a self-contained encoder in src/server/png.ts — no native dependency, no ImageMagick.
Renderer characteristics
RendererOutputFont dependentEditable
textPlain charactersYes (monospace assumed)Trivially
svgVectorNo (absolute positioning)In any vector editor
canvas → PNGRasterYes (baked in)No

Key takeaways

  • One index grid feeds every renderer, so outputs cannot diverge.
  • SVG glyph positions are absolute and font-independent.
  • PNG export needs no native dependency.

Back to top ↑

9Image provider adapters#

A provider is an object with a descriptor and a generate function. The descriptor declares credentials, models, aspect support and whether the flow is asynchronous. The registry resolves credentials from server environment variables first, then from session-supplied values, so an ASCII art request never depends on hard-coded keys.

The provider contract
export interface ImageProvider {
  descriptor: ProviderDescriptor;
  generate(
    request: GenerateRequest,
    context: {
      credentials: ProviderCredentials;
      signal?: AbortSignal;
      fetchAsDataUrl: (url: string) => Promise<string>;
      log?: (message: string) => void;
    },
  ): Promise<GenerateResult>;
}
  1. Create src/providers/<name>.ts implementing ImageProvider.
  2. Add it to the PROVIDERS array in src/providers/registry.ts.
  3. Document its environment variables in .env.example.

Routes, the provider dropdown and the credential form all discover providers from the registry, so a new provider needs no changes in the UI or API layer. Asynchronous providers use a shared submit-and-poll helper; Tencent Hunyuan signs requests with TC3-HMAC-SHA256, verified against the vendor’s published test vector.

Key takeaways

  • Adding a provider is one file plus one registry entry.
  • Remote images are proxied to same-origin data URLs by the server.
  • TC3 signing must use the exact Content-Type that is sent.

Back to top ↑

10HTTP API reference for ascii.im#

Every endpoint returns JSON. The ASCII art API is read-heavy: only /api/generate does real work on the server, while the rest are cheap lookups used by the interface itself.

Endpoints
MethodPathPurpose
GET/api/providersVisible provider catalogue with access tier
POST/api/generateGenerate an image and proxy it as a data URL
GET/api/localeLocale detection result and supported languages
GET/api/auth/providersConfigured sign-in methods and callback URLs
GET/api/auth/sessionCurrent session and quota
POST/api/auth/logoutClear the session cookie
GET/api/auth/{provider}Start an OAuth flow
GET/api/auth/{provider}/callbackOAuth redirect target
POST /api/generate — request
{
  "provider": "builtin",
  "prompt": "a red apple",
  "negativePrompt": "text, watermark",
  "aspect": "1:1",
  "seed": 42,
  "credentials": {}
}
Error envelope — the UI renders text from the code
{
  "ok": false,
  "error": { "code": "QUOTA_EXCEEDED", "vars": { "total": "3" } }
}
Error codes and status
CodeStatusMeaning
EMPTY_PROMPT400Prompt is empty
PROMPT_TOO_LONG400Prompt exceeds 1000 characters
INVALID_JSON400Body is not valid JSON
UNKNOWN_PROVIDER400 / 404Unknown id, or a hidden provider
MISSING_CREDENTIALS400Required keys are absent
LOGIN_REQUIRED401Provider tier requires a session
QUOTA_EXCEEDED402No free generations left
FORBIDDEN_TIER403Plan does not include the provider
RATE_LIMITED429Too many requests; see Retry-After
PROVIDER_ERROR502Upstream model rejected the request

Key takeaways

  • Errors are code + vars, never prose.
  • Hidden providers return 404, not 403.
  • Generation is rate limited per IP by default.

Back to top ↑

11Authentication, sessions and quota#

Sessions are stateless: base64url(JSON) plus an HMAC-SHA256 signature, compared with a constant-time equality check. Only the user id is stored in the cookie; the user record is re-read on every request, so a plan change takes effect immediately rather than at next login.

OAuth providers
ProviderToken authPKCEScope
GoogleRequest bodyYesopenid email profile
DiscordRequest bodyNoidentify email
X (Twitter)HTTP BasicRequiredtweet.read users.read offline.access
  • Callback URLs are derived from APP_BASE_URL: {APP_BASE_URL}/api/auth/{provider}/callback.
  • The state parameter and the PKCE verifier live in an HttpOnly signed cookie, which blocks CSRF and code interception.
  • Redirect targets are validated to be site-relative, so “//evil.com” and backslash variants fall back to the default page.
  • Quota is debited before the upstream call and refunded on any failure.
Access decision shape
type AccessDecision =
  | { allowed: true;  consume: boolean }   // consume = debit one free generation
  | { allowed: false; reason: 'LOGIN_REQUIRED' | 'QUOTA_EXCEEDED' | 'FORBIDDEN_TIER' };

Key takeaways

  • Sessions are signed, stateless and verified in constant time.
  • State and PKCE verifier never reach JavaScript.
  • Quota is refunded whenever the upstream call fails.

Back to top ↑

12Configuration, internationalisation and deployment#

Configuration is entirely environment-driven, and the i18n architecture follows the same rule: nothing is hard-coded per deployment and nothing is duplicated per language. This section lists every variable group, explains how a language is chosen on the server, and notes what must change when you run more than one instance.

Environment variable groups
GroupVariables
SiteAPP_BASE_URL, DEFAULT_LOCALE, GEOIP_ENDPOINT
AuthAUTH_SECRET, FREE_GENERATIONS, ADMIN_USER_IDS, GOOGLE_*, DISCORD_*, TWITTER_*
Provider accessPROVIDER_VISIBILITY, PROVIDER_TIERS, PROVIDER_DEFAULT_*
Provider keysDASHSCOPE_API_KEY, ARK_API_KEY, BIGMODEL_API_KEY, HUNYUAN_SECRET_ID/KEY, QIANFAN_API_KEY, MINIMAX_API_KEY
Rate limitRATE_LIMIT_PER_MIN

Provider access is expressed entirely in the environment. PROVIDER_VISIBILITY hides a provider from the catalogue and from the API at once; PROVIDER_TIERS assigns free, pro or admin. The built-in offline placeholder is always visible and always free, so there is never a dead end for a first-time visitor.

Internationalisation uses English as the single source of truth: Messages is derived from the English dictionary, so a missing key in any other language fails type checking. Dictionaries are selected on the server and passed down as props, which means the first paint already has the right html lang and dir, and the client bundle does not grow with the number of languages — a locale is imported lazily only when the user switches.

  • Detection order: ?lang → cookie → CDN country header → optional GeoIP → Accept-Language → English.
  • Private and reserved IP ranges are excluded from geographic detection.
  • Arabic switches the document to dir="rtl"; CSS uses logical properties rather than left/right.

Key takeaways

  • Provider visibility and tiers are configuration, not code.
  • English dictionaries are the compile-time source of truth.
  • Shared state (rate limit, user store) must move out of process when scaling.

Back to top ↑

Read the code, or just use it

The kernel is framework-agnostic — reuse it in a CLI, or start in the studio right away.

Open the studio →
ascii.im Docs: Architecture, Pipeline and API Reference