about summary refs log tree commit diff stats
path: root/src
diff options
context:
space:
mode:
authorbptato <nincsnevem662@gmail.com>2024-04-25 01:06:43 +0200
committerbptato <nincsnevem662@gmail.com>2024-04-25 01:20:58 +0200
commit14b871eb7eaf329b67b71385597f114f8782318a (patch)
treeac7b4655124b4579ad27d56116d9a2dee63cd31e /src
parent62944ac7abc6e37475739a1667ed5a0240fedf66 (diff)
downloadchawan-14b871eb7eaf329b67b71385597f114f8782318a.tar.gz
Initial image support
* png: add missing filters, various decoder fixes
* term: fix kitty response interpretation, add support for kitty image
  detection
* buffer, pager: initial image display support

Emphasis on "initial"; it only "works" with kitty output and PNG input.
Also, it's excruciatingly slow, and repaints images way too often.

Left undocumented intentionally it for now, until it actually becomes
useful.  In the meantime, adventurous users can find out themselves why:

[[siteconf]]
url = "https://.*"
images = true
Diffstat (limited to 'src')
-rw-r--r--src/config/config.nim4
-rw-r--r--src/css/cascade.nim6
-rw-r--r--src/html/dom.nim2
-rw-r--r--src/img/png.nim67
-rw-r--r--src/io/bufreader.nim5
-rw-r--r--src/io/bufwriter.nim8
-rw-r--r--src/layout/box.nim5
-rw-r--r--src/layout/engine.nim26
-rw-r--r--src/layout/renderdocument.nim39
-rw-r--r--src/local/container.nim9
-rw-r--r--src/local/pager.nim5
-rw-r--r--src/local/term.nim97
-rw-r--r--src/server/buffer.nim22
13 files changed, 233 insertions, 62 deletions
diff --git a/src/config/config.nim b/src/config/config.nim
index 1ac0393e..6611b4ef 100644
--- a/src/config/config.nim
+++ b/src/config/config.nim
@@ -33,6 +33,9 @@ type
 
   FormatMode* = set[FormatFlags]
 
+  ImageMode* = enum
+    imNone = "none", imSixel = "sixel", imKitty = "kitty"
+
   ChaPathResolved* = distinct string
 
   ActionMap = object
@@ -104,6 +107,7 @@ type
     color_mode* {.jsgetset.}: Option[ColorMode]
     format_mode* {.jsgetset.}: Option[FormatMode]
     no_format_mode* {.jsgetset.}: FormatMode
+    image_mode* {.jsgetset.}: Option[ImageMode]
     emulate_overline* {.jsgetset.}: bool
     alt_screen* {.jsgetset.}: Option[bool]
     highlight_color* {.jsgetset.}: RGBAColor
diff --git a/src/css/cascade.nim b/src/css/cascade.nim
index 9c3e5803..b2d007f2 100644
--- a/src/css/cascade.nim
+++ b/src/css/cascade.nim
@@ -380,7 +380,11 @@ proc applyRulesFrameInvalid(frame: CascadeFrame; ua, user: CSSStylesheet;
         styledParent.children.add(styledText)
     of peImage:
       let src = Element(styledParent.node).attr(satSrc)
-      let content = CSSContent(t: ContentImage, s: src)
+      let content = CSSContent(
+        t: ContentImage,
+        s: src,
+        bmp: HTMLImageElement(styledParent.node).bitmap
+      )
       let styledText = styledParent.newStyledReplacement(content)
       styledText.pseudo = pseudo
       styledParent.children.add(styledText)
diff --git a/src/html/dom.nim b/src/html/dom.nim
index 32e98824..76ed9b70 100644
--- a/src/html/dom.nim
+++ b/src/html/dom.nim
@@ -2849,7 +2849,7 @@ proc loadResource(window: Window; image: HTMLImageElement) =
           return
         let pngData = pngData.get
         let buffer = cast[ptr UncheckedArray[uint8]](pngData.buffer)
-        let high = int(pngData.size - 1)
+        let high = int(pngData.size) - 1
         image.bitmap = fromPNG(toOpenArray(buffer, 0, high))
       )
     window.loadingResourcePromises.add(p)
