画像バイナリデータからMIMEタイプを判定する。

Java で、画像バイナリデータ byte[] の状態のデータから image/jpeg などのタイプを調べます。
画像 File であれば、java.nio.file.Files probeContentType(Path) を使えば良いのですが、
バイナリデータの状態から検査したい場合が稀にあるでしょう。

データの先頭8バイトをチェックすれば済みます。
ついでに画像の width と height も取得します。

import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import javax.imageio.ImageIO;

public static Map<String, String> getImageInfo(byte[] b) throws IOException{
   Map<String, String> r = new HashMap<>();
   try(ByteArrayInputStream bin = new ByteArrayInputStream(b)){
      BufferedImage bimg = ImageIO.read(bin);
      r.put("width", Integer.toString(bimg.getWidth()));
      r.put("height", Integer.toString(bimg.getHeight()));
   }
   StringBuilder sb = new StringBuilder();
   for(int i=0;i < 8;i++){
      sb.append(String.format("%02x", b[i]));
      if (sb.toString().equals("ffd8")){
         r.put("type", "image/jpeg");
         break;
      }
      if (sb.toString().equals("424d")){
         r.put("type", "image/bmp");
         break;
      }
      if (sb.toString().equals("47494638")){
         r.put("type", "image/gif");
         break;
      }
      if (sb.toString().equals("49492a00")){
         r.put("type", "image/tiff");
         break;
     }
      if (sb.toString().equals("89504e470d0a1a0a")){
         r.put("type", "image/png");
      }
   }
   return r;
}

File を指定しても同じメソッドをcall するようにラップしておくと良いでしょう。

public static Map<String, String> getImageInfo(File file) throws IOException{
   try(InputStream in = new FileInputStream(file);
        ByteArrayOutputStream out = new ByteArrayOutputStream()){
      in.transferTo(out);
      out.flush();
      return getImageInfo(out.toByteArray());
   }
}

Wicket RequestCycle から getResponse した OutputStream で注意すべき点

Wicket 8.2.0 までは、Wicket RequestCycle
即ち、WebPage上で getRequestCycle().getResponse()
で取得する org.apache.wicket.request.Responseから
取得する OutputStream に書き込むことで、
HTML でないものをレスポンス応答することに問題は発生しなかった。
しかし、Wicket 8.3.0 以上のバージョンでは、
以下のように、JPEG画像を返す WebPage として構築すると、、
  flower.jpg を resouce上に置いたとして、

String classesPath = ((WebApplication)getApplication()).getServletContext().getRealPath("WEB-INF/classes");
File imagefile = new File(classesPath + "/img/flower.jpg");

OutputStream を RequestCycle から getResponse を介して取得して、

WebResponse webresponse = (WebResponse)getRequestCycle().getResponse();
webresponse.setContentType("image/jpeg");
try(InputStream in = new FileInputStream(imagefile)){
       OutputStream out = getRequestCycle().getResponse().getOutputStream();
       in.transferTo(out);
       out.flush();
}catch(Exception ex){
       logger.error(ex.getMessage(), ex);
}

このように画像を送ると、
重大: サーブレット [default] のServlet.service()が例外を投げました
java.lang.IllegalStateException: レスポンスがコミットされた後でsendError()を呼び出すことはできません

となってしまう。
Wicket 8.3.0 以降 WebFilter の処理が変わったことを示しており、
このように直接 RequestCycle から取得する OutputStream に出力したのでは順序が遅いようだ。
解決策、、、
Wicket 8.3.0~ 以降では、、
RequestCycle の scheduleRequestHandlerAfterCurrent を使用して
org.apache.wicket.request.IRequestHandler の
  respond(IRequestCycle requestCycle) 
ラムダ式で、IRequestCycle 即ち、RequestCycle から取得する OutputStreamで出力するようにする。

上の imagefile 出力は、以下のように書く。

getRequestCycle().scheduleRequestHandlerAfterCurrent(c->{
   WebResponse ws = (WebResponse)c.getResponse();
   ws.setContentType("image/jpeg");
   try(InputStream in = new FileInputStream(imagefile)){
      OutputStream out = c.getResponse().getOutputStream();
      in.transferTo(out);
      out.flush();
   }catch(Exception ex){
      logger.error(ex.getMessage(), ex);
   }
});

