// func(S) -> S
static new ThreadLocal htmlTable_cellEncoder;

// htmlEncode = true
static S htmlTable2(O data, O... _) {
  bool htmlEncode = optPar htmlEncode(_, true);
  bool useBr = boolPar useBr(_);
  Map<S, O[]> paramsByColName = cast optPar paramsByColName(_);
  O[] tdParams = cast optPar tdParams(_);
  SS replaceHeaders = cast optPar replaceHeaders(_);
  
  // prepare table
  
  new L<L<S>> rows;
  new L<S> cols;
  
  if (data instanceof L) {
    for (O x : (L) data) pcall {
      rows.add(dataToTable_makeRow(x, cols));
    }
  } else if (data instanceof Map) {
    Map map = cast data;
    for (O key : map.keySet()) {
      O value = map.get(key);
      rows.add(litlist(structureOrText(key), structureOrText(value)));
    }
  } else
    print("Unknown data type: " + data);
    
  // get table width
  int w = 0;
  for (L<S> row : rows)
    w = max(w, l(row));
    
  // construct HTML for table
  
  new StringBuilder buf;
  buf.append("<table border>\n");
  
  // title
  buf.append("<tr>\n");
  for (S cell : padList(cols, w, "?"))
    buf.append("  <th>" + htmlTable2_encodeCell(
      getOrKeep(replaceHeaders, cell), htmlEncode, useBr) + "</th>\n");
  buf.append("</tr>\n");
  
  // data
  for (L<S> row : rows) {
    buf.append("<tr>\n");
    int i = 0;
    for (S cell : padList(row, w, "")) {
      S col = get(cols, i++);
      O[] params = paramsPlus(tdParams, mapGet(paramsByColName, col));
      buf.append("  " + tag('td, htmlTable2_encodeCell(cell, htmlEncode, useBr), params) + "\n");
    }
    buf.append("</tr>\n");
  }
  buf.append("</table>\n");
  ret buf.toString();
}

static S htmlTable2_encodeCell(S cell, boolean useHtmlEncode, boolean useBr) {
  if (htmlTable_cellEncoder! != null) ret (S) callF(htmlTable_cellEncoder!, cell);
  if (useHtmlEncode) cell = htmlEncode2(cell);
  if (useBr) cell = nlToBr(cell);
  ret cell;
}