How to Format a Variable Text, to Produce Uniform Column Table?

Is there a way to make this text more readable


   7 - 9am Working
   9 - 11am Meeting
   11 - 11:30am Meeting
   11:30am - 12:30pm Meeting
   12:30pm - 1pm Working
   2 - 5pm Working

So, when converted, it looks like the text below.


   7 - 9am ............. Working
   9 - 11am ............ Meeting
   11 - 11:30am ........ Meeting
   11:30am - 12:30pm ... Meeting
   12:30pm - 1pm ....... Working
   2 - 5pm ............. Working

As you might have noticed,

  • Hours are not uniform, sometimes they might be short, like a "7", and sometimes it might be a longer text like "11:30am".
  • Text after the hours doesn't change. Text after the hours range always says "Working" or "Meeting"?
  • Fill out to 21 characters with periods.

EDIT: Added that each line would be 21 characters max., in case I need to add a new single entry

Found this very similar thread from two years ago:

Which is almost what I'd like it to do, except I'm not using brackets

I’m away from my Mac, so I can’t make a KeyboardMaestro macro, but here’s how it should work in Python 3:


import re
import fileinput
entry = re.compile(r'^(.+)(Meeting|Working)$')

for line in fileinput.input():
    m = entry.match(line)
    if m:
        print(f'{<21s} {}')

The key is breaking each line into the time part and the Meeting/Working part. Then use Python’s formatting specifications to print the time left-aligned, filling it out to 21 characters with periods (.<21).

If you or someone else can turn this into a KM macro, probably by getting standard input from the clipboard, great. If not, I can do that when I’m back at my Mac tonight.


In Execute a (JavaScript | AppleScript) KM actions (and even in Python) I use:

  • justifyLeft
  • justifyRight


The JavaScript versions are essentially just wrappers around .padStart, .padEnd

A JS example here might look like:

