about summary refs log blame commit diff stats
path: root/adapter/img/sixel.nim
blob: a668449c227a2ef33b6636c9454d3442a386c8ef (plain) (tree)
1
2
3
4
5
6
7
8
9


                                                              


                                                               


                                                                      
 


                                                                     
 


                                                                    
 



                                                                   
 
                    

                  
                

                   
                   



                    

                                       
 
                                                   
            

         
                 

                 
                                         
                                


                                
 








                                              


                               






                   









                                                        
                                                                              

                                                                           
               
                        
                   


                             
                         



                                                   
             



                       
       
                        

                       

                                                   












                                                          
                  
             
                                
 
                                              




                                      




               
                               
                    




                         
           
              






                                                           

       

                                                          
                                  






                                                                  
                    

                    


                                                                  




                                             
                                
                      
                     
          
                              

             






                                                           
 
                                                                              
                                       
                    
                                                                                

                                                                
                    
                       

                                                             



              
                                       




                       
                                                                           

                           
                                 
                    
                          
                             






                                                               
                              

                 
              

              
                                                                     
                                
                    




                                                                        
                      







                                                                        



                                                                 
                          
             
                                       
                             

                                         












                                                                       
                           
                      

                             
                              


                                      
                         
                      
             
                                    
 


                                                                     



                                       
                                                             



                                                             
                         
 


                                                    

                                                                        
                              



                         
         
 
    


                        



                     
              
                                                                      
                    


                                             
 

                                                       

                       


                          




                              
             









                           
                                


                         
 
                                                                                
                            

                             


                                                

                    


                                                       


                    
                                                    
 

                                                                     
                             

                                               
           


                                                                       
                              







                                                  
                                         





                                
                        
                          
                      
               
                     

                                           

                                      
   
                                            
                                             
               
                                
                         
             
                
                             
                     
                

                          
                                     
                           
                            





                                                    
                     
                  
                            
                                              
                          
                                        
                                  
                               
                       


                                
                                   













                                                                    
                



                                                                      
                                   
                                   


                             
                   
                                  
              
                
                                         
           

                 



                                         
            
                          
              
                           
                                   
                
                       
                                                                             
 
                                                              

                      
                                                                         


                                              
                                                                         


                                 
                                        

                                
                                                                        
              





                        
                  
                    
                                                     


                                    
                                               
                            
                                            


                                                 
                                                                               





                                                 
                                                                            
                            


                                                 
                                                                            
                            

                   
                     





                        
                                 
                       
                          
                 
                                         

                                      
                                                                               

                                                        

                                                                        
                   

      
# 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. If that isn't given, it's set
# according to Cha-Image-Quality.
#
# The encoder also has a "half-dump" mode, where a binary lookup table
# is appended to the file end to allow vertical cropping in ~constant
# time.
#
# This table is an array of 32-bit big-endian integers indicating the
# start index of every sixel, and finally a 32-bit big-endian integer
# indicating the number of sixels in the image.
#
# Warning: we intentionally leak the final octree. Be careful if you
# want to integrate this module into a larger program. Deallocation
# would (currently) look like:
#
# * Free the leaves first, since they might have been inserted more
#   than once (iterate over "nodes" seq)
# * Recurse to free the parent nodes (start from root, dealloc each
#   node where idx == -1)

import std/algorithm
import std/options
import std/os
import std/posix
import std/strutils

import io/dynstream
import types/color
import utils/sandbox
import utils/twtstr

proc puts(os: PosixStream; s: string) =
  os.sendDataLoop(s)

proc die(os: PosixStream; s: string) {.noreturn.} =
  os.puts(s)
  quit(1)

const DCS = "\eP"
const ST = "\e\\"

proc putU32BE(s: var string; n: uint32) =
  s &= char((n shr 24) and 0xFF)
  s &= char((n shr 16) and 0xFF)
  s &= char((n shr 8) and 0xFF)
  s &= char(n and 0xFF)

type
  Node = ptr NodeObj

  NodeObj = object
    idx: int # -1: parent, anything else: leaf
    u: NodeUnion

  NodeUnion {.union.} = object
    leaf: NodeLeaf
    children: NodeChildren

  NodeChildren = array[8, Node]

  NodeLeaf = object
    c: RGBColor
    n: uint32
    r: uint32
    g: uint32
    b: uint32

