about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorbptato <nincsnevem662@gmail.com>2024-09-01 01:03:50 +0200
committerbptato <nincsnevem662@gmail.com>2024-09-01 01:46:38 +0200
commit55cfd29e961488a8c1ed9eb7801d237d27bc86c7 (patch)
treec74569e15ca72d777eadcfd19a0203cbb76c3e3f
parente9466c4c436f964b53034e28356aa3f5c957a068 (diff)
downloadchawan-55cfd29e961488a8c1ed9eb7801d237d27bc86c7.tar.gz
canvas: move to separate CGI script
* stream: and passFd is now client-based, and accessible for buffers
* Bitmap's width & height is now int, not uint64
* no more non-network Bitmap special case in the pager for canvas

I just shoehorned it into the static image model, so it still doesn't
render changes after page load. But at least now it doesn't crash the
browser.
-rw-r--r--Makefile11
-rw-r--r--adapter/img/canvas.c5
-rw-r--r--adapter/img/canvas.nim141
-rw-r--r--adapter/img/jebp.nim5
-rw-r--r--res/urimethodmap1
-rw-r--r--src/css/cascade.nim16
-rw-r--r--src/css/cssvalues.nim2
-rw-r--r--src/html/dom.nim392
-rw-r--r--src/img/bitmap.nim16
-rw-r--r--src/img/painter.nim112
-rw-r--r--src/img/path.nim2
-rw-r--r--src/io/bufreader.nim36
-rw-r--r--src/io/bufwriter.nim14
-rw-r--r--src/io/promise.nim5
-rw-r--r--src/layout/box.nim4
-rw-r--r--src/layout/engine.nim2
-rw-r--r--src/layout/renderdocument.nim2
-rw-r--r--src/loader/loader.nim24
-rw-r--r--src/loader/response.nim42
-rw-r--r--src/local/container.nim2
-rw-r--r--src/local/pager.nim55
-rw-r--r--src/local/term.nim42
-rw-r--r--src/server/buffer.nim4
23 files changed, 571 insertions, 364 deletions
diff --git a/Makefile b/Makefile
index 369d79e7..c99165b4 100644
--- a/Makefile
+++ b/Makefile
@@ -51,7 +51,8 @@ all: $(OUTDIR_BIN)/cha $(OUTDIR_BIN)/mancha $(OUTDIR_CGI_BIN)/http \
 	$(OUTDIR_CGI_BIN)/cha-finger $(OUTDIR_CGI_BIN)/about \
 	$(OUTDIR_CGI_BIN)/file $(OUTDIR_CGI_BIN)/ftp \
 	$(OUTDIR_CGI_BIN)/man $(OUTDIR_CGI_BIN)/spartan \
-	$(OUTDIR_CGI_BIN)/stbi $(OUTDIR_CGI_BIN)/jebp $(OUTDIR_CGI_BIN)/sixel \
+	$(OUTDIR_CGI_BIN)/stbi $(OUTDIR_CGI_BIN)/jebp \
+	$(OUTDIR_CGI_BIN)/canvas $(OUTDIR_CGI_BIN)/sixel \
 	$(OUTDIR_LIBEXEC)/urldec $(OUTDIR_LIBEXEC)/urlenc \
 	$(OUTDIR_LIBEXEC)/md2html $(OUTDIR_LIBEXEC)/ansi2html
 	ln -sf "$(OUTDIR)/$(TARGET)/bin/cha" cha
@@ -91,6 +92,7 @@ $(OUTDIR_CGI_BIN)/gmifetch: adapter/protocol/gmifetch.c
 	$(CC) $(GMIFETCH_CFLAGS) adapter/protocol/gmifetch.c -o "$(OUTDIR_CGI_BIN)/gmifetch" $(GMIFETCH_LDFLAGS)
 
 twtstr = src/utils/twtstr.nim src/utils/charcategory.nim src/utils/map.nim
+dynstream = src/io/dynstream.nim src/io/serversocket.nim
 $(OUTDIR_CGI_BIN)/man: lib/monoucha/monoucha/jsregex.nim \
 		lib/monoucha/monoucha/libregexp.nim src/types/opt.nim $(twtstr)
 $(OUTDIR_CGI_BIN)/http: adapter/protocol/curlwrap.nim \
@@ -109,6 +111,11 @@ $(OUTDIR_CGI_BIN)/stbi: adapter/img/stbi.nim adapter/img/stb_image.c \
 		adapter/img/stb_image.h src/utils/sandbox.nim
 $(OUTDIR_CGI_BIN)/jebp: adapter/img/jebp.c adapter/img/jebp.h \
 		src/utils/sandbox.nim
+$(OUTDIR_CGI_BIN)/sixel: src/types/color.nim src/utils/sandbox.nim $(twtstr)
+$(OUTDIR_CGI_BIN)/canvas: src/css/cssvalues.nim src/img/bitmap.nim \
+	src/img/painter.nim src/img/path.nim src/io/bufreader.nim \
+	src/types/color.nim src/types/line.nim src/utils/sandbox.nim \
+	$(dynstream)
 $(OUTDIR_LIBEXEC)/urlenc: $(twtstr)
 $(OUTDIR_LIBEXEC)/gopher2html: adapter/gophertypes.nim $(twtstr)
 $(OUTDIR_LIBEXEC)/ansi2html: src/types/color.nim $(twtstr)
@@ -164,7 +171,7 @@ manpages = $(manpages1) $(manpages5)
 .PHONY: manpage
 manpage: $(manpages:%=doc/%)
 
-protocols = http about file ftp gopher gmifetch cha-finger man spartan stbi jebp sixel
+protocols = http about file ftp gopher gmifetch cha-finger man spartan stbi jebp sixel canvas
 converters = gopher2html md2html ansi2html gmi2html
 tools = urlenc
 
diff --git a/adapter/img/canvas.c b/adapter/img/canvas.c
new file mode 100644
index 00000000..9e386ca1
--- /dev/null
+++ b/adapter/img/canvas.c
@@ -0,0 +1,5 @@
+#define STBI_ONLY_PNG
+#define STBI_NO_STDIO
+#define STBI_NO_LINEAR
+#define STB_IMAGE_IMPLEMENTATION
+#include "stb_image.h"
diff --git a/adapter/img/canvas.nim b/adapter/img/canvas.nim
new file mode 100644
index 00000000..2182e1d9
--- /dev/null
+++ b/adapter/img/canvas.nim
@@ -0,0 +1,141 @@
+# Very simple canvas renderer. At the moment, it uses an undocumented binary
+# protocol for reading commands, and renders it whenever stdin is closed.
+# So for now, it can only really render a single frame.
+#
+# It uses unifont for rendering text - currently I just store it as PNG
+# and read it with stbi. (TODO: try switching to a more efficient format
+# like qemacs fbf.)
+
+import std/os
+import std/posix
+import std/strutils
+
+import css/cssvalues
+import img/bitmap
+import img/painter
+import img/path
+import io/bufreader
+import io/dynstream
+import types/color
+import types/line
+import utils/sandbox
+
+{.compile: "canvas.c".}
+
+{.passc: "-I" & currentSourcePath().parentDir().}
+
+{.push header: "stb_image.h".}
+proc stbi_load_from_memory(buffer: ptr uint8; len: cint; x, y, comp: ptr cint;
+  req_comp: cint): ptr uint8
+proc stbi_image_free(retval_from_stbi_load: pointer)
+{.pop.}
+
+const unifont = readFile"res/unifont_jp-15.0.05.png"
+proc loadUnifont(unifont: string): ImageBitmap =
+  var width, height, comp: cint
+  let p = stbi_load_from_memory(cast[ptr uint8](unsafeAddr unifont[0]),
+    cint(unifont.len), addr width, addr height, addr comp, 4)
+  let len = width * height
+  let bitmap = ImageBitmap(
+    px: cast[seq[RGBAColorBE]](newSeqUninitialized[uint32](len)),
+    width: int(width),
+    height: int(height)
+  )
+  copyMem(addr bitmap.px[0], p, len)
+  stbi_image_free(p)
+  return bitmap
+
+proc main() =
+  enterNetworkSandbox()
+  let os = newPosixStream(STDOUT_FILENO)
+  let ps = newPosixStream(STDIN_FILENO)
+  if getEnv("MAPPED_URI_SCHEME") != "img-codec+x-cha-canvas":
+    os.write("Cha-Control: ConnectionError 1 wrong scheme\n")
+    quit(1)
+  case getEnv("MAPPED_URI_PATH")
+  of "decode":
+    let headers = getEnv("REQUEST_HEADERS")
+    for hdr in headers.split('\n'):
+      if hdr.strip() == "Cha-Image-Info-Only: 1":
+        #TODO this is a hack...
+        # basically, we eat & discard all data from the buffer so it gets saved
+        # to a cache file. then, actually render when the pager asks us to
+        # do so.
+        # obviously this is highly sub-optimal; a better solution would be to
+        # leave stdin open & pass down the stream id from the buffer. (but then
+        # you have to save canvas output too, so it doesn't have to be
+        # re-coded, and handle that case in encoders... or implement on-demand
+        # multi-frame output.)
+        os.write("\n")
+        discard ps.recvAll()
+        quit(0)
+    var cmd: PaintCommand
+    var width: int
+    var height: int
+    ps.withPacketReader r:
+      r.sread(cmd)
+      if cmd != pcSetDimensions:
+        os.write("Cha-Control: ConnectionError 1 wrong dimensions\n")
+        quit(1)
+      r.sread(width)
+      r.sread(height)
+    os.write("Cha-Image-Dimensions: " & $width & "x" & $height & "\n\n")
+    let bmp = newBitmap(width, height)
+    var alive = true
+    while alive:
+      try:
+        ps.withPacketReader r:
+          r.sread(cmd)
+          case cmd
+          of pcSetDimensions:
+            alive = false
+          of pcFillRect, pcStrokeRect:
+            var x1, y1, x2, y2: int
+            var color: ARGBColor
+            r.sread(x1)
+            r.sread(y1)
+            r.sread(x2)
+            r.sread(y2)
+            r.sread(color)
+            if cmd == pcFillRect:
+              bmp.fillRect(x1, y1, x2, y2, color)
+            else:
+              bmp.strokeRect(x1, y1, x2, y2, color)
+          of pcFillPath:
+            var lines: PathLines
+            var color: ARGBColor
+            var fillRule: CanvasFillRule
+            r.sread(lines)
+            r.sread(color)
+            r.sread(fillRule)
+            bmp.fillPath(lines, color, fillRule)
+          of pcStrokePath:
+            var lines: seq[Line]
+            var color: ARGBColor
+            r.sread(lines)
+            r.sread(color)
+            bmp.strokePath(lines, color)
+          of pcFillText, pcStrokeText:
+            if unifontBitmap == nil:
+              unifontBitmap = loadUnifont(unifont)
+            var text: string
+            var x, y: float64
+            var color: ARGBColor
+            var align: CSSTextAlign
+            r.sread(text)
+            r.sread(x)
+            r.sread(y)
+            r.sread(color)
+            r.sread(align)
+            if cmd == pcFillText:
+              bmp.fillText(text, x, y, color, align)
+            else:
+              bmp.strokeText(text, x, y, color, align)
+      except EOFError, ErrorConnectionReset, ErrorBrokenPipe:
+        break
+    os.sendDataLoop(addr bmp.px[0], bmp.px.len * sizeof(bmp.px[0]))
+  of "encode":
+    os.write("Cha-Control: ConnectionError 1 not supported\n")
+    quit(1)
+
+main()
diff --git a/adapter/img/jebp.nim b/adapter/img/jebp.nim
index 03d72d59..afeb283e 100644
--- a/adapter/img/jebp.nim
+++ b/adapter/img/jebp.nim
@@ -6,9 +6,6 @@ import std/strutils
 import utils/sandbox
 import utils/twtstr
 
