about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--Makefile10
-rw-r--r--adapter/format/ansi2html.nim380
-rw-r--r--doc/mailcap.md16
-rw-r--r--res/mime.types1
-rw-r--r--res/ua.css6
-rw-r--r--src/config/config.nim18
-rw-r--r--src/config/mailcap.nim3
-rw-r--r--src/extern/runproc.nim2
-rw-r--r--src/html/dom.nim18
-rw-r--r--src/layout/renderdocument.nim (renamed from src/render/renderdocument.nim)0
-rw-r--r--src/local/client.nim12
-rw-r--r--src/local/pager.nim121
-rw-r--r--src/render/rendertext.nim121
-rw-r--r--src/server/buffer.nim147
14 files changed, 587 insertions, 268 deletions
diff --git a/Makefile b/Makefile
index 1f4c0f97..2add0049 100644
--- a/Makefile
+++ b/Makefile
@@ -46,7 +46,7 @@ all: $(OUTDIR_BIN)/cha $(OUTDIR_BIN)/mancha $(OUTDIR_CGI_BIN)/http \
 	$(OUTDIR_CGI_BIN)/data $(OUTDIR_CGI_BIN)/file $(OUTDIR_CGI_BIN)/ftp \
 	$(OUTDIR_CGI_BIN)/man $(OUTDIR_CGI_BIN)/spartan \
 	$(OUTDIR_LIBEXEC)/urldec $(OUTDIR_LIBEXEC)/urlenc \
-	$(OUTDIR_LIBEXEC)/md2html
+	$(OUTDIR_LIBEXEC)/md2html $(OUTDIR_LIBEXEC)/ansi2html
 
 $(OUTDIR_BIN)/cha: lib/libquickjs.a src/*.nim src/**/*.nim src/**/*.c res/* \
 		res/**/* res/map/idna_gen.nim nim.cfg
@@ -87,6 +87,12 @@ $(OUTDIR_LIBEXEC)/md2html: adapter/format/md2html.nim
 	$(NIMC) $(FLAGS) --nimcache:"$(OBJDIR)/$(TARGET)/md2html" \
 		-o:"$(OUTDIR_LIBEXEC)/md2html" adapter/format/md2html.nim
 
+$(OUTDIR_LIBEXEC)/ansi2html: adapter/format/ansi2html.nim src/io/posixstream.nim \
+		src/types/color.nim src/utils/twtstr.nim
+	@mkdir -p "$(OUTDIR_LIBEXEC)"
+	$(NIMC) $(FLAGS) --nimcache:"$(OBJDIR)/$(TARGET)/ansi2html" \
+		-o:"$(OUTDIR_LIBEXEC)/ansi2html" adapter/format/ansi2html.nim
+
 GMIFETCH_CFLAGS = -Wall -Wextra -std=c89 -pedantic -lcrypto -lssl -g -O3
 $(OUTDIR_CGI_BIN)/gmifetch: adapter/protocol/gmifetch.c
 	@mkdir -p "$(OUTDIR_CGI_BIN)"
@@ -224,6 +230,7 @@ install:
 	install -m755 "$(OUTDIR_CGI_BIN)/gopher" $(LIBEXECDIR_CHAWAN)/cgi-bin
 	install -m755 "$(OUTDIR_LIBEXEC)/gopher2html" $(LIBEXECDIR_CHAWAN)
 	install -m755 "$(OUTDIR_LIBEXEC)/md2html" $(LIBEXECDIR_CHAWAN)
+	install -m755 "$(OUTDIR_LIBEXEC)/ansi2html" $(LIBEXECDIR_CHAWAN)
 	install -m755 "$(OUTDIR_LIBEXEC)/gmi2html" $(LIBEXECDIR_CHAWAN)
 	install -m755 "$(OUTDIR_CGI_BIN)/gmifetch" $(LIBEXECDIR_CHAWAN)/cgi-bin
 	install -m755 "$(OUTDIR_CGI_BIN)/cha-finger" $(LIBEXECDIR_CHAWAN)/cgi-bin
@@ -262,6 +269,7 @@ uninstall:
 	rmdir $(LIBEXECDIR_CHAWAN)/cgi-bin || true
 	rm -f $(LIBEXECDIR_CHAWAN)/gopher2html
 	rm -f $(LIBEXECDIR_CHAWAN)/md2html
+	rm -f $(LIBEXECDIR_CHAWAN)/ansi2html
 	rm -f $(LIBEXECDIR_CHAWAN)/gmi2html
 	rm -f $(LIBEXECDIR_CHAWAN)/urldec
 	rm -f $(LIBEXECDIR_CHAWAN)/urlenc
