UDPでNAT超え

あらすじ

NAT超えという技術を知り、いったいどういうものなのか?を調べて、Goで簡単に実装してみました。

NAT

NATというのはグローバルIPとローカルIPを変換する機構です。グローバルIPの枯渇を防ぐため、ローカルIP以下のデバイスに対して同じグローバルIPを割り振るための仕組み提供します。最近はIPだけではなくポートも変換するNAPTというタイプが主流のようです。

例えば、2台のパソコンがNAT下にぶら下がっていてそれぞれがサーバーにアクセスするような場合は以下のような構成になります。

f:id:bamch0h:20200627184558j:plain

PC1がWEBサーバーにアクセスする時、アクセス要求はNATを経由してインターネットを通じてサーバーに到達します。サーバーは要求を処理し応答を返します。応答はインターネットを経由してNATまで届きます。NATはその応答をPC1に送り、無事に通信ができます。

NATがどのように応答を振り分けるというと、NAT内部にグローバルIPとローカルIPの変換表を持っていてそれをもとに応答を振り分けています。TCP/IPUDP/IPの場合、変換表には以下の情報が記録されます。

PC1がWEBサーバーにアクセスした場合は以下のような情報が変換表に記録されます。

プロトコル 送信元IP 送信元ポート グローバルIP グローバルポート 相手先IP 相手先ポート
TCP 192.168.0.2 49152 1.2.3.4 49152 2.3.4.5 80

サーバー側からの応答は送信先IPと相手先IPが変換表とは逆になって返信されます。NATはそれを考慮して変換表に当てはまるものがあるかを調べ、当てはまるものがあったなら、そのデバイスへ応答を渡します。なかった場合はその時点で破棄されます。

送信元ポートグローバルポートは基本的に同じになります。別々のローカルIPで同じポート番号から通信が来た場合にのみ変換されます。例えば、PC1とPC2が同じ送信元ポートで通信をした場合、ポートを変換せずに変換表に記録した場合、以下のようになります。

プロトコル 送信元IP 送信元ポート グローバルIP グローバルポート 相手先IP 相手先ポート
TCP 192.168.0.2 49152 1.2.3.4 49152 2.3.4.5 80
TCP 192.168.0.3 49152 1.2.3.4 49152 2.3.4.5 80

サーバーからの応答はどちらの要求に対しても 1.2.3.4:49152 に返信されるので、NATは変換表をチェックしたときに1番目にも2番目にも対象のIPがヒットするので応答を振り分けることができません。そこで、以下のようにグローバル側に見せるポートを変換することで、この問題を解決します。

プロトコル 送信元IP 送信元ポート グローバルIP グローバルポート 相手先IP 相手先ポート
TCP 192.168.0.2 49152 1.2.3.4 49152 2.3.4.5 80
TCP 192.168.0.3 49152 1.2.3.4 49153 2.3.4.5 80

こうすることで、PC1の応答は 1.2.3.4:49152 に、PC2の応答は 1.2.3.4:49153 に返されるため、NATは問題なく応答を振り分けることができるというわけです。

ポートマッピング

NATは基本的にグローバル側からアクセスは受け付けません。というのも、グローバル側から唐突に通信が来ても変換表から対応するローカルIPを割り出せないため、すべて破棄されるためです。これだと、ローカルにWEBサーバーを立てても外部に公開できなくて不便ですよね。なので、NATにはポートマッピングという機能があるものがあります。これは、特定のポートにやってきた通信を対応するIPのデバイスに転送する機能です。例えば、以下のようなマッピングテーブルがあったとします。

プロトコル ポート 転送IP
TCP 80 192.168.0.2

この場合、NATにやってきた要求のうち TCPポート80番に来た要求は全て 192.168.0.2 に転送されます。192.168.0.2 にWEBサーバーが起動していれば要求は処理され応答が返ります。

P2P

