Tab-Aligned Text

Perhaps your experiments reveal that this is not an approach well-matched to RTF ?

The path I personally take to tabulation in RTF involves:

  • a table defined in terms of HTML markup, and then
  • converted HTML -> RTF

(Where the conversion can be performed by textutil in the shell or by calling ObjC methods from an osascript language – JS or AppleScript)

Rough sketch (you could refine the intermediate HTML markup for fonts, alignments etc), and you might prefer textutil for the HTML -> RTF conversion, but here in a JavaScript for Automation version:

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

    // Rob Trew @2023
    // Draft 00.01

    ObjC.import("AppKit");

    const columnDelimiter = ":";


    // Column delimited lines copied as RTF table
    // (via HTML markup)

    const main = () => {
        const kmv = kmValue(kmInstance());

        return either(
            alert("Delimited rows copied as RTF table")
        )(
            copyTypedString(true)("public.rtf")
        )(
            rtfFromHTML(
                htmlTable(
                    lines(
                        kmv("local_ColonDelimitedLines")
                    )
                    .map(x => x.split(columnDelimiter))
                )
            )
        );
    };

    // ---------------- KEYBOARD MAESTRO -----------------

    // kmInstance :: () -> IO String
    const kmInstance = () =>
        ObjC.unwrap(
            $.NSProcessInfo.processInfo.environment
            .objectForKey("KMINSTANCE")
        ) || "";

    // kmValue :: KM Instance -> String -> IO String
    const kmValue = instance =>
        k => Application("Keyboard Maestro Engine")
        .getvariable(k, {instance});


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


    // copyTypedString :: Bool -> String -> String -> IO ()
    const copyTypedString = blnClear =>
    // public.html, public.rtf, public.utf8-plain-text
        pbType => s => {
            const pb = $.NSPasteboard.generalPasteboard;

            return (
                blnClear && pb.clearContents,
                pb.setStringForType(
                    $(s),
                    $(pbType)
                )
            );
        };

    // ---------------------- HTML -----------------------

    // htmlTable :: [[String]] -> HTML String
    const htmlTable = rows => {
        const
            thead = "<thead></thead>",
            htmlrows = rows.map(
                row => `<tr>${row.map(x => `<td>${x}<td>`)
                .join("")}</tr>`
            )
            .join("\n"),
            tbody = `<tbody>\n${htmlrows}\n</tbody>`;

        return `<table>\n${thead}\n${tbody}\n</table>`;
    };

    // rtfFromHTML :: String -> Either String String
    const rtfFromHTML = strHTML => {
        const
            as = $.NSAttributedString.alloc
            .initWithHTMLDocumentAttributes($(strHTML)
            .dataUsingEncoding($.NSUTF8StringEncoding),
            0
            );

        return bindLR(
            "function" !== typeof as
            .dataFromRangeDocumentAttributesError ? (
                    Left("String could not be parsed as HTML")
                ) : Right(as)
        )(
        // Function bound if Right value obtained above:
            htmlAS => {
                const
                    error = $(),
                    rtfData = htmlAS
                    .dataFromRangeDocumentAttributesError({
                        "location": 0,
                        "length": htmlAS.length
                    }, {
                        DocumentType: "NSRTF"
                    },
                    error
                    );

                return Boolean(
                    ObjC.unwrap(rtfData) && !error.code
                ) ? Right(
                        ObjC.unwrap($.NSString.alloc
                        .initWithDataEncoding(
                            rtfData,
                            $.NSUTF8StringEncoding
                        ))
                    ) : Left(ObjC.unwrap(
                        error.localizedDescription
                    ));
            }
        );
    };

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


    // bindLR (>>=) :: Either a ->
    // (a -> Either b) -> Either b
    const bindLR = m =>
        mf => m.Left ? (
            m
        ) : mf(m.Right);


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


    // 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.
        0 < s.length
            ? s.split(/\r\n|\n|\r/u)
            : [];


    return main();
})();

RTF table from delimited lines.kmmacros (8.4 KB)

From which, pasting into TextEdit, I am getting this kind of thing:

and pasting here in the forum:

Mamma just killed a man that's terrible!
Put a gun against his head why?!
Pulled my trigger makes sense I suppose...
Now he's dead hello officer, I'd like to report a murder
2 Likes

You angel. I'm going to dive into this!

One question... Can this be written to a file in the background? That was the stumbling block with the other method.

Does the JS rely on any dependencies? I just tried it and nothing is pasted. :man_shrugging:t2:

It should just copy to clipboard if that's what you mean.