Wicket 8.3.0 から、こう書かないとダメだ。

AjaxFileDropBehavior 利用をラムダで書くようにする

先日の Python 画像加工結果→Javaで受信→WebPage表示 - Oboe吹きプログラマの黙示録
僅かだがスマートに記述するために、Throwable で Serializable な BiConsumer を用意して
AjaxFileDropBehavior の継承を用意しました。
Throwable で Serializable な BiConsumer
yipuran-wicketcustom/SerialThrowableBiConsumer.java at master · yipuran/yipuran-wicketcustom · GitHub

AjaxFileDropBehavior の継承
https://github.com/yipuran/yipuran-wicketcustom/blob/master/src/main/java/org/yipuran/wicketcustom/ajax/AjaxFileDropUpdateBehavior.java

これにより、先日のドラッグ&ドロップによるファイルアップロードは、
ドロップしたファイルのサイズ(width, height)取得とリサイズのメソッドを外に出して
画像だけに制限して以下のように記述できます。

image.add(AjaxFileDropUpdateBehavior.of(
fu->Pattern.compile("^image/(jpeg|png|gif)$").matcher(fu.getContentType().toLowerCase()).matches()
, (t, flist)->{
   FileUpload fu = flist.get(0);
   imageName = fu.getClientFileName();
   try(InputStream in = fu.getInputStream();ByteArrayOutputStream out = new ByteArrayOutputStream()){
      in.transferTo(out);
      // 画像サイズ
      Map<String, Integer> smap = getScale(fu);
      Map<String, Integer> rmap = resize(smap, 420);
      imageWidth = smap.get("width").toString();
      imageHeight = smap.get("height").toString();
      
      // TODO 処理...

   }
}, (t, x)->{
   t.appendJavaScript("alert('Error')");
   logger.warn(x.getMessage(), x);
}));
queue(image);

Python 画像加工結果→Javaで受信→WebPage表示

画像URL → Python 処理に渡す。。。
Python PILLOW で画像加工
→ 結果をPython 実行の標準出力で出力
→ この Python 処理を Java のプロセス起動で実行して
 結果標準出力をストリームで受け取る。
→ Webページで表示
という流れのシナリオで一切画像ファイルでサーバ上のファイルシステム(ディスク)上には
置かせないというサンプルです。

Python 処理を Java のプロセス起動で実行するのは、以下を使用します。
https://github.com/yipuran/yipuran-core/wiki/Script_exec

Wicket を使用します。
Wicket WebPage で持つ変数として以下を用意します。

public String imageName = "";
public String imageWidth = "";
public String imageHeight = "";
public Integer reWidth;
public Integer reHeight;
static String TargetImage = "targetImage";
protected Key resultkey = null;
protected String imagetype = "JPEG";

ドラッグエリアは、初期画像:img タグとして以下 ResourceReferenceを定義します。

ResourceReference initImageRefer = new PackageResourceReference(SamplePage.class, "noimage.jpg");
final Image image = new Image("target", initImageRefer);
image.setOutputMarkupId(true);

AjaxFileDropBehavior をドラッグ&ドロップで実行する処理として、img タグにビヘビアとして追加します。
アップロード実行されて読み込んだ結果の処理なので結構やるべきことがいっぱいあります。