P2Pとは、サーバーを持たず、クライアント同士が通信する方法のことを指します。サーバー運営コストが少ない、サーバーの処理性能に左右されない等のメリットがあります。ただ、そのままではクライアント間で通信はできないため、古き良きインターネッツ時代にあったP2Pファイル共有ソフトではポート開放(ポートマッピング)を行うことで各ノード間の通信を可能にしていました。一方で、ポート開放はセキュリティリスクが高く、悪意のあるユーザーがそのポートに対してアクセスして悪意のある操作を可能にする可能性があるため、ユーザーとしてはあまりやりたいことではないと思います。そこで出てくるのがNAT超えです。

NAT超え

NAT超えとは、ポート開放せずにP2Pを可能にする技術のことです。クライアントだけではNAT越えは難しいので、STUNサーバーやTURNサーバーを使ってNAT超えを行うのがセオリーのようです。STUNサーバーやTURNサーバーのRFCがあるようです。私はまだ見れていません。。。

UDPでのNAT超え

UDPコネクションレスなので内側からデータを送信するだけでNATはポートを開放します。相手側にはデータは届きませんが応答を待っている状態ですので、相手側からも同じようにデータを送信することで通信が可能となります。ただ、内から外への通信によってポートが自動で開放されるとしても、相手のIPアドレスとポートを知る方法が必要です。

f:id:bamch0h:20200627184529j:plain

IP取得用サーバー

相手のIPアドレスとポートを知るためにサーバーを一つ用意します。そのサーバーに自分のIPアドレスとポートを登録しておき、相手から自由に取得できるようにしておきます。いかに例を示します。

f:id:bamch0h:20200628002505j:plain

  • ① サーバーにPC1のIPを登録
  • ② サーバーにPC2のIPを登録
  • ⓷ PC2のIPを取得
  • ⓸ NATのポート開放のためにPC2に通信する
  • ⑤ PC2に通信したことをサーバーに通知
  • ⑥ サーバーがPC2にPC1が通信したことを通知(合わせてPC1のIPとポートを伝える)
  • ⓻ PC2からPC1に伝えられたポートで通信

このような感じでPC1とPC2の疎通が可能となります。

Goのサンプル

上記を実装したのが以下になります。

クライアント

NAT超え over UDP

サーバー

STUN server over UDP

まとめ

  • UDPでNAT越えをするための理屈を説明しました。
  • Goでのサンプルを実装しました。

クリックした箇所をヒートマップで表示するサービスの技術的検証

概要

クリックした箇所をヒートマップで表示するにはどういうサービス構成になるのか?という技術的興味から heroku で以下のように表示をするサービスを作成しました。いろんな理由で実際の使用には耐えられないので、技術的検証というタイトルにしています。

f:id:bamch0h:20200407000342p:plain
ヒートマップサイト

経緯

ひょんなことから、こういうサイトがあることを知り、いったいどういう仕組みで動くのだろう? という技術的興味から、こうやったら作れそうだな。という見立てができたので実装してみました。

最終的なサービスの構造

最終的には以下のような構造になりました。

f:id:bamch0h:20200407001525j:plain
最終的なサービスの構造

heroku の dyno を3つ立ち上げて、それぞれに以下のサイトを設定しました。

  • サイト1: クリックポイントを取得する用のサイト (これは別で用意してもらっても構いません)
  • サイト2: クリックポイントを収集し表示する用のサイト
  • サイト3: クリックポイント対象のサイトのスクリーンショットを取得するためのサイト

ユーザーはサイト1にクリックポイントをサイト2に送る用のjavascriptを埋め込みます。これにより、クリックポイントの収集が可能になります。クリックポイントを収集した後、ユーザーがサイト2にアクセスすると、サイト2の内部でサイト1のスクリーンショットを取得します。スクリーションショットの取得はサイト3の puppeteer 経由で chromium を使用して行われます。スクリーンショットを取得すると、その画像の上にクリックポイントを重ねて表示します。クリック回数が多い場所が赤く、少ない場所は青く表示されます。