(() => {
    'use strict';

    // main :: IO ()
    const main = () => {
        const schedule =
            `   7 - 9am Working
   9 - 11am Meeting
   11 - 11:30am Meeting
   11:30am - 12:30pm Meeting
   12:30pm - 1pm Working
   2 - 5pm Working`;

            linePairs = lines(schedule).map(s =>
                s.split(/ (?=[MW])/)
            firstColumnWidth = 4 + maximum(
                    whenWhat => whenWhat[0].length
            whenWhat => justifyLeft(firstColumnWidth)('.')(
                `${whenWhat[0]} `
            ) + ` ${whenWhat[1]}`

    // --------------------- GENERIC ---------------------

    // fst :: (a, b) -> a
    const fst = tpl =>
        // First member of a pair.

    // justifyLeft :: Int -> Char -> String -> String
    const justifyLeft = n =>
        // The string s, followed by enough padding (with
        // the character c) to reach the string length n.
        c => s => n > s.length ? (
            s.padEnd(n, c)
        ) : s;

    // lines :: String -> [String]
    const lines = s =>
        // A list of strings derived from a single
        // string delimited by newline and or CR.
        0 < s.length ? (
        ) : [];

    // list :: StringOrArrayLike b => b -> [a]
    const list = xs =>
        // xs itself, if it is an Array,
        // or an Array derived from xs.
        Array.isArray(xs) ? (
        ) : Array.from(xs || []);

    // maximum :: Ord a => [a] -> a
    const maximum = xs => (
        // The largest value in a non-empty list.
        ys => 0 < ys.length ? (
                (a, y) => y > a ? (
                ) : a, ys[0]
        ) : undefined

    // MAIN ---
    return main();

JS, AS, PY: I find I can code faster if everything has the same name in each scripting language, or if I can at least look up the details in each language by a single key : -)


// justifyLeft :: Int -> Char -> String -> String
const justifyLeft = n =>
    // The string s, followed by enough padding (with
    // the character c) to reach the string length n.
    c => s => n > s.length ? (
        s.padEnd(n, c)
    ) : s;

// justifyRight :: Int -> Char -> String -> String
const justifyRight = n =>
    // The string s, preceded by enough padding (with
    // the character c) to reach the string length n.
    c => s => n > s.length ? (
        s.padStart(n, c)
    ) : s;


-- justifyLeft :: Int -> Char -> String -> String
on justifyLeft(n, cFiller, strText)
    if n > length of strText then
        text 1 thru n of (strText & replicate(n, cFiller))
    end if
end justifyLeft

-- justifyRight :: Int -> Char -> String -> String
on justifyRight(n, cFiller, strText)
    if n > length of strText then
        text -n thru -1 of ((replicate(n, cFiller) as text) & strText)
    end if
end justifyRight

(Mnemonic wrappers around .ljust, .rjust)

# justifyLeft :: Int -> Char -> String -> String
def justifyLeft(n):
    '''A string padded at right to length n,
       using the padding character c.
    return lambda c: lambda s: s.ljust(n, c)

# justifyRight :: Int -> Char -> String -> String
def justifyRight(n):
    '''A string padded at left to length n,
       using the padding character c.
    return lambda c: lambda s: s.rjust(n, c)
FWIW an AppleScript version
use AppleScript version "2.4"
use framework "Foundation"
use scripting additions

on run
    set schedule to "   7 - 9am Working\n   9 - 11am Meeting\n   11 - 11:30am Meeting\n   11:30am - 12:30pm Meeting\n   12:30pm - 1pm Working\n   2 - 5pm Working"
    script split
        on |λ|(s)
            set i to 1 + (location of (item 1 of regexMatches(" (?=[MW])", s)))
            {text 1 thru i of s, text i thru -1 of s}
        end |λ|
    end script
    set linePairs to map(split, paragraphs of schedule)
    set columnWidth to 3 + maximum(map(compose(|length|, fst), linePairs))
    script format
        on |λ|(x)
            justifyLeft(columnWidth, ".", fst(x)) & snd(x)
        end |λ|
    end script
    unlines(map(format, linePairs))
end run


-- compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
on compose(f, g)
        property mf : mReturn(f)
        property mg : mReturn(g)
        on |λ|(x)
            mf's |λ|(mg's |λ|(x))
        end |λ|
    end script
end compose

-- foldl :: (a -> b -> a) -> a -> [b] -> a
on foldl(f, startValue, xs)
    tell mReturn(f)
        set v to startValue
        set lng to length of xs
        repeat with i from 1 to lng
            set v to |λ|(v, item i of xs, i, xs)
        end repeat
        return v
    end tell
end foldl

-- fst :: (a, b) -> a
on fst(tpl)
    if class of tpl is record then
        |1| of tpl
        item 1 of tpl
    end if
end fst

-- justifyLeft :: Int -> Char -> String -> String
on justifyLeft(n, c, s)
    -- The string s padded to width n with 
    -- replications of the character c.
    if n > length of s then
        text 1 thru n of (s & replicate(n, c))
    end if
end justifyLeft

-- length :: [a] -> Int
on |length|(xs)
    set c to class of xs
    if list is c or string is c then
        length of xs
        (2 ^ 29 - 1) -- (maxInt - simple proxy for non-finite)
    end if
end |length|

-- mReturn :: First-class m => (a -> b) -> m (a -> b)
on mReturn(f)
    -- 2nd class handler function lifted into 1st class script wrapper. 
    if script is class of f then
            property |λ| : f
        end script
    end if
end mReturn

-- map :: (a -> b) -> [a] -> [b]
on map(f, xs)
    -- The list obtained by applying f
    -- to each element of xs.
    tell mReturn(f)
        set lng to length of xs
        set lst to {}
        repeat with i from 1 to lng
            set end of lst to |λ|(item i of xs, i, xs)
        end repeat
        return lst
    end tell
end map

-- maximum :: Ord a => [a] -> a
on maximum(xs)
        on |λ|(a, b)
            if a is missing value or b > a then
            end if
        end |λ|
    end script
    foldl(result, missing value, xs)
end maximum

-- replicate :: Int -> String -> String
on replicate(n, s)
    -- n replications of the string s.
    set out to ""
    if n < 1 then return out
    set dbl to s
    repeat while (n > 1)
        if (n mod 2) > 0 then set out to out & dbl
        set n to (n div 2)
        set dbl to (dbl & dbl)
    end repeat
    return out & dbl
end replicate

-- snd :: (a, b) -> b
on snd(tpl)
    if class of tpl is record then
        |2| of tpl
        item 2 of tpl
    end if
end snd

-- regexMatches :: Regex String -> String -> [[String]]
on regexMatches(strRegex, strHay)
    set ca to current application
    -- NSNotFound handling and and High Sierra workaround due to @sl1974
    set NSNotFound to a reference to 9.22337203685477E+18 + 5807
    set oRgx to ca's NSRegularExpression's regularExpressionWithPattern:strRegex ¬
        options:((ca's NSRegularExpressionAnchorsMatchLines as integer)) ¬
        |error|:(missing value)
    set oString to ca's NSString's stringWithString:strHay
    script matchString
        on |λ|(m)
            script rangeMatched
                on |λ|(i)
                    tell (m's rangeAtIndex:i)
                        set intFrom to its location
                        if NSNotFound ≠ intFrom then
                            text (intFrom + 1) thru (intFrom + (its |length|)) of strHay
                            missing value
                        end if
                    end tell
                end |λ|
            end script
        end |λ|
    end script
    script asRange
        on |λ|(x)
            range() of x
        end |λ|
    end script
    map(asRange, (oRgx's matchesInString:oString ¬
        options:0 range:{location:0, |length|:oString's |length|()}) as list)
end regexMatches

-- splitRegex :: Regex -> String -> [String]
on splitRegex(strRegex, str)
    set lstMatches to regexMatches(strRegex, str)
    if length of lstMatches > 0 then
        script preceding
            on |λ|(a, x)
                set iFrom to start of a
                set iLocn to (location of x)
                if iLocn > iFrom then
                    set strPart to text (iFrom + 1) thru iLocn of str
                    set strPart to ""
                end if
                {parts:parts of a & strPart, start:iLocn + (length of x) - 1}
            end |λ|
        end script
        set recLast to foldl(preceding, {parts:[], start:0}, lstMatches)
        set iFinal to start of recLast
        if iFinal < length of str then
            parts of recLast & text (iFinal + 1) thru -1 of str
            parts of recLast & ""
        end if
    end if
end splitRegex

-- unlines :: [String] -> String
on unlines(xs)
    -- A single string formed by the intercalation
    -- of a list of strings with the newline character.
    set {dlm, my text item delimiters} to ¬
        {my text item delimiters, linefeed}
    set s to xs as text
    set my text item delimiters to dlm
end unlines
1 Like

OK, here's a macro that incorporates what I posted earlier. Select your unformatted schedule and run the macro. It replaces the selection with the formatted schedule.

1 Like

While I understand the motivation to have consistent functions across languages, I wouldn't do this because it could go on forever. Also, there are symmetries that could be lost. For example, replacing Python's ljust and rjust with justifyLeft and justifyRight breaks the symmetry with lstrip and rstrip.

Python borrows some of its function names from the ML / Haskell tradition (see the opening para of the Python itertools documentation).

That tradition has a more rigorous and consistent underlying semantics, and preferences naturally vary, but I personally find that problem-solving is improved by thinking (and naming) within that stricter semantics, and then translating into whichever local vernacular is at hand : -)

Hub semantics ⇄ spoke vernaculars.

Given the gap in AppleScript, which supplies no pre-baked justification functions, justifyLeft and justifyRight (from the Haskell tradition) work well enough, I think. Clear, above all.

PS re ljust rjust lstrip rstrip – symmetry is pretty, but like anything else, lexical abbrevn has its costs. My naming preferences are growing more agglutinating these days.

A good discussion here, I think:

[German Naming Convention](

A Python footnote:

If we wanted to slightly generalize @drdrang's Python example:

  • allowing for any width of first column, rather than risking clipping or excessive spacing with a fixed 20 chars,
  • and using a KM variable directly rather than importing a file.

we could:

  • find the maximum width of the first column with max, applying .ljust(maxWidth, spacerChar),
  • and wrap the shell name of the multiLine KM variable in triple quotes (inside a heredoc).

Justified first column of unknown width.kmmacros (18.7 KB)

Shell source for KM action
/usr/local/bin/python3 <<PY_END 2>/dev/null
'''Triple quoted for multiline string'''
import re

linePairs = [
    re.split(r'(?=Work|Meet)', x) for x
    in '''$KMVAR_sampleString'''.splitlines()

colWidth = 3 + max(len(x[0]) for x in linePairs)
for x in linePairs:
        x[0].ljust(colWidth, '.') + ' ' + x[1]

Thanks @drdrang, great to see your original script only needed to be moved inside an action
Thanks @ComplexPoint as well for your options!

Hey @hello,

I'm not bothering with field widths here.

The limit is the single word second column – anything before that is considered column one.

The table will naturally expand its width as needed.


Make Two Column Table with . Leader v1.00.kmmacros (5.9 KB)

1 Like

Thanks @ccstone, in this case went with drdaang's option, since I needed to add additional entries, thus the need for keeping a uniform line length.

Try adding additional entries to mine and see what happens.


I would also note - on Python usage - that if @drdrang's code were extended to a battery of tests the "walrus" :slight_smile: operator (:=) in 3.8 would be handy...

... In my Python code (in md2pptx) I have a battery of RegExes, causing elif to come into play. Under those circumstances - prior to 3.8 - I would have to run each RegEx twice. Walrus would allow me to run each once.

(I've flagged clearly in the documentation and elsewhere that any release of md2pptx on or after 1 March 2021 will prerequisite 3.8 or higher.)

But this use case has only a single RegEx so the above doesn't currently apply. I guess there's a premium on condensing to a single RegEx - though a more complex one might run still slower.

1 Like

When trying a single line, there's no output:


that's why I was mentioning trying to keep a default line length, so any new entry remains the same, and grabs the default line length, ie. "21 characters"

Eh? Sure enough. Hmm...

Okay, easily fixed.

cat \
| sed -n '1,$'p \
| perl -ne 's!(\h+[^\h]+)$!\t$1!; print;' \
| column -t -s $'\t' \
| perl -ne 's!(?<= )( {2,})!("." x (length $1)) . " "!e; print;'

Thanks for clarifying – I missed the fact that you were adding the new entry to an existing table.

Where are you keeping the table?


The wizard reaches for another wand – hang on ... I can patch it ... :slight_smile:

This is real expelliarmus stuff ...

Do you find that you can still read Perl aloud in English paraphrase two weeks after writing it ?

I seem to lack that super-power – perhaps my age, but I find it write-only, these days 🤷🏽

1 Like

Over the years I've noted many times that you've had to patch or otherwise apply bandaids your own code.

Methinks thou dost protest too much.

And you sir of all people shouldn't be lecturing about readability.  :sunglasses:



A lecture ?

More a shift in my feelings about Perl.

I think that the ratio of readability to writability does vary between languages (modulo, as you point out, some room for sub-cultures)

I was asked offline for an example of using the Python 3.8 "walrus" operator. So here goes. (It's slightly pseudocode but I think that's OK. The essential syntax is right.)

So the best reference to the "walrus" operator is [here(

But consider this example:

MyRegex1 = r'...'
MyRegex2 = r'...'

if MyRegex1.match(myString):
    m = MyRegex1.match(myString)
elif MyRegex2.match(myString):
    m = MyRegex2.match(myString)

It would be better to replace the battery of tests with this:

if (m := MyRegex1.match(myString)):
elif (m := MyRegex2.match(myString)):

To be fair this only saves one RegEx match but it's more compact, a little faster, and once learnt a consumable idiom.

"Walrus" here refers to the := operator.

Appends it to a .txt file, sorry missed your comment.

Will share the macro once I drive test it a couple more days :slight_smile: