All projects

ascii-art

2025

--mode

Three modes, one photograph

Glyph density carries luminance and foreground colour carries colour. Neither is smuggled into the other, which is why the three panels differ in exactly one thing each.

                         .....::--::::::::.
                     .:-=======+=======+==--:.
                  .:--++***+**+++++*++++=++----:
                 .-++*+=-==+*+++++*+=-:::-+===--:.
               .-==+++==---==+++*===::-+++**++===:.
              .:=++******==---+#*=---:+*##*****++=:.
             :-=+**#*####*=-:-+**=--==**==+###***+=-.
            .-+**+*##-::-**-:-=++=::+*+.-=.-#*+***+=-.
            :=+*+***::%#:=*--++=++=-=*=:@#*.=+****#+--.
           .=+****#+.#%@:=+-=*-:-++-=#+.#@%.-*##***#+=:
           -+*++*#*+.+%+:*==+#-::+*==+*-.-:.=***+****+=:
          :=+*+****#-..:++=+*+:.:=#+=-=+=-=*#***##****==:
          :=****+***#++*=-=*#+:.:=*#+=--+**++++****+**+=-.
         .-=+*++*#**=+*=-+#%#+:::=+#%#*=-+****##*+****+=-.
         :=++*+***##**==*##%#+:::=+*#*+*++*#####*+*++====.
         -++*###########+=+**+=.-++++=--=***#%%#**++++*+=.
        :++**##%%##+++====+*+==:-=-=+=----+*#####*+*+***=.
       .=++++***##%#*++=-=++==-----===---:-=++**####*+**=.
      :=+*#**+**#######*=+==+====+=-===------+*###*****+=.
    .=**#%#####**#%##*##*+==+==+=======+===+####****#**==.
   .-*###**+=**+*##*#####*+++===+===+==#########**+****+=.
   :=*+++++--+++##**###*#****+*+**+++**#######*******#*=-
  .-===++==::-+*#***###*#*****##*****#####*#**#**##****+=.
  .--+*+++=-:-+***+*##*#*##*****+***###**********##***+==.
  :--+*+*+=-:-**#**###########******##*******###***++*+==:
  :==***++-::=**######***##########**#***********=++==+==:
  :++*#*++====+****####***#**#%###%###*#***+***+===+++++=-
  :+-**+#*++=-+==++*********+*###%@%%####****#*+**+**++==-
 .-+=*#*#***-==+=++*+**##*#*+**###%#%##*#*+*###****=====-:
 :+**#%**=+---=****+**##**#****###%###**##+==*****++-=+=-.
 =+*#***==:--=+**********+**=+*#######*###*+==+++====---:
.-=#%=+==+:-=++**++++++**++++=###****#**##*+==+==----:::.
.:=*#*--==-==+=++======***+++=*#****##***#*==-=-::-==-::.
::=+#+==+=======-----=-+*#*===****++*#*+***+----:.-==:.:.
-=+*#---*+-==-=+===-==-+***=+++**++=+****#*+=--=-::=-:::
-++*====*=*+=-=+==--=--+***++*==+====***+**=--=====+-:..
-++*-*==*+*=--=++=:----+***=+*=--=+=-**++=+=--==+==+=-:
=++#+*=*==--:-==+=--:--=**+=+*+--====*#+*=+---==-==+=-.
--**#++*=-==---=++-:::--**==+**+-====***+==:--=-:-++-.
---*#=#*=-+=::-=+++-::-=**=-+**++=+=-**+--===+=-:-++-
-=-#+=#%+=+=:.:++++-.:-+**=-=+++=++=-++=--===+=::-=-.
-**#=+*%**=-:::+#+:=:--+#*+++++-=++-:=---=+*-==::--.
-+#+=*##+==-=-:=*-:-+==+**++*#*+==+::-=-+=++==+::=:
.:++==#*=-:=+=:=*=::+***++=+=+***+==:-+*+-+=+==:-:
:.-*+*+*+-:=-=-==+-:+#**=-=+=====--:-=+*==++*---:
. .+++=+*+-=-==-===-=*#+*-==-------:==+==*=+=-:-.
   .---=++-+:-=:--*===+*++=-++-==--=++-=++==-===:
      ..:. -=-=-=====+=*+=====+++=*=++=+**--==+--
            ----=*+=+++++*====++*=+=+++*+==-=+:.:
            .:-+--**+*+==+=++++=++==+++++==---. .
             .::-=++****===**+++==+++==++==--.
              .::+#*#*+++*+++++=-=+++++++*+-:
        .      .=+%%*#++++=+=+=-::+++++++*=:.
      .--=:    :***#%*++=++====-::*+++=-==:.
     .-:-=+-..:=******========-:::++*+=-::.
     :..:--+==+**+***--=======-:::=++*+*=:
     .....:=+****+++**=========----+++**+=.
     ...=++**++**==-+#==-----------=+**+++=:     .:--:.
      .+#+++==-+*+--=*=--::::::---===+==++==-..:-=-----:
      .*=-=-----:::::==-:::::..::-=======*===-==----:..::
      :-.::-::.......-:..........::--====+====---:::....:
      :..............:.......  ....-**--=#*-==**::....
                    ........    ...=+=::=+=---=*=...
                                ...--::.::--::--=..
                                ...-..........::=..
                                 ..:............:.