ソースコード

  • サイト1

GitHub - bamchoh/heatmap-sample-site1 at for_blog

  • サイト2

GitHub - bamchoh/heatmap-sample-site2 at for_blog

  • サイト3

GitHub - bamchoh/heatmap-sample-site3 at for_blog

ハマリポイント

heatmap.js の使い方

heatmap.js : Dynamic Heatmaps for the Web

上記サイトにheatmap.js の使い方は記載されているのですが、はじめ setData を使っていて、なかなかうまくグラデーションが掛かりませんでした。 setData だと指定する Value を固定で設定する必要があるため、自前でドットの強弱を計算しておかなければならないようです。この問題は addData を使うことで解決できましたが、クリックポイントが多いと処理が重くなって表示までの時間にかかってしまうという問題があり、今のところ解決に至っていません。

fetch の クロスオリジン

この問題は Fetch: クロスオリジン(Cross-Origin) リクエスト に記載されている通りなのですが、別ドメインへの fetch リクエストは設定をちゃんとしておかないとリクエスト・レスポンスともに取得できない場合がありました。ブラウザからのリクエストは同じドメインに送信するようにし、サーバーサイドで別ドメインへのリクエストを送信することで、この問題を回避しています。ちゃんと設定すればブラウザレベルでも対応できたのでしょうけど、今回のヒートマップを表示する。という目標から考えると、本質的な問題ではないと考えて、今回はサーバー側での対応を行いました。

サイトのスクリーンショット

ハマリポイントというか、これ以外の方法が今のところ浮かんでないのですが、クリックポイントをサイトに重ねる方法として、Chromeスクリーンショット機能を使用しています。ローカルで行う分には問題なかったのですが、それを heroku で動かそうとすると Chrome をインストールする必要があり、heroku コンテナではそれはできません。 puppeteer という ChromeAPIを使用して色々できるライブラリが node.js にあり、それを使うことで解決しました。

スクリーンショットが文字化けする

heroku で puppeteer を使う記事はいくつかありますが、参考にするサイトによっては puppeteer が日本語に対応していないものをインストールすることになっているものがあります。そういった場合は日本語フォントを対応している puppeteer をインストールしなおすことで治ります。

HerokuにPuppeteerの実行環境を構築する - Qiita

現状の問題点

スクリーンショットが重い

スクリーンショットを撮る時にいちいち Chrome が起動してしまうので処理が重くなっています。キャッシュの検討が必要ですね。

クリックポイントが多くなってきた場合の処理速度の低下

heatmap.js の addData メソッドを使うと、自動でクリックポイントの値の強弱を計算してくれますが、その計算が重くクリックポイントが多くなると描画までの時間が長くなってしまいます。70個の点で数十秒待たないと描画されないので、自前で計算して setData で描画するほうがいいかもしれません。まだ、自前で計算する部分は考えられていません。

クリックポイントAPIの制限

現状、POSTメソッドは誰でもアクセスできるため、第三者がURLを偽ってクリックポイントを送ることができるので正確なクリックポイントが収集できません。トークンのようなものでアクセスを制限する方法を考えてみたのですが、タグを外部サイトに埋め込むという性質上、そのタグさえ知っていればトークンも取得できてしまうのではないか?と思っています。(もしかすると、うまくやる方法があるのかもですが、まだ調査しきれていません) もう一つ考えているのは、リクエスト元のURLとリクエスト内のURLを比較して、一致しているかどうかで判断する。ということもできるかと思っています。こちらは比較的容易に実装ができそうですが、これですべて賄えるかは検証できていません。

まとめ

クリックした箇所をヒートマップで表示するサービスを技術的検証レベルで実装してみました。 様々なハマリどころがあり、様々な問題点があることに気づけました。 今後も改善していけるところは改善していけたらと思っています。