No dependencies, but I would try the JS code on its own,
(i.e. build your own macro) with a variable name of your own at the point where I have written:

kmv("ColonDelimitedLines")

whereas I notice that the variable in the macro is local_ColonDelimitedLines

mea culpa

I'll correct the macro and source listing above in a minute.


Done.

Working here with macOS 13.5.2


The RTF source should be writable to an .rtf file yes. I'll sketch an approach later.

Here's a copy of the source which writes to a local file as well as copying to a public.rtf pasteboard item (again, adjust line 30 to match a KM variable name of your own) e.g.

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

    // Rob Trew @2023
    // Draft 00.02

    ObjC.import("AppKit");

    const columnDelimiter = ":";


    // Column delimited lines copied as RTF table
    // (via HTML markup)
    // Also written to local file in this copy.

    const main = () => {
        const kmv = kmValue(kmInstance());

        return either(
            alert("Delimited rows copied as RTF table")
        )(
            rtf => (
                copyTypedString(true)("public.rtf")(rtf),
                writeFile("~/Desktop/tabular.rtf")(rtf)
            )
        )(
            rtfFromHTML(
                htmlTable(
                    lines(
                        kmv("ColonDelimitedLines")
                    )
                    .map(x => x.split(columnDelimiter))
                )
            )
        );
    };

    // ---------------- KEYBOARD MAESTRO -----------------

    // kmInstance :: () -> IO String
    const kmInstance = () =>
        ObjC.unwrap(
            $.NSProcessInfo.processInfo.environment
            .objectForKey("KMINSTANCE")
        ) || "";

    // kmValue :: KM Instance -> String -> IO String
    const kmValue = instance =>
        k => Application("Keyboard Maestro Engine")
        .getvariable(k, {instance});


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


    // copyTypedString :: Bool -> String -> String -> IO ()
    const copyTypedString = blnClear =>
    // public.html, public.rtf, public.utf8-plain-text
        pbType => s => {
            const pb = $.NSPasteboard.generalPasteboard;

            return (
                blnClear && pb.clearContents,
                pb.setStringForType(
                    $(s),
                    $(pbType)
                )
            );
        };

    // writeFile :: FilePath -> String -> IO ()
    const writeFile = fp => s =>
        $.NSString.alloc.initWithUTF8String(s)
        .writeToFileAtomicallyEncodingError(
            $(fp)
            .stringByStandardizingPath, false,
            $.NSUTF8StringEncoding, null
        );

    // ---------------------- HTML -----------------------

    // htmlTable :: [[String]] -> HTML String
    const htmlTable = rows => {
        const
            thead = "<thead></thead>",
            htmlrows = rows.map(
                row => `<tr>${row.map(x => `<td>${x}<td>`)
                .join("")}</tr>`
            )
            .join("\n"),
            tbody = `<tbody>\n${htmlrows}\n</tbody>`;

        return `<table>\n${thead}\n${tbody}\n</table>`;
    };

    // rtfFromHTML :: String -> Either String String
    const rtfFromHTML = strHTML => {
        const
            as = $.NSAttributedString.alloc
            .initWithHTMLDocumentAttributes($(strHTML)
            .dataUsingEncoding($.NSUTF8StringEncoding),
            0
            );

        return bindLR(
            "function" !== typeof as
            .dataFromRangeDocumentAttributesError ? (
                    Left("String could not be parsed as HTML")
                ) : Right(as)
        )(
        // Function bound if Right value obtained above:
            htmlAS => {
                const
                    error = $(),
                    rtfData = htmlAS
                    .dataFromRangeDocumentAttributesError({
                        "location": 0,
                        "length": htmlAS.length
                    }, {
                        DocumentType: "NSRTF"
                    },
                    error
                    );

                return Boolean(
                    ObjC.unwrap(rtfData) && !error.code
                ) ? Right(
                        ObjC.unwrap($.NSString.alloc
                        .initWithDataEncoding(
                            rtfData,
                            $.NSUTF8StringEncoding
                        ))
                    ) : Left(ObjC.unwrap(
                        error.localizedDescription
                    ));
            }
        );
    };

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


    // bindLR (>>=) :: Either a ->
    // (a -> Either b) -> Either b
    const bindLR = m =>
        mf => m.Left ? (
            m
        ) : mf(m.Right);


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


    // 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.
        0 < s.length
            ? s.split(/\r\n|\n|\r/u)
            : [];


    return main();
})();
3 Likes

It works! Brilliant!! :clap:t3::clap:t3:

1 Like