Skip to main content
Participant
August 13, 2026
Question

Automating A3 in-store signs: how to fit variable-length names to a fixed frame without distorting type

  • August 13, 2026
  • 2 replies
  • 54 views

We generate in-store A3 landscape signs automatically from our ERP: a JSON job feeds an ExtendScript that drives InDesign 2026 and exports the PDF, with no manual step. Each sign holds 2 or 3 uppercase lines (the product name) whose length varies a lot from product to product.

 

Design goal: the largest possible cap height (these are read from the street), the text block filling the sheet, and no distorted type.

 

What the script does today: it sets the point size from the height each line gets, then raises horizontalScale until the line reaches the frame width. That lands at 115-130%, which is visibly distorted, so this is clearly the wrong tool.

 

Questions:

 

1. Is there a scripting equivalent of Illustrator's Type > Fit Headline in InDesign, or is adjusting tracking the accepted way to make a short line fill the measure?

 

2. For a line that is too long, is switching to the condensed cut of the same family the recommended automated fallback, rather than reducing the point size?

 

3. Is Line.horizontalOffset / Line.endHorizontalOffset the reliable way to measure the rendered line width so size and tracking can be computed in a single pass instead of a shrink-until-it-fits loop? Anything to watch out for (justification, optical margin alignment, first baseline offset)?

 

4. For an automated pipeline like this, are variable fonts with a wdth axis the right long-term answer for width fitting?

 

5. Is there any built-in feature (auto-size text frames, object styles, paragraph style settings) we should be using instead of a custom fitting loop?

 

