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

Although I may have missed it, can someone please post / share the final macro>

Thank you.

The final JavaScript was shared, in post #25. You should be able to copy/paste that into the JavaScript block in the full macro in post #21, replacing what's there.

-rob.

@griffman got it!

Will give it a try!!

Apologies (as the non-programmer here), but I downloaded the macro from Post 21, replaced the Java Script with the Java Script on post 25 and the only the was "true".

Please let me know I need to change?

Thank you.

It was written in 2023 before the (default) Modern Syntax option was introduced (and when I get back from the bush to a macOS machine next week I can update it) but in the meanwhile the simplest fix is probably to uncheck the Modern Syntax option (see the link below for details).

Appreciate both teh quick response and the future rewrite of teh script.

I already had "Modern Syntax" unchecked so still a 'no go" .

Look forward to your return and enjoy the bush.

First a quick check of:

  1. which script you are using, and
  2. how you are using it when you report a true return value.

You refer to posts #21 and #25, but that doesn't quite seem to match

https://forum.keyboardmaestro.com/t/tab-aligned-text/33131/21

(no script)

or

https://forum.keyboardmaestro.com/t/tab-aligned-text/33131/25

(no code)


Are you copying material and binding a Keyboard Maestro variable name to it ?

Yes, I am.

And you are using the code in #26

( Tab-Aligned Text - #26 by ComplexPoint )

which writes to a file ?

(when you report a result of just true, is that in a file ?)

Could you post here:

  1. the macro draft which you have a assembled
  2. a quick description of how you are using it it, and the difference between what you are expecting and what you are seeing ?

I am just walking into a meeting but will do later today / tonight (as soon as I can).

Thanks for looking at this.

1 Like

As requested:

  1. I have it setup in a test macro, I did not bring it into the larger macro. The test macro is below.

RTF table from delimited lines_As Fed File.kmmacros (9.6 KB)

  1. I expected it to out a display text window with formatted out rather that a display text window with which read "true" per the below as well as a file on my desktop titled tabular.rtf which is blank.

If you need anything else please let me know.

Thanks.

That, I think, is because of a slight inconsistency in variable names:

  • kmv("ColonDelimitedLines") in the source text (no local_ prefix)
  • but local_ColonDelimitedLines (with prefix) is the name to which you are binding your data.

a display text window

The source text only aims for file output – it doesn't put rich text in a display window.

(The true you are seeing is just a report that writefile has worked)


So adjusting the source line to:

kmv("local_ColonDelimitedLines")

and changing the display text action to show the (public.rtf) clipboard contents:

RTF table from delimited lines_As Fed File II.kmmacros (9,5 Ko)

Appreciated.

I note that the name I assigned is identical to the one that appeared in the Set Variable action that was i) in the original macro and ii) I disabled to feed it a file.

I will test late tonight when I am back from a meeting. Thank you!