ジェネリックのキャスト

ただの愚痴です。

なんしか、↓これがコンパイル通るのに実行時にキャストエラーになるのが納得いかない。

using System;
using System.Collections.Generic;

namespace GenericTest
{
    class Program
    {
        private static void Fn1(object list)
        {
            foreach (var i in (IList<int>)list) // ここでキャストエラー
            {
                Console.WriteLine(i);
            }
        }

        private static object Fn2(List<int> list1)
        {
            var list2 = new List<object>();

            foreach (var o in list1)
            {
                list2.Add(o);
            }

            return list2;
        }

        public static void Main(string[] args)
        {
            var list1 = new List<int>() { 1, 2, 3 };

            Fn1(Fn2(list1));
        }
    }
}

恐らく、list2に要素を詰めなおしているのが原因なんだと思うけど、こういう書き方はc#ではできないっぽい。Fn1が変更できないのであれば、詰めなおさないようにプログラムを書き直す。Fn2が変更できないのであれば、Fn1のキャストをIList<int> じゃなくて IList を使うようにする。できればコンパイル時にエラーになってほしいけど、object型が間に挟まっている以上無理っぽい。

IListの要素にインターフェースを強制したいのであれば var じゃなくて 型を明示するのがよさそう。

using System;
using System.Collections;
using System.Collections.Generic;

namespace GenericTest
{
    interface IValue
    {
        object Value();
    }

    class Number : IValue
    {
        private int i;
        public Number(int i)
        {
            this.i = i;
        }

        public object Value()
        {
            return i;
        }
    }

    class Text : IValue
    {
        private string s;
        public Text(string s)
        {
            this.s = s;
        }

        public object Value()
        {
            return s;
        }
    }

    class Program
    {
        private static void Fn1(object list)
        {
            // var じゃなくて IValueを使う
            foreach (IValue o in (IList)list)
            {
                Console.WriteLine(o.Value());
            }
        }

        private static object Fn2(List<IValue> list1)
        {
            var list2 = new List<object>();

            foreach (var o in list1)
            {
                list2.Add(o);
            }

            return list2;
        }

        public static void Main(string[] args)
        {
            var list1 = new List<IValue>() {
                new Number(1),
                new Text("2"),
                new Number(3),
            };

            Fn1(Fn2(list1));
        }
    }
}

JenkinsのジョブをキューイングするGoアプリ作った

概要

トリガとなるジョブと実行の遅いジョブとを連結させた場合に全体の実行が遅いジョブに依存してしまう問題を解決する為に、実行の遅いジョブの前にジョブキュー用のGoアプリを挟むことで問題を解決しました。

問題点1 ジョブキューにどんどんジョブが溜まる

例えば、以下のようなJob関係があるとします。

+----------+----------+
| Machine1 | Machine2 |
+----------+----------+
|          |          |
| +------+ |          |
| | JOB1 | |          |
| +--+---+ |          |
|    |     |          |
|    +----------+     |
|    |     |    |     |
| +--+---+ | +--+---+ |
| | JOB2 | | | JOB3 | |
| +------+ | +------+ |
|          |          |
+----------+----------+

Job1が終了すると、同じマシンでJob2が動作して、平行してJob3が別マシンで動作する。という感じです。あと、別マシンはJobが1つしか動作しない設定になっており、Job3はJob2に比べて十分に遅いものとします。そうした場合に、このジョブ群は1回目はうまく動作しますが、2回目、3回目とJob1が動作するたびにJob3のジョブキューにどんどんたまっていきます。

軽く考え付く解決案としては、「Job3のジョブを軽くする」というのと「Job3が実行中ならジョブキューにジョブを積まない」というのがあると思います。

1つ目の「Job3のジョブを軽くする」に関しては、たとえばJob3の中で実行しているジョブを細分化して別マシンに割り振ることで可能だという認識ですが、今回私が直面した問題ではマシンを数十用意しなければならず、コストの面で却下となりました。

