2011年3月8日火曜日

Processing によるデータ視覚化

キーボードとマウス

Processing は簡単にデータを視覚化できるようにしているだけでなく、マウスおよびキーボードからのユーザー入力もサポートします。Processing がユーザー入力のサポートを可能にしている手段は、ユーザーが入力したことを Processing プログラムに通知する、一連の関数とコールバックです。

キーボード・イベント

Processing には、キーが押されたこと、またはリリースされたことを Processing アプリケーションに通知するためのキーボード関数がいくつか用意されています。さらに、ユーザーが特殊文字を使用できる場合には、その入力を詳しく構文解析することも可能です。

キー押下イベントがあったことを伝えるには、keyPressed 関数を使用します。この関数をアプリケーションに定義すると、キー押下イベントが発生するたびに、この関数が呼び出されます。ユーザーが押した実際のキーを識別するには、このコールバック関数の中で key という特殊な変数を使用します。同じように、キーがリリースされた場合のイベントも keyReleased 関数を使用することで捕捉することができます。どちらの関数も同じ情報を提供しますが、アクションをトリガーするタイミングは自由に定義できるようになっています。

リスト 1 に、keyPressed 関数と keyReleased 関数を記載します。いずれの関数の中でも、プログラムがユーザーによる 2 つのタイプのキー・ストロークを解析します。2 つのタイプとは、ASCII 文字と非 ASCII 文字 (矢印キーなど) のことで、前者はコード化されませんが、後者の場合はコード化が必要です。コード化された文字に対しては、Processing は key 変数を CODED トークンに設定することによって、keyCode というもう 1 つの特殊な変数を調べるように指示します。したがって、key  CODED でなければ、key 変数にキー・ストロークが含まれるということです。一方、key が CODEDに設定されている場合は、keyCode 変数に実際の文字 (UPDOWNLEFTRIGHTALTCONTROL、または SHIFT) が含まれます。


リスト 1. keyPressed および keyReleased コールバック
void keyPressed() {   if (key == CODED) {     if (keyCode == DOWN) println("Key pressed: Down arrow");     if (keyCode == SHIFT) println("Key pressed: Shift key");   } else {     println("Key pressed: " + key );   } }  void keyReleased() {   if (key == CODED) {     if (keyCode == DOWN) println("Key released: Down arrow");     if (keyCode == SHIFT) println("Key released: Shift key");   } else {     println("Key released: " + key );   } } 

Processing 関数の中で使用できる特殊な変数には、keyPressed という変数もあります。keyPressed 関数はブール値を返して、押されているキーがあること (true)、またはどのキーも押されていないこと (false) を示します。この変数を使用することで、Processing アプリケーション内 (通常のコールバック構造の外部) で発生したキー・イベントを管理することができます。

マウス・イベント

マウス・イベントはキーボード・イベントと同様の構造に従いますが、キーボード・イベントとは異なり、発生する可能性のあるさまざまなマウス・ベースのイベントをサポートするために、複数の関数があります。マウス・イベントに定義できる基本的なコールバックは、以下の 4 つです。

  • mousePressed
  • mouseReleased
  • mouseMoved
  • mouseDragged

mousePressed コールバック関数は、ユーザーがマウス・ボタンを押下すると呼び出されます。どのマウス・ボタンが押下されたのかは、このコールバック関数に含まれる mouseButton 変数 (LEFTCENTERRIGHT) によって特定することができます。そしてマウス・ボタンがリリースされると、mouseReleased コールバック関数が呼び出されます。mousePressed  mouseReleased を組み合わせたコールバック関数 mouseClicked を使用することもできます。mouseMoved 関数は、マウス・ボタンが押下されていない状態でマウスが動かされると呼び出されます。最後の mouseDragged 関数は、マウス・ボタンが押下された状態でマウスが動かされると呼び出されます。

マウス・イベントにも、数々の特殊な変数を使用することができます。まず、mouseX および mouseY 変数にはマウスの現在の位置が取り込まれます。マウスが移動する前の位置を (前のフレームから) 取り込むには、pmouseX 変数と pmouseY 変数を使用します。また、マウス・ボタンが現在クリックされているかどうかを検出するには、Processing アプリケーション内で mouseClicked 変数を使用することができます。この変数を mouseButton と組み合わせれば、現在クリックされているボタンを特定することもできます。リスト 2 に、マウス・イベントのコールバックと特殊変数を記載します。