diff --git a/adapter/format/ansi2html.nim b/adapter/format/ansi2html.nim
new file mode 100644
index 00000000..a7242be7
--- /dev/null
+++ b/adapter/format/ansi2html.nim
@@ -0,0 +1,380 @@
+import std/options
+import std/selectors
+
+import io/posixstream
+import types/color
+import utils/twtstr
+
+type
+  FormatFlag = enum
+    ffBold
+    ffItalic
+    ffUnderline
+    ffReverse
+    ffStrike
+    ffOverline
+    ffBlink
+
+  Format = object
+    fgcolor: CellColor
+    bgcolor: CellColor
+    flags: set[FormatFlag]
+
+# https://www.ecma-international.org/wp-content/uploads/ECMA-48_5th_edition_june_1991.pdf
+type
+  AnsiCodeParseState = enum
+    acpsDone, acpsStart, acpsParams, acpsInterm, acpsFinal, acpsBackspace,
+    acpsInBackspaceTransition, acpsInBackspace
+
+  AnsiCodeParser = object
+    state: AnsiCodeParseState
+    params: string
+
+proc getParam(parser: AnsiCodeParser, i: var int, colon = false): string =
+  while i < parser.params.len and
+      not (parser.params[i] == ';' or colon and parser.params[i] == ':'):
+    result &= parser.params[i]
+    inc i
+  if i < parser.params.len:
+    inc i
+
+template getParamU8(parser: AnsiCodeParser, i: var int,
+    colon = false): uint8 =
+  if i >= parser.params.len:
+    return false
+  let u = parseUInt8(parser.getParam(i))
+  if u.isNone:
+    return false
+  u.get
+
+proc parseSGRDefColor(parser: AnsiCodeParser, format: var Format,
+    i: var int, isfg: bool): bool =
+  let u = parser.getParamU8(i, colon = true)
+  template set_color(c: CellColor) =
+    if isfg:
+      format.fgcolor = c
+    else:
+      format.bgcolor = c
+  if u == 2:
+    let param0 = parser.getParamU8(i, colon = true)
+    if i < parser.params.len:
+      let r = param0
+      let g = parser.getParamU8(i, colon = true)
+      let b = parser.getParamU8(i, colon = true)
+      set_color cellColor(rgb(r, g, b))
+    else:
+      set_color cellColor(gray(param0))
+  elif u == 5:
+    let param0 = parser.getParamU8(i, colon = true)
+    if param0 in 0u8..15u8:
+      set_color cellColor(ANSIColor(param0))
+    elif param0 in 16u8..255u8:
+      set_color cellColor(EightBitColor(param0))
+  else:
+    return false
+
+proc parseSGRColor(parser: AnsiCodeParser, format: var Format,
+    i: var int, u: uint8): bool =
+  if u in 30u8..37u8:
+    format.fgcolor = cellColor(ANSIColor(u - 30))
+  elif u == 38:
+    return parser.parseSGRDefColor(format, i, isfg = true)
+  elif u == 39:
+    format.fgcolor = defaultColor
+  elif u in 40u8..47u8:
+    format.bgcolor = cellColor(ANSIColor(u - 40))
+  elif u == 48:
+    return parser.parseSGRDefColor(format, i, isfg = false)
+  elif u == 49:
+    format.bgcolor = defaultColor
+  elif u in 90u8..97u8:
+    format.fgcolor = cellColor(ANSIColor(u - 82))
+  elif u in 100u8..107u8:
+    format.bgcolor = cellColor(ANSIColor(u - 92))
+  else:
+    return false
+  return true
+
+const FormatCodes: array[FormatFlag, tuple[s, e: uint8]] = [
+  ffBold: (1u8, 22u8),
+  ffItalic: (3u8, 23u8),
+  ffUnderline: (4u8, 24u8),
+  ffReverse: (7u8, 27u8),
+  ffStrike: (9u8, 29u8),
+  ffOverline: (53u8, 55u8),
+  ffBlink: (5u8, 25u8),
+]
+
+proc parseSGRAspect(parser: AnsiCodeParser, format: var Format,
+    i: var int): bool =
+  let u = parser.getParamU8(i)
+  for flag, (s, e) in FormatCodes:
+    if u == s:
+      format.flags.incl(flag)
+      return true
+    if u == e:
+      format.flags.excl(flag)
+      return true
+  if u == 0:
+    format = Format()
+    return true
+  else:
+    return parser.parseSGRColor(format, i, u)
+
+proc parseSGR(parser: AnsiCodeParser, format: var Format) =
+  if parser.params.len == 0:
+    format = Format()
+  else:
+    var i = 0
+    while i < parser.params.len:
+      if not parser.parseSGRAspect(format, i):
+        break
+
+proc parseControlFunction(parser: var AnsiCodeParser, format: var Format,
+    f: char) =
+  if f == 'm':
+    parser.parseSGR(format)
+  else:
+    discard # unknown
+
+proc reset(parser: var AnsiCodeParser) =
+  parser.state = acpsStart
+  parser.params = ""
+
+type State = object
+  os: PosixStream
+  outbufIdx: int
+  outbuf: array[4096, char]
+  parser: AnsiCodeParser
+  currentFmt: Format
+  pendingFmt: Format
+  tmpFlags: set[FormatFlag]
+  af: bool
+  spanOpen: bool
+  hasPrintingBuf: bool
+  backspaceDecay: int
+
+proc flushOutbuf(state: var State) =
+  if state.outbufIdx > 0:
+    discard state.os.sendData(addr state.outbuf[0], state.outbufIdx)
+    state.outbufIdx = 0
+
+proc putc(state: var State, c: char) {.inline.} =
+  if state.outbufIdx + 4 >= state.outbuf.len: # max utf-8 char length
+    state.flushOutbuf()
+  state.outbuf[state.outbufIdx] = c
+  inc state.outbufIdx
+
+proc puts(state: var State, s: string) =
+  #TODO this is slower than it could be
+  for c in s:
+    state.putc(c)
+
+proc puts(state: var State, s: openArray[char]) =
+  #TODO this is slower than it could be
+  for c in s:
+    state.putc(c)
+
+proc puts(state: var State, s: static string) {.inline.} =
+  for c in s:
+    state.putc(c)
+
+proc flushFmt(state: var State) =
+  if state.pendingFmt != state.currentFmt:
+    if state.spanOpen:
+      state.puts("</span>")
+    if state.pendingFmt == Format():
+      state.currentFmt = state.pendingFmt
+      state.spanOpen = false
+      return
+    state.spanOpen = true
+    state.puts("<span style='")
+    let fmt = state.pendingFmt
+    var buf = ""
+    if fmt.fgcolor.t != ctNone:
+      buf &= "color: "
+      case fmt.fgcolor.t
+      of ctNone: discard
+      of ctANSI: buf &= "-cha-ansi(" & $fmt.fgcolor.color & ")"
+      of ctRGB: buf &= $fmt.fgcolor
+      buf &= ";"
+    if fmt.bgcolor.t != ctNone:
+      buf &= "background-color: "
+      case fmt.bgcolor.t
+      of ctNone: discard
+      of ctANSI: buf &= "-cha-ansi(" & $fmt.bgcolor.color & ")"
+      of ctRGB: buf &= $fmt.bgcolor
+      buf &= ";"
+    if ffOverline in fmt.flags or ffUnderline in fmt.flags or
+        ffStrike in fmt.flags or ffBlink in fmt.flags:
+      buf &= "text-decoration: "
+      if ffOverline in fmt.flags:
+        buf &= "overline "
+      if ffUnderline in fmt.flags:
+        buf &= "underline "
+      if ffStrike in fmt.flags:
+        buf &= "line-through "
+      if ffBlink in fmt.flags:
+        buf &= "blink "
+      buf &= ";"
+    if ffBold in fmt.flags:
+      buf &= "font-weight: bold;"
+    if ffItalic in fmt.flags:
+      buf &= "font-style: italic;"
+    #TODO reverse
+    buf &= "'>"
+    state.puts(buf)
+    state.currentFmt = fmt
+    state.hasPrintingBuf = false
+
+type ParseAnsiCodeResult = enum
+  pacrProcess, pacrSkip
+
+proc parseAnsiCode(state: var State, format: var Format, c: char):
+    ParseAnsiCodeResult =
+  case state.parser.state
+  of acpsStart:
+    if 0x40 <= int(c) and int(c) <= 0x5F:
+      if c != '[':
+        #C1, TODO?
+        state.parser.state = acpsDone
+      else:
+        state.parser.state = acpsParams
+    else:
+      state.parser.state = acpsDone
+      return pacrProcess
+  of acpsParams:
+    if 0x30 <= int(c) and int(c) <= 0x3F:
+      state.parser.params &= c
+    else:
+      state.parser.state = acpsInterm
+      return state.parseAnsiCode(format, c)
+  of acpsInterm:
+    if 0x20 <= int(c) and int(c) <= 0x2F:
+      discard
+    else:
+      state.parser.state = acpsFinal
+      return state.parseAnsiCode(format, c)
+  of acpsFinal:
+    state.parser.state = acpsDone
+    if 0x40 <= int(c) and int(c) <= 0x7E:
+      state.parser.parseControlFunction(format, c)
+    else:
+      return pacrProcess
+  of acpsDone:
+    discard
+  of acpsBackspace:
+    # We used to emulate less here, but it seems to yield dubious benefits
+    # considering that
+    # a) the only place backspace-based formatting is used in is manpages
+    # b) we have w3mman now, which is superior in all respects, so this is
+    # pretty much never used
+    # c) if we drop generality, the output can be parsed much more efficiently
+    # (without having to buffer the entire line first)
+    #
+    # So we buffer only the last non-formatted UTF-8 char, and override it when
+    # necessary.
+    if not state.hasPrintingBuf:
+      state.parser.state = acpsDone
+      return pacrProcess
+    var i = state.outbufIdx - 1
+    while true:
+      if i < 0:
+        state.parser.state = acpsDone
+        return pacrProcess
+      if (int(state.outbuf[i]) and 0xC0) != 0x80:
+        break
+      dec i
+    if state.outbuf[i] == '_' or c == '_':
+      # underline for underscore overstrike
+      if ffUnderline notin state.pendingFmt.flags:
+        state.tmpFlags.incl(ffUnderline)
+        state.pendingFmt.flags.incl(ffUnderline)
+      elif c == '_' and ffBold notin state.pendingFmt.flags:
+        state.tmpFlags.incl(ffBold)
+        state.pendingFmt.flags.incl(ffBold)
+    else:
+      # represent *any* non-underline overstrike with bold.
+      # it is sloppy, but enough for our purposes.
+      if ffBold notin state.pendingFmt.flags:
+        state.tmpFlags.incl(ffBold)
+        state.pendingFmt.flags.incl(ffBold)
+    state.outbufIdx = i # move back output pointer
+    state.parser.state = acpsInBackspaceTransition
+    state.flushFmt()
+    return pacrProcess
+  of acpsInBackspaceTransition:
+    if (int(c) and 0xC0) != 0x80:
+      # backspace char end, next char begin
+      state.parser.state = acpsInBackspace
+    return pacrProcess
+  of acpsInBackspace:
+    if (int(c) and 0xC0) != 0x80:
+      # second char after backspaced char begin
+      if c == '\b':
+        # got backspace again, overstriking previous char. here we don't have to
+        # override anything
+        state.parser.state = acpsBackspace
+        return pacrProcess
+      # welp. we have to fixup the previous char's formatting
+      var i = state.outbufIdx - 1
+      while true:
+        assert i >= 0
+        if (int(state.outbuf[i]) and 0xC0) != 0x80:
+          break
+        dec i
+      let s = state.outbuf[i..<state.outbufIdx]
+      state.outbufIdx = i
+      for flag in FormatFlag:
+        if flag in state.tmpFlags:
+          state.pendingFmt.flags.excl(flag)
+      state.tmpFlags = {}
+      state.flushFmt()
+      state.puts(s)
+      state.parser.state = acpsDone
+    return pacrProcess
+  state.flushFmt()
+  pacrSkip
+
+proc processData(state: var State, buf: openArray[char]) =
+  for c in buf:
+    if state.parser.state != acpsDone:
+      case state.parseAnsiCode(state.pendingFmt, c)
+      of pacrSkip: continue
+      of pacrProcess: discard
+    state.hasPrintingBuf = true
+    case c
+    of '<': state.puts("&lt;")
+    of '>': state.puts("&gt;")
+    of '\'': state.puts("&apos;")
+    of '"': state.puts("&quot;")
+    of '\e': state.parser.reset()
+    of '\b': state.parser.state = acpsBackspace
+    of '\0': state.puts("\uFFFD") # HTML eats NUL, so replace it here
+    else: state.putc(c)
+
+proc main() =
+  let ps = newPosixStream(stdin.getFileHandle())
+  var state = State(os: newPosixStream(stdout.getFileHandle()))
+  state.puts("<!DOCTYPE html>\n<body><pre style='margin: 0'>")
+  ps.setBlocking(false)
+  var buffer {.noinit.}: array[4096, char]
+  var selector = newSelector[int]()
+  block mainloop:
+    while true:
+      try:
+        let n = ps.recvData(buffer.toOpenArrayByte(0, buffer.high))
+        if n == 0:
+          break
+        state.processData(buffer.toOpenArray(0, n - 1))
+      except ErrorAgain:
+        state.flushOutbuf()
+        selector.registerHandle(ps.fd, {Read}, 0)
+        let events = selector.select(-1)
+        for event in events:
+          if Error in event.events:
+            break mainloop
+        selector.unregister(ps.fd)
+  state.flushOutbuf()
+
+main()
diff --git a/doc/mailcap.md b/doc/mailcap.md
index e4dc41bc..de8abeaf 100644
--- a/doc/mailcap.md
+++ b/doc/mailcap.md
@@ -52,16 +52,20 @@ execution of every mailcap command.
 ### Fields
 
 The `test`, `nametemplate`, `needsterminal` and `copiousoutput` fields are
