diff --git a/src/environments/array.ts b/src/environments/array.ts
index 66231cf3..f453d88d 100644
--- a/src/environments/array.ts
+++ b/src/environments/array.ts
@@ -2,7 +2,7 @@ import {makeFragment, makeLineSpan, makeSpan, makeVList} from "../buildCommon";
 import Style from "../Style";
 import defineEnvironment from "../defineEnvironment";
 import {parseCD} from "./cd";
-import defineFunction from "../defineFunction";
+import defineFunction, {ordargument} from "../defineFunction";
 import defineMacro from "../defineMacro";
 import {MathNode} from "../mathMLTree";
 import ParseError from "../ParseError";
@@ -37,6 +37,29 @@ export type AlignSpec = {type: "separator", separator: string} | {
 // Type to indicate column separation in MathML
 export type ColSeparationType = "align" | "alignat" | "gather" | "small" | "CD";
 
+// Context made available to \multicolumn while the body of an array-like
+// environment is being parsed.
+export type ArrayContext = {
+    // Name of the environment being parsed ("" if unknown).
+    envName: string;
+    // Number of columns declared by the environment, i.e. the maximum number
+    // of columns that a \multicolumn may span.  Undefined when the
+    // environment grows columns dynamically (e.g. {matrix}).
+    multicolumnMaxCols: number | undefined;
+    // Columns consumed so far in the current row, counting \multicolumn
+    // spans.
+    usedCols: number;
+};
+
+// The environments in which \multicolumn may be used.
+const multicolumnEnvironments = new Set([
+    "array",
+    "matrix", "pmatrix", "bmatrix", "Bmatrix", "vmatrix", "Vmatrix",
+    "cases", "rcases",
+    "aligned",
+    "smallmatrix",
+]);
+
 // Helper functions
 function getHLines(parser: Parser): boolean[] {
     // Return an array. The array length = number of hlines.
@@ -58,6 +81,32 @@ function getHLines(parser: Parser): boolean[] {
     return hlineInfo;
 }
 
+// The number of array columns occupied by a row, counting the spans of
+// any \multicolumn cells.
+function rowColumnCount(row: AnyParseNode[]): number {
+    let count = 0;
+    for (let i = 0; i < row.length; i++) {
+        const cell = row[i];
+        count += cell.type === "multicolumn" ? cell.n : 1;
+    }
+    return count;
+}
+
+// Extract the text of a \multicolumn count or alignment argument, which must
+// consist solely of symbol nodes.  Returns null otherwise.
+function argToText(arg: AnyParseNode): string | null {
+    const nodes = ordargument(arg);
+    let text = "";
+    for (let i = 0; i < nodes.length; i++) {
+        const symNode = checkSymbolNodeType(nodes[i]);
+        if (!symNode) {
+            return null;
+        }
+        text += symNode.text;
+    }
+    return text;
+}
+
 const validateAmsEnvironmentContext = (context: EnvContextLike) => {
     const settings = context.parser.settings;
     if (!settings.displayMode) {
@@ -99,6 +148,8 @@ function parseArray(
         emptySingleRow,
         maxNumCols,
         leqno,
+        envName,
+        multicolumnMaxCols,
     }: {
         hskipBeforeAndAfter?: boolean;
         addJot?: boolean;
@@ -110,10 +161,23 @@ function parseArray(
         emptySingleRow?: boolean;
         maxNumCols?: number;
         leqno?: boolean;
+        envName?: string;
+        multicolumnMaxCols?: number;
     },
     style: StyleStr,
 ): ParseNode<"array"> {
     parser.gullet.beginGroup();
+
+    // Make the environment context available to \multicolumn while parsing
+    // the body, saving any outer context for nested environments.
+    const outerArrayContext = parser.arrayContext;
+    const arrayContext: ArrayContext = {
+        envName: envName || "",
+        multicolumnMaxCols,
+        usedCols: 0,
+    };
+    parser.arrayContext = arrayContext;
+
     if (!singleRow) {
         // \cr is equivalent to \\ without the optional size argument (see below)
         // TODO: provide helpful error when \cr is used outside array environment
@@ -173,20 +237,45 @@ function parseArray(
         const cellBody = parser.parseExpression(false, singleRow ? "\\end" : "\\\\");
         parser.gullet.endGroup();
         parser.gullet.beginGroup();
-        let cell: AnyParseNode = {
-            type: "ordgroup",
-            mode: parser.mode,
-            body: cellBody,
-        };
-        if (style) {
+        let cell: AnyParseNode;
+        if (cellBody.length === 1 && cellBody[0].type === "multicolumn") {
+            // A \multicolumn cell replaces the next n columns with a single
+            // cell; keep it unwrapped so the builders can see its span.
+            cell = cellBody[0];
+            if (style) {
+                // Cast the cell content into the environment's cell style,
+                // as is done for regular cells.
+                cell.body = [{
+                    type: "styling",
+                    mode: parser.mode,
+                    style,
+                    body: cell.body,
+                }];
+            }
+        } else {
+            for (let i = 0; i < cellBody.length; i++) {
+                if (cellBody[i].type === "multicolumn") {
+                    throw new ParseError(
+                        "\\multicolumn must be the only content of a cell",
+                        parser.nextToken);
+                }
+            }
             cell = {
-                type: "styling",
+                type: "ordgroup",
                 mode: parser.mode,
-                style,
-                body: [cell],
+                body: cellBody,
             };
+            if (style) {
+                cell = {
+                    type: "styling",
+                    mode: parser.mode,
+                    style,
+                    body: [cell],
+                };
+            }
         }
         row.push(cell);
+        arrayContext.usedCols += cell.type === "multicolumn" ? cell.n : 1;
         const next = parser.fetch().text;
         if (next === "&") {
             if (maxNumCols && row.length === maxNumCols) {
@@ -237,6 +326,7 @@ function parseArray(
             row = [];
             body.push(row);
             beginRow();
+            arrayContext.usedCols = 0;
         } else {
             throw new ParseError("Expected & or \\\\ or \\cr or \\end",
                                  parser.nextToken);
@@ -248,6 +338,9 @@ function parseArray(
     // End array group defining \cr
     parser.gullet.endGroup();
 
+    // Restore any outer environment context.
+    parser.arrayContext = outerArrayContext;
+
     return {
         type: "array",
         mode: parser.mode,
@@ -332,15 +425,41 @@ const htmlBuilder: HtmlBuilder<"array"> = function(group, options) {
     }
     setHLinePos(hLinesBeforeRow[0]);
 
+    // Determine the column layout of each row.  A \multicolumn cell occupies
+    // `n` consecutive columns and shifts the cells that follow it.
+    // `cellColumns[r]` holds the starting column of each cell in row r, and
+    // `multicolumns[r]` maps the starting column of each \multicolumn cell
+    // in row r to its span and alignment.
+    const cellColumns: Array<number[]> = [];
+    const multicolumns: Array<{[colStart: number]: {n: number, align: string}}> =
+        [];
+    for (r = 0; r < nr; ++r) {
+        const inrow = group.body[r];
+        const starts: number[] = [];
+        const spans: {[colStart: number]: {n: number, align: string}} = {};
+        let col = 0;
+        for (c = 0; c < inrow.length; ++c) {
+            const cell = inrow[c];
+            starts.push(col);
+            if (cell.type === "multicolumn") {
+                spans[col] = {n: cell.n, align: cell.align};
+                col += cell.n;
+            } else {
+                col += 1;
+            }
+        }
+        cellColumns.push(starts);
+        multicolumns.push(spans);
+        if (nc < col) {
+            nc = col;
+        }
+    }
+
     for (r = 0; r < group.body.length; ++r) {
         const inrow = group.body[r];
         let height = arstrutHeight; // \@array adds an \@arstrut
         let depth = arstrutDepth;   // to each tow (via the template)
 
-        if (nc < inrow.length) {
-            nc = inrow.length;
-        }
-
         const outrow: Outrow = (new Array(inrow.length) as any);
         for (c = 0; c < inrow.length; ++c) {
             const elt = html.buildGroup(inrow[c], options);
@@ -350,7 +469,7 @@ const htmlBuilder: HtmlBuilder<"array"> = function(group, options) {
             if (height < elt.height) {
                 height = elt.height;
             }
-            outrow[c] = elt;
+            outrow[cellColumns[r][c]] = elt;
         }
 
         const rowGap = group.rowGaps[r];
@@ -384,6 +503,33 @@ const htmlBuilder: HtmlBuilder<"array"> = function(group, options) {
     }
 
     const offset = totalHeight / 2 + options.fontMetrics().axisHeight;
+
+    // For each internal column boundary, find the vertical intervals in
+    // which a \multicolumn span crosses the boundary.  Vertical rules are
+    // suppressed within those intervals on a per-row basis.  `ruleGaps[k]`
+    // describes the boundary just before column k.
+    const ruleGaps: Array<Array<[number, number]> | undefined> = [];
+    for (let k = 1; k < nc; ++k) {
+        let gaps: Array<[number, number]> | undefined;
+        for (r = 0; r < nr; ++r) {