リスト 2. 基本的なマウス・イベント・コールバックのデモ
int curx, cury;  void setup() { size(100, 100);   curx = cury = 0; }  void mousePressed() {   println( "Mouse Pressed at " + mouseX + " " + mouseY );   if (mousePressed && (mouseButton == LEFT)) {     curx = mouseX;     cury = mouseY;   } }  void mouseReleased() {   println( "Mouse Released at " + mouseX + " " + mouseY ); }  void mouseMoved() {   println( "Mouse moved, now at " + mouseX + " " + mouseY ); }  void mouseDragged() {   println( "Mouse moved from " + curx + " " + cury +             " to " + mouseX + " " + mouseY ); } 

以上で説明したマウス・イベントとキーボード・イベントの関数が、UI を作成する際の基礎となります。オブジェクトをディスプレイ・ウィンドウに配置した後、マウス・イベント関数を使用して、マウス・ボタンが押されたかどうかを判断することができます。つまり、マウス・ボタンが押されたときに、オブジェクトの領域内にあるピクセルがマウスのカーソルであったかどうかを識別できるということです。

オブジェクト指向プログラミング

Processing では、オブジェクト指向プログラミング (OOP: Object-Oriented Programming) の手法を利用して、アプリケーションの開発を単純化し、さらにアプリケーションを保守しやすくすることができます。Processing 自体はオブジェクト指向ですが、オブジェクトの概念を無視したアプリケーションを開発することも可能です。けれども、OOP には情報の非表示、モジュール性、そしてカプセル化という利点があるため、OOP を使用することには重要な意味があります。

Processing は他のオブジェクト指向の言語と同じく、Class の概念を使用してオブジェクト・テンプレートを定義します。クラスから定義されたオブジェクトが保持するのは、データとそのデータに対して実行できる操作の一式です。これを説明するため、まずは単純なクラスを開発するところから始めます。その後、この例を展開させて、開発したクラスに複数のオブジェクトを組み込みます。

Processing でのクラスは、データとそのデータに適用される関数 (またはメソッド) の一式を定義します。この例で引用するデータは、2 次元空間の座標 (x、y) と直径によって、中心と大きさが指定される円のデータです。この円は、x 座標と y 座標、そして直径として 1 を指定して初期化することができますが、この場合を単に「円が指定されている」と言うことにします。円を初期化するための情報は、init 関数を使用して指定します。新たに描画される円は、前に描画された円よりも大きい円になります。直径がゼロより大きければ (円が指定されているオブジェクトであることを意味します)、その直径を増分して円のサイズを大きくします。直径を増分するのは、spread 関数です。そして最後に、show 関数が円をディスプレイ・ウィンドウに描画します。直径がゼロより大きい限り (つまり、有効な円である限り)、ellipse 関数を使用して円を作成し続け、円が特定の大きさになった時点で直径をゼロに設定し、円を取り消します。このサンプル・クラスは、リスト 3 のとおりです。


