見出し画像

【Illustrator】テキストをパス上文字に変換し、アーチ状にするスクリプト

テキストをパス上文字に変換し、テキストをアーチ状にするのは、さりげなく手間がかかる作業です。手順として書き出してみると、そうでもありませんが、実際、しんどいです。

  1. パスを作成する

  2. [パス上文字ツール]に持ち替えて、テキストを入力

  3. [ダイレクト選択ツール]に持ち替えて、パスを調整

これを解決する神スクリプトを高橋としゆきさんが公開くださっています。

長年にわたって活用させていただいてきましたが、次のような要望がありました。

  • 元の行揃えを保持するため、元のテキストが左揃えの場合、中央揃えにする手間が面倒

  • 曲線のまるみ度合いを調整したい

  • 上だけでなく、下方向にもしたい

  • パス上文字の効果を調整したい

  • トラッキングを調整したい

  • 後から調整できない

そこで、ダイアログボックスで設定できるように作成してみました。

高橋としゆきさんから、コメントを頂戴しました!

後からの修正にも対応

いったんパス上文字にした後でも、このスクリプトで再編集できます!!!!


スクリプト

コードをコピーしてテキストエディタにペーストして保存してお使いください。拡張子を.jsxに変更すればスクリプトファイルとして実行できます(有料記事になっていますが、ご自身で作業なされば無料です)。

#target illustrator
app.preferences.setBooleanPreference('ShowExternalJSXWarning', false);

/*
### スクリプト名:
ArcTextGenerator.jsx

### 更新日:
20260518

### 概要:
選択したテキストからアーチ状のパスを自動生成し、パス上文字にするツールです。

- まるみ:スライダーでアーチのカーブの深さを調整(0=直線/50=標準/100=最も丸い)
- フィット:常にON(文字サイズを自動調整してパス両端まで収める)
- 行揃え:常に中央
- アーチ方向:上向き/下向きを切り替え(初期値=上)
- 効果:パス上文字の効果を選択(虹/歪み/3D リボン/階段/引力)
- トラッキング:生成後の文字間隔を増減(既存のトラッキング値に加算)
- プレビュー:ダイアログ操作中に結果を確認(OFF・キャンセルで元に戻せます)

### オリジナル
高橋としゆき(@gautt)さん
https://note.com/gautt/n/n92f6faeda048

*/