proc getIdx(c: RGBColor; level: int): uint8 {.inline.} =
  let sl = 7 - level
  let idx = (((c.r shr sl) and 1) shl 2) or
    (((c.g shr sl) and 1) shl 1) or
    (c.b shr sl) and 1
  return idx

type TrimMap = array[7, seq[Node]]

proc insert(root: var NodeChildren; c: RGBColor; trimMap: var TrimMap): uint =
  # max level is 7, because we only have ~6.5 bits (0..100, inclusive)
  # (it *is* 0-indexed, but one extra level is needed for the final leaves)
  var level = 0
  var parent = addr root
  var split = false
  while true:
    assert level < 8
    let idx = c.getIdx(level)
    let old = parent[idx]
    if old == nil:
      let node = cast[Node](alloc(sizeof(NodeObj)))
      node.idx = 0
      node.u.leaf = NodeLeaf(
        c: c,
        n: 1,
        r: uint32(c.r),
        g: uint32(c.g),
        b: uint32(c.b)
      )
      parent[idx] = node
      return 1
    elif old.idx != -1:
      # split just once with identical colors
      if level == 7 or split and old.u.leaf.c == c:
        inc old.u.leaf.n
        old.u.leaf.r += uint32(c.r)
        old.u.leaf.g += uint32(c.g)
        old.u.leaf.b += uint32(c.b)
        return 0
      let oc = old.u.leaf.c
      let child = cast[Node](alloc(sizeof(NodeObj)))
      child.idx = 0
      child.u.leaf = old.u.leaf
      old.idx = -1
      zeroMem(addr old.u.children, sizeof(old.u.children))
      old.u.children[oc.getIdx(level + 1)] = child
      trimMap[level].add(old)
      split = true
    inc level
    parent = addr old.u.children

proc trim(trimMap: var TrimMap; K: var uint) =
  var node: Node = nil
  for i in countdown(trimMap.high, 0):
    if trimMap[i].len > 0:
      node = trimMap[i].pop()
      break
  var r = 0u32
  var g = 0u32
  var b = 0u32
  var n = 0u32
  var k = K + 1
  for child in node.u.children:
    if child != nil:
      r += child.u.leaf.r
      g += child.u.leaf.g
      b += child.u.leaf.b
      n += child.u.leaf.n
      dealloc(child)
      dec k
  node.idx = 0
  node.u.leaf = NodeLeaf(
    c: rgb(uint8(r div n), uint8(g div n), uint8(b div n)),
    r: r,
    g: g,
    b: b,
    n: n
  )
  K = k

proc quantize(img: openArray[RGBAColorBE]; outk: var uint;
    outTransparent: var bool): NodeChildren =
  var root = default(NodeChildren)
  if outk <= 2: # monochrome; not much we can do with an octree...
    root[0] = cast[Node](alloc0(sizeof(NodeObj)))
    root[0].u.leaf.c = rgb(0, 0, 0)
    root[7] = cast[Node](alloc0(sizeof(NodeObj)))
    root[7].u.leaf.c = rgb(100, 100, 100)
    outk = 2
    return root
  # number of leaves
  let palette = outk
  var K = 0u
  # map of non-leaves for each level.
  # (note: somewhat confusingly, this actually starts at level 1.)
  var trimMap: array[7, seq[Node]]
  var transparent = false
  for c0 in img:
    let c0 = c0.toARGBColor()
    transparent = transparent or c0.a != 255
    let c = RGBColor(uint32(c0).fastmul(100))
    K += root.insert(c, trimMap)
    while K > palette:
      trimMap.trim(K)
  outk = K
  outTransparent = transparent
  return root

proc flatten(children: NodeChildren; cols: var seq[Node]) =
  for node in children:
    if node != nil:
      if node.idx != -1:
        cols.add(node)
      else:
        node.u.children.flatten(cols)

proc flatten(root: NodeChildren; outs: var string; palette: uint): seq[Node] =
  var cols = newSeqOfCap[Node](palette)
  root.flatten(cols)
  # try to set the most common colors as the smallest numbers (so we write less)
  cols.sort(proc(a, b: Node): int = cmp(a.u.leaf.n, b.u.leaf.n),
    order = Descending)
  for n, it in cols:
    let c = it.u.leaf.c
    # 2 is RGB
    outs &= '#' & $n & ";2;" & $c.r & ';' & $c.g & ';' & $c.b
    it.idx = n
  return cols