もう一つの「Jobが実行中ならジョブキューにジョブを積まない」に関しては、groovy-label-assignment-plugin等を使用して、Groovyスクリプト等でJob1の最後にチェックしたり、Job3の頭でやったりすれば出来ると思います。ただ、すいません、このケースはあまり調査してなくて単純に次に話す問題点が解決しなかったので諦めました。

github.com

問題点2 ジョブがスキップされる場合がある

「Jobが実行中ならジョブキューにジョブを積まない」という解決策をとった場合、ジョブが実行されているがゆえに本来実行されるべきだったジョブがスキップされてしまうという問題がありました。

つまり、

  1. Job1が実行されJob3にジョブ(ビルド1)がキューに積まれる
  2. Job3がジョブ(ビルド1)を実行する
  3. Job1が再度実行されジョブ(ビルド2)をキューに積もうとするが、ジョブが実行中なのでキューに積まれない
  4. Job3がジョブ(ビルド1)を完了する

という流れになります。当たり前ですね。これを解決するためにはジョブをキューに積むようにしないといけないですが、それだと問題点1に逆戻りというジレンマを抱えます。

最終的に解決したかったこと。

最終的に解決したかったことは、「ジョブキューが空の場合はジョブキューにジョブを積み、ジョブキューにジョブがある場合はジョブキューをクリアして、最新のジョブをジョブキューに追加する」ということでした。

疑似的にコードを書くとするならこんな感じでしょうか。

if !queue.empty? {
  queue.clear()
}
queue.add(job)

実装してみた

github.com

実装してみました。ただ、ちょっとオーバーすぎる実装にしてしまったかなと思っています (^_^;)

今回はWeb Server経由でパラメータの受け渡しをするようにしていますが、単純に実装しようとおもったらCLIとして実装すればよかったかなと、この記事を書いている途中に気付きました。

まぁ、作ってしまったものは仕方がないということで以下簡単な説明です。

ジョブの関係は以下のようになります。

+----------+-----------+
| Machine1 | Machine2  |
+----------+-----------+
|          |           |
| +------+ |           |
| | JOB1 | |           |
| +--+---+ |           |
|    |     |           |
|    +-----------+     |
|    |     |     |     |
| +--+---+ | +---+---+ |
| | JOB2 | | | CALL1 | |
| +------+ | +---+---+ |
|          |     :     |
|          | +=======+ |
|          | | queue | |
|          | +===+===+ |
|          |     :     |
|          | +---+---+ |
|          | | JOB3  | |
|          | +--+----+ |
|          |           |
+----------+-----------+

今回新たに追加されたジョブは CALL1 です、これは jenkins-job-queue をコールするだけのジョブです。httpのPOSTメソッド経由で実行するのでhttpのレスポンスが返ってくればそこでジョブは完了します。 CALL1queue(jenkins-job-queue) の間はジョブの関係がありません。queue はjenkins上のジョブとしては存在していません。なので、ジョブを実行する前にexeを実行しておく必要があります(ここら辺も今後は改善していきたいと思っています)

queueJOB3 との間も httpのリクエストで繋がっていて、jenkinsの JSON APIを使ってジョブを実行します。実装するジョブにパラメータが必要な場合は CALL1 からパラメータを渡すこともできます。

queue の仕事は、先ほども言った通り、最新のジョブをキューにため続けることです。 CALL1 から要求が来るとキューに積まれます。JOB3 のジョブ実行状況を確認し、ジョブが実行されていなければキューのジョブ実行し、キューからジョブを削除します。もしジョブが実行されている場合は実行が完了するまで待ちます。キューにジョブが積まれている状態で CALL1 から新たに要求があればキューのジョブをクリアし、新しい要求をキューに追加します。

jenkins-job-queue の実行方法