image.add(new AjaxFileDropBehavior(){
   // 画像リファレンスKey
   private Key key = initImageRefer.getKey();
   @Override
   protected void onFileUpload(AjaxRequestTarget target, List<FileUpload> files){
      Optional.ofNullable(files).ifPresent(flist->{
         if (flist.size() > 0){
            FileUpload fu = flist.get(0);
            imageName = fu.getClientFileName();
            if (!imageavailable(fu)){
               target.appendJavaScript("alert('Not Image file');");
               return;
            }
            // upload File read ⇒ byte[] ⇒ ByteArrayResource  ⇒ 画像リファレンス差し替え
            try(InputStream in = fu.getInputStream();
                ByteArrayOutputStream out = new ByteArrayOutputStream()){

               in.transferTo(out);
               // 画像表示サイズ調整 → width:420以下に抑制
               Map<String, Integer> smap = getScale(fu);
               Map<String, Integer> rmap = resize(smap, 420);
               imageWidth = smap.get("width").toString();
               imageHeight = smap.get("height").toString();
               reWidth = rmap.get("width");
               reHeight = rmap.get("height");
               // 画像リファレンス差し替え
               getApplication().getSharedResources().remove(key);
               getApplication().getSharedResources()
               .add(TargetImage, new ByteArrayResource("image/jpeg", out.toByteArray()));
               ResourceReference reference = new SharedResourceReference(TargetImage);
               key = reference.getKey();
               image.setImageResourceReference(reference);
               target.add(image);
               target.add(info);
               String jscript
 = "$('#target').css('width','"+rmap.get("width")+"px'),$('#target').css('height','"+rmap.get("height")+"px');";
               target.appendJavaScript(jscript);
               target.appendJavaScript("$('#convert').prop('disabled',false);");
            }catch(Exception ex){
               logger.error(ex.getMessage(), ex);
            }
         }
      });
   }
   protected boolean imageavailable(FileUpload fu){
      String c = fu.getContentType();
      if (c.equals("image/jpeg")){
         imagetype = "JPEG";
         return true;
      }
      if (c.equals("image/png")){
         imagetype = "PNG";
         return true;
      }
      if (c.equals("image/gif")){
         imagetype = "GIF";
         return true;
      }
      return false;
   }
   protected Map<String, Integer> getScale(FileUpload f) throws Exception{
      Map<String, Integer> r = new HashMap<>();
      try(InputStream in = f.getInputStream()){
         BufferedImage bimg = ImageIO.read(in);
         r.put("width", bimg.getWidth());
         r.put("height", bimg.getHeight());
         return r;
      }
   }
   protected Map<String, Integer> resize(Map<String, Integer> m, int widthmax){
      int width = m.get("width");
      if (width > widthmax){
         Map<String, Integer> r = new HashMap<>();
         r.put("width", widthmax);
         BigDecimal k = new BigDecimal(widthmax).divide(new BigDecimal(width), 8, RoundingMode.HALF_UP);
         r.put("height", new BigDecimal(m.get("height")).multiply(k).setScale(0, RoundingMode.HALF_UP).intValue());
         return r;
      }
      return m;
   }
});
queue(image);

表示する画像は、ResourceReference で宣言、
setOutputMarkupId(true) でPythonで画像加工後に、表示更新できるようにしておきます。

final Image result = new Image("result", new PackageResourceReference(Sample.class, ""));
result.setOutputMarkupId(true);
queue(result);

Python に渡す URL です。→ 今回のサンプルは自分のサイトです。。

String url = "http://xxxxx/example/wicket/resource/org.apache.wicket.Application/" + TargetImage;

ボタンによる Python 実行→ 結果受け取り画面表示処理

queue(new Button("convert").add(AjaxEventBehavior.onEvent("click", t->{
   int sts = ScriptExecutor.runStream(()->"python " + pyscript + "/image/img_invert.py "
   , ()->Arrays.asList(url, "\n", imagetype, "\n\n")
   , inst->{
      try(ByteArrayOutputStream out = new ByteArrayOutputStream()){
         inst.transferTo(out);
         out.flush();
         if (resultkey != null) getApplication().getSharedResources().remove(resultkey);
         getApplication().getSharedResources()
            .add("resultgaus", new ByteArrayResource("image/jpeg", out.toByteArray()));
         ResourceReference reference = new SharedResourceReference("resultgaus");
         resultkey = reference.getKey();
         result.setImageResourceReference(reference);
         t.add(result);
         String jscript = "$('#result').css('width','"+reWidth+"px'),$('#target').css('height','"+reHeight+"px');";
         t.appendJavaScript(jscript);
      }catch(Exception ex){
         throw new RuntimeException(ex);
      }
   }, (e, x)->{
      logger.error(e);
      logger.error(x.getMessage(), x);
   });
})));

JavaScript として用意しておくソース、特定部分だけのドラッグにします。
ページ全体ではドラッグとドロップ操作不可にする。
しかし、AjaxFileDropBehavior 適用部分は、ドラッグ&ドロップ可能である。

window.addEventListener('dragover', function(ev){
  ev.preventDefault();
}, false);
window.addEventListener('drop', function(ev){
  ev.preventDefault();
  ev.stopPropagation();
}, false);

URL と画像タイプを標準入力で受け取ってURLが指す画像を取得して
ネガポジ変換して標準出力する Python

