Markdown: support column spans on |||---|||

Change-Id: Ia960a3fa9c01cccc23c4e12baf0fef99c6dea84a
diff --git a/Documentation/markdown.md b/Documentation/markdown.md
index 3a532c2..7ee70d9 100644
--- a/Documentation/markdown.md
+++ b/Documentation/markdown.md
@@ -458,8 +458,9 @@
 
 ### Column layout
 
-Gitiles markdown includes support for up to 4 columns of text within
-the width of the page.
+Gitiles markdown includes support for up to 12 columns of text across
+the width of the page.  By default space is divided equally between
+the columns.
 
 |||---|||
 #### Columns
@@ -500,6 +501,31 @@
 |||---|||
 ```
 
+Column spans can be specified on the first line as a comma separated
+list.  In the example below the first column is 4 wide or 4/12ths of
+the page width, the second is 2 wide (or 2/12ths) and the final column
+is 6 wide (6/12ths or 50%) of the page.
+
+```
+|||---||| 4,2,6
+```
+
+An empty column can be inserted by prefixing its width with `:`,
+for example shifting content onto the right by padding 6 columns
+on the left:
+
+```
+|||---||| :6,3
+# Right
+|||---|||
+```
+
+renders as:
+
+|||---||| :6,3
+# Right
+|||---|||
+
 ### HTML IFrame
 
 Although HTML is stripped the parser has special support for a limited
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/ColsNode.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/ColsNode.java
index 00945ef..7c31b0f 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/ColsNode.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/ColsNode.java
@@ -14,9 +14,11 @@
 
 package com.google.gitiles.doc;
 
+import org.pegdown.ast.HeaderNode;
 import org.pegdown.ast.Node;
 import org.pegdown.ast.SuperNode;
 
+import java.util.ArrayList;
 import java.util.List;
 
 /**
@@ -25,12 +27,74 @@
  * Each header within the layout creates a new column in the HTML.
  */
 public class ColsNode extends SuperNode {
-  ColsNode(List<Node> children) {
-    super(children);
+  static final int GRID_WIDTH = 12;
+
+  ColsNode(List<Column> spec, List<Node> children) {
+    super(wrap(spec, children));
   }
 
   @Override
   public void accept(org.pegdown.ast.Visitor visitor) {
     ((Visitor) visitor).visit(this);
   }
+
+  private static List<Node> wrap(List<Column> spec, List<Node> children) {
+    List<Column> columns = copyOf(spec);
+    splitChildren(columns, children);
+
+    int remaining = GRID_WIDTH;
+    for (int i = 0; i < columns.size(); i++) {
+      Column col = columns.get(i);
+      if (col.span <= 0 || col.span > GRID_WIDTH) {
+        col.span = remaining / (columns.size() - i);
+      }
+      remaining = Math.max(0, remaining - col.span);
+    }
+    return asNodeList(columns);
+  }
+
+  private static void splitChildren(List<Column> columns, List<Node> children) {
+    int idx = 0;
+    Column col = null;
+    for (Node n : children) {
+      if (col == null
+          || n instanceof HeaderNode
+          || n instanceof DivNode) {
+        for (;;) {
+          if (idx < columns.size()) {
+            col = columns.get(idx);
+          } else {
+            col = new Column();
+            columns.add(col);
+          }
+          idx++;
+          if (!col.empty) {
+            break;
+          }
+        }
+      }
+      col.getChildren().add(n);
+    }
+  }
+
+  private static <T> ArrayList<T> copyOf(List<T> in) {
+    return in != null && !in.isEmpty()
+        ? new ArrayList<>(in)
+        : new ArrayList<T>();
+  }
+
+  @SuppressWarnings("unchecked")
+  private static List<Node> asNodeList(List<? extends Node> columns) {
+    return (List<Node>) columns;
+  }
+
+  static class Column extends SuperNode {
+    int span;
+    boolean empty;
+
+    @Override
+    public void accept(org.pegdown.ast.Visitor visitor) {
+      ((Visitor) visitor).visit(this);
+    }
+  }
 }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitilesMarkdown.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitilesMarkdown.java
index 5494cd3..9683472 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitilesMarkdown.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitilesMarkdown.java
@@ -17,7 +17,9 @@
 import com.google.gitiles.GitilesView;
 
 import org.parboiled.Rule;
+import org.parboiled.common.Factory;
 import org.parboiled.support.StringBuilderVar;
+import org.parboiled.support.Var;
 import org.pegdown.Parser;
 import org.pegdown.ParsingTimeoutException;
 import org.pegdown.PegDownProcessor;
@@ -29,6 +31,7 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.util.ArrayList;
 import java.util.List;
 
 /** Parses Gitiles extensions to markdown. */
@@ -144,21 +147,50 @@
         sequence(string("aside"), push(match())));
   }
 
+  @SuppressWarnings("unchecked")
   public Rule cols() {
     StringBuilderVar body = new StringBuilderVar();
     return NodeSequence(
-        colsTag(), Newline(),
+        colsTag(), columnWidths(), Newline(),
         oneOrMore(
             testNot(colsTag(), Newline()),
             Line(body)),
         colsTag(), Newline(),
-        push(new ColsNode(parse(body))));
+        push(new ColsNode((List<ColsNode.Column>) pop(), parse(body))));
   }
 
   public Rule colsTag() {
     return string("|||---|||");
   }
 
+  public Rule columnWidths() {
+    ListVar widths = new ListVar();
+    return sequence(
+      zeroOrMore(
+        sequence(
+          Sp(), optional(ch(',')), Sp(),
+          columnWidth(widths))),
+      push(widths.get()));
+  }
+
+  public Rule columnWidth(ListVar widths) {
+    StringBuilderVar s = new StringBuilderVar();
+    return sequence(
+      optional(sequence(ch(':'), s.append(':'))),
+      oneOrMore(digit()), s.append(match()),
+      widths.get().add(parse(s.get().toString())));
+  }
+
+  static ColsNode.Column parse(String spec) {
+    ColsNode.Column c = new ColsNode.Column();
+    if (spec.startsWith(":")) {
+      c.empty = true;
+      spec = spec.substring(1);
+    }
+    c.span = Integer.parseInt(spec, 10);
+    return c;
+  }
+
   public List<Node> parse(StringBuilderVar body) {
     // The pegdown code doesn't provide enough visibility to directly
     // use its existing parsing rules. Recurse manually for inner text
@@ -168,4 +200,16 @@
     }
     return parser.parseMarkdown(body.getChars()).getChildren();
   }
+
+  public static class ListVar extends Var<List<Object>> {
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    public ListVar() {
+      super(new Factory() {
+        @Override
+        public Object create() {
+          return new ArrayList<>();
+        }
+      });
+    }
+  }
 }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java
index 62bc846..dd73ee4 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java
@@ -119,21 +119,17 @@
   @Override
   public void visit(ColsNode node) {
     html.open("div").attribute("class", "cols");
-    boolean open = false;
-    for (Node n : node.getChildren()) {
-      if (n instanceof HeaderNode || n instanceof DivNode) {
-        if (open) {
-          html.close("div");
-        }
-        html.open("div").attribute("class", "col-3");
-        open = true;
-      }
-      n.accept(this);
-    }
-    if (open) {
+    visitChildren(node);
+    html.close("div");
+  }
+
+  @Override
+  public void visit(ColsNode.Column node) {
+    if (1 <= node.span && node.span <= ColsNode.GRID_WIDTH) {
+      html.open("div").attribute("class", "col-" + node.span);
+      visitChildren(node);
       html.close("div");
     }
-    html.close("div");
   }
 
   @Override
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/Visitor.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/Visitor.java
index e91e073..d8f269c 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/Visitor.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/Visitor.java
@@ -16,6 +16,7 @@
 
 public interface Visitor extends org.pegdown.ast.Visitor {
   void visit(ColsNode node);
+  void visit(ColsNode.Column node);
   void visit(DivNode node);
   void visit(IframeNode node);
   void visit(TocNode node);
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/doc.css b/gitiles-servlet/src/main/resources/com/google/gitiles/static/doc.css
index 24b9b79..c397424 100644
--- a/gitiles-servlet/src/main/resources/com/google/gitiles/static/doc.css
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/doc.css
@@ -282,11 +282,23 @@
   margin: 0 -1.533%;
   width: 103.067%;
 }
-.col-3 {
+.col-1, .col-2, .col-3, .col-4, .col-5, .col-6,
+.col-7, .col-8, .col-9, .col-10, .col-11, .col-12 {
   float: left;
   margin: 0 1.488% 20px;
 }
-.col-3 { width: 22.023%; }
+.col-1 { width: 5.357%; }
+.col-2 { width: 13.690%; }
+.col-3 { width: 22.024%; }
+.col-4 { width: 30.357%; }
+.col-5 { width: 38.690%; }
+.col-6 { width: 47.024%; }
+.col-7 { width: 55.357%; }
+.col-8 { width: 63.690%; }
+.col-9 { width: 72.024%; }
+.col-10 { width: 80.357%; }
+.col-11 { width: 88.690%; }
+.col-12 { width: 97.024%; }
 .cols hr {
   width: 80%;
 }