mono glyph from luminance, one colour --mode mono --charset simple --invert Ink is glyph coverage and nothing else. Ten steps of ramp, no colour anywhere. This is the version that survives a log file.
                       '`^"lI!>-1|-~_><+-_I`
                    ';<(tjnjxrxufrjxxn/urf)1_I
                  "<1(uXJJJUCCzYvYXJcYzzrcn|))[!
                'I]uzLXj[/xvLXUzYULct[-~-(ux/t?-i^
               "]\nvzYj/(?1ffucXJnx/_>1uXXLOUUx/\_,
              :-fzYZmOZZC/f(][cw0f]-(_v0ppZJmZJUXj-;'
             i?tXLOdOkqpqLx]_[YLQf?-jf0L/\zbmmLCUv\-l
            :)nQJu0qm]i!]0Q]+?rcu\_-uQXI)xl?qOzCZ0ct),
           '~/nLULJZ<+aq!jC[]nzrvYn?fOf<BqC;tYOZC0wu)[^
           I\vL0OOwc;k#B+tz1rJ(+1cX(rwvlh8o;]LmqLJOwXf~
          `]uJuYQwQv;c*Y>JfjUm-i-c0/\YZ]!(<;xCOCYJULLX/!
          >xzJXJC00w]Il>zzfcOY+l+jwvt(\Yx]/CpCJOqmOmQJr\i'
         '-xJJCJUZLLqYvQx(nOkc_l~fQmUj1)vLLYYzXOQCJU0JXr("
         "|xzLzYJm0CxvLr)vpawc-!_/zwhbL\1n0CLOwqLzOZ0Jzr\:
         ~xcUZUZC0ddCZxxmdbawc-i~\zObQUCYYObdkdm0ULYcxf/\I
        `(vvLmqwppbbbqqmznUm0U\I?ucYYx||rmmQkaob0JYzcYJzfI
       `+uzJLmphhkdUzvx//fzLUjn<|t1rvf[??|YZmpdpd0XJYJQJ\"
       "\XcuYZO0mbapJYzx|nzvxf(()1(\rx|]]-)rcXJLwqmmCYQJ/"
     '_rXCm0QYLOwqdqppmOnujrYxrr\nx\/xf)?1))|u0bbwOZZ00Ur;
    ,tC0khbpmhmOZdapmOmqZcnjv/rnjn/tt/jcrt\cwbkdOQQQmO0xfI
   ^)CqmwOLvfCLuZpdOmkqmm0Uzzxxrzrxxzfjmwdpbkdqm0QYLZOLu/,
   inLYczcu|?nnzmdQCmbk0wC00JzQYL0zXuLZdppwpqqOLOO00OmCr|`
  ")jrxYUrr_<?cCm0JLmqb0mZ0OLQmmL0QZOdqqqq0mQLwCZwwCQQJXf:
  !?[vJzvvt?i1YCCJvLppZwZqwZL000U0QmwpmCZQCZCOZQOqwOZQXf/I
 '>)1zUYCut]-|JCmOQdddqmwpqbkqC0QCQZwmCQO0CJZwqwZQJzXQzj\<
  >/nCOQYv)__jJ0pmmppbO0OpbpbwkmkwpOZwOOLOJQZZQOLxuvrnX\j-
  +Yz0mJUztjjxcLJJJwmbwLLOwZCpaddk*pwqZwQZQU0ZJujnxvYczv\1
 '_c|ZZUmZUXx)un/uXQQZZZQOQJcZpwkM&habwpdOCO0qQYLUXJQYzft1
 ;)vrQk0wLJC[\\zfuXJY0OwqOpQcQZmdkMkabmmwZzCwppJOZCr\rxj)>
 !uZ0haZJxz)1?jOLLQUCZmwOLwmCCCqwpapkwQZwwcxjCQZQJXz|fn/[;
`\vOqZQJxf_?(rUOZLCQQCQOJvQCjvZwwmmdbwJpbdQutjcuXrjrj(||<'
I1fq*rXj/v-1rYUCJuuvzzzLZXYcnjmmwZOOZpC0pbQur\cjj())|___I
:itmdC(1jj?txnrcz\/jfrrLZLXcurZwZQCQwpZJObOx\)t?+-1jt]<_:
!<xUdzf/cxf\j/\x(1)\|/|YOwLjjjJO0JzXOwQY0ZZu[[[]i!]f\~!_^
]/zOk]?1Qu|n\|/c\\\)/\|XZZQjuuvJZUcfnOCCZq0c\(|f1~+f)i!>
]zYOr\fr0jLX\)tun/(1/|)UO0LvUC//Xfjt/COCUCJx((rxxtjv1>l"
[uXZ)J\r0UCn()fczf-][()Y0LJjvQt?]jv/|LZXznuf(]ffv/fuj?>
/YYknUrJrn|1_?/rzf??-?(xLLuxuJv[-tjf/LbYJnn(]1j\[ftcn),
))Q0puYL/[\j]]|fuc|+<~)\J0xxXOLu\fjtt0mCcjj-](n)_[nX]l
??1ObndC/?cj_<?tvYz]!<[xQ0x|zQOYurUf)0Qv((r\tun?_?vv?`
[/1hctqauxX/>l<vYzn(!+)cQCx|jzUcruX/[cu\]1nx\nt-~[r["
[00mrz0oLQr)_~iuwu-\+?(YmLvczzn|xvY(+x|(\xcJ1xx++11I
[zkXfJwwur\)x1>jZ|+]c/fvCJzzQwOcxjc--[t(YjzX/nv+<\i^
;_XYjrwZx?-/v/-t0j<+YLJCnnrnxYQZ0v/t<)c0Y|YnXn/_[>'
>;|0zJY0c]+r]\1tnv[_nwJQ/1/vfff/t|(+[\cCjxYcL))[~
: ^czvfU0u[\]ft(/nf|\LbcC)/x(1(1)?[+jfctjOnzf(<|l
   :[]1fvY1c_)f-|1Cr/xUQcX/1cn(tr(?ruY|xXUf\?jjj~
     'Il>: 1\?/[\xrrxznLzj/jtrzYUxC/Xzru0L)]tjn[?
           `[)|)/CYnXXvYXJf/jrnvCnYfcuYLYt/?\c+^i
            :-]c((JQzOY\fnxzvUYrccjrzXnXuxt1|]^ ^
             I->)tuUZ0L0nxxCCUYnjjvcYftnznf(1I'
              !-~XpOm0UYYLcvYYzt)tXUnczcvOX1_'
        ^'     lrcoomqUuvUnujXr(<~ccXzUcYQt~l
      l))t+'   >JLCmamYnruufxj\?_>CUYXt]ff~:'
     I|+1/u|:"<xCLO0QCjtfrf/\\]~<+UUJvj[+>"
     ~:;~?|nxxUQQXJZL\|\tffft\[-~<xczCULf>'
    'I;;I!-jYQZZZvvvQLfjjfttt/\|)](ucUL0zj;
     ^:lxcXCCUXJQx/|Yhrt|1]???[)(|(nvJJnvvf<`   ':+)]~,
     ',XqYuurj|vLc1?t0j(?+<<~+_?1|/jjuxjuYj/],,<|t([[[(<
      IC\|\[()1]-+~<_r\[-_~>!lli+1//frrfjLrx\[/r|((?>,,>i
      i[l_-?-~!;;;;::|ilIllI:,,:l<-[|tfr/Xr/j\[[?+~>;:,,i
      il:Ill;:,"",,,:i::,,"^^^^^":l?QJ|)/mQ)/rJC<!I,"^^``
      `^^`^^^``````^^^"""^^^^```^,;tYf-_/uj)1(xO/I:"`'
                      '`````````^,;|?~>!>-11_+](r;,`
                           ''''`^""?;l;::Illll!!\:^'
                               '^^^>^"""^^^"""""!:'
                                '``````'''''''``''
gray glyph + quantised grey --mode gray --charset safe --invert --gray-levels 16 Tone is expressed twice, as glyph coverage and again as foreground brightness. The two layers multiply, which is where the apparent range comes from.
                       '`^"lI!>-1|-~_><+-_I`
                    ';<(tjnjxrxufrjxxn/urf)1_I
                  "<1(uXJJJUCCzYvYXJcYzzrcn|))[!
                'I]uzLXj[/xvLXUzYULct[-~-(ux/t?-i^
               "]\nvzYj/(?1ffucXJnx/_>1uXXLOUUx/\_,
              :-fzYZmOZZC/f(][cw0f]-(_v0ppZJmZJUXj-;'
             i?tXLOdOkqpqLx]_[YLQf?-jf0L/\zbmmLCUv\-l
            :)nQJu0qm]i!]0Q]+?rcu\_-uQXI)xl?qOzCZ0ct),
           '~/nLULJZ<+aq!jC[]nzrvYn?fOf<BqC;tYOZC0wu)[^
           I\vL0OOwc;k#B+tz1rJ(+1cX(rwvlh8o;]LmqLJOwXf~
          `]uJuYQwQv;c*Y>JfjUm-i-c0/\YZ]!(<;xCOCYJULLX/!
          >xzJXJC00w]Il>zzfcOY+l+jwvt(\Yx]/CpCJOqmOmQJr\i'
         '-xJJCJUZLLqYvQx(nOkc_l~fQmUj1)vLLYYzXOQCJU0JXr("
         "|xzLzYJm0CxvLr)vpawc-!_/zwhbL\1n0CLOwqLzOZ0Jzr\:
         ~xcUZUZC0ddCZxxmdbawc-i~\zObQUCYYObdkdm0ULYcxf/\I
        `(vvLmqwppbbbqqmznUm0U\I?ucYYx||rmmQkaob0JYzcYJzfI
       `+uzJLmphhkdUzvx//fzLUjn<|t1rvf[??|YZmpdpd0XJYJQJ\"
       "\XcuYZO0mbapJYzx|nzvxf(()1(\rx|]]-)rcXJLwqmmCYQJ/"
     '_rXCm0QYLOwqdqppmOnujrYxrr\nx\/xf)?1))|u0bbwOZZ00Ur;
    ,tC0khbpmhmOZdapmOmqZcnjv/rnjn/tt/jcrt\cwbkdOQQQmO0xfI
   ^)CqmwOLvfCLuZpdOmkqmm0Uzzxxrzrxxzfjmwdpbkdqm0QYLZOLu/,
   inLYczcu|?nnzmdQCmbk0wC00JzQYL0zXuLZdppwpqqOLOO00OmCr|`
  ")jrxYUrr_<?cCm0JLmqb0mZ0OLQmmL0QZOdqqqq0mQLwCZwwCQQJXf:
  !?[vJzvvt?i1YCCJvLppZwZqwZL000U0QmwpmCZQCZCOZQOqwOZQXf/I
 '>)1zUYCut]-|JCmOQdddqmwpqbkqC0QCQZwmCQO0CJZwqwZQJzXQzj\<
  >/nCOQYv)__jJ0pmmppbO0OpbpbwkmkwpOZwOOLOJQZZQOLxuvrnX\j-
  +Yz0mJUztjjxcLJJJwmbwLLOwZCpaddk*pwqZwQZQU0ZJujnxvYczv\1
 '_c|ZZUmZUXx)un/uXQQZZZQOQJcZpwkM&habwpdOCO0qQYLUXJQYzft1
 ;)vrQk0wLJC[\\zfuXJY0OwqOpQcQZmdkMkabmmwZzCwppJOZCr\rxj)>
 !uZ0haZJxz)1?jOLLQUCZmwOLwmCCCqwpapkwQZwwcxjCQZQJXz|fn/[;
`\vOqZQJxf_?(rUOZLCQQCQOJvQCjvZwwmmdbwJpbdQutjcuXrjrj(||<'
I1fq*rXj/v-1rYUCJuuvzzzLZXYcnjmmwZOOZpC0pbQur\cjj())|___I
:itmdC(1jj?txnrcz\/jfrrLZLXcurZwZQCQwpZJObOx\)t?+-1jt]<_:
!<xUdzf/cxf\j/\x(1)\|/|YOwLjjjJO0JzXOwQY0ZZu[[[]i!]f\~!_^
]/zOk]?1Qu|n\|/c\\\)/\|XZZQjuuvJZUcfnOCCZq0c\(|f1~+f)i!>
]zYOr\fr0jLX\)tun/(1/|)UO0LvUC//Xfjt/COCUCJx((rxxtjv1>l"
[uXZ)J\r0UCn()fczf-][()Y0LJjvQt?]jv/|LZXznuf(]ffv/fuj?>
/YYknUrJrn|1_?/rzf??-?(xLLuxuJv[-tjf/LbYJnn(]1j\[ftcn),
))Q0puYL/[\j]]|fuc|+<~)\J0xxXOLu\fjtt0mCcjj-](n)_[nX]l
??1ObndC/?cj_<?tvYz]!<[xQ0x|zQOYurUf)0Qv((r\tun?_?vv?`
[/1hctqauxX/>l<vYzn(!+)cQCx|jzUcruX/[cu\]1nx\nt-~[r["
[00mrz0oLQr)_~iuwu-\+?(YmLvczzn|xvY(+x|(\xcJ1xx++11I
[zkXfJwwur\)x1>jZ|+]c/fvCJzzQwOcxjc--[t(YjzX/nv+<\i^
;_XYjrwZx?-/v/-t0j<+YLJCnnrnxYQZ0v/t<)c0Y|YnXn/_[>'
>;|0zJY0c]+r]\1tnv[_nwJQ/1/vfff/t|(+[\cCjxYcL))[~
: ^czvfU0u[\]ft(/nf|\LbcC)/x(1(1)?[+jfctjOnzf(<|l
   :[]1fvY1c_)f-|1Cr/xUQcX/1cn(tr(?ruY|xXUf\?jjj~
     'Il>: 1\?/[\xrrxznLzj/jtrzYUxC/Xzru0L)]tjn[?
           `[)|)/CYnXXvYXJf/jrnvCnYfcuYLYt/?\c+^i
            :-]c((JQzOY\fnxzvUYrccjrzXnXuxt1|]^ ^
             I->)tuUZ0L0nxxCCUYnjjvcYftnznf(1I'
              !-~XpOm0UYYLcvYYzt)tXUnczcvOX1_'
        ^'     lrcoomqUuvUnujXr(<~ccXzUcYQt~l
      l))t+'   >JLCmamYnruufxj\?_>CUYXt]ff~:'
     I|+1/u|:"<xCLO0QCjtfrf/\\]~<+UUJvj[+>"
     ~:;~?|nxxUQQXJZL\|\tffft\[-~<xczCULf>'
    'I;;I!-jYQZZZvvvQLfjjfttt/\|)](ucUL0zj;
     ^:lxcXCCUXJQx/|Yhrt|1]???[)(|(nvJJnvvf<`   ':+)]~,
     ',XqYuurj|vLc1?t0j(?+<<~+_?1|/jjuxjuYj/],,<|t([[[(<
      IC\|\[()1]-+~<_r\[-_~>!lli+1//frrfjLrx\[/r|((?>,,>i
      i[l_-?-~!;;;;::|ilIllI:,,:l<-[|tfr/Xr/j\[[?+~>;:,,i
      il:Ill;:,"",,,:i::,,"^^^^^":l?QJ|)/mQ)/rJC<!I,"^^``
      `^^`^^^``````^^^"""^^^^```^,;tYf-_/uj)1(xO/I:"`'
                      '`````````^,;|?~>!>-11_+](r;,`
                           ''''`^""?;l;::Illll!!\:^'
                               '^^^>^"""^^^"""""!:'
                                '``````'''''''``''
color glyph + averaged source RGB --mode color --saturation 0.8 --color-step 32 Glyph still carries luminance and only the foreground carries colour. Photographic saturation reads as noise at 6px, so it is pulled back toward grey.

Select any of it and you will select characters. The HTML output is a <pre> of real text with adjacent same-coloured cells grouped into spans, rather than an image of text. That is the argument for the format existing at all: an ASCII render dropped into a page as a .txt file, with no white-space: pre and a default line height, wraps and stretches into nothing.

--pipeline

One cell, start to finish

Every cell in the output grid runs the same sequence once. render.c decides everything tonal and chromatic, and output.c only encodes a grid that is already final, which is why the three formats can never disagree about content.

source region a rectangle of pixels, tiled exactly
average RGB every pixel in it, read once
luminance and the tone curve on top
ink direction threshold, and which end is ink
glyph wanted coverage → a character
colour quantised, then encoded
image.c loading and ownership of pixel data
sample.c block resampling
color.c luminance, tone curve, saturation, quantisation
charset.c ramps and glyph selection
render.c the pipeline: image in, grid of cells out
output.c encoding a finished grid as TXT / ANSI / HTML
config.c defaults, arguments, validation
--width

Averaging, not sampling

The naive renderer walks the image and reads one pixel per character. That throws away almost everything in a downscale, and it is unstable: shift the source by a pixel and a different set of glyphs comes out.

Here the output grid is mapped back onto the source with integer arithmetic. Each cell's x1 is the next cell's x0, so the blocks tile the source exactly, with no gaps, no overlap, and no rounding drift accumulating across a row. Every pixel in the rectangle is averaged, so cost is O(source pixels) whatever the output size.

A four-pixel checkerboard sampled at one pixel reads as pure black or pure white. Averaged, it reads as the mid-grey it actually is. A test is named for exactly that.

x0 = out_x * source_width  / output_width;
x1 = (out_x + 1) * source_width  / output_width;
y0 = out_y * source_height / output_height;
y1 = (out_y + 1) * source_height / output_height;

// tests/test_ascii.c
test_downscale_averages_not_samples();
test_sample_block();
test_html_proportions();
--char-aspect

A character is not a square

In a terminal a cell is about twice as tall as it is wide, so a grid with as many rows as columns renders the picture stretched to double height. The row count is therefore derived from the source's aspect and the cell's, not from the column count.

The two targets do not share a number. A terminal cell is about 0.5 wide over tall, while the emitted web CSS sets line-height: 0.72 and measures 3.61 × 4.31 px in Chrome, or 0.838. Using the terminal's 0.5 for HTML is what makes a render come out roughly 40% too short.

The HTML default is therefore derived from the same constants that generate the CSS, rather than written down twice where the two could drift apart, and test_html_proportions asserts the rendered block stays within 2% of the source's shape.

terminal · txt, ansi 0.5 line-height ≈ 1.2 × font-size, so the cell is about twice as tall as it is wide
web · html 0.83 line-height 0.72 makes the cell nearly square, measured at 3.61 × 4.31 px

Two kinds of density are easy to confuse. Spatial is --width, how many cells the image is cut into. Tonal is --charset, how many ink levels the glyphs can express, from 10 steps for simple to 85 for medium. Wide output with a coarse ramp is sharp but posterised, and narrow output with a fine ramp has nothing to be smooth about.

--format

Three encodings of the same grid

TXT for files and logs, ANSI true-colour for terminals, and an embeddable HTML fragment. Only the foreground is ever set, so trailing blanks are invisible in all three.

The HTML encoder groups adjacent cells sharing a foreground into one <span> and emits spaces bare, because they show no ink. On a 260 × 130 render that is 33,800 cells written as about 8,000 spans. Quantisation is what makes the grouping effective, so it decides the file size.

Grey levels become CSS classes. Colours stay inline, because with a useful --color-step there are more distinct colours than it is worth emitting a class for.

knobspanssize
--gray-levels 43,05695 K
--gray-levels 85,313149 K
--gray-levels 16default7,740210 K
--color-step 811,309431 K
--color-step 328,366328 K
--color-step 646,283256 K
--charset safe

This site is the client

Every render on this site comes out of a make site target in the repo, straight into the components the pages import. They all share one ramp, 8096453271:. , so the picture is drawn in digits rather than punctuation.

Column counts are not guesses either. Each is derived from the box it lands in and the mono face's own advance, so a digit is the same physical size everywhere on the page.

the one that cost an afternoon

Svelte and JSX parse { and } as expression delimiters, and the medium and full ramps both contain them. A 260-column render carries roughly 200 of each. Pasted into a .svelte file that is a compile error rather than a rendering glitch.

Hence --charset safe, the full ramp with the two braces removed, so the generated file is a valid component as-is. Importing the fragment as a raw string and rendering it with {@html} also works, but Svelte's scoped styles do not apply to that content, so the fragment's own style block leaks out as a global.

make site

The art is a build artifact

One command regenerates every render on this site from its source photograph, and the output is deterministic. The art is something the build produces rather than something that has to be preserved because nobody could make it again.

Deterministic
the same inputs produce byte-identical output. No timestamps, no ordering by hash iteration, no seed, so a regenerated component is a no-op in git unless the source or the flags changed.
One command
`make site` re-renders all five components straight into the paths the pages import. The flags for each live in the Makefile beside the reason for them.
Checked, not piped
17 test functions and 168 assertions run by `make test`, covering the pipeline stages, config validation, and the two claims most likely to rot: that downscaling averages rather than samples, and that the HTML block stays within 2% of the source’s proportions. There is no CI runner. This one is a Makefile target and honest about it.
--not-implemented

Where it stops

A 667 × 667 source at 400 columns takes about 13 ms, allocates one grid of cells plus the decoded image, and is byte-for-byte deterministic. The limits are in the rendering, not the speed.

  • The ramps are hand-ordered: glyph selection asks for wanted ink coverage in 0..1, and a Charset carries an optional measured-density table for answering it. That table is NULL today, so glyphs are assumed evenly spaced, which is the main quality ceiling in the current output. The hook and its test exist. The measurement does not.
  • Alpha is ignored: channel layouts are handled explicitly, but alpha is dropped rather than composited.
  • No edge awareness: a cell knows its own average and nothing about its neighbours, so a hard edge is reconstructed from tone alone rather than from a glyph that matches its direction.
  • Single-threaded, no dithering: deliberately so at this size, since the whole render is 13 ms. Threading, dithering, custom palettes, animation and WebAssembly are named as out of scope rather than left implied.