Thanks. Happy to share the script if it helps.

    2 replies

    m1b
    Community Expert
    Community Expert
    September 1, 2026

    Hi ​@kind_Ray39fb I’ve written a text-fitting function previously and I’ve updated it to work with variable width fonts. To try it out, select a text box and run the below script. If it works the way you want feel free to include it in your current script. I tested with a couple of default variable fonts I had on my system here so let me know if your font works as expected.

    – Mark

    /**
    * @file fitHeadingToFrame.js
    *
    * Auto-sizes a heading to fill a fixed text frame, using a variable font's
    * width axis where there is one, so that the type is never distorted by
    * horizontal scaling.
    *
    * We score each setting by the AREA the text consumes (larger area, better score).
    *
    * 1. measure the font's cap height ratio at each sampled width (cached);
    * 2. at each sampled width, binary-search the largest point size that
    * composes within the target number of lines without overflowing;
    * 3. keep the width covering the greatest area, preferring the widest
    * setting when several cover near enough the same.
    *
    * Can handle non-variable font, but won't always fit quite as well.
    *
    * `widthTrimLow` and `widthTrimHigh` fence off each end of the axis as a
    * percentage of its full span, so the same pair of numbers means the same thing
    * whatever a font's raw axis numbers happen to be. For an axis running 50 to
    * 115, a span of 65, passing 5 and 20 allows 53.25 to 102.
    *
    * Hyphenation is forced off, and the target line count is therefore
    * Math.min(number of words, `lineCount`) — without hyphenation a heading cannot
    * break over more lines than it has words.
    *
    * Run with one or more text frames selected, or call fitHeadingToFrame() from your
    * own script. (set `$.global.FIT_HEADING_TO_FRAME_AS_LIBRARY = true; in the entry script`)
    *
    * @author m1b
    * @version 2026-09-02
    * @discussion https://community.adobe.com/questions-671/automating-a3-in-store-signs-how-to-fit-variable-length-names-to-a-fixed-frame-without-distorting-type-1636918
    */

    var LINE_COUNT = 3;
    var MEASUREMENT_POINT_SIZE = 100;

    // run on the selection, unless this file was included by another script
    if (true !== $.global.FIT_HEADING_TO_FRAME_AS_LIBRARY) {

    try {
    app.doScript(main, ScriptLanguage.JAVASCRIPT, undefined, UndoModes.ENTIRE_SCRIPT, 'Fit Heading To Frame');

    } catch (error) {
    alert(error + (undefined === error.line ? '' : '\nLine ' + error.line));
    }

    }

    /**
    * Fits every selected text frame's heading.
    * @author m1b
    * @version 2026-09-02
    */
    function main() {

    if (0 === app.documents.length)
    throw new Error('Please open a document.');

    var frames = getSelectedTextFrames();

    if (0 === frames.length)
    throw new Error('Please select one or more text frames.');

    var report = [];

    for (var i = 0; i < frames.length; i++) {

    var result = fitHeadingToFrame(frames[i].texts[0], LINE_COUNT);

    report.push(
    result.pointSize.toFixed(2) + ' pt'
    + (null === result.widthAxisValue ? ', no width axis' : ', ' + result.widthAxisName + ' ' + result.widthAxisValue.toFixed(2))
    + ', covers ' + Math.round(result.coverage)
    + ', ' + result.lineCount + ' of ' + result.targetLineCount + ' line(s)'
    );

    }

    $.writeln(report.join('\n'));

    };


    /* ----------------------------- *
    * FITTING *
    * ----------------------------- */

    /**
    * Fits a heading into its text frame, choosing the width axis value and point
    * size that together cover the most of the frame, preferring the widest
    * setting when several cover near enough the same area.
    *
    * Note: mutates `text` — its point size, width axis and hyphenation. A heading
    * is taken to be a single paragraph, so the first paragraph of `text` is what
    * gets fitted; that also picks up any of the heading that is currently overset.
    *
    * Point sizes are always in points, whatever `app.scriptPreferences.
    * measurementUnit` the calling script is using.
    *
    * @author m1b
    * @version 2026-09-02
    * @param {Text} text - the text to fit, eg. myTextFrame.texts[0].
    * @param {Number} lineCount - the maximum number of lines to compose into.
    * @param {Object} [options]
    * @param {Number} [options.minPointSize] - the smallest point size to try (default: 4).
    * @param {Number} [options.maxPointSize] - the largest point size to try (default: twice the frame height, capped at InDesign's 1296 pt maximum).
    * @param {Number} [options.pointSizeTolerance] - how close to the ideal point size to get, in points (default: 0.05).
    * @param {Number} [options.widthSampleCount] - how many width axis values to try (default: 9).
    * @param {Number} [options.widthTolerance] - how close to the ideal width axis value to get (default: 0.5).
    * @param {Number} [options.tieTolerance] - how much covered area to give up for a wider setting, as a fraction (default: 0.015).
    * @param {Number} [options.widthTrimLow] - percentage of the width axis's full span to block off at the narrow end (default: 0).
    * @param {Number} [options.widthTrimHigh] - percentage of the width axis's full span to block off at the wide end (default: 0).
    * @param {String} [options.widthAxisName] - the design axis to open out (default: 'Width').
    * @returns {Object} - { pointSize, apparentSize, coverage, widthAxisName, widthAxisValue, designAxes, lineCount, targetLineCount, font }.
    */
    function fitHeadingToFrame(text, lineCount, options) {

    options = options || {};

    if (
    !text
    || !text.isValid
    )
    throw new Error('fitHeadingToFrame: bad `text` supplied.');

    if (
    'number' !== typeof lineCount
    || lineCount < 1
    )
    throw new Error('fitHeadingToFrame: bad `lineCount` supplied.');

    var minPointSize = options.minPointSize || 4;
    var maxPointSize = options.maxPointSize;
    var pointSizeTolerance = options.pointSizeTolerance || 0.05;
    var widthSampleCount = options.widthSampleCount || 9;
    var widthTolerance = options.widthTolerance || 0.5;
    var tieTolerance = options.tieTolerance || 0.015;
    var widthTrimLow = options.widthTrimLow || 0;
    var widthTrimHigh = options.widthTrimHigh || 0;
    var widthAxisName = options.widthAxisName || 'Width';

    if (widthSampleCount < 2)
    throw new Error('fitHeadingToFrame: `widthSampleCount` must be 2 or more.');

    if (
    widthTrimLow < 0
    || widthTrimHigh < 0
    || widthTrimLow + widthTrimHigh > 100
    )
    throw new Error('fitHeadingToFrame: `widthTrimLow` and `widthTrimHigh` must not be negative, and must not total more than 100.');

    // assume heading is a single paragraph
    text = text.paragraphs.firstItem();

    var containers = text.parentStory.textContainers;

    if (0 === containers.length)
    throw new Error('fitHeadingToFrame: the text is not in a text frame.');

    // when a story is threaded, only the last frame can overflow
    var frame = containers[containers.length - 1];
    var doc = getDocument(frame);

    // every character must be in the same font, but it need not be a variable
    // one — without a width axis there is simply nothing to sample
    var font = getUniformFont(text);
    var axisNames = getDesignAxisNames(font);
    var widthAxisIndex = indexOfDesignAxis(axisNames, widthAxisName);
    var hasWidthAxis = (-1 !== widthAxisIndex);

    var minWidth;
    var maxWidth;
    var baseAxes;

    if (hasWidthAxis) {

    // designAxesRange holds one [minimum, maximum] pair per axis
    var widthRange = font.designAxesRange[widthAxisIndex];
    minWidth = widthRange[0];
    maxWidth = widthRange[1];

    // block off each end of the axis by a percentage of its full span, so
    // the same limits mean the same thing whatever the font's raw numbers
    var widthSpan = maxWidth - minWidth;

    minWidth = minWidth + (widthSpan * widthTrimLow / 100);
    maxWidth = maxWidth - (widthSpan * widthTrimHigh / 100);

    // snapshot the axes once: setting one axis to an extreme can make
    // InDesign shift the others (Acumin drops Weight to 382.72 at Width
    // 115), and a read-modify-write would carry that drift into every
    // later write
    baseAxes = text.designAxes;

    }

    // hyphenation would let the heading break over more lines than it has
    // words, which would make `targetLineCount` too small, and would give
    // a heading a hyphen it should never have
    text.hyphenation = false;

    // without hyphenation, a heading cannot break over more lines than it has words
    var targetLineCount = Math.min(text.words.length, lineCount);

    /**
    * Returns true when the text composes inside the frame,
    * within the target number of lines.
    * @returns {Boolean}
    */
    function composes() {

    return (
    !frame.overflows
    && text.lines.length <= targetLineCount
    );
    };

    /**
    * @param {Number} size - a point size to try.
    * @returns {Boolean}
    */
    function pointSizeComposes(size) {

    text.pointSize = size;
    return composes();
    };

    // `pointSize` is a measurement property, so it is interpreted in whatever
    // unit the calling script has set — force points for the duration
    var savedMeasurementUnit = app.scriptPreferences.measurementUnit;

    // the fit composes the text hundreds of times, and on a document with an
    // open window each of those would otherwise force a screen redraw
    var savedEnableRedraw = app.scriptPreferences.enableRedraw;

    try {
    app.scriptPreferences.measurementUnit = MeasurementUnits.POINTS;
    app.scriptPreferences.enableRedraw = false;

    if (undefined === maxPointSize) {

    // a single line cannot be much taller than the frame, so there is
    // no point searching up to InDesign's 1296 pt maximum — composing
    // enormous type is slow, and every wasted probe costs a compose
    var bounds = frame.geometricBounds;
    maxPointSize = Math.min(1296, Math.ceil((bounds[2] - bounds[0]) * 2));

    }

    /**
    * Finds the largest point size that fits at one width axis value, and
    * scores it by the area of frame it covers.
    * @param {Number} [widthValue] - omitted when the font has no width axis.
    * @returns {Object|undefined} - { width, pointSize, apparentSize, coverage }.
    */
    function evaluateWidth(widthValue) {

    if (hasWidthAxis)
    setDesignAxis(text, baseAxes, widthAxisIndex, widthValue);

    var pointSize = largestPassing(minPointSize, maxPointSize, pointSizeTolerance, pointSizeComposes);

    if (undefined === pointSize)
    return undefined;

    // largestPassing leaves the last size it probed applied, not the
    // best one, so put the winner back before measuring the lines
    text.pointSize = pointSize;

    var capHeight = pointSize * getCapHeightRatio(doc, font, baseAxes, hasWidthAxis ? widthAxisIndex : -1, widthValue);

    return {
    width: hasWidthAxis ? widthValue : null,
    pointSize: pointSize,
    apparentSize: capHeight,
    coverage: capHeight * getTotalLineWidth(text)
    };

    };

    var chosen;

    if (!hasWidthAxis) {

    // nothing to sample — the point size search is the whole fit
    chosen = evaluateWidth();

    if (undefined === chosen)
    throw new Error('fitHeadingToFrame: the text will not fit into ' + targetLineCount + ' line(s) even at ' + minPointSize + ' pt.');

    } else {

    // scan the width axis, scoring each setting by the area of frame the
    // heading actually covers — wider letterforms and an extra line both
    // raise it, so the scan prefers a heading that fills its frame
    // trimming can collapse the range to a single value, which is a
    // legitimate way to pin the axis rather than an error
    var sampleCount = ((maxWidth - minWidth) < widthTolerance) ? 1 : widthSampleCount;
    var widthStep = (1 === sampleCount) ? 0 : (maxWidth - minWidth) / (sampleCount - 1);
    var candidates = [];
    var bestCoverage = 0;

    for (var i = 0; i < sampleCount; i++) {

    var candidate = evaluateWidth(minWidth + (widthStep * i));

    if (undefined === candidate)
    continue;

    candidates.push(candidate);

    if (candidate.coverage > bestCoverage)
    bestCoverage = candidate.coverage;

    }

    if (0 === candidates.length)
    throw new Error('fitHeadingToFrame: the text will not fit into ' + targetLineCount + ' line(s) even at ' + minPointSize + ' pt.');

    // of the widths that cover near enough the same area, take the widest —
    // the candidates are in ascending width order, so the last one wins
    chosen = candidates[0];

    for (var i = 0; i < candidates.length; i++) {

    if (candidates[i].coverage >= bestCoverage * (1 - tieTolerance))
    chosen = candidates[i];

    }

    // the scan lands on fixed steps, so creep further along the axis
    // while the heading still covers near enough the same area
    var low = chosen.width;
    var high = Math.min(maxWidth, chosen.width + widthStep);

    while (high - low > widthTolerance) {

    var middle = (low + high) / 2;
    var refined = evaluateWidth(middle);

    if (
    undefined !== refined
    && refined.coverage >= bestCoverage * (1 - tieTolerance)
    ) {
    chosen = refined;
    low = middle;

    } else {
    high = middle;
    }

    }

    setDesignAxis(text, baseAxes, widthAxisIndex, chosen.width);

    }

    text.pointSize = chosen.pointSize;

    return {
    pointSize: chosen.pointSize,
    apparentSize: chosen.apparentSize,
    coverage: chosen.coverage,
    widthAxisName: hasWidthAxis ? axisNames[widthAxisIndex] : null,
    widthAxisValue: chosen.width,
    designAxes: hasWidthAxis ? text.designAxes : null,
    lineCount: text.lines.length,
    targetLineCount: targetLineCount,
    font: text.appliedFont
    };

    } finally {
    app.scriptPreferences.measurementUnit = savedMeasurementUnit;
    app.scriptPreferences.enableRedraw = savedEnableRedraw;
    }

    };

    /**
    * Binary-searches for the largest value between `low` and `high` for which
    * `test` returns true, assuming `test` is true up to some threshold and false
    * above it.
    * @author m1b
    * @version 2026-09-01
    * @param {Number} low - the lowest value to try.
    * @param {Number} high - the highest value to try.
    * @param {Number} tolerance - how close to the threshold to get.
    * @param {Function} test - a function(value) returning a Boolean.
    * @returns {Number|undefined} - the largest passing value, or undefined if even `low` fails.
    */
    function largestPassing(low, high, tolerance, test) {

    if (test(high))
    return high;

    if (!test(low))
    return undefined;

    var best = low;

    while (high - low > tolerance) {

    var middle = (low + high) / 2;

    if (test(middle)) {
    best = middle;
    low = middle;
    } else {
    high = middle;
    }

    }

    return best;

    };

    /**
    * Returns the total width of every line of `text`, in points. The horizontal
    * half of the covered-area score.
    * Note: `horizontalOffset` and `endHorizontalOffset` are absolute positions, so
    * the difference is the line's width whichever way round they run.
    * @author m1b
    * @version 2026-09-02
    * @param {Text} text
    * @returns {Number}
    */
    function getTotalLineWidth(text) {

    var lines = text.lines;
    var total = 0;

    for (var i = 0; i < lines.length; i++)
    total += Math.abs(lines[i].endHorizontalOffset - lines[i].horizontalOffset);

    return total;

    };


    /* ----------------------------- *
    * FONT HELPERS *
    * ----------------------------- */

    /**
    * Returns a font's cap height at a given width axis value, as a fraction of the
    * point size. Measured, not looked up: InDesign exposes no cap height, but with
    * a cap height first baseline the first baseline sits exactly one cap height
    * below the top of the frame.
    * Results are cached on $.global for the session.
    * @author m1b
    * @version 2026-09-02
    * @param {Document} doc - a document to measure in.
    * @param {Font} font
    * @param {Array<Number>} [baseAxes] - the axis values to keep for every other axis.
    * @param {Number} axisIndex - the width axis, or -1 for a font without one.
    * @param {Number} [widthValue]
    * @returns {Number} - cap height divided by point size, eg. 0.668.
    */
    function getCapHeightRatio(doc, font, baseAxes, axisIndex, widthValue) {

    if (undefined === $.global.capHeightRatioCache)
    $.global.capHeightRatioCache = {};

    var cacheKey = font.name + '|' + axisIndex + '|' + widthValue;

    if (undefined !== $.global.capHeightRatioCache[cacheKey])
    return $.global.capHeightRatioCache[cacheKey];

    // 'H' has flat caps, so its ink stops exactly at the cap line —
    // a round letter like 'O' would overshoot and read too tall
    var measuringFrame = doc.pages[0].textFrames.add({
    geometricBounds: [0, 0, MEASUREMENT_POINT_SIZE * 6, MEASUREMENT_POINT_SIZE * 6],
    contents: 'H'
    });

    var ratio;

    try {
    measuringFrame.textFramePreferences.properties = {
    insetSpacing: [0, 0, 0, 0],
    firstBaselineOffset: FirstBaseline.CAP_HEIGHT,
    verticalJustification: VerticalJustification.TOP_ALIGN
    };

    var sample = measuringFrame.texts[0];

    sample.appliedFont = font;
    sample.pointSize = MEASUREMENT_POINT_SIZE;

    if (-1 !== axisIndex)
    setDesignAxis(sample, baseAxes, axisIndex, widthValue);

    ratio = (sample.lines[0].baseline - measuringFrame.geometricBounds[0]) / MEASUREMENT_POINT_SIZE;

    } finally {
    measuringFrame.remove();
    }

    $.global.capHeightRatioCache[cacheKey] = ratio;

    return ratio;

    };

    /**
    * Returns the font applied to `text`, throwing if the text does not have a
    * single font throughout.
    * Note: a variable instance's font name encodes its axis values, so matching
    * names also means matching design axes.
    * @author m1b
    * @version 2026-09-02
    * @param {Text} text
    * @returns {Font}
    */
    function getUniformFont(text) {

    var ranges = text.textStyleRanges;
    var first = resolveFont(ranges[0].appliedFont);

    for (var i = 1; i < ranges.length; i++) {

    var other = resolveFont(ranges[i].appliedFont);

    if (other.name !== first.name)
    throw new Error('fitHeadingToFrame: the text does not have a single font throughout — found "' + String(first.name).replace('\t', ' ') + '" on "' + ranges[0].contents + '" and "' + String(other.name).replace('\t', ' ') + '" on "' + ranges[i].contents + '".');

    }

    return first;

    };

    /**
    * Returns a Font, given either a Font or a font name, throwing if it does not
    * resolve to a font that is actually installed.
    * @author m1b
    * @version 2026-09-02
    * @param {Font|String} font
    * @returns {Font}
    */
    function resolveFont(font) {

    var name = ('string' === typeof font) ? font : undefined;
    var resolved = (undefined === name) ? font : app.fonts.itemByName(name);

    if (
    !resolved
    || !resolved.isValid
    )
    throw new Error('fitHeadingToFrame: the font ' + (undefined === name ? 'applied to the text' : '"' + name.replace('\t', ' ') + '"') + ' does not exist.');

    if (FontStatus.INSTALLED !== resolved.status)
    throw new Error('fitHeadingToFrame: the font "' + String(resolved.name).replace('\t', ' ') + '" is not installed — it is missing or substituted.');

    return resolved;

    };

    /**
    * Returns the design axis names of a variable font, or an empty array for a
    * font that has no design axes.
    * @author m1b
    * @version 2026-09-02
    * @param {Font} font
    * @returns {Array<String>} - eg. ['Weight', 'Width', 'Slant'].
    */
    function getDesignAxisNames(font) {

    try {
    // InDesign throws, rather than returning zero, for a non-variable font
    if (font.numDesignAxes > 0)
    return font.designAxesName;

    } catch (error) {
    // not a variable font, which is not an error here
    }

    return [];

    };

    /**
    * Returns the index of a named design axis, or -1.
    * @param {Array<String>} axisNames
    * @param {String} axisName
    * @returns {Number}
    */
    function indexOfDesignAxis(axisNames, axisName) {

    var wanted = String(axisName).toLowerCase();

    for (var i = 0; i < axisNames.length; i++) {

    if (String(axisNames[i]).toLowerCase() === wanted)
    return i;

    }

    return -1;

    };

    /**
    * Sets a single design axis value on `text`, leaving the other axes at the
    * values given by `baseAxes`.
    * Note: `designAxes` is a flat array of reals, one per axis, in the order given
    * by the font's `designAxesName`, and every axis value must be supplied at
    * once. Values outside the font's range throw. (Beware that `designAxesRange`,
    * unlike `designAxes` and `designAxesValues`, is nested: one [minimum, maximum]
    * pair per axis.)
    * @author m1b
    * @version 2026-09-02
    * @param {Text} text
    * @param {Array<Number>} baseAxes - the axis values to keep for every other axis.
    * @param {Number} axisIndex
    * @param {Number} value
    */
    function setDesignAxis(text, baseAxes, axisIndex, value) {

    var axes = [];

    for (var i = 0; i < baseAxes.length; i++)
    axes.push(baseAxes[i]);

    axes[axisIndex] = value;
    text.designAxes = axes;

    };


    /* ----------------------------- *
    * GENERAL *
    * ----------------------------- */

    /**
    * Returns the document containing a page item.
    * @param {PageItem} item
    * @returns {Document}
    */
    function getDocument(item) {

    while (
    item
    && 'Document' !== item.constructor.name
    )
    item = item.parent;

    return item;

    };

    /**
    * Returns the selected text frames, including the frames of any selected text.
    * @author m1b
    * @version 2026-09-01
    * @returns {Array<TextFrame>}
    */
    function getSelectedTextFrames() {

    var frames = [];
    var selection = app.selection;

    for (var i = 0; i < selection.length; i++) {

    var item = selection[i];

    if (item.hasOwnProperty('baseline'))
    // a text selection, rather than the frame itself
    item = item.parentTextFrames[0];

    if (
    item
    && 'TextFrame' === item.constructor.name
    )
    frames.push(item);

    }

    return frames;

    };

     

    Peter Kahrel
    Community Expert
    Community Expert
    August 14, 2026

    > 1. Is there a scripting equivalent of Illustrator's Type > Fit Headline in InDesign, or is adjusting tracking the accepted way to make a short line fill the measure?

    No, there isn't.

    > 2. For a line that is too long, is switching to the condensed cut of the same family the recommended automated fallback, rather than reducing the point size?

    I don't think that there's a recommended action, it's informed by taste and the amount of excess. If the line is only a little bit too long, then reducing the point size may be appropriate, if it's too long by a lot (whatever 'a lot' is), then a condensed type may be better. Or maybe even a combination of the two.

    > 3. Is Line.horizontalOffset / Line.endHorizontalOffset the reliable way to measure the rendered line width so size and tracking can be computed in a single pass instead of a shrink-until-it-fits loop? Anything to watch out for (justification, optical margin alignment, first baseline offset)?

    The horizontalOffsets are reliable. But how are you going to compute the increase in point size when you know you have, say 12 points space left on the line? And shrinking is even more complicated. A shrink-until-it-fits loop is very quick, depending on the precision and the number of iterations. Apart from that, there are more clever ways to fit type.

    > 4. For an automated pipeline like this, are variable fonts with a wdth axis the right long-term answer for width fitting?

    I don't know.

    > 5. Is there any built-in feature (auto-size text frames, object styles, paragraph style settings) we should be using instead of a custom fitting loop?

    If you can adjust the frame to the text, that's easy. You can use the auto-size settings in the text frame preferences (and/or set that in an object style).