-recognized. Additionally, the non-standard `x-htmloutput` extension field
-is recognized too.
+recognized. Additionally, the non-standard `x-htmloutput` and `x-ansioutput`
+extension fields are recognized too.
 
 * When the `test` named field is specified, the mailcap entry is only used
   if the test command returns 0.  
   Warning: as of now, %s does not work with test.
 * `copiousoutput` makes Chawan redirect the output of the external command
-  into a new buffer.
+  into a new buffer. If either x-htmloutput or x-ansioutput is defined too, then
+  it is ignored.
 * The `x-htmloutput` extension field behaves the same as `copiousoutput`,
   but makes Chawan interpret the command's output as HTML.
+* `x-ansioutput` makes Chawan pipe the output through the default "text/x-ansi"
+  content type handler. This means that you get colors, formatting, etc.
+  displayed with ANSI escape sequences.
 * `needsterminal` hands over control of the terminal to the command while
   it is running. Note: as of now, `needsterminal` does nothing if either
   `copiousoutput` or `x-htmloutput` is specified.
@@ -80,7 +84,7 @@ it could have been piped into the command.
 
 ## Note
 
-Entries with a content type of text/html are ignored.
+Entries with a content type of text/html or text/plain are ignored.
 
 ## Examples
 
@@ -92,7 +96,7 @@ Entries with a content type of text/html are ignored.
 text/markdown; pandoc - -f markdown -t html -o -; x-htmloutput
 
 # Show syntax highlighting for JavaScript source files using bat.
