Insert Indents & Line Breaks into Text on Clipboard

Hello!

I have a web app in which I need to convert long text on my clipboard like this:

Engineering fees are based on the standard 2 week turnaround time. Shorter turnaround times are available and include expediting fees (25% increase for 1-week lead time, and 45% increase for 2-day lead time).

into text like this:

1.1     Engineering fees are based on the standard 2 week turnaround time. ‏‏‎‏ Shorter
        turnaround times are available and include expediting fees (25% increase  ‏‏‎‏‎ ‎     ‏‏‎‏‎ ‎    
        for 1-week lead time, and 45% increase for 2-day lead time).

Essentially, I have a limit of 107 characters per line in that text field, and I want to fake having a hanging indent with spaces (the only characters allowed). The "tabs" are made up of a combination of spaces and Unicode Character “ ” (U+00A0), because the application simply trims initial spaces by themselves.

I have this working manually with snippets for

Snippet 1. The spaces in front of the number, the number (as a place holder), space after the number
Snippet 2. A line break and the unicode character to create the "hanging indent" look.

However, this manual process with a bit of text is quite laborious.

Any ideas?

Thank you!

This does something like what you want.

The wrapping has no configuration, so it may not match your desires.

1.1     Engineering fees are based on the standard 2 week turnaround time. Shorter
        turnaround times are available and include expediting fees (25% increase for 1
        -week lead time, and 45% increase for 2-day lead time).

Wrap.kmmacros (4.4 KB)

1 Like

And an additional approach would be to define the monospaced hanging indentation that you want as a JS function, for example:

// monoSpacedHangingIndent :: Char ->
// Int -> Int -> String -> String -> String
const monoSpacedHangingIndent = indentChar =>
    textWidth => indentWidth => outlineLabel => txt => {
        const
            tab = indentChar.repeat(indentWidth),
            singleLine = ''
            .concat(...lines(txt))
            .replace(/\s{2,}/g, ' '),
            xs = singleLine.match(RegExp(
                '.{1,' + (
                    textWidth - indentWidth
                ) + '}(\\s|$)', 'g'
            ));
        return unlines([
            outlineLabel + indentChar.repeat(
                indentWidth - outlineLabel.length
            ) + xs[0],
            ...xs.slice(1).map(x => tab + x)
        ]);
    };

and use that in an Execute JavaScript for Automation action:

Monospaced hanging indent.kmmacros (21.3 KB)