type
  DitherDiff = tuple[a, r, g, b: int32]

  Dither = object
    d1: seq[DitherDiff]
    d2: seq[DitherDiff]

proc getColor(nodes: seq[Node]; c: ARGBColor; diff: var DitherDiff): Node =
  var child: Node = nil
  var minDist = uint32.high
  var mdiff = default(DitherDiff)
  for node in nodes:
    let ic = node.u.leaf.c
    let ad = int32(c.a) - 100
    let rd = int32(c.r) - int32(ic.r)
    let gd = int32(c.g) - int32(ic.g)
    let bd = int32(c.b) - int32(ic.b)
    let d = uint32(abs(rd)) + uint32(abs(gd)) + uint32(abs(bd))
    if d < minDist:
      minDist = d
      child = node
      mdiff = (ad, rd, gd, bd)
      if ic == c:
        break
  diff = mdiff
  return child

proc getColor(root: var NodeChildren; c: ARGBColor; nodes: seq[Node];
    diff: var DitherDiff): int =
  if nodes.len < 64:
    # Octree-based nearest neighbor search creates really ugly artifacts
    # with a low amount of colors, which is exactly the case where
    # linear search is still acceptable.
    #
    # 64 is the first power of 2 that gives OK results on my test images
    # with the octree.
    #
    # (In practice, I assume no sane terminal would pick a palette (> 2)
    # that isn't a multiple of 4, so really only 16 is relevant here.
    # Even that is quite rare, unless you misconfigure XTerm - or
    # have a hardware terminal, but those didn't have private color
    # registers in the first place. I do like the aesthetics, though;
    # would be a shame if it didn't work :P)
    return nodes.getColor(c, diff).idx
  # Find a matching color in the octree.
  # Not as accurate as a linear search, but good enough (and much
  # faster) for palettes that reach this path.
  var level = 0
  var children = addr root
  while true:
    let idx = RGBColor(c).getIdx(level)
    let child = children[idx]
    if child == nil:
      let child = nodes.getColor(c, diff)
      # No child found in this corner of the octree. This is caused by
      # dithering.
      # Allocate at least 4 ancestors, so that other colors with the
      # same initial bits don't end up using something wildly different
      # than the dither intended.
      while level < 4:
        let idx = RGBColor(c).getIdx(level)
        let node = cast[Node](alloc0(sizeof(NodeObj)))
        node.idx = -1
        children[idx] = node
        children = addr node.u.children
        inc level
      let idx = RGBColor(c).getIdx(level)
      children[idx] = child
      return child.idx
    if child.idx != -1:
      let ic = child.u.leaf.c
      let a = int32(c.a) - 100
      let r = int32(c.r) - int32(ic.r)
      let g = int32(c.g) - int32(ic.g)
      let b = int32(c.b) - int32(ic.b)
      diff = (a, r, g, b)
      return child.idx
    inc level
    children = addr child.u.children

proc correctDither(c: ARGBColor; x: int; dither: Dither): ARGBColor =
  let (ad, rd, gd, bd) = dither.d1[x + 1]
  let pa = (uint32(c) shr 20) and 0xFF0
  let pr = (uint32(c) shr 12) and 0xFF0
  let pg = (uint32(c) shr 4) and 0xFF0
  let pb = (uint32(c) shl 4) and 0xFF0
  {.push overflowChecks: off.}
  let a = uint8(uint32(clamp(int32(pa) + ad, 0, 1600)) shr 4)
  let r = uint8(uint32(clamp(int32(pr) + rd, 0, 1600)) shr 4)
  let g = uint8(uint32(clamp(int32(pg) + gd, 0, 1600)) shr 4)
  let b = uint8(uint32(clamp(int32(pb) + bd, 0, 1600)) shr 4)
  {.pop.}
  return rgba(r, g, b, a)

proc fs(dither: var Dither; x: int; d: DitherDiff) =
  let x = x + 1 # skip first bounds check
  template at(p, mul: untyped) =
    var (ad, rd, gd, bd) = p
    p = (ad + d.a * mul, rd + d.r * mul, gd + d.g * mul, bd + d.b * mul)
  {.push overflowChecks: off.}
  at(dither.d1[x + 1], 7)
  at(dither.d2[x - 1], 3)
  at(dither.d2[x], 5)
  at(dither.d2[x + 1], 1)
  {.pop.}