diff --git a/src/img/png.nim b/src/img/png.nim
index 7f4d8493..bd7e6206 100644
--- a/src/img/png.nim
+++ b/src/img/png.nim
@@ -132,8 +132,7 @@ func spp(reader: PNGReader): int =
   of pcTrueColorWithAlpha: return 4
 
 func scanlen(reader: PNGReader): int {.inline.} =
-  let w = reader.width + 1
-  return (w * reader.spp * int(reader.bitDepth) + 7) div 8
+  return 1 + (reader.width * reader.spp * int(reader.bitDepth) + 7) div 8
 
 proc handleError(reader: var PNGReader; msg: string) =
   #TODO proper error handling?
@@ -258,28 +257,55 @@ proc readtRNS(reader: var PNGReader) =
     for i in 0 ..< reader.palette.len:
       reader.palette[i].a = reader.readU8()
 
+func paethPredictor(a, b, c: uint16): uint16 =
+  let pa0 = int(b) - int(c)
+  let pb0 = int(a) - int(c)
+  let pa = abs(pa0)
+  let pb = abs(pb0)
+  let pc = abs(pa0 + pb0)
+  return if pa <= pb and pa <= pc: a elif pb <= pc: b else: c
+
 proc unfilter(reader: var PNGReader; irow: openArray[uint8]; bpp: int) =
   # none, sub, up -> replace uprow directly
   # average, paeth -> copy to temp array, then replace uprow
-  let fil = irow[0]
-  let w = reader.width
-  case fil
+  case irow[0]
   of 0u8: # none
-    copyMem(addr reader.uprow[0], unsafeAddr irow[1], w)
+    copyMem(addr reader.uprow[0], unsafeAddr irow[1], irow.len)
   of 1u8: # sub
     for i in 1 ..< irow.len:
       let j = i - 1 # skip filter byte
-      reader.uprow[j] = irow[i]
-      if j - bpp >= 0:
-        reader.uprow[j] += irow[j - bpp]
+      let aidx = j - bpp
+      let x = uint16(irow[i])
+      let a = if aidx >= 0: uint16(reader.uprow[aidx]) else: 0u16
+      reader.uprow[j] = uint8((x + a) and 0xFF)
   of 2u8: # up
     for i in 1 ..< irow.len:
       let j = i - 1 # skip filter byte
-      reader.uprow[j] += irow[i]
+      let x = uint16(irow[i])
+      let b = uint16(reader.uprow[j])
+      reader.uprow[j] = uint8((x + b) and 0xFF)
   of 3u8: # average
-    reader.err "average not implemented yet"
+    for i in 1 ..< irow.len:
+      let j = i - 1 # skip filter byte
+      let aidx = j - bpp
+      let x = uint16(irow[i])
+      let a = if aidx >= 0: uint16(reader.uprow[aidx]) else: 0u16
+      let b = uint16(reader.uprow[j])
+      reader.uprow[j] = uint8((x + (a + b) div 2) and 0xFF)
   of 4u8: # paeth
-    reader.err "paeth not implemented yet"
+    var cmap: array[8, uint16] # max bpp is 16 bit with true color = 4 * 2
+    var k = 0
+    for i in 1 ..< irow.len:
+      let j = i - 1 # skip filter byte
+      let aidx = j - bpp
+      let x = uint16(irow[i])
+      let a = if aidx >= 0: uint16(reader.uprow[aidx]) else: 0u16
+      let b = uint16(reader.uprow[j])
+      let kk = k mod bpp
+      let c = cmap[kk]
+      cmap[kk] = b
+      reader.uprow[j] = uint8((x + paethPredictor(a, b, c)) and 0xFF)
+      inc k
   else:
     reader.err "got invalid filter"
 
@@ -340,11 +366,16 @@ proc writepxs(reader: var PNGReader; crow: var openArray[RGBAColor]) =
       crow[x] = rgba(n, n, n, a)
   of pcTrueColorWithAlpha:
     let step = int(reader.bitDepth) div 8
+    var i = 0
     for x in 0 ..< crow.len:
-      let r = reader.uprow[x * step]
-      let g = reader.uprow[(x + 1) * step]
-      let b = reader.uprow[(x + 2) * step]
-      let a = reader.uprow[(x + 3) * step]
+      let r = reader.uprow[i]
+      i += step
+      let g = reader.uprow[i]
+      i += step
+      let b = reader.uprow[i]
+      i += step
+      let a = reader.uprow[i]
+      i += step
       crow[x] = rgba(r, g, b, a)
 
 proc readPLTE(reader: var PNGReader) =