# -*- coding: UTF-8 -*-
from PIL import Image, ImageOps
import io
import sys
import requests

def imageinvert(url, type):
    image = requests.get(url).content
    img = Image.open(io.BytesIO(image))
    res = ImageOps.invert(img)
    #---- バイトで標準出力 ----
    po = io.BytesIO();
    res.save(po, format=type)
    sys.stdout.buffer.write(po.getvalue())
#######################
if __name__ == '__main__':
    inlist = []
    try:
        while True:
            inp = input('')
            if inp=='':break
            inlist.append(inp)
    except EOFError:
        pass
    # [0]=URL,  [1]=image type 'JPEG' or 'PNG'
    imageinvert(inlist[0], inlist[1])

Wicket ページHTML

<div class="drop-content">
   <img wicket:id="target" id="target" alt="drop target image">
</div>
<div wicket:id="info">
   <div wicket:id="imageName"></div>
   <div>width : <span wicket:id="width"></span>  height : <span wicket:id="height"></span></div>
</div>
<div>
   <button wicket:id="convert" id="convert" type="button" disabled="disabled">Pillow invert実行</button>
</div>

<div class="drop-content">
   <img wicket:id="result" id="result">
</div>

初期表示
f:id:posturan:20190501143415j:plain
画像をドラッグ&ドロップしてボタンを押すと、、
f:id:posturan:20190501143525j:plain

Python標準入力と Java

改行で標準入力→リストで取得の Python スクリプト、改行2回で標準入力が完了するスクリプト
stdio.py

# -*- coding: UTF-8 -*-
import sys

class Stdio:
    # 標準入力
    @staticmethod
    def input():
        inlist = []
        try:
            while True:
                i = input('')
                if i=='':break
                inlist.append(i)
        except EOFError:
            pass
        return inlist
    # list 改行無し標準出力
    @staticmethod
    def out(list):
        for n in list:
            sys.stdout.write(n)
    # list 改行無し標準エラー出力
    @staticmethod
    def err(list):
        for n in list:
            sys.stderr.write(n)

↑ を実行する Python
test.py

# -*- coding: UTF-8 -*-
from stdio import Stdio

list = Stdio.input()
Stdio.out(list)

Pythonスクリプト test.py をJavaから実行、
Script_exec · yipuran/yipuran-core Wiki · GitHub を参照

int sts = ScriptExecutor.run(()->"python test.py"
, ()->Arrays.asList("あabc", "A", "1", "\n").stream().map(e->e + "\n").collect(Collectors.toList())
, e->{
   System.out.println(e);
},(e, x)->{
   System.out.println(e);
   x.printStackTrace();
});

標準出力結果

あabcA1

Pythonスクリプトユニコードで渡す場合

int sts = ScriptExecutor.run(()->"python test.py"
, ()->Arrays.asList("あabc", "A", "1", "\n").stream().map(e->UnicodeTool.convertToUnicode(e) + "\n").collect(Collectors.toList())
, e->{
   System.out.println(e);
   System.out.println(UnicodeTool.convertToOiginal(e));
},(e, x)->{
   System.out.println(e);
   x.printStackTrace();
});

UnicodeToolは、

public final class UnicodeTool {
   private UnicodeTool(){}
   /**
    * Unicode文字列に変換する("あ" -> "\u3042")
    * @param original
    * @return
    */
   public static String convertToUnicode(String string){
      if (string==null || string.isEmpty()) return "";
      StringBuilder sb = new StringBuilder();
      for(int i = 0; i < string.length(); i++){
         sb.append(String.format("\\u%04X", Character.codePointAt(string, i)));
      }
      String unicode = sb.toString();
      return unicode;
   }

   /**
    * Unicode文字列から元の文字列に変換する ("\u3042" -> "あ")
    * @param unicode
    * @return
    */
   public static String convertToOiginal(String unicode){
      if (unicode==null || unicode.isEmpty()) return "";
      String[] codeStrs = unicode.split("\\\\u");
      int[] codePoints = new int[codeStrs.length - 1]; // 最初が空文字なのでそれを抜かす
      for(int i = 0; i < codePoints.length; i++){
         codePoints[i] = Integer.parseInt(codeStrs[i + 1], 16);
      }
      String encodedText = new String(codePoints, 0, codePoints.length);
      return encodedText;
   }
}