type
  SixelBand = object
    head: ptr SixelChunk
    tail: ptr SixelChunk

  SixelChunk = object
    x: int
    c: int
    nrow: uint
    # data is binary 0..63; compressSixel creates the final ASCII form
    data: seq[uint8]
    # linked list for chaining together bands
    # (yes, this *is* faster than a seq.)
    next: ptr SixelChunk

proc compressSixel(outs: var string; band: SixelBand) =
  var x = 0
  var chunk = band.head
  while chunk != nil:
    outs &= '#' & $chunk.c
    var n = chunk.x - x
    var c = '?'
    for u in chunk.data:
      let cc = char(u + 0x3F)
      if c != cc:
        if n > 3:
          outs &= '!' & $n & c
        else:
          for i in 0 ..< n:
            outs &= c
        c = cc
        n = 0
      inc n
    if n > 3:
      outs &= '!' & $n & c
    else:
      for i in 0 ..< n:
        outs &= c
    x = chunk.x + chunk.data.len
    let next = chunk.next
    chunk.next = nil
    chunk = next

proc createBands(bands: var seq[SixelBand]; activeChunks: seq[ptr SixelChunk]) =
  for chunk in activeChunks:
    var found = false
    for band in bands.mitems:
      if band.head.x > chunk.x + chunk.data.len:
        chunk.next = band.head
        band.head = chunk
        found = true
        break
      elif band.tail.x + band.tail.data.len <= chunk.x:
        band.tail.next = chunk
        band.tail = chunk
        found = true
        break
    if not found:
      bands.add(SixelBand(head: chunk, tail: chunk))

proc encode(os: PosixStream; img: openArray[RGBAColorBE];
    width, height, offx, offy, cropw, palette: int; halfdump: bool) =
  var palette = uint(palette)
  var transparent = false
  var root = img.quantize(palette, transparent)
  # prelude
  var outs = "Cha-Image-Sixel-Transparent: " & $int(transparent) & "\n"
  outs &= "Cha-Image-Sixel-Prelude-Len: "
  const PreludePad = "666 666 666"
  let preludeLenPos = outs.len
  outs &= PreludePad & "\n\n"
  let dcsPos = outs.len
  outs &= DCS
  if transparent:
    outs &= "0;1" # P2=1 -> image has transparency
  outs &= 'q'
  # set raster attributes
  outs &= "\"1;1;" & $width & ';' & $height
  let nodes = root.flatten(outs, palette)
  # prepend prelude size
  var ps = $(outs.len - dcsPos)
  while ps.len < PreludePad.len:
    ps &= ' '
  for i, c in ps:
    outs[preludeLenPos + i] = c
  let L = width * height
  let realw = cropw - offx
  var n = offy * width
  var ymap = ""
  var totalLen = 0u32
  # add +2 so we don't have to bounds check
  var dither = Dither(
    d1: newSeq[DitherDiff](realw + 2),
    d2: newSeq[DitherDiff](realw + 2)
  )
  var chunkMap = newSeq[SixelChunk](palette)
  var activeChunks: seq[ptr SixelChunk] = @[]
  var nrow = 1u
  # buffer to 64k, just because.
  const MaxBuffer = 65536
  while true:
    if halfdump:
      ymap.putU32BE(totalLen)
    for i in 0 ..< 6:
      if n >= L:
        break
      let mask = 1u8 shl i
      var chunk: ptr SixelChunk = nil
      for j in 0 ..< realw:
        let m = n + offx + j
        let c0 = img[m].toARGBColor()
        let c1 = ARGBColor(uint32(c0).fastmul1(100))
        let c2 = c1.correctDither(j, dither)
        if c2.a < 50: # transparent
          let diff = (int32(c2.a), 0i32, 0i32, 0i32)
          dither.fs(j, diff)
          chunk = nil
          continue
        var diff: DitherDiff
        let c = root.getColor(c2, nodes, diff)
        dither.fs(j, diff)
        if chunk == nil or chunk.c != c:
          chunk = addr chunkMap[c]
          if chunk.nrow < nrow:
            chunk.c = c
            chunk.nrow = nrow
            chunk.x = j
            chunk.data.setLen(0)
            activeChunks.add(chunk)
          elif chunk.x > j:
            let diff = chunk.x - j
            chunk.x = j
            let olen = chunk.data.len
            chunk.data.setLen(olen + diff)
            moveMem(addr chunk.data[diff], addr chunk.data[0], olen)
            zeroMem(addr chunk.data[0], diff)
          elif chunk.data.len < j - chunk.x:
            chunk.data.setLen(j - chunk.x)
        let k = j - chunk.x
        if k < chunk.data.len:
          chunk.data[k] = chunk.data[k] or mask
        else:
          chunk.data.add(mask)
      n += width
      var tmp = move(dither.d1)
      dither.d1 = move(dither.d2)
      dither.d2 = move(tmp)
      zeroMem(addr dither.d2[0], dither.d2.len * sizeof(dither.d2[0]))
    var bands: seq[SixelBand] = @[]
    bands.createBands(activeChunks)
    let olen = outs.len
    for i in 0 ..< bands.len:
      if i > 0:
        outs &= '$'
      outs.compressSixel(bands[i])
    if n >= L:
      outs &= ST
      totalLen += uint32(outs.len - olen)
      break
    else:
      outs &= '-'
      totalLen += uint32(outs.len - olen)
      if outs.len >= MaxBuffer:
        os.sendDataLoop(outs)
        outs.setLen(0)
    inc nrow
    activeChunks.setLen(0)
  if halfdump:
    ymap.putU32BE(totalLen)
    ymap.putU32BE(uint32(ymap.len))
    outs &= ymap
  os.sendDataLoop(outs)
  # Note: we leave octree deallocation to the OS. See the header for details.

