about summary refs log tree commit diff stats
path: root/src
diff options
context:
space:
mode:
authorbptato <nincsnevem662@gmail.com>2025-02-12 21:15:38 +0100
committerbptato <nincsnevem662@gmail.com>2025-02-12 23:27:34 +0100
commit8f3c96035f22a16459ad7c8996c280a015f0d2f5 (patch)
tree183424ee7b20eb53b313d0ff76885e7e3acdc0d5 /src
parent76e2b68af52bf51854388890172bc0b2f2aaf36b (diff)
downloadchawan-8f3c96035f22a16459ad7c8996c280a015f0d2f5.tar.gz
layout: separate out tree construction logic
For now, the skeleton remains in layout.  Eventually it should be
lazily constructed during the actual layout pass (thereby making layout
"single-pass" (sometimes :p))

The end goal is to do it all in styledNode.children, so that caching can
be as simple as "next box = find next matching cached box ?? make new".

This does mean that the internal structure of cached boxes will always
have to be reconstructed, but I don't see a better way.  (I suppose it
still remains possible to optimize out the unnecessary layout pass when
only a repaint is needed (e.g. color change) by modifying computed
values in-place.)
Diffstat (limited to 'src')
-rw-r--r--src/css/cascade.nim167
-rw-r--r--src/css/csstree.nim484
-rw-r--r--src/css/cssvalues.nim39
-rw-r--r--src/css/layout.nim492
-rw-r--r--src/server/buffer.nim1
5 files changed, 564 insertions, 619 deletions
diff --git a/src/css/cascade.nim b/src/css/cascade.nim
index ef4f1376..49fc6647 100644
--- a/src/css/cascade.nim
+++ b/src/css/cascade.nim
@@ -14,7 +14,6 @@ import html/catom
 import html/dom
 import html/enums
 import html/script
-import types/bitmap
 import types/color
 import types/jscolor
 import types/opt
@@ -387,169 +386,3 @@ proc applyStyle*(element: Element) =
     if map[pseudo].hasValues() or window.settings.scripting == smApp:
       let computed = map[pseudo].applyDeclarations(element, nil, window)
       element.computedMap[pseudo] = computed