リスト 3. 新たに描画される円が前に描画された円よりも大きくなる、円 (ドロップ) のサンプル・クラス
class Drop {    int x, y;		// Coordinate (center of circle)   int diameter;		// Diameter of circle (unused == 0).      void init( int ix, int iy ) {    x = ix;    y = iy;    diameter = 1;   }      void spread() {     if (diameter > 0) diameter += 1;   }      void show() {     if (diameter > 0) { ellipse( x, y, diameter, diameter );       if (diameter > 500) diameter = 0;     }   }    } 

今度は、この Drop クラスを使用して、ユーザー入力を用いたグラフィックを作成する方法を見てみましょう。リスト 4 に、Drop クラスを使用したアプリケーションを記載します。最初のステップは、ドロップの配列 (drops という名前の配列) を作成することです。配列を作成した後には、いくつかの定義が続きます (ドロップの数と、現在対象としているドロップの (drops 配列における) インデックス)。setup 関数では、ディスプレイ・ウィンドウを作成して drops 配列を初期化します (すべての直径がゼロに設定されます。つまり円が指定されていません)。ドロップのコア機能はこのクラス自体の中に含まれるため (リスト 3 の spread 関数と show 関数を参照)、draw 関数はかなり単純な内容になっています。最後に UI 部分を追加して、ユーザーがドロップの開始位置を定義できるようにします。具体的には、mousePressed コールバック関数がマウスの現在の位置の情報を使ってドロップを初期化した後 (これで、ドロップには直径が設定され、使用されることになります)、現在対象としているドロップを表すインデックスをインクリメントするという内容です。


リスト 4. 複数のユーザー定義ドロップを作成するアプリケーション
Drop[] drops; int numDrops = 30; int curDrop = 0;  void setup() { size(400, 400); ellipseMode(CENTER); smooth();   drops = new Drop[numDrops];   for (int i = 0 ; i < numDrops ; i++) {     drops[i] = new Drop();     drops[i].diameter = 0;   } }  void draw() { background(0);   for (int i = 0 ; i < numDrops ; i++) {       drops[i].spread();       drops[i].show();   } }  void mousePressed() {   drops[curDrop].init( mouseX, mouseY );   if (++curDrop == numDrops) curDrop = 0; } 

図 1 に、リスト 3 リスト 4 によるアプリケーションの出力を示します。ご覧のように、マウスが何度もクリックされたことによって、大きさの異なるドロップがいくつも描画されています。


図 1. リスト 3 および 4 によるアプリケーションのディスプレイ・ウィンドウ
黒い背景にさまざまなサイズの白い円が重なって描画され、雲のような形を呈しています。 

画像処理

Processing には、画像処理に役立つ興味深い機能が用意されています。このセクションでは、画像のフィルター、合成、そしてピクセルを使ったユーザー定義の画像処理のサポートについて詳しく説明します。

画像のフィルター

Processing は、filter 関数を介して、あらかじめ用意されている画像処理機能を提供します。この filter 関数は、フィルターのモードを直接ディスプレイ・ウィンドウに適用する関数です。リスト 5 に、フィルターを使用した単純な Processing アプリケーションを示します。図 2 に示すさまざまなタイプの出力は、このアプリケーションに関連する出力で、リスト 5 では BLUR のフィルターを適用しているだけですが、図 2 にはその他にも適用できるフィルターを指定したときの画像が (対応するコードと一緒に) 示されていることに注意してください。

filter 関数はディスプレイ・ウィンドウの表示内容に直接作用するため、この関数を使用するときに指定する必要があるのは、フィルターのモード (適用するフィルターのタイプ) と画質 (つまり、フィルターのモードに対する引数) だけです。リスト 5 は、画像を保管するためのデータ型である PImage 型の変数を宣言するところから始まっています。これに続き、setup 関数の中で特定の画像をPImage データ型の変数 (img1) にロードします。画像がロードされた時点で、画像のサイズが既知となるため、その画像のサイズを使ってウィンドウのサイズを設定します (PImage インスタンスの width および height 属性を使用)。続く draw 関数の中で、image 関数を呼び出して画像を表示します。image 関数は画像の左上隅の x 座標と y 座標を指定して、画像をディスプレイ・ウィンドウに表示するように要求します (画像の幅と高さを指定することも可能です)。そして最後に、フィルターを指定して適用するという流れです。この例では、BLUR モードを指定していますが、図 2 には他のフィルターを指定したときの画像も示してあるので、オリジナルの画像と比べてみてください。

※訳注: 上記段落の原文の最後にある「(also provided in Figure 5 below)」は、誤りであり、訳出すると読者が混乱すると思われるので訳出していません。


リスト 5. 単純なフィルター・アプリケーション
PImage img1;  void setup() {   img1 = loadImage("alaska1.png"); size(img1.width, img1.height); smooth(); }  void draw() { image(img1, 0, 0); filter(BLUR, 2); } 

図 2 に示されているように、Processing には、画像を操作するアプリケーションで一般的に使われている画像処理の操作があらかじめ用意されていますが、ピクセル単位で画像を操作することもできます。


図 2. フィルター操作の例
オリジナルの画像と、その画像に BLUR、GRAY、INVERT など、さまざまな種類のフィルターを適用した画像 

上記に示されていないフィルターの種類には、以下のものがあります。