@@ -404,10 +435,10 @@ proc readIDAT(reader: var PNGReader) =
   for y in reader.atline ..< maxline:
     let yi = y * sl
     assert yi + sl - 1 < reader.idatAt
-    reader.unfilter(toOpenArray(reader.idatBuf, yi, yi + sl - 1), bpp)
+    reader.unfilter(reader.idatBuf.toOpenArray(yi, yi + sl - 1), bpp)
     if unlikely(reader.bmp == nil): return
     let yj = y * reader.width
-    reader.writepxs(toOpenArray(bmp.px, yj, yj + reader.width - 1))
+    reader.writepxs(bmp.px.toOpenArray(yj, yj + reader.width - 1))
 
 proc readIEND(reader: var PNGReader) =
   if reader.i < reader.limit:
diff --git a/src/io/bufreader.nim b/src/io/bufreader.nim
index 6de269ac..3ee36208 100644
--- a/src/io/bufreader.nim
+++ b/src/io/bufreader.nim
@@ -6,6 +6,7 @@ import std/tables
 
 import io/dynstream
 import types/blob
+import types/color
 import types/formdata
 import types/opt
 import types/url
@@ -53,6 +54,7 @@ proc sread*(reader: var BufferedReader; part: var FormDataEntry)
 proc sread*(reader: var BufferedReader; blob: var Blob)
 proc sread*[T](reader: var BufferedReader; o: var Option[T])
 proc sread*[T, E](reader: var BufferedReader; o: var Result[T, E])
+proc sread*(reader: var BufferedReader; c: var RGBAColor) {.inline.}
 
 proc readData(reader: var BufferedReader; buffer: pointer; len: int) =
   assert reader.bufIdx + len <= reader.buffer.len
@@ -198,3 +200,6 @@ proc sread*[T, E](reader: var BufferedReader; o: var Result[T, E]) =
       o.err(e)
     else:
       o.err()
+
+proc sread*(reader: var BufferedReader; c: var RGBAColor) =
+  reader.sread(uint32(c))
diff --git a/src/io/bufwriter.nim b/src/io/bufwriter.nim
index 75da4190..0957d3e8 100644
--- a/src/io/bufwriter.nim
+++ b/src/io/bufwriter.nim
@@ -6,11 +6,11 @@ import std/sets
 import std/tables
 
 import io/dynstream
-
 import types/blob
+import types/color
 import types/formdata
-import types/url
 import types/opt
+import types/url
 
 type BufferedWriter* = object
   stream: DynStream
@@ -77,6 +77,7 @@ proc swrite*(writer: var BufferedWriter; part: FormDataEntry)
 proc swrite*(writer: var BufferedWriter; blob: Blob)
 proc swrite*[T](writer: var BufferedWriter; o: Option[T])
 proc swrite*[T, E](writer: var BufferedWriter; o: Result[T, E])
+proc swrite*(writer: var BufferedWriter; c: RGBAColor) {.inline.}
 
 proc writeData(writer: var BufferedWriter; buffer: pointer; len: int) =
   let targetLen = writer.bufLen + len
@@ -181,3 +182,6 @@ proc swrite*[T, E](writer: var BufferedWriter; o: Result[T, E]) =
   else:
     when not (E is void):
       writer.swrite(o.error)
+
+proc swrite*(writer: var BufferedWriter; c: RGBAColor) =
+  writer.swrite(uint32(c))
diff --git a/src/layout/box.nim b/src/layout/box.nim
index 79f927ef..40628204 100644
--- a/src/layout/box.nim
+++ b/src/layout/box.nim
@@ -1,5 +1,6 @@
 import css/stylednode
 import css/values
+import img/bitmap
 import layout/layoutunit
 
 type
@@ -12,7 +13,7 @@ type
     h*: LayoutUnit
 
   InlineAtomType* = enum
-    iatSpacing, iatWord, iatInlineBlock
+    iatSpacing, iatWord, iatInlineBlock, iatImage
 
   InlineAtom* = ref object
     offset*: Offset
@@ -24,6 +25,8 @@ type
       str*: string
     of iatInlineBlock:
       innerbox*: BlockBox