標準出力結果

\u3042\u0061\u0062\u0063\u0041\u0031\u000A
あabcA1

Wicket-stuff の Editable Grid をカスタマイズして使用する

Webページ上で、編集可能な表といえば、
Handsontable | JavaScript Data Grid Component For Web Apps
や、
http://tabulator.info/
が、有名なので、このどちらかを提案することが多いのだが、
Wicket は、これより昔から、DataView 使用と Form 入力フィールドの混合での構成で
Wicket-stuff から、Editable Grid が提供されてる。
Wicket の最新 latest バージョン 8.4.0 に対応して Wicket-Stuff も 8.4.0 で出してはいる。
Wicket-stuff のGit-Hub からサンプルソースを入手して動かしてみると
CSSスタイルは、何もなく原始的な table のままで、後から記述しないとならない。
Handsontable や Tabulator 等、メジャーで普及しているのは、機能も豊富で賛同するところもあり、
右クリックコンテキストメニューによる操作がフレームワークとして提供されているのは嬉しいが、
右クリックコンテキストメニューの存在を、エンドユーザに説明する必要があって
本当にBest なのか?いつも疑問が残る。

Editable Grid · wicketstuff/core Wiki · GitHub

Wicket-stuff の Editable Grid を サンプルをそのまま動かすと、、、
f:id:posturan:20190421112847j:plain
編集操作は全て、単純なリンクである。
見た目が簡素であるが、右クリックコンテキストメニューを採用しない代わりに
一目で操作方法は掌握できる。

これを、fontawesome や、Bootsrtap を使ってもう少し見栄えを良くしていく。
Wicket ページ HTML に、

<wicket:head>
<link href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.5.0/css/all.min.css" rel="stylesheet">
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" rel="stylesheet"/>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js"></script>
</wicket:head>

を記述する。
グリッドコンテンツのページ HTML は、

<div class="container">
	<div wicket:id="grid" id="grid"></div>
	<div style="margin-top:20px">
	<button wicket:id="submit" type="button" class="btn btn-primary">submit</button>
	</div>
</div>

今回、FeedbackPanel は使用せずに除去してある。
CSSは、

@charset "UTF-8";
#grid .navigator a{
	font-size: 1rem;
	margin: 1px 10px;
}
#grid .navigator span.goto a[disabled='disabled']{
	color: #ff00ff !important;
}
#grid .headers th{
   background-color: #cccccc;
}
#grid tbody tr:nth-child(even){
	background-color: #cce7ff;
}
#grid tbody tr td:last-child div div a{
	margin: 0 0.5rem;
	color: #aaaaaa;
}
#grid th, #grid td{ white-space: nowrap; }

リンク → アイコン にする JavaScript

/**
 * custom-grid.js
 */
var renderCount = function(){
	var navigationlabel = $('.navigatorLabel div').html();
	if (navigationlabel==undefined){
		$('#grid thead').prepend('<tr class="navigation"><td colspan="4"><div class="navigatorLabel">Total : ' + $('#grid tbody tr').length + '</div></td></tr>')
	}else{
		$('.navigatorLabel div').html('Total : ' + 	navigationlabel.split(/ /)[5]);
	}
};
var setActionCell = function(){
	$('#grid a[id^="edit"]').html('<i class="fa fa-edit" title="編集"></i>');
	$('#grid a[id^="save"]').html('<i class="fa fa-save" title="保存"></i>');
	$('#grid a[id^="cancel"]').html('<i class="fa fa-undo" title="キャンセル"></i>');
	$('#grid a[id^="delete"]').html('<i class="fa fa-trash-alt" title="削除"></i>');
	$('#grid a[id^="add"]').html('<button type="button" class="btn btn-secondary">追加</button>');
	setEdlitAction();
};
var setEdlitAction = function(){
	$('#grid a[id^="edit"]').click(function(e){
		setTimeout('setActionCell();', 100);
	});
};

ページ表示は、こんな感じ、、、
f:id:posturan:20190421114327j:plain

編集のアイコンをクリックした時、クリックした行は入力フィールドになる。
f:id:posturan:20190421114401j:plain