  • OPAQUE — アルファ・チャネルを不透明に設定します。
  • ERODE — 指定された画質パラメーターに応じて明るい部分を縮小します。
  • DILATE — 指定された画質パラメーターに応じて明るい部分を拡大します。

フィルターのその他のモードについては、「参考文献」セクションを参照してください。

画像の合成

Processing では画像を合成することができます。合成は、各画像 (または必要な場合には画像の部分的領域) のピクセルごとに行われます。この機能は、Adobe® Illustrator® や Photoshop® で使用されている機能を真似たものです。

リスト 6 に、ADD を指定して 2 つの画像を合成する操作を示します (図 3 を参照)。このリストでは、loadImage 関数によって 2 つの画像をロードした後、ADD モードを使用して img2  img1 に合成しています。blend 関数を呼び出す場合、対象となる合成先の画像 (img1) に対して合成する画像 (img2) を最初に指定します。その後に続ける 4 つのパラメーターは、合成する画像 (img2) の x 座標、y 座標、幅、高さです。さらに、合成先の画像 (img1) の左上隅の x 座標、y 座標、幅、高さのパラメーターを続けます。そして最後に定義するのが、モード・パラメーターです。この例で要求している ADD による合成では、dest_img_pixel += src_img_pixel*factor (ただし、dest_img_pixel の値が 255 を超えた場合は、dest_img_pixel の値は 255 とする) という操作を実行しています。


リスト 6. 画像の合成
void setup() { size(237, 178); smooth(); }  void draw() { PImage img1 = loadImage("alaska1.png"); PImage img2 = loadImage("alaska2.png");   img1.blend( img2, 0, 0, 237, 178, 0, 0, 237, 178, ADD ); image(img1, 0, 0); } 

この他にも、BLEND (dest_img_pixel の値は上限なし)、SUBTRACTDARKEST (dest_img_pixel  src_img_pixel*factor のうち、暗い方の色を採用)、LIGHTEST (dest_img_pixel  src_img_pixel*factor のうち、明るい方の色を採用)、MULTIPLY (画像が暗くなります) をはじめ、数々の操作を行うことができます。「参考文献」セクションに合成のモードに関するリンクを記載してあります。


図 3. 合成操作の結果の画像
オリジナルの画像として、入江の風景の画像と、かもめの画像、そしてこの 2 つを合成した画像が示されています。 

pixels 配列

最後に紹介する画像処理の方法は、よりマニュアルでの操作に近い手法です。このモードでは、ピクセルごとに個別の操作をすることができます。ディスプレイ・ウィンドウは、color 型からなる 1 次元の配列で構成されているため、(リスト 7 に示されているように、background 関数を使用して) 画像を表示した後 、loadPixels 関数を使用することによって、pixels 配列に含まれるディスプレイ・ウィンドウのピクセルにアクセスすることができます。loadPixels 関数はディスプレイ・ウィンドウを pixels 配列内にロードする一方、updatePixels 関数は pixels 配列を基にディスプレイ・ウィンドウを更新します。


リスト 7. ピクセル・マップを使った画像の操作
void setup() { size(237, 178); smooth(); }  void draw() { PImage img = loadImage("alaska2.png"); background(img); loadPixels();   for (int i = 0 ; i < img.width*img.height ; i++) {     color p = pixels[i];     float r = red(p)/2;     float g = green(p);     float b = blue(p); pixels[i] = color(r, g, b);   } updatePixels(); } 

ディスプレイ・ウィンドウは pixels 配列の中にありますが、これを操作するにはさまざまな手段を使うことができます。リスト 7 に示されているのは、最初に各ピクセルから color 型 (変数 p) を作成し、それを使って表示を変更する方法です。この変数は、redgreenblue 関数を使用することによって、さらに RGB それぞれの値に分解することができます。上記の例では、ディスプレイ・ウィンドウに表示された画像の赤の要素の値を半分にしてから、color 関数を使ってピクセルを再ロードしています。これは、RGB それぞれの色を取得して、ピクセルを再構成する関数です。図 4 に、リスト 7 を実行する前と実行した後の画像を示します。


図 4. pixels 配列を使用したマニュアルでの画像操作
オリジナルのかもめの画像と、赤の要素が減らされた後の青くなった画像が示されています。 

粒子群

このセクションでは、Processing (具体的には OOP (Object Oriented Programming: オブジェクト指向プログラミング)) の機能のいくつかを実演するアプリケーションを紹介します。このサンプル・アプリケーションは、数値最適化および機械学習の分野で使用されるアプリケーションです。

粒子群とは、自然から発想を得た最適化手法です。この手法では、探索空間のなかで見つかった最適解 (粒子自体の最適解と大域的最適解の両方) に従って移動する解候補 (粒子) の集団を使用します。単純ながらも、探索空間の興味深い視覚表現を提供する粒子群最適化 (PSO: Particle Swarm Optimization) は、データを視覚化するための言語 (詳細は「参考文献」を参照) で探索する際に使用するには最適な手法となります。粒子群は、2 次元空間を移動して大域的最適解を探索するからです。

最適化の手法

粒子群は、新しくて興味深い最適化手法で、関数最適化に役立つとともに、視覚化を行うための興味深い手法でもあります。粒子群と同じような機能を持つ最適化手法は他にも多数あり、例としては、蟻の集団を使って経路探索問題を解決する蟻コロニー最適化、一般的な問題の解決に解候補の集団を使用する遺伝的アルゴリズムなどが挙げられます。

PSO の実装

PSO の Processing 実装は、2 つのクラスからなります。その 1 つは個々の粒子を実装する Particle というクラスです。Particle クラスでは、PSO に従い、各粒子はその現在の位置、現在の速度、現在の適応度、最大適応度、および粒子の最適解を維持します (リスト 8 を参照)。Particle クラスが PSO をサポートするために提供する数々のメソッドには、コンストラクター (探索空間に粒子を不規則に配置)、適応度を計算する関数 (この例に示す calculateFitness 関数では、sombrero 関数に基づいて計算を行っており、z が適応度を表しています)、update 関数 (粒子をその現在の速度ベクトルに基づいて移動するための関数)、そして show 関数 (粒子を探索空間に表示するための関数) があります。さらに、粒子の要素 (適応度、x 位置、y 位置) をユーザーに公開する 3 つのヘルパー関数もあります。


リスト 8. PSO の Particle クラス
class Particle {    float locX, locY;   float velX = 0.0, velY = 0.0;   float fitness = 0.0;   float bestFitness = -10.0;   float pbestX = 0.0, pbestY = 0.0; // Best particle solution   float vMax = 10.0; // Max velocity   float dt = 0.1;    // Used to constrain changes to each particle    Particle() {     locX = random( dimension );     locY = random( dimension );   }    void calculateFitness() {     // Clip the particles     if ((locX < 0) || (locX > dimension) ||          (locY < 0) || (locY > dimension)) fitness = 0;     else {       // Calculate fitness based on the sombrero function.       float x = locX - (dimension / 2);       float y = locY - (dimension / 2);       float r = sqrt( (x*x) + (y*y) );       fitness = (sin(r)/r);     }       // Maintain the best particle solution     if (fitness > bestFitness) {       pbestX = locX; pbestY = locY;       bestFitness = fitness;     }   }      void update( float gbestX, float gbestY, float c1, float c2 ) {     // Calculate particle.x velocity and new location     velX = velX + (c1 * random(1) * (gbestX - locX)) +                    (c2 * random(1) * (pbestX - locX));     if (velX > vMax) velX = vMax;     if (velX < -vMax) velX = -vMax;     locX = locX + velX*dt;      // Calculate particle.y velocity and new location     velY = velY + (c1 * random(1) * (gbestY - locY)) +                    (c2 * random(1) * (pbestY - locY));     if (velY > vMax) velY = vMax;     if (velY < -vMax) velY = -vMax;     locY = locY + velY*dt;   }      void show() { point( (int)locX, (int)locY);   }      float pFitness() {     return fitness;   }      float xLocation() {     return locX;   }      float yLocation() {     return locY;   }  } 

もう 1 つのクラスは、粒子群を対象とした Swarm というクラスです (リスト 9 を参照)。このクラスは、Particles の配列 (粒子群コンストラクターで作成され、初期化されたもの)、現在の大域的最適解 (x 座標と y 座標)、そして 2 つの学習因数で構成された配列を保持します。学習因数は、粒子の移動 (探索) の中心が自己最適解 (c2) または大域的最適解 (c1) のどちらに置かれているのかの評価基準となります。因数のそれぞれが、最適解が粒子に及ぼす影響を示します。

run メソッドは、PSO シミュレーションにおける 1 つのステップを実行します。まず、このメソッドは粒子群に含まれる粒子ごとの適応度を計算し、それから大域的最適解を見つけます。そしてこの情報を指定して update 関数を呼び出して粒子を移動した後、show関数を呼び出して、粒子群に含まれる各粒子の移動結果を表示します。


リスト 9. PSO の Swarm クラス
class Swarm {    float gbestX = 0.0, gbestY = 0.0;   // Global best solution   float c1 = 0.1, c2 = 2.0;           // Learning factors    Particle swarm[];    Swarm() {     swarm = new Particle[numParticles];     for (int i = 0 ; i < numParticles ; i++) {       swarm[i] = new Particle();     }   }      void run() {     // Calculate each particle's fitness     for (int i = 0 ; i < numParticles ; i++) {       swarm[i].calculateFitness();     }          findGlobalBest();          // Update each particle and display it.     for (int i = 0 ; i < numParticles ; i++) {       swarm[i].update( gbestX, gbestY, c1, c2 );       swarm[i].show();     }   }      void findGlobalBest() {     float fitness = -10.0;     for (int i = 0 ; i < numParticles ; i++) {       if (swarm[i].pFitness() > fitness) {         gbestX = swarm[i].xLocation(); gbestY = swarm[i].yLocation();         fitness = swarm[i].pFitness();       }     }   }      void showGlobalBest() {     println("Best Particle Result: " + gbestX + " " + gbestY);   }    } 

リスト 10 に記載するのは、上記で定義したクラスを使用したユーザー・アプリケーションです。このアプリケーションは、粒子の数、ディスプレイ・ウィンドウのサイズ (dimension)、そして粒子群自体など、PSO で構成可能な項目のいくつかを定義しています。setup関数がウィンドウおよび色を準備すると、draw 関数が粒子群を呼び出して、大域的最適解を 10 回の繰り返しごとに出力します。このシミュレーションでは sombrero 関数を使って最適化を行うため、最適条件がディスプレイの中心となります。


リスト 10. PSO を駆動するアプリケーション
// Particle Swarm Optimization int numParticles = 200; int iteration = 0; float dimension = 500; Swarm mySwarm = new Swarm();  void setup() { background(255); fill(0); size( int(dimension), int(dimension)); smooth(); }  void draw() { background(255); // remove for trails   mySwarm.run();   if ((iteration++ % 10) == 0) mySwarm.showGlobalBest(); } 

以下に記載する 2 つの図に、Processing で実行した PSO シミュレーションの出力を示します。図 5 には、PSO の時間的推移を示しています。その後の図 6 に示す PSO の軌跡が、最適条件に至るまでの粒子の経路を明らかにします。


図 5. PSO シミュレーションの時間的推移
時間マーカーが付けられた一連の画像が示されています。初めのうちは散在しているドットが、時間が経つにつれ、中央に集まってきています。 

図 6 に示す軌跡からは、粒子の経路が中央の最適解に集まってきている様子が明らかに見て取れます。一部の経路はループしていることがわかりますが、これは、粒子が自己の最適解に群がってから、大域的最適解へと移動していることを示しています。


図 6. PSO シミュレーションの軌跡
白い背景の中央で爆発したかのように黒い軌跡が中央から周囲に放射状に広がっている様子を示す図 

アプリケーションの変換

Processing コードは Java 言語に変換されてから実行されます。そのため、Processing アプリケーションを Java アプレットや Java アプリケーションに変換するのは簡単です。この変換を行うには、Processing 開発環境 (PDE) で「File (ファイル)」をクリックしてから、「Export (エクスポート)」をクリックしてアプレットをエクスポートするか、「Export Application (アプリケーションのエクスポート)」をクリックして Java アプリケーションをエクスポートします。すると、sketchbook ディレクトリーにコードと、この操作に関連するファイルが生成されます。リスト 11 には、エクスポートされたアプレット (applet サブディレクトリー)、エクスポートされたアプリケーション (この特定ターゲットの 3 つの application ディレクトリー)、そしてソース自体 (pso.pde) が示されています。


リスト 11. エクスポート後の Processing の sketchbook サブディレクトリー
mtj@ubuntu:~/sketchbook/pso$ ls applet  application.linux  application.macosx  application.windows  pso.pde 

applet サブディレクトリー内にオリジナルの処理ソース、変換後の Java ソース、JAR ファイル、およびサンプル index.html ファイルが置かれているので、変換結果を確認することができます。

0 件のコメント:

コメントを投稿