+    of iatImage:
+      bmp*: Bitmap
 
   RootInlineFragment* = ref object
     # offset relative to parent
diff --git a/src/layout/engine.nim b/src/layout/engine.nim
index 99d205f5..30afb066 100644
--- a/src/layout/engine.nim
+++ b/src/layout/engine.nim
@@ -5,6 +5,7 @@ import std/unicode
 
 import css/stylednode
 import css/values
+import img/bitmap
 import layout/box
 import layout/layoutunit
 import types/winattrs
@@ -68,6 +69,7 @@ type
     text: seq[string]
     newline: bool
     splitType: set[SplitType]
+    bmp: Bitmap
 
   BlockBoxBuilder = ref object of BoxBuilder
     inlinelayout: bool
@@ -1500,8 +1502,20 @@ proc layoutInline(ictx: var InlineContext; box: InlineBoxBuilder):
   if ictx.firstTextFragment == nil:
     ictx.firstTextFragment = fragment
   ictx.lastTextFragment = fragment
-  ictx.layoutText(state, box.text)
-  ictx.layoutChildren(state, box.children)
+  if box.bmp != nil:
+    let iastate = InlineAtomState(
+      vertalign: state.computed{"vertical-align"},
+      baseline: ictx.cellheight
+    )
+    let atom = InlineAtom(
+      t: iatImage,
+      bmp: box.bmp,
+      size: Size(w: int(box.bmp.width), h: int(box.bmp.height)) #TODO overflow
+    )
+    discard ictx.addAtom(state, iastate, atom)
+  else:
+    ictx.layoutText(state, box.text)
+    ictx.layoutChildren(state, box.children)
   if stSplitEnd in box.splitType:
     let paddingRight = box.computed{"padding-right"}.px(lctx, ictx.space.w)
     ictx.currentLine.size.w += paddingRight
@@ -2856,10 +2870,10 @@ proc generateFromElem(ctx: var InnerBlockContext; styledNode: StyledNode) =
   of DisplayNone: discard
 
 proc generateAnonymousInlineText(ctx: var InnerBlockContext; text: string;
-    styledNode: StyledNode) =
+    styledNode: StyledNode; bmp: Bitmap = nil) =
   if ctx.iroot == nil:
     let computed = styledNode.computed.inheritProperties()
-    ctx.ibox = InlineBoxBuilder(computed: computed, node: styledNode)
+    ctx.ibox = InlineBoxBuilder(computed: computed, node: styledNode, bmp: bmp)
     if ctx.inlineStack.len > 0:
       let iparent = ctx.reconstructInlineParents()
       iparent.children.add(ctx.ibox)