インストールは以下でできると思います。Windowsでしか確認していないです。すいません (^_^;)

> go get -u github.com/bamchoh/jenkins-job-queue

$GOPATH\bin 以下に jenkins-job-queue.exe があります。以下のパラメータを指定して実行することができます。

オプション デフォルト 意味
-db 空文字 キュー用のデータベースファイル
-addr :20000 サーバーのアドレス
-bucket MyBucket データベース内の識別子(基本的に気にする必要はないです)

実行例を以下に示します。この例では ポート20000でデータベース test.db を実行ファイルパスと同じ場所に作成して起動します。

> jenkins-job-queue.exe -db test.db

相対パス指定すると実行ファイルパスと同じ場所にデータベースファイルを作成します。あまりこの実装はよくないと思っていて、ここも今後変更の課題となっています。 (^_^;)

CALL1 からの要求方法

HTTPのPOSTメソッドを投げることで要求を送信できます。curl だと以下のようになります。cmd.exe で記述してるので多少みにくくなっています。

> curl http://localhost:20000/job -XPOST -d "{\"title\": \"JOB3\", \"buildURL\": \"http://localhost:8080/job/JOB3/buildWithParameters?token=<token>\", \"user\": \"<username>:<password>\", \"observeURL\": \"http://localhost:8080/job/JOB3/api/json?tree=lastBuild[number],lastCompletedBuild[number],inQueue\", \"parameter\": {\"Output\":\"this is value2\"}}"

URLは <hostname>:<port>/job になります。body のデータはJSON形式で指定する必要があります

{
  "title": "JOB3",
  "buildURL": "http://localhost:8080/job/JOB3/buildWithParameters?token=<build_token>",
  "user": "<username>:<api_token>",
  "observeURL": "http://localhost:8080/job/JOB3/api/json?tree=lastBuild[number],lastCompletedBuild[number],inQueue",
  "parameter": {
    "Output": "this is value2"
  }
}

APIトークンに関しては、以下の記事を参考にしてください。

blog.kyanny.me

JSONに指定するパラメータの意味は以下の通りです。

キー 指定する値
title 要求を識別するためのID
buildURL 実行するジョブ(上記例ではJOB3をパラメータ付きで実行するジョブ)
user トークAPI で使用するuserとAPIトーク
observeURL buildURLが完了したかどうかをチェックするためのURL
parameter buildURLで渡すパラメータ、json objectで渡す

まとめ

恐らく一番苦労するところは JSONを作成するところだと思います。このあたり、もうちょっと改善したいですね。observeURL なんかは buildURLから自動生成できそうですし。なんしか、改善点はあるもののある程度形にできたので発表記事を書かせていただきました。m(_ _)m

相対パスでimportできない場合は go.mod に module 名を書こう

今まで、$GOPATHの外でgoを書いているときにサブパッケージを作って、それをインポートするときに、以下のように指定していた。

import "./hoge"

だけど、最近はこの書き方は推奨されていないらしい。というかエラーになる。

Go Moduleでローカルパッケージを作成する - ソースコードから理解する技術-UnderSourceCode

go 1.11のmodules(vgo)が有効な環境で相対importが cannot find module for path でエラーになった話。 - podhmo's diary

このあたりを参考にすると。 go.modmodule モジュール名 という行を追加して

import "モジュール名/hoge"

とすることで対応可能とのこと。

OpenCV を使って テンプレートマッチング

↓の画像を使って

www.pakutaso.com

↓こういうテンプレート画像を探し出す OpenCVの操作を Python で書いた。

f:id:bamch0h:20200228014912p:plain

import cv2
import numpy as np
from matplotlib import pyplot as plt

img = cv2.imread('cat.png')
template = cv2.imread('cat_face.png')
_, w, h = template.shape[::-1]

res = cv2.matchTemplate(img,template,cv2.TM_CCOEFF_NORMED)
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res)
top_left = max_loc
btm_right = (top_left[0] + w, top_left[1] + h)

