JXA draft script: point size that fits a line of text into a given rectangle?

Forking off from an earlier thread about OG (Test 7.3)'s omniJS, here is a first draft of a JXA script which aims to read font metrics for a given string, font and point size from:

NSMutableAttributedString

and iterate towards the largest font size that will fit a given string into a pair of width and height limits.

A few things to notice, if you would like to test it:

  1. I am personally running OG (Test) 7.3 in which JXA and AppleScript are not yet able to read the current selection, so I haven’t been able to test the version of it which acts on the first selected shape.
  2. This is written in (Sierra-compatible) ES6 JS, if you want to test it on an earlier macOS, you will need ES5 JS – cut and paste ES6 -> ES5 conversions are given at https://lebab.io/try-it
  3. All versions of OG are prone (in their response to scripts) to hit some potholes in the journey between Model and View - particularly with scripted changes to text and link lines, the screen may not actually display the change that has been made, at least initially. You may even need to close and reopen the file … and you are very likely to need to click or jiggle the shapes to force a repaint of the canvas.

Finally, if you are aiming to fit a line or phrase of text into a particular shape, remember to adjust for its margin/padding settings - otherwise this kind of script may seem to suggest a font-size that is slightly higher than you want.

(() => {
    'use strict';

    ObjC.import('AppKit');

    // GENERIC FUNCTIONS ---------------------------------------------------------------------------

    // enumFromTo :: Int -> Int -> [Int]
    const enumFromTo = (m, n) =>
        Array.from({
            length: Math.floor(n - m) + 1
        }, (_, i) => m + i);

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

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

    // until :: (a -> Bool) -> (a -> a) -> a -> a
    const until = (p, f, x) => {
        let v = x;
        while (!p(v)) v = f(v);
        return v;
    };

    // FONT SIZE THAT FITS TEXT LINE TO CONSTRAINING BOX

    // pointSizeToFitSingleLineInBox :: Num -> Num -> String ->
    //                                  String -> Num -> Num -> Num
    function pointSizeToFitLineInBox(boxWidth, boxHeight, fontName, strText,
        minPoints, maxPoints) {
        const
            nMin = minPoints || 5,
            nMax = maxPoints || 100,
            floor = Math.floor,
            dctRange = {
                location: 0,
                length: strText.length
            },
            mas = $.NSMutableAttributedString.alloc.init
            .initWithStringAttributes(
                strText, $({
                    'NSFont': $.NSFont.fontWithNameSize(fontName, nMin)
                })
            );
        // NB: This will be iteratively mutated
        let mutableSize = mas.size;

        return until(
                x => x.highest - x.lowest <= 1,
                x => {
                    const
                        blnOver = (mutableSize.width > boxWidth ||
                            mutableSize.height > boxHeight),
                        upper = blnOver ? x.pointSize : x.highest,
                        lower = blnOver ? x.lowest : x.pointSize,
                        pSize = lower + floor((upper - lower) / 2);

                    // ITERATIVE MUTATION     ---------------------------------
                    mas.setAttributesRange($({
                        'NSFont': $.NSFont.fontWithNameSize(fontName, pSize)
                    }), dctRange);
                    mutableSize = mas.size;
                    // --------------------------------------------------------

                    return {
                        highest: upper,
                        lowest: lower,
                        pointSize: pSize
                    };
                }, {
                    highest: nMax,
                    lowest: nMin,
                    pointSize: nMin
                }
            )
            .pointSize;
    }


    // OMNIGRAFFLE JXA TEST ---------------------------------------------------------------------------

    const
        og = Application('OmniGraffle'),
        ws = og.windows,
        w = ws.length ? ws.at(0) : undefined;

    if (w) {
        const
            d = w.document,
            cnv = d.canvases.at(0),

            lstSeln = w.selection();
        //lstSeln = [cnv.graphics.byId(5)];

        if (lstSeln.length < 1) return "Nothing selected";

        const
            g = lstSeln[0],
            text = g.text,
            strText = text(),
            lstSize = g.size(),
            hPad = g.sidePadding(),
            vPad = g.verticalPadding(),
            runs = g.text.attributeRuns;


        const pts = pointSizeToFitLineInBox(
            lstSize[0] - hPad, lstSize[1] - vPad, runs.at(0)
            .font(), strText
        );

        enumFromTo(0, runs.length - 1)
            .forEach(i => runs.at(i)
                .size = pts)

        return g.text.attributeRuns.at(0)
            .size();
    }
})();