Well, you are going to have to measure each word individually, so probably best to get a reusable reference to the Arial size 10
NS Font object, rather than repeatedly constructing it:
// fontAtSize :: String -> Num -> NSFont
const fontAtSize = fontName =>
points => $({
'NSFont': $.NSFont.fontWithNameSize(fontName, points)
});
You could then pass that to a function which, given the NSFont, returns a width and height measure for a given string.
// stringSizeInFont :: NSFont -> String -> { height :: Num, width :: Num }
const stringSizeInFont = oFont => txt =>
$.NSAttributedString.alloc.init.initWithStringAttributes(
txt, oFont
).size
and from those two general functions, given a font name
and a point size
, both defined in KM variables, you could derive a more specialised function which simply gives a width for any particular string:
// widthFromText :: String -> Float
const widthFromText = txt =>
stringSizeInFont(
fontAtSize(kmValue('wrapFont'))(
parseFloat(kmValue('wrapPointSize'))
)
)(txt).width;
The first thing to measure will probably be that special spacing character which you are using for indents.
const
indentSpaceChar = chr(kmValue('wrapSpaceCharHex')),
indentSpaceWidth = widthFromText(indentSpaceChar);
and you'll also need to measure for the given font and size, the ordinary space character which separate words.
const wordSpaceWidth = widthFromText(' ');
Once you know the width of your indent space character, and the number of them you want to use in your indents, you can get a measure for the width, in points, of those indents:
const
intIndentSpaces = parseInt(kmValue('wrapIndentWidth')),
indentWidth = indentSpaceWidth * intIndentSpaces;
and if you've defined the total width (in points) that you want to fit everything into, then the width of each text line will be that total width less the indent width:
const
totalWidth = parseFloat(kmValue('wrapTotalPointWidth')),
textWidth = totalWidth - indentWidth;
The standard indent string (lines 2 onwards), will just be N repetitions of your spacer character:
const indent = indentSpaceChar.repeat(intIndentSpaces);
and the indent for the first line will be the label, plus as many space characters as are needed to give a reasonable match for the standard indent width:
const
label = kmValue('wrapIndentLabel'),
labelWidth = widthFromText(label),
labelPadding = indentSpaceChar.repeat(
Math.round((indentWidth - labelWidth) / indentSpaceWidth)
);
Now we need a measure of every word in our para. To simplify, we could assume that every word is followed by one space, and add the width of that.
We can define an ordered list of (Word, Width) pairs, for the whole paragraph with the incantation:
// measuredWords :: [(String, Float)]
const measuredWords = map(
ap(Tuple)(
compose(
add(wordSpaceWidth),
widthFromText
)
)
)(
words(kmValue('wrapIndentPara'))
);
and we can perform a 'fold' or 'reduction' over that list of measured words to break each line before it gets too long:
const dctWrapped = measuredWords.reduce((dct, w) => {
const nextPosn = dct.position + w[1];
return nextPosn > textWidth ? {
position: 0,
activeLine: [],
previousLines: dct.previousLines.concat([
dct.activeLine.concat(w[0])
])
} : {
position: nextPosn,
activeLine: dct.activeLine.concat(w[0]),
previousLines: dct.previousLines
};
}, {
position: 0,
activeLine: [],
previousLines: []
})
Adding any remaining residue to the accumulated list of lines:
dctWrapped.previousLines.concat([
dctWrapped.activeLine
])
Finally, we can prepend our indents to each of the wrapped lines – a special label plus spacing for the first line, and standard indent strings for the remaining lines:
return bindMay(
uncons(map(unwords)(
dctWrapped.previousLines.concat([
dctWrapped.activeLine
])
))
)(
compose(
unlines,
uncurry(cons),
bimap(
// First Line - hanging indent,
append(label + labelPadding)
)(
// and remaining lines, indented.
map(append(indent))
)
)
);
So the following sketch (not tidied or organised, I'm afraid):
first sketch
(() => {
'use strict';
ObjC.import('AppKit');
const main = () => {
const
kme = Application('Keyboard Maestro Engine'),
kmValue = k => kme.getvariable(k),
fontName = kmValue('wrapFont'),
pointSize = parseFloat(kmValue('wrapPointSize'));
// widthFromText :: String -> Float
const widthFromText = txt =>
stringSizeInFont(
fontAtSize(fontName)(pointSize)
)(txt).width;
const
indentSpaceChar = chr(kmValue('wrapSpaceCharHex')),
indentSpaceWidth = widthFromText(indentSpaceChar);
const wordSpaceWidth = widthFromText(' ');
const
intIndentSpaces = parseInt(kmValue('wrapIndentWidth')),
indentWidth = indentSpaceWidth * intIndentSpaces;
const
totalWidth = parseFloat(kmValue('wrapTotalPointWidth')),
textWidth = totalWidth - indentWidth;
const indent = indentSpaceChar.repeat(intIndentSpaces);
const
label = kmValue('wrapIndentLabel'),
labelWidth = widthFromText(label),
labelPadding = indentSpaceChar.repeat(
Math.round((indentWidth - labelWidth) / indentSpaceWidth)
);
// measuredWords :: [(String, Float)]
const measuredWords = map(
ap(Tuple)(
compose(
add(wordSpaceWidth),
widthFromText
)
)
)(
words(kmValue('wrapIndentPara'))
);
const dctWrapped = measuredWords.reduce((dct, w) => {
const nextPosn = dct.position + w[1];
return nextPosn > textWidth ? {
position: 0,
activeLine: [],
previousLines: dct.previousLines.concat([
dct.activeLine.concat(w[0])
])
} : {
position: nextPosn,
activeLine: dct.activeLine.concat(w[0]),
previousLines: dct.previousLines
};
}, {
position: 0,
activeLine: [],
previousLines: []
})
return bindMay(
uncons(map(unwords)(
dctWrapped.previousLines.concat([
dctWrapped.activeLine
])
))
)(
compose(
unlines,
uncurry(cons),
bimap(
// First Line - hanging indent,
append(label + labelPadding)
)(
// and remaining lines, indented.
map(append(indent))
)
)
);
};
// ------------------- FONT METRICS -------------------
// fontAtSize :: String -> Num -> NSFont
const fontAtSize = fontName =>
points => $({
'NSFont': $.NSFont.fontWithNameSize(fontName, points)
});
// stringSizeInFont :: NSFont -> String -> { height :: Num, width :: Num }
const stringSizeInFont = oFont => txt =>
$.NSAttributedString.alloc.init.initWithStringAttributes(
txt, oFont
).size
// ------------------ GENERIC FUNCTIONS -------------------
// https://github.com/RobTrew/prelude-jxa
// Just :: a -> Maybe a
const Just = x => ({
type: 'Maybe',
Nothing: false,
Just: x
});
// Nothing :: Maybe a
const Nothing = () => ({
type: 'Maybe',
Nothing: true,
});
// Tuple (,) :: a -> b -> (a, b)
const Tuple = a =>
b => ({
type: 'Tuple',
'0': a,
'1': b,
length: 2
});
// add (+) :: Num a => a -> a -> a
const add = a =>
// Curried addition.
b => a + b;
// ap :: (a -> b -> c) -> (a -> b) -> a -> c
const ap = f =>
// Applicative instance for functions.
// f(x) applied to g(x).
g => x => f(x)(
g(x)
);
// append (++) :: [a] -> [a] -> [a]
// append (++) :: String -> String -> String
const append = xs =>
// A list or string composed by
// the concatenation of two others.
ys => xs.concat(ys);
// 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])
);
// bindMay (>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b
const bindMay = mb =>
mf => mb.Nothing ? (
mb
) : mf(mb.Just);
// chr :: Int -> Char
const chr = x =>
String.fromCodePoint(x);
// compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
const compose = (...fs) =>
fs.reduce(
(f, g) => x => f(g(x)),
x => x
);
// cons :: a -> [a] -> [a]
const cons = x =>
xs => [x].concat(xs);
// lines :: String -> [String]
const lines = s =>
// A list of strings derived from a single
// newline-delimited string.
0 < s.length ? (
s.split(/[\r\n]/)
) : [];
// map :: (a -> b) -> [a] -> [b]
const map = f =>
// The list obtained by applying f
// to each element of xs.
// (The image of xs under f).
xs => [...xs].map(f);
// maybe :: b -> (a -> b) -> Maybe a -> b
const maybe = v =>
// Default value (v) if m is Nothing, or f(m.Just)
f => m => m.Nothing ? v : f(m.Just);
// uncons :: [a] -> Maybe (a, [a])
const uncons = xs =>
// Just a tuple of the head of xs and its tail,
// Or Nothing if xs is an empty list.
0 < xs.length ? (
Just(Tuple(xs[0])(xs.slice(1))) // Finite list
) : Nothing();
// uncurry :: (a -> b -> c) -> ((a, b) -> c)
const uncurry = f =>
// A function over a pair, derived
// from a curried function.
function() {
const
args = arguments,
xy = Boolean(args.length % 2) ? (
args[0]
) : args;
return f(xy[0])(xy[1]);
};
// unlines :: [String] -> String
const unlines = xs =>
// A single string formed by the intercalation
// of a list of strings with the newline character.
xs.join('\n');
// unwords :: [String] -> String
const unwords = xs =>
// A space-separated string derived
// from a list of words.
xs.join(' ');
// words :: String -> [String]
const words = s => s.split(/\s+/);
// MAIN ---
return main();
})();
Should in theory (assuming that we have defined all the KM variables, and haven't overlooked too much)
return a string which looks ragged in monospace, but better aligned if Arial 10pt is applied to it:
ragged monospace appearance (line breaks and indents intended to work for Arial 10pt):
1.1 Engineering fees are based on the standard 2 week turnaround time. Shorter turnaround times
are available and include expediting fees (25% increase for 1-week lead time, and 45% increase
for 2-day lead time).