Does it look feasible to give omniJS access to scaling options?

I notice that the shape geometry scaling options

are not within reach of scripting at the moment (perhaps they have were added to OmniGraffle after the AppleScript interface was built ?)

I don’t know how feasible it looks, but scripting access to these options, particularly Scale Font, would be very useful.

CONTEXT

In nested tree-like, radial or block-like diagrams, I often need a different font size for each level of hierarchy, and the script has to make some attempt at finding the largest font that would not cause text overflow for any of the siblings at a given level.

This is clumsy at the moment, and needs manual intervention. Being able to resize a text-filled auto-text-scaling shape, and read through the scripting interface what its new font size is, would allow more much more powerful automation of peer-group font resizing.

These scaling options aren’t really that smart. It’s only comparing original and resulting width and then multiplying font size or stroke width by the same factor. E.g. make the shape half as wide via the geometry inspector while ‘Scale Font’ is set, and the font size is also halved. You could easily do the same calculation when setting shape geometry, and it’s simple enough that I think exposing it as API would just add complication without much real functionality.

It sounds like what you really want is different, which is a “fit text to shape” operation that’s kind of the opposite of the existing “fit shape to text”. This would theoretically set the font size to whatever it needs to be such that the text either doesn’t soft-wrap or doesn’t overflow. Then in your case you’d see whatever the smallest such resulting font size was, and set all shapes at that level to the smallest size (so they’d all match and still fit).

I bet the OmniJS interface is fast enough that you’d be best off just temporarily changing the solid autosizing property to Full (if you want to avoid soft-wrap) or Vertical (if you want to avoid overflow), and then probe smaller and larger font sizes until the resulting geometry is less than and as close as possible to the original geometry. Any API that we would add on our end would essentially be doing the same. When I get a chance, I’ll see if I can write this up as an example script, because it does seem like it would be generally useful to Graffle users.

1 Like

That sounds good - thanks !

function fitTextFontToShapeSize(g) {
	var startRect = g.geometry
	var startingSizing = g.autosizing
	if (startingSizing == TextAutosizing.Full || startingSizing == TextAutosizing.Vertical) {
		return // already autosizing, so already fitting
	}
	
	var lowest = 5 // min font size to use
	var highest = 100 // max font size to use

	g.autosizing = TextAutosizing.Vertical 
	while ((highest - lowest) > 1) {
		if ((g.geometry.width > startRect.width) || (g.geometry.height > startRect.height)) {
			highest = g.textSize
		} else {
			lowest = g.textSize
		}
		g.textSize = lowest + Math.floor((highest - lowest) / 2)
	}
	g.textSize = lowest
	g.autosizing = startingSizing
	g.geometry = startRect
}
2 Likes

Good and fast - thanks !

(Much faster than the old AppleScript code chugging away in that gif :-)

If we get it to return a value (maximum fontsize found), we can also map it across a set of peer shapes which need to share a font size, and go from the varied first-pass outputs:

to the largest font that avoids overflow right across the range of peers:

Preventing wrapping in mid-word remains a problem - is the internal model able to detect a mid-word wrap ?

If not, I guess the thing to do may be to find the longest word in all the peer texts

const longestWord = maximumBy(
    comparing(length),
    concatMap(shp => words(shp.text), shps)
);

and then run a separate kind of test to find the point of width overflow for a string of that length, with wrapping switched off, allowing a margin for the possibility that some words shorter in char count may be longer in points (M vs 1, and all the complexity that fonts are heir to etc)

Perhaps testing with, e.g. ‘Substantiation’ -> 14 -> ‘MMMMMMMMMMMMMM’ ?

Presumably we don’t have access to font metrics from NSMutableAttributedString etc ?

(JXA can call those methods)

Unfortunately, there’s no way to tell OmniGraffle to only word-wrap and not character-wrap (in the UI or via JS). In general, the text manipulation APIs is a weak point right now. We want to get in at least as much text manipulation support as Outliner’s OmniJS API has, for example.

You could change my function above so that it will produce a result font size that doesn’t soft-wrap at all (only actual newlines in the text cause a wrap) by changing this line to TextAutosizing.Full:

If nothing else, you could check to see if the shape text is only a single word, and if so use the resizing function with no soft-wrapping at all, and if there are multiple words use the original resizing function. In your example images here that seems like it would do most of the job? If you wanted to do even better you could make a temporary shape and give it text that concatenated together all your text at a level but replacing all spaces with newlines. Run a similar function with TextAutosizing.Full to figure out a font size that fits the original width (but NOT height). That would give you the maximum font size that fit the longest word in all the text at that level without wrapping it. Use that as an additional no-larger-than constraint.

With more complicated text, though, it definitely becomes harder to find heuristics to make them all look good until we provide a lot more granular text APIs. Sorry those aren’t there yet!

1 Like

As a footnote, here is a basic JXA example of obtaining the width, in points, of a particular string:

(() => {
    'use strict';

    // WIDTH OF A GIVEN STRING IN HELVETICA 12 --------------------------------

    // helvetica12Width :: String -> Num
    function helvetica12Width(str) {
        return $.NSAttributedString.alloc.init.initWithString(
                str
            )
            .size.width;
    }

    // helvetica12MaxWidth :: Int -> Num
    function helvetica12MaxWidth(intChars) {
        return $.NSAttributedString.alloc.init.initWithString(
                replicateS(intChars, 'M')
            )
            .size.width;
    }

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

    // concat :: [[a]] -> [a] | [String] -> String
    const concat = xs => {
        if (xs.length > 0) {
            const unit = typeof xs[0] === 'string' ? '' : [];
            return unit.concat.apply(unit, xs);
        } else return [];
    };

    // replicate :: Int -> a -> [a]
    const replicate = (n, a) => {
        let v = [a],
            o = [];
        if (n < 1) return o;
        while (n > 1) {
            if (n & 1) o = o.concat(v);
            n >>= 1;
            v = v.concat(v);
        }
        return o.concat(v);
    };

    // replicateS :: Int -> String -> String
    const replicateS = (n, s) => concat(replicate(n, s));

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


    // TEST -------------------------------------------------------------------
    return show(
        [
            ["Substantiation", "MMMMMMMMMMMMMM"]
            .map(helvetica12Width),
            helvetica12MaxWidth(14)
        ]
    );

})();

Returns:

[
  [
    76.0546875,
    139.9453125
  ],
  139.9453125
]

Or, more generally:

(function () {
    'use strict';

    ObjC.import('AppKit');

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

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

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

Returns:

[
  {
    "width": 171.015625,
    "height": 40
  },
  {
    "width": 342.03125,
    "height": 80
  },
  {
    "width": 319,
    "height": 78
  }
]

I don’t know whether it’s possible to give (perhaps indirect ?) access to a utility function like this within the omniJS JS context ?