@@ -2900,7 +2914,7 @@ proc generateReplacement(ctx: var InnerBlockContext;
     ctx.generateAnonymousInlineText(child.content.s, parent)
   of ContentImage:
     #TODO idk
-    ctx.generateAnonymousInlineText("[img]", parent)
+    ctx.generateAnonymousInlineText("[img]", parent, child.content.bmp)
   of ContentVideo:
     ctx.generateAnonymousInlineText("[video]", parent)
   of ContentAudio:
@@ -3116,7 +3130,7 @@ proc generateTableBox(styledNode: StyledNode; lctx: LayoutState;
   box.generateTableChildWrappers()
   return box
 
-proc renderLayout*(root: StyledNode; attrsp: ptr WindowAttributes): BlockBox =
+proc layout*(root: StyledNode; attrsp: ptr WindowAttributes): BlockBox =
   let space = AvailableSpace(
     w: stretch(attrsp[].width_px),
     h: stretch(attrsp[].height_px)
diff --git a/src/layout/renderdocument.nim b/src/layout/renderdocument.nim
index fe7b92cb..e57a6d2c 100644
--- a/src/layout/renderdocument.nim
+++ b/src/layout/renderdocument.nim
@@ -3,12 +3,13 @@ import std/unicode
 
 import css/stylednode
 import css/values
+import img/bitmap
 import layout/box
 import layout/engine
 import layout/layoutunit
-import types/winattrs
 import types/cell
 import types/color
+import types/winattrs
 import utils/strwidth
 
 type
@@ -208,12 +209,19 @@ proc setText(grid: var FlexibleGrid; linestr: string; x, y: int; format: Format;
   assert grid[y].formats[fi].pos <= nx
   # That's it!
 
-type RenderState = object
-  # Position of the absolute positioning containing block:
-  # https://drafts.csswg.org/css-position/#absolute-positioning-containing-block
-  absolutePos: seq[Offset]
-  bgcolor: CellColor
-  attrsp: ptr WindowAttributes
+type
+  PosBitmap* = ref object
+    x*: int
+    y*: int
+    bmp*: Bitmap
+
+  RenderState = object
+    # Position of the absolute positioning containing block:
+    # https://drafts.csswg.org/css-position/#absolute-positioning-containing-block
+    absolutePos: seq[Offset]
+    bgcolor: CellColor
+    attrsp: ptr WindowAttributes
+    images: seq[PosBitmap]
 
 template attrs(state: RenderState): WindowAttributes =
   state.attrsp[]
@@ -361,6 +369,15 @@ proc renderInlineFragment(grid: var FlexibleGrid; state: var RenderState;
         grid.setRowWord(state, atom, offset, format, fragment.node)
       of iatSpacing:
         grid.setSpacing(state, atom, offset, format, fragment.node)
+      of iatImage:
+        state.images.add(PosBitmap(
+          x: (offset.x div state.attrs.ppc).toInt,
+          y: (offset.y div state.attrs.ppl).toInt,
+          bmp: atom.bmp
+        ))
+  else:
+    for child in fragment.children:
+      grid.renderInlineFragment(state, child, offset)
   if fragment.computed{"position"} != PositionStatic:
     if fragment.splitType != {stSplitStart, stSplitEnd}:
       if stSplitStart in fragment.splitType:
@@ -370,8 +387,6 @@ proc renderInlineFragment(grid: var FlexibleGrid; state: var RenderState;
         ))
       if stSplitEnd in fragment.splitType:
         discard state.absolutePos.pop()
-  for child in fragment.children:
-    grid.renderInlineFragment(state, child, offset)
 
 proc renderRootInlineFragment(grid: var FlexibleGrid; state: var RenderState;
     root: RootInlineFragment; offset: Offset) =
@@ -440,7 +455,8 @@ proc renderBlockBox(grid: var FlexibleGrid; state: var RenderState;
         stack.add((box.nested[i], offset))
 
 proc renderDocument*(grid: var FlexibleGrid; bgcolor: var CellColor;
-    styledRoot: StyledNode; attrsp: ptr WindowAttributes) =
+    styledRoot: StyledNode; attrsp: ptr WindowAttributes;
+    images: var seq[PosBitmap]) =
   grid.setLen(0)
   if styledRoot == nil:
     # no HTML element when we run cascade; just clear all lines.
@@ -449,8 +465,9 @@ proc renderDocument*(grid: var FlexibleGrid; bgcolor: var CellColor;
     absolutePos: @[Offset(x: 0, y: 0)],
     attrsp: attrsp
   )
-  let rootBox = renderLayout(styledRoot, attrsp)
+  let rootBox = styledRoot.layout(attrsp)
   grid.renderBlockBox(state, rootBox, Offset(x: 0, y: 0))
   if grid.len == 0:
     grid.addLines(1)
   bgcolor = state.bgcolor
+  images = state.images
diff --git a/src/local/container.nim b/src/local/container.nim
index b88161b8..3a0f1daf 100644
--- a/src/local/container.nim
+++ b/src/local/container.nim
@@ -14,6 +14,7 @@ import io/socketstream
 import js/javascript
 import js/jstypes
 import js/regex
+import layout/renderdocument
 import loader/headers
 import loader/loader
 import loader/request
@@ -25,7 +26,6 @@ import types/cookie
 import types/referrer
 import types/url
 import types/winattrs
-import utils/luwrap
 import utils/mimeguess
 import utils/strwidth
 import utils/twtstr
@@ -152,6 +152,7 @@ type
     cacheFile* {.jsget.}: string
     mainConfig*: Config
     flags*: set[ContainerFlag]
+    images*: seq[PosBitmap]
 
 jsDestructor(Highlight)
 jsDestructor(Container)
@@ -458,7 +459,6 @@ proc requestLines(container: Container): EmptyPromise {.discardable.} =
     container.lineshift = w.a
     for y in 0 ..< min(res.lines.len, w.len):
       container.lines[y] = res.lines[y]
-      container.lines[y].str.mnormalize()
     var isBgNew = container.bgcolor != res.bgcolor
     if isBgNew:
       container.bgcolor = res.bgcolor
@@ -474,6 +474,7 @@ proc requestLines(container: Container): EmptyPromise {.discardable.} =
     let cw = container.fromy ..< container.fromy + container.height
     if w.a in cw or w.b in cw or cw.a in w or cw.b in w or isBgNew:
       container.triggerEvent(cetUpdate)
+    container.images = res.images
   )
 
 proc redraw(container: Container) {.jsfunc.} =
@@ -1387,9 +1388,9 @@ proc onload(container: Container; res: int) =
     container.triggerEvent(cetStatus)
     container.triggerEvent(cetLoaded)
     if cfHasStart notin container.flags and container.url.anchor != "":
-      container.requestLines().then(proc(): Promise[Opt[tuple[x, y: int]]] =
+      container.requestLines().then(proc(): Promise[GotoAnchorResult] =
         return container.iface.gotoAnchor()
-      ).then(proc(res: Opt[tuple[x, y: int]]) =
+      ).then(proc(res: GotoAnchorResult) =
         if res.isSome:
           let res = res.get
           container.setCursorXYCenter(res.x, res.y)
diff --git a/src/local/pager.nim b/src/local/pager.nim
index 28c1face..8e828190 100644
--- a/src/local/pager.nim
+++ b/src/local/pager.nim
@@ -449,6 +449,11 @@ proc draw*(pager: Pager) =
   else:
     pager.term.writeGrid(pager.statusgrid, 0, pager.attrs.height - 1)
   pager.term.outputGrid()
+  if container != nil:
+    pager.term.clearImages()
+    for image in container.images:
+      pager.term.outputImage(image.bmp, image.x - container.fromx,
+        image.y - container.fromy, pager.attrs.width, pager.attrs.height - 1)
   if pager.askpromise != nil:
     pager.term.setCursor(pager.askcursor, pager.attrs.height - 1)
   elif pager.lineedit.isSome:
diff --git a/src/local/term.nim b/src/local/term.nim
index eee53039..0da700f5 100644
--- a/src/local/term.nim
+++ b/src/local/term.nim
@@ -8,7 +8,9 @@ import std/unicode
 
 import bindings/termcap
 import config/config
+import img/bitmap
 import io/posixstream
+import js/base64
 import types/cell
 import types/color
 import types/opt
@@ -64,6 +66,7 @@ type
     attrs*: WindowAttributes
     colormode: ColorMode
     formatmode: FormatMode
+    imagemode: ImageMode
     smcup: bool
     tc: Termcap
     tname: string
@@ -128,6 +131,15 @@ const RMCUP = DECRST(1049)
 const SGRMOUSEBTNON = DECSET(1002, 1006)
 const SGRMOUSEBTNOFF = DECRST(1002, 1006)
 
+# application program command
+
+# This is only used in kitty images, and join()'ing kilobytes of base64
+# is rather inefficient so we don't use a template.
+const APC = "\e_"
+const ST = "\e\\"
+
+const KITTYQUERY = APC & "Gi=1,a=q;" & ST
+
 when not termcap_found:
   const CNORM = DECSET(25)
   const CIVIS = DECRST(25)
@@ -555,15 +567,13 @@ proc applyConfig(term: Terminal) =
   # colors, formatting
   if term.config.display.color_mode.isSome:
     term.colormode = term.config.display.color_mode.get
-  elif term.isatty():
-    let colorterm = getEnv("COLORTERM")
-    if colorterm in ["24bit", "truecolor"]:
-      term.colormode = cmTrueColor
   if term.config.display.format_mode.isSome:
     term.formatmode = term.config.display.format_mode.get
   for fm in FormatFlags:
     if fm in term.config.display.no_format_mode:
       term.formatmode.excl(fm)
+  if term.config.display.image_mode.isSome:
+    term.imagemode = term.config.display.image_mode.get
   if term.isatty():
     if term.config.display.alt_screen.isSome:
       term.smcup = term.config.display.alt_screen.get
@@ -603,6 +613,50 @@ proc outputGrid*(term: Terminal) =
   for i in 0 ..< term.canvas.cells.len:
     term.pcanvas[i] = term.canvas[i]
 
+proc clearImages*(term: Terminal) =
+  if term.imagemode == imKitty:
+    term.write(APC & "Ga=d" & ST)
+
+proc outputImage*(term: Terminal; bmp: Bitmap; x, y, maxw, maxh: int) =
+  case term.imagemode
+  of imNone: discard
+  of imSixel:
+    discard #TODO
+  of imKitty:
+    # max 4096 bytes, base encoded
+    const MaxPixels = ((4096 div 4) * 3) div 3
+    let offx = if x < 0: -(x * term.attrs.ppc) else: 0
+    let offy = if y < 0: -(y * term.attrs.ppl) else: 0
+    let w = int(bmp.width)
+    let h = int(bmp.height)
+    var dispw = w
+    if x + dispw div term.attrs.ppc > maxw:
+      dispw = (maxw - x) * term.attrs.ppc
+    var disph = h
+    if y + disph div term.attrs.ppl > maxh:
+      disph = (maxh - y) * term.attrs.ppl
+    var outs = term.cursorGoto(max(x, 0), max(y, 0))
+    outs &= APC & "Gf=24,m=1,a=T,C=1,s=" & $w & ",v=" & $h &
+      ",x=" & $offx & ",y=" & $offy & ",w=" & $dispw & ",h=" & $disph & ';'
+    var buf = newStringOfCap(MaxPixels * 4)
+    var i = 0
+    # transcode to RGB
+    while i < bmp.px.len: # max is 4096
+      if i > 0 and i mod MaxPixels == 0:
+        outs &= btoa(buf)
+        outs &= ST
+        term.write(outs)
+        buf.setLen(0)
+        outs = APC & "Gm=1;"
+      buf &= char(bmp.px[i].r)
+      buf &= char(bmp.px[i].g)
+      buf &= char(bmp.px[i].b)
+      inc i
+    outs = APC & "Gm=0;"
+    outs &= btoa(buf)
+    outs &= ST
+    term.write(outs)
+
 proc clearCanvas*(term: Terminal) =
   term.cleared = false
 
@@ -665,7 +719,7 @@ when termcap_found:
 
 type
   QueryAttrs = enum
-    qaAnsiColor, qaRGB, qaSixel
+    qaAnsiColor, qaRGB, qaSixel, qaKittyImage
 
   QueryResult = object
     success: bool
@@ -683,6 +737,7 @@ proc queryAttrs(term: Terminal; windowOnly: bool): QueryResult =
     const outs =
       XTGETFG &
       XTGETBG &
+      KITTYQUERY &
       GEOMPIXEL &
       GEOMCELL &
       XTGETTCAP("524742") &
@@ -764,12 +819,15 @@ proc queryAttrs(term: Terminal; windowOnly: bool): QueryResult =
       term.expect ';'
       if term.consume == 'r' and term.consume == 'g' and term.consume == 'b':
         term.expect ':'
-        template eat_color(tc: char): uint8 =
+        var was_esc = false
+        template eat_color(tc: set[char]): uint8 =
           var val = 0u8
           var i = 0
-          while (let c = term.consume; c != tc):
+          var c = char(0)
+          while (c = term.consume; c notin tc):
             let v0 = hexValue(c)
-            if i > 4 or v0 == -1: fail # wat
+            if i > 4 or v0 == -1:
+              fail # wat
             let v = uint8(v0)
             if i == 0: # 1st place
               val = (v shl 4) or v
@@ -777,10 +835,14 @@ proc queryAttrs(term: Terminal; windowOnly: bool): QueryResult =
               val = (val xor 0xF) or v
             # all other places are irrelevant
             inc i
+          was_esc = c == '\e'
           val
-        let r = eat_color '/'
-        let g = eat_color '/'
-        let b = eat_color '\a'
+        let r = eat_color {'/'}
+        let g = eat_color {'/'}
+        let b = eat_color {'\a', '\e'}
+        if was_esc:
+          # we got ST, not BEL; at least kitty does this
+          term.expect '\\'
         if c == '0':
           result.fgcolor = some(rgb(r, g, b))
         else:
@@ -805,7 +867,14 @@ proc queryAttrs(term: Terminal; windowOnly: bool): QueryResult =
         if id == tcapRGB:
           result.attrs.incl(qaRGB)
       else: # 0
-        term.expect '\e' # ST (1)
+        # pure insanity: kitty returns P0, but also +r524742 after. please
+        # make up your mind!
+        term.skip_until '\e' # ST (1)
+      term.expect '\\' # ST (2)
+    of '_': # APC
+      term.expect 'G'
+      result.attrs.incl(qaKittyImage)
+      term.skip_until '\e' # ST (1)
       term.expect '\\' # ST (2)
     else:
       fail
@@ -844,6 +913,10 @@ proc detectTermAttributes(term: Terminal; windowOnly: bool): TermStartResult =
         term.colormode = cmANSI
       if qaRGB in r.attrs:
         term.colormode = cmTrueColor
+      if qaSixel in r.attrs:
+        term.imagemode = imSixel
+      if qaKittyImage in r.attrs:
+        term.imagemode = imKitty
       # just assume the terminal doesn't choke on these.
       term.formatmode = {ffStrike, ffOverline}
       if r.bgcolor.isSome:
diff --git a/src/server/buffer.nim b/src/server/buffer.nim
index 10d1cce0..56ad946f 100644
--- a/src/server/buffer.nim
+++ b/src/server/buffer.nim
@@ -26,6 +26,7 @@ import html/enums
 import html/env
 import html/event
 import html/formdata as formdata_impl
+import img/bitmap
 import io/bufreader
 import io/bufstream
 import io/bufwriter
@@ -91,6 +92,7 @@ type
     ishtml: bool
     firstBufferRead: bool
     lines: FlexibleGrid
+    images: seq[PosBitmap]
     request: Request # source request
     attrs: WindowAttributes
     window: Window
@@ -648,7 +650,9 @@ proc findNextMatch*(buffer: Buffer; regex: Regex; cursorx, cursory: int;
       break
     inc y
 
-proc gotoAnchor*(buffer: Buffer): Opt[tuple[x, y: int]] {.proxy.} =
+type GotoAnchorResult* = Opt[tuple[x, y: int]]
+
+proc gotoAnchor*(buffer: Buffer): GotoAnchorResult {.proxy.} =
   if buffer.document == nil:
     return err()
   let anchor = buffer.document.findAnchor(buffer.url.anchor)
@@ -673,7 +677,8 @@ proc do_reshape(buffer: Buffer) =
     buffer.prevStyled = nil
   let styledRoot = buffer.document.applyStylesheets(uastyle,
     buffer.userstyle, buffer.prevStyled)
-  buffer.lines.renderDocument(buffer.bgcolor, styledRoot, addr buffer.attrs)
+  buffer.lines.renderDocument(buffer.bgcolor, styledRoot, addr buffer.attrs,
+    buffer.images)
   buffer.prevStyled = styledRoot
 
 proc processData0(buffer: Buffer; data: openArray[char]): bool =
@@ -1718,11 +1723,11 @@ proc readCanceled*(buffer: Buffer): bool {.proxy.} =
 proc findAnchor*(buffer: Buffer; anchor: string): bool {.proxy.} =
   return buffer.document != nil and buffer.document.findAnchor(anchor) != nil
 
-type GetLinesResult* = tuple[
-  numLines: int,
-  lines: seq[SimpleFlexibleLine],
+type GetLinesResult* = tuple
+  numLines: int
+  lines: seq[SimpleFlexibleLine]
   bgcolor: CellColor
-]
+  images: seq[PosBitmap]
 
 proc getLines*(buffer: Buffer; w: Slice[int]): GetLinesResult {.proxy.} =
   var w = w
@@ -1736,6 +1741,11 @@ proc getLines*(buffer: Buffer; w: Slice[int]): GetLinesResult {.proxy.} =
     result.lines.add(line)
   result.numLines = buffer.lines.len
   result.bgcolor = buffer.bgcolor
+  if buffer.config.images:
+    for image in buffer.images:
+      if image.y <= w.b and
+          image.y + int(image.bmp.height) div buffer.attrs.ppl >= w.a:
+        result.images.add(image)
 
 proc markURL*(buffer: Buffer; schemes: seq[string]) {.proxy.} =
   if buffer.document == nil or buffer.document.body == nil: