Conversion of rich text to mail

Here’s an oddity. If I copy a sequence of rows into Apple Mail, it gets bulleted. If I convert my clipboard to plain text, the bullets go away. Here’s an image.

When you copy Bike text it gets put on the pasteboard in a number of different formats.

I’m not sure what Mail.app is using by default, but both Bike’s rich text format and html format are based on nested lists. So I think that’s why Mail is adding those bullets. If you don’t want them, but do want rich text elsewhere… I’m not sure.

I see that you can paste into rich text document in TextEdit, select all, and then choose Format > List > None to remove those lists, but retain the rest of the rich text formatting. I’m not sure how to do the same in Mail.

Open to suggestions.

I have a little app called “plaintextmenu” that strips out everything for me and that’s what I am doing in this case. My guess is that when (if?) you get to allowing soft returns within a row I might put data like these into one row and concern will go away. Oddly enough, not a problem with Folding Text, still my go-to app for the rest of my life. Hoping the faster Bike will someday replace FT.

1 Like

Perhaps not as odd as you might think : -)

A macOS clipboard nearly always contains a number of separate “pasteboard items” in different formats (e.g. HTML, RTF, plain text) etc, and the choice of which format to use is up to the pasting application.

Mail chooses the public.html pasteboard item, and ( just as any browser does if you drag a Bike file into it ), interprets the HTML unordered list markup into bulleted lines.

Not sure exactly how you are doing it, but whatever you are doing will effectively be removing the public.html pasteboard item which is Mail’s first choice, and leaving only a public.utf8-plain-text pasteboard item.

For more control over the pasting format that you want, you can use a script.

This one, for example, replaces the default pasteboard items placed in the clipboard by Bike, mainly:

  • com.hogbaysoftware.bike.xml
  • org.opml.opml
  • public.html
  • public.utf8-plain-text

with a single public.html pasteboard item using Mail’s own particular style of indentation markup:

Paste text outline to Mail.app in Format > Indentation style.kmmacros.zip (4.9 KB)


Before using a Keyboard Maestro Paste action, it converts the clipboard content using this JavaScript for Automation script.

Expand disclosure triangle to view JS source
(() => {
    "use strict";

    ObjC.import("AppKit");

    // Paste text outline to Mail.app
    // in Mail > Format > Indentation format
    // ( responds to Increase ⌘] and Decrease ⌘[ )


    // Rob Trew @2022
    // Ver 0.01

    // main :: IO ()
    const main = () =>
        either(
            alert("Paste as Mail.app indented")
        )(
            x => x
        )(
            bindLR(
                clipTextLR()
            )(
                compose(
                    Right,
                    setClipOfTextType("public.html"),
                    mailQuoteBlocksFromForest,
                    forestFromIndentedLines,
                    indentLevelsFromLines,
                    lines
                )
            )
        );


    // ------- INDENTED MAIL.APP HTML FROM FOREST --------

    // mailQuoteBlocksFromForest :: [Tree String] -> String
    const mailQuoteBlocksFromForest = forest => {
        const
            style = "style=\"margin: 0px 0px 0px 40px;\"",
            go = tree => {
                const xs = tree.nest;

                return [
                        `<div>${tree.root || "&nbsp;"}</div>`,
                        ...(
                            0 < xs.length ? [
                                [
                                    `<blockquote ${style}>`,
                                    xs.flatMap(go).join("\n"),
                                    "</blockquote>"
                                ]
                                .join("\n")
                            ] : []
                        )
                    ]
                    .join("\n");
            };

        return forest.flatMap(go).join("\n");
    };


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

    // alert :: String => String -> IO String
    const alert = title =>
        s => {
            const sa = Object.assign(
                Application("System Events"), {
                    includeStandardAdditions: true
                });

            return (
                sa.activate(),
                sa.displayDialog(s, {
                    withTitle: title,
                    buttons: ["OK"],
                    defaultButton: "OK"
                }),
                s
            );
        };


    // clipTextLR :: () -> Either String String
    const clipTextLR = () => {
        // Either a message, (if no clip text is found),
        // or the string contents of the clipboard.
        const
            v = ObjC.unwrap(
                $.NSPasteboard.generalPasteboard
                .stringForType($.NSPasteboardTypeString)
            );

        return Boolean(v) && 0 < v.length ? (
            Right(v)
        ) : Left("No utf8-plain-text found in clipboard.");
    };


    // setClipOfTextType :: String -> String -> IO String
    const setClipOfTextType = utiOrBundleID =>
        txt => {
            const pb = $.NSPasteboard.generalPasteboard;

            return (
                pb.clearContents,
                pb.setStringForType(
                    $(txt),
                    utiOrBundleID
                ),
                txt
            );
        };


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

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


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


    // Node :: a -> [Tree a] -> Tree a
    const Node = v =>
        // Constructor for a Tree node which connects a
        // value of some kind to a list of zero or
        // more child trees.
        xs => ({
            type: "Node",
            root: v,
            nest: xs || []
        });


    // Tuple (,) :: a -> b -> (a, b)
    const Tuple = a =>
        // A pair of values, possibly of
        // different types.
        b => ({
            type: "Tuple",
            "0": a,
            "1": b,
            length: 2,
            *[Symbol.iterator]() {
                for (const k in this) {
                    if (!isNaN(k)) {
                        yield this[k];
                    }
                }
            }
        });


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


    // bindLR (>>=) :: Either a ->
    // (a -> Either b) -> Either b
    const bindLR = lr =>
        // Bind operator for the Either option type.
        // If lr has a Left value then lr unchanged,
        // otherwise the function mf applied to the
        // Right value in lr.
        mf => "Left" in lr ? (
            lr
        ) : mf(lr.Right);

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

    // either :: (a -> c) -> (b -> c) -> Either a b -> c
    const either = fl =>
        // Application of the function fl to the
        // contents of any Left value in e, or
        // the application of fr to its Right value.
        fr => e => e.Left ? (
            fl(e.Left)
        ) : fr(e.Right);


    // first :: (a -> b) -> ((a, c) -> (b, c))
    const first = f =>
        // A simple function lifted to one which applies
        // to a tuple, transforming only its first item.
        ([x, y]) => Tuple(f(x))(y);


    // forestFromIndentedLines :: [(Int, String)] ->
    // [Tree String]
    const forestFromIndentedLines = tuples => {
        const go = xs =>
            0 < xs.length ? (() => {
                // First line and its sub-tree,
                const [depth, body] = xs[0],
                    [tree, rest] = span(x => depth < x[0])(
                        xs.slice(1)
                    );

                // followed by the rest.
                return [
                    Node(body)(go(tree))
                ].concat(go(rest));
            })() : [];

        return go(tuples);
    };

    // indentLevelsFromLines :: [String] -> [(Int, String)]
    const indentLevelsFromLines = xs => {
        const
            pairs = xs.map(
                x => bimap(
                    cs => cs.length
                )(
                    cs => cs.join("")
                )(
                    span(isSpace)([...x])
                )
            ),
            indentUnit = pairs.reduce(
                (a, [i]) => 0 < i ? (
                    i < a ? i : a
                ) : a,
                Infinity
            );

        return [Infinity, 0].includes(indentUnit) ? (
            pairs
        ) : pairs.map(first(n => n / indentUnit));
    };

    // isSpace :: Char -> Bool
    const isSpace = c =>
        // True if c is a white space character.
        (/\s/u).test(c);


    // lines :: String -> [String]
    const lines = s =>
        // A list of strings derived from a single string
        // which is delimited by \n or by \r\n or \r.
        Boolean(s.length) ? (
            s.split(/\r\n|\n|\r/u)
        ) : [];


    // span :: (a -> Bool) -> [a] -> ([a], [a])
    const span = p =>
        // Longest prefix of xs consisting of elements which
        // all satisfy p, tupled with the remainder of xs.
        xs => {
            const i = xs.findIndex(x => !p(x));

            return -1 !== i ? (
                Tuple(xs.slice(0, i))(
                    xs.slice(i)
                )
            ) : Tuple(xs)([]);
        };


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

In short, if you tell us what format you would prefer for pasting into Mail, we may be able to sketch you a script that translates to it.

1 Like

Thanks. In the short run, I think I’ll stick with plaintextmenu. It’s doing the job just fine. I have no idea of how to deal with scripts. By the way, I certainly prefer rtf to markdown for most instances.

1 Like

Note, FWIW that Bike is rich text in the general (lower case rich text) sense of having a markup layer,

but not in the more particular sense of Microsoft’s Rich Text Format (RTF) word processing format.

Of course, I could always copy from Bike, past into WriteRoom, then copy from WriteRoom into Mail. That works! :slight_smile:

It can be useful in many contexts to have a general-purpose Paste as Plain Text macro.

If you are using Keyboard Maestro, for example, then perhaps something like:
Paste as plain text.kmmacros.zip (2.1 KB)

(Which I personally have bound to ⌘⇧V)

I don’t have Keyboard Maestro and $36 for a one-time need is more than I need (or think I need) at the moment. 10 years ago I’d have had fun with it, though. Thanks for the suggestion.

1 Like