# 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/algorithm
import std/os
import std/posix
import std/strutils
import types/path
import io/bufreader
import io/dynstream
import types/canvastypes
import types/color
import types/line
import types/vector
import utils/sandbox
import utils/twtuni
{.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.}
type
GlyphCacheItem = object
u: uint32
bmp: Bitmap
Bitmap = ref object
px: seq[RGBAColorBE]
width: int
height: int
proc newBitmap(width, height: int): Bitmap =
return Bitmap(
px: newSeq[RGBAColorBE](width * height),
width: width,
height: height
)
proc setpx(bmp: Bitmap; x, y: int; color: RGBAColorBE) {.inline.} =
bmp.px[bmp.width * y + x] = color
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: int): RGBAColorBE {.inline.} =
return bmp.px[bmp.width * y + x]
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: int; c: ARGBColor) {.inline.} =
bmp.setpxb(x, y, rgba_be(c.r, c.g, c.b, c.a))
const unifont = readFile"res/unifont_jp-15.0.05.png"
proc loadUnifont(unifont: string): Bitmap =
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 = Bitmap(
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
# https://en.wikipedia.org/wiki/Bresenham's_line_algorithm#All_cases
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 = y1;
for x in x1 ..< x2:
if x < 0 or y < 0 or x >= bmp.width or y >= bmp.height:
break
bmp.setpxb(x, y, color)
if D > 0:
y = y + yi;
D = D - 2 * dx;
D = D + 2 * dy;
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 = x1
for y in y1 ..< y2:
if x < 0 or y < 0 or x >= bmp.width or y >= bmp.height:
break
bmp.setpxb(x, y, color)
if D > 0:
x = x + xi
D = D - 2 * dy
D = D + 2 * dx
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(x1, y1, x2, y2, color)
else:
if y1 > y2:
bmp.plotLineHigh(x2, y2, x1, y1, color)
else:
bmp.plotLineHigh(x1, y1, x2, y2, color)
proc plotLine(bmp: Bitmap; a, b: Vector2D; color: ARGBColor) =
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; lines: seq[Line]; color: ARGBColor) =
for line in lines:
bmp.plotLine(line, color)
func isInside(windingNumber: int; fillRule: CanvasFillRule): bool =
return case fillRule
of cfrNonZero: windingNumber != 0
of cfrEvenOdd: windingNumber mod 2 == 0
# Algorithm originally from SerenityOS.
proc fillPath(bmp: Bitmap; lines: PathLines; color: ARGBColor;
fillRule: CanvasFillRule) =
var i = 0
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
for j in i ..< lines.len:
if lines[j].miny > float64(y):
break
if lines[j].maxy > float64(y):
ylines.add(lines[j])
inc i
ylines.sort(cmpLineSegmentX)
var w = if fillRule == cfrNonZero: 1 else: 0
for k in 0 ..< ylines.high:
let a = ylines[k]
let b = ylines[k + 1]
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(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:
if a.p0.y < a.p1.y:
inc w
else:
dec w
ylines[k].minyx += ylines[k].islope
if ylines.len > 0:
ylines[^1].minyx += ylines[^1].islope
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; x1, y1, x2, y2: int; color: ARGBColor) =
for x in x1 ..< x2:
bmp.setpxb(x, y1, color)
bmp.setpxb(x, y2, color)
for y in y1 ..< y2:
bmp.setpxb(x1, y, color)
bmp.setpxb(x2, y, color)
var unifontBitmap: Bitmap = nil
var glyphCache: seq[GlyphCacheItem] = @[]
var glyphCacheI = 0
proc getCharBmp(u: uint32): Bitmap =
# We only have the BMP.
let u = if u <= 0xFFFF: u else: 0xFFFD
for it in glyphCache:
if it.u == u:
return it.bmp
# Unifont glyphs start at x: 32, y: 64, and are of 8x16/16x16 size
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 ..< 16:
for x in 8 ..< 16:
if unifontBitmap.getpx(gx + x, gy + y) != white:
fullwidth = true
break loop
let bmp = newBitmap(if fullwidth: 16 else: 8, 16)
for y in 0 ..< bmp.height:
for x in 0 ..< bmp.width:
let c = unifontBitmap.getpx(gx + x, gy + y)
if c != white:
bmp.setpx(x, y, c)
if glyphCache.len < 256:
glyphCache.add(GlyphCacheItem(u: u, bmp: bmp))
else:
glyphCache[glyphCacheI] = GlyphCacheItem(u: u, bmp: bmp)
inc glyphCacheI
if glyphCacheI >= glyphCache.len:
glyphCacheI = 0
return bmp
proc drawBitmap(a, b: Bitmap; p: Vector2D) =
for y in 0 ..< b.height:
for x in 0 ..< b.width:
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: CanvasTextAlign) =
var w = 0f64
var glyphs: seq[Bitmap] = @[]
for u in text.points:
let glyph = getCharBmp(u)
glyphs.add(glyph)
w += float64(glyph.width)
var x = x
#TODO rtl
case textAlign
of ctaLeft, ctaStart: discard
of ctaRight, ctaEnd: x -= w
of ctaCenter: x -= w / 2
for glyph in glyphs:
bmp.drawBitmap(glyph, Vector2D(x: x, y: y - 8))
x += float64(glyph.width)
proc strokeText(bmp: Bitmap; text: string; x, y: float64; color: ARGBColor;
textAlign: CanvasTextAlign) =
#TODO
bmp.fillText(text, x, y, color, textAlign)
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: CanvasTextAlign
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()