cv2.rectangle(img,top_left, btm_right, 255, 2)

cv2.imshow("test", img)
cv2.waitKey(0)
cv2.destroyAllWindows()

こんな感じで表示される。 f:id:bamch0h:20200228015010p:plain

テキストの縁取りをする script-fu 書いた

最近、Youtube でもくもく読書配信をするのが日課になりつつありますが、サムネを作る時に見栄えよくするためにテキストの縁取りをしようと思い立ち、私は昔からGimpを使うことが多いので、サムネもGimpで作っていたのですが、Gimpにはテキストの縁取りをしてくれる機能がないみたいで、それなりに複雑な手順を経て縁取りをしないといけませんでした。今回はその手順をボタン一つでできるように script-fu を書いてみました。

; edging_around_text.scm

(define (script-fu-edging_around_text size color img drawable)
  (let* ((width  (car (gimp-image-width  img)))
         (height (car (gimp-image-height img)))
         (index (car (gimp-image-get-layer-position img drawable)))
         (src-name (car (gimp-layer-get-name drawable)))
         (dst-name (string-append src-name "-縁取り"))
         (theLayer (car (gimp-layer-new img width height RGBA-IMAGE dst-name 100 NORMAL-MODE)))
         (layer-size (car (gimp-image-get-layers img)))
         (layers (cadr (gimp-image-get-layers img)))
         (layer 0)
         (layer-name "")
         (layer-count 0)
         )
    (gimp-image-undo-group-start img)

    ; 縁取りレイヤーがあるなら削除
    (set! layer-count 0)
    (while (< layer-count layer-size)
           (set! layer (aref layers layer-count))
           (set! layer-name (car (gimp-layer-get-name layer)))
           (if (equal? layer-name dst-name)
             (gimp-image-remove-layer 1 layer))
           (set! layer-count (+ layer-count 1))
           )

    (gimp-selection-layer-alpha drawable)
    (gimp-selection-grow img size)
    (gimp-image-add-layer img theLayer (+ index 1))
    (gimp-context-set-foreground color)
    (gimp-edit-fill theLayer FOREGROUND-FILL)

    ; 表示に反映
    (gimp-displays-flush)

    ; ここまでがアンドゥの対象
    (gimp-image-undo-group-end img)
    ))

; ******** ******** ******** ********

(script-fu-register
  "script-fu-edging_around_text"
  "<Image>/Script-Fu/縁取り"
  "前景色でテキストの縁取りをする" ; このスクリプトの説明
  "bamchoh"     ; 作者名
  "(c)2020"    ; コピーライト
  "2020-02-23" ; 日付
  "RGB*, GRAY*, INDEXED*"  ; スクリプトが動作可能なモード
  SF-ADJUSTMENT "Size"     '(15 1 1000 1 10 0 SF-SPINNER)
  SF-COLOR      "Color"    '(0 0 0)
  SF-IMAGE      "Image"    0 ; 引数1
  SF-DRAWABLE   "Drawable" 0 ; 引数2
  )

使い方としては、上記のスクリプト%APPDATA%\GIMP\2.10\scripts に 好きな名前で保存してもらってから Gimpを起動して Filters > Script-Fu > Reflesh Scripts を押してください。

f:id:bamch0h:20200223025425p:plain

そのあと、何か文字を画面においてもらって

f:id:bamch0h:20200223025244p:plain

メニュー画面の Script-Fu > 縁取り をクリックすると

f:id:bamch0h:20200224021053p:plain

こんな感じの画面になるので、サイズと色と縁取りしたいレイヤーを選んでOKを押してください。

f:id:bamch0h:20200224024121p:plain

それを押してもらうと選んだ色とサイズで縁取りされます。

f:id:bamch0h:20200223025622p:plain

今後の課題

  • 前景色以外の色でも設定できるようにしたい。