Tidying nested / simple MultiMarkdown tables

A first draft of something I needed this week for working with (plain or) nested MMD tables, in which a parent cell may span several child cells.

Note that this differs from Fletcher's Format > Clean up > Selected Table(s) in MultiMarkdown composer.

Where Fletcher's Clean up normalises his deliberately extreme and eccentric sample table at http://fletcher.github.io/MultiMarkdown-5/tables.html

from:

|             |          Grouping           ||
First Header  | Second Header | Third Header |
 ------------ | :-----------: | -----------: |
Content       |          *Long Cell*        ||
Content       |   **Cell**    |         Cell |

New section   |     More      |         Data |
And more      | With an escaped '\|'         ||  
[Prototype table]

to a non-spanning table with elastic tabs:

|     |  Grouping    |     |  
First Header    | Second Header    | Third Header    |  
 ------------    | :-----------:    | -----------:    |  
Content     |  *Long Cell*    |     |  
Content     |  **Cell**    | Cell    |  
  
New section     | More     | Data    |  
And more     | With an escaped 'MMD-ESCAPED-PIPE'    |     |  
[Prototype table]

This tidying macro:

  1. Preserves any parent cell spanning of multiple child cells
  2. Ignores any claim of a child cell to have two mothers (e.g. the Long Cell in Fletcher's example)
  3. Assumes monospaced fonts, and uses space characters instead of tabs
  4. Leaves escaped pipe characters in place, rather than replacing them with 'MMD-ESCAPED-PIPE'

Returning:

|              |                     Grouping                      |||
| First Header |            Second Header            || Third Header |
|:------------:|:--------------------:|--------------:|:------------:|
|   Content    |             *Long Cell*             ||
|   Content    |       **Cell**       |           Cell|

| New section  |         More         |           Data|
|   And more   | With an escaped '\|' |
[Prototype table]

More generally, it aims to provide the (monospaced font) tidying that you would expect for any MMD table which is either:

  1. A simple grid, with no spanning cells,
  2. A nested structure in which parent cells may have several child cells, but no child cells have multiple or ambiguous parents.

(Test with dummy data to make sure that this is the form of tidying that you want).

Tidy MMD table (simple or nested).kmmacros (38.6 KB)

image

JavaScript source

(() => {
  'use strict';

  // Tidy a plain or nested (spanning) MultiMarkdown table

  // MultiMarkdown table in the clipboard
  // Rob Trew (c) 2018

  // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
  // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
  // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
  // IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
  // CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
  // TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
  // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

  // Ver 0.4

  // main :: () -> IO String
  const main = () => {
    const
      sa = standardAdditions(),
      rows = lines(sa.theClipboard()),
      ixs = nonTableIndices(rows);

    const lr = bindLR(
      bindLR(
        rulerAndTreeFromMMDTableLR(
          foldl(
            (a, x, i) => ixs.includes(i) ? (
              a
            ) : a + x + '\n',
            '',
            rows
          )
        ),
        tplRulerTree => Right(
          uncurry(mmdTableFromTree)(tplRulerTree)
        )
      ),
      strTidy => {
        const
          xs = lines(strTidy),
          strClip = unlines(
            mapAccumL((a, x, i) =>
              ixs.includes(i) ? (
                Tuple(a, x)
              ) : Tuple(a + 1, xs[a]),
              0, rows
            )[1]
          );
        return (
          sa.setTheClipboardTo(strClip),
          Right(strClip)
        );
      });

    return isLeft(lr) ? (
      JSON.stringify(lr)
    ) : lr.Right;
  };

  // GENERIC FUNCTIONS ------------------------------------

  // Just :: a -> Just a
  const Just = x => ({
    type: 'Maybe',
    Nothing: false,
    Just: x
  });

  // Left :: a -> Either a b
  const Left = x => ({
    type: 'Either',
    Left: x
  });

  // Node :: a -> [Tree a] -> Tree a
  const Node = (v, xs) => ({
    type: 'Node',
    root: v, // any type of value (but must be consistent across tree)
    nest: xs || []
  });

  // Nothing :: () -> Nothing
  const Nothing = () => ({
    type: 'Maybe',
    Nothing: true,
  });

  // Right :: b -> Either a b
  const Right = x => ({
    type: 'Either',
    Right: x
  });

  // Tuple (,) :: a -> b -> (a, b)
  const Tuple = (a, b) => ({
    type: 'Tuple',
    '0': a,
    '1': b,
    length: 2
  });

  // Determines whether all elements of the structure satisfy the predicate.
  // all :: (a -> Bool) -> [a] -> Bool
  const all = (p, xs) => xs.every(p);

  // bindLR (>>=) :: Either a -> (a -> Either b) -> Either b
  const bindLR = (m, mf) =>
    m.Right !== undefined ? (
      mf(m.Right)
    ) : m;

  // bindMay (>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b
  const bindMay = (mb, mf) =>
    mb.Nothing ? mb : mf(mb.Just);

  // Size of space -> filler Char -> String -> Centered String
  // center :: Int -> Char -> String -> String
  const center = (n, c, s) => {
    const
      qr = quotRem(n - s.length, 2),
      q = qr[0];
    return replicateString(q, c) +
      s + replicateString(q + qr[1], c);
  };

  // concat :: [[a]] -> [a]
  // concat :: [String] -> String
  const concat = xs =>
    xs.length > 0 ? (() => {
      const unit = typeof xs[0] === 'string' ? '' : [];
      return unit.concat.apply(unit, xs);
    })() : [];

  // concatMap :: (a -> [b]) -> [a] -> [b]
  const concatMap = (f, xs) => [].concat.apply([], xs.map(f));

  // filter :: (a -> Bool) -> [a] -> [a]
  const filter = (f, xs) => xs.filter(f);

  // findIndex :: (a -> Bool) -> [a] -> Maybe Int
  const findIndex = (p, xs) => {
    const i = xs.findIndex(p);
    return i !== -1 ? (
      Just(i)
    ) : Nothing();
  };

  // flip :: (a -> b -> c) -> b -> a -> c
  const flip = f => (a, b) => f.apply(null, [b, a]);

  // foldl :: (a -> b -> a) -> a -> [b] -> a
  const foldl = (f, a, xs) => xs.reduce(f, a);

  // foldl1 :: (a -> a -> a) -> [a] -> a
  const foldl1 = (f, xs) =>
    xs.length > 1 ? xs.slice(1)
    .reduce(f, xs[0]) : xs[0];

  // Note that that the Haskell signature of foldr is different from that of
  // foldl - the positions of accumulator and current value are reversed
  // foldr :: (a -> b -> b) -> b -> [a] -> b
  const foldr = (f, a, xs) => xs.reduceRight(flip(f), a);

  // fst :: (a, b) -> a
  const fst = tpl => tpl.type !== 'Tuple' ? undefined : tpl[0];

  // init :: [a] -> [a]
  const init = xs => xs.length > 0 ? xs.slice(0, -1) : undefined;

  // isLeft :: Either a b -> Bool
  const isLeft = lr =>
    lr.type === 'Either' && lr.Left !== undefined;

  // iterateUntil :: (a -> Bool) -> (a -> a) -> a -> [a]
  const iterateUntil = (p, f, x) => {
    const go = x => p(x) ? x : [x].concat(go(f(x)));
    return go(x);
  };

  // justifyLeft :: Int -> Char -> String -> String
  const justifyLeft = (n, cFiller, strText) =>
    n > strText.length ? (
      (strText + cFiller.repeat(n))
      .substr(0, n)
    ) : strText;

  // justifyRight :: Int -> Char -> String -> String
  const justifyRight = (n, cFiller, strText) =>
    n > strText.length ? (
      (cFiller.repeat(n) + strText)
      .slice(-n)
    ) : strText;

  // last :: [a] -> a
  const last = xs => xs.length ? xs.slice(-1)[0] : undefined;

  // levelNodes :: Tree a -> [[Tree a]]
  const levelNodes = tree =>
    iterateUntil(
      xs => xs.length < 1,
      xs => concatMap(x => x.nest, xs), [tree]
    );

  // lines :: String -> [String]
  const lines = s => s.split(/[\r\n]/);

  // map :: (a -> b) -> [a] -> [b]
  const map = (f, xs) => xs.map(f);

  // 'The mapAccumL function behaves like a combination of map and foldl;
  // it applies a function to each element of a list, passing an accumulating
  // parameter from left to right, and returning a final value of this
  // accumulator together with the new list.' (See Hoogle)
  // mapAccumL :: (acc -> x -> (acc, y)) -> acc -> [x] -> (acc, [y])
  const mapAccumL = (f, acc, xs) =>
    xs.reduce((a, x, i) => {
      const pair = f(a[0], x, i);
      return Tuple(pair[0], a[1].concat(pair[1]));
    }, Tuple(acc, []));

  // max :: Ord a => a -> a -> a
  const max = (a, b) => b > a ? b : a;

  // maximum :: Ord a => [a] -> a
  const maximum = xs =>
    xs.length > 0 ? (
      foldl1((a, x) => x > a ? x : a, xs)
    ) : undefined;

  // not :: Bool -> Bool
  const not = b => !b;

  // quotRem :: Int -> Int -> (Int, Int)
  const quotRem = (m, n) => Tuple(Math.floor(m / n), m % n);

  // regexMatches :: String -> String -> [[String]]
  const regexMatches = (strRgx, strHay) => {
    const rgx = new RegExp(strRgx, 'g');
    let m = rgx.exec(strHay),
      xs = [];
    while (m)(xs.push(m), m = rgx.exec(strHay));
    return xs;
  };

  // replicate :: Int -> a -> [a]
  const replicate = (n, x) =>
    Array.from({
      length: n
    }, () => x);

  // replicateString :: Int -> String -> String
  const replicateString = (n, s) => s.repeat(n);

  // showLog :: a -> IO ()
  const showLog = (...args) =>
    console.log(
      args
      .map(JSON.stringify)
      .join(' -> ')
    );

  // snd :: (a, b) -> b
  const snd = tpl => tpl.type !== 'Tuple' ? undefined : tpl[1];

  // take :: Int -> [a] -> [a]
  const take = (n, xs) => xs.slice(0, n);

  // First n members of an infinite cycle of xs
  // takeCycle :: Int -> [a] -> [a]
  const takeCycle = (n, xs) => {
    const lng = xs.length;
    return (lng >= n ? (
        xs
      ) : concat(replicate(Math.ceil(n / lng), xs)))
      .slice(0, n)
  };

  // Converts a function of more than one argument
  // to a function on Tuple type (Tuple ... TupleN)
  // or array which contains those arguments.
  // This implementation uses the fact that the Tuple
  // constructors create an object with a private .length property
  // uncurry :: (a -> b -> c) -> ((a, b) -> c)
  const uncurry = f => args => f.apply(null, args);

  // unlines :: [String] -> String
  const unlines = xs => xs.join('\n');

  // until :: (a -> Bool) -> (a -> a) -> a -> a
  const until = (p, f, x) => {
    let v = x;
    while (!p(v)) v = f(v);
    return v;
  };


  // JXA --------------------------------------------------

  // standardAdditions :: () -> Application
  const standardAdditions = () =>
    Object.assign(Application.currentApplication(), {
      includeStandardAdditions: true
    });

  // MMD --------------------------------------------------

  // Write MMD --------------------------------------------

  // alignFn :: Ordering -> (String -> String)
  const alignFn = o => ([
    justifyLeft, center, justifyRight
  ][1 + o]);

  // Maximum unit text width in 'column' above each leaf
  // (dimensions for a MultiMarkdown ruler)

  // treeColWidths :: Tree -> [Int]
  const treeColWidths = oNode => {
    const go = intMax => node => {
      const
        root = node.root,
        width = 2 + (Boolean(root) ? (
          root.text.length
        ) : 0),
        nest = node.nest,
        intPeers = nest.length;

      return intPeers > 0 ? (
        concatMap(
          go(max(intMax / intPeers, Math.ceil(width / intPeers))),
          nest
        )
      ) : [max(intMax, width)];
    };
    const root = oNode.root;
    return go(2 + (Boolean(root) ? (
      root.text.length / max(1, oNode.nest)
    ) : 0))(oNode);
  };

  // (Tree layers -> Decorated Tree layers )
  // withColIndices :: [[Node]] -> [[Node]]
  const withColIndices = treeLevels =>
    map(
      xs => {
        const tpl = mapAccumL((iCol, dctNode) => {
          const
            root = dctNode.root,
            blnRoot = Boolean(root);
          return Tuple(
            iCol + (blnRoot ? root.leafWidth : 1),
            Object.assign(dctNode, {
              root: Object.assign(blnRoot ? root : {}, {
                col: iCol
              })
            })
          );
        }, 0, xs);
        return tpl[1];
      },
      treeLevels
    );

  // ( Tree to decorated Tree )
  // withLeafWidths :: Tree -> Tree
  const withLeafWidths = node => {
    const
      nest = node.nest,
      sub = Object.assign(
        node, {
          nest: nest.map(withLeafWidths)
        }
      ),
      root = node.root;

    return Boolean(root) ? Object.assign(
      sub, {
        root: {
          text: (typeof root === 'string' ? (
            root
          ) : root.text).replace(/&%/g, '\\|'),
          leafWidth: nest.length > 0 ? (
            sub.nest.reduce((a, x) => a + x.root.leafWidth, 0)
          ) : 1
        }
      }
    ) : sub;
  };

  // MAIN TREE -> MMD WRITER

  // Ruler Tuple: (
  //    Index of ruler row
  //    sequence of Orderings (-1, 0, 1) = (Left, Centre, Right)
  // )
  // RulerTuple -> Tree -> String
  // mmdTableFromTree :: (Int, [Ordering]) -> Tree -> String
  const mmdTableFromTree = (tplRuler, oTree) => {
    const
      tree = withLeafWidths(oTree),
      colWidths = treeColWidths(tree),
      leafWidth = colWidths.length;

    const
      iRuler = tplRuler[0],
      alignments = tplRuler[1],
      lng = alignments.length,

      rules = lng >= leafWidth ? (
        alignments.slice(0, leafWidth)
      ) : takeCycle(leafWidth, (
        lng > 0 ? (
          alignments
        ) : [0]));

    const rows = map(
      cells => '|' + map(
        cell => {
          const
            root = cell.root,
            iCol = root.col,
            intLeaves = root.leafWidth;

          return (
            alignFn(rules[iCol])(
              colWidths.slice(iCol, iCol + intLeaves)
              .reduce((a, x) => a + x, 0),
              ' ',
              (root.text || '')
            )
          ) + replicateString(max(1, intLeaves), '|');
        },
        cells
      ).join(''),

      withColIndices(levelNodes(tree))
    );

    // MMD Table string

    const iTopRow = rows[0].includes(' ') ? 0 : 1;

    // Initial rows ---------------------------------------
    return rows.slice(iTopRow, iRuler + iTopRow).concat(

      // Ruler row ----------------------------------------
      '|' + map(
        (x, i) => (x <= 0 ? ':' : '-') +
        replicateString(colWidths[i] - 2, '-') +
        (x >= 0 ? ':' : '-'),
        rules
      ).join('|') + '|'
    ).concat(

      // Remaining rows -----------------------------------
      rows.slice(iTopRow + iRuler)
    ).join('\n');
  };

  // READ MMD ---------------------------------------------

  // isTable :: [[String]] -> Bool
  const isTable = xs =>
    xs.length > 1 && all(x => x.length > 0, xs);

  // nonTableIndices :: [String] -> [(Int, String)]
  const nonTableIndices = rows =>
    foldl(
      (a, x, i) => x.includes('|') ? (
        a
      ) : a.concat(i), [], rows
    );

  // isRuler :: String -> Bool
  const isRuler = s => /^[\|\-\:\s]+$/.test(s);

  // [-1:Left, 0:Center, 1:Right]
  // mmdTableAlignment :: String -> Int
  const mmdTableAlignment = s => {
    const
      l = s[0] === ':',
      r = s[s.length - 1] === ':';
    return l === r ? (
      0
    ) : r ? (
      1
    ) : -1;
  };

  // As many of the nodes (from the left) as are required
  // to reach a target leaf sum.
  // Node {leafWidth :: Int}
  // takeLeaves :: Int -> [Node] -> [Node]
  const takeLeaves = (n, xs) => {
    const lng = xs.length;
    return take(until(
      x => x.total >= n || x.index >= lng,
      x => ({
        total: x.total + xs[x.index].root.leafWidth,
        index: x.index + 1
      }), {
        total: 0,
        index: 0
      }
    ).index, xs);
  };

  // rulerAndTreeFromMMDTableLR :: String ->
  //    Either String ([Ordering], Tree)
  const rulerAndTreeFromMMDTableLR = s => {
    const
      rgxEscPipe = new RegExp('\\\\\\|', 'g'),
      rows = filter(
        x => x.length > 0 && x.includes('|'),
        map(x => x.trim(), lines(s.replace(rgxEscPipe, '&%')))
      );

    return isTable(rows) ? (() => {
      const
        mbiRuler = findIndex(isRuler, rows),
        cellRows = map(
          x => map(
            ms => Node({
              text: ms[1].trim(),
              leafWidth: max(1, ms[2].length)
            }),
            regexMatches(/([^|]+)(\|*)/, x)
          ),
          rows
        );

      const tplAlignmentsRows = mbiRuler.Nothing ? Tuple(
        replicate(
          maximum(map(
            cells => foldl((a, x) => a + x[1], 0, cells),
            cellRows
          )),
          0
        ),
        cellRows
      ) : bindMay(
        mbiRuler,
        iRuler => Tuple(
          map(
            mmdTableAlignment,
            filter(
              x => x.length > 0,
              rows[iRuler].split(/\s*\|\s*/g)
            )
          ),
          cellRows.slice(0, iRuler)
          .concat(cellRows.slice(iRuler + 1)),
        )
      );

      // forest :: [Node]
      const forest = foldr(
          (xCurrent, aBelow) => {
            const intBelow = aBelow.length;
            return intBelow > 0 ? (() => {
              const tpl = mapAccumL(
                (iFrom, nodeAbove, i) => iFrom >= intBelow ? Tuple(
                  iFrom, nodeAbove
                ) : (() => {
                  const
                    xs = takeLeaves(
                      nodeAbove.root.leafWidth,
                      aBelow.slice(iFrom)
                    );
                  return Tuple(
                    iFrom + xs.length,
                    Object.assign(nodeAbove, {
                      nest: xs
                    })
                  );
                })(),
                0,
                init(xCurrent)
              );
              // All remaining children gathered by rightmost parent
              return snd(tpl).concat(Object.assign(last(xCurrent), {
                nest: aBelow.slice(fst(tpl))
              }));
            })() : xCurrent;
          }, [],
          tplAlignmentsRows[1]
        ),
        intNodes = forest.length;

      return intNodes > 0 ? Right(
        Tuple(
          Tuple(mbiRuler.Just, tplAlignmentsRows[0]),
          intNodes > 1 ? (
            Node(undefined, forest)
          ) : forest[0]
        )
      ) : Left('Could not be parsed as an MMD table');
    })() : Left('Too sparse to parse as an MMD table.')
  };

  // MAIN -------------------------------------------------
  return main();
})();

2 Likes

Updated to Ver 0.4 above to make rightmost potential parent cell (in nested MMD tables), gather all remaining child cells (regardless of the rightmost spanning pipe count, which is normalised in the tidying), and to recognise a single pipe character as a minimal MMD ruler.

For example:

|Scripts
|
Greek|||Hebrew
Alpha|Beta|Gamma|Alef|Beit|Gimel

is tidied to:

|                Scripts                ||||||
|:-----:|:----:|:-----:|:----:|:----:|:-----:|
|       Greek        |||      Hebrew       |||
| Alpha | Beta | Gamma | Alef | Beit | Gimel |

which renders (with a Github stylesheet) as:

28

Also pruned some redundant whitespace so that this:

|Scripts
|
Greek|||Hebrew
α | β | γ | א | ב | ג |

is now tidied to:

|     Scripts      ||||||
|:-:|:-:|:-:|:-:|:-:|:-:|
|  Greek  ||| Hebrew  |||
| α | β | γ | א | ב | ג |

which renders (note a Bidirectional Text issue with MMD renderers) as:

53

That looks pretty cool. It is too bad that the Discourse forum software does not support MD tables. But it does support (with a plugin) HTML tables.

Any chance that you have a script to convert MD table to HTML table?

a script to convert MD table to HTML table?

That’s what any MMD renderer / previewer does.

See, in particular:

http://fletcherpenney.net/multimarkdown/

or, conveniently packaged:

http://multimarkdown.com/

and

1 Like

MD Table

Note, incidentally, that MD itself doesn’t define tables (you would have to use raw HTML markup manually).

(It has to be an MMD renderer, a plain MD renderer wouldn’t work)

a script to convert MD table to HTML table?

Well, it turns out that Fletcher Penney's MultiMarkdown renderer is slightly broken for bidirectional text (tables including both LTR and RTL cells – see below) so I will have to write my own after all, but it won't be until next week – other things press till at least the weekend.

1 Like

PS I notice that the current Discourse engine does now support MMD table markup, albeit with fairly low-key default CSS:

MMD:

| Col A | Col B |  Col C  |
|:-----:|:-----:|:-------:|
|  A1   |  B1   |   C1    |
|  A2   |       | :smile: |

Rendered as HTML:

Col A Col B Col C
A1 B1 C1
A2 :smile:

For other styles, short of using Admin rights to customise the default CSS, I thought it might be possible by using a variant of this macro:

to generate an HTML version of the MMD table with custom embedded style elements, but it turns out that the Discourse software overrides any styling directly embedded in table elements.

1 Like

Ah … Not quite MMD tables. (no colspan above 1 recognized)

It seems that the table markup supported by Discourse is some other extended Markdown - perhaps Github Flavoured ?