Full source for Execute JS action
(() => {
    'use strict';

    const main = () => {
        const
            kme = Application('Keyboard Maestro Engine'),
            kmValue = k => kme.getvariable(k);

        const
            hanging = monoSpacedHangingIndent(chr('0xA0'))(
                parseInt(kmValue('monospacedLineWidth'))
            )(
                parseInt(kmValue('monospacedIndentWidth'))
            );
        return hanging(
            kmValue('monospacedIndentLabel')
        )(
            kmValue('monospacedIndentPara')
        );
    };

    // -------------------- HANGING INDENT --------------------

    // monoSpacedHangingIndent :: Char ->
    // Int -> Int -> String -> String -> String
    const monoSpacedHangingIndent = indentChar =>
        textWidth => indentWidth => outlineLabel => txt => {
            const
                tab = indentChar.repeat(indentWidth),
                singleLine = ''
                .concat(...lines(txt))
                .replace(/\s{2,}/g, ' '),
                xs = singleLine.match(RegExp(
                    '.{1,' + (
                        textWidth - indentWidth
                    ) + '}(\\s|$)', 'g'
                ));
            return unlines([
                outlineLabel + indentChar.repeat(
                    indentWidth - outlineLabel.length
                ) + xs[0],
                ...xs.slice(1).map(x => tab + x)
            ]);
        };

    // ------------------ GENERIC FUNCTIONS -------------------
    // https://github.com/RobTrew/prelude-jxa

    // chr :: Int -> Char
    const chr = x =>
        String.fromCodePoint(x);

    // lines :: String -> [String]
    const lines = s =>
        // A list of strings derived from a single
        // newline-delimited string.
        0 < s.length ? (
            s.split(/[\r\n]/)
        ) : [];

    // unlines :: [String] -> String
    const unlines = xs =>
        // A single string formed by the intercalation
        // of a list of strings with the newline character.
        xs.join('\n');

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

2 Likes

Or FWIW, slightly more cautiously and functionally (easier to refactor, and better behaved with empty input)

JS alternative source
(() => {
    'use strict';

    const main = () => {
        const
            kme = Application('Keyboard Maestro Engine'),
            kmValue = k => kme.getvariable(k);
        const
            hanging = monoSpacedHangingIndent(chr('0xA0'))(
                parseInt(kmValue('monospacedLineWidth'))
            )(
                parseInt(kmValue('monospacedIndentWidth'))
            );
        return hanging(
            kmValue('monospacedIndentLabel')
        )(
            kmValue('monospacedIndentPara')
        );
    };

    // -------------------- HANGING INDENT --------------------

    // monoSpacedHangingIndent :: Char ->
    // Int -> Int -> String -> String -> String
    const monoSpacedHangingIndent = indentChar =>
        textWidth => indentWidth => outlineLabel => txt => {
            const tab = indentChar.repeat(indentWidth);
            return bindMay(
                // Wrapped lines, if any.
                uncons(''
                    .concat(...lines(txt))
                    .replace(/\s{2,}/g, ' ')
                    .match(RegExp(
                        '.{1,' + (
                            textWidth - indentWidth
                        ) + '}(\\s|$)', 'g'
                    ))
                )
            )(
                compose(
                    unlines,
                    uncurry(cons),
                    bimap(
                        // First line,
                        append(outlineLabel + indentChar.repeat(
                            indentWidth - outlineLabel.length
                        ))
                    )(
                        // and remaining lines.
                        map(append(tab))
                    )
                )
            )
        };

    // ------------------ GENERIC FUNCTIONS -------------------
    // https://github.com/RobTrew/prelude-jxa

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


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


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


    // append (++) :: [a] -> [a] -> [a]
    // append (++) :: String -> String -> String
    const append = xs =>
        // A list or string composed by
        // the concatenation of two others.
        ys => xs.concat(ys);


    // bimap :: (a -> b) -> (c -> d) -> (a, c) -> (b, d)
    const bimap = f =>
        // Tuple instance of bimap.
        // A tuple of the application of f and g to the
        // first and second values respectively.
        g => tpl => Tuple(f(tpl[0]))(
            g(tpl[1])
        );


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


    // chr :: Int -> Char
    const chr = x =>
        String.fromCodePoint(x);


    // compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
    const compose = (...fs) =>
        fs.reduce(
            (f, g) => x => f(g(x)),
            x => x
        );

    // cons :: a -> [a] -> [a]
    const cons = x =>
        xs => [x].concat(xs);


    // length :: [a] -> Int
    const length = xs =>
        // Returns Infinity over objects without finite
        // length. This enables zip and zipWith to choose
        // the shorter argument when one is non-finite,
        // like cycle, repeat etc
        'GeneratorFunction' !== xs.constructor.constructor.name ? (
            xs.length
        ) : Infinity;


    // lines :: String -> [String]
    const lines = s =>
        // A list of strings derived from a single
        // newline-delimited string.
        0 < s.length ? (
            s.split(/[\r\n]/)
        ) : [];


    // map :: (a -> b) -> [a] -> [b]
    const map = f =>
        // The list obtained by applying f
        // to each element of xs.
        // (The image of xs under f).
        xs => [...xs].map(f);


    // maybe :: b -> (a -> b) -> Maybe a -> b
    const maybe = v =>
        // Default value (v) if m is Nothing, or f(m.Just)
        f => m => m.Nothing ? v : f(m.Just);


    // uncons :: [a] -> Maybe (a, [a])
    const uncons = xs =>
        // Just a tuple of the head of xs and its tail,
        // Or Nothing if xs is an empty list.
        0 < xs.length ? (
            Just(Tuple(xs[0])(xs.slice(1))) // Finite list
        ) : Nothing();


    // uncurry :: (a -> b -> c) -> ((a, b) -> c)
    const uncurry = f =>
        // A function over a pair, derived
        // from a curried function.
        function() {
            const
                args = arguments,
                xy = Boolean(args.length % 2) ? (
                    args[0]
                ) : args;
            return f(xy[0])(xy[1]);
        };


    // unlines :: [String] -> String
    const unlines = xs =>
        // A single string formed by the intercalation
        // of a list of strings with the newline character.
        xs.join('\n');

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

Thank you @peternlewis and @ComplexPoint for your quick and thoughtful responses! That's why this forum (and Keyboard Maestro in general) is amazing.

The complexity of the javascript is a bit outside of my understanding, and the macro by @peternlewis is understandable but not configurable unfortunately, so I'll work on trying to customize that if I can understand what its doing.

Thanks again!

1 Like

Hi @ComplexPoint I love the "easier to refactor" version! It is actually working really well, except is there a way to customize the quantity of spaces between the item number and the paragraph text? That's the only part that doesn't align right now!

Sure, but first of all, the fonts are all monospaced ?

If not, then alignment will be elusive ...

In the code, look for the comment // First line,

the number of indent characters used is defined by the expression

indentWidth - outlineLabel.length

to which you could certainly add expressions like +1 or -2 etc

but if you are having to do that, I wonder whether you are using a different, or non-monospaced, font for the outline label text ?

The font is not monospaced (Arial), but this gets me as close as possible unless there's another way to do it.

I modified that first line and it works great - thanks again!

1 Like

@ComplexPoint I ran into an interesting behavior. With the following phrase, and a "monospacedlinewidth" set to 98 and a monospacedIndentWidth set to 8, the script puts the first "calendar" on the next on the next line when it clearly can fit on the previous line (screenshot). Is this a non-monospaced font issue?

Discount: Elevated Volume Client - an Elevated Volume Discount (~15%) will be applied to the final invoice of the 2nd of 2 projects that are assigned within the same calendar month. An additional High Volume Discount (~10%) will be applied to the final invoice for the 3rd and each subsequent project that is assigned within the same calendar month.

Yes, once you are not using a monospaced font, character counts really cease to be an adequate model. In Arial etc, your capital D, and %, for example are very much wider than your lower case i.

Not impossible to get font metrics from the system, but that takes things close to one more order of magnitude in terms of complexity and coding time.
Would it be possible for you to choose a monospaced font ?

This would get you started, but I don't think I'll get time this week to think it through or test anything:

(() => {
    'use strict';

    ObjC.import('AppKit');

    // show :: a -> String
    const show = x => JSON.stringify(x, null, 2);

    // stringSizeInFontAtPointSize :: String -> String -> Num
    //                                  -> {width:Num, height:Num}
    const stringSizeInFontAtPointSize = (fontName, points, str) =>
        $.NSAttributedString.alloc.init.initWithStringAttributes(
            str, $({
                'NSFont': $.NSFont.fontWithNameSize(fontName, points)
            })
        )
        .size;

    // TEST -------------------------------------------------------------------
    return show([
        stringSizeInFontAtPointSize("Geneva", 32, "Hello World!"),
        stringSizeInFontAtPointSize("Geneva", 64, "Hello World!"),
        stringSizeInFontAtPointSize("Helvetica", 64, "Hello World!")
    ]);
})();

I really appreciate your help @ComplexPoint!

So that script figures out the width of a string in points. Since my text will always be Arial size 10, I ran the script to figure out that for this specific case, the maximum point width of a line is 436.20 points.

Is the next step to update the original script that you created here to break any string into lines of that maximum width, including any item numbers and indents?

I'm not actually sure how to do that, but I'm trying to understand the approach.

Well, you are going to have to measure each word individually, so probably best to get a reusable reference to the Arial size 10 NS Font object, rather than repeatedly constructing it:

// fontAtSize :: String -> Num -> NSFont
const fontAtSize = fontName =>
    points => $({
        'NSFont': $.NSFont.fontWithNameSize(fontName, points)
    });

You could then pass that to a function which, given the NSFont, returns a width and height measure for a given string.

// stringSizeInFont :: NSFont -> String -> { height :: Num, width :: Num }
const stringSizeInFont = oFont => txt =>
    $.NSAttributedString.alloc.init.initWithStringAttributes(
        txt, oFont
    ).size

and from those two general functions, given a font name and a point size, both defined in KM variables, you could derive a more specialised function which simply gives a width for any particular string:

// widthFromText :: String -> Float
const widthFromText = txt =>
    stringSizeInFont(
        fontAtSize(kmValue('wrapFont'))(
            parseFloat(kmValue('wrapPointSize'))
        )
    )(txt).width;

The first thing to measure will probably be that special spacing character which you are using for indents.

const
    indentSpaceChar = chr(kmValue('wrapSpaceCharHex')),
    indentSpaceWidth = widthFromText(indentSpaceChar);

and you'll also need to measure for the given font and size, the ordinary space character which separate words.

const wordSpaceWidth = widthFromText(' ');

Once you know the width of your indent space character, and the number of them you want to use in your indents, you can get a measure for the width, in points, of those indents:

const
    intIndentSpaces = parseInt(kmValue('wrapIndentWidth')),
    indentWidth = indentSpaceWidth * intIndentSpaces;

and if you've defined the total width (in points) that you want to fit everything into, then the width of each text line will be that total width less the indent width:

const
    totalWidth = parseFloat(kmValue('wrapTotalPointWidth')),
    textWidth = totalWidth - indentWidth;

The standard indent string (lines 2 onwards), will just be N repetitions of your spacer character:

const indent = indentSpaceChar.repeat(intIndentSpaces);

and the indent for the first line will be the label, plus as many space characters as are needed to give a reasonable match for the standard indent width:

const
    label = kmValue('wrapIndentLabel'),
    labelWidth = widthFromText(label),
    labelPadding = indentSpaceChar.repeat(
        Math.round((indentWidth - labelWidth) / indentSpaceWidth)
    );

Now we need a measure of every word in our para. To simplify, we could assume that every word is followed by one space, and add the width of that.

We can define an ordered list of (Word, Width) pairs, for the whole paragraph with the incantation:

// measuredWords :: [(String, Float)]
const measuredWords = map(
    ap(Tuple)(
        compose(
            add(wordSpaceWidth),
            widthFromText
        )
    )
)(
    words(kmValue('wrapIndentPara'))
);

and we can perform a 'fold' or 'reduction' over that list of measured words to break each line before it gets too long:

const dctWrapped = measuredWords.reduce((dct, w) => {
    const nextPosn = dct.position + w[1];
    return nextPosn > textWidth ? {
        position: 0,
        activeLine: [],
        previousLines: dct.previousLines.concat([
            dct.activeLine.concat(w[0])
        ])
    } : {
        position: nextPosn,
        activeLine: dct.activeLine.concat(w[0]),
        previousLines: dct.previousLines
    };
}, {
    position: 0,
    activeLine: [],
    previousLines: []
})

Adding any remaining residue to the accumulated list of lines:

dctWrapped.previousLines.concat([
    dctWrapped.activeLine
])

Finally, we can prepend our indents to each of the wrapped lines – a special label plus spacing for the first line, and standard indent strings for the remaining lines:

return bindMay(
    uncons(map(unwords)(
        dctWrapped.previousLines.concat([
            dctWrapped.activeLine
        ])
    ))
)(
    compose(
        unlines,
        uncurry(cons),
        bimap(
            // First Line - hanging indent,
            append(label + labelPadding)
        )(
            // and remaining lines, indented.
            map(append(indent))
        )
    )
);

So the following sketch (not tidied or organised, I'm afraid):

first sketch
(() => {
    'use strict';

    ObjC.import('AppKit');

    const main = () => {
        const
            kme = Application('Keyboard Maestro Engine'),
            kmValue = k => kme.getvariable(k),
            fontName = kmValue('wrapFont'),
            pointSize = parseFloat(kmValue('wrapPointSize'));

        // widthFromText :: String -> Float
        const widthFromText = txt =>
            stringSizeInFont(
                fontAtSize(fontName)(pointSize)
            )(txt).width;

        const
            indentSpaceChar = chr(kmValue('wrapSpaceCharHex')),
            indentSpaceWidth = widthFromText(indentSpaceChar);

        const wordSpaceWidth = widthFromText(' ');

        const
            intIndentSpaces = parseInt(kmValue('wrapIndentWidth')),
            indentWidth = indentSpaceWidth * intIndentSpaces;

        const
            totalWidth = parseFloat(kmValue('wrapTotalPointWidth')),
            textWidth = totalWidth - indentWidth;

        const indent = indentSpaceChar.repeat(intIndentSpaces);

        const
            label = kmValue('wrapIndentLabel'),
            labelWidth = widthFromText(label),
            labelPadding = indentSpaceChar.repeat(
                Math.round((indentWidth - labelWidth) / indentSpaceWidth)
            );


        // measuredWords :: [(String, Float)]
        const measuredWords = map(
            ap(Tuple)(
                compose(
                    add(wordSpaceWidth),
                    widthFromText
                )
            )
        )(
            words(kmValue('wrapIndentPara'))
        );

        const dctWrapped = measuredWords.reduce((dct, w) => {
            const nextPosn = dct.position + w[1];
            return nextPosn > textWidth ? {
                position: 0,
                activeLine: [],
                previousLines: dct.previousLines.concat([
                    dct.activeLine.concat(w[0])
                ])
            } : {
                position: nextPosn,
                activeLine: dct.activeLine.concat(w[0]),
                previousLines: dct.previousLines
            };
        }, {
            position: 0,
            activeLine: [],
            previousLines: []
        })

        return bindMay(
            uncons(map(unwords)(
                dctWrapped.previousLines.concat([
                    dctWrapped.activeLine
                ])
            ))
        )(
            compose(
                unlines,
                uncurry(cons),
                bimap(
                    // First Line - hanging indent,
                    append(label + labelPadding)
                )(
                    // and remaining lines, indented.
                    map(append(indent))
                )
            )
        );
    };

    // ------------------- FONT METRICS -------------------

    // fontAtSize :: String -> Num -> NSFont
    const fontAtSize = fontName =>
        points => $({
            'NSFont': $.NSFont.fontWithNameSize(fontName, points)
        });

    // stringSizeInFont :: NSFont -> String -> { height :: Num, width :: Num }
    const stringSizeInFont = oFont => txt =>
        $.NSAttributedString.alloc.init.initWithStringAttributes(
            txt, oFont
        ).size


    // ------------------ GENERIC FUNCTIONS -------------------
    // https://github.com/RobTrew/prelude-jxa

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

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


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

    // add (+) :: Num a => a -> a -> a
    const add = a =>
        // Curried addition.
        b => a + b;

    // ap :: (a -> b -> c) -> (a -> b) -> a -> c
    const ap = f =>
        // Applicative instance for functions.
        // f(x) applied to g(x).
        g => x => f(x)(
            g(x)
        );

    // append (++) :: [a] -> [a] -> [a]
    // append (++) :: String -> String -> String
    const append = xs =>
        // A list or string composed by
        // the concatenation of two others.
        ys => xs.concat(ys);


    // bimap :: (a -> b) -> (c -> d) -> (a, c) -> (b, d)
    const bimap = f =>
        // Tuple instance of bimap.
        // A tuple of the application of f and g to the
        // first and second values respectively.
        g => tpl => Tuple(f(tpl[0]))(
            g(tpl[1])
        );


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


    // chr :: Int -> Char
    const chr = x =>
        String.fromCodePoint(x);


    // compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
    const compose = (...fs) =>
        fs.reduce(
            (f, g) => x => f(g(x)),
            x => x
        );

    // cons :: a -> [a] -> [a]
    const cons = x =>
        xs => [x].concat(xs);


    // lines :: String -> [String]
    const lines = s =>
        // A list of strings derived from a single
        // newline-delimited string.
        0 < s.length ? (
            s.split(/[\r\n]/)
        ) : [];


    // map :: (a -> b) -> [a] -> [b]
    const map = f =>
        // The list obtained by applying f
        // to each element of xs.
        // (The image of xs under f).
        xs => [...xs].map(f);


    // maybe :: b -> (a -> b) -> Maybe a -> b
    const maybe = v =>
        // Default value (v) if m is Nothing, or f(m.Just)
        f => m => m.Nothing ? v : f(m.Just);


    // uncons :: [a] -> Maybe (a, [a])
    const uncons = xs =>
        // Just a tuple of the head of xs and its tail,
        // Or Nothing if xs is an empty list.
        0 < xs.length ? (
            Just(Tuple(xs[0])(xs.slice(1))) // Finite list
        ) : Nothing();


    // uncurry :: (a -> b -> c) -> ((a, b) -> c)
    const uncurry = f =>
        // A function over a pair, derived
        // from a curried function.
        function() {
            const
                args = arguments,
                xy = Boolean(args.length % 2) ? (
                    args[0]
                ) : args;
            return f(xy[0])(xy[1]);
        };


    // unlines :: [String] -> String
    const unlines = xs =>
        // A single string formed by the intercalation
        // of a list of strings with the newline character.
        xs.join('\n');


    // unwords :: [String] -> String
    const unwords = xs =>
        // A space-separated string derived
        // from a list of words.
        xs.join(' ');


    // words :: String -> [String]
    const words = s => s.split(/\s+/);

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

Should in theory (assuming that we have defined all the KM variables, and haven't overlooked too much)

return a string which looks ragged in monospace, but better aligned if Arial 10pt is applied to it:

ragged monospace appearance (line breaks and indents intended to work for Arial 10pt):

1.1   Engineering fees are based on the standard 2 week turnaround time. Shorter turnaround times
        are available and include expediting fees (25% increase for 1-week lead time, and 45% increase
        for 2-day lead time).

In TextEdit, FWIW, I am seeing this:

Tidied into a macro which hang-indents at the given width for Arial pt 10, and also puts an RTF copy of the hang-indented version into the clipboard.

Named font hanging indent.kmmacros (29.9 KB)

JavaScript for Automation source
(() => {
    'use strict';

    ObjC.import('AppKit');

    // Rob Trew 2020
    // Ver 0.02

    // FROM PLAIN TEXT TO HANG-INDENTED FOR GIVEN
    // FONT, SIZE, AND LINE WIDTH IN POINTS.

    // hangIndented :: String -> String -> [String] -> String
    const hangIndented = labelledIndent =>
        plainIndent => textLines => maybe('')(
            compose(
                unlines,
                uncurry(cons),
                bimap(
                    append(labelledIndent)
                )(
                    map(append(plainIndent))
                )
            )
        )(uncons(textLines));


    // linesOfWidthForFont :: NSFont -> Float -> String -> [String]
    const linesOfWidthForFont = oFont =>
        widthInPoints => txt => {
            const
                stringWidth = s => $.NSAttributedString.alloc.init
                .initWithStringAttributes(s, oFont).size.width,
                spaceWidth = stringWidth(' ');

            return measuredWordsFoldedToLineWidth(widthInPoints)(
                map(
                    ap(Tuple)(
                        compose(add(spaceWidth), stringWidth)
                    )
                )(words(txt))
            );
        };

    // measuredWordsFoldedToLineWidth :: Float -> [(String, Float)] -> [String]
    const measuredWordsFoldedToLineWidth = pointWidth =>
        measuredWords => {
            const
                tplWrapped = measuredWords.reduce(
                    ([posn, current, sofar], mwd) => {
                        const nextPosn = posn + mwd[1];
                        return nextPosn > pointWidth ? [
                            mwd[1], [mwd[0]],
                            sofar.concat([
                                current
                            ])
                        ] : [
                            nextPosn,
                            current.concat(mwd[0]),
                            sofar
                        ];
                    }, [0, [],
                        []
                    ]);
            return map(unwords)(
                tplWrapped[2].concat(
                    [tplWrapped[1]]
                )
            );
        };

    // ----------------------- TEST -----------------------
    const main = () => {
        const
            kme = Application('Keyboard Maestro Engine'),
            kmValue = k => kme.getvariable(k),
            fontName = kmValue('wrapFont'),
            pointSize = parseFloat(kmValue('wrapPointSize'));

        // widthFromText :: String -> Float
        const widthFromText = txt =>
            stringSizeInFont(
                fontAtSize(fontName)(pointSize)
            )(txt).width;

        const
            indentSpaceChar = chr(kmValue('wrapSpaceCharHex')),
            indentSpaceWidth = widthFromText(indentSpaceChar);

        const
            intIndentSpaces = parseInt(kmValue('wrapIndentWidth')),
            indentWidth = indentSpaceWidth * intIndentSpaces;

        const
            label = kmValue('wrapIndentLabel'),
            labelPadding = indentSpaceChar.repeat(
                Math.round((indentWidth - widthFromText(label)) / indentSpaceWidth)
            );

        const strHanging = hangIndented(
            // Labelled indent first line,
            label + labelPadding
        )(
            // and plain indent for remaining lines.
            indentSpaceChar.repeat(intIndentSpaces)
        )(
            linesOfWidthForFont(
                fontAtSize(fontName)(pointSize)
            )(
                parseFloat(kmValue('wrapTotalPointWidth')) - indentWidth
            )(
                kmValue('wrapIndentPara')
            )
        );

        // COPIED TO THE CLIPBOARD AS AS HANG-INDENTED RTF
        // IN SPECIFIED FONT AND SIZE
        return copyTextInFont(fontName)(
            pointSize
        )(strHanging);
    };


    // ------------------- FONT METRICS -------------------

    // fontAtSize :: String -> Num -> NSFont
    const fontAtSize = fontName =>
        points => $({
            'NSFont': $.NSFont.fontWithNameSize(fontName, points)
        });

    // stringSizeInFont :: NSFont -> String -> { height :: Num, width :: Num }
    const stringSizeInFont = oFont =>
        txt => $.NSAttributedString.alloc.init.initWithStringAttributes(
            txt, oFont
        ).size

    //  ------------------ RTF CLIPBOARD ------------------

    // // copyTextInFont :: String -> Float -> String -> IO String
    const copyTextInFont = fontName =>
        pointSize => txt => {
            const pb = $.NSPasteboard.generalPasteboard;
            return (
                pb.clearContents,
                // As RTF in specified font and size.
                pb.setDataForType(
                    $.NSAttributedString.alloc.init
                    .initWithStringAttributes(
                        txt, $({
                            'NSFont': $.NSFont.fontWithNameSize(
                                fontName,
                                pointSize
                            )
                        })
                    ).RTFFromRangeDocumentAttributes({
                        'location': 0,
                        'length': txt.length
                    }, {
                        DocumentType: $.NSRTFTextDocumentType
                    }),
                    $.NSPasteboardTypeRTF
                ),
                // Also a plain text version for text editor etc.
                pb.setStringForType(
                    $(txt),
                    $.NSPasteboardTypeString
                ),
                txt
            );
        };


    // ---------------- GENERIC FUNCTIONS -----------------
    // https://github.com/RobTrew/prelude-jxa

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


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


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


    // add (+) :: Num a => a -> a -> a
    const add = a =>
        // Curried addition.
        b => a + b;


    // ap :: (a -> b -> c) -> (a -> b) -> a -> c
    const ap = f =>
        // Applicative instance for functions.
        // f(x) applied to g(x).
        g => x => f(x)(
            g(x)
        );


    // append (++) :: [a] -> [a] -> [a]
    // append (++) :: String -> String -> String
    const append = xs =>
        // A list or string composed by
        // the concatenation of two others.
        ys => xs.concat(ys);


    // bimap :: (a -> b) -> (c -> d) -> (a, c) -> (b, d)
    const bimap = f =>
        // Tuple instance of bimap.
        // A tuple of the application of f and g to the
        // first and second values respectively.
        g => tpl => Tuple(f(tpl[0]))(
            g(tpl[1])
        );


    // chr :: Int -> Char
    const chr = x =>
        // The character at unix code-point x.
        String.fromCodePoint(x);


    // compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
    const compose = (...fs) =>
        // A function defined by the right-to-left
        // composition of all the functions in fs.
        fs.reduce(
            (f, g) => x => f(g(x)),
            x => x
        );


    // cons :: a -> [a] -> [a]
    const cons = x =>
        // A list constructed from the item x,
        // followed by the existing list xs.
        xs => [x].concat(xs);


    // lines :: String -> [String]
    const lines = s =>
        // A list of strings derived from a single
        // newline-delimited string.
        0 < s.length ? (
            s.split(/[\r\n]/)
        ) : [];


    // map :: (a -> b) -> [a] -> [b]
    const map = f =>
        // The list obtained by applying f
        // to each element of xs.
        // (The image of xs under f).
        xs => [...xs].map(f);


    // maybe :: b -> (a -> b) -> Maybe a -> b
    const maybe = v =>
        // Default value (v) if m is Nothing, or f(m.Just)
        f => m => m.Nothing ? v : f(m.Just);

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

    // uncons :: [a] -> Maybe (a, [a])
    const uncons = xs =>
        // Just a tuple of the head of xs and its tail,
        // Or Nothing if xs is an empty list.
        0 < xs.length ? (
            Just(Tuple(xs[0])(xs.slice(1))) // Finite list
        ) : Nothing();


    // uncurry :: (a -> b -> c) -> ((a, b) -> c)
    const uncurry = f =>
        // A function over a pair, derived
        // from a curried function.
        function() {
            const
                args = arguments,
                xy = Boolean(args.length % 2) ? (
                    args[0]
                ) : args;
            return f(xy[0])(xy[1]);
        };


    // unlines :: [String] -> String
    const unlines = xs =>
        // A single string formed by the intercalation
        // of a list of strings with the newline character.
        xs.join('\n');


    // unwords :: [String] -> String
    const unwords = xs =>
        // A space-separated string derived
        // from a list of words.
        xs.join(' ');


    // words :: String -> [String]
    const words = s =>
        // List of space-delimited sub-strings.
        s.split(/\s+/);

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

Wow thank you so much @ComplexPoint! I just tried it and it seems to work most of the time, except it seems to stop working on the following place holder text:

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce accumsan urna a tellus ultrices, sit amet rutrum mi aliquam. Aliquam viverra mauris at lobortis rutrum. Aenean mi turpis, rhoncus ac leo in, hendrerit efficitur nisi. Vivamus metus est, posuere nec semper eu, laoreet sed nibh. Vestibulum vitae bibendum ipsum. In semper enim sed arcu lobortis posuere. Vestibulum eu magna non enim ornare lobortis. In nec volutpat sem.

With a wrapTotalPointWidth set to 435, the 2nd to last line comes out like this:

    Vestibulum vitae bibendum ipsum. In semper enim sed arcu lobortis posuere. Vestibulum eu

magna

The script is keeping the word magna on that line, but my web app then wraps that word to the next line. I checked the length of the line including the 8 space indent (with the previous script that you provided), and it is 462.46, which is (obviously) larger than wrapTotalPointWidth. Do you know why that is happening?

The script itself seems to be working fine with that text – pasting from it to TextEdit we get:

The complexity you are describing appears to be an interaction in your web app, which I don't really know anything about. A bit beyond the scope of Keyboard Maestro, I think ...

A trailing space or two will make a difference at the margin – perhaps you could try adding a space at the end of the text, or asking the script to work with a slightly narrower wapping width, to allow for a little more safety ?

Thank you for the quick response! The script is working, but if you look at your screenshot, the second to last line is longer than the other ones. In fact, the length of that line is 462.46, which is larger than wrapTotalPointWidth (435). Shouldn't wrapTotalPointWidth push the word "magna" in that line to the last line?

Got it. I was being slow :slight_smile:
I've updated the margin handling to something more sensibly conservative in Ver 0.02 above (source code and macro)

1 Like

OMG this is amazing @ComplexPoint. Thank you so much! This works great and the documentation that you provided allows me to tweak it for the variety of cases that I have.

Is there a place I can donate some money for your time?

One other question. Is there a simple way to "undo" this formatting? It would be great to have a macro that will "unformat" the text, so that the numbers (any numbers in the front), line breaks, and spaces are removed?

1 Like