about summary refs log tree commit diff stats
path: root/adapter/img/sixel.nim
diff options
context:
space:
mode:
authorbptato <nincsnevem662@gmail.com>2024-08-24 19:48:55 +0200
committerbptato <nincsnevem662@gmail.com>2024-08-24 19:52:43 +0200
commitb6e10287b23241fb8933f311a72ebe95976b174b (patch)
tree8124db650db1c149ba62f02066b88a64b2573092 /adapter/img/sixel.nim
parent8db0a94ca4287c4f2e1361b056994e0ec206712d (diff)
downloadchawan-b6e10287b23241fb8933f311a72ebe95976b174b.tar.gz
sixel: break out into a separate cgi script
This caches sixel output. Works best when the line height is a multiple
of 6px, but should still be faster than the previous solution everywhere
else too (simply by virtue of encoding separate images in parallel).

Next step: actual color quantization
Diffstat (limited to 'adapter/img/sixel.nim')
-rw-r--r--adapter/img/sixel.nim202
1 files changed, 202 insertions, 0 deletions
diff --git a/adapter/img/sixel.nim b/adapter/img/sixel.nim
new file mode 100644
index 00000000..02bb356e
--- /dev/null
+++ b/adapter/img/sixel.nim
@@ -0,0 +1,202 @@
+# Sixel codec. I'm lazy, so no decoder yet.
+#
+# "Regular" mode just encodes the image as a sixel image, with
+# Cha-Image-Sixel-Palette colors. (TODO: maybe adjust this based on quality?)
+# The encoder also has a "half-dump" mode, where the output is modified as
+# follows:
+#
+# * DCS q set-raster-attributes is omitted.
+# * 32-bit binary number in header indicates length of following palette.
+# * A lookup table is appended to the file end, which includes (height + 5) / 6
+#   32-bit binary numbers indicating the start index of every 6th row.
+#
+# This way, the image can be vertically cropped in ~constant time.
+
+import std/options
+import std/os
+import std/strutils
+
+import types/color
+import utils/sandbox
+import utils/twtstr
+
+const DCSSTART = "\eP"
+const ST = "\e\\"
+
+# data is binary 0..63; the output is the final ASCII form.
+proc compressSixel(data: openArray[uint8]; c: uint8): string =
+  var outs = newStringOfCap(data.len div 4 + 3)
+  outs &= '#'
+  outs &= $c
+  var n = 0
+  var c = char(0)
+  for u in data:
+    let cc = char(u + 0x3F)
+    if c != cc:
+      if n > 3:
+        outs &= '!' & $n & c
+      else: # for char(0) n is also 0, so it is ignored.
+        outs &= c.repeat(n)
+      c = cc
+      n = 0
+    inc n
+  if n > 3:
+    outs &= '!' & $n & c
+  else:
+    outs &= c.repeat(n)
+  return outs
+
+type SixelBand = object
+ c: uint8
+ data: seq[uint8]
+
+func find(bands: seq[SixelBand]; c: uint8): int =
+  for i, band in bands:
+    if band.c == c:
+      return i
+  -1
+
+proc setU32BE(s: var string; n: uint32) =
+  s[0] = char(n and 0xFF)
+  s[1] = char((n shr 8) and 0xFF)
+  s[2] = char((n shr 16) and 0xFF)
+  s[3] = char((n shr 24) and 0xFF)
+
+proc putU32BE(s: var string; n: uint32) =
+  s &= char(n and 0xFF)
+  s &= char((n shr 8) and 0xFF)
+  s &= char((n shr 16) and 0xFF)
+  s &= char((n shr 24) and 0xFF)
+
+proc encode(s: string; width, height, offx, offy, cropw: int; halfdump: bool;
+    bgcolor: ARGBColor; palette: int) =
+  if width == 0 or height == 0:
+    return # done...
+  # prelude
+  var outs = ""
+  if halfdump: # reserve size for prelude
+    outs &= "\0\0\0\0"
+  else:
+    outs &= DCSSTART & 'q'
+    # set raster attributes
+    outs &= "\"1;1;" & $width & ';' & $height
+  for b in 16 ..< 256:
+    # laziest possible color register allocation scheme
+    #TODO obviously this produces sub-optimal results
+    let rgb = EightBitColor(b).toRGB()
+    let rgbq = RGBColor(uint32(rgb).fastmul(100))
+    let n = b - 15
+    # 2 is RGB
+    outs &= '#' & $n & ";2;" & $rgbq.r & ';' & $rgbq.g & ';' & $rgbq.b
+  if halfdump:
+    # prepend prelude size
+    let L = outs.len - 4 # subtract length field
+    outs.setU32BE(uint32(L))
+  stdout.write(outs)
+  let W = width * 4
+  let H = W * height
+  var n = offy * W
+  var ymap = ""
+  var totalLen = 0
+  while n < H:
+    if halfdump:
+      ymap.putU32BE(uint32(totalLen))
+    var bands = newSeq[SixelBand]()
+    for i in 0 ..< 6:
+      if n >= H:
+        break
+      let mask = 1u8 shl i
+      let realw = cropw - offx
+      for j in 0 ..< realw:
+        let m = n + (j + offx) * 4
+        let r = uint8(s[m])
+        let g = uint8(s[m + 1])
+        let b = uint8(s[m + 2])
+        let a = uint8(s[m + 3])
+        var c0 = RGBAColorBE(r: r, g: g, b: b, a: a)
+        if c0.a != 255:
+          let c1 = bgcolor.blend(c0)
+          c0 = RGBAColorBE(r: c1.r, g: c1.g, b: c1.b, a: c1.a)
+        let c = uint8(c0.toEightBit())
+        if (let k = bands.find(c); k != -1):
+          bands[k].data[j] = bands[k].data[j] or mask
+        else:
+          bands.add(SixelBand(c: c, data: newSeq[uint8](realw)))
+          bands[^1].data[^1] = mask
+      n += W
+    outs.setLen(0)
+    for i in 0 ..< bands.high:
+      outs &= bands[i].data.compressSixel(bands[i].c - 15) & '$'
+    outs &= bands[^1].data.compressSixel(bands[^1].c - 15)
+    if n >= H:
+      outs &= ST
+    else:
+      outs &= '-'
+    totalLen += outs.len
+    stdout.write(outs)
+  if halfdump:
+    ymap.putU32BE(uint32(totalLen))
+    stdout.write(ymap)
+
+proc parseDimensions(s: string): (int, int) =
+  let s = s.split('x')
+  if s.len != 2:
+    stdout.writeLine("Cha-Control: ConnectionError 1 wrong dimensions")
+    return
+  let w = parseUInt32(s[0], allowSign = false)
+  let h = parseUInt32(s[1], allowSign = false)
+  if w.isNone or w.isNone:
+    stdout.writeLine("Cha-Control: ConnectionError 1 wrong dimensions")
+    return
+  return (int(w.get), int(h.get))
+
+proc main() =
+  enterNetworkSandbox()
+  let scheme = getEnv("MAPPED_URI_SCHEME")
+  let f = scheme.after('+')
+  if f != "x-sixel":
+    stdout.writeLine("Cha-Control: ConnectionError 1 unknown format " & f)
+    return
+  case getEnv("MAPPED_URI_PATH")
+  of "decode":
+    stdout.writeLine("Cha-Control: ConnectionError 1 not implemented")
+  of "encode":
+    let headers = getEnv("REQUEST_HEADERS")
+    var width = 0
+    var height = 0
+    var offx = 0
+    var offy = 0
+    var halfdump = false
+    var palette = -1
+    var bgcolor = rgb(0, 0, 0)
+    var cropw = -1
+    for hdr in headers.split('\n'):
+      let s = hdr.after(':').strip()
+      case hdr.until(':')
+      of "Cha-Image-Dimensions":
+        (width, height) = parseDimensions(s)
+      of "Cha-Image-Offset":
+        (offx, offy) = parseDimensions(s)
+      of "Cha-Image-Crop-Width":
+        let q = parseUInt32(s, allowSign = false)
+        if q.isNone:
+          stdout.writeLine("Cha-Control: ConnectionError 1 wrong palette")
+          return
+        cropw = int(q.get)
+      of "Cha-Image-Sixel-Halfdump":
+        halfdump = true
+      of "Cha-Image-Sixel-Palette":
+        let q = parseUInt16(s, allowSign = false)
+        if q.isNone:
+          stdout.writeLine("Cha-Control: ConnectionError 1 wrong palette")
+          return
+        palette = int(q.get)
+      of "Cha-Image-Background-Color":
+        bgcolor = parseLegacyColor0(s)
+    if cropw == -1:
+      cropw = width
+    let s = stdin.readAll()
+    stdout.write("Cha-Image-Dimensions: " & $width & 'x' & $height & "\n\n")
+    s.encode(width, height, offx, offy, cropw, halfdump, bgcolor, palette)
+
+main()