-
-# Abstraction over the DOM to pretend that elements, text, replaced and
-# pseudo-elements are derived from the same type.
-type
-  StyledType* = enum
-    stElement, stText, stReplacement
-
-  StyledNode* = object
-    element*: Element
-    pseudo*: PseudoElement
-    computed*: CSSValues
-    case t*: StyledType
-    of stText:
-      text*: CharacterData
-    of stElement:
-      anonChildren: seq[StyledNode]
-    of stReplacement:
-      # replaced elements: quotes, images, or (TODO) markers
-      content*: CSSContent
-
-when defined(debug):
-  func `$`*(node: StyledNode): string =
-    case node.t
-    of stText:
-      return node.text.data
-    of stElement:
-      if node.pseudo != peNone:
-        return $node.element.tagType & "::" & $node.pseudo
-      return $node.element
-    of stReplacement:
-      return "#replacement"
-
-proc initStyledElement*(element: Element): StyledNode =
-  if element.computed == nil:
-    element.applyStyle()
-  return StyledNode(t: stElement, element: element, computed: element.computed)
-
-proc initStyledReplacement(parent: Element; content: sink CSSContent;
-    computed: CSSValues): StyledNode =
-  return StyledNode(
-    t: stReplacement,
-    element: parent,
-    content: content,
-    computed: computed
-  )
-
-proc initStyledImage(parent: Element; bmp: NetworkBitmap): StyledNode =
-  return initStyledReplacement(
-    parent,
-    CSSContent(t: ContentImage, bmp: bmp),
-    parent.computed
-  )
-
-proc initStyledPseudo(parent: Element; pseudo: PseudoElement): StyledNode =
-  return StyledNode(
-    t: stElement,
-    pseudo: pseudo,
-    element: parent,
-    computed: parent.computedMap[pseudo]
-  )
-
-proc initStyledText(parent: Element; text: CharacterData): StyledNode =
-  return StyledNode(t: stText, element: parent, text: text)
-
-proc initStyledText(parent: Element; s: sink string): StyledNode =
-  #TODO should probably cache these...
-  return initStyledText(parent, newCharacterData(s))
-
-proc initStyledAnon(parent: Element; children: sink seq[StyledNode];
-    computed: CSSValues): StyledNode =
-  return StyledNode(
-    t: stElement,
-    element: parent,
-    anonChildren: children,
-    computed: computed
-  )
-
-iterator optionChildren(styledNode: StyledNode): StyledNode {.inline.} =
-  let option = HTMLOptionElement(styledNode.element)
-  if option.select != nil and option.select.attrb(satMultiple):
-    yield initStyledText(option, "[")
-    let cdata = newCharacterData(if option.selected: "*" else: " ")
-    let computed = option.computed.inheritProperties()
-    computed{"color"} = cssColor(ANSIColor(1)) # red
-    computed{"white-space"} = WhitespacePre
-    yield initStyledAnon(option, @[initStyledText(option, cdata)], computed)
-    yield initStyledText(option, "]")
-  for it in option.childList:
-    if it of Element:
-      yield initStyledElement(Element(it))
-    elif it of Text:
-      yield initStyledText(option, Text(it))
-
-# Many yields; we use a closure iterator to avoid bloating the code.
-iterator children*(styledNode: StyledNode): StyledNode {.closure.} =
-  if styledNode.t != stElement:
-    return
-  if styledNode.pseudo == peNone:
-    let parent = styledNode.element
-    if styledNode.anonChildren.len > 0:
-      for it in styledNode.anonChildren:
-        yield it
-    else:
-      if parent.computedMap[peBefore] != nil and
-          parent.computedMap[peBefore]{"content"}.len > 0:
-        yield initStyledPseudo(parent, peBefore)
-      case parent.tagType
-      of TAG_INPUT:
-        let cdata = HTMLInputElement(parent).inputString()
-        if cdata != nil and cdata.data.len != 0:
-          yield initStyledText(parent, cdata)
-      of TAG_TEXTAREA:
-        #TODO cache (do the same as with input, and add borders in render)
-        let cdata = HTMLTextAreaElement(parent).textAreaString()
-        yield initStyledText(parent, cdata)
-      of TAG_IMG: yield initStyledImage(parent, HTMLImageElement(parent).bitmap)
-      of TAG_CANVAS:
-        yield initStyledImage(parent, HTMLCanvasElement(parent).bitmap)
-      of TAG_VIDEO: yield initStyledText(parent, "[video]")
-      of TAG_AUDIO: yield initStyledText(parent, "[audio]")
-      of TAG_BR:
-        yield initStyledReplacement(
-          parent,
-          CSSContent(t: ContentNewline),
-          parent.computed
-        )
-      of TAG_IFRAME: yield initStyledText(parent, "[iframe]")
-      of TAG_FRAME: yield initStyledText(parent, "[frame]")
-      of TAG_OPTION:
-        for it in styledNode.optionChildren:
-          yield it
-      elif parent.tagType(Namespace.SVG) == TAG_SVG:
-        yield initStyledImage(parent, SVGSVGElement(parent).bitmap)
-      else:
-        for it in parent.childList:
-          if it of Element:
-            yield initStyledElement(Element(it))
-          elif it of Text:
-            yield initStyledText(parent, Text(it))
-      if parent.computedMap[peAfter] != nil and
-          parent.computedMap[peAfter]{"content"}.len > 0:
-        yield initStyledPseudo(parent, peAfter)
-  else:
-    let parent = styledNode.element
-    let computed = parent.computedMap[styledNode.pseudo].inheritProperties()
-    for content in parent.computedMap[styledNode.pseudo]{"content"}:
-      yield parent.initStyledReplacement(content, computed)
-
-when defined(debug):
-  proc computedTree*(styledNode: StyledNode): string =
-    result = ""
-    if styledNode.t != stElement:
-      result &= $styledNode
-    else:
-      result &= "<"
-      if styledNode.computed{"display"} != DisplayInline:
-        result &= "div"
-      else:
-        result &= "span"
-      let computed = styledNode.computed.copyProperties()
-      if computed{"display"} == DisplayBlock:
-        computed{"display"} = DisplayInline
-      result &= " style='" & $computed.serializeEmpty() & "'>\n"
-      for it in styledNode.children:
-        result &= it.computedTree()
-      result &= "\n</div>"
diff --git a/src/css/csstree.nim b/src/css/csstree.nim
new file mode 100644
index 00000000..d4702cea
--- /dev/null
+++ b/src/css/csstree.nim
@@ -0,0 +1,484 @@
+# Tree building.
+#
+# This wouldn't be nearly as complex as it is if not for CSS's asinine
+# anonymous table box generation rules.  In particular:
+# * Runs of misparented boxes inside a table/table row/table row group
+#   must be placed in appropriate anonymous wrappers.  For example, if
+#   we encounter two consecutive `display: block's inside a `display:
+#   table-row', these must be wrapped around a single `display:
+#   table-cell'.
+# * Runs of misparented table row/table row group/table cell boxes must
+#   be wrapped in an anonymous table, or in some cases an anonymous
+#   table row and *then* an anonymous table.  e.g. a `display:
+#   table-row', `display: table-cell', then a `display: table-row-group'
+#   will all be wrapped in a single table.
+# * If this weren't enough, we also have to *split up* the entire table
+#   into an inner and an outer table.  The outer table wraps the inner
+#   table and the caption.  The inner table (of DisplayTableWrapper)
+#   includes the rows/row groups.
+# Whatever your reason may be for looking at this: good luck.
+
+import chame/tags
+import css/cascade
+import css/cssvalues
+import css/selectorparser
+import html/catom
+import html/dom
+import types/bitmap
+import types/color
+import utils/twtstr
+
+type
+  StyledType* = enum
+    stElement, stText, stImage, stBr
+
+  # Abstraction over the DOM to pretend that elements, text, replaced
+  # and pseudo-elements are derived from the same type.
+  StyledNode* = object
+    element*: Element
+    computed*: CSSValues
+    pseudo*: PseudoElement
+    skipChildren: bool
+    case t*: StyledType
+    of stText:
+      text*: CharacterData
+    of stElement:
+      anonChildren: seq[StyledNode]
+    of stImage:
+      bmp*: NetworkBitmap
+    of stBr: # <br> element
+      discard
+
+  TreeContext* = object
+    quoteLevel: int
+    listItemCounter: int
+
+  TreeFrame = object
+    parent: Element
+    computed: CSSValues
+    children: seq[StyledNode]
+    lastChildWasInline: bool
+    captionSeen: bool
+    anonTableDisplay: CSSDisplay
+    anonComputed: CSSValues
+    anonInlineComputed: CSSValues
+    pctx: ptr TreeContext
+
+template ctx(frame: TreeFrame): var TreeContext =
+  frame.pctx[]
+
+when defined(debug):
+  func `$`*(node: StyledNode): string =
+    case node.t
+    of stText:
+      return node.text.data
+    of stElement:
+      if node.pseudo != peNone:
+        return $node.element.tagType & "::" & $node.pseudo
+      return $node.element
+    of stImage:
+      return "#image"
+    of stBr:
+      return "#br"
+
+# Root
+proc initStyledElement*(element: Element): StyledNode =
+  if element.computed == nil:
+    element.applyStyle()
+  result = StyledNode(
+    t: stElement,
+    element: element,
+    computed: element.computed
+  )
+
+func inheritFor(frame: TreeFrame; display: CSSDisplay): CSSValues =
+  result = frame.computed.inheritProperties()
+  result{"display"} = display
+
+proc initTreeFrame(ctx: var TreeContext; parent: Element; computed: CSSValues):
+    TreeFrame =
+  result = TreeFrame(parent: parent, computed: computed, pctx: addr ctx)
+
+proc getAnonInlineComputed(frame: var TreeFrame): CSSValues =
+  if frame.anonInlineComputed == nil:
+    if frame.computed{"display"} == DisplayInline:
+      frame.anonInlineComputed = frame.computed
+    else:
+      frame.anonInlineComputed = frame.computed.inheritProperties()
+  return frame.anonInlineComputed
+
+proc displayed(frame: TreeFrame; text: CharacterData): bool =
+  if text.data.len == 0:
+    return false
+  return frame.computed{"display"} == DisplayInline or
+    frame.lastChildWasInline or
+    frame.computed{"white-space"} in WhiteSpacePreserve or
+    not text.data.onlyWhitespace()
+
+#TODO implement table columns
+const DisplayNoneLike = {
+  DisplayNone, DisplayTableColumn, DisplayTableColumnGroup
+}
+
+proc displayed(frame: TreeFrame; pseudo: PseudoElement): bool =
+  return frame.parent.computedMap[pseudo] != nil and
+    frame.parent.computedMap[pseudo]{"content"}.len > 0 and
+    frame.parent.computedMap[pseudo]{"display"} notin DisplayNoneLike
+
+proc displayed(frame: TreeFrame; element: Element): bool =
+  return element.computed{"display"} notin DisplayNoneLike
+
+proc getInternalTableParent(frame: var TreeFrame; display: CSSDisplay):
+    var seq[StyledNode] =
+  if frame.anonTableDisplay != display:
+    if frame.anonComputed == nil:
+      frame.anonComputed = frame.inheritFor(display)
+    frame.anonTableDisplay = display
+    frame.children.add(StyledNode(
+      t: stElement,
+      element: frame.parent,
+      computed: frame.anonComputed,
+      skipChildren: true
+    ))
+  return frame.children[^1].anonChildren
+
+# Add an anonymous table to children, and return based on display either
+# * row, row group: the table children
+# * cell: its last anonymous row (if there isn't one, create it)
+# * caption: its outer box
+proc addAnonTable(frame: var TreeFrame; parentDisplay, display: CSSDisplay):
+    var seq[StyledNode] =
+  if frame.anonComputed == nil or
+      frame.anonComputed{"display"} notin DisplayInnerTable + {DisplayTableRow}:
+    let anonDisplay = if parentDisplay == DisplayInline:
+      DisplayInlineTable
+    else:
+      DisplayTable
+    let (outer, inner) = frame.inheritFor(anonDisplay).splitTable()
+    frame.anonComputed = outer
+    frame.children.add(StyledNode(
+      t: stElement,
+      computed: outer,
+      skipChildren: true,
+      anonChildren: @[StyledNode(
+        t: stElement,
+        computed: inner,
+        skipChildren: true
+      )]
+    ))
+  if display == DisplayTableCaption:
+    frame.anonComputed = frame.children[^1].computed
+    return frame.children[^1].anonChildren
+  if display in RowGroupBox + {DisplayTableRow}:
+    frame.anonComputed = frame.children[^1].computed
+    return frame.children[^1].anonChildren[0].anonChildren
+  assert display == DisplayTableCell
+  if frame.anonComputed{"display"} == DisplayTableRow:
+    return frame.children[^1].anonChildren[0].anonChildren[^1].anonChildren
+  frame.anonComputed = frame.inheritFor(DisplayTableRow)
+  frame.children[^1].anonChildren[0].anonChildren.add(StyledNode(
+    t: stElement,
+    element: frame.parent,
+    computed: frame.anonComputed,
+    skipChildren: true
+  ))
+  return frame.children[^1].anonChildren[0].anonChildren[^1].anonChildren
+
+proc getParent(frame: var TreeFrame; computed: CSSValues; display: CSSDisplay):
+    var seq[StyledNode] =
+  let parentDisplay = frame.computed{"display"}
+  case parentDisplay
+  of DisplayInnerFlex:
+    if display in DisplayOuterInline:
+      if frame.anonComputed == nil:
+        frame.anonComputed = frame.inheritFor(DisplayBlock)
+      frame.children.add(StyledNode(
+        t: stElement,
+        element: frame.parent,
+        computed: frame.anonComputed,
+        skipChildren: true
+      ))
+      return frame.children[^1].anonChildren
+  of DisplayTableRow:
+    if display != DisplayTableCell:
+      return frame.getInternalTableParent(DisplayTableCell)
+    frame.anonTableDisplay = DisplayNone
+  of RowGroupBox:
+    if display != DisplayTableRow:
+      return frame.getInternalTableParent(DisplayTableRow)
+    frame.anonTableDisplay = DisplayNone
+  of DisplayTableWrapper:
+    if display notin RowGroupBox + {DisplayTableRow}:
+      return frame.getInternalTableParent(DisplayTableRow)
+    frame.anonTableDisplay = DisplayNone
+  of DisplayInnerTable:
+    if frame.children.len > 0 and display != DisplayTableCaption:
+      return frame.children[0].anonChildren
+  of DisplayListItem:
+    if frame.computed{"list-style-position"} == ListStylePositionOutside and
+        frame.children.len >= 2:
+      return frame.children[1].anonChildren
+  elif display in DisplayInternalTable:
+    return frame.addAnonTable(parentDisplay, display)
+  else:
+    frame.captionSeen = false
+    frame.anonComputed = nil
+  return frame.children
+
+proc addListItem(frame: var TreeFrame; node: sink StyledNode) =
+  var node = node
+  # Generate a marker box.
+  inc frame.ctx.listItemCounter
+  let computed = node.computed.inheritProperties()
+  computed{"display"} = DisplayBlock
+  computed{"white-space"} = WhitespacePre
+  let t = computed{"list-style-type"}
+  let markerText = StyledNode(
+    t: stText,
+    element: node.element,
+    text: newCharacterData(t.listMarker(frame.ctx.listItemCounter)),
+    computed: computed.inheritProperties()
+  )
+  case node.computed{"list-style-position"}
+  of ListStylePositionOutside:
+    # Generate a separate box for the content and marker.
+    node.anonChildren.add(StyledNode(
+      t: stElement,
+      element: node.element,
+      computed: computed,
+      skipChildren: true,
+      anonChildren: @[markerText]
+    ))
+    let computed = node.computed.inheritProperties()
+    computed{"display"} = DisplayBlock
+    node.anonChildren.add(StyledNode(
+      t: stElement,
+      element: node.element,
+      computed: computed,
+      skipChildren: true
+    ))
+  of ListStylePositionInside:
+    node.anonChildren.add(markerText)
+  frame.getParent(node.computed, node.computed{"display"}).add(node)
+
+proc addTable(frame: var TreeFrame; node: sink StyledNode) =
+  var node = node
+  let (outer, inner) = node.computed.splitTable()
+  node.computed = outer
+  node.anonChildren.add(StyledNode(
+    t: stElement,
+    element: node.element,
+    computed: inner,
+    skipChildren: true
+  ))
+  frame.getParent(node.computed, node.computed{"display"}).add(node)
+
+proc add(frame: var TreeFrame; node: sink StyledNode) =
+  let display = node.computed{"display"}
+  if frame.captionSeen and display == DisplayTableCaption:
+    return
+  if node.t == stElement and node.anonChildren.len == 0:
+    case display
+    of DisplayListItem:
+      frame.addListItem(node)
+      frame.lastChildWasInline = false
+      return # already added
+    of DisplayInnerTable:
+      frame.addTable(node)
+      frame.lastChildWasInline = false
+      return # already added
+    else: discard
+  frame.getParent(node.computed, display).add(node)
+  frame.lastChildWasInline = display in DisplayOuterInline
+  if display == DisplayTableCaption:
+    frame.captionSeen = true
+
+proc addAnon(frame: var TreeFrame; children: sink seq[StyledNode];
+    computed: CSSValues) =
+  frame.add(StyledNode(
+    t: stElement,
+    element: frame.parent,
+    anonChildren: children,
+    computed: computed,
+    skipChildren: true
+  ))
+
+proc addElement(frame: var TreeFrame; element: Element) =
+  if element.computed == nil:
+    element.applyStyle()
+  if frame.displayed(element):
+    frame.add(StyledNode(
+      t: stElement,
+      element: element,
+      computed: element.computed
+    ))
+
+proc addPseudo(frame: var TreeFrame; pseudo: PseudoElement) =
+  if frame.displayed(pseudo):
+    frame.add(StyledNode(
+      t: stElement,
+      pseudo: pseudo,
+      element: frame.parent,
+      computed: frame.parent.computedMap[pseudo]
+    ))
+
+proc addText(frame: var TreeFrame; text: CharacterData) =
+  if frame.displayed(text):
+    frame.add(StyledNode(
+      t: stText,
+      element: frame.parent,
+      text: text,
+      computed: frame.getAnonInlineComputed()
+    ))
+
+proc addText(frame: var TreeFrame; s: sink string) =
+  #TODO should probably cache these...
+  frame.addText(newCharacterData(s))
+
+proc addImage(frame: var TreeFrame; bmp: NetworkBitmap) =
+  if bmp != nil and bmp.cacheId != -1:
+    frame.add(StyledNode(
+      t: stImage,
+      element: frame.parent,
+      bmp: bmp,
+      computed: frame.getAnonInlineComputed()
+    ))
+  else:
+    frame.addText("[img]")
+
+proc addBr(frame: var TreeFrame) =
+  frame.add(StyledNode(
+    t: stBr,
+    element: frame.parent,
+    computed: frame.computed
+  ))
+
+proc addElementChildren(frame: var TreeFrame) =
+  for it in frame.parent.childList:
+    if it of Element:
+      let element = Element(it)
+      frame.addElement(element)
+    elif it of Text:
+      #TODO collapse subsequent text nodes into one StyledNode
+      # (it isn't possible in HTML, only with JS DOM manipulation)
+      let text = Text(it)
+      frame.addText(text)
+
+proc addOptionChildren(frame: var TreeFrame; option: HTMLOptionElement) =
+  if option.select != nil and option.select.attrb(satMultiple):
+    frame.addText("[")
+    let cdata = newCharacterData(if option.selected: "*" else: " ")
+    let computed = option.computed.inheritProperties()
+    computed{"color"} = cssColor(ANSIColor(1)) # red
+    computed{"white-space"} = WhitespacePre
+    block anon:
+      var aframe = frame.ctx.initTreeFrame(option, computed)
+      aframe.addText(cdata)
+      frame.addAnon(move(aframe.children), computed)
+    frame.addText("]")
+  frame.addElementChildren()
+
+proc addChildren(frame: var TreeFrame) =
+  case frame.parent.tagType
+  of TAG_INPUT:
+    let cdata = HTMLInputElement(frame.parent).inputString()
+    if cdata != nil:
+      frame.addText(cdata)
+  of TAG_TEXTAREA:
+    #TODO cache (do the same as with input, and add borders in render)
+    frame.addText(HTMLTextAreaElement(frame.parent).textAreaString())
+  of TAG_IMG: frame.addImage(HTMLImageElement(frame.parent).bitmap)
+  of TAG_CANVAS: frame.addImage(HTMLCanvasElement(frame.parent).bitmap)
+  of TAG_VIDEO: frame.addText("[video]")
+  of TAG_AUDIO: frame.addText("[audio]")
+  of TAG_BR: frame.addBr()
+  of TAG_IFRAME: frame.addText("[iframe]")
+  of TAG_FRAME: frame.addText("[frame]")
+  of TAG_OPTION:
+    let option = HTMLOptionElement(frame.parent)
+    frame.addOptionChildren(option)
+  elif frame.parent.tagType(Namespace.SVG) == TAG_SVG:
+    frame.addImage(SVGSVGElement(frame.parent).bitmap)
+  else:
+    frame.addElementChildren()
+
+proc addContent(frame: var TreeFrame; content: CSSContent; ctx: var TreeContext;
+    computed: CSSValues) =
+  case content.t
+  of ContentString:
+    frame.addText(content.s)
+  of ContentOpenQuote:
+    let quotes = frame.computed{"quotes"}
+    if quotes == nil:
+      frame.addText(quoteStart(ctx.quoteLevel))
+    elif quotes.qs.len > 0:
+      frame.addText(quotes.qs[min(ctx.quoteLevel, quotes.qs.high)].s)
+    else:
+      return
+    inc ctx.quoteLevel
+  of ContentCloseQuote:
+    if ctx.quoteLevel > 0:
+      dec ctx.quoteLevel
+    let quotes = computed{"quotes"}
+    if quotes == nil:
+      frame.addText(quoteEnd(ctx.quoteLevel))
+    elif quotes.qs.len > 0:
+      frame.addText(quotes.qs[min(ctx.quoteLevel, quotes.qs.high)].e)
+  of ContentNoOpenQuote:
+    inc ctx.quoteLevel
+  of ContentNoCloseQuote:
+    if ctx.quoteLevel > 0:
+      dec ctx.quoteLevel
+
+proc build(frame: var TreeFrame; styledNode: StyledNode;
+    ctx: var TreeContext) =
+  for child in styledNode.anonChildren:
+    frame.add(child)
+  if styledNode.skipChildren:
+    return
+  let parent = styledNode.element
+  if styledNode.pseudo == peNone:
+    frame.addPseudo(peBefore)
+    frame.addChildren()
+    frame.addPseudo(peAfter)
+  else:
+    let computed = parent.computedMap[styledNode.pseudo].inheritProperties()
+    for content in parent.computedMap[styledNode.pseudo]{"content"}:
+      frame.addContent(content, ctx, computed)
+
+iterator children*(styledNode: StyledNode; ctx: var TreeContext): StyledNode
+    {.inline.} =
+  if styledNode.t == stElement:
+    for reset in styledNode.computed{"counter-reset"}:
+      if reset.name == "list-item":
+        ctx.listItemCounter = reset.num
+    let listItemCounter = ctx.listItemCounter
+    let parent = styledNode.element
+    var frame = ctx.initTreeFrame(parent, styledNode.computed)
+    frame.build(styledNode, ctx)
+    for child in frame.children:
+      yield child
+    ctx.listItemCounter = listItemCounter
+
+when defined(debug):
+  proc computedTree*(styledNode: StyledNode; ctx: var TreeContext): string =
+    result = ""
+    if styledNode.t != stElement:
+      result &= $styledNode
+    else:
+      result &= "<"
+      if styledNode.computed{"display"} != DisplayInline:
+        result &= "div"
+      else:
+        result &= "span"
+      let computed = styledNode.computed.copyProperties()
+      if computed{"display"} == DisplayBlock:
+        computed{"display"} = DisplayInline
+      result &= " style='" & $computed.serializeEmpty() & "'>\n"
+      for it in styledNode.children(ctx):
+        result &= it.computedTree(ctx)
+      result &= "\n</div>"
+
+  proc computedTree*(styledNode: StyledNode): string =
+    var ctx = TreeContext()
+    return styledNode.computedTree(ctx)
diff --git a/src/css/cssvalues.nim b/src/css/cssvalues.nim
index 2980e130..00011cc5 100644
--- a/src/css/cssvalues.nim
+++ b/src/css/cssvalues.nim
@@ -191,7 +191,6 @@ type
     DisplayInlineFlex = "inline-flex"
     # internal, for layout
     DisplayTableWrapper = ""