ページの Javaソースは、、
EditableGrid 設置のカスタマイズ
onDelete, onSave, onAdd で、対象リストを操作は不要!である。
onError は、Validatorによる入力エラー検知→FeedbackPanel 設置してなければ Override 意味がない。

queue(new EditableGrid<Person, String>("grid", getColumnProperty()
, new EditableListDataProvider<Person, String>(personlist), 5, Person.class){
   @Override
   protected void onError(AjaxRequestTarget target){
      // target.add(feedbackPanel); 
   }
   @Override
   protected void onCancel(AjaxRequestTarget target){
      target.appendJavaScript("setActionCell();");
   }
   @Override
   protected void onDelete(AjaxRequestTarget target, IModel<Person> rowModel){
      target.appendJavaScript("renderCount();setActionCell();");
      // 削除の Person → rowModel.getObject();
   }
   @Override
   protected void onSave(AjaxRequestTarget target, IModel<Person> rowModel){
      target.appendJavaScript("setActionCell();");
      // 更新の Person → rowModel.getObject();
   }
   @Override
   protected void onAdd(AjaxRequestTarget target, Person newRow){
      target.appendJavaScript("renderCount();setActionCell();");
      // 削除の Person → newRow
   }
   @Override
   protected void onAfterRender(){
      super.onAfterRender();
      // style 適用のJS実行
      JavaScriptUtils.writeJavaScript(getResponse(), "renderCount();setActionCell();");
   }
});
queue(new Button("submit").add(AjaxEventBehavior.onEvent("click", t->{
   personlist.stream().forEach(e->{
      logger.debug("■ id="+e.id+"  "+e.name+" "+e.address+"  "+e.age);
   });
   t.appendJavaScript("alert('submit');");
})));

EditableGrid コンストラクタで指定する行追加及び編集の入力フィールド Component 設置するための
List<AbstractEditablePropertyColumn> を取得するメソッド
FeedbackPanel 設置しないので RequiredEditableTextFieldColumn を使わずに
EditableTextFieldPropertyColumn 等を使用する。

private List<AbstractEditablePropertyColumn<Person, String>> getColumnProperty(){
   List<AbstractEditablePropertyColumn<Person, String>> columns
 = new ArrayList<AbstractEditablePropertyColumn<Person, String>>();
   columns.add(new EditableTextFieldPropertyColumn<Person, String>(new Model<String>("Name"), "name"));
   columns.add(new EditableTextFieldPropertyColumn<Person, String>(new Model<String>("Address"), "address"));
   columns.add(new AbstractEditablePropertyColumn<Person, String>(new Model<String>("Age"), "age"){
      @SuppressWarnings("rawtypes")
      @Override
      public EditableCellPanel getEditableCellPanel(String componentId){
         EditableRequiredDropDownCellPanel<Person, String> panel
 = new EditableRequiredDropDownCellPanel<Person, String>(componentId, this
, Arrays.asList("10", "11", "12", "13", "14", "15"));
         ((DropDownChoice)panel.get("dropdown")).setNullValid(true);
         return panel;
      }
   });
   return columns;
}

EditableListDataProvider で指定する List<Person> personlist は、
EditableGrid を queue する前に生成しておく。
age の選択、DropDownChoice が、未選択=「選んでください。」でなく空白にしたいので、
EditableRequiredDropDownCellPanel でセットする DropDownChoice を
setNullValid(true) にする。

Maven pom.xml の記述は、、

<dependency>
    <groupId>org.wicketstuff</groupId>
    <artifactId>wicketstuff-editable-grid</artifactId>
    <version>8.4.0</version>
</dependency>

Wicket-stuff の Editable Grid のメリットは、、
行の編集、更新、追加、削除、→ 対象がDBだった場合に、
EditableListDataProvider の onメソッドで、
リアルタイムに1レコードずつの操作→反映が、簡単に記述構成できる点である。

Handsontable や、Tabulator では、アクションに対するAJAX通信を大量に書かなくては
ならないはずで、入力エラーも考慮するとかなり面倒くさい。。

Wicket 9.0.0 - M1 が公開されてる。

Wicket が、Javaのバージョン 11
を最低条件に、Wicket 9.0.0 - M1 をリリース

いずれ、、Wicket 9 が登場するということで、JavaのバージョンUPと伴に、Wicket バージョンUPも
加速してきた。。。


wicket.apache.org