Copy MultiMarkdown table as HTML code

A first sketch.

Scope:

Intended to work with:

  1. Rectangular MMD tables (in which each cell has just one cell below it or above it)
  2. Nested MMD tables (in which a parent cell may span multiple child cells in the next row(s))

(Not that it is not intended for use with tables in which one child cell spans multiple parent cells in the row above. It will ignore the aspiration of any child cell to have two mothers)

Limitation:

  • this basic draft applies no style to the table.

Status:

  • experimental – no guarantees, and bug reports welcome.

Use:

  • Select an MMD table, and run the macro.
  • If the MMD table is well-formed enough to be parsed, it will be copied to the clipboard as an HTML table.

Copy MMD table as HTML table.kmmacros (41.5 KB)

JavaScript source

(() => {
    'use strict';

    // TRANSLATE A SINGLE MMD TABLE IN THE CLIPBOARD TO HTML

    // Robin 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.1

    // MAIN ---------------------------------------------------

    // main :: () -> IO String
    const main = () => {
        const
            // ASSUMES THAT AN MMD TABLE IS COPIED TO THE CLIPBOARD
            sa = standardAdditions(),
            strMMDTable = sa.theClipboard(),

            // EITHER HTML TRANSLATION OR ORIGINAL STRING
            lrHTML = htmlFromMMDTablesLR(strMMDTable);

        return isLeft(lrHTML) ? (
            strMMDTable // Text unchanged if couldn't be parsed as MMD table
        ) : (
            sa.setTheClipboardTo(lrHTML.Right),
            lrHTML.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);

    // 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));

    // eq (==) :: Eq a => a -> a -> Bool
    const eq = (a, b) => a === b;

    // 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();
    };

    // findIndexR :: (a -> Bool) -> [a] -> Maybe Int
    const findIndexR = (p, xs) => {
        const i = reverse(xs).findIndex(p);
        return i !== -1 ? (
            Just(xs.length - (1 + 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];

    // Typical usage: groupBy(on(eq, f), xs)
    // groupBy :: (a -> a -> Bool) -> [a] -> [[a]]
    const groupBy = (f, xs) => {
        const dct = xs.slice(1)
            .reduce((a, x) => {
                const h = a.active.length > 0 ? a.active[0] : undefined;
                return h !== undefined && f(h, x) ? {
                    active: a.active.concat([x]),
                    sofar: a.sofar
                } : {
                    active: [x],
                    sofar: a.sofar.concat([a.active])
                };
            }, {
                active: xs.length > 0 ? [xs[0]] : [],
                sofar: []
            });
        return dct.sofar.concat(dct.active.length > 0 ? [dct.active] : []);
    };

    // 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);
    };

    // 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;

    // e.g. sortBy(on(compare,length), xs)
    // on :: (b -> b -> c) -> (a -> b) -> a -> a -> c
    const on = (f, g) => (a, b) => f(g(a), g(b));

    // 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);

    // reverse :: [a] -> [a]
    const reverse = xs =>
        typeof xs === 'string' ? (
            xs.split('')
            .reverse()
            .join('')
        ) : xs.slice(0)
        .reverse();

    // 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
        });


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

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

    // 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);
    };

    // tableSandwichLR :: String ->
    //        Left Message Right {Pre :: String, Table :: String, Post :: String}
    const tableSandwichLR = s => {
        const
            xs = lines(s),
            p = x => x.includes('|'),
            mb = bindMay(
                findIndex(p, xs),
                iTable => bindMay(
                    findIndexR(p, xs),
                    iLast => Just({
                        pre: unlines(xs.slice(0, iTable)) + '\n',
                        table: unlines(xs.slice(iTable, iLast + 1)) + '\n',
                        post: unlines(xs.slice(iLast + 1)) + '\n'
                    })
                )
            );
        return mb.Nothing ? Left(
            'No MMD table found in string'
        ) : Right(mb.Just);
    };

    // 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.')
    };

    // WRITE NODE TREE -> HTML TABLE STRING ---------------------------------

    // Simplest adjustment of bidirectional cells run sequence

    // bidir :: [Node] -> [Node]
    const bidir = xs => {

        // isRTL :: String -> Bool
        const isRTL = s =>
            /^[\u0590-\u05FF\u0600-\u06FF\u0700-\u074Fֿ\s]+$/.test(s) &&
            !(/^\sֿ*$/.test(s));

        // isNum :: String -> Bool
        const isNum = s =>
            !isNaN(s) && /\d/.test(s);

        const dctRuns = foldr(
            (x, a) => {
                const
                    s = x.root.text,
                    blnInRTL = a.ltr.length > 0;
                return isRTL(s) ? { // APPENDED AT LEFT
                    rtl: a.rtl.concat(x),
                    ltr: a.ltr
                } : isNum(s) ? { // AT LEFT IN RTL RUN, AND VICE VERSA
                    rtl: blnInRTL ? a.rtl.concat(x) : [],
                    ltr: blnInRTL ? a.ltr : [x].concat(a.ltr)
                } : {
                    rtl: [], // APPENDED AT RIGHT
                    ltr: [x].concat(a.rtl).concat(a.ltr)
                };
            }, {
                rtl: [],
                ltr: []
            },
            xs
        );

        // MAIN (MIXED) ACCUMULATOR WITH ANY RESIDUE RIGHT-APPENDED
        return dctRuns.ltr.concat(dctRuns.rtl);
    };

    // singleSpaces :: String -> String
    const singleSpaces = s => s.replace(/\s+/g, ' ');

    // isHeader -> Dict Cell -> HTMLString
    // cellTag :: [Ordering] -> Bool -> Dict -> String
    const cellTag = (rules, blnHeader, cell) => {
        const intLeaves = cell.leafWidth;
        return htmlTag(
            blnHeader ? (
                'th'
            ) : 'td', {
                style: 'text-align:' + alignName(rules[cell.col]) + ';',
                colspan: intLeaves > 1 ? (
                    intLeaves.toString()
                ) : undefined
            },
            singleSpaces(htmlEncoded(cell.text || ''))
        );
    };

    // htmlEncoded :: String -> String
    const htmlEncoded = s => {
        const rgx = /[\w\s]/;
        return ''.concat.apply('',
            s.split('')
            .map(c => rgx.test(c) ? (
                c
            ) : '&#' + c.charCodeAt(0) + ';')
        );
    };

    // htmlTag :: String -> Dict -> String
    const htmlTag = (name, dct, content) =>
        '<' + name + ' ' + Object.keys(dct)
        .reduce((a, k) => {
                const v = dct[k];
                return v !== undefined ? a + k + '="' + dct[k] + '" ' : a;
            },
            ''
        ).trim() + '>' + content + '</' + name + '>';

    // 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;
    };


    // alignName :: Ordering -> String
    const alignName = eAlign => ['left', 'center', 'right'][eAlign + 1];

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

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

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

        // htmlRows :: [String]
        const htmlRows =
            map(
                (cells, iRow) => '<tr>\n' +
                map(
                    cell => '\t' + cellTag(
                        rules,
                        iRow < iRuler, // Header row ?
                        cell.root
                    ) + '\n',
                    bidir(cells)
                ).join('') + '</tr>',
                filter(
                    row => !findIndex(x => Boolean(x.root.text), row).Nothing,
                    withColIndices(levelNodes(tree))
                )
            );

        // MMD HTML string ---------------------------------------------------

        return Right(unlines([
            '<table>',
            '<colgroup>',
            map(eAlign =>
                '<col style="text-align:' + alignName(eAlign) + ';"/>',
                rules
            ).join('\n'),
            '</colgroup>',
            (() => {
                const rows = htmlRows.slice(0, iRuler);
                return rows.length > 0 ? (
                    '<thead>\n' + unlines(rows) + '\n</thead>'
                ) : '';
            })(),
            '<tbody>',
            unlines(
                htmlRows.slice(iRuler)
            ),
            '</tbody>',
            '</table>'
        ]));
    };

    // htmlFromMMDTablesLR :: String -> Either String HTML
    const htmlFromMMDTablesLR = s =>
        Right(unlines(map(
            xs => xs.length > 0 && xs[0].includes('|') ? (
                bindLR(
                    rulerAndTreeFromMMDTableLR(
                        unlines(xs)
                    ),
                    tplRulerTree => uncurry(
                        tableHTMLFromTree
                    )(tplRulerTree)
                ).Right
            ) : (() => {
                const ts = filter(
                    x => (x).trim().length > 0,
                    xs
                );
                return xs.length > 0 ? unlines(map(
                    t => '<p>' + htmlEncoded(t) + '</p>',
                    ts
                )) : '<p/>';
            })(),
            groupBy(
                on(eq, x => x.includes('|')),
                lines(s)
            )
        )));

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

1 Like