-    DisplayInlineTableWrapper = ""
 
   CSSWhiteSpace* = enum
     WhitespaceNormal = "normal"
@@ -283,7 +282,7 @@ type
 
   CSSContentType* = enum
     ContentString, ContentOpenQuote, ContentCloseQuote, ContentNoOpenQuote,
-    ContentNoCloseQuote, ContentImage, ContentNewline
+    ContentNoCloseQuote
 
   CSSFloat* = enum
     FloatNone = "none"
@@ -354,11 +353,8 @@ type
     num*: float32
 
   CSSContent* = object
-    case t*: CSSContentType
-    of ContentImage:
-      bmp*: NetworkBitmap
-    else:
-      s*: string
+    t*: CSSContentType
+    s*: string
 
   # nil -> auto
   CSSQuotes* = ref object
@@ -538,11 +534,25 @@ const OverflowScrollLike* = {OverflowScroll, OverflowAuto, OverflowOverlay}
 const OverflowHiddenLike* = {OverflowHidden, OverflowClip}
 const FlexReverse* = {FlexDirectionRowReverse, FlexDirectionColumnReverse}
 const DisplayOuterInline* = {
-  DisplayInline, DisplayInlineTable, DisplayInlineBlock,
-  DisplayInlineTableWrapper, DisplayInlineFlex
+  DisplayInline, DisplayInlineTable, DisplayInlineBlock, DisplayInlineFlex
 }
 const DisplayInnerFlex* = {DisplayFlex, DisplayInlineFlex}