-{.passc: "-fno-strict-aliasing".}
-{.passl: "-fno-strict-aliasing".}
-
 {.compile: "jebp.c".}
 
 when sizeof(cint) < 4:
@@ -61,10 +58,12 @@ proc myRead(data: pointer; size: csize_t; user: pointer): csize_t {.cdecl.} =
     n += csize_t(i)
   return n
 
+{.push header: "stb_image_resize.h".}
 proc stbir_resize_uint8(input_pixels: ptr uint8;
   input_w, input_h, input_stride_in_bytes: cint; output_pixels: ptr uint8;
   output_w, output_h, output_stride_in_bytes, num_channels: cint): cint
   {.importc.}
+{.pop.}
 
 proc writeAll(data: pointer; size: int) =
   var n = 0
diff --git a/res/urimethodmap b/res/urimethodmap
index b75dc2b0..356e1e4b 100644
--- a/res/urimethodmap
+++ b/res/urimethodmap
@@ -22,3 +22,4 @@ img-codec+bmp:		cgi-bin:stbi
 img-codec+x-unknown:	cgi-bin:stbi
 img-codec+webp:		cgi-bin:jebp
 img-codec+x-sixel:	cgi-bin:sixel
+img-codec+x-cha-canvas:	cgi-bin:canvas
diff --git a/src/css/cascade.nim b/src/css/cascade.nim
index efa9227f..1cee3399 100644
--- a/src/css/cascade.nim
+++ b/src/css/cascade.nim
@@ -416,13 +416,15 @@ proc applyRulesFrameInvalid(frame: CascadeFrame; ua, user: CSSStylesheet;
       let styledText = styledParent.newStyledReplacement(content, pseudo)
       styledParent.children.add(styledText)
     of peCanvas:
-      let content = CSSContent(
-        t: ContentImage,
-        s: "canvas://",
-        bmp: HTMLCanvasElement(styledParent.node).bitmap
-      )
-      let styledText = styledParent.newStyledReplacement(content, pseudo)
-      styledParent.children.add(styledText)
+      let bmp = HTMLCanvasElement(styledParent.node).bitmap
+      if bmp.cacheId != 0:
+        let content = CSSContent(
+          t: ContentImage,
+          s: "canvas://",
+          bmp: bmp
+        )
+        let styledText = styledParent.newStyledReplacement(content, pseudo)
+        styledParent.children.add(styledText)
     of peVideo:
       let content = CSSContent(t: ContentVideo)
       let styledText = styledParent.newStyledReplacement(content, pseudo)
diff --git a/src/css/cssvalues.nim b/src/css/cssvalues.nim
index f0b3cbc8..f8a50723 100644
--- a/src/css/cssvalues.nim
+++ b/src/css/cssvalues.nim
@@ -318,7 +318,7 @@ type
   CSSContent* = object
     t*: CSSContentType
     s*: string
-    bmp*: Bitmap
+    bmp*: NetworkBitmap
 
   CSSQuotes* = object
     auto*: bool
diff --git a/src/html/dom.nim b/src/html/dom.nim
index 22462db2..c1abd4e3 100644
--- a/src/html/dom.nim
+++ b/src/html/dom.nim
@@ -2,6 +2,7 @@ import std/algorithm
 import std/deques
 import std/math
 import std/options
+import std/posix
 import std/sets
 import std/strutils
 import std/tables
@@ -21,6 +22,7 @@ import html/script
 import img/bitmap
 import img/painter
 import img/path
+import io/bufwriter
 import io/dynstream
 import io/promise
 import js/console
@@ -39,6 +41,7 @@ import monoucha/quickjs
 import monoucha/tojs
 import types/blob
 import types/color
+import types/line
 import types/matrix
 import types/opt
 import types/referrer
@@ -102,6 +105,8 @@ type
     styling*: bool
     # ID of the next image
     imageId: int
+    # list of streams that must be closed for canvas rendering on load
+    pendingCanvasCtls*: seq[CanvasRenderingContext2D]
 
   # Navigator stuff
   Navigator* = object
@@ -343,8 +348,8 @@ type
   HTMLLabelElement* = ref object of HTMLElement
 
   HTMLCanvasElement* = ref object of HTMLElement
-    ctx2d: CanvasRenderingContext2D
-    bitmap*: Bitmap
+    ctx2d*: CanvasRenderingContext2D
+    bitmap*: NetworkBitmap
 
   DrawingState = object
     # CanvasTransform
@@ -366,6 +371,7 @@ type
     bitmap: Bitmap
     state: DrawingState
     stateStack: seq[DrawingState]
+    ps*: PosixStream
 
   TextMetrics = ref object
     # x-direction
@@ -384,7 +390,7 @@ type
     ideographicBaseline {.jsget.}: float64
 
   HTMLImageElement* = ref object of HTMLElement
-    bitmap*: Bitmap
+    bitmap*: NetworkBitmap
     fetchStarted: bool
 
   HTMLVideoElement* = ref object of HTMLElement
@@ -449,7 +455,48 @@ jsDestructor(CanvasRenderingContext2D)
 jsDestructor(TextMetrics)
 jsDestructor(CSSStyleDeclaration)
 
+# Forward declarations
+func attr*(element: Element; s: StaticAtom): string
+func attrb*(element: Element; s: CAtom): bool
+func baseURL*(document: Document): URL
+proc attr*(element: Element; name: CAtom; value: string)
+proc attr*(element: Element; name: StaticAtom; value: string)
+proc delAttr(element: Element; i: int; keep = false)
+proc getImageId(window: Window): int
 proc parseColor(element: Element; s: string): ARGBColor
+proc reflectAttr(element: Element; name: CAtom; value: Option[string])
+
+# Forward declaration hacks
+# set in css/cascade
+var appliesFwdDecl*: proc(mqlist: MediaQueryList; window: Window): bool
+  {.nimcall, noSideEffect.}
+# set in css/match
+var doqsa*: proc (node: Node; q: string): seq[Element] {.nimcall.} = nil
+var doqs*: proc (node: Node; q: string): Element {.nimcall.} = nil
+# set in html/chadombuilder
+var domParseHTMLFragment*: proc(element: Element; s: string): seq[Node]
+  {.nimcall.}
+# set in html/env
+var windowFetch*: proc(window: Window; input: JSValue;
+  init = RequestInit(window: JS_UNDEFINED)): JSResult[FetchPromise]
+  {.nimcall.} = nil
+
+# For now, these are the same; on an API level however, getGlobal is guaranteed
+# to be non-null, while getWindow may return null in the future. (This is in
+# preparation for Worker support.)
+func getGlobal*(ctx: JSContext): Window =
+  let global = JS_GetGlobalObject(ctx)
+  var window: Window
+  assert ctx.fromJS(global, window).isSome
+  JS_FreeValue(ctx, global)
+  return window
+
+func getWindow*(ctx: JSContext): Window =
+  let global = JS_GetGlobalObject(ctx)
+  var window: Window
+  assert ctx.fromJS(global, window).isSome
+  JS_FreeValue(ctx, global)
+  return window
 
 func console(window: Window): Console =
   return window.internalConsole
@@ -457,20 +504,125 @@ func console(window: Window): Console =
 proc resetTransform(state: var DrawingState) =
   state.transformMatrix = newIdentityMatrix(3)
 
-proc resetState(state: var DrawingState) =
+proc reset(state: var DrawingState) =
   state.resetTransform()
   state.fillStyle = rgba(0, 0, 0, 255)
   state.strokeStyle = rgba(0, 0, 0, 255)
   state.path = newPath()
 
 proc create2DContext*(jctx: JSContext; target: HTMLCanvasElement;
-    options = JS_UNDEFINED): CanvasRenderingContext2D =
-  let ctx = CanvasRenderingContext2D(
+    options = JS_UNDEFINED) =
+  var pipefd: array[2, cint]
+  if pipe(pipefd) == -1:
+    return
+  let window = jctx.getWindow()
+  let imageId = target.bitmap.imageId
+  let loader = window.loader
+  loader.passFd("canvas-ctl-" & $imageId, FileHandle(pipefd[0]))
+  discard close(pipefd[0])
+  let ps = newPosixStream(FileHandle(pipefd[1]))
+  let ctlreq = newRequest(newURL("stream:canvas-ctl-" & $imageId).get)
+  let ctlres = loader.doRequest(ctlreq)
+  doAssert ctlres.res == 0
+  let cacheId = loader.addCacheFile(ctlres.outputId, loader.clientPid)
+  target.bitmap.cacheId = cacheId
+  let request = newRequest(
+    newURL("img-codec+x-cha-canvas:decode").get,
+    httpMethod = hmPost,
+    headers = newHeaders({"Cha-Image-Info-Only": "1"}),
+    body = RequestBody(t: rbtOutput, outputId: ctlres.outputId)
+  )
+  let response = loader.doRequest(request)
+  if response.res != 0:
+    # no canvas module; give up
+    ps.sclose()
+    ctlres.resume()
+    ctlres.close()
+    return
+  ctlres.resume()
+  ctlres.close()
+  response.resume()
+  target.ctx2d = CanvasRenderingContext2D(
     bitmap: target.bitmap,
-    canvas: target
+    canvas: target,
+    ps: ps
   )
-  ctx.state.resetState()
-  return ctx
+  window.pendingCanvasCtls.add(target.ctx2d)
+  ps.withPacketWriter w:
+    w.swrite(pcSetDimensions)
+    w.swrite(target.bitmap.width)
+    w.swrite(target.bitmap.height)
+  target.ctx2d.state.reset()
+
+proc fillRect(ctx: CanvasRenderingContext2D; x1, y1, x2, y2: int;
+    color: ARGBColor) =
+  if ctx.ps != nil:
+    ctx.ps.withPacketWriter w:
+      w.swrite(pcFillRect)
+      w.swrite(x1)
+      w.swrite(y1)
+      w.swrite(x2)
+      w.swrite(y2)
+      w.swrite(color)
+
+proc strokeRect(ctx: CanvasRenderingContext2D; x1, y1, x2, y2: int;
+    color: ARGBColor) =
+  if ctx.ps != nil:
+    ctx.ps.withPacketWriter w:
+      w.swrite(pcStrokeRect)
+      w.swrite(x1)
+      w.swrite(y1)
+      w.swrite(x2)
+      w.swrite(y2)
+      w.swrite(color)
+
+proc fillPath(ctx: CanvasRenderingContext2D; path: Path; color: ARGBColor;
+    fillRule: CanvasFillRule) =
+  if ctx.ps != nil:
+    let lines = path.getLineSegments()
+    ctx.ps.withPacketWriter w:
+      w.swrite(pcFillPath)
+      w.swrite(lines)
+      w.swrite(color)
+      w.swrite(fillRule)
+
+proc strokePath(ctx: CanvasRenderingContext2D; path: Path; color: ARGBColor) =
+  if ctx.ps != nil:
+    var lines: seq[Line] = @[]
+    for line in path.lines:
+      lines.add(line)
+    ctx.ps.withPacketWriter w:
+      w.swrite(pcStrokePath)
+      w.swrite(lines)
+      w.swrite(color)
+
+proc fillText(ctx: CanvasRenderingContext2D; text: string; x, y: float64;
+    color: ARGBColor; align: CSSTextAlign) =
+  if ctx.ps != nil:
+    ctx.ps.withPacketWriter w:
+      w.swrite(pcFillText)
+      w.swrite(text)
+      w.swrite(x)
+      w.swrite(y)
+      w.swrite(color)
+      w.swrite(align)
+
+proc strokeText(ctx: CanvasRenderingContext2D; text: string; x, y: float64;
+    color: ARGBColor; align: CSSTextAlign) =
+  if ctx.ps != nil:
+    ctx.ps.withPacketWriter w:
+      w.swrite(pcStrokeText)
+      w.swrite(text)
+      w.swrite(x)
+      w.swrite(y)
+      w.swrite(color)
+      w.swrite(align)
+
+proc clearRect(ctx: CanvasRenderingContext2D; x1, y1, x2, y2: int) =
+  ctx.fillRect(0, 0, ctx.bitmap.width, ctx.bitmap.height, rgba(0, 0, 0, 0))
+
+proc clear(ctx: CanvasRenderingContext2D) =
+  ctx.clearRect(0, 0, ctx.bitmap.width, ctx.bitmap.height)
 
 # CanvasState
 proc save(ctx: CanvasRenderingContext2D) {.jsfunc.} =
@@ -481,10 +633,9 @@ proc restore(ctx: CanvasRenderingContext2D) {.jsfunc.} =
     ctx.state = ctx.stateStack.pop()
 
 proc reset(ctx: CanvasRenderingContext2D) {.jsfunc.} =
-  ctx.bitmap.clear()
-  #TODO empty list of subpaths
+  ctx.clear()
   ctx.stateStack.setLen(0)
-  ctx.state.resetState()
+  ctx.state.reset()
 
 # CanvasTransform
 #TODO scale
@@ -569,11 +720,11 @@ proc clearRect(ctx: CanvasRenderingContext2D; x, y, w, h: float64) {.jsfunc.} =
   #TODO clipping regions (right now we just clip to default)
   let bw = float64(ctx.bitmap.width)
   let bh = float64(ctx.bitmap.height)
-  let x0 = uint64(min(max(x, 0), bw))
-  let x1 = uint64(min(max(x + w, 0), bw))
-  let y0 = uint64(min(max(y, 0), bh))
-  let y1 = uint64(min(max(y + h, 0), bh))
-  ctx.bitmap.clearRect(x0, x1, y0, y1)
+  let x1 = int(min(max(x, 0), bw))
+  let y1 = int(min(max(y, 0), bh))
+  let x2 = int(min(max(x + w, 0), bw))
+  let y2 = int(min(max(y + h, 0), bh))
+  ctx.clearRect(x1, y1, x2, y2)
 
 proc fillRect(ctx: CanvasRenderingContext2D; x, y, w, h: float64) {.jsfunc.} =
   for v in [x, y, w, h]:
@@ -584,11 +735,11 @@ proc fillRect(ctx: CanvasRenderingContext2D; x, y, w, h: float64) {.jsfunc.} =
     return
   let bw = float64(ctx.bitmap.width)
   let bh = float64(ctx.bitmap.height)
-  let x0 = uint64(min(max(x, 0), bw))
-  let x1 = uint64(min(max(x + w, 0), bw))
-  let y0 = uint64(min(max(y, 0), bh))
-  let y1 = uint64(min(max(y + h, 0), bh))
-  ctx.bitmap.fillRect(x0, x1, y0, y1, ctx.state.fillStyle)
+  let x1 = int(min(max(x, 0), bw))
+  let y1 = int(min(max(y, 0), bh))
+  let x2 = int(min(max(x + w, 0), bw))
+  let y2 = int(min(max(y + h, 0), bh))
+  ctx.fillRect(x1, y1, x2, y2, ctx.state.fillStyle)
 
 proc strokeRect(ctx: CanvasRenderingContext2D; x, y, w, h: float64) {.jsfunc.} =
   for v in [x, y, w, h]:
@@ -599,11 +750,11 @@ proc strokeRect(ctx: CanvasRenderingContext2D; x, y, w, h: float64) {.jsfunc.} =
     return
   let bw = float64(ctx.bitmap.width)
   let bh = float64(ctx.bitmap.height)
-  let x0 = uint64(min(max(x, 0), bw))
-  let x1 = uint64(min(max(x + w, 0), bw))
-  let y0 = uint64(min(max(y, 0), bh))
-  let y1 = uint64(min(max(y + h, 0), bh))
-  ctx.bitmap.strokeRect(x0, x1, y0, y1, ctx.state.strokeStyle)
+  let x1 = int(min(max(x, 0), bw))
+  let y1 = int(min(max(y, 0), bh))
+  let x2 = int(min(max(x + w, 0), bw))
+  let y2 = int(min(max(y + h, 0), bh))
+  ctx.strokeRect(x1, y1, x2, y2, ctx.state.strokeStyle)
 
 # CanvasDrawPath
 proc beginPath(ctx: CanvasRenderingContext2D) {.jsfunc.} =
@@ -612,11 +763,11 @@ proc beginPath(ctx: CanvasRenderingContext2D) {.jsfunc.} =
 proc fill(ctx: CanvasRenderingContext2D; fillRule = cfrNonZero) {.jsfunc.} =
   #TODO path
   ctx.state.path.tempClosePath()
-  ctx.bitmap.fillPath(ctx.state.path, ctx.state.fillStyle, fillRule)
+  ctx.fillPath(ctx.state.path, ctx.state.fillStyle, fillRule)
   ctx.state.path.tempOpenPath()
 
 proc stroke(ctx: CanvasRenderingContext2D) {.jsfunc.} = #TODO path
-  ctx.bitmap.strokePath(ctx.state.path, ctx.state.strokeStyle)
+  ctx.strokePath(ctx.state.path, ctx.state.strokeStyle)
 
 proc clip(ctx: CanvasRenderingContext2D; fillRule = cfrNonZero) {.jsfunc.} =
   #TODO path
@@ -627,44 +778,14 @@ proc clip(ctx: CanvasRenderingContext2D; fillRule = cfrNonZero) {.jsfunc.} =
 # CanvasUserInterface
 
 # CanvasText
-const unifont = readFile"res/unifont_jp-15.0.05.png"
-proc loadUnifont(window: Window) =
-  #TODO this is very wrong, we should move the unifont file in a CGI script or
-  # something
-  if unifontBitmap != nil:
-    return
-  let request = newRequest(
-    newURL("img-codec+png:decode").get,
-    httpMethod = hmPost,
-    body = RequestBody(t: rbtString, s: unifont)
-  )
-  let response = window.loader.doRequest(request)
-  assert response.res == 0
-  let dims = response.headers.table["Cha-Image-Dimensions"][0]
-  let width = parseUInt64(dims.until('x'), allowSign = false).get
-  let height = parseUInt64(dims.after('x'), allowSign = false).get
-  let len = int(width) * int(height)
-  let bitmap = ImageBitmap(
-    px: cast[seq[RGBAColorBE]](newSeqUninitialized[uint32](len)),
-    width: width,
-    height: height
-  )
-  window.loader.resume(response.outputId)
-  response.body.recvDataLoop(addr bitmap.px[0], len * 4)
-  response.body.sclose()
-  unifontBitmap = bitmap
-
 #TODO maxwidth
 proc fillText(ctx: CanvasRenderingContext2D; text: string; x, y: float64)
     {.jsfunc.} =
   for v in [x, y]:
     if classify(v) in {fcInf, fcNegInf, fcNan}:
       return
-  #TODO should not be loaded here...
-  ctx.canvas.internalDocument.window.loadUnifont()
   let vec = ctx.transform(Vector2D(x: x, y: y))
-  ctx.bitmap.fillText(text, vec.x, vec.y, ctx.state.fillStyle,
-    ctx.state.textAlign)
+  ctx.fillText(text, vec.x, vec.y, ctx.state.fillStyle, ctx.state.textAlign)
 
 #TODO maxwidth
 proc strokeText(ctx: CanvasRenderingContext2D; text: string; x, y: float64)
@@ -672,11 +793,8 @@ proc strokeText(ctx: CanvasRenderingContext2D; text: string; x, y: float64)
   for v in [x, y]:
     if classify(v) in {fcInf, fcNegInf, fcNan}:
       return
-  #TODO should not be loaded here...
-  ctx.canvas.internalDocument.window.loadUnifont()
   let vec = ctx.transform(Vector2D(x: x, y: y))
-  ctx.bitmap.strokeText(text, vec.x, vec.y, ctx.state.strokeStyle,
-    ctx.state.textAlign)
+  ctx.strokeText(text, vec.x, vec.y, ctx.state.strokeStyle, ctx.state.textAlign)
 
 proc measureText(ctx: CanvasRenderingContext2D; text: string): TextMetrics
     {.jsfunc.} =
@@ -894,47 +1012,6 @@ const ReflectTable0 = [
   makef("onclick", AllTagTypes, "click"),
 ]
 
-# Forward declarations
-func attr*(element: Element; s: StaticAtom): string
-func attrb*(element: Element; s: CAtom): bool
-proc attr*(element: Element; name: CAtom; value: string)
-proc attr*(element: Element; name: StaticAtom; value: string)
-func baseURL*(document: Document): URL
-proc delAttr(element: Element; i: int; keep = false)
-proc reflectAttr(element: Element; name: CAtom; value: Option[string])
-
-# Forward declaration hacks
-# set in css/cascade
-var appliesFwdDecl*: proc(mqlist: MediaQueryList; window: Window): bool
-  {.nimcall, noSideEffect.}
-# set in css/match
-var doqsa*: proc (node: Node; q: string): seq[Element] {.nimcall.} = nil
-var doqs*: proc (node: Node; q: string): Element {.nimcall.} = nil
-# set in html/chadombuilder
-var domParseHTMLFragment*: proc(element: Element; s: string): seq[Node]
-  {.nimcall.}
-# set in html/env
-var windowFetch*: proc(window: Window; input: JSValue;
-  init = RequestInit(window: JS_UNDEFINED)): JSResult[FetchPromise]
-  {.nimcall.} = nil
-
-# For now, these are the same; on an API level however, getGlobal is guaranteed
-# to be non-null, while getWindow may return null in the future. (This is in
-# preparation for Worker support.)
-func getGlobal*(ctx: JSContext): Window =
-  let global = JS_GetGlobalObject(ctx)
-  var window: Window
-  assert ctx.fromJS(global, window).isSome
-  JS_FreeValue(ctx, global)
-  return window
-
-func getWindow*(ctx: JSContext): Window =
-  let global = JS_GetGlobalObject(ctx)
-  var window: Window
-  assert ctx.fromJS(global, window).isSome
-  JS_FreeValue(ctx, global)
-  return window
-
 func document*(node: Node): Document =
   if node of Document:
     return Document(node)
@@ -2763,7 +2840,15 @@ proc newHTMLElement*(document: Document; localName: CAtom;
   of TAG_LABEL:
     result = HTMLLabelElement()
   of TAG_CANVAS:
-    let bitmap = if document.scriptingEnabled: newBitmap(300, 150) else: nil
+    let bitmap = if document.scriptingEnabled:
+      NetworkBitmap(
+        contentType: "image/x-cha-canvas",
+        imageId: document.window.getImageId(),
+        width: 300,
+        height: 150
+      )
+    else:
+      nil
     result = HTMLCanvasElement(bitmap: bitmap)
   of TAG_IMG:
     result = HTMLImageElement()
@@ -3034,7 +3119,10 @@ proc loadResource(window: Window; link: HTMLLinkElement) =
         let res = res.get
         if res.getContentType() == "text/css":
           return res.text()
-        res.unregisterFun()
+        res.close()
+      let p = newPromise[JSResult[string]]()
+      p.resolve(JSResult[string].err(res.error))
+      return p
     ).then(proc(s: JSResult[string]) =
       if s.isSome:
         #TODO non-utf-8 css?
@@ -3071,7 +3159,8 @@ proc loadResource(window: Window; image: HTMLImageElement) =
         return
     let cachedURL = CachedURLImage(expiry: -1, loading: true)
     window.imageURLCache[surl] = cachedURL
-    let p = window.loader.fetch(newRequest(url)).then(
+    let headers = newHeaders({"Accept": "*/*"})
+    let p = window.loader.fetch(newRequest(url, headers = headers)).then(
       proc(res: JSResult[Response]): EmptyPromise =
         if res.isNone:
           return newResolvedPromise()
@@ -3092,8 +3181,7 @@ proc loadResource(window: Window; image: HTMLImageElement) =
         )
         let r = window.loader.fetch(request)
         response.resume()
-        response.unregisterFun()
-        response.body.sclose()
+        response.close()
         var expiry = -1i64
         if "Cache-Control" in response.headers:
           for hdr in response.headers.table["Cache-Control"]:
@@ -3113,8 +3201,7 @@ proc loadResource(window: Window; image: HTMLImageElement) =
           let response = res.get
           # close immediately; all data we're interested in is in the headers.
           response.resume()
-          response.unregisterFun()
-          response.body.sclose()
+          response.close()
           if "Cha-Image-Dimensions" notin response.headers.table:
             window.console.error("Cha-Image-Dimensions missing in",
               $response.url)
@@ -3122,12 +3209,13 @@ proc loadResource(window: Window; image: HTMLImageElement) =
           let dims = response.headers.table["Cha-Image-Dimensions"][0]
           let width = parseUInt64(dims.until('x'), allowSign = false)
           let height = parseUInt64(dims.after('x'), allowSign = false)
-          if width.isNone or height.isNone:
+          if width.isNone or height.isNone or width.get > uint64(int.high) or
+              height.get > uint64(int.high):
             window.console.error("wrong Cha-Image-Dimensions in", $response.url)
             return
           let bmp = NetworkBitmap(
-            width: width.get,
-            height: height.get,
+            width: int(width.get),
+            height: int(height.get),
             cacheId: cacheId,
             imageId: window.getImageId(),
             contentType: contentType
@@ -3244,10 +3332,24 @@ proc reflectAttr(element: Element; name: CAtom; value: Option[string]) =
     if element.scriptingEnabled and name in {satWidth, satHeight}:
       let w = element.attrul(satWidth).get(300)
       let h = element.attrul(satHeight).get(150)
-      let canvas = HTMLCanvasElement(element)
-      if canvas.bitmap == nil or canvas.bitmap.width != w or
-          canvas.bitmap.height != h:
-        canvas.bitmap = newBitmap(w, h)
+      if w <= uint64(int.high) and h <= uint64(int.high):
+        let w = int(w)
+        let h = int(h)
+        let canvas = HTMLCanvasElement(element)
+        if canvas.bitmap == nil or canvas.bitmap.width != w or
+            canvas.bitmap.height != h:
+          let window = element.document.window
+          if canvas.ctx2d != nil and canvas.ctx2d.ps != nil:
+            let i = window.pendingCanvasCtls.find(canvas.ctx2d)
+            window.pendingCanvasCtls.del(i)
+            canvas.ctx2d.ps.sclose()
+            canvas.ctx2d = nil
+          canvas.bitmap = NetworkBitmap(
+            contentType: "image/x-cha-canvas",
+            imageId: window.getImageId(),
+            width: w,
+            height: h
+          )
   of TAG_IMG:
     let image = HTMLImageElement(element)
     # https://html.spec.whatwg.org/multipage/images.html#relevant-mutations
@@ -4457,24 +4559,21 @@ func getElementReflectFunctions(): seq[TabGetSet] =
 proc getContext*(jctx: JSContext; this: HTMLCanvasElement; contextId: string;
     options = JS_UNDEFINED): RenderingContext {.jsfunc.} =
   if contextId == "2d":
-    if this.ctx2d != nil:
-      return this.ctx2d
-    return create2DContext(jctx, this, options)
+    if this.ctx2d == nil:
+      create2DContext(jctx, this, options)
+    return this.ctx2d
   return nil
 
 # Note: the standard says quality should be converted in a strange way for
 # backwards compat, but I don't care.
 proc toBlob(ctx: JSContext; this: HTMLCanvasElement; callback: JSValue;
     contentType = "image/png"; quality = none(float64)) {.jsfunc.} =
-  if not contentType.startsWith("image/"):
+  if not contentType.startsWith("image/") or this.bitmap.cacheId == 0:
     return
-  let url = newURL("img-codec+" & contentType.after('/') & ":encode")
-  if url.isNone:
+  let url0 = newURL("img-codec+" & contentType.after('/') & ":encode")
+  if url0.isNone:
     return
-  #TODO this is dumb (and slow)
-  var s = newString(this.bitmap.px.len * 4)
-  if s.len > 0:
-    copyMem(addr s[0], addr this.bitmap.px[0], s.len)
+  let url = url0.get
   let headers = newHeaders({
     "Cha-Image-Dimensions": $this.bitmap.width & 'x' & $this.bitmap.height
   })
@@ -4482,17 +4581,38 @@ proc toBlob(ctx: JSContext; this: HTMLCanvasElement; callback: JSValue;
     quality *= 99
     quality += 1
     headers.add("Cha-Image-Quality", $quality)
-  let request = newRequest(
-    url.get,
-    httpMethod = hmPost,
-    headers = headers,
-    body = RequestBody(t: rbtString, s: s)
-  )
   # callback will go out of scope when we return, so capture a new reference.
   let callback = JS_DupValue(ctx, callback)
   let window = this.document.window
+  let loader = window.loader
   let contentType = contentType.toLowerAscii()
-  window.loader.fetch(request).then(proc(res: JSResult[Response]) =
+  let cacheReq = newRequest(newURL("cache:" & $this.bitmap.cacheId).get)
+  loader.fetch(cacheReq).then(proc(res: JSResult[Response]): FetchPromise =
+    if res.isNone:
+      return newResolvedPromise(res)
+    let res = res.get
+    let p = loader.fetch(newRequest(
+      newURL("img-codec+x-cha-canvas:decode").get,
+      httpMethod = hmPost,
+      body = RequestBody(t: rbtOutput, outputId: res.outputId)
+    ))
+    res.resume()
+    res.close()
+    return p
+  ).then(proc(res: JSResult[Response]): FetchPromise =
+    if res.isNone:
+      return newResolvedPromise(res)
+    let res = res.get
+    let p = loader.fetch(newRequest(
+      url,
+      httpMethod = hmPost,
+      headers = headers,
+      body = RequestBody(t: rbtOutput, outputId: res.outputId)
+    ))
+    res.resume()
+    res.close()
+    return p
+  ).then(proc(res: JSResult[Response]) =
     if res.isNone:
       if contentType != "image/png":
         # redo as PNG.
diff --git a/src/img/bitmap.nim b/src/img/bitmap.nim
index c5df19fc..343e9ecf 100644
--- a/src/img/bitmap.nim
+++ b/src/img/bitmap.nim
@@ -3,8 +3,8 @@ import types/color
 type
   Bitmap* = ref object of RootObj
     px*: seq[RGBAColorBE]
-    width*: uint64
-    height*: uint64
+    width*: int
+    height*: int
 
   ImageBitmap* = ref object of Bitmap
 
@@ -13,27 +13,27 @@ type
     imageId*: int
     contentType*: string
 
-proc newBitmap*(width, height: uint64): ImageBitmap =
+proc newBitmap*(width, height: int): ImageBitmap =
   return ImageBitmap(
     px: newSeq[RGBAColorBE](width * height),
     width: width,
     height: height
   )
 
-proc setpx*(bmp: Bitmap; x, y: uint64; color: RGBAColorBE) {.inline.} =
+proc setpx*(bmp: Bitmap; x, y: int; color: RGBAColorBE) {.inline.} =
   bmp.px[bmp.width * y + x] = color
 
-proc setpx*(bmp: Bitmap; x, y: uint64; color: ARGBColor) {.inline.} =
+proc setpx*(bmp: Bitmap; x, y: int; color: ARGBColor) {.inline.} =
   bmp.px[bmp.width * y + x] = rgba_be(color.r, color.g, color.b, color.a)
 
-proc getpx*(bmp: Bitmap; x, y: uint64): RGBAColorBE {.inline.} =
+proc getpx*(bmp: Bitmap; x, y: int): RGBAColorBE {.inline.} =
   return bmp.px[bmp.width * y + x]
 
-proc setpxb*(bmp: Bitmap; x, y: uint64; c: RGBAColorBE) {.inline.} =
+proc setpxb*(bmp: Bitmap; x, y: int; c: RGBAColorBE) {.inline.} =
   if c.a == 255:
     bmp.setpx(x, y, c)
   else:
     bmp.setpx(x, y, bmp.getpx(x, y).blend(c))
 
-proc setpxb*(bmp: Bitmap; x, y: uint64; c: ARGBColor) {.inline.} =
+proc setpxb*(bmp: Bitmap; x, y: int; c: ARGBColor) {.inline.} =
   bmp.setpxb(x, y, rgba_be(c.r, c.g, c.b, c.a))
diff --git a/src/img/painter.nim b/src/img/painter.nim
index 7b844447..0479bd77 100644
--- a/src/img/painter.nim
+++ b/src/img/painter.nim
@@ -12,64 +12,67 @@ type CanvasFillRule* = enum
   cfrNonZero = "nonzero"
   cfrEvenOdd = "evenodd"
 
+type PaintCommand* = enum
+  pcSetDimensions, pcFillRect, pcStrokeRect, pcFillPath, pcStrokePath,
+  pcFillText, pcStrokeText
+
 # https://en.wikipedia.org/wiki/Bresenham's_line_algorithm#All_cases
-proc plotLineLow(bmp: Bitmap; x0, y0, x1, y1: int64; color: ARGBColor) =
-  var dx = x1 - x0
-  var dy = y1 - y0
+proc plotLineLow(bmp: Bitmap; x1, y1, x2, y2: int; color: ARGBColor) =
+  var dx = x2 - x1
+  var dy = y2 - y1
   var yi = 1
   if dy < 0:
     yi = -1
     dy = -dy
   var D = 2 * dy - dx;
-  var y = y0;
-  for x in x0 ..< x1:
-    if x < 0 or y < 0 or uint64(x) >= bmp.width or uint64(y) >= bmp.height:
+  var y = y1;
+  for x in x1 ..< x2:
+    if x < 0 or y < 0 or x >= bmp.width or y >= bmp.height:
       break
-    bmp.setpxb(uint64(x), uint64(y), color)
+    bmp.setpxb(x, y, color)
     if D > 0:
        y = y + yi;
        D = D - 2 * dx;
     D = D + 2 * dy;
 
-proc plotLineHigh(bmp: Bitmap; x0, y0, x1, y1: int64; color: ARGBColor) =
-  var dx = x1 - x0
-  var dy = y1 - y0
+proc plotLineHigh(bmp: Bitmap; x1, y1, x2, y2: int; color: ARGBColor) =
+  var dx = x2 - x1
+  var dy = y2 - y1
   var xi = 1
   if dx < 0:
     xi = -1
     dx = -dx
   var D = 2 * dx - dy
-  var x = x0
-  for y in y0 ..< y1:
-    if x < 0 or y < 0 or uint64(x) >= bmp.width or uint64(y) >= bmp.height:
+  var x = x1
+  for y in y1 ..< y2:
+    if x < 0 or y < 0 or x >= bmp.width or y >= bmp.height:
       break
-    bmp.setpxb(uint64(x), uint64(y), color)
+    bmp.setpxb(x, y, color)
     if D > 0:
        x = x + xi
        D = D - 2 * dy
     D = D + 2 * dx
 
-#TODO should be uint64...
-proc plotLine(bmp: Bitmap; x0, y0, x1, y1: int64; color: ARGBColor) =
-  if abs(y1 - y0) < abs(x1 - x0):
-    if x0 > x1:
-      bmp.plotLineLow(x1, y1, x0, y0, color)
+proc plotLine(bmp: Bitmap; x1, y1, x2, y2: int; color: ARGBColor) =
+  if abs(y2 - y1) < abs(x2 - x1):
+    if x1 > x2:
+      bmp.plotLineLow(x2, y2, x1, y1, color)
     else:
-      bmp.plotLineLow(x0, y0, x1, y1, color)
+      bmp.plotLineLow(x1, y1, x2, y2, color)
   else:
-    if y0 > y1:
-      bmp.plotLineHigh(x1, y1, x0, y0, color)
+    if y1 > y2:
+      bmp.plotLineHigh(x2, y2, x1, y1, color)
     else:
-      bmp.plotLineHigh(x0, y0, x1, y1, color)
+      bmp.plotLineHigh(x1, y1, x2, y2, color)
 
 proc plotLine(bmp: Bitmap; a, b: Vector2D; color: ARGBColor) =
-  bmp.plotLine(int64(a.x), int64(a.y), int64(b.x), int64(b.y), color)
+  bmp.plotLine(int(a.x), int(a.y), int(b.x), int(b.y), color)
 
 proc plotLine(bmp: Bitmap; line: Line; color: ARGBColor) =
   bmp.plotLine(line.p0, line.p1, color)
 
-proc strokePath*(bmp: Bitmap; path: Path; color: ARGBColor) =
-  for line in path.lines:
+proc strokePath*(bmp: Bitmap; lines: seq[Line]; color: ARGBColor) =
+  for line in lines:
     bmp.plotLine(line, color)
 
 func isInside(windingNumber: int; fillRule: CanvasFillRule): bool =
@@ -77,13 +80,12 @@ func isInside(windingNumber: int; fillRule: CanvasFillRule): bool =
   of cfrNonZero: windingNumber != 0
   of cfrEvenOdd: windingNumber mod 2 == 0
 
-# Mainly adapted from SerenityOS.
-proc fillPath*(bmp: Bitmap; path: Path; color: ARGBColor;
+# Algorithm originally from SerenityOS.
+proc fillPath*(bmp: Bitmap; lines: PathLines; color: ARGBColor;
     fillRule: CanvasFillRule) =
-  let lines = path.getLineSegments()
   var i = 0
-  var ylines: seq[LineSegment]
-  for y in int64(lines.miny) .. int64(lines.maxy):
+  var ylines: seq[LineSegment] = @[]
+  for y in int(lines.miny) .. int(lines.maxy):
     for k in countdown(ylines.high, 0):
       if ylines[k].maxy < float64(y):
         ylines.del(k) # we'll sort anyways, so del is fine
@@ -98,14 +100,14 @@ proc fillPath*(bmp: Bitmap; path: Path; color: ARGBColor;
     for k in 0 ..< ylines.high:
       let a = ylines[k]
       let b = ylines[k + 1]
-      let sx = int64(a.minyx)
-      let ex = int64(b.minyx)
+      let sx = int(a.minyx)
+      let ex = int(b.minyx)
       if w.isInside(fillRule) and y > 0:
         for x in sx .. ex:
           if x > 0:
-            bmp.setpxb(uint64(x), uint64(y), color)
-      if int64(a.p0.y) != y and int64(a.p1.y) != y and int64(b.p0.y) != y and
-          int64(b.p1.y) != y and sx != ex or a.islope * b.islope < 0:
+            bmp.setpxb(x, y, color)
+      if int(a.p0.y) != y and int(a.p1.y) != y and int(b.p0.y) != y and
+          int(b.p1.y) != y and sx != ex or a.islope * b.islope < 0:
         case fillRule
         of cfrEvenOdd: inc w
         of cfrNonZero:
@@ -117,26 +119,18 @@ proc fillPath*(bmp: Bitmap; path: Path; color: ARGBColor;
     if ylines.len > 0:
       ylines[^1].minyx += ylines[^1].islope
 
-proc fillRect*(bmp: Bitmap; x0, x1, y0, y1: uint64, color: ARGBColor) =
-  for y in y0 ..< y1:
-    for x in x0 ..< x1:
+proc fillRect*(bmp: Bitmap; x1, y1, x2, y2: int; color: ARGBColor) =
+  for y in y1 ..< y2:
+    for x in x1 ..< x2:
       bmp.setpxb(x, y, color)
 
-proc strokeRect*(bmp: Bitmap; x0, x1, y0, y1: uint64, color: ARGBColor) =
-  for x in x0 ..< x1:
-    bmp.setpxb(x, y0, color)
+proc strokeRect*(bmp: Bitmap; x1, y1, x2, y2: int; color: ARGBColor) =
+  for x in x1 ..< x2:
     bmp.setpxb(x, y1, color)
-  for y in y0 ..< y1:
-    bmp.setpxb(x0, y, color)
+    bmp.setpxb(x, y2, color)
+  for y in y1 ..< y2:
     bmp.setpxb(x1, y, color)
-
-proc clearRect*(bmp: Bitmap; x0, x1, y0, y1: uint64) =
-  for y in y0 ..< y1:
-    for x in x0 ..< x1:
-      bmp.setpx(x, y, rgba(0, 0, 0, 0))
-
-proc clear*(bmp: Bitmap) =
-  bmp.clearRect(0, bmp.width, 0, bmp.height)
+    bmp.setpxb(x2, y, color)
 
 type GlyphCacheItem = object
   u: uint32
@@ -152,14 +146,14 @@ proc getCharBmp(u: uint32): Bitmap =
     if it.u == u:
       return it.bmp
   # Unifont glyphs start at x: 32, y: 64, and are of 8x16/16x16 size
-  let gx = uint64(32 + 16 * (u mod 0x100))
-  let gy = uint64(64 + 16 * (u div 0x100))
+  let gx = int(32 + 16 * (u mod 0x100))
+  let gy = int(64 + 16 * (u div 0x100))
   var fullwidth = false
   const white = rgba_be(255, 255, 255, 255)
   block loop:
     # hack to recognize full width characters
-    for y in 0 ..< 16u64:
-      for x in 8 ..< 16u64:
+    for y in 0 ..< 16:
+      for x in 8 ..< 16:
         if unifontBitmap.getpx(gx + x, gy + y) != white:
           fullwidth = true
           break loop
@@ -181,15 +175,15 @@ proc getCharBmp(u: uint32): Bitmap =
 proc drawBitmap(a, b: Bitmap; p: Vector2D) =
   for y in 0 ..< b.height:
     for x in 0 ..< b.width:
-      let ax = uint64(p.x) + x
-      let ay = uint64(p.y) + y
+      let ax = int(p.x) + x
+      let ay = int(p.y) + y
       if ax >= 0 and ay >= y and ax < a.width and ay < a.height:
         a.setpxb(ax, ay, b.getpx(x, y))
 
 proc fillText*(bmp: Bitmap; text: string; x, y: float64; color: ARGBColor;
     textAlign: CSSTextAlign) =
   var w = 0f64
-  var glyphs: seq[Bitmap]
+  var glyphs: seq[Bitmap] = @[]
   for r in text.runes:
     let glyph = getCharBmp(uint32(r))
     glyphs.add(glyph)
diff --git a/src/img/path.nim b/src/img/path.nim
index d411eef6..7572ce20 100644
--- a/src/img/path.nim
+++ b/src/img/path.nim
@@ -215,7 +215,7 @@ iterator lines*(path: Path): Line {.inline.} =
 
 proc getLineSegments*(path: Path): PathLines =
   if path.subpaths.len == 0:
-    return
+    return PathLines()
   var miny = Inf
   var maxy = -Inf
   var segments: seq[LineSegment]
diff --git a/src/io/bufreader.nim b/src/io/bufreader.nim
index bc448edf..8dc7b0a2 100644
--- a/src/io/bufreader.nim
+++ b/src/io/bufreader.nim
@@ -34,7 +34,7 @@ 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 ARGBColor) {.inline.}
 proc sread*(reader: var BufferedReader; o: var RequestBody)
-proc sread*(reader: var BufferedReader; bmp: var Bitmap)
+proc sread*(reader: var BufferedReader; bmp: var NetworkBitmap)
 
 proc initReader*(stream: DynStream; len, auxLen: int): BufferedReader =
   assert len != 0
@@ -215,30 +215,10 @@ proc sread*(reader: var BufferedReader; o: var RequestBody) =
   of rbtMultipart: reader.sread(o.multipart)
   of rbtOutput: reader.sread(o.outputId)
 
-proc sread*(reader: var BufferedReader; bmp: var Bitmap) =
-  var isImageBitmap: bool
-  var width: uint64
-  var height: uint64
-  reader.sread(isImageBitmap)
-  reader.sread(width)
-  reader.sread(height)
-  if isImageBitmap:
-    bmp = ImageBitmap(
-      width: width,
-      height: height
-    )
-    reader.sread(bmp.px)
-  else:
-    var cacheId: int
-    var imageId: int
-    var contentType: string
-    reader.sread(cacheId)
-    reader.sread(imageId)
-    reader.sread(contentType)
-    bmp = NetworkBitmap(
-      width: width,
-      height: height,
-      cacheId: cacheId,
-      imageId: imageId,
-      contentType: contentType
-    )
+proc sread*(reader: var BufferedReader; bmp: var NetworkBitmap) =
+  bmp = NetworkBitmap()
+  reader.sread(bmp.width)
+  reader.sread(bmp.height)
+  reader.sread(bmp.cacheId)
+  reader.sread(bmp.imageId)
+  reader.sread(bmp.contentType)
diff --git a/src/io/bufwriter.nim b/src/io/bufwriter.nim
index 241d77cc..ed4cb725 100644
--- a/src/io/bufwriter.nim
+++ b/src/io/bufwriter.nim
@@ -44,7 +44,7 @@ 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: ARGBColor) {.inline.}
 proc swrite*(writer: var BufferedWriter; o: RequestBody)
-proc swrite*(writer: var BufferedWriter; bmp: Bitmap)
+proc swrite*(writer: var BufferedWriter; bmp: NetworkBitmap)
 
 const InitLen = sizeof(int) * 2
 const SizeInit = max(64, InitLen)
@@ -199,13 +199,9 @@ proc swrite*(writer: var BufferedWriter; o: RequestBody) =
   of rbtMultipart: writer.swrite(o.multipart)
   of rbtOutput: writer.swrite(o.outputId)
 
-proc swrite*(writer: var BufferedWriter; bmp: Bitmap) =
-  writer.swrite(bmp of ImageBitmap)
+proc swrite*(writer: var BufferedWriter; bmp: NetworkBitmap) =
   writer.swrite(bmp.width)
   writer.swrite(bmp.height)
-  if bmp of ImageBitmap:
-    writer.swrite(bmp.px)
-  else:
-    writer.swrite(NetworkBitmap(bmp).cacheId)
-    writer.swrite(NetworkBitmap(bmp).imageId)
-    writer.swrite(NetworkBitmap(bmp).contentType)
+  writer.swrite(bmp.cacheId)
+  writer.swrite(bmp.imageId)
+  writer.swrite(bmp.contentType)
diff --git a/src/io/promise.nim b/src/io/promise.nim
index 55dcfbf0..8fb55a9b 100644
--- a/src/io/promise.nim
+++ b/src/io/promise.nim
@@ -75,6 +75,11 @@ proc newResolvedPromise*(): EmptyPromise =
   res.resolve()
   return res
 
+proc newResolvedPromise*[T](x: T): Promise[T] =
+  let res = newPromise[T]()
+  res.resolve(x)
+  return res
+
 func empty*(map: PromiseMap): bool =
   map.tab.len == 0
 
diff --git a/src/layout/box.nim b/src/layout/box.nim
index 9812a9cd..cd94ca49 100644
--- a/src/layout/box.nim
+++ b/src/layout/box.nim
@@ -25,7 +25,7 @@ type
     of iatInlineBlock:
       innerbox*: BlockBox
     of iatImage:
-      bmp*: Bitmap
+      bmp*: NetworkBitmap
 
   RootInlineFragmentState* = object
     # offset relative to parent
@@ -73,7 +73,7 @@ type
     of iftNewline:
       discard
     of iftBitmap:
-      bmp*: Bitmap
+      bmp*: NetworkBitmap
     of iftBox:
       box*: BlockBox
 
diff --git a/src/layout/engine.nim b/src/layout/engine.nim
index 9d40b9ca..6943958d 100644
--- a/src/layout/engine.nim
+++ b/src/layout/engine.nim
@@ -1439,7 +1439,7 @@ proc addInlineBlock(ictx: var InlineContext; state: var InlineState;
   ictx.whitespacenum = 0
 
 proc addInlineImage(ictx: var InlineContext; state: var InlineState;
-    bmp: Bitmap; padding: LayoutUnit) =
+    bmp: NetworkBitmap; padding: LayoutUnit) =
   let atom = InlineAtom(
     t: iatImage,
     bmp: bmp,
diff --git a/src/layout/renderdocument.nim b/src/layout/renderdocument.nim
index a407cdfe..2ff8d484 100644
--- a/src/layout/renderdocument.nim
+++ b/src/layout/renderdocument.nim
@@ -235,7 +235,7 @@ type
     y*: int
     width*: int
     height*: int
-    bmp*: Bitmap
+    bmp*: NetworkBitmap
 
   AbsolutePos = object
     offset: Offset
diff --git a/src/loader/loader.nim b/src/loader/loader.nim
index c40c3cad..e3c36b01 100644
--- a/src/loader/loader.nim
+++ b/src/loader/loader.nim
@@ -108,7 +108,10 @@ type
   ClientData = ref object
     pid: int
     key: ClientKey
+    # List of cached resources.
     cacheMap: seq[CachedItem]
+    # List of file descriptors passed by the client.
+    passedFdMap: Table[string, FileHandle] # host -> fd
     config: LoaderClientConfig
 
   LoaderContext = ref object
@@ -119,8 +122,6 @@ type
     handleMap: Table[int, LoaderHandle]
     outputMap: Table[int, OutputHandle]
     selector: Selector[int]
-    # List of file descriptors passed by the pager.
-    passedFdMap: Table[string, FileHandle] # host -> fd
     # List of existing clients (buffer or pager) that may make requests.
     clientData: Table[int, ClientData] # pid -> data
     # ID of next output. TODO: find a better allocation scheme
@@ -356,8 +357,9 @@ proc loadStreamRegular(ctx: LoaderContext; handle, cachedHandle: LoaderHandle) =
   handle.outputs.setLen(0)
   handle.iclose()
 
-proc loadStream(ctx: LoaderContext; handle: LoaderHandle; request: Request) =
-  ctx.passedFdMap.withValue(request.url.pathname, fdp):
+proc loadStream(ctx: LoaderContext; client: ClientData; handle: LoaderHandle;
+    request: Request) =
+  client.passedFdMap.withValue(request.url.pathname, fdp):
     handle.sendResult(0)
     handle.sendStatus(200)
     handle.sendHeaders(newHeaders())
@@ -365,7 +367,7 @@ proc loadStream(ctx: LoaderContext; handle: LoaderHandle; request: Request) =
     var stats: Stat
     doAssert fstat(fdp[], stats) != -1
     handle.istream = ps
-    ctx.passedFdMap.del(request.url.pathname)
+    client.passedFdMap.del(request.url.pathname)
     if S_ISCHR(stats.st_mode) or S_ISREG(stats.st_mode):
       # regular file: e.g. cha <file
       # or character device: e.g. cha </dev/null
@@ -493,7 +495,7 @@ proc loadResource(ctx: LoaderContext; client: ClientData;
         assert ostream == nil
         handle.close()
     elif request.url.scheme == "stream":
-      ctx.loadStream(handle, request)
+      ctx.loadStream(client, handle, request)
       if handle.istream != nil:
         ctx.addFd(handle)
       else:
@@ -658,11 +660,12 @@ proc shareCachedItem(ctx: LoaderContext; stream: SocketStream;
   targetClient.cacheMap.add(item)
   stream.sclose()
 
-proc passFd(ctx: LoaderContext; stream: SocketStream; r: var BufferedReader) =
+proc passFd(ctx: LoaderContext; stream: SocketStream; client: ClientData;
+    r: var BufferedReader) =
   var id: string
   r.sread(id)
   let fd = stream.recvFileHandle()
-  ctx.passedFdMap[id] = fd
+  client.passedFdMap[id] = fd
   stream.sclose()
 
 proc removeCachedItem(ctx: LoaderContext; stream: SocketStream;
@@ -764,9 +767,6 @@ proc acceptConnection(ctx: LoaderContext) =
       of lcShareCachedItem:
         privileged_command
         ctx.shareCachedItem(stream, r)
-      of lcPassFd:
-        privileged_command
-        ctx.passFd(stream, r)
       of lcRedirectToFile:
         privileged_command
         ctx.redirectToFile(stream, r)
@@ -780,6 +780,8 @@ proc acceptConnection(ctx: LoaderContext) =
         ctx.addCacheFile(stream, client, r)
       of lcRemoveCachedItem:
         ctx.removeCachedItem(stream, client, r)
+      of lcPassFd:
+        ctx.passFd(stream, client, r)
       of lcLoad:
         ctx.load(stream, client, r)
       of lcTee:
diff --git a/src/loader/response.nim b/src/loader/response.nim
index d41f62eb..1d7307cd 100644
--- a/src/loader/response.nim
+++ b/src/loader/response.nim
@@ -3,7 +3,6 @@ import std/tables
 
 import chagashi/charset
 import chagashi/decoder
-import img/bitmap
 import io/dynstream
 import io/promise
 import loader/headers
@@ -13,7 +12,6 @@ import monoucha/jserror
 import monoucha/quickjs
 import monoucha/tojs
 import types/blob
-import types/color
 import types/opt
 import types/referrer
 import types/url
@@ -228,46 +226,6 @@ proc blob*(response: Response): Promise[JSResult[Blob]] {.jsfunc.} =
   response.resume()
   return opaque.bodyRead
 
-type BitmapOpaque = ref object of RootObj
-  bmp: Bitmap
-  idx: int
-  bodyRead: EmptyPromise
-
-proc onReadBitmap(response: Response) =
-  let opaque = BitmapOpaque(response.opaque)
-  let bmp = opaque.bmp
-  while true:
-    try:
-      let p = cast[ptr UncheckedArray[uint8]](addr bmp.px[0])
-      let L = bmp.px.len * 4 - opaque.idx
-      let n = response.body.recvData(addr p[opaque.idx], L)
-      opaque.idx += n
-      if n == 0:
-        break
-    except ErrorAgain:
-      break
-
-proc onFinishBitmap(response: Response; success: bool) =
-  let opaque = BitmapOpaque(response.opaque)
-  opaque.bodyRead.resolve()
-
-proc saveToBitmap*(response: Response; bmp: Bitmap): EmptyPromise =
-  assert not response.bodyUsed
-  let opaque = BitmapOpaque(bmp: bmp, idx: 0, bodyRead: EmptyPromise())
-  let size = bmp.width * bmp.height
-  bmp.px = cast[seq[RGBAColorBE]](newSeqUninitialized[uint32](size))
-  response.opaque = opaque
-  if size > 0:
-    response.onRead = onReadBitmap
-    response.onFinish = onFinishBitmap
-  else:
-    response.unregisterFun()
-    response.body.sclose()
-    opaque.bodyRead.resolve()
-  response.bodyUsed = true
-  response.resume()
-  return opaque.bodyRead
-
 proc json(ctx: JSContext; this: Response): Promise[JSValue] {.jsfunc.} =
   return this.text().then(proc(s: JSResult[string]): JSValue =
     if s.isNone:
diff --git a/src/local/container.nim b/src/local/container.nim
index ebfc3940..2c12c4ae 100644
--- a/src/local/container.nim
+++ b/src/local/container.nim
@@ -2070,7 +2070,7 @@ proc highlightMarks*(container: Container; display: var FixedGrid;
 
 func findCachedImage*(container: Container; image: PosBitmap;
     offx, erry, dispw: int): CachedImage =
-  let imageId = NetworkBitmap(image.bmp).imageId
+  let imageId = image.bmp.imageId
   for it in container.cachedImages:
     if it.bmp.imageId == imageId and it.width == image.width and
         it.height == image.height and it.offx == offx and it.erry == erry and
diff --git a/src/local/pager.nim b/src/local/pager.nim
index 8abaf16f..cb3f751e 100644
--- a/src/local/pager.nim
+++ b/src/local/pager.nim
@@ -130,7 +130,6 @@ type
     forkserver*: ForkServer
     formRequestMap*: Table[string, FormRequestType]
     hasload*: bool # has a page been successfully loaded since startup?
-    imageId: int # hack to allocate a new ID for canvas each frame, TODO remove
     inputBuffer*: string # currently uninterpreted characters
     iregex: Result[Regex, string]
     isearchpromise: EmptyPromise
@@ -484,8 +483,7 @@ proc redraw(pager: Pager) {.jsfunc.} =
 
 proc loadCachedImage(pager: Pager; container: Container; image: PosBitmap;
     offx, erry, dispw: int) =
-  let bmp = NetworkBitmap()
-  bmp[] = NetworkBitmap(image.bmp)[]
+  let bmp = image.bmp
   let request = newRequest(newURL("cache:" & $bmp.cacheId).get)
   let cachedImage = CachedImage(
     bmp: bmp,
@@ -505,7 +503,7 @@ proc loadCachedImage(pager: Pager; container: Container; image: PosBitmap;
       return
     let response = res.get
     let headers = newHeaders()
-    if uint64(image.width) != bmp.width or uint64(image.height) != bmp.height:
+    if image.width != bmp.width or image.height != bmp.height:
       headers.add("Cha-Image-Target-Dimensions", $image.width & 'x' &
         $image.height)
     let request = newRequest(
@@ -524,9 +522,6 @@ proc loadCachedImage(pager: Pager; container: Container; image: PosBitmap;
       pager.loader.removeCachedItem(bmp.cacheId)
       return
     let response = res.get
-    # take target sizes
-    bmp.width = uint64(image.width)
-    bmp.height = uint64(image.height)
     let headers = newHeaders({
       "Cha-Image-Dimensions": $image.width & 'x' & $image.height
     })
@@ -571,36 +566,28 @@ proc loadCachedImage(pager: Pager; container: Container; image: PosBitmap;
 proc initImages(pager: Pager; container: Container) =
   var newImages: seq[CanvasImage] = @[]
   for image in container.images:
-    var imageId = -1
-    var data: Blob = nil
-    var bmp0 = image.bmp
     var erry = 0
     var offx = 0
     var dispw = 0
-    if image.bmp of NetworkBitmap:
-      let bmp = NetworkBitmap(image.bmp)
-      if pager.term.imageMode == imSixel:
-        let xpx = (image.x - container.fromx) * pager.attrs.ppc
-        offx = -min(xpx, 0)
-        let maxwpx = pager.bufWidth * pager.attrs.ppc
-        dispw = min(int(image.width) + xpx, maxwpx) - xpx
-        let ypx = (image.y - container.fromy) * pager.attrs.ppl
-        erry = -min(ypx, 0) mod 6
-      let cached = container.findCachedImage(image, offx, erry, dispw)
-      imageId = bmp.imageId
-      if cached == nil:
-        pager.loadCachedImage(container, image, offx, erry, dispw)
-        continue
-      bmp0 = cached.bmp
-      data = cached.data
-      if not cached.loaded:
-        continue # loading
-    else:
-      imageId = pager.imageId
-      inc pager.imageId
-    let canvasImage = pager.term.loadImage(bmp0, data, container.process,
+    if pager.term.imageMode == imSixel:
+      let xpx = (image.x - container.fromx) * pager.attrs.ppc
+      offx = -min(xpx, 0)
+      let maxwpx = pager.bufWidth * pager.attrs.ppc
+      #TODO this is wrong if term caps sixel width
+      dispw = min(int(image.width) + xpx, maxwpx) - xpx
+      let ypx = (image.y - container.fromy) * pager.attrs.ppl
+      erry = -min(ypx, 0) mod 6
+    let cached = container.findCachedImage(image, offx, erry, dispw)
+    let imageId = image.bmp.imageId
+    if cached == nil:
+      pager.loadCachedImage(container, image, offx, erry, dispw)
+      continue
+    if not cached.loaded:
+      continue # loading
+    let canvasImage = pager.term.loadImage(cached.data, container.process,
       imageId, image.x - container.fromx, image.y - container.fromy,
-      image.x, image.y, pager.bufWidth, pager.bufHeight, erry, offx, dispw)
+      image.width, image.height, image.x, image.y, pager.bufWidth,
+      pager.bufHeight, erry, offx, dispw)
     if canvasImage != nil:
       newImages.add(canvasImage)
   pager.term.clearImages(pager.bufHeight)
@@ -1165,7 +1152,7 @@ proc applySiteconf(pager: Pager; url: var URL; charsetOverride: Charset;
     proxy: pager.config.network.proxy,
     filter: newURLFilter(
       scheme = some(url.scheme),
-      allowschemes = @["data", "cache"],
+      allowschemes = @["data", "cache", "stream"],
       default = true
     ),
     insecureSSLNoVerify: false
diff --git a/src/local/term.nim b/src/local/term.nim
index 78c50efc..7cd43e02 100644
--- a/src/local/term.nim
+++ b/src/local/term.nim
@@ -11,7 +11,6 @@ import chagashi/charset
 import chagashi/decoder
 import chagashi/encoder
 import config/config
-import img/bitmap
 import io/dynstream
 import js/base64
 import types/blob
@@ -57,17 +56,24 @@ type
   CanvasImage* = ref object
     pid: int
     imageId: int
+    # relative position on screen
     x: int
     y: int
+    # original dimensions (after resizing)
+    width: int
+    height: int
+    # offset (crop start)
     offx: int
     offy: int
+    # size cap (crop end)
+    # Note: this 0-based, so the final display size is
+    # (dispw - offx, disph - offy)
     dispw: int
     disph: int
     damaged: bool
     marked*: bool
     dead: bool
     kittyId: int
-    bmp: Bitmap
     # 0 if kitty
     erry: int
     # absolute x, y in container
@@ -643,11 +649,11 @@ proc outputGrid*(term: Terminal) =
   term.cursorx = -1
   term.cursory = -1
 
-func findImage(term: Terminal; pid, imageId: int; bmp: Bitmap;
-    rx, ry, erry, offx, dispw: int): CanvasImage =
+func findImage(term: Terminal; pid, imageId: int; rx, ry, width, height,
+    erry, offx, dispw: int): CanvasImage =
   for it in term.canvasImages:
     if not it.dead and it.pid == pid and it.imageId == imageId and
-        it.bmp.width == bmp.width and it.bmp.height == bmp.height and
+        it.width == width and it.height == height and
         it.rx == rx and it.ry == ry and
         (term.imageMode != imSixel or it.erry == erry and it.dispw == dispw and
           it.offx == offx):
@@ -669,8 +675,8 @@ proc positionImage(term: Terminal; image: CanvasImage; x, y, maxw, maxh: int):
   # origin (*not* offx/offy)
   let maxwpx = maxw * term.attrs.ppc
   let maxhpx = maxh * term.attrs.ppl
-  var width = int(image.bmp.width)
-  var height = int(image.bmp.height)
+  var width = image.width
+  var height = image.height
   if term.imageMode == imSixel:
     #TODO a better solution would be to split up the image here so that it
     # still gets fully displayed on the screen, or at least downscale it...
@@ -686,7 +692,7 @@ proc clearImage*(term: Terminal; image: CanvasImage; maxh: int) =
   of imNone: discard
   of imSixel:
     # we must clear sixels the same way as we clear text.
-    let ey = min(image.y + int(image.bmp.height), maxh)
+    let ey = min(image.y + image.height, maxh)
     let x = max(image.x, 0)
     for y in max(image.y, 0) ..< ey:
       term.lineDamage[y] = min(x, term.lineDamage[y])
@@ -699,10 +705,10 @@ proc clearImages*(term: Terminal; maxh: int) =
       term.clearImage(image, maxh)
     image.marked = false
 
-proc loadImage*(term: Terminal; bmp: Bitmap; data: Blob; pid, imageId,
-    x, y, rx, ry, maxw, maxh, erry, offx, dispw: int): CanvasImage =
-  if (let image = term.findImage(pid, imageId, bmp, rx, ry, erry, offx, dispw);
-      image != nil):
+proc loadImage*(term: Terminal; data: Blob; pid, imageId, x, y, width, height,
+    rx, ry, maxw, maxh, erry, offx, dispw: int): CanvasImage =
+  if (let image = term.findImage(pid, imageId, rx, ry, width, height, erry,
+        offx, dispw); image != nil):
     # reuse image on screen
     if image.x != x or image.y != y:
       # only clear sixels; with kitty we just move the existing image
@@ -714,7 +720,7 @@ proc loadImage*(term: Terminal; bmp: Bitmap; data: Blob; pid, imageId,
         return nil
     elif term.imageMode == imSixel:
       # check if any line of our image is damaged
-      let ey = min(image.y + int(image.bmp.height), maxh)
+      let ey = min(image.y + image.height, maxh)
       let mx = (image.offx + image.dispw) div term.attrs.ppc
       for y in max(image.y, 0) ..< ey:
         if term.lineDamage[y] < mx:
@@ -726,12 +732,13 @@ proc loadImage*(term: Terminal; bmp: Bitmap; data: Blob; pid, imageId,
     return image
   # new image
   let image = CanvasImage(
-    bmp: bmp,
     pid: pid,
     imageId: imageId,
     data: data,
     rx: rx,
     ry: ry,
+    width: width,
+    height: height,
     erry: erry
   )
   if term.positionImage(image, x, y, maxw, maxh):
@@ -751,7 +758,6 @@ proc outputSixelImage(term: Terminal; x, y: int; image: CanvasImage;
   let offy = image.offy
   let dispw = image.dispw
   let disph = image.disph
-  let bmp = image.bmp
   var outs = term.cursorGoto(x, y)
   outs &= DCSSTART & 'q'
   # set raster attributes
@@ -768,11 +774,11 @@ proc outputSixelImage(term: Terminal; x, y: int; image: CanvasImage;
   let L = data.len - lookupTableLen - 4
   # Note: we only crop images when it is possible to do so in near constant
   # time. Otherwise, the image is re-coded in a cropped form.
-  if realh == int(bmp.height): # don't crop
+  if realh == image.height: # don't crop
     term.write(data.toOpenArray(preludeLen, L - 1))
   else:
     let si = preludeLen + int(data.getU32BE(L + (offy div 6) * 4))
-    if disph == int(bmp.height): # crop top only
+    if disph == image.height: # crop top only
       term.write(data.toOpenArray(si, L - 1))
     else: # crop both top & bottom
       let ed6 = (disph - image.erry) div 6
@@ -803,7 +809,7 @@ proc outputSixelImage(term: Terminal; x, y: int; image: CanvasImage) =
 
 proc outputKittyImage(term: Terminal; x, y: int; image: CanvasImage) =
   var outs = term.cursorGoto(x, y) &
-    APC & "GC=1,s=" & $image.bmp.width & ",v=" & $image.bmp.height &
+    APC & "GC=1,s=" & $image.width & ",v=" & $image.height &
     ",x=" & $image.offx & ",y=" & $image.offy &
     ",w=" & $(image.dispw - image.offx) &
     ",h=" & $(image.disph - image.offy) &
diff --git a/src/server/buffer.nim b/src/server/buffer.nim
index 9370f90b..3f0b3a71 100644
--- a/src/server/buffer.nim
+++ b/src/server/buffer.nim
@@ -1134,6 +1134,10 @@ proc onload(buffer: Buffer) =
         buffer.document.readyState = rsComplete
         if buffer.config.scripting:
           buffer.dispatchLoadEvent()
+          for ctx in buffer.window.pendingCanvasCtls:
+            ctx.ps.sclose()
+            ctx.ps = nil
+          buffer.window.pendingCanvasCtls.setLen(0)
         if buffer.hasTask(bcGetTitle):
           buffer.resolveTask(bcGetTitle, buffer.document.title)
         if buffer.hasTask(bcLoad):