-text/javascript; bat -f -l es6 --file-name ${MAILCAP_URL:-STDIN} -; copiousoutput
+text/javascript; bat -f -l es6 --file-name ${MAILCAP_URL:-STDIN} -; x-ansioutput
 
 # Play music using mpv, and hand over control of the terminal until mpv exits.
 audio/*; mpv -; needsterminal
@@ -110,7 +114,7 @@ application/vnd.openxmlformats-officedocument.wordprocessingml.document;lowriter
 application/x-troff-man;pandoc - -f man -t html -o -; x-htmloutput
 
 # Following entry will be ignored, as text/html is supported natively by Chawan.
-text/html; cha -T text/html -I %{charset}; copiousoutput
+text/html; cha -dT text/html -I %{charset}; copiousoutput
 ```
 <!-- MANON
 ## See also
diff --git a/res/mime.types b/res/mime.types
index fe3052a6..a52768d4 100644
--- a/res/mime.types
+++ b/res/mime.types
@@ -10,3 +10,4 @@ text/css		css
 image/png		png
 text/markdown		md
 text/gemini		gmi
+text/x-ansi		ans	asc
diff --git a/res/ua.css b/res/ua.css
index a85c5146..43cccc71 100644
--- a/res/ua.css
+++ b/res/ua.css
@@ -191,12 +191,16 @@ h1, h2, h3, h4, h5, h6 {
 	font-weight: bold;
 }
 
-pre, plaintext {
+pre {
 	margin-top: 1em;
 	margin-bottom: 1em;
 	white-space: pre;
 }
 
+plaintext {
+	white-space: pre;
+}
+
 p {
 	margin-top: 1em;
 	margin-bottom: 1em;
diff --git a/src/config/config.nim b/src/config/config.nim
index 82200334..1ae0a727 100644
--- a/src/config/config.nim
+++ b/src/config/config.nim
@@ -357,12 +357,12 @@ proc readUserStylesheet(dir, file: string): string =
 # of several individual configuration files known as mailcap files.
 proc getMailcap*(config: Config): tuple[mailcap: Mailcap, errs: seq[string]] =
   let configDir = getConfigDir() / "chawan" #TODO store this in config?
-  const gopherPath0 = ChaPath("${%CHA_LIBEXEC_DIR}/gopher2html -u \\$MAILCAP_URL")
-  let gopherPath = gopherPath0.unquote().get
-  const geminiPath0 = ChaPath("${%CHA_LIBEXEC_DIR}/gmi2html")
-  let geminiPath = geminiPath0.unquote().get
-  const mdPath0 = ChaPath("${%CHA_LIBEXEC_DIR}/md2html")
-  let mdPath = mdPath0.unquote().get
+  template uq(s: string): string =
+    ChaPath(s).unquote.get
+  let gopherPath = "${%CHA_LIBEXEC_DIR}/gopher2html -u \\$MAILCAP_URL".uq
+  let geminiPath = "${%CHA_LIBEXEC_DIR}/gmi2html".uq
+  let mdPath = "${%CHA_LIBEXEC_DIR}/md2html".uq
+  let ansiPath = "${%CHA_LIBEXEC_DIR}/ansi2html".uq
   var mailcap: Mailcap = @[]
   var errs: seq[string]
   var found = false
@@ -393,6 +393,12 @@ proc getMailcap*(config: Config): tuple[mailcap: Mailcap, errs: seq[string]] =
     cmd: mdPath,
     flags: {HTMLOUTPUT}
   ))
+  mailcap.add(MailcapEntry(
+    mt: "text",
+    subt: "x-ansi",
+    cmd: ansiPath,
+    flags: {HTMLOUTPUT}
+  ))
   if not found:
     mailcap.add(MailcapEntry(
       mt: "*",
diff --git a/src/config/mailcap.nim b/src/config/mailcap.nim
index d5d17eae..89d268db 100644
--- a/src/config/mailcap.nim
+++ b/src/config/mailcap.nim
@@ -20,6 +20,7 @@ type
     NEEDSTERMINAL = "needsterminal"
     COPIOUSOUTPUT = "copiousoutput"
     HTMLOUTPUT = "x-htmloutput" # from w3m
+    ANSIOUTPUT = "x-ansioutput" # Chawan extension
 
   MailcapEntry* = object
     mt*: string
@@ -122,6 +123,8 @@ proc parseFieldKey(entry: var MailcapEntry, k: string): NamedField =
     entry.flags.incl(COPIOUSOUTPUT)
   of "x-htmloutput":
     entry.flags.incl(HTMLOUTPUT)
+  of "x-ansioutput":
+    entry.flags.incl(ANSIOUTPUT)
   of "test":
     return NAMED_FIELD_TEST
   of "nametemplate":
diff --git a/src/extern/runproc.nim b/src/extern/runproc.nim
index 7982dbda..9d189dff 100644
--- a/src/extern/runproc.nim
+++ b/src/extern/runproc.nim
@@ -55,4 +55,4 @@ proc runProcessInto*(cmd, ins: string): bool =
 
 proc myExec*(cmd: string) =
   discard execl("/bin/sh", "sh", "-c", cmd, nil)
-  quit(127)
+  exitnow(127)
diff --git a/src/html/dom.nim b/src/html/dom.nim
index f8ddaad5..1104ee90 100644
--- a/src/html/dom.nim
+++ b/src/html/dom.nim
@@ -1781,21 +1781,19 @@ proc write(document: Document, text: varargs[string]): Err[DOMException]
     CDB_parseDocumentWriteChunk(document.parser)
   return ok()
 
-func html*(document: Document): HTMLElement =
-  for element in document.elements(TAG_HTML):
+func findFirst*(document: Document, tagType: TagType): HTMLElement =
+  for element in document.elements(tagType):
     return HTMLElement(element)
+  nil
+
+func html*(document: Document): HTMLElement =
+  return document.findFirst(TAG_HTML)
 
 func head*(document: Document): HTMLElement {.jsfget.} =
-  let html = document.html
-  if html != nil:
-    for element in html.elements(TAG_HEAD):
-      return HTMLElement(element)
+  return document.findFirst(TAG_HEAD)
 
 func body*(document: Document): HTMLElement {.jsfget.} =
-  let html = document.html
-  if html != nil:
-    for element in html.elements(TAG_BODY):
-      return HTMLElement(element)
+  return document.findFirst(TAG_BODY)
 
 func select*(option: HTMLOptionElement): HTMLSelectElement =
   for anc in option.ancestors:
diff --git a/src/render/renderdocument.nim b/src/layout/renderdocument.nim
index 7526b111..7526b111 100644
--- a/src/render/renderdocument.nim
+++ b/src/layout/renderdocument.nim
diff --git a/src/local/client.nim b/src/local/client.nim
index e0a453db..71aaac8c 100644
--- a/src/local/client.nim
+++ b/src/local/client.nim
@@ -527,8 +527,8 @@ proc addConsole(pager: Pager, interactive: bool, clearFun, showFun, hideFun:
     if pipe(pipefd) == -1:
       raise newException(Defect, "Failed to open console pipe.")
     let url = newURL("stream:console").get
-    let container = pager.readPipe0(some("text/plain"), CHARSET_UNKNOWN,
-      pipefd[0], some(url), ConsoleTitle, canreinterpret = false)
+    let container = pager.readPipe0("text/plain", CHARSET_UNKNOWN, pipefd[0],
+      some(url), ConsoleTitle, canreinterpret = false)
     let err = newPosixStream(pipefd[1])
     err.writeLine("Type (M-c) console.hide() to return to buffer mode.")
     err.flush()
@@ -555,8 +555,8 @@ proc clearConsole(client: Client) =
     raise newException(Defect, "Failed to open console pipe.")
   let url = newURL("stream:console").get
   let pager = client.pager
-  let replacement = pager.readPipe0(some("text/plain"), CHARSET_UNKNOWN,
-    pipefd[0], some(url), ConsoleTitle, canreinterpret = false)
+  let replacement = pager.readPipe0("text/plain", CHARSET_UNKNOWN, pipefd[0],
+    some(url), ConsoleTitle, canreinterpret = false)
   replacement.replace = client.consoleWrapper.container
   pager.registerContainer(replacement)
   client.consoleWrapper.container = replacement
@@ -621,10 +621,10 @@ proc launchClient*(client: Client, pages: seq[string],
     let ismodule = client.config.start.startup_script.endsWith(".mjs")
     client.command0(s, client.config.start.startup_script, silence = true,
       module = ismodule)
-
   if not stdin.isatty():
+    # stdin may very well receive ANSI text
+    let contentType = contentType.get("text/x-ansi")
     client.pager.readPipe(contentType, cs, stdin.getFileHandle(), "*stdin*")
-
   for page in pages:
     client.pager.loadURL(page, ctype = contentType, cs = cs)
   client.pager.showAlerts()
diff --git a/src/local/pager.nim b/src/local/pager.nim
index c279faac..62746a4e 100644
--- a/src/local/pager.nim
+++ b/src/local/pager.nim
@@ -761,7 +761,7 @@ proc loadURL*(pager: Pager, url: string, ctype = none(string),
     if pager.container != prevc:
       pager.container.retry = urls
 
-proc readPipe0*(pager: Pager, ctype: Option[string], cs: Charset,
+proc readPipe0*(pager: Pager, contentType: string, cs: Charset,
     fd: FileHandle, location: Option[URL], title: string,
     canreinterpret: bool): Container =
   var location = location.get(newURL("stream:-").get)
@@ -773,12 +773,12 @@ proc readPipe0*(pager: Pager, ctype: Option[string], cs: Charset,
     title = title,
     canreinterpret = canreinterpret,
     fd = fd,
-    contentType = some(ctype.get("text/plain"))
+    contentType = some(contentType)
   )
 
-proc readPipe*(pager: Pager, ctype: Option[string], cs: Charset, fd: FileHandle,
+proc readPipe*(pager: Pager, contentType: string, cs: Charset, fd: FileHandle,
     title: string) =
-  let container = pager.readPipe0(ctype, cs, fd, none(URL), title, true)
+  let container = pager.readPipe0(contentType, cs, fd, none(URL), title, true)
   inc pager.numload
   pager.addContainer(container)
 
@@ -948,45 +948,85 @@ proc authorize(pager: Pager) =
 
 type CheckMailcapResult = tuple[promise: EmptyPromise, connect: bool]
 
+proc checkMailcap(pager: Pager, container: Container,
+  contentTypeOverride = none(string)): CheckMailcapResult
+
+# Pipe output of an x-ansioutput mailcap command to the text/x-ansi handler.
+proc ansiDecode(pager: Pager, container: Container, fdin: cint,
+    ishtml: var bool, fdout: var cint) =
+  let cs = container.charset
+  let url = container.location
+  let entry = pager.mailcap.getMailcapEntry("text/x-ansi", "", url, cs)
+  var canpipe = true
+  let cmd = unquoteCommand(entry.cmd, "text/x-ansi", "", url, cs, canpipe)
+  if not canpipe:
+    pager.alert("Error: could not pipe to text/x-ansi, decoding as text/plain")
+  else:
+    var pipefdOutAnsi: array[2, cint]
+    if pipe(pipefdOutAnsi) == -1:
+      raise newException(Defect, "Failed to open pipe.")
+    case fork()
+    of -1:
+      pager.alert("Error: failed to fork ANSI decoder process")
+      discard close(pipefdOutAnsi[0])
+      discard close(pipefdOutAnsi[1])
+    of 0: # child process
+      if fdin != -1:
+        discard close(fdin)
+      discard close(pipefdOutAnsi[0])
+      discard dup2(fdout, stdin.getFileHandle())
+      discard close(fdout)
+      discard dup2(pipefdOutAnsi[1], stdout.getFileHandle())
+      discard close(pipefdOutAnsi[1])
+      closeStderr()
+      myExec(cmd)
+      assert false
+    else:
+      discard close(pipefdOutAnsi[1])
+      discard close(fdout)
+      fdout = pipefdOutAnsi[0]
+      ishtml = HTMLOUTPUT in entry.flags
+
 # Pipe input into the mailcap command, then read its output into a buffer.
 # needsterminal is ignored.
 proc runMailcapReadPipe(pager: Pager, container: Container,
     entry: MailcapEntry, cmd: string): CheckMailcapResult =
-  var pipefd_in: array[2, cint]
-  if pipe(pipefd_in) == -1:
-    raise newException(Defect, "Failed to open pipe.")
-  var pipefd_out: array[2, cint]
-  if pipe(pipefd_out) == -1:
+  var pipefdIn: array[2, cint]
+  var pipefdOut: array[2, cint]
+  if pipe(pipefdIn) == -1 or pipe(pipefdOut) == -1:
     raise newException(Defect, "Failed to open pipe.")
   let pid = fork()
   if pid == -1:
+    pager.alert("Failed to fork process!")
     return (nil, false)
-  elif pid == 0:
-    # child process
-    discard close(pipefd_in[1])
-    discard close(pipefd_out[0])
-    stdout.flushFile()
-    discard dup2(pipefd_in[0], stdin.getFileHandle())
-    discard dup2(pipefd_out[1], stdout.getFileHandle())
+  elif pid == 0: # child process
+    discard close(pipefdIn[1])
+    discard close(pipefdOut[0])
+    discard dup2(pipefdIn[0], stdin.getFileHandle())
+    discard dup2(pipefdOut[1], stdout.getFileHandle())
     closeStderr()
-    discard close(pipefd_in[0])
-    discard close(pipefd_out[1])
+    discard close(pipefdIn[0])
+    discard close(pipefdOut[1])
     myExec(cmd)
     assert false
-  # parent
-  discard close(pipefd_in[0])
-  discard close(pipefd_out[1])
-  let fdin = pipefd_in[1]
-  let fdout = pipefd_out[0]
-  let p = container.redirectToFd(fdin, wait = false, cache = true)
-  let p2 = p.then(proc(): auto =
+  else:
+    # parent
+    discard close(pipefdIn[0])
+    discard close(pipefdOut[1])
+    let fdin = pipefdIn[1]
+    var fdout = pipefdOut[0]
+    var ishtml = HTMLOUTPUT in entry.flags
+    if not ishtml and ANSIOUTPUT in entry.flags:
+      # decode ANSI sequence
+      pager.ansiDecode(container, fdin, ishtml, fdout)
+    let p = container.redirectToFd(fdin, wait = false, cache = true)
     discard close(fdin)
-    let ishtml = HTMLOUTPUT in entry.flags
-    return container.readFromFd(fdout, $pid, ishtml)
-  ).then(proc() =
-    discard close(fdout)
-  )
-  return (p2, true)
+    let p2 = p.then(proc(): auto =
+      let p = container.readFromFd(fdout, $pid, ishtml)
+      discard close(fdout)
+      return p
+    )
+    return (p2, true)
 
 # Pipe input into the mailcap command, and discard its output.
 # If needsterminal, leave stderr and stdout open and wait for the process.
@@ -1048,11 +1088,13 @@ proc runMailcapReadFile(pager: Pager, container: Container,
       quit(ret)
     # parent
     discard close(pipefd[1])
-    let fdout = pipefd[0]
-    let ishtml = HTMLOUTPUT in entry.flags
-    return container.readFromFd(fdout, $pid, ishtml).then(proc() =
-      discard close(fdout)
-    )
+    var fdout = pipefd[0]
+    var ishtml = HTMLOUTPUT in entry.flags
+    if not ishtml and ANSIOUTPUT in entry.flags:
+      pager.ansiDecode(container, -1, ishtml, fdout)
+    let p = container.readFromFd(fdout, $pid, ishtml)
+    discard close(fdout)
+    return p
   )
   return (p, true)
 
@@ -1134,12 +1176,13 @@ proc filterBuffer(pager: Pager, container: Container): CheckMailcapResult =
 # pager is suspended until the command exits.
 #TODO add support for edit/compose, better error handling (use Promise[bool]
 # instead of tuple[EmptyPromise, bool])
-proc checkMailcap(pager: Pager, container: Container): CheckMailcapResult =
+proc checkMailcap(pager: Pager, container: Container,
+    contentTypeOverride = none(string)): CheckMailcapResult =
   if container.filter != nil:
     return pager.filterBuffer(container)
   if container.contentType.isNone:
     return (nil, true)
-  let contentType = container.contentType.get
+  let contentType = contentTypeOverride.get(container.contentType.get)
   if contentType == "text/html":
     # We support HTML natively, so it would make little sense to execute
     # mailcap filters for it.
@@ -1164,7 +1207,7 @@ proc checkMailcap(pager: Pager, container: Container): CheckMailcapResult =
     var canpipe = true
     let cmd = unquoteCommand(entry.cmd, contentType, outpath, url, cs, canpipe)
     putEnv("MAILCAP_URL", $url) #TODO delEnv this after command is finished?
-    if {COPIOUSOUTPUT, HTMLOUTPUT} * entry.flags == {}:
+    if {COPIOUSOUTPUT, HTMLOUTPUT, ANSIOUTPUT} * entry.flags == {}:
       # no output.
       if canpipe:
         return pager.runMailcapWritePipe(container, entry[], cmd)
diff --git a/src/render/rendertext.nim b/src/render/rendertext.nim
deleted file mode 100644
index 27992215..00000000
--- a/src/render/rendertext.nim
+++ /dev/null
@@ -1,121 +0,0 @@
-import std/streams
-import std/strutils
-import std/unicode
-
-import types/cell
-import utils/strwidth
-
-type StreamRenderer* = ref object
-  ansiparser: AnsiCodeParser
-  format: Format
-  af: bool
-  stream: Stream
-  newline: bool
-  w: int
-  j: int # byte in line
-
-proc newStreamRenderer*(): StreamRenderer =
-  return StreamRenderer(ansiparser: AnsiCodeParser(state: PARSE_DONE))
-
-proc rewind*(renderer: StreamRenderer) =
-  renderer.format = Format()
-  renderer.ansiparser.state = PARSE_DONE
-
-proc addFormat(grid: var FlexibleGrid, renderer: StreamRenderer) =
-  if renderer.af:
-    renderer.af = false
-    if renderer.j == grid[^1].str.len:
-      grid[^1].addFormat(renderer.w, renderer.format)
-
-proc processBackspace(grid: var FlexibleGrid, renderer: StreamRenderer,
-    r: Rune): bool =
-  let pj = renderer.j
-  var cr: Rune
-  fastRuneAt(grid[^1].str, renderer.j, cr)
-  if r == Rune('_') or cr == Rune('_') or r == cr:
-    let flag = if r == cr: FLAG_BOLD else: FLAG_UNDERLINE
-    if r != cr and cr == Rune('_'):
-      # original is _, we must replace :(
-      # like less, we assume no double _ for double width characters.
-      grid[^1].str.delete(pj..<renderer.j)
-      let s = $r
-      grid[^1].str.insert(s, pj)
-      renderer.j = pj + s.len
-    let n = grid[^1].findFormatN(renderer.w) - 1
-    if n != -1 and grid[^1].formats[n].pos == renderer.w:
-      let flags = grid[^1].formats[n].format.flags
-      if r == cr and r == Rune('_') and flag in flags:
-        # double overstrike of _, this is nonsensical on a teletype but less(1)
-        # treats it as an underline so we do that too
-        grid[^1].formats[n].format.flags.incl(FLAG_UNDERLINE)
-      else:
-        grid[^1].formats[n].format.flags.incl(flag)
-    elif n != -1:
-      var format = grid[^1].formats[n].format
-      format.flags.incl(flag)
-      grid[^1].insertFormat(renderer.w, n + 1, format)
-    else:
-      grid[^1].addFormat(renderer.w, Format(flags: {flag}))
-    renderer.w += r.twidth(renderer.w)
-    if renderer.j == grid[^1].str.len:
-      grid[^1].addFormat(renderer.w, Format())
-    return true
-  let n = grid[^1].findFormatN(renderer.w)
-  grid[^1].formats.setLen(n)
-  grid[^1].str.setLen(renderer.j)
-  return false
-
-proc processAscii(grid: var FlexibleGrid, renderer: StreamRenderer, c: char) =
-  case c
-  of '\b':
-    if renderer.j == 0:
-      grid[^1].str &= c
-      inc renderer.j
-      renderer.w += Rune(c).twidth(renderer.w)
-    else:
-      let (r, len) = lastRune(grid[^1].str, grid[^1].str.high)
-      renderer.j -= len
-      renderer.w -= r.twidth(renderer.w)
-  of '\n':
-    grid.addFormat(renderer)
-    renderer.newline = true
-  of '\r': discard
-  of '\e':
-    renderer.ansiparser.reset()
-  else:
-    grid.addFormat(renderer)
-    grid[^1].str &= c
-    renderer.w += Rune(c).twidth(renderer.w)
-    inc renderer.j
-
-proc renderChunk*(grid: var FlexibleGrid, renderer: StreamRenderer,
-    buf: openArray[char]) =
-  if grid.len == 0:
-    grid.addLine()
-  var i = 0
-  while i < buf.len:
-    if renderer.newline:
-      # avoid newline at end of stream
-      grid.addLine()
-      renderer.newline = false
-      renderer.w = 0
-      renderer.j = 0
-    let pi = i
-    var r: Rune
-    fastRuneAt(buf, i, r)
-    if renderer.j < grid[^1].str.len:
-      if grid.processBackspace(renderer, r):
-        continue
-    if uint32(r) < 0x80:
-      let c = char(r)
-      if renderer.ansiparser.state != PARSE_DONE:
-        if not renderer.ansiparser.parseAnsiCode(renderer.format, c):
-          if renderer.ansiparser.state == PARSE_DONE:
-            renderer.af = true
-          continue
-      grid.processAscii(renderer, c)
-    else:
-      grid.addFormat(renderer)
-      grid[^1].str &= r
-      renderer.w += r.twidth(renderer.w)
-      renderer.j += i - pi
diff --git a/src/server/buffer.nim b/src/server/buffer.nim
index 9ed49c96..783da17b 100644
--- a/src/server/buffer.nim
+++ b/src/server/buffer.nim
@@ -36,10 +36,9 @@ import js/javascript
 import js/regex
 import js/timeout
 import js/tojs
+import layout/renderdocument
 import loader/headers
 import loader/loader
-import render/renderdocument
-import render/rendertext
 import types/cell
 import types/color
 import types/cookie
@@ -109,7 +108,6 @@ type
     quirkstyle: CSSStylesheet
     userstyle: CSSStylesheet
     htmlParser: HTML5ParserWrapper
-    srenderer: StreamRenderer
     bgcolor: CellColor
     needsBOMSniff: bool
     decoder: TextDecoder
@@ -610,34 +608,43 @@ proc gotoAnchor*(buffer: Buffer): Opt[tuple[x, y: int]] {.proxy.} =
   return err()
 
 proc do_reshape(buffer: Buffer) =
-  if buffer.ishtml:
-    if buffer.document == nil:
-      return # not parsed yet, nothing to render
-    let uastyle = if buffer.document.mode != QUIRKS:
-      buffer.uastyle
-    else:
-      buffer.quirkstyle
-    if buffer.document.cachedSheetsInvalid:
-      buffer.prevStyled = nil
-    let styledRoot = buffer.document.applyStylesheets(uastyle,
-      buffer.userstyle, buffer.prevStyled)
-    buffer.lines.renderDocument(buffer.bgcolor, styledRoot, buffer.attrs)
-    buffer.prevStyled = styledRoot
+  if buffer.document == nil:
+    return # not parsed yet, nothing to render
+  let uastyle = if buffer.document.mode != QUIRKS:
+    buffer.uastyle
+  else:
+    buffer.quirkstyle
+  if buffer.document.cachedSheetsInvalid:
+    buffer.prevStyled = nil
+  let styledRoot = buffer.document.applyStylesheets(uastyle,
+    buffer.userstyle, buffer.prevStyled)
+  buffer.lines.renderDocument(buffer.bgcolor, styledRoot, buffer.attrs)
+  buffer.prevStyled = styledRoot
 
 proc processData0(buffer: Buffer, data: openArray[char]): bool =
   if buffer.ishtml:
     if buffer.htmlParser.parseBuffer(data) == PRES_STOP:
       buffer.charsetStack = @[buffer.htmlParser.builder.charset]
       return false
-    buffer.document = buffer.htmlParser.builder.document
   else:
-    buffer.lines.renderChunk(buffer.srenderer, data)
+    var plaintext = buffer.document.findFirst(TAG_PLAINTEXT)
+    if plaintext == nil:
+      const s = "<plaintext id='text'>"
+      doAssert buffer.htmlParser.parseBuffer(s) != PRES_STOP
+      plaintext = buffer.document.findFirst(TAG_PLAINTEXT)
+    if data.len > 0:
+      let lastChild = plaintext.lastChild
+      var text = newString(data.len)
+      copyMem(addr text[0], unsafeAddr data[0], data.len)
+      if lastChild != nil and lastChild of Text:
+        Text(lastChild).data &= text
+      else:
+        plaintext.insert(buffer.document.createTextNode(text), nil)
   true
 
 func canSwitch(buffer: Buffer): bool {.inline.} =
-  if buffer.ishtml and buffer.htmlParser.builder.confidence != ccTentative:
-    return false
-  return buffer.charsetStack.len > 0
+  return buffer.htmlParser.builder.confidence == ccTentative and
+    buffer.charsetStack.len > 0
 
 proc initDecoder(buffer: Buffer) =
   if buffer.charset != CHARSET_UTF_8:
@@ -648,11 +655,8 @@ proc initDecoder(buffer: Buffer) =
 proc switchCharset(buffer: Buffer) =
   buffer.charset = buffer.charsetStack.pop()
   buffer.initDecoder()
-  if buffer.ishtml:
-    buffer.htmlParser.restart(buffer.charset)
-  else:
-    buffer.srenderer.rewind()
-    buffer.lines.setLen(0)
+  buffer.htmlParser.restart(buffer.charset)
+  buffer.document = buffer.htmlParser.builder.document
 
 const BufferSize = 16384
 
@@ -812,41 +816,39 @@ proc rewind(buffer: Buffer): bool =
 proc setHTML(buffer: Buffer, ishtml: bool) =
   buffer.ishtml = ishtml
   buffer.initDecoder()
-  if ishtml:
-    let factory = newCAtomFactory()
-    buffer.factory = factory
-    let navigate = if buffer.config.scripting:
-      proc(url: URL) = buffer.navigate(url)
-    else:
-      nil
-    buffer.window = newWindow(
-      buffer.config.scripting,
-      buffer.config.images,
-      buffer.selector,
-      buffer.attrs,
-      factory,
-      navigate,
-      some(buffer.loader)
-    )
-    let confidence = if buffer.config.charsetOverride == CHARSET_UNKNOWN:
-      ccTentative
-    else:
-      ccCertain
-    buffer.htmlParser = newHTML5ParserWrapper(
-      buffer.window,
-      buffer.url,
-      buffer.factory,
-      confidence,
-      buffer.charset
-    )
-    assert buffer.htmlParser.builder.document != nil
-    const css = staticRead"res/ua.css"
-    const quirk = css & staticRead"res/quirk.css"
-    buffer.uastyle = css.parseStylesheet(factory)
-    buffer.quirkstyle = quirk.parseStylesheet(factory)
-    buffer.userstyle = parseStylesheet(buffer.config.userstyle, factory)
+  let factory = newCAtomFactory()
+  buffer.factory = factory
+  let navigate = if buffer.config.scripting:
+    proc(url: URL) = buffer.navigate(url)
+  else:
+    nil
+  buffer.window = newWindow(
+    buffer.config.scripting,
+    buffer.config.images,
+    buffer.selector,
+    buffer.attrs,
+    factory,
+    navigate,
+    some(buffer.loader)
+  )
+  let confidence = if buffer.config.charsetOverride == CHARSET_UNKNOWN:
+    ccTentative
   else:
-    buffer.srenderer = newStreamRenderer()
+    ccCertain
+  buffer.htmlParser = newHTML5ParserWrapper(
+    buffer.window,
+    buffer.url,
+    buffer.factory,
+    confidence,
+    buffer.charset
+  )
+  assert buffer.htmlParser.builder.document != nil
+  const css = staticRead"res/ua.css"
+  const quirk = css & staticRead"res/quirk.css"
+  buffer.uastyle = css.parseStylesheet(factory)
+  buffer.quirkstyle = quirk.parseStylesheet(factory)
+  buffer.userstyle = parseStylesheet(buffer.config.userstyle, factory)
+  buffer.document = buffer.htmlParser.builder.document
 
 proc extractCookies(response: Response): seq[Cookie] =
   result = @[]
@@ -1122,21 +1124,14 @@ proc finishLoad(buffer: Buffer): EmptyPromise =
   if buffer.decoder != nil and buffer.decoder.finish() == tdfrError or
       buffer.validator != nil and buffer.validator[].finish() == tvrError:
     doAssert buffer.processData0("\uFFFD")
-  var p: EmptyPromise
-  if buffer.ishtml:
-    buffer.htmlParser.finish()
-    buffer.document = buffer.htmlParser.builder.document
-    buffer.document.readyState = rsInteractive
-    buffer.dispatchDOMContentLoadedEvent()
-    p = buffer.loadResources()
-  else:
-    p = EmptyPromise()
-    p.resolve()
+  buffer.htmlParser.finish()
+  buffer.document.readyState = rsInteractive
+  buffer.dispatchDOMContentLoadedEvent()
   buffer.selector.unregister(buffer.fd)
   buffer.loader.unregistered.add(buffer.fd)
   buffer.fd = -1
   buffer.istream.close()
-  return p
+  return buffer.loadResources()
 
 type LoadResult* = tuple[
   atend: bool,
@@ -1235,12 +1230,10 @@ proc cancel*(buffer: Buffer): int {.proxy.} =
   buffer.loader.unregistered.add(buffer.fd)
   buffer.fd = -1
   buffer.istream.close()
-  if buffer.ishtml:
-    buffer.htmlParser.finish()
-    buffer.document = buffer.htmlParser.builder.document
-    buffer.document.readyState = rsInteractive
-    buffer.state = bsLoaded
-    buffer.do_reshape()
+  buffer.htmlParser.finish()
+  buffer.document.readyState = rsInteractive
+  buffer.state = bsLoaded
+  buffer.do_reshape()
   return buffer.lines.len
 
 #https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#multipart/form-data-encoding-algorithm