+const RowGroupBox* = {
+  # Note: caption is not included here
+  DisplayTableRowGroup, DisplayTableHeaderGroup, DisplayTableFooterGroup
+}
+const ProperTableChild* = RowGroupBox + {
+  DisplayTableRow, DisplayTableColumn, DisplayTableColumnGroup
+}
+const DisplayInnerTable* = {DisplayTable, DisplayInlineTable}
+const DisplayInternalTable* = {
+  DisplayTableCell, DisplayTableRow, DisplayTableCaption
+} + RowGroupBox
+const ProperTableRowParent* = RowGroupBox + {DisplayTableWrapper} #TODO remove
 const PositionAbsoluteFixed* = {PositionAbsolute, PositionFixed}
+const WhiteSpacePreserve* = {
+  WhitespacePre, WhitespacePreLine, WhitespacePreWrap
+}
 
 # Forward declarations
 proc parseValue(cvals: openArray[CSSComponentValue]; t: CSSPropertyType;
@@ -586,8 +596,6 @@ func `$`*(bmp: NetworkBitmap): string =
   return "" #TODO
 
 func `$`*(content: CSSContent): string =
-  if content.t == ContentImage:
-    return $content.bmp
   if content.s != "":
     return "url(" & content.s & ")"
   return "none"
@@ -711,7 +719,7 @@ func inherited*(t: CSSPropertyType): bool =
 func blockify*(display: CSSDisplay): CSSDisplay =
   case display
   of DisplayBlock, DisplayTable, DisplayListItem, DisplayNone, DisplayFlowRoot,
-      DisplayFlex, DisplayTableWrapper, DisplayInlineTableWrapper:
+      DisplayFlex, DisplayTableWrapper:
      #TODO grid
     return display
   of DisplayInline, DisplayInlineBlock, DisplayTableRow,
@@ -724,12 +732,6 @@ func blockify*(display: CSSDisplay): CSSDisplay =
   of DisplayInlineFlex:
     return DisplayFlex
 
-func toTableWrapper*(display: CSSDisplay): CSSDisplay =
-  if display == DisplayTable:
-    return DisplayTableWrapper
-  assert display == DisplayInlineTable
-  return DisplayInlineTableWrapper
-
 func bfcify*(overflow: CSSOverflow): CSSOverflow =
   if overflow == OverflowVisible:
     return OverflowAuto
@@ -1875,6 +1877,7 @@ func splitTable*(computed: CSSValues): tuple[outer, innner: CSSValues] =
       inner.copyFrom(computed, t)
       outer.setInitial(t)
   outer{"display"} = computed{"display"}
+  inner{"display"} = DisplayTableWrapper
   return (outer, inner)
 
 when defined(debug):
diff --git a/src/css/layout.nim b/src/css/layout.nim
index eddffced..0153b7e8 100644
--- a/src/css/layout.nim
+++ b/src/css/layout.nim
@@ -2,7 +2,7 @@ import std/algorithm
 import std/math
 
 import css/box
-import css/cascade
+import css/csstree
 import css/cssvalues
 import css/lunit
 import html/dom
@@ -47,8 +47,6 @@ type
     cellSize: Size # size(w = attrsp.ppc, h = attrsp.ppl)
     positioned: seq[PositionedItem]
     myRootProperties: CSSValues
-    # placeholder text data
-    imgText: CharacterData
     luctx: LUContext
 
 const DefaultSpan = Span(start: 0, send: LUnit.high)
@@ -120,8 +118,7 @@ func isDefinite(sc: SizeConstraint): bool =
 # children.
 func establishesBFC(computed: CSSValues): bool =
   return computed{"float"} != FloatNone or
-    computed{"display"} in {DisplayFlowRoot, DisplayTable, DisplayTableWrapper,
-      DisplayFlex} or
+    computed{"display"} in {DisplayFlowRoot, DisplayTable, DisplayFlex} or
     computed{"overflow-x"} notin {OverflowVisible, OverflowClip}
     #TODO contain, grid, multicol, column-span
 
@@ -404,7 +401,7 @@ type
     firstBaselineSet: bool
 
 # Forward declarations
-proc layoutTableWrapper(bctx: BlockContext; box: BlockBox; sizes: ResolvedSizes)
+proc layoutTable(bctx: BlockContext; box: BlockBox; sizes: ResolvedSizes)
 proc layoutFlex(bctx: var BlockContext; box: BlockBox; sizes: ResolvedSizes)
 proc layoutRootBlock(lctx: LayoutContext; box: BlockBox; offset: Offset;
   sizes: ResolvedSizes; includeMargin = false)
@@ -2107,16 +2104,6 @@ proc layoutListItem(bctx: var BlockContext; box: BlockBox;
 #      Distribute the table's content width among cells with an unspecified
 #      width. If this would give any cell a width < min_width, set that
 #      cell's width to min_width, then re-do the distribution.
-const RowGroupBox = {
-  # Note: caption is not included here
-  DisplayTableRowGroup, DisplayTableHeaderGroup, DisplayTableFooterGroup
-}
-const ProperTableChild = RowGroupBox + {
-  DisplayTableRow, DisplayTableColumn, DisplayTableColumnGroup
-}
-const DisplayInnerTable = {DisplayTable, DisplayInlineTable}
-const ProperTableRowParent = RowGroupBox + DisplayInnerTable
-
 type
   CellWrapper = ref object
     box: BlockBox
@@ -2414,7 +2401,7 @@ proc preLayoutTableRows(tctx: var TableContext; table: BlockBox) =
     of DisplayTableFooterGroup:
       for it in child.children:
         tfoot.add(BlockBox(it))
-    else: assert false
+    else: assert false, $child.computed{"display"}
   tctx.preLayoutTableRows(thead, table)
   tctx.preLayoutTableRows(tbody, table)
   tctx.preLayoutTableRows(tfoot, table)
@@ -2553,13 +2540,13 @@ proc layoutCaption(tctx: TableContext; parent, box: BlockBox) =
   parent.state.size.h += outerHeight
   parent.state.intr.h += outerHeight - box.state.size.h + box.state.intr.h
 
-proc layoutTable(tctx: var TableContext; table, parent: BlockBox;
+proc layoutInnerTable(tctx: var TableContext; table, parent: BlockBox;
     sizes: ResolvedSizes) =
   if table.computed{"border-collapse"} != BorderCollapseCollapse:
     let spc = table.computed{"border-spacing"}
     if spc != nil:
-      tctx.inlineSpacing = table.computed{"border-spacing"}.a.px(0)
-      tctx.blockSpacing = table.computed{"border-spacing"}.b.px(0)
+      tctx.inlineSpacing = spc.a.px(0)
+      tctx.blockSpacing = spc.b.px(0)
   tctx.preLayoutTableRows(table) # first pass
   # Percentage sizes have been resolved; switch the table's space to
   # fit-content if its width is auto.
@@ -2583,12 +2570,11 @@ proc layoutTable(tctx: var TableContext; table, parent: BlockBox;
 
 # As per standard, we must put the caption outside the actual table, inside a
 # block-level wrapper box.
-proc layoutTableWrapper(bctx: BlockContext; box: BlockBox;
-    sizes: ResolvedSizes) =
+proc layoutTable(bctx: BlockContext; box: BlockBox; sizes: ResolvedSizes) =
   let table = BlockBox(box.children[0])
   table.state = BoxLayoutState()
   var tctx = TableContext(lctx: bctx.lctx, space: sizes.space)
-  tctx.layoutTable(table, box, sizes)
+  tctx.layoutInnerTable(table, box, sizes)
   box.state.size = table.state.size
   box.state.baseline = table.state.size.h
   box.state.firstBaseline = table.state.size.h
@@ -2606,8 +2592,8 @@ proc layout(bctx: var BlockContext; box: BlockBox; sizes: ResolvedSizes;
     bctx.layoutFlow(box, sizes, canClear)
   of DisplayListItem:
     bctx.layoutListItem(box, sizes)
-  of DisplayTableWrapper, DisplayInlineTableWrapper:
-    bctx.layoutTableWrapper(box, sizes)
+  of DisplayInnerTable:
+    bctx.layoutTable(box, sizes)
   of DisplayInnerFlex:
     bctx.layoutFlex(box, sizes)
   else:
@@ -2878,125 +2864,29 @@ proc layoutRootBlock(lctx: LayoutContext; box: BlockBox; offset: Offset;
   box.state.marginBottom = marginBottom
 
 # 1st pass: build tree
-#TODO ideally we should move this to layout and/or cascade.
-# (Anon box generation probably belongs in cascade, so that caching
-# existing boxes becomes feasible.)
+#TODO this tree traversal is largely redundant, and should be moved
+# to csstree.  (Then it should be collapsed into the second pass by
+# lazily generating child boxes, taking boxes from the previous pass
+# when possible.)
 
-type
-  BoxBuilderContext = object
-    styledNode: StyledNode
-    lctx: LayoutContext
-    anonRow: BlockBox
-    anonTableWrapper: BlockBox
-    quoteLevel: int
-    listItemCounter: int
+proc build(ctx: var TreeContext; styledNode: StyledNode;
+  children: var seq[CSSBox])
 
-  ParentType = enum
-    ptBlock, ptInline
-
-# Forward declarations
-proc build(ctx: var BoxBuilderContext; box: BlockBox)
-proc buildInline(pctx: var BoxBuilderContext; styledNode: StyledNode):
-  InlineBox
-proc initBoxBuilderContext(styledNode: StyledNode; lctx: LayoutContext;
-  parent: ptr BoxBuilderContext): BoxBuilderContext
-proc flushTable(ctx: var BoxBuilderContext; parent: BlockBox)
-
-template initBoxBuilderContext(styledNode: StyledNode;
-    pctx: var BoxBuilderContext): BoxBuilderContext =
-  initBoxBuilderContext(styledNode, pctx.lctx, addr pctx)
-
-proc newMarkerBox(computed: CSSValues; listItemCounter: int): InlineBox =
-  let computed = computed.inheritProperties()
-  # Use pre, so the space at the end of the default markers isn't ignored.
-  computed{"white-space"} = WhitespacePre
-  let s = computed{"list-style-type"}.listMarker(listItemCounter)
-  return InlineBox(
-    t: ibtText,
-    computed: computed,
-    text: newCharacterData(s)
+proc buildInline(ctx: var TreeContext; styledNode: StyledNode): InlineBox =
+  let ibox = InlineBox(
+    t: ibtParent,
+    computed: styledNode.computed,
+    element: styledNode.element
   )
+  ctx.build(styledNode, ibox.children)
+  return ibox
 
-proc createAnonTable(ctx: var BoxBuilderContext; parentType: ParentType):
-    BlockBox =
-  if ctx.anonTableWrapper == nil:
-    let (outer, inner) = rootProperties().splitTable()
-    outer{"display"} = case parentType
-    of ptInline: DisplayInlineTableWrapper
-    of ptBlock: DisplayTableWrapper
-    let innerTable = BlockBox(computed: inner)
-    let box = BlockBox(
-      computed: outer,
-      children: @[CSSBox(innerTable)]
-    )
-    ctx.anonTableWrapper = box
-    return box
-  return ctx.anonTableWrapper
-
-proc createAnonRow(ctx: var BoxBuilderContext; parent: CSSBox): BlockBox =
-  if ctx.anonRow == nil:
-    let wrapperVals = parent.computed.inheritProperties()
-    wrapperVals{"display"} = DisplayTableRow
-    let box = BlockBox(computed: wrapperVals)
-    ctx.anonRow = box
-    return box
-  return ctx.anonRow
-
-proc flushTableRow(ctx: var BoxBuilderContext; parent: CSSBox;
-    parentType: ParentType) =
-  if ctx.anonRow != nil:
-    let parentDisplay = parent.computed{"display"}
-    if parentDisplay in ProperTableRowParent:
-      BlockBox(parent).children.add(ctx.anonRow)
-    else:
-      let anonTableWrapper = ctx.createAnonTable(parentType)
-      BlockBox(anonTableWrapper.children[0]).children.add(ctx.anonRow)
-    ctx.anonRow = nil
-
-proc flushTable(ctx: var BoxBuilderContext; parent: BlockBox) =
-  ctx.flushTableRow(parent, ptBlock)
-  if ctx.anonTableWrapper != nil:
-    parent.children.add(ctx.anonTableWrapper)
-    ctx.anonTableWrapper = nil
-
-proc flushInlineTable(ctx: var BoxBuilderContext; parent: InlineBox) =
-  ctx.flushTableRow(parent, ptInline)
-  if ctx.anonTableWrapper != nil:
-    assert ctx.anonTableWrapper.computed{"display"} == DisplayInlineTableWrapper
-    parent.children.add(InlineBox(
-      t: ibtBox,
-      computed: ctx.anonTableWrapper.computed.inheritProperties(),
-      box: ctx.anonTableWrapper
-    ))
-    ctx.anonTableWrapper = nil
-
-proc buildBlock(pctx: var BoxBuilderContext; styledNode: StyledNode):
-    BlockBox =
+proc buildBlock(ctx: var TreeContext; styledNode: StyledNode): BlockBox =
   let box = BlockBox(computed: styledNode.computed, element: styledNode.element)
-  var ctx = initBoxBuilderContext(styledNode, pctx)
-  ctx.build(box)
-  pctx.quoteLevel = ctx.quoteLevel
+  ctx.build(styledNode, box.children)
   return box
 
-proc buildInlineText(parent: StyledNode; text: CharacterData;
-    computed: CSSValues): InlineBox =
-  return InlineBox(
-    t: ibtText,
-    computed: computed,
-    element: parent.element,
-    text: text
-  )
-
-proc buildInlineText(parent: StyledNode; text: CharacterData): InlineBox =
-  return InlineBox(
-    t: ibtText,
-    computed: parent.computed,
-    element: parent.element,
-    text: text
-  )
-
-proc buildInlineBlock(ctx: var BoxBuilderContext; styledNode: StyledNode):
-    InlineBox =
+proc buildInlineBlock(ctx: var TreeContext; styledNode: StyledNode): InlineBox =
   return InlineBox(
     t: ibtBox,
     computed: styledNode.computed.inheritProperties(),
@@ -3004,313 +2894,51 @@ proc buildInlineBlock(ctx: var BoxBuilderContext; styledNode: StyledNode):
     box: ctx.buildBlock(styledNode)
   )
 
-proc buildListItem(ctx: var BoxBuilderContext; styledNode: StyledNode):
-    BlockBox =
-  inc ctx.listItemCounter
-  let marker = newMarkerBox(styledNode.computed, ctx.listItemCounter)
-  let position = styledNode.computed{"list-style-position"}
-  let content = BlockBox(
-    computed: styledNode.computed,
-    element: styledNode.element
-  )
-  var contentCtx = initBoxBuilderContext(styledNode, ctx.lctx, addr ctx)
-  case position
-  of ListStylePositionOutside:
-    contentCtx.build(content)
-    content.computed = content.computed.copyProperties()
-    content.computed{"display"} = DisplayBlock
-    let markerComputed = marker.computed.copyProperties()
-    markerComputed{"display"} = markerComputed{"display"}.blockify()
-    let marker = BlockBox(
-      computed: markerComputed,
-      children: @[CSSBox(marker)]
-    )
-    return BlockBox(
-      computed: styledNode.computed,
-      children: @[CSSBox(marker), CSSBox(content)]
-    )
-  of ListStylePositionInside:
-    content.children.add(marker)
-    contentCtx.build(content)
-    return content
-
-proc buildFromElem(ctx: var BoxBuilderContext; styledNode: StyledNode):
-    CSSBox =
+proc buildFromElem(ctx: var TreeContext; styledNode: StyledNode): CSSBox =
   return case styledNode.computed{"display"}
   of DisplayBlock, DisplayFlowRoot, DisplayFlex, DisplayTable,
-      DisplayTableCaption, DisplayTableCell, RowGroupBox, DisplayTableRow:
+      DisplayTableCaption, DisplayTableCell, RowGroupBox, DisplayTableRow,
+      DisplayTableWrapper, DisplayListItem:
     ctx.buildBlock(styledNode)
   of DisplayInlineBlock, DisplayInlineTable, DisplayInlineFlex:
     ctx.buildInlineBlock(styledNode)
-  of DisplayListItem: ctx.buildListItem(styledNode)
   of DisplayInline: ctx.buildInline(styledNode)
-  of DisplayTableColumn, DisplayTableColumnGroup: nil #TODO
-  of DisplayNone: nil
-  of DisplayTableWrapper, DisplayInlineTableWrapper:
+  of DisplayTableColumn, DisplayTableColumnGroup, #TODO
+      DisplayNone:
     assert false
     nil
 
-proc buildReplacement(ctx: var BoxBuilderContext; child: StyledNode;
-    computed: CSSValues): InlineBox =
-  case child.content.t
-  of ContentOpenQuote:
-    let quotes = child.computed{"quotes"}
-    let s = if quotes == nil:
-      quoteStart(ctx.quoteLevel)
-    elif quotes.qs.len > 0:
-      quotes.qs[min(ctx.quoteLevel, quotes.qs.high)].s
-    else:
-      return
-    let node = newCharacterData(s)
-    result = child.buildInlineText(node, computed)
-    inc ctx.quoteLevel
-  of ContentCloseQuote:
-    if ctx.quoteLevel > 0:
-      dec ctx.quoteLevel
-    let quotes = child.computed{"quotes"}
-    let s = if quotes == nil:
-      quoteEnd(ctx.quoteLevel)
-    elif quotes.qs.len > 0:
-      quotes.qs[min(ctx.quoteLevel, quotes.qs.high)].e
-    else:
-      return
-    let text = newCharacterData(s)
-    return child.buildInlineText(text, computed)
-  of ContentNoOpenQuote:
-    inc ctx.quoteLevel
-    return nil
-  of ContentNoCloseQuote:
-    if ctx.quoteLevel > 0:
-      dec ctx.quoteLevel
-    return nil
-  of ContentString:
-    #TODO make CharacterData in cssvalues?
-    let text = newCharacterData(child.content.s)
-    return child.buildInlineText(text, computed)
-  of ContentImage:
-    if child.content.bmp != nil and child.content.bmp.cacheId != -1:
-      return InlineBox(
-        t: ibtBitmap,
-        computed: computed,
-        element: child.element,
-        image: InlineImage(bmp: child.content.bmp)
-      )
-    return child.buildInlineText(ctx.lctx.imgText, computed)
-  of ContentNewline:
-    return InlineBox(
-      t: ibtNewline,
-      computed: computed,
-      element: child.element
-    )
-
-proc buildInline(pctx: var BoxBuilderContext; styledNode: StyledNode):
-    InlineBox =
-  let ibox = InlineBox(
-    t: ibtParent,
-    computed: styledNode.computed,
-    element: styledNode.element
-  )
-  var ctx = initBoxBuilderContext(styledNode, pctx)
-  for child in styledNode.children:
-    case child.t
-    of stElement:
-      let child = ctx.buildFromElem(child)
-      if child != nil:
-        case child.computed{"display"}
-        of DisplayTableRow:
-          ctx.flushTableRow(ibox, ptInline)
-          let anonTableWrapper = ctx.createAnonTable(ptInline)
-          BlockBox(anonTableWrapper.children[0]).children.add(child)
-        of RowGroupBox:
-          ctx.flushTableRow(ibox, ptInline)
-          let anonTableWrapper = ctx.createAnonTable(ptInline)
-          BlockBox(anonTableWrapper.children[0]).children.add(child)
-        of DisplayTableCell:
-          ctx.createAnonRow(ibox).children.add(child)
-        of DisplayTableCaption:
-          ctx.flushTableRow(ibox, ptInline)
-          let anonTableWrapper = ctx.createAnonTable(ptInline)
-          # only add first caption
-          if anonTableWrapper.children.len == 1:
-            anonTableWrapper.children.add(child)
-        else:
-          ctx.flushInlineTable(ibox)
-          ibox.children.add(child)
-    of stText:
-      ctx.flushInlineTable(ibox)
-      ibox.children.add(styledNode.buildInlineText(child.text))
-    of stReplacement:
-      ctx.flushInlineTable(ibox)
-      let child = ctx.buildReplacement(child, styledNode.computed)
-      if child != nil:
-        ibox.children.add(child)
-  ctx.flushInlineTable(ibox)
-  pctx.quoteLevel = ctx.quoteLevel
-  return ibox
-
-proc initBoxBuilderContext(styledNode: StyledNode; lctx: LayoutContext;
-    parent: ptr BoxBuilderContext): BoxBuilderContext =
-  result = BoxBuilderContext(styledNode: styledNode, lctx: lctx)
-  if parent != nil:
-    result.listItemCounter = parent[].listItemCounter
-    result.quoteLevel = parent[].quoteLevel
-  for reset in styledNode.computed{"counter-reset"}:
-    if reset.name == "list-item":
-      result.listItemCounter = reset.num
-
-proc buildTableRowChildWrappers(box: BlockBox) =
-  var wrapperVals: CSSValues = nil
-  for child in box.children:
-    if child.computed{"display"} != DisplayTableCell:
-      wrapperVals = box.computed.inheritProperties()
-      wrapperVals{"display"} = DisplayTableCell
-      break
-  if wrapperVals != nil:
-    # fixup row: put wrappers around runs of misparented children
-    var children = newSeqOfCap[CSSBox](box.children.len)
-    var wrapper: BlockBox = nil
-    for child in box.children:
-      if child.computed{"display"} != DisplayTableCell:
-        if wrapper == nil:
-          wrapper = BlockBox(computed: wrapperVals)
-          children.add(wrapper)
-        wrapper.children.add(child)
-      else:
-        wrapper = nil
-        children.add(child)
-    box.children = move(children)
-
-proc buildTableRowGroupChildWrappers(box: BlockBox) =
-  var wrapperVals: CSSValues = nil
-  for child in box.children:
-    if child.computed{"display"} != DisplayTableRow:
-      wrapperVals = box.computed.inheritProperties()
-      wrapperVals{"display"} = DisplayTableRow
-      break
-  if wrapperVals != nil:
-    # fixup row group: put wrappers around runs of misparented children
-    var wrapper: BlockBox = nil
-    var children = newSeqOfCap[CSSBox](box.children.len)
-    for child in box.children:
-      if child.computed{"display"} != DisplayTableRow:
-        if wrapper == nil:
-          wrapper = BlockBox(computed: wrapperVals, children: @[child])
-          children.add(wrapper)
-        wrapper.children.add(child)
-      else:
-        if wrapper != nil:
-          wrapper.buildTableRowChildWrappers()
-          wrapper = nil
-        children.add(child)
-    if wrapper != nil:
-      wrapper.buildTableRowChildWrappers()
-    box.children = move(children)
-
-proc buildTableChildWrappers(box: BlockBox; computed: CSSValues) =
-  let innerTable = BlockBox(computed: computed, element: box.element)
-  let wrapperVals = box.computed.inheritProperties()
-  wrapperVals{"display"} = DisplayTableRow
-  var caption: CSSBox = nil
-  var wrapper: BlockBox = nil
-  for child in box.children:
-    if child.computed{"display"} in ProperTableChild:
-      if wrapper != nil:
-        wrapper.buildTableRowChildWrappers()
-        wrapper = nil
-      innerTable.children.add(child)
-    elif child.computed{"display"} == DisplayTableCaption:
-      if caption == nil:
-        caption = child
-    else:
-      if wrapper == nil:
-        wrapper = BlockBox(computed: wrapperVals)
-        innerTable.children.add(wrapper)
-      wrapper.children.add(child)
-  if wrapper != nil:
-    wrapper.buildTableRowChildWrappers()
-  box.children = @[CSSBox(innerTable)]
-  if caption != nil:
-    box.children.add(caption)
-
-# Don't build empty anonymous inline blocks between block boxes
-func canBuildAnonInline(ctx: BoxBuilderContext; box: BlockBox; s: string):
-    bool =
-  return box.children.len > 0 and ctx.anonTableWrapper == nil and
-    ctx.anonRow == nil and
-    box.children[^1].computed{"display"} in DisplayOuterInline or
-    box.computed.whitespacepre or not s.onlyWhitespace()
-
-proc build(ctx: var BoxBuilderContext; box: BlockBox) =
-  let inlineComputed = box.computed.inheritProperties()
-  for child in ctx.styledNode.children:
+proc build(ctx: var TreeContext; styledNode: StyledNode;
+    children: var seq[CSSBox]) =
+  for child in styledNode.children(ctx):
     case child.t
     of stElement:
-      let child = ctx.buildFromElem(child)
-      if child != nil:
-        case child.computed{"display"}
-        of DisplayTableRow:
-          ctx.flushTableRow(box, ptBlock)
-          if box.computed{"display"} in ProperTableRowParent:
-            box.children.add(child)
-          else:
-            let anonTableWrapper = ctx.createAnonTable(ptBlock)
-            BlockBox(anonTableWrapper.children[0]).children.add(child)
-        of RowGroupBox:
-          ctx.flushTableRow(box, ptBlock)
-          if box.computed{"display"} in DisplayInnerTable:
-            box.children.add(child)
-          else:
-            let anonTableWrapper = ctx.createAnonTable(ptBlock)
-            BlockBox(anonTableWrapper.children[0]).children.add(child)
-        of DisplayTableCell:
-          if box.computed{"display"} == DisplayTableRow:
-            box.children.add(child)
-          else:
-            ctx.createAnonRow(box).children.add(child)
-        of DisplayTableCaption:
-          ctx.flushTableRow(box, ptBlock)
-          if box.computed{"display"} in DisplayInnerTable:
-            box.children.add(child)
-          else:
-            let anonTableWrapper = ctx.createAnonTable(ptBlock)
-            # only add first caption
-            if anonTableWrapper.children.len == 1:
-              anonTableWrapper.children.add(child)
-        else:
-          ctx.flushTable(box)
-          box.children.add(child)
+      children.add(ctx.buildFromElem(child))
     of stText:
-      let text = child.text
-      if ctx.canBuildAnonInline(box, text.data):
-        ctx.flushTable(box)
-        let child = ctx.styledNode.buildInlineText(text, inlineComputed)
-        box.children.add(child)
-    of stReplacement:
-      let child = ctx.buildReplacement(child, inlineComputed)
-      if child != nil:
-        ctx.flushTable(box)
-        box.children.add(child)
-  ctx.flushTable(box)
-  case box.computed{"display"}
-  of DisplayInnerFlex:
-    let blockComputed = inlineComputed.copyProperties()
-    blockComputed{"display"} = DisplayBlock
-    for it in box.children.mitems:
-      if it.computed{"display"} in DisplayOuterInline:
-        it = BlockBox(computed: blockComputed, children: @[it])
-  of DisplayInnerTable:
-    let (outer, inner) = box.computed.splitTable()
-    box.computed = outer
-    outer{"display"} = outer{"display"}.toTableWrapper()
-    box.buildTableChildWrappers(inner)
-  of RowGroupBox:
-    box.buildTableRowGroupChildWrappers()
-  of DisplayTableRow:
-    box.buildTableRowChildWrappers()
-  else:
-    discard
+      children.add(InlineBox(
+        t: ibtText,
+        computed: child.computed,
+        element: child.element,
+        text: child.text
+      ))
+    of stBr:
+      children.add(InlineBox(
+        t: ibtNewline,
+        computed: child.computed,
+        element: child.element
+      ))
+    of stImage:
+      children.add(InlineBox(
+        t: ibtBitmap,
+        computed: child.computed,
+        element: child.element,
+        image: InlineImage(bmp: child.bmp)
+      ))
 
 proc layout*(root: StyledNode; attrsp: ptr WindowAttributes): BlockBox =
+  let box = BlockBox(computed: root.computed, element: root.element)
+  var ctx = TreeContext()
+  ctx.build(root, box.children)
   let space = availableSpace(
     w = stretch(attrsp[].widthPx),
     h = stretch(attrsp[].heightPx)
@@ -3320,12 +2948,8 @@ proc layout*(root: StyledNode; attrsp: ptr WindowAttributes): BlockBox =
     cellSize: size(w = attrsp.ppc, h = attrsp.ppl),
     positioned: @[PositionedItem(), PositionedItem()],
     myRootProperties: rootProperties(),
-    imgText: newCharacterData("[img]"),
     luctx: LUContext()
   )
-  let box = BlockBox(computed: root.computed, element: root.element)
-  var ctx = initBoxBuilderContext(root, lctx, nil)
-  ctx.build(box)
   let sizes = lctx.resolveBlockSizes(space, box.computed)
   # the bottom margin is unused.
   lctx.layoutRootBlock(box, sizes.margin.topLeft, sizes)
diff --git a/src/server/buffer.nim b/src/server/buffer.nim
index f2e1d560..d20b73fe 100644
--- a/src/server/buffer.nim
+++ b/src/server/buffer.nim
@@ -13,6 +13,7 @@ import chame/tags
 import config/config
 import css/box
 import css/cascade
+import css/csstree
 import css/layout
 import css/lunit
 import css/render