proc parseDimensions(os: PosixStream; s: string): (int, int) =
  let s = s.split('x')
  if s.len != 2:
    os.die("Cha-Control: ConnectionError InternalError wrong dimensions")
  let w = parseUInt32(s[0], allowSign = false)
  let h = parseUInt32(s[1], allowSign = false)
  if w.isNone or w.isNone:
    os.die("Cha-Control: ConnectionError InternalError wrong dimensions")
  return (int(w.get), int(h.get))

proc main() =
  let os = newPosixStream(STDOUT_FILENO)
  case getEnv("MAPPED_URI_PATH")
  of "decode":
    os.die("Cha-Control: ConnectionError InternalError not implemented")
  of "encode":
    var width = 0
    var height = 0
    var offx = 0
    var offy = 0
    var halfdump = false
    var palette = -1
    var cropw = -1
    var quality = -1
    for hdr in getEnv("REQUEST_HEADERS").split('\n'):
      let s = hdr.after(':').strip()
      case hdr.until(':')
      of "Cha-Image-Dimensions":
        (width, height) = os.parseDimensions(s)
      of "Cha-Image-Offset":
        (offx, offy) = os.parseDimensions(s)
      of "Cha-Image-Crop-Width":
        let q = parseUInt32(s, allowSign = false)
        if q.isNone:
          os.die("Cha-Control: ConnectionError InternalError wrong crop width")
        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:
          os.die("Cha-Control: ConnectionError InternalError wrong palette")
        palette = int(q.get)
      of "Cha-Image-Quality":
        let q = parseUInt16(s, allowSign = false)
        if q.isNone:
          os.die("Cha-Control: ConnectionError InternalError wrong quality")
        quality = int(q.get)
    if cropw == -1:
      cropw = width
    if palette == -1:
      if quality < 30:
        palette = 16
      elif quality < 70:
        palette = 256
      else:
        palette = 1024
    if width == 0 or height == 0:
      quit(0) # done...
    let n = width * height
    let L = n * 4
    let ps = newPosixStream(STDIN_FILENO)
    let src = ps.recvDataLoopOrMmap(L)
    if src == nil:
      os.die("Cha-Control: ConnectionError InternalError failed to read input")
    enterNetworkSandbox() # don't swallow stat
    let p = cast[ptr UncheckedArray[RGBAColorBE]](src.p)
    os.encode(p.toOpenArray(0, n - 1), width, height, offx, offy, cropw,
      palette, halfdump)
    deallocMem(src)

main()