(function () {

    /* バージョン / Version */
    var SCRIPT_VERSION = "v1.0.0";

    /* 言語判定 / Language */
    function getCurrentLang() {
        return ($.locale.indexOf("ja") === 0) ? "ja" : "en";
    }
    var lang = getCurrentLang();

    /* 日英ラベル定義 / Japanese-English label definitions */
    var LABELS = {
        dialogTitle: { ja: "アーチ文字を生成", en: "Generate Arc Text" },
        arcRoundness: { ja: "まるみ:", en: "Roundness:" },
        arcDirection: { ja: "アーチ方向:", en: "Arc direction:" },
        arcDirectionUp: { ja: "上", en: "Up" },
        arcDirectionDown: { ja: "下", en: "Down" },
        effect: { ja: "効果:", en: "Effect:" },
        effectRainbow: { ja: "虹", en: "Rainbow" },
        effectDistort: { ja: "歪み", en: "Skew" },
        effectRibbon: { ja: "3D リボン", en: "3D Ribbon" },
        effectStep: { ja: "階段", en: "Stair Step" },
        effectGravity: { ja: "引力", en: "Gravity" },
        tracking: { ja: "トラッキング:", en: "Tracking:" },
        preview: { ja: "プレビュー", en: "Preview" },
        ok: { ja: "OK", en: "OK" },
        cancel: { ja: "キャンセル", en: "Cancel" },
        alertNoDoc: { ja: "ドキュメントが開かれていません", en: "No document is open." },
        alertNoText: { ja: "対象のテキストが見つかりません", en: "No target text found." },
        alertArcFail: { ja: "アーチ状のパス生成に失敗しました", en: "Failed to generate an arc path." }
    };

    function L(key) {
        try {
            if (LABELS[key] && LABELS[key][lang]) return LABELS[key][lang];
            if (LABELS[key] && LABELS[key].ja) return LABELS[key].ja;
        } catch (_) { }
        return key;
    }

    /* ===== ユーティリティ / Utilities ===== */

    // Parse a number from a string; return fallback when not numeric
    function parseNumber(str, fallback) {
        var n = Number(str);
        if (isNaN(n)) return fallback;
        return n;
    }

    // Arrow-key increment/decrement for an edittext (Shift = 10, Option = 0.1)
    function changeValueByArrowKey(editText, allowNegative, onChanged) {
        if (!editText) return;
        try {
            editText.addEventListener('keydown', function (event) {
                try {
                    if (!event || (event.keyName !== 'Up' && event.keyName !== 'Down')) return;

                    var value = Number(editText.text);
                    if (isNaN(value)) return;

                    var keyboard = ScriptUI.environment.keyboardState;
                    var delta = 1;

                    if (keyboard.shiftKey) {
                        delta = 10;
                        // Snap to multiples of 10 for Shift
                        if (event.keyName === 'Up') {
                            value = Math.ceil((value + 1) / delta) * delta;
                        } else {
                            value = Math.floor((value - 1) / delta) * delta;
                        }
                    } else if (keyboard.altKey) {
                        delta = 0.1;
                        if (event.keyName === 'Up') value += delta;
                        else value -= delta;
                    } else {
                        delta = 1;
                        if (event.keyName === 'Up') value += delta;
                        else value -= delta;
                    }

                    // Round
                    if (keyboard.altKey) value = Math.round(value * 10) / 10;
                    else value = Math.round(value);

                    // Clamp
                    if (!allowNegative && value < 0) value = 0;

                    editText.text = String(value);

                    // Prevent default arrow key behavior (cursor move)
                    try { event.preventDefault(); } catch (_) { }

                    if (onChanged && typeof onChanged === 'function') {
                        try { onChanged(); } catch (_) { }
                    }
                } catch (_) { }
            });
        } catch (_) { }
    }

    /* ===== 選択の取得 / Selection ===== */
    if (app.documents.length === 0) {
        alert(L('alertNoDoc'));
        return;
    }
    var doc = app.activeDocument;
    var sel = doc.selection;

    // Base selection snapshot (used for stable preview while dialog is open)
    var baseSelection = [];
    try { baseSelection = sel.slice(0); } catch (_) { baseSelection = []; }

    var targetItems = getTargetTextItems(sel);
    var selectedPaths = getSelectedPathItems(sel);

    if (targetItems.length === 0) {
        alert(L('alertNoText'));
        return;
    }

    /* ===== ダイアログ / Dialog ===== */
    var dlg = new Window('dialog', L('dialogTitle') + ' ' + SCRIPT_VERSION);
    dlg.orientation = 'column';
    dlg.alignChildren = ['fill', 'top'];
    dlg.margins = [15, 20, 15, 15];

    // Common width for the leading label of each row (keeps controls aligned)
    var LABEL_COLUMN_WIDTH = 100;

    /* まるみ / Roundness */
    var grpRoundness = dlg.add('group');
    grpRoundness.orientation = 'row';
    grpRoundness.alignChildren = ['left', 'center'];
    // grpRoundness.margins = [0, 5, 0, 10];

    var stArcRoundness = grpRoundness.add('statictext', undefined, L('arcRoundness'));
    stArcRoundness.preferredSize.width = LABEL_COLUMN_WIDTH;
    // Slider: 0 = flat, 50 = default arch, 100 = roundest
    var slArcRoundness = grpRoundness.add('slider', undefined, 50, 0, 100);
    slArcRoundness.preferredSize.width = 200;

    /* アーチ方向 / Arc direction */
    var grpArcDirection = dlg.add('group');
    grpArcDirection.orientation = 'row';
    grpArcDirection.alignChildren = ['left', 'center'];
    // grpArcDirection.margins = [0, 0, 0, 10];

    var stArcDirection = grpArcDirection.add('statictext', undefined, L('arcDirection'));
    stArcDirection.preferredSize.width = LABEL_COLUMN_WIDTH;
    var rbArcDirectionUp = grpArcDirection.add('radiobutton', undefined, L('arcDirectionUp'));
    var rbArcDirectionDown = grpArcDirection.add('radiobutton', undefined, L('arcDirectionDown'));
    rbArcDirectionUp.value = true;

    /* 効果 / Effect */
    var grpEffect = dlg.add('group');
    grpEffect.orientation = 'row';
    grpEffect.alignChildren = ['left', 'center'];
    // grpEffect.margins = [0, 0, 0, 10];

    var stEffect = grpEffect.add('statictext', undefined, L('effect'));
    stEffect.preferredSize.width = LABEL_COLUMN_WIDTH;
    var rbEffectRainbow = grpEffect.add('radiobutton', undefined, L('effectRainbow'));
    var rbEffectDistort = grpEffect.add('radiobutton', undefined, L('effectDistort'));
    var rbEffectRibbon = grpEffect.add('radiobutton', undefined, L('effectRibbon'));
    var rbEffectStep = grpEffect.add('radiobutton', undefined, L('effectStep'));
    var rbEffectGravity = grpEffect.add('radiobutton', undefined, L('effectGravity'));
    // 既定はパス上文字の標準スタイルと同じ「虹」 / Default = Rainbow (Illustrator's own default)
    rbEffectRainbow.value = true;

    /* トラッキング / Tracking */
    var grpTracking = dlg.add('group');
    grpTracking.orientation = 'row';
    grpTracking.alignChildren = ['left', 'center'];
    // grpTracking.margins = [0, 0, 0, 10];

    var stTracking = grpTracking.add('statictext', undefined, L('tracking'));
    stTracking.preferredSize.width = LABEL_COLUMN_WIDTH;
    var etTracking = grpTracking.add('edittext', undefined, '0');
    etTracking.characters = 6;
    var slTracking = grpTracking.add('slider', undefined, 0, -100, 500);
    slTracking.preferredSize.width = 150;
    // 矢印キーで増減 / Arrow-key support for tracking
    changeValueByArrowKey(etTracking, true, function () { syncTrackingFromEdit(); refreshPreviewIfNeeded(); });

    /* フッター / Footer */
    var footer = dlg.add('group');
    footer.orientation = 'row';
    footer.alignChildren = ['fill', 'center'];
    footer.alignment = ['fill', 'top'];

    var leftFooter = footer.add('group');
    leftFooter.orientation = 'row';
    leftFooter.alignment = ['left', 'center'];
    var cbPreview = leftFooter.add('checkbox', undefined, L('preview'));
    cbPreview.value = true;

    var rightFooter = footer.add('group');
    rightFooter.orientation = 'row';
    rightFooter.alignment = ['right', 'center'];
    var btnCancel = rightFooter.add('button', undefined, L('cancel'));
    var btnOk = rightFooter.add('button', undefined, L('ok'), { name: 'ok' });

    /* ===== プレビュー(Undoなし) / Preview (no undo) ===== */
    var previewTempItems = [];        // items created during preview
    var previewHiddenOriginals = [];  // originals hidden during preview

    function clearPreview() {
        // Remove temp items
        for (var i = previewTempItems.length - 1; i >= 0; i--) {
            try { previewTempItems[i].remove(); } catch (_) { }
        }
        previewTempItems = [];

        // Restore originals visibility
        for (var j = previewHiddenOriginals.length - 1; j >= 0; j--) {
            try { previewHiddenOriginals[j].hidden = false; } catch (_) { }
        }
        previewHiddenOriginals = [];
    }

    function hideOriginalForPreview(item) {
        try {
            if (!item) return;
            for (var k = 0; k < previewHiddenOriginals.length; k++) {
                if (previewHiddenOriginals[k] === item) return;
            }
            item.hidden = true;
            previewHiddenOriginals.push(item);
        } catch (_) { }
    }

    function applyPreview() {
        clearPreview();

        // Restore base selection so preview stays stable even after selection changes
        try { doc.selection = baseSelection; } catch (_) { }

        var currentSelection = [];
        try { currentSelection = doc.selection; } catch (_) { currentSelection = []; }
        if (!currentSelection || currentSelection.length === 0) {
            currentSelection = baseSelection;
        }
        targetItems = getTargetTextItems(currentSelection);
        selectedPaths = getSelectedPathItems(currentSelection);

        if (!targetItems || targetItems.length === 0) {
            cbPreview.value = false;
            return;
        }

        generateArcText(false, true);
        try { app.redraw(); } catch (_) { }
    }

    function refreshPreviewIfNeeded() {
        if (cbPreview.value) applyPreview();
    }

    /* ===== ハンドラ / Handlers ===== */
    slArcRoundness.onChange = refreshPreviewIfNeeded;
    rbArcDirectionUp.onClick = refreshPreviewIfNeeded;
    rbArcDirectionDown.onClick = refreshPreviewIfNeeded;
    rbEffectRainbow.onClick = refreshPreviewIfNeeded;
    rbEffectDistort.onClick = refreshPreviewIfNeeded;
    rbEffectRibbon.onClick = refreshPreviewIfNeeded;
    rbEffectStep.onClick = refreshPreviewIfNeeded;
    rbEffectGravity.onClick = refreshPreviewIfNeeded;

    /* トラッキング UI 同期 / Tracking UI sync (edittext <-> slider) */
    var trackingSyncLock = false;

    function syncTrackingFromEdit() {
        if (trackingSyncLock) return;
        trackingSyncLock = true;
        try {
            var v = Math.max(-100, Math.min(500, Math.round(parseNumber(etTracking.text, 0))));
            etTracking.text = String(v);
            try { slTracking.value = v; } catch (_) { }
        } catch (_) { }
        trackingSyncLock = false;
    }

    function syncTrackingFromSlider() {
        if (trackingSyncLock) return;
        trackingSyncLock = true;
        try {
            var v = Math.round(slTracking.value);
            etTracking.text = String(v);
        } catch (_) { }
        trackingSyncLock = false;
    }

    etTracking.onChanging = function () { syncTrackingFromEdit(); refreshPreviewIfNeeded(); };
    slTracking.onChanging = function () { syncTrackingFromSlider(); };
    slTracking.onChange = function () { syncTrackingFromSlider(); refreshPreviewIfNeeded(); };
    syncTrackingFromEdit();

    cbPreview.onClick = function () {
        if (cbPreview.value) {
            applyPreview();
        } else {
            clearPreview();
            try { app.redraw(); } catch (_) { }
        }
    };

    btnCancel.onClick = function () {
        clearPreview();
        dlg.close(0);
    };

    btnOk.onClick = function () {
        // If preview is ON, clear it first so we don't stack temporary objects.
        if (cbPreview.value) clearPreview();
        generateArcText(true, false);
        dlg.close(1);
    };

    /* 起動時に一度プレビュー / Auto-apply preview once on open */
    try {
        if (cbPreview.value) {
            applyPreview();
            try { app.redraw(); } catch (_) { }
        }
    } catch (_) { }

    var dialogResult = dlg.show();
    if (dialogResult !== 1) return;

    /* ===== テキスト・パス収集 / Collect text & paths ===== */

    // Get target text items (point text / path text), recursing into groups
    function getTargetTextItems(items) {
        var found = [];
        for (var i = 0; i < items.length; i++) {
            var item = items[i];
            if (item.typename === 'TextFrame') {
                try {
                    if (item.kind === TextType.POINTTEXT || item.kind === TextType.PATHTEXT) {
                        found.push(item);
                    }
                } catch (_) { }
            } else if (item.typename === 'GroupItem') {
                found = found.concat(getTargetTextItems(item.pageItems));
            }
        }
        return found;
    }

    // Get selected path items, recursing into groups
    function getSelectedPathItems(items) {
        var found = [];
        for (var i = 0; i < items.length; i++) {
            var item = items[i];
            if (item.typename === 'PathItem' || item.typename === 'CompoundPathItem') {
                found.push(item);
            } else if (item.typename === 'GroupItem') {
                found = found.concat(getSelectedPathItems(item.pageItems));
            }
        }
        return found;
    }

    /* ===== パススタイル / Path style ===== */

    // Run styleFn for each underlying PathItem (handles CompoundPathItem too).
    function forEachPathItem(pathItem, styleFn) {
        try {
            if (!pathItem || !styleFn) return;
            if (pathItem.typename === 'CompoundPathItem') {
                for (var cp = 0; cp < pathItem.pathItems.length; cp++) {
                    try { styleFn(pathItem.pathItems[cp]); } catch (_) { }
                }
            } else {
                try { styleFn(pathItem); } catch (_) { }
            }
        } catch (_) { }
    }

    // Per-PathItem style: invisible (no fill, stroke color/weight 0).
    function styleInvisiblePath(pi) {
        pi.filled = false;
        pi.stroked = false;
        pi.strokeWidth = 0;
    }

    // Generated arc path: stroke color/weight 0 (invisible guide).
    function applyInvisiblePathStyle(pathItem) {
        forEachPathItem(pathItem, styleInvisiblePath);
    }

    /* ===== アーチ生成 / Arc generation ===== */

    // If a path is selected together with text, it should not remain.
    // Preview: hide it (restored by clearPreview). Execute: delete it.
    function handleSelectedPaths(previewMode) {
        try {
            if (!selectedPaths || selectedPaths.length === 0) return;
            for (var i = selectedPaths.length - 1; i >= 0; i--) {
                var path = selectedPaths[i];
                if (!path) continue;
                if (previewMode) {
                    hideOriginalForPreview(path);
                } else {
                    try { path.remove(); } catch (_) { }
                }
            }
        } catch (_) { }
    }

    // Apply center justification to all paragraphs of a text frame
    function applyCenterJustification(textFrame) {
        try {
            if (!textFrame) return;
            try {
                if (textFrame.paragraphs && textFrame.paragraphs.length > 0) {
                    for (var i = 0; i < textFrame.paragraphs.length; i++) {
                        try {
                            textFrame.paragraphs[i].paragraphAttributes.justification = Justification.CENTER;
                        } catch (_) { }
                    }
                }
            } catch (_) { }
            try { textFrame.textRange.paragraphAttributes.justification = Justification.CENTER; } catch (_) { }
        } catch (_) { }
    }

    /* ===== 効果・トラッキング / Effect & tracking ===== */

    // Resolve the menu command for the selected effect (null = none)
    function getSelectedEffectCommand() {
        if (rbEffectRainbow.value) return 'Rainbow';
        if (rbEffectDistort.value) return 'Skew';
        if (rbEffectRibbon.value) return '3D ribbon';
        if (rbEffectStep.value) return 'Stair Step';
        if (rbEffectGravity.value) return 'Gravity';
        return null;
    }

    // Apply the selected path-text effect via menu command (requires selection)
    function applyPathTextEffect(textFrame) {
        var cmd = getSelectedEffectCommand();
        if (!cmd) return;

        var prevSelection = null;
        try { prevSelection = doc.selection; } catch (_) { prevSelection = null; }

        try {
            try { doc.selection = []; } catch (_) { }
            try { textFrame.selected = true; } catch (_) { }
            app.executeMenuCommand(cmd);
        } catch (_) { }

        // Always restore the previous selection (safer)
        try { doc.selection = prevSelection; } catch (_) { }
    }

    // Add the tracking delta to the existing tracking of each textRange
    function applyTrackingDelta(textFrame) {
        try {
            if (!textFrame) return;
            var delta = Math.round(parseNumber(etTracking.text, 0));
            if (!delta) return; // 0 -> do nothing

            var ranges = [];
            try {
                if (textFrame.textRanges && textFrame.textRanges.length > 0) {
                    for (var i = 0; i < textFrame.textRanges.length; i++) ranges.push(textFrame.textRanges[i]);
                }
            } catch (_) { }
            if (ranges.length === 0) {
                try { if (textFrame.textRange) ranges = [textFrame.textRange]; } catch (_) { ranges = []; }
            }

            for (var r = 0; r < ranges.length; r++) {
                var textRange = ranges[r];
                try {
                    var currentTracking = textRange.characterAttributes.tracking;
                    textRange.characterAttributes.tracking = currentTracking + delta;
                } catch (_) { }
            }
        } catch (_) { }
    }

    // Measure rendered text bounds via temporary outlines: [L, T, R, B] or null
    function measureTextBounds(originalText) {
        try {
            // [0] holds only the first line (used for the baseline),
            // [1] holds everything (used for the left/top/right extents).
            var measureTexts = [originalText.duplicate(), originalText.duplicate()];
            measureTexts[0].contents = '';
            for (var i = 0; i < originalText.lines[0].length; i++) {
                originalText.textRanges[i].duplicate(measureTexts[0]);
            }

            for (var k = 0; k < measureTexts.length; k++) {
                measureTexts[k] = measureTexts[k].createOutline();
            }

            var bounds = measureTexts[1].geometricBounds; // [L, T, R, B]
            bounds[3] = measureTexts[0].geometricBounds[3]; // baseline from the first line

            for (var r = 0; r < measureTexts.length; r++) {
                try { measureTexts[r].remove(); } catch (_) { }
            }
            return bounds;
        } catch (e) {
            return null;
        }
    }

    // Roundness from the slider, clamped to 0-100 (50 = default arch)
    function readRoundnessPercent() {
        var percent = 50;
        try { percent = Number(slArcRoundness.value); } catch (_) { percent = 50; }
        if (isNaN(percent)) return 50;
        return Math.max(0, Math.min(100, percent));
    }

    // Bend a 2-point straight path into an arc by moving its Bézier handles.
    // directionSign: +1 = bulge upward, -1 = bulge downward.
    // 水平ハンドルは固定、垂直ハンドル=カーブの深さ(50% で pathLength/4)。
    function applyArcHandles(arcPath, roundnessPercent, directionSign) {
        var pathLength = arcPath.length;
        var horizontalHandle = pathLength / 3.5;
        var verticalHandle = pathLength * (roundnessPercent / 100) * 0.5 * directionSign;

        var pathPoints = arcPath.pathPoints;
        pathPoints[0].rightDirection = [
            pathPoints[0].rightDirection[0] + horizontalHandle,
            pathPoints[0].rightDirection[1] + verticalHandle
        ];
        pathPoints[1].leftDirection = [
            pathPoints[1].leftDirection[0] - horizontalHandle,
            pathPoints[1].leftDirection[1] + verticalHandle
        ];
    }

    // Create an arc-shaped path sized to the given point text
    function createArcPathFromText(originalText, layer) {
        var baselineYMultiplier = 1.02;

        // Guard: empty / invalid text
        try {
            if (!originalText || originalText.typename !== 'TextFrame') return null;
            if (!originalText.lines || originalText.lines.length === 0) return null;
            if (!originalText.textRanges || originalText.textRanges.length === 0) return null;
        } catch (_) {
            return null;
        }

        try {
            var textBounds = measureTextBounds(originalText);
            if (!textBounds) return null;

            // Base straight path along the baseline
            var baselineY = textBounds[3] * baselineYMultiplier;
            var arcPath = layer.pathItems.add();
            arcPath.setEntirePath([
                [textBounds[0], baselineY],
                [textBounds[2], baselineY]
            ]);
            try {
                arcPath.stroked = false;
                arcPath.filled = false;
            } catch (_) { }

            // Bend the straight path into an arc(上=+ / 下=−)
            var directionSign = (rbArcDirectionDown && rbArcDirectionDown.value) ? -1 : 1;
            applyArcHandles(arcPath, readRoundnessPercent(), directionSign);

            return arcPath;
        } catch (e) {
            return null;
        }
    }

    // Main process: generate an arc path and place the text on it
    function generateArcText(showAlerts, previewMode) {
        if (typeof previewMode === 'undefined') previewMode = false;
        if (typeof showAlerts === 'undefined') showAlerts = true;
        var createdTexts = [];

        // A path selected together with text should not remain in arc mode
        handleSelectedPaths(previewMode);

        for (var j = 0; j < targetItems.length; j++) {
            var originalText = targetItems[j];
            var currentLayer = originalText.layer;

            // Create an arc-like path from the text bounds
            var arcPath = createArcPathFromText(originalText, currentLayer);
            if (!arcPath) {
                if (showAlerts) alert(L('alertArcFail'));
                continue;
            }

            // Generated arc path: stroke color/weight 0 (invisible guide)
            applyInvisiblePathStyle(arcPath);
            if (previewMode) previewTempItems.push(arcPath);

            // Create text on the path
            var textOnAPath = currentLayer.textFrames.pathText(arcPath);
            // Keep stacking position (avoid appearing to disappear behind other objects)
            try { textOnAPath.move(originalText, ElementPlacement.PLACEBEFORE); } catch (_) { }
            if (previewMode) previewTempItems.push(textOnAPath);

            // Keep the path used by the PathText invisible (AI may override style on conversion)
            try {
                var pathTextPath = textOnAPath.textPath;
                if (pathTextPath) applyInvisiblePathStyle(pathTextPath);
            } catch (_) { }

            // Duplicate textRanges from the original text frame
            for (var i = 0; i < originalText.textRanges.length; i++) {
                originalText.textRanges[i].duplicate(textOnAPath);
            }

            // 行揃え:常に中央 ※ duplicate 後に適用しないと上書きされる
            applyCenterJustification(textOnAPath);

            // トラッキング(既存値 + 指定値)※ フィット前に適用してオーバーセット判定へ反映
            applyTrackingDelta(textOnAPath);

            // 効果(パス上文字の効果をメニューコマンドで適用)
            applyPathTextEffect(textOnAPath);

            // Collect created PathText for fit (even in preview)
            createdTexts.push(textOnAPath);

            // Remove or hide the original text frame
            if (previewMode) {
                hideOriginalForPreview(originalText);
            } else {
                originalText.remove();
            }

            // Select the created text on a path
            if (!previewMode) {
                try { textOnAPath.selected = true; } catch (_) { }
            }
        }

        // フィット:常にON(ループ後にまとめて適用)
        try { fitTextToOpenPath(createdTexts); } catch (_) { }
    }

    /* ===== フィット / Fit ===== */

    // Fit PathText to OPEN path endpoints by adjusting font size only.
    // - If overset: shrink by small steps until it fits.
    // - If NOT overset: grow to intentionally create overset, then shrink to fit.
    function fitTextToOpenPath(frames) {
        if (!frames || frames.length === 0) return false;

        var opt = {
            increment: 0.1,
            minFontSize: 0.1,
            maxShrinkIter: 2000,
            maxGrowIter: 10,
            maxFontSize: 2000,
            alertOnMaxIter: false
        };

        function isClosedPathItem(item) {
            try {
                if (!item) return false;
                if (item.typename === 'CompoundPathItem') {
                    if (item.pathItems && item.pathItems.length > 0) return !!item.pathItems[0].closed;
                    return false;
                }
                if (item.typename === 'PathItem') return !!item.closed;
            } catch (_) { }
            return false;
        }

        function isTargetPathText(textFrame) {
            try {
                if (!textFrame || textFrame.typename !== 'TextFrame') return false;
                if (textFrame.kind !== TextType.PATHTEXT) return false;
                if (!textFrame.editable || textFrame.locked || textFrame.hidden) return false;
                var path = textFrame.textPath;
                if (!path) return false;
                // Only OPEN paths
                if (isClosedPathItem(path)) return false;
                return true;
            } catch (_) { }
            return false;
        }

        // Overset detection
        function isOverset(textFrame, lineAmount) {
            try {
                if (!textFrame) return false;

                if (textFrame.lines.length > 0) {
                    var charactersOnVisibleLines = 0;

                    if (typeof (lineAmount) === 'undefined' || lineAmount === null) {
                        lineAmount = 1;
                    } else {
                        lineAmount = Math.floor(lineAmount);
                        if (lineAmount < 1) lineAmount = 1;
                        if (lineAmount > textFrame.lines.length) lineAmount = textFrame.lines.length;
                    }

                    for (var i = 0; i < lineAmount; i++) {
                        charactersOnVisibleLines += textFrame.lines[i].characters.length;
                    }
                    return (charactersOnVisibleLines < textFrame.characters.length);
                } else if (textFrame.characters.length > 0) {
                    return true;
                }
            } catch (_) { }
            return false;
        }

        function shrinkFont(textFrame) {
            try {
                if (!textFrame || textFrame.characters.length <= 0) return;

                var lineAmount = (textFrame.lines && textFrame.lines.length > 0) ? textFrame.lines.length : 1;

                // If it is NOT overset, grow first (doubling) until it becomes overset
                if (!isOverset(textFrame, lineAmount)) {
                    var growIter = 0;
                    while (!isOverset(textFrame, lineAmount) && growIter < opt.maxGrowIter) {
                        var growSize = textFrame.textRange.characterAttributes.size;
                        if (growSize >= opt.maxFontSize) break;
                        textFrame.textRange.characterAttributes.size = Math.min(opt.maxFontSize, growSize * 2);
                        growIter++;
                    }
                }

                // Then shrink in small steps until it fits
                var shrinkIter = 0;
                while (isOverset(textFrame, lineAmount)) {
                    var currentSize = textFrame.textRange.characterAttributes.size;
                    if (currentSize <= opt.minFontSize) break;

                    textFrame.textRange.characterAttributes.size = Math.max(opt.minFontSize, currentSize - opt.increment);

                    shrinkIter++;
                    if (shrinkIter >= opt.maxShrinkIter) break;
                }
            } catch (_) { }
        }

        for (var i = 0; i < frames.length; i++) {
            var textFrame = frames[i];
            if (!isTargetPathText(textFrame)) continue;
            shrinkFont(textFrame);
        }

        return true;
    }
}());

アピアランスでできない?

ある意味、アピアランスの方が扱いがラクですし、特に帯を付けたい場合にはアピアランスが最適です。

ただし、厳密にいうと、アピアランスの方は文字の形状が少し歪んでしまいます。

ご参考

こちらのスクリプト(全部入り)から切り出しました。

アップデート

  • 「パス幅に合わせる」を追加

  • トラッキングにチェックボックスを追加

ダウンロード

ここから先は

34字 / 2ファイル

¥ 300

Amazon Payで支払うと最大2%還元のチャンス! 9/30まで

定期マガジンを購読されるとサンプルファイルをダウンロードいただけます。 https://note.com/dtp_tranist/m/mebd7eab21ea5