A first sketch.
Scope:
Intended to work with:
- Rectangular MMD tables (in which each cell has just one cell below it or above it)
- 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();
})();